diff options
88 files changed, 13932 insertions, 1090 deletions
@@ -30,3 +30,6 @@ __pycache__/ # Claude Code per-project tooling (swept 2026-07-01) /CLAUDE.md /AGENTS.md + +# Screenshot the gallery probes emit on every run +/tests/gallery-probes/fams.png @@ -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) @@ -27,7 +27,7 @@ The provisioning step is resumable: it records progress as marker files, so an i - Builds suckless tools (dwm, dmenu, st, slock) and an Emacs config from configurable Git repos, compiled on a tmpfs RAM disk - Stow-managed dotfiles in a separate repo, split into `common/`, `dwm/`, `hyprland/`, and `minimal/` trees - That repo's `Makefile` carries the stow / restow / reset / unstow / import targets; the installer clones and stows it automatically -- Two-theme system (`dupre`, `hudson`) switchable with the `set-theme` script +- Dupre theme managed by the `set-theme` script - VM-based integration test harness (QEMU/KVM) that installs into a throwaway VM and validates the result - Logs everything to `/var/log/archsetup-*.log` @@ -132,7 +132,7 @@ This `archsetup` repo no longer carries the dotfiles itself — the in-repo `dot ### Theme system -Two themes ship in the dotfiles repo, `dupre` (default) and `hudson`. Switch with the `set-theme` script (installed via the `common` dotfiles). Theme files live under `~/.dotfiles/hyprland/.config/themes/<theme>/` and cover foot, fuzzel, waybar, dunst, hyprlock, and Xresources. +The Dupre theme ships in the dotfiles repo and is applied with the `set-theme` script (installed via the `hyprland` dotfiles). Its files live under `~/.dotfiles/hyprland/.config/themes/dupre/` and cover foot, fuzzel, waybar, dunst, hyprlock, and Xresources. ## Desktop Environments diff --git a/archive/task-archive.org b/archive/task-archive.org index 662dd0f..2b5d26e 100644 --- a/archive/task-archive.org +++ b/archive/task-archive.org @@ -872,3 +872,699 @@ CLOSED: [2026-07-01 Wed] Move the timer module to the rightmost position, just left of the systray, and move the battery/sysmonitor module to second-to-rightmost. Config edit in the waybar config (dotfiles hyprland tier). Origin: roam inbox capture. Done 2026-07-01 (dotfiles waybar config): =custom/timer= now sits just left of =tray= with =custom/sysmon= second-to-rightmost. waybar regenerated + reloaded live on velox; visual confirmation pending Craig. +** DONE [#B] Right-click date/time: ntp sync + timezone update :feature:waybar: +CLOSED: [2026-07-02 Thu] +Right-click on the date updates the clock from ntpd (or whatever keeps the clock in sync); right-click on the time runs the update-timezone script. Neither opens a terminal — both run transparently in the background. Errors surface as notifications. The module should detect whether we're online and, if not, show a message instead of running the script. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 2f7993d). Date right-click = clock-sync (chronyc makestep behind a connectivity gate; offline/captive get explanatory notifications). Time right-click = timezone-set, rewritten to prefer WiFi geolocation (whereami → timeapi.io) over IP lookup — the hotel IP geolocated two timezones off, and ipapi.co is paywalled now (ipinfo.io is the fallback). Both promptless (sudo -n), all outcomes notify, worldclock gained signal 16 for an instant refresh. 13 new tests across clock-sync + timezone-set; live-verified on velox (WiFi path resolved Rhode Island → America/New_York correctly). +** DONE [#B] Screenshot "view image" option :feature:hyprland: +CLOSED: [2026-07-02 Thu] +The screenshot flow should also offer a "view image" selection: saves the shot, opens it in a viewer, and puts the path on the clipboard. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 10e5961). View Image entry in the post-capture fuzzel menu: opens the shot via xdg-open (default viewer, currently feh) and puts the path on the clipboard. Script gained env seams + a nine-test suite (menu dispatch, both capture modes, cancel, failure). Live check pending: take a shot and pick View Image once. +** DONE [#C] Collapse-triangle buttons: dimmer, inlaid styling :waybar: +CLOSED: [2026-07-02 Thu] +The triangle collapse buttons should look embedded (inlaid) and be slightly dimmed so they don't compete for eye attention with the other components. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 15cb93c): muted color + dark inset well + smaller glyph; hover still brightens. Hudson theme carries the same shape. Screenshot-verified. +** DONE [#C] Contrast button ignores the white=on / red=off paradigm :bug:waybar: +CLOSED: [2026-07-02 Thu] +The contrast button doesn't respect the white=on, red=off color paradigm the other waybar modules follow. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 15cb93c). The "contrast button" is the auto-dim module — its ON icon (nf-fa-adjust) is the classic contrast glyph. It showed gold when on and nothing when off; now on = default silver, off = terracotta, matching every other toggle. Screenshot-verified both states. +** DONE [#C] Off-state red inconsistent across waybar modules :bug:waybar: +CLOSED: [2026-07-02 Thu] +Terracotta red isn't applied uniformly for "off": the sleep icon is a different shade than the mouse/trackpad when off, and the dim indicator doesn't show red when off at all. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 15cb93c + 7f1f334). The touchpad script's Pango-markup red predated the terracotta theme pass (#d47c59 vs the CSS's #cb6b4d) — unified on #cb6b4d. Dim-off now red (see the contrast task). Bonus find: waybar hands every pulseaudio instance the sink's .muted class, so a muted speaker also painted the mic red — scoped with :not(.mic) so each glyph keys on its own device. +** DONE [#B] Waybar volume/mic toggle like the touchpad module :feature:waybar: +CLOSED: [2026-07-02 Thu] +Make the volume/mic waybar component look and behave like the touchpad/mouse toggle. +- Move the mic to the other side of the volume so the percentage isn't in the way. The mic and speaker icons sit the same distance apart as the hand and mouse. +- One keybinding cycles the four states: volume on / mic on, volume on / mic off (red), volume off (red) / mic on, volume off (red) / mic off (red). +- Move the trackpad/mouse toggle to another keybinding (discuss an open mnemonic, e.g. =d= for disable) and assign Super+M to this module (for mute). +Origin: roam inbox capture. + +Shipped 2026-07-02 (dotfiles 7f1f334). New audio-cycle script walks the four-state ring in the exact order above (wpctl-backed, explicit set-mute so the pair can't desync, 6 tests) on Super+M; live-verified the full ring on velox. Mic moved left of the speaker and hugs it via paired margins, percentage on the outside. Touchpad toggle moved to Super+Shift+I ("input devices" — d-for-disable was taken at both levels by removemaster and dim-toggle); its tooltip and tests follow. +** DONE [#C] Timer end sends no notification :bug:waybar: +CLOSED: [2026-07-02 Thu] +The end of a wtimer timer didn't fire a desktop notification. Needs reproduction to confirm frequency; priority follows the severity-by-frequency matrix once known (a reliably-missing timer-end alert would rate higher). Origin: roam inbox capture. + +Root-caused 2026-07-02 (dotfiles ca35642). The pipeline works (a live 3s timer fired and persisted), but notify sent everything --urgency=normal and dunstrc delays normal-urgency popups while a fullscreen window has focus — a timer ending mid-video sat invisible until fullscreen exit. Alarms now go critical urgency, which rides the fullscreen_show_critical rule; verified CRITICAL in dunst history. The alarm sound (paplay, separate from dunst) was never affected. +** DONE [#C] Keybind hints in waybar module tooltips :waybar: +CLOSED: [2026-07-02 Thu] +Every module's hover tooltip should list its keyboard shortcut(s), for discoverability. Audit the modules and add the bindings to each tooltip. Origin: roam inbox capture. + +Shipped 2026-07-02 (dotfiles 4c32aec). Audited every module: arrows (Super+[ / Super+]), sysmon (Super+R), net (Super+Shift+N), layout (Super+Shift+←/→), menu (Super+Space / Super+Shift+Q, tooltip enabled), plus the already-hinted dim/caffeine/touchpad/mic/volume. Date/time tooltips document their new right-click actions. Workspaces/window/tray don't take custom tooltips; timer has no keybind. +** CANCELLED [#C] Smooth waybar expansion animation :waybar: +CLOSED: [2026-07-02 Thu] +The cluster expansion jumps instead of animating, and a few systray icons pop in one-by-one afterward, which reads as glitchy. Animate the expansion smoothly if waybar allows it — width transitions are limited, so feasibility is uncertain (hence [#C]). Origin: roam inbox capture. + +Assessed infeasible 2026-07-02: collapse works by config rewrite + SIGUSR2 reload, which rebuilds every widget — nothing survives to transition, GTK3 can't animate add/remove without Revealer (an upstream waybar change), and the tray pop-in is async StatusNotifier re-registration. Full findings + revisit conditions: [[file:docs/design/2026-07-02-waybar-expansion-animation-feasibility.org]]. +** DONE [#C] Optional label on timer/alarm/stopwatch items :feature:waybar: +CLOSED: [2026-07-02 Thu] +Let each wtimer item carry an optional short text label. The data model already supports it (=add_timer/add_alarm/add_stopwatch/add_pomodoro= all take =label=""=, and =_describe= shows =label or type=); the gap is the fuzzel-driven creation flow, which doesn't prompt for a label. Add the optional label prompt on create. Origin: roam inbox capture. + +Shipped 2026-07-02 (dotfiles ca35642): =wtimer new= gained a "label (optional)" fuzzel prompt after the type/value prompts; empty keeps the unlabeled default. 2 new tests (89 total in the suite). +** DONE [#B] Pocketbook finish-or-cancel decision :pocketbook: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +Decided by Craig 2026-07-02, ahead of the scheduled checkpoint: remove pocketbook altogether. Executed same day — pip package uninstalled (user site clean), running instance killed, launcher gone, =pocketbook/= tree removed from the repo, Super+P rebound to toggle-touchpad (P for Pointers; Super+Shift+I unbound, waybar tooltip hint updated — dotfiles a750cb4). The org-capture popup remains the quick-notes surface. +** DONE [#B] Provision Eask in archsetup :tooling:eask: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-05-26 +:END: +Shipped 2026-07-02 (speedrun): npm global install block added after the nvm line — runs as $username with --prefix $HOME/.local, display/error_warn wrapped, output to $logfile, matching the claude-code block's shape. The npmrc decision went yes: dotfiles common/.npmrc pins prefix=${HOME}/.local (stowed; hand-linked live, npm config get prefix confirms ~/.local — dotfiles 01627cc). VM assertion added: ~/.local/bin/eask present + ~/.npmrc stowed. Live smoke: eask 0.12.9 on PATH. Full acceptance (fresh-install chime make setup/test) rides the next VM pass. +Add =@emacs-eask/cli= to archsetup's provisioning so fresh machines get it. Eask is installed by hand today and declared nowhere in archsetup or the dotfiles repo, yet both chime and linear-emacs depend on it (their =make setup/test/coverage= shell out to =eask=). Source: handoff from linear-emacs 2026-05-23. + +- Add a global npm install after the node block (=archsetup= ~2030, after =aur_install nvm=), modeled on the claude-code native-install block: run as =$username=, wrapped in =display=/=error_warn=, output to =$logfile=. Roughly =sudo -u "$username" bash -c 'npm install -g --prefix "$HOME/.local" @emacs-eask/cli'=. +- Pin the prefix to =~/.local= so eask lands at =~/.local/bin/eask= (already on PATH) and the install runs as the user, not root. On the current machine =npm config get prefix= returns =/usr=, so eask was installed with an explicit =--prefix=. +- Decision: also set a persistent user npm prefix (=~/.npmrc= with =prefix=${HOME}/.local=)? If yes, that =~/.npmrc= is a legitimate dotfile to stow; if no, rely on the explicit =--prefix= flag alone. =~/.eask/= is a regenerable cache — leave un-stowed. +- Acceptance: fresh run leaves =eask= on PATH at =~/.local/bin/eask= (no root); =cd ~/code/chime && make setup && make test= works. +** DONE [#C] Waybar timer dialog styling :waybar: +CLOSED: [2026-07-02 Thu] +From Craig's roam capture 2026-07-02: style the timer module dialogs like the screenshot dialog — tighter window, icons on the selections, colon+space after the prompt. + +Shipped 2026-07-02 (dotfiles 9ffcba7): dialogs size to content, type menu carries the kind glyphs, prompts end ": ". Three new tests; screenshot-verified live. +** DONE [#B] Waybar collapse jumps client windows :bug:waybar:hyprland: +CLOSED: [2026-07-02 Thu] +From Craig's roam capture 2026-07-02: collapsing/expanding (and any waybar teardown) snapped every tiled window up and back down; hold the clients still and let only the bar change. + +Shipped 2026-07-02 (dotfiles 4b1a4ec): waybar now runs exclusive:false and the new waybar-reserve script statically reserves the bar strip per monitor (wired as exec so config reloads re-apply it). Verified live: client geometry held constant through bar kill, relaunch, and a collapse round-trip. Eight new tests (script + pairing). +** DONE [#B] Bluetooth panel + bar module :feature:waybar:bluetooth: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:SPEC_ID: 1271a845-4463-4831-9902-990eda6b2265 +:END: +Spec: [[file:docs/specs/2026-07-02-bluetooth-panel-spec.org]] (IMPLEMENTED 2026-07-02 — all five phases shipped same day: engine eb2230f, panel 76b2c05, bar module e372de3, bt-priv + blueman retirement 2a026b1/d8d8c53, install wiring proven by VM assertions). Residual: the phase 4-5 VM assertions run on the next VM pass; ratio picks up the package removal + hand-links on its trip list. + +A bluetooth panel driving a CLI underneath (bluetoothctl one-shot verbs), consistent in look and feel with the net panel (GTK4 + layer-shell + Blueprint, humble-object presenter, verify-everything). Minimalistic interface, full functionality, plus a diagnostics/troubleshooting section mirroring the net panel's Diagnostics tab. Bar module glyph opens it. Craig's ask (2026-07-02): follow UX/UI best practices; where the net panel's patterns conflict with best practices, file a net-panel bug task rather than clone the flaw. + +*** 2026-07-02 Thu @ 13:30:42 -0400 Shipped phase 1 — the bt engine package (dotfiles eb2230f) +=bluetooth/src/bt/= mirrors the net engine's layout: btctl parsing boundary (show/devices/info, connect-error classifier), sysio rfkill/airplane, audio module over pw-dump/wpctl (HSP probe + A2DP switch repair with verify-after), redacted eventlog, six repair tiers, and the doctor chain (adapter → rfkill → service → powered → devices → audio profile) with safe auto-repairs behind =--fix= (never auto-connects; airplane blocks are named, not fought). 101 tests over fake binaries; 42 suites green (=make test= glob auto-discovered =tests/bt/= — gate check verified). Live read-only on velox: =bt status= + =bt doctor= read the real adapter/devices/audio graph; =~/.local/bin/bt= hand-linked (no restow under running Hyprland). Ground truth vs spec: profile inventory needs =pw-dump= (wpctl can't enumerate), and the card's =bluez5.profile= prop is unreliable — sink node's =api.bluez5.profile= is authoritative. Deferred INTO phase 2: the shared dupre css factoring (net's css is an inline string in =gui.py=, not an asset — factoring it without the bt-panel consumer just risks the working net panel). + +*** 2026-07-02 Thu @ 14:15:27 -0400 Shipped phase 2 — the GTK panel (dotfiles 76b2c05) +Cloned the net panel's shape over the phase 1 engine: GTK-free PanelModel + viewmodel (69 new tests, display-free), Blueprint pages (Devices with the adapter power row + Paired/Nearby sub-views; Diagnostics with the doctor cascade, inline fix buttons mapped from step =repair= keys, Advanced tool selector), gui.py controller (layer-shell OVERLAY 380x520 TOP+RIGHT, Esc closes, single-instance toggle, bg worker, passkey + confirm dialogs), pairing pty state machine (=bt/pairing.py=: confirm-passkey yes/no with default-deny, display-passkey, bounded deadline, tested over the fake's interactive mode), manage.py op envelopes shared by CLI and panel (cli refactored onto it; power + discoverable verbs added), =bt panel= subcommand + =bt-panel= toggle wrapper (hand-linked into =~/.local/bin=, no restow under live Hyprland). Shared dupre css factored: net's inline =_CSS= → =hyprland/.config/themes/dupre/panel.css= with =dupre-*= classes, both panels load it (stowed path first, repo-relative fallback; hand-linked =~/.config/themes/dupre/panel.css=). Super+Shift+B rebound blueman-manager → bt-panel (hyprctl reloaded, live). 43 suites green (=make test= exit 0). DEFERRED pending Zoom ending: the AT-SPI smoke (=make test-panel-bt=, written and wired) and any visual check of either panel with the factored css — both need a visible window. Gotcha for posterity: the old =test_panel_stub_exits_two= CLI test ran =bt panel= for real once cmd_panel was wired and launched the panel on the live compositor mid-meeting for ~30s before the test timeout killed it; it now asserts parser wiring only — never run =bt panel= inside =make test=. + +*** 2026-07-02 Thu @ 15:06:00 -0400 Shipped phase 3 — the bar module + blueman retirement (dotfiles e372de3) +=custom/bluetooth= over the engine: waybar-bt shim + =bt/indicator.py= (state-following glyph — slashed off/blocked/absent, plain dim idle, connected mark white; low-battery <15% adds a red pango percentage to the glyph; tooltip = connected devices with battery + Super+Shift+B hint). Signal 10; the panel pokes =pkill -RTMIN+10 -x waybar= after each status reload. Blueman retired from the Hyprland session: exec-once + both windowrules removed, applet killed live; waybar relaunched on the runtime config and the module verified on the bar (connected glyph, blueman tray icon gone). Theme drift guard caught that themes/*/waybar.css edits must mirror into the live =waybar/style.css= — all three updated. 43 suites green. ALSO closed this pass: the deferred phase 2 visual batch (bt AT-SPI smoke green after fixing its Connect/Disconnect state-following assertion — c1a8219; net smoke green on the factored css; both panels eyeballed correct in dupre). Left for phase 4: package removal (blueman out of archsetup), sxhkdrc's dwm blueman-manager binding decision rides that pass. + +*** 2026-07-02 Thu @ 15:16:51 -0400 Shipped phase 4 — bt-priv shim, blueman out, VM assertions +Dotfiles =2a026b1=: the stowed =bt-priv= shim over the phase-2 =bt.priv= module (one verb, =restart-bluetooth=; verified end-to-end against the symlinked fake-systemctl — rc 0 with =BT_SUDO= empty, rc 2 on bad verb/usage; hand-linked into =~/.local/bin=), and the sxhkd =Super+Shift+B= bind repointed from the retired blueman-manager to =st -e bluetoothctl= (the decided terminal fallback — the GTK panel is Wayland-only, and the bt CLI is hyprland-tier so dwm never gets it). 43 dotfiles suites green. archsetup: blueman dropped from the =desktop_environment= bluetooth loop (bluez + bluez-utils stay, solaar untouched); VM assertions added to =test_packages.py= (bluez/bluez-utils installed, blueman NOT installed as the retirement regression guard — collected 15, exercised on the next VM run since VM tests run committed code); =bash -n= + =py_compile= + =make test-unit= green. SUDOERS: no new rule needed, same conclusion as net-priv (2026-07-01 entry) — archsetup:1089 grants the primary user blanket =NOPASSWD: ALL=, which covers =systemctl restart bluetooth=; a narrow bt-priv rule would be dead config under the blanket grant, so phase 5's "sudoers placed" item is satisfied by the existing grant. LIVE: blueman package removed from velox (=pacman -Rns=, decision "drop it outright, both machines"); ratio needs the same + the bt-priv hand-link on its trip list. + +*** 2026-07-02 Thu @ 15:19:58 -0400 Shipped phase 5 — install-default wiring proven by VM assertions +No new install code was needed: the waybar =custom/bluetooth= module, the =Super+Shift+B= → =bt-panel= bind (hyprland.conf), and the shared =themes/dupre/panel.css= all live in the dotfiles hyprland tier, so the existing clone + =make stow hyprland= step lands them on a fresh install; sudoers is covered by the blanket grant (phase 4 conclusion). The phase's substance is the proof: =test_desktop.py= gained hyprland-gated assertions that the four bt bins (=bt=, =bt-panel=, =bt-priv=, =waybar-bt=) are stowed executable in =~/.local/bin= (either stow shape — per-file symlink or folded dir), the waybar config carries =custom/bluetooth=, hyprland.conf carries the =bt-panel= bind, and the stowed theme has =panel.css=. Collected 30 in =test_desktop.py=; exercised on the next VM run (VM tests run committed code). + +*** 2026-07-02 Thu @ 15:19:58 -0400 Test surface complete across all phases +Everything the surface named exists and is green: engine suites over fake binaries (phase 1, 101 tests — btctl parse, doctor chain, A2DP repair verify), PanelModel presenter suite (phase 2, 69 tests), pairing state-machine suite (passkey confirm / NoInputNoOutput / timeout, over the fake's interactive mode), bar-module suite (phase 3), gated AT-SPI smoke (=make test-panel-bt=, run green live), and the phase 4-5 VM assertions (=test_packages.py= bluetooth stack + blueman-absent guard; =test_desktop.py= panel wiring). 43 dotfiles suites green; VM assertions await the next VM run. +** DONE [#B] All error messages should be actionable with recovery steps +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +Shipped 2026-07-02 (speedrun). Structural fix at the helper: =error_fatal= now takes an optional third recovery-hint arg and every fatal prints the last five log lines inline, the full log path, the per-site "Fix:" when given, and the resume pointer (step markers mean a re-run continues where it stopped) — so even a hint-less fatal is actionable. All 17 fatal call sites got specific hints (keyring reinit, mirrorlist switch, userdel/USERNAME conflict, base-devel for makepkg, DESKTOP_ENV values, dotfiles-dir cleanup, tmpfs sizing, aur.archlinux.org reachability). The end-of-run Error Summary now closes with the grep-the-log line and the fix-and-re-run pointer. =error_warn= already carried what-failed + exit code into the summary; unchanged. +** DONE [#B] Improve logging consistency +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +Shipped 2026-07-02 (speedrun), paired with the actionable-errors task. Audit result: the install helpers (pacman_install/aur_install/retry_install/run_task/git_install/pipx) and error helpers already tee/append everything to $logfile — the gaps were direct mutations whose stderr went to the console and vanished. Swept every =sed -i= and file-write mutation lacking capture (locale.gen uncomment, pacman.conf ParallelDownloads/Color + multilib, waybar battery removal x3, wireless-regdom, geoclue BeaconDB, paccache, BRIO udev rule, fstab fmask, mkinitcpio HOOKS, sudoers append, ufw status read): each now sends stderr to $logfile, and the previously-silent ones (locale.gen, pacman.conf, multilib, waybar, regdom, geoclue, paccache, udev) gained =error_warn= handlers so failures land in the summary instead of passing silently. Verified: bash -n clean, 10 unit suites green, shellcheck warning-diff vs HEAD empty (no new findings). +** DONE [#B] Add NVIDIA preflight check for Hyprland +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-05-21 +:END: +Shipped 2026-07-02 (speedrun), TDD. =nvidia_preflight_report= is a pure sed-extractable core (same harness pattern as zig-pin): modalias scan for vendor 10DE — DRM first, PCI display-class (bc03) fallback so an NVIDIA audio function can't false-trigger — then the repo's =nvidia-utils= candidate major checked against 535. Prints the Wayland guidance + env vars (LIBVA_DRIVER_NAME, GBM_BACKEND, __GLX_VENDOR_LIBRARY_NAME, ELECTRON_OZONE_PLATFORM_HINT) and the pre-Turing/AUR-legacy note. preflight_checks aborts on <535/unknown (rc 11), prompts continue/abort on a healthy NVIDIA box (rc 10), silent on non-NVIDIA (rc 0). 9 Normal/Boundary/Error tests over fake modalias trees + a fake pacman (=tests/nvidia-preflight/=, glob-discovered by test-unit — 10 suites green). +** DONE [#C] Wlogout exit-menu buttons are rectangular, not square +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +The wlogout exit menu renders its buttons taller than they are wide on velox, so the cells read as vertical rectangles instead of squares. They render square (centered) correctly on ratio, so this is a per-host / resolution difference, not a flat bug. Fix the button sizing in the wlogout style (=~/.dotfiles/hyprland/.config/wlogout/style.css=) so each cell is square on both hosts. Noticed 2026-05-21. Related: the [#D] VERIFY about wlogout sizing across displays. + +The wlogout config uses fixed pixel margins, which is the likely reason sizing differs across the two displays — adjusting them for the laptop screen is part of the fix (folded in from the former "Test wlogout menu on laptop" VERIFY, 2026-06-24). + +Add a regression test so the square-cell fix doesn't silently break on a resolution change: assert the rendered (or computed) wlogout button cells are square across ratio's and velox's resolutions. Dropped :quick: — the cross-host test pushes this past a spare-moment fix. + +Shipped 2026-07-02 (dotfiles 775771b). Keybind now calls a =wlogout-menu= wrapper computing centered margins from the focused monitor (the old fixed L/R 1200 exceeded velox's 1436 logical width). Also fixed two styling defects the geometry hid: invisible unfocused borders (now muted, so the square edge is visible) and hover/focus sharing one gold rule (lock button glowed at launch; focus is now a muted ring). Tests: unit margin-math suite across both hosts' resolutions + portrait + small + bad-geometry, CSS regression suite, and a compositor-gated =make test-wlogout= smoke that launches a no-op probe, screenshots, and measures squareness (velox: 361x361 px, PASS). Ratio's visual eyeball rides the pending ratio sync. +** DONE [#C] Net panel: error toasts auto-dismiss unread :bug:network:waybar: +CLOSED: [2026-07-02 Thu] +Fixed in dotfiles 0f017d4: viewmodel.toast_plan owns the toast policy — errors show sticky and ignore the post-op refresh's empty clear (worst case: a forget failure's error was wiped within ~2s by its own refresh), and the next real status replaces them. Successes keep the 4s fade. 7 policy tests added; 41 suites green. +** DONE [#C] Net panel: verify claimed keyboard navigation :test:network:waybar: +CLOSED: [2026-07-02 Thu] +Found during the bluetooth-panel UX pass (2026-07-02). The V2 spec claims tab-between-sections, arrow-key row navigation, and type-to-filter, but no custom keyboard code exists in the panel — arrows and type-ahead may ride GTK ListBox defaults, tab-between-sections likely doesn't. Verify each claim against the live panel (AT-SPI smoke can assert focus order); implement or strike the claims from the spec so spec and panel agree. + +*** 2026-07-02 Thu @ 13:05:00 -0400 Code-level pass done; live probe deferred (Craig in a Zoom meeting) +Code reality (dotfiles net/src/net): Esc close is wired (gui EventControllerKey); row-activated -> primary is wired for both connection lists (pages.py:122,126), so Enter-on-row rides the GTK ListBox activate binding; arrows ride ListBox defaults; NOTHING implements type-to-filter (no search/filter code exists — that claim is false as written); Tab is the plain GTK focus chain, widget by widget, not section jumps. Live AT-SPI probe plan: launch panel in test mode, drive keys via hyprctl dispatch sendshortcut targeted AT THE PANEL WINDOW (never the focused surface — wtype/ydotool absent anyway), gate every key on the panel holding focus, never send Enter on the available list (real connect risk). BLOCKED at 13:05: active window is zoom (meeting) — no test windows, no synthetic input until clear. Then: verify focus order + arrows + no-filter live, strike/reword the spec's keyboard bullet to match. + +*** 2026-07-02 Thu @ 14:57:18 -0400 Ran the live probe; spec bullet reworded to match reality +Zoom ended ~14:45; probe ran per plan (panel in test mode, hyprctl dispatch sendshortcut targeted at the panel address, every key gated on panel focus, Enter never sent). Verdicts: arrows move row focus and Enter rides the ListBox activate binding (TRUE — kept); Esc closes (TRUE — kept); Tab is the plain GTK widget-by-widget chain and inside a list crawls row by row, no section jumps (claim FALSE — struck); type-to-filter does not exist (claim FALSE — struck; typing into the 24-row Saved list filtered nothing). Spec's Keyboard bullet reworded with the live evidence and a note that section-jump Tab or filtering would be new work. Probe gotchas for reuse: AT-SPI list items have empty accessible names, so row identity needs get_index_in_parent(); a killed test panel can leave a windowless single-instance process that eats the next launch via D-Bus activation — pkill -9 -f 'net panel$' and wait before relaunching. +** CANCELLED [#C] Pocketbook development backlog :pocketbook: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-05-26 +:END: +Cancelled with the 2026-07-02 remove-pocketbook decision — the app and its in-tree package are gone. +Pocketbook (GTK4 layer-shell notes panel, toggled via waybar) was pulled from publication 2026-05-26 — github repo + cjennings.net repo deleted, mirror hook removed — and folded into this repo at =pocketbook/= until it's ready to spin back out. Src-layout Python package with pytest tests and a Makefile. Develop it in-tree; the backing modules are =store/note/panel/layer_shell/app/note_widget= + =style.css=. + +Backlog (unordered; promote items to their own dated tasks as they're picked up): + +- Configurable options, possibly a dedicated configuration panel. +- Lose-focus hides pocketbook — configurable on/off. +- Configurable display order: chronological by creation date (asc/desc), manual, alphabetical (asc/desc). +- Search / filter notes. +- Global toggle keybind (Hyprland =bind=) alongside the waybar click; document the waybar integration. +- Note CRUD polish (create/edit/delete) + optional markdown rendering. +- Pin / favorite notes. +- Tags or notebooks / categories. +- Persistence: confirm store format + =~/.local/share/pocketbook/= location, add versioning/migration, decide a backup/sync story. +- Theming: track the dupre/hudson theme system so =style.css= follows =set-theme=. +- Layer-shell geometry config (anchor edge, width, margins) + HiDPI / multi-monitor behavior — ties into [[file:docs/PLAN-per-host-overrides.org][per-host overrides]] scaling work. +- Config file format (toml) + reload-without-restart. +- Expand test coverage (TDD per testing standards; =tests/= already exists). +- Release prep for the eventual spin-back-out: pyproject metadata, version, license. +- Re-wire the archsetup install (gtk4-layer-shell dep + install step + post-install clone) when pocketbook ships. Removed 2026-05-26 — see git history of =archsetup= / =scripts/post-install.sh=. +** CANCELLED [#C] Fn+F9 toggles pocketbook — source unlocated :hyprland:pocketbook: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-23 +:END: +Retired with pocketbook itself (2026-07-02 removal) per this task's own exit condition — with the app uninstalled and unbound, whatever Fn+F9 emitted has nothing to toggle. +On velox, pressing Fn+F9 (physical function key) toggles the pocketbook panel. It shouldn't. Raised from a home-project session 2026-06-23. + +Investigated 2026-06-23 and could not locate the trigger in any config. Ruled out, three ways: +- No F9 bind (bare / $mod / keycode) in the live =hyprland.conf= (now a stow symlink), the velox host tier =conf.d/local.conf=, or the waybar config. +- =hyprctl binds= runtime (all 90 active binds, authoritative) execs pocketbook on ONLY =SUPER+P=. No F9/XF86 path reaches it. The old touchpad toggle that used to sit on =$mod+F9= was moved to =$mod+M=, so F9 is unbound in Hyprland. +- No input remapper (keyd/xremap/input-remapper) and no hotkey daemon (sxhkd/swhkd) running or configured; pocketbook's own source has no F9 / GlobalShortcuts / portal / dbus listener (its GTK ShortcutController binds only Esc/Ctrl-n/Ctrl-j/Ctrl-k/Del/Return). pocketbook is a single-instance Gtk.Application, so any path that re-runs =pocketbook= toggles it. + +Parked at Craig's call (not worth deeper investigation now). If it resurfaces, the one unfinished step is to capture what keysym Fn+F9 actually emits (=wev -f wl_keyboard:key=, press Fn+F9, read the =sym:= / =code:=) and grep for that. Most likely folds into removing pocketbook from the waybar setup — if pocketbook leaves the bar, retire this with it. +** CANCELLED [#C] Waybar emacs-service status + control :feature:waybar: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +From the roam inbox (2026-06-22): with Emacs integrated into the system as file manager and instant note-taker, make bouncing it trivial. A waybar component showing the emacs service status, with detail on hover, that turns the server on / off / bounce via right-click. Pairs with running the Emacs daemon as a managed systemd user service. + +Cancelled 2026-07-02 per Craig during the task-batch pick: no current need. Re-add or pull back from Resolved if a need surfaces. +** DONE [#C] set-wallpaper detaches waypaper config from its stow symlink :bug:hyprland:quick:solo: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-28 +:END: +=set-wallpaper= persists with =mv "$tmp" "$CONFIG"=, which replaces the =~/.config/waypaper/config.ini= stow symlink with a real file. After the first run the live config is detached from =~/.dotfiles/hyprland/.config/waypaper/config.ini=, so a later =git pull= + restow won't update it and set-wallpaper changes never flow back to the repo. Fix: write in place rather than =mv= over the symlink — e.g. =cp "$tmp" "$CONFIG"= (follows the symlink to the real dotfiles file), or resolve the link target and write there. Lives in =~/.dotfiles/hyprland/.local/bin/set-wallpaper=; it has a test suite, so add a Boundary case for "CONFIG is a symlink". + +Shipped 2026-07-02 (dotfiles d826be4): write-back now redirects through the symlink instead of mv-ing over it; two boundary tests pin the invariant (replace + append paths). velox's live config was still a healthy symlink, so no repair needed. +** DONE [#B] Instrument-console rebuild: net + bluetooth panels :feature:waybar:network:bluetooth:solo: +CLOSED: [2026-07-03 Fri] +:PROPERTIES: +:SPEC_ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5 +:END: +The no-approvals speedrun build of the console design Craig approved through five prototype iterations (2026-07-02/03). Spec: [[file:docs/specs/2026-07-03-instrument-console-panels-spec.org]] — the interactive prototype [[file:docs/prototypes/2026-07-03-instrument-console-panels-prototype.html][docs/prototypes/2026-07-03-instrument-console-panels-prototype.html]] is the normative design reference. Folds three open tasks: network panel redesign, bt switch placement + title, bt rename devices. Code in ~/.dotfiles (net/, bluetooth/, themes/dupre/panel.css). Final step: flip the spec to IMPLEMENTED, write the findings summary to file, finalize session context. + +*** 2026-07-03 Fri @ 03:20:00 -0400 Phase 2 shipped: net GTK-free console layer + engine verbs +Dotfiles =81ec9c3= (TDD, 52 new tests, 581 net green). Pure presenter logic for the single-screen console, no view code touched: =viewmodel.net_faceplate= (state word + lamp + TUNNEL/AIRPLANE badges, wired-link-wins precedence), =network_console_rows= (ethernet pinned, radio-off note, active-then-signal sort, per-row lamp/caption/ladder/forget), =channel_headline= (wired device+speed / SSID+ladder+dBm / not-connected placeholder), =tunnel_console_rows=, dial-meter geometry (=meter_needle_deg= + =meter_scale= 100→1000 auto-relabel), =signal_bars=/=mbps_label=, and =panel.ArmState= (two-click arm-to-fire for forget/disconnect). Engine verbs: =manage.wifi_radio= (nmcli radio wifi on|off), =manage.device_up= (ethernet take-the-route), =sysio.link_speed_mbps= (/sys wired speed), =connections.ethernet_devices=, hidden flag on =manage.add=. + +*** 2026-07-03 Fri @ 06:02:32 -0400 Phases 3+4 shipped: net view rebuilt as the instrument console +Dotfiles =800ef60= (1197+/250-). =gui.py= rewritten as the single-screen console — no tabs, no Blueprint template (the dial meters and arm-to-fire rows are too dynamic, so the tree is built in Python; =pages.py= + the =*.blp/*.ui= are now orphaned, Phase 6 dead-code). Faceplate (lamp/word/TUNNEL+AIRPLANE badges/wifi-radio switch/close), engraved CHANNEL headline, scrolled NETWORKS + TUNNELS lamp rows, CONSOLE keys, two cairo dial meters, output well + dismiss ✕, toast. Interactions all wired: open network joins / secured opens the password dialog, active row arm-disconnects (gold), ✕ arm-forgets (terracotta), tunnel toggles, ethernet row takes/yields the route, radio switch flips the wifi radio (refuses under airplane with the way out), + hidden joins a non-broadcast SSID, DOCTOR streams diagnose+repair into the well, SPEED TEST sweeps both dials with the live rate then pins the final with HOLD (location/ping/final/tips in the well). =panel.css= grew the console classes (lamps+glow, b-face, engrave, chan, lamp-row + arm tints, c-btn, meter/mode/hold, output steps, toast). AT-SPI smoke + driver rewritten (anchor on the DOCTOR key). Phases 3 and 4 landed together because a view-only intermediate is a non-functional panel. Verified live on velox: full render screenshotted, console smoke green (faceplate/keys/sections/tunnels/DOCTOR/dismiss/close), DOCTOR streams real diagnose steps, SPEED TEST drove RX 36.6↓ / TX 90.7↑ then HELD. 581 net tests + full make test green. + +*** 2026-07-03 Fri @ 06:55:00 -0400 Phase 5 shipped: bt panel rebuilt as the instrument console +Bluetooth's turn, two commits mirroring net. Phase-5a (dotfiles =5318b34=, 47 new console tests): the GTK-free layer — =viewmodel.bt_faceplate= (POWERED/OFF/AIRPLANE word + lamp + LOW BATT/AIRPLANE badges), =paired_console_rows= / =nearby_console_rows= (lamp rows with connect/forget/rename affordances), =discoverable_chip=, count labels, =battery_gauges= (two dial slots, one per connected device, red under 15%, dim NO DEVICE / ADAPTER OFF empties), =STEP_NARRATION=, and =panel.ArmState= (the forget latch). Engine gaps: =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (set-alias has no MAC-addressed one-shot; =device_path= discovers the controller node from the object tree), =manage.rename= wraps it with a verify-after read, =parse_info= reads the Alias as the display name (a rename lands there, not on Name; the MAC-shaped placeholder stays "unnamed"), and =doctor= grew =on_report=/=on_begin= callbacks. Phase-5b (dotfiles =66f03d9=): =gui.py= rewritten as the single-screen console — faceplate (lamp/word/LOW BATT+AIRPLANE badges/adapter-power switch/close), engraved ADAPTER line with the clickable discoverable chip, scrolled PAIRED + NEARBY lamp rows, CONSOLE keys DOCTOR / SCAN, two cairo battery dials, output well + toast. Interactions: paired rows toggle connect/disconnect, ✎ renames via a dialog, ✕ arm-forgets, nearby rows run the pair flow into a passkey-confirm dialog, the chip toggles discoverability, the switch powers the adapter, SCAN refreshes nearby, DOCTOR streams checks + repairs. =panel.css= gained =.chip= / =.pen= / =.o-passkey= (the rest already shared with net). AT-SPI smoke rewritten (anchor on the bt-only SCAN key). Verified live on velox: smoke green end to end, screenshot matches the prototype (POWERED faceplate, four paired audio devices, two NO DEVICE battery dials). 46 suites + full make test green. Phase 6 next: live both-panel verify, folded tasks closed, dead code removed, spec → IMPLEMENTED. + +*** 2026-07-03 Fri @ 06:49:45 -0400 Phase 6 shipped: build closed out, dead code removed, spec IMPLEMENTED +Live both-panel verify on velox: 46 suites + full make test green, and both AT-SPI smokes green end to end (net: faceplate NET·01/ONLINE, DOCTOR streams real diagnose steps, tunnels rows, close; bt: BT·01/POWERED, SCAN/DOCTOR keys, battery dials, close). The two =gui.py= files are byte-identical to their screenshot-verified commits (net =800ef60=, bt =66f03d9=), so the render carries over from the phase-3/4/5 screenshots — this pass touched no view code. Dead code removed (dotfiles =f4e688e=): both panels' orphaned =pages.py= + =ui/= (=*.blp/*.ui=) gone now that =gui.py= builds the tree in Python, the now-dead =make ui= Blueprint-compile target and its =.PHONY= entry dropped, and the stale =gui.py / pages.py= mention in bt =viewmodel.py= fixed; nothing imported the removed modules. Three folded tasks close with this build (network panel redesign, bt switch placement + title, bt rename devices). Build summary written to [[file:assets/2026-07-03-instrument-console-panels-build-summary.org][assets/2026-07-03-instrument-console-panels-build-summary.org]]. Spec =e73877f5= flipped DOING → IMPLEMENTED. Manual-test checklist for the real-device bt interactions filed under Manual testing and validation. +** DONE [#B] Net diagnostics: narrate every step :feature:network:solo: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-02 +:END: +Follow-on 2 (dotfiles =ebf24fe=, Craig's decision 2026-07-02, option 1 of the discussed policies): mutating tiers pre-check whether we're online before acting. dns-test/dns-override short-circuit with an "already online" step and touch nothing (live-verified on hotel wifi: 100 ms skip, DNS untouched); reset/bounce/nm-restart/resolved-restart proceed (reset has a legit online use — fresh MAC) but carry "(was online before ...)" in their evidence; the panel's repair confirm warns via the cached probe verdict (=probe.cached_online=, file read only); rfkill/dns-revert/tunnel-down already verify their own state, unchanged. 492 net tests / 45 suites / panel smoke green. + +Follow-on (dotfiles =50a7239=, Craig's ask 2026-07-02): the same requirements now cover every Advanced-dropdown action — all repair ids + portal narrate in the panel's step rows, =net repair= / =net portal= human-by-default (=--json= kept, added to portal), doctor's attempts render like checks, and mutating steps keep their next_action on pass (the fail/warn-only rule was dropping dns-override's revert pointer, portal-login's login click, and cleanup-unverified's manual revert). 479 net tests / 45 suites green; panel smoke shows narration in live rows. + +Shipped (dotfiles =7772427=): every diagnose step id carries a one-line narration (what it tests and why) in a viewmodel table, and both human renderers print every step — status, title, narration, evidence with timing, and the fix pointer on fail/warn — in =net doctor= and =net diagnose= alike. Bare =net diagnose= now prints the narrated report (was raw JSON; =--json= keeps the machine envelope; =net-fix= already used =--json= explicitly, the panel calls the engine in-process). A completeness test walks diag.py's step ids against the narration table so a new step can't land unnarrated. 470 net tests / 45 suites green; verified live on velox hotel wifi — both commands narrate the full probe sequence. Ratio picks it up with its queued dotfiles pull (source-imported, no restow-only step). + +Original ask (roam inbox, 2026-07-02, from a real net doctor run on hotel wifi): the output isn't enough to know what's being tested, why, and whether it passed — a failing run printed only the verdict, the fix pointer, and the failing rows: + +#+begin_example +% net doctor +net doctor: fixable + DNS not resolving + -> net repair dns-test + diagnose: + fail: DNS resolution — no resolution (portal may be stalling DNS) + fail: Internet — link up but no clean internet (DNS or egress issue) +#+end_example +** DONE [#B] Network panel: identify tunnel backends + richer connection info :feature:waybar:network: +CLOSED: [2026-07-02 Thu] +Shipped (dotfiles =405235f=). Identification: every Tunnels row's caption now leads with its backend — "tailscale", "WireGuard (NetworkManager)", "openvpn (NetworkManager)" (from the profile's =vpn.service-type=, resolved on the panel path only), "Proton VPN CLI" — via =viewmodel.tunnel_kind_label=. Found and fixed a real gap: NM vpn-type profiles (openvpn etc.) weren't listed at all, only wireguard type. Active tunnels now carry their device's IP4 address. Info page: the active connection's live subtitle gains IP, gateway, and DNS via =build_status(full=True)= (panel poll only — the bar's one-nmcli hot path is untouched). Live-verified on velox: all 9 tunnel rows correctly labeled (tailscale w/ tailnet + peers, 7 WireGuard NM profiles, Proton CLI), live subtitle shows IP/gw/DNS on hotel wifi. 523 net tests / 45 suites / panel smoke green. + +Craig's ask (roam inbox, 2026-07-02): the Tunnels rows all look alike — no way to tell which is tailscale, which is an NM wireguard/openvpn profile, and which is proton CLI without prior knowledge (e.g. when you want to bounce tailscale specifically). Second half: improve the stats under each connection — the panel is effectively a connection's info page. +** DONE [#B] Timer: alarm am/pm input silently fails :bug:waybar:solo: +CLOSED: [2026-07-02 Thu] +Fixed (dotfiles =8dd36c4=). Two root causes: =parse_alarm= only accepted 24h =HH:MM=, and =cmd_new= suppressed the ValueError, so any 12h input silently created nothing. Now accepts 24h (=14:30=, bare =14=) and all common 12h shapes (=2:30pm=, =2:30 PM=, =7:15p=, =7p=; any case, optional space, bare a/p; 12am = midnight), and input that still doesn't parse fires a fail notification instead of vanishing. 107 wtimer tests green (10 new parse cases + notify-on-error CLI tests). Manual test filed (live dialog run). + +Craig's report (roam inbox, 2026-07-02): when setting an alarm, entering am or pm in any fashion makes the timer silently fail. It should accept 24h and 12h variants — capitalization, spaces, bare "a"/"p" — all common forms. +** DONE [#B] Timer: escape doesn't cancel the dialog flow :bug:waybar:solo: +CLOSED: [2026-07-02 Thu] +Fixed (dotfiles =8dd36c4=). Root cause: =_fuzzel= ignored fuzzel's exit code, so Escape (fuzzel exits 2 on a dmenu abort — confirmed in its changelog) returned "" and the flow fed it onward to the next prompt. =_fuzzel= now returns None on any non-zero exit and =cmd_new= aborts the whole flow on None at any step (type, duration/alarm, label). Escape-at-each-step covered by CLI tests against a fake fuzzel exiting 2. Manual test filed (real keyboard Escape). + +Craig's report (roam inbox, 2026-07-02): hitting cancel via escape at the step after choosing "timer" does nothing but proceed to the next step — likely the same for the other dialog steps. +** DONE [#B] Network panel: stream speedtest results live :feature:waybar:network:solo: +CLOSED: [2026-07-02 Thu] +FIX-UP (dotfiles =60707be=, 2026-07-03, caught by Craig): the first shipped version didn't actually stream — speedtest-go buffers all phase lines to process exit when piped (per-line arrival timestamps proved it: 25s of silence, then everything at once; the original "live" verification never checked arrival timing). The stream now runs the binary under a pty, where terminal mode redraws continuously: in-flight rates tick (download climbing like a speedometer), ANSI/spinner noise is stripped, and on_update fires per changed value. CLI closes with a "final:" settled-numbers line. Re-verified with timestamps (server +1s, ping +2s, download first tick +4s, upload +19s, final +29s) AND an AT-SPI probe of the live panel that sampled the results box mid-run: ping filled at 4s, download ticking at 12s, upload at 24s, final rows + conditioned tips at the end. 529 net tests / 45 suites green. + +Shipped (dotfiles =38171e8=). =run_speedtest_stream= runs speedtest-go's plain mode, whose lines land one per completed phase (parser written against a real captured hotel-wifi run). Panel: a checklist fills in as ping → download → upload arrive, final rows at the end. =net speedtest= streams the same lines at the terminal (=--json= keeps the one-shot envelope). Bonus from the text mode: jitter (rides the Ping row) and packet loss (own row, warns >1%) — the JSON mode never reported either. The static Tip is gone; =speedtest_tips= derives guidance from the numbers (high ping >100ms, download < half of upload, <10 Mbps both ways, loss >1%), each tip naming its trigger values — that's the answer to Craig's "what criteria" question: the old tip had none, the new ones are stated rules. 509 net tests / 45 suites green; live CLI run streamed correctly and fired the asymmetric-download tip on real numbers (33 down / 76 up). Manual test filed for the in-panel run. + +Craig's ask (roam inbox, 2026-07-02): the speedtest only shows results at the end; typical speedtest UIs report the numbers as they come in. Stream the CLI's progress into the results box as it arrives, then the final numbers at the end. Screenshot: ~/pictures/screenshots/2026-07-02_225441.png. +** DONE [#C] Bluetooth bar icon: gray instead of the bar's white :bug:waybar:bluetooth:solo: +CLOSED: [2026-07-03 Fri] +Fixed (dotfiles =27d8eda=). Root cause: the =on= state sat in the css dim group with off/absent/degraded (a deliberate "idle dims" choice that read as broken). Removed =.on= from the dim rule in all three css copies (dupre, hudson, live style.css — the theme-drift guard suite pins them together); indicator docstring updated. Live-verified: SIGUSR2 css reload, bar screenshot shows the bt glyph in the bar's resting white alongside battery/text. + +Craig's report (roam inbox, 2026-07-03): the bluetooth waybar icon renders gray, not the same white as the other bar module icons. +** DONE [#B] Bluetooth panel: close button like the net panel :feature:waybar:bluetooth:solo: +CLOSED: [2026-07-03 Fri] +Shipped (dotfiles =42c93d6=): a flat circular Close button right of the tab switcher (accessible "Close" label, "Close (Esc)" tooltip), wired to window.close. The bt smoke asserts it exists AND that clicking it exits the panel (run green live). Plot twist answered in-session: the net panel had no close button either — Craig's leaner-chrome pass removed it 2026-07-01 (787b475) on the Esc-suffices theory; he asked where it went, so it was restored with the same tab-row button (=6a0aff7=, net smoke extended the same way). Both panels match again. + +Craig's ask (roam inbox, 2026-07-03): the bt panel needs a close button matching the network panel's. +** DONE [#B] Bluetooth panel: switch placement + panel title :feature:waybar:bluetooth:solo: +CLOSED: [2026-07-03 Fri] +Delivered by the instrument-console rebuild (spec e73877f5, phase 5). The adapter-power switch now sits on the faceplate above every console key, and the engraved ADAPTER line is the panel's title row with the clickable discoverable chip right-justified on it. + +Craig's ask (roam inbox, 2026-07-02): move the bluetooth on/off switch above all the buttons. "Bluetooth" becomes the panel's title, with the on/off switch right-justified on that title row. Panel code in ~/.dotfiles =bluetooth/= (GTK4 + Blueprint, phase-2 PanelModel/presenter — see the shipped panel task in Resolved). Presenter tests + the AT-SPI smoke likely need their layout assertions updated. +** DONE [#B] Bluetooth panel: rename devices :feature:waybar:bluetooth:solo: +CLOSED: [2026-07-03 Fri] +Delivered by the instrument-console rebuild (spec e73877f5, phase 5). =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (no MAC-addressed one-shot exists; =device_path= finds the controller node from the object tree), =manage.rename= wraps it with a verify-after read, and =parse_info= reads the Alias as the display name. Each paired row carries a ✎ affordance opening a rename dialog. Live-probed the mechanism on velox before wiring it (rename + restore verified on the M650). + +Craig's ask (roam inbox, 2026-07-02): the panel should be able to rename a device. bluez supports per-device aliases (=bluetoothctl= device menu =set-alias=; the one-shot invocation shape needs verifying at the btctl boundary). Wire it through the engine (=bluetooth/src/bt/=) with a verify-after read, and surface a rename affordance on the device row consistent with the panel's existing patterns. +** DONE [#B] Network panel: other network interfaces (tailscale, VPNs, wireguard) :feature:waybar:network: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:SPEC_ID: 79a1075a-4b56-4f25-a861-b69f120a636a +:END: +Spec: [[file:docs/specs/2026-07-02-net-panel-other-interfaces-spec.org]] (DOING — reviewed READY and decomposed 2026-07-02 evening; all four decisions were resolved same morning, claims re-verified live at review: protonvpn binary, tailscale JSON shape, seven importable wireguard configs). + +Tunnels visible and controllable in the net panel: tailscale + NM wireguard + proton-vpn-cli probes, a Tunnels group in Connections, diagnose/doctor route-ownership awareness, a bar badge when a tunnel owns the default route, archsetup operator flag + package swap, and the one-time NM import of the seven Proton configs. Origin: roam inbox capture 2026-07-02. + +*** 2026-07-02 Thu @ 18:47:05 -0400 Shipped phase 1 — overlay probes (dotfiles 2d9d060) +=net/src/net/overlays.py=: one probe per backend, shared row shape ={kind, name, state, addr, detail, can_toggle}=. tailscale parses =status --json= (up/down/needs-login/stopped, tailnet + N/M peers online + exit node detail, first TailscaleIP); wireguard rows filter =nmcli connection show= by type with uuids for the existing up/down wrappers; proton drives the official CLI — ground truth sampled live before writing the parser: the GUI-running refusal prints to stdout and EXITS 0 (text-detected, =can_toggle false=), disconnected = "Status: Disconnected", and the CLI's account store is separate from the GTK app's (=protonvpn info= → Account 'None' — sign-in is a phase 6 migration step for Craig). =net status= gained a fast-path overlays section (tailscale + wireguard only; the python CLI's ~300ms startup stays out of the indicator poll, and an active proton tunnel surfaces as its NM wireguard row anyway), guarded so a probe crash yields =[]= not a dead indicator. 19 new tests over fake-tailscale/fake-protonvpn/fake-nmcli (45 suites green); live check on velox: tailscale row up, 5/6 peers, hot path 149ms. proton-vpn-cli 1.0.1 installed on velox (GTK app stays until phase 5). + +*** 2026-07-02 Thu @ 19:02:45 -0400 Shipped phase 2 — panel Tunnels sub-view (dotfiles 21db05a) +Connections gained a third sub-view (Available | Saved | Tunnels — a StackSwitcher page, the natural landing for the spec's "fourth group" in this UI): rows from =overlays.collect(fast=False)= with the vpn glyph, name, and a =tunnel_caption= (state · addr · backend detail); one primary button follows the selected row via =PanelModel.tunnel_primary()= — Bring Up/Bring Down when toggleable, disabled explainers for needs-login ("Sign in first: tailscale up") and the Proton GUI-running case. =manage.tunnel_up/down= dispatch by kind (wireguard rides the existing nmcli up envelope + =connection down=; tailscale/protonvpn shell their tools into a =_tool_result= envelope carrying stderr on failure); ops run on the worker thread, rows + bar reload on land. gui grew =refresh_tunnels()= (bg, full probe set) kicked from the list load. AT-SPI smoke extended (Tunnels tab, action button, rows — POLLING for the bg load; a fixed sleep raced it and false-failed). 22 new tests (45 suites green). LIVE on velox: smoke fully green, rows eyeballed in dupre (tailscale up caption with peers count; proton app-running row), =tailscale set --operator=cjennings= applied and the user-mode =tailscale down/up= round-trip verified (Self.Online back true). Gotcha reconfirmed: stray test panels leave a windowless single-instance process — =pkill -9 -f '[n]et panel'= + wait before relaunch. + +*** 2026-07-02 Thu @ 19:11:47 -0400 Shipped phase 3 — diagnose/doctor tunnel awareness (dotfiles 31ba056) +=overlays.default_route_owner()= classifies the default route's owner (tailscale prefix, wg/pvpn/proton/tun/tap prefixes, else the active NM connection's type — imports can name a wireguard device anything). diag's route step went three-way: overlay owner = informational pass row ("internet flows through the tailscale tunnel tailscale0"), other physical link = the old multi-homing warn. When the HTTP probe fails while a tunnel owns the route, a new "tunnel" edge row LEADS the evidence and the classifier returns fixable/action tunnel-down (the deferred-vpn verdict is retired — it was look-don't-touch, and it never caught tailscale at all since NM lists it unmanaged; an NM VPN that doesn't own the route now falls through to normal classification instead of being blamed). =repair_tunnel_down= dispatches by owner (tailscale CLI / protonvpn CLI for pvpn-named devs / nmcli connection down via active-connection lookup), verifies route ownership actually moved, and registered in ACTIONS so Get Me Online drives it. fake-ip gained FAKE_IP_DEFAULT_DEV_SEQ (head-first line consume, the UP_RC_SEQ idiom) so tests watch the owner change across the verify. 11 new tests, 2 old deferred-vpn pins rewritten to the new contract; 45 suites green; live read-only diagnose on velox clean (wlan owns the route — no tunnel rows, as designed). + +*** 2026-07-02 Thu @ 19:14:58 -0400 Shipped phase 4 — bar tunnel badge (dotfiles b4010bf) +=net status= carries =tunnel_route= ({dev, kind} via =overlays.default_route_owner=, exception-guarded like the overlays list, present on the no-device path too). The indicator appends a small nf-md-vpn badge after the state glyph, emits =["<state>", "tunnel"]= as a waybar class list (string class unchanged when no tunnel), and the tooltip names the owner ("Tunnel: default route via tailscale0 (tailscale)"). No css edit — presence is the signal, themes can hook the class later, and the waybar/style.css drift test stays untouched. 4 new tests; StatusHarness gained fake-ip so the machine's real route can't leak into assertions (462 net tests, 45 suites green). Live payload on velox verified badge-free (wlp170s0 owns the route — correct); a badge render awaits the first real tunnel-owned route (phase 6's wg import or a tailscale exit node). + +*** 2026-07-02 Thu @ 21:56:00 -0400 Shipped phase 5 — installer proton CLI swap + tailscale operator (archsetup 0389790); GTK app retired live on velox +The feat commit landed at 19:16 (the session died before this close-out): installer enables tailscaled with =--now= and grants =tailscale set --operator= to the primary user (brief retry while the daemon's socket comes up), proton-vpn-cli replaces proton-vpn-gtk-app, VM asserts the vpn stack + the retirement + the OperatorUser pref (format verified against a live daemon). Live velox application finished 21:55: the =protonvpn-app --start-minimized= exec-once removed (dotfiles b5c8442 — nothing replaces it, the CLI is on-demand from the panel), the running app killed, =pacman -Rns proton-vpn-gtk-app= (proton-vpn-daemon stays — separate package the CLI uses). CLI verified unblocked: =protonvpn status= → "Status: Disconnected", =protonvpn info= → Account 'None' (sign-in is Craig's step, filed under Manual testing and validation). + +*** 2026-07-02 Thu @ 21:57:00 -0400 Shipped phase 6 — wireguard import script + velox migration (scripts/import-wireguard-configs.sh) +The script stages each config through a =wgpvpn.conf= temp copy (NM's import name must be a valid <=15-char interface name; several config names are longer), renames by the UUID parsed from the import output (never by the transient name, so a stray same-named connection can't be hit), forces =autoconnect no= (full-tunnel AllowedIPs 0.0.0.0/0 must not arm itself at boot), skips already-imported names, and refuses to run past a stale =wgpvpn= connection (an earlier run that died between import and rename — it still has autoconnect on). =tests/import-wireguard-configs/=: 10 cases over a fake nmcli; writing them caught a real bug (under =set -e= the grep-for-UUID pipeline aborted before the error message printed). shellcheck clean; 11 unit suites green. Velox migration verified: the crashed session had already run the import, so tonight's run exercised the skip path live — all 7 connections confirmed wireguard type, autoconnect no, iface wgpvpn, no stale leftovers; =net status= overlays show tailscale + all 7 rows. Ratio runs the script on its trip (rides the archsetup pull). + +*** 2026-07-02 Thu @ 21:58:00 -0400 Test surface complete across the phases +Probe suites over fake tailscale/nmcli/protonvpn (19, phase 1), panel-model Tunnels coverage (22, phase 2), diag overlay-ownership cases (11, phase 3), badge suite (4, phase 4) — all in dotfiles; VM assertions for phase 5 in archsetup 0389790; the import-script suite (10, phase 6) closes the set. +** CANCELLED [#B] File-manager swallow pattern :feature:hyprland: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-02 +:END: +Reassigned to .emacs.d 2026-07-02 (handoff: =~/.emacs.d/inbox/2026-07-02-2231-from-archsetup-dirvish-popup-swallow-handoff.org=). The "file manager" is the dirvish popup (Super+F, an Emacs frame), not nautilus — so the fix is elisp in dirvish's external-open path (=cj/xdg-open=): spawn the handler directly with =start-process=, hide the popup frame, restore it from the process sentinel, notify on non-zero exit. The spec drafted here first ([[file:docs/specs/2026-07-02-file-manager-swallow-spec.org]], now CANCELLED) records the feasibility finding that stays useful: gio/xdg-open launches double-fork, so no PID-ancestry approach (Hyprland native swallow included) can ever connect viewer to launcher. + +When the file manager launches another app, it should hide to a special workspace (the "swallow" pattern) and return when that process ends, rather than vanishing. Today it disappears with no signal of whether it's coming back, so the user can't tell success from failure — they should quit explicitly instead. Origin: roam inbox capture. + +*** 2026-07-02 Thu @ 22:20:00 -0400 Feasibility ground truth: Hyprland native swallow ruled out +=misc:enable_swallow= would be the whole feature in two config lines, but it matches by PID ancestry, and nautilus's launch path (GLib =g_app_info_launch_default_for_uri=) orphans the handler — reproduced live on velox with a python-gi launcher: feh came up with PPID 1 while the launcher was still running. The spec's design is therefore an event-listener daemon (socket2 =openwindow=/=closewindow= while nautilus is active), the touchpad-auto shape. Handlers sampled: pdf → zathura, image → feh (X11 — flagged as a side task), video → mpv, text → emacsclient (exempt candidate, decision 2). +** DONE [#C] Open meeting links in the browser instead of the Zoom app :feature: +CLOSED: [2026-07-02 Thu] +Shipped 2026-07-02, mechanism per Craig ("the Linux zoom app is really terrible — one less dependency"): a =zoommtg://= URL handler, and the native app retired outright. =zoom-web= (dotfiles 187414a, 10 tests) registers as the xdg default for x-scheme-handler/zoommtg via =zoom-web.desktop=; Zoom's launch-page bounce rewrites deterministically to =https://<host>/wc/join/<confno>?pwd=…= in the default browser (subdomain hosts preserved, tracking params dropped, start action mapped, malformed URIs notify + exit 2). The registration landed in the stowed mimeapps.list, so it ships with dotfiles. Zoom uninstalled from velox (=pacman -Rns=), its windowrules removed from hyprland.conf, =aur_install zoom= dropped from archsetup, and the VM retired-package assertion now covers blueman + zoom. Known limit, accepted: a host who disabled join-from-browser blocks the web client — that meeting needs the native app installed ad hoc. Ratio trip: =pacman -Rns zoom= + the pull brings the handler; run =xdg-mime default zoom-web.desktop x-scheme-handler/zoommtg= if the stowed mimeapps.list doesn't take effect. +** DONE [#B] Network panel redesign — no terminals, verify-everything, full failure coverage :feature:waybar:network: +CLOSED: [2026-07-03 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-02 +:END: +Delivered by the instrument-console rebuild (spec e73877f5). The three locked decisions all landed: no terminals (the single-screen console renders every action and result in the output well — net-popup is gone), the passwordless privileged path (the net-priv helper + narrow NOPASSWD sudoers, shipped earlier and carried forward), and verify-every-action (arm-to-fire mutations plus doctor's re-probe). The failure-mode catalog below is the diagnose/repair contract, built out across the net-diagnostics tasks and this rebuild's DOCTOR path; the catalog stays here as the standing completeness reference for that path. + +Major evolution of the shipped =custom/net= module ([[file:docs/design/2026-06-29-waybar-network-module-spec.org]]). +Reverses the spec's "privileged tiers run in a net-popup terminal" decision. Origin: +design conversation 2026-06-30. + +*** Locked decisions +- *No terminals anywhere in the module.* Delete =net-popup= entirely. Every action and + every result renders in the panel. +- *Passwordless privileged path (the enabler).* A single root-owned helper runs net's + specific privileged commands (rfkill unblock, nmcli modify/up, networking off/on, + systemctl restart NetworkManager/systemd-resolved, resolvectl dns/revert, DoT toggle), + installed by archsetup with a narrow NOPASSWD sudoers rule scoped to that helper only + (never blanket mv/systemctl). =repair.py= calls =sudo <helper> <verb>=. This supersedes + and absorbs the earlier [#C] "Passwordless DoT toggle" follow-up. Without it an in-panel + worker thread can't prompt for a password, so this gates the whole no-terminal goal. +- *Verify every action.* Every mutating op confirms its effect before reporting success + (doctor already re-probes; generalize so each repair, connect, forget, add, and DNS + override re-checks and surfaces pass/fail in the panel). +- *Detect + respond to every failure mode below* (auto-fix where we can, else report the + helpful text), including the edge cases. + +*** Navigation (confirmed) +- Top tabs: =Connections= | =Diagnostics= | =Performance=. +- Connections: saved + in-range list, connect / add / forget. +- Diagnostics: sub-row =Diagnose= | =Get Me Online= | =Advanced=; shared area below shows + diagnose items AND streams repair progress (replacing the terminal). =Advanced= reveals + the individual repair buttons, renamed with tooltips describing each. +- Performance: Speedtest (+ live throughput later). + +*** Failure-mode catalog — detect / correct-or-report (the completeness backbone) +Organized by the connectivity stack, bottom-up. "Fix" = auto-correct + verify; "Report" = +the in-panel text when there's no safe auto-fix. Audit this list for completeness; it is the +contract for what diagnose must detect and what the panel must say. + +**** Radio / hardware +- rfkill soft block — Detect: rfkill soft. Fix: unblock + =nmcli radio wifi on=, verify radio unblocked. +- rfkill hard block — Detect: rfkill hard. Report: "WiFi is off at the hardware switch — flip the physical switch or Fn key." +- No WiFi adapter present — Detect: no wifi device in nmcli + rfkill absent. Report: "No WiFi adapter detected — use ethernet, or check the driver (dmesg | grep firmware)." +- Driver/firmware not loaded — Detect: device present but errored / no operational state. Report: "WiFi driver or firmware didn't load — check dmesg for the adapter." +- USB WiFi adapter unplugged — Detect: device disappeared since last scan. Report: "WiFi adapter was removed — reconnect it." +- Airplane mode on — Detect: airplane state file set. Fix: offer toggle off (Super+Shift+A), verify radios back. + +**** Association (L2 link) +- Not connected / disconnected — Detect: link down, device disconnected. Fix: reset (reconnect saved), verify link up. +- Stuck "connecting" — Detect: device state connecting > budget. Fix: reset, verify; if it persists Report: "Stuck connecting to <ssid> — the AP may be rejecting us." +- Weak signal / high loss — Detect: associated but signal below threshold (dBm) or heavy packet loss. Report: "Signal is weak (<dBm>) — move closer to the access point." +- Saved network not in range — Detect: profile active target not in scan. Report: "<ssid> isn't in range here." +- AP roaming flap — Detect: BSSID bouncing. Report: "Connection is unstable — switching between access points." + +**** Authentication +- Wrong WPA password / missing secret — Detect: NM state 120 (snapshot; live detection is a known limit). Report + in-panel re-enter: "Saved password for <ssid> was rejected — re-enter it." +- Enterprise / 802.1X cert or identity failure — Detect: 802.1X profile + activation failure. Report: "Enterprise auth failed — check the certificate or identity (edit the profile)." +- Randomized MAC rejected by AP — Detect: reset-with-random-MAC fails where a prior connect worked. Fix: retry reset with the permanent MAC, verify; else Report. +- WPA3/SAE incompatibility — Detect: SAE key-mgmt + association failure. Report: "This network needs WPA3 and the adapter or profile may not support it." + +**** IP / DHCP +- No IPv4 lease (DHCP timeout) — Detect: connected, no IP4.ADDRESS. Fix: reset → bounce, verify lease. +- APIPA / link-local only (169.254.x) — Detect: only a link-local IPv4. Fix: reset/bounce, verify real lease; else Report: "DHCP server didn't answer — switch network." +- IPv6-only network (no IPv4 by design) — Detect: no IPv4 but IPv6 address + online via v6. Report (not a failure): "Online over IPv6 (no IPv4 here)." Requires making diagnose IPv6-aware. +- IP but no gateway — Detect: IP4.ADDRESS present, IP4.GATEWAY empty. Fix: bounce, verify gateway; else Report. +- Duplicate IP / ARP conflict — Detect: kernel ARP-conflict signal. Report: "Another device is using our IP address — reconnect to get a new lease." (edge) + +**** Gateway (L3 local) +- Gateway unreachable — Detect: no route out, gateway no ICMP. Fix: try one bounce (renew route), verify online; else Report: "No route to the gateway — switch network." (closes the spec/code gap where bounce was never tried) + +**** DNS +- No resolver configured — Detect: IP4.DNS empty. Fix: bounce to re-pull DHCP DNS, verify; else Report. +- Venue DNS broken, public DNS works — Detect: name fails to resolve but 1.1.1.1 resolves (dns-test). Fix: set a PERSISTENT resolver override (1.1.1.1 / 9.9.9.9), verify resolution + online, offer revert. (closes gap #1 — today dns-test reverts and misreports as upstream.) +- DNS hijack (resolves to gateway / private IP) — Detect: classify_resolution hijack. Treat as captive → portal-login flow. +- DNSSEC validation failure — Detect: resolution fails with SERVFAIL where public resolver succeeds without DNSSEC. Report: "DNS security checks are failing on this network." (edge) +- Encrypted DNS (DoT/DoH) hiding the portal — Detect: captive suspected + DoT on. Fix: portal-login drops DoT, opens portal, auto-restores. (existing) + +**** Egress / internet +- Upstream / AP outage (no uplink) — Detect: link/IP/DNS fine, http-probe fail, not a redirect. Report: "This network has no internet — switch network or contact the venue." +- Captive portal (redirect) — Detect: probe redirected. Fix: portal-login opens the page; verify online after login. +- Captive blocked pre-auth (no portal URL) — Detect: probe blocked, no URL. Fix: fresh MAC + open trigger; verify. +- Proxy-required network — Detect: probe fails but a PAC/proxy is advertised (WPAD/env). Report: "This network requires a proxy — configure it in settings." (edge) +- MTU / MSS blackhole (PMTUD broken) — Detect: small probe ok, large transfer hangs. Fix: lower the interface MTU, verify; else Report. (edge) +- Clock skew breaking TLS — Detect: HTTPS/portal fails with cert-time errors + system clock far off. Fix: trigger a time sync, verify; else Report: "System clock is wrong — fix the date/time." (edge) + +**** Routing / multi-homing +- VPN owns the route, no internet through it — Detect: VPN device connected + http-probe fail. Report: "Internet is routed through a VPN (<dev>) — check the VPN, not WiFi." +- VPN up but dead — Detect: VPN device up, no traffic/handshake. Report: "The VPN is connected but not passing traffic." (Phase 5 territory) +- WiFi + tether/ethernet both active — Detect: which iface owns the default route + whether the system is online by any path. Report: "You're online through <other iface>; WiFi itself has no internet," or let the user pick. (closes gap #4) + +**** Infrastructure / system +- Wedged NetworkManager — Detect: nmcli fails / API unresponsive. Fix: restart NetworkManager (bounce escalation), verify. +- NetworkManager not running — Detect: service inactive. Fix: start it, verify; else Report. +- systemd-resolved down — Detect: resolved inactive / DNS via it fails. Fix: restart, verify. +- resolv.conf not resolved-managed — Detect: /etc/resolv.conf not the resolved stub. Report: "DNS isn't managed by systemd-resolved — manual resolv.conf in play." (edge) + +**** Tooling / environment +- nmcli / NM API unavailable — Detect: nmcli error or timeout. Report: "Can't reach NetworkManager — is it installed and running?" +- Slow / hung tool — Detect: step exceeds budget. Fix: degrade that step, retry within budget. +- Stale / corrupt cache — Detect: schema/age mismatch. Fix: self-heal (atomic write + invalidation). +- Missing speedtest backend — Detect: speedtest-go absent. Report: "Install speedtest-go to run a speed test." +- Privileged op fails (helper missing / sudo declined) — Detect: helper exits non-zero or absent. Report: "Couldn't get admin rights for this repair — <install/fix the helper>." + +*** 2026-07-01 Wed @ 13:02 -0400 net-priv helper landed (V2.1) +Craig's call: stowed (not root-owned), low security on locked-down single-user machines. +Shipped =net.priv= module + stowed =net-priv= bin (dotfiles =00aac1e=): a fixed 12-verb set +(rfkill/radio/mac-random/conn-up/net-off/net-on/restart-nm/dns-set/dns-revert/restart-resolved/ +dot-disable/dot-enable) with per-arg validation (uuid/iface/ipv4/resolved.conf.d-path, injection +rejected). =repair.py= now routes every privileged op through =priv.run(verb)= in-process instead +of scattered inline sudo — which also fixes the detached DoT-restore watcher (runs privileged ops +with no tty) and closes the gap where rfkill repair ran unprivileged. 244 net + 33 dotfiles suites +green. NO new sudoers needed: archsetup already grants =%<user> ALL=(ALL) NOPASSWD: ALL= +(archsetup:1089), so every build's primary user already runs net-priv's commands passwordless; +"replicate in archsetup" is already satisfied. net-priv rides =make stow hyprland=; hand-linked on +velox. The velox DoT-path reconcile (whether velox should run DoT at all) stays open — folded into +the deeper reconcile, low priority since the guard makes it a no-op. +*** 2026-07-01 Wed @ 14:05:47 -0400 Shipped V2.2 — merged Diagnostics panel + nav restructure, no terminals +Built the V2 panel (dotfiles =75ed825=, pushed): three top tabs Connections | +Diagnostics | Performance; Diagnostics merges the old Diagnose + Repair pages into a +sub-row (Diagnose | Get me online | Advanced) over a shared area that shows diagnose +rows AND streams repair progress in-panel. net-popup deleted entirely; repairs run on +a worker thread through net-priv (no tty). doctor grew an =on_step= callback so Get me +online streams each escalation step live. Connections groups Saved / Available now / +Wired with a golden group header and joins from a row (=join_plan= auth matrix + +=manage.join= one-step connect, secret to NM only); the Add modal became the hidden-network +affordance. Every diagnose/repair/speed run offers a Copy/Open redacted report +(=report.py=, MAC/IP scrubbed). Waybar visual contract applied (dark capsule, golden +border, monospace) via a CssProvider. =net-fix= opens the panel on Diagnostics instead of +a terminal; middle-click runs =net portal= directly. TDD: 34 new GTK-free tests (grouping, +join_plan, join, report, on_step, eventlog.tail); 278 net + 33 dotfiles suites green. +Live-verified: AT-SPI panel_smoke passes end-to-end + screenshots confirm both pages and +the visual contract. DAILY-DRIVER: waybar config + net-fix are stow symlinks (live on +disk); ratio needs =git pull= + waybar restart; velox waybar picks up on next restart. +**** 2026-06-30 Tue @ 17:36 -0400 Dispositioned the 4th-review findings into the spec +Codex's 9 fourth-review findings (8 accept, 1 modify) are folded into the spec's +"V2 panel UX — the target design" section (cookie [40/40]): single nav target, +saved-vs-available groups, join-from-row instead of Add, the auth-class join matrix, +progressive loading, future-tense + verified Forget, a findable redacted diagnostics +report, the Waybar visual contract, and a lightweight inline latency probe (full speed +test stays under Performance per decision 19). The V2 build below implements that +design: [[file:docs/design/2026-06-29-waybar-network-module-spec.org::*V2 panel UX][V2 panel UX]]. +*** 2026-07-01 Wed @ 22:01:38 -0400 Made diagnose IPv6-aware and multi-homing-aware (dotfiles c0d48e2) +IPv6-only networks pass the DHCP step ("IPv6 only: <addr>") with the v6 gateway standing in for the ping; a bare fe80:: doesn't count. A new route step fires only under multi-homing and names the interface that owns the default route (tether/ethernet/VPN). Also landed the adjacent IP-layer detects: APIPA 169.254 fails DHCP with a link-local explanation, address-without-gateway fails the gateway step as a bad DHCP answer, and a weak wifi signal (below fair) warns on the link step with the dBm. fake-nmcli grew IP6.* and a fake ip(8) serves the JSON route reads. TDD, 33 suites green. + +*** TODO Close every detect/correct gap in the catalog, with post-action verification +**** 2026-07-01 Wed @ 22:41:51 -0400 Closed the feasible edge rows (dotfiles d096b30, 241744b, fafefb6) +Three grouped commits, all TDD. Services/radio: dead NetworkManager and dead systemd-resolved get their own diagnose steps and verified restart repairs (resolved only when resolv.conf is resolved-managed; hand-managed DNS gets a heads-up row), airplane mode fails the link by name and classifies needs-user-action ahead of rfkill, and a missing WiFi adapter is named with the dmesg pointer. Association/auth: reset retries once with the permanent MAC when the randomized one is rejected (new mac-permanent net-priv verb), SAE/WPA3 activation failures classify sae-incompat, and stuck-connecting classifies fixable/reset. Egress edges (run only on an existing failure): DNSSEC validation failure named via resolvectl, clock skew off the probe's Date header, MTU/PMTUD blackhole via df-bit pings, and proxy detection (env vars or an advertised WPAD name). Deferred as infeasible without state the engine doesn't keep: AP roaming flap (needs BSSID history), duplicate-IP/ARP conflict (needs the kernel log), and the USB-unplug transition (its end state is the no-adapter row). Still open here: generalized post-action verification for connect/forget/add. + +**** 2026-07-01 Wed @ 22:01:38 -0400 Closed the two named correct gaps (dotfiles 7819f58) +Gateway unreachable now earns one bounce before the upstream verdict (classifier returns fixable/bounce on gateway warn/fail + probe fail; reachable-gateway keeps the honest upstream call, DNS failure still outranks it). Venue-DNS-broken-but-public-works now ends online: the dns-test chain escalates to a persistent dns-override (1.1.1.1 on the link, dies on reconnect, offered dns-revert undo; a useless override reverts itself) instead of auto-reverting into a misreported upstream outage. Override-aware getent/curl fakes model the venue end to end. Remaining: the edge rows (DNSSEC, proxy, MTU blackhole, clock skew, ARP conflict, roaming flap, stuck-connecting budget, USB-adapter unplug, driver/firmware, WPA3/SAE, randomized-MAC retry, NM-not-running, resolved-down, unmanaged resolv.conf) and the generalized post-action verification for connect/forget/add. +*** TODO Automatic diagnostic verbose-capture (failing diagnose + Advanced toggle) +On =overall: fail=, elevate the underlying stack (NM =WIFI,DHCP,DNS,CORE= / systemd-resolved / +wpa_supplicant) to debug at runtime, run the escalation, capture the journal + dmesg window + +=curl -v=, then restore every level. Also a manual "Debug on/off" toggle in Advanced for +reproducing intermittent failures. HARD: restore is guaranteed (try/finally) AND crash-guarded +(next run detects a left-elevated stack and restores it, like the DoT-restore watcher); the +captured journal is REDACTED before the bundle is written/shown (raw wpa_supplicant/NM debug +carries the PSK/EAP secret in cleartext) with a secret-leak test; log-level toggles run via the +V2 sudo-helper. Bonus: wpa_supplicant debug catches wrong-password/EAP failures the current NM +state-120 snapshot misses, so it also closes the auth live-detection gap. Spec: Observability → +"Automatic diagnostic verbose-capture". Origin: Craig 2026-06-30. + +*** VERIFY Dead-GUI console recovery vs "no terminals" — keep =make online= or replace it? :network: +The cj comment (2026-07-01) said scrub every terminal the module uses to report to or get input +from the user, and I folded that into decision 15 (all module UX is in-panel). The one place it +collides: the deliberate console-recovery path — =make online= / =net doctor --fix= run from a +bare TTY when waybar and the GUI are *down* — is the whole point of the CLI being usable with no +GUI. That's a terminal reporting to the user, but only because there's no panel to use. Keep it +as an explicit carve-out (recovery-only, not terminal-as-UI), or replace it with something else +(a TTY text UI still counts as a terminal)? Your call settles whether the Makefile/CLI recovery +targets stay in the spec. +** DONE [#B] Audio panel spec :feature:waybar:audio:solo: +CLOSED: [2026-07-03 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-02 +:END: +Went past the spec to a full build in a no-approvals speedrun. Spec is now IMPLEMENTED ([[file:docs/specs/2026-07-03-audio-panel-spec.org]], all 5 Decisions resolved). The panel shipped in the dotfiles repo (branch panel-bugfixing, commits 65e5bb0..9601420): pactl engine, GTK-free presenter, GTK instrument-console panel (OUTPUTS/INPUTS device rows with faders + per-device mute, LIVE/MUTED/PUSH·TALK mic keys, twin VU gauges, master quick-mute), Hyprland-bind push-to-talk, bar indicator, and the bar/keybind wiring (Super+A → panel, XF86AudioMute → master quick-mute). 102 unit tests + a passing AT-SPI smoke on velox. Live-eyeball validation filed under Manual testing and validation. Apply steps + follow-ups handed to the dotfiles project inbox. + +Original ask (roam inbox, 2026-07-02): net/bt-panel kin — change default output/input, volume for both, push-to-talk mic mode for meetings, master quick-mute, bar sound-glyph state. Related bindings: Super+M audio-cycle ring, Super+Shift+A mic-toggle. Prototype: =docs/prototypes/2026-07-03-sound-panel-prototype.html=. +** DONE [#B] Panels moveable + resizable by drag :feature:waybar:network:bluetooth: +CLOSED: [2026-07-04 Sat] +Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): both net + bt panels switched from anchored gtk4-layer-shell overlays to normal floating windows (set_decorated(False), positioned by the net.cjennings.netpanel window rule), so Hyprland moves them on drag and resizes on corner-drag natively. That was exactly the "switch to a normal floating window" approach the design note flagged as the required decision. + +Both the net and bluetooth instrument-console panels should be repositionable and resizable at runtime: click-drag to move the panel anywhere on screen, drag the corners to resize. Raised from roam capture 2026-07-03. + +Design note: the panels are gtk4-layer-shell overlays anchored TOP+RIGHT with fixed margins — layer-shell surfaces are compositor-positioned, so free drag-move/resize needs either dynamic margin updates on pointer motion or a switch to a normal floating window (Hyprland moves/resizes those natively). Approach decision required before build. +** CANCELLED [#B] Net panel wider initial width :waybar:network:quick: +CLOSED: [2026-07-04 Sat] +Superseded by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel is now a floating, user-resizable window (set_default_size(420, 560)), no longer a right-anchored layer-shell surface. The task's mechanic ("keep the right edge fixed, extend the left border leftward") assumed the old anchored surface, which no longer exists — the width is now drag-adjustable. Cancelled per the 2026-07-04 audit (Craig's call to close rather than re-file a "bump the 420px default" task). + +Start the network panel a bit wider — keep the right edge fixed (it's right-anchored), extend the left border leftward. Raised from roam capture 2026-07-03. +** DONE [#B] Net panel doctor results can't display :bug:waybar:network: +CLOSED: [2026-07-04 Sat] +Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel gained a streaming output well (gui.py) with a "Copy results" button (via wl-copy) and a dismiss control that collapses the well back to the panel's pre-open height (_shrink_to_compact asks Hyprland to resize back). Doctor/speed-test output streams into it as appended lines — matching the task's ask for a tall results box, copy button, and collapse-back. This capture (filed 2026-07-03 morning) predates the same-day 22:06 redesign that addressed it. + +The doctor diagnostic output is unreadable — the results well is too constrained to show the multi-line result. It should open a results box tall enough for several lines with a copy-results button; closing it via an X in the box's upper-right collapses the space back to what it occupied before. Raised from roam capture 2026-07-03. +** DONE [#B] Timer GTK panel :feature:waybar: +CLOSED: [2026-07-05 Sun] +Built and shipped to dotfiles 2026-07-05 in a no-approvals speedrun (4 commits =1f4f270=..=78d3cbb=): wtimer gained watch/lap/save; a new =timer/= package holds a GTK-free PanelModel (62 tests) and the GTK instrument-console panel; the bar's =custom/timer= now opens the panel and the fuzzel creation flow retired. Spec: [[file:docs/specs/2026-07-02-timer-panel-spec.org]] (IMPLEMENTED). Code-complete; live GTK verification filed under Manual testing and validation below. + +From Craig's roam capture 2026-07-02: give the timer a GTK UI/UX like the network panel. Scope expanded via a later cj comment (queue/output-wall auto-sorted by fire time, stopwatch lap/stop + saveable runs, 5/25 configurable defaults, up to 10 timers, widget-gallery elements) — folded into the spec's Build scope and shipped. + +*** 2026-07-05 Sun @ 07:20:20 -0500 Redesign shipped — hero-on-top rebuild +The UI/UX redesign (decided through the prototype process, final = [[file:docs/prototypes/2026-07-02-timer-panel-prototype-3.html]]) built and shipped to dotfiles in a no-approvals speedrun, 5 commits =c7ac193=..=5a863b5=: Phase 1 wtimer engine (timer repeat; recurring alarms with snooze/ringing/dismiss; =@half=/=@hour=/=+dur= alarm parse; the rebuilt configurable pomodoro cycle — work/rest short+long, long-every-N, auto vs awaiting); Phase 2 PanelModel view-data (=row_view=, ringing-first sort, per-type create options as wtimer flags, locked presets + half-past + named pomodoro cycles); Phase 3 GTK hero-on-top panel (Cairo progress ring + stopwatch analog sweep dial, per-type create strips, one transport row, close ✕/Esc); Phase 4 bar-tooltip parity. wtimer + timer suites 231 green, full =make test= green. Spec re-flipped DOING → IMPLEMENTED. Stopwatch run-save deferred to vNext. Live GTK render is the refreshed manual checklist below. +** CANCELLED [#B] Test each modernization thoroughly before replacing +CLOSED: [2026-07-04 Sat] +Retired in the 2026-07-04 audit (Craig's call): a standing-judgment umbrella with no completion criterion. The fleet is Hyprland-only now, and per-change test discipline is already carried by the actual work (TDD + the VM harness), so this adds nothing to track. Original intent: ensure new tools integrate with the Hyprland environment and don't break workflow (archsetup still supports DWM/X11 but no current machine uses it). +** DONE [#C] Window focus lost when unhiding stashed windows :bug:hyprland: +CLOSED: [2026-07-04 Sat] +Verified fixed live on ratio 2026-07-04 (Craig at the machine). Stash (Super+O) → restore (Super+Shift+O) left the restored window focused, and Super+J/K (layout-navigate) cycled focus normally afterward — both original symptoms gone. Resolved by two fixes that postdated the filing: dotfiles 5619342 (raise window on focus nav, stop float focus-follow, 2026-06-28) and 09815f3 (cycle focus by address so j/k works in monocle, 2026-06-29). Both confirmed present in ratio's HEAD and in the live layout-navigate script at test time. + +From the roam inbox: hiding a window (e.g. the org-capture popup) then unhiding it should leave the unhidden window focused, but another window typically takes focus. Also =ctrl+j/k= (layout-navigate) can't reach the unhidden window afterward — it should always reach any visible window except the waybar. Involves stash-restore + layout-navigate; needs interactive reproduction with Craig. (Note: the actual bind is Super+J/K, not ctrl+j/k as the capture said.) +** DONE [#C] Instrument-console panel bugs (net/bt/audio) :bug:dotfiles: +CLOSED: [2026-07-05 Sun] +Batch from the roam inbox (2026-07-05). Panel code lives in =~/.dotfiles= (net/, bluetooth/, audio/). All eight shipped 2026-07-05, each verified live and covered by the panel test suites. +*** 2026-07-05 Sun @ 17:49:51 -0400 Titled the panel windows Network/Bluetooth/Audio (was python3) +The GTK app set no window title, so it fell back to the process name. dotfiles 2d03451. +*** 2026-07-05 Sun @ 17:49:51 -0400 Faceplate word is the subsystem name, not live state (net/bt/audio) +NETWORKING / BLUETOOTH / AUDIO; state now reads off the lamp colour + badges. Bluetooth's DOCTOR feedback (CHECKING/FIXING) moved to the status line and output well. dotfiles 5c58833. +*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel flags a signed-out Proton CLI as needs-login +The proton probe reads =protonvpn info= first, since =protonvpn status= says Disconnected either way. The panel already blocks a needs-login row with a sign-in hint. dotfiles 2671472. +*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel sorts live tunnels to the top +Stable within each backend group. dotfiles 2671472. +*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel refuses a second full-tunnel VPN while one is active +tailscale is a mesh overlay, not a full tunnel, so it never conflicts. dotfiles 307a0fe. +*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel dedupes Proton's own wireguard row +The proton CLI's =ProtonVPN <server>= NM profile no longer shows alongside the proton backend row. dotfiles dbc9ee8. +** DONE [#B] Maintenance console build :feature: +CLOSED: [2026-07-08 Wed] +:PROPERTIES: +:SPEC_ID: 9d9df833-c592-4aec-a7df-50d588e943ce +:END: +Build the maintenance console per [[file:docs/specs/2026-07-07-maintenance-console-spec.org][maintenance-console-spec.org]] (DOING; review incorporated 2026-07-07 — two rounds, all 13 decisions DONE, all 10 findings DONE, verdict Ready; Craig approved config paths + sysmon right-click re-homing). Design source of truth: [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]]; pixel reference: [[file:docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5 prototype]]. CLI-first =maint= package in dotfiles (archsetup-owns-dotfiles applies: edit, test, commit, push, note dotfiles inbox per phase); thresholds TOML + VM harness in archsetup. Each phase gates on green tests before its commit. + +*** 2026-07-07 Tue @ 20:11:23 -0500 Built Phase 1 — package skeleton, thresholds, contract (dotfiles 43a39ac) +maint/ package + shim + tests/maint/ (48 tests, fake-tool harness); thresholds two-layer merge with disable flags + env overrides; capability probe (battery ⇔ type==Battery, verified against ratio's Mains+USB tree); =maint status --json= with disk-usage/pacman-cache/failed-units pilots. Seeded configs/maintenance-thresholds.toml in archsetup from the workflow's values and installed a copy to ~/.config/archsetup/ on ratio (installer wiring stays Phase 13). Full make test green (50 suites). Live on ratio: worst=warn from the real 10.75 GB cache; du grades on its printed total, not its exit code (root-owned partial-download dirs make it exit 1) — pinned in a test. + +*** 2026-07-07 Tue @ 20:49:05 -0500 Built Phase 2 — storage & snapshot collectors (dotfiles 94e8371) +All Phase 2 collectors TDD'd against the fake-tool harness (argv-matched cases added): btrfs unallocated/scrub-age/device-errors (SMART cross-check), per-disk SMART health + last self-test, fstrim posture (discard mount option = continuous trim, ratio's real setup), disk top-consumers on a new slow-local cache (=maint scan --slow=, ~/.local/state/maint), snapper counts split timeline·single·pre-post (split keys on cleanup, not type), full ZFS family. =priv.py= pulled forward from Phase 6 (read-only verbs =smart_json= + =btrfs_scrub_status=, validated args, MAINT_SUDO) so probes stay elevation-free. maint suites 48→118; make test green (51). Live read-only verified on BOTH hosts: ratio worst=warn (10.75 GB cache; scrub 0d, SMART ok), velox over tailscale shows the ZFS family live (ONLINE/autotrim-on/scrub 1d/231 snaps) with a genuine fstrim warn (timer off — Phase 6 Confirm remedy). TOML gained storage keys (btrfs_unalloc_warn_gb, smart_*, fstrim_stale_days, hog_*); installed copies refreshed on ratio + velox. + +*** 2026-07-07 Tue @ 21:30:59 -0500 Built Phase 3 — packages, security, systemd collectors (dotfiles 3eaab6e) +All Phase 3 collectors TDD'd: orphans name+size with [curation.kept_orphans], pacnew safe-delete vs needs-merge, keyring freshness, reboot-required (uname -r vs modules dir), -Qkk on =maint scan --slow= with a noise split (unverifiable-as-user reads, mtime-only, [curation.qkk_known] paths tallied but never counted — an unfiltered live run sat permanently red at 68 "altered" files; classified, ratio shows 10 real findings); =maint scan --net= writes checkupdates/yay/arch-audit/fwupd caches (yay rc-1-silent = AUR-clean zero; failures never clobber good cache; malformed payloads degrade to unprobed), readers grade with age + stale bump; is-system-running with state-aware cause, failed-unit roster since/exit/journalctl hint, maintenance-timers meta-metric (capability-derived expected set, @-prefix instance match), taint letters decoded. Prereq done: arch-audit added to archsetup deps + installed both hosts. Review subagent (live-verified) found 2 Important (yay AUR-clean rc quirk, cache-shape envelope kill) + 2 minor — all fixed red-first; archsetup now enables btrfs-scrub@-.timer on btrfs installs (was ratio-only out-of-band, the meta-metric expects it). maint suites 118→169; full make test green. Live on ratio: 23-metric envelope, worst=warn from real findings (10.75GB cache, 8 orphans, mirrorlist.pacnew, 713 repo updates, 32 CVEs, 1 firmware). + +*** 2026-07-07 Tue @ 21:59:27 -0500 Built Phase 4 — logs + mem·pwr collectors (dotfiles 40c448b) +All Phase 4 collectors TDD'd: journal error digest (identifier groups w/ first/last seen + journalctl hint, known-noise curation tallied but never counted, byte-array MESSAGE decoded), coredumps by binary over a TOML window, kernel/hw event classes (MCE/EDAC/IO/thermal/GPU — generic driver noise excluded), journald disk usage, app-log staleness mirroring the log-cleanup cron's filename-date rules; memory pressure + top-5 RSS, OOM kills (kill-verdict lines only — one event spans several matching lines), swap/zram, temps via sensors -j (k10temp Tctl / coretemp package / amdgpu edge), Intel thermal-throttle count, EPP read, battery capacity/health/charge-cap, unclean-shutdown rate from wtmp (last -x pairing). Throttle/EPP/battery self-gate on sysfs and drop out of the envelope. Review subagent (live-verified) found 1 Blocking — windowed-empty coredumpctl exits 1 (my earlier --since-now pin was wrong), which left the metric permanently unprobed on healthy hosts — plus OOM ~3× overcount and a malformed-curation-entry envelope crash; all fixed red-first, re-review Approve. maint suites 169→227; full make test green. Live read-only both hosts: ratio 34 metrics (telega-server crashing 3× in window — real find), velox 38 (genuine 75% battery-health warn; charge_control_end_threshold ABSENT on velox's cros_ec hardware → Phase 9's SET 80% lever must gate on the file). TOML +journal_disk_warn_gb/coredump_window_days, new [memory], [power] temp/health/unclean keys; installed both hosts. + +*** 2026-07-07 Tue @ 22:35:28 -0500 Built Phase 5 — network posture + services collectors (dotfiles 2e92a9f) +All Phase 5 collectors TDD'd after live-pinning every tool format on both hosts (velox presence sweep over tailscale: full tool set present): nm_state (passive — NM's own connectivity check covers DNS+HTTP, so status never generates traffic), chronyc offset vs [network].ntp_offset_warn_ms, tailscale Health + peer tally, fail2ban per-jail bans, listeners digest via priv ss (expected-entry curation tallied-not-counted, unexpected+wildcard=crit, v4/v6 dedup, multi-holder sockets match any name), firewall (priv ufw status; fallback requires unit-active AND ufw.conf ENABLED=yes; names "ufw down — N public binds exposed" from the digest), rsyncshot freshness (1MB log-tail parse; hourly graded only where the log shows hourly runs — velox is daily-only; daily ≥48h/missing = crit), docker df JSONL + stopped containers w/ [curation.expected_containers], libvirt roster (queried only when libvirtd already active — a read must not socket-activate), cron drift (root rsyncshot expectations gate on backup-source). priv.py +4 read-only verbs; thresholds.curation_entries() tolerant helper. Review subagent (live-verified): Request Changes — 1 Important (malformed fail2ban counts raise ValueError into the envelope) + ufw-disable unit-state false positive, docker unknown-unit sizes reading 0, unfloatable chrony offset; all fixed red-first, re-review Approve. maint suites 227→297; full make test green (54 suites, unpiped exit 0 — an earlier tail-piped run masked make's exit code, rerun properly). Live both hosts: ratio 45 metrics, velox 49. REAL FINDS: listeners crit on both (ratio postgres/8080 wildcard binds, velox syncthing *:8384/*:22000) — curation material for Craig; backups stale on both since 2026-07-06 (truenas down for repairs, Craig confirmed — clears when it returns). TOML +[network]/hourly-backup/cron keys, installed both hosts. + +*** 2026-07-07 Tue @ 23:14:56 -0500 Built Phase 6 — remedy layer, priv verbs, guard, doctor CLI (dotfiles e171c08) +The full remedy layer TDD'd in one pass (no 6a/6b split needed): remedies.py allowlisted table (~30 remedies — exact argv, tiers per the design tables as amended by the dated decisions, re_probe_ids, long flags, RECLAIM SPACE macro resolving steps against the envelope incl. per-config snapper expansion); priv.py +28 write verbs (closed builders, type-validated args, per-verb timeouts; =build()= returns the exact sudo-prefixed argv so dry-run and the GUI arm-press share one string); guard.py pure live-update matcher over new TOML =[updates] guard_patterns= (fnmatch, case-sensitive, pairwise-tested; cold pending-cache is surfaced as a note, never silent); curation.py user-layer writes (mark/unmark/clear + KEEP/MARK KNOWN/EXPECTED wrappers, disable-flags for shipped defaults, self-verifying TOML emitter); doctor.py clean|review + iter_fix streaming the event feed (begin/ok/fail/guard/note; failures red without aborting; re-probed truth after each action); cli =maint fix <id> [--dry-run] [--force]= + =maint doctor clean|review= wall renderer; status envelope now stamps =levers= (always-on for free controls: epp_set, charge_limit, update, topgrade). KILL four guards: pid:name victim, TERM-only verb, session-critical denylist ([curation.session_critical], new TOML table) at preview, comm revalidation + denylist re-check at fire. Live-pinned before coding: coredumpctl HAS NO clean verb (find -mtime +N -delete via priv), log-cleanup at ~/.local/bin/cron/, rsyncshot takes <mode> <keep>, ufw enable needs --force, EPP value enum. Review subagent (live-verified): Request Changes — 1 Blocking (json.dumps astral-char surrogate pairs = invalid TOML: one emoji in a MARK KNOWN example would brick thresholds.load() for the whole console → ensure_ascii=False + write self-verifies by re-parsing before replace) + 1 Important (KeyError from sparse installed TOML escapes the event stream → caught, degrades to a red wall entry) + 2 Minor (cold-cache guard note, comm-vs-denylist re-check); all fixed red-first (6 new tests), re-review Approve. maint suites 297→393 (96 in the new suite); full make test green ×2 (unpiped, exit 0). VERIFIED LIVE on ratio: doctor clean executed the Auto tier for real — paccache -ruk0 freed 223.91 MiB and the re-probe measured the cache drop exactly; the live-update guard trips on ratio's genuinely-pending mesa/vulkan-radeon set. TOML +guard_patterns/+session_critical/+timeline_quarterly installed both hosts. + +*** 2026-07-07 Tue @ 23:52:40 -0500 Built Phase 7 — GUI shell (dotfiles d28435e) +The GTK4 window lands as the fourth panel sibling (net.cjennings.maintpanel, humble-object layering: GTK-free PanelModel presenter + pure viewmodel formatters + thin gui.py view). Read-only shell: faceplate (worst lamp, CVE/ATTN badges, MNT·01, close), inert doctor/update keys, updates strip (pending/CVE/AUR/firmware from the strip ids), eight-band selector (lamp + fixable/watch split from the envelope's levers; updates/security categories fold into PACKAGES), metric-row subpanel per band with per-id value formatting (bytes/percent/days/°C/ms/bool). Hydration off-thread; 3s live tier (build_live: cheap mem·pwr reads) + 30s full rebuild, both visibility-gated; MAINT_PANEL_FIXTURE=good|bad renders shipped envelope fixtures (reshaped from the E5 prototype by tests/maint/gen_fixtures.py, deterministic, real remedy ids) and freezes both tiers so the degraded board stays put on a healthy machine. Review subagent: Request Changes — 1 Blocking (build_live returned bare metrics, so the 3s merge stripped the levers the full envelope established and the band splits oscillated between tiers → attach_levers in build_live) + 2 Minor (synthetic error envelope missing worst read as healthy green → panel.error_envelope with worst=unprobed; smoke leaked the panel process on mid-run assertion errors → try/finally reap); all fixed red-first, re-review Approve (+1 hardening: merge into an error envelope is a no-op). maint suites 393→447 (54 in test_panel.py); full make test green (56 suites, unpiped exit 0). AT-SPI smoke PASS on live Hyprland (faceplate, badges, strip, all 8 tiles, band switch, clean close; window parked on a special workspace, never the active one). Screenshots off-workspace via headless output: bad fixture + live board vs the E5 reference. Live board on ratio honest: 32 CVE / 13 ATTN, NETWORK red from the real listeners crit, SERVICES fixable from the truenas-down backup staleness. GOTCHAS: windowrules need hyprctl reload before first launch (panel opens tiled otherwise); pkill -f "maint panel" self-matches the invoking shell — anchor with "maint panel$". + +*** 2026-07-08 Wed @ 00:39:23 -0500 Built Phase 8 — GUI levers: digests, armed keys, rotary band, live strip (dotfiles 388521e) +The storage/snapshots/packages subpanels grew their evidence digests (disk hogs, per-drive SMART, device errors, snapper by-type, orphans KEEP/REMOVE, pacnew DELETE/MERGE, CVE advisories), packages got the E5 rotary band selector (ORPHANS/PACNEW/ADVISORIES), and the updates strip went live: state-tiered border, UPDATE/TOPGRADE behind the arm-to-override live-update guard, REBOOT offered when required or after an update lands. Every lever is arm-then-fire — first press shows the exact argv on the new act line (identical to =maint fix --dry-run=, the parity criterion), second press fires through =doctor.iter_fix= with the re-probe landing back on the act line; one key armed at a time, band/deck switch disarms; fixture boards always dry-run and never write curation, which is what lets the AT-SPI smoke exercise the full press-press path safely. Engine support: snapper probe emits =stale_singles= (DELETE STALE's candidates, newest kept), both scrub probes parse running-%, =doctor.pending_updates()= public for the arm-press guard read, fixtures regenerated with deterministic evidence. Review subagent (live-verified): Request Changes — 2 Important (refresh tiers raced a firing remedy: a 30s tick could probe the package DB mid-transaction and land stale data after the fire's own re-probe → tiers pause on =firing= + post-fire rebuild retries past an in-flight tick; digest builders KeyError'd on identity-less evidence rows replayed from cache files → skip-and-degrade, 8 new red-first tests) + 4 Minor (all taken: broadened strip-key except, scrub key offers first idle target instead of vanishing while any runs, busy-refused press no longer consumes the arm, fire-time guard refusal names the tripped set in GUI wording). Re-review: Approve. maint suites 447→516; full =make test= green (exit 0, unpiped); AT-SPI smoke PASS end-to-end on the bad fixture; headless-output screenshots vs E5; live board honest on ratio (real orphans w/ KEEP/REMOVE, 10.5 GB cache CLEAN, arm-press names the genuinely pending mesa set). Velox pulled to 388521e (no restow/TOML/windowrule changes). Scroll-position-on-arm check added to Manual testing. + +*** 2026-07-08 Wed @ 01:51:26 -0500 Built Phase 9 — GUI: systemd, logs, mem·pwr, network, services (dotfiles 9a2985f + a3da942) +Shipped in two gated halves. 9a (9a2985f): failed-unit + timer rosters (armed RESTART/RESET per unit; ENABLE only on present-but-inactive timers), logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, row levers VACUUM/CLEAR/RUN. 9b (a3da942): CPU-mode segmented control (current + "default" excluded — priv's closed verb refuses "default"), top-memory guarded KILL (session-critical disabled pre-press, arm refusal behind it, fire-time comm revalidation behind both), battery SET 80% gated on the sysfs knob (velox: unsupported → withheld, verified live), listeners curation (probe emits pid + owning unit from the cgroup v2 line; STOP for system.slice services, KILL otherwise, "?" watch-only), firewall ENABLE / tailscale UP / chrony STEP row levers, services deck (containers MARK EXPECTED + START, docker df, cron roster + backups w/ worst-of chip lamp). RECLAIM SPACE deferred to Phase 10's wall by design — the act line can't honestly render a 7-step stream. Reviews: 9a Request Changes (curation done-callbacks bypassed the busy-retry rebuild → routed through _refresh_after_fire) → Approve; 9b Request Changes (DEFAULT EPP dead control + cgroup hybrid-hierarchy, "?"-curation, cron chip lamp, unprobed ✓ minors — all fixed red-first) → Approve. maint 516→612 tests; make test 58 suites exit 0; AT-SPI smoke PASS across all 8 bands; headless screenshots vs E5; live envelopes verified both hosts. +**** 2026-07-08 Wed @ 01:13:48 -0500 Built 9a — systemd + logs subpanels (dotfiles 9a2985f) +Failed-unit roster (armed RESTART/RESET per unit, since/exit context, journalctl hint) + expected-timer roster (ENABLE only on present-but-inactive timers); logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, and row levers VACUUM / CLEAR / RUN. Review subagent (live-verified): Request Changes — 1 Important (curation done-callbacks called refresh_full, which silently no-ops while a tick refresh holds busy → a mid-tick write left a pre-write envelope on the board up to 30s while the act line claimed success; routed all four incl. Phase 8's keep through _refresh_after_fire) + minors taken (single _mark_token definition, three test gaps closed, docstring truthfulness, stale arm wording cleared on band/deck switch — my own find during screenshot eyeballing). Re-review: Approve. maint 516→562 tests; make test 58 suites exit 0 unpiped; AT-SPI smoke PASS incl. the new rosters/deck/mark path; headless screenshots vs E5. velox pulled (no restow). Accepted gap: KNOWN NOISE subsec lists the live curation table on a fixture board — fixture-sourced entries list is later polish. + +*** 2026-07-08 Wed @ 02:36:54 -0500 Built Phase 10 — results wall + doctor keys (dotfiles cc2fb5d) +Results wall shipped as the session action log: doctor events stream per-event (begin opens a running entry with the exact argv, ok/fail resolves in place, re-probe notes follow), 3.5-visible-row scroll cap with autoscroll, HIDE/COPY (COPY emits CLI-wall text), curation writes logged (fixture-suppressed as dim notes). CLEAN UP streams the auto tier with no arm press; REVIEW & FIX swaps the subpanel to the confirm-tier roster (item-less remedies get armed FIX keys, per-item ones point at their subpanel; review events carry item + worst severity). RECLAIM SPACE macro landed on the disk-usage row (deferred from 9b); macro arm line shows first step + remaining count; zero-step macros refuse by name. Review (live-verified): Request Changes — 1 Important: the roster's FIX for update/topgrade could never override the live-update guard (fire-time re-arm used the bare rid while the FIX key arms on "rid:", and _on_lever never passed force — an infinite arm→guard loop); fixed by threading the pressed token through _fire and sharing the strip's guard arming (_guard_arm_line + panel.guarded). Minors all taken: wall_abort resolves stranded running entries red when a stream dies, empty-macro IndexError → named RemedyError, test predicate tightened. Re-review: Approve, no residuals. maint 612→645; make test 59 suites exit 0 ×2; AT-SPI smoke extended (wall, HIDE/SHOW, CLEAN UP stream, review roster, 14-line RECLAIM stream) PASS; screenshots vs E5; LIVE verify on ratio: real CLEAN UP on the live board streamed "3 done" with real re-probes (coredumps warn 5, disk_usage ok 60). Velox pulled cc2fb5d. + +*** 2026-07-08 Wed @ 03:24:44 -0500 Built Phase 11 — waybar glyph + timers (dotfiles 10033be) +custom/maint replaced custom/sysmon: waybar-maint renders from the state file maint-scan.timer writes every 30 min; color tracks the worst diagnostic (lever-less) metric only, actionable findings never color the bar; missing/stale scan data dims to an honest unprobed state naming the timer. Battery hosts show live battery % + charging icon from sysfs on waybar's interval; low charge forces red. Left-click toggles the console (new maint-panel wrapper); right-click keeps the btop scratchpad. Bare =maint scan= became the glyph scan (envelope → state → RTMIN+12); maint-net-scan.timer drives =--net --slow= hourly. Retired same commit: waybar-sysmon, sysmon-cycle, both suites, #custom-sysmon CSS ×3 — plus unnamed collateral the grep caught: waybar-collapse's collapsed base set (maint glyph stays on battery hosts — it IS the battery display now) and a stale dotfiles todo. Review (live-verified): Approve with 4 Minor, all taken red-first — scope!=Device gate so a wireless mouse's battery can't masquerade as the machine's (shared capability.device_scope), unprobed diagnostics named in the tooltip instead of "all clear" under a dimmed glyph, comment truth, dead state field dropped; re-confirm clean (677 maint tests). Full make test exit 0; smoke PASS (first run's pipe masked a transient AT-SPI race — rerun unpiped clean). LIVE both hosts: ratio timer fired for real (glyph amber from genuine diagnostics: firmware update + journal errors; 11 actionable NOT coloring), velox pulled + one-time stow + battery 100% text w/ warning class. velox's user systemd manager found wedged (pre-existing) — timers enabled via manual symlinks + state seeded; Craig rebooting to clear. + +*** 2026-07-08 Wed @ 04:15:45 -0500 Built Phase 11b — prototype fidelity pass (dotfiles 3ee22a8) +Rebuilt the GUI presentation to the E5 instrument-card idiom. Each band renders a 4-column card grid: big-number readouts (viewmodel.card_spec registry — one builder per bespoke metric id, count-card fallback for the rest), cairo progress bars w/ threshold ticks (cache scaled to 1.5× the warn line), radial gauges (scrub cadence, keyring/topgrade age, temperatures — sweep scaled to the metric's own threshold), status chips (PASSED/FAILING/INACTIVE/YES; firewall chips the state word, exposure detail moves to the caption), and the CPU-mode segmented control promoted from digest section to card (current mode lit, alternatives as joined armed keys — same DIGEST_ONLY epp_set arm-then-fire). Evidence digests stay full-width below the grid. Selector became a two-row 4-col grid of wide tiles w/ count chips (3! · 7✓, crit reddens) + severity left borders; subpanel header carries the attention/ok/fixable/watch summary right-aligned beside a gold band title. The scoped NVMe wear dial was skipped honestly — no standalone wear metric exists (wear rides SMART evidence rows). Presentation only: engine/remedies/doctor/curation and the lever contracts unchanged. New 42-test suite (card kinds, threshold scales, running-scrub override, unprobed degrade incl. tick drop, seg exclusions, chips/subhead/band_counts, both-fixture sweep). Review (live-verified): Approve w/ 1 Important (pre-existing: chrony "not running" string crashed format_value's ms branch — fixed w/ card guard) + 4 Minor all taken red-first (disk None guard, _pct negative clamp, unprobed tick, dead row_detail/split_label/.maint-val removed); re-review Approve. make test 61 suites exit 0 ×2 unpiped; AT-SPI smoke 92 checks PASS (new gauge/chip/subhead assertions — DrawingAreas carry accessible_role=IMG + names so the smoke can see them); all 8 bands + review roster screenshotted on both fixtures + the live board against the settled headless-Chrome prototype render. Velox pulled (no restow). Gotcha pinned: a card body's vexpand propagates up a Gtk.Grid — a one-card band ballooned until grid.set_vexpand(False). + +*** 2026-07-08 Wed @ 05:05:20 -0500 Ran the granted /refactor sweep over the maint package (dotfiles a178470..73e9d94) +Four parallel read-only scans (complexity, duplication, dead-code, simplification) converged on the same hotspots; ten behavior-preserving commits applied and pushed. Headlines: six curation handlers folded into one _curation helper (~150 lines of repeated fixture/done plumbing, act/wall strings byte-identical), _on_lever/_on_strip_key merged into _press_lever, _on_fired split (_rearm_after_guard + _fire_summary), the "tool: not found / exit N" wording single-sited in cmd.why() across 20 probe sites, exact clones deduped (packages curation_entries, _parse_asctime, double VERSION), ten uniform digest sections folded into _evidence_digest, _card_body split per kind, listener key rule named, plus a small-simplifications batch. Deliberately skipped: format_value's flat chain (readable as-is), _journal_lines generalization (rc-semantics risk), test-panel-maint Makefile target (Phase 12 scope). Verification: suite parity exact pre/post (59 suites / 2490 tests, exit 0 unpiped), AT-SPI smoke PASS end-to-end, live ratio envelope 45 metrics zero unprobed, review subagent walked the full diff against 3ee22a8 and approved (no behavior change; accessible names untouched; its one latent-robustness minor pinned as an invariant comment). gui.py 1680→1605; net −103 lines. Velox pulled. + +*** 2026-07-08 Wed @ 05:46:02 -0500 Built Phase 12 — VM remedy scenarios + AT-SPI target (archsetup d6993d3, dotfiles 0636554) +Scenario orchestration landed as =run-maint-scenarios.sh= over the existing vm-utils.sh snapshot primitives: nine break → =maint fix= → assert scenarios in three groups (logs, packages, systemd), batched per Decision 11 (one boot per group, maint-ready snapshot restored between groups, clean-install restored at the end). =run-maint-nspawn.sh= is the fast lane — the packages group against a cached pacstrap rootfs in seconds. Plan layer is pure (=--list=, no KVM) with a 19-test unit suite; =make test-maint= wires it in. Dotfiles side: panel smoke now runs GOOD + BAD fixture passes (healthy render, badge tallies from the fixture, no-REBOOT, leak check) and =make test-panel-maint= is enumerated. Verified: full VM run 9/9 green from pristine snapshot ×2 (test-results/maint-20260708-054029), nspawn 4/4, smoke 100 checks exit 0, unit suite green under both FS_PROFILE values. Review subagent: Request Changes (FS_PROFILE env leak in the unit suite, journal-vacuum assert vacuous both directions) — all six findings fixed red-first, re-review Approve. FS_PROFILE=zfs scenarios: filtered but unexercised — the zfs base image fails to build (see the zfs DKMS task). + +*** 2026-07-08 Wed @ 06:18:58 -0500 Built Phase 13 — install wiring, workflow move, docs (archsetup bef7053 + 18c081f, dotfiles 9a3f0c7) +Installer: install_maintenance_config in user_customizations installs the shipped TOML to ~/.config/archsetup/ (re-run refreshes it, ~/.config/maint/ user layer never touched) and enables maint-scan/maint-net-scan timers via wants-symlinks (hyprland-only — the units live in that stow tier; no session bus during install, syncthing idiom); orchestrator + dispatch-branch tests pin the wiring. Deps swept from the probes' argv: expac, lm_sensors, fwupd added (arch-audit was already in from Phase 3; the rest ride base/archinstall or existing sections). maint/README.md in dotfiles covers user (CLI, panel, glyph, config layers, guard, privilege) + developer (module map, env seams, four test layers, add-a-metric recipe). system-health-check.org moved home → archsetup docs/workflows/ and rewired: TOML-authoritative severity section, threshold lines cite TOML keys (TOML wins on disagreement), inventory paths → docs/homelab-inventory/, maint-CLI fast-path note; only the four #+HOSTNAME: host inventories moved (+ the truenas specs asset they link) — the personal gear records stay in home per the domain split; discoverability via .ai/project-workflows symlink + notes.org entry; handoff note in home's inbox (2026-07-08-0607) lists home-side removals and flags strix-soak-watch as a candidate to move later. Verified: fresh-shell =maint status= green from installed paths on ratio; make test-unit exit 0; dotfiles make test exit 0; shellcheck count unchanged; review subagent Approve (2 Minor doc nits, both taken). + +*** 2026-07-08 Wed @ 06:18:58 -0500 Flipped the spec to IMPLEMENTED +Spec keyword DOING → IMPLEMENTED with a dated history line naming the closing commits (phases 1-13, dotfiles 43a39ac..9a3f0c7, archsetup d6993d3/bef7053/18c081f) and the Status mirror updated. Residuals tracked as their own tasks: manual-testing checklist, zfs base-image DKMS bug [#C], vNext [#D]. +** DONE [#B] Signal and known-noise counts disagree with the journal :bug:maint:solo: +CLOSED: [2026-07-09 Thu] +Not a counting bug — a semantics bug (dotfiles eded0c0). journal_errors grouped by identifier and summed lines, but displayed one sample message, and logs.py overwrote that sample on every iteration so it named the *last* error rather than the counted one. Live on ratio: systemd had 10 error lines this boot — 6 "Failed to start Emacs text editor." and 4 org-roam auto-sync — which is exactly the 10-vs-6. Now grouped by identifier + normalized message (pids, hex addresses, uuids, long integer runs blanked), so a row's count is that message's count, and each row's hint carries a =--grep= reproducing it (verified: 6 and 4). MARK KNOWN binds the volatile-free literal instead of the pid-bearing sample, so marking suppresses exactly what the row counted. The KNOWN NOISE headline counts matched lines while the deck lists patterns; the caption now names both units. +Logs > SIGNAL reports 10 journal errors and the signal item claims 10, but the group holds one item and the open journal shows it 6 times. KNOWN NOISE reports 19 against 12 visible entries. The counts are the point of the band, so a wrong number undermines the whole diagnostic. Find where the tally diverges from the rendered set — likely counting matched journal lines rather than distinct grouped entries, and counting curated entries against a different window than the one displayed. +** DONE [#B] Persist the CPU pill setting across reboot :feature:maint: +CLOSED: [2026-07-09 Thu] +The kernel resets energy_performance_preference to the driver default every boot. A successful epp_set now records the preference in the maint user config; maint-epp-restore.service replays it at login. No-op when nothing is remembered, the host lacks EPP, or the mode already matches. A hand-edited bogus value is refused by priv's closed verb. Installer enables the unit via the existing wants-symlink idiom. Verified live on ratio: unit enabled, ran clean, EPP untouched. (dotfiles a7f34bd, archsetup 430ef1a) + +The power/perf/balance pill sets EPP live, but the value reverts to its original setting after a reboot. Add a way to make a selection permanent. Needs a call on where persistence lives — a systemd unit, a udev rule, or a config file the maint package owns. +** DONE [#B] Preview the pacman + AUR update queue before upgrade :feature:maint:solo: +CLOSED: [2026-07-09 Thu] +A QUEUE key on the updates strip streams the pending pacman + AUR set onto the results wall, each package with its version move; `maint queue` prints the same in a terminal. The cap reports how many it dropped rather than truncating silently, and a cold cache says it has no pending set instead of rendering "nothing pending". Verified live against ratio's 105 pending packages. (dotfiles 8837a47) + +Show which pacman and AUR packages are queued before UPDATE or topgrade runs, so the armed live-update guard isn't the first place the package set becomes visible. +** DONE [#B] Audio PTT state desync across panel, waybar icon, and actual state :bug:audio:solo: +CLOSED: [2026-07-09 Thu] +Two state stores and two copies of the logic. The panel flipped an in-memory ptt_armed flag with its own enter/exit rules; the CLI toggle and the bar tag read a runtime state file. The file is now the only state and ptt.toggle_plan the only decision. The panel adopts the persisted state on every refresh, routes its toggle through the shared plan, and persists before signalling waybar (writing after would race the bar into re-rendering the pre-toggle class). Tests drive both entry points against one state across armed/disarmed x muted/live. (dotfiles 9524a44) + +The audio panel's push-to-talk, the waybar PTT icon, and the real PTT state disagree. All three must agree at all times. Cover every state transition with tests, not just the happy path — this is the second PTT bug after the pre-talk mute restore landed 2026-07-05 (dotfiles 1443b9e). +** DONE [#B] Publish the homelab inventory into the agent knowledge base :chore:docs: +CLOSED: [2026-07-09 Thu] +The four host inventories, the TrueNAS specs asset, and the ratio USB/xHCI record now live as roam nodes in =~/org/roam/hardware/=, linked from the "Homelab Hardware Inventory" index node. =docs/homelab-inventory/= is gone; roam is canonical. + +Carried rather than pointed, per home's argument, which beat mine. Pointing keeps one canonical copy but breaks the write path: a third project that discovers a durable hardware fact would have to write into archsetup's repo to record it, which the cross-project rules forbid, so the fact would sit in an inbox instead of on the device's page. Moving answers the canonical-copy worry without that cost. + +The =* Automated Capabilities= drawer moved with the pages. =system-health-check.org= now resolves a host's node by its =#+HOSTNAME:= keyword under =${ROAM_DIR:-$HOME/org/roam}/hardware/= — never by filename, since the timestamp prefix isn't stable. Verified: all four hosts resolve, an unknown host degrades to "NO INVENTORY FILE", and ratio's capability drawer still parses. A host without the roam clone (mybitch, truenas) takes the same no-inventory path it always did. + +The strix kernel watch stayed at =docs/workflows/strix-soak-watch.org= — it's a workflow, not an inventory page. +** DONE [#C] Optional ticking sound for the pomodoro :feature:timer: +CLOSED: [2026-07-09 Thu] +WTIMER_TICK=1 plays a quiet tick each second through a pomodoro's work phase; off by default, silent through rest (a tick there would undo the break), while paused, and while awaiting a manual advance. Rides the bar's existing once-a-second `wtimer render` call. Playback is fire-and-forget so it can never stall or break the bar. Sound is an 18ms 1.4kHz sine at a tenth of full scale (notify/tick.ogg); WTIMER_TICK_SOUND overrides. Craig still has to judge whether the tick sounds right. (dotfiles 886dafd) + +Timers module. Off by default; needs a sound choice and a volume decision. +** DONE [#C] Refresh indicator for the top-like items :feature:maint: +CLOSED: [2026-07-09 Thu] +A hairline under the top-memory section drains toward the next live refresh, so a slow board reads differently from a frozen one. Withheld on a fixture board, which never live-ticks — a countdown that never counts is worse than none. The fraction is a pure viewmodel function; only the cairo stroke lives in the GUI. (dotfiles 5d384d9) + +Show when the process/memory rows are about to refresh, so a stale-looking board is distinguishable from a frozen one. Shape is a design call — a countdown, a pulse, or a progress hairline. +** DONE [#C] CPU pill should not reposition on selection :bug:maint:quick:solo: +CLOSED: [2026-07-09 Thu] +epp_rows dropped the current preference from the key list, so the remaining segments slid sideways on every selection. Every preference now renders in a fixed order with the current one lit and unpressable in place, at full contrast rather than GTK's disabled dimming. The separate lit label is gone — the active segment is the lit one. Three separate tests encoded the old contract (unit, AT-SPI smoke, phase11b card); all moved with the behavior. The smoke caught a real gap the unit tests missed: the EPP row renders through the card path, which ignored the disabled flag. (dotfiles 5d384d9) + +The perf/balance/power pill moves position depending on which option is selected. The segmented control should hold a fixed layout and mark the active segment in place. +** DONE [#C] Session identifier needs more width :bug:waybar:quick: +CLOSED: [2026-07-09 Thu] +Not waybar — the tmux status line. status-left-length was never set, so tmux clipped the drawn status-left to its default of 10 columns: "[aiv-archsetup] " came out "[aiv-archs", losing the closing bracket and the separating space, which is why the window list ran into it. Set to 40. Applied to the live tmux server too. (dotfiles dc3c27f) + +The session section truncates; give it more characters, and keep the window sections that follow from running into it. Screenshot: [[file:/home/cjennings/pictures/screenshots/2026-07-07_145236.png][2026-07-07_145236.png]] +** DONE [#C] minimal tier zsh login PATH gap :bug:dotfiles:quick:solo: +CLOSED: [2026-07-09 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +Fixed (dotfiles =c6a7878=). =minimal/.zprofile= added, mirroring =common/='s PATH-only prepend and its rationale comment. + +Tested the invariant rather than the file, because the bug *was* a per-file fix missing a tier: any stow tier shipping a =.zshrc= must ship a =.zprofile= that prepends =~/.local/bin=. Red first, and it named =minimal=. Verified behaviorally as the task asked — stowed the tier into a throwaway HOME and read a real =zsh -lc 'echo $PATH'=, which now leads with =~/.local/bin=. Full =make test= green. + +The 2026-07-08 fix gave common/ a .zprofile (zsh logins never read .profile, so ~/.local/bin was missing from TTY/ssh PATH — dotfiles 39cef40). The minimal/ tier has the same gap: it carries .zshrc and .profile but no .zprofile, so headless installs (DESKTOP_ENV=none stows minimal/ instead of common/) still get zsh logins without ~/.local/bin. Add a matching minimal/.zprofile (PATH-only prepend, same rationale comment). Verify: fresh minimal stow, zsh -lc 'echo $PATH' shows ~/.local/bin first. The installer itself is clean — it writes no shell profiles; the stow layer owns this. +** DONE [#B] Doctor button for the audio panel :feature:audio: +CLOSED: [2026-07-10 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +What happens when pipewire or pulseaudio is broken — would we know? Spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] — *READY*, all eight decisions closed 2026-07-09, Phase 0 shipped. Buildable: Phase 1 diag, Phase 2 classify, Phase 3 repair + doctor, Phase 4 the DOCTOR key and wall. Grounded in a live survey of ratio's stack: there is no PulseAudio (pipewire + wireplumber + pipewire-pulse, all user-scope, so no sudo anywhere), =pactl= hangs against a server that accepts and never answers, and the panel can't diagnose itself because =pactl= is the layer most likely to be down. Mirrors the net panel's diag/classify/repair/doctor split, not the maint console's priv-verb table. + +Re-graded =[#C]= → =[#B]= by the 2026-07-09 audit: the spec is written, Phase 0 shipped, and six decisions are all that stand between this and a build. Active backlog, not parking lot. + +*** 2026-07-09 Thu @ 14:12:00 -0500 Fixed: waybar's audio module died against a half-wedged server +My first read of this was wrong. =pactl.run()= already carries =timeout=8= and raises =PactlTimeout=, so the panel degrades and does not freeze. Correcting the record rather than leaving the claim standing. + +The real bug, found by driving it: =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard. A server that answered the lists and then stopped answering raised =PactlTimeout= straight out of a function whose whole contract is to degrade to =ok: False=. =waybar-audio= calls it with nothing catching it, so the bar's audio module died instead of dimming; the GTK panel survived only because its background worker catches everything. + +A server that hangs on the *first* call never exposes this — =list_sinks= raises inside the guard and masks the unguarded reads behind it — so the obvious regression test passes against broken code. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults". Verified end to end: =waybar-audio= now emits =class: degraded= / "Audio graph unavailable" and exits 0. dotfiles =4d42eb3=; full =make test= green. + +*** 2026-07-10 Fri @ 06:41:00 -0500 Shipped: the audio doctor, phases 1 through 4 +=audio diag=, =audio doctor=, =audio doctor --fix=, and the panel's DOCTOR section header with DIAGNOSE / FIX keys streaming into a results wall (copy + close). Spec is IMPLEMENTED. dotfiles =01a1d80=, =76857e3=, =7223c51=, =1550ac8=, =4320255=, =bd33440=. + +Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5, and found two bugs no fake could surface: =pactl get-default-sink= prints the literal =@DEFAULT_SINK@= placeholder when no default is set, and remedy 1 re-probed before WirePlumber had re-created the devices, calling a repaired stack broken. Both fixed. + +Left open, not a v1 gap: mpv played silently while the doctor read healthy. The stack was genuinely fine, and per-application stream routing is an explicit spec Non-Goal. Worth a task only if it recurs. + +Second sighting, 2026-07-10: Chrome stopped recognizing the microphone while the stack was healthy (Shure MV7+ default, unmuted, 82%, PTT off). Restarting Chrome fixed it; nothing on the machine was touched. Both sightings share a shape the doctor cannot see: the graph is fine and one client cannot use it. A third sighting turns this into a design question, namely whether the doctor should say "the stack is fine, the fault is in the application" rather than a bare healthy. @@ -1945,7 +1945,12 @@ configure_pre_pacman_snapshots() { action="installing pre-pacman snapshot hook" && display "task" "$action" mkdir -p /etc/pacman.d/hooks - cat << 'EOF' > /etc/pacman.d/hooks/zfs-snapshot.hook + # Remove the pre-ordering filename from earlier archsetup runs; leaving it + # would execute a second snapshot after mkinitcpio's removal hook. + rm -f /etc/pacman.d/hooks/zfs-snapshot.hook + # Pacman runs hooks lexically. Keep this before the distribution's + # 60-mkinitcpio-remove hook so the snapshot preserves a bootable /boot. + cat << 'EOF' > /etc/pacman.d/hooks/05-zfs-snapshot.hook [Trigger] Operation = Upgrade Operation = Install @@ -2048,8 +2053,8 @@ hyprland() { action="Hyprland Utilities" && display "subtitle" "$action" aur_install pyprland # scratchpads, magnify, expose (fixes special workspace issues) pacman_install waybar # status bar - pacman_install gtk4-layer-shell # custom/net connection panel (GTK4 layer-shell) - pacman_install python-gobject # PyGObject for the net panel + pacman_install gtk4-layer-shell # clock panel overlay (GTK4 layer-shell) + pacman_install python-gobject # PyGObject for the GTK panels (net, bluetooth, audio, clock) pacman_install blueprint-compiler # regenerate the net panel's .ui from .blp (dev; committed .ui runs without it) aur_install speedtest-go-bin # net panel speed test backend pacman_install fuzzel # app launcher (native Wayland, pinentry support) @@ -2104,7 +2109,12 @@ UDEVEOF action="installing the live-update guard pacman hook" && display "task" "$action" mkdir -p /etc/pacman.d/hooks - cat > /etc/pacman.d/hooks/hypr-live-update-guard.hook << 'HOOKEOF' + # Migrate installations made before the numeric ordering prefix. + rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook + # This AbortOnFail guard must run before 60-mkinitcpio-remove; otherwise a + # blocked transaction can remove the current initramfs without reaching the + # PostTransaction hook that rebuilds it. + cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook << 'HOOKEOF' [Trigger] Operation = Upgrade Type = Package @@ -2131,7 +2141,7 @@ Exec = /usr/local/bin/hypr-live-update-guard AbortOnFail NeedsTargets HOOKEOF - chmod 644 /etc/pacman.d/hooks/hypr-live-update-guard.hook + chmod 644 /etc/pacman.d/hooks/10-hypr-live-update-guard.hook } ### Display Server (conditional) diff --git a/assets/color-themes/dupre/dupre-palette.org b/assets/color-themes/dupre/dupre-palette.org index cd4f26a..5df2778 100644 --- a/assets/color-themes/dupre/dupre-palette.org +++ b/assets/color-themes/dupre/dupre-palette.org @@ -202,24 +202,16 @@ Point-based configs (foot, fuzzel, dunst, Xresources) use 13pt. Waybar CSS uses | =hyprlock.conf= | Hyprlock screen | | =Xresources= | X11 apps / Emacs | -** Switching Themes +** Applying Dupre =set-theme= (=~/.local/bin/set-theme=) copies theme configs into place, applies Hyprland/hy3 colors via =hyprctl=, merges Xresources, and reloads waybar and dunst. #+begin_src sh set-theme dupre # apply dupre -set-theme --toggle # toggle between dupre and hudson -set-theme --pick # fuzzel picker set-theme --current # print current theme name #+end_src -Keybindings: =$mod+Y= toggles, =$mod+Shift+Y= opens picker. - -** Adding a New Theme - -1. Create =~/.config/themes/<name>/= with all seven config files -2. Add a case block in =set-theme= for Hyprland/hy3 runtime colors -3. The theme will appear automatically in =set-theme --list= and the fuzzel picker +Dupre is the single supported theme; it has no theme-toggle or picker keybinding. * Palette Image diff --git a/configs/maintenance-thresholds.toml b/configs/maintenance-thresholds.toml index c14eaec..340c66c 100644 --- a/configs/maintenance-thresholds.toml +++ b/configs/maintenance-thresholds.toml @@ -102,6 +102,13 @@ unclean_boot_sample = 10 # boots assessed for the rate [network] ntp_offset_warn_ms = 100 # chrony last-offset bound (typical is sub-ms) +# Tailscale health messages that are standing noise on this fleet (substring +# match). tailscaled can't read resolved's stub/DoT config, so its +# DNS-unreachable complaint fires permanently while DNS works — it kept the +# lamp amber on every machine. A real DNS outage still surfaces elsewhere +# (clock sync, the net-tier scans). Ignored messages stay visible in the +# metric's evidence as health_ignored. +tailscale_health_ignore = ["can't reach the configured DNS servers"] [backups] rsyncshot_daily_crit_hours = 48 # daily backup older than this is a fire diff --git a/docs/2026-06-10-tool-evaluation-criteria.org b/docs/2026-06-10-tool-evaluation-criteria.org index f5fec34..be0a79a 100644 --- a/docs/2026-06-10-tool-evaluation-criteria.org +++ b/docs/2026-06-10-tool-evaluation-criteria.org @@ -14,7 +14,7 @@ A replacement tool earns adoption only when it beats the incumbent on a dimensio * Weighting criteria (compare on these) - Daily-use win: does it remove a real friction, or is it novelty? "Answers in one command what took a pipeline" (dust) beats "same answer, prettier" (gping). - Workflow fit: Emacs (dired, vterm, magit) and tmux already own many niches. A candidate duplicating them adds maintenance surface without adding capability (see: terminal tabs/splits, broot vs dired+zoxide). -- Theme-system fit: can set-theme drive it (INI/TOML include, runtime color escape, reload signal)? Tools that can't follow dupre/hudson stay second-class. +- Theme-system fit: can the Dupre apply path drive it (INI/TOML include, runtime color escape, reload signal)? Tools that cannot follow the desktop palette stay second-class. - Packaging: official repo > AUR > git build. Every step down adds update fragility to fresh installs. - Migration cost: config translation, muscle-memory retraining, installer changes. Cost scales with how load-bearing the incumbent is. diff --git a/docs/design/2026-06-29-waybar-timer-module-spec.org b/docs/design/2026-06-29-waybar-timer-module-spec.org index e8eecbe..4db4c8f 100644 --- a/docs/design/2026-06-29-waybar-timer-module-spec.org +++ b/docs/design/2026-06-29-waybar-timer-module-spec.org @@ -157,14 +157,11 @@ Update the =tests/waybar-collapse= base-set expectations to match (TDD the chang * CSS -Add =#custom-timer= plus the state classes to all three stylesheets. Keep the -*selectors and structure* parallel across the three (what the theme-drift test -checks); the actual color *values* are per-theme (dupre vs hudson) and differ by -design, so this is structural parity, not byte-identity. Confirm against the real -CSS files what the drift test compares before editing. +Add =#custom-timer= plus the state classes to the live and Dupre stylesheets. +Keep them byte-identical: the apply script copies Dupre over the live stylesheet. +Confirm against the real CSS files what the drift test compares before editing. - =hyprland/.config/waybar/style.css= - =hyprland/.config/themes/dupre/...= waybar css -- =hyprland/.config/themes/hudson/...= waybar css Colors: normal = foreground; =urgent= = a warning hue (reuse the sysmon warn/crit palette); =paused= = dimmed; =pomodoro-break= = a calmer accent. @@ -194,7 +191,7 @@ dotfiles branch =waybar-timer-module=: - =tests/wtimer/test_wtimer.py= (new) - =hyprland/.config/waybar/config= (module def + modules-right position) - =hyprland/.local/bin/waybar-collapse= (base-set) + =tests/waybar-collapse/...= (expectations) -- =hyprland/.config/waybar/style.css= + dupre + hudson waybar css (CSS) +- =hyprland/.config/waybar/style.css= + Dupre waybar css (CSS) archsetup (main, at the end): - this spec diff --git a/docs/design/2026-06-29-zfs-pre-snapshot-installer.org b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org index e5a339e..1b9235e 100644 --- a/docs/design/2026-06-29-zfs-pre-snapshot-installer.org +++ b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org @@ -30,9 +30,9 @@ version below); live backup at =/usr/local/bin/zfs-pre-snapshot.bak-2026-06-29=. In the archzfs / ZFS-on-root install path, gated to ZFS-root installs (velox is the only ZFS daily driver; ratio is btrfs), install: -1. =/etc/pacman.d/hooks/zfs-snapshot.hook= — the =PreTransaction= hook that +1. =/etc/pacman.d/hooks/05-zfs-snapshot.hook= — the =PreTransaction= hook that runs the script. *Not included in the handoff* — source it from velox - (=/etc/pacman.d/hooks/zfs-snapshot.hook=) or write it. + (=/etc/pacman.d/hooks/05-zfs-snapshot.hook=) or write it. 2. =/usr/local/bin/zfs-pre-snapshot= — the =KEEP=10= self-pruning version below. @@ -80,8 +80,10 @@ fi * Implementation (2026-06-30) -- Hook sourced from velox (=/etc/pacman.d/hooks/zfs-snapshot.hook=) and embedded +- Hook sourced from velox (=/etc/pacman.d/hooks/05-zfs-snapshot.hook=) and embedded as a heredoc in =configure_pre_pacman_snapshots()=. +- The installer removes the legacy unprefixed hook filenames during migration; + leaving them would run duplicate hooks, including a too-late snapshot. - Insertion point: a new =configure_pre_pacman_snapshots()= gated on =is_zfs_root=, called from =boot_ux= (the last step) so the hook doesn't fire during the install's own package operations — the first pre-pacman snapshot is diff --git a/docs/design/2026-07-10-net-bt-failure-taxonomy.org b/docs/design/2026-07-10-net-bt-failure-taxonomy.org new file mode 100644 index 0000000..74790c6 --- /dev/null +++ b/docs/design/2026-07-10-net-bt-failure-taxonomy.org @@ -0,0 +1,434 @@ +#+TITLE: Linux Network and Bluetooth Failure Taxonomy — for the net and bt doctors +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-10 + +* Purpose + +A catalogue of real, user-reported ways Linux networking (WiFi/ethernet/DNS/egress) and Bluetooth fail, grounded in forum and issue-tracker reports. Built to feed the net-panel and bt-panel doctors the same way the audio taxonomy fed the audio doctor: each entry is triaged into a remedy class — AUTO (the doctor applies a user-scope reversible fix), PRIV (needs root; runs where passwordless sudo exists), REBOOT-TAIL (a privileged fix that only takes hold after a reboot), or GUIDE (nothing to run — a physical action, a config decision, or an upstream/venue problem). This mirrors [[file:2026-07-10-audio-failure-taxonomy.org][the audio taxonomy]] and reuses its four-class privilege model. + +The net and bt doctors already exist (=~/.dotfiles= =net/= and =bluetooth/=). This taxonomy grounds their *expansion* — widening the diagnosis space and hardening the verdict/remedy structure — the way the audio input-side spec grew from the audio taxonomy. It fed two DRAFT specs: [[file:../specs/2026-07-11-net-doctor-expansion-spec.org][the net doctor expansion]] and [[file:../specs/2026-07-11-bt-doctor-expansion-spec.org][the bt doctor expansion]]. + +** Method + +Built 2026-07-10 from a blind, by-layer parallel research fan-out — the same first-sweep method as the audio taxonomy. Eleven agents (six network, five bluetooth), each scoped to one layer and blind to any cluster structure, drew real failure reports from Arch BBS/wiki, kernel bugzilla/launchpad/gitlab, distro forums, and upstream issue trackers, weighted toward 2023-2026 and the Arch / NetworkManager / systemd-resolved / bluez stack. Each entry names the layer, the user-reported symptom, the root cause, the known fix (with the concrete command where one exists), the privilege/reboot signal, and a source. + +The =(privilege signal)= after each title is the first triage cue: =no= = user-scope, no reboot; =yes= = needs root; =both= = root AND a reboot; =physical= = a hardware/physical/BIOS action. A =no= is an AUTO candidate; =both= or =physical= can only ever be REBOOT-TAIL or GUIDE. + +A saturation resample (the audio taxonomy's blind-different-axis check) is a later pass, noted at the end. + +* Network failure modes + +Six layers, mirroring the net doctor's probe ladder (link → IP/DHCP → gateway/route → DNS → egress) plus the NetworkManager control plane that sits beside all of them. ~98 raw entries from the sweep; the obvious cross-layer duplicates (VPN route capture, PMTUD blackhole, broken-IPv6/happy-eyeballs, unmanaged device, dhcpcd-vs-NM, wifi powersave, connectivity-check false negative, MAC randomization, iwd/wpa_supplicant backend) are merged inline and cross-noted, leaving ~74 distinct root causes. + +** Link / radio / driver / firmware / rfkill + +- Missing iwlwifi firmware for a very recent Intel card (both). No wifi interface in =ip link=; dmesg "Direct firmware load for iwlwifi-… failed with error -2". The installed linux-firmware predates the card's blob. Fix: update/install linux-firmware (or linux-firmware-git), reboot. [[https://bbs.archlinux.org/viewtopic.php?id=193570][arch 193570]] +- iwlwifi firmware-load regression after a kernel update (both). Wifi vanished right after an upgrade; dmesg shows iwlwifi failing to load firmware it loaded before. A kernel-side change broke firmware negotiation for that module. Fix: boot linux-lts or update to the fixed kernel, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=304848][arch 304848]] +- Realtek rtw89 in-tree module shadowed by an AUR DKMS driver (both). RTL8852BE wifi disappeared after an update; two conflicting modules present. A stale 8852be/rtw89-dkms-git overrides the in-tree rtw89_8852be. Fix: remove the AUR DKMS package, ensure linux-firmware is current, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=286109][arch 286109]] +- MediaTek mt7921e broken by a linux-firmware update (both). Wifi worked, then a pacman upgrade killed it; firmware load timeout / "hardware init failed" for WIFI_RAM_CODE_MT7961. A regression in the packaged blob fails to init the chip. Fix: downgrade/pin linux-firmware-mediatek to the last-good version, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=308360][arch 308360]] +- Broadcom BCM4360 needs the proprietary wl driver (both). "No wifi adapter"; lspci shows BCM4360 but no wlan interface. The open brcm/b43/bcma drivers claim it but can't drive it. Fix: install broadcom-wl-dkms, blacklist b43/bcma/brcmsmac/brcmfmac/ssb, rebuild initramfs, reboot. [[https://wiki.archlinux.org/title/Broadcom_wireless][archwiki broadcom]] +- broadcom-wl (non-DKMS) breaks after a kernel upgrade (both). Wifi gone after a kernel bump; wl won't load against the new kernel. The prebuilt module isn't rebuilt automatically. Fix: switch to broadcom-wl-dkms, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=305252][arch 305252]] +- USB wifi dongle needs an out-of-tree DKMS driver (both). An RTL8811AU/8812AU/8821AU dongle enumerates in lsusb but no wlan interface appears. No in-tree driver binds it. Fix: install the matching DKMS package with kernel headers, reboot. [[https://aur.archlinux.org/packages/rtl8812au-dkms-git][aur rtl8812au]] +- Wifi soft-blocked by rfkill (no). =rfkill list= reports "Soft blocked: yes"; the radio is off. Software (a prior toggle, a hotkey, NM state) set the soft block. Fix: =rfkill unblock wifi= (or =nmcli radio wifi on=). [[https://bbs.archlinux.org/viewtopic.php?id=236715][arch 236715]] +- Wifi hard-blocked by a physical switch / airplane / BIOS (physical). "Hard blocked: yes"; no software clears it. A kill-switch, Fn toggle, or BIOS setting physically disables the radio. Fix: flip the physical/Fn switch or enable the radio in BIOS. [[https://bbs.archlinux.org/viewtopic.php?id=207376][arch 207376]] +- rfkill stuck hard-blocked after suspend from competing WMI modules (both). Wifi comes back hard-blocked after resume and unblock won't clear it. Two vendor WMI modules (acer_wmi + hp_wmi) both claim the switch and wedge it. Fix: blacklist the wrong WMI module, rebuild initramfs, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=236657][arch 236657]] +- iwlwifi crashloops on resume, needs a module reload (yes). Wifi dead after suspend; dmesg shows microcode errors / firmware crashloop until the driver reloads. Firmware state isn't restored across resume. Fix: =modprobe -r iwlmvm iwlwifi && modprobe iwlwifi= (or a system-sleep hook). [[https://bbs.archlinux.org/viewtopic.php?id=293404][arch 293404]] +- ath11k (QCA6390/QCNFA765) firmware crash, dead until reload (yes). Wifi drops after resume or at random; dmesg "ath11k_pci … firmware crashed: MHI_CB_SYS_ERROR". The card's firmware faults and doesn't recover. Fix: =modprobe -r ath11k_pci && modprobe ath11k_pci=. [[https://bbs.archlinux.org/viewtopic.php?id=310363][arch 310363]] +- WiFi powersave drops the connection (yes; merges the auth-layer and NM-layer duplicates). Wifi connects then stalls/disconnects, worse on battery. Aggressive power management (driver =power_save= and/or NM =wifi.powersave=3=) breaks association with some APs. Fix: =iw dev wlan0 set power_save off= now; persist via NM =[connection] wifi.powersave=2= drop-in and/or =options iwlwifi power_save=0= in modprobe.d. [[https://bbs.archlinux.org/viewtopic.php?id=171342][arch 171342]] +- USB wifi dongle killed by USB autosuspend (yes). A working dongle drops out after idle and returns on replug; =power/control= reads "auto". Autosuspend powers the device down mid-use. Fix: a udev rule pinning =power/control=on= (or disable USB autosuspend in TLP). [[https://bbs.archlinux.org/viewtopic.php?id=184823][arch 184823]] +- RTL8125 ethernet flips from r8125 to in-tree r8169 after a kernel update (both). Wired network dies after an upgrade; the NIC binds r8169 and shows NO-CARRIER. A kernel update overrode the AUR r8125 module. Fix: blacklist r8169, install/force r8125-dkms, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=262120][arch 262120]] +- RTL8125 link downshifts / won't negotiate full speed (yes). The 2.5GbE NIC links but only at 1Gbps/100Mbps; ethtool shows the reduced speed. EEE / Giga-Lite negotiation downshifts the PHY. Fix: =ethtool --set-eee <if> eee off= (or force speed via =ethtool -s=), or swap cable/port. [[https://bbs.archlinux.org/viewtopic.php?id=264742][arch 264742]] +- Ethernet NO-CARRIER from an unplugged/faulty cable or dead switch port (physical). Interface UP but NO-CARRIER, no IP ever assigned. No link partner: cable unseated/broken or switch port down. Fix: reseat/replace the cable, try another port — no software repair until carrier returns. [[https://bbs.archlinux.org/viewtopic.php?id=268745][arch 268745]] +- Adapter present but left "unmanaged" by NetworkManager (no/yes; merges the NM-layer duplicate). =nmcli device= lists the device "unmanaged". Another daemon (iwd, systemd-networkd) owns it, or an =unmanaged-devices= rule / =NM_MANAGED=no= excludes it. Fix: =nmcli device set <dev> managed yes= (AUTO); remove the config rule or disable the rival manager to persist (PRIV). [[https://bbs.archlinux.org/viewtopic.php?id=291445][arch 291445]] + +** WiFi association / authentication + +- Wrong PSK, NM state 120 / secrets required (no; TERMINAL). "Secrets were required, but not provided"; journal "4-Way Handshake failed — pre-shared key may be incorrect". NM stored the wrong key — no repair fixes it; the user must re-enter. Fix: =nmcli con modify <name> wifi-sec.psk <key>= or re-enter in the picker. [[https://bbs.archlinux.org/viewtopic.php?id=306757][arch 306757]] +- Pure WPA3-SAE won't associate (no). "No PSK available for association" / "SME: Failed to set WPA key management" on a WPA3-only AP. The profile defaults to WPA-PSK key-mgmt with no PMF, which a pure-SAE AP rejects. Fix: =nmcli con modify <name> wifi-sec.key-mgmt sae 802-11-wireless-security.pmf 3=. [[https://bbs.archlinux.org/viewtopic.php?id=256573][arch 256573]] +- PMF mismatch on a WPA2/WPA3 transition AP (no). SAE assoc rejected on mixed-mode; connects to WPA2 but not the SAE path. Transition mode requires PMF-capable and rejects SAE when 802.11w isn't negotiated. Fix: match the AP's PMF — =nmcli con modify <name> 802-11-wireless-security.pmf 2=. [[https://hostap.shmoo.narkive.com/38Iowl2x/ieee-802-11w-support-in-wpa-supplicant-management-frame-protection-required-but-client-did-not][hostap 802.11w]] +- SAE H2E-only AP vs hunt-and-peck client (yes). WPA3 assoc fails "unspecified failure" on a Hash-to-Element-only AP. wpa_supplicant defaults to hunt-and-peck; the PWE method mismatches. Fix: =sae_pwe=2= in wpa_supplicant global config. [[https://wiki.archlinux.org/title/Wpa_supplicant][archwiki wpa_supplicant]] +- eduroam PEAP cert validation failed (no). "Certificate verification failed"; EAP drops. Missing/wrong CA cert, or NM ignoring system CA certs. Fix: set the correct CA cert / server-name in 802-1x (temporarily =system-ca-certs=false= to test). [[https://debianforum.de/forum/viewtopic.php?t=169560][debianforum 169560]] +- iwd eduroam bad_certificate (no). iwd refuses eduroam "bad_certificate" though it works elsewhere. iwd's EAP needs the server CA cert and a matching domain. Fix: add =EAP-PEAP-CACert=<ca.pem>= (and Domain/ServerDomainMask) to the iwd profile. [[https://bbs.archlinux.org/viewtopic.php?id=291921][arch 291921]] +- 802.1X won't save/verify the CA cert (no). Enterprise wifi only connects with "No CA certificate required" ticked. NM wants a PEM (not DER) bundle and mishandles the ca-cert setting. Fix: =nmcli con modify <name> 802-1x.ca-cert /etc/ssl/certs/ca-certificates.crt=. [[https://bugzilla.redhat.com/show_bug.cgi?id=1455833][redhat 1455833]] +- Wrong eduroam anonymous/outer identity (no). Auth rejected at the RADIUS server. The outer identity has the wrong realm, or the anonymous ID is reused as the inner identity. Fix: set =anonymous_identity="anonymous@realm"= and =identity="user@realm"= to the home realm. [[https://community.jisc.ac.uk/library/network-and-technology-service-docs/2021-04-advisory-android-11-configuration-issues][jisc eduroam]] +- Hidden SSID never connects (no). A saved non-broadcast SSID doesn't auto-connect; "No network with SSID found". NM won't send a directed probe unless the profile is flagged hidden. Fix: =nmcli con modify <name> wifi.hidden yes=. [[https://bbs.archlinux.org/viewtopic.php?id=39839][arch 39839]] +- Randomized MAC breaks captive portal / MAC allow-list (no; merges the NM-layer duplicate). Connects but the portal re-prompts every time, or a MAC-filtered router refuses the device. Per-connection random MAC changes each connect. Fix: =nmcli con modify <name> wifi.cloned-mac-address stable= (or =permanent=). [[https://fedoramagazine.org/randomize-mac-address-nm/][fedora NM MAC]] +- scan-rand-mac-address breaks scan/assoc on some drivers (yes). After an NM upgrade no networks appear, or assoc fails "Authentication timeout" (ath9k/ath9k_htc, some Realtek/Atheros). The driver can't handle the randomized scan MAC. Fix: =[device] wifi.scan-rand-mac-address=no= drop-in. [[https://github.com/qca/open-ath9k-htc-firmware/issues/132][qca ath9k-htc 132]] +- Wrong regulatory domain hides/blocks channels (yes). A 5GHz AP is invisible / "no suitable AP"; the card won't use the AP's channels. Regulatory domain unset or wrong, so the kernel forbids those frequencies. Fix: =iw reg set <CC>=, persist via =WIRELESS_REGDOM= (install wireless-regdb). [[https://bbs.archlinux.org/viewtopic.php?id=249979][arch 249979]] +- Won't roam, stuck on a weak AP (no). The client clings to a weak BSSID even with a stronger same-SSID AP in range. NM+wpa_supplicant roaming is conservative. Fix: bounce the connection, tune bgscan / lock a bssid, or try the iwd backend. [[https://bbs.archlinux.org/viewtopic.php?id=291466][arch 291466]] +- 4-way-handshake timeout / deauth loop, reason 15 (both). "4way_handshake_timeout" or repeated deauth (Reason 15) on iwlwifi/some drivers. An 802.11n handshake bug or AP port-security kicks the client mid-handshake. Fix: =options iwlwifi 11n_disable=1= (reboot), or try the other backend. [[https://bbs.archlinux.org/viewtopic.php?id=264378][arch 264378]] +- iwd associates but NM(wpa_supplicant) doesn't (yes; see also the NM backend entries). Standalone iwd connects but NM's wpa_supplicant path fails to associate on the same AP. Backend-specific supplicant behavior differs. Fix: =[device] wifi.backend=iwd= drop-in, disable standalone iwd.service, restart NM. [[https://bbs.archlinux.org/viewtopic.php?id=292603][arch 292603]] + +** IP / DHCP / gateway / routing / VPN policy + +- dhcpcd competing with NM's internal DHCP client (yes; merges the two DHCP-conflict duplicates). Interface never gets an IPv4 address; NM stuck "getting IP configuration". A standalone =dhcpcd.service= and NM's internal client fight over the interface. Fix: =systemctl disable --now dhcpcd= and let NM own DHCP. [[https://bbs.archlinux.org/viewtopic.php?id=165793][arch 165793]] +- dhcpcd 9 backend breaks NM IPv4 (yes). No IPv4 after a dhcpcd upgrade; IPv6 may still work. NM's dhcpcd backend was incompatible with dhcpcd 9's changed behavior. Fix: =dhcp=internal= in NetworkManager.conf (or NM ≥1.30.4-3), restart NM. [[https://bugs.archlinux.org/task/66231][arch fs66231]] +- APIPA / link-local only, no DHCP answer (no). The interface self-assigns 169.254.x.x; only local-link hosts reachable. No DHCP server answered in the timeout. Fix: re-trigger DHCP — =nmcli device reapply <dev>=; a recurring case is a server-side fault. [[https://oneuptime.com/blog/post/2026-03-20-fix-apipa-169-254-address/view][apipa 169.254]] +- Default route never installed by the connection (yes). Has an IP and pings the LAN, but no default route. The profile has =ipv4.never-default yes= ("use only for resources on its network"). Fix: =nmcli con modify <name> ipv4.never-default no=, up. [[https://bbs.archlinux.org/viewtopic.php?id=84453][arch 84453]] +- DHCP gateway in a different subnet than the lease (yes). IP assigned but no default route; "SIOCADDRT: No such process". The DHCP-offered gateway is off the assigned prefix, so the on-link route is refused. Fix: add the on-link route then default (=ip route add <gw> dev <iface>; ip route add default via <gw>=), or fix the DHCP scope. [[https://bbs.archlinux.org/viewtopic.php?id=72685][arch 72685]] +- Duplicate IPv4 on the LAN, address skipped by ACD (yes). Connection comes up but no usable address; journal logs the conflicting MAC. NM's RFC 5227 conflict detection found the IP already in use. Fix: change the offending host / DHCP reservation, or set a distinct static IP. [[https://bbs.archlinux.org/viewtopic.php?id=287424][arch 287424]] +- IPv6 RA not received, no SLAAC address or route (yes). No IPv6 address/route; interface hangs "configuring". =accept_ra=0=, forwarding enabled without =accept_ra=2=, or an upstream RA-guard drops ICMPv6 type 134. Fix: =sysctl net.ipv6.conf.<iface>.accept_ra=2= (or unblock RA at the router). [[https://oneuptime.com/blog/post/2026-03-20-ipv6-ra-not-received/view][ipv6 ra]] +- DHCPv6-only network, address but no route (yes). An IPv6 address via stateful DHCPv6, but no default route. Routing lives in the RA, not DHCPv6. Fix: ensure the router sends an RA with the M flag and let NM process both (=ipv6.method auto=). [[https://bbs.archlinux.org/viewtopic.php?id=273336][arch 273336]] +- Happy-eyeballs stall on broken IPv6 (no; the routing angle — see also the DNS AAAA and egress v6 entries). Pages load with a visible hang; IPv6 is present but black-holed. Dual-stack apps race IPv6 first and wait to fall back. Fix: =nmcli con modify <name> ipv6.method disabled= (or fix the upstream v6 path). [[https://oneuptime.com/blog/post/2026-03-20-ipv6-happy-eyeballs-issues/view][happy-eyeballs]] +- WireGuard AllowedIPs=0.0.0.0/0 strands the host when the tunnel drops (no). Everything offline after the VPN drops; ping and DNS fail. Full-tunnel installed a default route through wg0 with no live peer. Fix: bring the tunnel down (=wg-quick down <iface>= / =nmcli con down <vpn>=) to restore the physical route. [[https://discourse.coreelec.org/t/wireguard-client-not-connected-when-allowedips-0-0-0-0-0/18187][wg allowedips]] +- WireGuard split-tunnel misconfig captures the LAN (yes). VPN connects but LAN devices/printers become unreachable. =AllowedIPs=0.0.0.0/0= used where a split tunnel was intended. Fix: set AllowedIPs to only the intended subnets, re-up. [[https://oneuptime.com/blog/post/2026-03-20-wireguard-allowedips-ipv4-split-tunneling/view][wg split-tunnel]] +- OpenVPN redirect-gateway route left behind after an abnormal disconnect (yes). No internet/DNS after the tunnel dies; can't even reconnect. redirect-gateway rewrote the default route and a hard link-down never restored it. Fix: =ip route del default dev tun0; systemctl restart openvpn-client@<name>= (use =def1= going forward). [[https://forums.openvpn.net/viewtopic.php?t=22572][openvpn redirect-gw]] +- Tailscale exit node captures the default route (no). Internet dies after selecting an exit node; tailnet peers still reachable. The exit-node route conflicts with the working one, or policy lacks =autogroup:internet=. Fix: =tailscale set --exit-node== to drop back to the local default route. [[https://github.com/tailscale/tailscale/issues/2654][tailscale exitnode]] +- Docker bridge subnet collides with a real external subnet (yes). A specific external host (172.17.x.x) is unreachable while the rest of the internet works. docker0 claimed 172.17.0.0/16. Fix: set ="bip": "172.26.0.1/16"= in daemon.json, prune, restart docker. [[https://github.com/moby/moby/issues/36619][moby 36619]] +- libvirt virbr0 subnet collides with the real network (yes). Host/VMs can only reach the bridge/host, not the WAN. virbr0 came up on a range overlapping the LAN. Fix: re-address the virtual net via =virsh net-edit default=, destroy/start it. [[https://bugzilla.redhat.com/show_bug.cgi?id=811967][rhbz 811967]] +- Wifi + ethernet up with conflicting metrics, traffic exits the dead link (yes). Intermittent/no connectivity with two links up; egress uses the wrong interface. Both installed a default route and the lower metric wins even when it can't reach the internet. Fix: raise the bad link's metric — =nmcli con modify <name> ipv4.route-metric <higher>=. [[https://www.baeldung.com/linux/change-network-routing-metric][route-metric]] + +** DNS / resolver / DoT / DNSSEC + +- Dangling resolv.conf symlink after resolved is disabled/not started (yes). Can ping 1.1.1.1 but nothing resolves; =/etc/resolv.conf= symlinks to a stub file that doesn't exist. The stub only exists while systemd-resolved runs. Fix: =systemctl enable --now systemd-resolved= (or repoint the link at a real file). [[https://github.com/systemd/systemd/issues/5395][systemd 5395]] +- resolv.conf not pointing at the stub while resolved runs (yes). Names fail though resolved is up; resolv.conf is a stale static file. An installer/upgrade replaced it. Fix: =ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf=, verify with =resolvectl status=. [[https://wiki.archlinux.org/title/Systemd-resolved][archwiki resolved]] +- systemd-resolved stub 127.0.0.53 unreachable / port 53 conflict (yes). resolv.conf says 127.0.0.53 but every query is connection-refused. A second resolver (dnsmasq, bind) holds port 53, so the stub never binds. Fix: stop the conflicting resolver or set DNSStubListener, restart resolved. [[https://github.com/systemd/systemd/issues/10298][systemd 10298]] +- NetworkManager not integrated with resolved (yes). resolved is up but knows no upstream servers; NM writes its own resolv.conf. =dns=systemd-resolved= not set, so DHCP servers never reach resolved. Fix: add =dns=systemd-resolved= under [main] in NetworkManager.conf, restart NM. [[https://wiki.archlinux.org/title/Systemd-resolved][archwiki resolved]] +- DNSSEC=yes rejects a non-compliant venue resolver (yes). Pages won't load on hotel/ISP wifi though ping works; "DNSSEC validation failed". resolved rejects the venue resolver that mangles DNSSEC records. Fix: =DNSSEC=allow-downgrade= (or no) in resolved.conf, restart resolved. [[https://bbs.archlinux.org/viewtopic.php?id=240427][arch 240427]] +- DNSSEC "no-signature" verdict sticks after a network change (yes). Resolution dies sporadically after reconnecting; resolved reuses a cached bogus/downgrade verdict from the previous network. Fix: =resolvectl reset-server-features= (and flush), or DNSSEC=allow-downgrade. [[https://github.com/systemd/systemd/issues/12388][systemd 12388]] +- DoT strict mode with port 853 blocked (yes). Everything stalls; DNSOverTLS=yes forbids fallback and the network drops 853. Fix: =DNSOverTLS=opportunistic= (or no) in resolved.conf, restart resolved. [[https://wiki.archlinux.org/title/Systemd-resolved][archwiki resolved]] +- DoT strict to an unreachable / cert-mismatched server (yes). "Failed to invoke gnutls_handshake: Error in the certificate verification"; the pinned DoT server is down or MITM'd. Strict mode plus IP#hostname pinning rejects the cert and won't fall back. Fix: correct the server/#hostname, switch provider, or relax to opportunistic, restart resolved. [[https://github.com/systemd/systemd/issues/16531][systemd 16531]] +- DHCP handed a broken resolver (no). Nothing resolves on one network though egress works; the DHCP DNS server is dead/filtering. Fix: =nmcli con mod <con> ipv4.ignore-auto-dns yes ipv4.dns "1.1.1.1 9.9.9.9"=, up. [[http://adam.younglogic.com/2019/05/using-nmcli-to-set-nameservers/][nmcli dns]] +- Manually-pinned 8.8.8.8 blocked by the venue (no). A hard-set public DNS resolves nothing though 1.1.1.1 pings; the network blocks external port 53. Fix: drop the manual pin and use DHCP DNS (=ipv4.ignore-auto-dns no=, clear =ipv4.dns=), reconnect. [[https://forum.netgate.com/topic/187060/port-53-dns][netgate port 53]] +- Stale poisoned cache after captive-portal login (yes). First lookups after the portal keep returning the portal/blocked address. Pre-login hijacked answers were cached. Fix: =resolvectl flush-caches=. [[https://oneuptime.com/blog/post/2026-03-20-flush-dns-cache-linux-macos-windows/view][flush-caches]] +- VPN split-DNS not applied / leaks to the ISP resolver (no). Internal/corp names fail (or a censored ISP resolver still answers) after connecting the VPN. NM/resolved didn't install the VPN link's domain as a routing domain. Fix: =resolvectl domain <vpn-link> '~corp.example'= and =resolvectl default-route <vpn-link> false=. [[https://blogs.gnome.org/mcatanzaro/2020/12/17/understanding-systemd-resolved-split-dns-and-vpn-configuration/][catanzaro split-dns]] +- IPv6 AAAA lookups stall resolution (yes; the DNS angle of the broken-v6 family). Every name takes ~5s then works; A and AAAA go in parallel and AAAA is never answered. Broken/half-configured IPv6 swallows the AAAA query. Fix: fix or disable IPv6 on the link, or =options single-request timeout:1=. [[https://bbs.archlinux.org/viewtopic.php?id=262926][arch 262926]] +- Another daemon overwrites resolv.conf (yes). DNS works then breaks (or breaks after VPN up/down) as dhcpcd/openvpn/openresolv rewrites resolv.conf. Multiple tools claim it with no coordination. Fix: pick one manager (openresolv =resolvconf=NO=, dhcpcd =nohook resolv.conf=), point resolv.conf at the stub, restart resolved. [[https://github.com/adrienverge/openfortivpn/issues/674][openfortivpn 674]] +- nsswitch.conf hosts line broken (yes). All resolution fails, or LAN/mDNS names never resolve; the hosts line lacks =resolve=/=dns= in the right order or references an uninstalled nss module. Fix: set =hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns=. [[https://man.archlinux.org/man/nss-resolve.8.en][nss-resolve]] +- Avahi/.local mDNS not resolving (yes). *.local names don't resolve though unicast DNS works. nss-mdns not wired in, or resolved's built-in mDNS collides with avahi. Fix: install nss-mdns, add =mdns_minimal [NOTFOUND=return]= before =resolve=, enable avahi-daemon, disable resolved MulticastDNS if both run. [[https://wiki.archlinux.org/title/Avahi][archwiki avahi]] + +** Egress / captive portal / MTU / proxy / clock / upstream + +- Captive portal held state, no auto-popup (no). Wifi associates and gets a lease but every site fails; no login window appears. Without a DE NetworkManager integration or a dispatcher script, NM detects the PORTAL state but nothing opens the login page. Fix: open a plain-HTTP probe (=xdg-open http://neverssl.com=) or the URL NM reports; add an nm-dispatcher script to auto-launch. [[https://wiki.archlinux.org/title/NetworkManager#Captive_portals][archwiki captive portals]] +- Connectivity check over HTTPS never triggers the portal redirect (no). Behind a portal the login popup never fires. NM's connectivity URI was probed over HTTPS, which the portal can't transparently redirect. Fix: point NM's check at an HTTP URI (=20-connectivity.conf= =uri=http://…=) and open a plain-HTTP page. [[https://bugzilla.redhat.com/show_bug.cgi?id=1252839][redhat 1252839]] +- Custom DNS / DoT / DNSSEC hides the captive portal (yes). No portal page ever resolves and the machine stays cut off. resolved pinned to 1.1.1.1 with DoT/DNSSEC bypasses the portal's DNS hijack, so lookups just fail. Fix: drop DoT/DNSSEC for the captive network (=resolvectl dnsovertls <iface> no; resolvectl dnssec <iface> no=), log in, restore. [[https://github.com/systemd/systemd/issues/27485][systemd 27485]] +- HSTS blocks the portal redirect in the browser (no). The login page won't load; the browser shows a cert/HSTS error instead of the form. The first site was HSTS-pinned, so the browser refuses the portal's HTTP redirect. Fix: browse to a non-HSTS plain-HTTP address (http://neverssl.com, http://captive.apple.com). [[https://community.ui.com/questions/Solution-for-HSTS-issues-with-captive-portal/17b033e7-3dfe-4830-af8f-bf6ead23d8b0][ui hsts portal]] +- PMTUD blackhole from dropped ICMP (no; merges the IP-layer duplicate). Small requests and DNS work, pages start then stall, large downloads/SSH hang. A middlebox drops ICMP "fragmentation needed", so PMTUD never learns the real MTU and oversized packets vanish. Fix: lower the interface MTU (=nmcli con modify <con> 802-11-wireless.mtu 1400=), or MSS-clamp. [[https://ipfyi.com/scenario/path-mtu-blackhole/][pmtud blackhole]] +- PPPoE / VPN link with a lower MTU not clamped (no). Browsing works but big transfers / some HTTPS hang. A PPPoE (1492) or VPN path has a smaller MTU and the too-large segments get dropped. Fix: set the tunnel/link MTU down (=.mtu 1420= for VPN, 1492 for PPPoE) or MSS-clamp on the gateway. [[https://thelineman.ca/articles/article-8-mtu-vpn-mss][vpn mtu/mss]] +- Stale http_proxy env var points at a dead proxy (no). Every curl/wget/pacman fails though the network is fine; browsers may work. A leftover =http_proxy= points at an offline/off-network proxy. Fix: unset the vars, remove the export from =~/.profile= / =/etc/environment=. [[https://everything.curl.dev/usingcurl/proxies/env.html][curl proxy env]] +- Unreachable PAC file off the corporate network hangs everything (no). Away from the office the browser stalls with no error. A system proxy set to "automatic" with a PAC URL that only resolves on the corporate LAN blocks waiting instead of falling back to DIRECT. Fix: switch system proxy to None (=gsettings … org.gnome.system.proxy mode 'none'=) or clear the PAC URL. [[https://bugzilla.mozilla.org/show_bug.cgi?id=1121800][ff pac hang]] +- Clock skew breaks every TLS handshake (yes). "Your connection is not private" on every HTTPS site though ping/DNS work; the clock is hours/years off. A dual-boot Windows RTC-localtime, unsynced NTP, or a dead CMOS battery leaves the clock wrong. Fix: =timedatectl set-ntp true= (=set-local-rtc 0= on dual-boot), replace the CMOS battery if it recurs. [[https://wiki.archlinux.org/title/System_time][archwiki system time]] +- Firewall default-deny drops all egress (yes). No traffic leaves right after enabling a firewall, or after both ufw and firewalld are on; even DNS fails. A default outgoing-deny policy, or two firewalls fighting over nftables. Fix: allow egress (=ufw default allow outgoing=) and run only one firewall. [[https://wiki.archlinux.org/title/Uncomplicated_Firewall][archwiki ufw]] +- VPN kill-switch / leftover iptables rule strangles egress after VPN drops (yes; distinct from the route-capture case). Internet dies the moment the VPN disconnects and never returns until reboot. A kill-switch rule pinned traffic to tun0 and the leftover rule keeps dropping everything on the real interface. Fix: flush the stale rules (=iptables -F; iptables -P OUTPUT ACCEPT=, or restart the firewall), reconnect. [[https://bbs.archlinux.org/viewtopic.php?id=300104][arch ufw killswitch]] +- IPv6 egress broken while IPv4 works (no; the egress angle of the broken-v6 family). Pages load slowly/intermittently; IPv4-only hosts are fine. The network advertises IPv6 with no working route and Happy Eyeballs keeps trying the dead AAAA path. Fix: =nmcli con modify <con> ipv6.method disabled= until the network's IPv6 is fixed. [[https://help.ubuntu.com/community/WebBrowsingSlowIPv6IPv4][ubuntu slow ipv6]] +- False "limited connectivity" from a local DNS/VPN service (no; merges the NM-layer connectivity-check duplicate). The applet shows "limited/no internet" and apps that gate on NM refuse to work, yet browsing functions. NM's check to its connectivity URI fails — a local Pi-hole/VPN blocks the probe host, a false negative. Fix: point =20-connectivity.conf= at a reachable 204 endpoint, or =[connectivity] enabled=false=, reload NM. [[https://bbs.archlinux.org/viewtopic.php?id=272292][arch nm limited]] +- ISP outage or modem stuck in a bad state — not locally fixable (physical). Everything downstream is "connected, no internet"; the router's WAN IP is 0.0.0.0. The upstream link is down or the modem latched into a bad state. The doctor must recognize this and STOP. Fix: power-cycle modem then router; check ISP status / contact the provider. [[https://whizz-tech.com/support/wifi-router-fixes/wifi-stops-working-after-power-outage/][modem bad state]] +- Specific outbound port blocked while web works (no). Browsing is fine but email won't send or a service times out. The network/ISP blocks that port (SMTP 25, some VPN ports). A partial block, not a full outage. Fix: use an allowed alternate (submission 587, VPN over 443/TCP), or switch networks — the policy isn't host-fixable. [[https://help.dreamhost.com/hc/en-us/articles/217071167-Port-25-Blocking][port 25 blocking]] + +** NetworkManager control plane / backends / config + +- NetworkManager service masked or failed to start (yes). No networking at all; "Unit NetworkManager.service is masked" or the unit dies immediately. The unit was masked, or an invalid unit file / malformed NetworkManager.conf blocks startup. Fix: =systemctl unmask NetworkManager && systemctl start NetworkManager=; reinstall if the unit is corrupt, validate the conf. [[https://bbs.archlinux.org/viewtopic.php?id=193036][arch 193036]] +- NetworkManager segfaults and restart-loops (yes). Connectivity drops intermittently; journal "Failed with result 'core-dump'" and repeated respawns. A bug in the running NM build crashes the daemon. Fix: =coredumpctl info NetworkManager=, then update/downgrade the package, restart NM. [[https://bbs.archlinux.org/viewtopic.php?id=236562][arch 236562]] +- iwd and wpa_supplicant both active, backends fight (both). Wifi scans but never associates, or associates then drops; both daemons drive one radio. Fix: pick one backend, =systemctl disable --now= the other, set =[device] wifi.backend==, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=292603][arch 292603]] +- wifi.backend=iwd configured but iwd not installed/running (both). No wifi device appears at all; NM acts as if there's no radio. NM is told to use iwd but the package isn't installed or its service can't start. Fix: =pacman -S iwd= (or remove the drop-in to fall back), reboot. [[https://bbs.archlinux.org/viewtopic.php?id=305442][arch 305442]] +- systemd-networkd and NetworkManager both managing the link (both). The interface gets an IP then loses it, or flaps; two managers own one NIC. Fix: =systemctl disable --now systemd-networkd systemd-networkd.socket= (mask if dbus-activated), reboot. [[https://bbs.archlinux.org/viewtopic.php?id=184164][arch 184164]] +- Keyfile permissions void a saved connection (yes). A saved network shows in nmcli but never activates; journal marks it insecure and skips it. The =.nmconnection= isn't 600 root-owned. Fix: =chmod 600= and =chown root:root= the file in system-connections, =nmcli connection reload=. [[https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/8/html/configuring_and_managing_networking/assembly_networkmanager-connection-profiles-in-keyfile-format_configuring-and-managing-networking][rhel keyfile]] +- Autoconnect disabled on the profile (no). The network works when activated by hand but never comes up on boot/resume. =connection.autoconnect=no=. Fix: =nmcli connection modify <name> connection.autoconnect yes= (user-scope). [[https://bbs.archlinux.org/viewtopic.php?id=281611][arch 281611]] +- Duplicate/conflicting profiles for one SSID (no). Two entries for the same network; the wrong one activates. Leftover =.nmconnection= files created a second profile with conflicting settings. Fix: =nmcli connection delete <uuid>= the stale one, or raise the good one's =autoconnect-priority=. [[https://bbs.archlinux.org/viewtopic.php?id=274552][arch 274552]] +- A dispatcher script errors and blocks connectivity (yes). Network events don't complete, or a connectivity hook silently doesn't run. A script in dispatcher.d fails on a permission/ownership problem or loops. Fix: make it root-owned and non-group-writable or remove it; debug via =journalctl -u NetworkManager-dispatcher=. [[https://man.archlinux.org/man/NetworkManager-dispatcher.8.en][arch dispatcher]] +- Polkit won't authorize a non-root user to control networking (no). =nmcli= up/modify fails "Not authorized to control networking". No polkit agent, or the login has no active seat session. Fix: start a polkit agent and ensure a proper loginctl session (relogin via the display manager), or add a polkit rule. [[https://bbs.archlinux.org/viewtopic.php?id=141465][arch 141465]] +- No reconnect after suspend/resume (yes). After waking, wifi/wired stays down until the service is kicked. On resume NM doesn't re-establish the link. Fix: =systemctl restart NetworkManager= (the doctor's nm-restart); durable fix is a system-sleep hook. [[https://bbs.archlinux.org/viewtopic.php?id=234725][arch 234725]] +- NetworkManager-wait-online hangs boot (both). Boot stalls ~90s on NetworkManager-wait-online; the desktop is late. The service waits for connectivity that never arrives (unplugged NIC, non-autoconnect port). Fix: =systemctl disable NetworkManager-wait-online.service= (or drop the unused profile); effective next boot. [[https://bbs.archlinux.org/viewtopic.php?id=298817][arch 298817]] + +* Bluetooth failure modes + +Five layers, mirroring the bt doctor's chain (adapter present → rfkill → bluetooth.service → adapter powered → per-device → audio profile). ~70 raw entries from the sweep; hardware-block duplicates (a Bluetooth hard-block appears in every one of the first three layers) and the pairing/connection overlaps are merged inline, leaving ~55 distinct root causes. + +** Adapter / hardware / driver / firmware + +- btusb module not auto-loaded (yes). lsusb shows the adapter but no hci0, =/sys/class/bluetooth= empty, "No default controller available". btusb didn't load at boot (missing modules-load entry or a blacklist). Fix: =modprobe btusb=, persist via =/etc/modules-load.d/bluetooth.conf=. [[https://bbs.archlinux.org/viewtopic.php?id=267428][arch 267428]] +- Intel ibt-*.sfi firmware missing/mismatched (both). No controller; dmesg "Direct firmware load for intel/ibt-NN-N.sfi failed with error -2". linux-firmware lacks the exact .sfi/.ddc the controller requests. Fix: update linux-firmware (or symlink the requested name), reboot. [[https://bbs.archlinux.org/viewtopic.php?id=302314][arch 302314]] +- Intel "Failed to send firmware data" during load (both). Adapter present then dies; "hci0: Failed to send firmware data (-38)/(-110)". Firmware transfer aborts (USB/init timing, often kernel-specific). Fix: =options btusb reset=1= and reload, or downgrade the kernel, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=248784][arch 248784]] +- Intel AX200/AX210 controller lost after a kernel update (both). WiFi fine, BT gone, "No default controller available" right after an upgrade. A kernel/firmware regression stops the BT half enumerating. Fix: boot linux-lts or downgrade until fixed, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=282611][arch 282611]] +- Intel broken-initial-NCMD init hang (both). "hci0: command tx timeout" during setup; the controller never comes up. Some Intel controllers need the BTUSB_INTEL_BROKEN_INITIAL_NCMD quirk absent on older kernels. Fix: run a kernel new enough to carry the quirk, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=226312][arch 226312]] +- MediaTek MT7921/MT7961 firmware filename mismatch (both). No adapter; "Direct firmware load for mediatek/BT_RAM_CODE_MT7961_1a_2_hdr.bin failed with error -2". linux-firmware ships the blob compressed or under a different name. Fix: decompress/rename to the requested file, reload btusb, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=296026][arch 296026]] +- MediaTek MT7922 btmtk regression after a kernel bump (both). BT stopped initializing after a specific kernel; WiFi unaffected. A regression in the mt7921e/btmtk path. Fix: downgrade to the last working kernel (or linux-lts), reboot. [[https://bbs.archlinux.org/viewtopic.php?id=313561][arch 313561]] +- MediaTek MT7922 stuck in a residual power state (physical). "No Bluetooth adapters found" persists across warm reboots; only a full power-off clears it. The controller latched in a bad state a soft reboot doesn't reset. Fix: shut down, pull power ~10s, cold boot. [[https://bbs.archlinux.org/viewtopic.php?id=308169][arch 308169]] +- MediaTek MT7920/MT7922 bogus firmware in linux-firmware (both). WiFi works, BT half fails "Failed to set up firmware" after a linux-firmware update. A shipped BT blob is broken in the current package. Fix: downgrade linux-firmware-mediatek to the prior version, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=311544][arch 311544]] +- MediaTek MT7925 killed by aggressive USB autosuspend (both). BT flaky/absent on a recent kernel; "Opcode 0x0c03 failed: -16". Kernel power-saving suspends the controller. Fix: =usbcore.autosuspend=-1 btusb.enable_autosuspend=n= on the kernel cmdline, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=306366][arch 306366]] +- Realtek RTL8761B(U) firmware name mismatch (yes). USB dongle dead; driver requests rtl_bt/rtl8761bu_fw.bin but firmware ships rtl8761b_fw.bin. Fix: symlink to the bu names in /lib/firmware/rtl_bt (or update linux-firmware), replug — no reboot. [[https://bbs.archlinux.org/viewtopic.php?id=309671][arch 309671]] +- Realtek RTL8852BU/newer dongle needs an out-of-tree driver (both). A recent combo dongle isn't recognized at all; no in-kernel support. Fix: build the vendor DKMS driver (lwfinger/rtl8852bu), reboot. [[https://github.com/lwfinger/rtl8852bu][rtl8852bu]] +- Fake/clone CSR dongle resets and times out (yes). A cheap 0a12:0001 "CSR" dongle works briefly then "command tx timeout"; "Unbranded CSR clone detected". A counterfeit controller mishandles CSR reset. Fix: rely on the kernel clone quirks, or =options btusb reset=1 enable_autosuspend=0=, reload btusb. [[https://bbs.archlinux.org/viewtopic.php?id=272230][arch 272230]] +- Qualcomm WCN6855/QCA6390 version read fails (both). No BT; "Reading QCA version information failed (-110)" via ath11k. Missing/mismatched QCA firmware or a btqca init-timing bug. Fix: update linux-firmware and run a newer kernel (or reload ath11k_pci/btusb), reboot. [[https://bbs.archlinux.org/viewtopic.php?id=284506][arch 284506]] +- Broadcom BCM .hcd patch file missing (both). Adapter enumerates then stalls; "hci0: BCM: Patch brcm/BCMxxxxx-….hcd not found". The .hcd firmware isn't in linux-firmware. Fix: install broadcom-bt-firmware (AUR) or drop the correct .hcd into /lib/firmware/brcm, reboot. [[https://github.com/winterheart/broadcom-bt-firmware/issues/30][broadcom-bt-fw 30]] +- USB autosuspend powers the adapter off after suspend/idle (both; the hardware angle — see also the resume entry in the rfkill layer). BT works at boot, then hci0 vanishes after a suspend/resume or idle; only a full shutdown restores it. btusb autosuspend cuts power and the controller doesn't recover. Fix: =btusb.enable_autosuspend=0= on the kernel cmdline (or exclude the device in TLP), reboot. [[https://forum.manjaro.org/t/bluetooth-adapter-disappears-after-suspend-only-shutdown-can-fix/157919][manjaro 157919]] +- Bluetooth hard-blocked by a BIOS/hardware switch (physical; the canonical hard-block — appears identically across the rfkill layer). No adapter and =rfkill list= shows Bluetooth "Hard blocked: yes" (or hci0 absent) after a wireless switch/Fn toggle or a BIOS setting. Fix: flip the physical switch / Fn key, or enable Bluetooth in BIOS/UEFI — no software can clear a hard block. [[https://github.com/systemd/systemd/issues/4117][systemd 4117]] + +** rfkill / power / service + +- Soft-blocked adapter needs manual unblock (no). =rfkill list= shows Bluetooth "Soft blocked: yes"; bluetoothctl "Failed to set power on: org.bluez.Error.Failed". A prior software toggle left the soft-block set and bluetoothd won't power a blocked adapter. Fix: =rfkill unblock bluetooth= (then =bluetoothctl power on=). [[https://bbs.archlinux.org/viewtopic.php?id=169530][arch 169530]] +- AutoEnable off leaves BT powered down every boot (yes). Adapter present and service running, but "Powered: no" at every login. bluez main.conf has AutoEnable=false (also the static default when main.conf is absent), so bluetoothd never powers the controller. Fix: =AutoEnable=true= under [Policy] in main.conf, restart bluetooth. [[https://github.com/bluez/bluez/issues/886][bluez 886]] +- bluetooth.service masked (yes). =systemctl start bluetooth= returns "Unit bluetooth.service is masked". The unit was masked by an earlier disable or a tuning guide. Fix: =systemctl unmask bluetooth.service && systemctl enable --now bluetooth=. [[https://bbs.archlinux.org/viewtopic.php?id=220081][arch 220081]] +- bluetooth.service not enabled at boot (yes). BT works after a manual start but is "inactive (dead)" on every fresh boot. On Arch the service ships disabled, so nothing starts bluetoothd at boot. Fix: =systemctl enable --now bluetooth.service=. [[https://bbs.archlinux.org/viewtopic.php?id=287428][arch 287428]] +- D-Bus policy blocks bluetoothd from owning org.bluez (yes). "D-Bus setup failed: Connection is not allowed to own the service 'org.bluez'"; bluetoothctl can't reach bluez. A missing/broken =/usr/share/dbus-1/system.d/bluetooth.conf= policy (partial install/upgrade). Fix: reinstall bluez to restore the policy, restart dbus and bluetooth. [[https://bbs.archlinux.org/viewtopic.php?id=283337][arch 283337]] +- bluez upgrade leaves a bad unit-file setting (yes). After a bluez update "Unit bluetooth.service has a bad unit file setting". A changed ExecStart path or a removed option in the shipped unit breaks parsing until daemon-reload. Fix: reconcile the .pacnew, correct ExecStart to =/usr/lib/bluetooth/bluetoothd=, =daemon-reload && restart bluetooth=. [[https://github.com/bluez/bluez/issues/736][bluez 736]] +- Airplane mode / physical switch hard-blocks BT (physical; the rfkill-layer view of the shared hard-block). =rfkill list= "Hard blocked: yes"; software toggles and unblock do nothing. A physical/firmware kill switch, Fn key, or BIOS airplane state holds the block. Fix: turn airplane mode off / flip the switch, or enable BT in BIOS. [[https://bugzilla.redhat.com/show_bug.cgi?id=713006][rh 713006]] +- WiFi rfkill toggle also kills BT on a combo chip (no). Turning WiFi off drops Bluetooth too, and both come up soft-blocked at boot. A shared WiFi/BT combo exposes a coupled rfkill so the wlan block cascades. Fix: =rfkill unblock bluetooth= after the toggle; unblock all at boot. [[https://forum.artixlinux.org/index.php/topic,5062.0.html][artix 5062]] +- TLP disables Bluetooth on startup (yes). BT is soft-blocked at every boot on a TLP laptop though the service is fine. TLP's =DEVICES_TO_DISABLE_ON_STARTUP= includes bluetooth (or RESTORE_DEVICE_STATE restores off). Fix: remove bluetooth from that list in =/etc/tlp.conf=; =rfkill unblock bluetooth= now. [[https://dev.to/sharafat/disable-bluetooth-at-startup-in-tlp-2k8d][tlp startup]] +- systemd-rfkill restores a stale soft-block at boot (yes). BT was on before shutdown but boots soft-blocked, or airplane-off never sticks. systemd-rfkill saved a blocked state to =/var/lib/systemd/rfkill= and reapplies it (a hotplug race can overwrite the good state). Fix: =rfkill unblock bluetooth=; clear the saved state (or mask systemd-rfkill if TLP owns radio state). [[https://github.com/systemd/systemd/issues/42148][systemd 42148]] +- rfkill block puts bluetoothd into a 100% CPU loop (yes). After =rfkill block bluetooth= (an airplane toggle) bluetoothd spins at 100% CPU and the adapter is unresponsive even after unblock. A bluez rfkill-handling regression busy-loops. Fix: =systemctl restart bluetooth= after unblocking; upgrade/downgrade bluez past the affected version. [[https://github.com/bluez/bluez/issues/785][bluez 785]] +- Adapter powers back off after a desktop-driven power-off (no). =bluetoothctl power on= fails specifically after BT was switched off in GNOME/desktop settings; only the desktop toggle restores it. The desktop's saved off-state re-blocks the adapter under the CLI. Fix: re-enable from the desktop toggle, or =rfkill unblock bluetooth= and power on. [[https://discourse.gnome.org/t/cannot-power-on-bluetooth-from-bluetoothctl-if-it-was-powered-off-from-the-gnome-settings/24782][gnome 24782]] +- BT soft-blocked/unresponsive on resume, needs a module reload (yes; distinct from the hardware-autosuspend case — here power state is stuck, not cut). After suspend/resume the adapter is lost or soft-blocked; toggling "shows" enabled but does nothing and restarting the service doesn't help. btusb fails to re-init on resume, leaving a stuck rfkill/power state. Fix: =rmmod btusb && modprobe btusb= (then restart bluetooth); automate via a post-resume sleep hook. [[https://forums.opensuse.org/t/btusb-broken-after-resuming-from-sleep/133499][opensuse 133499]] +- Wrong/missing default controller with multiple adapters (no; see also the multi-controller pairing case). "No default controller available" or commands hit the wrong hciN when a dongle and an onboard radio are both present. bluez picked a different default than the user wants. Fix: =bluetoothctl select <MAC>= then power on; pin it if needed. [[https://bbs.archlinux.org/viewtopic.php?id=286766][arch 286766]] + +** Pairing / bonding + +- Generic AuthenticationFailed on pair (no). "Failed to pair: org.bluez.Error.AuthenticationFailed" on an audio device. A half-written or contested bond from a prior attempt makes the device reject the new authentication. Fix: bluetoothctl disconnect, untrust, remove <mac>, restart bluetooth, pair from a clean scan; factory-reset the device if it persists. [[https://bbs.archlinux.org/viewtopic.php?id=267381][arch 267381]] +- Paired but not trusted, no auto-reconnect (no). Device pairs but never reconnects on its own after a reboot/power cycle; =info= shows "Trusted: no". bluez won't auto-accept a connection from an untrusted bond. Fix: =bluetoothctl trust <mac>=. [[https://techwiser.com/fix-bluetooth-device-doesnt-auto-connect-in-linux/][techwiser autoconnect]] +- Stale link key after a dual-boot re-pair (yes). A device paired in Windows no longer connects in Linux with AuthenticationFailed. A device stores one link key per host; re-pairing under the other OS overwrote it. Fix: =bluetoothctl remove <mac>= and pair again, or sync the keys between OSes (bt-dualboot / edit the LinkKey under /var/lib/bluetooth). [[https://armujahid.me/blog/dual-boot-bluetooth-pairing/][armujahid dualboot]] +- No default pairing agent registered (no). Pairing a PIN/keyboard device silently fails or the passkey prompt never appears (esp. headless). bluetoothctl's built-in agent is unregistered once it exits, so nothing answers the request. Fix: =agent on= then =default-agent= (or a persistent bt-agent / NoInputNoOutput agent for unattended Just Works). [[https://technotes.kynetics.com/2018/pairing-agents-bluez/][kynetics agents]] +- BLE device never appears in scan (no). A BLE peripheral shows in other OSes but never in =bluetoothctl scan=, while BR/EDR devices scan fine. The scan defaults to BR/EDR-only or LE isn't enabled. Fix: =menu scan / transport le= (or =btmgmt le on=); last resort =ControllerMode = le= in main.conf. [[https://bbs.archlinux.org/viewtopic.php?id=281064][arch 281064]] +- Corrupt device cache survives remove (yes). Repeated pairing failures for one device; =remove= reports success but the cache stays and the bad state returns. bluez doesn't fully purge =/var/lib/bluetooth/<adapter>/<mac>=. Fix: stop bluetooth, =rm -rf= the stale device dir, start bluetooth, pair fresh. [[https://github.com/bluez/bluez/issues/191][bluez 191]] +- ConnectionAttemptFailed / br-connection-page-timeout (yes). Pairing or first connect fails "Page Timeout" / "br-connection-page-timeout", often after a bluez upgrade. The daemon's baseband page times out; a version regression triggered it. Fix: =systemctl restart bluetooth= and retry; downgrade bluez if it recurs after an update. [[https://github.com/bluez/bluez/issues/230][bluez 230]] +- Re-pair loop: pairs then instantly disconnects (yes). Device pairs, drops within seconds, and re-pairing only works until the next attempt. A corrupt in-memory bond/device DB wedges the daemon. Fix: =rm -rf /var/lib/bluetooth/*= (or the device dir) and =systemctl restart bluetooth=, then pair once cleanly. [[https://bbs.archlinux.org/viewtopic.php?id=267233][arch 267233]] +- Headset un-pairs itself on disconnect (yes). A headset loses its bond every time it disconnects. The device sends an HID virtual_cable_unplug on disconnect and bluez treats it as an un-pair. Fix: pair again; no clean user setting — avoid fully powering the device off; restart bluetooth if the bond is gone. [[https://github.com/bluez/bluez/issues/2048][bluez 2048]] +- BLE address rotates every session (physical). A BLE device pairs once but can't reconnect; its address differs each power cycle. It uses a random/resolvable-private address, so a bond keyed to the old address won't match unless IRK resolution works. Fix: pair so the IRK is exchanged (bond, not just connect), or disable address privacy on the device; re-pair to the current identity. [[https://github.com/hbldh/bleak/issues/363][bleak 363]] +- Legacy keyboard needs the PIN typed on the device (physical). A legacy BT keyboard fails with an authentication timeout during pairing. It uses legacy PIN/passkey-entry: bluez shows a passkey that must be typed on the keyboard itself. Fix: register a KeyboardDisplay agent, type the displayed passkey on the keyboard, press Enter within the timeout. [[https://bbs.archlinux.org/viewtopic.php?id=174239][arch 174239]] +- Too many bonded devices, keys lost (yes). With many devices paired, after a reboot or dongle re-insert none connect. Past ~14 bonds bluez fails to restore the LTKs. Fix: =bluetoothctl remove= enough devices to drop below the limit, restart bluetooth. [[https://github.com/bluez/bluez/issues/201][bluez 201]] +- Dual-mode device pairs on the wrong transport (yes). A dual-mode device (AirPods) refuses to pair/connect because bluez tries BR/EDR when the device needs LE, with no per-device override. Fix: =ControllerMode = bredr= (or le) in main.conf [General], restart, pair, then restore =dual=. [[https://github.com/bluez/bluez/issues/514][bluez 514]] +- Adapter Pairable/Discoverable off (no). Scan finds nothing or the controller rejects pairing. The adapter has =Pairable: no= / =Discoverable: no=. Fix: =power on=, =pairable on=, =scan on= (or set the main.conf defaults). [[https://wiki.archlinux.org/title/Bluetooth][archwiki bt]] +- Target device not in pairing mode (physical). A peripheral never appears in scan though the adapter works. The device is powered but not advertising. Fix: hold the pairing button until the LED signals, re-run =scan on=. [[https://bbs.archlinux.org/viewtopic.php?id=261540][arch 261540]] +- Paired without a bond via blueman (no). A gamepad/mouse paired through blueman connects but is unusable, while bluetoothctl works. blueman completed pairing without establishing bonding, so no usable link key was stored. Fix: remove the device and =bluetoothctl pair <mac>= (which bonds), then trust and connect. [[https://github.com/blueman-project/blueman/issues/2219][blueman 2219]] + +** Connection stability + +- Xbox/PS controller drops seconds after connecting unless ERTM is off (both). A controller pairs and connects, then disconnects within seconds, repeatedly. bluez's Enhanced Retransmission Mode is incompatible with these controllers' L2CAP handling. Fix: =echo 1 > /sys/module/bluetooth/parameters/disable_ertm=, persist =options bluetooth disable_ertm=1= in modprobe.d, reboot. [[https://github.com/atar-axis/xpadneo/issues/295][xpadneo 295]] +- Paired+trusted device never reconnects on boot/resume (no). A trusted HID/audio device stays disconnected after boot or resume and needs a manual connect each time. AutoEnable only powers the adapter; it doesn't initiate outbound connections to peripherals that don't self-reconnect. Fix: install and enable bluetooth-autoconnect (a systemd service that connects trusted devices when the adapter powers on, including post-resume). [[https://github.com/jrouleau/bluetooth-autoconnect][bt-autoconnect]] +- ReconnectIntervals/AutoEnable policy leaves the link dead after a drop (yes). After a device drops out of range and returns, bluetoothd doesn't re-establish the link. The [Policy] AutoEnable and [GATT] ReconnectIntervals settings are unset or too sparse. Fix: set =AutoEnable=true= and =ReconnectIntervals=1,2,4,8,16,32,64= in main.conf, restart bluetooth. [[https://wiki.archlinux.org/title/Bluetooth][archwiki bt]] +- USB 3.0 port radio noise degrades BT on the same side of the chassis (physical). A BT mouse/headset stutters or drops, worst under USB load or with an external SSD in an adjacent USB3 port; SuperSpeed signaling adds ~20 dB of broadband noise across 2.4-2.5 GHz. Fix: move the dongle/device off the USB3 port (USB2 port or an extension cable to distance the receiver). [[https://www.usb.org/sites/default/files/327216.pdf][usb-if 327216]] +- WiFi/BT coexistence on a 2.4GHz combo chip causes stutter and drops (yes). On an AX200 (and similar combos) BT audio/HID stutters and 2.4GHz WiFi collapses when both radios are active, sharing one antenna path. Fix: move WiFi to 5GHz, or tune coexistence via =/etc/modprobe.d/iwlwifi.conf=, reload iwlwifi. [[https://community.intel.com/t5/Wireless/Wi-Fi-6-AX200-Bluetooth-causing-WiFi-to-disconnect-randomly/td-p/1437797][intel ax200]] +- btusb USB autosuspend causes input lag/dropout after idle (both; the connection-health view of the adapter-layer autosuspend cause). A BT mouse/keyboard lags or briefly disconnects after no input, then wakes sluggishly; the kernel autosuspends the btusb controller. Fix: =btusb.enable_autosuspend=0= on the kernel cmdline (or a udev =power/control=on= rule), reboot. [[https://community.frame.work/t/solved-bluetooth-mouse-lag-linux-autosuspend/26763][framework autosuspend]] +- Adapter dead / devices won't reconnect after suspend-resume (yes; the same resume-init failure the rfkill layer's module-reload entry names, from the device-connection side). After resume the adapter is gone or devices refuse to reconnect (dmesg shows btusb resume errors). The controller doesn't survive suspend/resume cleanly on some kernels. Fix: a systemd sleep hook that runs =rmmod btusb; modprobe btusb= (or restarts bluetooth) post-resume. [[https://bugzilla.redhat.com/show_bug.cgi?id=2100761][rh 2100761]] +- BLE device disconnects with reason 0x08 from a too-aggressive supervision timeout (yes). A BLE peripheral connects then drops after a few seconds with reason 0x08 (Timeout); the negotiated supervision timeout is too short for the interval and latency. Fix: raise it via =/sys/kernel/debug/bluetooth/hci0/supervision_timeout=, persist with a startup service (debugfs resets on reboot). [[https://bbs.archlinux.org/viewtopic.php?id=279872][arch 279872]] +- Realtek RTL8761B wrong-firmware reconnect-loop (yes; same root cause as the adapter-layer name-mismatch entry, manifesting as a fast reconnect loop). An RTL8761B/BU dongle disconnects and reconnects several times a second; autodetect requests the wrong firmware name (or a bad blob). Fix: symlink the correct blob in /lib/firmware/rtl_bt, reload btusb. [[https://bbs.archlinux.org/viewtopic.php?id=309671][arch 309671]] +- Disconnected/loose internal antenna gives a few-feet range then drops (physical). BT works only within a couple of feet and cuts out beyond; a U.FL antenna lead popped off the card or none is attached. Fix: reseat the U.FL coax, or use an external USB adapter; keep the device closer as a stopgap. [[https://community.intel.com/t5/Wireless/How-can-I-deal-with-the-extremely-limited-range-of-Bluetooth-in/td-p/692325][intel range]] +- "Software caused connection abort" on connect-after-scan (no). A device with a fine bond aborts with "Software caused connection abort" (err 103) when a scan was stopped just before connecting; stopping discovery races the connection attempt in bluez. Fix: keep discovery running across the connect, or retry without a preceding scan-stop. [[https://github.com/hbldh/bleak/issues/631][bleak 631]] +- A2DP audio stutters whenever a BT mouse is also connected (no). Headset audio skips only while a BT mouse/HID is connected on the same adapter; the two profiles contend for the single controller's scheduling. Fix: switch the audio codec (SBC → SBC-XQ/LDAC) to lower airtime, or move the mouse to a separate dongle; disconnecting the mouse clears it. [[https://bbs.archlinux.org/viewtopic.php?id=279749][arch 279749]] +- BT mouse stutters at ~20 Hz unless a scan is active (no). Mouse motion is choppy but becomes smooth the instant a scan starts; without background inquiry the controller's scheduling starves the HID link. Fix: keep discovery on as a workaround, or apply the connection-parameter fix below. [[https://bbs.archlinux.org/viewtopic.php?id=261632][arch 261632]] +- BLE mouse lag from a too-high negotiated connection interval (yes). A BLE mouse feels laggy because it negotiated a power-saving (long) connection interval and high slave latency. Fix: set =[ConnectionParameters]= (MinInterval=6, MaxInterval=9, Latency=44, Timeout=216) in =/var/lib/bluetooth/<adapter>/<device>/info=, restart bluetooth. [[https://wiki.archlinux.org/title/Bluetooth_mouse][archwiki bt mouse]] + +** Bluetooth audio profiles / device classes + +Bluetooth-specific audio failures the bt doctor names at its last chain link. The generic PipeWire/WirePlumber internals are owned by [[file:2026-07-10-audio-failure-taxonomy.org][the audio taxonomy]]; these are the ones a bt-panel probe would surface for a connected BT audio device. + +- Stale bluez pairing state hides A2DP (yes). Headset connects but only HSP/HFP exist, no A2DP sink. Corrupt/partial pairing records in /var/lib/bluetooth leave A2DP unadvertised. Fix: =rm -rf /var/lib/bluetooth/<adapter>/<dev>=, restart bluetooth, re-pair. [[https://bbs.archlinux.org/viewtopic.php?id=280717][arch 280717]] +- Leftover pulseaudio-bluetooth module steals the endpoint (yes). After moving to PipeWire the device connects but no sink shows. pulseaudio-bluetooth still installed grabs the bluez D-Bus endpoint so pipewire-pulse never creates the card. Fix: =pacman -Rns pulseaudio-bluetooth pulseaudio=, install pipewire-pulse, restart the session. [[https://bbs.archlinux.org/viewtopic.php?id=267690][arch 267690]] +- bluez SPA plugin not installed, no card at all (yes). Device connects at the protocol level but pw-dump shows no bluez node; "api.bluez5.enum.dbus could not be loaded". The libspa bluetooth plugin is missing, so WirePlumber's bluez monitor can't enumerate it. Fix: install the PipeWire bluetooth SPA plugin (libspa-0.2-bluetooth on split distros), restart wireplumber. [[https://bbs.archlinux.org/viewtopic.php?id=304688][arch 304688]] +- WirePlumber seat-monitoring drops the node (no). A connected BT audio device intermittently has no card/node on a non-logind or nested seat. WirePlumber gates node creation on an active logind seat and misfires. Fix: =monitor.bluez.seat-monitoring = disabled= drop-in, restart wireplumber. [[https://pipewire.pages.freedesktop.org/wireplumber/daemon/configuration/bluetooth.html][wp bt config]] +- Autoswitch-to-headset fires on any input stream (no). Music drops to mono phone-call quality whenever any app opens the mic. WirePlumber's =bluetooth.autoswitch-to-headset-profile= flips to HSP/HFP on any capture stream and doesn't switch back. Fix: =wpctl settings --save bluetooth.autoswitch-to-headset-profile false=, or force =a2dp-sink= via set-card-profile. [[https://bbs.archlinux.org/viewtopic.php?pid=1973004][arch 1973004]] +- Codec falls back to SBC, wanted codec never negotiates (no). aptX/LDAC-capable headphones connect but only SBC is active. Default codec order drops to the SBC baseline, or the wanted codec isn't in the allowed set. Fix: =bluez5.codecs = [ ldac aptx_hd aptx aac sbc ]= in a wireplumber drop-in, reconnect. [[https://bbs.archlinux.org/viewtopic.php?id=300230][arch 300230]] +- AAC unavailable because PipeWire lacks fdk-aac (yes). An AAC-only headset never offers AAC and lands on SBC. PipeWire was built without the fdk-aac module (non-free, omitted on some builds). Fix: install libfdk-aac and a PipeWire build with =bluez5-codec-aac= enabled. [[https://bbs.archlinux.org/viewtopic.php?id=289177][arch 289177]] +- LDAC negotiates but adaptive bitrate stays low (no). LDAC is active yet sounds no better than SBC; bitrate never reaches 990k. The default =ldac.quality = auto= collapses to the low tier on a busy 2.4GHz link. Fix: pin =bluez5.a2dp.ldac.quality = "hq"= in a monitor.bluez.rules drop-in, reconnect. [[https://www.guyrutenberg.com/2025/01/09/configuring-ldac-quality-in-pipewire/][guyrutenberg ldac]] +- BT sink not made default, audio stays on laptop speakers (no). Headphones connect but sound keeps coming from the built-in speakers until switched by hand. WirePlumber doesn't promote a freshly-connected BT sink, and the default isn't restored across reconnect. Fix: =wpctl set-default <bt-sink-id>= now; persist with a priority.session bump or default-target restore. [[https://bbs.archlinux.org/viewtopic.php?id=300751][arch 300751]] +- Absolute-volume passthrough makes the slider a no-op (both). The OS volume slider does nothing or only toggles mute/max. AVRCP absolute-volume hands volume to device hardware that only honors 0/100. Fix: =bluez5.enable-hw-volume = false= in a wireplumber drop-in, restart pipewire+wireplumber. [[https://wiki.archlinux.org/title/Bluetooth_headset][archwiki bt headset]] +- Silent sink after suspend/resume despite reconnect (no; the BT-transport view of the audio taxonomy's resume-silence family). After wake the device reconnects but the playing stream is silent; audio returns only on a new track. The resumed transport isn't re-initialized for the existing stream. Fix: =wpctl set-default= to bounce the stream, or disable suspend-on-idle for the node; scripted disconnect/reconnect on resume as a workaround. [[https://forums.linuxmint.com/viewtopic.php?p=2781321][mint 2781321]] +- oFono present blocks native HFP / mSBC (yes; overlaps the audio taxonomy's BT-mic cluster — kept here for the bt-doctor probe). No HFP profile or only CVSD narrowband; mSBC never appears. An installed-but-unconfigured oFono claims the HFP backend so the native backend (which offers mSBC) stays inactive. Fix: remove ofono (or set the native HFP backend), restart pipewire, reconnect. [[https://bbs.archlinux.org/viewtopic.php?id=266252][arch 266252]] +- mSBC wideband unsupported by adapter/kernel (physical). HFP mic works only in muffled narrowband; mSBC never negotiates even on the native backend. mSBC needs adapter+kernel support for USB ALT6 / the transparent SCO path, which some controllers lack. Fix: none in software — use a different adapter, or accept CVSD narrowband. [[https://www.collabora.com/news-and-blog/news-and-events/pipewire-bluetooth-support-status-update.html][collabora bt]] +- HFP profile output dead, mic works (no). In HSP/HFP the mic captures fine but there's no playback through the headset. The native HFP backend's SCO output path fails to route on certain PipeWire versions/devices. Fix: force a2dp-sink for playback and only enter HFP for the call; try switching the HFP backend or a PipeWire version without the regression. [[https://discussion.fedoraproject.org/t/no-audio-output-with-bluetooth-headset-in-hsp-hfp-profile-mic-works-output-doesn-t/155135][fedora hfp-output]] +- Device seen as LE/GATT-only, no audio class (yes). A headset connects but is treated as a data/LE device with no A2DP sink. bluez brings it up over LE (GATT only) instead of Classic/BR-EDR. Fix: =DisableLE= (or =ControllerMode = bredr=) in main.conf, restart bluetooth, remove and re-pair. [[https://bbs.archlinux.org/viewtopic.php?id=303288][arch 303288]] +- Battery percent never reported (yes). =bluetoothctl info= shows no Battery line and the tray shows no BT battery. bluez's battery provider is gated behind experimental mode. Fix: =Experimental = true= under [General] in main.conf, restart bluetooth, reconnect (the device must expose the Battery Service). [[https://github.com/bluez/bluez/issues/362][bluez 362]] +- Multi-device: first-connected BT audio wins, wrong default (no). Two BT audio devices paired; audio always goes to whichever connected first and the other can't be made default cleanly. WirePlumber keeps the earlier sink as default. Fix: =wpctl set-default <desired-sink-id>=; give the preferred device a higher priority.session via a monitor.bluez.rules match. [[https://0110.be/posts/Connecting_two_Bluetooth_headsets_to_your_Linux_system:_audio_routing_in_PipeWire][0110.be dual bt]] +- HFP narrowband mic during calls is an expected tradeoff (no; GUIDE). Opening the mic (calls/meetings) tanks playback to tinny mono. Inherent to HFP/HSP sharing one low-bandwidth SCO link for bidirectional audio; A2DP has no mic path. Fix: none to repair — keep A2DP for listening, accept HFP for calls, or use a duplex codec (FastStream / aptX-LL) that carries the mic without leaving A2DP. [[https://webcodr.io/2024/10/fixing-no-a2dp-with-bluetooth-headsets-on-linux/][webcodr a2dp]] + +* Network triage + +The ~74 network failure modes sorted into eight symptom clusters, each owned by a tier the net doctor already probes (link → IP → route → DNS → egress), plus a control-plane cluster for NetworkManager itself and a drops cluster for intermittent faults. Every entry carries a remedy class, using the audio taxonomy's four: + +- *AUTO* — user-scope, reversible, no root. For net this is the nmcli surface the doctor already drives (bounce/reset, =con modify= for dns/mtu/ipv6/mac/autoconnect, tunnel-down, portal-open) plus user-service restarts. Polkit on an active session (or passwordless sudo on an archsetup install) covers these. +- *PRIV* — needs root: =systemctl restart NetworkManager/systemd-resolved=, editing =/etc/=, installing a firmware/driver package, a modprobe option. Runs silently where passwordless sudo exists, else Confirm/Arm. +- *REBOOT-TAIL* — the runnable part (often PRIV: firmware/driver install, initramfs rebuild, kernel change, a modprobe option) then the reboot the doctor instructs but can't verify past. +- *GUIDE* — nothing to run: the user must supply a secret/cert, flip a physical switch, change a BIOS setting, make a config trade-off, or wait out a venue/ISP outage. + +Rough distribution: ~30 AUTO, ~14 PRIV, ~12 REBOOT-TAIL, ~24 GUIDE (several are AUTO-now-plus-PRIV-to-persist, or AUTO-plus-GUIDE where the fix is a decision). The buildable share (AUTO plus the runnable part of PRIV/REBOOT-TAIL) is a little over half — the same shape as audio. Triage is a proposal; correct any class. + +Two structural notes carried from the audio design. First, the clusters map onto the probe ladder, so the fix/guide boundary falls along tiers the doctor already walks — cluster N is "the fault the Nth probe step catches". Second, the AUTO core is a handful of primitives (bounce/reset, an nmcli-setter, tunnel-down, a user-service restart, portal-open); the breadth is in the *diagnosis*, not the remedies — exactly as the audio doctor's ~50 diagnoses collapsed to five remedy primitives. + +** Cluster 1 — no link (radio/carrier down) +Probe: the link step (=_step_link=: rfkill, has-wifi-device, carrier). New work: a dmesg firmware-load hint would let the doctor name the specific blob (iwlwifi/rtw89/mt7921) rather than a generic "no adapter". + +- Wifi soft-blocked by rfkill :: AUTO — =rfkill unblock wifi= / =nmcli radio wifi on= (the doctor's rfkill repair). +- Wifi hard-blocked by a physical switch / BIOS :: GUIDE — flip the switch or enable in BIOS. +- rfkill stuck hard-blocked after suspend (WMI conflict) :: REBOOT-TAIL — blacklist the wrong WMI module, rebuild initramfs, reboot. +- Missing iwlwifi/mt7921/rtw89 firmware for a new card :: REBOOT-TAIL — install/update linux-firmware (PRIV), reboot. +- Firmware-load regression after a kernel/firmware update :: REBOOT-TAIL — boot linux-lts or pin the last-good firmware, reboot. +- Realtek rtw89 shadowed by an AUR DKMS driver :: REBOOT-TAIL — remove the DKMS package, reboot. +- Broadcom BCM4360 needs the wl driver / wl breaks after a kernel upgrade :: REBOOT-TAIL — install broadcom-wl-dkms, blacklist the open drivers, reboot. +- USB wifi dongle needs an out-of-tree DKMS driver :: REBOOT-TAIL — install the DKMS package, reboot. +- Ethernet NO-CARRIER (cable/switch) :: GUIDE — reseat/replace the cable, try another port. +- RTL8125 flips to r8169 after a kernel update :: REBOOT-TAIL — blacklist r8169, install r8125-dkms, reboot. +- RTL8125 downshifts / won't negotiate speed :: PRIV — =ethtool --set-eee eee off= (GUIDE the cable/switch swap). +- Adapter left "unmanaged" by NetworkManager :: AUTO + PRIV — =nmcli device set managed yes= (AUTO); remove the config rule / disable the rival manager to persist (PRIV). Also lives in cluster 7. + +** Cluster 2 — associated but no IP +Probe: the DHCP step (=_step_dhcp=). New work: distinguish "no DHCP answer" (server-side) from "conflict/ACD refused" (a duplicate on the LAN) so the message points the right way. + +- dhcpcd competing with NM's internal client :: PRIV — =systemctl disable --now dhcpcd=. Also cluster 7. +- dhcpcd 9 backend breaks NM IPv4 :: PRIV — =dhcp=internal= in NetworkManager.conf. +- APIPA / no DHCP answer :: AUTO — re-trigger DHCP (=nmcli device reapply=); a recurrence is server-side (GUIDE). +- Duplicate IPv4, address skipped by ACD :: GUIDE — change the conflicting host/reservation (a network decision). +- IPv6 RA not received, no SLAAC :: PRIV — =sysctl accept_ra=2= (or GUIDE the router RA-guard). +- DHCPv6-only network, address but no route :: GUIDE — the router must send an RA with the M flag; =ipv6.method auto= is AUTO but the fix is router-side. + +** Cluster 3 — have IP, no route to the internet +Probe: gateway + route + VPN-policy steps (=_step_gateway=, =_step_route=, =_vpn_policy_signal=). This is where the doctor's tunnel-down repair and the "VPN owns the route" terminal case live. + +- Default route never installed (=never-default=) :: AUTO — =nmcli con modify never-default no=, up. +- DHCP gateway off the lease's subnet :: PRIV — add the on-link route then default (GUIDE the DHCP-scope fix). +- Wifi+ethernet conflicting metrics, exits the dead link :: AUTO — raise the bad link's =ipv4.route-metric=. +- WireGuard AllowedIPs=0.0.0.0/0 strands the host :: AUTO — bring the tunnel down (the doctor's tunnel-down repair). +- WireGuard split-tunnel captures the LAN :: GUIDE — fix AllowedIPs (a config decision). +- OpenVPN redirect-gateway route left behind :: PRIV — flush the stale route, restart the client. +- Tailscale exit node captures the default route :: AUTO — =tailscale set --exit-node==. +- Docker bridge / libvirt virbr0 subnet collision :: PRIV — re-address the bridge (a config decision, GUIDE-adjacent). + +** Cluster 4 — authentication rejected (mostly terminal) +Probe: NM state 120 + GENERAL.REASON + the journal tail (the doctor's =_recent_auth_failure=). This is the cluster the classifier already marks TERMINAL and refuses to loop repairs on — the fix is almost always the user supplying a correct secret or cert. + +- Wrong PSK, NM state 120 :: GUIDE (TERMINAL) — the user re-enters the key; no repair fixes it. +- Pure WPA3-SAE won't associate :: AUTO — set =key-mgmt sae= + PMF on the profile. +- PMF mismatch on a transition AP :: AUTO — match the AP's =pmf=. +- SAE H2E-only vs hunt-and-peck :: PRIV — =sae_pwe=2= in wpa_supplicant config. +- eduroam/enterprise cert validation failed (PEAP/iwd/802.1X/anon-identity) :: GUIDE — supply the correct CA cert / server-name / realm; the doctor can't invent institutional certs. +- Hidden SSID never connects :: AUTO — =wifi.hidden yes= on the profile. +- Randomized MAC breaks a portal / allow-list :: AUTO — =cloned-mac-address stable= (or =permanent=). +- scan-rand-mac-address breaks scan/assoc :: PRIV — a =wifi.scan-rand-mac-address=no= drop-in. +- Wrong regulatory domain hides channels :: PRIV — =iw reg set=, persist via wireless-regdom. +- Won't roam, stuck on a weak AP :: AUTO — bounce the connection (GUIDE the bgscan/backend tuning). +- 4-way-handshake timeout / deauth loop :: REBOOT-TAIL — =11n_disable=1= modprobe option, reboot. + +** Cluster 5 — route works, names don't +Probe: dns-config + resolver-health + dns-resolve + the doctor's dns-test (which proves venue-resolver-broken vs egress-dead) and its dns-override/revert repair. + +- Dangling resolv.conf symlink / resolv.conf not at the stub :: PRIV — enable resolved or repoint the symlink. +- resolved stub unreachable / port-53 conflict :: PRIV — stop the conflicting resolver, restart resolved. +- NM not integrated with resolved :: PRIV — =dns=systemd-resolved= in NetworkManager.conf. +- DNSSEC rejects a venue resolver / sticky no-signature verdict :: PRIV — =DNSSEC=allow-downgrade= (=resolvectl reset-server-features= is AUTO for the sticky case). GUIDE the trade-off decision. +- DoT strict with 853 blocked / unreachable server :: PRIV — =DNSOverTLS=opportunistic=. GUIDE the trade-off. +- DHCP handed a broken resolver / pinned public DNS blocked :: AUTO — the doctor's dns-override (switch to working DNS) / drop the pin. +- Stale poisoned cache after a portal login :: AUTO — =resolvectl flush-caches=. +- VPN split-DNS not applied :: AUTO — =resolvectl domain/default-route= on the VPN link. +- IPv6 AAAA lookups stall :: AUTO — disable IPv6 on the link (or the single-request option). Also cluster 8. +- Another daemon overwrites resolv.conf :: PRIV — pick one manager, point resolv.conf at the stub. +- nsswitch.conf hosts line / avahi mDNS broken :: PRIV — fix the hosts line, install nss-mdns. + +** Cluster 6 — names resolve, egress blocked +Probe: the http/egress-edges steps + the terminal upstream outcome. This is the cluster where the doctor most often must STOP and say "not locally fixable" rather than loop repairs. + +- Captive portal held state / no auto-popup / HTTPS-check miss / HSTS block :: AUTO — the doctor's portal-open (load a plain-HTTP probe); PRIV to fix NM's connectivity URI to http. +- Custom DNS/DoT hides the captive portal :: AUTO + PRIV — drop DoT/DNSSEC for the network, log in, restore. +- PMTUD blackhole / PPPoE-VPN MTU not clamped :: AUTO — the doctor's mtu-test then mtu-override (lower the interface MTU). +- Stale http_proxy env / unreachable PAC :: AUTO — clear the proxy env / set system proxy to None. +- Clock skew breaks TLS :: PRIV — the doctor's clock-sync (=timedatectl set-ntp true=); GUIDE the CMOS-battery replacement. +- Firewall default-deny / VPN kill-switch leftover rule :: PRIV — allow egress / flush the stale rule. +- IPv6 egress broken while IPv4 works :: AUTO — disable IPv6 on the connection. Also cluster 8. +- False "limited connectivity" from a local DNS/VPN :: PRIV — fix NM's connectivity URI (AUTO to disable the check). Also cluster 7. +- ISP outage / modem in a bad state :: GUIDE (TERMINAL) — power-cycle the modem, contact the ISP; the doctor recognizes and stops. +- Specific outbound port blocked :: GUIDE — use an allowed alternate or switch networks; not host-fixable. + +** Cluster 7 — control plane broken (NetworkManager itself) +Probe: process/service state + config checks. New work: a "two managers fighting" detector (dhcpcd / systemd-networkd / iwd co-active with NM) and a keyfile-permission check, since these fail silently with a healthy-looking radio. + +- NM masked / failed to start :: PRIV — =systemctl unmask && start=; reinstall if corrupt. +- NM segfaults / restart-loops :: PRIV — coredump triage, update/downgrade the package. +- iwd + wpa_supplicant both active / backend mismatch / iwd configured-not-installed :: REBOOT-TAIL — pick one backend, disable the other, reboot. +- systemd-networkd + NM both managing the link :: REBOOT-TAIL — disable networkd, reboot. +- Keyfile permissions void a saved connection :: PRIV — =chmod 600=, =chown root=, reload. +- Autoconnect disabled :: AUTO — =connection.autoconnect yes=. +- Duplicate/conflicting profiles for one SSID :: AUTO — delete the stale profile / raise the good one's priority. +- Dispatcher script errors and blocks connectivity :: PRIV — fix ownership or remove the script. +- Polkit won't authorize a non-root user :: GUIDE — start a polkit agent / relogin for an active seat session. +- NetworkManager-wait-online hangs boot :: PRIV — disable the wait-online service. + +** Cluster 8 — flaky / drops / powersave +Probe: over-time / event-log signals (the panel's eventlog) plus a re-probe-after-idle check — the least-developed tier, the net analogue of the audio "works then dies" cluster. New work: correlate drop events with powersave/roam/suspend transitions. + +- WiFi powersave drops the connection :: AUTO — =iw set power_save off= now; PRIV to persist the NM/modprobe setting. +- USB wifi dongle killed by autosuspend :: PRIV — a udev =power/control=on= rule. +- iwlwifi/ath11k firmware crashloop on resume :: PRIV — reload the module (a sleep hook to persist). +- No reconnect after suspend/resume :: AUTO — the doctor's nm-restart; PRIV for a durable sleep hook. +- IPv6 happy-eyeballs stalls :: AUTO — disable IPv6 on the link. Shared with clusters 5/6 — same broken-v6 family, three probe points (route absent, egress black-holed, AAAA unanswered). + +* Bluetooth triage + +The ~55 bluetooth failure modes sorted into five symptom clusters, each owned by a link in the bt doctor's existing chain (adapter → rfkill/service/powered → per-device → audio). Same four remedy classes. The bt doctor's design already encodes a key constraint that shapes this triage: *connecting and pairing are user intents, not health repairs* — so the doctor never auto-connects or auto-pairs, and the destructive re-pair is always user-confirmed. That pushes clusters 3 and 4 heavily toward GUIDE and user-initiated actions, with far fewer silent AUTO fixes than the net side. + +Rough distribution: ~12 AUTO, ~16 PRIV, ~12 REBOOT-TAIL, ~15 GUIDE. The buildable share is lower than net's because the adapter/firmware cluster is almost entirely PRIV/REBOOT-TAIL/GUIDE and the pairing cluster is mostly user-driven. The doctor's existing safe-auto tiers (unblock, power-on, service-restart, a2dp) all live in clusters 2 and 5 — which is exactly where the buildable share concentrates. + +** Cluster 1 — no adapter +Probe: the adapter step (=btctl.show=). New work: a dmesg firmware-load hint would name the vendor blob (ibt-*.sfi, BT_RAM_CODE_MT7961, rtl8761, QCA, BCM .hcd) instead of a generic "no adapter found" — the direct parallel to the audio cluster-1 dmesg-hint proposal. + +- btusb not auto-loaded :: PRIV — =modprobe btusb=, persist via modules-load.d. +- Intel/MediaTek/Realtek/Qualcomm/Broadcom firmware missing or mismatched :: REBOOT-TAIL — update linux-firmware or symlink/decompress the requested blob (PRIV), reboot. +- Intel "failed to send firmware data" / broken-initial-NCMD :: REBOOT-TAIL — =btusb reset=1= or a newer kernel, reboot. +- Controller lost after a kernel update (Intel AX / MT7922 / QCA) :: REBOOT-TAIL — boot linux-lts or downgrade, reboot. +- MediaTek MT7922 stuck in a residual power state :: GUIDE — full power-off, pull power ~10s, cold boot. +- Realtek/Broadcom dongle needs an out-of-tree driver or .hcd :: REBOOT-TAIL — build the DKMS driver / install the firmware, reboot. +- Fake CSR clone resets/times out :: PRIV — the kernel clone quirks or =btusb reset=1=, reload. +- MT7925 killed by USB autosuspend :: REBOOT-TAIL — =btusb.enable_autosuspend=n= on the cmdline, reboot. Also cluster 4. +- Bluetooth hard-blocked by BIOS/switch :: GUIDE — flip the switch / enable in BIOS. + +** Cluster 2 — adapter blocked, unpowered, or the daemon is down +Probe: the rfkill + service + powered steps. This is where all four of the doctor's safe auto-repairs (unblock, service-restart, power-on) live. + +- Soft-blocked adapter :: AUTO — the doctor's unblock (=rfkill unblock bluetooth=). +- Airplane / physical hard-block :: GUIDE — turn airplane mode off / flip the switch (the doctor deliberately won't touch this radio). +- AutoEnable off, powered down every boot :: PRIV — =AutoEnable=true= in main.conf. +- bluetooth.service masked / not enabled :: PRIV — =systemctl unmask / enable --now= (service-restart is the AUTO tier for a stopped-but-enabled service). +- D-Bus policy blocks org.bluez / bad unit-file after a bluez upgrade :: PRIV — reinstall bluez / reconcile the .pacnew, daemon-reload. +- WiFi rfkill toggle also killed BT (combo chip) :: AUTO — the doctor's unblock. +- TLP disables BT on startup / systemd-rfkill restores a stale block :: PRIV — remove BT from the TLP list / clear the saved rfkill state (unblock is AUTO now). +- rfkill block spins bluetoothd at 100% CPU :: AUTO — service-restart after unblock. +- Adapter powers back off after a desktop toggle :: AUTO — unblock + power-on (or re-enable from the desktop). +- BT stuck/soft-blocked on resume, needs a module reload :: PRIV — reload btusb (a sleep hook to persist). Also cluster 4. +- Wrong/missing default controller (multiple adapters) :: AUTO — =bluetoothctl select <MAC>= then power-on. + +** Cluster 3 — won't pair / bond +Probe: none in-chain — pairing is a user intent the panel offers, not something the doctor health-repairs. The doctor's one destructive tier here (re-pair = remove + pair) is always user-confirmed, never chained. New work: a "stale bond / corrupt cache" signature (a device that fails auth repeatedly with a bond present) would let the doctor *offer* the re-pair with confidence rather than guess. + +- Generic AuthenticationFailed / re-pair loop / corrupt cache survives remove :: PRIV (user-confirmed) — stop bluetooth, =rm -rf= the stale =/var/lib/bluetooth/<adapter>/<mac>=, restart, re-pair. +- Stale link key after a dual-boot re-pair :: GUIDE — remove and re-pair (or sync keys between OSes); a user decision. +- Paired but not trusted, no auto-reconnect :: AUTO — =bluetoothctl trust <mac>= (a safe, non-connecting fix). Bridges to cluster 4. +- No default pairing agent :: GUIDE — register an agent (=agent on=); mostly a headless-setup concern. +- BLE device never appears in scan / not in pairing mode / adapter not pairable :: GUIDE — enable LE transport / put the device in pairing mode / =pairable on= — user actions. +- ConnectionAttemptFailed / page-timeout after a bluez upgrade :: PRIV — restart bluetooth; downgrade bluez if it recurs. +- Headset un-pairs itself on disconnect / BLE address rotates :: GUIDE — re-pair; a device-behavior limitation. +- Legacy keyboard needs the PIN typed on-device :: GUIDE — type the passkey on the keyboard. +- Too many bonded devices, keys lost :: PRIV (user-confirmed) — remove devices below the ~14 limit, restart. +- Dual-mode device pairs on the wrong transport / seen as LE-only :: PRIV — =ControllerMode = bredr= in main.conf, restart, re-pair. Shared with cluster 5. +- Paired without a bond via blueman :: GUIDE — re-pair with =bluetoothctl pair= (which bonds). + +** Cluster 4 — paired but the link won't hold +Probe: the per-device connected-state step. A disconnected paired device is information, not a failure; reconnecting is user-initiated. New work: connection-parameter / coexistence hints (name USB3 noise, 2.4GHz coexistence, or a too-aggressive supervision timeout) so a "keeps dropping" verdict points somewhere. + +- Paired+trusted device never reconnects on boot/resume :: PRIV — install bluetooth-autoconnect, or set =ReconnectIntervals= in main.conf. +- Xbox/PS controller drops unless ERTM is off :: REBOOT-TAIL — =disable_ertm=1= modprobe option, reboot. +- USB3 port radio noise / loose antenna / short range :: GUIDE — move the device off the USB3 port, reseat the antenna, close the distance. +- WiFi/BT 2.4GHz coexistence stutter :: PRIV — move WiFi to 5GHz / tune the coexistence modprobe option (GUIDE the band move). +- btusb autosuspend input lag / adapter dead after resume :: REBOOT-TAIL — =btusb.enable_autosuspend=0= on the cmdline (PRIV sleep hook for the resume case). Shared with clusters 1/2. +- BLE supervision-timeout / connection-interval too aggressive :: PRIV — raise the timeout / set =[ConnectionParameters]= (a startup service to persist debugfs). +- RTL8761B wrong-firmware reconnect loop :: PRIV — symlink the correct blob, reload. Same cause as cluster 1's name-mismatch. +- "Software caused connection abort" on connect-after-scan :: AUTO — retry the connect without a preceding scan-stop. +- A2DP stutters with a BT mouse also connected / mouse stutters without a scan :: GUIDE — lower audio-codec airtime or move the mouse to a dongle; a contention trade-off. + +** Cluster 5 — connected but audio is wrong +Probe: the audio-profile step (=audio.pw_dump=, card/sink for the device). The doctor's a2dp repair (force the A2DP profile) is the AUTO tier here. The generic sound-server internals belong to the audio taxonomy; these are the BT-specific ones the bt panel names. + +- No A2DP sink / stale bluez state hides A2DP :: PRIV (user-confirmed) — clear the stale =/var/lib/bluetooth= record, re-pair (the a2dp repair is AUTO when the card is merely on the wrong profile). +- Stuck in HSP/HFP, autoswitch-to-headset fired :: AUTO — the doctor's a2dp repair / disable autoswitch. +- Leftover pulseaudio-bluetooth steals the endpoint / bluez SPA plugin missing :: PRIV — remove pulseaudio-bluetooth / install the SPA plugin. +- WirePlumber seat-monitoring drops the node :: PRIV — a =seat-monitoring = disabled= drop-in. +- Codec falls back to SBC / LDAC stuck low / AAC missing :: AUTO — a =bluez5.codecs= / ldac-quality drop-in (PRIV/REBOOT for the fdk-aac rebuild). +- BT sink not made default :: AUTO — =wpctl set-default= (the audio taxonomy's set-default primitive). +- Absolute-volume slider no-op :: PRIV — =enable-hw-volume = false= drop-in. +- Silent sink after resume :: AUTO — bounce the stream via set-default. +- oFono blocks native HFP / mSBC :: PRIV — remove ofono / set the native backend. +- mSBC unsupported by adapter / HFP output dead :: GUIDE — a hardware or PipeWire-version limitation. +- Device seen as LE/GATT-only, no audio class :: PRIV — =ControllerMode = bredr=, re-pair. Shared with cluster 3. +- Battery percent never reported :: PRIV — =Experimental = true= in main.conf. +- Multi-device, wrong audio default :: AUTO — =wpctl set-default= / a priority.session rule. +- HFP narrowband mic during calls :: GUIDE — an inherent A2DP/HFP trade-off. + +* Saturation test + +Deferred to a later pass, mirroring the audio taxonomy's blind-different-axis resample. When run, it must sample on a *different* axis than this by-layer first sweep — by hardware vendor, by distro, by device class (headset vs mouse vs controller vs enterprise-wifi vs VPN), weighted toward non-Arch 2024-2026 sources — and run the agents blind to these clusters, so it tests whether the eight net / five bt clusters are representative rather than re-deriving them. The audio resample forced zero new clusters from 108 fresh reports; the expectation here is the same, but it is unproven until run. + diff --git a/docs/design/2026-07-12-control-grammars-reference.org b/docs/design/2026-07-12-control-grammars-reference.org new file mode 100644 index 0000000..beee4f5 --- /dev/null +++ b/docs/design/2026-07-12-control-grammars-reference.org @@ -0,0 +1,196 @@ +#+TITLE: Control Grammars and Interaction Taxonomies — Reference Note +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-12 + +Source material for the widget-gallery classification brainstorm (todo.org "Widget gallery upgrades" → "Component classification taxonomy"). The gallery is at 100 cards; every card's spec sheet already records its input model. This note collects the vocabulary and the literature the taxonomy should build on, so the brainstorm starts from named prior art instead of re-deriving it. + +* The named control grammars + +A control grammar is the sentence structure of an interaction — what order the operator supplies action, operand, and commitment. The kit already holds an exemplar of each: + +- Verb-noun :: action first, then operand. DSKY (R41): VERB 35 ENTR. Command lines and vi's =d2w= are the software descendants. +- Noun-verb (object-action) :: select the thing, then act on it. Every modern GUI; in the kit, the waveform region editor (R19) — pick a flag, then move it. +- Modal :: the same inputs mean different things depending on a mode set elsewhere. The keyed mode switch (N06) is the hardware form; vi is the canonical software one. +- Quasimode (held state) :: the state exists only while physically maintained; release returns to baseline. Dead-man button (R38), push-to-talk, the shift key. Term coined by Raskin. +- Chorded :: simultaneous inputs form one command. Key chords; the two-hand anti-tie-down control is the safety-hardware form. +- Arm-then-commit :: a two-step grammar where the first action only enables the second. Arm-to-fire (08), guarded toggle (R29). +- Direct manipulation :: no grammar — the operand is dragged itself. Faders, the response graph (R26), the attitude indicator (R43). Shneiderman's term. + +* The literature + +No single canonical registry of control grammars exists. The concept is split across these traditions, listed by usefulness to this project: + +- Moran, "The Command Language Grammar" (1981, Int. J. Man-Machine Studies) :: origin of the phrase; models a whole interface as a layered grammar (task, semantic, syntactic, interaction levels). +- Foley, Wallace & Chan, "The Human Factors of Computer Graphics Interaction Techniques" (1984, IEEE CG&A) :: the classic six elemental interaction tasks — select, position, orient, path, quantify, text. The strongest single candidate for the taxonomy's use-axis; most of the 100 cards drop cleanly into one of the six. +- Buxton, "Lexical and Pragmatic Considerations of Input Structures" (1983) and the three-state model of graphical input (1990) :: formal state machines for hover/drag/click; the right lens for the drag-vs-click portability question (Emacs has states 0 and 2 but not smooth state-1 motion). +- Card, Mackinlay & Robertson, "A Morphological Analysis of the Design Space of Input Devices" (1991) :: a design-space grid (linear/rotary × position/force × dimensions) that classifies the physical inputs themselves. +- Raskin, *The Humane Interface* (2000) :: modes vs quasimodes, and why held state beats latched state for safety-relevant functions. +- Shneiderman, "Direct Manipulation: A Step Beyond Programming Languages" (1983, IEEE Computer) :: the case for grammar-free interaction. +- MIL-STD-1472 (Human Engineering) and NASA's Human Integration Design Handbook (HIDH, NASA/SP-2010-3407) :: the closest thing to "the definition of them all" for physical controls — catalogs of control types (toggle, rotary selector, thumbwheel, legend switch, handwheel...) with selection criteria, spacing, resistance, and coding rules. FAA HF-STD-001 is the aviation equivalent. + +* Proposed axes for the classification brainstorm + +Starting point, to be argued in the brainstorm, not settled here. Argued 2026-07-16 — see "The brainstorm, run" below, which adds an eighth axis and records what the cross found. + +1. Elemental task (Foley) :: select / position / orient / path / quantify / text — plus the display-side tasks the gallery adds (indicate state, indicate quantity, indicate history, indicate spatial status). +2. Input model :: none (display) / click / click-step / drag-1D / drag-2D / held / composed. Already on every spec sheet; drives Emacs portability directly. +3. Grammar :: the seven above. Orthogonal to input model — a click can belong to any grammar. +4. Cardinality of the value :: binary / one-of-N / many-of-N / many-to-many / continuous scalar / 2-axis / bitmap / compound. +5. State authority :: operator-set / system-set (breaker trip, servo needle) / co-set (heading bug vs actual). +6. Persistence :: latched / held / transient / mechanical-persistent (flip-disc). +7. Era :: the period field, where not timeless. + +The gap-finding move the brainstorm exists for: cross axes 1×4 and look for empty cells — a use-case with no component is a cell the catalogue cannot fill. + +* The brainstorm, run (2026-07-16) + +The gap-finding cross, run against the 109-card kit. What follows is what it found, the axis it needed that the seven above don't carry, and the period models proposed for each empty cell. Build decisions live in todo.org; this section is the map they come from. + +** Axis 8 — set stability (new, and the one that finds the hole) + +*Fixed* (the option set is knowable when the panel is built) versus *dynamic* (the set is discovered at run time and changes without the panel changing). + +The seven axes above cannot find the kit's largest gap, because axis 4 lumps all one-of-N together: a chicken-head selector whose stops are cut in metal and a WiFi network list sit in the same cell, though nothing about the first tells you how to build the second. Crossing task x cardinality alone therefore reports the cell as densely covered. Only set stability separates them. + +The axis is invisible from inside hardware, which is why it was missing. *Every* one-of-N control in this kit has its positions engraved at manufacture — silkscreened legends, cut detents, printed scales. A faceplate physically cannot hold a set that changes, so a catalogue derived from period hardware inherits a blind spot exactly at dynamic sets. The kit's consumers are software panels (net, bt, audio, maint) whose central objects are dynamic sets: available networks, paired devices, running services. The blind spot lands precisely on what the consumers most need. + +** The cross, run — elemental task (Foley) x cardinality x set stability + +First pass, scoped to the Controls section (the input side, where the cross bites). Counts are card counts; =--= is an empty cell. Display-side tasks are summarized below rather than celled, since the gaps there are not where the interest is. + +| Elemental task | bin | 1-of-N fixed | 1-of-N dyn | m-of-N | m<->m | cont | 2-axis | compound | +|----------------+-----+--------------+------------+--------+-------+------+--------+----------| +| select | 10 | 13 | -- | 3 | 2 | -- | -- | -- | +|----------------+-----+--------------+------------+--------+-------+------+--------+----------| +| quantify | -- | -- | -- | -- | -- | 13 | -- | 2 | +|----------------+-----+--------------+------------+--------+-------+------+--------+----------| +| position | -- | -- | -- | -- | -- | -- | 2 | -- | +|----------------+-----+--------------+------------+--------+-------+------+--------+----------| +| orient | -- | -- | -- | -- | -- | -- | 2 | -- | +|----------------+-----+--------------+------------+--------+-------+------+--------+----------| +| path | -- | -- | -- | -- | -- | -- | 1 | -- | +|----------------+-----+--------------+------------+--------+-------+------+--------+----------| +| text (digits) | -- | -- | -- | -- | -- | -- | -- | 3 | +|----------------+-----+--------------+------------+--------+-------+------+--------+----------| +| text (alpha) | -- | -- | -- | -- | -- | -- | -- | -- | +|----------------+-----+--------------+------------+--------+-------+------+--------+----------| + +Cell contents, first pass: + +- select x binary :: 01, 07, 08, N01, R03, R24, R29, R38, R48, R50. +- select x 1-of-N fixed :: 02, 06, 24, N03, N06, R06, R20, R22, R27, R28, R33, R34, R42. +- select x 1-of-N dynamic :: *empty*. +- select x many-of-N :: N09, R51, R52. +- select x many-to-many :: N28, R37. +- quantify x continuous :: 03, 04, 05, N05, N07, N08, N10, R02, R04, R12, R14, R18, R23. +- quantify x compound :: N04, R49. +- position / orient / path :: R19, R26, R43, R44. +- text x digits :: R16, R39, R41. +- text x alphanumeric :: *empty*. + +Display side (Meters, Indicators — roughly 55 cards) covers indicate-state, indicate-quantity, indicate-history and indicate-spatial-status densely; no empty cell worth a card was found there. Left uncelled deliberately. + +** Findings — the two empty cells + +*Foley names six irreducible tasks. The kit serves five and a half.* + +1. *select x one-of-N, dynamic set.* The bt panel lists paired devices; the net panel lists available networks, each row carrying an SSID, a signal strength and a lock state. The catalogue meant to describe those panels has no widget for the thing those panels most obviously are. Nearest misses: N03 (a fixed, small preset bank) and R20 (a drum of values, not rich rows). Neither is it. +2. *text x alphanumeric.* The kit enters digits three ways and text zero ways. The net panel needs a WiFi password. + +Both are needed by live consumers today, which is what makes them the two that sting. + +** Why hardware had no dynamic-set control, and what it did instead + +The period answer to a set that changes was never a control. It was *a re-labelable slot*: hardware supplied a holder and let a human write the label. + +- Jukebox title-strip rack (Seeburg wallbox) :: printed strips slotted into a rack and replaced when the records change; a page-flip lever browses them; a letter-plus-number selector picks one. A scrollable list of rich rows with selection, built in 1950. +- Rolodex :: spin through rich cards; add and remove at will. +- Mixing-console scribble strip :: tape and grease pencil over each channel. +- Telephone switchboard subscriber strips :: relabeled as subscribers came and went. + +The shared grammar is worth naming, because it is the aesthetic-honest answer to every dynamic set: *the slot is manufactured, the label is not.* A device list rendered as a rack of slotted, hand-labeled strips is not a skeuomorphic joke — "somebody wrote these because the set changes" is exactly what a device list means. + +** Period models proposed per gap + +One row per candidate model, so each model keeps its own verdict. + +| Gap | Model to build from | Verdict | +|-------------------------+---------------------------------------------+----------------------------------------------| +| select x 1-of-N dynamic | Jukebox wallbox title-strip rack | First pick. Page-flip browse, rich strips, | +| | | select one. | +|-------------------------+---------------------------------------------+----------------------------------------------| +| select x 1-of-N dynamic | Rolodex card spinner | Alternative. Better for long sets, weaker at | +| | | showing several rows at once. | +|-------------------------+---------------------------------------------+----------------------------------------------| +| text x alphanumeric | Industrial ABC-order keypad | *First pick*, superseding the teletype — see | +| | | the survey below. A faceplate, not a | +| | | machine. | +|-------------------------+---------------------------------------------+----------------------------------------------| +| text x alphanumeric | Index typewriter (stylus + print lever) | Strongest novel idiom found: select and | +| | | commit are two separate controls. | +|-------------------------+---------------------------------------------+----------------------------------------------| +| text x alphanumeric | Chorded keyset (Microwriter / Brailler) | Six keys, no key per letter. The most | +| | | compact honest answer. | +|-------------------------+---------------------------------------------+----------------------------------------------| +| text x alphanumeric | Letter-drum bank (combination lock) | Craig's reference. Fixed length, so wrong | +| | | for a password; right for a callsign. | +|-------------------------+---------------------------------------------+----------------------------------------------| +| text x alphanumeric | Teletype (ASR-33) keyboard + line | Demoted. Honest but it is a machine, not a | +| | | faceplate, and QWERTY is the least | +| | | panel-native option. | +|-------------------------+---------------------------------------------+----------------------------------------------| +| text x alphanumeric | Dymo embossing wheel | Charming and faceplate-native; painful past | +| | | about eight characters. A descendant of the | +| | | index typewriter, mechanism differs. | +|-------------------------+---------------------------------------------+----------------------------------------------| +| swatch pick (chrome) | Signal-lamp lens turret, or the theatrical | A rotary whose detents are coloured lenses; | +| | gel wheel | the sample is the label. Joins the kit's | +| | | existing rotary family. | +|-------------------------+---------------------------------------------+----------------------------------------------| +| cycling state button | Legend switch (MIL-STD-1472) | Lit pushbutton whose cap legend and colour | +| | | ARE the state; press to step. The kit has | +| | | lit keys (02), not this. | +|-------------------------+---------------------------------------------+----------------------------------------------| + +** Survey: how period hardware entered letters (2026-07-16) + +Craig fed four reference photos (filed in =working/retro-stereo-widgets/references/= as =2026-07-16-abc-keypad-stainless.png=, =-abc-keypad-membrane-fkeys.png=, =-abc-keypad-membrane-color.png=, =-letter-drum-bank.png=) and asked for a wider search. Both moves changed the recommendation. + +*The references beat the teletype.* All three keypad photos are *ABC-order*, not QWERTY: A-Z laid out alphabetically with a separate 0-9 block, on stainless or membrane, with colour-coded function keys (yellow CANCEL, red CLEAR/NO, green ENTER/YES, blue HELP, F1-F4). That is the industrial convention wherever the operator cannot be assumed to touch-type — fleet, kiosk, access control. It is a faceplate rather than a machine, it panel-mounts, and its colour coding lands on the kit's palette without translation (terracotta for CLEAR/NO, run green for ENTER/YES, amber for CANCEL). The teletype is honest but it is furniture; the ABC keypad is the panel-native answer and supersedes it. + +*The survey found idioms genuinely unlike a keypad.* Ranked by distinctness: + +- Index typewriter :: Hall Type-Writer No. 1 (1881), AEG Mignon Model 4 (1924-30s). One hand walks a *stylus over a printed index plate*, the other pulls a *print lever* to commit. Select and commit are two separate physical controls — an idiom nothing in the kit has. Arbitrary length. Reads unmistakably as period hardware at small size. The Dymo wheel is a descendant, but stylus-on-index plus lever is a different mechanism from rotate-dial plus squeeze. *Strongest find.* +- Chorded keyset :: Microwriter (1978-80s, six keys — one per finger plus two thumb), Stenotype (patents 1911/1913), Perkins Brailler (1951, six keys as dots 1-2-3 / 4-5-6). No key per letter at all; a simultaneous *chord* is the character. Six keys plus a live chord readout is the most compact honest answer for a password field, and the kit already names the chorded grammar (R50 is its safety-hardware form) without holding a chorded *text* control. +- CDU / MCDU scratchpad + line-select keys :: Boeing/Airbus FMS, ~1980s-present. Text lands in a *scratchpad buffer* and stays uncommitted until a line-select key beside the target field both commits it and says where it goes. A different *flow*, not a different selector — its character source is an ABC keypad. The screen-flanking LSK bezel is iconic. +- Joystick scroll-and-fire :: Asteroids (1979); Defender/Stargate allowed more than three characters. Up/down steps the alphabet, fire commits and advances. The three-initial limit was an obscenity policy, not a technical one. Generalises to any shaft-encoder-scrolls-an-alphabet control. +- Multi-tap / T9 :: multi-tap early 1990s; T9 from Tegic, 1995, first shipped on the Benefon iO and Samsung Anycall. Twelve keys, letters cycled by repeat press with a timeout commit. The most *practical* small-panel password entry found, and the letter-cycling state animates well. +- Trackball gesture :: Atari Quantum (1982) — the player encircles letters, and top scorers draw their initials freehand. Genuinely unlike everything else; needs a drawing surface, so it is big. +- Keypunch + program drum :: IBM 029 (1964). A punched card on a *program drum* templates the fields — auto-duplicate, force numeric/alpha, skip. The column ruler plus field mask is a different framing; the machine is huge. +- Keyboard + lampboard :: Enigma (1920s-45). A keypress lights exactly one of 26 lamps and the operator hand-copies it. Judged a *feedback* idiom, not a selector — the 26-lamp alphabet grid is worth stealing as a readout for some other card, not as text entry. +- Keyboard perforator :: Teletype Model 15 (1930-63), Model 19 with integral perforator. Keying punches tape offline; compose and transmit are separated in time. The punched-tape strip is a fine visual but the keyboard is ASR-33-adjacent. + +*Two flags from the survey, carried rather than smoothed over:* + +- No true A-Z *thumbwheel switch* could be verified — Digitran and Grayhill mil-spec coded wheels are 0-9 or hex (0-9 A-F) only. Craig's letter-drum reference is a *combination lock*, not a thumbwheel switch. The drum bank is real, but its lineage is locks, and a card should say so rather than implying a switch that may not exist. +- The trackball-gesture and Quantum details are single-sourced. + +*Recommendation.* Build the ABC keypad first — it fills the empty cell, two live panels want it, and it is what the references show. Then the index typewriter as the second card, because it is the one genuinely new *grammar* (select on one control, commit on another) and it is small and beautiful. The chorded keyset is the third if a compact password field is wanted. Everything else is banked. + +** The gallery cannot build its own chrome from its own kit + +The last two rows are a finding in their own right. The gallery's own controls — the style-chip swatch rows, the screen-family chips, the validation lamps that cycle off/amber/green — are all bespoke HTML with no card behind them. The size toggle is a segmented control the kit already has (06) and does not use. + +That is the cheapest completeness test available: a catalogue that cannot build its own interface from itself has holes, and it names them for free. Worth re-running whenever the gallery's chrome grows. + +** Still open + +- The full seven-axis classification is not settled. Only axes 1 (elemental task) and 4 (cardinality), plus the new axis 8, were argued; grammar, state authority, persistence and era are untouched. +- The cell assignments above are a first pass from card names and spec sheets, not a card-by-card audit. Confidence of completeness needs the audit. +- The display side is uncelled. +- Whether the chrome gaps (swatch, legend switch) earn cards, or stay page furniture, is undecided. + +* Related + +- Gallery: [[file:../prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]] (110 cards, spec sheets carry input model + period). +- Source survey: takuzu =working/research/ui-components/historical-panel-components.org= (processed 2026-07-12; build + banked + stale verdicts recorded in todo.org). diff --git a/docs/design/2026-07-14-undeclared-packages-evidence.org b/docs/design/2026-07-14-undeclared-packages-evidence.org new file mode 100644 index 0000000..86fa0aa --- /dev/null +++ b/docs/design/2026-07-14-undeclared-packages-evidence.org @@ -0,0 +1,84 @@ +#+TITLE: Undeclared ratio packages — include/ignore evidence +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-14 + +Evidence sweep for the "Review undeclared ratio packages for installer inclusion" task. Source: =make package-diff= run on ratio 2026-07-14 (64 explicit packages installed but not declared; the June run saw 62). Per package: pacman's description, what requires it (dependency vs standalone), and the install date. The include/ignore decisions are Craig's; the buckets below carry the default lean. + +Rerun =make package-diff= after any include pass to confirm the count drops. + +** Candidates to declare (30) +Explicitly installed apps and tools with nothing requiring them. Each needs Craig's include/ignore call. +- =aws-cli-v2= :: Universal Command Line Interface for Amazon Web Services (version 2) · installed 2026-04-25 +- =baobab= :: A graphical directory tree analyzer · installed 2026-07-08 +- =bats= :: Bash Automated Testing System · installed 2026-04-21 +- =blueman= :: GTK+ Bluetooth Manager · installed 2026-01-25 +- =drawio-desktop= :: Official Electron build of draw.io · installed 2026-07-08 +- =emacs= :: The extensible, customizable, self-documenting real-time display editor · installed 2026-05-03 +- =flatpak= :: Linux application sandboxing and distribution framework (formerly xdg-app) · installed 2026-06-13 +- =geeqie= :: Lightweight image viewer · installed 2026-07-13 +- =git-lfs= :: Git extension for versioning large files · installed 2026-04-24 +- =github-cli= :: The GitHub CLI · installed 2026-07-08 +- =hugo= :: Fast and Flexible Static Site Generator in Go · installed 2026-07-13 +- =imv= :: Image viewer for Wayland and X11 · installed 2026-07-13 +- =inform= :: The Inform 6 compiler · installed 2026-07-11 +- =lc0= :: UCI-compliant chess engine designed to play chess via neural network, specifically those of the LeelaChessZero project. · installed 2026-05-16 +- =ledger= :: Double-entry accounting system with a command-line reporting interface · installed 2026-04-25 +- =libreoffice-fresh= :: LibreOffice branch which contains new features and program enhancements · installed 2026-07-13 +- =minidlna= :: A DLNA/UPnP-AV Media server (aka ReadyDLNA) · installed 2026-05-16 +- =openai-codex= :: OpenAIs lightweight coding agent that runs in your terminal · installed 2026-07-13 +- =pacoloco= :: Pacman caching proxy server · installed 2026-07-08 +- =proton-vpn-gtk-app= :: ProtonVPN GTK app, Maintained by Community · installed 2026-07-08 +- =protontricks= :: Run Winetricks commands for Steam Play/Proton games among other common Wine features · installed 2026-05-09 +- =python-pip= :: The PyPA recommended tool for installing Python packages · installed 2026-06-04 +- =python-pipx= :: Install and Run Python Applications in Isolated Environments · installed 2026-07-08 +- =python-sphinx= :: Python documentation generator · installed 2026-04-29 +- =shortwave= :: Internet radio player · installed 2026-03-22 +- =spotify-launcher= :: Client for spotify's apt repository in Rust for Arch Linux · installed 2026-06-04 +- =tidal-dl-ng= :: A tool for downloading music and albums from TIDAL · installed 2026-01-30 +- =typescript-language-server= :: Language Server Protocol (LSP) implementation for TypeScript using tsserver · installed 2026-03-02 +- =whisper.cpp= :: Port of OpenAI's Whisper model in C/C++ · installed 2026-06-04 +- =zoom= :: Video Conferencing and Web Conferencing Service · installed 2026-07-08 + +** Dependencies of other packages (6) +Something installed requires these; leaving them to dependency resolution is the default lean. +- =blas-openblas= :: An optimized BLAS library based on GotoBLAS2 1.13 BSD (Provides BLAS/CBLAS/LAPACK/LAPACKE system-wide) · required by: gsl python-numpy suitesparse · installed 2026-05-16 +- =lc0-network-sm= :: Small network for lc0 chess engine, requires ~1.6 GB of memory, recommended for CPU usage. · required by: lc0 · installed 2026-05-16 +- =openblas= :: An optimized BLAS library based on GotoBLAS2 1.13 BSD · required by: blas-openblas lc0 libggml-git · installed 2026-04-25 +- =pkcs11-helper= :: A library that simplifies the interaction with PKCS11 providers for end-user applications using a simple API and optional OpenSSL engine · required by: openvpn · installed 2026-02-20 +- =proton-vpn-daemon= :: Daemons for Proton VPN Linux client (split tunneling service) · required by: proton-vpn-cli proton-vpn-gtk-app · installed 2026-05-05 +- =python-lyricsgenius= :: A Python client for the Genius.com API, that provides a simple interface to the song, artist, and lyrics data stored on Genius.com · required by: tidal-dl · installed 2026-01-25 + +** Libraries with no requirer (7) +The task's library watchlist: likely former dependencies now orphaned, or toolchain pieces. Lean: ignore unless a build recipe needs them pinned. +- =eigen= :: Lightweight C++ template library for vector and matrix math, a.k.a. linear algebra · installed 2026-04-05 +- =freerdp= :: Free implementation of the Remote Desktop Protocol (RDP) · installed 2026-07-08 +- =lib32-openal= :: Cross-platform 3D audio library, software implementation (32-bit) · installed 2026-05-12 +- =rust-bindgen= :: Automatically generates Rust FFI bindings to C (and some C++) libraries · installed 2026-07-08 +- =sane= :: Scanner Access Now Easy · installed 2026-02-24 +- =tk= :: A windowing toolkit for use with tcl · installed 2026-02-26 +- =webkit2gtk= :: Web content engine for GTK · installed 2026-05-03 + +** Structural (pacstrap / VM harness / kernel) (21) +Already excluded by the task's 2026-06-14 analysis; listed so the report matches the raw diff count. +- =archiso= :: Tools for creating Arch Linux live and install iso images · installed 2026-04-05 +- =base= :: Minimal package set to define a basic Arch Linux installation · installed 2026-02-12 +- =bridge-utils= :: Utilities for configuring the Linux ethernet bridge · installed 2026-01-26 +- =btrfs-progs= :: Btrfs filesystem utilities · required by: archinstall grub-btrfs libguestfs snap-pac snapper · installed 2026-05-12 +- =dnsmasq= :: Lightweight, easy to configure DNS forwarder and DHCP server · installed 2026-06-08 +- =efibootmgr= :: Linux user-space application to modify the EFI Boot Manager · installed 2026-05-10 +- =ex-vi-compat= :: The ex and vi commands based on vim's compatibility modes · installed 2026-02-06 +- =grub= :: GNU GRand Unified Bootloader (2) · required by: grub-btrfs · installed 2026-01-25 +- =libguestfs= :: Access and modify virtual machine disk images · installed 2026-07-08 +- =linux= :: The Linux kernel and modules · installed 2026-07-13 +- =linux-headers= :: Headers and scripts for building modules for the Linux kernel · installed 2026-07-13 +- =linux-lts= :: The LTS Linux kernel and modules · installed 2026-07-13 +- =linux-lts-headers= :: Headers and scripts for building modules for the LTS Linux kernel · installed 2026-07-13 +- =linux-lts-strix= :: The LTS Linux with Strix Halo PMFW VPE-idle-pg workaround patch kernel and modules · installed 2026-04-30 +- =logrotate= :: Rotates system logs automatically · installed 2026-04-19 +- =qemu-full= :: A full QEMU setup · required by: libguestfs · installed 2026-07-13 +- =sudo= :: Give certain users the ability to run some commands as root · required by: base-devel devtools · installed 2026-07-08 +- =virt-manager= :: Desktop user interface for managing virtual machines · installed 2026-01-26 +- =virt-viewer= :: A lightweight interface for interacting with the graphical display of virtualized guest OS. · installed 2026-01-26 +- =yay= :: Yet another yogurt. Pacman wrapper and AUR helper written in go. · installed 2026-07-08 +- =zram-generator= :: Systemd unit generator for zram devices · installed 2026-06-08 + diff --git a/docs/design/system-monitor-design-ideas.org b/docs/design/system-monitor-design-ideas.org index 26619a0..b536604 100644 --- a/docs/design/system-monitor-design-ideas.org +++ b/docs/design/system-monitor-design-ideas.org @@ -395,7 +395,7 @@ Representation: Tools: - ZFS: =zfs list -t snapshot=, =zfs-pre-snapshot=, - =/etc/pacman.d/hooks/zfs-snapshot.hook=. + =/etc/pacman.d/hooks/05-zfs-snapshot.hook=. - Btrfs: =snapper list=, =snap-pac=, =grub-btrfs-mkconfig=, =/.snapshots=. - Common: =pacman -Q= for snapshot packages. diff --git a/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html b/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html deleted file mode 100644 index 8e642f4..0000000 --- a/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html +++ /dev/null @@ -1,338 +0,0 @@ -<!doctype html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title>Panel widget gallery — dupre instrument console</title> -<style> -:root{ - --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; - --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; - --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; - --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; - --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; -} -*{box-sizing:border-box;margin:0;padding:0} -html{background:var(--ground)} -body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; - background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} -.wrap{max-width:1320px;margin:0 auto} -.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} -h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} -.masthead p{color:var(--dim);font-size:.86rem;max-width:86ch} -.masthead p b{color:var(--silver)} -h2{color:var(--steel);font-size:.74rem;letter-spacing:.24em;text-transform:uppercase; - margin:2.2rem 0 .2rem;display:flex;align-items:center;gap:12px} -h2::after{content:"";height:1px;background:var(--wash);flex:1} - -.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:14px;margin-top:1rem} -.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px; - padding:13px 14px 12px;display:flex;flex-direction:column;gap:9px; - box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)} -.wname{color:var(--cream);font-size:.82rem;font-weight:700;display:flex;align-items:center;gap:8px} -.wname .no{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.6rem;padding:0 5px;font-weight:400} -.stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px; - min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap} -.wnote{color:var(--dim);font-size:.72rem;line-height:1.4} -.wnote b{color:var(--steel);font-weight:400} - -/* ---- 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(218,181,61,.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 .7s ease-in-out infinite} -@keyframes pulse{50%{opacity:.25}} - -.switch{width:40px;height:21px;border-radius:11px;background:var(--wash);border:1px solid var(--slate); - position:relative;cursor:pointer} -.switch::after{content:"";position:absolute;top:2px;left:2px;width:15px;height:15px;border-radius:50%; - background:var(--dim);transition:left .15s} -.switch.on{background:var(--slate);border-color:var(--gold)} -.switch.on::after{left:21px;background:var(--gold)} -.switch.red{background:rgba(203,107,77,.2);border-color:var(--fail)} -.switch.red::after{background:var(--fail);left:2px} - -.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; - background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; - 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,#f0d879,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} - -.chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px} -.chip.on{color:var(--gold);border-color:var(--gold)} - -.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} -.badge.red{background:var(--fail);color:var(--cream)} -.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} - -/* fader */ -.fader{width:150px;height:16px;position:relative;cursor:pointer} -.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,#8a7524,var(--gold))} -.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; - background:linear-gradient(180deg,#f0d879,#caa233);border:1px solid #7a6414;box-shadow:0 1px 2px rgba(0,0,0,.5)} -/* vertical fader */ -.vfader{width:16px;height:64px;position:relative;cursor:pointer} -.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,#8a7524,var(--gold))} -.vfader .cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px; - background:linear-gradient(90deg,#caa233,#f0d879);border:1px solid #7a6414} - -/* rotary knob */ -.knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:pointer; - 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,215,95,.6)} -.knob-scale{position:relative;width:64px;height:40px} -.knob-scale .arc{position:absolute;inset:0 0 -24px 0;border:1.5px solid var(--wash);border-top-color:var(--gold);border-radius:50%;opacity:.5} - -/* needle gauge */ -.gauge{width:96px} -.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 .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,215,95,.5); - transition:transform .5s 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} - -/* segmented VU / LED bar */ -.vu{width:170px;display:flex;flex-direction:column;gap:5px} -.vurow{display:flex;align-items:center;gap:7px} -.vurow .ch{color:var(--steel);font-size:.6rem;width:8px} -.vubar{flex:1;display:flex;gap:2px;height:9px} -.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} -.vubar i.on{opacity:1;background:var(--pass)}.vubar i.hot{opacity:1;background:var(--gold)} -.vubar i.clip{opacity:1;background:var(--fail)}.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px} - -/* mini 4-bar signal */ -.sig{display:flex;align-items:flex-end;gap:2px;height:18px} -.sig i{width:4px;background:var(--wash);border-radius:1px} -.sig i:nth-child(1){height:5px}.sig i:nth-child(2){height:9px}.sig i:nth-child(3){height:13px}.sig i:nth-child(4){height:17px} -.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)} - -/* signal ladder (wifi bars) */ -.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px} -.ladder i{width:5px;background:var(--wash);border-radius:1px} -.ladder i:nth-child(1){height:6px}.ladder i:nth-child(2){height:10px} -.ladder i:nth-child(3){height:14px}.ladder i:nth-child(4){height:18px} -.ladder.l3 i:nth-child(-n+3){background:var(--gold)} - -/* linear progress / fuel bar */ -.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} -.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px} -.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} - -/* radial ring */ -.ring{width:60px;height:60px;border-radius:50%; - background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0); - display:grid;place-items:center;position:relative} -.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} -.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums} - -/* tabular readout */ -.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em} -.readout small{color:var(--dim);font-size:12px;font-weight:400} -.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px} - -/* sparkline */ -.spark{width:170px;height:44px} -.spark svg{display:block;width:100%;height:100%} - -/* lamp row (list item) */ -.lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px} -.lrow:hover{background:var(--wash)} -.lrow .who{color:var(--silver)}.lrow .who b{color:var(--cream)} -.lrow .what{margin-left:auto;color:var(--dim);font-size:11px} - -/* arm-to-fire */ -.arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; - border-radius:8px;padding:7px 12px} -.arm.armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)} - -/* stepper / segmented selector */ -.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,#f0d879,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; - display:flex;align-items:center;gap:9px} -.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} -.engrave::before{max-width:10px} -.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} - -/* waveform strip */ -.wave{width:170px;height:38px} -.wave svg{width:100%;height:100%;display:block} - -/* toast */ -.toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px} - -/* output well (log step) */ -.owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px} -.ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0} -.ostep .lamp{margin-top:3px;width:7px;height:7px} -.ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px} - -@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} -</style> -</head> -<body> -<div class="wrap"> -<header class="masthead"> - <div class="eyebrow">archsetup · dupre panel family</div> - <h1>Widget gallery — the instrument-console kit</h1> - <p>Every control + display idiom we can build in the dupre faceplate language, all rendering from - the same tokens the net / bt / sound panels use. <b>Controls</b> take input; <b>meters & - gauges</b> show a live analog value; <b>indicators & readouts</b> show state or a number. - Live ones animate. Pick what fits each job — most cost pure CSS; the two that need a real - drawing surface (needle gauge, waveform) are flagged in their notes.</p> -</header> - -<h2>Controls — take input</h2> -<div class="grid" id="controls"></div> - -<h2>Meters & gauges — live analog value</h2> -<div class="grid" id="meters"></div> - -<h2>Indicators & readouts — state or number</h2> -<div class="grid" id="indicators"></div> - -</div> -<script> -const $ = id => document.getElementById(id); -const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; -function card(host, no, name, html, note){ - const c=document.createElement('div'); c.className='card'; - c.innerHTML=`<div class="wname"><span class="no">${no}</span>${name}</div>`+ - `<div class="stagew">${html}</div><div class="wnote">${note}</div>`; - host.appendChild(c); return c; -} -function buildBars(el,n){el.innerHTML='';for(let k=0;k<n;k++)el.appendChild(document.createElement('i'));} - -/* ============ CONTROLS ============ */ -const C=$('controls'); -card(C,'01','Toggle switch', - `<span class="switch on" onclick="this.classList.toggle('on')"></span> - <span class="switch red"></span>`, - '<b>on / off / muted.</b> The faceplate master control — wifi radio, bt power, master-mute. Click to flip.'); -card(C,'02','Console key', - `<button class="key on">LIVE</button><button class="key">SCAN</button><button class="key red">MUTED</button>`, - '<b>physical push button.</b> DOCTOR / SPEED TEST / mic mode. Gold = engaged, terracotta = off.'); -card(C,'03','Horizontal fader', - `<div class="fader" id="f1"><div class="slot"><div class="fill" style="width:68%"></div></div><div class="cap" style="left:68%"></div></div>`, - '<b>continuous 0-100.</b> Per-device volume, brightness, kbd backlight. Drag; the gold cap tracks.'); -card(C,'04','Vertical fader', - `<div class="vfader"><div class="slot"><div class="fill" style="height:60%"></div></div><div class="cap" style="bottom:60%"></div></div> - <div class="vfader"><div class="slot"><div class="fill" style="height:35%"></div></div><div class="cap" style="bottom:35%"></div></div>`, - '<b>channel-strip style.</b> A mixer column per device if you want the classic board look.'); -card(C,'05','Rotary knob', - `<span class="knob" id="knob" onclick="bumpKnob()"><span class="ind" id="kind"></span></span>`, - '<b>dial in a value.</b> Volume/gain the analog way. Click to turn; drag in the real build. Pairs with a scale arc.'); -card(C,'06','Segmented selector', - `<div class="seg"><button class="on">TIMER</button><button>ALARM</button><button>POMO</button></div>`, - '<b>pick one of a few.</b> Timer type, layout mode, theme. One press-lit segment.'); -card(C,'07','Chip toggle', - `<span class="chip on" onclick="this.classList.toggle('on')">discoverable on</span>`, - '<b>inline binary.</b> A soft toggle inside a line of text — discoverable, auto-dim, DND. Gold when on.'); -card(C,'08','Arm-to-fire', - `<button class="arm" id="arm" onclick="armFire()">forget</button>`, - '<b>two-stage confirm.</b> Destructive/disruptive actions — forget network, disconnect. First click arms (red), second fires.'); -card(C,'09','Lamp row', - `<div class="lrow"><span class="lamp gold"></span><span class="who"><b>WH-1000XM4</b></span><span class="what">tap to connect</span></div>`, - '<b>actionable list item.</b> The net/bt/sound row: lamp + name + status, click acts. The workhorse.'); - -/* ============ METERS & GAUGES ============ */ -const M=$('meters'); -card(M,'10','Needle gauge', - `<div class="gauge"><div class="dial"><div class="arc"></div> - <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div> - <div class="ndl" id="g1"></div><div class="hub"></div></div><div class="gv"><span id="g1v">0</span>%</div></div>`, - '<b>analog dial.</b> Throughput, battery, volume level. <b>Needs a Cairo/GTK drawing area</b> — CSS can fake a fixed angle but not a smooth sweep in waybar.'); -card(M,'11','Stereo VU (LED bar)', - `<div class="vu"><div class="vurow"><span class="ch">L</span><span class="vubar" id="vuL"></span></div> - <div class="vurow"><span class="ch">R</span><span class="vubar" id="vuR"></span></div></div>`, - '<b>live signal level.</b> The sound panel\'s second meter row. Peak-hold outline. Pure CSS — pango/box segments.'); -card(M,'12','Mini signal (4-bar)', - `<span class="sig" id="mini"></span>`, - '<b>compact activity.</b> Per-row "is this device playing" indicator. Cheap enough to sit in every list row.'); -card(M,'13','Signal ladder', - `<span class="ladder l3"><i></i><i></i><i></i><i></i></span>`, - '<b>discrete strength.</b> Wifi bars, bt RSSI — a stepped 0-4. Already in the net panel.'); -card(M,'14','Linear fuel bar', - `<div class="bar"><span style="width:72%"></span></div><div class="bar warn"><span style="width:12%"></span></div>`, - '<b>a single 0-100.</b> Battery, disk, download progress. Warn tint under threshold. Trivial in CSS.'); -card(M,'15','Radial ring', - `<span class="ring" style="--p:68"><b>68</b></span>`, - '<b>percentage as a donut.</b> CPU, battery, a single meter where a needle is overkill. conic-gradient, pure CSS.'); -card(M,'16','Sparkline', - `<span class="spark" id="spark"><svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline id="sparkp" fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg></span>`, - '<b>recent history.</b> Throughput/CPU over the last minute. SVG here; a drawing area in GTK.'); -card(M,'17','Waveform strip', - `<span class="wave" id="wave"><svg viewBox="0 0 170 38" preserveAspectRatio="none"><path id="wavep" fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg></span>`, - '<b>audio waveform / scope.</b> A richer signal view for the sound panel. <b>Needs a drawing surface.</b>'); - -/* ============ INDICATORS & READOUTS ============ */ -const I=$('indicators'); -card(I,'18','Status lamp', - `<span class="lamp"></span><span class="lamp gold"></span><span class="lamp red"></span><span class="lamp off"></span><span class="lamp busy"></span>`, - '<b>one-glance health.</b> Green ok · gold engaged · red fail · dim off · pulsing busy. The family signature.'); -card(I,'19','Badge / tag', - `<span class="badge">TUNNEL</span> <span class="badge red">LOW BATT</span> <span class="badge ghost">2.4G</span>`, - '<b>a labelled flag.</b> On the faceplate or a row — MUTED, AIRPLANE, DEF, a band tag.'); -card(I,'20','Tabular readout', - `<div style="text-align:center"><div class="readout">24:10</div><span class="u">timer</span></div> - <div style="text-align:center"><div class="readout">68<small>%</small></div></div>`, - '<b>a precise number.</b> Clock, countdown, volume %. BerkeleyMono tabular-nums so digits don\'t jitter.'); -card(I,'21','Engraved label', - `<span class="engrave">outputs<span class="cnt">· 3</span></span>`, - '<b>section divider.</b> The hairline-flanked caps label with a count. Groups a panel into readable blocks.'); -card(I,'22','Output well', - `<div class="owell"><div class="ostep"><span class="lamp"></span><span><b>Link</b><span class="ev">wlp170s0 · @Hyatt</span></span></div> - <div class="ostep"><span class="lamp gold"></span><span><b>DNS</b><span class="ev">resolving…</span></span></div></div>`, - '<b>streaming step log.</b> The doctor/scan output — lamp-per-step with evidence. For any run-and-report action.'); -card(I,'23','Toast / status line', - `<span class="toastw">joined @Hyatt_WiFi — saved</span>`, - '<b>transient confirmation.</b> The one-line result after an action. Auto-dismiss; red variant for errors.'); - -/* ---- live animation ---- */ -let ph=0, kang=140; -function bumpKnob(){ kang=(kang+35)%300-0; $('kind').style.transform=`rotate(${kang-150}deg)`; } -function armFire(){ const a=$('arm'); if(a.classList.contains('armed')){a.classList.remove('armed');a.textContent='forget';} - else{a.classList.add('armed');a.textContent='forget? again';} } -buildBars($('vuL'),16); buildBars($('vuR'),16); buildBars($('mini'),0); -$('mini').innerHTML='<i></i><i></i><i></i><i></i>'; -$('kind').style.transform=`rotate(${kang-150}deg)`; -const hist=Array.from({length:40},()=>0.5); -function paintVU(el,l,pk){const b=el.children,n=b.length,lit=Math.round(l*n); - pk.v=Math.max(lit,(pk.v||0)-0.4);const p=Math.round(pk.v); - for(let k=0;k<n;k++){let c=k<lit?(k>=n-2?'clip':k>=n-4?'hot':'on'):'';if(p>0&&k===p-1)c=(c?c+' ':'')+'peak';b[k].className=c;}} -const pkL={v:0},pkR={v:0}; -function paintMini(el,l){const b=el.children,lit=Math.round(l*4);for(let k=0;k<4;k++)b[k].className=k<lit?(k>=3?'clip':k>=2?'hot':'on'):'';} -function lvl(){return Math.max(0,Math.min(1,0.5+0.4*Math.sin(ph*1.3)+ (Math.random()<0.15?Math.random()*0.4:0) - Math.random()*0.08));} -function tick(){ - ph+=0.09; - const a=lvl(), b=lvl(); - paintVU($('vuL'),a,pkL); paintVU($('vuR'),b,pkR); paintMini($('mini'),a); - // needle sweeps 0..100 - const gv=Math.round(50+45*Math.sin(ph*0.7)); - $('g1').style.transform=`rotate(${-60+gv/100*120}deg)`; $('g1v').textContent=gv; - // sparkline - hist.push(0.5+0.42*Math.sin(ph*0.9)+ (Math.random()-0.5)*0.25); hist.shift(); - $('sparkp').setAttribute('points',hist.map((v,i)=>`${i/(hist.length-1)*170},${44-Math.max(0,Math.min(1,v))*40-2}`).join(' ')); - // waveform - let d='M0 19'; for(let x=0;x<=170;x+=3){const y=19+Math.sin(x*0.18+ph*3)*Math.sin(x*0.05)*14; d+=` L${x} ${y.toFixed(1)}`;} - $('wavep').setAttribute('d',d); -} -if(!reduced) setInterval(tick,80); else tick(); -</script> -</body> -</html> diff --git a/docs/prototypes/README.org b/docs/prototypes/README.org index 9df85cb..a4f591f 100644 --- a/docs/prototypes/README.org +++ b/docs/prototypes/README.org @@ -15,7 +15,123 @@ point at. affordance for the NETWORKS/NEARBY headers (busy-style throbber + list fade). - [[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:2026-07-03-panel-widget-gallery-prototype.html][2026-07-03-panel-widget-gallery-prototype.html]] — the shared instrument-console - widget kit (lamps, engraved sections, console keys, needle gauges). +- [[file:panel-widget-gallery.html][panel-widget-gallery.html]] — The Dupre Kit: the shared instrument-console + gallery (lamps, engraved sections, console keys, needle gauges). The + living catalogue: every instrument here is the visual + behavioral spec for + its reusable-component ports. All instruments build from [[file:widgets.js][widgets.js]] (below); + the gallery page is the reference consumer. - [[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). + +* The Dupre Kit ([[file:widgets.js][widgets.js]]) + +The whole kit lives in =widgets.js= — a classic script (no modules, no build +step) exposing the =DUPRE= namespace with one builder per gallery card. Load it +with =<script src="widgets.js"></script>= and call builders directly. + +** Builder contract + +=DUPRE.name(host, opts)= → handle ={el, get, set, ...}=. =host= is an empty +element the instrument renders into. =opts.onChange(value, text)= fires on every +state change, including the initial paint; =text= is the instrument's canonical +readout string. (Exception: live meters whose original init was silent — they +paint on the first =set=/=push=.) + +** Styling + +Instrument-internal CSS ships inside =widgets.js= (the =DUPRE_CSS= block, injected +once as =<style id="dupre-css">= at load), so instruments are fully styled on any +page. The design tokens (=--gold=, =--glow-hi=, the amber family, =--mono=, +=--pulse-rate=, …) must be defined on =:root= by the consumer — the gallery's +generated =:root= block is the reference; =tokens.json= is the source. + +** Tick contract (live meters) + +The page owns the clock and the signal source; builders own rendering state. +Live meters expose value-driven handles that repaint synchronously and fire +=onChange=: + +- =set(level)= for level meters (peak-hold logic lives in the builder) +- =push(v)= / =set(samples, current)= for history meters (the builder owns + the ring buffer) +- =set(samples, amp)= for sampled traces (samples normalized) + +Instrument-owned animation (the R17 scope trace, R31 radar sweep, R44 servo +chase, R52 pseudo-PC, R53 day clock, R10 TIME page) runs inside the builder, +gated on =prefers-reduced-motion=. + +** Keyboard contract (instruments that take keys) + +Sibling of the tick contract above, and the same split: focus is an ambient +resource like the clock — exactly one thing holds it across the whole page — +so *the target owns focus and delivery, the builder declares what it accepts*. +Neither half works alone. Only the page knows which instrument is live; only the +instrument knows that Backspace means DEL. + +*The builder declares* a =KEYS= table on itself, mapping a canonical key name +to the argument its handle's =press= takes: + +: DUPRE.abcKeypad.KEYS = { A:'A', ..., '0':'0', ..., Space:'SPC', Backspace:'DEL', Enter:'ENT' } + +One declaration, wired natively per target: + +- *Web* — the builder attaches a =keydown= listener *to its own focusable + element* (=tabindex=0=, focused on click). =DUPRE.slideRule= is the precedent: + arrows step it, scoped to its own element. The browser's focus system does + the arbitration, which is why it doesn't fight the gallery's own global + Escape handler. +- *Emacs* — the SVG region is an image and never sees a keypress, so the mode + installs the same table into its keymap and calls =press=. This is why the + declaration is a table and not a function over a DOM event: a port that + can't read the instrument's intent has to re-derive it, and then the two drift. +- *waybar* — GTK focus, same shape. + +Rules, each of them a bug someone will otherwise ship: + +- *Never listen on =document= or =window=.* An instrument that does fights every + sibling and the page's own handlers; on the gallery, every keystroke + anywhere would type into whichever card bound last. +- *=preventDefault= only what has a default worth suppressing* — Space scrolls + the page, Backspace can navigate back. Nothing else. +- *Let Tab and Escape bubble.* Tab is how the page is navigable at all, and + the gallery's audit stepper owns Escape. An instrument that swallows either + breaks something it can't see. +- *=press= filters, it does not trust.* A handle that appends whatever it is + handed will cheerfully append "F1". The table is the allowlist. +- *Click and key must land in the same place* — both route through =press=, so + the two paths cannot drift apart or fire different events. + +An instrument with no =KEYS= table takes no keys, which is most of them: the kit is +click-first and that is what makes it port. Keys are additive, and in Emacs +they are the *more* native idiom, not the less. + +** Style options + +Instruments with named style axes take them as constructor opts backed by a +=STYLES= table on the builder — e.g. =DUPRE.slideToggle(host, {onStyle:'green', +thumb:'brass'})= with the axes enumerated in =DUPRE.slideToggle.STYLES=, and +=handle.setStyle(axis, name)= to restyle a live instance. The gallery's +option chips are demo rigs driving =setStyle=. + +** Verification + +=tests/gallery-probes/= drives the full kit headlessly over CDP: +=probe.mjs= (every card renders + responds, size toggle, drags at zoom) and +=probe-fams.mjs= (screen-family retinting). Run with =node= against the +gallery file URL; both must pass before a gallery or widgets.js change lands. + +* 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:gallery-tokens.el][gallery-tokens.el]] — an elisp alist for svg.el renderers + +[[file:gallery-widget.el][gallery-widget.el]] is the Emacs renderer (proof instrument: the needle gauge, +gallery card 10) — it reads gallery-tokens.el and emits the instrument 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=. diff --git a/docs/prototypes/gallery-tokens.el b/docs/prototypes/gallery-tokens.el new file mode 100644 index 0000000..1fb6e52 --- /dev/null +++ b/docs/prototypes/gallery-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/gallery-widget.el b/docs/prototypes/gallery-widget.el new file mode 100644 index 0000000..5d69148 --- /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 gallery-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 "gallery-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 . "dupre-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(#dupre-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/gen_tokens.py b/docs/prototypes/gen_tokens.py new file mode 100644 index 0000000..9f011e9 --- /dev/null +++ b/docs/prototypes/gen_tokens.py @@ -0,0 +1,184 @@ +#!/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 gallery-tokens.el beside it. +""" + +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOKENS_START = "/* @tokens:start */" +TOKENS_END = "/* @tokens:end */" + +# The elisp target's filename must match its (provide 'gallery-tokens) so +# `require` resolves it from a load-path. +DEFAULT_ELISP_NAME = "gallery-tokens.el" + +# 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, DEFAULT_ELISP_NAME) + + 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 new file mode 100644 index 0000000..9ff492d --- /dev/null +++ b/docs/prototypes/panel-widget-gallery.html @@ -0,0 +1,1854 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>The Dupre Kit — retro instrument console (interactive)</title> +<style> +:root{ +/* @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; + --pulse-rate:1s; +/* @tokens:end */ +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground);font-size:130%} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1320px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:86ch} +.masthead p b{color:var(--silver)} +.szbar{display:flex;align-items:center;gap:8px;margin-top:.8rem} +.szbar .lab{color:var(--steel);font-size:.68rem;letter-spacing:.22em;text-transform:uppercase;margin-right:4px} + +/* masthead row: description left, index panel right */ +.mastrow{display:flex;gap:20px;align-items:flex-start;justify-content:flex-end;flex-wrap:wrap} +.mastintro{flex:1 1 340px} +#polpanel{max-width:250px} +#polpanel .pnote{color:var(--dim);font-size:.72rem;line-height:1.5;margin:2px 0 8px} +.toc{position:relative;flex:0 0 auto;min-width:220px;padding:14px 20px 13px;border-radius:12px; + background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.4)} +.toc .tt{color:var(--steel);font-size:.66rem;letter-spacing:.26em;text-transform:uppercase; + margin-bottom:7px;display:flex;align-items:center;gap:10px} +.toc .tt::after{content:"";height:1px;background:var(--wash);flex:1;min-width:24px} +.toc a{display:block;color:var(--dim);text-decoration:none;font-size:.8rem;letter-spacing:.04em;padding:3.5px 0} +.toc a::before{content:"▸ ";color:var(--steel);font-size:.72em} +.toc a:hover{color:var(--gold-hi)} +.toc .scr{position:absolute;width:5px;height:5px;border-radius:50%; + background:radial-gradient(circle at 35% 30%,#524d43,#161310);box-shadow:0 .5px 0 rgba(255,255,255,.08)} +html{scroll-behavior:smooth} +/* INSTRUMENTS/ROW sets an explicit column count (1-4); the stage zoom scales with it + so an instrument fills its column — fewer per row, bigger instruments. The card text + stays at its own (slightly raised) size, untouched by the zoom. */ +body[data-cols="1"] .grid{grid-template-columns:1fr} +body[data-cols="2"] .grid{grid-template-columns:repeat(2,1fr)} +body[data-cols="3"] .grid{grid-template-columns:repeat(3,1fr)} +body[data-cols="4"] .grid{grid-template-columns:repeat(4,1fr)} +body[data-cols="1"] .stagew{zoom:2.8} +body[data-cols="2"] .stagew{zoom:2.1} +body[data-cols="3"] .stagew{zoom:1.55} +body[data-cols="4"] .stagew{zoom:1.15} +/* intrinsically tiny instruments get an extra bump so they do not float in an empty stage */ +.stagew.boost{zoom:1.5} +body[data-cols="1"] .stagew.boost{zoom:4.2} +body[data-cols="2"] .stagew.boost{zoom:3.15} +body[data-cols="3"] .stagew.boost{zoom:2.3} +body[data-cols="4"] .stagew.boost{zoom:1.7} +/* a single column shouldn't span the whole monitor; 2-4 keep the standard 1320 wrap */ +body[data-cols="1"] .wrap{max-width:960px} + +.secnote{color:var(--dim);font-size:.74rem;margin-top:.5rem;max-width:86ch} +.palgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(176px,1fr));gap:10px;margin-top:1rem} +.swatch{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:9px; + padding:9px;display:flex;flex-direction:column;gap:6px} +.swatch .sw{height:44px;border-radius:6px;border:1px solid rgba(255,255,255,.08)} +.swatch .sw.txt{display:flex;align-items:center;justify-content:center;color:var(--steel);font-size:.62rem; + background:var(--well);padding:0 6px;text-align:center;overflow:hidden} +.swatch .swn{color:var(--cream);font-size:.8rem} +.swatch .swv{color:var(--dim);font-size:.72rem;line-height:1.35} +.palhead{grid-column:1/-1;color:var(--steel);font-size:.72rem;letter-spacing:.22em;text-transform:uppercase; + margin-top:.8rem;display:flex;align-items:center;gap:12px} +.palhead::after{content:"";height:1px;background:var(--wash);flex:1} +h2{color:var(--steel);font-size:.74rem;letter-spacing:.24em;text-transform:uppercase; + margin:2.2rem 0 .2rem;display:flex;align-items:center;gap:12px} +h2::after{content:"";height:1px;background:var(--wash);flex:1} + +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:14px;margin-top:1rem} +.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px; + padding:13px 14px 12px;display:flex;flex-direction:column;gap:9px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)} +.wname{color:var(--cream);font-size:.95rem;font-weight:700;display:flex;align-items:center;gap:8px} +.wname .no{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.7rem;padding:0 5px;font-weight:400} +/* validation lamp: off = not done, amber = in progress, green = done; click cycles, state persists */ +/* colour-policy badge: green = free (consumer picks), amber = locked (a stated + reason it can't change), dim = not yet classified. The whole point is scanning + the page and seeing at a glance what the colour pass has and hasn't reached. */ +.cpol{margin-left:auto;flex:0 0 auto;font-size:.56rem;letter-spacing:.06em;text-transform:uppercase; + padding:1px 5px;border-radius:3px;border:1px solid var(--wash);color:var(--steel);cursor:default;font-weight:400} +.cpol[data-free="true"]{color:var(--pass);border-color:rgba(116,147,47,.5)} +.cpol[data-free="false"]{color:var(--gold-hi);border-color:rgba(255,190,84,.4)} +.cpol[data-pol="none"]{opacity:.45} +.vlamp{flex:0 0 auto;width:10px;height:10px;border-radius:50%;cursor:pointer; + background:#221f1b;border:1px solid #33302b} +.vlamp[data-v="amber"],.vtally .vdot[data-v="amber"],#vaudit .vdot[data-v="amber"]{background:var(--gold);border-color:#7d5c16;box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} +.vlamp[data-v="green"],.vtally .vdot[data-v="green"],#vaudit .vdot[data-v="green"]{background:var(--pass);border-color:#4a5c22;box-shadow:0 0 6px 1px rgba(116,147,47,.6)} +/* audit stepper pill: docks bottom-right while stepping through one state's cards */ +#vaudit{position:fixed;right:18px;bottom:16px;z-index:60;display:flex;align-items:center;gap:7px; + padding:8px 13px;border-radius:20px;background:var(--raise);border:1px solid var(--wash); + color:var(--cream);font-size:.78rem;letter-spacing:.05em;cursor:pointer;user-select:none; + box-shadow:0 4px 14px rgba(0,0,0,.5)} +#vaudit .vdot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:#221f1b;border:1px solid #33302b} +#vaudit .vx{margin-left:6px;color:var(--steel);padding:0 2px} +#vaudit .vx:hover{color:var(--fail)} +/* validation tally in the index: .vdot mirrors the lamp look but must NOT match + .vlamp — the counter counts .vlamp nodes */ +.vtally{margin-top:2px} +.vtally .vrow{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:.75rem; + letter-spacing:.04em;padding:2.5px 0} +.vtally .vrow[data-v]{cursor:pointer} +.vtally .vrow[data-v]:hover{color:var(--gold-hi)} +.vtally .vdot{flex:0 0 auto;width:8px;height:8px;border-radius:50%; + background:#221f1b;border:1px solid #33302b} +.vtally .vn{margin-left:auto;color:var(--cream);font-variant-numeric:tabular-nums} +.vtally .vtot{border-top:1px solid var(--wash);margin-top:3px;padding-top:4.5px;color:var(--steel)} +.vtally .vcopy{margin-top:5px;width:100%;font:inherit;font-size:.7rem;letter-spacing:.04em; + background:transparent;color:var(--steel);border:1px solid var(--wash);border-radius:3px; + padding:2.5px 0;cursor:pointer} +.vtally .vcopy:hover{color:var(--gold-hi);border-color:var(--gold)} +.stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px; + min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap} +.wrd{color:var(--gold-hi);font-size:.8rem;letter-spacing:.06em;font-variant-numeric:tabular-nums; + min-height:.95rem;display:flex;align-items:center;gap:6px; + border-top:1px solid var(--wash);padding-top:9px;margin-top:2px} +.wrd::before{content:"▸";color:var(--steel);font-size:.7rem} +.opts{display:flex;flex-direction:column;gap:5px} +.opts:empty{display:none} +.wnote{color:var(--dim);font-size:.85rem;line-height:1.45; + border-top:1px solid var(--wash);padding-top:9px;margin-top:2px} +.wnote b{color:var(--steel);font-weight:400} + +/* per-card spec sheet (collapsible) */ +.winfo{border-top:1px dashed var(--wash);padding-top:6px;margin-top:2px} +.winfo summary{color:var(--steel);font-size:.74rem;letter-spacing:.18em;text-transform:uppercase; + cursor:pointer;list-style:none;user-select:none} +.winfo summary::-webkit-details-marker{display:none} +.winfo summary::before{content:"▸ "} +.winfo[open] summary::before{content:"▾ "} +.igrid{display:grid;grid-template-columns:max-content 1fr;gap:4px 10px;margin-top:7px} +.igrid .ik{color:var(--steel);font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;padding-top:1px} +.igrid .iv{color:var(--dim);font-size:.82rem;line-height:1.45} +.igrid .iv b{color:var(--silver);font-weight:400} +.igrid .iv a{color:var(--gold);text-decoration:none;border-bottom:1px dotted var(--gold)} +.igrid .iv a:hover{color:var(--gold-hi);border-color:var(--gold-hi)} +.card a.xref{color:var(--gold);text-decoration:none;border-bottom:1px dotted var(--gold)} +.card a.xref:hover{color:var(--gold-hi);border-color:var(--gold-hi)} +.card:target{border-color:var(--gold);box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 0 0 1px var(--gold),0 6px 14px rgba(0,0,0,.4)} + +/* option chips: each group is an encircled capsule (label + choices); groups flow with spacing */ +.famchips{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:1px} +.fgroup{display:inline-flex;align-items:center;gap:6px;padding:3px 9px 3px 8px; + border:1px solid #2e2b26;border-radius:10px;background:rgba(255,255,255,.015)} +.famchips .lab{color:var(--steel);font-size:.6rem;letter-spacing:.14em;text-transform:uppercase;margin-right:2px} +.famchips .fc{width:13px;height:13px;border-radius:50%;cursor:pointer; + border:1px solid rgba(255,255,255,.22);box-shadow:0 1px 3px rgba(0,0,0,.5)} +.famchips .fc.on{outline:2px solid var(--silver);outline-offset:1px} + +/* N20's chips are miniature flap cells: the board colour comes from the rig's + inline dot; the letter shows the actual ink (skins) or the actual face (fonts) */ +a.no{text-decoration:none;cursor:pointer} +#card-N20 .wrd{display:none} +#card-N20 .famchips .fc{width:17px;height:20px;border-radius:4px;position:relative;border:1px solid #4a453c} +#card-N20 .famchips .fc::before{content:"";position:absolute;left:1px;right:1px;top:50%;height:1px;background:rgba(128,128,128,.35)} +#card-N20 .famchips .fc::after{position:absolute;inset:0;display:grid;place-items:center;font-size:12px;font-weight:700;line-height:1;content:"A"} +#card-N20 .fc[title="dark"]::after{color:#f3e7c5} +#card-N20 .fc[title="white"]::after{color:#ffffff} +#card-N20 .fc[title="light"]::after{color:#1b1813} +#card-N20 .fc[title="paper"]::after{color:#17181a} +#card-N20 .fc[title="mono"]::after{color:#bfc4d0;font-family:var(--mono)} +#card-N20 .fc[title="helv"]::after{color:#bfc4d0;font-family:Helvetica,'Helvetica Neue',Arial,sans-serif} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body data-cols="3"> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · live kit</div> + <h1>The Dupre Kit — the instrument console, fully driveable</h1> + <div class="mastrow"> + <div class="mastintro"> + <p>Every control + display idiom in the dupre faceplate language, merged into one live console — the base kit, + the candidate batch, and the growing reference batch (R: modeled on period hardware), all rendering from + the same tokens the net / bt / sound panels use. + <b>Controls</b> take input; <b>meters & gauges</b> show a live analog value; <b>indicators & + readouts</b> show state or a number. Every instrument is operable: drag the faders, knobs and gauges; click the + switches, keys and lamps. Each card carries a live readout that tracks what you do. The trace displays run + a live signal; the pointer types drive from your drag.</p> + <div class="szbar"><span class="lab">instruments/row</span> + <button class="dupre-key" data-cols="1">1</button> + <button class="dupre-key" data-cols="2">2</button> + <button class="dupre-key dupre-on" data-cols="3">3</button> + <button class="dupre-key" data-cols="4">4</button> + </div> + </div> + <nav class="toc"> + <span class="scr" style="top:6px;left:6px"></span><span class="scr" style="top:6px;right:6px"></span> + <span class="scr" style="bottom:6px;left:6px"></span><span class="scr" style="bottom:6px;right:6px"></span> + <div class="tt">Index</div> + <a href="#sec-controls">Controls</a> + <a href="#sec-meters">Meters & gauges</a> + <a href="#sec-indicators">Indicators & readouts</a> + <a href="#sec-palette">Palette</a> + <div class="tt" style="margin-top:11px">Validation</div> + <div class="vtally" id="vtally"></div> + </nav> + <nav class="toc" id="polpanel"> + <span class="scr" style="top:6px;left:6px"></span><span class="scr" style="top:6px;right:6px"></span> + <span class="scr" style="bottom:6px;left:6px"></span><span class="scr" style="bottom:6px;right:6px"></span> + <div class="tt">Policy</div> + <div class="pnote">What each instrument's colour is bound by, and what it may change and stay true. Click a row to walk its cards; open a card's spec sheet for the reason and the authentic range.</div> + <div class="vtally ptally" id="ptally"></div> + </nav> + </div> +</header> + +<h2 id="sec-controls">Controls — take input</h2> +<div class="grid" id="controls"></div> + +<h2 id="sec-meters">Meters & gauges — live analog value</h2> +<div class="grid" id="meters"></div> + +<h2 id="sec-indicators">Indicators & readouts — state or number</h2> +<div class="grid" id="indicators"></div> + +<h2 id="sec-palette">Palette — the instrument colors</h2> +<p class="secnote">Every named color the instruments are built from — markers, hands, faces, lamps, phosphors, + materials — with where it appears. Page chrome (background, body text) is deliberately absent.</p> +<div class="palgrid" id="palette"></div> + +</div> +<script src="widgets.js"></script> +<script> +const $ = id => document.getElementById(id); +const {svgEl,polar,dragX,dragY,dragDelta,seg7,buildBars,VUDB,vuDb,SCREEN_FAMS}=DUPRE; +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +document.querySelectorAll('.szbar .dupre-key').forEach(b=>b.addEventListener('click',()=>{ + document.body.dataset.cols=b.dataset.cols; + localStorage.setItem('gv-cols',b.dataset.cols); + document.querySelectorAll('.szbar .dupre-key').forEach(k=>k.classList.toggle('dupre-on',k===b)); +})); +(function(){const s=localStorage.getItem('gv-cols'); + if(!s||!/^[1234]$/.test(s))return; + document.body.dataset.cols=s; + document.querySelectorAll('.szbar .dupre-key').forEach(k=>k.classList.toggle('dupre-on',k.dataset.cols===s));})(); +/* boost the intrinsically tiny instruments (audit: content under 10% of the stage) */ +const BOOST=['01','07','08','12','13','N14','18']; + +/* ---- palette: the named instrument colors, grouped by role. No codes shown — names and uses. ---- */ +const PALETTE=[ + ['Materials',[ + ['Brass','#b8944a','comfort-meter bezel and knurl'], + ['Copper','#ad7a43','knife-switch blades, jaws and hinge'], + ['Chrome silver','var(--silver)','needles, ball lever, fader caps, chrome handles'], + ['Machined steel','var(--steel)','engraved captions, scale furniture, printed panel lines'], + ['Aluminum','#c9c4b8','timer hub, lever shafts, counter bezels'], + ['Rubber & bakelite','#2c2824','knob bodies, rocker pad, stomp switch'], + ]], + ['Faces & inks',[ + ['Cream dial','var(--cream)','tick marks, scale numerals, dial faces, flag windows'], + ['Chart paper','#efe9da','day-date disc, circular chart, comfort face, telegraph dial'], + ['Face ink','#14110e','numerals and printing on the paper faces'], + ['CRT face green','#b9d8c0','round-scope face tint'], + ['Sky slate','var(--slate-hi)','attitude-indicator sky'], + ['Earth brown','#5c4630','attitude-indicator ground'], + ['Instrument black','#17140f','bezels, plates and housings'], + ]], + ['Lamps, LEDs & jewels',[ + ['Lit amber','var(--gold-hi)','lit keys, sweep line, fixed indexes, blinkenlight lamps'], + ['Panel amber','var(--gold)','key gradients, bearing rings, seated pins, heading bug'], + ['Warning amber','var(--amber-warn)','warn cells, tripped collars, gear-transit lamps'], + ['Run green','var(--pass)','run lamps, gear greens, monitor bars, LIVE lamps'], + ['Fail terracotta','var(--fail)','muted keys, fault cells, TRIP button'], + ['LED green','var(--sevgrn)','seven-segment digits, battery cells'], + ['LED red','var(--sevred)','red seven-segment variant'], + ['Matrix red','#ff4a30','dot-matrix LEDs'], + ['Jewel red','var(--jewel-r)','pilot-lamp lens'], + ['Jewel amber','var(--jewel-a)','pilot-lamp lens, four-way corner lamps'], + ['Jewel green','var(--jewel-g)','pilot-lamp lens'], + ['Neon orange','#ff9a4c','nixie digits, dekatron glow, red screen family'], + ['Flip-disc yellow','#e3d44f','flip-dot faces'], + ['PDP magenta','#3a1420','blinkenlights panel skin'], + ]], + ['Screens & phosphors',[ + ['Phosphor green','var(--phos)','oscilloscope traces, green screen family'], + ['Phosphor dim','var(--phos-dim)','phosphor afterglow and dark screen grounds'], + ['VFD cyan','var(--vfd)','marquee, filter-bank arrows, vfd screen family'], + ['Graticule green','#3d5c46','CRT graticule rulings'], + ['LCD white','#f2f4f2','waveform-editor ink, white screen family'], + ['P4 blue-white','#cfe4ff','blue screen family'], + ]], + ['Needles & controls',[ + ['Needle red','#c23a28','comfort-meter needles, red hands'], + ['Control red','#e0523a','stop knobs, telegraph pointer, palm buttons, OFF marks'], + ['Pointer yellow','#e8cf4a','day-date read hand'], + ]], +]; +(function buildPalette(){ + const host=$('palette'); if(!host)return; + for(const [group,colors] of PALETTE){ + const h=document.createElement('div'); h.className='palhead'; h.textContent=group; + host.appendChild(h); + for(const [name,val,used] of colors){ + const tile=document.createElement('div'); tile.className='swatch'; + tile.innerHTML=`<div class="sw" style="background:${val}"></div>`+ + `<div class="swn">${name}</div><div class="swv">${used}</div>`; + host.appendChild(tile);}} +})(); + +/* ---- screen-color families: period-authentic alternates for the screen instruments ---- + green = P1 CRT phosphor (radar/scopes; hi reuses --phos, graticule keeps the kit's scope green) + amber = P3 phosphor / backlit amber (the gold family the kit already speaks) + red = neon orange-red, the nixie's color pulled out as a family + blue = P4 blue-white (early TV / precision scopes) + vfd = blue-green vacuum-fluorescent (the marquee's --vfd pulled out as a family) + white = monochrome LCD/CRT white + Exploration data, deliberately NOT in tokens.json yet — families get tokenized once picked, + the same way amber earned its tokens. Values keyed by the CSS custom property they drive. */ +/* SCREEN_FAMS lives in widgets.js (DUPRE.SCREEN_FAMS) */ +function screenChips(no,el,fams,def){ + const cardEl=el.closest('.card'); if(!cardEl)return; + const row=document.createElement('div'); row.className='famchips'; + const g=document.createElement('span'); g.className='fgroup'; row.appendChild(g); + const lab=document.createElement('span'); lab.className='lab'; lab.textContent='screen'; g.appendChild(lab); + fams.forEach(f=>{const b=document.createElement('span'); + b.className='fc'+(f===def?' on':''); b.style.background=SCREEN_FAMS[f]['--scr-hi']; b.title=f; + b.addEventListener('click',()=>{ + for(const [k,v] of Object.entries(SCREEN_FAMS[f])) el.style.setProperty(k,v); + g.querySelectorAll('.fc').forEach(x=>x.classList.toggle('on',x===b)); + setRd(no,'screen '+f);}); + g.appendChild(b);}); + cardEl.querySelector('.opts').appendChild(row); +} +/* ---- per-card spec sheets ---- + input how you drive it, incl. the model: click instruments port to every target (Emacs click-regions + included); drag instruments have no Emacs drag equivalent and need a click/key idiom there; + display instruments take no input in real use (gallery interaction is demo only). + period present only when the component is clearly not timeless — use it to keep a console + period-consistent (swap a modern part for its historical counterpart or vice versa). */ +const INFO={ +'01':{input:'Click to flip. Click-only, ports everywhere incl. Emacs.', + solves:'One persistent on/off state, visible at a glance without reading text.', + use:'Common in every modern UI. Shines as a master control: power, mute, enable.', + limits:'Two states only. Ambiguous if the on-direction is not marked.', + origin:'Slide switches (transistor radios, Walkman hold) → the iPod hold switch with its orange reveal → the iOS toggle (2007). This card wears the hardware skin of that lineage.',difficulty:'Intuitive.', + prefer:'The state persists and gets glanced at more than changed. For period consoles use R03 or N01 instead.', + period:'The pill rendering is modern (iPod/iOS era); the slide-switch mechanism underneath is 1950s+.', + ref:'reference/slide-toggle.jpg'}, +'02':{input:'Click one key to engage it; the others release. Click-only.', + solves:'One-of-few mode selection where the active mode must glow.', + use:'Common on consoles. Shines for 2-4 modes switched often.', + limits:'Wide per option; past ~5 keys a selector reads better.', + origin:'Broadcast and studio consoles.',difficulty:'Intuitive.', + prefer:'Each mode deserves a lit, labeled, pressable identity.'}, +'03':{input:'Drag along the track. Drag-only — needs a click/key idiom for Emacs.', + solves:'Continuous level with position as the value readout.', + use:'Common in audio and settings. Shines when relative position matters.', + limits:'Precision bounded by track length; needs horizontal room.', + origin:'Mixing desks.',difficulty:'Intuitive.', + prefer:'The value is a level the eye should compare across channels.'}, +'04':{input:'Drag along the track. Drag-only — needs a click/key idiom for Emacs.', + solves:'Same as the horizontal fader in a column footprint.', + use:'Common. Shines in channel strips where many sit side by side.', + limits:'Needs vertical room; thumb occludes the scale mid-drag.', + origin:'Mixing desks.',difficulty:'Intuitive.', + prefer:'Levels stack in parallel columns (mixer, EQ bank).'}, +'05':{input:'Drag vertically to rotate. Drag-only — Emacs wants +/- keys or click-step.', + solves:'Continuous control in the smallest footprint of any analog input.', + use:'Common everywhere. Shines for gain, tune, adjust anywhere space is tight.', + limits:'Drag direction is a learned convention; fine setting needs a readout.', + origin:'Radio and audio gear.',difficulty:'Easy — the vertical-drag convention must be learned.', + prefer:'Space is tight and the exact value matters less than the gesture.'}, +'06':{input:'Click a segment. Click-only, ports everywhere.', + solves:'One-of-N selection with every option visible at once.', + use:'Common. Shines for 3-6 named, equally likely options.', + limits:'Options must fit the bar; long labels break it.', + origin:'Test equipment range switches, modern segmented controls.',difficulty:'Intuitive.', + prefer:'All options should stay readable without opening anything.'}, +'07':{input:'Click to toggle. Click-only.', + solves:'A lightweight boolean that reads as a tag, not a switch.', + use:'Common in filters and option sets. Shines in rows of independent flags.', + limits:'Low visual weight — poor for states that must shout.', + origin:'Modern web UI, filter chips.',difficulty:'Intuitive.', + prefer:'Many independent booleans sit together and density wins.'}, +'08':{input:'Two clicks: arm, then fire; disarms on timeout. Click-only.', + solves:'Destructive actions need friction sized to their consequence.', + use:'Specialty. Shines guarding resets, wipes, irreversible sends.', + limits:'Friction annoys if the action is actually routine.', + origin:'Missile and launch panels, industrial interlocks.',difficulty:'Easy.', + prefer:'A mis-click would cost real work — make intent two-step.'}, +'09':{input:'Click a lamp to toggle it. Click-only.', + solves:'A bank of independent booleans with lit state per item.', + use:'Common. Shines for channel enables, feature flags.', + limits:'Unlabeled lamps need a legend; color alone excludes colorblind users.', + origin:'Console channel strips.',difficulty:'Intuitive.', + prefer:'The set is small and each member needs its own light.'}, +'24':{input:'Click to step to the next position. Click-only.', + solves:'One-of-N selection with a physical pointer you can read across the room.', + use:'Common on period gear. Shines for mode/range knobs.', + limits:'Stepping is sequential — jumping to a far position takes several clicks.', + origin:'Rotary switches on radios and instruments.',difficulty:'Intuitive.', + prefer:'The selection should read like hardware, not a menu.'}, +'25':{input:'Click a numeral, a mark, or between marks for the units; focused, arrows step one unit. Click + keys.', + solves:'Reading a value against a long calibrated scale.', + use:'Specialty. Shines where the scale itself carries meaning.', + limits:'Scale literacy required; poor for quick setting.', + origin:'Slide rules, tuning scales.',difficulty:'Moderate.', + prefer:'The scale markings are the point, not just the value.', + period:'1930s-60s.'}, +'N01':{input:'Click to rock. Click-only.', + solves:'Mains-grade on/off with an unmistakable thrown state.', + use:'Common. Shines as the one switch that powers the whole unit.', + limits:'Visually heavy — wrong for minor options.', + origin:'Appliance and amplifier mains switches.',difficulty:'Intuitive.', + prefer:'The action is THE power decision for the device.'}, +'N02':{input:'Click a transport key; keys are mutually exclusive. Click-only.', + solves:'Media transport state with one lit verb at a time.', + use:'Common wherever playback exists. Shines with a moving mechanism to confirm state.', + limits:'The verb set is fixed by convention — do not repurpose.', + origin:'Tape decks.',difficulty:'Intuitive.', + prefer:'Anything plays, records, or rewinds.', + period:'1960s-80s tape era styling.'}, +'N03':{input:'Click a numbered preset. Click-only.', + solves:'Instant recall of a small set of saved destinations.', + use:'Common. Shines for stations, scenes, memory slots.', + limits:'Anonymous numbers — the user must remember the mapping.', + origin:'Car radio mechanical presets.',difficulty:'Intuitive.', + prefer:'A handful of favorites deserve one-press recall.', + period:'1950s-70s mechanical-preset styling.'}, +'N04':{input:'Drag either ring: inner and outer rotate independently. Drag-only, and the two targets need care in Emacs.', + solves:'Two related parameters in one knob footprint.', + use:'Specialty. Shines for coarse/fine or volume/tone pairs.', + limits:'Which ring is which must be learned; easy to grab the wrong one.', + origin:'Radio concentric controls.',difficulty:'Moderate.', + prefer:'Two parameters are so coupled they should live on one axis.', + period:'1940s-70s radio styling.'}, +'N05':{input:'Drag to spin; the LED ring shows position. Drag-only.', + solves:'Endless rotation where the value can also be set by the program.', + use:'Common on modern gear. Shines when presets move the value under the knob.', + limits:'No physical end stops — the ring is the only position cue.', + origin:'Digital synths and controllers.',difficulty:'Easy.', + prefer:'Software also writes the value the knob edits.', + period:'1980s onward.'}, +'N06':{input:'Click to turn the key through its positions. Click-only.', + solves:'Mode changes that should require deliberate, almost credentialed intent.', + use:'Specialty. Shines for maintenance/normal/locked modes.', + limits:'Slow by design; wrong for anything frequent.', + origin:'Machine and alarm key switches.',difficulty:'Easy.', + prefer:'Casual switching must be discouraged by the control itself.'}, +'N07':{input:'Drag along the track; a detent holds center. Drag-only.', + solves:'Balancing two sources with an honest neutral point.', + use:'Specialty. Shines for A/B blend, pan, crossfade.', + limits:'Only meaningful with exactly two poles.', + origin:'DJ mixers.',difficulty:'Intuitive.', + prefer:'The midpoint is a real state, not just halfway.'}, +'N08':{input:'Drag a wheel vertically to step its digit. Drag-only — Emacs wants per-digit +/-.', + solves:'Exact multi-digit numeric entry without a keyboard.', + use:'Specialty. Shines for frequencies, counts, codes set digit by digit.', + limits:'Slow for big changes; each digit is its own control.', + origin:'Avionics and test equipment.',difficulty:'Expert — digit-wise thinking.', + prefer:'Exactness beats speed and a keypad would be overkill.', + period:'1960s-80s.'}, +'N09':{input:'Click a switch to flip it. Click-only.', + solves:'A persistent configuration word set once and read rarely.', + use:'Specialty. Shines for boot-time or install-time options.', + limits:'Cryptic without its legend; not for live settings.', + origin:'Circuit-board DIP switches.',difficulty:'Expert — meaningless without the manual.', + prefer:'Config should be physical, deliberate, and survive resets.', + period:'1975-95 computing.'}, +'N10':{input:'Drag the inner jog for fine steps, the outer shuttle for velocity. Drag-only, two coupled gestures.', + solves:'Navigating a timeline at both frame and sweep speeds.', + use:'Specialty. Shines for scrubbing media or logs.', + limits:'Shuttle velocity mapping must be learned; hard to discover.', + origin:'Video edit decks.',difficulty:'Expert.', + prefer:'One control must cover both precision and distance.', + period:'1980s-90s edit suites.'}, +'R02':{input:'Drag to rotate the disc under the fixed hairline. Drag-only.', + solves:'Reading a continuous setting to a tenth of a division.', + use:'Specialty. Shines for calibration and lab-grade settings.', + limits:'Vernier reading is a skill; overkill for coarse values.', + origin:'Laboratory instruments.',difficulty:'Expert — vernier literacy.', + prefer:'The precision of the readback is the whole point.', + period:'1930s-60s lab styling.'}, +'R03':{input:'Click to throw the lever. Click-only.', + solves:'A binary with a big, satisfying, unmistakable throw.', + use:'Common on period gear. Shines where the thrown angle must be visible.', + limits:'Larger than a rocker for the same bit of state.', + origin:'Aviation and lab toggles.',difficulty:'Intuitive.', + prefer:'State must read from across the panel.'}, +'R04':{input:'Drag vertically to rotate. Drag-only.', + solves:'Same job as the rotary knob in a period skin.', + use:'Skin variant. Shines in 30s-50s compositions.', + limits:'As the rotary knob.', + origin:'Bakelite radio knobs.',difficulty:'Easy.', + prefer:'The console is dressed to that era.', + period:'1930s-50s.'}, +'R05':{input:'Drag each band vertically. Drag-only, one gesture per band.', + solves:'Shaping a curve by its bands, position = the curve itself.', + use:'Common as graphic EQ. Shines when the fader tops draw the response.', + limits:'Fixed bands; inter-band moves need many gestures.', + origin:'Filter banks, then graphic equalizers.',difficulty:'Easy.', + prefer:'The overall shape matters more than any single value.', + period:'1950s-60s studio filter styling.'}, +'R06':{input:'Click to snap to the next position. Click-only.', + solves:'One-of-few selection with an emphatic pointer.', + use:'Common on amps and lab gear. Shines for 2-4 coarse modes.', + limits:'Sequential stepping; the blade hides labels under it.', + origin:'Amplifier and instrument selectors.',difficulty:'Intuitive.', + prefer:'The selector should look like it belongs on a tube amp.', + period:'1940s-60s.'}, +'R12':{input:'Drag the handle along the slot. Drag-only.', + solves:'A signed level (boost/cut) around a marked zero.', + use:'Common on period hi-fi. Shines for tone and balance trims.', + limits:'Chrome furniture is wide for what it holds.', + origin:'Hi-fi tone controls.',difficulty:'Intuitive.', + prefer:'Zero is the default and deviation should be obvious.', + period:'1960s-70s hi-fi.'}, +'R14':{input:'Drag to rotate; the knurl ring turns with it. Drag-only.', + solves:'Slow, geared tuning across an engraved scale.', + use:'Skin variant for tuning. Shines in early-radio compositions.', + limits:'As the rotary knob; the engraved scale needs light to read.', + origin:'1920s receivers.',difficulty:'Easy.', + prefer:'The console leans into the earliest radio era.', + period:'1920s-30s.'}, +'R15':{input:'Drag the bandspread dial; click the band ring to switch bands. Mixed — the click half ports to Emacs, the drag half needs an idiom.', + solves:'One dial covering several ranges without losing resolution.', + use:'Specialty. Shines for shortwave-style band plus fine-tune.', + limits:'Band/bandspread interplay confuses newcomers.', + origin:'Multi-band receivers.',difficulty:'Expert.', + prefer:'The domain genuinely has bands (ranges, scopes, zoom levels).', + period:'1930s-50s.'}, +'R16':{input:'Click digits to buffer, OK to commit, C to clear. Click-only, fully Emacs-portable.', + solves:'Deliberate numeric entry with an explicit confirm step.', + use:'Common industrial. Shines for codes, channels, quantities.', + limits:'Slower than typing on a real keyboard when one exists.', + origin:'Industrial and security keypads.',difficulty:'Intuitive.', + prefer:'Entry must be explicit and cancellable, not live.'}, +'R18':{input:'Drag each lit column vertically; channels are independent. Drag-only.', + solves:'Two adjacent levels set by feel with lit numeric feedback.', + use:'Specialty. Shines for blend/mix pairs on processors.', + limits:'Compact but dense; the lit figure is the only precise cue.', + origin:'Compressor blend controls.',difficulty:'Easy.', + prefer:'Two coupled levels should sit shoulder to shoulder.', + period:'1970s-80s rack gear.'}, +'R19':{input:'Drag horizontally; the nearer flag follows, flags cannot cross. Drag-only.', + solves:'Marking a start/end region inside a visible signal.', + use:'Specialty. Shines for trims, loops, selections over a waveform.', + limits:'Flag semantics (S/E, no-cross) must be learned; small targets.', + origin:'Hardware samplers.',difficulty:'Expert.', + prefer:'The region IS the value and the data must stay visible.', + period:'1980s sampler styling.'}, +'R20':{input:'Drag each drum vertically to roll it. Drag-only.', + solves:'Stepped selection with a satisfying mechanical roll.', + use:'Specialty. Shines for paired coarse selections (EQ turnovers).', + limits:'Values hidden off-drum; sequential like all steppers.', + origin:'Channel-strip EQ rollers.',difficulty:'Easy.', + prefer:'A stepped pair should feel like machinery, not menus.', + period:'1960s console styling.'}, +'R21':{input:'Click a key; its LED takes the selection. Click-only.', + solves:'One-of-N program recall where the LED, not the key, carries state.', + use:'Common on rack gear. Shines for 4-8 named programs; maps directly onto a waybar workspace selector.', + limits:'One LED of state per key — no per-key value.', + origin:'Lexicon 224 program row.',difficulty:'Intuitive.', + prefer:'Selection state should read as a dot row even from an angle.', + period:'Late 1970s-80s digital rack.'}, +'R22':{input:'Click a zone to select it directly (A / AB / B). Click-only.', + solves:'Three-way routing with direct selection, no cycling.', + use:'Specialty. Shines for source/both/monitor selects.', + limits:'Three positions only; AB lighting both LEDs must be understood.', + origin:'Hi-fi speaker selectors.',difficulty:'Intuitive.', + prefer:'The middle state is a real combination, not a midpoint.'}, +'R23':{input:'Drag vertically to rotate. Drag-only.', + solves:'Same job as the rotary knob in a machined-metal skin.', + use:'Skin variant. Shines on 70s hi-fi faceplates.', + limits:'As the rotary knob.', + origin:'Spun-aluminum hi-fi knobs.',difficulty:'Easy.', + prefer:'The console is dressed silver-on-black.', + period:'1970s hi-fi.'}, +'R24':{input:'Click the stomp to latch; the jewel lights when engaged. Click-only.', + solves:'A rugged latch whose state glows at foot distance.', + use:'Common on pedals. Shines for engage/bypass.', + limits:'One bit; the jewel is the only state cue.', + origin:'Guitar stompboxes.',difficulty:'Intuitive.', + prefer:'The toggle should survive abuse and read from far away.'}, +'R27':{input:'Drag to step through the detents. Drag with detents — Emacs wants +/- steps.', + solves:'Stepped gain where each position is a calibrated, repeatable value.', + use:'Specialty. Shines for preamp gain in fixed dB steps.', + limits:'No values between detents by design.', + origin:'Neve-style console gain selectors.',difficulty:'Easy.', + prefer:'Repeatability beats continuous freedom.', + period:'1970s console styling.'}, +'R28':{input:'Click to step the disc to the next position, wrapping. Click-only.', + solves:'Heavy-duty source selection with an unmistakable handle.', + use:'Specialty. Shines for OFF/ON/COMBINE-style power routing.', + limits:'Sequential stepping; big footprint.', + origin:'Marine battery selectors.',difficulty:'Intuitive.', + prefer:'The selection routes something heavy and should look like it.'}, +'R29':{input:'Click the guard open, then click to throw; closing re-guards. Click-only, two-step.', + solves:'A switch that cannot be thrown by accident, period.', + use:'Specialty. Shines over anything irreversible.', + limits:'Two actions every time; reserve for genuine hazards.', + origin:'Aviation guarded switches.',difficulty:'Easy.', + prefer:'Like arm-to-fire but the guard state itself must be visible.'}, +'R32':{input:'Drag the dial to wind minutes on (turn to start); click the red knob to zero it (push to stop). Mixed — the stop is click-portable, the wind needs a key idiom in Emacs.', + solves:'Setting a duration and watching it run down, with OFF as a real position.', + use:'Common in kitchens and labs. Shines for timeboxes, pomodoros, sleep timers.', + limits:'One duration, coarse resolution; the demo runs a minute per second.', + origin:'Spring-driven interval timers.',difficulty:'Intuitive.', + prefer:'The remaining time should read as a dial position, not digits.'}, +'R33':{input:'Click an arrow or anywhere in its quadrant to step that direction. Click-only — the most Emacs-portable control in the kit, since it is literally arrow keys.', + solves:'Discrete navigation in four directions without a pointer position.', + use:'Common on remotes, cameras, avionics MFDs. Shines for menu and grid focus.', + limits:'Steps, not analog rate; diagonals need two presses; no held-repeat here.', + origin:'Remote-control and camera four-way pads.',difficulty:'Intuitive.', + prefer:'Focus moves through a grid or menu and each step is deliberate.'}, +'R34':{input:'Click a quadrant; the lever throws there and stays. Click-only, fully portable.', + solves:'One-of-four stateful selection where the selector itself shows the state.', + use:'Specialty. Shines for quadrant modes (channel pairs, axis pairs, routing).', + limits:'Exactly four states; diagonal labels need the printed legend.', + origin:'Test-panel quadrant switches with ball levers.',difficulty:'Intuitive.', + prefer:'The rocker steps a cursor; this holds a position. Reach for it when the four options are states, not directions.'}, +'R37':{input:'Click a row/column intersection to seat or pull a pin. Click-only, fully portable.', + solves:'Many-to-many routing where every active route stays simultaneously readable.', + use:'Specialty. Shines for source-to-destination mapping: audio routing, signal paths, rule wiring.', + limits:'Grid size is the ceiling; dense matrices need hover labels to stay legible.', + origin:'EMS VCS3 synthesizer pin matrix.',difficulty:'Easy — rows and columns must be read.', + prefer:'The patch bay shows one cable per route; the matrix shows the whole routing state at once.', + period:'Late 1960s-70s synth and routing gear.'}, +'R38':{input:'Press and hold; release anywhere drops to SAFE. Hold-based — pointer capture on web, a held key in Emacs; the only held-state control in the kit.', + solves:'Actions that must stop the moment the operator lets go.', + use:'Specialty. Shines for jog/feed, test firing, anything unsafe to walk away from.', + limits:'Fatigue is the design point — do not use it for long operations.', + origin:'Dead-man controls on trains, cranes, machine tools.',difficulty:'Intuitive.', + prefer:'Release-to-safe matters more than convenience.'}, +'R39':{input:'Click a hole; the wheel winds to the stop and returns, then the digit registers. Click-only, though the return animation is the point.', + solves:'Numeric entry with built-in pacing — larger digits genuinely take longer.', + use:'Specialty and nostalgic. Shines when entry should feel deliberate, or in period sets.', + limits:'Slow by design; ten digits only; no correction but a redial.', + origin:'Rotary telephone dials (Strowger pulse dialing).',difficulty:'Intuitive to those who have seen one.', + prefer:'The keypad enters numbers; the dial performs them.', + period:'1920s-70s telephony; hard-clashes with anything digital.'}, +'R40':{input:'Click a breaker to open or close it; TRIP pops one out under fault. A tripped breaker resets in two clicks: pull to off, then close. Click-only.', + solves:'Protective state the SYSTEM can change — no other control in the kit throws itself.', + use:'Common industrially. Shines for services/circuits that can fail independently: a tripped breaker is a crashed daemon.', + limits:'The tripped mid-position must be visually distinct or the story is lost.', + origin:'Aircraft and distribution breaker rows.',difficulty:'Easy — the two-step reset must be learned.', + prefer:'Faults should be visible at the switch, not in a separate alarm list.'}, +'R41':{input:'Press VERB or NOUN, type two digits, ENTR commits; V35 runs the lamp test, bad grammar lights OPR ERR. Click-only, fully portable.', + solves:'Reaching many functions from few keys via a verb-noun command grammar.', + use:'Specialty. Shines when the function space outgrows the panel space.', + limits:'The grammar must be memorized — the tradeoff is capability for discoverability.', + origin:'Apollo Guidance Computer DSKY.',difficulty:'Expert — by design.', + prefer:'Dozens of rare commands beat dozens of rare buttons.', + period:'1960s-70s spaceflight; the aesthetic survives in every command palette.'}, +'R42':{input:'Click to advance a step (or start from OFF); it then walks itself through the program. Click-only.', + solves:'A whole procedure shown as one rotating position — where you are, what came before, what is next.', + use:'Common on appliances and process gear. Shines for fixed multi-step sequences.', + limits:'The program is fixed at manufacture; skipping steps means clicking through them.', + origin:'Washing-machine and dishwasher cam timers.',difficulty:'Intuitive.', + prefer:'The sequence matters more than any single state — R30 shows a state, this shows a program.', + period:'1950s-80s appliance controls.'}, +'R48':{input:'Click to throw the blade open or closed. Click-only.', + solves:'A disconnect whose open state is physically visible — no lamp to trust, the gap IS the proof.', + use:'Specialty. Shines as a master isolate where "is it really off" matters.', + limits:'Semantically just a toggle; the value is entirely in the visible gap.', + origin:'Early power-station switchboards.',difficulty:'Intuitive.', + prefer:'A lamp says off; an air gap proves it. Use where proof beats economy of space.', + period:'1890s-1930s power switching; pure theater after that, and worth it.'}, +'R49':{input:'Drag each knob to its digit; the windows read the digits and the readout sums them. Drag with detents — Emacs wants per-digit +/-.', + solves:'Composing one precise value from independent per-decade digits.', + use:'Specialty. Shines for calibration values, resistances, counts where each digit is a deliberate choice.', + limits:'Range fixed by the decade count; slow for sweeping a value.', + origin:'General Radio decade resistance boxes.',difficulty:'Easy.', + prefer:'The thumbwheel sets a digit; this composes a NUMBER from digits with the arithmetic visible.', + period:'1930s-70s lab bench.'}, +'R50':{input:'Click one palm button, then the other within 0.5s; same hand twice or too slow faults. Click-only — the hardware holds both, the screen keeps the two-target-within-window grammar.', + solves:'Commands that must cost both hands — presence at two separated points before anything moves.', + use:'Specialty. Shines for genuinely destructive cycles where arm-to-fire is not enough.', + limits:'Deliberately annoying; the anti-tie-down rule must fault same-hand repeats or the guarantee is void.', + origin:'OSHA press and cutter controls.',difficulty:'Easy — the window must be learned.', + prefer:'Arm-to-fire proves intent; two-hand proves position. The strongest friction in the kit.'}, +'R51':{input:'Click a loop key: off → monitor → talk → off. Talk is exclusive; monitors are independent. Click-only, fully portable.', + solves:'Attending many channels at once while speaking on exactly one.', + use:'Specialty. Shines for channels, feeds, log streams — anything monitored in parallel.', + limits:'Flicker communicates activity, not content; past a dozen loops even controllers tiered them.', + origin:'NASA mission-control comm keysets.',difficulty:'Easy.', + prefer:'R21 selects one program; this holds many half-attended and one active.', + period:'1960s-90s console era; the pattern lives on in every notification mute matrix.'}, +'R52':{input:'Display plus the switch register: click a toggle to flip its bit and the activity pattern folds it in. The lamps take no input.', + solves:'Showing computation happening — activity, not just state.', + use:'Specialty. Shines for load, traffic, progress-with-texture; honest busy-ness.', + limits:'Patterns are read as rhythm and density, not decoded bit by bit.', + origin:'PDP-11/70 and IMSAI front panels.',difficulty:'Intuitive to watch, expert to read.', + prefer:'A spinner says busy; blinkenlights say HOW busy and with what rhythm.', + period:'1965-80 minicomputer era; the icon of computing at work.'}, +'R58':{input:'Click a character to move the stylus, then pull the lever to print it. Or click the plate and type: keys move the stylus, Enter is the lever. Selecting never prints.', + solves:'Free text where the operator must SEE the whole character set and commit deliberately — the plate is the documentation, and nothing lands by accident.', + use:'Specialty. Shines where a stray keypress must not enter anything, and where the available characters are worth showing rather than remembering.', + limits:'Two acts per character, so it is the slowest text control in the kit by design. No space cell (the real machine has a separate space key); no 1 or 0 (type them with lowercase l and capital O).', + origin:'AEG Mignon Model 4 (1924), the best of the index typewriters. Plate transcribed from the reference photo; layout and zones are declared tables.', + difficulty:'Obvious to read, slow to use. The two-handed grammar is the point, not an obstacle.', + prefer:'Deliberate commitment matters more than speed, or the character set itself needs to be on display. R57 is the fast alternative and R16 the digits-only one.', + period:'1903-1934 (index typewriters); the idiom predates the keyboard becoming standard.', + ref:'../../working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg'}, +'R57':{input:'Click keys to type, or click the plate and type on the keyboard (letters, digits, space, Backspace, Enter). DEL takes back one character, CLR wipes the lot, ENT commits.', + solves:'Free alphanumeric text on a panel that has no keyboard — the one job the rest of the kit cannot do.', + use:'Specialty. Shines where a panel must accept a string it could not know in advance: a passphrase, an SSID, a callsign, a label.', + limits:'Clicking is slow by design: a 20-character passphrase is 20 clicks, which is why the plate also takes the keyboard when focused. It is for panels with no keyboard of their own, and a real one still beats it for volume.', + origin:'Fleet, kiosk and access-control keypads — a membrane plate for the colour-coded function keys, a stainless one for the letters-left layout and the backspace.', + difficulty:'Intuitive to use. Clicking is tedious past a few words, which is what the keyboard is for.', + prefer:'Text is genuinely unbounded. R16 is the digits-only sibling and is faster where the alphabet is not needed.', + ref:'../../working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-color.png'}, +'R56':{input:'Drag the left half for temperature, the right for humidity. Drag-only, two surfaces.', + solves:'A categorical verdict from two values — the crossing point falls into a printed judgment.', + use:'Common on weather stations and comfort meters. Shines when the pair only matters as a combined condition: comfort, safe-operating region, duty envelope.', + limits:'The zone thresholds are opinions baked into the face; five zones is about the ceiling.', + origin:'Brass household comfort meters.',difficulty:'Intuitive — the verdict is literally written down.', + prefer:'N13 derives a number from the crossing, R55 reads two values separately; this one answers "is the combination OK" without asking you to do the math.'}, +'R54':{input:'Display in real use; drag vertically here to spin the tape. Drag-only for the demo.', + solves:'A long scale in a narrow window — the value sits at a fixed eye position with its neighborhood visible above and below.', + use:'Common in modern cockpits (airspeed, altitude) and rack meters. Shines when panel width is scarce and trend context matters.', + limits:'No at-a-glance needle angle; rate reads as scroll speed, which takes acclimation.', + origin:'Ki-57 remote tachometer; universal in glass cockpits since.',difficulty:'Easy.', + prefer:'The needle gauge gives angle-at-a-glance; the tape gives context-around-the-value in a tenth the width.'}, +'R55':{input:'Drag the left half for the left needle, right half for the right. Drag-only, two independent surfaces.', + solves:'Two related values in one instrument hole, each on its own mirrored scale.', + use:'Common on aircraft engine panels. Shines for pairs the eye checks together: fuel/oil, left/right, in/out.', + limits:'Both scales must share a range family or the mirroring misleads.', + origin:'Ki-57 fuel/oil pressure gauge; standard twin-engine instrument form.',difficulty:'Intuitive.', + prefer:'N13 crosses two needles to read their intersection; this houses two needles to read separately. Symmetry here means "both healthy looks symmetric".'}, +'R53':{input:'Display; click loads fresh paper. The real recorder takes no input.', + solves:'Cyclic time — the same hour lands at the same angle, so daily rhythm becomes shape.', + use:'Specialty. Shines for temperatures, loads, and rates with a daily or weekly cycle.', + limits:'One revolution of history; overlapping days need pen colors, like the hardware.', + origin:'Partlow and Honeywell circular chart recorders.',difficulty:'Easy.', + prefer:'The strip chart (N16) answers what just happened; this answers what this time of day usually looks like.', + period:'1900s-80s process recording.'}, +'10':{input:'Display; drag the gallery card to drive the demo value. Real use: none.', + solves:'A magnitude plus its rate of change, read peripherally.', + use:'Common. Shines where trend and zone matter more than digits.', + limits:'Coarse precision; needs face room.', + origin:'Moving-coil panel meters.',difficulty:'Intuitive.', + prefer:'The eye should catch movement, not read a number.'}, +'11':{input:'Display only; runs from the live demo signal.', + solves:'Two channels of level with peak behavior, at a glance.', + use:'Common in audio. Shines for stereo program level.', + limits:'Segment resolution; no history.', + origin:'LED meter bridges.',difficulty:'Intuitive.', + prefer:'Level must be watchable continuously without fatigue.'}, +'12':{input:'Display only.', + solves:'Signal strength in the smallest possible footprint.', + use:'Common. Shines in status bars and corners.', + limits:'Four levels of resolution, no more.', + origin:'Phone signal bars.',difficulty:'Intuitive.', + prefer:'One glance must answer roughly-how-strong.'}, +'13':{input:'Click a rung to set the demo level. Real use: display.', + solves:'A discrete level presented as steps rather than a continuum.', + use:'Common. Shines when the value naturally has rungs.', + limits:'Step count fixed by the ladder.', + origin:'Level ladders on meters and mixers.',difficulty:'Intuitive.', + prefer:'The scale is genuinely stepped (volume notches, severity).'}, +'14':{input:'Display; drag the gallery bar to drive the demo. Real use: none.', + solves:'How full, with zone warnings, in one horizontal read.', + use:'Common. Shines for capacity: fuel, disk, battery.', + limits:'One value; zones must be pre-agreed.', + origin:'Fuel gauges.',difficulty:'Intuitive.', + prefer:'The question is how much is left.'}, +'15':{input:'Display; click/drag drives the demo. Real use: none.', + solves:'Progress or proportion wrapped around a compact center.', + use:'Common in modern UI. Shines when the center holds the number.', + limits:'Angular reading is coarser than linear.', + origin:'Modern dashboard rings.',difficulty:'Intuitive.', + prefer:'A percentage needs to share space with its own label.', + period:'Modern (2010s UI) — pair with restraint on a period console.'}, +'16':{input:'Display only; live rolling data.', + solves:'The shape of recent history in a text-sized strip.', + use:'Common in dashboards. Shines inline next to a current value.', + limits:'No axes or absolute scale — trend only.', + origin:'Tufte sparklines.',difficulty:'Intuitive.', + prefer:'Direction-of-travel matters and space is text-sized.', + period:'Modern (2000s information design).'}, +'17':{input:'Display only; live signal.', + solves:'Raw signal texture over the last moments.', + use:'Specialty. Shines for audio/telemetry liveliness.', + limits:'Qualitative, not measured.', + origin:'Waveform strips on recorders.',difficulty:'Intuitive.', + prefer:'Presence and texture of a signal beat any single number.'}, +'N11':{input:'Display only; live trace.', + solves:'Seeing the waveform itself, not a summary of it.', + use:'Specialty. Shines for diagnosing shape, noise, clipping.', + limits:'Needs a real drawing surface everywhere it ports.', + origin:'Cathode-ray oscilloscopes.',difficulty:'Easy to watch, expert to interpret.', + prefer:'The shape of the signal is the information.'}, +'N12':{input:'Display only; live bands.', + solves:'Energy distribution across bands at a glance.', + use:'Common in audio. Shines for spectrum and per-core loads.', + limits:'Band resolution fixed; no phase/history.', + origin:'Spectrum analyzers and EQ displays.',difficulty:'Intuitive.', + prefer:'The question is where the energy sits, not how much total.'}, +'N13':{input:'Display only.', + solves:'Two related values on one face, their crossing point meaningful.', + use:'Specialty. Shines for forward/reflected power, in/out levels.', + limits:'Reading the iso-curves takes practice.', + origin:'Ham radio SWR meters.',difficulty:'Expert.', + prefer:'The ratio between two needles is the real value.'}, +'N14':{input:'Display only.', + solves:'A bounded scalar with universal instant recognition.', + use:'Common. Shines for temperature and anything temperature-like.', + limits:'Vertical footprint for one value.', + origin:'Mercury thermometers.',difficulty:'Intuitive.', + prefer:'The metaphor does the labeling for you.'}, +'N15':{input:'Display only.', + solves:'Industrial magnitude with legal-looking zone arcs.', + use:'Common industrial. Shines for pressure and load with red-line semantics.', + limits:'Face room; coarse read.', + origin:'Bourdon-tube pressure gauges (1849).',difficulty:'Intuitive.', + prefer:'The red zone must carry authority.'}, +'N16':{input:'Display only; live scrolling pen.', + solves:'A continuous value plotted against time, automatically.', + use:'Specialty. Shines for drift, cycles, events-over-hours.', + limits:'One channel per pen; horizontal footprint.', + origin:'Chart recorders.',difficulty:'Easy.', + prefer:'History matters as much as the current value.', + period:'Pre-digital logging (1900s-80s) styling.'}, +'N17':{input:'Display only.', + solves:'A signed relationship swinging around a marked zero.', + use:'Specialty. Shines for stereo correlation, balance, error.', + limits:'Meaning of + and - must be learned per domain.', + origin:'Broadcast correlation meters.',difficulty:'Moderate.', + prefer:'Zero is the healthy state and deviation is the signal.'}, +'N18':{input:'Display only.', + solves:'Charge state in discrete, honest cells.', + use:'Common. Shines for battery and quota displays.', + limits:'Cell resolution; green here is the LED green (sevgrn), not the phosphor green.', + origin:'Consumer battery gauges.',difficulty:'Intuitive.', + prefer:'Discrete cells match how users think about the resource.'}, +'R01':{input:'Display only; true VU ballistics on the live signal.', + solves:'Perceived loudness via standardized needle mass and dB law.', + use:'Common in audio. Shines as THE program-level meter.', + limits:'Slow by standard — misses fast peaks by design.', + origin:'The 1939 VU standard.',difficulty:'Easy.', + prefer:'Loudness as humans hear it beats instantaneous peaks.'}, +'R07':{input:'Display only.', + solves:'A dB read in a sealed porthole, panel-mount style.', + use:'Skin variant of the VU idea. Shines flush-mounted in rows.', + limits:'Small face, coarse scale.', + origin:'Panel meters on lab and broadcast gear.',difficulty:'Easy.', + prefer:'Several meters must sit in a disciplined row.', + period:'1950s-60s.'}, +'R08':{input:'Display only.', + solves:'A tuning/level window dressed in chrome for the living room.', + use:'Skin variant. Shines on consumer hi-fi faces.', + limits:'Ornamental furniture around a small read.', + origin:'Consumer hi-fi indicators.',difficulty:'Intuitive.', + prefer:'The console is consumer-facing, not lab-facing.', + period:'1960s-70s hi-fi.'}, +'R09':{input:'Display only.', + solves:'A flight-critical value with green/amber/red zones readable in the dark.', + use:'Specialty. Shines where out-of-zone must be instantly obvious.', + limits:'Zones must be authoritative or they train complacency.', + origin:'Aircraft engine and airspeed gauges.',difficulty:'Intuitive.', + prefer:'The zone, not the number, drives the response.'}, +'R13':{input:'Display only.', + solves:'A meter squeezed edgewise into a rack unit.', + use:'Common on rack gear. Shines where vertical space is the scarce resource.', + limits:'Compressed log scale takes a moment to read.', + origin:'Edgewise meters on processors.',difficulty:'Moderate.', + prefer:'Rack density forbids a round face.', + period:'1960s-80s rack.'}, +'R43':{input:'Drag: left/right banks, up/down pitches. The second 2D drag in the kit (after R26) — the hardest input model to port.', + solves:'Two-axis orientation read at a glance — no pair of scalar gauges can match it.', + use:'Specialty. Shines for anything with attitude, tilt, or balance across two axes.', + limits:'Needs interpretation training in the real world; here the drag teaches it.', + origin:'Cockpit artificial horizons.',difficulty:'Moderate.', + prefer:'The two values are physically one orientation, not two numbers.'}, +'R44':{input:'Drag to set the amber bug (commanded); the needle (actual) chases it with servo lag. Drag-only.', + solves:'Setpoint versus reality on one dial — the gap between bug and needle is the state.', + use:'Common in cockpits and process control. Shines for anything with a target and a lagging actual: thermostat, autopilot, motor speed.', + limits:'Wrap-around chase must take the short way or the story reads wrong.', + origin:'HSI heading bugs, synchro repeaters, motorized pointers.',difficulty:'Easy.', + prefer:'Showing only the actual hides the intent; showing both shows the system working.'}, +'R17':{input:'Display only; live trace. Screen chips switch the phosphor family.', + solves:'The round-tube scope look: waveform inside bezel and screws.', + use:'Skin variant of the oscilloscope. Shines in period lab compositions.', + limits:'Round face wastes corners; same porting need as any trace.', + origin:'Round-CRT lab scopes.',difficulty:'Easy to watch.', + prefer:'The era calls for a tube, not a rectangle.', + period:'1940s-60s.'}, +'18':{input:'Click cycles the demo state. Real use: display.', + solves:'One state, one lamp: ok, busy, fault at a glance.', + use:'Common everywhere. Shines beside the thing it describes.', + limits:'Color alone excludes colorblind users — pair with a label.', + origin:'Pilot lamps.',difficulty:'Intuitive.', + prefer:'A single state needs the smallest honest indicator.'}, +'19':{input:'Display only.', + solves:'A short status word with categorical color.', + use:'Common. Shines in lists and headers.', + limits:'Text-sized; not for continuous values.', + origin:'Tags and badges in modern UI.',difficulty:'Intuitive.', + prefer:'The state has a name and belongs inline.'}, +'20':{input:'Display only.', + solves:'Labeled values aligned for vertical scanning.', + use:'Common. Shines for 3-8 key/value facts.', + limits:'Static layout; long values break alignment.', + origin:'Instrument data plates.',difficulty:'Intuitive.', + prefer:'Several named numbers must be scannable in a column.'}, +'21':{input:'None — panel furniture.', + solves:'Naming a region so controls need shorter labels.', + use:'Common. Shines grouping related controls under one legend.', + limits:'Informational only.', + origin:'Engraved faceplate legends.',difficulty:'Intuitive.', + prefer:'A cluster needs a name more than each member does.'}, +'22':{input:'Display region; the overlay pair adds copy/clear.', + solves:'A place for streaming text output that is clearly not chrome.', + use:'Common in tools. Shines for logs and command output.', + limits:'Unstructured text; needs its own scroll discipline.', + origin:'Terminal wells in tool UIs.',difficulty:'Intuitive.', + prefer:'Output length is unbounded and text-shaped.'}, +'23':{input:'Display only; transient.', + solves:'A message that matters now but not forever.', + use:'Common. Shines for confirmations and background events.', + limits:'Easy to miss; sticky errors need a dismissal policy.', + origin:'Status lines and toasts.',difficulty:'Intuitive.', + prefer:'The message should not occupy permanent space.'}, +'26':{input:'Display only.', + solves:'A glowing digit with unmatched period warmth.', + use:'Specialty. Shines for counters and clocks meant to be loved.', + limits:'Neon is only ever orange — no color variants exist honestly; digits stack in depth.', + origin:'Nixie tubes.',difficulty:'Intuitive.', + prefer:'Warmth and era matter more than crispness.', + period:'1955-75 hard period: sits with keypads and telegraphs, clashes with VFD and LED seven-seg.'}, +'N20':{input:'Click steps to the next word. Each cell flips one flap at a time through its charset, so cells arrive staggered; a mid-spin change re-aims the cascade.', + solves:'A changed value that announces itself mechanically.', + use:'Specialty. Shines for arrivals-board style updates.', + limits:'Character set and flap speed are the charm and the constraint.', + origin:'Split-flap rail/airport boards.',difficulty:'Intuitive.', + prefer:'The moment of change deserves sound and motion.', + period:'1960s-90s boards.'}, +'N21':{input:'Display only.', + solves:'Digits readable at distance with segment honesty.', + use:'Common. Shines for counts, clocks, channel numbers.', + limits:'Digits and a few letters only.', + origin:'LED seven-segment displays.',difficulty:'Intuitive.', + prefer:'Numeric state should read like an appliance.', + period:'1975 onward.'}, +'N22':{input:'Display only; scrolls.', + solves:'More text than fits, one glowing window at a time.', + use:'Common on period appliances. Shines for track names, status crawl.', + limits:'Reading takes waiting; blue-green is the honest VFD color.', + origin:'Vacuum-fluorescent displays.',difficulty:'Intuitive.', + prefer:'A short crawl beats truncation.', + period:'1980s appliance/hi-fi.'}, +'R45':{input:'Display; the gallery makes each disc clickable. The real sign takes no input.', + solves:'Persistent visual state with zero holding power — the disc stays where it flipped.', + use:'Common on transit signs and industrial boards. Shines for slow-changing state that must survive power loss.', + limits:'Two colors, no glow (needs ambient light); flip rate limits animation.', + origin:'Flip-dot transit signs and magnetic indicators.',difficulty:'Intuitive.', + prefer:'The dot matrix glows and forgets; the flip-disc is dimmer but remembers.', + period:'1960s-90s transit era; still in service.'}, +'R46':{input:'Click = one pulse; the glow steps a cathode clockwise, wrapping with a carry blink. Click-only.', + solves:'A count where the counting itself is visible — position, motion, and carry.', + use:'Specialty. Shines for event counters and rate displays where each increment should be seen.', + limits:'One digit per tube; chains needed for big numbers, exactly like the hardware.', + origin:'Dekatron glow-transfer counter tubes.',difficulty:'Intuitive.', + prefer:'The nixie announces the digit; the Dekatron performs the increment.', + period:'1950s-60s counting gear; pre-IC logic made visible.'}, +'R47':{input:'Click the lever to cycle the gear; lamps pulse amber through transit. Click-only.', + solves:'Spatial status — each lamp is a physical thing in its physical place, plus an honest in-between state.', + use:'Specialty. Shines when components have positions (gear, doors, valves) and "moving" is a real state.', + limits:'The three-greens convention must be known; transit needs a timeout in real use.', + origin:'Aircraft landing-gear panels.',difficulty:'Intuitive.', + prefer:'The annunciator names conditions; this maps them onto the machine body.'}, +'N23':{input:'Click a cell to raise or cycle its alarm; ACK steadies the flashing master caution, RESET clears the board, TEST proves the bulbs. Click-only.', + solves:'Many named conditions, each with its own lit cell.', + use:'Specialty. Shines for fault boards where absence of light is health.', + limits:'Cell text must stay short; layout is fixed.', + origin:'Power-plant and aircraft annunciators.',difficulty:'Intuitive.', + prefer:'Every fault deserves a permanent, named home.'}, +'N24':{input:'Display only.', + solves:'A few states as colored jewels with period glow.', + use:'Common on period gear. Shines for power/signal/fault triples.', + limits:'Jewel green (jewel-g) is its own green — distinct from phosphor and VFD.', + origin:'Jewel pilot lamps.',difficulty:'Intuitive.', + prefer:'Indicator lights should be furniture, not pixels.', + period:'1930s-60s.'}, +'N25':{input:'Display only; counts.', + solves:'A resettable position count with mechanical drum digits.', + use:'Specialty. Shines for tape position, cycle counts.', + limits:'Relative number — meaningful only against a noted zero.', + origin:'Tape deck counters.',difficulty:'Easy.', + prefer:'A relative index beats an absolute timestamp.', + period:'1950s-80s decks.'}, +'N26':{input:'Display only; live time.', + solves:'Time of day, read culturally, instantly.', + use:'Common. Shines as console furniture that also works.', + limits:'Analog read is approximate by nature.', + origin:'Clocks.',difficulty:'Intuitive.', + prefer:'Approximate time at a glance beats digits.'}, +'N27':{input:'Display only.', + solves:'Where in the band the tuning currently sits.', + use:'Specialty. Shines paired with a tuning control.', + limits:'Furniture without its knob.', + origin:'Radio dial scales.',difficulty:'Intuitive.', + prefer:'Position-in-range should be visible independent of the control.', + period:'1930s-60s radio.'}, +'N28':{input:'Display only in the gallery; real bays patch.', + solves:'Which connections exist, shown as physical presence.', + use:'Specialty. Shines for routing state.', + limits:'Dense; jack labels carry all the meaning.', + origin:'Telephone and studio patch bays.',difficulty:'Moderate.', + prefer:'Connections are the state worth showing.'}, +'R10':{input:'Click steps the page. Screen chips switch the family. Otherwise display.', + solves:'Several small status pages in one fixed window.', + use:'Common on gear. Shines for SYS/NET/TIME-style rotations.', + limits:'One page visible; page order must be learned.', + origin:'Status LCDs on rack gear.',difficulty:'Intuitive.', + prefer:'A few short pages beat one crowded panel.', + period:'1980s-90s LCD era.'}, +'R11':{input:'Display only; the flag slides in on fault.', + solves:'A fault state that physically occludes the healthy read.', + use:'Specialty. Shines where ignoring a failure must be impossible.', + limits:'Binary; the barber pole means only one thing.', + origin:'Avionics warning flags.',difficulty:'Intuitive.', + prefer:'Failure must block the reading, not sit next to it.', + period:'1950s-70s avionics.'}, +'R25':{input:'Display only.', + solves:'Letters and digits in segments, still glowing, still period.', + use:'Specialty. Shines for short mode names (ZOOM, HALL).', + limits:'Fourteen segments approximate the alphabet, with charm.', + origin:'Alphanumeric LED displays.',difficulty:'Intuitive.', + prefer:'Words must glow like the rest of the rack.', + period:'1970s-80s.'}, +'R26':{input:'Drag the peak anywhere on the plot — the first 2D drag in the kit. Drag-only.', + solves:'A frequency response edited by grabbing the curve itself.', + use:'Common in DAWs. Shines for parametric EQ, any x/y parameter pair.', + limits:'Log-frequency mapping assumed knowledge; 2D drag is the hardest Emacs port in the kit.', + origin:'Mastering processor displays.',difficulty:'Expert.', + prefer:'Two coupled parameters live naturally on a plot.', + period:'Modern (digital-era display).'}, +'R35':{input:'Live readout initialized to today; click rolls midnight forward one day. Click-only.', + solves:'A compound value (weekday plus date) read at one index from coaxial discs.', + use:'Specialty. Shines wherever two coupled discrete values should share one pointer.', + limits:'The 31-slot date disc ignores short months — faithfully, since that is the real complication problem.', + origin:'Wristwatch day-date movements.',difficulty:'Intuitive.', + prefer:'Digits would work, but the mechanism itself is the display worth showing.', + period:'1950s-onward watchmaking; the exposed-movement look is the charm.'}, +'R36':{input:'Display; the gallery makes each dot clickable so the bitmap is paintable. The real part takes no input.', + solves:'A free-form bitmap in indicator form — glyphs, icons, patterns, tiny animations from one part.', + use:'Common in signs, elevators, synth modules. Shines when one small display must show anything.', + limits:'8x8 holds one glyph; multiplex flicker shows on camera; red-first technology.', + origin:'LED dot-matrix modules (HP displays, scrolling signs, the K4816 faceplate).',difficulty:'Intuitive.', + prefer:'Segments cover digits; reach for the matrix when the content is not characters.', + period:'1970s-90s LED-matrix era styling; the part is still made.'}, +'R30':{input:'Click steps the state, wrapping. Click-only.', + solves:'Current state shown by a pointer into labeled sectors.', + use:'Specialty. Shines for coarse machine states (RUN/STOP/TEST).', + limits:'Sector count fixed by the dial face.', + origin:'Ship engine telegraphs.',difficulty:'Intuitive.', + prefer:'State should read like a dial position, not a word.', + period:'1900s-50s marine styling.'}, +'R31':{input:'Click marks the current bearing. Screen chips switch the phosphor. Otherwise live display.', + solves:'Position around you, decaying like a real PPI.', + use:'Specialty. Shines for anything with bearing and range.', + limits:'The sweep metaphor implies periodic refresh.', + origin:'Radar plan-position indicators.',difficulty:'Easy to watch.', + prefer:'Angular position matters and the era wants a tube.', + period:'1940s-70s radar styling.'}, +}; +const INFO_FIELDS=[['input','input'],['solves','solves'],['use','use'],['limits','limits'], + ['origin','origin'],['difficulty','difficulty'],['prefer','prefer when'],['period','period']]; +const VLAMP_TITLES={off:'validation: not done',amber:'validation: in progress',green:'validation: done'}; +/* VSTATUS — the baked validation record: the durable half of the walk. + Lamp clicks write localStorage, which is per-browser-profile and dies with a + cache clear, so the walk's progress is baked back into source periodically + (the "copy for source" control under the tally emits this block; paste it + here). Only amber/green are listed — off is the default and stays implicit. + Precedence is localStorage first, VSTATUS second: the live walk wins on the + machine doing it, and the baked record fills in on a fresh profile, after a + clear, or on the other daily driver. 'off' is a stored value like any other, + so deliberately un-checking a card beats a baked green rather than reverting + to it on reload. */ +const VSTATUS={ + "01": "green", + "06": "green", + "25": "green", + "R05": "green", + "12": "green", + "R07": "amber", + "18": "green", + "R10": "green", + "R11": "green", + "R52": "amber" +}; +/* GVexport — the live lamp state in VSTATUS shape, ready to paste back above. + Reads the lamps rather than localStorage so what you copy is what you see. + Builds the text by hand rather than via JSON.stringify: stringify orders + canonical integer keys ('12') numerically ahead of everything else ('01' has + a leading zero, 'R05' isn't numeric), which would reshuffle the baked block + on every bake and bury each walk's real change in diff noise. Card order is + DOM order, so the block grows in place. */ +function GVexport(){ + const rows=[]; + document.querySelectorAll('.vlamp').forEach(l=>{ + const v=l.dataset.v; + if(v!=='off') rows.push(' "'+l.closest('.card').id.slice(5)+'": "'+v+'"');}); + return '{\n'+rows.join(',\n')+'\n}'; +} +/* Hand the export over selected, and let the reader press Ctrl+C themselves. + The page deliberately never tries to copy on their behalf, because from a + file:// origin neither programmatic path works here (Chrome 150, 2026-07-16): + - navigator.clipboard.writeText rejects NotAllowedError, "Write permission + denied" — a file origin can't hold the clipboard-write permission. + - execCommand('copy') returns TRUE and writes nothing. Worse than failing, + because the return value invites the page to claim success it didn't have. + A hand-typed Ctrl+C works fine, so this is specific to a page copying on the + user's behalf, NOT a broken clipboard: a Wayland-set clipboard crosses to X11 + and Emacs reads it correctly (sentinel-verified). Selecting the text is + therefore the whole job — the user's own copy is the one that works, and it + lands in the real clipboard where every app can reach it. + Do NOT "improve" this by reading the X/Wayland PRIMARY selection instead: + PRIMARY holds whatever was last selected ANYWHERE, so it silently returns + unrelated content (it produced a private SMS while this was being built). */ +function gvCopy(btn){ + const flash=m=>{btn.textContent=m;setTimeout(()=>btn.textContent='copy for source',2600);}; + const ta=document.createElement('textarea'); + ta.value=GVexport(); + ta.style.cssText='position:fixed;left:12px;bottom:12px;z-index:70;width:320px;height:170px;'+ + 'font:12px ui-monospace,monospace;background:#14120f;color:#e8dcc0;border:1px solid #7d5c16;'+ + 'border-radius:4px;padding:6px'; + document.body.appendChild(ta); ta.select(); + flash('press Ctrl+C ▸'); + ta.addEventListener('blur',()=>ta.remove()); +} +/* index tally: recounts every card lamp; setV calls it on every state change. + Clicking a row jumps to the next card in that state (cycles), so a count + that disagrees with a visual scan can be audited card by card. */ +const VJUMP={green:0,amber:0,off:0}; +/* shared tally-row markup for the index panels (validation + policy): the same + dot/label/count row was written out per bucket in both tallies. attr names the + grouping attribute (data-v or data-g); dot is the .vdot state or '' for none. */ +function tallyRow(attr,key,dot,label,n){ + return `<div class="vrow" ${attr}="${key}"><span class="vdot"${dot?` data-v="${dot}"`:''}></span>${label}<span class="vn">${n}</span></div>`; +} +function tallyTotal(n){return `<div class="vrow vtot">total<span class="vn">${n}</span></div>`;} +function updateVTally(){ + const el=$('vtally'); if(!el)return; + const lamps=[...document.querySelectorAll('.vlamp')]; + const n={off:0,amber:0,green:0}; + lamps.forEach(l=>{n[l.dataset.v]=(n[l.dataset.v]||0)+1;}); + el.innerHTML= + tallyRow('data-v','green','green','done',n.green)+ + tallyRow('data-v','amber','amber','in progress',n.amber)+ + tallyRow('data-v','off','','not done',n.off)+ + tallyTotal(lamps.length)+ + `<button class="vcopy" type="button">copy for source</button>`; + el.querySelectorAll('.vrow[data-v]').forEach(row=>row.addEventListener('click',()=>{ + VJUMP[row.dataset.v]=0; auditNext(row.dataset.v); + })); + el.querySelector('.vcopy').addEventListener('click',e=>gvCopy(e.target)); +} +/* audit stepper: a row click jumps to the first card in that state and docks a + floating pill; clicking the pill advances through the rest without scrolling + back to the index. The card list is re-read on every step, so lamp changes + mid-audit are picked up. Esc or the x dismisses. */ +const VAUDIT_LBL={green:'done',amber:'in progress',off:'not done'}; +function auditNext(v){ + const cards=[...document.querySelectorAll('.vlamp')].filter(l=>l.dataset.v===v) + .map(l=>l.closest('.card')); + if(!cards.length){auditStop();return;} + const i=VJUMP[v]%cards.length; VJUMP[v]++; + location.hash='';location.hash=cards[i].id; /* re-fire :target ring on repeat visits */ + let pill=$('vaudit'); + if(!pill){pill=document.createElement('div');pill.id='vaudit';document.body.appendChild(pill); + pill.addEventListener('click',e=>{ + if(e.target.classList.contains('vx')){auditStop();return;} + auditNext(pill.dataset.v);});} + pill.dataset.v=v; + pill.innerHTML=`<span class="vdot" data-v="${v==='off'?'':v}"></span>`+ + `${VAUDIT_LBL[v]} ${i+1}/${cards.length} · next ▸<span class="vx" title="stop auditing">✕</span>`; +} +function auditStop(){const p=$('vaudit');if(p)p.remove();} +addEventListener('keydown',e=>{if(e.key==='Escape')auditStop();}); +/* card(host, no, name, htmlOrBuild, note) — the declarative card record. + htmlOrBuild: a legacy stage-HTML string, or a builder function (stage, rd) => handle + that instantiates a DUPRE.* instrument into the stage; rd(txt) writes the card readout. */ +/* Recover a card's builder from the arrow it was handed — the first DUPRE.<name>( + in the source — and read its POLICY record off it. Single source: the record + lives on DUPRE.<builder>.POLICY in widgets.js and the card only displays it, so the + page can't drift from the code. Unclassified cards read '—', which is the point: + the gallery becomes the colour pass's visible worklist. */ +function cardPolicy(html){ + if(typeof html!=='function') return null; + /* Assumes the FIRST DUPRE.<name>( in the arrow is the builder — true because every + card is (st,rd)=>DUPRE.builder(st,{...}) and helpers are destructured, called + bare. An arrow that referenced a second DUPRE.<name>( before its builder would + mis-derive; none do. A wrapped or renamed builder reads '—' rather than + wrong, which is safe. */ + const m=html.toString().match(/DUPRE\.(\w+)\s*\(/); + return (m && DUPRE[m[1]] && DUPRE[m[1]].POLICY) || null; +} +function card(host, no, name, html, note){ + const isBuild=typeof html==='function'; + const P=cardPolicy(html), kind=P&&P.kind; + const meta=kind&&DUPRE.POLICIES[kind]; + const free=meta&&meta.free; + const polTip=meta?(meta.gist||''):'policy not yet classified'; + const c=document.createElement('div'); c.className='card'; c.id='card-'+no; + c.dataset.cpol=kind||'none'; + c.innerHTML=`<div class="wname"><a class="no" href="#card-${no}">${no}</a>${name}`+ + `<span class="cpol" data-pol="${kind||'none'}" data-free="${kind?free:''}" title="${polTip.replace(/"/g,'"')}">${kind||'—'}</span>`+ + `<span class="vlamp" data-v="off"></span></div>`+ + `<div class="stagew">${isBuild?'':html}</div><div class="wrd" id="rd-${no}">—</div>`+ + `<div class="opts"></div><div class="wnote">${note}</div>`; + const lamp=c.querySelector('.vlamp'), VKEY='gv-'+no, VSTATES=['off','amber','green']; + const setV=v=>{lamp.dataset.v=v;lamp.title=VLAMP_TITLES[v];updateVTally();}; + setV(localStorage.getItem(VKEY)||VSTATUS[no]||'off'); + lamp.addEventListener('click',e=>{e.stopPropagation(); + const v=VSTATES[(VSTATES.indexOf(lamp.dataset.v)+1)%3]; + setV(v);localStorage.setItem(VKEY,v);}); + const inf=(typeof INFO!=='undefined')&&INFO[no]; + if(inf){const d=document.createElement('details'); d.className='winfo'; + let rows=''; + if(P){ + rows+=`<div class="ik">policy</div><div class="iv"><b>${kind}</b> — ${meta.gist}</div>`; + rows+=`<div class="ik">why</div><div class="iv">${P.why}</div>`; + rows+=`<div class="ik">authentic</div><div class="iv">${P.authentic}</div>`; + } else { + rows+=`<div class="ik">policy</div><div class="iv">unclassified — colour pass pending</div>`; + } + for(const [k,lbl] of INFO_FIELDS) if(inf[k]) rows+=`<div class="ik">${lbl}</div><div class="iv">${inf[k]}</div>`; + if(inf.ref) rows+=`<div class="ik">reference</div><div class="iv"><a href="${inf.ref}" target="_blank">photo</a></div>`; + d.innerHTML=`<summary>spec sheet</summary><div class="igrid">${rows}</div>`; + c.appendChild(d);} + host.appendChild(c); + if(isBuild)c.dupre=html(c.querySelector('.stagew'),txt=>setRd(no,txt)); + return c; +} +function setRd(no,txt){const e=$('rd-'+no);if(e)e.textContent=txt;} +/* Colour-policy tally: free / locked / unclassified across all cards, so the + index shows how far the colour pass has reached the way the validation tally + shows the walk. Free = accent+screen; locked = the four protected policies; + the rest await classification. Clicking a row jumps through its cards. */ +const FREEP=['accent','screen'], LOCKP=['coded','emissive','relational','material']; +const PJUMP={free:0,locked:0,none:0}; +function updatePolTally(){ + const el=$('ptally'); if(!el)return; + const cards=[...document.querySelectorAll('.card')]; + const grp=p=>FREEP.includes(p)?'free':LOCKP.includes(p)?'locked':'none'; + const n={free:0,locked:0,none:0}; + cards.forEach(c=>{n[grp(c.dataset.cpol)]++;}); + el.innerHTML= + tallyRow('data-g','free','green','free',n.free)+ + tallyRow('data-g','locked','amber','locked',n.locked)+ + tallyRow('data-g','none','','unclassified',n.none)+ + tallyTotal(cards.length); + el.querySelectorAll('.vrow[data-g]').forEach(row=>row.addEventListener('click',()=>{ + const g=row.dataset.g, hits=cards.filter(c=>grp(c.dataset.cpol)===g); + if(!hits.length)return; const i=PJUMP[g]++%hits.length; + location.hash=''; location.hash=hits[i].id;})); +} +/* drag helpers, seg7, buildBars live in widgets.js (DUPRE) */ + +/* ============ CONTROLS ============ */ +const C=$('controls'); +card(C,'01','Slide toggle', + (st,rd)=>DUPRE.slideToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + '<b>on / off, the touchscreen way.</b> The one born-digital control in the kit — the smartphone pill toggle the live net/bt/audio panels use. Click to flip; readout shows the state. Physical cousins: the bat-handle (R03) and the rocker (N01).'); +card(C,'02','Console key', + (st,rd)=>DUPRE.consoleKeys(st,{onChange:(i,t)=>rd(t)}), + '<b>physical push button.</b> Mutually exclusive — click one to engage. Green = live, terracotta = muted, gold = any other engaged key.'); +card(C,'03','Horizontal fader', + (st,rd)=>DUPRE.faderH(st,{value:68,onChange:(v,t)=>rd(t)}), + '<b>continuous 0-100.</b> Per-device volume, brightness. Drag the gold cap; readout tracks.'); +card(C,'04','Vertical fader', + (st,rd)=>{let ready=false; + const upd=()=>{if(ready)rd('A '+Math.round(a.get())+' · B '+Math.round(b.get()));}; + const a=DUPRE.faderV(st,{value:60,onChange:upd}); + const b=DUPRE.faderV(st,{value:35,onChange:upd}); + ready=true;upd();return {a,b};}, + '<b>channel-strip style.</b> A mixer column per device. Drag each fader up or down.'); +card(C,'05','Rotary knob', + (st,rd)=>DUPRE.knob(st,{value:53,onChange:v=>rd('gain '+Math.round(v))}), + '<b>dial in a value.</b> Volume/gain the analog way. Drag up/down to turn; readout shows the level.'); +card(C,'06','Segmented selector', + (st,rd)=>DUPRE.segmented(st,{onChange:(i,t)=>rd(t)}), + '<b>pick one of a few.</b> Timer type, layout mode. Click a segment; readout names the choice. The lit segment ships in amber, green, or red — the accent chips below switch it.'); +card(C,'07','Chip toggle', + (st,rd)=>DUPRE.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + '<b>inline binary.</b> A soft toggle inside a line of text. Click to flip. The lit colour is the consumer\'s to pick from the accent family, because "on" is good news in one panel and a fault in the next — amber by default.'); +card(C,'08','Arm-to-fire', + (st,rd)=>DUPRE.armButton(st,{onChange:(v,t)=>rd(t)}), + '<b>two-stage confirm.</b> Destructive actions. First click arms (red), second fires. Readout shows the stage.'); +card(C,'09','Lamp row', + (st,rd)=>DUPRE.lampRow(st,{onChange:(i,t)=>rd(t)}), + '<b>actionable list item.</b> Lamp + name + status. Click to cycle idle → connecting → connected.'); +card(C,'24','Rotary selector', + (st,rd)=>DUPRE.rotarySelector(st,{onChange:(v,t)=>rd(t)}), + '<b>pick one of N by position.</b> Printed detents, the pointer names the value. Click to turn; readout shows it.'); +card(C,'25','Slide-rule dial', + (st,rd)=>DUPRE.slideRule(st,{onChange:(v,t)=>rd(t)}), + '<b>value on a printed scale.</b> A lit pointer glides a warm-backlit strip. Printed numerals are the majors; the units between them carry minor ticks. Click any of them to jump, or focus and press ←/→ (↑/↓) to step a unit; readout shows it. Four faces — warm backlit, chrome, 80s black glass, marantz blue — on the chips below.'); +card(C,'N01','Rocker power switch', + (st,rd)=>DUPRE.rocker(st,{on:true,onChange:(v,t)=>rd(t)}), + '<b>hard on / off, lit legend.</b> A master power paddle — the pressed half glows. Click to rock.'); +card(C,'N02','Transport cluster', + (st,rd)=>DUPRE.transport(st,{mode:'play',animate:!reduced,onChange:(m,t)=>rd(t)}), + '<b>run / pause / capture.</b> One mode lit at a time, reels turn while it plays. Click a button.'); +card(C,'N03','Radio preset bank', + (st,rd)=>DUPRE.presetBank(st,{onChange:(i,t)=>rd(t)}), + '<b>pick one, the rest release.</b> The car-radio preset row — mechanically exclusive. Click one.'); +card(C,'N04','Concentric dual knob', + (st,rd)=>DUPRE.dualKnob(st,{outer:50,inner:50,onChange:(o,i)=>rd('bass '+Math.round(o)+' · treble '+Math.round(i))}), + '<b>two values on one spindle.</b> Bass / treble, coarse / fine. Drag the outer ring or inner cap — each is independent.'); +card(C,'N05','Rotary encoder + LED ring', + (st,rd)=>DUPRE.encoder(st,{value:7,onChange:(v,t)=>rd(t)}), + '<b>endless dial, lit position.</b> A detentless encoder; the LED arc lights to the level and the value keeps accumulating. Drag to turn.'); +card(C,'N06','Keyed mode switch', + (st,rd)=>DUPRE.keySwitch(st,{index:1,onChange:(i,t)=>rd(t)}), + '<b>guarded three-position mode.</b> Off · on · run. The key-bit points at the live position. Click to turn.'); +card(C,'N07','Center-detented crossfader', + (st,rd)=>DUPRE.crossfader(st,{onChange:(v,t)=>rd(t)}), + '<b>throw to either side of zero.</b> A fader notched at center — balance, L/R mix. Drag; readout shows the signed value.'); +card(C,'N08','Thumbwheel', + (st,rd)=>DUPRE.thumbwheel(st,{value:42,onChange:(v,t)=>rd(t)}), + '<b>ribbed edge-wheel, one value.</b> A knurled wheel with a windowed number. Drag up/down to roll.'); +card(C,'N09','DIP-switch bank', + (st,rd)=>DUPRE.dipBank(st,{onChange:(w,t)=>rd(t)}), + '<b>a bank of hard flags.</b> Hardware slide switches — up is on. Click each to flip; readout is the binary word.'); +card(C,'N10','Jog / shuttle wheel', + (st,rd)=>DUPRE.jogWheel(st,{onChange:(v,t)=>rd(t)}), + '<b>scrub fine, shuttle fast.</b> The edit-deck wheel. Drag to jog the inner wheel; readout shows the position.'); +card(C,'R02','Calibrated vernier dial', + (st,rd)=>DUPRE.vernierDial(st,{value:42.0,onChange:(v,t)=>rd(t)}), + '<b>precision on a rotating disc.</b> The big lab dial — the calibrated disc turns under a fixed hairline. Drag up/down; readout reads to a tenth. After a 1950s lab-oscillator vernier.'); +card(C,'R03','Bat-handle toggle', + (st,rd)=>DUPRE.batToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + '<b>the period power switch.</b> Chrome bat lever on a hex bushing, snaps up ON / down OFF. Click to throw it. After a lab-instrument panel toggle.'); +card(C,'R04','Bakelite fluted knob', + (st,rd)=>DUPRE.flutedKnob(st,{value:63,onChange:(v,t)=>rd(t)}), + '<b>the skirted console knob.</b> Scalloped bakelite skirt, glossy dome, amber index over a printed 0-10 scale. Drag up/down to turn. After a vintage console mixer knob.'); +card(C,'R05','Filter slider bank', + (st,rd)=>DUPRE.filterBank(st,{onChange:(v,t)=>rd(t)}), + '<b>a wall of band faders.</b> Twelve bands on a screwed faceplate, dB rails both sides. Drag any cap; readout names band and level. Skins split three independent axes — panel (silver hi-fi after the Pioneer SG-9500 / studio black after the Technics SH-8065), cap shape (tall block fader after the Zaxcom Oasis / short ribbed / chrome T), and cap color (black white-index / red / green / blue / amber stripes / chrome / cream). The chips below mix them freely.'); +card(C,'R06','Chicken-head selector', + (st,rd)=>DUPRE.chickenHead(st,{index:2,onChange:(i,t)=>rd(t)}), + '<b>the pointer-lever switch.</b> The tapered bakelite lever IS the indicator — it aims at the engraved position. Click to step through. After a modulator mode switch.'); +card(C,'R12','Chrome slot fader', + (st,rd)=>DUPRE.slotFader(st,{value:-4,onChange:(v,t)=>rd(t)}), + '<b>the console gain slide.</b> A chrome T-handle rides a recessed slot, the dB scale engraved on the plate — gain above zero, cut below. Drag; readout shows signed dB. After a vintage channel-strip output fader.'); +card(C,'R14','Spade-pointer tuning knob', + (st,rd)=>DUPRE.spadeKnob(st,{value:8.3,onChange:(v,t)=>rd(t)}), + '<b>the pointer rides the knob.</b> A knurled bakelite knob carries a bright spade pointer over a scale engraved into the panel itself. Drag up/down to tune. After a 1920s radio receiver dial.'); +card(C,'R15','Multi-band dial', + (st,rd)=>DUPRE.multiBandDial(st,{onChange:(v,t)=>rd(t)}), + '<b>four scales, one needle.</b> Nested band arcs on a cream face; the active ring reads bold. Drag the left dial to tune; click the bandspread dial to switch bands. After a shortwave receiver dial pair.'); +card(C,'R16','Entry keypad', + (st,rd)=>DUPRE.entryKeypad(st,{onChange:(v,t)=>rd(t)}), + '<b>a composed entry instrument.</b> Worn keycaps feed the amber display; the lamp watches the buffer; the amber keys confirm and clear. Click the keys. After an industrial keypad prop.'); +card(C,'R18','Thumb-slide attenuator pair', + (st,rd)=>DUPRE.thumbSlide(st,{onChange:(v,t)=>rd(t)}), + '<b>a lit column of numbers, a thumb tab.</b> The scale glows inside the rail; the cream tab rides beside it and the nearest figure lights. Drag either channel. After a compressor blend/mix pair.'); +card(C,'R19','Waveform region editor', + (st,rd)=>DUPRE.waveRegion(st,{start:22,end:76,onChange:(v,t)=>rd(t)}), + '<b>pick a region on the wave.</b> The monochrome LCD shows the sample; drag near either flag to move START or END, and the selection draws bright. After a sampler edit screen.'); +card(C,'R20','Drum roller selector', + (st,rd)=>DUPRE.drumRoller(st,{onChange:(v,t)=>rd(t)}), + '<b>roll to the number.</b> The numbered drum shows through a tall window and the center value sits on an inverted chip. Drag either drum. After an equalizer tone selector.'); +card(C,'R21','LED program row', + (st,rd)=>DUPRE.ledRow(st,{index:5,onChange:(v,t)=>rd(t)}), + '<b>identical keys, one lit dot.</b> The LED above the button carries the selection, not the key itself — the classic program-select row. Click a key; readout names the program. After a digital reverb’s program bank.'); +card(C,'R22','Three-position slide', + (st,rd)=>DUPRE.pillSlide(st,{index:1,onChange:(v,t)=>rd(t)}), + '<b>a detented pill, three stops.</b> The chrome pill parks at A, AB, or B, and the LED pair below tells the truth — both light on AB. Click a position. After a drum machine variation switch.'); +card(C,'R23','Spun-aluminum knob', + (st,rd)=>DUPRE.spunKnob(st,{value:62,onChange:(v,t)=>rd(t)}), + '<b>bright metal, machined rings.</b> A spun face with concentric tooling marks in a knurled grip ring, red index over dot ticks. Drag up/down. After a boutique pedal knob, confirmed by an avionics cluster.'); +card(C,'R24','Stomp switch + jewel', + (st,rd)=>DUPRE.stompSwitch(st,{on:false,onChange:(v,t)=>rd(t)}), + '<b>the engage pair.</b> A chrome dome stomp and the faceted jewel that reports it — press to engage, the jewel lights; press again to bypass. Click it. After a tremolo pedal footswitch.'); +card(C,'R27','Winged gain selector', + (st,rd)=>DUPRE.wingSelector(st,{index:7,onChange:(v,t)=>rd(t)}), + '<b>the red T-bar, stepped.</b> A bar-grip handle over a dot ring, snapping between labeled gain detents. Drag up/down to step it. After a classic mic-preamp gain switch.'); +card(C,'R28','Rotary disc switch', + (st,rd)=>DUPRE.discSwitch(st,{index:1,onChange:(v,t)=>rd(t)}), + '<b>the master power disc.</b> The whole red disc turns, grip bar molded in — a heavy three-position selector for OFF, ON, and COMBINE. Click to step. After a marine battery switch.'); +card(C,'R29','Guarded toggle', + (st,rd)=>DUPRE.guardedToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + '<b>friction sized to consequence.</b> Guard posts flank the lever and a red collar marks it critical — the avionics way to prevent an accidental throw. Click to throw it deliberately. After a flight-deck switch panel.'); +card(C,'R32','Mechanical timer dial', + (st,rd)=>DUPRE.timerDial(st,{minutes:0,onChange:(v,t)=>rd(t)}), + '<b>turn to start, push to stop.</b> Drag the knurled dial to wind minutes onto the fixed red index; it winds back down to OFF on its own (demo runs a minute per second). Click the red knob to stop it dead. After a mechanical interval timer.'); +card(C,'R33','Four-way rocker', + (st,rd)=>DUPRE.rockerPad(st,{onChange:(v,t)=>rd(t)}), + '<b>discrete direction, one press at a time.</b> Click an arrow (the whole quadrant is the target) to step that way; the readout tracks the cursor position it drives. After a remote-control navigation pad.'); +card(C,'R34','Four-way toggle selector', + (st,rd)=>DUPRE.fourWayToggle(st,{position:'C',onChange:(v,t)=>rd(t)}), + '<b>a lever that lives in one of four states.</b> Click a quadrant and the ball lever throws to A, B, C or D; the matching corner lamp takes the light. The rocker steps — this one selects. After a test-panel quadrant switch.'); +card(C,'R37','Pin routing matrix', + (st,rd)=>DUPRE.pinMatrix(st,{onChange:(v,t)=>rd(t)}), + '<b>many-to-many routing you can read.</b> Sources are rows, destinations are columns; click an intersection to seat a pin and the route exists. Every simultaneous route stays visible. After the EMS VCS3 pin matrix.'); +card(C,'R38','Dead-man button', + (st,rd)=>DUPRE.deadMan(st,{onChange:(v,t)=>rd(t)}), + '<b>active only while held.</b> Press and hold — the ring arms, the lamp runs, the dwell clock counts. Let go anywhere and it drops to SAFE. The kit has toggles and momentaries; this is the only control whose state IS your grip.'); +card(C,'R39','Rotary telephone dial', + (st,rd)=>DUPRE.telephoneDial(st,{onChange:(v,t)=>rd(t)}), + '<b>numeric entry embodied as time.</b> Click a hole: the finger wheel winds to the stop and spins back, and the digit lands in the number. Bigger digits take longer, exactly like the pulses did. After a Western Electric dial.'); +card(C,'R40','Circuit breaker panel', + (st,rd)=>DUPRE.breakerPanel(st,{onChange:(v,t)=>rd(t)}), + '<b>the first control the system can throw.</b> Click a breaker to close or open it; hit TRIP and a fault pops one out to the tripped mid-position, amber collar showing. A tripped breaker must be pulled to reset before it closes again. After an aircraft breaker row.'); +card(C,'R41','DSKY verb-noun panel', + (st,rd)=>DUPRE.dsky(st,{onChange:(v,t)=>rd(t)}), + '<b>a command grammar, not a button per command.</b> VERB is the action, NOUN is the operand: press VERB 3 5 ENTR for the lamp test. Wrong grammar lights OPR ERR. Two keys plus digits reach every function — the Apollo answer to a panel with too few buttons.'); +card(C,'R42','Cam-timer program drum', + (st,rd)=>DUPRE.camTimer(st,{position:0,onChange:(v,t)=>rd(t)}), + '<b>a procedure as a rotating position.</b> The ring is the whole program — fill, wash, rinse, spin — and the pointer is where you are in it. Click to advance (or start from OFF); it then walks itself through the steps. After a washing-machine cam timer.'); +card(C,'R48','Knife switch', + (st,rd)=>DUPRE.knifeSwitch(st,{closed:true,onChange:(v,t)=>rd(t)}), + '<b>the visible disconnect.</b> Copper blades either sit in their jaws or hang open in the air — you can SEE the circuit is broken, which is the entire safety argument. Click the handle to throw it. After early power-station switchboards.'); +card(C,'R49','Decade switch box', + (st,rd)=>DUPRE.decadeBox(st,{digits:[3,5,0,0],onChange:(v,t)=>rd(t)}), + '<b>a number composed a digit at a time.</b> Four skirted knobs, one per decade — drag each to its digit and the total just IS their sum. The thumbwheel does one digit; this is how a lab built a precise value. After a General Radio decade resistance box.'); +card(C,'R50','Two-hand safety control', + (st,rd)=>DUPRE.twoHandSafety(st,{onChange:(v,t)=>rd(t)}), + '<b>both hands, deliberately.</b> Click one palm button to arm the window, the other within half a second to cycle the press. Too slow — TIE-DOWN fault, start over. The hardware keeps hands out of the die; the grammar survives on screen. After OSHA press controls.'); +card(C,'R51','Voice-loop keyset', + (st,rd)=>DUPRE.voiceLoop(st,{onChange:(v,t)=>rd(t)}), + '<b>attention management, not selection.</b> Each loop is independent: click to monitor (green bar, flickering with activity), again to talk (amber, exclusive — one voice out), again to drop. Flight controllers ran a dozen of these at once. After a mission-control comm keyset.'); +card(C,'R57','ABC entry keypad', + (st,rd)=>DUPRE.abcKeypad(st,{onChange:(v,t)=>rd(t)}), + '<b>letters on a faceplate.</b> A-Z laid out alphabetically, not QWERTY — the industrial convention wherever the operator is not assumed to touch-type. Click the keys, or click the plate and type: it takes the keyboard once focused, and every key routes through the same handle a click does. DEL takes back one character, CLR wipes the lot, ENT commits. R16 enters digits; this is its alphanumeric sibling, and the only card in the kit that takes free text. After fleet and kiosk keypads: the membrane plate\'s colour-coded CLR / ENT, the stainless plate\'s letters-left arrangement and its backspace. Its CANCEL is dropped — that plate is a whole terminal with a transaction to abandon, where this is one control in a panel that owns its own dismiss.'); + +card(C,'R58','Index typewriter', + (st,rd)=>DUPRE.indexPlate(st,{onChange:(v,t)=>rd(t)}), + '<b>type without a keyboard.</b> One hand walks the stylus over a printed plate, the other pulls the lever, and only the lever prints — hunt as long as you like and nothing happens until the second hand moves. Select and commit on two separate controls, which no other card in the kit does. The plate is the AEG Mignon Model 4\'s own (1924), transcribed from the reference: both full cases so there is no shift key, the accents and fractions a keyboard skips, and two economies worth noticing. There is <b>no 1 and no 0</b> — you type them with lowercase l and capital O. And the inversion is positional, not by case: the capitals block is dark discs on a light panel and the lowercase block is its photographic negative, but J and j sit out on the halftone ring, un-inverted, with the punctuation.'); + +/* ============ METERS & GAUGES ============ */ +const M=$('meters'); +card(M,'10','Needle gauge', + (st,rd)=>DUPRE.needleGauge(st,{value:50,onChange:(v,t)=>rd(t)}), + '<b>analog dial.</b> Throughput, battery, volume. Drag up/down to sweep the needle; readout tracks. <b>Smooth sweep wants a Cairo/GTK area.</b>'); +card(M,'11','Stereo VU (LED bar)', + (st,rd)=>DUPRE.vuPair(st,{onChange:(v,t)=>rd(t)}), + '<b>live signal level.</b> The sound panel meter row, peak-hold outline. Runs a live signal; readout shows L/R.'); +card(M,'12','Mini signal (4-bar)', + (st,rd)=>DUPRE.miniSig(st,{onChange:(v,t)=>rd(t)}), + '<b>compact activity.</b> Per-row "is this device playing". Live signal; readout shows activity %.'); +card(M,'13','Signal ladder', + (st,rd)=>DUPRE.signalLadder(st,{value:3,onChange:(v,t)=>rd(t)}), + '<b>discrete strength.</b> Wifi bars, bt RSSI — a stepped 0-4. Click to cycle strength; readout shows bars.'); +card(M,'14','Linear fuel bar', + (st,rd)=>{const a=DUPRE.fuelBar(st,{value:72,onChange:(v,t)=>rd(t)}); + const b=DUPRE.fuelBar(st,{value:40,onChange:(v,t)=>rd(t)}); + a.set(72);return {a,b};}, + '<b>a single 0-100.</b> Battery, disk, download. Drag either bar; warn tint under threshold; readout tracks.'); +card(M,'15','Radial ring', + (st,rd)=>DUPRE.radialRing(st,{value:68,onChange:(v,t)=>rd(t)}), + '<b>percentage as a donut.</b> CPU, battery. Drag up/down to set; conic-gradient tracks the readout.'); +card(M,'16','Sparkline', + (st,rd)=>DUPRE.sparkline(st,{onChange:(v,t)=>rd(t)}), + '<b>recent history.</b> Throughput/CPU over the last minute. Live trace; readout shows the current value.'); +card(M,'17','Waveform strip', + (st,rd)=>DUPRE.waveStrip(st,{onChange:(v,t)=>rd(t)}), + '<b>audio waveform / scope.</b> A richer signal view. Live trace; readout shows amplitude. <b>Needs a drawing surface.</b>'); +card(M,'N11','Oscilloscope', + (st,rd)=>DUPRE.scope(st,{onChange:(v,t)=>rd(t)}), + '<b>a live phosphor trace.</b> A waveform swept over a graticule. Live; readout shows Vpp. <b>Needs a Cairo/GTK area for a smooth trace.</b>'); +card(M,'N12','Spectrum / EQ bars', + (st,rd)=>DUPRE.eqBars(st,{onChange:(v,t)=>rd(t)}), + '<b>level across bands.</b> A live multi-band bar graph — spectrum, per-core CPU. Live; readout shows the peak band.'); +card(M,'N13','Crossed-needle meter', + (st,rd)=>DUPRE.crossNeedle(st,{value:55,onChange:(v,t)=>rd(t)}), + '<b>two readings, one face.</b> Forward + reflected needles cross. Drag up/down to drive power; readout shows both. <b>Round build wants a drawing surface.</b>'); +card(M,'N14','Thermometer column', + (st,rd)=>DUPRE.thermometer(st,{value:58,onChange:(v,t)=>rd(t)}), + '<b>a rising column.</b> The classic mercury tube — temperature, a climbing load. Drag up/down; readout shows the value.'); +card(M,'N15','Bourdon pressure gauge', + (st,rd)=>DUPRE.bourdon(st,{value:45,onChange:(v,t)=>rd(t)}), + '<b>a round dial with a red zone.</b> Needle over a printed arc, redline in terracotta. Drag to set PSI; readout tracks. <b>Round sweep is a drawing-area job.</b>'); +card(M,'N16','Strip-chart recorder', + (st,rd)=>DUPRE.stripChart(st,{onChange:(v,t)=>rd(t)}), + '<b>a scrolling pen trace.</b> History scrolls left, the pen writes at the right edge. Live; readout shows the current value. <b>Real trace wants a drawing surface.</b>'); +card(M,'N17','Correlation meter (±)', + (st,rd)=>DUPRE.corrMeter(st,{value:58,onChange:(v,t)=>rd(t)}), + '<b>deviation from center.</b> The needle rests at 0 and swings ±. Drag left/right; readout shows the signed value.'); +card(M,'N18','Battery-cell gauge', + (st,rd)=>DUPRE.battCells(st,{value:62,onChange:(v,t)=>rd(t)}), + '<b>charge as discrete cells.</b> Filled cells count up, the last warn in terracotta. Drag left/right to set; readout shows %.'); +card(M,'R01','Moving-coil VU meter', + (st,rd)=>DUPRE.mcVu(st,{onChange:(v,t)=>rd(t)}), + '<b>the classic cream-faced VU.</b> Printed dB arc, red zone past 0, ballistic needle. Runs the live signal; readout shows VU. After a vintage console meter pair.'); +card(M,'R07','Round panel meter', + (st,rd)=>DUPRE.roundMeter(st,{value:50,onChange:(v,t)=>rd(t)}), + '<b>the porthole lab meter.</b> Cream dial in a round black bezel, printed dB arc, black needle. Drag up/down to drive it; readout shows dB. After a modulator front meter.'); +card(M,'R08','Chrome MIN/MAX indicator', + (st,rd)=>DUPRE.chromeMinMax(st,{value:50,onChange:(v,t)=>rd(t)}), + '<b>the hi-fi trim window.</b> Polished chrome ring, brushed dome, a dark pointer sweeping MIN to MAX over the metal. Drag up/down; readout shows the position. After a hi-fi amplifier level window.'); +card(M,'R09','Black-face aviation gauge', + (st,rd)=>DUPRE.aviationGauge(st,{value:52,onChange:(v,t)=>rd(t)}), + '<b>amber on black, zone-marked.</b> The cockpit round: amber ticks and numerals on a black face, painted caution and limit sectors, amber needle. Drag up/down; readout tracks. After a flight-instrument cluster.'); +card(M,'R13','Edgewise strip meter', + (st,rd)=>DUPRE.edgeMeter(st,{value:.62,onChange:(v,t)=>rd(t)}), + '<b>a needle on a vertical scale.</b> The parchment strip reads attenuation down a compressed log scale; the bar rides its edge. Drag up/down; readout shows dB. After a channel-strip gain-reduction meter.'); +card(M,'R43','Attitude indicator', + (st,rd)=>DUPRE.attitudeIndicator(st,{onChange:(v,t)=>rd(t)}), + '<b>orientation, not a number.</b> The horizon rolls and shifts behind the fixed amber airplane — two axes in one read. Drag it: left/right banks, up/down pitches. The first two-axis instrument in the kit. After a cockpit artificial horizon.'); +card(M,'R44','Heading bug + servo needle', + (st,rd)=>DUPRE.headingBug(st,{value:90,onChange:(v,t)=>rd(t)}), + '<b>commanded versus actual on one dial.</b> Drag to park the amber bug where you want to go; the needle chases it with honest servo lag. The gap between them IS the information. After an HSI heading bug and synchro repeaters.'); +card(M,'R54','Vertical tape instrument', + (st,rd)=>DUPRE.verticalTape(st,{value:24,onChange:(v,t)=>rd(t)}), + '<b>the scale moves, the index does not.</b> A scrolling tape behind a fixed amber index — your value is always at eye level and the neighborhood above and below stays visible. Drag to spin the tape. After the Ki-57 remote tachometer; modern cockpits tape everything.'); +card(M,'R55','Twin-needle gauge', + (st,rd)=>DUPRE.twinNeedle(st,{fuel:2.4,oil:3.1,onChange:(v,t)=>rd(t)}), + '<b>two values, one housing, mirrored scales.</b> Fuel presses up the left arc, oil up the right, each with its own needle from the shared hub. Drag either half to set that side. Not the crossed-needle (N13) — these are read separately, not at their intersection. After the Ki-57 fuel/oil pressure gauge.'); +card(M,'R56','Comfort-zone crossed needles', + (st,rd)=>DUPRE.comfortMeter(st,{temp:72,humidity:45,onChange:(v,t)=>rd(t)}), + '<b>two values, one verdict.</b> Temperature and humidity needles cross over a face printed with judgments — the crossing point lands in TOO WARM, TOO DRY, or JUST RIGHT. N13 derives a number from the crossing; this one derives an opinion. Drag each half. After a brass weather-station comfort meter.'); +card(M,'R53','Circular chart recorder', + (st,rd)=>DUPRE.chartRecorder(st,{onChange:(v,t)=>rd(t)}), + '<b>cyclic time on round paper.</b> The pen sweeps a full day per revolution, so "same time yesterday" is the same angle and daily rhythm draws itself as a flower. Click for fresh paper. The strip chart shows a window; this shows the cycle. After a Partlow round-chart recorder.'); +card(M,'R17','Round CRT scope', + (st,rd)=>DUPRE.roundCrt(st,{onChange:(v,t)=>rd(t)}), + '<b>the round-tube scope.</b> Pale phosphor face in a screwed porthole bezel, dark graticule, a bright live trace. Runs live; readout shows Vpp. After a 1950s bench oscilloscope.'); + +/* ============ INDICATORS & READOUTS ============ */ +const I=$('indicators'); +card(I,'18','Status lamp', + (st,rd)=>DUPRE.statusLamps(st,{onChange:(v,t)=>rd(t)}), + '<b>one-glance health.</b> Green ok · gold engaged · red fail · dim off · pulsing busy. Click any lamp to cycle its state.'); +card(I,'19','Badge / tag', + (st,rd)=>DUPRE.badges(st,{onChange:(v,t)=>rd(t)}), + '<b>a labelled flag.</b> MUTED, AIRPLANE, a band tag. Click a badge to cycle its variant.'); +card(I,'20','Tabular readout', + (st,rd)=>DUPRE.tabularReadout(st,{onChange:(v,t)=>rd(t)}), + '<b>a precise number.</b> Clock, countdown, volume %. Runs a live countdown; click to pause/resume.'); +card(I,'21','Engraved label', + (st,rd)=>DUPRE.engravedLabel(st,{onChange:(v,t)=>rd(t)}), + '<b>section divider.</b> The hairline-flanked caps label with a count. Click to bump the count.'); +card(I,'22','Output well', + (st,rd)=>DUPRE.outputWell(st,{onChange:(v,t)=>rd(t)}), + '<b>streaming step log.</b> Lamp-per-step with evidence. Click to stream the next step.'); +card(I,'23','Toast / status line', + (st,rd)=>DUPRE.toast(st,{onChange:(v,t)=>rd(t)}), + '<b>transient confirmation.</b> The one-line result after an action. Click to fire the next toast.'); +card(I,'26','Nixie tube', + (st,rd)=>DUPRE.nixie(st,{onChange:(v,t)=>rd(t)}), + '<b>a single warm-glowing numeral.</b> One lit digit per tube, leading zeros dark. Click to increment the count.'); +card(I,'N20','Split-flap display', + (st)=>DUPRE.splitFlap(st,{rows:3,cells:6,words:['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','STATUS','FILTER','TUNING','NEEDLE','SWITCH','TOGGLE','DIMMER','CHROME','MODULE','OUTPUT','SCREEN','COPPER','DYNAMO','PISTON','MAGNET','BEACON','RADIAL','BRIDGE'],animate:!reduced,skin:'paper',font:'helv'}), + '<b>flips to the new value.</b> A three-row, six-cell board; every advance sends each row to a different random word from the pool, and each cell runs at its own mechanical rate, so letters and whole words finish at different times. After settling it dwells two seconds, then advances. Click to advance now. Skins: white cards with dark lettering (default), white ink on black, the kit cream, or the ivory board; face in Helvetica (default) or Berkeley Mono. The slider sets the flap rate.'); +card(I,'N21','Seven-segment display', + (st,rd)=>DUPRE.sevenSeg(st,{onChange:(v,t)=>rd(t)}), + '<b>a lit-segment number.</b> Green segments, distinct from the nixie glow. Live countdown; click to add a minute.'); +card(I,'N22','VFD marquee', + (st,rd)=>DUPRE.vfdMarquee(st,{onChange:(v,t)=>rd(t)}), + '<b>scrolling status line.</b> Teal glow, dot-matrix mesh, text tracks across. Scrolls live; click to change the message.'); +card(I,'N23','Annunciator panel', + (st,rd)=>DUPRE.annunciator(st,{onChange:(v,t)=>rd(t)}), + '<b>a grid of named alarms with the alarm lifecycle.</b> Cells light amber to warn, red to fault; any active alarm flashes MSTR CAUTION until ACK steadies it, a new alarm re-flashes it, RESET clears the board, TEST proves the bulbs. Click cells to raise alarms.'); +card(I,'N24','Jewel pilot lamps', + (st,rd)=>DUPRE.jewels(st,{onChange:(v,t)=>rd(t)}), + '<b>a faceted glass indicator.</b> The chunky bezel pilot lamp with a real bloom. Click a jewel to cycle red · amber · green · dark.'); +card(I,'N25','Tape counter', + (st,rd)=>DUPRE.tapeCounter(st,{onChange:(v,t)=>rd(t)}), + '<b>rolling odometer digits.</b> Numbered wheels roll to the next figure. Rolls live; readout shows the total.'); +card(I,'N26','Analog clock', + (st,rd)=>DUPRE.analogClock(st,{onChange:(v,t)=>rd(t)}), + '<b>time on a real face.</b> Engraved ticks, three hands, live seconds sweep. Runs live; readout shows the time.'); +card(I,'N27','Frequency-dial scale', + (st,rd)=>DUPRE.freqScale(st,{onChange:(v,t)=>rd(t)}), + '<b>a printed log scale.</b> Marks crowd low, spread high — a real logarithmic axis. Drag the pointer; readout shows the frequency.'); +card(I,'N28','Patch-bay jack field', + (st,rd)=>DUPRE.patchBay(st,{onChange:(v,t)=>rd(t)}), + '<b>routing shown as patches.</b> A grid of jacks with cables between connected pairs. Click one jack then another to add/remove a cable.'); +card(I,'R10','Data matrix readout', + (st,rd)=>DUPRE.dataMatrix(st,{onChange:(v,t)=>rd(t)}), + '<b>a page of amber fields.</b> The console data block — several labeled values on one lit matrix. Click to cycle pages; readout names the page. After a cockpit data readout.'); +card(I,'R11','Warning flag window', + (st,rd)=>DUPRE.warningFlag(st,{onChange:(v,t)=>rd(t)}), + '<b>the mechanical alarm flag.</b> A striped barber-pole slides into a window when the condition trips — not a lamp, a flag. Click to trip and clear it. After the VIB flag on an altimeter.'); +card(I,'R25','Fourteen-segment display', + (st,rd)=>DUPRE.seg14(st,{onChange:(v,t)=>rd(t)}), + '<b>segments that spell.</b> The starburst alphanumeric — diagonals and split bars let it write words, not just digits. Click to cycle the word. After a pedal preset display.'); +card(I,'R26','Response graph', + (st,rd)=>DUPRE.responseGraph(st,{onChange:(v,t)=>rd(t)}), + '<b>a curve on labeled axes.</b> Log frequency across, dB up the side, and the amber peak is yours to place — drag it anywhere on the plot. After the response display of a mastering processor.'); +card(I,'R30','Telegraph indicator', + (st,rd)=>DUPRE.telegraphIndicator(st,{onChange:(v,t)=>rd(t)}), + '<b>state on a pie of sectors.</b> The pointer and its little flag name the active state, engine-telegraph style. Click to step the state. After an electric indicator dial.'); +card(I,'R31','Radar sweep', + (st,rd)=>DUPRE.radarSweep(st,{onChange:(v,t)=>rd(t)}), + '<b>the rotating scan.</b> A sweep beam circles the bearing ring, contacts bloom as it passes and fade after. Click to mark the current bearing. After a radar PPI scope.'); +card(I,'R35','Day-date disc calendar', + (st,rd)=>DUPRE.dayDateCal(st,{onChange:(v,t)=>rd(t)}), + '<b>two coaxial discs, one index.</b> The outer ring carries the date, the inner disc the weekday; both read where the yellow hand points. Starts on today; click to roll midnight forward and watch the discs step. After a watch day-date movement.'); +card(I,'R36','LED dot matrix', + (st,rd)=>DUPRE.dotMatrix(st,{onChange:(v,t)=>rd(t)}), + '<b>a bitmap, one red dot at a time.</b> 64 discrete LEDs behind a tinted window — segments draw digits, but a matrix draws anything. Click dots to paint the pattern; the readout counts what is lit. After the K4816 pattern generator faceplate.'); +card(I,'R45','Flip-disc tile array', + (st,rd)=>DUPRE.flipDisc(st,{onChange:(v,t)=>rd(t)}), + '<b>the mechanical pixel.</b> Each disc flips between its yellow face and its black back and then STAYS — no power, no glow, state that persists. Click a disc and watch it flip. The dot matrix glows; this one clacks. After transit flip-dot signs.'); +card(I,'R46','Dekatron counting ring', + (st,rd)=>DUPRE.dekatron(st,{onChange:(v,t)=>rd(t)}), + '<b>a count as a position, and the counting is visible.</b> Each click is a pulse; the neon glow steps one cathode around the ring and carries on wrap. Nixies show the digit — the Dekatron shows the arithmetic. After glow-transfer counter tubes.'); +card(I,'R47','Landing gear indicator', + (st,rd)=>DUPRE.gearIndicator(st,{onChange:(v,t)=>rd(t)}), + '<b>three greens or nothing.</b> A spatial lamp panel: nose and mains each get a light, green only when down and locked; the amber transit pulse means neither state. Click the wheel lever to cycle the gear. After a cockpit gear panel.'); +card(I,'R52','Blinkenlights front panel', + (st,rd)=>DUPRE.blinkenlights(st,{onChange:(v,t)=>rd(t)}), + '<b>the machine thinking, out loud.</b> Address and data lamps ripple with the live computation; the switch register below is yours — flip bits and watch the pattern change. DIP switches hold config; this shows work happening. After a PDP-11/70 front panel.'); + +/* ============ CONTROL WIRING ============ */ +/* 01 toggle */ +/* 01-09, 24, 25, N01-N10 extracted to widgets.js (DUPRE.*) — wired at their card records */ + +/* ============ METER WIRING (drag-driven) ============ */ +/* 10-17, N11-N18 extracted to widgets.js (DUPRE.*) — wired at their card records */ + +/* ============ INDICATOR WIRING ============ */ +/* 18-23, 26 extracted to widgets.js (DUPRE.*) — wired at their card records */ +/* N20-N28 extracted to widgets.js (DUPRE.*) — wired at their card records */ + +/* ============ REFERENCE-BATCH (R) INSTRUMENTS — SVG builders ============ */ +/* svgEl, polar, VUDB live in widgets.js (DUPRE) */ + +/* R01 ballistics: the page owns the signal chase; the meter just takes set(t) */ +function tickMcVu(){const a=lvl();const target=Math.min(1.02,a*1.12); + const t=MH.mcvu.get();MH.mcvu.set(t+(target-t)*(target>t?0.32:0.10));} + +/* R02-R06, R12, R14, R15 extracted to widgets.js (DUPRE.*) */ + + + + + + + + + +/* R07-R09 extracted to widgets.js (DUPRE.*) */ + +/* R10, R11 extracted to widgets.js (DUPRE.*) */ + + + + +/* R13, R17 extracted to widgets.js (DUPRE.*) */ + +/* R25, R26, R30, R31 extracted to widgets.js (DUPRE.*) */ + + + + +/* ============ LIVE SIGNAL LOOPS ============ */ +/* extracted meters: the page keeps the clock + demo signal, drives card handles */ +const MH={vu:$('card-11').dupre,mini:$('card-12').dupre,spark:$('card-16').dupre,wave:$('card-17').dupre, + scope:$('card-N11').dupre,eq:$('card-N12').dupre,strip:$('card-N16').dupre,mcvu:$('card-R01').dupre}; +const IH={flap:$('card-N20').dupre,seven:$('card-N21').dupre,vfd:$('card-N22').dupre, + counter:$('card-N25').dupre,clock:$('card-N26').dupre}; + +let ph=0; +const eqBands=Array.from({length:11},(_,i)=>({v:0.4,ph:i*0.6})); +function lvl(){return Math.max(0,Math.min(1,0.5+0.4*Math.sin(ph*1.3)+(Math.random()<0.15?Math.random()*0.4:0)-Math.random()*0.08));} +function fastTick(){ + ph+=0.09; + const a=lvl(),b=lvl(); + MH.vu.set(a,b);MH.mini.set(a); + MH.spark.push(0.5+0.42*Math.sin(ph*0.9)+(Math.random()-0.5)*0.25); + const env=Math.min(1,0.6+0.3*Math.sin(ph*0.4)); + const smp=[];for(let x=0;x<=170;x+=3)smp.push(Math.sin(x*0.18+ph*3)*Math.sin(x*0.05)); + MH.wave.set(smp,env); + const senv=Math.min(1,0.6+0.3*Math.sin(ph*0.4)); + const ssmp=[];for(let x=0;x<=176;x+=3)ssmp.push(Math.sin(x*0.16+ph*3.2)*senv); + MH.scope.set(ssmp,0.6+0.3*Math.sin(ph*0.4)); + eqBands.forEach(bd=>{bd.ph+=0.16;bd.v=Math.max(0.05,Math.min(1,0.5+0.42*Math.sin(bd.ph)+(Math.random()<0.2?Math.random()*0.3:0)-Math.random()*0.06));}); + MH.eq.set(eqBands.map(bd=>bd.v)); + MH.strip.push((28+24*Math.sin(ph*0.7)*(0.6+0.4*Math.sin(ph*0.13))-(Math.random()-0.5)*4)/56); +} +/* indicator loops: builders own state + clicks; the page keeps the clocks and + the time source (tick contract) */ +function tickClock(){const d=new Date();IH.clock.set(d.getHours(),d.getMinutes(),d.getSeconds());} +const FLAP_DWELL_MS=2000; let flapTimer; +// The board advances only after it has settled AND dwelt on the reading — +// a fixed interval would retarget mid-cascade and the panel would never rest. +if(!reduced)IH.flap.onSettle(()=>{clearTimeout(flapTimer);flapTimer=setTimeout(()=>IH.flap.next(),FLAP_DWELL_MS);}); +function tickSeven(){IH.seven.tick();} +function tickT(){$('card-20').dupre.tick();} +function tickVfd(){IH.vfd.tick();} +function tickCounter(){IH.counter.set(IH.counter.get()+137);} + +/* static paint for reduced motion */ +function paintStatic(){ + MH.vu.set(0.55,0.5);MH.mini.set(0.55); + MH.spark.fill(0.5); + MH.wave.set([],0.6); + eqBands.forEach((bd,i)=>bd.v=0.3+0.5*Math.abs(Math.sin(i*0.9))); + MH.eq.set(eqBands.map(bd=>bd.v)); + MH.scope.set(Array.from({length:60},(_,i)=>Math.sin(i*0.5)*18/22),0.74); + MH.strip.set(Array.from({length:60},(_,i)=>0.5+Math.sin(i*0.3)*16/56),0.5); + tickClock();IH.flap.set(0); + MH.mcvu.set(.5); +} + +if(!reduced){ + setInterval(fastTick,80); + setInterval(tickMcVu,80); + setInterval(tickClock,1000);tickClock(); + flapTimer=setTimeout(()=>IH.flap.next(),FLAP_DWELL_MS); + setInterval(tickVfd,32); + setInterval(tickCounter,1600); + setInterval(tickSeven,1000); + setInterval(tickT,1000); + document.querySelectorAll('.dupre-reel.dupre-spin').forEach(r=>r.style.animationPlayState='running'); +}else{ + paintStatic(); + document.querySelectorAll('.dupre-reel.dupre-spin').forEach(r=>r.style.animationPlayState='paused'); +} + +/* R35, R36 extracted to widgets.js (DUPRE.*) */ + + +/* R43, R44 extracted to widgets.js (DUPRE.*) */ + +/* R45-R47, R52 extracted to widgets.js (DUPRE.*) */ + + + + +/* R53-R56 extracted to widgets.js (DUPRE.*) */ + +/* ---- screen-family chips: first round, the clearly-screen instruments ---- + Defaults match each instrument's shipped color (var fallbacks), so no family is applied until a chip is clicked. + The nixie stays chipless on purpose: neon is only ever orange, so alternates would be historically false. */ +screenChips('R10',$('card-R10').dupre.el,['amber','green','red','blue','vfd'],'amber'); +screenChips('R17',$('card-R17').dupre.el,['green','amber','red','blue','vfd'],'green'); +screenChips('R19',$('card-R19').dupre.el,['white','green','amber','blue','vfd'],'white'); +screenChips('R31',$('card-R31').dupre.el,['amber','green','red','blue','vfd'],'amber'); +screenChips('N11',$('card-N11').dupre.el,['green','amber','red','blue','vfd'],'green'); +/* all six, where its siblings each carry five: a passphrase window has no reason + to prefer one phosphor, so it offers the whole set including the marquee cyan */ +screenChips('R57',$('card-R57').dupre.el,['amber','green','red','blue','vfd','white'],'amber'); +BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no); + if(rd)rd.closest('.card').querySelector('.stagew').classList.add('boost');}); + +/* cross-reference links: card IDs mentioned in notes and spec sheets jump to that card */ +(function(){const ids=new Set([...document.querySelectorAll('.card .no')].map(n=>n.textContent)); + document.querySelectorAll('.wnote,.igrid .iv').forEach(el=>{ + if(el.querySelector('a[href^="reference/"]'))return; // leave the photo-link rows alone + el.innerHTML=el.innerHTML.replace(/\b(R\d{2}|N\d{2})\b/g, + m=>ids.has(m)?`<a class="xref" href="#card-${m}">${m}</a>`:m);});})(); + +/* style chips: shared demo rig for builders with constructor style opts — + reads a builder's STYLES table and drives setStyle on the live card instance. + PRESETS (optional) adds a leading group of named combinations: a preset chip + applies all axes at once and resyncs the axis chips beneath it, and changing + any axis afterwards clears the preset — the chips must never claim a preset + the instrument has since diverged from. */ +function styleChips(no,STYLES,AXES,PRESETS,defPreset){ + const h=$('card-'+no)?.dupre; if(!h)return; + const cardEl=h.el.closest('.card'); + const row=document.createElement('div'); row.className='famchips'; + const sel={}, sync={}, groups=[]; let clearPreset=()=>{}; + /* Re-apply every axis on any change rather than just the one clicked: axes are + not independent (onText overrides the ink `on` sets), so a lone setStyle can + drop a selection the chips still show as lit — the card would then display a + combination the instrument isn't in. Applying all of them in AXES order makes + the outcome independent of click order. */ + const applyAll=()=>{for(const [,axis] of AXES) if(sel[axis]) h.setStyle(axis,sel[axis]);}; + for(const [label,axis,def] of AXES){ + sel[axis]=def; + const g=document.createElement('span'); g.className='fgroup'; + const lab=document.createElement('span'); lab.className='lab'; lab.textContent=label; g.appendChild(lab); + const chips=[]; + for(const [name,o] of Object.entries(STYLES[axis])){ + const b=document.createElement('span'); b.className='fc'+(name===def?' on':''); + b.style.background=o.dot; b.title=name; chips.push(b); + b.addEventListener('click',()=>{sel[axis]=name; applyAll(); + chips.forEach(x=>x.classList.toggle('on',x===b)); clearPreset();}); + g.appendChild(b);} + sync[axis]=name=>{sel[axis]=name; chips.forEach(x=>x.classList.toggle('on',x.title===name));}; + groups.push(g);} + if(PRESETS&&h.setPreset){ + const g=document.createElement('span'); g.className='fgroup'; + const lab=document.createElement('span'); lab.className='lab'; lab.textContent='preset'; g.appendChild(lab); + const chips=[]; + for(const [name,p] of Object.entries(PRESETS)){ + const b=document.createElement('span'); b.className='fc'+(name===defPreset?' on':''); b.title=name; + /* the on-tone is the preset's identity, so it doubles as the swatch */ + b.style.background=STYLES.on[p.on].dot; chips.push(b); + b.addEventListener('click',()=>{ + const applied=h.setPreset(name); if(!applied)return; + chips.forEach(x=>x.classList.toggle('on',x===b)); + for(const [axis,style] of Object.entries(applied)) sync[axis]?.(style);}); + g.appendChild(b);} + clearPreset=()=>chips.forEach(x=>x.classList.remove('on')); + row.appendChild(g);} + groups.forEach(g=>row.appendChild(g)); + cardEl.querySelector('.opts').appendChild(row); +} +styleChips('N20',DUPRE.splitFlap.STYLES,[['skin','skin','paper'],['font','font','helv']]); +(()=>{const h=$('card-N20').dupre,row=document.querySelector('#card-N20 .famchips');if(!h||!row)return; + const gr=document.createElement('span');gr.className='fgroup'; + gr.innerHTML='<span class="lab">flap</span><input type="range" min="30" max="160" value="100" style="width:70px;accent-color:var(--gold);vertical-align:middle"><span class="lab fms">100ms</span>'; + const sl=gr.querySelector('input'),ms=gr.querySelector('.fms'); + sl.addEventListener('input',()=>{h.setFlapMs(+sl.value);ms.textContent=sl.value+'ms';}); + row.appendChild(gr);})(); +styleChips('01',DUPRE.slideToggle.STYLES, + [['on','on','amber'],['on text','onText','panel'],['off','off','dark'], + ['off text','offText','white'],['thumb','thumb','light']], + DUPRE.slideToggle.PRESETS,'panel'); +styleChips('R05',DUPRE.filterBank.STYLES,[['panel','panel','silver'],['shape','caps','block'],['color','capColor','black']]); +styleChips('06',DUPRE.segmented.STYLES,[['accent','accent','amber']]); +styleChips('07',DUPRE.chipToggle.STYLES,[['accent','accent','amber']]); +styleChips('25',DUPRE.slideRule.STYLES,[['face','skin','warm']]); + +/* final tally pass: setV fires per card during build, but each card is still + detached at that moment, so the running counts lag by one — recount now */ +updateVTally(); +updatePolTally(); +</script> +</body> +</html> diff --git a/docs/prototypes/reference/slide-toggle.jpg b/docs/prototypes/reference/slide-toggle.jpg Binary files differnew file mode 100644 index 0000000..c1c0b14 --- /dev/null +++ b/docs/prototypes/reference/slide-toggle.jpg 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.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/docs/prototypes/widgets.js b/docs/prototypes/widgets.js new file mode 100644 index 0000000..57ad33e --- /dev/null +++ b/docs/prototypes/widgets.js @@ -0,0 +1,6142 @@ +/* widgets.js — The Dupre Kit: retro-instrument component library (DUPRE namespace). + Classic script: load after the token :root block (tokens.json → gen_tokens.py), + before any DUPRE.* call. Works from file:// — no modules, no build step. + Contract: each gallery card in panel-widget-gallery.html is the visual + + behavioral spec its builder is judged against. + Spec: docs/specs/2026-07-12-component-generation-spec.org */ +(function () { +'use strict'; +const DUPRE = {}; + +/* ================= shared engine ================= */ + +const SVGNS = 'http://www.w3.org/2000/svg'; +function svgEl(parent, tag, attrs) { + const e = document.createElementNS(SVGNS, tag); + for (const k in attrs) e.setAttribute(k, attrs[k]); + parent.appendChild(e); return e; +} +function polar(cx, cy, r, deg) { + const a = deg * Math.PI / 180; + return [cx + r * Math.sin(a), cy - r * Math.cos(a)]; +} + +/* ---- pointer-drag helpers (rect-ratio based, so CSS zoom is transparent) ---- */ +function dragX(el, onPct) { + el.style.touchAction = 'none'; + const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (e.clientX - r.left) / r.width * 100))); }; + el.addEventListener('pointerdown', e => { + el.setPointerCapture(e.pointerId); at(e); + const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; + el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault(); + }); +} +function dragY(el, onPct) { + el.style.touchAction = 'none'; + const at = e => { const r = el.getBoundingClientRect(); onPct(Math.max(0, Math.min(100, (r.bottom - e.clientY) / r.height * 100))); }; + el.addEventListener('pointerdown', e => { + el.setPointerCapture(e.pointerId); at(e); + const mv = ev => at(ev), up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; + el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault(); + }); +} +function dragDelta(el, get, set, opts) { + opts = opts || {}; el.style.touchAction = 'none'; + const min = opts.min == null ? 0 : opts.min, max = opts.max == null ? 100 : opts.max, sens = opts.sens || ((max - min) / 160); + el.addEventListener('pointerdown', e => { + if (opts.stop) e.stopPropagation(); el.setPointerCapture(e.pointerId); + const y0 = e.clientY, v0 = get(); + const mv = ev => { let v = v0 + (y0 - ev.clientY) * sens; set(Math.max(min, Math.min(max, v))); }; + const up = () => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; + el.addEventListener('pointermove', mv); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); e.preventDefault(); + }); +} + +/* ---- seven-segment digit builder ---- */ +const SEG = { '0': 'abcdef', '1': 'bc', '2': 'abged', '3': 'abgcd', '4': 'fbgc', '5': 'afgcd', '6': 'afgedc', '7': 'abc', '8': 'abcdefg', '9': 'abcdfg', '-': 'g', ' ': '' }; +function hseg(cy) { return `5,${cy} 7,${cy - 2} 17,${cy - 2} 19,${cy} 17,${cy + 2} 7,${cy + 2}`; } +function vseg(cx, ty, by) { return `${cx},${ty} ${cx - 2},${ty + 2} ${cx - 2},${by - 2} ${cx},${by} ${cx + 2},${by - 2} ${cx + 2},${ty + 2}`; } +const SEGPTS = { a: hseg(3), g: hseg(22), d: hseg(41), f: vseg(5, 4, 21), b: vseg(19, 4, 21), e: vseg(5, 23, 40), c: vseg(19, 23, 40) }; +function seg7(ch, cls) { + const lit = SEG[ch] || ''; let s = `<svg class="seg7 ${cls || ''}" viewBox="0 0 24 44">`; + for (const k of 'abcdefg') { + const on = lit.includes(k); + const col = on ? (cls === 'red' ? 'var(--sevred)' : 'var(--sevgrn)') : 'var(--sevoff)'; + s += `<polygon points="${SEGPTS[k]}" fill="${col}"/>`; + } + return s + '</svg>'; +} + +function buildBars(el, n) { el.innerHTML = ''; for (let k = 0; k < n; k++) el.appendChild(document.createElement('i')); } + +/* ---- VU law: authentic nonlinear dB scale, dB → 0..1 sweep position ---- */ +const VUDB = [[-20, 0], [-10, .20], [-7, .31], [-5, .40], [-3, .50], [-2, .58], [-1, .66], [0, .75], [1, .84], [2, .91], [3, 1]]; +/* interpolate a dB reading from a 0..1 sweep position */ +function vuDb(t) { + let i = 0; while (i < VUDB.length - 2 && VUDB[i + 1][1] < t) i++; + const [d1, t1] = VUDB[i], [d2, t2] = VUDB[i + 1]; + return d1 + (d2 - d1) * Math.max(0, Math.min(1, (t - t1) / (t2 - t1))); +} + +/* ---- screen-color families: period phosphor/LCD palettes as scoped CSS vars. + Shipped literals are the fallbacks, so defaults are pixel-identical until applied. ---- */ +const SCREEN_FAMS = { + green: { '--scr-hi': '#7fe0a0', '--scr-ink': '#58b87e', '--scr-dim': '#3d5c46', + '--scr-bg1': '#0a120c', '--scr-bg2': '#050c07', '--scr-bgc': '#04140a', '--scr-bge': '#020a05', '--scr-brd': '#123018', + '--scr-grat': 'rgba(111,206,51,.18)', '--scr-gratc': 'rgba(111,206,51,.35)', '--scr-glow': 'rgba(127,224,160,.8)', + '--crt-face1': '#b9d8c0', '--crt-face2': '#8bb296', '--crt-glow': '#eef7ee' }, + amber: { '--scr-hi': '#ffbe54', '--scr-ink': '#e2a038', '--scr-dim': '#7d5c16', + '--scr-bg1': '#140d06', '--scr-bg2': '#0a0705', '--scr-bgc': '#140d06', '--scr-bge': '#0a0603', '--scr-brd': '#33230e', + '--scr-grat': 'rgba(226,160,56,.18)', '--scr-gratc': 'rgba(226,160,56,.35)', '--scr-glow': 'rgba(255,190,84,.8)', + '--crt-face1': '#d8cba6', '--crt-face2': '#b3a276', '--crt-glow': '#ffe7c0' }, + red: { '--scr-hi': '#ff9a4c', '--scr-ink': '#e0742e', '--scr-dim': '#5c3416', + '--scr-bg1': '#140a05', '--scr-bg2': '#0c0603', '--scr-bgc': '#140a05', '--scr-bge': '#0a0502', '--scr-brd': '#331c0c', + '--scr-grat': 'rgba(224,116,46,.18)', '--scr-gratc': 'rgba(224,116,46,.35)', '--scr-glow': 'rgba(255,154,76,.8)', + '--crt-face1': '#d8b9a6', '--crt-face2': '#b39276', '--crt-glow': '#ffdcc0' }, + blue: { '--scr-hi': '#cfe4ff', '--scr-ink': '#94b8d8', '--scr-dim': '#3a4c60', + '--scr-bg1': '#0a0e14', '--scr-bg2': '#05080c', '--scr-bgc': '#0a1018', '--scr-bge': '#04070c', '--scr-brd': '#16283a', + '--scr-grat': 'rgba(148,184,216,.18)', '--scr-gratc': 'rgba(148,184,216,.35)', '--scr-glow': 'rgba(207,228,255,.8)', + '--crt-face1': '#c2d2dd', '--crt-face2': '#93a8b6', '--crt-glow': '#eaf2ff' }, + vfd: { '--scr-hi': '#63e6c8', '--scr-ink': '#46b89e', '--scr-dim': '#1e4a40', + '--scr-bg1': '#06100d', '--scr-bg2': '#040b09', '--scr-bgc': '#041410', '--scr-bge': '#020a08', '--scr-brd': '#0e3028', + '--scr-grat': 'rgba(70,184,158,.18)', '--scr-gratc': 'rgba(70,184,158,.35)', '--scr-glow': 'rgba(99,230,200,.8)', + '--crt-face1': '#b0d8cd', '--crt-face2': '#84ada2', '--crt-glow': '#dcfff5' }, + white: { '--scr-hi': '#f2f4f2', '--scr-ink': '#c8cac8', '--scr-dim': '#5a5c5a', + '--scr-bg1': '#0b0c0b', '--scr-bg2': '#070807', '--scr-bgc': '#0d0e0d', '--scr-bge': '#060706', '--scr-brd': '#2a2c2a', + '--scr-grat': 'rgba(200,202,200,.18)', '--scr-gratc': 'rgba(200,202,200,.35)', '--scr-glow': 'rgba(242,244,242,.7)', + '--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' }, +}; + +/* ================= instrument builders ================= + Every builder: DUPRE.name(host, opts) → handle. host is an empty element the + instrument renders into. opts.onChange(value, text) fires on every state change, + including the initial paint; text is the instrument's canonical readout string. */ + +const noop = () => {}; + +/* ---- the accent family ---- + One named set of lit colours for the instruments whose colour IS their claim: a + chip, a lamp, a badge, a status line. "On" is good in one panel, a warning in + the next and a fault in the one after, so the colour belongs to the consumer + rather than the builder. + Deliberately NOT applied to instruments whose colour is the object rather than a + state — the nixie (the palette is explicit that neon is only ever orange), the + flip-disc's yellow, the dekatron's glow, a red needle — nor to the ones where + the colour is a standard rather than a preference: three greens on the landing + gear means down and locked, and a breaker's red/amber/green is its trip + semantics. Recolouring those doesn't restyle them, it makes them lie. + accentStyles(varName) builds a STYLES axis driving one CSS custom property, so + each consumer names its own var and the family stays single-source. */ +const ACCENTS = { + amber: 'var(--gold)', + green: 'var(--pass)', + red: 'var(--fail)', + white: 'var(--cream)', + vfd: 'var(--vfd)', +}; +DUPRE.accentStyles = varName => Object.fromEntries( + Object.entries(ACCENTS).map(([name, colour]) => [name, { dot: colour, vars: { [varName]: colour } }])); + +/* ---- policy ---- + Every instrument's colour is either the consumer's to pick or locked for a reason, + and the reason is one of these six kinds. Declared per builder (DUPRE.<name>.POLICY) + so it's a checked property rather than something the next person recalls — the + colour pass kept turning up cards where "is this one a standard?" was answered + from memory and answered wrong. The two FREE kinds get chips; the four LOCKED + ones must not, and the probe enforces the split. + Policy is a property of the INSTRUMENT, not the colour: the same vfd cyan is a free + accent on a chip and a locked emissive identity on the marquee. A few cards are + mixed (a free display with a coded red-line) and declare per element in time. + Each builder's POLICY is a record { kind, why, authentic }: + kind — one of the six below. + why — why THIS instrument is bound that way, in its own terms. + authentic — what may change and still be true to the reference (the range + that actually existed), or 'nothing' when the colour is fixed. */ +DUPRE.POLICIES = { + accent: { free: true, gist: 'A lit state whose meaning varies by panel; the consumer picks from the accent family.' }, + screen: { free: true, gist: 'A display whose phosphor was made in several real colours; the consumer picks from the screen family.' }, + coded: { free: false, gist: 'Colour is meaning fixed by an external standard; a recolour misleads a trained operator (landing-gear three-greens, breaker trip).' }, + emissive: { free: false, gist: 'Colour is what the physical source emits and no other was made; unrecognisable otherwise (nixie neon, dekatron glow).' }, + relational: { free: false, gist: 'Colour means what it does only by contrast with another on the same instrument; the set is a scale or legend (VU zones, crossed needles).' }, + material: { free: false, gist: 'Colour of a static physical part, not a state — nothing to parameterise as a signal (needle, brass bezel, knife blade).' }, +}; + +/* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*). + opts.onStyle / offStyle / offText / thumb pick a named style per axis from + DUPRE.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's + setStyle(axis, name) restyles a live instance (the gallery chips use it). */ +DUPRE.slideToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw); + const setStyle = (axis, name) => { + const o = (DUPRE.slideToggle.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v); + }; + /* opts.preset names an intent; per-axis opts still win over it, so a caller can + take 'armed' and swap only the thumb. setPreset re-applies all four axes at + once and returns the axis map so a host UI can resync its own controls. */ + const preset = DUPRE.slideToggle.PRESETS[opts.preset] || DUPRE.slideToggle.PRESETS.panel; + /* AXIS_ORDER is load-bearing, not cosmetic: onText overrides the ink that the + `on` style sets, so applying the axes in any other order silently drops it. */ + const setPreset = name => { + const p = DUPRE.slideToggle.PRESETS[name]; + if (!p) return null; + for (const axis of DUPRE.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]); + return p; + }; + const pick = { on: opts.onStyle, onText: opts.onText, off: opts.offStyle, + offText: opts.offText, thumb: opts.thumb }; + for (const axis of DUPRE.slideToggle.AXIS_ORDER) setStyle(axis, pick[axis] || preset[axis]); + let on; + const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; + sw.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: sw, get: () => on, set, setStyle, setPreset }; +}; +/* named presets — a combination that carries intent, which is what a consumer + actually reaches for; the STYLES axes below are how one is built. The `on` + tone does the semantic work and the thumb material backs it: a touchscreen + pill wears plastic, a run or armed switch wears metal. */ +DUPRE.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb']; +DUPRE.slideToggle.PRESETS = { + panel: { on: 'amber', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' }, + run: { on: 'green', onText: 'panel', off: 'dark', offText: 'dim', thumb: 'chrome' }, + armed: { on: 'red', onText: 'cream', off: 'dark', offText: 'white', thumb: 'chrome' }, + caution: { on: 'warn', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' }, + /* dark — neither state lights the pill; the legend alone carries the state, + green for on and Control red ("OFF marks") for off. The chrome thumb stays + the position affordance, since with both pills dark it is the only one. */ + dark: { on: 'dark', onText: 'green', off: 'dark', offText: 'red', thumb: 'chrome' }, +}; +/* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */ +DUPRE.slideToggle.STYLES = { + on: { + amber: { dot: 'var(--gold-hi)', vars: { '--sw-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--sw-on-brd': 'var(--gold-hi)', '--sw-on-ink': 'var(--panel)' } }, + green: { dot: '#8fb944', vars: { '--sw-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--sw-on-brd': '#a9c95f', '--sw-on-ink': 'var(--panel)' } }, + /* red/warn make the ENGAGED state the notable one — mute, record, airplane + mode. Before them the only red was on the `off` axis, which paints the + disengaged state: the opposite claim. Palette semantics: --fail is + "muted keys", --amber-warn is "warn cells", distinct from panel amber. */ + red: { dot: 'var(--fail)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-on-brd': 'var(--fail)', '--sw-on-ink': 'var(--cream)' } }, + warn: { dot: 'var(--amber-warn)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#f7d193,var(--amber-warn))', '--sw-on-brd': 'var(--amber-warn)', '--sw-on-ink': 'var(--panel)' } }, + dark: { dot: '#242019', vars: { '--sw-on-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-on-brd': 'var(--slate)', '--sw-on-ink': 'var(--cream)' } }, + }, + off: { + dark: { dot: '#242019', vars: { '--sw-off-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-off-brd': 'var(--slate)' } }, + red: { dot: 'var(--fail)', vars: { '--sw-off-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-off-brd': 'var(--fail)' } }, + }, + /* onText overrides the ink the `on` style sets. The two are not independent — + ink must contrast with its own background, which is why each `on` atom still + carries a sensible default; naming onText is how a caller says "keep the + pill, change the legend". That is what makes the `dark` preset possible: + both states unlit, the legend colour carrying the state on its own. */ + onText: { + panel: { dot: 'var(--panel)', vars: { '--sw-on-ink': 'var(--panel)' } }, + cream: { dot: 'var(--cream)', vars: { '--sw-on-ink': 'var(--cream)' } }, + green: { dot: 'var(--sevgrn)', vars: { '--sw-on-ink': 'var(--sevgrn)' } }, + }, + offText: { + white: { dot: 'var(--cream)', vars: { '--sw-off-ink': 'var(--cream)' } }, + /* Control red — the palette's "OFF marks" ink, so a red OFF legend is the + kit's existing grammar rather than a new claim. */ + red: { dot: '#e0523a', vars: { '--sw-off-ink': '#e0523a' } }, + /* steel is the kit's engraved-caption ink: lets an off toggle recede where a + panel carries many of them and eight cream OFFs would all shout equally. */ + dim: { dot: 'var(--steel)', vars: { '--sw-off-ink': 'var(--steel)' } }, + black: { dot: '#14110e', vars: { '--sw-off-ink': '#14110e' } }, + }, + thumb: { + light: { dot: '#d8d2c4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a)' } }, + dark: { dot: '#2c2824', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#4a453e,#16130f)' } }, + chrome: { dot: '#9aa2b4', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#dfe6f2,#6d7484)' } }, + brass: { dot: '#b8944a', vars: { '--sw-thumb': 'radial-gradient(circle at 35% 28%,#e2c47a,#8a6a24)' } }, + }, +}; + +/* 02 console keys — mutually exclusive push buttons; {label, tone} per key. + tone picks the engaged look: undefined = gold (the default lit key), 'green' = + run green, 'red' = terracotta. Reading order runs safe -> live -> muted, and + LIVE takes green because that is what --pass means everywhere else in the kit + (the palette names it "run lamps, gear greens, monitor bars, LIVE lamps"). + Contract (everything a consumer needs; no page globals touched): + opts: keys ([{label, tone}], default DEFAULT_KEYS); active (index; the + default set rests on LIVE, a caller's own set on its first key); + onChange(idx, label) fires on every engage. + handle: el, get() (engaged index), set(i) (clamped). + CSS lives in the "console keys" block of DUPRE_CSS; dsky and annunciator + reuse the key class for their pads. */ +DUPRE.consoleKeys = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const keys = opts.keys || DUPRE.consoleKeys.DEFAULT_KEYS; + const wrap = document.createElement('span'); host.appendChild(wrap); + const btns = keys.map(k => { + const b = document.createElement('button'); b.className = 'dupre-key'; b.textContent = k.label; + wrap.appendChild(b); return b; + }); + let idx; + const set = i => { + idx = Math.max(0, Math.min(keys.length - 1, i)); + btns.forEach((b, j) => { + b.classList.remove('dupre-on', 'dupre-green', 'dupre-red'); + if (j === idx) b.classList.add('dupre-' + (keys[idx].tone || 'on')); + }); + onChange(idx, keys[idx].label); + }; + btns.forEach((b, i) => b.addEventListener('click', () => set(i))); + /* The default set engages LIVE, not the first key: it is the meaningful + resting state for a console and it shows the green. A caller supplying its + own keys gets the first one unless it says otherwise. */ + set(opts.active !== undefined ? opts.active + : (keys === DUPRE.consoleKeys.DEFAULT_KEYS ? 1 : 0)); + return { el: wrap, get: () => idx, set }; +}; +DUPRE.consoleKeys.DEFAULT_KEYS = [ + { label: 'SCAN' }, { label: 'LIVE', tone: 'green' }, { label: 'MUTED', tone: 'red' }, +]; + +/* R57 ABC entry keypad — alphanumeric text on a faceplate. + Modelled on the industrial membrane keypad (reference: fleet/kiosk gear, + 2026-07-16-abc-keypad-membrane-color.png): a 0-9 block beside A-Z laid out + ALPHABETICALLY, not QWERTY. That is the convention wherever the operator + can't be assumed to touch-type, and it is what makes this a faceplate rather + than a keyboard — R16's alphanumeric sibling. + Departures from the photo, all deliberate and all noted at their site: + - Its letters are blue. The kit has no blue control colour (blue appears + only as a screen phosphor and a jewel lens), so the letters take the + standard pale keycap and the digits a darker one — which keeps the + photo's two-tone digit/letter grouping without importing a foreign hue. + CLEAR/NO and ENTER/YES keep the reference's red and green, which are + already --fail and --pass. + - Letters left, digits right (the stainless reference's arrangement). + - DEL added, CANCEL dropped. See the layout block below for both. + Contract (everything a consumer needs; no page globals touched): + opts: max (16, buffer cap); onChange(buf, label) on every press — ENT + reports 'ENTER · <buf>', DEL/CLR report the remaining buffer. + handle: el (focusable; keyboard per KEYS), get() (the buffer), press(k) + (any ACTIONS member: plate characters, SPC, DEL, ENT, CLR). + CSS lives in the "keypad / index-plate focus" block of DUPRE_CSS (the + faceplate itself is drawn SVG); the entry window recolours with the shared + --scr-* screen vars. */ +DUPRE.abcKeypad = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const MAX = opts.max || 16; + const s = stageSvg(host, 'rsvg', 232, 226); + gradDef('abcKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]); + gradDef('abcNum', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#6f6a61'], ['1', '#464239']]); + gradDef('abcRed', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d98a6f'], ['1', 'var(--fail)']]); + gradDef('abcGrn', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#9cbf5e'], ['1', 'var(--pass)']]); + gradDef('abcAmb', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'var(--amber-grad-top)'], ['1', 'var(--gold)']]); + /* faceplate + the recessed window the legend prints into */ + svgEl(s, 'rect', { x: 2, y: 2, width: 228, height: 222, rx: 7, fill: '#17140f', stroke: '#0a0908', 'stroke-width': 2 }); + /* The window is a screen like the scope's or the marquee's, so it takes the + screen families through the --scr-* vars, shipped colours as the fallbacks: + nothing moves until a chip is clicked. Both the glass and the ink recolour — + a screen that changes its text and keeps its backlight isn't a screen. */ + svgEl(s, 'rect', { class: 'dupre-kp-win', x: 12, y: 10, width: 208, height: 30, rx: 4, + fill: 'var(--scr-bg1, #0a0806)', stroke: 'var(--scr-brd, #2c261d)', 'stroke-width': 2 }); + const disp = svgEl(s, 'text', { x: 20, y: 31, 'font-size': 14, 'letter-spacing': '.14em', + 'font-family': 'var(--mono)', fill: 'var(--scr-hi, var(--gold-hi))' }); + + let buf = ''; + /* The window shows the tail: a passphrase outruns the plate long before MAX. + Spaces are drawn as ␣ rather than as themselves. SVG collapses whitespace, + so a real space is invisible — and past the truncation boundary there are no + pad dots left for it to displace, which makes SPACE a keypress with no + feedback at all: press, see nothing, press again, and now carry two spaces + you can't see in a passphrase you can't read back. Only the DISPLAY is + substituted; buf keeps the real character. */ + const show = t => t.replace(/ /g, '␣'); + const render = () => { + disp.textContent = buf.length > 13 ? '‹' + show(buf.slice(-12)) : show(buf).padEnd(13, '·'); + }; + /* press is the allowlist, not the keydown handler above it. Filtering only in + the handler guards the web and leaves the Emacs port wide open: that port + installs KEYS into a keymap and calls press directly, with no handler in the + stack, so an unguarded press would append a stray 'F1' as literal text. + Gated on ACTIONS rather than on KEYS because they are different sets — CLR + is a real key on the plate that no keystroke maps to. */ + const press = k => { + if (!DUPRE.abcKeypad.ACTIONS.has(k)) return; + if (k === 'ENT') { onChange(buf, buf ? 'ENTER · ' + buf : 'empty'); return; } + if (k === 'DEL') { buf = buf.slice(0, -1); render(); onChange(buf, buf || 'empty'); return; } + if (k === 'CLR') { buf = ''; render(); onChange(buf, 'cleared'); return; } + if (buf.length >= MAX) { onChange(buf, 'full · ' + buf); return; } + buf += (k === 'SPC' ? ' ' : k); render(); onChange(buf, buf); + }; + /* [label, col, row, width-in-cols, tone]. + Letters left, numbers right — the stainless reference's arrangement rather + than the membrane's. The membrane photo puts its digit block on the left, + which lands A-L in columns 3-5 while M-X starts at column 0: the alphabet + stops column-aligning with itself halfway down and the eye has to jump. + This way A-Z reads straight down the plate in one continuous block and the + digits keep their own quadrant. */ + const L = []; + const DIG = ['1','2','3','4','5','6','7','8','9']; + [...'ABCDEFGHIJKL'].forEach((c, i) => L.push([c, i % 3, Math.floor(i / 3), 1, 'key'])); + [...'MNOPQRSTUVWX'].forEach((c, i) => L.push([c, i % 6, 4 + Math.floor(i / 6), 1, 'key'])); + /* DEL earns its own key: without a backspace the only exit from a typo is CLR, + which on a long passphrase means retyping the lot. + The three function keys read as a ladder, so the colour says what a key + costs before you read its legend: amber DEL takes one character back, red + CLR throws the whole entry away, green ENT commits it. Plain caps do + nothing you'd regret. (The stainless reference's backspace is plain steel, + but that plate has no other coloured key to be graded against.) + DEL sits in the block beside the digits and CLR is exiled to the far corner, + which is the opposite of where they started. Two reasons, pulling the same + way: DEL is the one you reach for constantly and CLR is the one you reach + for almost never, and a mis-hit on DEL costs one character where a mis-hit + on CLR costs the whole entry. Frequency and blast radius both say the safe + key gets the good spot. + The reference plate also carries CANCEL, and this one deliberately doesn't. + On that device the plate IS the whole terminal, so CANCEL has a transaction + to abandon; here the keypad is one control inside a panel that owns its own + dismiss, which left CANCEL doing exactly what CLR does. A key that means + nothing the panel doesn't already mean is a key worth removing. */ + L.push(['Y', 0, 6, 1, 'key'], ['Z', 1, 6, 1, 'key'], ['SPC', 2, 6, 3, 'key'], + ['CLR', 5, 6, 1, 'red']); + DIG.forEach((d, i) => L.push([d, 3 + (i % 3), Math.floor(i / 3), 1, 'num'])); + L.push(['DEL', 3, 3, 1, 'amb'], ['0', 4, 3, 1, 'num'], ['ENT', 5, 3, 1, 'grn']); + const FILL = { key: 'url(#abcKey)', num: 'url(#abcNum)', red: 'url(#abcRed)', grn: 'url(#abcGrn)', amb: 'url(#abcAmb)' }; + const INK = { key: '#2b2721', num: '#efeae0', red: 'var(--cream)', grn: 'var(--cream)', amb: 'var(--panel)' }; + /* every function key wears a three-letter legend, so they read as one family + and each fits a single-column cap; only SPACE has the width to spell out */ + const FACE = { SPC: 'SPACE' }; + /* per-key rotation: a real plate is printed, not typeset — the legends sit a + fraction off true. Same trick R16 uses, keyed off the index so it is stable. */ + const jitter = i => [-1.4, .9, -.6, 1.2, 0, -1.1, .7, -.9, 1.3, .4, -.5, 1][i % 12]; + L.forEach(([k, c, r, w, tone], i) => { + const x = 14 + c * 35.5, y = 50 + r * 24.5, wd = w * 35.5 - 5.5; + const g = svgEl(s, 'g', {}); g.setAttribute('class', 'dupre-kp-key'); g.dataset.k = k; + g.style.cursor = 'pointer'; g.style.transition = 'transform .07s'; + svgEl(g, 'rect', { x, y, width: wd, height: 20, rx: 3.5, fill: FILL[tone], stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 }); + const t = svgEl(g, 'text', { x: x + wd / 2, y: y + 14.5, 'text-anchor': 'middle', + 'font-size': w > 1 ? 8 : 11, 'font-weight': 700, 'font-family': 'var(--mono)', fill: INK[tone], + transform: `rotate(${jitter(i)},${x + wd / 2},${y + 10})` }); + t.textContent = FACE[k] || k; + g.addEventListener('click', () => { + g.style.transform = 'translateY(1.5px)'; + setTimeout(() => { g.style.transform = ''; }, 80); + press(k); + }); + }); + /* Keyboard, per the README's keyboard contract. The listener is bound to the + pad's own focusable element, never to the document: a global binding would + type into this card from anywhere on a 110-card page and fight the gallery's + own Escape handler. DUPRE.slideRule is the precedent. */ + s.setAttribute('class', 'rsvg dupre-kp-pad'); + s.setAttribute('tabindex', '0'); + s.addEventListener('click', () => s.focus()); + s.addEventListener('keydown', e => { + if (e.ctrlKey || e.metaKey || e.altKey) return; /* leave shortcuts alone */ + const name = e.key === ' ' ? 'Space' : (e.key.length === 1 ? e.key.toUpperCase() : e.key); + const k = DUPRE.abcKeypad.KEYS[name]; + if (!k) return; /* not on the plate: let it bubble */ + /* Spend preventDefault only where there is a default worth killing — Space + scrolls the page, Backspace can navigate back. Tab and Escape are never + ours: Tab is how the page stays navigable, Escape belongs to the audit + stepper, and neither reaches here anyway because they are not in KEYS. */ + if (name === 'Space' || name === 'Backspace') e.preventDefault(); + press(k); + }); + render(); onChange('', 'type a passphrase'); + return { el: s, get: () => buf, press }; +}; +/* The plate's keys, declared as a table so every target reads the same intent. + Deliberately not a function over a DOM event: the Emacs port installs this + into a keymap and never sees a keydown, so a function would force it to + re-derive what the instrument accepts and the two bindings would drift apart. + This is also press()'s allowlist — press appends whatever it is handed, so + without the table a stray 'F1' would land in the buffer as text. */ +DUPRE.abcKeypad.KEYS = (() => { + const m = {}; + for (const c of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') m[c] = c; + m.Space = 'SPC'; m.Backspace = 'DEL'; m.Enter = 'ENT'; + return m; +})(); +/* The plate's whole vocabulary — every argument press accepts, from any caller. + A superset of the KEYS values: CLR is on the plate but no keystroke reaches it + (Escape is the obvious candidate and belongs to the gallery's audit stepper). */ +DUPRE.abcKeypad.ACTIONS = new Set([ + ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 'SPC', 'DEL', 'ENT', 'CLR', +]); + +/* R58 index typewriter — the oldest way to type without a keyboard. + After the AEG Mignon Model 4 (1924), the best of the index machines: one hand + walks a pointer over a printed plate of characters, the other pulls a lever, + and only the lever prints. Select and commit live on two separate controls, so + you can hunt as long as you like and nothing happens until the second hand + moves. That separation is the whole card — R57 is a keypad, this is not. + + Two things kept from the Mignon and one deliberately dropped. + Kept: the plate considered the characters a keyboard skips (accents, section + mark, fractions, a full punctuation ring), and it carries BOTH cases with no + shift key, which is how a keyless machine reaches a whole character set. + Dropped: its key order. The real plate runs P U G Q / V I N A B / L D E T M, + a frequency layout you cannot read your way around — and reading the plate is + the entire interaction. Ours is alphabetical, capitals beside lowercase at the + same column offset: find the letter, then pick the case. + The layout is a table rather than drawing, because Craig has already said the + keys will be revisited and a layout welded into the geometry never is. + Contract (everything a consumer needs; no page globals touched): + opts: max (22, line cap); onChange(buf, label) on select, print and clear. + handle: el (focusable; typing selects a cell, Enter pulls the lever), + get() (the printed line), select(c), print(), press(k) (any + ACTIONS member: plate characters, PRINT, CLR), selected() (the + stylus position, null before the first select). + CSS lives in the "keypad / index-plate focus" block of DUPRE_CSS (the + machine itself is drawn SVG); the paper recolours with the shared --scr-* + screen vars. */ +DUPRE.indexPlate = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const MAX = opts.max || 22; + const L = DUPRE.indexPlate.LAYOUT; + const COLS = Math.max(...L.map(r => r.length)), ROWS = L.length; + /* Width is driven by the plate, not guessed: the lever and CLR live in a gutter + to its right. Sized from the layout table so a wider plate can't slide them + back on top of the characters (they were, at 300 wide — the last column and + the PRINT legend went under the lever). */ + const PX = 16, PY = 60, CW = 22, CH = 22, GUT = 46; + const PLATE_R = PX - 8 + COLS * CW + 14; + /* The gutter stack (lever, PRINT legend, CLR) is anchored to PY and needs 130px + whatever the plate does, so VH takes a floor. Without it a SHORTER table — + five rows is a plausible edit — shrinks VH until CLR rides up over the PRINT + legend and then the lever. Growth was always safe; shrink was the trap. */ + const VW = PLATE_R + GUT; + const VH = Math.max(PY - 10 + ROWS * CH + 16 + 16, PY + 130); + const s = stageSvg(host, 'rsvg dupre-ix-pad', VW, VH); + gradDef('ixPlate', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#efe6c4'], ['1', '#d8caa0']]); + gradDef('ixBody', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#26221c'], ['1', '#100e0b']]); + gradDef('ixSteel', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e6e9ef'], ['1', '#8d93a1']]); + /* body, then the paper the machine prints onto */ + svgEl(s, 'rect', { x: 2, y: 2, width: VW - 4, height: VH - 4, rx: 8, fill: 'url(#ixBody)', stroke: '#0a0908', 'stroke-width': 2 }); + svgEl(s, 'rect', { x: 14, y: 10, width: VW - 28, height: 30, rx: 2, + fill: 'var(--scr-bg1, #efe9da)', stroke: 'var(--scr-brd, #b3a883)', 'stroke-width': 1 }); + const paper = svgEl(s, 'text', { x: 22, y: 31, 'font-size': 13, 'letter-spacing': '.1em', + 'font-family': 'var(--mono)', fill: 'var(--scr-hi, #241d12)' }); + + /* the index plate: cream, rounded, characters in printed rings like the Mignon's. + The ring area carries a halftone screen, which is what the real plate prints + its outer characters onto. */ + if (!document.getElementById('ixHalf')) { + const defs = svgEl(s, 'defs', {}); + const pat = svgEl(defs, 'pattern', { id: 'ixHalf', width: 4, height: 4, patternUnits: 'userSpaceOnUse' }); + svgEl(pat, 'rect', { width: 4, height: 4, fill: 'url(#ixPlate)' }); + svgEl(pat, 'circle', { cx: 1, cy: 1, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 }); + svgEl(pat, 'circle', { cx: 3, cy: 3, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 }); + } + svgEl(s, 'rect', { x: PX - 8, y: PY - 10, width: COLS * CW + 14, height: ROWS * CH + 16, rx: 7, + fill: 'url(#ixHalf)', stroke: '#8d8268', 'stroke-width': 1.5 }); + /* the inverted blocks, drawn under the cells: a light panel for the capitals, + a dark one for the lowercase — the plate's photographic-negative trick */ + const ZFILL = { caps: '#e9e0bb', lower: '#231d13' }; + const zoneAt = (r, c) => { + const z = DUPRE.indexPlate.ZONES.find(z => + r >= z.rows[0] && r <= z.rows[1] && c >= z.cols[0] && c <= z.cols[1]); + return z ? z.zone : 'ring'; + }; + DUPRE.indexPlate.ZONES.forEach(z => { + svgEl(s, 'rect', { + x: PX + z.cols[0] * CW - 1, y: PY + z.rows[0] * CH - 1, + width: (z.cols[1] - z.cols[0] + 1) * CW + 2, height: (z.rows[1] - z.rows[0] + 1) * CH + 2, + fill: ZFILL[z.zone] || 'none', + }); + }); + + let sel = null, buf = ''; + /* null-prototype: cells is keyed by the plate's characters, and a plain {} would + resolve select('constructor') through Object.prototype and throw. */ + const cells = Object.create(null); + const render = () => { paper.textContent = (buf.length > 21 ? '‹' + buf.slice(-20) : buf.padEnd(21, ' ')).replace(/ /g, '␣'); }; + /* The stylus: a cone on a short shaft, hovering over the selected cell. The + Mignon's arm reaches back to a pivot, but a full arm drawn here crosses the + plate and hides the characters the operator is trying to read — the one thing + this instrument must not do. A tip is enough to say "the pointer is here". */ + const arm = svgEl(s, 'g', {}); arm.setAttribute('class', 'dupre-ix-stylus'); + svgEl(arm, 'line', { x1: 0, y1: -9, x2: 0, y2: -20, stroke: 'url(#ixSteel)', 'stroke-width': 2.6 }); + svgEl(arm, 'circle', { cx: 0, cy: -21, r: 3.4, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .6 }); + svgEl(arm, 'path', { d: 'M -3.6 -9 L 3.6 -9 L 0 0 Z', fill: 'url(#ixSteel)', stroke: '#41464f', 'stroke-width': .7 }); + arm.style.transition = 'transform .12s'; + arm.style.opacity = '0'; + + const select = c => { + const cell = cells[c]; if (!cell) return; + sel = c; + Object.values(cells).forEach(x => x.ring.setAttribute('stroke-opacity', '.35')); + cell.ring.setAttribute('stroke-opacity', '1'); + arm.style.opacity = '1'; + arm.setAttribute('transform', `translate(${cell.x},${cell.y - 9})`); + onChange(buf, 'stylus over ' + c); + }; + L.forEach((row, r) => row.forEach((c, i) => { + if (!c) return; + const x = PX + i * CW + CW / 2, y = PY + r * CH + CH / 2; + /* disc and glyph invert together, by zone: dark disc + light glyph inside the + capitals block, the negative of that in the lowercase block, light-on-dark + everywhere on the ring. */ + const z = zoneAt(r, i); + const DISC = { caps: '#231d13', lower: '#f2ecd6', ring: '#f2ecd6' }; + const INK = { caps: '#f2ecd6', lower: '#231d13', ring: '#231d13' }; + const g = svgEl(s, 'g', {}); g.setAttribute('class', 'dupre-ix-cell'); + g.dataset.c = c; g.dataset.zone = z; + g.style.cursor = 'pointer'; + const ring = svgEl(g, 'circle', { cx: x, cy: y, r: 8.6, fill: DISC[z], + stroke: z === 'caps' ? '#e9e0bb' : '#7a6a3e', 'stroke-width': 1.4, 'stroke-opacity': .35 }); + svgEl(g, 'text', { x, y: y + 3.6, 'text-anchor': 'middle', + 'font-size': /[a-z]/.test(c) || /[A-Z]/.test(c) ? 9.5 : 8.5, 'font-weight': 600, + 'font-family': 'var(--mono)', fill: INK[z] }).textContent = c; + cells[c] = { ring, x, y }; + g.addEventListener('click', () => press(c)); + })); + s.appendChild(arm); + + /* the lever: the only thing that prints. Lives in the gutter right of the plate */ + const LX = PLATE_R + GUT / 2; + const lever = svgEl(s, 'g', {}); lever.setAttribute('class', 'dupre-ix-lever'); + lever.style.cursor = 'pointer'; lever.style.transition = 'transform .08s'; + svgEl(lever, 'rect', { x: LX - 3.5, y: PY + 6, width: 7, height: 72, rx: 3.5, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .8 }); + svgEl(lever, 'circle', { cx: LX, cy: PY + 2, r: 8, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': 1 }); + svgEl(s, 'text', { x: LX, y: PY + 94, 'text-anchor': 'middle', 'font-size': 6.5, 'letter-spacing': '.1em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'PRINT'; + const print = () => { + if (!sel) { onChange(buf, 'no character selected'); return; } + if (buf.length >= MAX) { onChange(buf, 'line full'); return; } + buf += sel; render(); onChange(buf, buf); + lever.style.transform = 'translateY(5px)'; + setTimeout(() => { lever.style.transform = ''; }, 90); + }; + lever.addEventListener('click', () => press('PRINT')); + s.appendChild(lever); + + /* fresh paper */ + const clr = svgEl(s, 'g', {}); clr.setAttribute('class', 'dupre-ix-clear'); clr.style.cursor = 'pointer'; + /* anchored to PY like the rest of the gutter stack, not to VH — mixing the two + is what let a shorter plate slide this up onto the PRINT legend */ + svgEl(clr, 'rect', { x: LX - 16, y: PY + 104, width: 32, height: 18, rx: 3, fill: '#3a332a', stroke: '#5c5348', 'stroke-width': 1 }); + svgEl(clr, 'text', { x: LX, y: PY + 117, 'text-anchor': 'middle', 'font-size': 7.5, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = 'CLR'; + const fresh = () => { buf = ''; render(); onChange(buf, 'fresh paper'); }; + clr.addEventListener('click', () => press('CLR')); + + /* THE single entry. Every caller goes through it — cell clicks, the lever, CLR, + the keyboard, and any port — so the mouse cannot quietly diverge from the keys. + Binding the handlers straight to select/print/fresh worked only because press + happened to be a pure dispatcher: the moment it grows a guard or a sound, the + primary input on this card would skip it and every probe would stay green. + Gated on ACTIONS, so it selects nothing it has no cell for. */ + const press = k => { + if (!DUPRE.indexPlate.ACTIONS.has(k)) return; + if (k === 'PRINT') return print(); + if (k === 'CLR') return fresh(); + select(k); + }; + + /* Keyboard, per the README's keyboard contract: bound to this element, never to + the document. Typing SELECTS and Enter pulls the lever, which is the card's + whole grammar carried onto the keys — a keypress that printed would make this + R57 with a nicer plate. Nothing is uppercased on the way in, because the plate + holds both cases: Shift picks the case, since 'a' and 'A' are different cells. + Space isn't on the plate, so it isn't ours and still scrolls the page. */ + s.setAttribute('tabindex', '0'); + s.addEventListener('click', () => s.focus()); + s.addEventListener('keydown', e => { + if (e.ctrlKey || e.metaKey || e.altKey) return; + const k = DUPRE.indexPlate.KEYS[e.key]; + if (!k) return; + e.preventDefault(); /* Enter would submit a form; a cell key has no default worth keeping */ + press(k); + }); + + render(); onChange('', 'point, then pull'); + return { el: s, get: () => buf, select, print, press, selected: () => sel }; +}; +/* The plate, as data. Capitals block beside lowercase at the same column offset, + so a letter and its case sit in the same row six columns apart. Digits and the + punctuation ring keep the Mignon's edges. Rewrite this table to relayout the + plate: nothing below reads it except the renderer. */ +/* The Mignon Model 4's own plate, transcribed from the reference photo + (working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg). + Faithful first, iterate second — Craig's call, and the right order: the layout + has things in it worth understanding before replacing. + Two of its economies are load-bearing and easy to "fix" by accident: + - There is no 1 and no 0. You type them with lowercase l and capital O. + - J and j live out on the ring, not in the case blocks, which is why the + inversion below is positional and not "capitals are dark". + The two dashes differ: a long one top-right, a hyphen bottom-right. */ +DUPRE.indexPlate.LAYOUT = [ + ['&','(',')',':','"','!', '?',"'",'ä','ö','ü','—'], + ['§','P','F','U','G','Q', 'p','f','u','g','q',';'], + ['J','V','I','N','A','B', 'v','i','n','a','b','j'], + ['/','L','D','E','T','M', 'l','d','e','t','m',','], + ['%','K','O','S','R','Z', 'k','o','s','r','z','='], + ['¾','Y','C','H','W','X', 'y','c','h','w','x','+'], + ['½','¼','2','3','4','5', '6','7','8','9','.','-'], +]; +/* The plate's two inverted regions, as rectangles over LAYOUT rather than a rule + about characters. On the real plate the capitals block is dark discs on a light + panel and the lowercase block is its photographic negative, while everything on + the outer ring stays light-on-halftone — including the capital J, which is why + no case-based rule can describe this. Move a block, move its rectangle. + rows and cols are inclusive [start, end] indices into LAYOUT. */ +DUPRE.indexPlate.ZONES = [ + { rows: [1, 5], cols: [1, 5], zone: 'caps' }, + { rows: [1, 5], cols: [6, 10], zone: 'lower' }, +]; +/* Both tables are DERIVED from the layout, never maintained beside it: relaying + the plate must not leave a keybinding aimed at a character it no longer has. + Every plate character maps to itself (no case folding — the plate has both, so + Shift does the work a shift key would), and Enter is the lever. Space is + deliberately absent: there's no space cell yet, so Space isn't ours to claim. */ +DUPRE.indexPlate.KEYS = (() => { + const m = {}; + for (const c of DUPRE.indexPlate.LAYOUT.flat()) if (c) m[c] = c; + m.Enter = 'PRINT'; + return m; +})(); +/* Every argument press accepts. A superset of the KEYS values: CLR is a real + control that no keystroke reaches, the same shape as the keypad's. */ +DUPRE.indexPlate.ACTIONS = new Set([...DUPRE.indexPlate.LAYOUT.flat().filter(Boolean), 'PRINT', 'CLR']); + +/* 03 horizontal fader — continuous 0-100; drag anywhere on the slot to seek. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 68); onChange(value, label) on every move. + handle: el, get(), set(v) (clamped 0-100). + CSS lives in the "fader" block of DUPRE_CSS; no other styles involved. */ +DUPRE.faderH = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const f = document.createElement('div'); f.className = 'dupre-fader'; + f.innerHTML = '<div class="dupre-slot"><div class="dupre-fill"></div></div><div class="dupre-cap"></div>'; + host.appendChild(f); + const fill = f.querySelector('.dupre-fill'), cap = f.querySelector('.dupre-cap'); + let val; + const set = p => { + val = Math.max(0, Math.min(100, p)); + fill.style.width = val + '%'; + cap.style.left = val + '%'; + onChange(val, 'level ' + Math.round(val)); + }; + dragX(f, set); + set(opts.value !== undefined ? opts.value : 68); + return { el: f, get: () => val, set }; +}; + +/* 04 vertical fader — continuous 0-100, one channel-strip fader; compose per + channel. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 60); onChange(value, label) on every move. + handle: el, get(), set(v) (clamped 0-100). + CSS lives in the "vertical fader" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.faderV = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const f = document.createElement('div'); f.className = 'dupre-vfader'; + f.innerHTML = '<div class="dupre-slot"><div class="dupre-fill"></div></div><div class="dupre-cap"></div>'; + host.appendChild(f); + const fill = f.querySelector('.dupre-fill'), cap = f.querySelector('.dupre-cap'); + let val; + const set = p => { + val = Math.max(0, Math.min(100, p)); + fill.style.height = val + '%'; + cap.style.bottom = val + '%'; + onChange(val, 'level ' + Math.round(val)); + }; + dragY(f, set); + set(opts.value !== undefined ? opts.value : 60); + return { el: f, get: () => val, set }; +}; + +/* 05 rotary knob — drag up/down to turn, -150°..+150° sweep. + Contract (everything a consumer needs; no page globals touched): + opts: min (0), max (100), value (53); onChange(value, label) per step. + handle: el, get(), set(v) (clamped min..max). + CSS lives in the "rotary knob" block of DUPRE_CSS; rotarySelector and + encoder reuse the knob classes for their spindles. */ +DUPRE.knob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const min = opts.min !== undefined ? opts.min : 0, max = opts.max !== undefined ? opts.max : 100; + const k = document.createElement('span'); k.className = 'dupre-knob'; + k.innerHTML = '<span class="dupre-ind"></span>'; + host.appendChild(k); + const ind = k.querySelector('.dupre-ind'); + let val; + const set = v => { + val = Math.max(min, Math.min(max, v)); + ind.style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`; + onChange(val, String(Math.round(val))); + }; + dragDelta(k, () => val, set, { min, max }); + set(opts.value !== undefined ? opts.value : 53); + return { el: k, get: () => val, set }; +}; + +/* 06 segmented selector — pick one of a few. opts.accent picks the lit-segment + color from DUPRE.segmented.STYLES (amber / green / red); defaults match the + stylesheet fallbacks. setStyle(axis, name) restyles a live instance. + Contract (everything a consumer needs; no page globals touched): + opts: items (labels, default TIMER/ALARM/POMO); active (0); accent + ('amber', a STYLES name); onChange(idx, label) on every pick. + handle: el, get(), set(i) (clamped), setStyle(axis, name). + CSS lives in the "stepper / segmented selector" block of DUPRE_CSS; no + other styles involved. */ +DUPRE.segmented = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || ['TIMER', 'ALARM', 'POMO']; + const seg = document.createElement('div'); seg.className = 'dupre-seg'; host.appendChild(seg); + const setStyle = (axis, name) => { + const o = (DUPRE.segmented.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) seg.style.setProperty(k, v); + }; + setStyle('accent', opts.accent || 'amber'); + const btns = items.map(t => { const b = document.createElement('button'); b.textContent = t; seg.appendChild(b); return b; }); + let idx; + const set = i => { + idx = Math.max(0, Math.min(items.length - 1, i)); + btns.forEach((b, j) => b.classList.toggle('dupre-on', j === idx)); + onChange(idx, items[idx]); + }; + btns.forEach((b, i) => b.addEventListener('click', () => set(i))); + set(opts.active || 0); + return { el: seg, get: () => idx, set, setStyle }; +}; +/* named styles per axis: dot = swatch color for pickers, vars = --seg-* overrides */ +DUPRE.segmented.STYLES = { + accent: { + amber: { dot: 'var(--gold-hi)', vars: { '--seg-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--seg-on-ink': 'var(--panel)' } }, + green: { dot: '#8fb944', vars: { '--seg-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--seg-on-ink': 'var(--panel)' } }, + red: { dot: 'var(--fail)', vars: { '--seg-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--seg-on-ink': 'var(--panel)' } }, + }, +}; + +/* 07 chip toggle — inline binary inside a line of text. + Contract (everything a consumer needs; no page globals touched): + opts: label ('discoverable on'); on (true); accent ('amber', a STYLES + name); onChange(on, 'ON'|'OFF') on every flip. + handle: el, get(), set(bool), setStyle(axis, name). + CSS lives in the "chip toggle" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.chipToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const chip = document.createElement('span'); chip.className = 'dupre-chip'; + chip.textContent = opts.label || 'discoverable on'; + host.appendChild(chip); + const setStyle = (axis, name) => { + const o = (DUPRE.chipToggle.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) chip.style.setProperty(k, v); + }; + setStyle('accent', opts.accent || 'amber'); + let on; + const set = v => { on = !!v; chip.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); }; + chip.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: chip, get: () => on, set, setStyle }; +}; +/* The chip's lit colour, from the shared accent family. Gold was hardcoded, which + let the chip say exactly one thing — but "on" is good in one panel, a warning in + the next, and a fault in the one after. The colour is the claim, so it belongs + to the consumer. */ +DUPRE.chipToggle.STYLES = { accent: DUPRE.accentStyles('--chip-on') }; + +/* 08 arm-to-fire — two-stage confirm for destructive actions: first click + arms, second fires and resets to safe. + Contract (everything a consumer needs; no page globals touched): + opts: label ('forget'); armLabel (label + '? again'); onChange(state, + label) with state 'safe' | 'armed' | 'fired'. + handle: el, get() ('armed'|'safe'), fire() (clicks through one stage). + CSS lives in the "arm-to-fire" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.armButton = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const label = opts.label || 'forget', armLabel = opts.armLabel || label + '? again'; + const a = document.createElement('button'); a.className = 'dupre-arm'; a.textContent = label; + host.appendChild(a); + let armed = false; + a.addEventListener('click', () => { + if (!armed) { armed = true; a.classList.add('dupre-armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); } + else { armed = false; a.classList.remove('dupre-armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); } + }); + onChange('safe', 'SAFE'); + return { el: a, get: () => (armed ? 'armed' : 'safe'), fire: () => a.click() }; +}; + +/* 09 lamp row — actionable list item: lamp + name + status, click to cycle. + states are [lampTone, label] pairs; lampTone '' | 'gold' | 'busy' | 'red' + maps onto the shared dupre-lamp classes. + Contract (everything a consumer needs; no page globals touched): + opts: name ('WH-1000XM4'); states ([[tone, label], ...]); state (0); + onChange(idx, label) on every step. + handle: el, get(), set(i) (clamped). + CSS lives in the "lamp row (list item)" block of DUPRE_CSS; the lamp + itself is the shared dupre-lamp. */ +DUPRE.lampRow = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const name = opts.name || 'WH-1000XM4'; + const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']]; + const row = document.createElement('div'); row.className = 'dupre-lrow'; + row.innerHTML = `<span class="dupre-lamp"></span><span class="dupre-who"><b></b></span><span class="dupre-what"></span>`; + row.querySelector('b').textContent = name; + host.appendChild(row); + const lamp = row.querySelector('.dupre-lamp'), what = row.querySelector('.dupre-what'); + let idx; + const set = i => { + idx = Math.max(0, Math.min(states.length - 1, i)); + lamp.className = 'dupre-lamp' + (states[idx][0] ? ' dupre-' + states[idx][0] : ''); + what.textContent = states[idx][1]; + onChange(idx, states[idx][1]); + }; + row.addEventListener('click', () => set((idx + 1) % states.length)); + set(opts.state || 0); + return { el: row, get: () => idx, set }; +}; + +/* 24 rotary selector — pick one of five printed detents by position; click + the knob to step (wraps). Position/angle plates are fixed at five stops, + so values takes at most five entries. + Contract (everything a consumer needs; no page globals touched): + opts: values (up to 5 labels, default [4,6,8,10,12]); index (2); + fmt(v) → readout label; onChange(value, label) per step. + handle: el, get() (the value), set(i) (wraps modulo values). + CSS lives in the "rotary selector" block of DUPRE_CSS; the spindle + reuses the shared dupre-knob classes from the "rotary knob" block. */ +DUPRE.rotarySelector = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const values = (opts.values || [4, 6, 8, 10, 12]).slice(0, 5); + const fmt = opts.fmt || (v => 'size ' + v); + const POS = [[14, 53], [28, 18], [50, 7], [72, 18], [86, 53]], ANG = [-70, -35, 0, 35, 70]; + const rs = document.createElement('span'); rs.className = 'dupre-rotsel'; + const marks = values.map((v, i) => { + const p = document.createElement('span'); p.className = 'dupre-pos'; + p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v; + rs.appendChild(p); return p; + }); + const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = '<span class="dupre-ind"></span>'; rs.appendChild(k); + host.appendChild(rs); + const ind = k.querySelector('.dupre-ind'); + let idx; + const set = i => { + idx = ((i % values.length) + values.length) % values.length; + ind.style.transform = `rotate(${ANG[idx]}deg)`; + marks.forEach((p, j) => p.classList.toggle('dupre-on', j === idx)); + onChange(values[idx], fmt(values[idx])); + }; + k.addEventListener('click', () => set(idx + 1)); + set(opts.index !== undefined ? opts.index : 2); + return { el: rs, get: () => values[idx], set }; +}; + +/* 25 slide-rule dial — lit pointer on a printed scale. The printed numerals + are the majors; the integer units between them get minor ticks and are + selectable too. Click a numeral, a mark, or between marks; ←/→ (↑/↓) step + one unit. + Contract (everything a consumer needs; no page globals touched): + opts: values (majors, default [4,6,8,10,12]); value or index (2) for the + initial stop; fmt(v) → readout label; skin ('warm', a STYLES name: + warm backlit / chrome / 80s black glass / marantz blue); + onChange(value, label) per step. + handle: el, get() (the value), set(stopIndex) (clamped), setStyle(axis, name). + CSS lives in the "slide-rule tuner dial" block of DUPRE_CSS; skins override + its --tn-* vars. */ +DUPRE.slideRule = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const majors = opts.values || [4, 6, 8, 10, 12]; + const fmt = opts.fmt || (v => 'pos ' + v); + const X = [12, 51, 90, 129, 168]; + /* expand to unit stops: every integer between adjacent numeric majors */ + const stops = []; + majors.forEach((v, i) => { + stops.push({ v, x: X[i], major: true }); + const b = majors[i + 1]; + if (typeof v === 'number' && typeof b === 'number') + for (let u = v + 1; u < b; u++) + stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false }); + }); + const t = document.createElement('span'); t.className = 'dupre-tuner'; + t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value'); + const setStyle = (axis, name) => { + const o = (DUPRE.slideRule.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v); + }; + setStyle('skin', opts.skin || 'warm'); + stops.forEach(s => { + t.insertAdjacentHTML('beforeend', s.major + ? `<span class="dupre-tick" style="left:${s.x}px"></span><span class="dupre-mk" style="left:${s.x}px">${s.v}</span>` + : `<span class="dupre-tick dupre-mn" style="left:${s.x}px"></span>`); + }); + const ndl = document.createElement('span'); ndl.className = 'dupre-ndl'; t.appendChild(ndl); + host.appendChild(t); + let idx; + const set = i => { + idx = Math.max(0, Math.min(stops.length - 1, i)); + ndl.style.left = stops[idx].x + 'px'; + t.querySelectorAll('.dupre-mk').forEach(m => m.classList.toggle('dupre-on', +m.textContent === stops[idx].v)); + onChange(stops[idx].v, fmt(stops[idx].v)); + }; + t.addEventListener('click', e => { + const r = t.getBoundingClientRect(); + /* rect is visual px but stop coords are layout px — divide the CSS zoom out */ + const x = (e.clientX - r.left) * (t.offsetWidth / r.width); + let best = 0, bd = 1e9; stops.forEach((s, j) => { const d = Math.abs(s.x - x); if (d < bd) { bd = d; best = j; } }); + set(best); t.focus(); + }); + t.addEventListener('keydown', e => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); set(idx - 1); } + else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); set(idx + 1); } + }); + const initV = opts.value !== undefined ? opts.value : majors[opts.index !== undefined ? opts.index : 2]; + const init = stops.findIndex(s => s.v === initV); + set(init < 0 ? 0 : init); + return { el: t, get: () => stops[idx].v, set, setStyle }; +}; +/* named faces: dot = swatch color for pickers, vars = --tn-* overrides. + warm restates the stylesheet fallbacks so switching back is exact. */ +DUPRE.slideRule.STYLES = { + skin: { + warm: { dot: 'var(--gold-hi)', vars: { + '--tn-bg': 'linear-gradient(180deg,#191510,#0b0908)', '--tn-brd': '#2a251c', + '--tn-glow': 'rgba(var(--glow-lo),.12)', '--tn-tick': 'var(--steel)', '--tn-ink': 'var(--steel)', + '--tn-on': 'var(--gold-hi)', '--tn-onglow': 'rgba(var(--glow-hi),.6)', + '--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.85)' } }, + chrome: { dot: '#dfe6f2', vars: { + '--tn-bg': 'linear-gradient(180deg,#e9e9e5,#bcbcb6)', '--tn-brd': '#8e8e88', + '--tn-glow': 'rgba(255,255,255,.28)', '--tn-tick': '#3c3a34', '--tn-ink': '#3c3a34', + '--tn-on': '#14110e', '--tn-onglow': 'none', + '--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.45)' } }, + black: { dot: '#1c1a18', vars: { + '--tn-bg': 'linear-gradient(180deg,#17181a,#08090a)', '--tn-brd': '#000', + '--tn-glow': 'rgba(255,255,255,.05)', '--tn-tick': '#c8cac8', '--tn-ink': '#c8cac8', + '--tn-on': '#f2f4f2', '--tn-onglow': 'rgba(242,244,242,.5)', + '--tn-ndl': '#ff3b28', '--tn-ndlglow': 'rgba(255,59,40,.9)' } }, + blue: { dot: '#8fb4d8', vars: { + '--tn-bg': 'linear-gradient(180deg,#0c1520,#070c14)', '--tn-brd': '#1c2c40', + '--tn-glow': 'rgba(120,170,230,.16)', '--tn-tick': '#8fb4d8', '--tn-ink': '#8fb4d8', + '--tn-on': '#cfe4ff', '--tn-onglow': 'rgba(150,200,255,.7)', + '--tn-ndl': '#e8f2ff', '--tn-ndlglow': 'rgba(180,220,255,.9)' } }, + }, +}; + +/* N01 rocker power switch — hard on/off, lit legend. + Contract (everything a consumer needs; no page globals touched): + opts: onLabel ('ON'); offLabel ('OFF'); on (true); onChange(on, + 'ON'|'OFF') on every flip. + handle: el, get(), set(bool). + CSS lives in the "rocker" block of DUPRE_CSS; no other styles involved. */ +DUPRE.rocker = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const r = document.createElement('span'); r.className = 'dupre-rocker'; + r.innerHTML = `<span class="dupre-half dupre-top">${opts.onLabel || 'ON'}</span><span class="dupre-half dupre-bot">${opts.offLabel || 'OFF'}</span>`; + host.appendChild(r); + let on; + const set = v => { on = !!v; r.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); }; + r.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: r, get: () => on, set }; +}; + +/* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing. + Contract (everything a consumer needs; no page globals touched): + opts: mode ('play', one of rew|play|stop|rec); animate (reels obey play + state; default: not prefers-reduced-motion); onChange(mode, NAME) + on every pick. + handle: el, get() (the mode key), set(mode). + CSS lives in the "transport" block of DUPRE_CSS plus the reelspin + keyframes in the motion section. */ +DUPRE.transport = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const animate = opts.animate !== undefined ? opts.animate : !matchMedia('(prefers-reduced-motion: reduce)').matches; + const MODES = { rew: '⏮', play: '▶', stop: '⏹', rec: '⏺' }; + const NAMES = { rew: 'REW', play: 'PLAY', stop: 'STOP', rec: 'REC' }; + const wrap = document.createElement('div'); + wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;align-items:center'; + wrap.innerHTML = `<div class="dupre-reels"><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span></div><div class="dupre-transport"></div>`; + const bar = wrap.querySelector('.dupre-transport'); + const reels = wrap.querySelectorAll('.dupre-reel'); + const btns = {}; + for (const m of Object.keys(MODES)) { + const b = document.createElement('button'); b.className = 'dupre-tbtn' + (m === 'rec' ? ' dupre-rec' : ''); + b.textContent = MODES[m]; bar.appendChild(b); btns[m] = b; + b.addEventListener('click', () => set(m)); + } + host.appendChild(wrap); + let mode; + const set = m => { + mode = m; + for (const k in btns) btns[k].classList.toggle('dupre-on', k === m); + reels.forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused'); + onChange(m, NAMES[m]); + }; + /* initial: light the mode but leave reel play-state to CSS, matching load behavior */ + mode = opts.mode || 'play'; + btns[mode].classList.add('dupre-on'); + onChange(mode, NAMES[mode]); + return { el: wrap, get: () => mode, set }; +}; + +/* N03 radio preset bank — mechanically exclusive presets. + Contract (everything a consumer needs; no page globals touched): + opts: items (labels, default WIFI/ETH/CELL/OFF); active (0); + onChange(idx, label) on every pick. + handle: el, get(), set(i) (clamped). + CSS lives in the "radio bank" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.presetBank = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF']; + const bank = document.createElement('div'); bank.className = 'dupre-radiobank'; host.appendChild(bank); + const btns = items.map(t => { const b = document.createElement('button'); b.className = 'dupre-preset'; b.textContent = t; bank.appendChild(b); return b; }); + let idx; + const set = i => { idx = Math.max(0, Math.min(items.length - 1, i)); btns.forEach((b, j) => b.classList.toggle('dupre-on', j === idx)); onChange(idx, items[idx]); }; + btns.forEach((b, i) => b.addEventListener('click', () => set(i))); + set(opts.active || 0); + return { el: bank, get: () => idx, set }; +}; + +/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap. + Contract (everything a consumer needs; no page globals touched): + opts: outer (50), inner (50), each 0..100; onChange(outer, inner) on + every move of either. + handle: el, get() ([outer, inner]), set(outer, inner) (either may be + undefined to hold; clamped 0..100). + CSS lives in the "concentric dual knob" block of DUPRE_CSS; its indicator + is self-styled, independent of the shared dupre-knob classes. */ +DUPRE.dualKnob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const clamp = v => Math.max(0, Math.min(100, v)); + const dk = document.createElement('span'); dk.className = 'dupre-dualknob'; + dk.innerHTML = `<span class="dupre-outer"><span class="dupre-tick" style="transform:rotate(-120deg)"></span><span class="dupre-tick" style="transform:rotate(120deg)"></span></span><span class="dupre-inner"><span class="dupre-ind"></span></span>`; + host.appendChild(dk); + const o = dk.querySelector('.dupre-outer'), n = dk.querySelector('.dupre-inner'); + let oV, iV; + const paint = () => { + o.style.transform = `rotate(${-150 + oV / 100 * 300}deg)`; + n.style.transform = `rotate(${-150 + iV / 100 * 300}deg)`; + onChange(oV, iV); + }; + dragDelta(o, () => oV, v => { oV = v; paint(); }, { min: 0, max: 100 }); + dragDelta(n, () => iV, v => { iV = v; paint(); }, { min: 0, max: 100, stop: true }); + oV = clamp(opts.outer !== undefined ? opts.outer : 50); + iV = clamp(opts.inner !== undefined ? opts.inner : 50); + paint(); + return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = clamp(a); if (b !== undefined) iV = clamp(b); paint(); } }; +}; + +/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level. + Contract (everything a consumer needs; no page globals touched): + opts: value (7, accumulates without bound); onChange(value, label) per + step. + handle: el, get(), set(v). + CSS lives in the "rotary encoder + LED ring" block of DUPRE_CSS; the + spindle reuses the shared dupre-knob classes from the "rotary knob" + block. */ +DUPRE.encoder = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const enc = document.createElement('span'); enc.className = 'dupre-encoder'; host.appendChild(enc); + const R = 27, cx = 33, cy = 33; + const leds = []; + for (let i = 0; i < 12; i++) { + const a = (i * 30 - 90) * Math.PI / 180; + const d = document.createElement('span'); d.className = 'dupre-led'; + d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px'; + enc.appendChild(d); leds.push(d); + } + const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = '<span class="dupre-ind"></span>'; enc.appendChild(k); + let val; + const set = v => { + val = v; + const lit = ((Math.floor(val) % 12) + 12) % 12; + leds.forEach((l, i) => l.classList.toggle('dupre-on', i <= lit)); + k.style.transform = `rotate(${val * 30}deg)`; + onChange(val, 'pos ' + Math.round(val)); + }; + dragDelta(enc, () => val, set, { min: 0, max: 100000, sens: 0.25 }); + set(opts.value !== undefined ? opts.value : 7); + return { el: enc, get: () => val, set }; +}; + +/* N06 keyed mode switch — guarded three-position mode, key-bit points at the + live one; click the barrel to step (wraps). Position/angle plates are fixed + at three stops, so items takes at most three entries. + Contract (everything a consumer needs; no page globals touched): + opts: items (up to 3 labels, default OFF/ON/RUN); index (1); + onChange(idx, label) per step. + handle: el, get(), set(i) (wraps modulo items). + CSS lives in the "keyed mode switch" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.keySwitch = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = (opts.items || ['OFF', 'ON', 'RUN']).slice(0, 3); + const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]]; + const kl = document.createElement('span'); kl.className = 'dupre-keylock'; + const marks = items.map((t, i) => { + const p = document.createElement('span'); p.className = 'dupre-kpos'; + p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = t; + kl.appendChild(p); return p; + }); + const body = document.createElement('span'); body.className = 'dupre-body'; + body.innerHTML = '<span class="dupre-bit"></span><span class="dupre-barrel"></span>'; + kl.appendChild(body); host.appendChild(kl); + const bit = body.querySelector('.dupre-bit'); + let idx; + const set = i => { + idx = ((i % items.length) + items.length) % items.length; + bit.style.transform = `rotate(${ANG[idx]}deg)`; + marks.forEach((p, j) => p.classList.toggle('dupre-on', j === idx)); + onChange(idx, items[idx]); + }; + body.addEventListener('click', () => set(idx + 1)); + set(opts.index !== undefined ? opts.index : 1); + return { el: kl, get: () => idx, set }; +}; + +/* N07 center-detented crossfader — throw to either side of zero. + Contract (everything a consumer needs; no page globals touched): + opts: aLabel ('A'), bLabel ('B'); value (50, the cap position 0..100); + onChange(v, label) with v = position - 50 (so -50..+50) per move. + handle: el, get() (-50..+50), set(pct) (0..100, clamped). + CSS lives in the "crossfader" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.crossfader = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const xf = document.createElement('div'); xf.className = 'dupre-xfader'; + xf.innerHTML = `<div class="dupre-slot"></div><div class="dupre-detent"></div><span class="dupre-end" style="left:0">${opts.aLabel || 'A'}</span><span class="dupre-end" style="right:0">${opts.bLabel || 'B'}</span><div class="dupre-cap"></div>`; + host.appendChild(xf); + const cap = xf.querySelector('.dupre-cap'); + let pct; + const set = p => { + pct = Math.max(0, Math.min(100, p)); + cap.style.left = pct + '%'; + const v = Math.round(pct - 50); + onChange(v, (v > 0 ? '+' : '') + v); + }; + dragX(xf, set); + set(opts.value !== undefined ? opts.value : 50); + return { el: xf, get: () => Math.round(pct - 50), set }; +}; + +/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value. + Contract (everything a consumer needs; no page globals touched): + opts: value (42); onChange(value, label) per step, value wrapped to + 0..99. + handle: el, get() (0..99), set(v) (wraps modulo 100). + CSS lives in the "thumbwheel" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.thumbwheel = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const w = document.createElement('div'); w.className = 'dupre-thumbw'; + w.innerHTML = '<span class="dupre-thumbwheel"></span><span class="dupre-win"></span>'; + host.appendChild(w); + const win = w.querySelector('.dupre-win'); + let val; + const set = v => { + val = Math.round(v); + const s = ((val % 100) + 100) % 100; + win.textContent = String(s).padStart(2, '0'); + onChange(s, 'value ' + s); + }; + dragDelta(w.querySelector('.dupre-thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 }); + set(opts.value !== undefined ? opts.value : 42); + return { el: w, get: () => ((val % 100) + 100) % 100, set }; +}; + +/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word. + Contract (everything a consumer needs; no page globals touched): + opts: bits (array of booleans, one switch each, default 6 switches); + onChange(word, label) — both are the binary word string ('101100'). + handle: el, get() (the word string), set(w) — a word string; position i + goes up on '1', down on any other character. + CSS lives in the "DIP bank" block of DUPRE_CSS; no other styles involved. */ +DUPRE.dipBank = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const bits = opts.bits || [true, false, true, true, false, false]; + const bank = document.createElement('span'); bank.className = 'dupre-dip'; host.appendChild(bank); + const sws = bits.map(on => { + const s = document.createElement('span'); s.className = 'dupre-dipsw' + (on ? ' dupre-on' : ''); + s.innerHTML = '<i></i>'; bank.appendChild(s); return s; + }); + const word = () => sws.map(x => x.classList.contains('dupre-on') ? '1' : '0').join(''); + const upd = () => onChange(word(), word()); + sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('dupre-on'); upd(); })); + upd(); + return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('dupre-on', w[i] === '1')); upd(); } }; +}; + +/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit. + Contract (everything a consumer needs; no page globals touched): + opts: value (initial position, default 0); onChange(v, label) on every + move. + handle: el, get() (the position), set(v) — unclamped by design; the jog + is a relative scrub, not an absolute dial (drag spans ±100000). + CSS lives in the "jog / shuttle" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.jogWheel = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const j = document.createElement('span'); j.className = 'dupre-jog'; + j.innerHTML = `<span class="dupre-shuttle" style="--sh:40deg"></span><span class="dupre-inner"><span class="dupre-dimple"></span></span>`; + host.appendChild(j); + const inner = j.querySelector('.dupre-inner'); + let val; + const set = v => { + val = v; + inner.style.transform = `rotate(${val * 4}deg)`; + onChange(val, 'pos ' + Math.round(val)); + }; + dragDelta(j, () => val, set, { min: -100000, max: 100000, sens: 0.5 }); + set(opts.value !== undefined ? opts.value : 0); + return { el: j, get: () => val, set }; +}; + +/* ---- shared SVG defs: gradients/filters referenced by url(#id) are + document-scoped, and several are used across instruments. Builders ensure the + defs they use; the first caller creates it, later calls are no-ops. ---- */ +let defsRoot = null; +const defsMade = new Set(); +function defsHost() { + if (!defsRoot) { + const svg = document.createElementNS(SVGNS, 'svg'); + svg.setAttribute('width', 0); svg.setAttribute('height', 0); + svg.setAttribute('aria-hidden', 'true'); + svg.style.position = 'absolute'; + defsRoot = document.createElementNS(SVGNS, 'defs'); + svg.appendChild(defsRoot); + document.body.appendChild(svg); + } + return defsRoot; +} +function def(id, make) { if (defsMade.has(id)) return; defsMade.add(id); make(defsHost(), id); } +function gradDef(id, kind, attrs, stops) { + def(id, d => { + const g = svgEl(d, kind, Object.assign({ id }, attrs)); + for (const [offset, color] of stops) svgEl(g, 'stop', { offset, 'stop-color': color }); + }); +} +function stageSvg(host, cls, vw, vh) { + const s = document.createElementNS(SVGNS, 'svg'); + s.setAttribute('class', cls); s.setAttribute('viewBox', `0 0 ${vw} ${vh}`); + s.setAttribute('width', vw); s.setAttribute('height', vh); + host.appendChild(s); return s; +} +/* instance-unique ids for defs that can't be shared (e.g. clip paths sized per instance) */ +let uidN = 0; +function uid(prefix) { return prefix + '-' + (++uidN); } + +/* R02 calibrated vernier dial — the disc turns under a fixed hairline. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 42.0); onChange(v, label) on every move. + handle: el, get(), set(v) (clamped 0-100). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.vernierDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 150, 150), cx = 75, cy = 75; + gradDef('vernFace', 'radialGradient', { cx: '50%', cy: '45%', r: '60%' }, [['0', '#f7f2da'], ['1', '#e4dfc2']]); + gradDef('bakeKnob', 'radialGradient', { cx: '42%', cy: '36%', r: '75%' }, [['0', '#37332c'], ['1', '#060505']]); + svgEl(s, 'circle', { cx, cy, r: 72, fill: '#0c0b0a', stroke: '#262320' }); + const disc = svgEl(s, 'g', {}); + svgEl(disc, 'circle', { cx, cy, r: 66, fill: 'url(#vernFace)', stroke: '#b7b29a', 'stroke-width': 1 }); + for (let u = 0; u <= 100; u++) { + const a = u * 3, major = u % 10 === 0, half = u % 5 === 0; + const len = major ? 10 : half ? 7 : 4.5, [x1, y1] = polar(cx, cy, 63, a), [x2, y2] = polar(cx, cy, 63 - len, a); + svgEl(disc, 'line', { x1, y1, x2, y2, stroke: '#3a3128', 'stroke-width': major ? 1.3 : half ? 1 : .7 }); + if (major) { + const g = svgEl(disc, 'g', { transform: `rotate(${a},${cx},${cy})` }); + svgEl(g, 'text', { x: cx, y: cy - 44, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#3a3128' }).textContent = u; + } + } + for (let k = 0; k < 12; k++) { const [x, y] = polar(cx, cy, 24, k * 30); svgEl(s, 'circle', { cx: x, cy: y, r: 4.8, fill: 'url(#bakeKnob)' }); } + svgEl(s, 'circle', { cx, cy, r: 24, fill: 'url(#bakeKnob)', stroke: '#000', 'stroke-width': .6 }); + svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#bakeKnob)' }); + svgEl(s, 'ellipse', { cx: 69, cy: 66, rx: 7, ry: 4, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-32,69,66)' }); + svgEl(s, 'line', { x1: cx, y1: 6, x2: cx, y2: 22, stroke: 'var(--fail)', 'stroke-width': 2.2, 'stroke-linecap': 'round' }); + let val; + const set = v => { val = Math.max(0, Math.min(100, v)); disc.setAttribute('transform', `rotate(${-val * 3},75,75)`); onChange(val, val.toFixed(1)); }; + dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.15 }); + set(opts.value !== undefined ? opts.value : 42.0); + return { el: s, get: () => val, set }; +}; + +/* R03 bat-handle toggle — chrome lever throws between lit legends. + Contract (everything a consumer needs; no page globals touched): + opts: on (initial state, default true); onLabel / offLabel (legends, + default 'ON' / 'OFF'); onChange(on, label) on every throw. + handle: el, get() (boolean), set(v) (coerced to boolean). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.batToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44; + gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]); + gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]); + gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]); + const lblOn = svgEl(s, 'text', { x: cx, y: 9, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); + lblOn.textContent = opts.onLabel || 'ON'; + const lblOff = svgEl(s, 'text', { x: cx, y: 87, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + lblOff.textContent = opts.offLabel || 'OFF'; + const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 17 * Math.cos(a)) + ',' + (cy + 17 * Math.sin(a))); } + svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' }); + svgEl(s, 'circle', { cx, cy, r: 9, fill: '#14110e', stroke: '#000' }); + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'path', { d: `M ${cx - 3.2} ${cy} L ${cx - 1.8} 21 L ${cx + 1.8} 21 L ${cx + 3.2} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 }); + svgEl(lever, 'ellipse', { cx, cy: 17, rx: 6.5, ry: 8, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 }); + lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s'; + let on; + const set = v => { + on = !!v; + lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)'; + lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)'); + lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)'); + onChange(on, on ? 'ON' : 'OFF'); + }; + s.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: s, get: () => on, set }; +}; + +/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100 internally, default 63; the arc prints it as 0-10); + onChange(v, label) on every move, label as 'x.x / 10'. + handle: el, get(), set(v) (clamped 0-100). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.flutedKnob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 54; + gradDef('bakeSk', 'radialGradient', { cx: '42%', cy: '36%', r: '80%' }, [['0', '#312d27'], ['1', '#050404']]); + gradDef('bakeDome', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#3a362f'], ['1', '#0a0908']]); + for (let i = 0; i <= 10; i++) { + const a = -135 + i * 27, major = i % 5 === 0; + const [x1, y1] = polar(cx, cy, 38, a), [x2, y2] = polar(cx, cy, major ? 45 : 43.5, a); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': major ? 1.5 : 1 }); + if (major) { + const [x, y] = polar(cx, cy, 50.5, a); + svgEl(s, 'text', { x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': 8, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i; + } + } + for (let k = 0; k < 18; k++) { const [x, y] = polar(cx, cy, 31, k * 20); svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: 'url(#bakeSk)' }); } + svgEl(s, 'circle', { cx, cy, r: 31, fill: 'url(#bakeSk)', stroke: '#000', 'stroke-width': .6 }); + const idx = svgEl(s, 'line', { x1: cx, y1: cy - 30, x2: cx, y2: cy - 21, stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' }); + svgEl(s, 'circle', { cx, cy, r: 19, fill: 'url(#bakeDome)' }); + svgEl(s, 'ellipse', { cx: 49, cy: 46, rx: 6, ry: 3.5, fill: 'rgba(255,255,255,.14)', transform: 'rotate(-28,49,46)' }); + let val; + const set = v => { val = Math.max(0, Math.min(100, v)); idx.setAttribute('transform', `rotate(${val * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); }; + dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.25 }); + set(opts.value !== undefined ? opts.value : 63); + return { el: s, get: () => val, set }; +}; + +/* R05 filter slider bank — a dense fader wall on a real faceplate. After the + Pioneer SG-9500, the Technics SH-8065, and the Zaxcom Oasis block faders. + Contract (everything a consumer needs; no page globals touched): + opts: freqs (band centers in Hz, default 12 bands); values (initial dB + cuts 0-60, padded/truncated to freqs); onChange({band, hz, db}, + label) per move. Period skins via DUPRE.filterBank.STYLES, three + independent axes: panel (silver hi-fi aluminum / studio black), + caps — the cap SHAPE (chrome T / short ribbed / tall block fader), + capColor — the cap FINISH (black white-index / color stripes / + chrome / cream). opts.style picks a native trio (silver+chrome+ + chrome, studio+ribbed+red); opts.panel / opts.caps / opts.capColor + override an axis. + handle: el, get() (dB array copy), set(i, db) (band clamped, dB clamped + 0-60), setStyle(axis, name) — restyles live, values kept. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.filterBank = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const freqs = opts.freqs || [78, 113, 163, 235, 339, 487, 701, 1010, 1450, 2090, 3010, 4340]; + const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length).map(v => Math.max(0, Math.min(60, v))); + while (vals.length < freqs.length) vals.push(30); + const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + const ST = DUPRE.filterBank.STYLES; + const native = { + silver: { caps: 'chrome', capColor: 'chrome' }, + studio: { caps: 'ribbed', capColor: 'red' }, + }; + const nat = native[opts.style] || {}; + const cur = { + panel: ST.panel[opts.panel] ? opts.panel : (ST.panel[opts.style] ? opts.style : 'silver'), + caps: ST.caps[opts.caps] ? opts.caps : (nat.caps || 'block'), + capColor: ST.capColor[opts.capColor] ? opts.capColor : (nat.capColor || 'black'), + }; + gradDef('fbPlateSilver', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8d8d4'], ['.5', '#c6c6c1'], ['1', '#adada7']]); + gradDef('fbPlateStudio', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#221f1c'], ['1', '#131110']]); + gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]); + gradDef('fbCapBlack', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2a2724'], ['1', '#171412']]); + gradDef('fbCapCream', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2ecd8'], ['1', '#cfc8ae']]); + const s = stageSvg(host, 'rsvg', 250, 112); + const y0 = 20, y1 = 92, x0 = 36, dx = (214 - x0) / (freqs.length - 1); + const yOf = db => y0 + db / 60 * (y1 - y0); + const caps = []; + const place = i => caps[i].setAttribute('transform', `translate(0,${yOf(vals[i])})`); + const drawCap = (g, kind, x) => { + const c = ST.capColor[cur.capColor]; + if (kind === 'chrome') { + svgEl(g, 'rect', { x: x - 6, y: -3.25, width: 12, height: 6.5, rx: .9, fill: c.body, stroke: c.edge, 'stroke-width': .6 }); + /* bare metal needs no painted stripe — chrome-on-chrome stays lineless */ + if (cur.capColor !== 'chrome') + svgEl(g, 'line', { x1: x - 6, y1: 0, x2: x + 6, y2: 0, stroke: c.index, 'stroke-width': 1 }); + svgEl(g, 'line', { x1: x - 6, y1: 2, x2: x + 6, y2: 2, stroke: c.rib, 'stroke-width': .5 }); + } else if (kind === 'ribbed') { + svgEl(g, 'rect', { x: x - 5, y: -4, width: 10, height: 8, rx: 1, fill: c.body, stroke: c.edge, 'stroke-width': .6 }); + for (const dy of [-2.4, 2.4]) + svgEl(g, 'line', { x1: x - 4, y1: dy, x2: x + 4, y2: dy, stroke: c.rib, 'stroke-width': .6 }); + svgEl(g, 'line', { x1: x - 5, y1: 0, x2: x + 5, y2: 0, stroke: c.index, 'stroke-width': 1.1 }); + } else { /* block — tall ribbed fader after the Zaxcom Oasis; fine rib + groups above and below a mid-cap index line that marks the value */ + svgEl(g, 'rect', { x: x - 4.5, y: -8, width: 9, height: 16, rx: 1.1, fill: c.body, stroke: c.edge, 'stroke-width': .6 }); + for (const dy of [-6.4, -4.8, -3.2, -1.6, 1.6, 3.2, 4.8, 6.4]) + svgEl(g, 'line', { x1: x - 3.7, y1: dy, x2: x + 3.7, y2: dy, stroke: c.rib, 'stroke-width': .6 }); + svgEl(g, 'line', { x1: x - 4.5, y1: 0, x2: x + 4.5, y2: 0, stroke: c.index, 'stroke-width': 1.2 }); + } + }; + const set = (i, db) => { + i = Math.max(0, Math.min(freqs.length - 1, i)); + db = Math.max(0, Math.min(60, db)); vals[i] = db; place(i); + onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`); + }; + const render = () => { + const st = ST.panel[cur.panel]; + s.replaceChildren(); caps.length = 0; + svgEl(s, 'rect', { x: 1.5, y: 1.5, width: 247, height: 109, rx: 5, fill: st.plate, stroke: st.edge, 'stroke-width': 1 }); + svgEl(s, 'rect', { x: 3, y: 3, width: 244, height: 106, rx: 4, fill: 'none', stroke: st.bevel, 'stroke-width': .7 }); + [[9, 9], [241, 9], [9, 103], [241, 103]].forEach(([sx, sy], k) => { + svgEl(s, 'circle', { cx: sx, cy: sy, r: 2.7, fill: st.screw, stroke: 'rgba(0,0,0,.55)', 'stroke-width': .6 }); + svgEl(s, 'line', { x1: sx - 1.9, y1: sy, x2: sx + 1.9, y2: sy, stroke: 'rgba(0,0,0,.6)', 'stroke-width': .7, transform: `rotate(${25 + k * 40},${sx},${sy})` }); + }); + for (let db = 0; db <= 60; db += 10) { + const y = yOf(db), major = db % 20 === 0, len = major ? 4 : 2.5; + svgEl(s, 'line', { x1: 24 - len, y1: y, x2: 24, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 }); + svgEl(s, 'line', { x1: 226, y1: y, x2: 226 + len, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 }); + if (major) { + svgEl(s, 'text', { x: 17, y: y + 2, 'text-anchor': 'end', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db; + svgEl(s, 'text', { x: 233, y: y + 2, 'text-anchor': 'start', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db; + } + } + svgEl(s, 'text', { x: 20, y: 100, 'text-anchor': 'end', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.dim }).textContent = 'dB'; + freqs.forEach((f, i) => { + const x = x0 + i * dx; + svgEl(s, 'text', { x, y: 13, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.ink }).textContent = fmtHz(f); + svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: st.track, 'stroke-width': st.trackW, 'stroke-linecap': 'round' }); + if (st.trackHi) svgEl(s, 'line', { x1: x - st.trackW / 2 - .35, y1: y0, x2: x - st.trackW / 2 - .35, y2: y1, stroke: st.trackHi, 'stroke-width': .7 }); + const cap = svgEl(s, 'g', {}); + drawCap(cap, cur.caps, x); + caps.push(cap); place(i); + const hit = svgEl(s, 'rect', { x: x - dx / 2, y: y0 - 6, width: dx, height: y1 - y0 + 12, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragY(hit, pct => set(i, (100 - pct) / 100 * 60)); + }); + }; + const setStyle = (axis, name) => { + if (!ST[axis] || !ST[axis][name]) return; + cur[axis] = name; render(); + }; + render(); + onChange(null, 'drag a band'); + return { el: s, get: () => vals.slice(), set, setStyle }; +}; +/* named styles per axis: dot = swatch color for pickers. caps is the cap + shape; capColor is the cap finish (body + rib + index line), applicable to + any shape. */ +DUPRE.filterBank.STYLES = { + panel: { + silver: { dot: '#c9c9c5', plate: 'url(#fbPlateSilver)', edge: '#8e8e88', bevel: 'rgba(255,255,255,.4)', ink: '#33332f', dim: '#5c5c56', screw: '#9c9c96', track: '#121210', trackW: 2.0, trackHi: null }, + studio: { dot: '#1c1a18', plate: 'url(#fbPlateStudio)', edge: '#000', bevel: 'rgba(255,255,255,.06)', ink: '#b9b4a6', dim: '#6f6a5e', screw: '#3c3832', track: '#050505', trackW: 2.2, trackHi: 'rgba(255,255,255,.05)' }, + }, + caps: { + block: { dot: '#2c2824' }, + ribbed: { dot: '#6f6a5e' }, + chrome: { dot: '#dfe6f2' }, + }, + capColor: { + black: { dot: '#211e1b', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#f2f4f2', edge: '#000' }, + red: { dot: 'var(--sevred)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevred)', edge: '#000' }, + green: { dot: 'var(--sevgrn)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevgrn)', edge: '#000' }, + blue: { dot: '#4f9fe0', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#4f9fe0', edge: '#000' }, + amber: { dot: 'var(--gold-hi)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--gold-hi)', edge: '#000' }, + chrome: { dot: '#dfe6f2', body: 'url(#sfChrome)', rib: 'rgba(0,0,0,.28)', index: '#14110e', edge: '#3c3a34' }, + cream: { dot: '#efe9d4', body: 'url(#fbCapCream)', rib: 'rgba(0,0,0,.18)', index: '#3a3128', edge: '#8a8474' }, + }, +}; + +/* R06 chicken-head selector — tapered lever aims at the position. + Contract (everything a consumer needs; no page globals touched): + opts: items ([label, angleDeg] pairs, default OFF/LO/MID/HI); index + (initial position, default 2); onChange(index, label) per step. + Click advances one position, wrapping. + handle: el, get() (the index), set(i) (wraps modulo items.length). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.chickenHead = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || [['OFF', -60], ['LO', -20], ['MID', 20], ['HI', 60]]; + const s = stageSvg(host, 'rsvg press', 110, 96), cx = 55, cy = 58; + gradDef('chickG', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#35312b'], ['1', '#070606']]); + const lbls = []; + items.forEach(([lbl, a]) => { + const [tx, ty] = polar(cx, cy, 42, a); + const t = svgEl(s, 'text', { x: tx, y: ty + 2.5, 'text-anchor': 'middle', 'font-size': 6.5, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + t.textContent = lbl; lbls.push(t); + const [mx1, my1] = polar(cx, cy, 33, a), [mx2, my2] = polar(cx, cy, 29, a); + svgEl(s, 'line', { x1: mx1, y1: my1, x2: mx2, y2: my2, stroke: 'var(--steel)', 'stroke-width': 1.2 }); + }); + svgEl(s, 'circle', { cx, cy, r: 12, fill: '#0c0b0a' }); + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'path', { d: `M ${cx} ${cy - 29} L ${cx + 8.5} ${cy - 4} Q ${cx + 9} ${cy + 8} ${cx + 5} ${cy + 11} A 7 7 0 0 1 ${cx - 5} ${cy + 11} Q ${cx - 9} ${cy + 8} ${cx - 8.5} ${cy - 4} Z`, fill: 'url(#chickG)', stroke: '#000', 'stroke-width': .7 }); + svgEl(lever, 'line', { x1: cx, y1: cy - 26, x2: cx, y2: cy + 6, stroke: '#4a463e', 'stroke-width': 1.4, 'stroke-linecap': 'round' }); + lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s'; + let idx; + const set = i => { + idx = ((i % items.length) + items.length) % items.length; + lever.style.transform = `rotate(${items[idx][1]}deg)`; + lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)')); + onChange(idx, items[idx][0]); + }; + s.addEventListener('click', () => set(idx + 1)); + set(opts.index !== undefined ? opts.index : 2); + return { el: s, get: () => idx, set }; +}; + +/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate. + Contract (everything a consumer needs; no page globals touched): + opts: value (dB, default -4); onChange(db, label) on every move. + handle: el, get() (the dB), set(v) (clamped -24..+12). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.slotFader = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 90, 150), cx = 45, yTop = 30, yBot = 120; + const yOf = db => yTop + (12 - db) / 36 * (yBot - yTop); + gradDef('sfPlate', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#242019'], ['1', '#131110']]); + gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + svgEl(s, 'rect', { x: 14, y: 8, width: 62, height: 134, rx: 6, fill: 'url(#sfPlate)', stroke: '#0a0908', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 15.5, y: 9.5, width: 59, height: 131, rx: 5, fill: 'none', stroke: 'rgba(255,255,255,.05)', 'stroke-width': 1 }); + for (const sy of [16, 134]) { + svgEl(s, 'circle', { cx, cy: sy, r: 3.2, fill: 'url(#sfScrew)', stroke: '#14110e', 'stroke-width': .6 }); + const [x1, y1] = polar(cx, sy, 3, 112), [x2, y2] = polar(cx, sy, 3, 292); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#221f1a', 'stroke-width': 1 }); + } + svgEl(s, 'rect', { x: 40.5, y: yTop - 4, width: 9, height: yBot - yTop + 8, rx: 4, fill: '#0b0a09', stroke: '#050404' }); + for (const db of [12, 8, 4, 0, -4, -8, -12, -16, -20, -24]) { + const y = yOf(db); + svgEl(s, 'line', { x1: 26, y1: y, x2: 39, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 }); + svgEl(s, 'line', { x1: 51, y1: y, x2: 64, y2: y, stroke: 'var(--steel)', 'stroke-width': db === 0 ? 1.3 : .8, opacity: db === 0 ? 1 : .75 }); + svgEl(s, 'text', { x: 23, y: y + 2.2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: db === 0 ? 'var(--cream)' : 'var(--steel)' }).textContent = Math.abs(db); + } + const handle = svgEl(s, 'g', {}); + svgEl(handle, 'polygon', { points: '45,0 58,-5 58,5', fill: '#b9b6ae', stroke: '#5e5a52', 'stroke-width': .5 }); + svgEl(handle, 'rect', { x: 58, y: -7, width: 22, height: 14, rx: 3.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 }); + svgEl(handle, 'rect', { x: 60, y: -5.2, width: 18, height: 2.2, rx: 1, fill: 'rgba(255,255,255,.4)' }); + let db; + const set = v => { + db = Math.max(-24, Math.min(12, v)); + handle.setAttribute('transform', `translate(0,${30 + (12 - db) / 36 * 90})`); + onChange(db, (db > 0 ? '+' : db < 0 ? '−' : '') + Math.abs(db).toFixed(1) + ' dB'); + }; + dragY(s, pct => set(-24 + pct / 100 * 36)); + set(opts.value !== undefined ? opts.value : -4); + return { el: s, get: () => db, set }; +}; + +/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-10, default 8.3); onChange(v, label) on every move. + handle: el, get(), set(v) (clamped 0-10). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.spadeKnob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 140, 124), cx = 70, cy = 82; + const sweep = v => -80 + v / 10 * 160; + gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]); + gradDef('spadeMetal', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e9e7e0'], ['1', '#8d897f']]); + /* engraved arc: every stroke carries a faint light twin offset below (incised relief) */ + const engrave = (x1, y1, x2, y2, w) => { + svgEl(s, 'line', { x1, y1: y1 + .8, x2, y2: y2 + .8, stroke: 'rgba(255,255,255,.13)', 'stroke-width': w }); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#050404', 'stroke-width': w }); + }; + for (let i = 0; i <= 40; i++) { + const v = i / 4, a = sweep(v), major = i % 4 === 0; + const [x1, y1] = polar(cx, cy, major ? 50 : 52, a), [x2, y2] = polar(cx, cy, 58, a); + engrave(x1, y1, x2, y2, major ? 1.6 : .9); + if (major) { + const [nx, ny] = polar(cx, cy, 65, a); + svgEl(s, 'text', { x: nx, y: ny + 3.2, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: 'rgba(255,255,255,.13)' }).textContent = v; + svgEl(s, 'text', { x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 7.5, 'font-family': 'var(--mono)', fill: '#0a0908' }).textContent = v; + } + } + const grp = svgEl(s, 'g', {}); + svgEl(grp, 'path', { d: `M ${cx - 2} ${cy - 28} L ${cx - 4.5} ${cy - 42} L ${cx} ${cy - 54} L ${cx + 4.5} ${cy - 42} L ${cx + 2} ${cy - 28} Z`, fill: 'url(#spadeMetal)', stroke: '#55524a', 'stroke-width': .6 }); + svgEl(grp, 'circle', { cx, cy, r: 30, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .8 }); + svgEl(grp, 'circle', { cx, cy, r: 28.5, fill: 'none', stroke: '#0a0908', 'stroke-width': 3, 'stroke-dasharray': '2.2 2.2' }); + svgEl(grp, 'circle', { cx, cy, r: 22, fill: 'url(#spadeKnob)' }); + svgEl(grp, 'ellipse', { cx: cx - 8, cy: cy - 9, rx: 8, ry: 5, fill: 'rgba(255,255,255,.08)', transform: `rotate(-30,${cx - 8},${cy - 9})` }); + let val; + const set = v => { + val = Math.max(0, Math.min(10, v)); + grp.setAttribute('transform', `rotate(${-80 + val / 10 * 160},70,82)`); + onChange(val, val.toFixed(1)); + }; + dragDelta(s, () => val, set, { min: 0, max: 10, sens: .05 }); + set(opts.value !== undefined ? opts.value : 8.3); + return { el: s, get: () => val, set }; +}; + +/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring. + Contract (everything a consumer needs; no page globals touched): + opts: ranges ([lo, hi] Mc pairs, one ring each, default 4 bands); value + (0-100 across the ring, default 45); band (initial ring index, + default 2); onChange({band, mc}, label) on every change. Dragging + the main dial tunes; clicking the bandspread dial cycles bands. + handle: el, get() ([value, band]), set(v, b) (value clamped 0-100, band + clamped to ranges; omit b to keep the current band). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.multiBandDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const ranges = opts.ranges || [[0.54, 1.6], [1.6, 5.1], [5.1, 15.5], [15.5, 30.5]]; + const s = stageSvg(host, 'rsvg', 190, 110), cx = 62, cy = 62; + const sweep = t => -70 + t * 140; + gradDef('mbFace', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#eee5c8'], ['1', '#dcd2ae']]); + gradDef('spadeKnob', 'radialGradient', { cx: '42%', cy: '34%', r: '80%' }, [['0', '#33302a'], ['1', '#080706']]); + svgEl(s, 'rect', { x: 4, y: 4, width: 182, height: 102, rx: 12, fill: '#171412', stroke: '#060505' }); + svgEl(s, 'rect', { x: 9, y: 9, width: 172, height: 92, rx: 9, fill: 'url(#mbFace)', stroke: '#0a0908' }); + const INK = '#2c2318'; + const rings = []; + [18, 26, 34, 42].forEach(r => { + const ring = svgEl(s, 'g', {}); + const [x1, y1] = polar(cx, cy, r, -70), [x2, y2] = polar(cx, cy, r, 70); + svgEl(ring, 'path', { d: `M ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2}`, fill: 'none', stroke: INK, 'stroke-width': .9 }); + for (let i = 0; i <= 8; i++) { + const a = sweep(i / 8), [tx1, ty1] = polar(cx, cy, r, a), [tx2, ty2] = polar(cx, cy, r + 3, a); + svgEl(ring, 'line', { x1: tx1, y1: ty1, x2: tx2, y2: ty2, stroke: INK, 'stroke-width': .8 }); + } + rings.push(ring); + }); + const needle = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: '#1a1613', 'stroke-width': 1.6, 'stroke-linecap': 'round' }); + svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 }); + svgEl(s, 'text', { x: 14, y: 100, 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'MEGACYCLES'; + /* bandspread dial (click = band select) */ + const bx = 142, by = 56; + for (let i = 0; i < 12; i++) { const a = i * 30, [x1, y1] = polar(bx, by, 24, a), [x2, y2] = polar(bx, by, 21, a); svgEl(s, 'line', { x1, y1, x2, y2, stroke: INK, 'stroke-width': .8 }); } + svgEl(s, 'circle', { cx: bx, cy: by, r: 17, fill: 'none', stroke: INK, 'stroke-width': .7, opacity: .5 }); + const spread = svgEl(s, 'line', { x1: bx, y1: by, x2: bx, y2: by - 22, stroke: '#1a1613', 'stroke-width': 1.4, 'stroke-linecap': 'round' }); + svgEl(s, 'circle', { cx: bx, cy: by, r: 6, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 }); + svgEl(s, 'text', { x: bx, y: 96, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'BANDSPREAD'; + let val, band; + const set = (v, b = band) => { + val = Math.max(0, Math.min(100, v)); + band = Math.max(0, Math.min(ranges.length - 1, b)); + needle.setAttribute('transform', `rotate(${-70 + val / 100 * 140},62,62)`); + spread.setAttribute('transform', `rotate(${-45 + band * 30},142,56)`); + rings.forEach((g, i) => g.setAttribute('opacity', i === band ? '1' : '.4')); + const [lo, hi] = ranges[band]; const mc = lo + val / 100 * (hi - lo); + onChange({ band, mc }, `B${band + 1} · ${mc.toFixed(2)} Mc`); + }; + const dragHit = svgEl(s, 'rect', { x: 9, y: 9, width: 100, height: 92, fill: 'transparent' }); + dragHit.style.cursor = 'ns-resize'; + dragDelta(dragHit, () => val, v => set(v, band), { min: 0, max: 100 }); + const clickHit = svgEl(s, 'rect', { x: 112, y: 9, width: 69, height: 92, fill: 'transparent' }); + clickHit.style.cursor = 'pointer'; + clickHit.addEventListener('click', () => set(val, (band + 1) % ranges.length)); + set(opts.value !== undefined ? opts.value : 45, opts.band !== undefined ? opts.band : 2); + return { el: s, get: () => [val, band], set }; +}; + +/* R16 entry keypad — worn keys feed the amber display; lamps watch state. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(buffer, text) fires on every keypress and on the initial + paint ('', 'enter a code'); ✓ reports 'OK · <code>' (or 'empty') + then clears, ✗ reports 'cleared' and flashes the lower lamp. + handle: el, get() (the buffer string, up to 6 digits), press(key) — any + of '0'-'9', '✓', '✗'; digits past 6 are ignored. The upper lamp + lights while the buffer is non-empty. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.entryKeypad = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 150, 190); + gradDef('kpKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]); + gradDef('kpAmber', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'var(--amber-grad-top)'], ['1', 'var(--amber-grad-mid)']]); + const lamp = cy => svgEl(s, 'circle', { cx: 13, cy, r: 6.5, fill: '#3a0f0a', stroke: '#14100e', 'stroke-width': 1.5 }); + const lampTop = lamp(20), lampBot = lamp(38); + svgEl(s, 'rect', { x: 26, y: 8, width: 116, height: 32, rx: 5, fill: '#0a0806', stroke: '#2c261d', 'stroke-width': 2 }); + const disp = svgEl(s, 'text', { x: 84, y: 31, 'text-anchor': 'middle', 'font-size': 16, 'letter-spacing': '.22em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); + svgEl(s, 'rect', { x: 24, y: 48, width: 118, height: 138, rx: 8, fill: '#1a1714', stroke: '#0a0908', 'stroke-width': 1.5 }); + let buf = ''; + const render = () => { + disp.textContent = buf.padEnd(6, '–'); + lampTop.setAttribute('fill', buf ? 'var(--jewel-r)' : '#3a0f0a'); + }; + const press = k => { + if (k === '✓') { onChange(buf, buf ? 'OK · ' + buf : 'empty'); buf = ''; render(); return; } + if (k === '✗') { + buf = ''; render(); onChange(buf, 'cleared'); + lampBot.setAttribute('fill', 'var(--jewel-r)'); + setTimeout(() => lampBot.setAttribute('fill', '#3a0f0a'), 350); return; + } + if (/^[0-9]$/.test(k) && buf.length < 6) { buf += k; render(); onChange(buf, buf); } + }; + const KEYS = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['✓', '0', '✗']]; + const jitter = [-2, 1, -1, 2, 0, -2, 1, -1, 2, 0, -1, 1]; + KEYS.forEach((row, r) => row.forEach((k, c) => { + const x = 47 + c * 36, y = 68 + r * 32, amber = (k === '✓' || k === '✗'); + const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s'; + svgEl(g, 'rect', { x: x - 14, y: y - 11, width: 28, height: 24, rx: 4, fill: amber ? 'url(#kpAmber)' : 'url(#kpKey)', stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 }); + svgEl(g, 'text', { x, y: y + 6, 'text-anchor': 'middle', 'font-size': 13, 'font-weight': 700, 'font-family': 'var(--mono)', fill: k === '✓' ? '#3f5a1f' : k === '✗' ? '#7a2a1a' : '#2b2721', transform: `rotate(${jitter[r * 3 + c]},${x},${y})` }).textContent = k; + g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); press(k); }); + })); + render(); onChange('', 'enter a code'); + return { el: s, get: () => buf, press }; +}; + +/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab. + + Contract (everything a consumer needs; no page globals touched): + opts: channels (array of { name, v } strips, v 0-100, default BLEND/MIX + pair — the layout is sized for two); onChange(values array, + 'NAME v · NAME v') fires on every set, including the initial paint. + handle: el, get() (values array), set(i, v) — v clamps 0-100; each strip + drags vertically on its own hit rect, and the numerals within 5 + of the value light up. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.thumbSlide = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const chans = (opts.channels || [{ name: 'BLEND', v: 74 }, { name: 'MIX', v: 92 }]).map(c => ({ name: c.name, v: c.v })); + const s = stageSvg(host, 'rsvg', 130, 150), y0 = 22, y1 = 122; + gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]); + gradDef('thTab', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2efe6'], ['1', '#c6c1b3']]); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + const parts = []; + const set = (i, v) => { + v = Math.max(0, Math.min(100, Math.round(v))); chans[i].v = v; + const p = parts[i]; + p.tab.setAttribute('transform', `translate(0,${28 + (100 - v) * 0.88})`); + p.nums.forEach(t => { + const on = Math.abs(parseInt(t.textContent) - v) <= 5; + t.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--gold)'); t.setAttribute('opacity', on ? '1' : '.55'); + }); + onChange(chans.map(c => c.v), `${chans[0].name} ${chans[0].v} · ${chans[1].name} ${chans[1].v}`); + }; + chans.forEach((ch, i) => { + const x = 40 + i * 50; + svgEl(s, 'rect', { x: x - 9, y: y0 - 10, width: 18, height: y1 - y0 + 20, rx: 3, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 }); + for (const sy of [y0 - 6, y1 + 6]) { + svgEl(s, 'circle', { cx: x, cy: sy, r: 2.4, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 }); + svgEl(s, 'line', { x1: x - 1.8, y1: sy - 1, x2: x + 1.8, y2: sy + 1, stroke: '#14110e', 'stroke-width': .8 }); + } + svgEl(s, 'rect', { x: x - 5, y: y0, width: 10, height: y1 - y0, fill: '#0d0a08', stroke: '#050404' }); + const nums = []; + for (let n = 0; n <= 10; n++) { + const val = 100 - n * 10, y = y0 + 6 + n * (y1 - y0 - 12) / 10; + const t = svgEl(s, 'text', { x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .55 }); + t.textContent = val; nums.push(t); + } + const tab = svgEl(s, 'g', {}); + svgEl(tab, 'rect', { x: x + 7, y: -6, width: 8, height: 12, rx: 2, fill: 'url(#thTab)', stroke: '#7a766a', 'stroke-width': .6 }); + svgEl(tab, 'rect', { x: x + 8.2, y: -1, width: 5.6, height: 1.6, fill: 'rgba(0,0,0,.25)' }); + svgEl(s, 'text', { x, y: 145, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name; + const hit = svgEl(s, 'rect', { x: x - 12, y: y0 - 10, width: 30, height: y1 - y0 + 20, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + parts.push({ tab, nums }); + dragY(hit, pct => set(i, pct)); + }); + set(0, chans[0].v); set(1, chans[1].v); + return { el: s, get: () => chans.map(c => c.v), set }; +}; + +/* R19 waveform region editor — monochrome LCD, draggable S/E flags. + + Contract (everything a consumer needs; no page globals touched): + opts: start / end (region bounds in percent, defaults 22 / 76); + onChange({ s, e }, 'S n% · E n%') fires on every set, including + the initial paint. + handle: el, get() ({ s, e }), set(s, e) — s clamps 0-96, e clamps 4-100, + and e is kept at least 4 above s; dragging moves whichever flag + is nearer the pointer. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the LCD inks read the --scr-* screen tokens with hex + fallbacks. */ +DUPRE.waveRegion = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 190, 100), x0 = 10, x1 = 180, yMid = 48, N = 85; + svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 98, rx: 6, fill: 'var(--scr-bg1,#0b0c0b)', stroke: 'var(--scr-brd,#2a2c2a)', 'stroke-width': 2 }); + svgEl(s, 'text', { x: 12, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C1:START'; + svgEl(s, 'text', { x: 78, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C2:OFF'; + svgEl(s, 'text', { x: 136, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'C3:END'; + svgEl(s, 'line', { x1: x0, y1: yMid, x2: x1, y2: yMid, stroke: '#3a3c3a', 'stroke-width': .6, 'stroke-dasharray': '1.5 2' }); + const bars = []; + for (let i = 0; i < N; i++) { + const x = x0 + (i / (N - 1)) * (x1 - x0); + const env = Math.abs(Math.sin(i * 0.19)) * Math.abs(Math.sin(i * 0.045)) * (i % 17 < 11 ? 1 : .25); + const h = Math.max(1.2, env * 26); + bars.push(svgEl(s, 'rect', { x: x - 0.8, y: yMid - h, width: 1.6, height: h * 2, fill: 'var(--scr-dim,#5a5c5a)' })); + } + const flag = lbl => { + const g = svgEl(s, 'g', {}); + svgEl(g, 'line', { x1: 0, y1: 18, x2: 0, y2: 78, stroke: 'var(--scr-hi,#f2f4f2)', 'stroke-width': 1 }); + svgEl(g, 'rect', { x: lbl === 'S' ? 0 : -8, y: 70, width: 8, height: 8, fill: 'var(--scr-hi,#f2f4f2)' }); + svgEl(g, 'text', { x: lbl === 'S' ? 4 : -4, y: 76.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = lbl; + return g; + }; + const flagS = flag('S'), flagE = flag('E'); + svgEl(s, 'rect', { x: 10, y: 84, width: 78, height: 11, fill: 'var(--scr-hi,#e8eae8)' }); + svgEl(s, 'text', { x: 14, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'ENC:ZOOM(1x)'; + svgEl(s, 'text', { x: 96, y: 92.5, 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--scr-hi,#e8eae8)' }).textContent = 'A-3 M:[S]'; + svgEl(s, 'rect', { x: 148, y: 84, width: 32, height: 11, fill: 'var(--scr-hi,#e8eae8)' }); + svgEl(s, 'text', { x: 164, y: 92.5, 'text-anchor': 'middle', 'font-size': 6.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--scr-bg1,#0b0c0b)' }).textContent = 'MENU'; + s.style.cursor = 'ew-resize'; + let S, E; + const set = (sv, ev) => { + S = Math.max(0, Math.min(96, sv)); E = Math.max(4, Math.min(100, ev)); + if (E < S + 4) E = S + 4; + const xOf = p => x0 + p / 100 * (x1 - x0); + flagS.setAttribute('transform', `translate(${xOf(S)},0)`); + flagE.setAttribute('transform', `translate(${xOf(E)},0)`); + bars.forEach((b, i) => { + const p = i / (bars.length - 1) * 100; + b.setAttribute('fill', (p >= S && p <= E) ? 'var(--scr-hi,#f2f4f2)' : 'var(--scr-dim,#5a5c5a)'); + }); + onChange({ s: S, e: E }, `S ${Math.round(S)}% · E ${Math.round(E)}%`); + }; + dragX(s, pct => { + /* move whichever flag is nearer the pointer */ + if (Math.abs(pct - S) <= Math.abs(pct - E)) set(Math.min(pct, E - 4), E); + else set(S, Math.max(pct, S + 4)); + }); + set(opts.start !== undefined ? opts.start : 22, opts.end !== undefined ? opts.end : 76); + return { el: s, get: () => ({ s: S, e: E }), set }; +}; + +/* R20 drum roller selector — numbered paper drum in a window, center chip reads it. + + Contract (everything a consumer needs; no page globals touched): + opts: title (engraved header, default 'EQUALIZER'); channels (array of + { name, v } drums, v 1-10, default HIGH/LOW pair — the layout is + sized for two); onChange(values array, 'NAME v · NAME v') fires on + every set, including the initial paint. + handle: el, get() (values array), set(i, v) — v clamps 1-10; each drum + drags vertically on its own hit strip. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.drumRoller = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v })); + const s = stageSvg(host, 'rsvg', 130, 140), cy = 76, step = 17; + gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]); + gradDef('drPaper', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]); + gradDef('drCurve', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.4)'], ['.28', 'rgba(0,0,0,0)'], ['.72', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.4)']]); + const localDefs = svgEl(s, 'defs', {}); + svgEl(s, 'text', { x: 65, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER'; + const grps = []; + const set = (i, v) => { + v = Math.max(1, Math.min(10, v)); chans[i].v = v; + const p = grps[i]; + p.nums.style.transform = `translateY(${(v - p.base) * step}px)`; + p.chip.textContent = Math.round(v); + onChange(chans.map(c => c.v), `${chans[0].name} ${Math.round(chans[0].v)} · ${chans[1].name} ${Math.round(chans[1].v)}`); + }; + chans.forEach((ch, i) => { + const x = 45 + i * 50, cid = uid('drClip'); + svgEl(s, 'text', { x: x + 18, y: 34, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+'; + svgEl(s, 'text', { x: x + 18, y: 124, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−'; + svgEl(s, 'rect', { x: x - 12, y: 24, width: 24, height: 104, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 }); + const clip = svgEl(localDefs, 'clipPath', { id: cid }); + svgEl(clip, 'rect', { x: x - 9, y: 28, width: 18, height: 96 }); + svgEl(s, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drPaper)' }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${cid})` }); + const nums = svgEl(g, 'g', {}); nums.style.transition = 'transform .12s'; + for (let n = 1; n <= 10; n++) + svgEl(nums, 'text', { x, y: cy + 3 + (ch.v - n) * step, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' }).textContent = n; + svgEl(g, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drCurve)', 'pointer-events': 'none' }); + svgEl(s, 'rect', { x: x - 8, y: cy - 6.5, width: 16, height: 13, rx: 1.5, fill: '#1a1613', opacity: .92 }); + const chip = svgEl(s, 'text', { x, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }); + svgEl(s, 'text', { x, y: 136, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name; + const hit = svgEl(s, 'rect', { x: x - 14, y: 24, width: 28, height: 104, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + grps.push({ nums, chip, base: ch.v }); + dragDelta(hit, () => chans[i].v, v => set(i, v), { min: 1, max: 10, sens: .08 }); + }); + set(0, chans[0].v); set(1, chans[1].v); + return { el: s, get: () => chans.map(c => c.v), set }; +}; + +/* R21 LED program row — exclusive select, the LED above the key carries the state. + + Contract (everything a consumer needs; no page globals touched): + opts: items (program names, default 8 reverb programs — the layout is + sized for about eight); label (engraved caption, default + 'PROGRAM'); index (initial selection — default 5 for the default + set, first item for a caller's own set); onChange(index, + 'n · name') fires on every set, including the initial paint. + handle: el, get() (selected index), set(i) — i clamps to the items + range; exactly one LED is lit at a time. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.ledRow = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || ['Sm Hall B', 'VocPlate', 'Lg Hall B', 'Chamber', 'ParcPlate', 'Sm Hall A', 'Room A', 'Const Plate']; + const s = stageSvg(host, 'rsvg', 190, 58); + gradDef('lrKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2b2823'], ['1', '#171512']]); + const leds = []; + let idx; + const set = i => { + idx = Math.max(0, Math.min(items.length - 1, i)); + leds.forEach((l, k) => { + const on = k === idx; + l.setAttribute('fill', on ? 'var(--jewel-r)' : '#3a0f0a'); + l.setAttribute('style', on ? 'filter:drop-shadow(0 0 3px rgba(255,91,69,.8))' : ''); + }); + onChange(idx, `${idx + 1} · ${items[idx]}`); + }; + items.forEach((_, i) => { + const x = 16 + i * 22.6; + svgEl(s, 'text', { x, y: 9, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = i + 1; + leds.push(svgEl(s, 'circle', { cx: x, cy: 17, r: 2.4, fill: '#3a0f0a' })); + const g = svgEl(s, 'g', {}); g.style.cursor = 'pointer'; g.style.transition = 'transform .07s'; + svgEl(g, 'rect', { x: x - 8.5, y: 24, width: 17, height: 22, rx: 2.5, fill: 'url(#lrKey)', stroke: '#0a0908', 'stroke-width': 1 }); + svgEl(g, 'rect', { x: x - 6.5, y: 26.5, width: 13, height: 3, rx: 1.5, fill: 'rgba(255,255,255,.06)' }); + g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); set(i); }); + }); + svgEl(s, 'text', { x: 95, y: 56, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'PROGRAM'; + set(opts.index !== undefined ? opts.index : opts.items ? 0 : 5); + return { el: s, get: () => idx, set }; +}; + +/* R22 three-position slide — chrome pill between detents, honest LED pair. + + Contract (everything a consumer needs; no page globals touched): + opts: positions (three detent labels, default A / AB / B — the detents + are fixed at three); title (engraved header, default + 'BASIC · VARIATION'); index (initial detent, default 1); + onChange(index, label) fires on every set, including the initial + paint. + handle: el, get() (detent index), set(i) — i clamps to the detents; + clicking the stage snaps the pill to the nearest detent, and the + LED pair reports A-side / B-side engagement (both lit at AB). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.pillSlide = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const positions = opts.positions || ['A', 'AB', 'B']; + const s = stageSvg(host, 'rsvg press', 110, 64), detX = [34, 55, 76]; + gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]); + svgEl(s, 'text', { x: 55, y: 10, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'BASIC · VARIATION'; + svgEl(s, 'rect', { x: 20, y: 16, width: 70, height: 14, rx: 7, fill: '#0b0a09', stroke: '#000', 'stroke-width': 1 }); + svgEl(s, 'rect', { x: 21, y: 17, width: 68, height: 5, rx: 2.5, fill: 'rgba(255,255,255,.04)' }); + const pill = svgEl(s, 'rect', { x: -13, y: 18.5, width: 26, height: 9, rx: 4.5, fill: 'url(#sfChrome)', stroke: '#4e4a42', 'stroke-width': .6 }); + pill.style.transition = 'transform .12s'; + const lbls = positions.map((lbl, i) => { + const t = svgEl(s, 'text', { x: detX[i], y: 40, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + t.textContent = lbl; return t; + }); + svgEl(s, 'rect', { x: 32, y: 45, width: 46, height: 11, rx: 2, fill: '#141210', stroke: '#060505' }); + const leds = [44, 66].map(x => svgEl(s, 'circle', { cx: x, cy: 50.5, r: 2.4, fill: '#3a0f0a' })); + let idx; + const set = i => { + idx = Math.max(0, Math.min(positions.length - 1, i)); + pill.setAttribute('transform', `translate(${detX[idx]},0)`); + lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)')); + const lit = [idx === 0 || idx === 1, idx === 2 || idx === 1]; + leds.forEach((l, k) => l.setAttribute('fill', lit[k] ? 'var(--jewel-r)' : '#3a0f0a')); + onChange(idx, positions[idx]); + }; + s.addEventListener('click', e => { + const r = s.getBoundingClientRect(); + const x = (e.clientX - r.left) / r.width * 110; + set(x < 45 ? 0 : x < 66 ? 1 : 2); + }); + set(opts.index !== undefined ? opts.index : 1); + return { el: s, get: () => idx, set }; +}; + +/* R23 spun-aluminum knob — machined rings in a knurled grip, red index. + + Contract (everything a consumer needs; no page globals touched): + opts: label (engraved caption, default 'SPEED'); value (initial 0-100, + default 62); onChange(value, 'n%') fires on every set, including + the initial paint. + handle: el, get() (value), set(v) — v clamps 0-100; the whole stage + drags vertically. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.spunKnob = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; + gradDef('spunFace', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#e8e6e0'], ['.7', '#b3b0a8'], ['1', '#8a877e']]); + for (let k = 0; k < 8; k++) { const [x, y] = polar(cx, cy, 45, -135 + k * 38.6); svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: 'var(--steel)' }); } + for (let k = 0; k < 22; k++) { const [x, y] = polar(cx, cy, 36, k * 16.36); svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: '#14110e' }); } + svgEl(s, 'circle', { cx, cy, r: 36, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 }); + svgEl(s, 'circle', { cx, cy, r: 30, fill: 'url(#spunFace)', stroke: '#6e6b63', 'stroke-width': .8 }); + for (const r of [24, 18, 12, 6]) + svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: r % 12 === 0 ? 'rgba(0,0,0,.12)' : 'rgba(255,255,255,.3)', 'stroke-width': .6 }); + const idx = svgEl(s, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 10, stroke: 'var(--fail)', 'stroke-width': 2.6, 'stroke-linecap': 'round' }); + svgEl(s, 'ellipse', { cx: cx - 9, cy: cy - 11, rx: 9, ry: 5, fill: 'rgba(255,255,255,.28)', transform: `rotate(-32,${cx - 9},${cy - 11})` }); + svgEl(s, 'text', { x: cx, y: 103, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'SPEED'; + let val; + const set = v => { + val = Math.max(0, Math.min(100, v)); + idx.setAttribute('transform', `rotate(${-135 + val / 100 * 270},${cx},${cy})`); + onChange(val, Math.round(val) + '%'); + }; + dragDelta(s, () => val, set, { min: 0, max: 100 }); + set(opts.value !== undefined ? opts.value : 62); + return { el: s, get: () => val, set }; +}; + +/* R24 stomp switch + jewel — press to engage, the jewel reports. + + Contract (everything a consumer needs; no page globals touched): + opts: on (initial state, default false); onChange(on, 'ENGAGED' / + 'bypass') fires on every set, including the initial paint. + handle: el, get() (boolean), set(v) — coerced to boolean; clicking the + stage toggles, the dome dips, and the amber jewel glows while + engaged. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients and the avGlow filter register in the shared defs + plate. */ +DUPRE.stompSwitch = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55; + gradDef('stompDome', 'radialGradient', { cx: '40%', cy: '30%', r: '80%' }, [['0', '#f4f2ec'], ['.55', '#b9b6ae'], ['1', '#6e6b63']]); + gradDef('stompJewel', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#ffe9b0'], ['.45', 'var(--jewel-a)'], ['1', '#5c3d0c']]); + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + const glow = svgEl(s, 'circle', { cx, cy: 22, r: 14, fill: 'rgba(255,180,58,.35)', filter: 'url(#avGlow)', opacity: 0 }); + const jewel = svgEl(s, 'circle', { cx, cy: 22, r: 9, fill: '#241f1b', stroke: '#0a0908', 'stroke-width': 1.5 }); + for (let k = 0; k < 6; k++) { + const a1 = k * 60, a2 = k * 60 + 30; + const [x1, y1] = polar(cx, 22, 9, a1), [x2, y2] = polar(cx, 22, 9, a2); + svgEl(s, 'path', { d: `M ${cx} 22 L ${x1} ${y1} A 9 9 0 0 1 ${x2} ${y2} Z`, fill: k % 2 ? 'rgba(255,255,255,.09)' : 'rgba(0,0,0,.10)', 'pointer-events': 'none' }); + } + for (let k = 0; k < 20; k++) { const [x, y] = polar(cx, 72, 25, k * 18); svgEl(s, 'circle', { cx: x, cy: y, r: 2.4, fill: '#14110e' }); } + svgEl(s, 'circle', { cx, cy: 72, r: 25, fill: '#1a1714', stroke: '#000', 'stroke-width': .8 }); + const dome = svgEl(s, 'g', {}); dome.style.transition = 'transform .08s'; + svgEl(dome, 'circle', { cx, cy: 72, r: 19, fill: 'url(#stompDome)', stroke: '#55524a', 'stroke-width': .8 }); + svgEl(dome, 'ellipse', { cx: cx - 6, cy: 64, rx: 8, ry: 4.5, fill: 'rgba(255,255,255,.5)', transform: `rotate(-24,${cx - 6},64)` }); + let on; + const set = v => { + on = !!v; + jewel.setAttribute('fill', on ? 'url(#stompJewel)' : '#241f1b'); + glow.setAttribute('opacity', on ? '1' : '0'); + onChange(on, on ? 'ENGAGED' : 'bypass'); + }; + s.addEventListener('click', () => { dome.style.transform = 'translateY(1.5px)'; setTimeout(() => dome.style.transform = '', 90); set(!on); }); + set(opts.on !== undefined ? opts.on : false); + return { el: s, get: () => on, set }; +}; + +/* R27 winged gain selector — red T-bar over a dot ring, stepped detents. + + Contract (everything a consumer needs; no page globals touched): + opts: steps (dB values, one detent each, default 12 from -80 to +10); + index (initial detent, default 7); onChange(db, '+n dB') fires on + every set with the step's dB value, including the initial paint. + handle: el, get() (detent index, not the dB value), set(i) — i rounds + and clamps to the steps range; the whole stage drags vertically + between detents. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.wingSelector = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10]; + const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; + gradDef('wingRed', 'radialGradient', { cx: '40%', cy: '32%', r: '80%' }, [['0', '#d98a6f'], ['.6', 'var(--fail)'], ['1', '#6e2415']]); + steps.forEach((db, i) => { + const a = -135 + i / (steps.length - 1) * 270; + const [dx, dy] = polar(cx, cy, 38, a); + svgEl(s, 'circle', { cx: dx, cy: dy, r: 1.5, fill: 'var(--steel)' }); + if (i % 2 === 1 || db === 0 || db === 10 || db === -80) { + const [tx, ty] = polar(cx, cy, 46.5, a); + svgEl(s, 'text', { x: tx, y: ty + 2.2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = db > 0 ? '+' + db : db; + } + }); + const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .09s'; + svgEl(grp, 'rect', { x: cx - 6.5, y: cy - 30, width: 13, height: 60, rx: 6, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 }); + svgEl(grp, 'circle', { cx, cy, r: 15, fill: 'url(#wingRed)', stroke: '#4e150a', 'stroke-width': .8 }); + svgEl(grp, 'line', { x1: cx, y1: cy - 28, x2: cx, y2: cy - 18, stroke: 'var(--cream)', 'stroke-width': 2, 'stroke-linecap': 'round' }); + svgEl(grp, 'ellipse', { cx: cx - 4, cy: cy - 8, rx: 5, ry: 3, fill: 'rgba(255,255,255,.18)', transform: `rotate(-30,${cx - 4},${cy - 8})` }); + let idx; + const set = v => { + idx = Math.max(0, Math.min(steps.length - 1, Math.round(v))); + grp.style.transform = `rotate(${-135 + idx / (steps.length - 1) * 270}deg)`; + grp.style.transformOrigin = `${cx}px ${cy}px`; + const db = steps[idx]; + onChange(db, (db > 0 ? '+' : '') + db + ' dB'); + }; + dragDelta(s, () => idx, set, { min: 0, max: steps.length - 1, sens: .05 }); + set(opts.index !== undefined ? opts.index : 7); + return { el: s, get: () => idx, set }; +}; + +/* R28 rotary disc switch — the whole disc turns between heavy positions. + + Contract (everything a consumer needs; no page globals touched): + opts: positions (array of [label, angle°] stops, default OFF / ON / + COMBINE); index (initial stop, default 1); onChange(index, label) + fires on every set, including the initial paint. + handle: el, get() (stop index), set(i) — i clamps to the stops; clicking + the stage advances to the next stop, wrapping. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.discSwitch = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]]; + const s = stageSvg(host, 'rsvg press', 110, 116), cx = 55, cy = 58; + gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + svgEl(s, 'circle', { cx, cy, r: 48, fill: '#1a1714', stroke: '#060505', 'stroke-width': 1.5 }); + for (let k = 0; k < 4; k++) { + const [x, y] = polar(cx, cy, 44, 45 + k * 90); + svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 }); + } + const lbls = positions.map(([lbl, a]) => { + const [tx, ty] = polar(cx, cy, 42, a); + const t = svgEl(s, 'text', { x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 5.8, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + t.textContent = lbl; return t; + }); + svgEl(s, 'circle', { cx, cy, r: 36, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 }); + const grp = svgEl(s, 'g', {}); grp.style.transition = 'transform .15s'; grp.style.transformOrigin = `${cx}px ${cy}px`; + svgEl(grp, 'rect', { x: cx - 27, y: cy - 8, width: 54, height: 16, rx: 8, fill: 'url(#discRed)', stroke: '#8f3520', 'stroke-width': 1 }); + svgEl(grp, 'rect', { x: cx - 25, y: cy - 6.5, width: 50, height: 4, rx: 2, fill: 'rgba(255,255,255,.16)' }); + svgEl(grp, 'line', { x1: cx + 20, y1: cy, x2: cx + 26, y2: cy, stroke: 'var(--cream)', 'stroke-width': 2.5, 'stroke-linecap': 'round' }); + svgEl(s, 'ellipse', { cx: cx - 12, cy: cy - 14, rx: 12, ry: 6, fill: 'rgba(255,255,255,.10)', transform: `rotate(-28,${cx - 12},${cy - 14})` }); + let idx; + const set = i => { + idx = Math.max(0, Math.min(positions.length - 1, i)); + grp.style.transform = `rotate(${positions[idx][1]}deg)`; + lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)')); + onChange(idx, positions[idx][0]); + }; + s.addEventListener('click', () => set((idx + 1) % positions.length)); + set(opts.index !== undefined ? opts.index : 1); + return { el: s, get: () => idx, set }; +}; + +/* R29 guarded toggle — guard posts + red collar mark the critical throw. + + Contract (everything a consumer needs; no page globals touched): + opts: on (initial state, default true); onLabel / offLabel (engraved + legends, defaults ON / OFF); onChange(on, label) fires on every + set, including the initial paint. + handle: el, get() (boolean), set(v) — coerced to boolean; clicking the + stage throws the lever, and the active legend carries the gold + ink. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.guardedToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52; + gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]); + gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]); + gradDef('chromeG', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]); + gradDef('tipG', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]); + const lblOn = svgEl(s, 'text', { x: cx, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); + lblOn.textContent = opts.onLabel || 'ON'; + const lblOff = svgEl(s, 'text', { x: cx, y: 96, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + lblOff.textContent = opts.offLabel || 'OFF'; + svgEl(s, 'circle', { cx, cy, r: 13, fill: 'url(#discRed)', stroke: '#4e150a', 'stroke-width': 1 }); + const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 11 * Math.cos(a)) + ',' + (cy + 11 * Math.sin(a))); } + svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#nutG)', stroke: '#2b2822' }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#14110e', stroke: '#000' }); + for (const gx of [16, 74]) { + svgEl(s, 'rect', { x: gx - 6, y: cy - 24, width: 12, height: 48, rx: 6, fill: 'url(#nutG)', stroke: '#2b2822', 'stroke-width': 1 }); + svgEl(s, 'rect', { x: gx - 4, y: cy - 21, width: 3.5, height: 42, rx: 2, fill: 'rgba(255,255,255,.14)' }); + } + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 }); + svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 }); + for (let r = 0; r < 4; r++) svgEl(lever, 'line', { x1: cx - 3.5, y1: 18.5 + r * 2.4, x2: cx + 3.5, y2: 18.5 + r * 2.4, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 }); + lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s'; + let on; + const set = v => { + on = !!v; + lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)'; + lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)'); + lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)'); + onChange(on, on ? 'ON' : 'OFF'); + }; + s.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: s, get: () => on, set }; +}; + +/* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob. + Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. + + Contract (everything a consumer needs; no page globals touched): + opts: minutes (initial wind, clamps 0-60, default 0); + onChange(minutes, 'T-N MIN'|'OFF'|'STOP · OFF'|'DING · OFF') fires + on every set, including the initial paint. + handle: el, get() (minutes remaining), set(m) — m clamps 0-60; the dial + face drags, the red knob clicks to 0. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.timerDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58; + gradDef('mtFace', 'radialGradient', { cx: '50%', cy: '42%', r: '75%' }, [['0', '#282320'], ['1', '#14110e']]); + gradDef('mtRed', 'radialGradient', { cx: '38%', cy: '32%', r: '80%' }, [['0', '#e0523a'], ['1', '#8f2416']]); + gradDef('mtHub', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#c9c4b8'], ['1', '#6e685c']]); + /* bezel + knurled coin edge */ + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 47.5, fill: 'none', stroke: '#3a352c', 'stroke-width': 4, 'stroke-dasharray': '2 1.6' }); + svgEl(s, 'circle', { cx, cy, r: 44, fill: 'url(#mtFace)', stroke: '#0a0908', 'stroke-width': 1 }); + /* rotating scale: OFF at 0, minutes climb clockwise at 4.5 deg/min (60 at 270) */ + const rot = svgEl(s, 'g', {}); + for (let m = 0; m <= 60; m += 5) { + const major = m % 20 === 0; + const [x1, y1] = polar(cx, cy, 40, m * 4.5), [x2, y2] = polar(cx, cy, major ? 34 : 37, m * 4.5); + svgEl(rot, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': major ? 1.2 : .55, opacity: major ? .9 : .55 }); + } + for (const m of [20, 40, 60]) { + const [x, y] = polar(cx, cy, 27, m * 4.5); + svgEl(rot, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = String(m); + } + const [ox, oy] = polar(cx, cy, 27, 0); + svgEl(rot, 'text', { x: ox, y: oy + 3, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#e0523a' }).textContent = 'OFF'; + const [mx, my] = polar(cx, cy, 16, 180); + svgEl(rot, 'text', { x: mx, y: my + 2.5, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'MINUTES'; + /* hub with screw slot */ + svgEl(s, 'circle', { cx, cy, r: 9, fill: 'url(#mtHub)', stroke: '#3c382f', 'stroke-width': 1 }); + svgEl(s, 'line', { x1: cx - 5.5, y1: cy, x2: cx + 5.5, y2: cy, stroke: '#4a4438', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 2.2, fill: '#8f897b' }); + /* fixed red index at top + winding-direction arrow */ + svgEl(s, 'line', { x1: cx, y1: cy - 44, x2: cx, y2: cy - 32, stroke: '#e0523a', 'stroke-width': 1.6 }); + svgEl(s, 'path', { d: 'M 122 14 A 26 26 0 0 1 136 26', fill: 'none', stroke: 'var(--steel)', 'stroke-width': 1.6 }); + svgEl(s, 'polygon', { points: '136,26 130.5,24.5 134.5,19.5', fill: 'var(--steel)' }); + /* engraved plate captions */ + const cap = (t, y) => svgEl(s, 'text', { x: 4, y, 'font-size': 6.2, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = t; + cap('PUSH TO STOP', 118); cap('TURN TO START', 128); + let min; + const set = m => { + min = Math.max(0, Math.min(60, m)); + rot.setAttribute('transform', `rotate(${(-min * 4.5).toFixed(2)},${cx},${cy})`); + onChange(min, min > 0.5 ? 'T-' + Math.round(min) + ' MIN' : 'OFF'); + }; + /* dial drag surface (under the knob in DOM so the knob wins the overlap) */ + const hit = svgEl(s, 'circle', { cx, cy, r: 50, fill: 'transparent' }); + hit.style.cursor = 'grab'; + dragDelta(hit, () => min, set, { min: 0, max: 60 }); + /* red stop knob: fluted edge, domed center */ + const knob = svgEl(s, 'g', {}); + svgEl(knob, 'circle', { cx: 126, cy: 104, r: 20, fill: 'url(#mtRed)', stroke: '#5c150c', 'stroke-width': 1.5 }); + svgEl(knob, 'circle', { cx: 126, cy: 104, r: 18, fill: 'none', stroke: 'rgba(0,0,0,.4)', 'stroke-width': 3.4, 'stroke-dasharray': '3 2.4' }); + svgEl(knob, 'circle', { cx: 126, cy: 104, r: 8.5, fill: 'url(#mtRed)', stroke: 'rgba(0,0,0,.3)', 'stroke-width': .8 }); + svgEl(knob, 'ellipse', { cx: 121, cy: 98, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.18)', transform: 'rotate(-28,121,98)' }); + knob.style.cursor = 'pointer'; + knob.addEventListener('click', () => { set(0); onChange(0, 'STOP · OFF'); }); + set(opts.minutes !== undefined ? opts.minutes : 0); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { if (min > 0) { set(min - 1); if (min === 0) onChange(0, 'DING · OFF'); } }, 1000); + return { el: s, get: () => min, set }; +}; + +/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange({x, y, dir}, 'DIR · x N y N') fires on every press and + once at the initial paint (dir null, origin). + handle: el, get() ({x, y} cursor position). No set — the cursor is a + relative accumulator; position only moves by presses. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.rockerPad = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55; + gradDef('rk4Pad', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#33302b'], ['1', '#141210']]); + gradDef('rk4Arr', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#e9e4d6'], ['1', '#9d988b']]); + gradDef('rk4Nub', 'radialGradient', { cx: '38%', cy: '30%', r: '85%' }, [['0', '#4a453e'], ['1', '#0d0c0a']]); + /* recessed plate + rubber pad */ + svgEl(s, 'rect', { x: 3, y: 3, width: 104, height: 104, rx: 18, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 }); + svgEl(s, 'circle', { cx, cy, r: 45, fill: '#0f0d0b' }); + svgEl(s, 'circle', { cx, cy, r: 41, fill: 'url(#rk4Pad)', stroke: '#060505', 'stroke-width': 1 }); + /* four arrows, pointing outward */ + const ARR = { up: '55,22 45,36 65,36', down: '55,88 45,74 65,74', left: '22,55 36,45 36,65', right: '88,55 74,45 74,65' }; + const arrows = {}; + for (const [dir, pts] of Object.entries(ARR)) + arrows[dir] = svgEl(s, 'polygon', { points: pts, fill: 'url(#rk4Arr)', stroke: '#3c382f', 'stroke-width': .7 }); + /* center pivot nub */ + svgEl(s, 'circle', { cx, cy, r: 7, fill: 'url(#rk4Nub)', stroke: '#060505', 'stroke-width': .8 }); + svgEl(s, 'ellipse', { cx: cx - 2, cy: cy - 2.5, rx: 2.6, ry: 1.7, fill: 'rgba(255,255,255,.25)' }); + /* quadrant hit zones (drawn last so they win), press flash + tracked position */ + let rx = 0, ry = 0; + const press = (dir, dx, dy) => { + rx += dx; ry += dy; + onChange({ x: rx, y: ry, dir }, dir.toUpperCase() + ' · x ' + rx + ' y ' + ry); + arrows[dir].setAttribute('fill', 'var(--gold-hi)'); + setTimeout(() => arrows[dir].setAttribute('fill', 'url(#rk4Arr)'), 160); + }; + const zone = (dir, pts, dx, dy) => { + const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' }); + z.style.cursor = 'pointer'; z.addEventListener('click', () => press(dir, dx, dy)); + }; + zone('up', `${cx},${cy} 20,20 90,20`, 0, 1); + zone('down', `${cx},${cy} 20,90 90,90`, 0, -1); + zone('left', `${cx},${cy} 20,20 20,90`, -1, 0); + zone('right', `${cx},${cy} 90,20 90,90`, 1, 0); + onChange({ x: 0, y: 0, dir: null }, 'x 0 y 0'); + return { el: s, get: () => ({ x: rx, y: ry }) }; +}; + +/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state. + + Contract (everything a consumer needs; no page globals touched): + opts: position ('A'|'B'|'C'|'D', default 'C'; anything else falls back + to 'C'); onChange(pos, 'POS Q') fires on every set, including the + initial paint. + handle: el, get() (current quadrant letter), set(q) — invalid quadrants + are ignored; quadrant click zones select directly. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.fourWayToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; + gradDef('fw4Chrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e8e6e0'], ['.5', '#9a968c'], ['1', '#55524a']]); + gradDef('fw4Ball', 'radialGradient', { cx: '36%', cy: '30%', r: '80%' }, [['0', '#f2f0ea'], ['.55', '#8e8a80'], ['1', '#3c3a34']]); + /* printed panel graphics: axes, circle, diagonal square, labels */ + svgEl(s, 'line', { x1: 8, y1: cy, x2: 122, y2: cy, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 }); + svgEl(s, 'line', { x1: cx, y1: 8, x2: cx, y2: 122, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .7 }); + svgEl(s, 'text', { x: cx + 2, y: 13, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'Y'; + svgEl(s, 'text', { x: 116, y: cy - 4, 'font-size': 7, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'X'; + svgEl(s, 'circle', { cx, cy, r: 42, fill: 'none', stroke: 'var(--steel)', 'stroke-width': .9, opacity: .8 }); + const QUAD = { A: 315, B: 45, C: 135, D: 225 }; + const diag = Object.values(QUAD).map(a => polar(cx, cy, 42, a)); + svgEl(s, 'polygon', { points: diag.map(p => p.join(',')).join(' '), fill: 'none', stroke: 'var(--steel)', 'stroke-width': .8, opacity: .8 }); + const LAMP_AT = { A: [14, 14], B: [116, 14], C: [116, 116], D: [14, 116] }; + const lamps = {}; + for (const [q, a] of Object.entries(QUAD)) { + const [lx, ly] = polar(cx, cy, 42, a); + const [px, py] = LAMP_AT[q]; + svgEl(s, 'line', { x1: lx, y1: ly, x2: px, y2: py, stroke: 'var(--steel)', 'stroke-width': .7, opacity: .5 }); + svgEl(s, 'circle', { cx: lx, cy: ly, r: 6.5, fill: '#14110e', stroke: 'var(--steel)', 'stroke-width': .9 }); + svgEl(s, 'text', { x: lx, y: ly + 2.8, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = q; + svgEl(s, 'circle', { cx: px, cy: py, r: 5.5, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 }); + lamps[q] = svgEl(s, 'circle', { cx: px, cy: py, r: 3.6, fill: '#3a3426' }); + } + /* bezel + boot */ + svgEl(s, 'circle', { cx, cy, r: 20, fill: '#0d0c0a', stroke: '#2a2722', 'stroke-width': 1.5 }); + svgEl(s, 'circle', { cx, cy, r: 15, fill: '#17140f', stroke: '#060505', 'stroke-width': 1 }); + /* lever: shaft + grip rotate to the selected diagonal; ball pivot stays centered */ + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'polygon', { points: `${cx - 3},${cy} ${cx + 3},${cy} ${cx + 2},${cy - 26} ${cx - 2},${cy - 26}`, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 }); + svgEl(lever, 'rect', { x: cx - 3.4, y: cy - 40, width: 6.8, height: 16, rx: 3, fill: 'url(#fw4Chrome)', stroke: '#3c3a34', 'stroke-width': .6 }); + svgEl(s, 'circle', { cx, cy, r: 8.5, fill: 'url(#fw4Ball)', stroke: '#26241f', 'stroke-width': .8 }); + svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' }); + let pos = null; + const set = q => { + if (!Object.hasOwn(QUAD, q)) return; + pos = q; + lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`); + for (const k of Object.keys(QUAD)) { + lamps[k].setAttribute('fill', k === q ? 'var(--jewel-a)' : '#3a3426'); + lamps[k].setAttribute('filter', k === q ? 'drop-shadow(0 0 4px rgba(255,180,58,.8))' : 'none'); + } + onChange(pos, 'POS ' + q); + }; + /* quadrant click zones (diagonal quadrants, drawn last) */ + const zone = (q, pts) => { + const z = svgEl(s, 'polygon', { points: pts, fill: 'transparent' }); + z.style.cursor = 'pointer'; z.addEventListener('click', () => set(q)); + }; + zone('A', `${cx},${cy} ${cx},5 5,5 5,${cy}`); + zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`); + zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`); + zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`); + set(Object.hasOwn(QUAD, opts.position || '') ? opts.position : 'C'); + return { el: s, get: () => pos, set }; +}; + +/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many. + + Contract (everything a consumer needs; no page globals touched): + opts: rows (source labels, default 5-row synth set); cols (destination + labels, default 6-col set; the layout is sized for 5x6); pins + (initial 'ROW>COL' keys — keys naming no intersection are + dropped); onChange(pins array, 'N routes') fires on every seat or + pull, including the initial paint. + handle: el, get() ('ROW>COL' keys array). No set — pins seat and pull + by intersection click. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.pinMatrix = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV']; + const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD']; + /* a legible default patch; keys that name no intersection are dropped */ + const pins = new Set((opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']) + .filter(k => { const [r, c] = k.split('>'); return ROWS.includes(r) && COLS.includes(c) && k === r + '>' + c; })); + const s = stageSvg(host, 'rsvg press', 160, 110); + const X0 = 42, Y0 = 30, DX = 19, DY = 15.5; + svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + COLS.forEach((c, j) => svgEl(s, 'text', { x: X0 + j * DX, y: 16, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = c); + ROWS.forEach((r, i) => svgEl(s, 'text', { x: 34, y: Y0 + i * DY + 2, 'text-anchor': 'end', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r); + ROWS.forEach((r, i) => COLS.forEach((c, j) => { + const cx = X0 + j * DX, cy = Y0 + i * DY, key = r + '>' + c; + svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 }); + const cap = svgEl(s, 'circle', { cx, cy, r: 4.4, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': 1, opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' }); + const hit = svgEl(s, 'circle', { cx, cy, r: 8, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { + const on = !pins.has(key); + if (on) pins.add(key); else pins.delete(key); + cap.setAttribute('opacity', on ? 1 : 0); + onChange([...pins], (on ? r + ' → ' + c : 'pulled ' + r + ' → ' + c) + ' · ' + pins.size + ' routes'); + }); + })); + onChange([...pins], pins.size + ' routes'); + return { el: s, get: () => [...pins] }; +}; + +/* R38 dead-man button — state exists only while the pointer holds it down. + + Contract (everything a consumer needs; no page globals touched): + opts: caption (plate legend, default 'HOLD TO RUN'); label (button + face, default 'RUN'); onChange(held, 'RUNNING N.Ns'|'SAFE...') + fires on press, release, every 100 ms while held, and the + initial paint. + handle: el, get() (true while held). No set — by design the state + exists only under a live pointer. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.deadMan = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58; + gradDef('dmBtn', 'radialGradient', { cx: '40%', cy: '32%', r: '85%' }, [['0', '#3a3631'], ['1', '#16130f']]); + svgEl(s, 'rect', { x: 4, y: 4, width: 112, height: 102, rx: 12, fill: '#211e1a', stroke: '#0c0b09', 'stroke-width': 1.5 }); + svgEl(s, 'text', { x: cx, y: 18, 'text-anchor': 'middle', 'font-size': 6.2, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.caption || 'HOLD TO RUN'; + const lamp = svgEl(s, 'circle', { cx: 104, cy: 16, r: 4.5, fill: '#1e2a12' }); + const ring = svgEl(s, 'circle', { cx, cy, r: 33, fill: 'none', stroke: '#2c2820', 'stroke-width': 3 }); + const btn = svgEl(s, 'g', {}); + svgEl(btn, 'circle', { cx, cy, r: 28, fill: 'url(#dmBtn)', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(btn, 'circle', { cx, cy, r: 22, fill: 'none', stroke: 'rgba(255,255,255,.07)', 'stroke-width': 1 }); + svgEl(btn, 'text', { x: cx, y: cy + 2.5, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--silver)' }).textContent = opts.label || 'RUN'; + btn.style.cursor = 'pointer'; + let t0 = null, iv = null; + const stop = () => { + if (t0 === null) return; + const held = ((Date.now() - t0) / 1000).toFixed(1); + t0 = null; clearInterval(iv); + btn.removeAttribute('transform'); + ring.setAttribute('stroke', '#2c2820'); ring.removeAttribute('filter'); + lamp.setAttribute('fill', '#1e2a12'); lamp.removeAttribute('filter'); + onChange(false, 'SAFE (held ' + held + 's)'); + }; + btn.addEventListener('pointerdown', e => { + btn.setPointerCapture(e.pointerId); + t0 = Date.now(); + btn.setAttribute('transform', 'translate(0,1.5)'); + ring.setAttribute('stroke', 'var(--gold)'); ring.setAttribute('filter', 'drop-shadow(0 0 5px rgba(var(--glow-lo),.7))'); + lamp.setAttribute('fill', 'var(--pass)'); lamp.setAttribute('filter', 'drop-shadow(0 0 4px rgba(116,147,47,.8))'); + onChange(true, 'RUNNING 0.0s'); + iv = setInterval(() => { if (t0 !== null) onChange(true, 'RUNNING ' + ((Date.now() - t0) / 1000).toFixed(1) + 's'); }, 100); + e.preventDefault(); + }); + btn.addEventListener('pointerup', stop); + btn.addEventListener('pointercancel', stop); + onChange(false, 'SAFE'); + return { el: s, get: () => t0 !== null }; +}; + +/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns. + Spin animation is reduced-motion-gated; clicks during a spin are ignored. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(dialed, dialed) fires when each digit's return spin + lands, plus once at the initial paint ('dial a number'). + handle: el, get() (dialed string, last 10 digits kept). No set — digits + only arrive through the dial. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.telephoneDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63; + const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; + const angOf = i => (60 - i * 30 + 360) % 360; /* 1 at 60cw, counterclockwise to 0 at 150 */ + const STOP = 105; + gradDef('tdWheel', 'radialGradient', { cx: '42%', cy: '36%', r: '85%' }, [['0', '#8f8a7e'], ['1', '#4a463e']]); + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1e1a16', stroke: '#0a0908', 'stroke-width': 1 }); + /* rotating finger wheel: ring + hole openings (dark wells punched in the metal) */ + const wheel = svgEl(s, 'g', {}); + svgEl(wheel, 'circle', { cx, cy, r: 46, fill: 'none', stroke: 'url(#tdWheel)', 'stroke-width': 17, opacity: .95 }); + DIGITS.forEach((_, i) => { + const [x, y] = polar(cx, cy, 38, angOf(i)); + svgEl(wheel, 'circle', { cx: x, cy: y, r: 7.2, fill: '#1e1a16', stroke: '#26221c', 'stroke-width': 1.4 }); + }); + /* fixed digits ON TOP so they read through the holes (they stay put while the wheel spins) */ + DIGITS.forEach((d, i) => { + const [x, y] = polar(cx, cy, 38, angOf(i)); + svgEl(s, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 8.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = d; + }); + svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#tdWheel)', stroke: '#26221c', 'stroke-width': 1 }); + /* finger stop */ + const [fx, fy] = polar(cx, cy, 52, STOP); + svgEl(s, 'rect', { x: fx - 2.5, y: fy - 8, width: 5, height: 16, rx: 2, fill: '#b8b2a4', stroke: '#3c382f', 'stroke-width': 1, transform: `rotate(${STOP},${fx},${fy})` }); + let dialed = '', spinning = false; + const setW = a => wheel.setAttribute('transform', `rotate(${a},${cx},${cy})`); + const finish = d => { dialed = (dialed + d).slice(-10); onChange(dialed, dialed); spinning = false; }; + DIGITS.forEach((d, i) => { + const [x, y] = polar(cx, cy, 38, angOf(i)); + const hit = svgEl(s, 'circle', { cx: x, cy: y, r: 8.5, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { + if (spinning) return; spinning = true; + const R = (STOP - angOf(i) + 360) % 360; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { setW(R); setTimeout(() => { setW(0); finish(d); }, 160); return; } + const t0 = performance.now(), Tf = R / 300 * 1000, Tb = R / 450 * 1000; + const step = t => { + const el = t - t0; + if (el < Tf) { setW(R * el / Tf); requestAnimationFrame(step); } + else if (el < Tf + 80) { setW(R); requestAnimationFrame(step); } + else if (el < Tf + 80 + Tb) { setW(R * (1 - (el - Tf - 80) / Tb)); requestAnimationFrame(step); } + else { setW(0); finish(d); } + }; + requestAnimationFrame(step); + }); + }); + onChange('', 'dial a number'); + return { el: s, get: () => dialed }; +}; + +/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step + (a tripped handle clicks to off, then to on). + + Contract (everything a consumer needs; no page globals touched): + opts: names (breaker labels, default MAIN/PUMP/LAMP/AUX — the layout is + sized for four); onChange(states array, 'N/M closed[ · NAME + TRIPPED]') fires on every click or trip, including the initial + paint. The first three breakers start on, the rest off. + handle: el, get() (per-breaker 'on'|'off'|'tripped' array). No set — + breakers move by handle click and the TRIP button. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.breakerPanel = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX']; + const s = stageSvg(host, 'rsvg press', 160, 110); + svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + const brk = []; + const draw = () => { + let closed = 0, trip = null; + brk.forEach(b => { + if (b.state === 'on') closed++; if (b.state === 'tripped') trip = b.name; + b.lamp.setAttribute('fill', b.state === 'on' ? 'var(--pass)' : '#1e2a12'); + b.lamp.setAttribute('filter', b.state === 'on' ? 'drop-shadow(0 0 3px rgba(116,147,47,.8))' : 'none'); + b.handle.setAttribute('y', b.state === 'on' ? 40 : b.state === 'tripped' ? 57 : 74); + b.collar.setAttribute('opacity', b.state === 'tripped' ? 1 : 0); + }); + onChange(brk.map(b => b.state), closed + '/' + NAMES.length + ' closed' + (trip ? ' · ' + trip + ' TRIPPED' : '')); + }; + NAMES.forEach((name, i) => { + const x = 14 + i * 30; + svgEl(s, 'rect', { x, y: 36, width: 20, height: 52, rx: 4, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1 }); + const lamp = svgEl(s, 'circle', { cx: x + 10, cy: 26, r: 4, fill: '#1e2a12' }); + const collar = svgEl(s, 'rect', { x: x + 4, y: 69, width: 12, height: 5, fill: 'var(--amber-warn)', opacity: 0 }); + const handle = svgEl(s, 'rect', { x: x + 4, y: 40, width: 12, height: 14, rx: 2, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + svgEl(s, 'text', { x: x + 10, y: 98, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = name; + const hit = svgEl(s, 'rect', { x, y: 20, width: 20, height: 80, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + const b = { name, state: i < 3 ? 'on' : 'off', lamp, collar, handle }; + hit.addEventListener('click', () => { b.state = b.state === 'on' ? 'off' : b.state === 'off' ? 'on' : 'off'; draw(); }); + brk.push(b); + }); + const tb = svgEl(s, 'g', {}); + svgEl(tb, 'rect', { x: 132, y: 40, width: 22, height: 16, rx: 4, fill: 'var(--fail)', stroke: '#5c150c', 'stroke-width': 1 }); + svgEl(tb, 'text', { x: 143, y: 50.5, 'text-anchor': 'middle', 'font-size': 6, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = 'TRIP'; + tb.style.cursor = 'pointer'; + tb.addEventListener('click', () => { for (let i = brk.length - 1; i >= 0; i--) if (brk[i].state === 'on') { brk[i].state = 'tripped'; break; } draw(); }); + draw(); + return { el: s, get: () => brk.map(b => b.state) }; +}; + +/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(vals {prog, verb, noun}, status text) fires on ENTR, + RSET, OPR ERR, and the initial paint. Grammar: VERB/NOUN arm a + window, digits fill it (2 max), ENTR commits — V35 runs the lamp + test, V16 N36 sets PROG 16 (monitor clock), a bare-verb commit + with under 2 digits flashes OPR ERR. + handle: el, get() ({prog, verb, noun} copy). + CSS lives in the "DSKY" block of DUPRE_CSS; keys reuse the shared + .dupre-key face and digits render as shared .seg7 glyphs. */ +DUPRE.dsky = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR']; + const el = document.createElement('div'); el.className = 'dupre-dsky'; host.appendChild(el); + el.innerHTML = + `<div class="dupre-dsky-lamps">${LAMPS.map(l => `<div class="dupre-dsky-sl" data-l="${l}">${l}</div>`).join('')}</div>` + + `<div class="dupre-dsky-right"><div class="dupre-dsky-wins">` + + `<div class="dupre-dsky-win" data-w="prog"><span class="dupre-dsky-wl">PROG</span><span class="dupre-dsky-wd"></span></div>` + + `<div class="dupre-dsky-win" data-w="verb"><span class="dupre-dsky-wl">VERB</span><span class="dupre-dsky-wd"></span></div>` + + `<div class="dupre-dsky-win" data-w="noun"><span class="dupre-dsky-wl">NOUN</span><span class="dupre-dsky-wd"></span></div>` + + `</div><div class="dupre-dsky-pad"></div></div>`; + const win = w => el.querySelector(`[data-w="${w}"]`); + const setWin = (w, txt) => { win(w).querySelector('.dupre-dsky-wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); }; + const lampEl = l => el.querySelector(`[data-l="${l}"]`); + let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = ''; + const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0']; + const pad = el.querySelector('.dupre-dsky-pad'); + const hot = () => { el.querySelectorAll('.dupre-dsky-win').forEach(w => w.classList.toggle('dupre-hot', w.dataset.w === mode)); }; + const oprErr = () => { lampEl('OPR ERR').classList.add('dupre-on'); setTimeout(() => lampEl('OPR ERR').classList.remove('dupre-on'), 1200); onChange(vals, 'OPR ERR'); }; + const commit = () => { + if (vals.verb.trim().length < 2) { oprErr(); return; } + const v = vals.verb, n = vals.noun.trim(); + if (v === '35') { + LAMPS.forEach(l => lampEl(l).classList.add('dupre-on')); + setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')), 1400); + onChange(vals, 'V35 · lamp test'); + } + else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); } + else onChange(vals, 'V' + v + (n ? ' N' + n : '') + ' ENTR'); + vals.verb = ' '; vals.noun = ' '; entry = ''; mode = null; hot(); + }; + KEYS.forEach(k => { + const b = document.createElement('button'); b.className = 'dupre-key'; b.textContent = k; + b.addEventListener('click', () => { + if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); } + else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } } + else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')); onChange(vals, 'RSET'); } + else if (k === 'ENTR') commit(); + else if (/\d/.test(k)) { + if (!mode) { oprErr(); return; } + entry = (entry + k).slice(0, 2); vals[mode] = entry.padEnd(2, ' '); setWin(mode, vals[mode]); + } + }); + pad.appendChild(b); + }); + vals.prog = '00'; setWin('prog', vals.prog); setWin('verb', ' '); setWin('noun', ' '); + onChange(vals, 'VERB ## ENTR'); + return { el, get: () => ({ ...vals }) }; +}; + +/* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it. + Runs its own reduced-motion-gated 2 s step interval once started. + + Contract (everything a consumer needs; no page globals touched): + opts: steps (ring labels, index 0 is OFF, default 12-step wash + program — the layout is sized for 12); colors (label→wedge + fill map, default wash palette); position (initial step, clamps + to the ring, default 0); onChange(pos, 'OFF'|'STEP N · + LABEL'|'CYCLE DONE · OFF') fires on every set, including the + initial paint. + handle: el, get() (step index), set(i) — i clamps to the ring; any + click advances one step. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.camTimer = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL']; + const COLS = opts.colors || { OFF: '#3a3426', FILL: '#54677d', WASH: '#d29638', RINSE: '#46b89e', DRAIN: '#969385', SPIN: '#cb6b4d', FLUFF: '#c9b98a', COOL: '#7c99b0' }; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + STEPS.forEach((st, i) => { + const a0 = i * 30 - 90, a1 = a0 + 30; + const p0o = polar(cx, cy, 52, a0 + 90), p1o = polar(cx, cy, 52, a1 + 90), p0i = polar(cx, cy, 38, a0 + 90), p1i = polar(cx, cy, 38, a1 + 90); + svgEl(s, 'path', { d: `M ${p0i[0]} ${p0i[1]} L ${p0o[0]} ${p0o[1]} A 52 52 0 0 1 ${p1o[0]} ${p1o[1]} L ${p1i[0]} ${p1i[1]} A 38 38 0 0 0 ${p0i[0]} ${p0i[1]} Z`, fill: COLS[st], opacity: .75, stroke: '#0a0908', 'stroke-width': .8 }); + const [tx, ty] = polar(cx, cy, 45, i * 30 + 15); + svgEl(s, 'text', { x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.4, 'font-weight': 700, 'font-family': 'var(--mono)', fill: st === 'OFF' ? 'var(--cream)' : '#14110e' }).textContent = st === 'OFF' ? 'OFF' : st[0]; + }); + svgEl(s, 'circle', { cx, cy, r: 34, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 }); + STEPS.forEach((st, i) => { + if (i === 0) return; const [lx, ly] = polar(cx, cy, 27, i * 30 + 15); + svgEl(s, 'text', { x: lx, y: ly + 1.8, 'text-anchor': 'middle', 'font-size': 4.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = st; + }); + svgEl(s, 'circle', { cx, cy, r: 17, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1.5 }); + const ptr = svgEl(s, 'polygon', { points: `${cx - 2.5},${cy} ${cx + 2.5},${cy} ${cx},${cy - 31}`, fill: 'var(--gold-hi)', filter: 'drop-shadow(0 0 3px rgba(var(--glow-lo),.7))' }); + svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let pos; + const set = i => { + pos = Math.max(0, Math.min(STEPS.length - 1, i | 0)); + ptr.setAttribute('transform', `rotate(${pos * 30 + 15},${cx},${cy})`); + onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => set((pos + 1) % STEPS.length)); + set(opts.position !== undefined ? opts.position : 0); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { if (pos > 0) { const n = (pos + 1) % STEPS.length; set(n); if (n === 0) onChange(0, 'CYCLE DONE · OFF'); } }, 2000); + return { el: s, get: () => pos, set }; +}; + +/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw. + + Contract (everything a consumer needs; no page globals touched): + opts: closed (initial state, coerced to boolean, default true); + onChange(closed, 'CLOSED · live'|'OPEN · visibly dead') fires on + every set, including the initial paint. + handle: el, get() (true when closed), set(v) — coerced to boolean; any + click toggles. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.knifeSwitch = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 110); + gradDef('ksCu', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d09a5c'], ['1', '#8a5a2a']]); + const HX = 32, HY = 74; /* hinge pivot */ + svgEl(s, 'rect', { x: 6, y: 56, width: 118, height: 44, rx: 6, fill: '#22262a', stroke: '#0c0d0e', 'stroke-width': 1.5 }); + const lamp = svgEl(s, 'circle', { cx: 116, cy: 22, r: 4.5, fill: '#1e2a12' }); + svgEl(s, 'text', { x: 116, y: 36, 'text-anchor': 'middle', 'font-size': 4.8, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'LIVE'; + /* hinge post (left) and jaw clip (right), both on the base */ + svgEl(s, 'rect', { x: HX - 6, y: HY - 6, width: 12, height: 16, rx: 2, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 }); + svgEl(s, 'rect', { x: 96, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 }); + svgEl(s, 'rect', { x: 104, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 }); + /* blade + upright handle, hinged at (HX,HY) */ + const blade = svgEl(s, 'g', {}); + svgEl(blade, 'rect', { x: HX, y: HY - 3, width: 71, height: 6, rx: 2.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 }); + svgEl(blade, 'rect', { x: 88, y: HY - 26, width: 7, height: 26, rx: 3, fill: '#14110e', stroke: '#000', 'stroke-width': 1 }); + svgEl(blade, 'circle', { cx: 91.5, cy: HY - 26, r: 5, fill: '#14110e', stroke: '#000', 'stroke-width': 1 }); + svgEl(s, 'circle', { cx: HX, cy: HY, r: 3, fill: '#5c3a18' }); + blade.style.transformBox = 'view-box'; blade.style.transformOrigin = `${HX}px ${HY}px`; + blade.style.transition = 'transform .28s ease'; + let closed; + const set = v => { + closed = !!v; + blade.style.transform = closed ? 'rotate(0deg)' : 'rotate(-46deg)'; + lamp.setAttribute('fill', closed ? 'var(--pass)' : '#1e2a12'); + lamp.setAttribute('filter', closed ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' : 'none'); + onChange(closed, closed ? 'CLOSED · live' : 'OPEN · visibly dead'); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => set(!closed)); + set(opts.closed !== undefined ? opts.closed : true); + return { el: s, get: () => closed, set }; +}; + +/* R49 decade box — four skirted knobs, one digit each; the value is their sum. + + Contract (everything a consumer needs; no page globals touched): + opts: digits (array of four 0-9 digits, most-significant first, default + [3, 5, 0, 0] — missing entries read 0, values clamp to 0-9); + onChange(total, 'N,NNN Ω') fires on every redraw, including the + initial paint. + handle: el, get() (the summed value; digits weight x1000/x100/x10/x1). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; no gradients registered. */ +DUPRE.decadeBox = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1']; + const digs = MUL.map((_, i) => Math.round(Math.max(0, Math.min(9, +(opts.digits || [3, 5, 0, 0])[i] || 0)))); + const s = stageSvg(host, 'rsvg', 160, 100); + svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 }); + const wins = [], knobs = []; + const total = () => digs.reduce((a, d, i) => a + d * MUL[i], 0); + const draw = () => { + wins.forEach((w, i) => w.textContent = String(digs[i])); + knobs.forEach((k, i) => k.setAttribute('transform', `rotate(${digs[i] * 36},${28 + i * 35},58)`)); + onChange(total(), total().toLocaleString('en-US') + ' Ω'); + }; + MUL.forEach((_, i) => { + const cx = 28 + i * 35, cy = 58; + svgEl(s, 'rect', { x: cx - 8, y: 16, width: 16, height: 12, rx: 2, fill: '#0a0806', stroke: '#2c2820', 'stroke-width': 1 }); + wins.push(svgEl(s, 'text', { x: cx, y: 25.5, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' })); + svgEl(s, 'circle', { cx, cy, r: 15, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 }); /* skirt */ + for (let t = 0; t < 10; t++) { + const [x1, y1] = polar(cx, cy, 14, t * 36), [x2, y2] = polar(cx, cy, 12, t * 36); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': .6, opacity: .7 }); + } + const k = svgEl(s, 'g', {}); + svgEl(k, 'circle', { cx, cy, r: 10, fill: '#2c2824', stroke: '#060505', 'stroke-width': 1 }); + svgEl(k, 'line', { x1: cx, y1: cy - 9, x2: cx, y2: cy - 4, stroke: 'var(--gold-hi)', 'stroke-width': 1.8 }); + knobs.push(k); + svgEl(s, 'text', { x: cx, y: 88, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = LBL[i]; + const hit = svgEl(s, 'circle', { cx, cy, r: 16, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, () => digs[i], v => { digs[i] = Math.round(Math.max(0, Math.min(9, v))); draw(); }, { min: 0, max: 9, sens: 9 / 70 }); + }); + draw(); + return { el: s, get: () => total() }; +}; + +/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(state, text) fires on every transition — 'ready', + 'armed' (one palm down, 0.5s window), 'running' (1.5s cycle), + 'fault' (window expired or same hand twice); initial fire 'ready'. + handle: el, press(side) — side is 'L' or 'R' only, anything else is + ignored; presses during a running cycle are ignored. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; no gradients registered. */ +DUPRE.twoHandSafety = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 160, 100); + svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + const runLamp = svgEl(s, 'circle', { cx: 80, cy: 24, r: 5.5, fill: '#1e2a12' }); + svgEl(s, 'text', { x: 80, y: 40, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'CYCLE'; + const mkBtn = (cx, lbl) => { + const g = svgEl(s, 'g', {}); + svgEl(g, 'ellipse', { cx, cy: 70, rx: 19, ry: 6, fill: '#0d0b09' }); + const cap = svgEl(g, 'circle', { cx, cy: 62, r: 16, fill: '#8f2416', stroke: '#5c150c', 'stroke-width': 1.5 }); + svgEl(g, 'ellipse', { cx: cx - 5, cy: 56, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.22)' }); + svgEl(s, 'text', { x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl; + g.style.cursor = 'pointer'; return { g, cap }; + }; + const L = mkBtn(32, 'LEFT PALM'), R = mkBtn(128, 'RIGHT PALM'); + let armed = null, armT = null, busy = false; + const flash = cap => { cap.setAttribute('fill', '#e0523a'); setTimeout(() => cap.setAttribute('fill', '#8f2416'), 200); }; + const fault = () => { armed = null; clearTimeout(armT); onChange('fault', 'TIE-DOWN FAULT · release and retry'); }; + const press = side => { + if (side !== 'L' && side !== 'R') return; + if (busy) return; + if (armed === null) { + armed = side; flash(side === 'L' ? L.cap : R.cap); + onChange('armed', side + ' armed · other hand within 0.5s'); + armT = setTimeout(fault, 500); return; + } + if (armed === side) { fault(); return; } /* same hand twice = tie-down */ + clearTimeout(armT); armed = null; busy = true; + flash(side === 'L' ? L.cap : R.cap); + runLamp.setAttribute('fill', 'var(--pass)'); + runLamp.setAttribute('filter', 'drop-shadow(0 0 5px rgba(116,147,47,.8))'); + onChange('running', 'CYCLE RUNNING'); + setTimeout(() => { + runLamp.setAttribute('fill', '#1e2a12'); runLamp.removeAttribute('filter'); + busy = false; onChange('ready', 'ready'); + }, 1500); + }; + L.g.addEventListener('click', () => press('L')); + R.g.addEventListener('click', () => press('R')); + onChange('ready', 'ready'); + return { el: s, press }; +}; + +/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker. + + Contract (everything a consumer needs; no page globals touched): + opts: loops (array of key labels; the default 8-loop set also gets a + legible demo state — FD + A/G monitored, GNC talking — while a + caller's own set starts all-idle); onChange(states, text) fires + on every click and the initial paint — states is an array of + '0' idle / '1' monitored / '2' talking, one per loop. + handle: el, get() (the states array). Click cycles idle → monitored → + talking → idle; talk is exclusive across keys. Activity + flicker is display-side and honors prefers-reduced-motion. + CSS lives in the "voice-loop keyset" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.voiceLoop = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO']; + const el = document.createElement('div'); el.className = 'dupre-vloop'; host.appendChild(el); + const keys = LOOPS.map(l => { + const k = document.createElement('div'); k.className = 'dupre-vk'; + k.innerHTML = l + '<span class="dupre-vk-bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k; + }); + if (!opts.loops) { + /* a legible default: FD + A/G monitored, GNC talking */ + keys[0].dataset.state = '1'; keys[0].classList.add('dupre-mon'); + keys[4].dataset.state = '1'; keys[4].classList.add('dupre-mon'); + keys[1].dataset.state = '2'; keys[1].classList.add('dupre-mon', 'dupre-tlk'); + } + const refresh = () => { + const mon = keys.filter(k => k.dataset.state !== '0').length; + const tlk = keys.findIndex(k => k.dataset.state === '2'); + onChange(keys.map(k => k.dataset.state), mon === 0 ? 'all loops dropped' : mon + ' monitored' + (tlk >= 0 ? ' · talk ' + LOOPS[tlk] : '')); + }; + keys.forEach(k => k.addEventListener('click', () => { + const st = k.dataset.state; + if (st === '0') { k.dataset.state = '1'; k.classList.add('dupre-mon'); } + else if (st === '1') { + keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('dupre-tlk'); } }); + k.dataset.state = '2'; k.classList.add('dupre-tlk'); + } + else { k.dataset.state = '0'; k.classList.remove('dupre-mon', 'dupre-tlk', 'dupre-act'); } + refresh(); + })); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { + const t = performance.now() / 1000; + keys.forEach((k, i) => { + if (k.dataset.state === '0') { k.classList.remove('dupre-act'); return; } + k.classList.toggle('dupre-act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55); + }); + }, 300); + refresh(); + return { el, get: () => keys.map(k => k.dataset.state) }; +}; + +/* ---- meters & gauges ---- + Tick contract: the page owns the clock and the signal; live meters expose + value-driven handles (set/push) that repaint synchronously and fire + onChange(value, text) like every other builder. Display-side state that + belongs to the instrument (peak-hold, history buffers) lives in here. */ + +/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, 'value N%') fires on + every set, including the initial paint. + handle: el, get() (current value), set(v) — no clamping beyond the + drag helper's 0-100; the needle maps value linearly to ±60°. + CSS lives in the "needle gauge" block of DUPRE_CSS; no other styles involved. */ +DUPRE.needleGauge = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 50; + const el = document.createElement('div'); el.className = 'dupre-gauge'; + el.innerHTML = `<div class="dupre-dial"><div class="dupre-arc"></div> + <div class="dupre-tk" style="transform:rotate(-60deg)"></div><div class="dupre-tk" style="transform:rotate(0)"></div><div class="dupre-tk" style="transform:rotate(60deg)"></div> + <div class="dupre-ndl"></div><div class="dupre-hub"></div></div><div class="dupre-gv"><span>0</span>%</div>`; + host.appendChild(el); + const ndl = el.querySelector('.dupre-ndl'), num = el.querySelector('.dupre-gv span'); + function set(nv) { + v = nv; + ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`; + num.textContent = Math.round(v); + onChange(v, 'value ' + Math.round(v) + '%'); + } + dragDelta(el, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both. + + Contract (everything a consumer needs; no page globals touched): + opts: bars (cells per channel, default 16); onChange([l, r], 'L n · R n') + fires on every set (values 0-1, readout in percent). + handle: el, get() ([l, r] last set), set(l, r). Peak-hold is display-side + state owned in here: each channel's peak cell decays 0.4 cells + per set() call, so the page's tick cadence is the decay clock + (the tick contract — the page owns the clock and the signal). + CSS lives in the "segmented VU / LED bar" block of DUPRE_CSS; no other + styles involved. */ +DUPRE.vuPair = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const n = opts.bars || 16; + const el = document.createElement('div'); el.className = 'dupre-vu'; + el.innerHTML = `<div class="dupre-vurow"><span class="dupre-ch">L</span><span class="dupre-vubar"></span></div> + <div class="dupre-vurow"><span class="dupre-ch">R</span><span class="dupre-vubar"></span></div>`; + host.appendChild(el); + const bars = el.querySelectorAll('.dupre-vubar'); + bars.forEach(b => buildBars(b, n)); + const pkL = { v: 0 }, pkR = { v: 0 }; + function paint(bar, l, pk) { + const b = bar.children, lit = Math.round(l * n); + pk.v = Math.max(lit, (pk.v || 0) - 0.4); const p = Math.round(pk.v); + for (let k = 0; k < n; k++) { + let c = k < lit ? (k >= n - 2 ? 'dupre-clip' : k >= n - 4 ? 'dupre-hot' : 'dupre-on') : ''; + if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'dupre-peak'; + b[k].className = c; + } + } + let lv = 0, rv = 0; + function set(l, r) { + lv = l; rv = r; + paint(bars[0], l, pkL); paint(bars[1], r, pkR); + onChange([l, r], 'L ' + Math.round(l * 100) + ' · R ' + Math.round(r * 100)); + } + return { el, get: () => [lv, rv], set }; +}; + +/* 12 mini 4-bar signal — compact activity meter; set(level). + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(level, 'N%') fires on every set (level 0-1, clamped; + readout in percent). No initial fire — the page's tick drives it + (the tick contract — the page owns the clock and the signal). + handle: el, get() (last level set), set(level). Bars light bottom-up: + dupre-on, third bar dupre-hot, top bar dupre-clip. + CSS lives in the "mini 4-bar signal" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.miniSig = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const el = document.createElement('span'); el.className = 'dupre-sig'; + el.innerHTML = '<i></i><i></i><i></i><i></i>'; + host.appendChild(el); + let v = 0; + function set(l) { + v = Math.max(0, Math.min(1, l)); + const b = el.children, lit = Math.round(v * 4); + for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'dupre-clip' : k >= 2 ? 'dupre-hot' : 'dupre-on') : ''; + onChange(v, Math.round(v * 100) + '%'); + } + return { el, get: () => v, set }; +}; + +/* 13 signal ladder — stepped 0-4 strength; click cycles. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-4, default 3); onChange(v, 'v/4') fires on every set, + including the initial paint. + handle: el, get() (current step), set(v) — bars at or below v light + gold; click advances (v + 1) % 5. + CSS lives in the "signal ladder" block of DUPRE_CSS; the lit/unlit bar + colours are inline (--gold / --wash), no other styles involved. */ +DUPRE.signalLadder = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 3; + const el = document.createElement('span'); el.className = 'dupre-ladder'; + el.innerHTML = '<i></i><i></i><i></i><i></i>'; + host.appendChild(el); + function set(nv) { + v = nv; + const bars = el.children; + for (let i = 0; i < bars.length; i++) bars[i].style.background = i < v ? 'var(--gold)' : 'var(--wash)'; + onChange(v, v + '/4'); + } + el.onclick = () => set((v + 1) % 5); + set(v); + return { el, get: () => v, set }; +}; + +/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); warnAt (threshold, default 20 — below + it the fill tints dupre-warn); onChange(value, 'N%') fires on + every set, including the initial paint. + handle: el, get() (current value), set(v) — clamped to 0-100; drag + along the bar sets by pointer position. + CSS lives in the "linear progress / fuel bar" block of DUPRE_CSS; no + other styles involved. */ +DUPRE.fuelBar = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const warnAt = opts.warnAt ?? 20; + let v = opts.value ?? 50; + const el = document.createElement('div'); el.className = 'dupre-bar'; + el.innerHTML = '<span></span>'; + host.appendChild(el); + const fill = el.querySelector('span'); + function set(p) { + v = Math.max(0, Math.min(100, p)); + fill.style.width = v + '%'; + el.classList.toggle('dupre-warn', v < warnAt); + onChange(v, Math.round(v) + '%'); + } + dragX(el, set); + set(v); + return { el, get: () => v, set }; +}; + +/* 15 radial ring — percentage donut; drag up/down to set. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 68); onChange(value, 'N%') fires on every + set, including the initial paint. + handle: el, get() (current value), set(v) — clamped to 0-100; the fill + is a conic gradient driven by the element-scoped --p property. + CSS lives in the "radial ring" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.radialRing = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 68; + const el = document.createElement('span'); el.className = 'dupre-ring'; + el.innerHTML = '<b></b>'; + host.appendChild(el); + const num = el.querySelector('b'); + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + el.style.setProperty('--p', v); + num.textContent = Math.round(v); + onChange(v, Math.round(v) + '%'); + } + dragDelta(el, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it. + + Contract (everything a consumer needs; no page globals touched): + opts: samples (history length, default 40, floor 2 — the trace needs + two points); value (initial level, default 0.5); onChange(last, + 'N') fires on every repaint (samples 0-1, clamped on entry; + readout 0-100). No initial fire — the page's tick drives it (the + tick contract — the page owns the clock and the signal). The + history buffer is display-side state owned in here. + handle: el, get() (newest sample), push(v) (append, oldest drops), + fill(v) (level the whole buffer). + CSS lives in the "sparkline" block of DUPRE_CSS; the trace colour is an + inline stroke, no other styles involved. */ +DUPRE.sparkline = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const n = Math.max(2, opts.samples || 40); + const clamp = x => Math.max(0, Math.min(1, x)); + const hist = Array.from({ length: n }, () => clamp(opts.value ?? 0.5)); + const el = document.createElement('span'); el.className = 'dupre-spark'; + el.innerHTML = '<svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg>'; + host.appendChild(el); + const line = el.querySelector('polyline'); + function paint() { + line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - v * 40 - 2}`).join(' ')); + onChange(hist[n - 1], String(Math.round(hist[n - 1] * 100))); + } + function push(v) { hist.push(clamp(v)); hist.shift(); paint(); } + function fill(v) { hist.fill(clamp(v)); paint(); } + return { el, get: () => hist[n - 1], push, fill }; +}; + +/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1. + + Contract (everything a consumer needs; no page globals touched): + opts: amp (initial amplitude for the readout, default 0.6); + onChange(amp, 'amp N%') fires on every set, including the + initial paint (a flat centre line). + handle: el, get() (last amp), set(samples, amp) — samples in -1..1, + clamped per sample; fewer than two samples draws the flat + centre line. Amp is readout-only; the trace height comes from + the samples themselves. + CSS lives in the "waveform strip" block of DUPRE_CSS; the trace colour + is an inline stroke, no other styles involved. */ +DUPRE.waveStrip = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const el = document.createElement('span'); el.className = 'dupre-wave'; + el.innerHTML = '<svg viewBox="0 0 170 38" preserveAspectRatio="none"><path fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg>'; + host.appendChild(el); + const path = el.querySelector('path'); + const clamp = x => Math.max(-1, Math.min(1, x)); + let amp = 0; + function set(samples, a) { + amp = a; + let d = 'M0 19'; + if (samples.length < 2) d += ' L170 19'; + else for (let i = 0; i < samples.length; i++) + d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + clamp(samples[i]) * 14).toFixed(1)}`; + path.setAttribute('d', d); + onChange(amp, 'amp ' + Math.round(amp * 100) + '%'); + } + set([], opts.amp ?? 0.6); + return { el, get: () => amp, set }; +}; + +/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(vpp, 'Vpp N') fires on every set (readout is vpp x100). + No initial fire — the page's tick drives it (the tick contract — + the page owns the clock and the signal). + handle: el, get() (last vpp), set(samples, vpp) — samples in -1..1, + clamped per sample; fewer than two samples clears the trace. + Vpp is readout-only; the trace comes from the samples. + CSS lives in the "oscilloscope" block of DUPRE_CSS; the screen-family + --scr-* vars retint it, with the original green as fallback. */ +DUPRE.scope = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const el = document.createElement('span'); el.className = 'dupre-scope'; + el.innerHTML = '<span class="dupre-grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>'; + host.appendChild(el); + const line = el.querySelector('polyline'); + const clamp = x => Math.max(-1, Math.min(1, x)); + let vpp = 0; + function set(samples, v) { + vpp = v; + const n = samples.length; + line.setAttribute('points', n < 2 ? '' : + samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + clamp(s) * 22).toFixed(1)}`).join(' ')); + onChange(vpp, 'Vpp ' + Math.round(vpp * 100)); + } + return { el, get: () => vpp, set }; +}; + +/* N12 spectrum / EQ bars — set(values) paints one column per band. + + Contract (everything a consumer needs; no page globals touched): + opts: bands (columns, default 11); cells (segments per column, default + 9); onChange(values, 'peak N%') fires on every set (values 0-1 + per band, clamped; a missing band reads 0). No initial fire — + the page's tick drives it (the tick contract — the page owns the + clock and the signal). + handle: el, get() (last values array), set(values). Cells light + bottom-up: dupre-on, top three dupre-hot, topmost dupre-clip. + CSS lives in the "spectrum / EQ" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.eqBars = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const bands = opts.bands || 11, cells = opts.cells || 9; + const el = document.createElement('span'); el.className = 'dupre-eq'; + host.appendChild(el); + for (let b = 0; b < bands; b++) { + const band = document.createElement('span'); band.className = 'dupre-band'; + for (let s = 0; s < cells; s++) band.appendChild(document.createElement('i')); + el.appendChild(band); + } + let vals = []; + function set(values) { + vals = values; + let peak = 0; + for (let b = 0; b < bands; b++) { + const col = el.children[b].children, val = Math.max(0, Math.min(1, values[b] || 0)), lit = Math.round(val * cells); + peak = Math.max(peak, val); + for (let k = 0; k < cells; k++) { + let c = ''; + if (k < lit) c = (k >= cells - 1 ? 'dupre-clip' : k >= cells - 3 ? 'dupre-hot' : 'dupre-on'); + col[k].className = c; + } + } + onChange(vals, 'peak ' + Math.round(peak * 100) + '%'); + } + return { el, get: () => vals, set }; +}; + +/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 55); onChange(value, 'FWD n · RFL n') fires + on every set — RFL derives from the drive value (0.68×), the way a + crossed-needle SWR face couples the two. + handle: el, get() (current value), set(v) — clamped to 0-100; drag on + the face sets by vertical delta. + CSS lives in the "crossed-needle" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.crossNeedle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 55; + const el = document.createElement('div'); el.className = 'dupre-crossm'; + el.innerHTML = `<div class="dupre-face"><div class="dupre-crossm-arc"></div><div class="dupre-fwd"></div><div class="dupre-rfl"></div></div> + <div class="dupre-lbl"><span>FWD</span><span>RFL</span></div>`; + host.appendChild(el); + const face = el.querySelector('.dupre-face'), nA = el.querySelector('.dupre-fwd'), nB = el.querySelector('.dupre-rfl'); + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + const fwd = v, rfl = v * 0.68; + nA.style.transform = `rotate(${-42 + fwd / 100 * 84}deg)`; + nB.style.transform = `rotate(${42 - rfl / 100 * 84}deg)`; + onChange(v, 'FWD ' + Math.round(fwd) + ' · RFL ' + Math.round(rfl)); + } + dragDelta(face, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* N14 thermometer column — mercury rises with the value. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 58); onChange(value, 'N°') fires on every + set — the readout maps 0-100 onto the printed 30-90° scale. + handle: el, get() (current value), set(v) — clamped to 0-100; drag + anywhere on the column sets by vertical delta. + CSS lives in the "thermometer" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.thermometer = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 58; + const el = document.createElement('div'); el.className = 'dupre-thermo'; + el.innerHTML = `<div class="dupre-scale"><span>90</span><span>60</span><span>30</span></div> + <div class="dupre-wrapcol"><div class="dupre-thermo-tube"><div class="dupre-thermo-fill"></div></div><div class="dupre-bulb"></div></div>`; + host.appendChild(el); + const fill = el.querySelector('.dupre-thermo-fill'); + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + fill.style.height = v + '%'; + onChange(v, Math.round(30 + v / 100 * 60) + '°'); + } + dragDelta(el, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* N15 bourdon pressure gauge — needle over a printed arc with a redline. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 45); onChange(value, 'N PSI') fires on every + set — the readout maps 0-100 onto a 0-160 PSI scale. + handle: el, get() (current value), set(v) — clamped to 0-100, the + needle sweeps -60°..+60°; drag anywhere on the dial sets by + vertical delta. The printed arc and redline are the builder's + own inline SVG. + CSS lives in the "bourdon" block of DUPRE_CSS; no other styles involved. */ +DUPRE.bourdon = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let v = opts.value ?? 45; + const el = document.createElement('div'); el.className = 'dupre-bourdon'; + el.innerHTML = `<svg viewBox="0 0 82 82"><path d="M14 62 A34 34 0 0 1 68 62" fill="none" stroke="#2c2f32" stroke-width="3"/> + <path d="M52 22 A34 34 0 0 1 68 62" fill="none" stroke="#cb6b4d" stroke-width="3"/></svg> + <div class="dupre-bourdon-ndl"></div><div class="dupre-bourdon-hub"></div><div class="dupre-bourdon-cap">PSI</div>`; + host.appendChild(el); + const ndl = el.querySelector('.dupre-bourdon-ndl'); + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`; + onChange(v, Math.round(v / 100 * 160) + ' PSI'); + } + dragDelta(el, () => v, set, { min: 0, max: 100 }); + set(v); + return { el, get: () => v, set }; +}; + +/* N16 strip-chart recorder — scrolling history, pen rides the newest value. + + Contract (everything a consumer needs; no page globals touched): + opts: samples (history length, default 60, floor 2); value (initial + level 0-1, default 0.5); onChange(current, 'N') fires on every + paint (readout in percent). Values clamp to 0-1 at paint. + handle: el, get() (newest value), push(v) appends one sample and + scrolls, set(samples, current) replaces the whole trace (a + short or missing array back-fills 0.5). + CSS lives in the "strip-chart" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.stripChart = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const n = Math.max(2, opts.samples || 60); + const hist = Array.from({ length: n }, () => opts.value ?? 0.5); + const el = document.createElement('span'); el.className = 'dupre-strip'; + el.innerHTML = '<span class="dupre-rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="dupre-pen" style="top:31px"></span>'; + host.appendChild(el); + const line = el.querySelector('polyline'), pen = el.querySelector('.dupre-pen'); + const clamp = x => Math.max(0, Math.min(1, x)); + const yPx = v => 59 - clamp(v) * 56; + function paint(current) { + line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 176},${yPx(v).toFixed(1)}`).join(' ')); + pen.style.top = yPx(current) + 'px'; + onChange(current, String(Math.round(clamp(current) * 100))); + } + function push(v) { hist.push(v); hist.shift(); paint(v); } + function set(samples, current) { + if (!Array.isArray(samples)) samples = []; + for (let i = 0; i < n; i++) hist[i] = samples[i] ?? 0.5; + paint(current ?? hist[n - 1]); + } + return { el, get: () => hist[n - 1], push, set }; +}; + +/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 58 — 50 is the 0 rest point); onChange(corr, + '±x.xx') fires on every set with the CORRELATION (-1..+1 mapped + from the 0-100 position), not the raw position. + handle: el, get() (current 0-100 position), set(p) — clamped to 0-100; + drag left/right sets by pointer position. + CSS lives in the "correlation" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.corrMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + let p = opts.value ?? 58; + const el = document.createElement('div'); el.className = 'dupre-corr'; + el.innerHTML = `<div class="dupre-corr-face"><div class="dupre-corr-arc"></div><div class="dupre-zero"></div><div class="dupre-corr-ndl"></div></div> + <div class="dupre-corr-lbl"><span>−1</span><span>0</span><span>+1</span></div>`; + host.appendChild(el); + const ndl = el.querySelector('.dupre-corr-ndl'); + function set(np) { + p = Math.max(0, Math.min(100, np)); + const v = (p - 50) / 50; + ndl.style.transform = `rotate(${v * 38}deg)`; + onChange(v, (v >= 0 ? '+' : '') + v.toFixed(2)); + } + dragX(el, set); + set(p); + return { el, get: () => p, set }; +}; + +/* N18 battery-cell gauge — charge as discrete cells, the low end warns. + + Contract (everything a consumer needs; no page globals touched): + opts: cells (count, default 8, floor 1); warnAt (threshold, default 25 — + at or below it lit cells tint dupre-warn); value (0-100, default + 62); onChange(value, 'N%') fires on every set. + handle: el, get() (current value), set(v) — clamped to 0-100; drag + left/right sets by pointer position. Lit cells carry dupre-on. + CSS lives in the "battery" block of DUPRE_CSS; no other styles involved. */ +DUPRE.battCells = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const cells = Math.max(1, opts.cells || 8), warnAt = opts.warnAt ?? 25; + let p = opts.value ?? 62; + const el = document.createElement('div'); el.className = 'dupre-batt'; + el.innerHTML = '<div class="dupre-cells"></div><span class="dupre-nub"></span>'; + host.appendChild(el); + const cc = el.querySelector('.dupre-cells'); + for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'dupre-cell'; cc.appendChild(c); } + function set(np) { + p = Math.max(0, Math.min(100, np)); + const lit = Math.round(p / 100 * cells); + for (let i = 0; i < cells; i++) cc.children[i].className = 'dupre-cell' + (i < lit ? ' dupre-on' : '') + ((p <= warnAt && i < lit) ? ' dupre-warn' : ''); + onChange(p, Math.round(p) + '%'); + } + dragX(el, set); + set(p); + return { el, get: () => p, set }; +}; + +/* R01 moving-coil VU — pivot below the face, authentic nonlinear dB scale; + ballistics belong to the signal owner. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-1.02, default .35 — clamped, the needle paints from it + at build); onChange(t, '+x.x VU') fires on every set, no initial + fire — the readout maps t through the shared VU law (VUDB/vuDb), + the needle caps at t=1. + handle: el, get() (current t), set(t) — clamped to 0-1.02; positions + instantly, no ballistics. No drag — display-only. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the glass gradient and face clip are per-instance defs + entries. */ +DUPRE.mcVu = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 150, 96); + const cx = 75, cy = 112, sweep = t => -43 + t * 86; + const glassId = uid('vuGlass'), clipId = uid('vuFace'); + const defs = svgEl(s, 'defs', {}); + const g1 = svgEl(defs, 'linearGradient', { id: glassId, x1: 0, y1: 0, x2: 0, y2: 1 }); + svgEl(g1, 'stop', { offset: '0', 'stop-color': 'rgba(255,255,255,.13)' }); + svgEl(g1, 'stop', { offset: '.35', 'stop-color': 'rgba(255,255,255,0)' }); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3 }); + svgEl(s, 'rect', { x: .5, y: .5, width: 149, height: 95, rx: 7, fill: '#16130f', stroke: '#060505' }); + svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: 'var(--cream)' }); + const face = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const arcPt = t => polar(cx, cy, 58, sweep(t)); + const [ax1, ay1] = arcPt(0), [ax2, ay2] = arcPt(1); + svgEl(face, 'path', { d: `M ${ax1} ${ay1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.2 }); + const [rx1, ry1] = arcPt(.75); + svgEl(face, 'path', { d: `M ${rx1} ${ry1} A 58 58 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3.5 }); + for (const [db, t] of VUDB) { + const a = sweep(t), [x1, y1] = polar(cx, cy, 55, a), [x2, y2] = polar(cx, cy, 63, a); + svgEl(face, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1.1 }); + } + for (const [db, t] of VUDB) { + if (![-20, -10, -5, -3, 0, 1, 2, 3].includes(db)) continue; + const [x, y] = polar(cx, cy, 69, sweep(t)); + svgEl(face, 'text', { + x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.8, 'font-family': 'var(--mono)', + fill: db > 0 ? 'var(--fail)' : '#3a3128' + }).textContent = Math.abs(db); + } + svgEl(face, 'text', { + x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#3a3128' + }).textContent = 'VU'; + let t = Math.max(0, Math.min(1.02, opts.value ?? .35)); + const needle = svgEl(face, 'line', { + x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6, + transform: `rotate(${sweep(Math.min(1, t))},${cx},${cy})` + }); + svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' }); + svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` }); + function set(nt) { + t = Math.max(0, Math.min(1.02, nt)); + needle.setAttribute('transform', `rotate(${sweep(Math.min(1, t))},${cx},${cy})`); + const db = vuDb(Math.min(1, t)); + onChange(t, (db > 0 ? '+' : '') + db.toFixed(1) + ' VU'); + } + return { el: s, get: () => t, set }; +}; + +/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, '+x.x dB') fires on + every set — the readout maps the linear value through the shared + VU law (VUDB/vuDb), like every VU-family meter. + handle: el, get() (current value), set(v) — the needle sweeps -40°..+40° + over the clamped 0-1 position. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the face clip is a per-instance defs entry. */ +DUPRE.roundMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 104); + const cx = 55, fy = 54, py = 74, sweep = t => -40 + t * 80; + const clipId = uid('rndFace'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy: fy, r: 40 }); + svgEl(s, 'circle', { cx, cy: fy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy: fy, r: 40, fill: 'var(--cream)' }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const pt = (r, t) => polar(cx, py, r, sweep(t)); + const [ax1, ay1] = pt(44, 0), [ax2, ay2] = pt(44, 1); + svgEl(g, 'path', { d: `M ${ax1} ${ay1} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: '#3a3128', 'stroke-width': 1.1 }); + const [rzx, rzy] = pt(44, .75); + svgEl(g, 'path', { d: `M ${rzx} ${rzy} A 44 44 0 0 1 ${ax2} ${ay2}`, fill: 'none', stroke: 'var(--fail)', 'stroke-width': 3 }); + for (const [db, t] of VUDB) { + const [x1, y1] = pt(41.5, t), [x2, y2] = pt(47, t); + svgEl(g, 'line', { x1, y1, x2, y2, stroke: db > 0 ? 'var(--fail)' : '#3a3128', 'stroke-width': 1 }); + } + for (const [db, t] of VUDB) { + if (![-20, -10, -5, -3, 0, 3].includes(db)) continue; + const [x, y] = pt(52, t); + svgEl(g, 'text', { + x, y: y + 2, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', + fill: db > 0 ? 'var(--fail)' : '#3a3128' + }).textContent = Math.abs(db); + } + svgEl(g, 'text', { + x: cx, y: fy + 14, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#3a3128' + }).textContent = 'dB'; + const needle = svgEl(g, 'line', { + x1: cx, y1: py, x2: cx, y2: py - 46, stroke: '#1a1613', 'stroke-width': 1.5, + transform: `rotate(0,${cx},${py})` + }); + svgEl(g, 'circle', { cx, cy: py, r: 5, fill: '#141110' }); + svgEl(s, 'ellipse', { cx: cx - 8, cy: fy - 22, rx: 20, ry: 9, fill: 'rgba(255,255,255,.07)', transform: `rotate(-18,${cx - 8},${fy - 22})` }); + let v = opts.value ?? 50; + function set(nv) { + v = nv; + const t = Math.max(0, Math.min(1, v / 100)); + needle.setAttribute('transform', `rotate(${-40 + t * 80},${cx},${py})`); + const db = vuDb(t); + onChange(v, (db > 0 ? '+' : '') + db.toFixed(1) + ' dB'); + } + dragDelta(s, () => v, set, { min: 0, max: 100 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, readout) fires on + every set — the readout says 'MIN' at ≤2, 'MAX' at ≥98, 'N%' + between. + handle: el, get() (current value), set(v) — clamped to 0-100, the + pointer sweeps -55°..+55°; drag anywhere sets by vertical + delta. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the ring and dome gradients register in the shared defs + plate. */ +DUPRE.chromeMinMax = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 104); + const cx = 55, cy = 58; + gradDef('chrRing', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#eceef0'], ['.5', '#9aa0a8'], ['1', '#54585e']]); + gradDef('chrDome', 'radialGradient', { cx: '48%', cy: '20%', r: '85%' }, [['0', '#e6e8ec'], ['1', '#82878f']]); + svgEl(s, 'circle', { cx, cy: 54, r: 47, fill: 'url(#chrRing)' }); + svgEl(s, 'circle', { cx, cy: 54, r: 40, fill: '#101214', stroke: '#2c2f33', 'stroke-width': 1 }); + svgEl(s, 'path', { d: `M 21 60 A 34 34 0 0 1 89 60 Z`, fill: 'url(#chrDome)', stroke: '#55595f', 'stroke-width': .5 }); + for (const r of [14, 22, 30]) + svgEl(s, 'path', { d: `M ${cx - r} 60 A ${r} ${r} 0 0 1 ${cx + r} 60`, fill: 'none', stroke: 'rgba(255,255,255,.13)', 'stroke-width': .7 }); + const ptr = svgEl(s, 'line', { x1: cx, y1: cy - 10, x2: cx, y2: cy - 31, stroke: '#17191b', 'stroke-width': 2.2, 'stroke-linecap': 'round' }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#23262a' }); + for (let k = 0; k < 8; k++) { + const [x, y] = polar(cx, cy, 6, k * 45); + svgEl(s, 'circle', { cx: x, cy: y, r: 1.3, fill: '#23262a' }); + } + svgEl(s, 'circle', { cx, cy, r: 1.6, fill: '#0c0d0e' }); + svgEl(s, 'text', { + x: 24, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em', + 'font-family': 'var(--mono)', fill: '#d7dade' + }).textContent = 'MIN'; + svgEl(s, 'text', { + x: 86, y: 66, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.05em', + 'font-family': 'var(--mono)', fill: '#d7dade' + }).textContent = 'MAX'; + svgEl(s, 'text', { + x: cx, y: 84, 'text-anchor': 'middle', 'font-size': 7, 'font-style': 'italic', + 'font-family': 'var(--mono)', fill: '#787c82' + }).textContent = 'dupre.'; + svgEl(s, 'ellipse', { cx: 47, cy: 34, rx: 22, ry: 9, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-16,47,34)' }); + let v = opts.value ?? 50; + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + ptr.setAttribute('transform', `rotate(${-55 + v / 100 * 110},${cx},${cy})`); + onChange(v, v <= 2 ? 'MIN' : v >= 98 ? 'MAX' : Math.round(v) + '%'); + } + dragDelta(s, () => v, set, { min: 0, max: 100 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 52); onChange(value, 'x.x ×100 rpm') fires + on every set — the readout maps 0-100 onto 0-8 ×100 RPM. + handle: el, get() (current value), set(v) — clamped to 0-100, the + needle sweeps -135°..+135°; drag anywhere sets by vertical + delta. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the needle-glow filter registers in the shared defs plate. */ +DUPRE.aviationGauge = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg drag', 110, 104); + const cx = 55, cy = 52, sweep = t => -135 + t * 270; + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + svgEl(s, 'circle', { cx, cy, r: 47, fill: '#1b1917', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 41, fill: '#0a0908', stroke: '#3a352c', 'stroke-width': 1 }); + const zone = (t1, t2, color) => { + const [x1, y1] = polar(cx, cy, 37, sweep(t1)), [x2, y2] = polar(cx, cy, 37, sweep(t2)); + svgEl(s, 'path', { d: `M ${x1} ${y1} A 37 37 0 ${(t2 - t1) > .5 ? 1 : 0} 1 ${x2} ${y2}`, fill: 'none', stroke: color, 'stroke-width': 3, opacity: .85 }); + }; + zone(.15, .6, 'var(--pass)'); zone(.6, .85, 'var(--gold)'); zone(.85, 1, 'var(--fail)'); + for (let i = 0; i <= 40; i++) { + const t = i / 40, major = i % 5 === 0, a = sweep(t); + const [x1, y1] = polar(cx, cy, 40, a), [x2, y2] = polar(cx, cy, major ? 32 : 36, a); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--gold)', 'stroke-width': major ? 1.6 : .8, opacity: major ? 1 : .7 }); + if (major) { + const [nx, ny] = polar(cx, cy, 25, a); + svgEl(s, 'text', { + x: nx, y: ny + 2.4, 'text-anchor': 'middle', 'font-size': 6.5, 'font-family': 'var(--mono)', + fill: 'var(--gold-hi)' + }).textContent = i / 5; + } + } + svgEl(s, 'text', { + x: cx, y: cy + 16, 'text-anchor': 'middle', 'font-size': 5.5, 'letter-spacing': '.14em', + 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .8 + }).textContent = '×100 RPM'; + const needle = svgEl(s, 'g', {}); + svgEl(needle, 'line', { + x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 4, + 'stroke-linecap': 'round', opacity: .5, filter: 'url(#avGlow)' + }); + svgEl(needle, 'line', { + x1: cx, y1: cy + 8, x2: cx, y2: cy - 30, stroke: 'var(--gold-hi)', 'stroke-width': 2.2, + 'stroke-linecap': 'round' + }); + svgEl(s, 'circle', { cx, cy, r: 5, fill: '#1b1917', stroke: '#3a352c' }); + svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 24, rx: 18, ry: 8, fill: 'rgba(255,255,255,.05)', transform: `rotate(-18,${cx - 10},${cy - 24})` }); + let v = opts.value ?? 52; + function set(nv) { + v = Math.max(0, Math.min(100, nv)); + needle.setAttribute('transform', `rotate(${-135 + v / 100 * 270},${cx},${cy})`); + onChange(v, (v / 100 * 8).toFixed(1) + ' ×100 rpm'); + } + dragDelta(s, () => v, set, { min: 0, max: 100 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R13 edgewise strip meter — compressed log scale, the bar rides the edge. + + Contract (everything a consumer needs; no page globals touched): + opts: value (fraction of the window, .08-.94, default .62); onChange( + frac, '−x.x dB') fires on every set — the readout interpolates + the compressed 0..−40 dB scale printed on the face. + handle: el, get() (current fraction), set(f) — clamped to .08-.94 (the + printed scale's span); drag up/down sets by pointer position. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the face gradient is a per-instance defs entry. */ +DUPRE.edgeMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]]; + const s = stageSvg(host, 'rsvg drag', 70, 130); + const wy0 = 12, wy1 = 118; + const faceId = uid('edgeFace'); + const defs = svgEl(s, 'defs', {}); + const pg = svgEl(defs, 'linearGradient', { id: faceId, x1: 0, y1: 0, x2: 1, y2: 0 }); + svgEl(pg, 'stop', { offset: '0', 'stop-color': '#efe6ca' }); + svgEl(pg, 'stop', { offset: '1', 'stop-color': '#d9cfae' }); + svgEl(s, 'rect', { x: 8, y: 6, width: 54, height: 118, rx: 8, fill: '#171412', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: wy1 - wy0, fill: `url(#${faceId})`, stroke: '#0a0908' }); + const yOf = f => wy0 + f * (wy1 - wy0); + for (const [db, f] of SCALE) { + const y = yOf(f); + svgEl(s, 'line', { x1: 19, y1: y, x2: 40, y2: y, stroke: '#3a3128', 'stroke-width': 1 }); + svgEl(s, 'text', { + x: 52, y: y + 2.4, 'text-anchor': 'end', 'font-size': 7, 'font-family': 'var(--mono)', + fill: '#3a3128' + }).textContent = db; + } + for (let i = 0; i < SCALE.length - 1; i++) { + const y = yOf((SCALE[i][1] + SCALE[i + 1][1]) / 2); + svgEl(s, 'line', { x1: 19, y1: y, x2: 32, y2: y, stroke: '#3a3128', 'stroke-width': .6, opacity: .7 }); + } + const bar = svgEl(s, 'polygon', { points: '56,0 24,-1.4 24,1.4', fill: '#1a1613' }); + svgEl(s, 'rect', { x: 14, y: wy0, width: 42, height: 24, fill: 'rgba(255,255,255,.08)' }); + let frac = opts.value ?? .62; + function set(f) { + frac = Math.max(.08, Math.min(.94, f)); + bar.setAttribute('transform', `translate(0,${wy0 + frac * (wy1 - wy0)})`); + let i = 0; while (i < SCALE.length - 2 && SCALE[i + 1][1] < frac) i++; + const [d1, f1] = SCALE[i], [d2, f2] = SCALE[i + 1]; + const db = d1 + (d2 - d1) * Math.max(0, Math.min(1, (frac - f1) / (f2 - f1))); + onChange(frac, '−' + db.toFixed(1) + ' dB'); + } + dragY(s, pct => set(.08 + (100 - pct) / 100 * .86)); + set(frac); + return { el: s, get: () => frac, set }; +}; + +/* R17 round CRT scope — pale phosphor face; the trace is the instrument's own + animation, so it lives here with its reduced-motion gate. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(amp, 'Vpp N') fires on every animation tick, including + the initial paint — the demo waveform is instrument-owned. + handle: el, tick() — one animation step (the reduced-motion path paints + once and never ticks again on its own). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate, except the + face gradient, which reads screen-family vars from this instrument's + subtree and so lives in a local defs. */ +DUPRE.roundCrt = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 110, 104); + const cx = 55, cy = 52; + const faceId = uid('rcrtFace'), clipId = uid('rcrtClip'); + gradDef('sfScrew', 'radialGradient', { cx: '40%', cy: '35%', r: '75%' }, [['0', '#9b968a'], ['1', '#4a463e']]); + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + /* the face gradient reads screen-family vars from this instrument's subtree, + so it must live in the local defs, not the shared def sink */ + const defs = svgEl(s, 'defs', {}); + const ph = svgEl(defs, 'radialGradient', { id: faceId, cx: '50%', cy: '44%', r: '70%' }); + svgEl(ph, 'stop', { offset: '0', 'stop-color': 'var(--crt-face1,#b9d8c0)' }); + svgEl(ph, 'stop', { offset: '1', 'stop-color': 'var(--crt-face2,#8bb296)' }); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy, r: 38 }); + svgEl(s, 'circle', { cx, cy, r: 48, fill: '#141110', stroke: '#060505', 'stroke-width': 2 }); + for (let k = 0; k < 4; k++) { + const [x, y] = polar(cx, cy, 43, 45 + k * 90); + svgEl(s, 'circle', { cx: x, cy: y, r: 2.6, fill: 'url(#sfScrew)', stroke: '#0a0908', 'stroke-width': .5 }); + } + svgEl(s, 'circle', { cx, cy, r: 38, fill: `url(#${faceId})` }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + for (let i = -4; i <= 4; i++) { + svgEl(g, 'line', { x1: cx + i * 9, y1: cy - 38, x2: cx + i * 9, y2: cy + 38, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 }); + svgEl(g, 'line', { x1: cx - 38, y1: cy + i * 9, x2: cx + 38, y2: cy + i * 9, stroke: 'var(--scr-dim,#3d5c46)', 'stroke-width': i === 0 ? .9 : .5, opacity: .8 }); + } + const glow = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: 'var(--crt-glow,#eef7ee)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' }); + const trace = svgEl(g, 'polyline', { points: '', fill: 'none', stroke: '#f4fcf4', 'stroke-width': 1.3 }); + svgEl(s, 'ellipse', { cx: cx - 10, cy: cy - 16, rx: 20, ry: 10, fill: 'rgba(255,255,255,.10)', transform: `rotate(-20,${cx - 10},${cy - 16})` }); + let tph = 0; + function tick() { + tph += 0.12; + const amp = 8 + 3 * Math.sin(tph * 0.35); + let pts = ''; + for (let x = -38; x <= 38; x += 2) { + const y = Math.sin(x * 0.55 + tph * 2.5) * amp; + pts += `${cx + x},${(cy + y).toFixed(1)} `; + } + trace.setAttribute('points', pts.trim()); + glow.setAttribute('points', pts.trim()); + onChange(amp, 'Vpp ' + Math.round(amp * 2 * 4.5)); + } + tick(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 80); + return { el: s, tick }; +}; + +/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag. + + Contract (everything a consumer needs; no page globals touched): + opts: bank (initial degrees, -60..60, default 0); pitch (initial + degrees, -20..20, default 0); onChange({bank, pitch}, 'BANK … + · PITCH …') fires on every set, including the initial paint. + handle: el, get() ({bank, pitch}), set(bank, pitch) — both clamp to + their ranges; the stage itself is a 2D drag surface. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.attitudeIndicator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 130); + const cx = 65, cy = 65; + const clipId = uid('aiClip'); + gradDef('aiSky', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#54677d'], ['1', '#3a4c60']]); + gradDef('aiGnd', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#5c4630'], ['1', '#3c2e1e']]); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy, r: 48 }); + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const ball = svgEl(g, 'g', {}); + svgEl(ball, 'rect', { x: cx - 90, y: cy - 180, width: 180, height: 180, fill: 'url(#aiSky)' }); + svgEl(ball, 'rect', { x: cx - 90, y: cy, width: 180, height: 180, fill: 'url(#aiGnd)' }); + svgEl(ball, 'line', { x1: cx - 90, y1: cy, x2: cx + 90, y2: cy, stroke: '#f3e7c5', 'stroke-width': 1.4 }); + for (const p of [-20, -10, 10, 20]) { + const y = cy - p * 1.6, w = p % 20 === 0 ? 16 : 10; + svgEl(ball, 'line', { x1: cx - w, y1: y, x2: cx + w, y2: y, stroke: '#f3e7c5', 'stroke-width': .8, opacity: .85 }); + svgEl(ball, 'text', { + x: cx + w + 3, y: y + 2, 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#f3e7c5', opacity: .85 + }).textContent = String(Math.abs(p)); + } + /* fixed bank scale + pointer + miniature aircraft */ + for (const b of [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60]) { + const [x1, y1] = polar(cx, cy, 47, b), [x2, y2] = polar(cx, cy, b % 30 === 0 ? 42 : 44, b); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': b === 0 ? 1.6 : .9, opacity: .9 }); + } + svgEl(s, 'polygon', { points: `${cx - 3.5},${cy - 38} ${cx + 3.5},${cy - 38} ${cx},${cy - 44}`, fill: 'var(--gold-hi)' }); + svgEl(s, 'path', { + d: `M ${cx - 22} ${cy} L ${cx - 7} ${cy} L ${cx - 4} ${cy + 4} M ${cx + 22} ${cy} L ${cx + 7} ${cy} L ${cx + 4} ${cy + 4}`, + fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' + }); + svgEl(s, 'circle', { cx, cy, r: 2, fill: 'var(--gold-hi)' }); + let bank = 0, pitch = 0; + function set(b, p) { + bank = Math.max(-60, Math.min(60, b)); pitch = Math.max(-20, Math.min(20, p)); + ball.setAttribute('transform', `rotate(${(-bank).toFixed(1)},${cx},${cy}) translate(0,${(pitch * 1.6).toFixed(1)})`); + onChange({ bank, pitch }, 'BANK ' + (bank < 0 ? 'L' : bank > 0 ? 'R' : '') + Math.abs(Math.round(bank)) + + ' · PITCH ' + (pitch >= 0 ? '+' : '-') + String(Math.abs(Math.round(pitch))).padStart(2, '0')); + } + s.style.touchAction = 'none'; s.style.cursor = 'move'; + s.addEventListener('pointerdown', e => { + s.setPointerCapture(e.pointerId); + const x0 = e.clientX, y0 = e.clientY, b0 = bank, p0 = pitch; + const mv = ev => set(b0 + (ev.clientX - x0) * 0.4, p0 + (ev.clientY - y0) * 0.25); + const up = () => { s.removeEventListener('pointermove', mv); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); }; + s.addEventListener('pointermove', mv); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); e.preventDefault(); + }); + set(opts.bank ?? 0, opts.pitch ?? 0); + return { el: s, get: () => ({ bank, pitch }), set }; +}; + +/* R44 heading bug + servo needle — drag parks the command; the needle chases + with honest servo lag (instrument-owned animation, reduced-motion gated). + + Contract (everything a consumer needs; no page globals touched): + opts: value (initial commanded heading in degrees, default 90 — + normalized to 0..360); onChange({cmd, act}, 'CMD … · ACT …') + fires on every set and on every servo step. + handle: el, get() ({cmd, act}), set(v) — v normalizes to 0..360; + dragging the stage moves the command bug, the needle chases. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.headingBug = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 130); + const cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 }); + for (let d = 0; d < 360; d += 10) { + const maj = d % 30 === 0; + const [x1, y1] = polar(cx, cy, 48, d), [x2, y2] = polar(cx, cy, maj ? 42 : 45, d); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': maj ? 1 : .5, opacity: maj ? .9 : .5 }); + } + for (let d = 0; d < 360; d += 30) { + const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : String(d / 10); + const [x, y] = polar(cx, cy, 35, d); + svgEl(s, 'text', { + x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': d % 90 === 0 ? 8 : 6.4, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: d % 90 === 0 ? 'var(--cream)' : 'var(--steel)' + }).textContent = lbl; + } + svgEl(s, 'line', { x1: cx, y1: cy - 56, x2: cx, y2: cy - 48, stroke: 'var(--fail)', 'stroke-width': 2 }); + const bug = svgEl(s, 'path', { + d: `M ${cx - 6} ${cy - 49} L ${cx - 6} ${cy - 44} L ${cx - 2.5} ${cy - 44} L ${cx} ${cy - 47} L ${cx + 2.5} ${cy - 44} L ${cx + 6} ${cy - 44} L ${cx + 6} ${cy - 49} Z`, + fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': .7 + }); + const needle = svgEl(s, 'g', {}); + svgEl(needle, 'polygon', { + points: `${cx - 2},${cy + 10} ${cx + 2},${cy + 10} ${cx + 1.2},${cy - 40} ${cx},${cy - 44} ${cx - 1.2},${cy - 40}`, + fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6 + }); + svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let cmd = ((opts.value ?? 90) % 360 + 360) % 360, act = cmd; + const draw = () => { + bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`); + needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`); + onChange({ cmd, act }, 'CMD ' + String(Math.round((cmd % 360 + 360) % 360)).padStart(3, '0') + + ' · ACT ' + String(Math.round((act % 360 + 360) % 360)).padStart(3, '0')); + }; + function set(v) { cmd = (v % 360 + 360) % 360; draw(); } + dragDelta(s, () => cmd, set, { min: -100000, max: 100000, sens: 1.2 }); + draw(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { + const diff = ((cmd - act + 540) % 360) - 180; + if (Math.abs(diff) < 0.4) { if (act !== cmd) { act = cmd; draw(); } return; } + act = (act + diff * 0.07 + 360) % 360; draw(); + }, 80); + return { el: s, get: () => ({ cmd, act }), set }; +}; + +/* R53 circular chart recorder — a day per revolution; the pen draws the cycle + (instrument-owned clock, reduced-motion paints the full day once); click for fresh paper. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(hour, 'HH:MM · N%') fires on every pen step — the demo + day-cycle signal is instrument-owned. + handle: el, get() (current hour, 0..24), reset() — fresh paper, pen to + 00:00; clicking the stage does the same. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.chartRecorder = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 130); + const cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 54, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': .8 }); + for (const r of [20, 31, 42]) + svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: '#b8ae98', 'stroke-width': .5 }); + for (let h = 0; h < 24; h++) { + const a = h * 15; + const [x1, y1] = polar(cx, cy, 54, a), [x2, y2] = polar(cx, cy, h % 6 === 0 ? 12 : 48, a); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#b8ae98', 'stroke-width': h % 6 === 0 ? .6 : .35, opacity: .8 }); + if (h % 6 === 0) { + const [tx, ty] = polar(cx, cy, 50, a + 5); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#6e6552' + }).textContent = String(h).padStart(2, '0'); + } + } + const trace = svgEl(s, 'path', { d: '', fill: 'none', stroke: '#8f2416', 'stroke-width': 1.1, 'stroke-linejoin': 'round' }); + const pen = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 54, stroke: '#4a463e', 'stroke-width': 1 }); + const penDot = svgEl(s, 'circle', { cx, cy: cy - 30, r: 2, fill: '#8f2416' }); + svgEl(s, 'circle', { cx, cy, r: 4, fill: '#4a463e' }); + const val = h => 52 + 30 * Math.sin(h / 24 * 2 * Math.PI - 2.1) + 12 * Math.sin(h / 24 * 6 * Math.PI) + 4 * Math.sin(h * 2.7); + const rOf = v => 12 + Math.max(0, Math.min(100, v)) * 0.36; + let pts = [], hour = 0; + const draw = () => { + const a = hour * 15, r = rOf(val(hour)); + pts.push(polar(cx, cy, r, a)); + if (pts.length > 1) trace.setAttribute('d', 'M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L ')); + pen.setAttribute('transform', `rotate(${a},${cx},${cy})`); + const [px, py] = polar(cx, cy, r, a); + penDot.setAttribute('cx', px); penDot.setAttribute('cy', py); + onChange(hour, String(Math.floor(hour)).padStart(2, '0') + ':' + (hour % 1 >= 0.5 ? '30' : '00') + ' · ' + Math.round(val(hour)) + '%'); + }; + const reset = () => { pts = []; hour = 0; trace.setAttribute('d', ''); draw(); }; + s.style.cursor = 'pointer'; + s.addEventListener('click', reset); + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (hour = 0; hour < 24; hour += 0.25) draw(); + hour = 23.75; + } else { + reset(); + setInterval(() => { hour += 0.25; if (hour >= 24) { pts = []; hour = 0; } draw(); }, 375); + } + return { el: s, get: () => hour, reset }; +}; + +/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive. + + Contract (everything a consumer needs; no page globals touched): + opts: value (initial units, 5..35 on the printed RPM x100 scale, + default 24); onChange(value, 'RPM N') fires on every set, + including the initial paint. + handle: el, get() (units), set(v) — v clamps 5..35; the stage drags + vertically. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.verticalTape = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 70, 130); + const CX = 38, CY = 65; + const MIN = 5, MAX = 35, PPU = 5.5; /* rpm x100, px per unit */ + const clipId = uid('vtClip'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4 }); + svgEl(s, 'rect', { x: 2, y: 2, width: 66, height: 126, rx: 7, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4, fill: '#0d0c0a', stroke: '#2c2820', 'stroke-width': 1 }); + const tapeG = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const tape = svgEl(tapeG, 'g', {}); + for (let u = MIN; u <= MAX; u++) { + const y = -u * PPU; /* higher value further up the tape */ + const maj = u % 5 === 0; + svgEl(tape, 'line', { x1: maj ? 20 : 26, y1: y, x2: 34, y2: y, stroke: 'var(--cream)', 'stroke-width': maj ? 1.1 : .55, opacity: maj ? .95 : .6 }); + if (maj) svgEl(tape, 'text', { + x: 48, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: 'var(--cream)' + }).textContent = String(u); + } + /* fixed index: side pointer + line across the window at center height */ + svgEl(s, 'polygon', { points: `8,${CY} 15,${CY - 4} 15,${CY + 4}`, fill: 'var(--gold-hi)' }); + svgEl(s, 'line', { x1: 15, y1: CY, x2: 62, y2: CY, stroke: 'var(--gold-hi)', 'stroke-width': 1, opacity: .85 }); + svgEl(s, 'text', { + x: CX, y: 126, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.1em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'RPM x100'; + let v = opts.value ?? 24; + function set(nv) { + v = Math.max(MIN, Math.min(MAX, nv)); + tape.setAttribute('transform', `translate(0,${(CY + v * PPU).toFixed(1)})`); + onChange(v, 'RPM ' + Math.round(v * 100)); + } + s.style.cursor = 'ns-resize'; + dragDelta(s, () => v, set, { min: MIN, max: MAX, sens: (MAX - MIN) / 140 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles. + + Contract (everything a consumer needs; no page globals touched): + opts: fuel (initial, 0..4, default 2.4); oil (initial, 0..4, default + 3.1); onChange([fuel, oil], 'FUEL n.n · OIL n.n') fires on every + set, including the initial paint. + handle: el, get() ([fuel, oil]), set(fuel, oil) — both clamp 0..4; + each half of the stage drags its own needle vertically. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.twinNeedle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 120, 110); + const cx = 60, cy = 62, R = 44; + svgEl(s, 'circle', { cx, cy, r: 52, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 46, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 }); + /* mirrored arcs: left = FUEL (0 at bottom-left up to 4 at top), right = OIL */ + const angL = v => -170 + v / 4 * 160; /* 0..4 -> -170..-10 (left side, cw from top) */ + const angR = v => 170 - v / 4 * 160; /* 0..4 -> 170..10 (right side) */ + for (let v = 0; v <= 4; v++) { + for (const ang of [angL(v), angR(v)]) { + const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 5, ang); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': 1, opacity: .9 }); + const [tx, ty] = polar(cx, cy, R - 11, ang); + svgEl(s, 'text', { + x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)', + fill: 'var(--cream)' + }).textContent = String(v); + } + } + for (let v = 0.5; v < 4; v += 0.5) { + if (v % 1 === 0) continue; + for (const ang of [angL(v), angR(v)]) { + const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 3, ang); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .5, opacity: .55 }); + } + } + svgEl(s, 'text', { + x: cx - 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'FUEL'; + svgEl(s, 'text', { + x: cx + 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'OIL'; + svgEl(s, 'text', { + x: cx, y: cy - 26, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.06em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'kg/cm2'; + const needle = color => { + const g = svgEl(s, 'g', {}); + svgEl(g, 'polygon', { + points: `${cx - 1.6},${cy + 8} ${cx + 1.6},${cy + 8} ${cx + 1},${cy - R + 8} ${cx},${cy - R + 4} ${cx - 1},${cy - R + 8}`, + fill: color, stroke: 'rgba(0,0,0,.4)', 'stroke-width': .5 + }); return g; + }; + const nF = needle('#e0523a'), nO = needle('#bfc4d0'); + svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let vF, vO; + const draw = () => { + nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`); + nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`); + onChange([vF, vO], 'FUEL ' + vF.toFixed(1) + ' · OIL ' + vO.toFixed(1)); + }; + function set(f, o) { vF = Math.max(0, Math.min(4, f)); vO = Math.max(0, Math.min(4, o)); draw(); } + /* each half is its own drag surface */ + const half = (x, get, setV) => { + const hit = svgEl(s, 'rect', { x, y: 10, width: 60, height: 104, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, get, v => { setV(Math.max(0, Math.min(4, v))); draw(); }, { min: 0, max: 4, sens: 4 / 110 }); + }; + half(0, () => vF, v => vF = v); + half(60, () => vO, v => vO = v); + set(opts.fuel ?? 2.4, opts.oil ?? 3.1); + return { el: s, get: () => [vF, vO], set }; +}; + +/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts. + + Contract (everything a consumer needs; no page globals touched): + opts: temp (initial °F, 40..100, default 72); humidity (initial %, + 0..100, default 45); onChange([temp, humidity], 'NF · N% RH · + VERDICT') fires on every set, including the initial paint. + handle: el, get() ([temp, humidity]), set(temp, humidity) — both clamp + to their scales; the left half of the stage drags temp, the + right half humidity. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.comfortMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 122); + const cx = 65, cy = 60; + const clipId = uid('cmClip'); + gradDef('cmBrass', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8b25c'], ['.5', '#a07c30'], ['1', '#6e5218']]); + const defs = svgEl(s, 'defs', {}); + svgEl(s, 'circle', { cx, cy, r: 56, fill: 'url(#cmBrass)', stroke: '#4a3610', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 53, fill: 'none', stroke: '#7a5c1e', 'stroke-width': 2.5, 'stroke-dasharray': '2 1.4' }); + svgEl(s, 'circle', { cx, cy, r: 49, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': 1 }); + const PT = [38, 96], PH = [92, 96]; /* temp pivot (BL), humidity pivot (BR) */ + const angT = t => 105 - (t - 40) / 60 * 70; /* 40..100F -> bearing 105..35 from PT */ + const angH = h => -105 + h / 100 * 70; /* 0..100% -> bearing -105..-35 from PH */ + /* scale ticks along each sweep, near the rim */ + for (let t = 40; t <= 100; t += 10) { + const a = angT(t); + const [x1, y1] = polar(PT[0], PT[1], 86, a), [x2, y2] = polar(PT[0], PT[1], 82, a); + if (Math.hypot(x1 - cx, y1 - cy) < 48) { + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': t % 20 === 0 ? 1 : .5 }); + if (t % 20 === 0) { + const [tx, ty] = polar(PT[0], PT[1], 78, a); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#3c382f' + }).textContent = String(t); + } + } + } + for (let h = 0; h <= 100; h += 10) { + const a = angH(h); + const [x1, y1] = polar(PH[0], PH[1], 86, a), [x2, y2] = polar(PH[0], PH[1], 82, a); + if (Math.hypot(x1 - cx, y1 - cy) < 48) { + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': h % 20 === 0 ? 1 : .5 }); + if (h % 20 === 0) { + const [tx, ty] = polar(PH[0], PH[1], 78, a); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#3c382f' + }).textContent = String(h); + } + } + } + /* printed zone verdicts; the active one goes red */ + const zones = {}; + const zone = (key, txt, x, y, rot, size) => { + zones[key] = svgEl(s, 'text', { + x, y, 'text-anchor': 'middle', + 'font-size': size || 5.2, 'font-weight': 700, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', + fill: '#8f897b', transform: `rotate(${rot},${x},${y})` + }); zones[key].textContent = txt; + }; + zone('humid', 'TOO HUMID', 42, 34, -32); + zone('warm', 'TOO WARM', 89, 34, 32); + zone('right', 'JUST RIGHT', 65, 58, 0, 6.2); + zone('dry', 'TOO DRY', 38, 74, 28); + zone('cold', 'TOO COLD', 93, 74, -28); + svgEl(s, 'text', { + x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.6, 'letter-spacing': '.18em', + 'font-family': 'var(--mono)', fill: '#3c382f' + }).textContent = 'WEATHER STATION'; + /* needles: temp from bottom-left, humidity from bottom-right — clipped to the face */ + const nclip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(nclip, 'circle', { cx, cy, r: 49 }); + const ng = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const needle = () => { + const g = svgEl(ng, 'g', {}); + svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: -84, stroke: '#c23a28', 'stroke-width': 1.6, 'stroke-linecap': 'round' }); + svgEl(g, 'circle', { cx: 0, cy: 0, r: 2.6, fill: '#8f2416' }); return g; + }; + const nT = needle(), nH = needle(); + nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`); + nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`); + let vT, vH; + const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right'; + const draw = () => { + nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`); + nH.setAttribute('transform', `translate(${PH[0]},${PH[1]}) rotate(${angH(vH).toFixed(1)})`); + const v = verdict(); + for (const k of Object.keys(zones)) zones[k].setAttribute('fill', k === v ? '#c23a28' : '#8f897b'); + onChange([vT, vH], Math.round(vT) + 'F · ' + Math.round(vH) + '% RH · ' + zones[v].textContent); + }; + function set(t, h) { vT = Math.max(40, Math.min(100, t)); vH = Math.max(0, Math.min(100, h)); draw(); } + const half = (x, get, setV, min, max) => { + const hit = svgEl(s, 'rect', { x, y: 8, width: 65, height: 106, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, get, v => { setV(Math.max(min, Math.min(max, v))); draw(); }, { min, max, sens: (max - min) / 110 }); + }; + half(0, () => vT, v => vT = v, 40, 100); + half(65, () => vH, v => vH = v, 0, 100); + set(opts.temp ?? 72, opts.humidity ?? 45); + return { el: s, get: () => [vT, vH], set }; +}; + +/* ================= indicators & readouts ================= */ + +/* 18 status lamps — one lamp per health state; click any lamp to cycle it. + + Contract (everything a consumer needs; no page globals touched): + opts: states (array of state indices, default [0,1,2,3,4] — one lamp + per entry; 0 ok · 1 engaged · 2 fault · 3 off · 4 busy); + onChange(states array copy, state name) fires per change and once + at build with a count summary. + handle: el, get() (states copy), set(i, st) — st wraps modulo 5. + CSS lives in the "shared primitives" .dupre-lamp block of DUPRE_CSS + (shared with the output well's step lamps); no other styles involved. */ +DUPRE.statusLamps = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const CLS = ['dupre-lamp', 'dupre-lamp dupre-gold', 'dupre-lamp dupre-red', 'dupre-lamp dupre-off', 'dupre-lamp dupre-busy']; + const NAMES = ['ok', 'engaged', 'fault', 'off', 'busy']; + const states = (opts.states || [0, 1, 2, 3, 4]).slice(); + const wrap = document.createElement('span'); + wrap.style.cssText = 'display:inline-flex;gap:9px;align-items:center'; + const lamps = states.map((st, i) => { + const l = document.createElement('span'); l.className = CLS[st]; l.style.cursor = 'pointer'; + l.addEventListener('click', () => set(i, states[i] + 1)); + wrap.appendChild(l); return l; + }); + host.appendChild(wrap); + function set(i, st) { + states[i] = ((st % CLS.length) + CLS.length) % CLS.length; + lamps[i].className = CLS[states[i]]; + onChange(states.slice(), NAMES[states[i]]); + } + onChange(states.slice(), states.length === 5 ? 'five states' : states.length + ' states'); + return { el: wrap, get: () => states.slice(), set }; +}; + +/* 19 badges — labelled flags; click a badge to cycle its variant. + + Contract (everything a consumer needs; no page globals touched): + opts: items (array of [label, variant] pairs, variant 0 gold · 1 red · + 2 ghost, default a three-badge demo set); onChange(variants array + copy, label) fires per change and once at build with a count + summary. + handle: el, get() (variants copy), set(i, v) — v wraps modulo 3. + CSS lives in the .dupre-badge block of DUPRE_CSS; no other styles + involved. */ +DUPRE.badges = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const CLS = ['dupre-badge', 'dupre-badge dupre-red', 'dupre-badge dupre-ghost']; + const norm = v => (((v | 0) % CLS.length) + CLS.length) % CLS.length; + const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]]; + const vars = items.map(it => norm(it[1])); + const wrap = document.createElement('span'); + const els = items.map(([txt], i) => { + const b = document.createElement('span'); b.className = CLS[vars[i]]; b.textContent = txt; b.style.cursor = 'pointer'; + b.addEventListener('click', () => set(i, vars[i] + 1)); + wrap.appendChild(b); + if (i < items.length - 1) wrap.appendChild(document.createTextNode(' ')); + return b; + }); + host.appendChild(wrap); + function set(i, v) { + vars[i] = norm(v); + els[i].className = CLS[vars[i]]; + onChange(vars.slice(), items[i][0]); + } + onChange(vars.slice(), items.length === 3 ? 'three variants' : items.length + ' variants'); + return { el: wrap, get: () => vars.slice(), set }; +}; + +/* 20 tabular readout — mm:ss countdown; builder owns the state, the page + drives tick() on its own clock (tick contract); click pauses/resumes. + + Contract (everything a consumer needs; no page globals touched): + opts: secs (initial seconds, wraps modulo 3600, default 24:10); run + (initial running state, default true); unit (caption under the + digits, default 'timer'); onChange(secs, 'running'/'paused') + fires on every set, including the initial paint. + handle: el, get() (secs), set(v) — v wraps modulo 3600; tick() — one + second down when running (the page's clock drives it); clicking + the digits pauses/resumes. + CSS lives in the .dupre-readout block of DUPRE_CSS; no other styles + involved. */ +DUPRE.tabularReadout = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const wrap = document.createElement('div'); wrap.style.textAlign = 'center'; + wrap.innerHTML = '<div class="dupre-readout"></div><span class="dupre-unit"></span>'; + const out = wrap.querySelector('.dupre-readout'); + wrap.querySelector('.dupre-unit').textContent = opts.unit || 'timer'; + host.appendChild(wrap); + let secs, run = opts.run !== undefined ? !!opts.run : true; + const draw = () => { + out.textContent = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0'); + onChange(secs, run ? 'running' : 'paused'); + }; + const set = v => { secs = ((v % 3600) + 3600) % 3600; draw(); }; + out.addEventListener('click', () => { run = !run; draw(); }); + set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10); + return { el: wrap, get: () => secs, set, tick: () => { if (run) set(secs - 1); } }; +}; + +/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9. + + Contract (everything a consumer needs; no page globals touched): + opts: label (caption text, default 'outputs'); count (initial count, + default 3); onChange(count, 'count N') fires on every set, + including the initial paint. + handle: el, get() (count), set(v); clicking the label cycles the count + 1..9. + CSS lives in the .dupre-engrave block of DUPRE_CSS; no other styles + involved. */ +DUPRE.engravedLabel = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const e = document.createElement('span'); e.className = 'dupre-engrave'; + e.append(opts.label || 'outputs'); + const c = document.createElement('span'); c.className = 'dupre-cnt'; e.appendChild(c); + host.appendChild(e); + let n; + const set = v => { n = v; c.textContent = '· ' + n; onChange(n, 'count ' + n); }; + e.addEventListener('click', () => set(n % 9 + 1)); + set(opts.count !== undefined ? opts.count : 3); + return { el: e, get: () => n, set }; +}; + +/* 22 output well — streaming step log, lamp per step; click streams the next + demo step; push([lampCls, name, evidence]) appends programmatically */ +/* 22 output well — a scrolling log of status steps; click streams the next demo + step, oldest rows scroll off once the well is full. + + Contract (everything a consumer needs; no page globals touched): + opts: seed (initial rows, default two); steps (the click-cycle demo pool); + keep (max visible rows before the oldest scrolls off, default 5). + Each row is [tone, label, detail]; tone is '' | 'gold' | 'red' and + lights the row's dupre-lamp accent. onChange(row|null, caption) fires + on every push and once at build with a hint. + handle: el, push(row) — appends a row and trims the well to keep. Click + advances through steps and pushes the next. + CSS lives in the "output well" block of DUPRE_CSS; the row lamp is the shared + .dupre-lamp primitive tinted by the tone accent. */ +DUPRE.outputWell = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const seed = opts.seed || [['', 'Link', 'wlp170s0 · @Hyatt'], ['gold', 'DNS', 'resolving…']]; + const steps = opts.steps || [['gold', 'Probe', '8.8.8.8 …'], ['', 'Gateway', '10.0.0.1 ok'], ['', 'DNS', '1.1.1.1 ok'], ['red', 'Retry', 'timeout']]; + const keep = opts.keep || 5; + const w = document.createElement('div'); w.className = 'dupre-owell'; host.appendChild(w); + const add = s => { + const d = document.createElement('div'); d.className = 'dupre-ostep'; + d.innerHTML = `<span class="dupre-lamp${s[0] ? ' dupre-' + s[0] : ''}"></span><span><b>${s[1]}</b><span class="dupre-ev">${s[2]}</span></span>`; + w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild); + }; + seed.forEach(add); + const push = s => { add(s); onChange(s, '+ ' + s[1]); }; + let i = 0; + w.addEventListener('click', () => { i = (i + 1) % steps.length; push(steps[i]); }); + onChange(null, 'click to stream'); + return { el: w, push }; +}; + +/* 23 toast — one-line transient confirmation; click fires the next demo + message; fire(msg) shows any message with the fade-in. + + Contract (everything a consumer needs; no page globals touched): + opts: msgs (the demo message pool, cycled by click); text (initial + message, default the last pool entry); onChange(n, 'fired n') fires + on each click and once at build with a hint. + handle: el, fire(msg) — shows any message with the opacity fade-in; the + page can drive it with its own text. Click cycles the demo pool. + CSS lives in the "toast" block of DUPRE_CSS; no other styles involved. */ +DUPRE.toast = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const msgs = opts.msgs || ['link up · 300 Mbps', 'bt paired — WH-1000XM4', 'profile switched', 'joined @Hyatt_WiFi — saved']; + const t = document.createElement('span'); t.className = 'dupre-toastw'; + t.textContent = opts.text || msgs[msgs.length - 1]; + host.appendChild(t); + const fire = msg => { + t.textContent = msg; t.style.opacity = '0'; + requestAnimationFrame(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '1'; }); + }; + let n = 0; + t.addEventListener('click', () => { n++; fire(msgs[n % msgs.length]); onChange(n, 'fired ' + n); }); + onChange(0, 'click to fire'); + return { el: t, fire }; +}; + +/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments. + + Contract (everything a consumer needs; no page globals touched): + opts: digits (tube count, default 2); value (initial, default 8); + onChange(value, zero-padded string) fires on every set. + handle: el, get() (current value), set(v) — wraps modulo 10^digits; + leading-zero tubes go dark rather than showing 0, like the + hardware. + CSS lives in the "nixie tube" block of DUPRE_CSS; no other styles involved. */ +DUPRE.nixie = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const digits = opts.digits || 2; + const max = Math.pow(10, digits); + const nx = document.createElement('span'); nx.className = 'dupre-nixie'; + for (let i = 0; i < digits; i++) { + const tu = document.createElement('span'); tu.className = 'dupre-tube'; tu.innerHTML = '<b></b>'; nx.appendChild(tu); + } + host.appendChild(nx); + let v; + const set = x => { + v = ((x % max) + max) % max; + const s = String(v).padStart(digits, '0'); + [...nx.children].forEach((tu, i) => { + tu.classList.toggle('dupre-off', i < digits - 1 && v < Math.pow(10, digits - 1 - i)); + tu.querySelector('b').textContent = s[i]; + }); + onChange(v, s); + }; + nx.addEventListener('click', () => set(v + 1)); + set(opts.value !== undefined ? opts.value : 8); + return { el: nx, get: () => v, set }; +}; + +/* N20 split-flap — an honest Solari mechanism. Each cell owns a stack of + flaps in `chars` order (data, like R58's LAYOUT) and can only advance one + flap at a time, so a changed reading cascades through intermediates and + cells arrive staggered by travel distance. setText() mid-cascade re-aims + the running cells; animate:false (the page's reduced-motion gate) + collapses every move to an instant jump. One flip is two half-panel + animations: the current top falls (rotateX 0 -> -180) while the next + bottom lands (180 -> 0), backfaces hidden, crease preserved. First paint + is silent (no cascade on load), like the original. The page drives next() + on its own cadence. + + Contract (everything a consumer needs; no page globals touched): + opts: rows (1) x cells (4) grid; chars (flap order, default space+A-Z+0-9); + flapMs (100, per-cell rate jittered 0.8x-1.35x so letters finish + at different times, base mutable via setFlapMs); animate (true); + skin ('paper'|'white'|'dark'|'light', card default paper); font ('helv'|'mono', card default helv); + words (the demo pool, at least rows+1 entries: next() sends every + row to a different random word, mutually distinct; set(i) pins the + top row for determinism); onChange(idx|-1, top word) fires at + command time. get() is the last set() index, -1 after a scramble. + handle: el, get(), set(i), next(), setText(multi-line string), + reading() (displayed grid, rows joined with newline), + setStyle(axis, name), chars, flapMs()/setFlapMs(ms) (base flap + rate, clamped 20-400), onSettle(cb) — cb(reading) fires once per + command when every cell has arrived (dwell hook). + CSS lives in the "split-flap" block of DUPRE_CSS; no other styles involved. */ +DUPRE.splitFlap = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN']; + const animate = opts.animate !== undefined ? !!opts.animate : true; + const rows = opts.rows || 1; + const cells = opts.cells || 4; + const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const clampMs = ms => Math.min(400, Math.max(20, ms | 0)); + let flapMs = clampMs(opts.flapMs || 100); + + const ci = ch => Math.max(0, chars.indexOf(ch)); + const fit = (str, w) => String(str).padEnd(w, ' ').slice(0, w); + const wordAt = k => words[((k % words.length) + words.length) % words.length]; + + // Four stacked half-panels per cell, painting order = stacking order: + // top-next (revealed as the current top falls), bottom-current (covered as + // the next bottom lands), top-current (the falling flap), bottom-next (the + // landing flap, resting folded away at rotateX(180) with its back hidden). + const st = []; // row-major cell states + const settleCbs = []; if (opts.onSettle) settleCbs.push(opts.onSettle); + let announced = true; // no announcement for the silent first paint + let idx = 0; // last set() index; -1 after a scramble + let lastGrid = ''; // the last commanded grid — the demo picks against + // commands, not in-flight glyphs mid-cascade + const allSettled = () => st.every(S => !S.running && S.cur === S.target); + const fitGrid = str => Array.from({ length: rows }, + (_, r) => fit(String(str).split('\n')[r] || '', cells)).join('\n'); + const reading = () => Array.from({ length: rows }, + (_, r) => st.slice(r * cells, (r + 1) * cells).map(S => chars[S.cur]).join('')).join('\n'); + const makeCell = () => { + const d = document.createElement('span'); d.className = 'flapd'; + d.innerHTML = '<b class="fh ftn"></b><b class="fh fbc"></b><b class="fh ftc"></b><b class="fh fbn"></b>'; + st.push({ + cur: 0, target: 0, running: false, + // Each cell's mechanism has its own character: a fixed per-cell jitter + // (0.8x-1.35x of the base rate) so letters finish at genuinely + // different times, beyond what travel distance alone staggers. + jitter: 0.8 + 0.55 * Math.random(), + ftn: d.querySelector('.ftn'), fbc: d.querySelector('.fbc'), + ftc: d.querySelector('.ftc'), fbn: d.querySelector('.fbn'), + }); + return d; + }; + const f = document.createElement('span'); f.className = 'flap' + (rows > 1 ? ' flap-rows' : ''); + for (let r = 0; r < rows; r++) { + const line = rows > 1 ? document.createElement('span') : f; + if (rows > 1) { line.className = 'flapline'; f.appendChild(line); } + for (let c = 0; c < cells; c++) line.appendChild(makeCell()); + } + host.appendChild(f); + + // One cell's cascade: flip one place through the charset until the (live) + // target is reached. A retarget while running just moves the goalpost. + const run = S => { + if (S.running) return; + S.running = true; + (async () => { + while (S.cur !== S.target) { + const nk = (S.cur + 1) % chars.length; + S.ftn.textContent = chars[nk]; S.fbn.textContent = chars[nk]; + if (animate) { + try { + const flip = { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' }; + await Promise.all([ + S.ftc.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }], flip).finished, + S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }], flip).finished, + ]); + } catch { break; /* cancelled (card torn down mid-flip) */ } + } + S.ftc.textContent = S.fbc.textContent = chars[nk]; + S.cur = nk; + } + S.running = false; + maybeSettle(); + })(); + }; + // Announce once per command, when the whole board has arrived. Consumers + // use it to dwell on the settled reading before commanding the next update. + const maybeSettle = () => { + if (announced || !allSettled()) return; + announced = true; + const r = reading(); + settleCbs.forEach(cb => cb(r)); + }; + + // Map a multi-line string onto the grid; missing lines/cells pad with space. + const gridTargets = str => { + const lines = String(str).split('\n'); + return st.map((S, i) => ci(fit(lines[(i / cells) | 0] || '', cells)[i % cells])); + }; + const setText = str => { + announced = false; + lastGrid = fitGrid(str); + // Assign every target before running any cell: a cell that finishes + // synchronously (animate:false) must not see its neighbours' stale, + // already-satisfied targets and announce a partial board as settled. + gridTargets(str).forEach((k, i) => { st[i].target = k; }); + st.forEach(run); + maybeSettle(); // covers a command that changes nothing + }; + const silent = str => { + lastGrid = fitGrid(str); + gridTargets(str).forEach((k, i) => { + const S = st[i]; S.cur = S.target = k; + S.ftn.textContent = S.fbc.textContent = S.ftc.textContent = S.fbn.textContent = chars[k]; + }); + }; + + // The words demo: every advance sends each row to a completely different + // word, drawn at random from the pool; rows stay mutually distinct. set(i) + // pins the top row (deterministic callers, tests); next() scrambles all. + const rowWords = () => lastGrid.split('\n'); + const pickNew = taken => { + const open = words.filter(w => !taken.includes(fit(w, cells))); + return fit(open[(Math.random() * open.length) | 0] || wordAt(0), cells); + }; + const gridFor = top => { + const out = []; + const prev = rowWords(); + for (let r = 0; r < rows; r++) + out.push(r === 0 && top !== null ? fit(top, cells) : pickNew(out.concat(prev[r]))); + return out.join('\n'); + }; + const set = i => { idx = ((i % words.length) + words.length) % words.length; setText(gridFor(wordAt(idx))); onChange(idx, wordAt(idx).trim()); }; + const scramble = () => { idx = -1; const g = gridFor(null); setText(g); onChange(-1, g.split('\n')[0].trim()); }; + f.addEventListener('click', scramble); + + // construction axes (skin, font): swap this axis's classes per STYLES. + // Polarity and typeface are construction, not accent — no colour policy. + const setStyle = (axis, name) => { + const ax = DUPRE.splitFlap.STYLES[axis]; const o = ax && ax[name]; + if (!o) return; + Object.values(ax).forEach(v => { if (v.cls) f.classList.remove(v.cls); }); + if (o.cls) f.classList.add(o.cls); + }; + if (opts.skin) setStyle('skin', opts.skin); + if (opts.font) setStyle('font', opts.font); + + // first paint: consecutive pool words, silent (no cascade on load) + silent(Array.from({ length: rows }, (_, r) => fit(wordAt(r), cells)).join('\n')); + return { + el: f, get: () => idx, set, next: scramble, setStyle, + setText, chars, reading, onSettle: cb => settleCbs.push(cb), + flapMs: () => flapMs, setFlapMs: ms => { flapMs = clampMs(ms); }, + }; +}; +DUPRE.splitFlap.STYLES = { + // board polarity + ink: dark board with the kit's cream ink (default), + // dark board with true-white ink, or the inverse ivory board + // dot: the board colour; the gallery card letters each chip in the skin's + // ink so the swatch is a miniature flap cell, not an ambiguous colour dot + skin: { + paper: { cls: 'flap-paper', dot: '#ffffff' }, + white: { cls: 'flap-white', dot: '#141210' }, + dark: { cls: '', dot: '#141210' }, + light: { cls: 'flap-light', dot: '#f3e7c5' }, + }, + // typeface: the kit's Berkeley Mono, or the grotesque real boards wore + font: { + helv: { cls: 'flap-helv', dot: '#0d0c0b' }, + mono: { cls: '', dot: '#0d0c0b' }, + }, +}; + +/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick(); + click adds a minute. + + Contract (everything a consumer needs; no page globals touched): + opts: secs (initial seconds, default 24:10); onChange(secs, 'mm:ss') + fires on every set. + handle: el, get() (current seconds), set(v) — wraps modulo 3600; + tick() steps one second down (the tick contract — the page owns + the clock); click adds 60. + CSS lives in the "seven-segment" block of DUPRE_CSS; the digit glyphs come + from the shared seg7() engine helper (.seg7, shared with the DSKY). */ +DUPRE.sevenSeg = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const sv = document.createElement('span'); sv.className = 'dupre-seven'; host.appendChild(sv); + let secs; + const set = v => { + secs = ((v % 3600) + 3600) % 3600; + const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0'); + sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '<span class="dupre-colon"><i></i><i></i></span>' + seg7(ss[0]) + seg7(ss[1]); + onChange(secs, mm + ':' + ss); + }; + sv.addEventListener('click', () => set(secs + 60)); + set(opts.secs !== undefined ? opts.secs : 24 * 60 + 10); + return { el: sv, get: () => secs, set, tick: () => set(secs - 1) }; +}; + +/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message. + + Contract (everything a consumer needs; no page globals touched): + opts: msgs (message pool, default demo set); width (window px, 176); + onChange(msg index, 'msg i/n') fires on click-cycle and once at + build. + handle: el, get() (current message index), tick() — advances the scroll + 1.1px and wraps when the text clears the window (the tick + contract — the page owns the clock). + CSS lives in the "VFD marquee" block of DUPRE_CSS; no other styles involved. */ +DUPRE.vfdMarquee = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C']; + const W = opts.width || 176; + const m = document.createElement('span'); m.className = 'dupre-vfdm'; + m.innerHTML = '<span class="dupre-txt"></span><span class="dupre-mesh"></span>'; + const t = m.querySelector('.dupre-txt'); t.textContent = msgs[0]; + host.appendChild(m); + let mi = 0, x = W; + m.addEventListener('click', () => { + mi = (mi + 1) % msgs.length; t.textContent = msgs[mi]; x = W; + onChange(mi, 'msg ' + (mi + 1) + '/' + msgs.length); + }); + onChange(0, 'scrolling'); + return { + el: m, get: () => mi, + tick: () => { x -= 1.1; if (x < -t.offsetWidth) x = W; t.style.left = x + 'px'; } + }; +}; + +/* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET + lifecycle; TEST proves the bulbs then restores the board. + + Contract (everything a consumer needs; no page globals touched): + opts: cells (array of [label, state] where state is 0 ok · 1 warn · + 2 fault; defaults to a six-cell demo). onChange fires on every + transition: (count, summary) after a refresh, (index, 'label → name') + after a cell change, (null, 'lamp test') during TEST. + handle: el, get() (per-cell state array 0/1/2), set(i, st) — st clamps to + the 0..2 lens range at build AND at set, so an out-of-range caller + can't paint an undefined class. A new warn/fault re-arms the + MSTR CAUTION flasher; ACK stops it, RESET clears the board. + CSS lives in the "annunciator" block of DUPRE_CSS; the ACK/TEST/RESET buttons + are the shared .dupre-key primitive; the board uses the kit-wide dupre-warn + modifier and MSTR CAUTION the kit-wide dupre-on. */ +DUPRE.annunciator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const CLS = ['dupre-acell', 'dupre-acell dupre-warn', 'dupre-acell dupre-fault']; + const NAMES = ['ok', 'warn', 'fault']; + const clampSt = st => Math.max(0, Math.min(CLS.length - 1, st | 0)); + const cells = opts.cells || [['SYNC', 0], ['LOW BATT', 1], ['LINK', 0], ['NO DNS', 2], ['VPN', 0], ['DISK', 0]]; + const wrap = document.createElement('div'); wrap.className = 'dupre-annwrap'; + const grid = document.createElement('div'); grid.className = 'dupre-annun'; wrap.appendChild(grid); + const bar = document.createElement('div'); bar.className = 'dupre-annbar'; + const mc = document.createElement('span'); mc.className = 'dupre-mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc); + ['ACK', 'TEST', 'RESET'].forEach(a => { + const b = document.createElement('button'); b.className = 'dupre-key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b); + }); + wrap.appendChild(bar); host.appendChild(wrap); + const els = cells.map(([label, st]) => { + const c = document.createElement('span'); c.className = CLS[clampSt(st)]; c.textContent = label; grid.appendChild(c); return c; + }); + let acked = false; + const active = () => grid.querySelectorAll('.dupre-warn,.dupre-fault').length; + const refresh = () => { + const n = active(); + mc.classList.toggle('dupre-on', n > 0); mc.classList.toggle('dupre-fl', n > 0 && !acked); + onChange(n, n === 0 ? 'clear' : n + ' active · ' + (acked ? 'ACK' : 'UNACK')); + }; + els.forEach((c, i) => c.addEventListener('click', () => { + let j = CLS.indexOf(c.className.trim()); if (j < 0) j = 0; j = (j + 1) % CLS.length; c.className = CLS[j]; + if (j > 0) acked = false; /* a new alarm re-arms the flasher */ + refresh(); onChange(j, cells[i][0] + ' → ' + NAMES[j]); + })); + bar.querySelectorAll('.dupre-key').forEach(k => k.addEventListener('click', () => { + const a = k.dataset.k; + if (a === 'ACK') { if (active() > 0) acked = true; refresh(); } + else if (a === 'RESET') { els.forEach(c => c.className = 'dupre-acell'); acked = false; refresh(); } + else if (a === 'TEST') { + const prev = els.map(c => c.className); + els.forEach(c => c.className = 'dupre-acell dupre-fault'); + mc.classList.add('dupre-on', 'dupre-fl'); onChange(null, 'lamp test'); + setTimeout(() => { els.forEach((c, i) => c.className = prev[i]); refresh(); }, 1100); + } + })); + refresh(); + return { + el: wrap, get: () => els.map(c => Math.max(0, CLS.indexOf(c.className.trim()))), + set: (i, st) => { els[i].className = CLS[clampSt(st)]; if (clampSt(st) > 0) acked = false; refresh(); } + }; +}; + +/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark. + + Contract (everything a consumer needs; no page globals touched): + opts: states (array, one jewel per entry: 0 red · 1 amber · 2 green · + -1 dark; default [0,1,2,-1]); onChange(state, name) fires per + change and once at build with a hint. + handle: el, get() (states copy), set(i, st) — st clamps to the lens + range, negatives dark the jewel. State lives in here; the DOM + is a paint of it (was the reverse — the old builder kept state + only in classList/--jc, so nothing could read or drive it). + CSS lives in the "jewel" block of DUPRE_CSS; no other styles involved. */ +DUPRE.jewels = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)']; + const NAMES = ['red', 'amber', 'green']; + const states = (opts.states || [0, 1, 2, -1]).slice(); /* index into cols; -1 = dark */ + const wrap = document.createElement('span'); + wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center'; + const paint = i => { + const st = states[i], j = els[i]; + j.className = 'dupre-jewel' + (st < 0 ? ' dupre-dim' : ''); + if (st >= 0) j.style.setProperty('--jc', cols[st]); else j.style.removeProperty('--jc'); + }; + function set(i, st) { + states[i] = st < 0 ? -1 : Math.min(st, cols.length - 1); + paint(i); + onChange(states[i], states[i] < 0 ? 'dark' : NAMES[states[i]]); + } + const els = states.map((_, i) => { + const j = document.createElement('span'); + j.addEventListener('click', () => set(i, states[i] >= cols.length - 1 ? -1 : states[i] + 1)); + wrap.appendChild(j); return j; + }); + host.appendChild(wrap); + states.forEach((_, i) => paint(i)); + onChange(null, 'click to cycle'); + return { el: wrap, get: () => states.slice(), set }; +}; + +/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit. + + Contract (everything a consumer needs; no page globals touched): + opts: wheels (digit count, default 6); redFrom (index from which the + high-order wheels wear the red band, default 4); value (initial, + default 471300); onChange(value, zero-padded string) fires on set. + handle: el, get() (current total), set(total) — rounds fractional input + and wraps modulo 10^wheels, then rolls each wheel to its digit. + CSS lives in the "tape counter" block of DUPRE_CSS; no other styles involved. */ +DUPRE.tapeCounter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4; + const ct = document.createElement('span'); ct.className = 'dupre-counter'; host.appendChild(ct); + for (let idx = 0; idx < wheels; idx++) { + const w = document.createElement('span'); w.className = 'dupre-cwheel' + (idx >= redFrom ? ' dupre-redw' : ''); + const col = document.createElement('span'); col.className = 'dupre-col'; + for (let n = -1; n <= 10; n++) { const sp = document.createElement('span'); sp.textContent = ((n + 10) % 10); col.appendChild(sp); } + w.appendChild(col); ct.appendChild(w); + } + const max = Math.pow(10, wheels); + let v; + const set = x => { + v = ((Math.round(x) % max) + max) % max; + const s = String(v).padStart(wheels, '0'); + [...ct.children].forEach((w, i) => { w.querySelector('.dupre-col').style.top = (-(+s[i] + 1) * 34) + 'px'; }); + onChange(v, s); + }; + set(opts.value !== undefined ? opts.value : 471300); + return { el: ct, get: () => v, set }; +}; + +/* N26 analog clock — engraved ticks + three hands; the page owns the time source + and drives set(h, m, s); silent until the first set, like the live original. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange([h, m, s], 'hh:mm:ss') fires on every set. + handle: el, set(h, m, s) — the minute hand carries a seconds fraction + and the hour hand a minutes fraction, so the face reads like a + real movement. Display-only: no state to get, no clock of its + own (the tick contract — the page owns the time source). + CSS lives in the "analog clock" block of DUPRE_CSS; no other styles involved. */ +DUPRE.analogClock = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const cl = document.createElement('span'); cl.className = 'dupre-clock'; + for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'dupre-tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); } + cl.insertAdjacentHTML('beforeend', '<span class="dupre-hh"></span><span class="dupre-mh"></span><span class="dupre-sh"></span><span class="dupre-pin"></span>'); + host.appendChild(cl); + const hh = cl.querySelector('.dupre-hh'), mh = cl.querySelector('.dupre-mh'), sh = cl.querySelector('.dupre-sh'); + const set = (h, m, s) => { + sh.style.transform = `rotate(${s * 6}deg)`; + mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`; + hh.style.transform = `rotate(${(h % 12) * 30 + m * 0.5}deg)`; + onChange([h, m, s], String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')); + }; + return { el: cl, set }; +}; + +/* N27 frequency-dial scale — printed log axis, marks crowd low and spread high; + drag the pointer to tune. + + Contract (everything a consumer needs; no page globals touched): + opts: width (px, default 184); marks (labeled major marks, log-spaced, + default 0.5..20); unit (band label, default 'MHz'); value (initial + pointer percent 0..100, default 45); onChange(freq, 'nn unit') fires + on set and drag. + handle: el, get() (pointer percent 0..100), set(pct) — clamps to 0..100 + and maps to a frequency along the log axis. dragX drives it. + CSS lives in the "frequency-dial scale" block of DUPRE_CSS; the ticks and + marks reuse the shared scoped dupre-tick/dupre-mk names (like the tuner and + dualknob), and the unit label is dupre-fs-band (dupre-band is the EQ's). */ +DUPRE.freqScale = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const W = opts.width || 184; + const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20]; + const unit = opts.unit || 'MHz'; + const lo = Math.log10(marks[0]), hi = Math.log10(marks[marks.length - 1]); + const px = m => 6 + ((Math.log10(m) - lo) / (hi - lo)) * (W - 12); + const fs = document.createElement('span'); fs.className = 'dupre-freqscale'; + const band = document.createElement('span'); band.className = 'dupre-fs-band'; band.textContent = unit; fs.appendChild(band); + marks.forEach(m => { + const tk = document.createElement('span'); tk.className = 'dupre-tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk); + const mk = document.createElement('span'); mk.className = 'dupre-mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk); + }); + for (let m = marks[0]; m <= marks[marks.length - 1]; m += (m < 2 ? 0.25 : m < 10 ? 1 : 2)) { + const tk = document.createElement('span'); tk.className = 'dupre-tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk); + } + const ptr = document.createElement('span'); ptr.className = 'dupre-fptr'; fs.appendChild(ptr); + host.appendChild(fs); + let pct; + const set = p => { + pct = Math.max(0, Math.min(100, p)); + ptr.style.left = (6 + pct / 100 * (W - 12)) + 'px'; + const f = Math.pow(10, lo + (pct / 100) * (hi - lo)); + onChange(f, (f < 10 ? f.toFixed(1) : Math.round(f)) + ' ' + unit); + }; + dragX(fs, set); + set(opts.value !== undefined ? opts.value : 45); + return { el: fs, get: () => pct, set }; +}; + +/* N28 patch bay — jack grid with SVG cables; click one jack then another to + patch or unpatch the pair; redraws itself on window resize. + + Contract (everything a consumer needs; no page globals touched): + opts: rows (default 2), cols (default 4); conns (initial cables as + 'a-b' jack-index strings, default ['0-5', '2-7']); onChange(conns + array, summary) fires on every patch/unpatch and once at build. + handle: el, get() (conns copy), set(conns) — replaces the patch set and + redraws the cables. Click one jack then another to patch or + unpatch; the SVG cables re-solve on window resize. + CSS lives in the "patch-bay" block of DUPRE_CSS; a live jack wears the + kit-wide dupre-hot modifier. */ +DUPRE.patchBay = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const rows = opts.rows || 2, cols = opts.cols || 4; + const patch = document.createElement('div'); patch.className = 'dupre-patch'; + const jacks = []; + for (let r = 0; r < rows; r++) { + const rowEl = document.createElement('div'); rowEl.className = 'dupre-row'; + for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'dupre-jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); } + patch.appendChild(rowEl); + } + const svg = document.createElementNS(SVGNS, 'svg'); patch.appendChild(svg); + host.appendChild(patch); + let conns = (opts.conns || ['0-5', '2-7']).slice(); let pending = null; + const key = (a, b) => Math.min(a, b) + '-' + Math.max(a, b); + const draw = () => { + while (svg.firstChild) svg.removeChild(svg.firstChild); + const pr = patch.getBoundingClientRect(); + jacks.forEach(j => j.classList.remove('dupre-hot')); + conns.forEach(pair => { + const [a, b] = pair.split('-').map(Number); + const ra = jacks[a].getBoundingClientRect(), rb = jacks[b].getBoundingClientRect(); + const x1 = ra.left - pr.left + ra.width / 2, y1 = ra.top - pr.top + ra.height / 2; + const x2 = rb.left - pr.left + rb.width / 2, y2 = rb.top - pr.top + rb.height / 2; + const p = document.createElementNS(SVGNS, 'path'); + const my = Math.max(y1, y2) + 16; p.setAttribute('d', `M${x1} ${y1} C${x1} ${my} ${x2} ${my} ${x2} ${y2}`); svg.appendChild(p); + jacks[a].classList.add('dupre-hot'); jacks[b].classList.add('dupre-hot'); + }); + onChange(conns.slice(), conns.length ? conns.map(c => { const [a, b] = c.split('-'); return a + '↔' + b; }).join(' ') : 'no cables'); + }; + jacks.forEach(j => { + j.addEventListener('click', () => { + const id = +j.dataset.id; + if (pending == null) { pending = id; j.classList.add('dupre-sel'); return; } + if (pending === id) { pending = null; j.classList.remove('dupre-sel'); return; } + const k = key(pending, id); const i = conns.indexOf(k); if (i >= 0) conns.splice(i, 1); else conns.push(k); + jacks[pending].classList.remove('dupre-sel'); pending = null; draw(); + }); + }); + draw(); window.addEventListener('resize', draw); + return { el: patch, get: () => conns.slice(), set: c => { conns = c.slice(); draw(); } }; +}; + +/* R10 data matrix readout — a page of labeled amber fields; click cycles pages; + a page marked live re-renders on the builder's own 1s clock (reduced-motion gated). + + Contract (everything a consumer needs; no page globals touched): + opts: pages (array of [name, rowsFn, live?] tuples, default SYS/NET/TIME; + rowsFn() returns the three text rows, and a truthy third element + marks a page that re-renders on the 1s clock); page (initial index, + default 0); onChange(index, 'page NAME') fires on set. + handle: el, get() (page index), set(i) — wraps modulo the page count. + Click cycles pages; a live page re-renders on the builder's own + 1s clock (reduced-motion gated). + SVG-built: styling is inline attributes plus the shared .rsvg stage block of + DUPRE_CSS; the glow filter registers in the shared defs plate, but the + screen-family background gradient lives in this instrument's local defs (it + reads --scr-* vars from its own subtree). */ +DUPRE.dataMatrix = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const pages = opts.pages || [ + ['SYS', () => ['CPU 42 MEM 61', 'DSK 58 TMP 47C', 'ARCH 6.18 LTS']], + ['NET', () => ['WIFI @HYATT 300M', 'VPN UP DNS OK', 'BT 2 PAIRED']], + ['TIME', () => { + const d = new Date(), p = n => String(n).padStart(2, '0'); + return [`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`, + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`, + 'UP 14D 06:12']; + }, true]]; + const s = stageSvg(host, 'rsvg press', 190, 74); + def('dmxGlow', d => { + const fl = svgEl(d, 'filter', { id: 'dmxGlow', x: '-30%', y: '-30%', width: '160%', height: '160%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.1 }); + }); + /* the background gradient reads screen-family vars from this instrument's subtree, + so it lives in the local defs, not the shared def sink */ + const bgId = uid('dmxBg'); + const defs = svgEl(s, 'defs', {}); + const bg = svgEl(defs, 'linearGradient', { id: bgId, x1: 0, y1: 0, x2: 0, y2: 1 }); + svgEl(bg, 'stop', { offset: '0', 'stop-color': 'var(--scr-bg1,#140d06)' }); + svgEl(bg, 'stop', { offset: '1', 'stop-color': 'var(--scr-bg2,#0a0705)' }); + svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 72, rx: 5, fill: `url(#${bgId})`, stroke: '#2c261d' }); + const lines = []; + for (let r = 0; r < 3; r++) { + const glow = svgEl(s, 'text', { + x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em', + 'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))', opacity: .6, filter: 'url(#dmxGlow)' + }); + const crisp = svgEl(s, 'text', { + x: 12, y: 24 + r * 18, 'font-size': 9, 'letter-spacing': '.15em', + 'font-family': 'var(--mono)', fill: 'var(--scr-hi,var(--gold-hi))' + }); + lines.push([glow, crisp]); + } + let pi; + const set = i => { + pi = ((i % pages.length) + pages.length) % pages.length; + const [name, rowsFn] = pages[pi]; + rowsFn().forEach((txt, r) => { lines[r][0].textContent = txt; lines[r][1].textContent = txt; }); + onChange(pi, 'page ' + name); + }; + s.addEventListener('click', () => set(pi + 1)); + set(opts.page || 0); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { if (pages[pi][2]) set(pi); }, 1000); + return { el: s, get: () => pi, set }; +}; + +/* R11 warning flag window — the striped mechanical flag slides into the window + when the condition trips; click trips and clears it. + + Contract (everything a consumer needs; no page globals touched): + opts: label (window legend, default 'VIB'); on (initial tripped state, + default false); onChange(on, 'FLAG'|'clear') fires on set. + handle: el, get() (tripped bool), set(v) — slides the striped flag into + the window when truthy, retracts it when not. Click toggles. + SVG-built: styling is inline attributes plus the shared .rsvg stage block of + DUPRE_CSS; the barber-stripe pattern registers in the shared defs plate, the + window clip in this instrument's local defs. */ +DUPRE.warningFlag = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 120, 64); + def('barber', d => { + const pat = svgEl(d, 'pattern', { id: 'barber', width: 10, height: 10, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }); + svgEl(pat, 'rect', { x: 0, y: 0, width: 10, height: 10, fill: '#17130c' }); + svgEl(pat, 'rect', { x: 0, y: 0, width: 5, height: 10, fill: 'var(--gold)' }); + }); + const clipId = uid('flagWin'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'rect', { x: 14, y: 20, width: 56, height: 24 }); + svgEl(s, 'rect', { x: 11, y: 17, width: 62, height: 30, rx: 3, fill: '#0a0908', stroke: '#2c261d', 'stroke-width': 2 }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const stripes = svgEl(g, 'rect', { x: 14, y: 20, width: 56, height: 24, fill: 'url(#barber)' }); + stripes.style.transition = 'transform .18s'; stripes.style.transform = 'translateX(-58px)'; + svgEl(s, 'text', { + x: 92, y: 35, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.14em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = opts.label || 'VIB'; + let on; + const set = v => { on = !!v; stripes.style.transform = on ? 'translateX(0)' : 'translateX(-58px)'; onChange(on, on ? 'FLAG' : 'clear'); }; + s.addEventListener('click', () => set(!on)); + set(opts.on || false); + return { el: s, get: () => on, set }; +}; + +/* R25 fourteen-segment display — the starburst alphanumeric that spells words; + click cycles the word. + + Contract (everything a consumer needs; no page globals touched): + opts: words (the four-letter word pool cycled by click, default + ZOOM/ECHO/TAPE/…; letters outside the segment MAP render blank); + index (initial, default 0); onChange(index, word) fires on set. + handle: el, get() (word index), set(i) — wraps modulo the pool and lights + the fourteen-segment cells to spell the word. Click cycles. + SVG-built: styling is inline attributes plus the shared .rsvg stage block of + DUPRE_CSS; the segments are filled inline (lit vs --sevoff), no shared defs. */ +DUPRE.seg14 = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const words = opts.words || ['ZOOM', 'ECHO', 'TAPE', 'MOOD', 'HALL', 'COMP']; + const MAP = { + A: ['a', 'b', 'c', 'e', 'f', 'g1', 'g2'], C: ['a', 'd', 'e', 'f'], + D: ['a', 'b', 'c', 'd', 'i', 'l'], E: ['a', 'd', 'e', 'f', 'g1', 'g2'], H: ['b', 'c', 'e', 'f', 'g1', 'g2'], + L: ['d', 'e', 'f'], M: ['b', 'c', 'e', 'f', 'h', 'j'], O: ['a', 'b', 'c', 'd', 'e', 'f'], + P: ['a', 'b', 'e', 'f', 'g1', 'g2'], T: ['a', 'i', 'l'], Z: ['a', 'd', 'j', 'k'] + }; + const s = stageSvg(host, 'rsvg press', 130, 54); + svgEl(s, 'rect', { x: 1, y: 1, width: 128, height: 52, rx: 5, fill: '#0a0f08', stroke: '#1c2a16', 'stroke-width': 2 }); + /* segment shapes in a 22x36 local box */ + const TH = 2.6, W = 22, H = 36, mid = H / 2; + const segs = { + a: [[2, 0], [W - 2, 0], [W - 4, TH], [4, TH]], + d: [[2, H], [W - 2, H], [W - 4, H - TH], [4, H - TH]], + f: [[0, 2], [TH, 4], [TH, mid - 2], [0, mid - 1]], + e: [[0, H - 2], [TH, H - 4], [TH, mid + 2], [0, mid + 1]], + b: [[W, 2], [W - TH, 4], [W - TH, mid - 2], [W, mid - 1]], + c: [[W, H - 2], [W - TH, H - 4], [W - TH, mid + 2], [W, mid + 1]], + g1: [[2, mid], [4, mid - TH / 2], [W / 2 - 1, mid - TH / 2], [W / 2 - 1, mid + TH / 2], [4, mid + TH / 2]], + g2: [[W - 2, mid], [W - 4, mid - TH / 2], [W / 2 + 1, mid - TH / 2], [W / 2 + 1, mid + TH / 2], [W - 4, mid + TH / 2]], + i: [[W / 2 - TH / 2, 3], [W / 2 + TH / 2, 3], [W / 2 + TH / 2, mid - 2], [W / 2 - TH / 2, mid - 2]], + l: [[W / 2 - TH / 2, H - 3], [W / 2 + TH / 2, H - 3], [W / 2 + TH / 2, mid + 2], [W / 2 - TH / 2, mid + 2]], + h: [[3, 3], [5.5, 3], [W / 2 - 2, mid - 3], [W / 2 - 4.5, mid - 3]], + j: [[W - 3, 3], [W - 5.5, 3], [W / 2 + 2, mid - 3], [W / 2 + 4.5, mid - 3]], + k: [[3, H - 3], [5.5, H - 3], [W / 2 - 2, mid + 3], [W / 2 - 4.5, mid + 3]], + m: [[W - 3, H - 3], [W - 5.5, H - 3], [W / 2 + 2, mid + 3], [W / 2 + 4.5, mid + 3]] + }; + const cells = []; + for (let d = 0; d < 4; d++) { + const x0 = 13 + d * 28, y0 = 9, cell = {}; + for (const k in segs) { + cell[k] = svgEl(s, 'polygon', { points: segs[k].map(p => `${x0 + p[0]},${y0 + p[1]}`).join(' '), fill: 'var(--sevoff)' }); + } + cells.push(cell); + } + let wi; + const set = i => { + wi = ((i % words.length) + words.length) % words.length; + const word = words[wi].padEnd(4, ' '); + cells.forEach((cell, d) => { + const lit = MAP[word[d]] || []; + for (const k in cell) { + const on = lit.includes(k); + cell[k].setAttribute('fill', on ? 'var(--sevgrn)' : 'var(--sevoff)'); + cell[k].setAttribute('style', on ? 'filter:drop-shadow(0 0 2.5px rgba(87,211,87,.6))' : ''); + } + }); + onChange(wi, words[wi]); + }; + s.addEventListener('click', () => set(wi + 1)); + set(opts.index || 0); + return { el: s, get: () => wi, set }; +}; + +/* R26 response graph — log-frequency axes with a draggable amber peak; 2D drag + places the peak in both axes at once. + + Contract (everything a consumer needs; no page globals touched): + opts: fc (initial center frequency Hz, default 1200); gain (initial dB, + default 6.5); onChange([fc, gain], 'f · ±g dB') fires on set and drag. + handle: el, get() ([fc, gain]), set(f, g) — fc clamps 32..16000 Hz, gain + clamps ±12 dB; draws the bell curve and moves the peak handle. A + 2D drag on the plot places the peak in both axes at once. + SVG-built: styling is inline attributes plus the shared .rsvg stage block of + DUPRE_CSS; the amber glow filter registers in the shared defs plate. */ +DUPRE.responseGraph = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 190, 110); + const RG = { x0: 30, x1: 182, y0: 8, y1: 86, fLo: Math.log10(32), fHi: Math.log10(16000), db: 12 }; + def('avGlow', d => { + const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); + svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); + }); + svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 108, rx: 5, fill: '#0d1410', stroke: '#22301f', 'stroke-width': 2 }); + const FREQS = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000]; + const xOf = f => RG.x0 + (Math.log10(f) - RG.fLo) / (RG.fHi - RG.fLo) * (RG.x1 - RG.x0); + const yOf = db => RG.y0 + (RG.db - db) / (2 * RG.db) * (RG.y1 - RG.y0); + FREQS.forEach(f => { + const x = xOf(f); + svgEl(s, 'line', { x1: x, y1: RG.y0, x2: x, y2: RG.y1, stroke: '#22301f', 'stroke-width': .6 }); + svgEl(s, 'text', { + x, y: RG.y1 + 9, 'text-anchor': 'middle', 'font-size': 4.8, 'font-family': 'var(--mono)', + fill: 'var(--steel)' + }).textContent = f < 1000 ? f : (f / 1000) + 'k'; + }); + for (const db of [12, 6, 0, -6, -12]) { + const y = yOf(db); + svgEl(s, 'line', { x1: RG.x0, y1: y, x2: RG.x1, y2: y, stroke: '#22301f', 'stroke-width': db === 0 ? 1 : .6 }); + svgEl(s, 'text', { + x: RG.x0 - 4, y: y + 2, 'text-anchor': 'end', 'font-size': 4.8, 'font-family': 'var(--mono)', + fill: 'var(--steel)' + }).textContent = (db > 0 ? '+' : '') + db; + } + const glowLine = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 3, opacity: .35, filter: 'url(#avGlow)' }); + const curve = svgEl(s, 'polyline', { points: '', fill: 'none', stroke: 'var(--gold)', 'stroke-width': 1.6 }); + const handle = svgEl(s, 'circle', { r: 4, fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': 1 }); + handle.style.cursor = 'move'; + let fc, gain; + const set = (f, g) => { + fc = Math.max(32, Math.min(16000, f)); gain = Math.max(-12, Math.min(12, g)); + const sigma = 0.28; let pts = ''; + for (let i = 0; i <= 76; i++) { + const lf = RG.fLo + i / 76 * (RG.fHi - RG.fLo); + const db = gain * Math.exp(-Math.pow(lf - Math.log10(fc), 2) / (2 * sigma * sigma)); + pts += `${xOf(Math.pow(10, lf)).toFixed(1)},${yOf(db).toFixed(1)} `; + } + curve.setAttribute('points', pts.trim()); glowLine.setAttribute('points', pts.trim()); + handle.setAttribute('cx', xOf(fc)); handle.setAttribute('cy', yOf(gain)); + const fLbl = fc < 1000 ? Math.round(fc) + ' Hz' : (fc / 1000).toFixed(1) + ' kHz'; + onChange([fc, gain], `${fLbl} · ${gain >= 0 ? '+' : ''}${gain.toFixed(1)} dB`); + }; + /* 2D drag: the plot places the peak in both axes at once */ + s.style.touchAction = 'none'; + s.addEventListener('pointerdown', e => { + s.setPointerCapture(e.pointerId); + const move = ev => { + const r = s.getBoundingClientRect(), sx = 190 / r.width, sy = 110 / r.height; + const px = (ev.clientX - r.left) * sx, py = (ev.clientY - r.top) * sy; + const lf = RG.fLo + Math.max(0, Math.min(1, (px - RG.x0) / (RG.x1 - RG.x0))) * (RG.fHi - RG.fLo); + const g = RG.db - Math.max(0, Math.min(1, (py - RG.y0) / (RG.y1 - RG.y0))) * 2 * RG.db; + set(Math.pow(10, lf), g); + }; + move(e); + const up = () => { s.removeEventListener('pointermove', move); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); }; + s.addEventListener('pointermove', move); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); + e.preventDefault(); + }); + set(opts.fc !== undefined ? opts.fc : 1200, opts.gain !== undefined ? opts.gain : 6.5); + return { el: s, get: () => [fc, gain], set }; +}; + +/* R30 telegraph indicator — the pointer names the active state on labeled + sectors, engine-telegraph style; click steps the state */ +DUPRE.telegraphIndicator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const states = opts.states || ['OK', 'TEST', 'BUSY', 'STOP', 'ATTN', 'FAULT']; + const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55, N = states.length; + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#171412', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 43, fill: 'var(--cream)', stroke: '#b7b29a', 'stroke-width': 1 }); + states.forEach((st, i) => { + const a = i * 360 / N - 90 + 180 / N; + const [lx1, ly1] = polar(cx, cy, 9, i * 360 / N), [lx2, ly2] = polar(cx, cy, 43, i * 360 / N); + svgEl(s, 'line', { x1: lx1, y1: ly1, x2: lx2, y2: ly2, stroke: '#2b2418', 'stroke-width': 1 }); + const g = svgEl(s, 'g', { transform: `rotate(${a + 90},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 7, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#2b2418' + }).textContent = st; + }); + const ptr = svgEl(s, 'g', {}); ptr.style.transition = 'transform .18s'; ptr.style.transformOrigin = `${cx}px ${cy}px`; + svgEl(ptr, 'polygon', { points: `${cx - 2.5},${cy} ${cx},${cy - 38} ${cx + 2.5},${cy}`, fill: 'var(--fail)', stroke: '#7a2a1a', 'stroke-width': .5 }); + svgEl(ptr, 'rect', { x: cx + 1, y: cy - 16, width: 10, height: 7, rx: 1, fill: 'var(--gold)', stroke: '#7d5c16', 'stroke-width': .6 }); + svgEl(s, 'circle', { cx, cy, r: 8, fill: '#171412', stroke: '#000' }); + let si; + const set = i => { + si = ((i % N) + N) % N; + ptr.style.transform = `rotate(${si * 360 / N + 180 / N}deg)`; + onChange(si, states[si]); + }; + s.addEventListener('click', () => set(si + 1)); + set(opts.index || 0); + return { el: s, get: () => si, set }; +}; + +/* R31 radar sweep — the beam circles the bearing ring on the builder's own + clock (reduced-motion gated), contacts bloom as it passes; click marks the bearing */ +DUPRE.radarSweep = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const contacts = opts.contacts || [[60, .55], [205, .7], [318, .4]]; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; + const clipId = uid('radarClip'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy, r: 46 }); + svgEl(s, 'circle', { cx, cy, r: 62, fill: '#1a1714', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 52, fill: 'var(--scr-bg1,#120b05)', stroke: 'var(--scr-brd,#3a2a12)', 'stroke-width': 1 }); + for (let d = 0; d < 360; d += 30) { + const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 47, d); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': 1, opacity: .8 }); + const g = svgEl(s, 'g', { transform: `rotate(${d},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 47.5, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: 'var(--scr-ink,var(--gold))', opacity: .9 + }).textContent = String(d).padStart(3, '0'); + } + for (let d = 0; d < 360; d += 10) { + if (d % 30 === 0) continue; + const [x1, y1] = polar(cx, cy, 51, d), [x2, y2] = polar(cx, cy, 49, d); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--scr-ink,var(--gold))', 'stroke-width': .6, opacity: .5 }); + } + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + svgEl(g, 'circle', { cx, cy, r: 46, fill: 'var(--scr-bg2,#0d0803)' }); + for (const r of [15, 30, 45]) + svgEl(g, 'circle', { cx, cy, r, fill: 'none', stroke: 'var(--scr-ink,var(--gold))', 'stroke-opacity': .18, 'stroke-width': .7 }); + const sweep = svgEl(g, 'g', {}); + for (let t = 0; t < 5; t++) { + const a0 = -14 + t * 2.8, a1 = a0 + 2.8; + const [x1, y1] = polar(cx, cy, 46, a0), [x2, y2] = polar(cx, cy, 46, a1); + svgEl(sweep, 'path', { + d: `M ${cx} ${cy} L ${x1} ${y1} A 46 46 0 0 1 ${x2} ${y2} Z`, + fill: 'var(--scr-ink,var(--gold))', 'fill-opacity': (0.04 + t * 0.045).toFixed(3) + }); + } + svgEl(sweep, 'line', { + x1: cx, y1: cy, x2: cx, y2: cy - 46, stroke: 'var(--scr-hi,var(--gold-hi))', 'stroke-width': 1.4, + transform: `rotate(0,${cx},${cy})`, opacity: .95 + }); + const blips = contacts.map(([bd, rf]) => { + const [x, y] = polar(cx, cy, 46 * rf, bd); + return svgEl(g, 'circle', { cx: x, cy: y, r: 2.2, fill: 'var(--scr-hi,var(--gold-hi))', opacity: 0 }); + }); + svgEl(s, 'circle', { cx, cy, r: 3, fill: 'var(--scr-ink,var(--gold))', opacity: .8 }); + const mark = svgEl(s, 'text', { + x: cx, y: 124, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }); + mark.textContent = 'BEARING —'; + let a = opts.bearing !== undefined ? opts.bearing : 210; + const tick = () => { + a = (a + 2.6) % 360; + sweep.setAttribute('transform', `rotate(${a},${cx},${cy})`); + contacts.forEach(([bd], i) => { + const d = (a - bd + 360) % 360; /* degrees since beam passed */ + blips[i].setAttribute('opacity', d < 200 ? Math.max(0, .95 - d / 200) : 0); + }); + mark.textContent = mark.textContent.startsWith('MARK') ? mark.textContent : + 'BEARING ' + String(Math.round(a) % 360).padStart(3, '0'); + }; + s.addEventListener('click', () => { + const b = String(Math.round(a) % 360).padStart(3, '0'); + mark.textContent = 'MARK ' + b; + onChange(a, 'mark ' + b); + }); + tick(); onChange(a, 'sweeping'); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 50); + return { el: s, get: () => a, tick }; +}; + +/* R35 day-date disc calendar — coaxial printed discs rotate so today reads + under the fixed hand; starts on today; click rolls midnight forward. + + Contract (everything a consumer needs; no page globals touched): + opts: date (1-31) and day (0-6, SUN=0), both defaulting to today; + onChange([date, day], 'DAY d') fires on every set, including the + initial paint. + handle: el, get() ([date, day]), set(d, w) — rotates both discs so the + commanded pair reads under the fixed hand; click advances date + and weekday together like a midnight rollover. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.dayDateCal = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; + const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65, HAND = 135; + gradDef('ddFace', 'radialGradient', { cx: '46%', cy: '40%', r: '75%' }, [['0', '#efe9da'], ['1', '#cfc8b6']]); + /* movement plate + white calendar disc */ + svgEl(s, 'circle', { cx, cy, r: 62, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + for (let k = 0; k < 5; k++) { + const [x, y] = polar(cx, cy, 58, 20 + k * 72); + svgEl(s, 'circle', { cx: x, cy: y, r: 1.8, fill: '#060505' }); + } + svgEl(s, 'circle', { cx, cy, r: 54, fill: 'url(#ddFace)', stroke: '#8f897b', 'stroke-width': .8 }); + /* date ring: 31 radial numerals on its own group */ + const dateG = svgEl(s, 'g', {}); + for (let d = 1; d <= 31; d++) { + const a = (d - 1) * (360 / 31); + const g = svgEl(dateG, 'g', { transform: `rotate(${a},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 46, 'text-anchor': 'middle', 'font-size': 7.5, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#14110e' + }).textContent = String(d); + } + /* inner weekday disc: 7 names twice around, separated by a hairline ring */ + svgEl(s, 'circle', { cx, cy, r: 38, fill: 'none', stroke: '#8f897b', 'stroke-width': .7 }); + const dayG = svgEl(s, 'g', {}); + for (let i = 0; i < 14; i++) { + const a = i * (360 / 14); + const g = svgEl(dayG, 'g', { transform: `rotate(${a},${cx},${cy})` }); + svgEl(g, 'text', { + x: cx, y: cy - 28, 'text-anchor': 'middle', 'font-size': 6.8, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: '#14110e' + }).textContent = DAYS[i % 7]; + } + /* fixed yellow hand from hub toward the read index, and the hub */ + const [hx, hy] = polar(cx, cy, 44, HAND); + svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: '#e8cf4a', 'stroke-width': 3, 'stroke-linecap': 'round' }); + svgEl(s, 'line', { x1: cx, y1: cy, x2: hx, y2: hy, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#b8b2a4', stroke: '#5c574c', 'stroke-width': 1.2 }); + svgEl(s, 'circle', { cx, cy, r: 2.4, fill: '#7a7466' }); + s.style.cursor = 'pointer'; + let date, day; + const set = (d, w) => { + date = d; day = w; + dateG.setAttribute('transform', `rotate(${(HAND - (d - 1) * (360 / 31)).toFixed(2)},${cx},${cy})`); + dayG.setAttribute('transform', `rotate(${(HAND - w * (360 / 14)).toFixed(2)},${cx},${cy})`); + onChange([d, w], DAYS[w] + ' ' + d); + }; + s.addEventListener('click', () => set(date % 31 + 1, (day + 1) % 7)); + const now = new Date(); + set(opts.date !== undefined ? opts.date : now.getDate(), + opts.day !== undefined ? opts.day : now.getDay()); + return { el: s, get: () => [date, day], set }; +}; + +/* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots + to paint; starts on the reference K */ +DUPRE.dotMatrix = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const N = 8, STEP = 11, X0 = 10.5, Y0 = 10.5; + const glyph = opts.glyph || ['10000100', '10001000', '10010000', '11100000', '10100000', '10010000', '10001000', '10000100']; + const s = stageSvg(host, 'rsvg press', 108, 108); + svgEl(s, 'rect', { x: 2, y: 2, width: 104, height: 104, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 6, y: 6, width: 96, height: 96, rx: 5, fill: '#0d0505', stroke: '#241010', 'stroke-width': 1 }); + let lit = 0; + const paint = (d, on) => { + d.on = on; + d.el.setAttribute('fill', on ? '#ff4a30' : '#2a1210'); + d.el.setAttribute('filter', on ? 'drop-shadow(0 0 3.5px rgba(255,74,48,.85))' : 'none'); + }; + for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) { + const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2, cy: Y0 + r * STEP + STEP / 2, r: 4.1, fill: '#2a1210' }); + el.style.cursor = 'pointer'; + const d = { el, on: false }; + paint(d, glyph[r][c] === '1'); if (d.on) lit++; + el.addEventListener('click', () => { + paint(d, !d.on); lit += d.on ? 1 : -1; + onChange(lit, lit + ' / 64 lit'); + }); + } + onChange(lit, lit + ' / 64 lit'); + return { el: s, get: () => lit }; +}; + +/* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the + color swap at the midpoint; click a disc to flip it */ +DUPRE.flipDisc = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const COLS = 7, ROWS = 5, STEP = 14, X0 = 13, Y0 = 13; + const glyph = opts.glyph || ['0000100', '0000010', '1111111', '0000010', '0000100']; /* arrow right */ + const s = stageSvg(host, 'rsvg press', 120, 92); + svgEl(s, 'rect', { x: 2, y: 2, width: 116, height: 88, rx: 7, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 }); + let lit = 0; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + const el = svgEl(s, 'circle', { cx: X0 + c * STEP + STEP / 2 - 2, cy: Y0 + r * STEP + STEP / 2 - 2, r: 5.6, stroke: '#060505', 'stroke-width': .8 }); + el.style.transformBox = 'fill-box'; el.style.transformOrigin = 'center'; el.style.transition = 'transform .11s ease-in'; + el.style.cursor = 'pointer'; + const d = { el, on: glyph[r][c] === '1' }; + el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); if (d.on) lit++; + el.addEventListener('click', () => { + d.on = !d.on; lit += d.on ? 1 : -1; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); } + else { + el.style.transform = 'scaleX(0)'; + setTimeout(() => { el.setAttribute('fill', d.on ? '#e3d44f' : '#141210'); el.style.transform = 'scaleX(1)'; }, 115); + } + onChange(lit, lit + ' discs showing'); + }); + } + onChange(lit, lit + ' discs showing'); + return { el: s, get: () => lit }; +}; + +/* R46 dekatron counting ring — each click pulses the neon glow one cathode + around; the wrap flashes the carry dot */ +DUPRE.dekatron = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 120, 120), cx = 60, cy = 60; + svgEl(s, 'circle', { cx, cy, r: 52, fill: '#171310', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 46, fill: '#0d0a08', stroke: '#2a221c', 'stroke-width': 1 }); + svgEl(s, 'ellipse', { cx: cx - 14, cy: cy - 18, rx: 20, ry: 11, fill: 'rgba(255,255,255,.05)', transform: `rotate(-24,${cx - 14},${cy - 18})` }); + const dots = []; + for (let i = 0; i < 10; i++) { + const [x, y] = polar(cx, cy, 34, i * 36); + svgEl(s, 'text', { + x: polar(cx, cy, 43, i * 36)[0], y: polar(cx, cy, 43, i * 36)[1] + 2, 'text-anchor': 'middle', 'font-size': 5.2, + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = String(i); + dots.push(svgEl(s, 'circle', { cx: x, cy: y, r: 3.4, fill: '#3a2a20' })); + } + const carry = svgEl(s, 'circle', { cx, cy: cy - 6, r: 3, fill: '#2a1c14' }); + svgEl(s, 'text', { + x: cx, y: cy + 8, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.12em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'CARRY'; + let pos = opts.count || 0, carries = 0; + const draw = () => { + dots.forEach((d, i) => { + const on = i === pos; + d.setAttribute('fill', on ? '#ff9a4c' : '#3a2a20'); + d.setAttribute('filter', on ? 'drop-shadow(0 0 5px rgba(255,154,76,.9))' : 'none'); + }); + onChange(pos, 'COUNT ' + pos + (carries ? ' · CARRY x' + carries : '')); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => { + pos = (pos + 1) % 10; + if (pos === 0) { + carries++; carry.setAttribute('fill', '#ff9a4c'); + carry.setAttribute('filter', 'drop-shadow(0 0 4px rgba(255,154,76,.9))'); + setTimeout(() => { carry.setAttribute('fill', '#2a1c14'); carry.removeAttribute('filter'); }, 220); + } + draw(); + }); + draw(); + return { el: s, get: () => pos }; +}; + +/* R47 landing gear indicator — three greens or nothing; the lever cycles + down → transit (amber pulse) → up */ +DUPRE.gearIndicator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 140, 110); + svgEl(s, 'rect', { x: 2, y: 2, width: 136, height: 106, rx: 9, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + const POS = [['NOSE', 48, 28], ['L', 24, 74], ['R', 72, 74]]; + const lamps = {}; + POS.forEach(([n, x, y]) => { + svgEl(s, 'circle', { cx: x, cy: y, r: 9, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1.5 }); + lamps[n] = svgEl(s, 'circle', { cx: x, cy: y, r: 6.2, fill: '#16240f' }); + svgEl(s, 'text', { + x, y: y + 18, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = n; + }); + /* gear lever: slot + wheel-shaped knob */ + svgEl(s, 'rect', { x: 106, y: 22, width: 8, height: 66, rx: 4, fill: '#0d0b09', stroke: '#2c2820', 'stroke-width': 1 }); + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'rect', { x: 107.5, y: 36, width: 5, height: 26, fill: '#8f8a7e' }); + svgEl(lever, 'circle', { cx: 110, cy: 32, r: 8, fill: '#26221c', stroke: '#4a463e', 'stroke-width': 2.4 }); + svgEl(lever, 'circle', { cx: 110, cy: 32, r: 3, fill: '#4a463e' }); + svgEl(s, 'text', { + x: 110, y: 100, 'text-anchor': 'middle', 'font-size': 5.2, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'GEAR'; + let state = opts.state || 'down', busy = false; + const paint = () => { + const c = state === 'down' ? 'var(--pass)' : state === 'transit' ? 'var(--amber-warn)' : '#16240f'; + for (const n of Object.keys(lamps)) { + lamps[n].setAttribute('fill', c); + lamps[n].setAttribute('filter', state === 'down' ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' : + state === 'transit' ? 'drop-shadow(0 0 4px rgba(var(--glow-lo),.7))' : 'none'); + lamps[n].style.animation = state === 'transit' ? 'pulse var(--pulse-rate) ease-in-out infinite' : 'none'; + } + lever.setAttribute('transform', state === 'up' ? 'translate(0,-8)' : state === 'transit' ? 'translate(0,-4)' : ''); + onChange(state, state === 'down' ? 'GEAR DOWN · 3 GREEN' : state === 'up' ? 'GEAR UP' : 'IN TRANSIT'); + }; + s.style.cursor = 'pointer'; + s.addEventListener('click', () => { + if (busy) return; + const target = state === 'down' ? 'up' : 'down'; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { state = target; paint(); return; } + busy = true; state = 'transit'; paint(); + setTimeout(() => { state = target; busy = false; paint(); }, 1500); + }); + paint(); + return { el: s, get: () => state }; +}; + +/* R52 blinkenlights front panel — address/data lamps ripple with a live + pseudo-PC (builder-owned clock, reduced-motion gated) that folds in the + switch register; flip the SR bits and the pattern changes */ +DUPRE.blinkenlights = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 170, 100); + svgEl(s, 'rect', { x: 2, y: 2, width: 166, height: 96, rx: 8, fill: '#3a1420', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 8, y: 8, width: 154, height: 62, rx: 5, fill: '#14090e' }); + const mkRow = (y, lbl) => { + const row = []; + svgEl(s, 'text', { + x: 12, y: y + 2.6, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', + fill: '#b08a96' + }).textContent = lbl; + for (let i = 0; i < 12; i++) row.push(svgEl(s, 'circle', { cx: 46 + i * 10, cy: y, r: 3, fill: '#2a1210' })); + return row; + }; + const addr = mkRow(24, 'ADDR'), data = mkRow(46, 'DATA'); + const paint = (row, val) => row.forEach((d, i) => { + const on = (val >> (11 - i)) & 1; + d.setAttribute('fill', on ? '#ffb43a' : '#2a1210'); + d.setAttribute('filter', on ? 'drop-shadow(0 0 3px rgba(255,180,58,.8))' : 'none'); + }); + /* switch register: 8 mini toggles */ + let sr = opts.sr !== undefined ? opts.sr : 0b10100101; + const toggles = []; + for (let i = 0; i < 8; i++) { + const x = 46 + i * 14; + svgEl(s, 'rect', { x: x - 3, y: 76, width: 6, height: 16, rx: 3, fill: '#0d0b09', stroke: '#4a2432', 'stroke-width': 1 }); + const t = svgEl(s, 'circle', { cx: x, cy: 80, r: 4, fill: '#8f8a7e', stroke: '#3c382f', 'stroke-width': 1 }); + t.style.cursor = 'pointer'; + const hit = svgEl(s, 'rect', { x: x - 7, y: 72, width: 14, height: 24, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { sr ^= (1 << (7 - i)); draw(); }); + toggles.push(t); + } + svgEl(s, 'text', { + x: 12, y: 83, 'font-size': 4.6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', + fill: '#b08a96' + }).textContent = 'SR'; + let pc = 0o1234; + const draw = () => { + toggles.forEach((t, i) => t.setAttribute('cy', ((sr >> (7 - i)) & 1) ? 76 : 84)); + onChange(sr, 'SR ' + sr.toString(8).padStart(3, '0') + ' (octal)'); + }; + const tick = () => { pc = ((pc * 5 + sr + 1) & 0xFFF); paint(addr, pc); paint(data, (pc ^ (pc << 3) ^ sr) & 0xFFF); }; + draw(); tick(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(tick, 250); + return { el: s, get: () => sr, tick }; +}; + +/* ---- instrument CSS: injected once, grows as builders move in ---- */ +/* instrument-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */ +const DUPRE_CSS = ` +/* ---- shared primitives ---- */ +.dupre-lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.dupre-lamp.dupre-gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} +.dupre-lamp.dupre-red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.dupre-lamp.dupre-off{background:var(--wash);box-shadow:none} +.dupre-lamp.dupre-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}} + +/* slide toggle — after the iPod-hold-switch pill: dome thumb, revealed state engraving. + Flex layout with fixed px only: absolute insets and stretched widths misrender under stage zoom. */ +.switch{display:inline-flex;vertical-align:middle;align-items:center;justify-content:space-between; + width:52px;height:23px;border-radius:12px;padding:2px 7px 2px 2px;cursor:pointer; + background:var(--sw-off-bg,linear-gradient(180deg,#15130f,#242019)); + border:1px solid var(--sw-off-brd,var(--slate)); + box-shadow:inset 0 1.5px 3px rgba(0,0,0,.55)} +.switch::before{content:"OFF";order:2;font-size:7px;letter-spacing:.06em;font-weight:700; + font-family:var(--mono);color:var(--sw-off-ink,var(--cream))} +.switch::after{content:"";order:1;width:17px;height:17px;border-radius:50%; + background:var(--sw-thumb,radial-gradient(circle at 35% 28%,#f6f2e8,#b0aa9a)); + box-shadow:0 1px 2.5px rgba(0,0,0,.65),inset 0 1px 0 rgba(255,255,255,.25)} +.switch.on{background:var(--sw-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold))); + border-color:var(--sw-on-brd,var(--gold-hi)); + padding:2px 2px 2px 7px;box-shadow:inset 0 1.5px 3px rgba(0,0,0,.3)} +.switch.on::before{content:"ON";order:1;color:var(--sw-on-ink,var(--panel))} +.switch.on::after{order:2} +.switch.red{background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail);padding:2px 2px 2px 7px} +.switch.red::before{content:"OFF";order:1;color:var(--cream)} +.switch.red::after{order:2} + +/* keypad / index-plate focus — both take keys, so each must show when it is + the one listening: an unlit focus state means typing vanishes into a card + you thought was live. + :focus, not :focus-visible — Chrome won't match :focus-visible on a + mouse-driven focus of a non-text element, and clicking the plate IS how it + gets focus here, so the ring would have appeared only when tabbed to. */ +.dupre-kp-pad,.dupre-ix-pad{outline:none} +.dupre-kp-pad:focus,.dupre-ix-pad:focus{outline:2px solid var(--gold-hi);outline-offset:3px;border-radius:9px} + +/* console keys */ +.dupre-key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + 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)} +.dupre-key:hover{color:var(--gold);border-color:var(--gold)} +.dupre-key:active{transform:translateY(1px)} +.dupre-key.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700} +.dupre-key.dupre-green{color:var(--cream);background:linear-gradient(180deg,#9cbf5e,var(--pass));border-color:var(--pass);font-weight:700} +.dupre-key.dupre-red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} + +/* chip toggle */ +.dupre-chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px} +.dupre-chip.dupre-on{color:var(--chip-on,var(--gold));border-color:var(--chip-on,var(--gold))} + +.dupre-badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} +.dupre-badge.dupre-red{background:var(--fail);color:var(--cream)} +.dupre-badge.dupre-ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} + +/* fader */ +.dupre-fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none} +.dupre-fader .dupre-slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.dupre-fader .dupre-fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold))} +.dupre-fader .dupre-cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; + 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 */ +.dupre-vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none} +.dupre-vfader .dupre-slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.dupre-vfader .dupre-fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,var(--amber-grad-bot),var(--gold))} +.dupre-vfader .dupre-cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px; + background:linear-gradient(90deg,var(--amber-grad-mid),var(--amber-grad-top));border:1px solid var(--amber-edge)} + +/* rotary knob */ +.dupre-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)} +.dupre-knob .dupre-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(var(--glow-hi),.6)} + +/* needle gauge */ +.dupre-gauge{width:96px;cursor:ns-resize;touch-action:none} +.dupre-gauge .dupre-dial{position:relative;height:48px;overflow:hidden} +.dupre-gauge .dupre-arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%} +.dupre-gauge .dupre-tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px} +.dupre-gauge .dupre-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)} +.dupre-gauge .dupre-hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)} +.dupre-gauge .dupre-gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums} + +/* segmented VU / LED bar */ +.dupre-vu{width:170px;display:flex;flex-direction:column;gap:5px} +.dupre-vurow{display:flex;align-items:center;gap:7px} +.dupre-vurow .dupre-ch{color:var(--steel);font-size:.6rem;width:8px} +.dupre-vubar{flex:1;display:flex;gap:2px;height:9px} +.dupre-vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} +.dupre-vubar i.dupre-on{opacity:1;background:var(--pass)}.dupre-vubar i.dupre-hot{opacity:1;background:var(--gold)} +.dupre-vubar i.dupre-clip{opacity:1;background:var(--fail)}.dupre-vubar i.dupre-peak{outline:1px solid var(--gold-hi);outline-offset:-1px} + +/* mini 4-bar signal */ +.dupre-sig{display:flex;align-items:flex-end;gap:2px;height:18px} +.dupre-sig i{width:4px;background:var(--wash);border-radius:1px} +.dupre-sig i:nth-child(1){height:5px}.dupre-sig i:nth-child(2){height:9px}.dupre-sig i:nth-child(3){height:13px}.dupre-sig i:nth-child(4){height:17px} +.dupre-sig i.dupre-on{background:var(--pass)}.dupre-sig i.dupre-hot{background:var(--gold)}.dupre-sig i.dupre-clip{background:var(--fail)} + +/* signal ladder (wifi bars) */ +.dupre-ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer} +.dupre-ladder i{width:5px;background:var(--wash);border-radius:1px} +.dupre-ladder i:nth-child(1){height:6px}.dupre-ladder i:nth-child(2){height:10px} +.dupre-ladder i:nth-child(3){height:14px}.dupre-ladder i:nth-child(4){height:18px} + +/* linear progress / fuel bar */ +.dupre-bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none} +.dupre-bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px} +.dupre-bar.dupre-warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} + +/* radial ring */ +.dupre-ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none; + background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative} +.dupre-ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.dupre-ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums} + +/* tabular readout */ +.dupre-readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer} +.dupre-readout small{color:var(--dim);font-size:12px;font-weight:400} +.dupre-unit{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px} + +/* sparkline */ +.dupre-spark{width:170px;height:44px} +.dupre-spark svg{display:block;width:100%;height:100%} + +/* lamp row (list item) */ +.dupre-lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px} +.dupre-lrow:hover{background:var(--wash)} +.dupre-lrow .dupre-who{color:var(--silver)}.dupre-lrow .dupre-who b{color:var(--cream)} +.dupre-lrow .dupre-what{margin-left:auto;color:var(--dim);font-size:11px} + +/* arm-to-fire */ +.dupre-arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; + border-radius:8px;padding:7px 12px} +.dupre-arm.dupre-armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)} + +/* stepper / segmented selector */ +.dupre-seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.dupre-seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer} +.dupre-seg button:last-child{border-right:0} +.dupre-seg button.dupre-on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700} + +/* engraved section label */ +.dupre-engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer; + display:flex;align-items:center;gap:9px} +.dupre-engrave::before,.dupre-engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.dupre-engrave::before{max-width:10px} +.dupre-engrave .dupre-cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} + +/* waveform strip */ +.dupre-wave{width:170px;height:38px} +.dupre-wave svg{width:100%;height:100%;display:block} + +/* toast */ +.dupre-toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px;cursor:pointer} + +/* output well (log step) */ +.dupre-owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer} +.dupre-ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0} +.dupre-ostep .dupre-lamp{margin-top:3px;width:7px;height:7px} +.dupre-ostep b{color:var(--cream);font-weight:700}.dupre-ostep .dupre-ev{color:var(--steel);display:block;font-size:10.5px} + +/* rotary selector */ +.dupre-rotsel{position:relative;width:118px;height:74px} +.dupre-rotsel>.dupre-knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer} +.dupre-rotsel .dupre-pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em} +.dupre-rotsel .dupre-pos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} + +/* slide-rule tuner dial */ +.dupre-tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer; + background:var(--tn-bg,linear-gradient(180deg,#191510,#0b0908));border:1px solid var(--tn-brd,#2a251c); + box-shadow:inset 0 0 20px var(--tn-glow,rgba(var(--glow-lo),.12)),inset 0 1px 0 rgba(255,255,255,.03)} +.dupre-tuner .dupre-tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)} +.dupre-tuner .dupre-tick.dupre-mn{height:6px;opacity:.5} +.dupre-tuner .dupre-mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px} +.dupre-tuner .dupre-mk.dupre-on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))} +.dupre-tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px} +.dupre-tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px} +.dupre-tuner .dupre-ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px; + background:var(--tn-ndl,var(--fail));box-shadow:0 0 7px var(--tn-ndlglow,rgba(203,107,77,.85));transition:left .25s} + +/* nixie tube */ +.dupre-nixie{display:inline-flex;gap:5px;cursor:pointer} +.dupre-nixie .dupre-tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden; + background:radial-gradient(circle at 50% 38%,#241a12,#0b0807);border:1px solid #2c261d; + display:grid;place-items:center;box-shadow:inset 0 0 12px rgba(0,0,0,.6)} +.dupre-nixie .dupre-tube b{font-size:26px;font-weight:400;color:#ff9a3c; + text-shadow:0 0 6px rgba(255,140,50,.85),0 0 15px rgba(255,120,40,.45)} +.dupre-nixie .dupre-tube.dupre-off b{color:#3a2a1c;text-shadow:none} +.dupre-nixie .dupre-tube::after{content:"";position:absolute;inset:0;pointer-events:none; + background:linear-gradient(180deg,rgba(255,255,255,.06),transparent 32%)} + +/* ===== candidate-unique CSS ===== */ +/* rocker */ +.dupre-rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative; + background:linear-gradient(180deg,#25211c,#141210);border:1px solid #34302a; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5);overflow:hidden} +.dupre-rocker .dupre-half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center; + font-size:12px;letter-spacing:.06em;color:var(--dim)} +.dupre-rocker .dupre-top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)} +.dupre-rocker .dupre-bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)} +.dupre-rocker.dupre-on .dupre-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)} +.dupre-rocker.dupre-on .dupre-bot{color:var(--steel)} +.dupre-rocker:not(.dupre-on) .dupre-top{color:var(--steel)} +.dupre-rocker:not(.dupre-on) .dupre-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)} + +/* transport */ +.dupre-reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px} +.dupre-reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative; + background:radial-gradient(circle at 45% 40%,#211d18,#0d0b09)} +.dupre-reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310} +.dupre-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} +.dupre-reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)} +.dupre-reel i:nth-child(2){transform:rotate(120deg)}.dupre-reel i:nth-child(3){transform:rotate(240deg)} +.dupre-transport{display:flex;gap:5px} +.dupre-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} +.dupre-tbtn:hover{color:var(--gold);border-color:var(--gold)} +.dupre-tbtn.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)} +.dupre-tbtn.dupre-rec.dupre-on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} + +/* radio bank */ +.dupre-radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden; + background:#0c0b0a;padding:3px;box-shadow:inset 0 2px 4px rgba(0,0,0,.5)} +.dupre-preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px; + background:linear-gradient(180deg,#211d19,#161310);box-shadow:inset 0 1px 0 rgba(255,255,255,.04)} +.dupre-preset+.dupre-preset{margin-left:3px} +.dupre-preset:hover{color:var(--silver)} +.dupre-preset.dupre-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 */ +.dupre-dualknob{width:64px;height:64px;position:relative} +.dupre-dualknob .dupre-outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none; + background:radial-gradient(circle at 40% 35%,#2a2622,#100e0c);border:1px solid #3a352c; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.dupre-dualknob .dupre-outer .dupre-tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px} +.dupre-dualknob .dupre-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)} +.dupre-dualknob .dupre-inner .dupre-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(var(--glow-hi),.6)} + +/* rotary encoder + LED ring */ +.dupre-encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none} +.dupre-encoder .dupre-led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash); + left:50%;top:50%;margin:-2.5px} +.dupre-encoder .dupre-led.dupre-on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)} +.dupre-encoder .dupre-knob{width:40px;height:40px;cursor:ns-resize} +.dupre-encoder .dupre-knob .dupre-ind{transform-origin:50% 15px;top:4px;height:12px} + +/* keyed mode switch */ +.dupre-keylock{position:relative;width:110px;height:70px} +.dupre-keylock .dupre-body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%; + background:radial-gradient(circle at 42% 36%,#2a2622,#0f0d0b);border:1px solid #3a352c;cursor:pointer; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.dupre-keylock .dupre-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)} +.dupre-keylock .dupre-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(var(--glow-hi),.55);transition:transform .2s} +.dupre-keylock .dupre-kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)} +.dupre-keylock .dupre-kpos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} + +/* crossfader */ +.dupre-xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none} +.dupre-xfader .dupre-slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18} +.dupre-xfader .dupre-detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7} +.dupre-xfader .dupre-end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)} +.dupre-xfader .dupre-cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s; + 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 */ +.dupre-thumbw{display:flex;align-items:center;gap:10px} +.dupre-thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden; + border:1px solid #34302a;box-shadow:inset 0 0 8px rgba(0,0,0,.6); + background:repeating-linear-gradient(0deg,#0e0c0a 0 2px,#221e19 2px 3px,#2f2a23 3px 5px,#221e19 5px 6px)} +.dupre-thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))} +.dupre-thumbw .dupre-win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums; + background:var(--well);border:1px solid #231f18;border-radius:4px;padding:3px 8px} + +/* DIP bank */ +.dupre-dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px; + box-shadow:inset 0 1px 3px rgba(0,0,0,.5)} +.dupre-dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42} +.dupre-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} +.dupre-dipsw.dupre-on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))} + +/* jog / shuttle */ +.dupre-jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none} +.dupre-jog .dupre-shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713; + background:conic-gradient(from -90deg,#3a352c 0 var(--sh,40deg),#141210 0 360deg); + box-shadow:inset 0 0 8px rgba(0,0,0,.6)} +.dupre-jog .dupre-inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear; + background:radial-gradient(circle at 42% 36%,#2c2822,#100e0c);border:1px solid #3a352c;box-shadow:0 2px 5px rgba(0,0,0,.6)} +.dupre-jog .dupre-dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%; + background:radial-gradient(circle at 40% 35%,#151210,#000);box-shadow:inset 0 1px 2px rgba(0,0,0,.9)} + +/* oscilloscope — screen-family vars with the original green as fallback */ +.dupre-scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden; + background:radial-gradient(circle at 50% 45%,var(--scr-bgc,#04140a),var(--scr-bge,#020a05)); + border:1px solid var(--scr-brd,#123018); + box-shadow:inset 0 0 16px rgba(0,0,0,.7)} +.dupre-scope .dupre-grat{position:absolute;inset:0;opacity:.5; + background-image:linear-gradient(var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px),linear-gradient(90deg,var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px); + background-size:22px 19.5px} +.dupre-scope .dupre-grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))} +.dupre-scope svg{position:absolute;inset:0;width:100%;height:100%} +.dupre-scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))} + +/* voice-loop keyset */ +.dupre-vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px} +.dupre-vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em; + text-align:center;padding:6px 4px 4px;cursor:pointer; + color:var(--dim);background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-radius:4px; + font-family:var(--mono)} +.dupre-vk .dupre-vk-bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f} +.dupre-vk.dupre-mon{color:var(--silver)} +.dupre-vk.dupre-mon .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)} +.dupre-vk.dupre-mon.dupre-act .dupre-vk-bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)} +.dupre-vk.dupre-tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold)); + border-color:var(--gold-hi);font-weight:700} +.dupre-vk.dupre-tlk .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)} + +/* spectrum / EQ */ +.dupre-eq{display:flex;align-items:flex-end;gap:3px;height:60px} +.dupre-eq .dupre-band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%} +.dupre-eq .dupre-band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3} +.dupre-eq .dupre-band i.dupre-on{opacity:1;background:var(--pass)} +.dupre-eq .dupre-band i.dupre-hot{opacity:1;background:var(--gold)} +.dupre-eq .dupre-band i.dupre-clip{opacity:1;background:var(--fail)} + +/* crossed-needle */ +.dupre-crossm{width:120px} +.dupre-crossm .dupre-face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none} +.dupre-crossm .dupre-crossm-arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%} +.dupre-crossm .dupre-fwd{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(var(--glow-hi),.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} +.dupre-crossm .dupre-rfl{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)} +.dupre-crossm .dupre-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} + +/* thermometer */ +.dupre-thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none} +.dupre-thermo .dupre-thermo-tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.dupre-thermo .dupre-thermo-fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))} +.dupre-thermo .dupre-bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px; + box-shadow:0 0 6px rgba(203,107,77,.5)} +.dupre-thermo .dupre-scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)} +.dupre-thermo .dupre-wrapcol{position:relative;padding-bottom:9px} + +/* bourdon */ +.dupre-bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none; + background:radial-gradient(circle at 48% 42%,#1d1a16,#0c0b09);border:2px solid #2c261d; + box-shadow:inset 0 2px 5px rgba(0,0,0,.6)} +.dupre-bourdon svg{position:absolute;inset:0} +.dupre-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(var(--glow-hi),.55);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} +.dupre-bourdon-hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)} +.dupre-bourdon-cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)} + +/* strip-chart */ +.dupre-strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden; + background:#0c0e0f;border:1px solid #231f18} +.dupre-strip .dupre-rule{position:absolute;inset:0;opacity:.4; + background-image:linear-gradient(90deg,rgba(150,147,133,.14) 1px,transparent 1px);background-size:16px 100%} +.dupre-strip svg{position:absolute;inset:0;width:100%;height:100%} +.dupre-strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4} +.dupre-strip .dupre-pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi); + box-shadow:0 0 6px rgba(var(--glow-hi),.8);transition:top .08s linear} + +/* correlation */ +.dupre-corr{width:150px;cursor:pointer;touch-action:none} +.dupre-corr-face{position:relative;height:44px;overflow:hidden} +.dupre-corr-arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%} +.dupre-corr .dupre-zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px} +.dupre-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(var(--glow-hi),.5);transition:transform .1s ease-out} +.dupre-corr-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} + +/* battery */ +.dupre-batt{display:flex;align-items:center;cursor:pointer;touch-action:none} +.dupre-batt .dupre-cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10} +.dupre-batt .dupre-cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35} +.dupre-batt .dupre-cell.dupre-on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)} +.dupre-batt .dupre-cell.dupre-warn.dupre-on{background:linear-gradient(180deg,var(--fail),#a04a34)} +.dupre-batt .dupre-nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0} + +/* split-flap — four clipped half-panels per cell; the fold happens on + .ftc (falls) and .fbn (lands), driven by the builder's WAAPI flips */ +.flap{display:inline-flex;gap:4px;cursor:pointer} +.flapd{width:30px;height:42px;border-radius:5px;position:relative; + background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5); + perspective:220px;transform-style:preserve-3d} +.flapd .fh{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream); + text-shadow:0 1px 2px rgba(0,0,0,.7);backface-visibility:hidden;transform-origin:50% 50%} +.flapd .ftn,.flapd .ftc{clip-path:inset(0 0 50% 0)} +.flapd .fbc,.flapd .fbn{clip-path:inset(50% 0 0 0)} +.flapd .fbn{transform:rotateX(180deg)} +.flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;z-index:5; + background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)} +/* multi-row board: column of flap lines */ +.flap-rows{flex-direction:column;gap:5px} +.flapline{display:inline-flex;gap:4px} +/* white skin — the dark board with true-white ink (the default ink is cream) */ +.flap-white .flapd .fh{color:#fff} +/* helvetica — the grotesque face real Solari boards wore */ +.flap-helv .flapd .fh{font-family:Helvetica,'Helvetica Neue',Arial,sans-serif;font-weight:600} +/* paper skin — true-white cards, dark lettering */ +.flap-paper .flapd{background:linear-gradient(180deg,#ffffff,#e6e6e3);border-color:#b6b6b2} +.flap-paper .flapd .fh{color:#17181a;text-shadow:0 1px 0 rgba(255,255,255,.5)} +.flap-paper .flapd::after{background:rgba(0,0,0,.28);box-shadow:0 1px 0 rgba(255,255,255,.35)} +/* light skin — the inverse board: ivory flaps, near-black ink, softer crease */ +.flap-light .flapd{background:linear-gradient(180deg,#f7eed3,#dccfa8);border-color:#a89c7e} +.flap-light .flapd .fh{color:#1b1813;text-shadow:0 1px 0 rgba(255,255,255,.45)} +.flap-light .flapd::after{background:rgba(0,0,0,.3);box-shadow:0 1px 0 rgba(255,255,255,.3)} + +/* seven-segment */ +.dupre-seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer; + box-shadow:inset 0 0 10px rgba(0,0,0,.6)} +.seg7{width:22px;height:40px;filter:drop-shadow(0 0 3px rgba(87,211,87,.55))} +.seg7.red{filter:drop-shadow(0 0 3px rgba(226,84,63,.55))} +.dupre-seven .dupre-colon{align-self:center;display:flex;flex-direction:column;gap:9px} +.dupre-seven .dupre-colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)} + +/* DSKY verb/noun panel */ +.dupre-dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px} +.dupre-dsky-lamps{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px} +.dupre-dsky-sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center; + background:#141210;color:#5c574c;border-radius:2px;border:1px solid #26221c} +.dupre-dsky-sl.dupre-on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700; + box-shadow:0 0 6px rgba(var(--glow-lo),.5)} +.dupre-dsky-right{display:flex;flex-direction:column;gap:6px} +.dupre-dsky-wins{display:flex;gap:8px} +.dupre-dsky-win{display:flex;flex-direction:column;align-items:center;gap:2px} +.dupre-dsky-wl{font-size:6px;letter-spacing:.14em;color:var(--steel)} +.dupre-dsky-wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px} +.dupre-dsky-wd .seg7{width:13px;height:24px} +.dupre-dsky-win.dupre-hot .dupre-dsky-wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)} +.dupre-dsky-pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px} +.dupre-dsky-pad .dupre-key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em} + +/* VFD marquee */ +.dupre-vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer; + background:linear-gradient(180deg,#04100e,#020807);border:1px solid #123028;box-shadow:inset 0 0 12px rgba(0,0,0,.6)} +.dupre-vfdm .dupre-txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em; + color:var(--vfd);text-shadow:0 0 6px rgba(99,230,200,.65)} +.dupre-vfdm .dupre-mesh{position:absolute;inset:0;pointer-events:none;opacity:.35; + background-image:radial-gradient(rgba(0,0,0,.6) 40%,transparent 41%);background-size:3px 3px} + +/* annunciator */ +.dupre-annwrap{display:flex;flex-direction:column;gap:6px} +.dupre-annbar{display:flex;gap:5px;align-items:center} +.dupre-mc{font-size:7px;letter-spacing:.08em;padding:4px 8px;border-radius:3px;background:#141210;color:#5c574c; + border:1px solid #26221c;text-align:center} +.dupre-mc.dupre-on{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream);font-weight:700; + box-shadow:0 0 8px rgba(203,107,77,.5)} +.dupre-mc.dupre-on.dupre-fl{animation:pulse var(--pulse-rate) ease-in-out infinite} +.dupre-annbar .dupre-key{padding:3px 7px;font-size:7.5px;border-radius:4px} +.dupre-annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px} +.dupre-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} +.dupre-acell.dupre-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)} +.dupre-acell.dupre-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 */ +.dupre-jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer; + background:radial-gradient(circle at 36% 30%,rgba(255,255,255,.75),var(--jc) 42%,#3a0d08 100%); + box-shadow:0 0 10px 1px var(--jc),inset 0 -2px 3px rgba(0,0,0,.5)} +.dupre-jewel::after{content:"";position:absolute;inset:0;border-radius:50%; + background:conic-gradient(from 0deg,rgba(255,255,255,.12) 0 22deg,transparent 22deg 45deg,rgba(0,0,0,.18) 45deg 67deg,transparent 67deg 90deg); + background-repeat:repeat} +.dupre-jewel.dupre-dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)} + +/* tape counter */ +.dupre-counter{display:inline-flex;gap:2px;padding:4px;background:#0c0b0a;border:1px solid #2c261d;border-radius:4px; + box-shadow:inset 0 1px 3px rgba(0,0,0,.6)} +.dupre-cwheel{width:18px;height:34px;border-radius:2px;position:relative;overflow:hidden; + background:linear-gradient(180deg,#efe9d6,#c7c0ac);box-shadow:inset 0 0 3px rgba(0,0,0,.3)} +.dupre-cwheel .dupre-col{position:absolute;left:0;right:0;text-align:center;color:#1a1613;font-size:18px;font-weight:700;line-height:34px; + transition:top .35s cubic-bezier(.4,1.4,.5,1)} +.dupre-cwheel .dupre-col span{display:block;height:34px} +.dupre-cwheel::before,.dupre-cwheel::after{content:"";position:absolute;left:0;right:0;height:9px;z-index:2;pointer-events:none} +.dupre-cwheel::before{top:0;background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)} +.dupre-cwheel::after{bottom:0;background:linear-gradient(0deg,rgba(0,0,0,.4),transparent)} +.dupre-counter .dupre-redw{background:linear-gradient(180deg,#e7b46a,#cf9440)} + +/* analog clock */ +.dupre-clock{width:78px;height:78px;border-radius:50%;position:relative; + background:radial-gradient(circle at 46% 40%,#1c1915,#0c0b09);border:2px solid #2c261d;box-shadow:inset 0 2px 5px rgba(0,0,0,.6)} +.dupre-clock .dupre-tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px} +.dupre-clock .dupre-hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream); + transform-origin:50% 100%;border-radius:2px} +.dupre-clock .dupre-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} +.dupre-clock .dupre-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(var(--glow-hi),.5)} +.dupre-clock .dupre-pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)} + +/* frequency-dial scale */ +.dupre-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(var(--glow-lo),.1)} +.dupre-freqscale .dupre-tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)} +.dupre-freqscale .dupre-mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px} +.dupre-freqscale .dupre-fs-band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em} +.dupre-freqscale .dupre-fptr{position:absolute;top:3px;bottom:14px;width:2px;margin-left:-1px;border-radius:1px; + background:var(--fail);box-shadow:0 0 7px rgba(203,107,77,.85)} + +/* patch-bay */ +.dupre-patch{padding:7px 9px;background:#141210;border:1px solid #2c261d;border-radius:5px;position:relative} +.dupre-patch .dupre-row{display:flex;gap:9px} +.dupre-patch .dupre-row+.dupre-row{margin-top:9px} +.dupre-patch .dupre-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)} +.dupre-patch .dupre-jack.dupre-hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)} +.dupre-patch .dupre-jack.dupre-sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)} +.dupre-patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%} +.dupre-patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round} + +/* ===== reference-batch (R) instruments — SVG-first, after period hardware ===== */ +.rsvg{display:block} +.rsvg.drag{cursor:ns-resize;touch-action:none} +.rsvg.press{cursor:pointer} + +/* transport reels spin (was a page-side keyframe injector) */ +@keyframes reelspin{to{transform:rotate(360deg)}} +.dupre-reel.dupre-spin{animation:reelspin 2.6s linear infinite} +`; +function ensureCss() { + if (document.getElementById('dupre-css') || !DUPRE_CSS) return; + const st = document.createElement('style'); st.id = 'dupre-css'; st.textContent = DUPRE_CSS; + document.head.appendChild(st); +} +if (document.head) ensureCss(); +else document.addEventListener('DOMContentLoaded', ensureCss); + +/* Policy classification (see DUPRE.POLICIES). This is the colour pass's worklist made + explicit: every card gets a record here as we review it — the kind it's bound + by, why in its own terms, and the range it may vary and stay authentic. This + round covers the cards already touched; the rest are unclassified on purpose, + the review still to do card by card, not a memory dump to fill now. Assigned + after the builders exist, in one place, so it reads as a catalogue. */ +DUPRE.slideToggle.POLICY = { kind: 'accent', + why: 'On means good in one panel and a fault in the next, so the lit colour is the consumer’s claim, not the instrument’s.', + authentic: 'The on-colour from the accent family (red, amber, green, white, vfd), plus the pill and thumb finishes. The switch form stays.' }; +DUPRE.segmented.POLICY = { kind: 'accent', + why: 'The active segment signals a mode whose meaning depends on the panel it sits in.', + authentic: 'The accent colour of the active segment. The segment count and labels are the deployment’s, not a colour choice.' }; +DUPRE.chipToggle.POLICY = { kind: 'accent', + why: 'A filter chip’s on-state reads as good, warning or fault entirely by where it is used.', + authentic: 'The lit colour from the accent family. The chip keeps its inline weight and dotted underline.' }; +DUPRE.dataMatrix.POLICY = { kind: 'screen', + why: 'LED and LCD dot-matrix modules were sold in several emitter colours, so no one colour is definitive.', + authentic: 'The screen family (amber, green, red, blue, vfd, white) — each a panel that was actually built. Not an arbitrary hue.' }; +DUPRE.roundCrt.POLICY = { kind: 'screen', + why: 'CRT phosphors were manufactured in more than one colour (P1 green, P3 amber, white), so the trace colour is a real choice.', + authentic: 'The screen family, limited to phosphors that existed. The face tint follows the phosphor.' }; +DUPRE.waveRegion.POLICY = { kind: 'screen', + why: 'A backlit editor LCD was made in several tints; the ink colour is the panel’s, not fixed.', + authentic: 'The screen family. The waveform and region handles recolour with the screen, staying legible on it.' }; +DUPRE.radarSweep.POLICY = { kind: 'screen', + why: 'PPI radar scopes ran amber and green phosphors both; neither is the one true colour.', + authentic: 'The screen family, phosphors that shipped. The sweep and afterglow track the chosen screen.' }; +DUPRE.abcKeypad.POLICY = { kind: 'screen', + why: 'The entry window is a screen like any other; its phosphor was made in several colours. (Mixed card: the keys are fixed-function and not a colour choice.)', + authentic: 'The window’s screen family. The keycap colours are functional and stay put.' }; + +Object.assign(DUPRE, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS }); +window.DUPRE = DUPRE; +})(); diff --git a/docs/specs/2026-07-02-desktop-settings-panel-spec.org b/docs/specs/2026-07-02-desktop-settings-panel-spec.org index 50853f3..17fa0e6 100644 --- a/docs/specs/2026-07-02-desktop-settings-panel-spec.org +++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org @@ -136,7 +136,7 @@ other things" into this panel, but the extras weren't enumerated. Current control list (above): auto-dim, idle/caffeine, touchpad, mouse, airplane, screen brightness, keyboard backlight. Candidates raised or adjacent — confirm which belong here vs the audio panel vs the bar: night-light / color -temperature, a theme/dupre-vs-hudson switch (theme-studio kin), volume or a +temperature, volume or a master-mute mirror (or leave all audio to the audio panel), a notifications/do-not-disturb toggle (dunst), lock/suspend actions. Craig to name the set. diff --git a/docs/specs/2026-07-02-timer-panel-spec.org b/docs/specs/2026-07-02-timer-panel-spec.org index 275bb2c..e5f0f4e 100644 --- a/docs/specs/2026-07-02-timer-panel-spec.org +++ b/docs/specs/2026-07-02-timer-panel-spec.org @@ -127,7 +127,7 @@ panel proves out. * Build scope (decided design — folds the prototype-3 redesign into the shipped =timer/= package) -The panel is the existing =timer/= dotfiles package (src-layout, GTK4 + gtk4-layer-shell, humble-object PanelModel, instrument-console faceplate). wtimer stays the state engine; the panel is a view over it. This rebuild reshapes the layout (see Layout sketch) and adds the per-type functionality below. UI idioms draw from the widget gallery (=docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html=); the reference build is prototype-3. +The panel is the existing =timer/= dotfiles package (src-layout, GTK4 + gtk4-layer-shell, humble-object PanelModel, instrument-console faceplate). wtimer stays the state engine; the panel is a view over it. This rebuild reshapes the layout (see Layout sketch) and adds the per-type functionality below. UI idioms draw from the widget gallery (=docs/prototypes/panel-widget-gallery.html=); the reference build is prototype-3. Queue + primary: - Up to 10 items, auto-sorted by soonest fire time (four buckets: active countdown < paused countdown < active stopwatch < paused stopwatch). The soonest-firing is the hero/primary (the bar glyph slot). Promote via a row's promote key or by cycling ‹ ›; cycling and promotion include stopwatches and wrap around the whole list. diff --git a/docs/specs/2026-07-07-maintenance-console-spec.org b/docs/specs/2026-07-07-maintenance-console-spec.org index 9810fe8..1044cfc 100644 --- a/docs/specs/2026-07-07-maintenance-console-spec.org +++ b/docs/specs/2026-07-07-maintenance-console-spec.org @@ -88,7 +88,7 @@ Temps, memory, and throttle re-read every ~3 s while visible; local probes every *Refresh tiers* (all engine-side, GUI subscribes): live group ~3 s panel-open + subpanel-visible; fast local ~30 s panel-open + post-action re-probe; hydration on open (fast reads first, process probes behind, sub-second perceived); network tier from the hourly timer cache, on-demand refresh only; *slow local tier* — probes whose cost class matches the network tier despite being local (full =pacman -Qkk=, measured 47 s on ratio; the disk top-consumers scan) ride the same hourly timer cache with age shown, on-demand refresh only, never in hydration or fast-local; closed-panel glyph fed by a ~30-min systemd user timer running =maint scan --glyph= to write the state file =waybar-maint= reads. Two user units ship with the package: =maint-scan.timer= (glyph) and =maint-net-scan.timer= (hourly network-tier + slow-local cache). -*Waybar wiring.* =custom/maint= replaces =custom/sysmon= in the modules list and config; glyph state file + SIGRTMIN signal refresh like =custom/ptt=. Gotcha (from the PTT build): waybar runs a *generated runtime config* — the module lands via =waybar-active-config= + SIGUSR2, not just a canonical config edit. Theme CSS mirrored in both themes (dupre, hudson). +*Waybar wiring.* =custom/maint= replaces =custom/sysmon= in the modules list and config; glyph state file + SIGRTMIN signal refresh like =custom/ptt=. Gotcha (from the PTT build): waybar runs a *generated runtime config* — the module lands via =waybar-active-config= + SIGUSR2, not just a canonical config edit. Theme CSS lives in Dupre and mirrors the live Waybar stylesheet. *Cross-links, not duplicates.* DNS/NetworkManager repair deep-links to the net panel's doctor; OPEN JOURNAL launches a terminal running =journalctl -p err -b= (the NET DOCTOR delegation pattern). @@ -229,7 +229,7 @@ Round-1 (verified live): every other assumed tool present; =arch-audit= is not. Round-1: the hourly network-tier/slow-local cache must survive reboots (else "no data" for up to an hour after boot) — =$XDG_RUNTIME_DIR= wrong for it. Resolved: added to the open config-paths decision; proposal now puts glyph state + cache under =~/.local/state/maint/=. ** DONE Panel-CSS "both themes" ambiguity -Round-1 (code fact): sibling panels load =themes/dupre/panel.css= hardcoded (=audio/gui.py:52-61=); hudson has no panel.css. Resolved: Phase 7 follows the sibling dupre-only panel-CSS convention; Phase 11 scopes both-themes CSS to the waybar glyph stylesheets. +Round-1 (code fact): sibling panels load =themes/dupre/panel.css= hardcoded (=audio/gui.py:52-61=). Resolved: Phase 7 follows that shared Dupre panel-CSS convention; Phase 11 scopes glyph CSS to the Dupre/live Waybar stylesheet pair. * Implementation phases @@ -254,7 +254,7 @@ DNS/NM reachability, firewall state (+ public-bind exposure naming when down), l =remedies.py= (full allowlisted table with =re_probe_ids= + running-% for long ops), =priv.py= verb table, =guard.py= live-update matcher (pure), curation writes (mark/unmark/clear/keep/expected), =maint fix <id> [--dry-run]=, =maint doctor clean|review= streaming an event feed (text wall in the terminal). Topgrade wrapper with =--disable git=. KILL's four guards. Argv-construction tests for every remedy; no execution. Splits naturally into 6a (remedies/priv/guard) + 6b (curation writes + doctor + CLI) if the session runs long — 6a alone leaves a working tree. ** Phase 7 — GUI shell -GTK4 window, faceplate, category selector row (lamps + fixable/watch splits), subpanel scaffolding, hydration tiers + refresh cadence (live/fast, visibility-gated like the audio meters), =MAINT_PANEL_FIXTURE= loading, panel CSS following the sibling convention (=themes/dupre/panel.css=, the shared panel stylesheet — hudson carries waybar CSS only, no panel.css). Renders real =status= data read-only — no levers yet. +GTK4 window, faceplate, category selector row (lamps + fixable/watch splits), subpanel scaffolding, hydration tiers + refresh cadence (live/fast, visibility-gated like the audio meters), =MAINT_PANEL_FIXTURE= loading, panel CSS following the sibling convention (=themes/dupre/panel.css=, the shared panel stylesheet). Renders real =status= data read-only — no levers yet. ** Phase 8 — GUI subpanels: storage, snapshots, packages, updates strip Evidence digests, armed per-item keys (orphan REMOVE/KEEP, pacnew MERGE, DELETE STALE, scrub with running-%), the Packages rotary band selector, updates strip with state-tiered border, UPDATE/TOPGRADE behind the arming guard, armed REBOOT on completion. @@ -266,7 +266,7 @@ Failed-unit roster keys, journal digest + MARK KNOWN lifecycle UI + OPEN JOURNAL The wall widget (lamp stream, date+time, inline results, running-%, HIDE/COPY, 3.5-entry cap, dark scrollbar), CLEAN UP / REVIEW & FIX wired through =doctor.py=, every armed key streaming, curation events logged, post-action re-probes visible. ** Phase 11 — Waybar glyph + timers -=indicator.py= + =waybar-maint=, =custom/maint= replacing =custom/sysmon= (canonical config + =waybar-active-config= runtime path + SIGUSR2), signal-driven refresh, =maint-scan.timer= + =maint-net-scan.timer= user units, glyph color tracks worst diagnostic from the state file; on battery hosts the module text is the live battery level (sysfs read per waybar interval, charging indicator, low-charge threshold feeding the diagnostic state) and any existing battery module retires. Glyph CSS lands in both theme waybar stylesheets (dupre, hudson). Retirement collateral in the same commit (Craig ruled 2026-07-07): =custom/maint= right-click re-homes the btop scratchpad (=pypr toggle monitor=); the =waybar-sysmon= + =sysmon-cycle= scripts and their suites (=tests/waybar-sysmon/=, =tests/sysmon-cycle/=) retire, and the =#custom-sysmon= CSS blocks come out of =waybar/style.css= and both themes. +=indicator.py= + =waybar-maint=, =custom/maint= replacing =custom/sysmon= (canonical config + =waybar-active-config= runtime path + SIGUSR2), signal-driven refresh, =maint-scan.timer= + =maint-net-scan.timer= user units, glyph color tracks worst diagnostic from the state file; on battery hosts the module text is the live battery level (sysfs read per waybar interval, charging indicator, low-charge threshold feeding the diagnostic state) and any existing battery module retires. Glyph CSS lands in the Dupre source and live Waybar stylesheet. Retirement collateral in the same commit (Craig ruled 2026-07-07): =custom/maint= right-click re-homes the btop scratchpad (=pypr toggle monitor=); the =waybar-sysmon= + =sysmon-cycle= scripts and their suites (=tests/waybar-sysmon/=, =tests/sysmon-cycle/=) retire, and the =#custom-sysmon= CSS blocks come out of =waybar/style.css= and Dupre. ** Phase 11b — Prototype fidelity pass (added 2026-07-08) Added mid-build from Craig's live-board review: phases 7-10 delivered E5's structure and behavior but rendered subpanel metrics as one-line list rows (the sibling panels' idiom) instead of the prototype's instrument-card grid; the per-phase screenshot checks verified structure, not presentation. Scope: card-grid subpanels (4-up metric cards — big-number value, caption line, progress bars, radial gauges for scrub cadence and NVMe wear, status chips, corner lever key), two-row selector tiles with compact count chips, subpanel attention/ok/fixable/watch header line, evidence digests as full-width rosters under the grid. Engine and viewmodel contracts unchanged; =gui.py= layout + =panel.css=. Sequenced before Phase 12 so the AT-SPI smoke targets the final layout. Verification is a pixel-level comparison against the settled E5 render (headless Chrome), not structural spot checks. diff --git a/docs/specs/2026-07-10-audio-doctor-input-side-spec.org b/docs/specs/2026-07-10-audio-doctor-input-side-spec.org index 4a62c83..0e7e529 100644 --- a/docs/specs/2026-07-10-audio-doctor-input-side-spec.org +++ b/docs/specs/2026-07-10-audio-doctor-input-side-spec.org @@ -209,6 +209,28 @@ Three entries carry no remedy on purpose, and they are not the same kind of no. Remedies 1 and 6 both restart WirePlumber, and that is deliberate: =no-output-devices= and =mic-unrecognized= are different diagnoses that happen to share a fix. Collapsing them would couple two faults that should read apart, the same call the parent spec made for =pulse-hung= and =pulse-down=. +* Verdict clusters and remedy classes + +The verdicts and remedies above are the v1 core. Their full shape comes from a failure taxonomy built and saturation-tested after the first draft: [[file:../design/2026-07-10-audio-failure-taxonomy.org][docs/design/2026-07-10-audio-failure-taxonomy.org]], a catalogue of 74 input and 77 output real, user-reported failure modes. A blind resample (108 fresh reports through different sources, none shown the structure) forced zero new clusters, so the taxonomy is representative, not an artifact of sampling. It sorts every failure into nine symptom clusters per direction, and those clusters are what the doctor's verdicts name. + +** The clusters are the verdict structure, and they map to the probe ladder + +The nine input clusters are: (1) no capture device at all, (2) device present but records silence, (3) works but too quiet, (4) works then dies or drops, (5) the server lost a device the hardware has, (6) Bluetooth mic, (7) stack healthy but an app can't hear, (8) hardware/physical, (9) environment/config. Output mirrors this, swapping the mic-specific clusters for speaker-vs-headphone jack routing, HDMI/DisplayPort, and a degraded-audio (crackle/xrun) cluster. + +The clusters are not arbitrary. Seven of the nine are the tiers the doctor's probe ladder already separates (units, graph, pulse, semantic, kernel), and the two it cannot fully see (cluster 7 app-layer, and part of cluster 8 hardware) are exactly the ones that resolve to Guide. So the boundary between what the doctor fixes and what it can only guide falls along the tiers it already probes. The v1 verdicts in the table above are the subset of these clusters the current ladder reaches; the rest are staged below. + +** Remedy classes come from the privilege decision + +Every remedy carries one of four classes, resolved at run time (see the decision "The doctor may use sudo, resolved by context at run time"): Auto, Privileged, Reboot-tail, Guide. The taxonomy tags every entry, so the buildable share is known — a little over half of each direction. The Auto core reduces to five primitives (=set-default=, =set-card-profile=, unmute, restart-services, config-drop-in-plus-restart) applied across roughly fifty diagnoses, so the breadth is in the diagnosis, not the remedies. + +** Three new probes the taxonomy implies + +Read-only and bounded, in the spirit of the kernel tier: + +- *dmesg-pattern hints* (cluster 1). So "no capture hardware" can name the specific cause — firmware-load-fail, NHLT-missing, codec-probe-timeout — instead of a generic verdict, and the Guide can print the right command. +- *the unmute-doesn't-stick signature* (cluster 8). Issue an unmute, re-probe; a mute that will not clear is a hardware switch, not software. This is what tells an Auto (software mute) from a Guide (flip the physical switch). +- *re-probe-after-idle* (cluster 4). The current single-shot probe misses the suspend and autosuspend "works then dies" cases; a second read after an idle window catches them. + * Alternatives Considered ** Rebuild push-to-talk so it does not mute the mic @@ -407,6 +429,17 @@ Retire the DOCTOR header row and its DIAGNOSE key. OUTPUTS and INPUTS each gain ** TODO Phase 4 — flip this spec to IMPLEMENTED And amend the parent spec's history to record that its DOCTOR-key decision was superseded here. +** Later phases — the taxonomy expansion (vNext) +v1 (phases 0-4) ships the buildable Auto core and the Guide tail for the clusters the current probe ladder already separates, and flips this spec to IMPLEMENTED. The rest of the taxonomy ([[file:../design/2026-07-10-audio-failure-taxonomy.org][the taxonomy]]) is staged as later phases so v1 is not blocked on them. Each is a small, independent addition; the taxonomy is the backlog and this list is the order: + +- The dmesg-hint probe and the firmware/driver Guide tail (cluster 1). +- The unmute-doesn't-stick signature probe and the hardware-layer Guide (cluster 8). +- Re-probe-after-idle for the suspend and autosuspend cases (cluster 4). +- The Privileged and Reboot-tail remedy machinery: the run-time privilege resolution (=sudo -n= / tty / GUI detection) and the Confirm/Arm-default floor. +- The app-layer Guide tail (clusters 7 and 9): the tail on a healthy verdict that names the likely app or portal cause and prints the fix. + +These grow into their own spec when picked up. + * Acceptance criteria - A machine with no capture hardware, under a press of the input doctor, reports =no-mic-hardware= and offers nothing to press. diff --git a/docs/specs/2026-07-11-bt-doctor-expansion-spec.org b/docs/specs/2026-07-11-bt-doctor-expansion-spec.org new file mode 100644 index 0000000..6fcc6ed --- /dev/null +++ b/docs/specs/2026-07-11-bt-doctor-expansion-spec.org @@ -0,0 +1,251 @@ +#+TITLE: Bt Doctor Expansion — name the blob, catch the boot-disable +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-11 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Bt Doctor Expansion +:PROPERTIES: +:ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7 +:END: +- [2026-07-12 Sun @ 09:12 -0500] IMPLEMENTED — all v1 phases shipped on dotfiles main: Phase 0 read-only probes (=d19fdca=), Phase 1 firmware-hint Guide verdict (=f05a9b4=), Phase 2 persistent-power verdict + fix through the shared panelkit privilege model (=d7d859f=). 287 bt tests + 65 suites green against the fake harness. The live half (real reboot persistence) is handed off to the "Manual testing and validation" checklist in =todo.org= — findings there come back as bugs, not spec work. vNext items logged to =todo.org=. +- [2026-07-11 Sat @ 02:30 -0500] DOING — decomposed into build tasks (spec-response Phase 6); parent task in =todo.org= bound by =:SPEC_ID:=. Phases 0-1 (the two read-only probes + the firmware-hint Guide verdict) are buildable now and =:solo:=; Phase 2 (the persistent fix) is gated on the shared cross-panel privilege model. +- [2026-07-11 Sat @ 02:00 -0500] READY — third skeptical re-review returned Ready with caveats, no blocking findings; all round-2 resolutions verified against the engine. Caveat accepted: Phase 2 (the persistent fix) depends on the shared cross-panel privilege model, which doesn't exist yet; Phase 0 (both read-only probes) and Phase 1 (the firmware-hint Guide) are buildable today. +- [2026-07-11 Sat @ 01:45 -0500] DRAFT — round-2 review + response. A skeptical re-review caught a real blocker: the AutoEnable default is =true=, not =false= (verified, bluez 5.87), so round 1's "dead every boot on absent config" premise was backwards and would fire a false positive on healthy machines. Corrected — the fault fires only on explicit AutoEnable=false / disabled service / TLP. Also folded three non-blocking corrections (the =code= key is additive-not-existing; the INI-setter is real Phase 2 work; the sequencing caveat now actually in Risks). Findings =[8/8]=, decisions =[3/3]=. Awaiting a third re-review. +- [2026-07-11 Sat @ 01:20 -0500] DRAFT — review incorporated (spec-response). All four findings dispositioned (=[4/4]=), all three decisions accepted and closed (=[3/3]=). The verdict-representation blocker resolved by defining a "verdict" as a step outcome in the existing status model. +- [2026-07-11 Sat @ 00:59:30 -0500] DRAFT — reviewed (spec-review). Stays DRAFT: three decisions open plus one =:blocking:= finding (the bt doctor has no named-verdict layer, so the proposed =no-adapter-firmware=/=powered-off-persistent= verdicts need their representation defined first). Design confirmed against the live engine — the two target gaps (kernel-log firmware hint, boot-persistence read) genuinely do not exist yet. Findings in =* Review findings=. +- [2026-07-11 Sat @ 00:08:41 -0500] DRAFT — drafted. Extends the existing bt doctor (=~/.dotfiles/bluetooth/=, shipped) using the bluetooth half of the failure taxonomy ([[file:../design/2026-07-10-net-bt-failure-taxonomy.org][2026-07-10-net-bt-failure-taxonomy.org]]). Grounded in a read of the live engine, not memory. + +* Metadata + +| Field | Value | +|----------+-----------------------------------------------------------------------------------| +| Status | implemented | +|----------+-----------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+-----------------------------------------------------------------------------------| +| Reviewer | Craig Jennings | +|----------+-----------------------------------------------------------------------------------| +| Related | [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][net/bt failure taxonomy]] ; the cross-panel run-time-privilege and copy+close tasks | +|----------+-----------------------------------------------------------------------------------| + +* Summary + +The bt doctor walks a clean chain — adapter → rfkill → bluetooth.service → powered → per-device → audio profile — and applies four safe repairs (unblock, power-on, service-restart, a2dp). The failure taxonomy shows the chain is structurally right but blind in two specific spots the user hits most: when there is no adapter it says "no adapter found" without naming *why* (a missing firmware blob the dmesg log already names), and it never checks whether the adapter is configured to power on at boot, so an =AutoEnable=false= laptop that is dead every login reads as a healthy powered-off adapter. This spec adds those two probes and adopts the cross-panel privilege model. It is an expansion of a working doctor. + +* Problem / Context + +The taxonomy sorted ~55 real bluetooth failure modes into five clusters keyed to the doctor's chain. Read against the live engine (=~/.dotfiles/bluetooth/src/bt/doctor.py=), the doctor already handles cluster 2 well (unblock, service-restart, power-on are its exact auto-fix tiers) and cluster 5 partially (the =a2dp= repair forces the A2DP profile for a card stuck in HSP/HFP or with no sink). Clusters 3 (pairing) and 4 (connection stability) are deliberately light — the doctor treats connecting and pairing as user intents, not health repairs, and that is a correct design choice, not a gap. The two real gaps are at the ends of the chain. + +** "No adapter found" doesn't say why (cluster 1) + +=_adapter_step= reports a missing adapter as "no Bluetooth adapter found (hardware/driver)" and stops. But the taxonomy's cluster 1 is almost entirely *firmware* faults, and the kernel already logged the specific cause: "Direct firmware load for intel/ibt-…sfi failed", "mediatek/BT_RAM_CODE_MT7961… failed", "BCM: Patch …hcd not found", "Reading QCA version information failed". The doctor has that log a =journalctl -k= read away and doesn't consult it. A user who sees "no adapter — hardware/driver" is left to search; a user who sees "the Intel firmware blob ibt-20-1-4.sfi failed to load — update linux-firmware and reboot" has the fix. This is the direct parallel to the audio doctor's cluster-1 dmesg-hint proposal, and the same read-only, bounded shape. + +** A boot-disabled adapter reads as merely powered-off (cluster 2) + +=_powered_step= checks whether the adapter is powered *now* and offers =power-on=. But =power-on= via bluetoothctl doesn't persist: on a machine with =AutoEnable=false= explicitly set (bluez's compiled default is =true= — verified in bluez 5.87's =/etc/bluetooth/main.conf= — so an absent or unset config auto-enables, and the fault is an explicit opt-out, not the missing-config case), or a TLP laptop that disables bluetooth on startup, the adapter is dead again next boot. The doctor fixes the symptom every session and never names the cause. The taxonomy's cluster 2 has a whole sub-family here — AutoEnable explicitly off, service-not-enabled-at-boot, TLP-disables-on-startup, systemd-rfkill-restores-a-stale-block — that all present as "powered off / blocked" and all need a *persistent* fix the doctor doesn't distinguish from a one-shot power-on. + +** Pairing and connection clusters stay light on purpose (clusters 3, 4) + +The doctor doesn't auto-pair or auto-connect, and the destructive re-pair is always user-confirmed. That is right. The one addition worth considering is a *stale-bond signature* — a device that fails to connect with a bond present, repeatedly — so the doctor can *offer* the re-pair with confidence instead of the user guessing. That is a candidate, held as a decision, not a v1 commitment. The rest of clusters 3/4 (physical range, USB3 noise, coexistence, connection parameters) are Guide-only and mostly out of a health doctor's reach. + +* Goals and Non-Goals + +** Goals + +- When there is no adapter, name the specific firmware/driver cause by reading the kernel log, and print the matching fix — instead of a generic "hardware/driver." +- Distinguish a *persistently* disabled adapter (AutoEnable off, service not enabled at boot, TLP-disabled) from a merely powered-off one, and offer the persistent fix rather than a one-shot power-on that dies next boot. +- Adopt the cross-panel run-time privilege model for the new root-needing repairs (editing main.conf's AutoEnable, =systemctl enable=, editing tlp.conf), under the Confirm/Arm-default floor. + +** Non-Goals + +- Auto-pairing or auto-connecting devices. Connecting and pairing are user intents; the doctor's existing stance stands. +- Fixing the firmware itself. The dmesg-hint probe *names* the missing blob and prints the update/reboot instruction; it does not install firmware or rebuild initramfs inside a diagnose run (that is a Reboot-tail Guide the user runs). +- The physical/coexistence connection-stability faults (USB3 noise, range, 2.4GHz coexistence, connection parameters). These are Guide-only and stay out of the health chain. +- Silent privileged action. Editing main.conf, enabling the service, or touching tlp.conf all default to Confirm/Arm, never silent Auto. + +** Scope tiers + +- *v1:* the dmesg firmware-hint probe for the no-adapter case (cluster 1); the boot-enablement probe distinguishing AutoEnable-off / service-not-enabled / TLP-disabled from a plain power-off (cluster 2), with the persistent fix; adoption of the run-time privilege model. +- *Out of scope:* auto-pair/auto-connect; firmware installation inside a run; the physical/coexistence connection faults. +- *vNext:* the stale-bond re-pair-offer signature (cluster 3, pending the decision below); a connection-parameter/coexistence hint tail for a "keeps dropping" verdict (cluster 4); the bluetooth-specific audio-profile expansion beyond the current a2dp repair (codec fallback, default-sink, absolute-volume — several overlap the audio taxonomy and want coordination with the audio doctor). All logged to =todo.org=. + +* Design + +** For the user + +Two verdicts that today under-inform start telling the user the actual cause. + +When there is no adapter, the wall stops at "no adapter" no longer. It names the blob and the fix: "the MediaTek BT firmware (BT_RAM_CODE_MT7961) failed to load — update linux-firmware and reboot," pulled straight from the kernel log the doctor now reads. When the log shows a clean absence (no firmware error, genuinely no controller), it says that instead — the same fail-honest distinction the audio doctor draws between "the server lost a device" and "nothing is attached." + +When the adapter is powered off, the doctor asks a second question: is it *supposed* to be on at boot? If AutoEnable is off, or bluetooth.service isn't enabled, or TLP is disabling it on startup, the verdict names that — "your adapter powers off every boot because AutoEnable is false" — and FIX offers the persistent fix (set AutoEnable, enable the service) rather than a power-on that will be undone by the next reboot. A plain "it's off right now" still gets the quick power-on. + +** For the implementer + +*** How the new "verdicts" are represented + +The bt doctor has no verdict-enum layer like the net doctor's =classify= actions. A step result today carries a fixed key set — =id=, =status= (=pass=/=fail=/=warn=/=info=), =title=, =evidence=, =elapsed_ms=, =safety=, =next_action= (=schema.step=) — and the run rolls the statuses up into an =overall= (=ok=/=warn=/=fail=, =doctor.py:182-183=). So a "verdict" in this spec is not a new =overall= value — it is a distinct step *outcome*: the same =status= with a specific message and evidence, and a =next_action= carrying the fix for the persistent case. The human distinction rides in the existing =evidence= and =next_action= fields — =format_doctor_human= renders =evidence= (=cli.py:94=), so the named blob or persistence cause needs no formatter change. If a stable machine identifier is wanted so =--json= consumers can branch on the outcome without string-matching, that is an *additive* =code= key on =schema.step= — and because the step schema is a locked test contract, adding it ripples into any test asserting exact step shape, so it is called out here rather than assumed. =_adapter_step= and =_powered_step= already own their results; the new probes add branches inside them. Nothing touches the =overall= vocabulary or =AUTO_FIX= tiers — the additions stay inside the existing step-status model rather than inventing a parallel verdict system. + +*** The dmesg firmware-hint probe + +When =_adapter_step= finds no adapter, run a bounded =journalctl -k -b --no-pager= (or =dmesg=) read, scanned for the known firmware-load-failure signatures per vendor (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom BCM .hcd, Qualcomm QCA version-read). A match sets the =no-adapter-firmware= code on the adapter step, naming the blob and the Reboot-tail Guide (update linux-firmware / symlink the blob / reboot). No match keeps the existing generic no-adapter outcome. The read reuses the engine's existing bounded pattern — =cmd.run(journalctl -u bluetooth -n 1)= already runs for service-log evidence (=doctor.py:84=, 5s timeout) — so the kernel-log read is the same bounded shape, read-only, and runs only in the no-adapter branch, costing nothing on a healthy adapter. + +*** The boot-enablement probe + +When =_powered_step= finds the adapter powered off (or =_rfkill_step= finds a soft-block), consult three persistence signals: bluez =AutoEnable= (parse =/etc/bluetooth/main.conf= [Policy]), =systemctl is-enabled bluetooth=, and whether TLP's =DEVICES_TO_DISABLE_ON_STARTUP= lists bluetooth. The fault fires only on an *explicit* boot-disable: =AutoEnable=false= set in main.conf (bluez's compiled default is =true=, so an absent or unset key means auto-enable-on and is never the fault), a =disabled= bluetooth.service, or a TLP entry. A powered-off adapter with one of those gets a =powered-off-persistent= outcome distinct from the plain =powered-off=; its fix is the persistent one (set AutoEnable / enable the service / edit tlp.conf), Privileged/Confirm. The existing =power-on= stays for the plain case (including the common absent-config machine, which auto-enables by default). + +*** The privilege model + +The new repairs — editing main.conf, =systemctl enable bluetooth=, editing tlp.conf — are root-needing and adopt the cross-panel run-time model: run where passwordless sudo exists, prompt on a tty, default to Confirm/Arm, never silent Auto. Same shared implementation as the net and audio doctors. =priv.py= today exposes exactly one privileged verb, =restart-bluetooth=, via a plain verb→argv dispatch (=priv.py:25-34=); the three new fixes add verbs to it, each a narrowly-scoped operation — set the single =AutoEnable= key, enable one named unit, set the single TLP key — not a general edit-file-as-root. The passwordless-sudo grant widens by three tight verbs, not by a root file-editor, so the Confirm floor is the second guard rather than the only one. One caveat the implementer must not miss: =systemctl enable bluetooth= is a clean argv verb, but the two config edits are not. main.conf is INI — setting =AutoEnable= needs an idempotent setter that creates the [Policy] section when absent and preserves comments (a shipped helper or crudini). tlp.conf is a flat =KEY=VALUE= file — the fix removes =bluetooth= from the =DEVICES_TO_DISABLE_ON_STARTUP= list, a list-edit, not a section-create. Both are real Phase 2 work, not one-line argv verbs. The existing four auto-fix tiers (unblock, power-on, service-restart, a2dp) are unchanged — they are already user-scope or already the doctor's safe tier. + +* Alternatives Considered + +** Print a generic "check firmware" hint without reading the log + +- Good, because it needs no journal read. +- Bad, because "check your firmware" is exactly the uselessly-generic verdict this fixes; the whole value is naming the specific blob the kernel already identified. +- Rejected. The log has the answer; read it. + +** Make power-on always persistent (set AutoEnable on every power-on) + +- Good, because the adapter would then stay on across boots without a separate verdict. +- Bad, because it conflates a deliberate boot-disable (a user or TLP choosing bluetooth-off-by-default for battery) with a fault, and silently overrides a policy the user may have set on purpose. Persistence is a distinct decision that deserves a distinct, confirmed action. +- Rejected in favor of a separate verdict the user confirms. + +** Build the stale-bond re-pair signature into v1 + +- Good, because it would let the doctor offer re-pair with confidence. +- Bad, because re-pair is destructive (removes the bond) and the signature ("fails to connect with a bond present, repeatedly") needs care to not fire on a merely-out-of-range device; getting it wrong offers a destructive fix for a transient condition. +- Held as an open decision, not a v1 commitment. + +* Decisions [3/3] + +** DONE The dmesg firmware-hint probe +Context: cluster 1 is almost all firmware faults the kernel already logs, and the doctor reports a generic "hardware/driver" instead of the named blob. +Decision: we will read the kernel log in the no-adapter branch, match the known per-vendor firmware-load-failure signatures, and set a =no-adapter-firmware= outcome code naming the blob and its Reboot-tail Guide; no match falls back to the generic no-adapter outcome. +Consequences: the most common no-adapter cause becomes self-explaining; harder — a per-vendor signature table to maintain, and the read must stay bounded and only run in the no-adapter branch so it never slows a healthy run. +Resolution: accepted as proposed (the generic "check firmware" alternative was rejected — the whole value is naming the specific blob). Owner: Craig. + +** DONE The boot-enablement probe and the persistent-vs-transient split +Context: =power-on= doesn't persist, so an AutoEnable-off / not-enabled / TLP-disabled adapter is dead every boot and the doctor fixes only the symptom. +Decision: we will add a persistence probe (AutoEnable, service-enabled, TLP) and a =powered-off-persistent= outcome code distinct from =powered-off=, whose fix is the persistent one under the Confirm floor; the plain power-on stays for the transient case. +Consequences: the boot-disable cause is named and fixed once instead of every session; harder — the doctor must not override a *deliberate* boot-disable, so the persistent fix is always confirmed and the outcome names the specific persistence cause rather than blanket-enabling. +Resolution: accepted as proposed (the always-persistent-power-on alternative was rejected — it would silently override a deliberate battery-saving boot-disable). Round-2 correction: the verdict fires only on an *explicit* AutoEnable=false / disabled service / TLP entry — bluez's compiled default is =true= (verified, bluez 5.87), so absent or unset config is auto-enable-on and never the fault. Owner: Craig. + +** DONE Whether the stale-bond re-pair-offer lands in v1 or vNext +Context: a device failing to connect with a bond present, repeatedly, is a re-pair candidate — but re-pair is destructive and the signature can misfire on an out-of-range device. +Decision: defer to vNext. v1 keeps re-pair strictly user-initiated; the signature is designed and validated before the doctor ever *offers* it. +Consequences: no risk of the doctor offering a destructive fix for a transient condition in v1; harder — the "keeps failing to connect" case stays unnamed for now, which is the one cluster-3 gap a user might reasonably expect the doctor to catch. +Resolution: accepted — deferral confirmed. The "keeps failing to connect" gap is logged to vNext. Owner: Craig. + +* Review findings [8/8] + +** DONE The bt doctor has no named-verdict layer, so "emit a verdict named X" is undefined :blocking: +The spec proposes named verdicts throughout — =no-adapter-firmware=, =powered-off-persistent= "distinct from =powered-off=" — mirroring the net doctor's action-identifier vocabulary. But the bt doctor has no such layer. Verified: a step carries a =status= ∈ =pass/fail/warn/info= and the run has an =overall= ∈ =ok/warn/fail= (=doctor.py:182-183=); there are no verdict identifiers as *names*. So "add a =powered-off-persistent= verdict distinct from =powered-off=" had no existing mechanism to attach to. +Disposition: accepted. Added Design "How the new 'verdicts' are represented": a verdict here is a distinct step *outcome* — the same =status= plus a stable machine =code= and a specific message/evidence, set inside =_adapter_step= / =_powered_step=, rendered by =format_doctor_human= and carried in =--json=. Nothing touches the =overall= vocabulary or =AUTO_FIX= tiers. The Decisions and probe descriptions now speak in "outcome code" terms, so the representation is defined before Phase 1. + +** DONE New root repairs expand the NOPASSWD sudoers surface — name it in rollout +=priv.py= today exposes a single privileged verb, =restart-bluetooth= (=priv.py:27-34=), backed by a NOPASSWD sudoers entry. The three new persistent fixes (edit main.conf, =systemctl enable bluetooth=, edit tlp.conf) are each a new root-capable verb. +Disposition: accepted, modified. Rather than just "name the expansion," the resolution constrains each new verb to a narrowly-scoped operation (set the single =AutoEnable= key, enable one named unit, set the single TLP key) — not a general edit-file-as-root — so the passwordless-sudo grant widens by three tight verbs, not a root file-editor. Folded into the privilege-model design and the Rollout dimension. + +** DONE The diagnostic surface is =bt doctor='s JSON, not a =bt diag= subcommand +Phase 0 said "=bt diag --json= (or the equivalent) shows the new signals." Verified: there is no =bt diag= subcommand; =diagnose()= is the report authority (=doctor.py:174=), =doctor()= wraps it (=:207=), JSON is the default output, and =format_doctor_human= (=cli.py:94=) renders the human view. +Disposition: accepted. Phase 0 now points at the real surface (the doctor's JSON report + =format_doctor_human=) and drops the =bt diag= reference. + +** DONE Firmware-hint probe is new but has a bounded precedent — cite it +Phase 0's =journalctl -k= reader is genuinely new (=_adapter_step= reads nothing beyond =btctl.show=, =doctor.py:46-50=), but the engine already runs a bounded =journalctl -u bluetooth -n 1= for service-log evidence (=doctor.py:84=, timeout 5s via =cmd.run=). +Disposition: accepted. The firmware-hint probe design now cites =doctor.py:84= as the bounded precedent the kernel-log read reuses, so the implementer reaches for the same =cmd.run= shape rather than an unbounded call. + +** DONE Round 2 (skeptical review): the AutoEnable default is true, not false :blocking: +Round 1 asserted (twice) that =AutoEnable=false= is "bluez's static default when main.conf is absent," and the whole "dead every boot" premise rested on it. A skeptical re-review read the installed bluez 5.87 =/etc/bluetooth/main.conf= and found the opposite: "Defaults to 'true'." Verified independently (=#AutoEnable=true= is the commented compiled default, bluez 5.87). An implementer trusting the round-1 text would code default=False and report =powered-off-persistent= falsely on every common healthy machine with no/default config — the exact "targets a non-problem" failure. +Disposition: accepted — a real blocker. Corrected the premise everywhere: the fault now fires only on an *explicit* AutoEnable=false / disabled service / TLP entry, never on config absence. Fixed Problem/Context, the boot-enablement probe design, decision 2, and added an acceptance criterion that absent/default config gets the transient power-on. + +** DONE Round 2 (skeptical review): the machine =code= field doesn't exist on the step schema +Round 1's representation subsection spoke of "a stable machine =code=" as if the step result already had one. The skeptical review confirmed =schema.step= returns a fixed key set (=id=, =status=, =title=, =evidence=, =elapsed_ms=, =safety=, =next_action=) with no =code=, and the step schema is a locked test contract. +Disposition: accepted. The subsection now carries the human distinction in the existing =evidence=/=next_action= fields (no formatter change), and names the optional =code= key as an *additive* schema change with the test-contract ripple called out — not assumed to exist. + +** DONE Round 2 (skeptical review): "single-key set" for main.conf/tlp.conf hides real work +Round 1 called the config-edit verbs "narrowly-scoped" without naming the mechanism. The skeptical review noted =systemctl enable= is a clean argv verb but editing an INI key needs an idempotent setter (create the section if absent, preserve comments), which is more than a one-liner. +Disposition: accepted. The privilege-model design now names the INI-setter requirement and marks it Phase 2 work, distinct from the argv-clean service-enable verb. + +** DONE Round 2 (skeptical review): the shared-privilege-code sequencing caveat was claimed but missing +The round-1 review-history entry said the shared-privilege-model sequencing caveat was "already in Risks," but the Risks section did not mention it, and Phase 1 lands "shared cross-panel" code while =priv.py= is bt-local with one verb today — so Phase 1 isn't independently landable until that shared model ships. +Disposition: accepted. Added the sequencing caveat to Risks, naming Phase 0 (the two read-only probes) as the independently-landable slice and noting the AutoEnable correction makes those probes worth landing on their own. + +* Implementation phases + +Each phase leaves the tree green and independently useful, as the existing bt phases did. + +** DONE Phase 0 — the two read-only probes +Pure engine, no repair changes. The dmesg firmware-hint reader (no-adapter branch) and the boot-enablement reader (AutoEnable / service-enabled / TLP), both reporting into the =diagnose()= report dict. That dict feeds both views: the human summary (the default) and =--json= (=cli.py:113-116=); =format_doctor_human= (=cli.py:94=) renders the new evidence. There is no separate =bt diag= subcommand. Fakes: a canned journal buffer with each vendor's signature, and injected main.conf / =systemctl is-enabled= / tlp.conf states. + +** DONE Phase 1 — the firmware-hint verdict (Guide, no privilege dependency) +=diagnose= emits =no-adapter-firmware= (naming the blob, Reboot-tail Guide) when the signature matches. No auto-fix — this verdict is a Guide, so it needs no privilege model and lands independently of the shared cross-panel code. The run-time privilege resolution is a Phase 2 prerequisite, not this phase. + +** DONE Phase 2 — the persistent-power verdict and its fix (gated on the shared privilege model) +=powered-off-persistent= distinct from =powered-off=; the persistent fix (set AutoEnable / enable service / tlp) registers Privileged/Confirm through =priv.py= (which is one verb today), plus the INI/list setters above. =bt doctor --fix= applies it under the Confirm floor; the plain power-on path is unchanged. Hard ordering gate: this phase must not land before the shared cross-panel run-time privilege model exists — shipping a Privileged fix before the Confirm floor would let =--fix= edit root config via passwordless sudo ungated, the exact outcome the model forbids. + +** DONE Phase 3 — flip this spec to IMPLEMENTED +And log the vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) to =todo.org=. + +* Acceptance criteria + +- [ ] With no adapter and a MediaTek/Intel/Realtek/Broadcom/Qualcomm firmware-load error in the kernel log, the doctor names the specific blob and prints the update-and-reboot Guide. +- [ ] With no adapter and no firmware error in the log, the doctor reports the generic "no controller attached" without inventing a firmware cause. +- [ ] An adapter that is powered off with AutoEnable=false *explicitly set* reports =powered-off-persistent= and FIX offers the persistent fix, not just a one-shot power-on. +- [ ] An adapter powered off with AutoEnable on (a transient off) still gets the quick power-on. +- [ ] An adapter powered off with main.conf absent or AutoEnable unset is treated as auto-enable-on (bluez's compiled default is true) and gets the transient power-on, never =powered-off-persistent=. +- [ ] Every new root-needing repair defaults to Confirm/Arm and never runs silently as Auto. +- [ ] The existing chain (rfkill/service/powered/device/audio) and its four auto-fix tiers classify and repair exactly as today (regression). + +* Readiness dimensions + +- *Data model & ownership* — the diagnose report gains a firmware-hint field (generated from the kernel log) and boot-persistence signals (generated from main.conf / systemctl / tlp.conf). The doctor writes main.conf / tlp.conf / the service enablement only under the Confirm floor. +- *Errors, empty states & failure* — an unreadable journal or main.conf yields "unknown," never a false firmware or persistence verdict. A no-match firmware scan falls back to the existing generic verdict (the safe direction). +- *Security & privacy* — device MACs are already redacted by =redact.py=; the new probes read the kernel log and config files, no secrets. The firmware-signature scan must not surface unrelated journal content. +- *Observability* — the wall names the blob and the persistence cause. The =--json= output carries the new signals. +- *Performance & scale* — the journal read runs only in the no-adapter branch; the persistence read only when powered-off/blocked. Neither touches a healthy run. +- *Reuse & lost opportunities* — reuses the existing chain, =btctl=, =sysio=, =priv.py=, and the shared cross-panel privilege model rather than a bt-local one. =diagnose= stays the single report authority. +- *Architecture fit* — both probes are additive inside existing chain branches (=_adapter_step=, =_powered_step=), not new chain links. Weak point: the firmware-signature table is vendor-specific and will drift as new hardware appears; it degrades to the generic verdict rather than mis-naming, which bounds the risk. +- *Config surface* — none new for the doctor; it *reads* bluez/tlp config and *writes* it only as a confirmed repair. N/A for its own knobs. +- *Documentation plan* — module docstrings, as the package does today. The wall is the user documentation. +- *Dev tooling* — =make test= and the bt panel smoke cover it; the new probes need a canned journal fixture and injected config states, fixture shapes the package can adopt. +- *Rollout, compatibility & rollback* — additive; the existing chain is untouched. The persistent fixes change bluez/tlp config and service enablement, so all are Confirm-tier and reversible by the user. Each new privileged repair adds a narrowly-scoped verb to =priv.py= (single-key sets, one named unit-enable) rather than a general root file-edit, so the passwordless-sudo surface grows by three tight verbs beyond today's single =restart-bluetooth=; the sudoers/priv change ships with the fixes. +- *External APIs & deps* — =journalctl -k=/=dmesg=, =/etc/bluetooth/main.conf=, =systemctl is-enabled=, and =/etc/tlp.conf= layouts are verified against the live system before Phase 0. The per-vendor firmware signatures come from the taxonomy's cluster-1 sources; no new packages. + +* Risks, rabbit holes, and drawbacks + +The firmware-signature table is the maintenance rabbit hole: each vendor logs its failure differently and new hardware adds new strings. The mitigation is that a miss degrades to the existing generic verdict — the doctor is never *worse* than today, only sometimes not-better — so the table can grow incrementally without a correctness cliff. + +The persistent-power fix must not fight a deliberate choice. A user (or TLP on a battery-conscious laptop) may want bluetooth off by default; blanket-enabling AutoEnable would override that silently. The verdict names the specific persistence cause and the fix is always confirmed, so the user sees exactly what would change before it does. + +The bt-audio-profile expansion (vNext) overlaps the audio taxonomy's Bluetooth-mic cluster. When it is picked up, it needs coordination with the audio doctor so the two panels don't both claim the same A2DP/HFP diagnosis with divergent verdicts. Named here so the seam is known. + +The privilege model this spec adopts is shared cross-panel code that does not exist yet — =priv.py= is bt-local today with one verb (=restart-bluetooth=). Phase 1 (which lands the run-time privilege resolution) can only land independently once that shared model ships somewhere; sequence it so whichever panel lands the shared code first, the others depend on it. Until then, Phase 0 (the two read-only probes) is the independently-landable slice, and the AutoEnable-default correction means those probes are worth landing on their own — they add the firmware and boot-persistence *signals* even before any privileged fix exists. + +* Review and iteration history + +** 2026-07-11 Sat @ 00:08:41 -0500 — Craig Jennings — Author +- What: drafted the bt doctor expansion from the bluetooth half of the failure taxonomy. v1 adds the dmesg firmware-hint probe (cluster 1) and the boot-enablement probe (cluster 2); the pairing/connection/audio expansions are staged to vNext. +- Why: the taxonomy showed the doctor's chain is structurally right but blind at its ends — a no-adapter verdict that won't name the firmware blob the kernel already logged, and a power-on that doesn't persist across boots. +- Artifacts: [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][the net/bt failure taxonomy]]; code read across =~/.dotfiles/bluetooth/src/bt/= (=doctor.py=, =repair.py=, =btctl.py=, =sysio.py=, =audio.py=, =priv.py=). + +** 2026-07-11 Sat @ 00:59:30 -0500 — Claude Code (archsetup) — Reviewer +- What: ran spec-review. Rubric =Not ready=. Recorded four findings, one =:blocking:= (no named-verdict layer to attach the proposed verdicts to) and three non-blocking (sudoers-surface expansion, the real diagnostic surface, the bounded firmware-read precedent). The three proposed decisions remain open. +- Why: the design is sound and the code read confirmed both target gaps are real — the engine reads no kernel log for firmware hints and nothing for boot persistence (=main.conf=/=is-enabled=/=tlp.conf= all grep-clean). What holds the rubric is that the spec borrows the net doctor's verdict-naming language, but the bt doctor is step-status-based (=status= pass/fail/warn/info, =overall= ok/warn/fail, =doctor.py:182-183=) with no verdict enum — so how each new "verdict" is represented is undefined. Once that representation is decided and Craig accepts the three decisions, this reaches =Ready with caveats= (shared privilege-model code being the sequencing caveat, already in Risks). +- Artifacts: engine facts verified across =doctor.py= (:25 AUTO_FIX, :46-50 no-adapter branch, :84 bounded journalctl precedent, :100-107 powered step, :174-183 diagnose/overall), =priv.py:27-34= (single =restart-bluetooth= verb), =cli.py:94/173= (formatter + json default), =redact.py:11-20=. Findings in =* Review findings=. + +** 2026-07-11 Sat @ 01:20:00 -0500 — Claude Code (archsetup) — Responder +- What: ran spec-response. Dispositioned all four findings (three accepts, one accept-with-modify: the new privileged verbs are narrowly scoped, not a general root file-editor) and closed all three decisions as accepted. Added Design "How the new 'verdicts' are represented" defining a verdict as a step outcome code in the existing status model; cited the bounded =journalctl= precedent; corrected the Phase 0 diagnostic surface; constrained the sudoers expansion in the privilege-model design and the Rollout dimension. Both =[/]= cookies now read complete. +- Why: convergence toward implementation-ready. The verdict-representation finding was the real blocker — the spec had borrowed net's verdict language onto a step-status engine, so defining "verdict = outcome code" is what makes the two probes implementable without inventing a parallel system. +- Artifacts: findings =[4/4]=, decisions =[3/3]=. Scope expanded only by the tightened privilege-verb obligation, filed in the Rollout dimension, so the readiness rubric holds. Awaiting re-review to flip DRAFT → READY. + +** 2026-07-11 Sat @ 01:45:00 -0500 — Claude Code (archsetup) — Reviewer + Responder (round 2) +- What: a skeptical adversarial re-review returned Not ready with one hard blocker — the AutoEnable default is =true=, not =false= (round 1 had it backwards, verified against bluez 5.87), which would fire =powered-off-persistent= falsely on every healthy default-config machine. Corrected the premise everywhere so the fault fires only on explicit signals. Also fixed three non-blocking issues: the machine =code= is an additive schema change (=schema.step= has no =code= key today), the INI-setter for main.conf/tlp.conf is real Phase 2 work not a one-liner, and the shared-privilege-code sequencing caveat is now actually in Risks (round 1's history claimed it was). Findings now =[8/8]=. +- Why: round 1 rubber-stamped the AutoEnable default without checking the installed bluez. Round 2 verified it directly (=#AutoEnable=true= in =/etc/bluetooth/main.conf=). The correction also strengthens phasing: Phase 0's read-only probes are worth landing on their own, before any privileged fix. +- Artifacts: bluez 5.87 =/etc/bluetooth/main.conf= (AutoEnable default true); =schema.step= (fixed key set, no =code=); =priv.py:25-34= (verb→argv dispatch). Awaiting a third re-review. + +** 2026-07-11 Sat @ 02:00:00 -0500 — Claude Code (archsetup) — Reviewer (round 3) +- What: third skeptical adversarial re-review. Verdict =Ready with caveats=, no blocking findings. Verified all four round-2 resolutions: the AutoEnable premise is now self-consistent (every fault sentence keys on an explicit signal, acceptance criteria cover the absent-config case), the verdict representation is correct against =schema.step= (no =code= key; the distinction rides =evidence=/=next_action=, rendered by =cli.py:98-102= with no formatter change), the INI-setter is concretely named, and Phase 0 is genuinely independently landable. Folded two non-blocking corrections (the human summary is the doctor default not JSON; tlp.conf is a flat list-edit not an INI section) and decoupled Phase 1's Guide-only firmware verdict from the blocked privilege scaffolding. Flipped DRAFT → READY. +- Why: the loop terminates at the rubric. Two skeptical passes plus code re-verification found no remaining blocker. The caveat — Phase 2's persistent fix needs the shared privilege model — is recorded in Risks, Decision 2/the privilege design, and a hard ordering gate on Phase 2; Phases 0 and 1 build today. +- Artifacts: findings =[8/8]=, decisions =[3/3]=. Non-blocking notes folded into wording; no open blocker. diff --git a/docs/specs/2026-07-11-net-doctor-expansion-spec.org b/docs/specs/2026-07-11-net-doctor-expansion-spec.org new file mode 100644 index 0000000..2727702 --- /dev/null +++ b/docs/specs/2026-07-11-net-doctor-expansion-spec.org @@ -0,0 +1,267 @@ +#+TITLE: Net Doctor Expansion — the clusters the ladder doesn't yet name +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-11 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Net Doctor Expansion +:PROPERTIES: +:ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3 +:END: +- [2026-07-12 Sun @ 09:12 -0500] IMPLEMENTED — all v1 phases shipped on dotfiles main: Phase 0 read-only control-plane probe (=21ca3ff=), Phase 1 control-plane verdicts through the shared panelkit privilege model (=d73eba6=, =c9f8604=, =be15a81=), Phase 2 sharpened auth verdict (=12e3e76=). 813 net tests + 65 suites green against the fake harness. The privileged-fix live halves (real masked-NM/rival/keyfile states, real WPA3/hidden profiles) are handed off to the VM scenario harness (archsetup =a364c1e=) and the "Manual testing and validation" checklist in =todo.org= — findings there come back as bugs, not spec work. vNext items logged to =todo.org=. +- [2026-07-11 Sat @ 02:30 -0500] DOING — decomposed into build tasks (spec-response Phase 6); parent task in =todo.org= bound by =:SPEC_ID:=. Phase 0 (read-only control-plane probe) is buildable now and =:solo:=; Phases 1-2 are gated on the shared cross-panel privilege model. The systemic connection-name redaction gap is a separate filed task. +- [2026-07-11 Sat @ 02:00 -0500] READY — third skeptical re-review returned Ready with caveats, no blocking findings; all round-2 resolutions verified against the engine. Caveat accepted: Phases 1-2 depend on the shared cross-panel run-time privilege model, which doesn't exist yet, so only Phase 0 (read-only detection) is buildable today. The redaction de-scope (parity + separate systemic task) is the one product call for Craig's eye. +- [2026-07-11 Sat @ 01:45 -0500] DRAFT — round-2 review + response. A skeptical re-review caught two blockers the first round missed (one I introduced): the redaction "copy/--json surface" does not exist, and =rival-manager= as =needs-user-action= could never run its own fix. Both corrected — parity redaction + systemic gap filed separately; all three control-plane verdicts are =fixable=. Auth signal repointed at profile key-mgmt + scan security; masked-NM early-return path named. Findings =[9/9]=, decisions =[3/3]=. Awaiting a third re-review. +- [2026-07-11 Sat @ 01:20 -0500] DRAFT — review incorporated (spec-response). All five findings dispositioned (=[5/5]=), all three decisions accepted and closed (=[3/3]=). Both cookies complete; awaiting a re-review to flip DRAFT → READY. Redaction blocker resolved (later found wrong in round 2). +- [2026-07-11 Sat @ 00:59:30 -0500] DRAFT — reviewed (spec-review). Stays DRAFT: three decisions open plus one =:blocking:= finding (connection names are not redacted today, contra the spec's Security dimension). Design confirmed against the live engine — the two target gaps (=is-enabled= reads, =system-connections= reads) genuinely do not exist yet, so the expansion targets real ground. Findings in =* Review findings=. +- [2026-07-11 Sat @ 00:08:41 -0500] DRAFT — drafted. Extends the existing net doctor (=~/.dotfiles/net/=, shipped) using the network half of the failure taxonomy ([[file:../design/2026-07-10-net-bt-failure-taxonomy.org][2026-07-10-net-bt-failure-taxonomy.org]]). Grounded in a read of the live engine, not memory. + +* Metadata + +| Field | Value | +|----------+-----------------------------------------------------------------------------------| +| Status | implemented | +|----------+-----------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+-----------------------------------------------------------------------------------| +| Reviewer | Craig Jennings | +|----------+-----------------------------------------------------------------------------------| +| Related | [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][net/bt failure taxonomy]] ; the cross-panel run-time-privilege and copy+close tasks | +|----------+-----------------------------------------------------------------------------------| + +* Summary + +The net doctor is the most mature of the three panel doctors: its probe ladder already walks link → IP → gateway → route → DNS → egress, and its classifier already names most of the taxonomy's failure clusters with the lightest fix. This spec closes the two clusters it does *not* reach — control-plane conflicts (two network managers fighting over one interface, a masked NetworkManager, a keyfile the daemon silently refuses) and the sharper naming of the terminal auth cluster — and adopts the cross-panel run-time privilege model. It is an expansion of a working doctor, not a rewrite. + +* Problem / Context + +The failure taxonomy sorted ~74 real network failure modes into eight symptom clusters. Read against the live classifier (=~/.dotfiles/net/src/net/classify.py=), the net doctor already reaches six of them: cluster 1 (=rfkill=, =manage-device=), cluster 2 (the DHCP-failed path), cluster 3 (=tunnel-down=, =vpn-policy=), cluster 5 (=resolved-restart=, =dns-test=), and cluster 6 (=portal=, =clock-sync=, =proxy=, and the =upstream-not-local= terminal STOP). Those are =classify='s real action identifiers; a few taxonomy labels (no-hardware, DHCP-failed, DNS-not-resolving) are message text the actions carry, not separate verdicts, and =dns-override= is a doctor =FIX_CHAIN=, not a classifier action. The classifier's terminal-first ordering already refuses to loop repairs against a wrong password, a held portal, or a VPN-owned route. That is a lot of the taxonomy, already built. + +Two clusters fall through. + +** The control plane can be broken while the radio looks fine (cluster 7) + +The taxonomy's largest untouched cluster is NetworkManager itself. When =dhcpcd.service= runs beside NM's internal DHCP client, or =systemd-networkd= and NM both claim one link, or =iwd= and =wpa_supplicant= are both active, the interface flaps or never leases — and every existing probe reads a plausible-looking radio with no verdict that names the fight. NM masked (=systemctl start= returns "Unit is masked") reads as "NetworkManager isn't running" today, which points =nm-restart= at a service that cannot start. A =.nmconnection= keyfile that isn't =600= root-owned is silently skipped by the daemon, so a saved network "just won't connect" with no signal the doctor surfaces. These are distinct root causes with distinct fixes, and no current probe detects any of them. The classifier does carry granular control-plane actions (=reset=, =bounce=, =rfkill=, =nm-restart=), but =nm-restart= is narrowly the dead-service restart, and nothing reads =systemctl is-enabled= or the =system-connections= keyfiles — the reads that would surface a masked NM, a rival manager, or a bad keyfile. The gap is missing detection, not one verdict overloaded across three faults. + +** The auth cluster is named too coarsely (cluster 4) + +=classify= detects an auth failure from NM state 120 / GENERAL.REASON and returns =needs-user-action= — correct, and correctly terminal. But the taxonomy shows the auth cluster is not one failure: a pure-WPA3/SAE association failure, a hidden SSID never probed, an enterprise cert mismatch, and a wrong regulatory domain are different faults with different next actions, and some (SAE key-mgmt, =wifi.hidden yes=) are one-line profile fixes rather than "re-enter the password." The doctor collapses them all to one message. + +** The flaky/drops cluster needs signal the one-shot doctor doesn't collect (cluster 8) + +Powersave disconnects, roaming stalls, USB autosuspend, no-reconnect-after-resume, firmware crashloops — these are intermittent, and a single-shot "why am I offline right now" probe cannot see them. Naming them needs event-log correlation the doctor doesn't do. This cluster is real but out of v1; it is named here so the boundary is explicit. + +* Goals and Non-Goals + +** Goals + +- Add a control-plane cluster: a probe that detects a second network/DHCP manager active alongside NetworkManager, a masked/failed NM distinct from a merely-stopped one, and a keyfile-permission fault, each with its own verdict and the lightest fix. +- Sharpen the auth verdict: extract the specific auth cluster cause (SAE, hidden SSID, enterprise cert, regdom) so =needs-user-action= names the real next step, and apply the one-line profile fix where one exists. +- Adopt the cross-panel run-time privilege model (Auto / Privileged / Reboot-tail / Guide, resolved from =sudo -n= + tty + GUI), so control-plane repairs that need root (=systemctl disable dhcpcd=, =chmod= a keyfile, =systemctl unmask=) run under the same Confirm/Arm-default floor the audio doctor defined. + +** Non-Goals + +- Rebuilding the existing ladder. Clusters 1/2/3/5/6 stay exactly as they classify today; this spec only adds where the taxonomy shows a gap. +- The flaky/drops cluster (cluster 8). Naming intermittent faults needs event-log correlation that a one-shot doctor doesn't do. Logged to =todo.org=, not built here. +- Turning the doctor into a NetworkManager profile editor. The auth-cluster fixes are limited to the one-line profile settings that get a stuck association online (SAE key-mgmt, hidden flag); it will not manage certificates, enterprise identities, or credential entry — those stay Guide. +- Silent privileged action. Every Privileged/Reboot-tail control-plane repair defaults to Confirm/Arm, never silent Auto — same stance as the audio spec. + +** Scope tiers + +- *v1:* the control-plane conflict probe and its verdicts (rival-manager, NM-masked, keyfile-perms); sharper auth-cluster reason extraction plus the SAE/hidden one-line fixes; adoption of the run-time privilege model for the new root-needing repairs. +- *Out of scope:* the flaky/drops cluster; certificate/enterprise credential management; any change to the six clusters already classified. +- *vNext:* event-log correlation for cluster 8 (powersave/roam/suspend/autosuspend drop signatures); a DoT/DNSSEC-specific verdict distinguishing "the venue resolver mangles DNSSEC" from the generic DNS-not-resolving; per-profile autoconnect/duplicate-profile hygiene. All logged to =todo.org=. + +* Design + +** For the user + +Nothing changes for the six clusters that already work. What changes is that two failure shapes that today produce a wrong or vague verdict start naming themselves. + +When a second manager is fighting NetworkManager for the link, the wall says so by name — "dhcpcd is running alongside NetworkManager and they're fighting over the interface" — and offers the fix (stop the rival) rather than bouncing a connection that will flap again the moment the other daemon re-grabs it. When NM is masked, the verdict distinguishes "masked — it can't start until unmasked" from "stopped," so FIX unmasks rather than uselessly restarting. When a saved network silently won't load because its keyfile is world-readable, the doctor names the permission fault instead of leaving the user to wonder why a known-good network never activates. + +When association fails on auth, the wall stops saying only "authentication failed" and names which auth: a WPA3-only network the profile isn't set for, a hidden SSID that needs the hidden flag, an enterprise network missing its CA cert. The first two carry a one-press fix; the rest tell the user the specific thing to supply. + +** For the implementer + +*** The control-plane probe + +A new read-only probe tier, beside the existing ones, answering three questions the current ladder never asks: + +1. *Is a rival manager active?* Check whether =dhcpcd.service=, =systemd-networkd.service=, or a standalone =iwd.service= is active while NetworkManager is also active and owns (or wants to own) the link. This is a =systemctl is-active= read plus NM's backend setting — bounded, no hang. A rival that is active is the verdict; the fix is =systemctl disable --now <rival>= (Privileged), never a connection bounce. +2. *Is NM masked or failed, as distinct from stopped?* =systemctl is-enabled NetworkManager= returns =masked=; the unit's =ActiveState=/=Result= distinguishes a crash-loop from a clean stop. A masked NM gets an =unmask= verdict (Privileged), not the existing =nm-restart=. +3. *Does the active profile's keyfile have the wrong permissions?* For the selected connection, stat its =/etc/NetworkManager/system-connections/*.nmconnection=; a non-=600= or non-root-owned file is the silent-skip fault. Fix: =chmod 600= + =chown root= (Privileged). + +The probe runs before the existing "NetworkManager isn't running" rule, because a masked NM and a rival-manager fight are both more specific than "not running" and would otherwise be mis-verdicted by it. One ordering subtlety verified against the engine: a fully-down NM makes =nmcli= raise and =diagnose= early-returns with only the service and link steps (=diag.py:503-511=), so the masked/failed check has to run inside that early-return path — otherwise a masked NM short-circuits before the new probe and never reaches its verdict. + +*** The classifier gains control-plane verdicts + +=classify= adds, in specificity order ahead of the generic =nm-restart= rule: =rival-manager= (fixable → Privileged disable-rival), =nm-masked= (fixable → unmask), =keyfile-perms= (fixable → chmod/chown). All three are =fixable= — the doctor only applies a repair when the outcome is =fixable= (=doctor.py:181=), so a terminal =needs-user-action= would never run its fix — and each is Privileged, so =net doctor --fix= still gates it on the Confirm floor rather than acting silently. Each carries evidence naming the specific rival/unit/file. These are additive; the existing rules below them are untouched. + +*** The auth verdict takes a reason + +The specific auth cause is not in =auth_failed_reason= — that REASON/journal string (=doctor.py:32-56=) only marks *that* auth failed, not whether it was SAE, a hidden SSID, or an enterprise cert. The distinction is read from signals the engine already has: the active profile's key-mgmt (=manage.py:36-47=, which already detects WPA3/SAE incompatibility) and the scanned network's SECURITY flags (=nmcli.py:164=). From those, =gather_context= derives a small classifier: SAE/PMF, hidden-SSID, enterprise-cert, or generic-PSK. (regdom has no engine signal — grep-clean — so it stays Guide.) The =needs-user-action= message is keyed off it. For SAE and hidden-SSID — the two with a deterministic one-line profile fix — the verdict becomes =fixable= with a Privileged/Auto profile-modify action (=key-mgmt sae= + PMF, or =wifi.hidden yes=) rather than terminal. The rest stay =needs-user-action= with a sharpened message. + +*** The privilege model + +The new repairs (=systemctl disable <rival>=, =unmask=, =chmod=/=chown= a keyfile, profile-modify) are the net doctor's first root-needing doctor repairs beyond the ones already in =priv.py=. They adopt the cross-panel run-time resolution the audio spec defined: Privileged remedies run silently where passwordless sudo exists (every archsetup install), prompt on a CLI with a tty, and default to Confirm/Arm — never silent Auto. This is the same standard, not a net-specific one; the shared implementation is the tracked cross-panel task. Concretely, the three fixes register through =priv.py='s =VERBS= table and =repair.py='s =ACTIONS= registry (=disable-rival=, =unmask-nm=, =chmod-keyfile=) — the existing dispatch path, not a new one — and each verb is a narrowly-scoped command (disable one named unit, unmask NetworkManager, chmod/chown one keyfile), never a general run-as-root, so the passwordless-sudo grant stays tight. + +*** Redaction of the new evidence + +The new verdicts surface a connection name (=rival-manager=, =keyfile-perms=) and a =/etc/NetworkManager/system-connections/*.nmconnection= basename. The redaction reality, verified against the engine: SSID redaction exists only in the event log (=redact_event=, gated on =redact_ssid=, default off); the copyable report scrubs MAC/IP only (=scrub_text=, =redact.py=), and =--json= is a raw =json.dumps(out)= with no redaction at all (=cli.py=). Connection names already appear in the clear in the link-step evidence (=diag.py:103=, e.g. "wlan0 connected (HomeNetwork)") and in =--json= today. So there is no copy-vs-wall redaction surface to piggyback on, and the new verdicts add no new leak class — a connection name already shows for the link step. v1 keeps parity: the new evidence is redacted exactly as the existing link-step evidence is (MAC/IP via =scrub_text=; connection name in the clear). The systemic gap — connection names and SSIDs leaking into the copyable report and =--json= across every step, gated behind a default-off toggle — predates this spec and spans the whole engine, so it is filed as its own task rather than half-solved for two new verdicts. The keyfile probe reads permissions, not secrets; the auth-reason extraction reads NM's reason string, not the PSK. + +* Alternatives Considered + +** Fold the control-plane faults into the existing =nm-restart= verdict + +- Good, because it is zero new classifier surface. +- Bad, because =nm-restart= is the wrong fix for all three: restarting a masked NM fails, restarting NM does not stop a rival =dhcpcd=, and it does nothing for a bad keyfile. A shared verdict would send the doctor's one fix at three faults it can't fix. +- Rejected. Different root causes with different fixes are different verdicts — the same principle the audio spec used for =pulse-hung= vs =pulse-down=. + +** Make the doctor a full profile editor for the auth cluster + +- Good, because it could fix more auth failures automatically. +- Bad, because credential and certificate entry is a genuine user decision, not a repair — the doctor cannot invent an enterprise CA or a password. Auto-editing profiles beyond the two deterministic one-liners risks writing a wrong setting the user then has to unwind. +- Rejected in favor of fixing only SAE key-mgmt and the hidden flag, and guiding the rest. + +** Build the flaky/drops cluster now with a synthetic re-probe + +- Good, because it would catch powersave/autosuspend "works then dies" cases. +- Bad, because a one-shot doctor invoked when the user is already offline has no drop history to read; catching intermittent faults needs the panel's event log correlated over time, which is a separate probe surface. +- Rejected for v1; logged as vNext. + +* Decisions [3/3] + +** DONE The control-plane probe and its three verdicts +Context: cluster 7 is the taxonomy's largest untouched cluster, and no current probe detects a masked NM, a rival manager, or a bad keyfile. +Decision: we will add a read-only control-plane probe (rival-manager active-check, NM masked-vs-failed-vs-stopped, active-profile keyfile permissions) and three verdicts ahead of the generic =nm-restart= rule, each with the lightest specific fix. +Consequences: the doctor names the fight instead of bouncing a link that will re-flap; harder — three new verdicts and a probe that reads =systemctl= state plus a stat, and the ordering has to sit ahead of the existing not-running rule without disturbing it. +Resolution: accepted as proposed (the fold-into-nm-restart alternative was rejected — that fix cannot address any of the three faults). Owner: Craig. + +** DONE How far the auth-cluster fix goes +Context: the auth cluster is terminal today; some members (SAE, hidden SSID) have deterministic one-line profile fixes, others (enterprise cert, credential) do not. +Decision: we will extract the specific auth reason and make only SAE-key-mgmt and hidden-flag =fixable=; everything else stays =needs-user-action= with a sharpened, cause-named message. Both profile-modify fixes are Confirm-tier (persisted state). +Consequences: two more auth failures self-heal; harder — the doctor now writes to a connection profile, which is a heavier action than a bounce, and the boundary between "fix" and "guide" inside one cluster has to be defended so it doesn't creep into credential management (the Non-Goal is the guardrail). +Resolution: accepted as proposed. Owner: Craig. + +** DONE Adopt the run-time privilege model as the cross-panel standard +Context: the new control-plane repairs need root; the audio spec already defined the four-class run-time model and made it a cross-panel standard. +Decision: we will adopt it verbatim — Privileged repairs run where passwordless sudo exists, prompt on a tty, default to Confirm/Arm, never silent Auto — sharing the implementation with the other panels rather than reimplementing it. +Consequences: the net doctor's root repairs are consistent with audio/bt/maint; harder — it couples this spec to the shared privilege-model task, so the ordering across panels has to be settled (which panel lands the shared code). That sequencing caveat is recorded in Risks. +Resolution: accepted — this adopts an already-decided cross-panel standard, not a new choice. Owner: Craig. + +* Review findings [9/9] + +** DONE Connection names are not redacted today, but the spec assumes they are :blocking: +The Security & privacy dimension stated "SSIDs and connection names are already redacted by =redact.py=." Verified against the live engine: =redact.py= redacts SSID, MAC, IP, secret-keys, and =portal_url= only (=redact.py:9-53=) — there is no connection-name redaction. The new =rival-manager= and =keyfile-perms= verdicts surface a connection name and a =/etc/NetworkManager/system-connections/*.nmconnection= path (the file basename is the connection name) into the wall and the =--json= output. +Disposition: accepted, modified in one detail. Rather than redact everywhere (which would blank the network name on the user's own screen), v1 redacts the connection name and keyfile basename at the same copy/=--json= surface the existing SSID redaction covers — the shareable text is scrubbed, the on-screen wall still names the network. Folded into Design "Redaction of the new evidence," the Security & privacy dimension, Phase 1, and a new acceptance criterion. + +** DONE "One =nm-restart= verdict covers the whole control plane" overstates current scope +Problem/Context and the first Alternative rested the motivation on the claim that the doctor "currently has one verdict (=nm-restart=) covering the whole control plane." Verified: =nm-restart= is one narrow action for a dead NetworkManager *service* only (=classify.py:96-97=, =repair.py:705=); the classifier already carries granular actions (=reset=, =bounce=, =rfkill=, =manage-device=, =resolved-restart=…). The real, verified gap is that *no* probe detects a rival manager, a masked NM, or a bad keyfile (=is-enabled= and =system-connections= reads are grep-clean). +Disposition: accepted. Reworded the cluster-7 paragraph to rest on the missing detections; the first Alternative already argues against reusing =nm-restart= as a fix, which stays valid. + +** DONE "=systemctl is-enabled= already used by the engine" is inaccurate +External APIs & deps said =systemctl is-active/is-enabled= are "already used by the engine." Verified: only =is-active= is used (=cmd.py:27=); =is-enabled= has zero hits, and =system-connections= is not read today either. +Disposition: accepted. Corrected the dimension to say =is-active= and =nmcli= are used today, =is-enabled= and the keyfile reads/=stat= are new bounded calls. + +** DONE Problem/Context lists message text as if it were classifier verdicts +The six-cluster evidence listed =no-hardware=, =DHCP-failed=, =DNS-not-resolving=, =dns-override= as classifier verdicts. Verified: the first three are message text, and =dns-override= is a doctor =FIX_CHAIN= (=doctor.py:24=), not a =classify= action. +Disposition: accepted. Rewrote the listing to cite =classify='s real action identifiers and note which taxonomy labels are message text. + +** DONE Name the =priv.py= / =repair.py= integration point for the new privileged repairs +The spec said it reuses =priv.py= but the phase plan did not name the concrete integration: privileged repairs dispatch through =priv.py='s =VERBS= table (=priv.py:150=) and =repair.py='s =ACTIONS= registry (=repair.py:695=), not a new path. +Disposition: accepted, extended. Phase 1 and the privilege-model design now name =disable-rival=/=unmask-nm=/=chmod-keyfile= as =VERBS= + =ACTIONS= entries, and add that each verb is a narrowly-scoped command (not a general run-as-root) so the passwordless-sudo grant stays tight. + +** DONE Round 2 (skeptical review): the redaction "copy/--json surface" does not exist :blocking: +The round-1 disposition claimed v1 would redact at "the same copy/=--json= surface the existing SSID redaction covers." A second skeptical review traced every redaction call site and found no such surface: SSID redaction lives only in =redact_event= (event log, gated on =redact_ssid=, default off), =--json= is a raw =json.dumps(out)= with no redaction, and the copyable report scrubs MAC/IP only (=scrub_text=). Connection names already appear in the clear in the link-step evidence (=diag.py:103=) today. So the round-1 resolution invented a surface that isn't there. +Disposition: accepted — the round-1 fix was wrong and is corrected. v1 keeps parity with existing link-step behavior (the new verdicts leak no more than the link step already does); the systemic connection-name/SSID redaction gap across the report and =--json= is filed as a separate task. Rewrote Design "Redaction of the new evidence," the Security dimension, Phase 1, and the acceptance criterion. + +** DONE Round 2 (skeptical review): rival-manager can't satisfy its own FIX criterion :blocking: +Round 1 marked =rival-manager= =needs-user-action= (terminal) while the acceptance criterion required FIX to stop the rival. But the doctor only applies a repair when the outcome is =fixable= (=doctor.py:181=), so a terminal =rival-manager= would never run =disable-rival=. +Disposition: accepted. =rival-manager= is now =fixable= (Privileged/Confirm), consistent with =nm-masked= and =keyfile-perms=; the Confirm floor still gates it. Corrected the classifier design and added an acceptance criterion that all three control-plane verdicts are =fixable=. + +** DONE Round 2 (skeptical review): auth-cluster classifier pointed at the wrong signal +Round 1 said =auth_failed_reason= would carry the SAE/hidden/enterprise distinction. The skeptical review confirmed that REASON/journal string only marks *that* auth failed; the actual discriminating data lives in the profile key-mgmt (=manage.py:36-47=, already detects SAE incompatibility) and the scanned SECURITY flags (=nmcli.py:164=). +Disposition: accepted. Phase 2 / the auth-verdict design now reads those signals instead of =auth_failed_reason=; regdom stays Guide (no engine signal). No new data collection is forced. + +** DONE Round 2 (skeptical review): masked-NM must be reached in the NM-down early-return +When NM is fully down, =nmcli= raises and =diagnose= early-returns with only the service and link steps (=diag.py:503-511=), so a masked NM would short-circuit before the control-plane probe and never reach the new verdict. +Disposition: accepted. The control-plane probe design now states the masked/failed check must run inside that early-return path. + +* Implementation phases + +Each phase leaves the tree green and independently useful, as the existing net phases did. + +** DONE Phase 0 — the control-plane probe (read-only) +Pure engine, no classifier changes. A probe module that reports rival-manager state, NM masked/failed/stopped, and active-profile keyfile permissions into the diag context. =net diag --json= shows the new signals. Fakes: injected =systemctl is-active/is-enabled= results and a temp system-connections tree. + +** DONE Phase 1 — control-plane verdicts + the privilege model +=classify= gains =rival-manager=, =nm-masked=, =keyfile-perms= (all =fixable=), ordered ahead of the generic not-running rule, with the masked/failed check reachable in the NM-down early-return path. The run-time privilege resolution lands (shared with the cross-panel task) and the three new fixes register as Privileged/Confirm through =priv.py='s =VERBS= table and =repair.py='s =ACTIONS= registry (=disable-rival=, =unmask-nm=, =chmod-keyfile=), each a narrowly-scoped verb. The new evidence keeps parity with existing redaction (MAC/IP via =scrub_text=); the systemic connection-name gap is a separate task, not this phase. =net doctor= names them; =net doctor --fix= applies them under the Confirm floor. Hard ordering gate: this phase must not land before the shared run-time privilege model exists — =priv.py= today is a bare =VERBS=+sudo dispatcher with no Confirm/Arm resolution (=priv.py:150=) and =_attempt= runs repairs ungated, so shipping the Privileged =fixable= verdicts first would let =net doctor --fix= silently disable =dhcpcd= via passwordless sudo, the exact outcome the model forbids. Phase 0 (read-only detection) has no such dependency and lands first; if the fault-naming is wanted before the privilege model, the verdicts can ship detection-only (no =--fix= action) as an interim slice. + +** DONE Phase 2 — the sharpened auth verdict +The auth-reason classifier; SAE and hidden-SSID become =fixable= profile-modify repairs; the rest get cause-named =needs-user-action= messages. Pairwise over (reason × profile-state). + +** DONE Phase 3 — flip this spec to IMPLEMENTED +And log the vNext items (flaky/drops cluster, DoT/DNSSEC verdict, profile hygiene) to =todo.org=. + +* Acceptance criteria + +- [ ] With =dhcpcd.service= active alongside NetworkManager, the doctor reports =rival-manager= naming dhcpcd, and FIX stops it rather than bouncing the connection. +- [ ] A masked NetworkManager reports =nm-masked= (distinct from stopped), and FIX unmasks it. +- [ ] A non-600 keyfile for the active profile reports =keyfile-perms=, and FIX corrects the permissions. +- [ ] A WPA3-only association failure reports the SAE cause and (with --fix) sets the profile's key-mgmt, rather than saying only "authentication failed." +- [ ] An enterprise-cert auth failure stays =needs-user-action= but names the missing CA cert. +- [ ] Every new root-needing repair defaults to Confirm/Arm and never runs silently as Auto. +- [ ] =rival-manager=, =nm-masked=, and =keyfile-perms= are all =fixable= outcomes, so =net doctor --fix= actually runs their repairs (a terminal outcome would be skipped by =doctor.py:181=). +- [ ] The =rival-manager= and =keyfile-perms= verdicts expose the connection name no more than the existing link-step evidence does (MAC/IP scrubbed, connection name in the clear — parity, not a new leak). The systemic connection-name/=--json= redaction gap is tracked as a separate task. +- [ ] The six existing clusters classify exactly as they do today (regression). + +* Readiness dimensions + +- *Data model & ownership* — the diag context gains control-plane signals (generated per-probe) and a richer =auth_failed_reason= (generated from GENERAL.REASON + journal, as today). The doctor never writes NM config except the two auth profile-modifies and the keyfile-perms fix, all under the Confirm floor. +- *Errors, empty states & failure* — an unreadable =systemctl=/=stat= yields "unknown," never a false rival/masked/perms verdict (the safe direction). Partial reads degrade to the existing behavior. +- *Security & privacy* — verified: SSID redaction exists only in the event log (=redact_event=, gated on =redact_ssid=, default off); the copyable report scrubs MAC/IP only (=scrub_text=) and =--json= is raw. Connection names already appear in the clear in the link-step evidence and =--json= today, so the new =rival-manager=/=keyfile-perms= verdicts add no new leak class — they reach parity with existing behavior. The systemic connection-name/SSID redaction gap across the report and =--json= is pre-existing and filed as a separate task. The keyfile probe reads permissions, not secrets; the auth-reason extraction reads NM's reason string, not the PSK. +- *Observability* — the wall names the specific rival/unit/file. =--json= carries the new context. +- *Performance & scale* — three =systemctl= reads and a stat; negligible beside the existing probe cost. +- *Reuse & lost opportunities* — reuses =gather_context='s existing auth-reason extraction, =priv.py=, and the shared cross-panel privilege model rather than a net-local one. =classify= stays the single verdict authority. +- *Architecture fit* — the new probe is additive beside the existing tiers; the classifier additions sit ahead of the generic not-running rule and leave the rest untouched. Weak point: ordering — the new verdicts must precede =nm-restart= or a masked/rival case mis-classifies. +- *Config surface* — none new. N/A. +- *Documentation plan* — module docstrings, as the package does today. The wall is the user documentation. +- *Dev tooling* — =make test= and the net panel smoke cover it; the control-plane probe needs injected =systemctl= state, a fixture shape the package already uses elsewhere. +- *Rollout, compatibility & rollback* — additive; =net doctor= with no new fault behaves as today. The auth profile-modify and keyfile-perms fixes change persisted NM state, so both are Confirm-tier and reversible by the user. +- *External APIs & deps* — =systemctl is-active= and =nmcli= are already used by the engine; =systemctl is-enabled= and reads/=stat= under =/etc/NetworkManager/system-connections= are new calls (bounded with an explicit timeout, like every existing probe). No new packages. The exact rival-manager set (dhcpcd/networkd/iwd) is verified against the live system before Phase 0. + +* Risks, rabbit holes, and drawbacks + +The rival-manager check can false-positive if a rival service is active but not actually contending for the same interface (e.g. =systemd-networkd= managing a container bridge while NM owns wifi). The probe must scope the conflict to the link the doctor is diagnosing, not merely "is networkd running" — otherwise it cries wolf on a legitimate split. This is the main correctness rabbit hole and wants a test with a bridge-only networkd. + +The auth-cluster boundary between "fix" and "guide" is a slope. SAE and the hidden flag are safe because they are deterministic and reversible; the temptation is to add "just one more" auto-fix until the doctor is editing enterprise profiles it shouldn't. The Non-Goal is the guardrail; hold it. + +Coupling to the shared privilege-model task means this spec can't fully land until that model exists somewhere. Sequence it: whichever panel lands the shared code first, the others depend on it. + +* Review and iteration history + +** 2026-07-11 Sat @ 00:08:41 -0500 — Craig Jennings — Author +- What: drafted the net doctor expansion from the network half of the failure taxonomy. v1 adds the control-plane cluster (rival-manager, NM-masked, keyfile-perms) and sharpens the auth verdict; the flaky/drops cluster is staged to vNext. +- Why: the taxonomy showed the net doctor already reaches six of eight clusters, and the two it misses (control plane, auth naming) are where its verdicts are wrong or vague today. +- Artifacts: [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][the net/bt failure taxonomy]]; code read across =~/.dotfiles/net/src/net/= (=classify.py=, =diag.py=, =doctor.py=, =repair.py=, =priv.py=). + +** 2026-07-11 Sat @ 00:59:30 -0500 — Claude Code (archsetup) — Reviewer +- What: ran spec-review. Rubric =Not ready=. Recorded five findings, one =:blocking:= (connection-name redaction assumed but absent) and four non-blocking accuracy/integration corrections. The three proposed decisions remain open. +- Why: the design is sound and the code read confirmed both target gaps are real (=is-enabled= and =system-connections= reads do not exist in the engine today), so the expansion is well-grounded. What holds the rubric is the privacy claim the spec states as already-true (=redact.py= redacts SSID/MAC/IP/secret-keys/portal_url only — not connection names, =redact.py:9-53=) and the still-open decisions. Once the redaction finding is dispositioned and Craig accepts the three decisions, this reaches =Ready with caveats= (the cross-panel shared privilege-model code is the remaining sequencing caveat, already recorded in Risks). +- Artifacts: engine facts verified across =classify.py= (:96-97, :55-62), =diag.py= (:518-537, bounded probes), =doctor.py= (:112-116 auth-reason), =cmd.py:27= (=is-active= only), =priv.py:150=/=repair.py:695= (dispatch registry), =redact.py:9-53=. Findings in =* Review findings=. + +** 2026-07-11 Sat @ 01:20:00 -0500 — Claude Code (archsetup) — Responder +- What: ran spec-response. Dispositioned all five findings (four accepts, one accept-with-modify: the redaction scrubs at the copy/=--json= surface, not the on-screen wall) and closed all three decisions as accepted. Folded the redaction requirement into Design, the Security dimension, Phase 1, and a new acceptance criterion; reworded the =nm-restart=/control-plane motivation and the six-cluster evidence onto the verified facts; named the =VERBS=/=ACTIONS= dispatch integration and tightened the new verbs to narrowly-scoped commands. Both =[/]= cookies now read complete. +- Why: convergence toward implementation-ready. The redaction modify avoids blanking the network name on the user's own screen while still scrubbing the shareable text — a connection is usually named after its SSID, so leaving it unredacted leaks the already-redacted SSID by a side channel. +- Artifacts: findings =[5/5]=, decisions =[3/3]=. Scope expanded only by the redaction obligation, which is filed as an acceptance criterion and a Phase 1 deliverable, so the readiness rubric holds. Awaiting re-review to flip DRAFT → READY. + +** 2026-07-11 Sat @ 01:45:00 -0500 — Claude Code (archsetup) — Reviewer + Responder (round 2) +- What: a skeptical adversarial re-review returned Not ready with two blockers, both in the parts round 1 claimed to resolve. (1) The redaction "copy/--json surface" I asserted in round 1 does not exist — SSID redaction is event-log-only, =--json= is raw, the copyable report scrubs MAC/IP only. (2) =rival-manager= marked =needs-user-action= could never run its fix, since the doctor only repairs =fixable= outcomes (=doctor.py:181=). Fixed both, plus two non-blocking corrections (auth signal repointed at =manage.py:36-47= + =nmcli.py:164=; masked-NM early-return path). Findings now =[9/9]=. +- Why: the first round introduced a factual error by asserting a redaction surface without verifying =redact.py='s structure. Round 2 verified every claim against the engine before writing. The redaction resolution is now a de-scope: v1 keeps parity with the pre-existing link-step behavior and the systemic redaction gap is a separate task — a scope call worth Craig's eye. +- Artifacts: engine re-verified at =doctor.py:181= (fix gate), =cli.py= (raw =--json=), =report.py= / =redact.py= (MAC-IP-only scrub), =diag.py:103= (link evidence), =diag.py:503-511= (NM-down early return), =manage.py:36-47= / =nmcli.py:164= (auth signals). Awaiting a third re-review. + +** 2026-07-11 Sat @ 02:00:00 -0500 — Claude Code (archsetup) — Reviewer (round 3) +- What: third skeptical adversarial re-review. Verdict =Ready with caveats=, no blocking findings. Verified all five round-2 resolutions against the engine: redaction parity is coherent and buildable (no criterion assumes unbuilt redaction), rival-manager as =fixable= preserves terminal-first ordering with no loop (=doctor.py:184-195=), the auth signals (=manage.py:46= SAE, =nmcli.py:159-176= hidden/SECURITY) distinguish all three cases, and the masked-NM check is addable in the early-return path (=diag.py:503-511=). Flipped DRAFT → READY. +- Why: the loop terminates at the rubric, not at exhaustion. Two independent skeptical passes plus a code re-verification found no remaining blocker. The one named caveat — Phase 1 needs the shared privilege model or =--fix= runs ungated (=priv.py:150=, =_attempt= ungated) — is honestly recorded in Risks, Decision 3, and now a hard ordering gate on Phase 1; Phase 0 is fully buildable now. +- Artifacts: findings =[9/9]=, decisions =[3/3]=. Non-blocking: keyfile-perms on an inactive profile names a connection the disconnected link step wouldn't — same data class, covered by the separate redaction task. diff --git a/docs/specs/2026-07-12-component-generation-spec.org b/docs/specs/2026-07-12-component-generation-spec.org new file mode 100644 index 0000000..09d0fce --- /dev/null +++ b/docs/specs/2026-07-12-component-generation-spec.org @@ -0,0 +1,207 @@ +#+TITLE: Widget Component Generation — Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-12 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DOING Widget component generation +:PROPERTIES: +:ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba +:END: +- 2026-07-12 Sun @ 22:56:40 -0500 — Phase 1 complete (still DOING): all 109 builders extracted into =widgets.js=, cards are declarative records, widget CSS moved into =DUPRE_CSS= (pixel-diff identical), slide toggle carries the first constructor-opts style axes, README consumers section written. Commits acee657 → 7b3bc47 on main; probes + 239-check suite green throughout. Remaining: Phases 2–5. +- 2026-07-12 Sun @ 20:57:50 -0500 — READY → DOING: phases decomposed into todo.org tasks under "Retro widget catalogue" (=:SPEC_ID:= stamped); Phase 1 extraction starts now per the option-1 approval. +- 2026-07-12 Sun @ 20:57:50 -0500 — DRAFT → READY: spec-review passed (Ready; 3 findings — the option-1 supersession, the card-record refactor, stale counts — all accepted and folded same pass). +- 2026-07-12 Sun @ 10:12:06 -0500 — drafted. + +** Prototype iterations +Per =claude-rules/ui-prototyping.md=, a non-trivial-UI spec carries its prototype evidence here. This spec's UI evidence is the [[file:../prototypes/panel-widget-gallery.html][panel widget gallery]] itself — the living catalogue is the prototype set, not a numbered iteration series: + +- *Research:* reference photos of period hardware (Berna 3 studio suite, aviation clusters, broadcast consoles, pedals, marine/telegraph instruments) filed in [[file:../../working/retro-stereo-widgets/references/][working/retro-stereo-widgets/references/]]; in-chat-pasted references have provenance noted in card notes and the 2026-07-11/12 session archive. +- *Iterations:* the gallery's git history is the iteration record — N-series base widgets, then reference-derived R01–R56 built and behaviorally verified card by card (7fff507 through the gallery-upgrades squash merge bc93388 and beyond, 2026-07-12). 109 cards as of the 2026-07-12 review, every one CDP-verified (drag tracking, click response, animation, zero console errors); the committed probe suite lives in =tests/gallery-probes/=. +- *Final:* the gallery at current =main= is the settled visual + behavioral spec each component port is judged against — it keeps growing, so a port is judged against its card as it stands, not a frozen SHA. + +* Metadata +| Status | doing | +|----------+-----------------------------------| +| Owner | Craig Jennings | +|----------+-----------------------------------| +| Reviewer | Claude Code (archsetup) | +|----------+-----------------------------------| +| Related | [[file:../../todo.org][todo.org — Retro widget catalogue]] | +|----------+-----------------------------------| + +* Summary +The panel widget gallery (109 behaviorally-verified retro-instrument cards as of the 2026-07-12 review) is a spec, not yet a library. This document defines how gallery cards become reusable components for three real targets — web (vanilla JS, the gallery itself as first consumer), Emacs (svg.el), and waybar (GTK CSS). The web library is extracted whole (a lossless transform of code that already exists); the Emacs and waybar ports are driven by actual demand rather than porting the full catalogue. + +* Problem / Context +The gallery grew card by card as a visual and behavioral catalogue: each card is a self-contained inline builder function inside one ~4300-line HTML file (as of the 2026-07-12 review; it still grows). That was right for the collection phase — fast iteration, one canvas, one review surface. It is wrong for consumption: a waybar panel, an Emacs game, or a web UI that wants a VU meter today would have to copy-paste from the gallery's internals and hand-fork the styling. + +The groundwork is already laid. Tokens are single-sourced in [[file:../prototypes/tokens.json][tokens.json]]; =gen_tokens.py= emits the gallery's =:root= CSS block, [[file:../prototypes/tokens-waybar.css][tokens-waybar.css]], and [[file:../prototypes/gallery-tokens.el][gallery-tokens.el]] (27 tests, 100% coverage). An svg.el proof widget ([[file:../prototypes/gallery-widget.el][gallery-widget.el]], the needle gauge, 10 ERT tests plus an rsvg-convert side-by-side render) proved the Emacs pipeline — the riskiest unknown — end to end. What's missing is the middle layer: parameterized, importable widgets per target, and a decision procedure for which widgets earn a port. + +* Goals and Non-Goals +** Goals +- A consumer in any of the three targets can use a ported widget without reading the gallery's source. +- The gallery stays the single visual/behavioral spec; ports are judged against it, and the web port *is* what the gallery runs. +- Porting effort tracks demand: a widget is ported when a named consumer wants it, not because it exists. +- Every port carries tests in its target's idiom (CDP behavioral for web, ERT + visual harness for Emacs, panel test suite for waybar). + +** Non-Goals +- Emacs/waybar ports of the full catalogue. Most cards are catalogue entries; a scripted port with no consumer is inventory nobody asked for. (The web extraction is exempt — it transforms code that already exists.) +- Shared rendering code across targets. Reuse lives in shared tokens and shared SVG geometry math, not shared code — each target renders in its own idiom (recorded architecture decision). +- Framework wrappers (React or otherwise) ahead of a real framework consumer. +- Level-2/3 codegen (widget-spec → renderer generation) in this spec's v1 — that's the Phase 5 decision point, deliberately after hand-porting experience exists. + +** Scope tiers +- v1: full web library extraction (with the card-record refactor), demand inventory for the scripted targets, Emacs ports of the demanded widgets, one waybar pilot panel, the Level-2 generator go/no-go. +- Out of scope: new widget collection as part of this work (the catalogue keeps growing on its own track — new cards land as =DUPRE.*= calls once extracted), shared cross-target rendering code, interaction parity beyond each target's native idiom. +- vNext (log to todo.org at hand-off): React/framework wrappers, Level-2/3 codegen if Phase 5 says go, ports beyond the demand matrix as consumers appear. + +* Design +** For a consumer +Web: include =widgets.js= (classic script) after the token =:root= block; call a builder — e.g. =DUPRE.vuMeter(el, {value: 0.6})= — and get back a handle with setters. Widget-internal CSS ships inside =widgets.js= (the =DUPRE_CSS= block, self-injected at load), so the consumer supplies only the tokens. The gallery page itself becomes a grid of exactly these calls, so the demo and the library cannot drift. + +Emacs: =(require 'gallery-widgets)= (the =gallery-widget.el= pattern, one file per widget family or one shared file — Phase 3 sizes this); each widget is a function from parameters to an svg.el object, tokens resolved through =gallery-tokens.el=. Interaction (keymap, click regions) is per-widget Emacs idiom, not a translation of pointer drags. + +Waybar: no widget functions — waybar consumes =tokens-waybar.css= plus the banked composition idioms (lit/unlit key states, LED rows, engraved captions). The pilot restyles one real panel (audio, in =~/.dotfiles=) so its GTK CSS reads from the generated token sheet; "component" at this target means a documented CSS pattern, because GTK CSS has no scripting surface. + +** For an implementer +Three layers, already agreed: tokens (values) → widget spec (the gallery card: geometry, params, behavior) → per-target renderers. The card is the contract. A port re-reads the card's builder for geometry constants and behavioral rules (detent snapping, VU ballistics via the =vuDb()= law, exclusive-select), then re-expresses them natively. Geometry math (polar helpers, scale interpolation tables like =EDGE_SCALE=, =SEG14_MAP=) is copied deliberately per target and cross-checked by tests, not shared as a runtime dependency — the false-coupling judgment from the token work applies to code too. + +Extraction order inside Phase 1: the card builders already share =svgEl=/=polar=/=vuDb=/drag helpers and document-scoped gradients/filters; lifting them means (a) moving the shared engine and each builder into =widgets.js= behind a parameter object, (b) replacing its gallery card with a declarative card record (no / title / stage builder / options / readout / note / spec sheet incl. reference link / validation lamp) rendered by one =card()= path, (c) re-running the existing CDP behavioral probes unchanged — the probes drive the rendered DOM, so a green re-run is the no-regression proof. Batches of roughly 10-15 widgets keep each commit reviewable and the gallery working throughout. + +* Alternatives Considered +** Port everything to every target up front +- Good, because the catalogue would be uniformly consumable. +- Bad, because ~109 × 3 ports is months of work with no consumer for most of it, and unconsumed ports rot silently. +- Bad, because it front-loads the Level-2 codegen question before hand-port experience exists to answer it. + +** Shared renderer core (one geometry engine, thin target adapters) +- Good, because geometry fixes would land once. +- Bad, because the targets' rendering models genuinely differ (DOM+CSS animation vs static svg.el re-render vs GTK CSS with no scripting); an abstraction spanning them is speculative and was already rejected in the recorded architecture decision. +- Neutral, because shared *values* (tokens) and shared *math* (cross-checked constants) capture most of the benefit without the coupling. + +** Skip the web extraction; keep the gallery monolithic and port only outward +- Good, because it's less work now. +- Bad, because the gallery-as-demo property is lost: web consumers copy-paste internals, and the spec and any web usage drift apart immediately. +- Bad, because the extraction is cheap insurance — the CDP tests already exist to catch regressions. + +* Decisions [8/8] +** DONE SVG-first substrate +- Context: curved dials, needles, glow filters must render identically enough across web and Emacs; librsvg reproduces gradients and blur filters. +- Decision: widgets with curved/needle geometry are authored as SVG in every scripted target; plain-CSS forms stay CSS only where the target is CSS-native (waybar). +- Consequences: easier — one geometry language, rsvg side-by-side verification works; harder — some pure-CSS gallery cards need SVG re-expression when ported to Emacs. +- Resolved live with Craig through the gallery/svg.el work (2026-07-11/12); proven by the needle-gauge slice (b137223). + +** DONE Single token source +- Context: the amber retune required a 30-site find/replace before tokenization. +- Decision: =tokens.json= is the only place a design value is authored; =gen_tokens.py= emits all three target artifacts; generated blocks carry markers and are never hand-edited. +- Consequences: easier — one-line retunes, drift impossible; harder — adding a token means touching the generator (tested, 100% coverage). +- Resolved and shipped (4408779). + +** DONE Per-target interaction layers +- Context: pointer drag has no Emacs equivalent; GTK CSS has no scripting. +- Decision: interaction is designed per target in its native idiom; only the widget's *state model* (positions, ranges, detents, exclusivity) is common, carried by the card. +- Consequences: easier — each target feels native; harder — behavioral parity is a per-port judgment, tested per target rather than shared. +- Resolved live with Craig (2026-07-11). + +** DONE Demand-driven porting (Emacs and waybar targets) +- Context: 109 cards (as of 2026-07-12 review), three targets, most cards have no Emacs or waybar consumer today. +- Decision: an *Emacs or waybar* port happens when the demand matrix names a real consumer (a waybar panel, an Emacs game/UI); the matrix is Phase 1's deliverable and Craig approves it. The *web* extraction is exempt: it is a lossless transform of code that already exists, so it covers the full catalogue (option-1 approval, 2026-07-12). +- Consequences: easier — port effort lands where it's used, and the web library is complete from day one; harder — the Emacs/waybar surface stays partially ported indefinitely, by design. +- Agreed in the 2026-07-12 outline; web-extraction exemption approved with the option-1 go-ahead (2026-07-12). + +** DONE Gallery-as-spec +- Context: the ui-prototyping rule wants working prototypes as design evidence. +- Decision: the gallery is the canonical visual + behavioral spec; every port is judged against its card, and the web library's demo *is* the gallery. +- Consequences: easier — no separate spec artifact to maintain; harder — gallery regressions are spec regressions, so its CDP checks are load-bearing. +- Agreed in the 2026-07-12 outline; prototype evidence under the status heading. + +** DONE Level-1 codegen only, for now +- Context: token codegen shipped; widget-level codegen (Level 2/3) is speculative before hand-port experience. +- Decision: defer the Level-2 go/no-go to Phase 5, after roughly five hand ports. +- Consequences: easier — no premature generator abstraction; harder — early ports carry hand-duplicated geometry constants until the decision point. +- Recorded architecture decision (2026-07-11 session). + +** DONE Waybar pilot is the audio panel +- Context: the pilot should be a real panel with active development and a full test suite. +- Decision: the audio panel (=~/.dotfiles=) is the waybar pilot; archsetup owns the dotfiles work end to end per the standing rule in =.ai/notes.org=. +- Consequences: easier — freshest panel, known suite; harder — visual changes to a daily-driver panel need the manual-testing checklist. +- Agreed in the 2026-07-12 outline. + +** DONE Web library packaging +- Context: the gallery is a single self-contained =file://= HTML page; ES modules don't load over =file://=, and a React consumer doesn't exist yet. +- Decision: =widgets.js= ships as a classic script exposing one namespace object (working name =DUPRE=), loaded by the gallery via a relative =<script src>=; the shared helpers (=svgEl=, =polar=, =vuDb=, scale tables) move inside it. Framework wrappers are vNext, demand-gated. +- Consequences: easier — gallery keeps working from =file://=, zero build tooling; harder — no tree-shaking or import isolation (acceptable at this scale). +- Approved by Craig 2026-07-12 (componentization go-ahead, option 1). The extraction itself is ungated — a lossless transform verified by the CDP probes; Craig's per-card validation pass (the status lamps) gates only the per-widget Emacs ports and the final blessing. + +* Review findings [3/3] +** DONE Extraction scope and gate superseded by the option-1 approval :blocking: +The spec's Phase 1 → Phase 2 order gates the web extraction on the demand matrix, and the Web-library-packaging decision says the build is "gated on his per-card validation pass." Craig's componentization go-ahead (option 1, 2026-07-12 ~21:30) superseded both: the web extraction covers the *full* catalogue as a lossless transform and starts immediately; the demand matrix and the validation lamps gate only the per-widget Emacs ports and the final blessing. An implementer reading the spec as written would follow the retired order. Recommended change: re-scope the Demand-driven-porting decision to the Emacs/waybar targets, drop the extraction gate from the packaging decision, and reorder/reword Phases 1-2. +Disposition: accepted — pre-agreed by Craig (recorded in todo.org and the 2026-07-12 session log). Folded into Decisions 4 and 8 and Phases 1-2. +** DONE Card-as-component refactor absent from Phase 2 +The approved build includes restructuring each gallery card into a declarative record (no/title/stage builder/options/readout/note/spec-sheet incl. reference link/validation lamp) so the page becomes data plus =DUPRE.*= calls, but Phase 2 as written only covers lifting builders into =widgets.js=. Without it the "gallery becomes calls into the library" claim leaves the card chrome hand-rolled per card. Recommended change: name the card-record refactor as part of Phase 2's deliverable. +Disposition: accepted — folded into the Design section and Phase 2. +** DONE Stale gallery facts +The spec states 84 cards, a 2258-line file, the R-series closed at R31, and 52a43ec as the final SHA. The gallery has since grown to 109 cards (R-series through R56, ~4300 lines) across the gallery-upgrades merge and the takuzu-survey builds. Not a design problem, but stale numbers in the contract mislead the implementer sizing the extraction. Recommended change: refresh the counts and phrase them as of the review date, since the catalogue still grows. +Disposition: accepted — counts refreshed and dated throughout. + +* Implementation phases +** Phase 1 — Web library extraction +Lift *all* card builders into =docs/prototypes/widgets.js= behind parameter objects (classic script, =DUPRE= namespace, shared helpers inside), and restructure each gallery card as a declarative record (no / title / stage builder / options / readout / note / spec sheet incl. reference link / validation lamp) so the page becomes data plus =DUPRE.*= calls. Batched (roughly 10-15 widgets per batch); a green re-run of the CDP behavioral probes (=tests/gallery-probes/=) is the per-batch no-regression gate, and each batch leaves the gallery fully working from =file://=. Ungated by the validation lamps (option-1 approval — lossless transform). + +** Phase 2 — Demand inventory (Emacs / waybar) +Build the widget-to-target matrix for the *scripted-port* targets: walk the live waybar panels (net/bt/audio/maint) and the Emacs surfaces Craig names (games, dashboards), and record which gallery cards each actually wants. Deliverable: a short matrix table in this spec's appendix, approved by Craig. Tree untouched. Runs in parallel with or after Phase 1 — the extraction does not wait on it. + +** Phase 3 — Emacs ports +Extend the =gallery-widget.el= pattern for the demanded widgets: per-widget ERT (token resolution, geometry math normal/boundary/error, SVG structure, state-tracks-value) plus the rsvg-convert side-by-side visual harness against the gallery card. Keymap/click-region interaction designed per widget. Wired into =make test-elisp=. + +** Phase 4 — Waybar pilot (audio panel) +Restyle the audio panel's GTK CSS onto =tokens-waybar.css= plus the banked composition idioms. Lives in =~/.dotfiles=; archsetup drives edit/test/commit/push end to end and drops the dotfiles inbox note. Visual result goes on the manual-testing checklist (daily-driver panel). + +** Phase 5 — Generator decision point +After ~5 hand ports, revisit Level-2 codegen with evidence: how much of each port was mechanical duplication vs judgment. Deliverable: a go/no-go recorded as a decision here (go spawns its own spec). Tree untouched. + +* Acceptance criteria +- [ ] Demand matrix exists in the appendix and Craig has approved it. +- [X] Every gallery card is a declarative record calling into =widgets.js=; CDP probes green; gallery renders unchanged from =file://=. (Done 2026-07-12: 109/109 builders, widget CSS in =DUPRE_CSS=, pixel-diff verified under forced reduced motion.) +- [ ] Every demanded Emacs widget has ERT coverage and an rsvg side-by-side render matching its card; =make test-unit= green. +- [ ] The audio panel reads its palette from =tokens-waybar.css=; panel suite green; manual-testing checklist entry filed. +- [ ] Phase 5 go/no-go recorded as a dated decision. + +* Readiness dimensions +Answer each, or write "N/A because…". +- Data model & ownership: =tokens.json= user-authored; =:root= block, =tokens-waybar.css=, =gallery-tokens.el= generated (marker-fenced, never hand-edited); =widgets.js= and Emacs widget files hand-authored; the gallery HTML owns card layout and demo calls. +- Errors, empty states & failure: builders validate parameter ranges and clamp (the gallery's existing behavior — e.g. no-cross flag clamps, detent wrap); Emacs widgets signal =user-error= on out-of-range tokens/params (the =gallery-widget-token= missing-token error is the pattern). +- Security & privacy: N/A because everything is local rendering of design assets; no credentials, no network. +- Observability: web — CDP probes plus zero-console-error checks; Emacs — ERT output; waybar — the panel's existing smoke/AT-SPI harness. +- Performance & scale: N/A beyond the existing gallery scale (109 cards render fine); ports are per-widget, no aggregate load. +- Reuse & lost opportunities: tokens and geometry constants reused by value with cross-checking tests; svg.el, librsvg, and GTK CSS used as the platforms provide them; deliberate non-reuse of cross-target code recorded above. +- Architecture fit & weak points: fits the shipped 3-layer architecture; weak point is spec drift between a card and its ports — mitigated by the side-by-side harness and by the gallery-as-demo property on web. +- Config surface: no new knobs; token values remain the only tuning surface. +- Documentation plan: =docs/prototypes/README.org= gains a consumers section (how to use =widgets.js= / the Emacs widgets / the waybar tokens); no user-facing docs beyond that. +- Dev tooling: existing =make test-unit= / =make test-elisp= cover new suites (tests auto-discovered under =tests/gallery-*=); =gen_tokens.py= unchanged; no new targets expected. +- Rollout, compatibility & rollback: web/Emacs are additive files; the audio-panel restyle is the one shared-state change — it rides the dotfiles repo, reverts with =git revert=, and changes styling only (no behavior). +- External APIs & deps: rsvg-convert and svg.el already proven by the slice; waybar GTK CSS subset already exercised by the live panels; no unverified assumptions. + +* Risks, Rabbit Holes, and Drawbacks +- Pure-CSS cards demanded into Emacs need SVG re-expression — sized per widget in Phase 3; if one balloons, it goes back to the demand matrix rather than stalling the phase. +- The gallery extraction could tempt a redesign pass; the phase is a *lossless transform* only — builders move and cards become records, but no widget's look or behavior changes (the probes enforce it). +- GTK CSS renders differently from the browser (no SVG filters, different gradient behavior); the pilot may reveal idioms that don't translate — acceptable, the pilot exists to find them, and findings feed the composition-idiom notes rather than blocking. +- Hand-duplicated geometry constants can drift before Phase 5; each port's tests pin its constants against the card's values. + +* References / Appendix +- Architecture + banked variant/composition ledger: 2026-07-11/12 session archive (=.ai/sessions/=). +- Token generator: [[file:../prototypes/gen_tokens.py][gen_tokens.py]], tests in =tests/gallery-tokens/=. +- Emacs slice: [[file:../prototypes/gallery-widget.el][gallery-widget.el]], tests in =tests/gallery-widgets/=. +- Demand matrix: (Phase 2 deliverable — lands here.) + +* Review and iteration history +** 2026-07-12 Sun @ 20:57:50 -0500 — Claude Code (archsetup) — reviewer + responder +- What: full spec-review (code read against the live gallery, probes re-baselined green at 753380e). Three findings, all accepted and folded: the option-1 approval superseded the demand-gated extraction order (Phases 1-2 swapped and re-scoped, Decisions 4 and 8 reworded), the card-record refactor named as part of the extraction phase, stale counts refreshed (84→109 cards, R31→R56). Rubric: Ready. Flipped READY, then DOING with the phase decomposition into todo.org. +- Why: Craig's componentization go-ahead (option 1, 2026-07-12 ~21:30) landed after drafting — extraction is a lossless transform and proceeds ungated; the validation lamps gate only Emacs ports and the final blessing. The spec had to match the approved build before tasks decomposed from it. +- Artifacts: the three completed findings under =* Review findings=; probe repair commit 753380e; todo.org build tasks under "Retro widget catalogue" (=:SPEC_ID:= 3ac0d42c-db1a-4d21-bce4-e63785fef0ba). + +** 2026-07-12 Sun @ 10:12:06 -0500 — Craig Jennings — author +- What: initial draft. +- Why: collection phase closed at R01–R31; porting needs a decision procedure and per-target shape before any consumer work starts. +- Artifacts: [[file:../prototypes/panel-widget-gallery.html][panel-widget-gallery.html]], todo.org "Retro widget catalogue" (DOING). diff --git a/scripts/testing/net-scenarios/10-nm-masked.sh b/scripts/testing/net-scenarios/10-nm-masked.sh new file mode 100644 index 0000000..d3b382b --- /dev/null +++ b/scripts/testing/net-scenarios/10-nm-masked.sh @@ -0,0 +1,29 @@ +# shellcheck shell=bash disable=SC2034 # sourced by run-net-scenarios.sh +# Scenario: a masked NetworkManager is unmasked and started. +# +# Masking NetworkManager makes the generic "isn't running" verdict useless — +# systemctl can't start a masked unit — so the doctor must name the mask and +# unmask first. Break by masking; the fix's chain should unmask then start. +SCENARIO_DESC="masked NetworkManager is unmasked and started" +SCENARIO_GROUP="control-plane" + +scenario_break() { + nexec "systemctl mask NetworkManager" +} + +# The verdict the read-only diagnose should produce before any fix runs. +scenario_diagnose_expect() { + ndoctor_json ".outcome" | grep -qx fixable \ + && ndoctor_json '.report.control_plane.nm_state' | grep -qx masked +} + +scenario_fix() { + nfix # net doctor --fix +} + +scenario_assert() { + # Unmasked (the state flip, testable anywhere) AND active (needs a real + # NetworkManager, so a container without netlink can only show the unmask). + ! nexec "systemctl is-enabled NetworkManager | grep -qx masked" \ + && nexec "systemctl is-active NetworkManager >/dev/null" +} diff --git a/scripts/testing/net-scenarios/20-rival-manager.sh b/scripts/testing/net-scenarios/20-rival-manager.sh new file mode 100644 index 0000000..0d7836b --- /dev/null +++ b/scripts/testing/net-scenarios/20-rival-manager.sh @@ -0,0 +1,27 @@ +# shellcheck shell=bash disable=SC2034 # sourced by run-net-scenarios.sh +# Scenario: a rival network manager active alongside NetworkManager is disabled. +# +# dhcpcd (or systemd-networkd / iwd) running next to NM fights it for the link. +# Break by enabling dhcpcd; the fix disables it, and the chain resets to +# reconnect. Requires dhcpcd installed in the target. +SCENARIO_DESC="an active rival manager (dhcpcd) is disabled" +SCENARIO_GROUP="control-plane" + +scenario_break() { + nexec "systemctl enable --now dhcpcd" +} + +scenario_diagnose_expect() { + # The verdict fires only when the link is also failing; in a fresh target + # with no configured uplink that holds, so the rival should be named. + ndoctor_json ".report.control_plane.rivals | index(\"dhcpcd\")" \ + | grep -vqx null +} + +scenario_fix() { + nfix +} + +scenario_assert() { + ! nexec "systemctl is-active dhcpcd >/dev/null 2>&1" +} diff --git a/scripts/testing/net-scenarios/30-keyfile-perms.sh b/scripts/testing/net-scenarios/30-keyfile-perms.sh new file mode 100644 index 0000000..8149f87 --- /dev/null +++ b/scripts/testing/net-scenarios/30-keyfile-perms.sh @@ -0,0 +1,30 @@ +# shellcheck shell=bash disable=SC2034 # sourced by run-net-scenarios.sh +# Scenario: a profile keyfile with unsafe permissions is set back to 0600 root. +# +# NetworkManager silently ignores a world-readable keyfile, so the profile +# never activates. Break by chmod 0644 on an existing profile's keyfile; the +# fix chmods it back to 0600 root. The doctor must run as root to read the +# keyfile dir, so this scenario runs the doctor with sudo. Requires at least +# one saved profile in the target (NET_SCENARIO_PROFILE names it). +SCENARIO_DESC="an unsafe-perms profile keyfile is restored to 0600 root" +SCENARIO_GROUP="control-plane" + +_keyfile() { + echo "/etc/NetworkManager/system-connections/${NET_SCENARIO_PROFILE:?set NET_SCENARIO_PROFILE to a saved profile name}.nmconnection" +} + +scenario_break() { + nexec "chmod 0644 '$(_keyfile)'" +} + +scenario_diagnose_expect() { + ndoctor_json ".report.control_plane.keyfile.perms_ok" | grep -qx false +} + +scenario_fix() { + nfix +} + +scenario_assert() { + nexec "[ \"\$(stat -c '%a %U' '$(_keyfile)')\" = '600 root' ]" +} diff --git a/scripts/testing/run-net-scenarios.sh b/scripts/testing/run-net-scenarios.sh new file mode 100755 index 0000000..7197e37 --- /dev/null +++ b/scripts/testing/run-net-scenarios.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Run net-doctor control-plane repair scenarios against a booted VM. +# Author: Craig Jennings <craigmartinjennings@gmail.com> +# License: GNU GPLv3 +# +# Live verification for net Phase 1's three privileged fixes (unmask-nm, +# disable-rival, chmod-keyfile). Each scenario breaks the target in a known +# way, runs the real `net doctor --fix` inside it, and asserts the post-state. +# These states are dangerous on a daily driver (masking NM kills the network), +# so they run against a DISPOSABLE VM you can revert, never ratio/velox. +# +# The target must be a booted VM (a real kernel + network stack — NetworkManager +# does not run reliably in an nspawn container) reachable over ssh as root, with +# NetworkManager and dhcpcd installed. The runner rsyncs the net + panelkit +# source trees in and runs the doctor from them via a small wrapper (no install +# needed), matching the stowed shim. jq is used host-side to read the JSON. +# +# STATUS: first-draft harness. The scenario break/fix/assert logic is the +# reviewed part; the ssh transport plumbing has not been exercised against a +# live target yet and may need a shakeout on the first real run. +# +# Usage: run-net-scenarios.sh --target root@HOST [--list] [--profile NAME] +# --target ssh destination of the booted VM (required unless --list) +# --profile a saved NM profile name for the keyfile scenario +# (else the keyfile scenario is skipped) +# --list print the scenario plan and exit +# +# Reverting the VM to a clean snapshot between runs is the caller's job; each +# scenario also restores what it broke on the way out. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCENARIO_DIR="${NET_SCENARIO_DIR:-$SCRIPT_DIR/net-scenarios}" +DOTFILES="${DOTFILES:-$HOME/.dotfiles}" +T_NET="/root/net-scenario-tree" # where the source trees land in the target + +TARGET="" +LIST_ONLY=false +export NET_SCENARIO_PROFILE="${NET_SCENARIO_PROFILE:-}" + +while [[ $# -gt 0 ]]; do + case $1 in + --target) TARGET="$2"; shift 2 ;; + --profile) NET_SCENARIO_PROFILE="$2"; shift 2 ;; + --list) LIST_ONLY=true; shift ;; + *) echo "usage: $0 --target root@HOST [--list] [--profile NAME]" >&2; exit 1 ;; + esac +done + +mapfile -t S_FILES < <(find "$SCENARIO_DIR" -maxdepth 1 -name '*.sh' | sort) + +if [ "$LIST_ONLY" = true ]; then + echo "net doctor scenario plan:" + for f in "${S_FILES[@]}"; do + ( . "$f"; printf ' %-24s %s\n' "$(basename "$f" .sh)" "$SCENARIO_DESC" ) + done + exit 0 +fi + +[ -n "$TARGET" ] || { echo "--target root@HOST is required" >&2; exit 1; } +command -v jq >/dev/null || { echo "jq is required host-side" >&2; exit 1; } + +SSH() { ssh -o BatchMode=yes "$TARGET" "$@"; } + +# ── target helpers (available to the scenario scripts) ─────────────────── +# nexec runs a payload in the target as root; the doctor runs through the +# /root/net-run wrapper synced below, so no fragile inline quoting is needed. +nexec() { SSH "bash -lc $(printf '%q' "$*")"; } +nfix() { nexec "/root/net-run doctor --fix --json"; } +ndoctor_json() { nexec "/root/net-run doctor --json" | jq -er "$1"; } + +info() { printf ' %s\n' "$*"; } +pass() { printf ' PASS %s\n' "$*"; } +fail() { printf ' FAIL %s\n' "$*"; } + +# ── sync the source trees + the doctor wrapper into the target ─────────── +echo "==> syncing net + panelkit into $TARGET:$T_NET" +SSH "mkdir -p $T_NET/net $T_NET/panelkit" +rsync -a -e "ssh -o BatchMode=yes" "$DOTFILES/net/src" "$TARGET:$T_NET/net/" +rsync -a -e "ssh -o BatchMode=yes" "$DOTFILES/panelkit/src" "$TARGET:$T_NET/panelkit/" +# The wrapper: run as root, so NET_SUDO empty (commands run directly) and +# PANELKIT_SUDO=sudo (root's `sudo -n true` succeeds -> the model resolves RUN). +SSH "cat > /root/net-run" <<EOF +#!/bin/bash +exec env PYTHONPATH=$T_NET/net/src:$T_NET/panelkit/src NET_SUDO= PANELKIT_SUDO=sudo \\ + python3 -c 'import sys; from net.cli import main; sys.exit(main(sys.argv[1:]))' "\$@" +EOF +SSH "chmod +x /root/net-run" + +# ── run each scenario ──────────────────────────────────────────────────── +fails=0 +for f in "${S_FILES[@]}"; do + name="$(basename "$f" .sh)" + # shellcheck disable=SC1090 + ( . "$f" + case "$name" in + *keyfile*) [ -n "$NET_SCENARIO_PROFILE" ] || { info "skip $name (no --profile)"; exit 0; } ;; + esac + echo "== $name — $SCENARIO_DESC" + scenario_break || { fail "$name: break failed"; exit 1; } + if declare -F scenario_diagnose_expect >/dev/null; then + if scenario_diagnose_expect; then pass "$name: diagnose named it" + else fail "$name: diagnose did NOT name it (inspect net doctor --json)"; fi + fi + scenario_fix || info "$name: net doctor --fix returned non-zero" + if scenario_assert; then pass "$name: repaired" + else fail "$name: NOT repaired"; exit 1; fi + ) || fails=$((fails + 1)) +done + +echo +[ "$fails" -eq 0 ] && echo "all scenarios passed" || echo "$fails scenario(s) failed" +exit "$fails" diff --git a/scripts/testing/tests/test_boot.py b/scripts/testing/tests/test_boot.py index e442682..66b78e4 100644 --- a/scripts/testing/tests/test_boot.py +++ b/scripts/testing/tests/test_boot.py @@ -76,8 +76,8 @@ def test_zfs_pre_pacman_snapshot_hook(host): script = host.file("/usr/local/bin/zfs-pre-snapshot") assert script.exists and script.is_file, "pre-pacman snapshot script missing" assert script.mode & 0o111, "pre-pacman snapshot script is not executable" - hook = host.file("/etc/pacman.d/hooks/zfs-snapshot.hook") - assert hook.exists and hook.is_file, "zfs-snapshot.hook missing" + hook = host.file("/etc/pacman.d/hooks/05-zfs-snapshot.hook") + assert hook.exists and hook.is_file, "05-zfs-snapshot.hook missing" assert "PreTransaction" in hook.content_string, "hook not PreTransaction" assert "/usr/local/bin/zfs-pre-snapshot" in hook.content_string, \ "hook does not exec the snapshot script" diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py index 6f79bfd..468bc75 100644 --- a/scripts/testing/tests/test_desktop.py +++ b/scripts/testing/tests/test_desktop.py @@ -56,7 +56,7 @@ def test_live_update_guard_installed(host, hyprland_installed): guard = host.file("/usr/local/bin/hypr-live-update-guard") assert guard.exists, "live-update guard script missing" assert guard.mode & 0o111, "live-update guard not executable" - hook = host.file("/etc/pacman.d/hooks/hypr-live-update-guard.hook") + hook = host.file("/etc/pacman.d/hooks/10-hypr-live-update-guard.hook") assert hook.exists, "live-update guard pacman hook missing" assert "hypr-live-update-guard" in hook.content_string, \ "hook does not invoke the guard script" diff --git a/tests/gallery-probes/README.md b/tests/gallery-probes/README.md new file mode 100644 index 0000000..9b5ce7d --- /dev/null +++ b/tests/gallery-probes/README.md @@ -0,0 +1,26 @@ +# Gallery CDP probes + +Behavioral verification for docs/prototypes/panel-widget-gallery.html — headless Chrome +driven over the DevTools protocol (Node global WebSocket/fetch, no playwright). + +- probe.mjs — full regression: card count, size toggle, drag + click behavior under zoom, + zero-exception gate. Run: `node probe.mjs [--shot out.png]`. Update the card-count + assertion when cards are added. +- probe-fams.mjs — screen-color families: default fallbacks pixel-identical, chip clicks + recolor ink/face/trace, dynamic fills stay var-based. + +Both exit nonzero on failure. The gallery's componentization work (widgets.js extraction) +uses a green run of these as its no-regression gate per batch. + +## Traps when writing new checks + +- Dispatch clicks on the element that owns the listener, not an ancestor; drive drags + with in-page synthetic PointerEvents (setPointerCapture stubbed). +- A `find()` by textContent that matches nothing returns undefined, and the dispatch + no-ops silently — the check then fails looking like a widget bug. Match glyphs + exactly against the builder source (e.g. the transport stop button is '⏹' U+23F9, + not '■' U+25A0), and prefer asserting the find() hit before dispatching. +- Reduced-motion emulation must be set before Page.navigate (launch on about:blank, + Emulation.setEmulatedMedia, then navigate) or the page's matchMedia snapshot misses it. +- Kill stale headless Chromes after a crashed run; bracket the pkill pattern + (`pkill -f 'remote-debugging-port=934[5]'`) so it can't match its own command line. diff --git a/tests/gallery-probes/audit-extraction.mjs b/tests/gallery-probes/audit-extraction.mjs new file mode 100644 index 0000000..3a69f2b --- /dev/null +++ b/tests/gallery-probes/audit-extraction.mjs @@ -0,0 +1,77 @@ +// audit-extraction.mjs — report-only grader for the extraction-readiness bar. +// +// Every DUPRE builder should be liftable out of the gallery without archaeology: +// a contract comment documenting opts + handle, its CSS in DUPRE_CSS rather than +// the page stylesheet, and no page code reaching past a handle into instrument +// DOM. This audit is static (no browser): it reads the sources and reports a +// worklist. It gates nothing — the gate flips on only when the sweep reaches +// 100% (the VSTATUS/policy ratchet pattern). Exit 0 always, unless the audit +// itself cannot parse the sources. +// +// Checks: +// C1 contract comment — the block comment directly above DUPRE.<name> = +// function mentions both opts and the handle (the split-flap shape). +// C2 page-styled internals — class selectors in the page <style> that also +// appear in DUPRE_CSS (the page overriding component internals). +// C3 page reach-ins — page script querySelectors descending into a card's +// stage (past the handle) instead of using the .dupre surface. +// +// Usage: node audit-extraction.mjs [--verbose] + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const kitSrc = readFileSync(join(root, 'docs/prototypes/widgets.js'), 'utf8'); +const page = readFileSync(join(root, 'docs/prototypes/panel-widget-gallery.html'), 'utf8'); +const verbose = process.argv.includes('--verbose'); + +// ---- C1: contract comments ------------------------------------------------- +// Builders are `DUPRE.<name> = function`; the comment block that ends directly +// above (allowing blank lines) is its documentation. +const builders = []; +const re = /DUPRE\.(\w+)\s*=\s*function/g; +for (let m; (m = re.exec(kitSrc)); ) { + const name = m[1]; + const head = kitSrc.slice(0, m.index); + // Only the LAST comment block directly above counts — a lazy match from the + // file start would hand every builder the whole file's text and grade + // everything compliant (it did, on the audit's first run). + const at = head.lastIndexOf('/*'); + const tail = at >= 0 ? head.slice(at) : ''; + const comment = /^\/\*[\s\S]*\*\/\s*$/.test(tail) ? tail : ''; + const contract = /\bopts\b/i.test(comment) && /\bhandle\b/i.test(comment); + builders.push({ name, contract, commentLen: comment.length }); +} + +// ---- C2: page-styled instrument internals ------------------------------------- +const classSels = src => new Set([...src.matchAll(/\.([a-zA-Z][\w-]*)/g)].map(x => x[1])); +const cssBlock = kitSrc.match(/DUPRE_CSS\s*=\s*\[([\s\S]*?)\]\.join|DUPRE_CSS\s*=\s*`([\s\S]*?)`/); +const dupreCss = cssBlock ? (cssBlock[1] || cssBlock[2]) : ''; +if (!dupreCss) { console.error('AUDIT ERROR: DUPRE_CSS not found'); process.exit(1); } +const pageStyle = [...page.matchAll(/<style>([\s\S]*?)<\/style>/g)].map(x => x[1]).join('\n'); +const dupreClasses = classSels(dupreCss); +// Rig and page chrome legitimately live in the page; only instrument-internal +// classes (defined in DUPRE_CSS) styled by the page are violations. +const pageOverrides = [...classSels(pageStyle)].filter(c => dupreClasses.has(c)); + +// ---- C3: page reach-ins ---------------------------------------------------- +// Page script (inline <script> bodies only; widgets.js is external). +const pageScript = [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(x => x[1]).join('\n'); +const RIG = /\.(famchips|fc|fgroup|lab|opts|wrd|stagew|winfo|wnote|wname|no)\b/; +const reachIns = [...pageScript.matchAll(/querySelector(?:All)?\(\s*['"](#card-[\w-]+\s+[^'"]+)['"]/g)] + .map(x => x[1]).filter(sel => !RIG.test(sel)); + +// ---- report ---------------------------------------------------------------- +const good = builders.filter(b => b.contract); +console.log(`extraction audit — ${builders.length} builders`); +console.log(`C1 contract comments: ${good.length}/${builders.length} compliant`); +if (verbose || true) { + const bad = builders.filter(b => !b.contract).map(b => b.name); + if (bad.length) console.log(` missing (${bad.length}): ${bad.join(' ')}`); +} +console.log(`C2 page-styled instrument internals: ${pageOverrides.length}${pageOverrides.length ? ' — ' + [...new Set(pageOverrides)].join(' ') : ' (clean)'}`); +console.log(`C3 page reach-ins past handles: ${reachIns.length}${reachIns.length ? '' : ' (clean)'}`); +reachIns.forEach(r => console.log(' ' + r)); +process.exit(0); diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs new file mode 100644 index 0000000..a2333fb --- /dev/null +++ b/tests/gallery-probes/probe-fams.mjs @@ -0,0 +1,101 @@ +// Verify screen-family chips: presence, default fallbacks intact, live recolor on click. +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +const PORT = 9335; +const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html'; +const chrome = spawn('google-chrome-stable', ['--headless=new', `--remote-debugging-port=${PORT}`, '--no-first-run', '--window-size=1600,1200', URL], { stdio: 'ignore' }); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +let ws, id = 0; const pending = new Map(); const events = []; +for (let i = 0; i < 40; i++) { try { const l = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); const p = l.find(t => t.type === 'page' && t.url.startsWith('file')); if (p) { ws = new WebSocket(p.webSocketDebuggerUrl); break; } } catch {} await sleep(250); } +await new Promise(r => ws.onopen = r); +ws.onmessage = m => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method) events.push(d); }; +const send = (method, params = {}) => new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); +const evl = async e => { const r = await send('Runtime.evaluate', { expression: e, returnByValue: true }); if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails)); return r.result.result.value; }; +const fails = []; +const ok = (n, c, d = '') => { console.log(`${c ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`); if (!c) fails.push(n); }; +await send('Runtime.enable'); await sleep(1500); + +/* Everything below runs inside try/finally so a failing check cannot orphan the + browser. It could before, and the consequence was worse than a leak: the next + run connected to the survivor on this same port and reported results from a + STALE page — old widgets.js, chips already clicked. A probe that answers from + the previous run is worse than one that crashes. */ +try { + ok('no exceptions on load', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0); + // count screen-family rows only — card option groups (e.g. card 01) reuse .famchips + const famRows = await evl(`[...document.querySelectorAll('.famchips .lab')].filter(l=>l.textContent==='screen').length`); + ok('6 screen chip rows', famRows === 6, `got ${famRows}`); + + // default fallback: dmx ink computes to gold-hi rgb(255,190,84) + const dmxFill0 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`); + ok('R10 default ink = gold-hi', dmxFill0 === 'rgb(255, 190, 84)', dmxFill0); + + // click green chip on R10 -> ink becomes phos rgb(127,224,160) + await evl(`document.querySelector('#card-R10').querySelector('.fc[title="green"]').click()`); + const dmxFill1 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`); + ok('R10 green chip recolors ink', dmxFill1 === 'rgb(127, 224, 160)', dmxFill1); + + // radar: default sweep line = gold-hi; click green -> phos + const swSel = `document.querySelector('#card-R31')`; + const line0 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`); + await evl(`${swSel}.querySelector('.fc[title="green"]').click()`); + const line1 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`); + ok('R31 green chip recolors furniture', line0 !== line1 && line1 === 'rgb(88, 184, 126)', `${line0} -> ${line1}`); + + // scope (CSS instrument): trace stroke changes on amber chip + const tr0 = await evl(`getComputedStyle(document.querySelector('.dupre-scope polyline')).stroke`); + await evl(`document.querySelector('.dupre-scope').closest('.card').querySelector('.fc[title="amber"]').click()`); + const tr1 = await evl(`getComputedStyle(document.querySelector('.dupre-scope polyline')).stroke`); + ok('N11 amber chip recolors trace', tr0 !== tr1 && tr1 === 'rgb(255, 190, 84)', `${tr0} -> ${tr1}`); + + // R19: drag still works after family switch (click vfd, then check the handle's set() writes var-based fills) + await evl(`document.querySelector('#card-R19 .fc[title="vfd"]').click()`); + await evl(`document.getElementById('card-R19').dupre.set(30,70)`); + const barFill = await evl(`(()=>{const b=document.querySelectorAll('#card-R19 svg rect')[10];return b.getAttribute('fill');})()`); + ok('R19 dynamic fills use vars', barFill.startsWith('var(--scr-'), barFill); + + // R17: face gradient stop resolves to amber face after chip + await evl(`document.querySelector('#card-R17 .fc[title="amber"]').click()`); + const face = await evl(`getComputedStyle(document.querySelector('#card-R17 svg radialGradient stop')).stopColor`); + ok('R17 amber chip retints face', face === 'rgb(216, 203, 166)', face); + + // R57's window is a screen like any other, and it offers ALL six families + // rather than the five its siblings carry — including vfd, the marquee cyan. + const kpFams = await evl(`(()=>{ + const chips = [...document.querySelectorAll('#card-R57 .fc')].map(c => c.title); + const want = ['amber','green','red','blue','vfd','white']; + return want.every(f => chips.includes(f)) ? 'ok' : 'got ' + JSON.stringify(chips); + })()`); + ok('R57 offers all six screen families', kpFams === 'ok', kpFams); + + // Default is the shipped gold, unchanged until a chip is clicked. It reads the + // same as the amber family's --scr-hi because --gold-hi IS #ffbe54 — which is + // what makes the amber default pixel-identical rather than a near-miss. + const kpInk0 = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-pad text')).fill`); + ok('R57 default ink = gold-hi', kpInk0 === 'rgb(255, 190, 84)', kpInk0); + + // vfd recolors both the ink and the window behind it — a screen is not just its text + await evl(`document.querySelector('#card-R57 .fc[title="vfd"]').click()`); + const kpInk1 = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-pad text')).fill`); + ok('R57 vfd chip recolors the ink to marquee cyan', kpInk1 === 'rgb(99, 230, 200)', kpInk1); + /* .dupre-kp-win, not the first rect on the pad — that one is the faceplate, and + reading it made this check report the plate's colour and fail for the wrong + reason. */ + const kpBg = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-win')).fill`); + ok('R57 vfd chip recolors the window too', kpBg === 'rgb(6, 16, 13)', kpBg); + + ok('no exceptions after chip clicks', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0); + + // screenshot the recolored screens region (scroll R31 into view) + await evl(`document.querySelector('#card-R31 svg').scrollIntoView({block:'center'})`); + await sleep(400); + const shot = await send('Page.captureScreenshot', { format: 'png' }); + writeFileSync('fams.png', Buffer.from(shot.result.data, 'base64')); + console.log('shot: fams.png'); +} catch (e) { + console.error('PROBE ERROR: ' + e.message); + fails.push('probe-error'); +} finally { + chrome.kill(); +} +process.exit(fails.length ? 1 : 0); diff --git a/tests/gallery-probes/probe-vstatus.mjs b/tests/gallery-probes/probe-vstatus.mjs new file mode 100644 index 0000000..aefe43b --- /dev/null +++ b/tests/gallery-probes/probe-vstatus.mjs @@ -0,0 +1,209 @@ +// CDP probe for the gallery's baked validation statuses (VSTATUS) — no playwright. +// Usage: node probe-vstatus.mjs +// +// Runs against a throwaway --user-data-dir so localStorage starts empty every run: +// the whole point of VSTATUS is what renders when there is no local state, so a +// probe that inherited a profile's leftover gv-* keys would test nothing. +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const PORT = 9334; +const URL_FILE = '/home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html'; +const URL = 'file://' + URL_FILE; +const PROFILE = mkdtempSync(join(tmpdir(), 'gallery-vstatus-')); + +const chrome = spawn('google-chrome-stable', [ + '--headless=new', `--remote-debugging-port=${PORT}`, + `--user-data-dir=${PROFILE}`, + '--no-first-run', '--no-default-browser-check', + '--window-size=1600,1200', URL, +], { stdio: 'ignore' }); + +const sleep = ms => new Promise(r => setTimeout(r, ms)); +let ws, id = 0; +const pending = new Map(); +const events = []; + +async function connect() { + for (let i = 0; i < 40; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); + const page = list.find(t => t.type === 'page' && t.url.startsWith('file')); + if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; } + } catch { /* retry */ } + await sleep(250); + } + if (!ws) throw new Error('no page target'); + await new Promise(r => ws.onopen = r); + ws.onmessage = m => { + const d = JSON.parse(m.data); + if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } + else if (d.method) events.push(d); + }; +} +function send(method, params = {}) { + return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); +} +async function evl(expr) { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }); + if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text)); + return r.result.result.value; +} +async function reload() { + await send('Page.reload'); + await sleep(1200); +} +const lampOf = no => `document.querySelector('#card-${no} .vlamp').dataset.v`; + +const fails = []; +const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); }; + +try { + await connect(); + await send('Runtime.enable'); + await send('Page.enable'); + await sleep(1500); + + const errs = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200)); + + // 1. VSTATUS exists and is a plain record of no -> state + const shape = await evl(`(()=>{ + if (typeof VSTATUS === 'undefined') return 'undefined'; + if (typeof VSTATUS !== 'object' || VSTATUS === null || Array.isArray(VSTATUS)) return 'not-an-object'; + const bad = Object.entries(VSTATUS).filter(([,v]) => !['amber','green'].includes(v)); + return bad.length ? 'bad-values:' + JSON.stringify(bad.slice(0,3)) : 'ok'; + })()`); + ok('VSTATUS is a no->state record of amber/green only', shape === 'ok', shape); + + // 2. every baked entry names a real card + const orphans = await evl(`Object.keys(VSTATUS).filter(no => !document.getElementById('card-'+no))`); + ok('no baked entry names a missing card', orphans.length === 0, orphans.join(', ')); + + // 2b. the baked block's SOURCE TEXT is in DOM order — it is hand-pasted from + // GVexport, and a block written in some other order reshuffles wholesale on + // the next bake. This reads the file rather than the live object on purpose: + // JS hoists canonical integer keys ('12' before '01') on any object, so + // Object.keys can never report DOM order and asserting it would be + // unsatisfiable. Only the text on disk carries the property that matters. + const src = readFileSync(URL_FILE, 'utf8'); + const block = src.match(/const VSTATUS=\{([\s\S]*?)\n\};/); + if (!block) { + ok('VSTATUS source block is findable', false, 'regex did not match'); + } else { + const textKeys = [...block[1].matchAll(/"([^"]+)"\s*:/g)].map(m => m[1]); + const domOrder = await evl(`[...document.querySelectorAll('.vlamp')].map(l => l.closest('.card').id.slice(5))`); + const want = domOrder.filter(no => textKeys.includes(no)); + ok('VSTATUS source text is in DOM order', + JSON.stringify(textKeys) === JSON.stringify(want), + `baked=${JSON.stringify(textKeys)} want=${JSON.stringify(want)}`); + } + + // 3. with empty localStorage, baked entries render as their baked state + const mismatched = await evl(`Object.entries(VSTATUS) + .filter(([no,v]) => document.querySelector('#card-'+no+' .vlamp').dataset.v !== v) + .map(([no,v]) => no+':want='+v+',got='+document.querySelector('#card-'+no+' .vlamp').dataset.v)`); + ok('baked entries render on a fresh profile', mismatched.length === 0, mismatched.slice(0, 5).join(' ')); + + // 4. a card with no baked entry renders off + const unbaked = await evl(`(()=>{ + const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS)); + return l ? l.closest('.card').id.slice(5) + '=' + l.dataset.v : 'none'; + })()`); + ok('an unbaked card defaults to off', unbaked === 'none' || unbaked.endsWith('=off'), unbaked); + + // 5. the tally's done count equals the green lamps on screen + const tally = await evl(`(()=>{ + const green = document.querySelectorAll('.vlamp[data-v="green"]').length; + const row = document.querySelector('.vtally .vrow[data-v="green"] .vn'); + return [green, row ? +row.textContent : -1]; + })()`); + ok('tally done count matches green lamps', tally[0] === tally[1], `lamps=${tally[0]} tally=${tally[1]}`); + + // 6. localStorage wins over the default. Holds with an empty VSTATUS, so this + // check is live from the first commit rather than waiting on a baked block. + const any = await evl(`(()=>{ + const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS)); + return l ? l.closest('.card').id.slice(5) : null; + })()`); + if (any) { + await evl(`localStorage.setItem('gv-${any}','green'); ''`); + await reload(); + const set = await evl(lampOf(any)); + ok('localStorage sets an unbaked lamp', set === 'green', `card ${any} -> ${set}`); + await evl(`localStorage.removeItem('gv-${any}'); ''`); + await reload(); + const cleared = await evl(lampOf(any)); + ok('clearing an unbaked lamp returns it to off', cleared === 'off', `card ${any} -> ${cleared}`); + } + + // 6b. localStorage beats baked, both ways — a deliberate un-check must survive a + // baked green, and clearing must fall back rather than stick at off. + const target = await evl(`Object.keys(VSTATUS)[0] || null`); + if (target) { + await evl(`localStorage.setItem('gv-${target}','off'); ''`); + await reload(); + const got = await evl(lampOf(target)); + ok('localStorage off overrides a baked entry', got === 'off', `card ${target} -> ${got}`); + await evl(`localStorage.removeItem('gv-${target}'); ''`); + await reload(); + const back = await evl(lampOf(target)); + const want = await evl(`VSTATUS['${target}']`); + ok('clearing localStorage falls back to baked', back === want, `card ${target} -> ${back}, want ${want}`); + } else { + console.log('SKIP baked-override checks — VSTATUS is empty (nothing baked yet)'); + } + + // 7. the export affordance reproduces what is on screen, in VSTATUS shape + const round = await evl(`(()=>{ + if (typeof GVexport !== 'function') return 'no-GVexport'; + let parsed; + try { parsed = JSON.parse(GVexport()); } catch (e) { return 'not-json: ' + e.message; } + const live = {}; + document.querySelectorAll('.vlamp').forEach(l => { + const v = l.dataset.v; if (v !== 'off') live[l.closest('.card').id.slice(5)] = v; + }); + return JSON.stringify(parsed) === JSON.stringify(live) ? 'ok' + : 'export=' + JSON.stringify(parsed).slice(0,120) + ' live=' + JSON.stringify(live).slice(0,120); + })()`); + ok('GVexport reproduces the live lamp state', round === 'ok', round); + + // 7b. exported keys follow card order. JSON.stringify would hoist canonical + // integer keys ('12') above the rest ('01', 'R05'), reshuffling the baked + // block on every bake; the export builds its text by hand to avoid it. + const order = await evl(`(()=>{ + const keys = [...GVexport().matchAll(/"([^"]+)":/g)].map(m => m[1]); + const dom = [...document.querySelectorAll('.vlamp')] + .filter(l => l.dataset.v !== 'off').map(l => l.closest('.card').id.slice(5)); + return JSON.stringify(keys) === JSON.stringify(dom) ? 'ok' + : 'export=' + JSON.stringify(keys) + ' dom=' + JSON.stringify(dom); + })()`); + ok('exported keys follow card order', order === 'ok', order); + + // 8. export tracks a click, so a mid-walk copy is current + const clicked = await evl(`(()=>{ + const l = document.querySelector('.vlamp'); + const no = l.closest('.card').id.slice(5); + l.click(); + const v = l.dataset.v; + const parsed = JSON.parse(GVexport()); + return (v === 'off' ? !(no in parsed) : parsed[no] === v) ? 'ok' : 'stale: lamp=' + v + ' export=' + JSON.stringify(parsed[no]); + })()`); + ok('GVexport tracks a lamp click', clicked === 'ok', clicked); + + const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions after interaction', errs2.length === 0); +} catch (e) { + console.error('PROBE ERROR: ' + e.message); + fails.push('probe-error'); +} finally { + // Wait for Chrome to actually exit before removing its profile — kill() only + // signals, and a browser still flushing its profile dir makes rmSync throw + // ENOTEMPTY, which would fail an otherwise-green run. + chrome.kill(); + await Promise.race([new Promise(r => chrome.once('exit', r)), sleep(3000)]); + rmSync(PROFILE, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +} +process.exit(fails.length ? 1 : 0); diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs new file mode 100644 index 0000000..3db09a5 --- /dev/null +++ b/tests/gallery-probes/probe.mjs @@ -0,0 +1,1563 @@ +// CDP probe for the instrument gallery — no playwright, Node global WebSocket/fetch. +// Usage: node probe.mjs [--shot out.png] [--size N] +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; + +const PORT = 9333; +const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html'; +const shotIdx = process.argv.indexOf('--shot'); +const shotPath = shotIdx > -1 ? process.argv[shotIdx + 1] : null; + +const chrome = spawn('google-chrome-stable', [ + '--headless=new', `--remote-debugging-port=${PORT}`, + '--no-first-run', '--no-default-browser-check', + '--window-size=1600,1200', URL, +], { stdio: 'ignore' }); + +const sleep = ms => new Promise(r => setTimeout(r, ms)); +let ws, id = 0; +const pending = new Map(); +const events = []; + +async function connect() { + for (let i = 0; i < 40; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); + const page = list.find(t => t.type === 'page' && t.url.startsWith('file')); + if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; } + } catch { /* retry */ } + await sleep(250); + } + if (!ws) throw new Error('no page target'); + await new Promise(r => ws.onopen = r); + ws.onmessage = m => { + const d = JSON.parse(m.data); + if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } + else if (d.method) events.push(d); + }; +} +function send(method, params = {}) { + return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); +} +async function evl(expr) { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }); + if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text)); + return r.result.result.value; +} +async function drag(x1, y1, x2, y2) { + await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, button: 'left', clickCount: 1 }); + const steps = 8; + for (let i = 1; i <= steps; i++) + await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1 + (x2 - x1) * i / steps, y: y1 + (y2 - y1) * i / steps, button: 'left' }); + await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, button: 'left', clickCount: 1 }); +} + +const fails = []; +const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); }; + +try { + await connect(); + await send('Runtime.enable'); + await send('Page.enable'); + await sleep(1500); + + // 0. console errors / exceptions + const errs = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200)); + + // 1. card count (the instruments/row default is checked in 1h) + const cards = await evl(`document.querySelectorAll('.card').length`); + ok('111 cards', cards === 111, `got ${cards}`); + + // 1b. Every card carries a spec sheet. R58 shipped without one and nothing + // noticed — the sheet is the card's actual specification, so a card without + // it is a drawing with no contract. Cheap to forget, cheap to check. + const sheets = await evl(`(()=>{ + const missing = [...document.querySelectorAll('.card')] + .filter(c => !c.querySelector('.winfo')) + .map(c => c.id.slice(5)); + return missing.length ? 'no spec sheet: ' + missing.join(' ') : 'ok'; + })()`); + ok('every card has a spec sheet', sheets === 'ok', sheets); + + // 1c. Card 07's lit colour comes from the shared accent family, not a hardcode. + // The chip said exactly one thing (gold) when "on" is good in one panel, a + // warning in the next and a fault in the one after. Checks the family is + // shared rather than copied: a second consumer must get the same five. + const accents = await evl(`(()=>{ + const want = ['amber','green','red','white','vfd']; + const chips = [...document.querySelectorAll('#card-07 .fc')].map(c => c.title); + const missing = want.filter(w => !chips.includes(w)); + if (missing.length) return 'card 07 missing accents: ' + missing.join(' '); + const built = Object.keys(DUPRE.accentStyles('--x')); + return JSON.stringify(built) === JSON.stringify(want) ? 'ok' : 'family is ' + JSON.stringify(built); + })()`); + ok('card 07 offers the shared accent family', accents === 'ok', accents); + + // 1d. The chip actually recolours, and its default is untouched until asked — + // the same fallback discipline the screen families use. + const chipInk = await evl(`(()=>{ + const chip = document.querySelector('#card-07 .dupre-chip'); + const lit = () => getComputedStyle(chip).color; + const before = lit(); + document.querySelector('#card-07 .fc[title="vfd"]').click(); + const after = lit(); + return before === 'rgb(226, 160, 56)' && after === 'rgb(99, 230, 200)' + ? 'ok' : before + ' -> ' + after; + })()`); + ok('card 07 defaults to gold and recolours on click', chipInk === 'ok', chipInk); + + // 1e. The colour-policy backbone. Every instrument's colour is either free (the + // consumer picks) or locked for a stated reason, and the colour pass kept + // finding cards where "is this one a standard?" was answered from memory and + // wrong. So policy is declared on the builder and checked here. The invariant + // that matters: the accent recolour mechanism and the 'accent' declaration + // imply each other, so a locked card can't silently sprout accent chips and + // an accent card can't forget to say so. Coverage grows card by card; this + // round classifies the touched set, and the count is reported not gated. + const policy = await evl(`(()=>{ + const KINDS = DUPRE.POLICIES; + if (!KINDS) return 'no POLICIES vocabulary'; + const valid = new Set(Object.keys(KINDS)); + const fns = Object.entries(DUPRE).filter(([,v]) => typeof v === 'function'); + const declared = fns.filter(([,v]) => v.POLICY); + // each POLICY record is complete: a valid kind, a why, an authentic range + const shape = declared.filter(([,v]) => !valid.has(v.POLICY.kind) || !v.POLICY.why || !v.POLICY.authentic) + .map(([k,v]) => k + '(' + v.POLICY.kind + ')'); + if (shape.length) return 'malformed policy record: ' + shape.join(' '); + // A recolour mechanism and an 'accent' kind imply each other, BOTH directions, + // and both directions must recognise the SAME mechanisms — or a locked card + // carrying a multi-colour on-family slips the reverse check. A recolour + // mechanism is a STYLES.accent axis or a STYLES.on family (slideToggle's shape). + const hasMech = v => !!(v.STYLES && (v.STYLES.accent || v.STYLES.on)); + const mechNotAccent = fns.filter(([,v]) => hasMech(v) && (!v.POLICY || v.POLICY.kind !== 'accent')).map(([k]) => k); + if (mechNotAccent.length) return 'recolour mechanism but not declared accent: ' + mechNotAccent.join(' '); + const accentNoMech = declared.filter(([,v]) => v.POLICY.kind === 'accent' && !hasMech(v)).map(([k]) => k); + if (accentNoMech.length) return 'declared accent but no recolour mechanism: ' + accentNoMech.join(' '); + // a screen kind must actually read the screen vars + const screenNoVars = declared.filter(([,v]) => v.POLICY.kind === 'screen' && !v.toString().includes('--scr-')).map(([k]) => k); + if (screenNoVars.length) return 'declared screen but reads no --scr- vars: ' + screenNoVars.join(' '); + return 'ok (' + declared.length + ' classified)'; + })()`); + ok('policy records valid, complete and mechanism-matched', policy.startsWith('ok'), policy); + + // 1f. The policy is on the PAGE, not just in code and this probe. Each card + // shows a badge derived from its builder's COLOR (single source, so the + // page can't drift from the declaration), and the badge marks free vs + // locked vs unclassified. Checks a known classified card renders its policy + // and a locked/unclassified one renders honestly. + const shown = await evl(`(()=>{ + const badge = no => document.querySelector('#card-' + no + ' .cpol'); + const b07 = badge('07'), b10 = badge('R10'); + if (!b07 || !b10) return 'no policy badge on card'; + if (b07.textContent !== 'accent' || b07.dataset.free !== 'true') return 'card 07 badge: ' + b07.textContent + '/' + b07.dataset.free; + if (b10.textContent !== 'screen') return 'card R10 badge: ' + b10.textContent; + // an unclassified card shows the dash, not a stale or invented policy + const un = [...document.querySelectorAll('.cpol')].find(b => b.dataset.pol === 'none'); + if (un && un.textContent !== '—') return 'unclassified badge should read dash: ' + un.textContent; + // the badge text always matches the builder's actual COLOR, never a copy + const drift = [...document.querySelectorAll('.card')].map(c => { + const b = c.querySelector('.cpol'); const want = c.dataset.cpol; + return (want === 'none' ? '—' : want) === b.textContent ? null : c.id; + }).filter(Boolean); + return drift.length ? 'badge drifts from data-cpol: ' + drift.slice(0,3).join(' ') : 'ok'; + })()`); + ok('colour policy is shown on each card, derived not copied', shown === 'ok', shown); + + // 1g. The index tally counts coverage the way the validation tally counts the + // walk, so "how far has the colour pass reached" is a glance, not a grep. + // free + locked + unclassified must sum to the card total. + const ptally = await evl(`(()=>{ + const rows = [...document.querySelectorAll('#ptally .vrow[data-g] .vn')].map(v => +v.textContent); + const total = +document.querySelector('#ptally .vtot .vn').textContent; + const cards = document.querySelectorAll('.card').length; + if (total !== cards) return 'tally total ' + total + ' != ' + cards + ' cards'; + if (rows.reduce((a,b)=>a+b,0) !== total) return 'free+locked+unclassified ' + rows + ' != ' + total; + return 'ok free=' + rows[0] + ' locked=' + rows[1] + ' unclassified=' + rows[2]; + })()`); + ok('colour policy tally sums to the card total', ptally.startsWith('ok'), ptally); + + // 1h. instruments/row: the control sets an explicit column count and the cards + // resize to match. One-per-row cards must be much wider than four-per-row, + // and the count actually reaches the grid. + ok('default is 3 instruments/row', await evl(`document.body.dataset.cols`) === '3'); + await evl(`document.querySelector('.szbar .dupre-key[data-cols="1"]').click()`); + const w1col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); + const cols1 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); + await evl(`document.querySelector('.szbar .dupre-key[data-cols="4"]').click()`); + const w4col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); + const cols4 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); + ok('1/row is much wider than 4/row', w1col > w4col * 2.5, `w1=${Math.round(w1col)} w4=${Math.round(w4col)}`); + ok('the grid renders the chosen column count', cols1 === 1 && cols4 === 4, `1->${cols1} 4->${cols4}`); + ok('instruments/row chip flips state', await evl(`document.body.dataset.cols`) === '4'); + + // 3. behavioral, zoomed: fader drag on card 03 changes readout. Run at 2/row, + // not 1/row: at one-per-row the card is wide enough that its controls fall + // outside the 1600px probe window and the dispatched mouse events miss. + await evl(`document.querySelector('.szbar .dupre-key[data-cols="2"]').click()`); + await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`); + await sleep(200); + const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.dupre-fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`); + const before = await evl(`document.getElementById('rd-03').textContent`); + await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2); + await sleep(150); + const after = await evl(`document.getElementById('rd-03').textContent`); + ok('fader drag tracks at 2/row', before !== after && after !== '—', `'${before}' -> '${after}'`); + + // 4. behavioral, zoomed: toggle click on card 01 + await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`); + await sleep(200); + const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`); + const t0 = await evl(`document.getElementById('rd-01').textContent`); + await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sw[0], y: sw[1], button: 'left', clickCount: 1 }); + await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 }); + await sleep(150); + const t1 = await evl(`document.getElementById('rd-01').textContent`); + ok('toggle click responds at 2/row', t0 !== t1, `'${t0}' -> '${t1}'`); + + // 5. card 02 console keys: reading order and per-key tone. LIVE is green + // because --pass is what the kit means by live everywhere else; gold stays + // the generic engaged look. Craig's call, 2026-07-16. + const order = await evl(`[...document.querySelectorAll('#card-02 .dupre-key')].map(b => b.textContent)`); + ok('card 02 keys read SCAN, LIVE, MUTED', + JSON.stringify(order) === JSON.stringify(['SCAN', 'LIVE', 'MUTED']), JSON.stringify(order)); + + const engaged = await evl(`(()=>{ + const lit = [...document.querySelectorAll('#card-02 .dupre-key')].filter(b => /\\bdupre-(on|green|red)\\b/.test(b.className)); + return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('dupre-key dupre-','') : 'lit=' + lit.length; + })()`); + ok('card 02 engages LIVE in green by default', engaged === 'LIVE:green', engaged); + + const muted = await evl(`(()=>{ + const keys = [...document.querySelectorAll('#card-02 .dupre-key')]; + keys.find(b => b.textContent === 'MUTED').click(); + const lit = keys.filter(b => /\\bdupre-(on|green|red)\\b/.test(b.className)); + return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('dupre-key dupre-','') : 'lit=' + lit.length; + })()`); + ok('card 02 MUTED engages red and releases LIVE', muted === 'MUTED:red', muted); + + const rd02 = await evl(`document.getElementById('rd-02').textContent`); + ok('card 02 readout tracks the engaged key', rd02 === 'MUTED', rd02); + + // 6. card 01 slide toggle: the new tone atoms exist. red/warn on the `on` axis + // let the ENGAGED state be the notable one (mute, record, airplane) — before + // these, red was only reachable via the `off` axis, which colours the + // disengaged state instead. dim offText lets an off toggle recede in a dense + // panel. Craig's call, 2026-07-16. + const atoms = await evl(`(()=>{ + const S = DUPRE.slideToggle.STYLES; + const want = { on: ['amber','green','dark','red','warn'], onText: ['panel','cream','green'], + off: ['dark','red'], offText: ['white','red','black','dim'], + thumb: ['light','dark','chrome','brass'] }; + const missing = []; + for (const [axis, names] of Object.entries(want)) + for (const n of names) if (!S[axis] || !S[axis][n]) missing.push(axis + '.' + n); + return missing.length ? 'missing: ' + missing.join(', ') : 'ok'; + })()`); + ok('slide toggle style atoms present', atoms === 'ok', atoms); + + // 7. presets name intent (a combination), not paint. Every axis a preset names + // must resolve in STYLES — a typo here would silently fall through to the + // stylesheet default and look "nearly right", which is the worst outcome. + const presets = await evl(`(()=>{ + const P = DUPRE.slideToggle.PRESETS, S = DUPRE.slideToggle.STYLES; + if (!P) return 'no PRESETS'; + const AX = DUPRE.slideToggle.AXIS_ORDER; + const bad = []; + for (const [name, p] of Object.entries(P)) { + for (const ax of AX) { + if (!p[ax]) bad.push(name + ' missing ' + ax); + else if (!S[ax][p[ax]]) bad.push(name + '.' + ax + '="' + p[ax] + '" not in STYLES'); + } + } + return bad.length ? bad.join('; ') : Object.keys(P).join(','); + })()`); + ok('every preset names a resolvable style on every axis', + presets === 'panel,run,armed,caution,dark', presets); + + // 7a. the dark preset: neither state lights the pill, so the legend colour is + // the only thing carrying state. Both backgrounds must stay dark while the + // inks diverge — if either pill lights, the preset has lost its point. + const dark = await evl(`(()=>{ + const P = DUPRE.slideToggle.PRESETS.dark, S = DUPRE.slideToggle.STYLES; + const onBg = S.on[P.on].vars['--sw-on-bg'], offBg = S.off[P.off].vars['--sw-off-bg']; + const onInk = S.onText[P.onText].vars['--sw-on-ink'], offInk = S.offText[P.offText].vars['--sw-off-ink']; + if (onBg !== offBg) return 'pills differ: on=' + onBg + ' off=' + offBg; + if (onInk === offInk) return 'inks identical, state unreadable: ' + onInk; + return 'ok'; + })()`); + ok('dark preset keeps both pills dark and the inks distinct', dark === 'ok', dark); + + // 7b. the card claims the preset it is actually in. The instrument defaults to + // `panel`, so a preset group with nothing lit would assert "no preset + // active" — false, and exactly the kind of quiet mislabel this walk exists + // to catch. Nothing above this point touches card 01's chips (checks 2-4 + // only toggle its switch, which does not restyle), so the default still + // stands here — but this must stay ABOVE checks 8/8b/9, which do mutate + // the chips. Insert preset-touching checks after them, not before. + const defaultPreset = await evl(`(()=>{ + const pg = [...document.querySelectorAll('#card-01 .fgroup')] + .find(g => g.querySelector('.lab')?.textContent === 'preset'); + if (!pg) return 'no preset group'; + const lit = [...pg.querySelectorAll('.fc')].filter(c => c.classList.contains('on')); + return lit.length === 1 ? lit[0].title : 'lit=' + lit.length; + })()`); + ok('card 01 defaults to the panel preset', defaultPreset === 'panel', defaultPreset); + + // 8. a preset chip drives the instrument AND re-syncs the axis chips, so the card + // never shows a combination the instrument isn't in. + const applied = await evl(`(()=>{ + const card = document.getElementById('card-01'); + const groups = [...card.querySelectorAll('.fgroup')]; + const pg = groups.find(g => g.querySelector('.lab')?.textContent === 'preset'); + if (!pg) return 'no preset group'; + const armed = [...pg.querySelectorAll('.fc')].find(c => c.title === 'armed'); + if (!armed) return 'no armed chip'; + armed.click(); + const onGroup = groups.find(g => g.querySelector('.lab')?.textContent === 'on'); + const lit = [...onGroup.querySelectorAll('.fc')].filter(c => c.classList.contains('on')); + const brd = card.querySelector('.switch').style.getPropertyValue('--sw-on-brd'); + return (lit.length === 1 ? lit[0].title : 'lit=' + lit.length) + '|' + brd; + })()`); + ok('armed preset drives instrument and syncs the on chip', applied === 'red|var(--fail)', applied); + + // 8b. axes are not independent: onText overrides the ink `on` sets, so changing + // `on` after picking an onText must not silently revert the legend while its + // chip still shows lit. Drive it in the hazardous order and check the ink. + const orderSafe = await evl(`(()=>{ + const card = document.getElementById('card-01'); + const groups = [...card.querySelectorAll('.fgroup')]; + const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l); + const chip = (l, t) => [...grp(l).querySelectorAll('.fc')].find(c => c.title === t); + chip('on text', 'green').click(); // legend green + chip('on', 'dark').click(); // then change the pill — must keep it + const ink = card.querySelector('.switch').style.getPropertyValue('--sw-on-ink'); + const lit = [...grp('on text').querySelectorAll('.fc')].filter(c => c.classList.contains('on')); + return ink + '|' + (lit.length === 1 ? lit[0].title : 'lit=' + lit.length); + })()`); + ok('changing on keeps the chosen onText (chips cannot lie)', + orderSafe === 'var(--sevgrn)|green', orderSafe); + + // 9. diverging on one axis clears the preset — the card stops claiming a preset + // it is no longer in. Re-establishes its own precondition (preset lit) rather + // than inheriting it: the checks above already clicked axis chips, each of + // which clears the preset, so asserting lit===0 without re-lighting first + // would pass against an already-cleared group and prove nothing — including + // against a regression where clearing fired for onText but not for `on`. + const diverged = await evl(`(()=>{ + const groups = [...document.querySelectorAll('#card-01 .fgroup')]; + const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l); + const litPresets = () => [...grp('preset').querySelectorAll('.fc')].filter(c => c.classList.contains('on')).length; + [...grp('preset').querySelectorAll('.fc')].find(c => c.title === 'run').click(); + const before = litPresets(); + [...grp('on').querySelectorAll('.fc')].find(c => c.title === 'green').click(); + return before + '->' + litPresets(); + })()`); + ok('changing an axis clears the preset selection', diverged === '1->0', diverged); + + // 10. R57 ABC keypad — fills the taxonomy's text x alphanumeric empty cell. + // ABC order is the whole point: it is what industrial keypads do wherever + // the operator can't be assumed to touch-type, and it is what Craig's + // reference photos show. A QWERTY drift here would silently lose the idiom. + const abcOrder = await evl(`(()=>{ + const letters = [...document.querySelectorAll('#card-R57 .dupre-kp-key')] + .map(k => k.dataset.k).filter(k => /^[A-Z]$/.test(k)); + const want = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ']; + return JSON.stringify(letters) === JSON.stringify(want) + ? 'ok' : 'got ' + letters.length + ': ' + letters.join(''); + })()`); + ok('R57 carries A-Z in alphabetical order', abcOrder === 'ok', abcOrder); + + const abcDigits = await evl(`(()=>{ + const d = [...document.querySelectorAll('#card-R57 .dupre-kp-key')] + .map(k => k.dataset.k).filter(k => /^[0-9]$/.test(k)); + return d.length === 10 ? 'ok' : 'got ' + d.length + ': ' + d.join(''); + })()`); + ok('R57 carries a full 0-9 block', abcDigits === 'ok', abcDigits); + + // 10b. LAYOUT, geometrically. The A-Z check above reads DOM order, which the + // builder controls by push order — it would pass with every key rendered + // in the wrong place. This reads actual x positions instead: letters own + // the left columns, digits the right, and the alphabet column-aligns with + // itself all the way down (the discontinuity Craig caught: A-L used to + // start at column 3 while M-X started at column 0). + const layout = await evl(`(()=>{ + const x = k => { + const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + return g ? Math.round(g.querySelector('rect').getBBox().x) : null; + }; + const colStarts = ['A','D','G','J','M','S','Y'].map(x); + if (new Set(colStarts).size !== 1) return 'alphabet not column-aligned: ' + JSON.stringify(colStarts); + if (!(x('A') < x('1'))) return 'letters not left of digits: A=' + x('A') + ' 1=' + x('1'); + if (!(x('DEL') > x('J'))) return 'DEL not in the block beside the digits: DEL=' + x('DEL') + ' J=' + x('J'); + return 'ok'; + })()`); + ok('R57 letters left, digits right, alphabet column-aligned', layout === 'ok', layout); + + // 10c. DEL sits where the hand already is and CLR is exiled to the corner. + // Frequency and blast radius pull the same way: DEL is constant and costs + // one character, CLR is rare and costs the entry. Pinned because it is a + // deliberate inversion of where they started, easy to "tidy" back. + const reach = await evl(`(()=>{ + const box = k => { + const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + return g ? g.querySelector('rect').getBBox() : null; + }; + const del = box('DEL'), clr = box('CLR'), ent = box('ENT'); + if (!del || !clr || !ent) return 'missing key'; + if (!(del.y < clr.y)) return 'DEL should sit above CLR: DEL.y=' + del.y + ' CLR.y=' + clr.y; + if (!(Math.abs(del.y - ent.y) < 1)) return 'DEL should share the ENT row'; + return 'ok'; + })()`); + ok('R57 DEL is in reach, CLR is in the corner', reach === 'ok', reach); + + // 10d. The three function keys are a cost ladder — DEL takes one character + // back, CLR throws the entry away, ENT commits — so each must read as a + // different key before the legend is read. Checks they are mutually + // distinct and all differ from a plain cap, rather than naming a gradient: + // the palette may be retuned, the distinction may not collapse. + const ladder = await evl(`(()=>{ + const fill = k => { + const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + return g ? g.querySelector('rect').getAttribute('fill') : null; + }; + const f = { DEL: fill('DEL'), CLR: fill('CLR'), ENT: fill('ENT'), plain: fill('A'), digit: fill('1') }; + const fn = [f.DEL, f.CLR, f.ENT]; + if (new Set(fn).size !== 3) return 'function keys not mutually distinct: ' + JSON.stringify(f); + if (fn.includes(f.plain) || fn.includes(f.digit)) return 'a function key wears a plain cap: ' + JSON.stringify(f); + return 'ok'; + })()`); + ok('R57 DEL/CLR/ENT each read as their own key', ladder === 'ok', ladder); + + // 11. typing accumulates, in order. A keypad that registers presses but drops + // or reorders them is the failure that matters for a password field. + const typed = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + for (const c of ['W','I','F','I','7']) key(c).dispatchEvent(new MouseEvent('click', {bubbles:true})); + return document.getElementById('rd-R57').textContent; + })()`); + ok('R57 accumulates typed characters in order', typed.includes('WIFI7'), typed); + + // 11b. DEL takes back ONE character. Without it the only way out of a typo is + // wiping the whole entry, which on a 20-character passphrase means + // starting over — so the check that matters is that DEL is not CLR. + const del = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})); + click('CLR'); + for (const c of ['C','A','B','S']) click(c); + click('DEL'); + const back = document.getElementById('rd-R57').textContent; + for (let i = 0; i < 6; i++) click('DEL'); // past empty: must not throw or wrap + const floor = document.getElementById('rd-R57').textContent; + return back + ' | ' + floor; + })()`); + // Both halves are asserted: the earlier version computed the past-empty + // state and then never looked at it, so "stops at empty" was a promise in + // the name only — a DEL that wrapped the buffer would have passed. + ok('R57 DEL takes back one character, and stops at empty', + del.split(' | ')[0] === 'CAB' && del.split(' | ')[1] === 'empty', del); + + // 11c. A space must be VISIBLE in the window. The buffer is honest either way, + // but SVG collapses trailing whitespace, so a space rendered as a space is + // a keypress with no feedback: the operator presses SPACE, sees nothing, + // presses again, and now carries two spaces they cannot see in a + // passphrase they can't read back. Checked past the 13-char truncation + // boundary, where there are no pad dots left for a space to displace. + const space = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})); + const win = () => document.querySelector('#card-R57 text[font-size="14"]').textContent; + click('CLR'); + for (let i = 0; i < 13; i++) click('A'); + const before = win(); + click('SPC'); + const after = win(); + if (before === after) return 'space produced no visible change: ' + JSON.stringify(after); + if (/ $/.test(after)) return 'space rendered as a raw trailing space (invisible): ' + JSON.stringify(after); + click('CLR'); + return 'ok'; + })()`); + ok('R57 a typed space is visible in the window', space === 'ok', space); + + // 12. the two committing keys do different things: ENTER commits the buffer, + // CLEAR empties it. Types its own buffer rather than inheriting one from + // the checks above — they mutate it, so a check that assumed their leftovers + // would pass or fail on their behaviour instead of its own. + const committed = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})); + click('CLR'); + for (const c of ['N','E','T','5']) click(c); + click('ENT'); + const after = document.getElementById('rd-R57').textContent; + click('CLR'); + return after + ' | ' + document.getElementById('rd-R57').textContent; + })()`); + // Asserts the COMMIT SIGNAL ('ENTER · ' + buf), not merely that the buffer + // is still readable: typing the last character already put NET5 in the + // readout, so /NET5/ was true before ENT was ever pressed. That check + // passed with the ENT branch deleted (the key falls through to buf += 'ENT' + // and NET5 still matches) — it could not fail. + ok('R57 ENTER commits and CLEAR empties', + /^ENTER · NET5$/.test(committed.split(' | ')[0]) && !/NET5/.test(committed.split(' | ')[1]), committed); + + // 13. KEYS is a declarative TABLE, not a function over a DOM event. The Emacs + // port installs this same table into a keymap — it never sees a keydown — + // so a function here would force it to re-derive the instrument's intent and + // the two bindings would drift. (README, keyboard contract.) + const keysTable = await evl(`(()=>{ + const K = DUPRE.abcKeypad.KEYS; + if (!K) return 'no KEYS'; + if (typeof K !== 'object' || Array.isArray(K)) return 'KEYS is not a plain table: ' + typeof K; + const missing = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'].filter(c => K[c] !== c); + if (missing.length) return 'unmapped or mis-mapped: ' + missing.join(''); + const want = { Space: 'SPC', Backspace: 'DEL', Enter: 'ENT' }; + for (const [k, v] of Object.entries(want)) if (K[k] !== v) return k + ' -> ' + K[k] + ', want ' + v; + return 'ok'; + })()`); + ok('R57 KEYS is a declarative table covering the plate', keysTable === 'ok', keysTable); + + // 13b. THE contract's first rule: no document-level listener. An instrument that + // binds globally types into itself from anywhere on a 110-card page. + // Typing at the body with the card unfocused must do nothing at all. + const unfocused = await evl(`(()=>{ + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + document.activeElement.blur(); + for (const c of ['A','B','C']) document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: c, bubbles: true, cancelable: true })); + return document.getElementById('rd-R57').textContent; + })()`); + ok('R57 ignores keys when it does not have focus', unfocused === 'cleared', unfocused); + + // 13c. Focused, the same keys land — and land through press(), so click and key + // cannot drift apart. + const typedByKey = await evl(`(()=>{ + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); + ['n','e','t',' ','5'].forEach(send); // lowercase must normalise; space must map to SPC + const typed = document.getElementById('rd-R57').textContent; + send('Backspace'); + const bs = document.getElementById('rd-R57').textContent; + send('Enter'); + return typed + ' | ' + bs + ' | ' + document.getElementById('rd-R57').textContent; + })()`); + ok('R57 types from the keyboard when focused', typedByKey === 'NET 5 | NET | ENTER · NET ', typedByKey); + + // 13c-2. Click and key must not drift. Both routes are supposed to land in the + // same press(), so the same sequence entered each way must produce an + // identical buffer and readout. Duplicated logic in the handler would + // pass every check above this one and fail here. + const drift = await evl(`(()=>{ + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + const rd = () => document.getElementById('rd-R57').textContent; + const seq = ['A','B','SPC','7']; + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + seq.forEach(k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true}))); + const byClick = rd() + '/' + document.getElementById('card-R57').dupre.get(); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + ['A','B',' ','7'].forEach(k => + pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }))); + const byKey = rd() + '/' + document.getElementById('card-R57').dupre.get(); + return byClick === byKey ? 'ok' : 'drift: click=' + byClick + ' key=' + byKey; + })()`); + ok('R57 click and key land in the same place', drift === 'ok', drift); + + // 13c-3. press() is the allowlist, not the keydown handler. The handler guards + // the web; the Emacs port installs KEYS into a keymap and calls press + // directly, with no handler in the stack — so a press that trusts its + // caller is a hole in exactly the target the table exists for. + const pressGuard = await evl(`(()=>{ + const card = document.getElementById('card-R57'); + const h = card.dupre; + if (!h || !h.press) return 'no handle'; + const key = k => [...card.querySelectorAll('.dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + h.press('F1'); h.press('ArrowLeft'); h.press(''); + const junk = h.get(); + h.press('A'); + return junk === '' && h.get() === 'A' ? 'ok' : 'junk=' + JSON.stringify(junk) + ' then=' + JSON.stringify(h.get()); + })()`); + ok('R57 press() filters junk from any caller, not just the keyboard', pressGuard === 'ok', pressGuard); + + // 13c-4. Every key the table maps must be a real plate action, or the Emacs + // port installs a binding that silently does nothing. + const keysSubset = await evl(`(()=>{ + const bad = Object.entries(DUPRE.abcKeypad.KEYS).filter(([,v]) => !DUPRE.abcKeypad.ACTIONS.has(v)); + return bad.length ? 'KEYS maps to non-actions: ' + JSON.stringify(bad) : 'ok'; + })()`); + ok('R57 every KEYS value is a real plate action', keysSubset === 'ok', keysSubset); + + // 13d. preventDefault is spent only where there is a default worth killing. + // Space scrolls and Backspace navigates back, so those are claimed; Tab is + // how the page is navigable and Escape belongs to the audit stepper, so a + // instrument that swallows either breaks something it cannot see. + const defaults = await evl(`(()=>{ + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + pad.focus(); + const fired = k => { + const e = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }); + pad.dispatchEvent(e); + return e.defaultPrevented; + }; + const claimed = { Space: fired(' '), Backspace: fired('Backspace') }; + /* 'A' and 'Enter' are MAPPED keys that must still not be claimed — they reach + the same code path as Space, so they are what catches a preventDefault + moved after the lookup. Tab/Escape/F1 return before it and would stay green + through that regression on their own. */ + const free = { A: fired('A'), Enter: fired('Enter'), Tab: fired('Tab'), Escape: fired('Escape'), F1: fired('F1') }; + if (!claimed.Space || !claimed.Backspace) return 'not claimed: ' + JSON.stringify(claimed); + if (Object.values(free).some(Boolean)) return 'swallowed: ' + JSON.stringify(free); + return 'ok'; + })()`); + ok('R57 claims Space and Backspace, lets Tab/Escape through', defaults === 'ok', defaults); + + // 13e. press() is an allowlist, not a mailbox: an unmapped key must not append. + const unmapped = await evl(`(()=>{ + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); + key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + ['F1','ArrowLeft','Home','é','!'].forEach(k => + pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }))); + return document.getElementById('rd-R57').textContent; + })()`); + ok('R57 drops keys that are not on the plate', unmapped === 'cleared', unmapped); + + // 14. R58 index typewriter. THE check: selecting is not committing. Walking the + // stylus over the plate must print nothing at all — that separation IS the + // grammar, and it's the whole reason this card exists next to R57. If a cell + // click ever prints, the card has silently become a keypad with extra steps. + // Reads the BUFFER, not the card readout: the readout is supposed to change + // while hunting ("stylus over g"), and asserting on it would fail a correct + // instrument for showing the operator where the pointer is. + const grammar = await evl(`(()=>{ + const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]'); + const lever = document.querySelector('#card-R58 .dupre-ix-lever'); + const buf = () => document.getElementById('card-R58').dupre.get(); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + const empty = buf(); + ['M','i','g'].forEach(c => cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true}))); + const afterSelecting = buf(); + lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); + return JSON.stringify([empty, afterSelecting, buf()]); + })()`); + ok('R58 selecting prints nothing; only the lever commits', grammar === '["","","g"]', grammar); + + // 14b. The lever prints whatever the stylus is resting on, once per pull — the + // operator's two hands are two separate acts. + const spelled = await evl(`(()=>{ + const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]'); + const lever = document.querySelector('#card-R58 .dupre-ix-lever'); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + for (const c of ['M','i','g','n','o','n']) { + cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true})); + lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); + } + return document.getElementById('rd-R58').textContent; + })()`); + ok('R58 stylus + lever spells a word', spelled.includes('Mignon'), spelled); + + // 14c. Pulling the lever twice prints the character twice: the selection stays + // put, which is what lets you type 'ss' without re-aiming. + const repeat = await evl(`(()=>{ + const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]'); + const lever = document.querySelector('#card-R58 .dupre-ix-lever'); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + cell('s').dispatchEvent(new MouseEvent('click', {bubbles:true})); + lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); + lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); + return document.getElementById('card-R58').dupre.get(); + })()`); + /* Exact, on the buffer. /ss$/ on the readout also matched 'sss', so it passed + even if selecting printed — unable to fail on the one bug this card is about. */ + ok('R58 the lever repeats without re-aiming', repeat === 'ss', JSON.stringify(repeat)); + + // 14d. The plate's whole point, per Craig: it considered the characters a + // keyboard skips. Both cases with no shift key, plus accents, fractions and + // the section mark — that coverage is the idea being preserved from the + // Mignon, where the key ORDER deliberately is not. + // The plate is the Mignon's own, so the set is its set: both full cases, + // the accents and fractions a keyboard skips — and deliberately NO 1 and NO + // 0, because the machine has you type them with lowercase l and capital O. + // That economy is an old value worth keeping, so it's asserted rather than + // tolerated: adding a 1 key would be a silent departure from the reference. + const charset = await evl(`(()=>{ + const have = new Set([...document.querySelectorAll('#card-R58 .dupre-ix-cell')].map(c => c.dataset.c)); + const need = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ...'abcdefghijklmnopqrstuvwxyz', + ...'23456789', 'ä','ö','ü','§','½','¼','¾']; + const missing = need.filter(c => !have.has(c)); + if (missing.length) return 'missing: ' + missing.join(' '); + const shouldNotExist = ['1','0'].filter(c => have.has(c)); + if (shouldNotExist.length) return 'plate grew a key the Mignon economises away: ' + shouldNotExist.join(' '); + return 'ok'; + })()`); + ok('R58 carries the Mignon set: both cases, accents, no 1 or 0', charset === 'ok', charset); + + // 14j. The inversion is ZONAL, not per-case. The capitals block is dark discs on + // a light panel; the lowercase block is its photographic negative. But J is + // a capital sitting out on the ring, un-inverted — so a rule of "capitals + // are dark" is wrong, and only position decides. + const zones = await evl(`(()=>{ + const disc = c => { + const g = [...document.querySelectorAll('#card-R58 .dupre-ix-cell')].find(e => e.dataset.c === c); + return g ? g.querySelector('circle').getAttribute('fill') : null; + }; + const caps = disc('P'), lower = disc('p'), ringCap = disc('J'), ringPunct = disc('&'); + if (caps === lower) return 'capitals and lowercase discs are the same: ' + caps; + if (ringCap !== ringPunct) return 'J should wear the ring look like punctuation: J=' + ringCap + ' &=' + ringPunct; + if (ringCap === caps) return 'J is inverted, but it sits on the ring, not in the block'; + return 'ok'; + })()`); + ok('R58 inversion is zonal: J is a capital but not inverted', zones === 'ok', zones); + + // 14e. The layout is DATA, so revising the order is a table edit rather than a + // redraw. Craig has already said the keys will change; a layout welded into + // the drawing is one that never does. + const layoutData = await evl(`(()=>{ + const L = DUPRE.indexPlate && DUPRE.indexPlate.LAYOUT; + if (!L) return 'no LAYOUT'; + if (!Array.isArray(L) || !L.every(Array.isArray)) return 'LAYOUT is not a grid'; + const cells = document.querySelectorAll('#card-R58 .dupre-ix-cell').length; + const declared = L.flat().filter(Boolean).length; + return cells === declared ? 'ok' : 'drawn ' + cells + ' but declared ' + declared; + })()`); + ok('R58 layout is a declared table the plate renders', layoutData === 'ok', layoutData); + + // 14f. Nothing sits on top of the plate. The lever and CLR started life over the + // last column, burying characters and the PRINT legend, and every check + // above stayed green through it — geometry is invisible to behaviour. The + // gutter is sized off the layout table, so a wider plate must not slide the + // controls back onto the characters. + const clear = await evl(`(()=>{ + const box = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; }; + const cells = [...document.querySelectorAll('#card-R58 .dupre-ix-cell')].map(c => c.getBBox()); + const right = Math.max(...cells.map(b => b.x + b.width)); + const hits = []; + for (const sel of ['.dupre-ix-lever', '.dupre-ix-clear']) { + const b = box(sel); + if (!b) { hits.push(sel + ' missing'); continue; } + if (b.x < right) hits.push(sel + ' starts at ' + Math.round(b.x) + ', left of the plate edge ' + Math.round(right)); + } + return hits.length ? hits.join('; ') : 'ok'; + })()`); + ok('R58 lever and CLR clear the plate', clear === 'ok', clear); + + // 14g. The gutter stack doesn't collide with itself. The x-only check above + // can't see this: the lever and legend are anchored to the plate's top and + // CLR to the viewBox, so a SHORTER layout table (five rows is a plausible + // edit) used to ride CLR up over the PRINT legend. Growth was always safe; + // shrink was the trap, which is why VH now takes a floor. + const stack = await evl(`(()=>{ + const bb = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; }; + const lever = bb('.dupre-ix-lever'), clr = bb('.dupre-ix-clear'); + const legend = [...document.querySelectorAll('#card-R58 text')].find(t => t.textContent === 'PRINT'); + if (!lever || !clr || !legend) return 'missing gutter part'; + const lg = legend.getBBox(); + const overlaps = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && + a.y < b.y + b.height && b.y < a.y + a.height; + if (overlaps(lever, clr)) return 'lever overlaps CLR'; + if (overlaps(lg, clr)) return 'PRINT legend overlaps CLR'; + if (overlaps(lg, lever)) return 'PRINT legend overlaps the lever'; + const vb = document.querySelector('#card-R58 .dupre-ix-pad').viewBox.baseVal; + if (clr.y + clr.height > vb.height) return 'CLR falls outside the viewBox'; + return 'ok'; + })()`); + ok('R58 gutter stack does not collide or overflow', stack === 'ok', stack); + + // 14h. The layout table has no duplicate characters. cells{} and KEYS{} are both + // keyed by character, so a repeat would silently keep only the last: two + // cells would render and both be clickable, but clicking the first would + // jump the stylus across the plate to the second. 14e can't see it — a + // duplicate inflates the drawn count and the declared count equally. + const dupes = await evl(`(()=>{ + const flat = DUPRE.indexPlate.LAYOUT.flat().filter(Boolean); + const seen = new Set(), dup = new Set(); + for (const c of flat) (seen.has(c) ? dup : seen).add(c); + return dup.size ? 'duplicated on the plate: ' + [...dup].join(' ') : 'ok'; + })()`); + ok('R58 layout has no duplicate characters', dupes === 'ok', dupes); + + // 14i. press() is the allowlist for R58 too. R57 has this check; without it, + // press('F1') reaching select() unfiltered would ship green. + const ixPress = await evl(`(()=>{ + const h = document.getElementById('card-R58').dupre; + h.press('CLR'); + ['F1', 'PRINT ', '', 'constructor', 'ZZ'].forEach(k => h.press(k)); + return JSON.stringify([h.get(), h.selected()]); + })()`); + ok('R58 press() filters junk from any caller', ixPress.startsWith('[""'), ixPress); + + // 15. R58's keymap comes OUT of the layout table rather than beside it, so + // relaying the plate can't leave a keybinding pointing at a character the + // plate no longer carries. Enter is the lever. + const ixKeys = await evl(`(()=>{ + const K = DUPRE.indexPlate.KEYS, L = DUPRE.indexPlate.LAYOUT; + if (!K) return 'no KEYS'; + const chars = L.flat().filter(Boolean); + const missing = chars.filter(c => K[c] !== c); + if (missing.length) return 'plate chars not mapped: ' + missing.join(' '); + if (K.Enter !== 'PRINT') return 'Enter -> ' + K.Enter + ', want PRINT'; + const extra = Object.keys(K).filter(k => k !== 'Enter' && !chars.includes(k)); + return extra.length ? 'maps keys not on the plate: ' + extra.join(' ') : 'ok'; + })()`); + ok('R58 keymap is derived from the layout, Enter is the lever', ixKeys === 'ok', ixKeys); + + // 15b. THE grammar again, now through the keyboard. Typing a letter must move + // the stylus and print NOTHING. If a keypress ever prints, the card has + // quietly become R57 with a nicer plate, and the one thing it exists to + // demonstrate is gone. + const ixType = await evl(`(()=>{ + const pad = document.querySelector('#card-R58 .dupre-ix-pad'); + const h = document.getElementById('card-R58').dupre; + const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + ['M','i','g'].forEach(send); + const afterTyping = h.get(); + const resting = h.selected(); + send('Enter'); + return JSON.stringify([afterTyping, resting, h.get()]); + })()`); + ok('R58 typing selects, Enter prints', ixType === '["","g","g"]', ixType); + + // 15c. The plate holds both cases, so nothing is uppercased on the way in: + // Shift picks the case because 'a' and 'A' are different cells. R57 has to + // uppercase; this one must not, and that difference is the plate's whole + // argument for having no shift key. + const ixCase = await evl(`(()=>{ + const pad = document.querySelector('#card-R58 .dupre-ix-pad'); + const h = document.getElementById('card-R58').dupre; + const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + pad.focus(); + send('a'); send('Enter'); + send('A'); send('Enter'); + return h.get(); + })()`); + ok('R58 keeps case: a and A are different cells', ixCase === 'aA', ixCase); + + // 15d. Space isn't on the plate, so it isn't ours: it must scroll the page like + // always. (The missing space cell is a known gap — the real Mignon has a + // separate space key.) + const ixSpace = await evl(`(()=>{ + const pad = document.querySelector('#card-R58 .dupre-ix-pad'); + pad.focus(); + const e = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + pad.dispatchEvent(e); + return e.defaultPrevented ? 'swallowed space' : 'ok'; + })()`); + ok('R58 leaves Space alone (not on the plate)', ixSpace === 'ok', ixSpace); + + // 15e. Unfocused, it hears nothing — the contract's first rule, on the second + // instrument to take keys. + // Asserts nothing CHANGED, rather than expecting a cleared selection: CLR + // is fresh paper, and fresh paper doesn't move the operator's hand, so the + // stylus legitimately stays where the previous check left it. + const ixBlur = await evl(`(()=>{ + const h = document.getElementById('card-R58').dupre; + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + document.activeElement.blur(); + const before = JSON.stringify([h.get(), h.selected()]); + ['Q','Z'].forEach(k => document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }))); + const after = JSON.stringify([h.get(), h.selected()]); + return before === after ? 'ok' : before + ' -> ' + after; + })()`); + ok('R58 ignores keys when unfocused', ixBlur === 'ok', ixBlur); + + // 12. N20 split-flap — the flip is a mechanism, not a fade: cells step + // through the declared charset one flap at a time, retarget cleanly, and + // animate:false jumps straight to the target. + const flapApi = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + return { reading: typeof h.reading, chars: typeof h.chars, set: typeof h.set }; + })()`); + ok('N20 handle exposes reading() and chars', flapApi.reading === 'function' && flapApi.chars === 'string', + JSON.stringify(flapApi)); + + // Park the page's 1.5s demo ticker so it can't retarget mid-check + // (IH.flap is the ticker's handle reference; the card element keeps the real one). + await evl(`(() => { window.__realFlap = IH.flap; IH.flap = { next(){}, set(){}, tick(){} }; })()`); + // Let the board settle on a known grid first — the demo ticker left it + // anywhere, and every command cascades (there is deliberately no teleport + // path). setText is the deterministic seam; next() is random by design. + await evl(`document.querySelector('#card-N20').dupre.setText('SYSTEM\\nVOLUME\\nSIGNAL')`); + await sleep(6500); + + // Start a cascade and sample the displayed reading every 15ms until stable. + await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + window.__n20 = { samples: [], anims: 0 }; + h.setText('VOLUME\\nSIGNAL\\nRECORD'); // multi-step distances on several cells + window.__n20.n = 0; + window.__n20.timer = setInterval(() => { + window.__n20.samples.push(h.reading()); + // Every 4th tick (keeps the callback light), count only WAAPI-driven + // animations: a CSSAnimation carries animationName, a WAAPI one doesn't, + // so the old flipdrop class swap can't satisfy this. + if (++window.__n20.n % 4 === 0 && + document.querySelector('#card-N20').getAnimations({ subtree: true }).some(a => !a.animationName)) + window.__n20.anims++; + }, 15); + })()`); + await sleep(6500); + const n20 = await evl(`(() => { + clearInterval(window.__n20.timer); + const h = document.querySelector('#card-N20').dupre; + return { samples: [...new Set(window.__n20.samples)], final: h.reading(), anims: window.__n20.anims, + chars: h.chars }; + })()`); + ok('N20 cascade arrives at the commanded grid', n20.final === 'VOLUME\nSIGNAL\nRECORD', JSON.stringify(n20.final)); + ok('N20 cascade passes through intermediate readings (never teleports)', + n20.samples.filter(s => s !== 'SYSTEM\nVOLUME\nSIGNAL' && s !== 'VOLUME\nSIGNAL\nRECORD').length >= 2, + n20.samples.length + ' distinct states'); + ok('N20 flip runs WAAPI animations (a CSS class swap cannot satisfy this)', n20.anims > 0, 'anim samples ' + n20.anims); + { + // Cell 0 travels S->V; every observed transition must step +1 through the charset. + const seq = [...new Set(n20.samples.map(s => s[0]))]; + let stepped = seq.length >= 3 ? 'ok' : 'too few states: ' + seq.join(''); + for (let i = 1; i < seq.length && stepped === 'ok'; i++) { + const a = n20.chars.indexOf(seq[i - 1]), b = n20.chars.indexOf(seq[i]); + if (b !== (a + 1) % n20.chars.length) stepped = seq[i - 1] + '->' + seq[i] + ' is not one flap'; + } + ok('N20 cell 0 steps one flap at a time in charset order', stepped === 'ok', stepped); + } + + // Retarget mid-cascade: aim at word 2, then word 3 while still spinning; must land on 3. + await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + window.__n20r = { samples: [] }; + window.__n20r.timer = setInterval(() => window.__n20r.samples.push(h.reading()), 15); + h.setText('SIGNAL\\nRECORD\\nONLINE'); setTimeout(() => h.setText('RECORD\\nONLINE\\nSTEREO'), 180); + })()`); + await sleep(7000); + const n20b = await evl(`(() => { + clearInterval(window.__n20r.timer); + const h = document.querySelector('#card-N20').dupre; + return { final: h.reading().split('\\n')[0], target: 'RECORD', + sawSync: window.__n20r.samples.some(s => s.split('\\n')[0] === 'SIGNAL'), + anims: document.querySelector('#card-N20').getAnimations({ subtree: true }).length }; + })()`); + ok('N20 retarget mid-cascade lands on the new target', n20b.final === n20b.target, n20b.final); + ok('N20 retarget re-aims rather than queueing (SIGNAL never fully forms)', !n20b.sawSync, n20b.sawSync ? 'saw SIGNAL' : 'ok'); + ok('N20 no cascade left running after arrival', n20b.anims === 0, 'live anims ' + n20b.anims); + + // The demo scramble: next() sends each row to a random pool word — every + // row changes, rows stay mutually distinct, all drawn from the pool. + const n20s = await evl(`(() => { + const el = document.createElement('div'); document.body.appendChild(el); + const pool = ['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','FILTER','OUTPUT']; + const h = DUPRE.splitFlap(el, { rows: 3, cells: 6, words: pool, animate: false }); + const grids = [h.reading().split('\\n')]; + h.next(); grids.push(h.reading().split('\\n')); + h.next(); grids.push(h.reading().split('\\n')); + el.remove(); + for (const g of grids) { + if (new Set(g).size !== 3) return 'rows not distinct: ' + g.join(','); + if (!g.every(w => pool.includes(w.trim()))) return 'word outside pool: ' + g.join(','); + } + for (let i = 1; i < grids.length; i++) + for (let r = 0; r < 3; r++) + if (grids[i][r] === grids[i - 1][r]) return 'row ' + r + ' kept its word: ' + grids[i][r]; + return 'ok'; + })()`); + ok('N20 scramble sends every row to a different pool word', n20s === 'ok', n20s); + + // animate:false (the reduced-motion path) jumps straight to the target. + const n20c = await evl(`(() => { + const el = document.createElement('div'); document.body.appendChild(el); + const settles = []; + const h = DUPRE.splitFlap(el, { animate: false, onSettle: r => settles.push(r) }); + h.set(1); + const r = h.reading(); el.remove(); + return { r, settles }; + })()`); + ok('N20 animate:false jumps straight to the target', n20c.r.trim() === 'LINK', n20c.r); + ok('N20 settle announces once per command', n20c.settles.length === 1 && n20c.settles[0].trim() === 'LINK', + JSON.stringify(n20c.settles)); + + // The card is a three-row six-letter board; the demo draws random pool words. + const n20d = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + return { cells: document.querySelectorAll('#card-N20 .flapd').length, + lines: document.querySelectorAll('#card-N20 .flapline').length, + readLines: h.reading().split('\\n').length }; + })()`); + ok('N20 board is three rows of six cells', n20d.cells === 18 && n20d.lines === 3 && n20d.readLines === 3, + JSON.stringify(n20d)); + + // Skin axis: dark (cream on black) and the inverse light (black on cream), + // switched by the chip rig via setStyle, computed ink actually inverting. + const n20e = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + if (typeof h.setStyle !== 'function' || !DUPRE.splitFlap.STYLES || !DUPRE.splitFlap.STYLES.skin) return 'no skin axis'; + const skins = Object.keys(DUPRE.splitFlap.STYLES.skin); + if (skins[0] !== 'paper') return 'paper must list first, got: ' + skins.join(','); + if (!['dark','white','light','paper'].every(k => skins.includes(k))) return 'skins are: ' + skins.join(','); + const ink = () => getComputedStyle(document.querySelector('#card-N20 .fh')).color; + const lum = c => { const m = c.match(/[0-9]+/g).map(Number); return m[0] + m[1] + m[2]; }; + const paperDef = ink(); // the card's default skin is the white card, dark letters + if (lum(paperDef) >= 300) return 'default ink is not dark-on-paper: ' + paperDef; + if (!document.querySelector('#card-N20 .flap').classList.contains('flap-paper')) return 'default skin class is not paper'; + h.setStyle('skin', 'white'); + const white = ink(); + if (white !== 'rgb(255, 255, 255)') return 'white skin ink is ' + white; + h.setStyle('skin', 'dark'); + const cream = ink(); + h.setStyle('skin', 'light'); + const light = ink(); + const gotClass = document.querySelector('#card-N20 .flap').classList.contains('flap-light'); + h.setStyle('skin', 'paper'); + const back = ink(); + if (!gotClass) return 'light skin class missing'; + if (!(lum(cream) > 400 && lum(cream) < 760 && lum(light) < 300)) return 'inks wrong: ' + cream + ' / ' + light; + if (back !== paperDef) return 'did not restore the paper default: ' + back; + return 'ok'; + })()`); + ok('N20 skins: paper default first, white ink, cream, ivory — all switch and restore', n20e === 'ok', n20e); + + const n20fnt = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + if (!DUPRE.splitFlap.STYLES.font) return 'no font axis'; + const fam = () => getComputedStyle(document.querySelector('#card-N20 .fh')).fontFamily; + const helv = fam(); // the card's default face is Helvetica + if (!/helvetica/i.test(helv)) return 'default face is not helvetica: ' + helv; + h.setStyle('font', 'mono'); + const mono = fam(); + h.setStyle('font', 'helv'); + const back = fam(); + if (!/berkeley|mono/i.test(mono) || /helvetica/i.test(mono)) return 'mono did not apply: ' + mono; + if (back !== helv) return 'did not restore the helv default: ' + back; + return 'ok'; + })()`); + ok('N20 face: Helvetica default, Berkeley Mono one click away', n20fnt === 'ok', n20fnt); + const n20f = await evl(`(() => { + const chips = [...document.querySelectorAll('#card-N20 .famchips .fc, #card-N20 .famchips [title]')].map(c => c.title || c.textContent); + return chips.length ? chips.join(',') : 'no chips'; + })()`); + ok('N20 card offers skin and font chips', + ['dark','white','light','paper','mono','helv'].every(t => n20f.includes(t)), n20f); + const n20h = await evl(`(() => { + const rd = document.querySelector('#card-N20 .wrd'); + const slider = document.querySelector('#card-N20 .famchips input[type=range]'); + if (rd && getComputedStyle(rd).display !== 'none') return 'readout still visible'; + if (!slider) return 'slider not in the chips row'; + return 'ok'; + })()`); + ok('N20 board is the only display: readout hidden, slider rides with the chips', n20h === 'ok', n20h); + + // Flap speed: the handle takes setFlapMs and the card's slider drives it. + const n20v = await evl(`(() => { + const h = document.querySelector('#card-N20').dupre; + if (typeof h.setFlapMs !== 'function' || typeof h.flapMs !== 'function') return 'no speed api'; + const before = h.flapMs(); + if (before !== 100) return 'default flap rate is ' + before + ', want 100'; + h.setFlapMs(120); + if (h.flapMs() !== 120) return 'setFlapMs did not take: ' + h.flapMs(); + const slider = document.querySelector('#card-N20 input[type=range]'); + if (!slider) { h.setFlapMs(before); return 'no slider on the card'; } + slider.value = '90'; slider.dispatchEvent(new Event('input', { bubbles: true })); + const viaSlider = h.flapMs(); + h.setFlapMs(before); slider.value = String(before); + return viaSlider === 90 ? 'ok' : 'slider did not drive setFlapMs: ' + viaSlider; + })()`); + ok('N20 flap speed: setFlapMs api + card slider drive the rate', n20v === 'ok', n20v); + + // Unpark the ticker, restore the demo state, and measure the dwell: the + // board must settle, hold the reading ~2s, and only then take the next update. + await evl(`(() => { + IH.flap = window.__realFlap; + const h = document.querySelector('#card-N20').dupre; + window.__n20dwell = { settleAt: 0, changeAt: 0, settled: '' }; + h.onSettle(r => { if (!window.__n20dwell.settleAt) { window.__n20dwell.settleAt = performance.now(); window.__n20dwell.settled = r; } }); + window.__n20dwell.timer = setInterval(() => { + const d = window.__n20dwell; + if (d.settleAt && !d.changeAt && h.reading() !== d.settled) { d.changeAt = performance.now(); clearInterval(d.timer); } + }, 50); + h.set(0); + })()`); + await sleep(10500); + const dwell = await evl(`(() => { + const d = window.__n20dwell; clearInterval(d.timer); + return { settleAt: d.settleAt, delta: d.changeAt ? d.changeAt - d.settleAt : -1 }; + })()`); + ok('N20 settles, then dwells ~2s before the next update', + dwell.settleAt > 0 && dwell.delta >= 1800 && dwell.delta <= 4000, 'delta ' + Math.round(dwell.delta)); + + // Skin chips carry visible swatch dots (the rig paints STYLES[..].dot; + // a string-valued STYLES entry leaves both chips transparent and identical). + const n20g = await evl(`(() => { + const chips = [...document.querySelectorAll('#card-N20 .famchips .fc')]; + if (chips.length < 6) return 'chips missing: ' + chips.length; + // each chip is a miniature flap cell: board colour + a letter in the ink + const pair = c => getComputedStyle(c).backgroundColor + '/' + getComputedStyle(c, '::after').color; + const skins = chips.slice(0, 4).map(pair); + if (new Set(skins).size !== 4) return 'skin previews not distinct: ' + skins.join(' '); + if (chips.slice(0, 4).some(c => getComputedStyle(c, '::after').content === 'none')) return 'skin chip has no ink letter'; + const fams = chips.slice(4).map(c => getComputedStyle(c, '::after').fontFamily); + if (!fams.some(f => /helvetica/i.test(f)) || fams[0] === fams[1]) return 'font previews wrong: ' + fams.join(' vs '); + const size = parseFloat(getComputedStyle(chips[0]).width); + return size >= 15 ? 'ok' : 'chips too small to read: ' + size; + })()`); + ok('N20 chips are miniature flap cells: board colour, ink letter, real face', n20g === 'ok', n20g); + + // N24 jewels — the handle reads and drives lamp state. Waybar-bound: a + // consumer must be able to set a jewel without synthesizing clicks. + const jw = await evl(`(() => { + const h = document.getElementById('card-N24').dupre; + if (!h.get || !h.set) return 'no get/set on handle'; + const init = h.get().join(','); + if (init !== '0,1,2,-1') return 'initial ' + init; + h.set(3, 1); + if (h.get()[3] !== 1) return 'set(3,1) not reflected: ' + h.get().join(','); + const el = document.querySelectorAll('#card-N24 .dupre-jewel')[3]; + if (!el) return 'no .dupre-jewel elements'; + if (el.classList.contains('dupre-dim')) return 'jewel 3 still dark after set(3,1)'; + h.set(3, -1); + if (!el.classList.contains('dupre-dim')) return 'set(3,-1) did not dark'; + if (h.get()[3] !== -1) return 'get after dark: ' + h.get().join(','); + return 'ok'; + })()`); + ok('N24 jewels handle exposes get/set', jw === 'ok', jw); + + // Under prefers-reduced-motion the board paints once and RESTS — the dwell + // chain must stay gated off, or the panel self-advances every 2s forever. + await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + await send('Page.reload'); + await sleep(2500); + const rm1 = await evl(`document.querySelector('#card-N20').dupre.reading()`); + await sleep(5200); + const rm2 = await evl(`(() => ({ r: document.querySelector('#card-N20').dupre.reading(), anims: document.getAnimations().length }))()`); + ok('N20 rests under reduced motion (no self-advancing dwell chain)', + rm1 === rm2.r && rm2.anims === 0, JSON.stringify({ before: rm1, after: rm2.r, anims: rm2.anims })); + + // Every card's ID badge is its own anchor: click it and the URL carries + // the card's deep link. + const anchors = await evl(`(() => { + const bad = [...document.querySelectorAll('.card .wname .no')] + .filter(n => n.tagName !== 'A' || n.getAttribute('href') !== '#' + n.closest('.card').id) + .map(n => n.textContent); + const count = document.querySelectorAll('.card .wname a.no').length; + if (bad.length) return 'not anchors: ' + bad.slice(0, 5).join(' '); + return count === document.querySelectorAll('.card').length ? 'ok' : 'anchor count ' + count; + })()`); + ok('every card ID badge links to its own anchor', anchors === 'ok', anchors); + + // Batch-3 set() hardening: out-of-range input cannot wedge an instrument. + // presetBank clamps; dualKnob clamps both spindles. Built on detached hosts + // so the gallery's own cards stay untouched. + const clamps = await evl(`(() => { + const pb = DUPRE.presetBank(document.createElement('div')); + pb.set(99); const hi = pb.get(); + pb.set(-5); const lo = pb.get(); + const dk = DUPRE.dualKnob(document.createElement('div')); + dk.set(500, -500); const [o, i] = dk.get(); + return JSON.stringify({ hi, lo, o, i }); + })()`); + ok('presetBank and dualKnob set() clamp out-of-range input', + clamps === JSON.stringify({ hi: 3, lo: 0, o: 100, i: 0 }), clamps); + + // Batch-4 set() hardening, same detached-host shape: vernierDial and + // flutedKnob clamp 0-100; filterBank clamps the band index (an out-of-range + // band lands on the last, it doesn't throw); multiBandDial clamps the band + // to its rings and keeps the current band when set() omits it. + const clamps4 = await evl(`(() => { + const vd = DUPRE.vernierDial(document.createElement('div')); + vd.set(500); const vHi = vd.get(); + vd.set(-5); const vLo = vd.get(); + const fk = DUPRE.flutedKnob(document.createElement('div')); + fk.set(500); const fHi = fk.get(); + const fb = DUPRE.filterBank(document.createElement('div')); + fb.set(99, 30); const fbLast = fb.get()[11]; + const mb = DUPRE.multiBandDial(document.createElement('div')); + mb.set(50, 99); const [, bHi] = mb.get(); + mb.set(70); const [v2, bKeep] = mb.get(); + return JSON.stringify({ vHi, vLo, fHi, fbLast, bHi, v2, bKeep }); + })()`); + ok('batch-4 set() clamps: vernier/fluted 0-100, filterBank + multiBand band index', + clamps4 === JSON.stringify({ vHi: 100, vLo: 0, fHi: 100, fbLast: 30, bHi: 3, v2: 70, bKeep: 3 }), clamps4); + + // The batch-4 rename holds end to end: a detached dipBank round-trips its + // word through the dupre- prefixed switch classes, and jogWheel's cached + // inner disc actually rotates on set. + const b4dom = await evl(`(() => { + const db = DUPRE.dipBank(document.createElement('div')); + db.set('101'); + const jw = DUPRE.jogWheel(document.createElement('div')); + jw.set(10); + const inner = jw.el.querySelector('.dupre-inner'); + return JSON.stringify({ word: db.get(), sw: db.el.querySelectorAll('.dupre-dipsw').length, + rot: inner ? inner.style.transform : 'missing' }); + })()`); + ok('dipBank word round-trips and jogWheel rotates via dupre- classes', + b4dom === JSON.stringify({ word: '101000', sw: 6, rot: 'rotate(40deg)' }), b4dom); + + // Batch-5 set() hardening, same detached-host shape: ledRow, pillSlide and + // discSwitch clamp their index (discSwitch used to throw); waveRegion keeps + // E at least 4 above S however set() is called, not only on the drag path. + // ledRow's default index is 5 only for the default program list — a caller's + // own items start at 0 (the consoleKeys default-active rule). + const clamps5 = await evl(`(() => { + const lr = DUPRE.ledRow(document.createElement('div')); + lr.set(99); const lrHi = lr.get(); + lr.set(-3); const lrLo = lr.get(); + const lrDef = DUPRE.ledRow(document.createElement('div')).get(); + const lrOwn = DUPRE.ledRow(document.createElement('div'), { items: ['A', 'B', 'C'] }).get(); + const ps = DUPRE.pillSlide(document.createElement('div')); + ps.set(9); const psHi = ps.get(); + ps.set(-1); const psLo = ps.get(); + let dsHi; + try { const ds = DUPRE.discSwitch(document.createElement('div')); ds.set(9); dsHi = ds.get(); } + catch (e) { dsHi = 'threw'; } + const wr = DUPRE.waveRegion(document.createElement('div')); + wr.set(80, 20); const { s: wrS, e: wrE } = wr.get(); + wr.set(-10, 200); const { s: wrS2, e: wrE2 } = wr.get(); + return JSON.stringify({ lrHi, lrLo, lrDef, lrOwn, psHi, psLo, dsHi, wrS, wrE, wrS2, wrE2 }); + })()`); + ok('batch-5 set() clamps: ledRow/pillSlide/discSwitch index, waveRegion S/E order', + clamps5 === JSON.stringify({ lrHi: 7, lrLo: 0, lrDef: 5, lrOwn: 0, psHi: 2, psLo: 0, dsHi: 2, wrS: 80, wrE: 84, wrS2: 0, wrE2: 100 }), clamps5); + + // entryKeypad buffer round-trip on a detached host: digits accumulate, the + // buffer caps at 6, keys outside the contract's set are ignored, ✗ clears, + // ✓ commits and clears. + const b5dom = await evl(`(() => { + const kp = DUPRE.entryKeypad(document.createElement('div')); + ['1', '2', '3'].forEach(k => kp.press(k)); const buf3 = kp.get(); + kp.press('X'); const stray = kp.get(); + ['4', '5', '6', '7'].forEach(k => kp.press(k)); const cap = kp.get(); + kp.press('✗'); const clr = kp.get(); + kp.press('1'); kp.press('✓'); const commit = kp.get(); + return JSON.stringify({ buf3, stray, cap, clr, commit }); + })()`); + ok('entryKeypad buffers digits only, caps at 6, clears and commits', + b5dom === JSON.stringify({ buf3: '123', stray: '123', cap: '123456', clr: '', commit: '' }), b5dom); + + // batch-6 domain gates on detached hosts: fourWayToggle falls back to C on a + // bad initial position and ignores invalid quadrants in set(), camTimer + // clamps set() to the ring (12 steps → 0..11, fractions floor), pinMatrix + // drops initial pins that name no intersection. + const clamps6 = await evl(`(() => { + const fw = DUPRE.fourWayToggle(document.createElement('div'), { position: 'Z' }); + const fwDef = fw.get(); fw.set('B'); const fwB = fw.get(); fw.set('Q'); const fwQ = fw.get(); + fw.set('toString'); const fwProto = fw.get(); + const ct = DUPRE.camTimer(document.createElement('div'), { position: 99 }); + const ctHi = ct.get(); ct.set(-5); const ctLo = ct.get(); ct.set(2.7); const ctInt = ct.get(); + const pm = DUPRE.pinMatrix(document.createElement('div'), { pins: ['OSC1>VCF', 'BAD>KEY', 'LFO>NOPE', 'OSC2>VCA>X'] }); + return JSON.stringify({ fwDef, fwB, fwQ, fwProto, ctHi, ctLo, ctInt, pmN: pm.get().length }); + })()`); + ok('batch-6 domain gates: fourWayToggle quadrant, camTimer step clamp, pinMatrix pin filter', + clamps6 === JSON.stringify({ fwDef: 'C', fwB: 'B', fwQ: 'B', fwProto: 'B', ctHi: 11, ctLo: 0, ctInt: 2, pmN: 1 }), clamps6); + + // dsky verb/noun grammar through the prefixed DOM (catches rename stragglers): + // VERB arms exactly one hot window, V16 N36 commits PROG 16 and disarms, + // V35 lights all six status lamps. + const b6dom = await evl(`(() => { + const d = DUPRE.dsky(document.createElement('div')); + const K = {}; + d.el.querySelectorAll('.dupre-dsky-pad .dupre-key').forEach(b => K[b.textContent] = b); + K.VERB.click(); + const hot1 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length; + K['1'].click(); K['6'].click(); K.NOUN.click(); K['3'].click(); K['6'].click(); K.ENTR.click(); + const prog = d.get().prog; + const hot0 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length; + K.VERB.click(); K['3'].click(); K['5'].click(); K.ENTR.click(); + const lit = d.el.querySelectorAll('.dupre-dsky-sl.dupre-on').length; + return JSON.stringify({ hot1, prog, hot0, lit }); + })()`); + ok('dsky grammar drives prefixed DOM: hot window, V16 N36 sets PROG, V35 lights all lamps', + b6dom === JSON.stringify({ hot1: 1, prog: '16', hot0: 0, lit: 6 }), b6dom); + + // batch-7 domain gates on detached hosts: decadeBox coerces and clamps its + // digits, twoHandSafety ignores sides outside L/R, the meters clamp their + // set()/push() domains, sparkline floors its history at two points, scope + // clears the trace below two samples, eqBars reads missing bands as 0. + const clamps7 = await evl(`(() => { + const db = DUPRE.decadeBox(document.createElement('div'), { digits: [12, -3, undefined, 7] }); + const dbTot = db.get(); + const thsLog = []; + const ths = DUPRE.twoHandSafety(document.createElement('div'), { onChange: st => thsLog.push(st) }); + ths.press('X'); ths.press('L'); ths.press('R'); + const ms = DUPRE.miniSig(document.createElement('div')); + ms.set(2); const msHi = ms.get(); ms.set(-1); const msLo = ms.get(); + const fb = DUPRE.fuelBar(document.createElement('div')); + fb.set(150); const fbHi = fb.get(); + const rr = DUPRE.radialRing(document.createElement('div')); + rr.set(150); const rrHi = rr.get(); + const sp = DUPRE.sparkline(document.createElement('div'), { samples: 1 }); + sp.push(5); const spHi = sp.get(); + const spPts = sp.el.querySelector('polyline').getAttribute('points'); + const ws = DUPRE.waveStrip(document.createElement('div')); + ws.set([0, 5, -5], 0.5); + const wsD = ws.el.querySelector('path').getAttribute('d'); + const sc = DUPRE.scope(document.createElement('div')); + sc.set([0.5], 1); + const scEmpty = sc.el.querySelector('polyline').getAttribute('points'); + sc.set([5, -5], 1); + const scPts = sc.el.querySelector('polyline').getAttribute('points'); + let eqTxt; const eq = DUPRE.eqBars(document.createElement('div'), { bands: 3, cells: 4, onChange: (v, t) => eqTxt = t }); + eq.set([2, -1]); + const eqB0 = eq.el.children[0].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length; + const eqRest = eq.el.children[1].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length + + eq.el.children[2].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length; + return JSON.stringify({ dbTot, ths: thsLog.join('|'), msHi, msLo, fbHi, rrHi, spHi, + spOk: !spPts.includes('NaN'), wsOk: !wsD.includes('NaN') && wsD.includes(' 33.0') && wsD.includes(' 5.0'), + scEmpty, scPts, eqTxt, eqB0, eqRest }); + })()`); + ok('batch-7 domain gates: decadeBox digits, twoHandSafety side, meter clamps, trace guards', + clamps7 === JSON.stringify({ dbTot: 9007, ths: 'ready|armed|running', msHi: 1, msLo: 0, fbHi: 100, + rrHi: 100, spHi: 1, spOk: true, wsOk: true, scEmpty: '', scPts: '0.0,61.0 176.0,17.0', + eqTxt: 'peak 100%', eqB0: 4, eqRest: 0 }), clamps7); + + // voiceLoop grammar through the prefixed DOM (catches rename stragglers): + // the default set opens FD/GNC/A-G engaged with GNC talking, click cycles + // idle → monitored → talking → idle with talk exclusive, and a caller's own + // loop set starts all-idle (the demo default is the stock set's alone). + const b7dom = await evl(`(() => { + const vl = DUPRE.voiceLoop(document.createElement('div')); + const ks = vl.el.querySelectorAll('.dupre-vk'); + const nKeys = ks.length; + const mon0 = vl.el.querySelectorAll('.dupre-mon').length; + const tlk0 = vl.el.querySelectorAll('.dupre-tlk').length; + ks[2].click(); + const mon1 = vl.el.querySelectorAll('.dupre-mon').length; + ks[0].click(); + const tlk1 = vl.el.querySelectorAll('.dupre-tlk').length; + const fdTalks = ks[0].classList.contains('dupre-tlk') && !ks[1].classList.contains('dupre-tlk'); + ks[0].click(); + const fdIdle = vl.get()[0] === '0' && !ks[0].classList.contains('dupre-mon'); + const own = DUPRE.voiceLoop(document.createElement('div'), { loops: ['A', 'B', 'C'] }); + const ownIdle = own.get().join('') === '000' && own.el.querySelectorAll('.dupre-mon,.dupre-tlk').length === 0; + return JSON.stringify({ nKeys, mon0, tlk0, mon1, tlk1, fdTalks, fdIdle, ownIdle }); + })()`); + ok('voiceLoop cycles through prefixed DOM: demo default, exclusive talk, own set idle', + b7dom === JSON.stringify({ nKeys: 8, mon0: 3, tlk0: 1, mon1: 4, tlk1: 1, fdTalks: true, fdIdle: true, ownIdle: true }), b7dom); + + // batch-8 domain gates on detached hosts: the needle meters clamp set() to + // their documented 0-100, stripChart floors its history at two samples and + // survives a non-array set(), battCells floors its cell count at one. + const clamps8 = await evl(`(() => { + const xn = DUPRE.crossNeedle(document.createElement('div')); + xn.set(150); const xnHi = xn.get(); xn.set(-10); const xnLo = xn.get(); + const th = DUPRE.thermometer(document.createElement('div')); + th.set(-5); const thLo = th.get(); + const bd = DUPRE.bourdon(document.createElement('div')); + bd.set(200); const bdHi = bd.get(); + const st = DUPRE.stripChart(document.createElement('div'), { samples: 1 }); + st.set(null, 0.5); + const stPts = st.el.querySelector('polyline').getAttribute('points'); + let cmTxt; const cm = DUPRE.corrMeter(document.createElement('div'), { onChange: (v, t) => cmTxt = t }); + cm.set(200); const cmHi = cm.get(); + const bc = DUPRE.battCells(document.createElement('div'), { cells: -3 }); + const bcCells = bc.el.querySelectorAll('.dupre-cell').length; + bc.set(-10); const bcLo = bc.get(); + const vu = DUPRE.mcVu(document.createElement('div'), { value: 5 }); + const vuInit = vu.get(); + const vuNdl = vu.el.querySelector('line[stroke-width="1.6"]').getAttribute('transform'); + return JSON.stringify({ xnHi, xnLo, thLo, bdHi, stOk: !stPts.includes('NaN'), cmHi, cmTxt, bcCells, bcLo, vuInit, vuNdl }); + })()`); + ok('batch-8 domain gates: needle-meter clamps, stripChart array/floor guards, battCells cell floor, mcVu init', + clamps8 === JSON.stringify({ xnHi: 100, xnLo: 0, thLo: 0, bdHi: 100, stOk: true, + cmHi: 100, cmTxt: '+1.00', bcCells: 1, bcLo: 0, vuInit: 1.02, vuNdl: 'rotate(43,75,112)' }), clamps8); + + // batch-8 state through the prefixed DOM (catches rename stragglers): the + // battery lights dupre-on cells and tints dupre-warn under the threshold, + // the crossed needles and mercury column track set() through their renamed + // internals. + const b8dom = await evl(`(() => { + const bc = DUPRE.battCells(document.createElement('div')); + const nCells = bc.el.querySelectorAll('.dupre-cell').length; + const on62 = bc.el.querySelectorAll('.dupre-cell.dupre-on').length; + const warn62 = bc.el.querySelectorAll('.dupre-warn').length; + bc.set(20); + const on20 = bc.el.querySelectorAll('.dupre-cell.dupre-on').length; + const warn20 = bc.el.querySelectorAll('.dupre-cell.dupre-warn.dupre-on').length; + const xn = DUPRE.crossNeedle(document.createElement('div')); + xn.set(0); + const fwdT = xn.el.querySelector('.dupre-fwd').style.transform; + const rflT = xn.el.querySelector('.dupre-rfl').style.transform; + const th = DUPRE.thermometer(document.createElement('div')); + th.set(75); + const thH = th.el.querySelector('.dupre-thermo-fill').style.height; + const bd = DUPRE.bourdon(document.createElement('div')); + bd.set(50); + const bdT = bd.el.querySelector('.dupre-bourdon-ndl').style.transform; + const cm = DUPRE.corrMeter(document.createElement('div')); + cm.set(50); + const cmT = cm.el.querySelector('.dupre-corr-ndl').style.transform; + const stc = DUPRE.stripChart(document.createElement('div')); + stc.push(1); + const penT = stc.el.querySelector('.dupre-pen').style.top; + return JSON.stringify({ nCells, on62, warn62, on20, warn20, fwdT, rflT, thH, bdT, cmT, penT }); + })()`); + ok('batch-8 renamed DOM tracks state: battery on/warn cells, needles and pen move', + b8dom === JSON.stringify({ nCells: 8, on62: 5, warn62: 0, on20: 2, warn20: 2, + fwdT: 'rotate(-42deg)', rflT: 'rotate(42deg)', thH: '75%', bdT: 'rotate(0deg)', + cmT: 'rotate(0deg)', penT: '3px' }), b8dom); + + // batch-9 domain gates on detached hosts: the flight instruments clamp (or + // normalize) both set() and their build opts, badges coerces junk variants, + // the readout wraps its clock. + const clamps9 = await evl(`(() => { + const ai = DUPRE.attitudeIndicator(document.createElement('div'), { bank: -999, pitch: 5 }); + const aiB = ai.get().bank; + ai.set(999, 999); const aiHi = ai.get(); + const hb = DUPRE.headingBug(document.createElement('div'), { value: 450 }); + const hbB = hb.get().cmd; + const hbNdl = hb.el.querySelector('g polygon').parentNode.getAttribute('transform'); + hb.set(-30); const hbSet = hb.get().cmd; + const vt = DUPRE.verticalTape(document.createElement('div'), { value: 99 }); + const vtB = vt.get(); vt.set(0); const vtLo = vt.get(); + const vtT = vt.el.querySelector('g g').getAttribute('transform'); + const tn = DUPRE.twinNeedle(document.createElement('div'), { fuel: 99, oil: -5 }); + const tnB = tn.get(); + let cmTxt; const cm = DUPRE.comfortMeter(document.createElement('div'), + { temp: 999, humidity: -40, onChange: (v, t) => cmTxt = t }); + const cmB = cm.get(); + const bg = DUPRE.badges(document.createElement('div'), { items: [['X', 7], ['Y', 'junk']] }); + const bgVars = bg.get(); + const tr = DUPRE.tabularReadout(document.createElement('div'), { secs: -10 }); + const trB = tr.get(); + return JSON.stringify({ aiB, aiHi, hbB, hbNdl, hbSet, vtB, vtLo, vtT, tnB, cmB, cmTxt, bgVars, trB }); + })()`); + ok('batch-9 domain gates: flight-instrument clamps at build and set, badges variant coercion, readout wrap', + clamps9 === JSON.stringify({ aiB: -60, aiHi: { bank: 60, pitch: 20 }, hbB: 90, + hbNdl: 'rotate(90.0,65,65)', hbSet: 330, vtB: 35, vtLo: 5, vtT: 'translate(0,92.5)', + tnB: [4, 0], cmB: [100, 0], cmTxt: '100F · 0% RH · TOO WARM', bgVars: [1, 0], trB: 3590 }), clamps9); + + // batch-9 state through the renamed DOM (catches rename stragglers): badges + // swap variant classes, the readout counts down through dupre-readout and + // carries a styled dupre-unit (the old .readout .u rule was dead — .u is a + // sibling, so the caption rendered unstyled), the engraved label counts + // through dupre-cnt, and the SVG flight instruments track set(). + const b9dom = await evl(`(() => { + const bg = DUPRE.badges(document.createElement('div')); + const nBadge = bg.el.querySelectorAll('.dupre-badge').length; + const red0 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length; + const ghost0 = bg.el.querySelectorAll('.dupre-badge.dupre-ghost').length; + bg.set(0, 1); + const red1 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length; + const trHost = document.createElement('div'); document.body.appendChild(trHost); + const tr = DUPRE.tabularReadout(trHost); + const trTxt = tr.el.querySelector('.dupre-readout').textContent; + const unit = tr.el.querySelector('.dupre-unit'); + const unitTxt = unit.textContent; + const unitDisp = getComputedStyle(unit).display; + tr.tick(); const trTick = tr.el.querySelector('.dupre-readout').textContent; + tr.el.querySelector('.dupre-readout').click(); tr.tick(); + const trPaused = tr.el.querySelector('.dupre-readout').textContent; + trHost.remove(); + const el = DUPRE.engravedLabel(document.createElement('div')); + const cnt0 = el.el.querySelector('.dupre-cnt').textContent; + el.set(7); const cnt7 = el.el.querySelector('.dupre-cnt').textContent; + el.el.click(); const cnt8 = el.el.querySelector('.dupre-cnt').textContent; + const tn = DUPRE.twinNeedle(document.createElement('div')); + tn.set(0, 4); + const tnF = tn.el.querySelector('polygon[fill="#e0523a"]').parentNode.getAttribute('transform'); + const tnO = tn.el.querySelector('polygon[fill="#bfc4d0"]').parentNode.getAttribute('transform'); + const cm = DUPRE.comfortMeter(document.createElement('div')); + cm.set(70, 80); + const humid = [...cm.el.querySelectorAll('text')].find(t => t.textContent === 'TOO HUMID').getAttribute('fill'); + const ai = DUPRE.attitudeIndicator(document.createElement('div')); + ai.set(30, -10); + const aiT = ai.el.querySelector('g g').getAttribute('transform'); + const rc = DUPRE.roundCrt(document.createElement('div')); + const p1 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points'); + rc.tick(); + const p2 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points'); + const cr = DUPRE.chartRecorder(document.createElement('div')); + cr.reset(); + const crH = cr.get(); + const crD = cr.el.querySelector('path[stroke="#8f2416"]').getAttribute('d'); + return JSON.stringify({ nBadge, red0, ghost0, red1, trTxt, unitTxt, unitDisp, trTick, trPaused, + cnt0, cnt7, cnt8, tnF, tnO, humid, aiT, crtMoves: p1 !== p2 && !p2.includes('NaN'), crH, crD }); + })()`); + ok('batch-9 renamed DOM tracks state: badge variants, readout clock + styled unit, engraved count, instruments follow set()', + b9dom === JSON.stringify({ nBadge: 3, red0: 1, ghost0: 1, red1: 2, trTxt: '24:10', + unitTxt: 'timer', unitDisp: 'block', trTick: '24:09', trPaused: '24:09', + cnt0: '· 3', cnt7: '· 7', cnt8: '· 8', tnF: 'rotate(-170.0,60,62)', tnO: 'rotate(10.0,60,62)', + humid: '#c23a28', aiT: 'rotate(-30.0,65,65) translate(0,-16.0)', + crtMoves: true, crH: 0, crD: '' }), b9dom); + + // batch-10 domain gate: the annunciator documents a 0..2 state per cell, so + // an out-of-range initial state or set() must clamp rather than paint an + // undefined class (the mcVu build-enforcement precedent). Without the clamp, + // CLS[9] is undefined and get() reads back 0 — this fails RED on that. + const clamps10 = await evl(`(() => { + const an = DUPRE.annunciator(document.createElement('div'), { cells: [['A', 9], ['B', -3], ['C', 1]] }); + const anB = an.get(); + an.set(0, 9); an.set(1, -5); + const anSet = an.get(); + return JSON.stringify({ anB, anSet }); + })()`); + ok('batch-10 domain gate: annunciator state clamps to 0..2 at build and set', + clamps10 === JSON.stringify({ anB: [2, 0, 1], anSet: [2, 0, 1] }), clamps10); + + // batch-10 state through the renamed DOM (catches rename stragglers): the six + // extracted DOM builders each expose their handle and paint the dupre- classes + // — a JS/CSS name mismatch would render unstyled without throwing, so assert + // the class names directly. patchBay lights hot jacks and freqScale carries + // the compound dupre-fs-band unit label (dupre-band is the EQ's). + const b10dom = await evl(`(() => { + const ow = DUPRE.outputWell(document.createElement('div')); + const owRoot = ow.el.className, owSeed = ow.el.querySelectorAll('.dupre-ostep').length; + ow.push(['red', 'Err', 'boom']); + const owN = ow.el.querySelectorAll('.dupre-ostep').length; + const owEv = ow.el.querySelector('.dupre-ostep:last-child .dupre-ev').textContent; + const owRed = ow.el.querySelector('.dupre-ostep:last-child .dupre-lamp').classList.contains('dupre-red'); + const to = DUPRE.toast(document.createElement('div')); + const toRoot = to.el.className; to.fire('ping'); const toTxt = to.el.textContent; + const tc = DUPRE.tapeCounter(document.createElement('div')); + const tcRoot = tc.el.className; + const tcWheels = tc.el.querySelectorAll('.dupre-cwheel').length; + const tcRed = tc.el.querySelectorAll('.dupre-redw').length; + tc.set(120); const tcV = tc.get(); + const fq = DUPRE.freqScale(document.createElement('div')); + const fqBand = fq.el.querySelector('.dupre-fs-band').textContent; + const fqTicks = fq.el.querySelectorAll('.dupre-tick').length > 0; + const fqPtr = !!fq.el.querySelector('.dupre-fptr'); + fq.set(50); const fqV = fq.get(); + const pb = DUPRE.patchBay(document.createElement('div')); + const pbJacks = pb.el.querySelectorAll('.dupre-jack').length; + const pbHot = pb.el.querySelectorAll('.dupre-jack.dupre-hot').length; + const pbConns = pb.get(); + const an = DUPRE.annunciator(document.createElement('div')); + const anCells = an.el.querySelectorAll('.dupre-acell').length; + const anWarn = an.el.querySelectorAll('.dupre-warn').length; + const anFault = an.el.querySelectorAll('.dupre-fault').length; + const anMcOn = an.el.querySelector('.dupre-mc').classList.contains('dupre-on'); + return JSON.stringify({ owRoot, owSeed, owN, owEv, owRed, toRoot, toTxt, + tcRoot, tcWheels, tcRed, tcV, fqBand, fqTicks, fqPtr, fqV, + pbJacks, pbHot, pbConns, anCells, anWarn, anFault, anMcOn }); + })()`); + ok('batch-10 renamed DOM tracks state: well/toast/counter/freqscale/patchbay/annunciator handles + dupre- classes', + b10dom === JSON.stringify({ owRoot: 'dupre-owell', owSeed: 2, owN: 3, owEv: 'boom', owRed: true, + toRoot: 'dupre-toastw', toTxt: 'ping', tcRoot: 'dupre-counter', tcWheels: 6, tcRed: 2, tcV: 120, + fqBand: 'MHz', fqTicks: true, fqPtr: true, fqV: 50, pbJacks: 8, pbHot: 4, pbConns: ['0-5', '2-7'], + anCells: 6, anWarn: 1, anFault: 1, anMcOn: true }), b10dom); + + // late exceptions from interactions + const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions after interaction', errs2.length === 0); + + if (shotPath) { + await evl(`window.scrollTo(0,0)`); + await sleep(300); + const shot = await send('Page.captureScreenshot', { format: 'png' }); + writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64')); + console.log('shot: ' + shotPath); + } +} catch (e) { + console.error('PROBE ERROR: ' + e.message); + fails.push('probe-error'); +} finally { + chrome.kill(); +} +process.exit(fails.length ? 1 : 0); diff --git a/tests/gallery-tokens/test_gen_tokens.py b/tests/gallery-tokens/test_gen_tokens.py new file mode 100644 index 0000000..b9fbb50 --- /dev/null +++ b/tests/gallery-tokens/test_gen_tokens.py @@ -0,0 +1,278 @@ +"""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 DefaultElispFilename(unittest.TestCase): + """The default elisp target must be gallery-tokens.el so that the file's + (provide 'gallery-tokens) matches its name and `require` can resolve it + from a load-path. A tokens.el/gallery-tokens feature mismatch is a trap.""" + + def test_default_elisp_path_matches_provided_feature(self): + self.assertEqual( + os.path.basename(gt.DEFAULT_ELISP_NAME), "gallery-tokens.el") + + +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, "gallery-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() diff --git a/tests/gallery-widgets/test-gallery-widget.el b/tests/gallery-widgets/test-gallery-widget.el new file mode 100644 index 0000000..166cd59 --- /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 gallery-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 diff --git a/tests/installer-steps/test_pacman_hook_order.py b/tests/installer-steps/test_pacman_hook_order.py new file mode 100644 index 0000000..49acdbe --- /dev/null +++ b/tests/installer-steps/test_pacman_hook_order.py @@ -0,0 +1,27 @@ +"""Regression tests for the pacman hook ordering safety invariant.""" + +import os +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +class PacmanHookOrderTests(unittest.TestCase): + def test_safety_hooks_precede_mkinitcpio_removal(self): + with open(ARCHSETUP, encoding="utf-8") as source: + text = source.read() + + self.assertIn( + "cat << 'EOF' > /etc/pacman.d/hooks/05-zfs-snapshot.hook", text) + self.assertIn( + "cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook", text) + self.assertLess("05-zfs-snapshot.hook", "60-mkinitcpio-remove.hook") + self.assertLess("10-hypr-live-update-guard.hook", "60-mkinitcpio-remove.hook") + self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", text) + self.assertIn("rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", text) + + +if __name__ == "__main__": + unittest.main() @@ -21,65 +21,347 @@ The vocabulary is open — topic tags are coined as needed — so these are conv - *Effort / autonomy*: =:quick:= a spare-moment fix (minutes, not a sitting); =:solo:= Claude can carry it end to end — there's a build path, a test path, and no upfront decision needed (a leftover manual spot-check doesn't disqualify it). - *Topic / area* (open): the subsystem a task touches — e.g. =:hyprland:= =:waybar:= =:mpd:= =:music:= =:network:= =:tooling:= =:llm:= =:eask:= =:pocketbook:= =:cmail:=. Coin a new one when it aids filtering. * Archsetup Open Work -** TODO [#D] Maintenance console vNext :feature: -Deferred from the [[file:docs/specs/2026-07-07-maintenance-console-spec.org][spec]] (v1 ships determinate remedies only): AI/workflow assistance on read-only metrics (the vLater tier — failed-unit diagnosis, journal-error fix suggestions, OOM investigation); SIGKILL escalation for TERM-survivors on the KILL lever; live streaming meters on evidence rows where a count could be a level. +** TODO [#A] Velox boot recovery — no kernel in BE :bug:velox:zfs: +SCHEDULED: <2026-07-16> +Velox won't boot: ZBM prompts for the passphrase, unlocks, then reports no bootable environment with a kernel. Cause: an interrupted kernel =-Syu= removed the old kernel and never installed the new one — /mnt/be/boot (from zroot/ROOT/default) holds ONLY intel-ucode.img; vmlinuz-linux + both initramfs are gone. /boot lives inside zroot/ROOT/default (no separate boot dataset), so root-dataset snapshots capture it. -** TODO [#D] Retention-repair remedy unreachable from the panel GUI :bug:dotfiles: -snapshot_retention_repair (WRITE SANE LIMITS — =snapper -c <config> set-config TIMELINE_CREATE=yes TIMELINE_LIMIT_*=<TOML values>=) sits in the maint remedy table but is wired into no GUI layer (no reference in panel.py, viewmodel.py, or gui.py) — it's reachable only via =maint doctor review= / =maint fix=. Worse, the REVIEW & FIX roster's wording for item-bearing remedies says "per-item — on its subpanel", which for this remedy points at a key that doesn't exist. Found 2026-07-08 while walking the SNAPSHOTS subpanel with Craig. +Status 2026-07-15: a first rollback attempt did NOT fix it (square zero after reboot) — suspected typo in the snapshot name, so the rollback likely errored and did nothing. NOT verified. Next session: verify state in the ZBM recovery shell BEFORE any reboot. -Two fix shapes, decide at work time: (1) wire a digest key — e.g. on the timeline row when the config's installed TIMELINE_LIMIT_* drift from the TOML values (needs the probe to read the installed limits for comparison); or (2) keep it CLI-only deliberately and special-case the roster wording for remedies with no panel key ("CLI only — maint fix"). Option 2 is a few lines; option 1 makes limit drift visible on the board, which is the 2026-05-26 pile-up's root cause. Severity minor × rare edge = P4 per the bug matrix. +Recovery lever: the pre-pacman ZFS snapshot hook (live on velox since 2026-06-29) snapshots zroot/ROOT/default@pre-pacman_<ts> before every pacman transaction. The newest =pre-pacman_<ts>= predating the failed upgrade holds the intact old kernel — roll back to it. + +Morning steps (Craig at velox ZBM → recovery shell, Ctrl+R): +#+begin_src sh +# 1. pool writable + key loaded +zpool get readonly zroot +zfs get -H -o value keystatus zroot/ROOT/default +# if readonly=on: zpool export zroot && zpool import -f -N zroot +# if keystatus=unavailable: zfs load-key zroot + +# 2. list snapshots — COPY THE EXACT NAME (the typo bit here last time) +zfs list -t snapshot -o name,creation zroot/ROOT/default | grep pre-pacman + +# 3. see current /boot state (read-only mount) +umount /mnt/be 2>/dev/null; mkdir -p /mnt/be +mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be +ls -la /mnt/be/boot + +# 4. if /boot still shows only intel-ucode.img: redo rollback with the exact name +umount /mnt/be 2>/dev/null +zfs rollback -r zroot/ROOT/default@pre-pacman_<EXACT-TS> # -r, NOT -R + +# 5. VERIFY before reboot — remount RO, confirm the kernel is back +mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be +ls -la /mnt/be/boot # MUST show vmlinuz-linux + initramfs-linux.img +umount /mnt/be + +# 6. only once /boot shows a kernel: +zpool export zroot && reboot +#+end_src +Scope: only zroot/ROOT/default reverts; /home, /var, /media are separate datasets, untouched. After boot: =pacman -Syu= attended, confirm /boot holds vmlinuz-linux + initramfs before any shutdown. Full diagnosis: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-velox-boot-failure-handoff.org=; ZBM photo: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-PXL_20260715_043758976.jpg= (local on ratio; inbox is gitignored). + +** TODO [#B] Velox boot-failure retrospective — upgrade guard gaps :bug:zfs:maint: +Post-mortem for the 2026-07-15 velox no-kernel boot failure, from the archsetup/maint code review: +- maint's UPDATE remedy runs a plain =yay -Syu --noconfirm= (remedies.py:297). The live-update guard (guard.py) only matches mesa/hyprland (the 2026-06-07 live-swap class) — it never checks /boot, kernel, initramfs, or mkinitcpio exit. No post-upgrade /boot assertion exists. An interrupted kernel transaction slips straight through. +- Add a post-upgrade /boot assertion: after a transaction touching linux/linux-*, confirm vmlinuz-* + initramfs-*.img present and mkinitcpio exit 0; refuse to end the run (or page Craig) otherwise. Would have caught this. +- Sanoid-vs-actual dataset drift: configure_zfs_snapshots configures zroot/var/log + zroot/var/lib/pacman as separate datasets; velox's actual layout has neither separate (/var/log sits inside zroot/var). Reconcile. +- Confirm the pre-pacman snapshot hook is actually installed + firing on velox (it should be — it's what makes recovery possible). + +** TODO [#C] Add inetutils to install base :feature:solo:quick:network: +TRAMP's /ftp: method (ange-ftp) shells out to a command-line ftp client; Arch ships none by default. GNU inetutils provides =/usr/bin/ftp=. Craig's dirvish config has an FTP quick-access entry (phone FTP server), so it's a config dependency. Installed manually on ratio 2026-07-14; velox needs it once it boots. Add to the install base so future machines get it for free; verify via VM test. From .emacs.d handoff 2026-07-14-1751. + +** TODO [#C] Restore date-format scrolling on the waybar date module :feature:waybar:dotfiles:quick: +Scrolling the date module used to cycle its text format (=date-cycle= over full/ordinal/longfull/utc); that scroll binding was removed when format cycling moved to the time module (the date now shows a fixed ordinal "Fri Jul 17th"). Re-add the =on-scroll-up/down= bindings on =custom/date= so the date cycles its own formats independently of the time. The =waybar-date= script and =date-cycle= are intact and already support the ring — this is re-adding two config lines. One small design call: whether the date ring should still carry =utc= and =longfull= now that the time module owns UTC and the combined date+time readout (likely trim to date-only formats). Update =tests/waybar-clock-layout= (it currently asserts the date does not scroll) and =tests/date-cycle= accordingly. -** DONE [#C] minimal tier zsh login PATH gap :bug:dotfiles:quick:solo: -CLOSED: [2026-07-09 Thu] +** TODO [#C] Notification sound loudness :chore:audio:quick:solo: +Reduce notification-sound playback loudness by 40% (0.6 gain, approximately +-4.4 dB). Change the =NOTIFY_VOLUME= playback control rather than re-encoding +the normalized sound files; verify each notification type still plays clearly. + +** DOING [#B] Widget gallery upgrades :feature:design: :PROPERTIES: -:LAST_REVIEWED: 2026-07-09 +:LAST_REVIEWED: 2026-07-13 :END: -Fixed (dotfiles =c6a7878=). =minimal/.zprofile= added, mirroring =common/='s PATH-only prepend and its rationale comment. +Usability + documentation pass over the [[file:docs/prototypes/panel-widget-gallery.html][panel widget gallery]], orthogonal to the component-generation spec work, so it runs on the =gallery-upgrades= branch (squash merge to main after Craig's UI confirmation + tweaks). Items 1-4 run as a no-approvals speedrun (Craig authorized 2026-07-12); item 5 is a joint brainstorm after the merge. +*** TODO Extraction-readiness bar for every gallery component :refactor:design: +Craig's standing directive (2026-07-18, set while finishing the split-flap): every =DUPRE.*= builder should meet the bar the split-flap now sets, since these become regular components. The bar: a contract comment documenting every opt and the full handle surface; no page globals touched (page owns cadence via handles/callbacks, e.g. =onSettle=); all component CSS in one named =DUPRE_CSS= block; refactored until no opportunity worth doing remains (small named helpers, no duplication); construction axes declared via =STYLES= where the component has them. Sweep the existing builders against that list, fix the gaps, and make the bar a stated convention in the widgets.js header or README so new builders inherit it. Overlaps the component-generation spec's extraction phase — reconcile there rather than doing the work twice. + +*** 2026-07-18 Sat @ 04:32:20 -0500 Made the N20 split-flap an honest Solari mechanism +=GW.splitFlap= rebuilt from the drop-fade fake: charset-as-drum stepping (=opts.chars= is the flap order, one flip at a time through intermediates, staggered arrival), re-aim-not-queue retargeting, and the real two-half-panel fold (WAAPI, backfaces hidden), with =animate:false= collapsing to instant jump for reduced motion. Handle grew =setText=/=chars=/=reading()=; default width 3 → 4 cells. Nine probe checks written red-first (arrival, intermediates, one-flap stepping, re-aim discriminator, instant path); technique studied from HotFX and re-derived — no license on their repo, nothing copied (reference filed in =working/retro-stereo-widgets/references/=). Review: sound; its two test-strength notes addressed in the same change. + +*** 2026-07-12 Sun @ 12:59:48 -0500 Added the card size toggle (1x/2x/3x, default 3x) +Masthead size chips apply CSS zoom per grid; 2x/3x drop the 1320px wrap cap so wide monitors get the room. CDP-verified: 84 cards, no exceptions, fader drag and toggle click both track at 3x (drag helpers are rect-ratio based, so zoom is transparent to them). +*** 2026-07-12 Sun @ 13:42:29 -0500 Retuned the scale split per Craig +Craig: cards a fifth smaller, fonts and palette back to normal but a little larger. The toggle (now S/M/L, "widget size") zooms only the stage — L is 2.4x, M 1.7x — while card text sits at its own raised base size (wname .95rem, note .85, spec-sheet .82) and the palette left the zoom rules entirely (tiles bumped 148→176px, fonts up a notch). Grid columns widen per size step instead of zooming. Probes re-run green. +*** 2026-07-12 Sun @ 13:52:11 -0500 Raised all reading text to 12-13pt per Craig +One move: html font-size 130%, scaling every rem-based size (notes/spec sheets/palette/masthead/readouts). Verified computed: notes 13.3pt, spec sheets 12.8pt, palette 12.5pt — was ~10pt. Widget-internal px sizes untouched (they are drawing, scaled by the stage zoom). +*** 2026-07-12 Sun @ 14:07:00 -0500 Added R32 mechanical timer dial from Craig's reference +Wind-up interval timer (Controls, after R29; gallery at 85 cards): knurled coin-edge dial rotating under a fixed red index, OFF/20/40/60 at 4.5°/min, hub screw, winding arrow, PUSH TO STOP / TURN TO START engraving, red fluted stop knob. Drag winds (turn to start), the dial runs itself back down at a demo minute-per-second (reduced-motion gated), red knob click zeroes it (push to stop). Spec sheet included. Reference filed (2026-07-12-mechanical-timer-dial.png). CDP-verified: wind → T-35 MIN, tick 35→34, stop → OFF, zero exceptions. +*** 2026-07-12 Sun @ 14:21:27 -0500 Added R33 four-way rocker from Craig's reference +Navigation pad (Controls, after R32; gallery at 86 cards): recessed plate, rubber pad, four outward silver arrows, center pivot nub. Quadrant-sized click zones step a tracked cursor (readout shows direction + x/y), pressed arrow flashes gold. Click-only — noted in the spec sheet as the most Emacs-portable control in the kit (it is literally arrow keys). Reference filed (2026-07-12-four-way-rocker.png). CDP-verified: all four quadrants step and accumulate correctly, zero exceptions. +*** 2026-07-12 Sun @ 14:26:20 -0500 Timing-marker dial judged covered (Craig concurred: probable dupe) +Scope timing-marker reference (VARIABLE TIMING / MARKER SEC): a knob rotates a printed scale disc under a fixed top index — that mechanism is R02 (vernier disc under hairline), and the value-in-a-window read is R20 (drum roller). BANKED enrichment, new to the kit: the backlit active-value window — the full scale stays visible but only the selected value glows through an amber window (R02 shows all + highlights none; R20 highlights one + hides the rest). Also noted: the 1-2-5 stepped decade scale (us/ms/s) as detent content for any stepped dial. Reference filed (2026-07-12-timing-marker-dial.png); no new card. +*** 2026-07-12 Sun @ 14:29:22 -0500 Added R34 four-way toggle selector from Craig's reference +NOT a dupe of R33: the rocker is momentary (press = step), this is stateful (the lever lives in A/B/C/D). Test-panel styling: printed X/Y axes + circle + diagonal legend square, A-D label rings, corner lamps wired by printed lines, chrome ball lever that throws to the clicked quadrant while its lamp takes the light (exactly one lit, jewel-a). Click-only. Gallery at 87 cards. Reference filed (2026-07-12-four-way-toggle.png). CDP-verified: default POS C per the photo, all quadrants select, single-lamp invariant holds, lever transform tracks, zero exceptions. +*** 2026-07-12 Sun @ 14:37:46 -0500 Added R35 day-date disc calendar from Craig's reference +Watch day-date complication (Indicators, after R31; gallery at 88 cards). Judged NOT covered: N04 is two nested input knobs, R02 one disc under a hairline — a compound READOUT from independent coaxial discs is a new indicator form. Cream disc, 31 radial date numerals (upside down at bottom like the movement), weekday names twice around inside, fixed yellow hand as the read index, movement-plate screws. Live: initializes to today; click rolls midnight one day (both discs step). Spec sheet notes the honest limitation — the 31-slot disc ignores short months, exactly like the hardware. Reference filed (2026-07-12-daydate-discs.png). CDP-verified: today correct (SUN 12), click → MON 13, both disc transforms track, zero exceptions. +*** 2026-07-12 Sun @ 14:40:47 -0500 Added R36 LED dot matrix from Craig's reference (answered: what display is this) +Answered Craig's question: an 8x8 red LED dot-matrix module (64 discrete LEDs behind a tinted window, multiplexed; Kingbright/Lite-On family part) on Kilpatrick Audio's K4816 Pattern Generator, drawing its K logo. Judged new form: seven-seg/starburst are fixed segments, VFD marquee is character cells, R10 is a text LCD — a free bitmap display is none of those. Built as R36 (Indicators; gallery at 89 cards): 8x8 paintable matrix starting on the reference K (17 dots), click toggles any dot, readout counts lit. Reference filed (2026-07-12-led-dot-matrix.png). CDP-verified: K = 17/64, paint on → 18, paint off → 17, zero exceptions. +*** 2026-07-12 Sun @ 18:11:02 -0500 Built the takuzu-survey Tier 1+2: R37-R47 + N23 alarm lifecycle (gallery at 100 cards) +Craig picked option 2 from my assessment of takuzu's historical-panel-components research (60+ candidates; ~a fifth of its "new" verdicts were stale against the current kit, and the magic-eye entry re-litigated a removal takuzu itself requested 2026-07-11). Built + CDP-verified in 4 commits: 9b625d6 R37 pin routing matrix (VCS3 many-to-many, pins seat/pull, route count) + R38 dead-man button (first held-state control: pointer-capture, dwell clock, release-to-safe) + R39 rotary telephone dial (wheel winds to the stop and returns, digit pacing authentic); 109b3dd R40 breaker panel (first system-thrown control: TRIP pops a breaker to the amber mid-state, two-step reset) + R41 DSKY (verb/noun grammar, V35 lamp test, OPR ERR on bad grammar — one bug caught by probe: stale verb register re-executed on bare ENTR, fixed) + R42 cam-timer drum (procedure-as-position, self-advances after a click starts it); eb926ea R43 attitude indicator (first two-axis instrument, 2D drag) + R44 heading bug + servo needle (commanded-vs-actual, shortest-path chase); cbcad5b R45 flip-disc array (bistable pixel, scaleX flip) + R46 dekatron (pulse-stepped glow, carry blink on wrap) + R47 landing gear (spatial three-greens + transit pulse) + N23 upgraded with the alarm lifecycle (flash → ACK steadies → new alarm re-arms → RESET / TEST). All spec sheets written. BANKED as idioms, not cards: mimic diagram, oscilloscope cluster, throttle quadrant, voice-loop strip, blinkenlights, lockout-tag disabled-with-reason, knife-switch skin, decade-switch bank, normalled-jack overlay, neon-vs-jewel, VHF detent, valve handwheel, circular chart, two-hand anti-tie-down. Judged covered/stale in the doc: warning flag (R11), split-flap (N20), strip-chart (N16), odometer (N25), interval timer (R32), telegraph (R30), foot switch (R24), light gun/trackball (the mouse), guarded-toggle two-step (R29 already does it). Full regression green at cbcad5b. +*** 2026-07-12 Sun @ 18:29:52 -0500 Control-grammars reference note + the six promoted banked cards (gallery at 106) +Craig picked option 3 (note first, then build) and asked for the reading list in home's inbox. Shipped: docs/design/2026-07-12-control-grammars-reference.org (b0cebcf — the seven named grammars each with a kit exemplar, the literature from Moran CLG through MIL-STD-1472/NASA HIDH, and seven proposed taxonomy axes; the gap-finding move is crossing task x cardinality for empty cells). Reading list delivered to home/inbox (2026-07-12-1822). Then promoted six banked items to cards, CDP-verified in two commits: 5ef616f R48 knife switch (side-view blade on a real hinge; the air gap is the proof) + R49 decade box (four skirted decade knobs compose 3,500 Ω style values) + R50 two-hand safety (arm window 0.5s, same-hand and timeout both fault); 8b4978b R51 voice-loop keyset (independent monitors, exclusive talk, per-loop activity flicker) + R52 blinkenlights (live ADDR/DATA lamps folding in a clickable switch register, octal readout) + R53 circular chart recorder (day-per-revolution pen trace, fresh paper on click). Full regression green at 8b4978b, 106 cards. Still banked (genuinely idioms): mimic diagram, scope cluster, throttle quadrant, lockout tag, knife-skin, normalled jacks, neon-vs-jewel, VHF detent, valve handwheel. +*** 2026-07-12 Sun @ 18:52:41 -0500 Added R54 vertical tape + R55 twin-needle gauge from the Ki-57 panel (gallery at 108) +Craig fed a Mitsubishi Ki-57 right-panel photo (instruments identified card by card, labels read from the Japanese: 速度計 airspeed, 遠方回転計 remote tach, 昇降計 VSI, 人工水平儀 artificial horizon, 高度計 altimeter, 気筒温度計 CHT, etc.). Two genuine gaps built: R54 vertical tape instrument (scrolling scale behind a fixed amber index — the Ki-57 remote tach's form, universal in glass cockpits; drag spins the tape) and R55 twin-needle gauge (mirrored FUEL/OIL half-scales, two needles one hub, per-half drag surfaces — the banked "twin mirrored gauge" from the French jet panel, distinct from N13 whose needles are read at their crossing). Cleanup pass earlier (c4fcee6): rotary-dial digits were painted under the finger wheel — now on top; legible demo defaults for pin matrix / voice loop / DSKY PROG; caption color unified; cam labels bumped. Reference filed (2026-07-12-ki57-right-panel.jpg). CDP-verified: tape drags (2400→1329), needles independent (fuel up leaves oil, oil down leaves fuel), zero exceptions. +*** 2026-07-12 Sun @ 18:59:08 -0500 Added R56 comfort-zone crossed needles (gallery at 109) +Craig fed a brass weather-station comfort meter and correctly picked it as different from both dual meters: N13 derives a NUMBER from the crossing (iso-curves), R55 reads two needles separately; this one lands the crossing in a printed categorical VERDICT (TOO WARM / TOO DRY / JUST RIGHT — active zone goes red, a digital advantage the paper face lacks). Brass knurled bezel, cream face, temp needle from bottom-left, humidity from bottom-right, per-half drag surfaces, needles clipped to the face (probe caught tails poking past the bezel at low values — fixed). Reference filed (2026-07-12-comfort-meter.png). CDP-verified: default 72F/45%/JUST RIGHT, warm and dry verdicts flip with the active label, zero exceptions. +*** 2026-07-12 Sun @ 19:13:41 -0500 Squash-merged to main (bc93388) + cleanup round 2 +Squash merge of the 25-commit branch landed on main; branch deleted local+remote. Then a programmatic defect sweep (per-card overflow/empty/dead-readout/sparse bounding-box audit) caught R51's monitor bars streaking across the card — Chrome's CSS zoom miscomputes absolute left/right insets, fr tracks, AND stretched widths inside zoomed stages, so the keyset now uses fixed 52px grid tracks and fixed 40px flow bars (fixed px scale correctly under zoom; this is the third zoom-layout trap after the two in the same widget — noted for future stage-internal CSS: prefer fixed px inside .stagew). Seven intrinsically tiny widgets (toggle, chip, arm-to-fire, mini signal, ladder, thermometer, status lamp) got a .boost stage zoom (1.5x, compounds with the size toggle) so they stop floating in empty stages. Sweep false positives understood: clipped content (R43 horizon, R54 tape, N22 marquee, N25 counter) reports as overflow because getBoundingClientRect ignores clip; N13/N17/N28/R20/10 verified clean visually. Full regression green at 109 cards. +*** 2026-07-12 Sun @ 19:25:04 -0500 Page iteration: palette rebuilt as named instrument colors, moved to bottom; default size M +Per Craig: the palette left the top (now below Indicators) and stopped being a token dump — it is now a curated card of 36 NAMED colors grouped by role (Materials / Faces & inks / Lamps, LEDs & jewels / Screens & phosphors / Needles & controls), each with a name (Brass, Chart paper, Neon orange, Graticule green...) and where it appears on the instruments. No hex codes visible anywhere on the page (verified by innerText scan); page chrome (background, body text) deliberately excluded. Default widget size dropped 3x → M (1.7x). Data lives in a PALETTE array in gallery JS — the curation IS the data, since most instrument colors are local literals, not tokens. +*** 2026-07-12 Sun @ 13:01:27 -0500 Added the palette section +New "Palette — design tokens" section above Controls: 33 tiles read live from the generated =:root= rule via document.styleSheets (zero drift possible — what renders is what the widgets use). Hex tokens and glow rgb triples get swatches; mono/pulse-rate render as text tiles. Scales with the size toggle. +*** 2026-07-12 Sun @ 13:08:42 -0500 Added screen-color families + chips (first round) +Six period families defined (green = P1 phosphor built on =phos=, amber = P3 on the gold family, red = the nixie neon pulled out, blue = P4 white-blue, vfd = the marquee's =--vfd=, white = mono LCD), applied via scoped CSS vars with the shipped color as fallback — defaults are pixel-identical until a chip is clicked. Chips on the five clearly-screen widgets: R10 data matrix (amber), R17 CRT scope (green face), R19 waveform LCD (white), R31 radar (amber — the "in amber" one; green chip gives the classic PPI), N11 oscilloscope (green). Answers to the green question: the kit deliberately has four greens — phos #7fe0a0 (CRT trace), vfd #63e6c8 (marquee, blue-leaning), sevgrn #57d357 (LED), jewel-g #6fce33 (jewel lens); the phosphor-screen green is phos, not the others. Nixie left chipless: neon is only ever orange. Families live in gallery JS, not tokens.json — tokenize the winners once picked, the way amber earned its tokens. CDP-verified: recolors land on all five, interactions intact, zero exceptions. +*** 2026-07-12 Sun @ 13:15:19 -0500 Added spec sheets to all 84 cards +Every card now carries a collapsible "spec sheet" (a details element under the note) with up to 8 fields: input (always present, names the model — click ports everywhere including Emacs click-regions, drag needs a click/key idiom there, display widgets take no real input), solves, use (common vs specialty + where it shines), limits, origin, difficulty, prefer-when, and period (present on 41 of 84 — only where the component is clearly not timeless, e.g. nixie 1955-75 sits with keypads/telegraphs and clashes with VFD and LED seven-seg). Content lives in one INFO object keyed by card number; card() renders it. CDP-verified: 84/84 sheets present (missing-key check), fields render, zero exceptions. +*** 2026-07-14 Tue @ 02:00:49 -0500 Judged the two 2026-07-13 cross-needle references covered +The weather-station comfort meter is R56's form exactly (crossing lands in a printed categorical verdict; five zones vs R56's three is content, not mechanism). The SWR cross-needle meter is N13's defining mechanism (the crossing derives a number off printed iso-curves — forward/reflected watts → SWR). No new cards; both references stay filed in working/retro-stereo-widgets/references/. +*** DOING Widget validation pass +Craig walks all 110 cards; the lamps are his (click cycles off → amber → green, per-card localStorage key =gv-<no>=). Gate change with the option-1 approval (2026-07-12): the lamps no longer gate the widgets.js extraction (lossless, done) — they gate per-widget Emacs ports and the final catalogue blessing. -Tested the invariant rather than the file, because the bug *was* a per-file fix missing a tier: any stow tier shipping a =.zshrc= must ship a =.zprofile= that prepends =~/.local/bin=. Red first, and it named =minimal=. Verified behaviorally as the task asked — stowed the tier into a throwaway HOME and read a real =zsh -lc 'echo $PATH'=, which now leads with =~/.local/bin=. Full =make test= green. +Runs as a *joint loop* (Craig, 2026-07-16): he walks a batch of 10-15 and reports card numbers + what's wrong; Claude fixes them in one pass, gates on the three probes, he reloads and re-walks. The lamps track progress only — they never recorded *what* was wrong with a card, so the defects live in this task body as they surface. -The 2026-07-08 fix gave common/ a .zprofile (zsh logins never read .profile, so ~/.local/bin was missing from TTY/ssh PATH — dotfiles 39cef40). The minimal/ tier has the same gap: it carries .zshrc and .profile but no .zprofile, so headless installs (DESKTOP_ENV=none stows minimal/ instead of common/) still get zsh logins without ~/.local/bin. Add a matching minimal/.zprofile (PATH-only prepend, same rationale comment). Verify: fresh minimal stow, zsh -lc 'echo $PATH' shows ~/.local/bin first. The installer itself is clean — it writes no shell profiles; the stow layer owns this. +Statuses bake into the gallery source (=VSTATUS=, top of the lamp section) *periodically*, not once at completion as originally planned — localStorage is per-profile and dies with a cache clear, so at 8 cards baking is free and at 60 it's the difference between a record and a bad afternoon. Craig clicks "copy for source" under the index tally and pastes the block into the chat; the agent pastes it into =VSTATUS=. Precedence is localStorage → VSTATUS → off, so the live walk wins on the machine doing it and the baked record fills in on a fresh profile, after a clear, or on velox. Covered by =tests/gallery-probes/probe-vstatus.mjs=. -** TODO [#C] zfs base VM image build failure: ZFS DKMS module missing :bug:zfs: +*The page can't copy for you; you press Ctrl+C* (settled 2026-07-16). Clicking "copy for source" drops the export into a selected textarea at the bottom-left of the page. Ctrl+C it and paste it into the chat. From a =file://= origin Chrome refuses both programmatic copy paths — =navigator.clipboard.writeText= rejects NotAllowedError, and =execCommand('copy')= returns *true while writing nothing*, which is worse because the page then claims a success it never had. A hand-typed Ctrl+C works fine, so nothing about the clipboard is broken: a Wayland-set clipboard crosses to X11 and Emacs reads it correctly (sentinel-verified against =gui-get-selection=). + +*And the agent doesn't fetch it.* Never read the export with =wl-paste --primary=: PRIMARY holds whatever was last selected anywhere, so a blind read returns unrelated content (it surfaced a private SMS during this session). Craig pastes; the agent doesn't reach for it. + +Progress: 8 green + 2 amber (R07, R52) of 109, baked 2026-07-16. +*** 2026-07-16 Thu @ 08:11:18 -0500 Ran the classification brainstorm — two empty cells and a missing axis +Brainstormed with Craig and wrote the result into [[file:docs/design/2026-07-12-control-grammars-reference.org][the control-grammars reference]] ("The brainstorm, run"). Crossed Foley's six elemental tasks against cardinality over the 109 cards. + +Added *axis 8, set stability* (fixed at manufacture vs discovered at run time). The doc's original seven axes cannot find the kit's largest gap: axis 4 lumps all one-of-N together, so a chicken-head selector and a WiFi list share a cell that then reads as densely covered. The axis is invisible from inside hardware — every one-of-N in the kit has its positions engraved at manufacture — so a catalogue derived from period hardware inherits a blind spot exactly at dynamic sets, which is what its software consumers are made of. + +Two empty cells, both wanted by live panels today: *select x one-of-N dynamic* (the bt device list, the net network list) and *text x alphanumeric* (the WiFi password; the kit enters digits three ways and text zero ways). Foley names six irreducible tasks; the kit serves five and a half. + +Period models proposed rather than invented, per Craig's aesthetic gate: the answer to a changing set was never a control but a *re-labelable slot* — jukebox title-strip rack, Rolodex, scribble strip, switchboard strips. The slot is manufactured, the label is not. + +Also found: the gallery cannot build its own chrome from its own kit (swatch chips, validation lamps, size toggle are all bespoke HTML; the size toggle duplicates card 06). That is a free completeness test worth re-running as the chrome grows. + +Still open, recorded in the doc: the full seven-axis classification, the card-by-card audit behind the first-pass cell assignments, and the display side. + +*** DOING Build the four widgets the taxonomy found :feature:design: +From the [[file:docs/design/2026-07-12-control-grammars-reference.org][taxonomy brainstorm]] (2026-07-16). Craig's gate: stick to the aesthetic — model a period control, don't invent a software-native one. Reference photo first per the usual pipeline, then judge, then card. + +1. *Dynamic list* — model the jukebox wallbox title-strip rack (page-flip browse, rich slotted strips, select one). Fills the most damning cell; the bt and net panels both want it. Alternative model: Rolodex card spinner, better for long sets and weaker at showing several rows at once. +2. *Alphanumeric entry* — model the *industrial ABC-order keypad*. Craig's four references (=working/retro-stereo-widgets/references/2026-07-16-abc-keypad-*.png=, =-letter-drum-bank.png=) plus a survey superseded the teletype: the references are all ABC-order, not QWERTY, on stainless or membrane, with colour-coded function keys (yellow CANCEL, red CLEAR/NO, green ENTER/YES) that land on the kit's palette without translation. A faceplate, not furniture. +3. *Index typewriter* — stylus over a printed index plate, print lever commits (Hall 1881, AEG Mignon 1924). The survey's strongest find: select and commit on two separate controls is a grammar the kit has no exemplar of, and it is small and beautiful. Second card after the keypad. + + Craig's brief (2026-07-16, after reading the AEG Mignon Model 4 reference): *reproduce the device, keep its extended character set, treat the layout as ours.* What he admires is that the plate considered high-ASCII at all — accents, section mark, fractions, the full punctuation ring — and it carries both cases with no shift key, which is how a keyless machine reaches a whole character set. What he doesn't admire is the Mignon's key order: the real plate runs =P U G Q / V I N A B / L D E T M= (a frequency layout), which you cannot read your way around. "We'll revisit the keys and the layout. The best ideas will be preserved." + + So the layout ships as *data, not drawing* — a table the builder renders — because a revisable layout that requires touching the mechanism won't get revised. First pass: alphabetical, capitals block beside lowercase block at the same column offset (find the letter, then pick the case), the Mignon's two-block structure with a legible order. Expect it to change. + + References: =working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg= (Model 4, casing off, plate legible; CC BY-SA, Uwe Aranas, attribution required if reproduced) and =-mignon-4-index-typewriter.jpg= (museum context; public domain). Credits in =2026-07-16-mignon-CREDITS.txt=. Reference only, drawn from scratch. + +**** 2026-07-16 Thu @ 16:39:05 -0500 Built R58 index typewriter (gallery at 111) +Item 3 done. =GW.indexPlate=: click a cell to move the stylus, pull the lever to print, and only the lever prints. The first card where selecting and committing are separate acts. Keyboard follows the same rule — typing SELECTS, Enter is the lever — because a keypress that printed would make this R57 with a nicer plate. + +No case folding, unlike R57: the plate holds both cases as distinct cells, so Shift does the work a shift key would on a machine that has none. =KEYS= and =ACTIONS= are both *derived* from =LAYOUT= at load, so rewriting the table relays the plate, the keymap and the allowlist together — a binding aimed at a character the plate no longer carries isn't expressible. + +Craig's brief honoured: kept the extended set (Ä Ö Ü ä ö ü ß § ½ ¼ and the punctuation row) and the no-shift both-cases plate; dropped the Mignon's frequency order for alphabetical, capitals beside lowercase at the same column offset. + +Review (subagent) cleared the grammar — no path where selecting prints, none where the lever prints anything but the resting selection — and found five defects, all fixed: +- *Clicks bypassed press().* The contract says click and key must both route through it; R57 obeys, R58 had three direct handler bindings. No divergence yet, only because press was a pure dispatcher — add a guard to it and the mouse, the primary input on this card, would silently skip it with every probe green. Exactly the drift the rule exists to prevent, in the second card written against the rule. +- *Check 14c could not fail.* =/ss$/= on the readout also matches 'sss', so it passed even if selecting printed — blind to the one bug the card is about. Now exact, against the buffer. +- *cells{} collided on duplicate layout characters* and resolved =select('constructor')= through Object.prototype. Now =Object.create(null)=, plus a check that the table has no duplicates (14e couldn't see it: a duplicate inflates the drawn and declared counts equally). +- *Geometry broke on SHRINK.* The lever and legend anchor to the plate's top, CLR anchored to the viewBox, so a five-row table rode CLR up over the PRINT legend. Growth was always safe; shrink was the trap. VH now takes a floor, and I verified it by building the plate at 4, 5 and 9 rows rather than trusting the arithmetic: 190/192/280, no collisions. +- *No allowlist coverage on press().* R57 had it, R58 didn't. + +Known gap, Craig's to weigh when he revisits the keys: no space cell, so Space isn't in KEYS and still scrolls the page (correct per the contract). You can type Mignon-4 but not "Mignon 4". The real machine has a separate space key. +4. [@4] *Chorded keyset* — six keys, no key per letter; the chord IS the character (Microwriter 1978, Perkins Brailler 1951). The most compact honest password field. The kit names the chorded grammar already (R50 is its safety form) but holds no chorded text control. Third, if a compact field is wanted. +5. [@5] *Swatch picker* — model the signal-lamp lens turret or theatrical gel wheel; a rotary whose detents are coloured lenses. Undecided whether it earns a card or stays page furniture. +6. [@6] *Legend switch* — the lit pushbutton whose cap legend and colour ARE the state, press to step (MIL-STD-1472 catalogs it; the doc already cites the standard). The validation lamps are one. + +Items 1 and 2 fill the two empty cells and are wanted by live panels today; 3 and 4 are the survey's genuinely-new grammars; 5 and 6 come from the gallery-can't-build-itself finding and are lower stakes. + +**** 2026-07-16 Thu @ 13:53:34 -0500 Built R57 ABC entry keypad — the text x alphanumeric cell is filled (gallery at 110) +Item 2 done. Modelled on the membrane reference (=working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-color.png=): a 0-9 block beside A-Z in alphabetical order, CLR / ENT / CANCEL, typed text in an amber window, 16-char cap with the window showing the tail. The kit's first free-text control, and R16's alphanumeric sibling. + +Two deliberate departures from the photo, both recorded in the builder comment: its letters are *blue*, and the kit has no blue control colour (blue is only ever a screen phosphor or a jewel lens), so letters take the standard pale keycap and digits a darker one — which keeps the photo's two-tone digit/letter grouping without importing a foreign hue. CANCEL is amber, borrowed from the sibling reference whose CANCEL is yellow; CLEAR/NO and ENTER/YES keep the reference's red and green, which are already =--fail= and =--pass=. + +TDD: four probe checks written first and confirmed red (A-Z in alphabetical order — a QWERTY drift would silently lose the idiom; a full 0-9 block; typed characters accumulate in order; ENT commits while CLR empties). All green, plus the card count bumped 109 → 110. Verified visually in both the empty and typed states rather than trusting the green run. + +Craig then caught a layout flaw the membrane reference itself carries: with the digit block on the left, A-L sits in columns 3-5 while M-X starts at column 0, so the alphabet stops column-aligning with itself halfway down and the eye has to jump. Swapped to letters-left / digits-right, which is the *stainless* reference's arrangement — so the card is now a synthesis of the two photos (membrane colour-coding, stainless layout) and reads A-Z down one unbroken block. Card note and spec-sheet origin updated to credit both. + +That swap also exposed a hole in my own checks: the A-Z check reads DOM order, which the builder controls by push order, so it would pass with every key rendered in the wrong place. Added a geometric check (letters left of digits, function block on the digit side, and A/D/G/J/M/S/Y sharing one x) that pins the layout Craig asked for. + +Craig then caught the missing backspace: with only CLR, one mistyped character costs the whole entry. Added DEL, and on his call it sits in the digit block wearing amber while CLR is exiled to the far corner in red. Both swaps pull the same way — DEL is the key you reach for constantly and costs one character, CLR is the one you reach for almost never and costs the entry, so the safe key gets the good spot and the colour grades the cost before you read the legend. The three function keys now read as a ladder: amber takes one back, red throws it all away, green commits. + +And he caught that CLR and CANCEL did the same thing — both just emptied the buffer, differing only in the readout string. Dropped CANCEL. The reference plate carries it because on that device the plate IS the whole terminal and has a transaction to abandon; here the keypad is one control inside a panel that owns its own dismiss, so CANCEL meant nothing the panel didn't already mean. SPACE widened to fill, DEL took the rightmost slot, and the now-unused amber gradient and tone came out with it. The only colour left is CLR and ENT, the two irreversible keys. + +Adding the DEL check also caught order-dependence in my own suite: it emptied the buffer that the ENT/CLR check had been inheriting from the check above it — the same defect the reviewer found in check 9 this morning. That check now types its own buffer. + +Review (subagent, since I wrote it all) cleared the widget itself — buffer logic, layout arithmetic, and the gradient id space all traced clean — and found three real defects, all fixed before the commit: +- *A typed space was invisible past 13 characters.* SVG collapses trailing whitespace, and past the display's truncation boundary there are no pad dots left for a space to displace, so SPACE became a keypress with no feedback: press, see nothing, press again, and carry two spaces you can't see in a passphrase you can't read back. The window now draws spaces as =␣= while the buffer keeps the real character. Verified by eye that the glyph isn't a tofu box. +- *The ENT check could not fail.* It typed NET5, pressed ENT, and asserted =/NET5/= — which typing the 5 had already made true. Deleting the whole ENT branch still passed, because the key then fell through to =buf += 'ENT'= and NET5 still matched. It now asserts the commit signal itself. +- *The DEL check asserted half its name*, computing the past-empty state and never looking at it. + +Third vacuous check in one day, and the shape is consistent: checks written in the builder's own vocabulary inherit its assumptions and confirm what the code does rather than what it should do. The one that mattered was found by someone reading the render arithmetic cold. + +**** 2026-07-16 Thu @ 14:42 -0500 Keyboard contract recorded, R57 built against it +Craig asked whether keyboard input is the widget's job or its container's, and picked "record the contract first, then build against it" — it's a decision about all 110 cards, not one. + +The kit already held the answer in two halves. =GW.slideRule= takes arrow keys scoped to its *own focusable element* (the browser arbitrates focus, which is why it never fights the gallery's global Escape handler at line 1157) — that's the web-correct pattern. But Emacs can't do that at all: the SVG region is an image and never sees a keypress, so the mode's keymap must own delivery and call =press=. Same split as the tick contract, which the README already states: the page owns the ambient resource (the clock there, focus here), the builder exposes a handle. + +Contract written into [[file:docs/prototypes/README.org][the widget-library README]] beside the tick contract: *the target owns focus and delivery, the builder declares what it accepts* via a =KEYS= table. Deliberately a table and not a function over a DOM event — the Emacs port installs it into a keymap and never sees a keydown, so a function would force it to re-derive the widget's intent and the two bindings would drift. Five rules, each one a bug otherwise shipped: never listen on =document=; spend =preventDefault= only where there's a default worth killing (Space scrolls, Backspace navigates back); let Tab and Escape bubble (Tab is how the page stays navigable, Escape belongs to the audit stepper); =press= filters rather than trusts (it appends whatever it's handed, so a stray F1 would land as text); click and key both route through =press= so they can't drift. A widget with no =KEYS= table takes no keys, which is most of them — the kit is click-first and that's what makes it port. + +R57 built against it: =tabindex=, focus on click, element-scoped keydown, gold focus ring (an unlit focus state means typing vanishes into a card you thought was live). Five checks written first and confirmed red, including the contract's own rules — unfocused keys ignored (the no-document-listener rule, tested behaviourally), Space and Backspace claimed, Tab/Escape/F1 let through, unmapped keys dropped. Audited the kit: no document-level key listener anywhere in widgets.js, so the contract holds retroactively. + +Spec sheet reworded. "Click-only, so it ports to Emacs unchanged" stopped being true, and the honest version is the opposite: keys are the *more* native idiom in Emacs, so the card ports better, not worse. + +Review (subagent) found two High defects in the keyboard work, both fixed: +- *The contract's own rule was unmet by the commit that recorded it.* The README says "press filters, it does not trust", and the filter went into the keydown handler instead — leaving press wide open for the one caller the table exists to serve, since the Emacs port installs KEYS into a keymap and calls press directly with no handler in the stack. Now gated on =GW.abcKeypad.ACTIONS= (the plate's whole vocabulary), which is a superset of the KEYS values because CLR is a real key no keystroke reaches. +- *The focus ring never appeared on the path anyone uses.* =:focus-visible= doesn't match a mouse-driven focus on a non-text element in Chrome, and clicking the plate IS how it takes focus, so the ring showed only when tabbed to. Now =:focus=. +Also: two more weak checks. One claimed click and key can't drift while only reading a string (now enters the same sequence both ways and compares buffer + readout); one asserted Tab/Escape/F1 aren't swallowed, which couldn't fail because those keys return before the preventDefault (now also checks 'A' and Enter, which are mapped and reach the same code path). + +*** 2026-07-16 Thu @ 15:20 -0500 Screen families on the keypad window, and a probe that was lying +Craig: offer the display in every colour, including the vfd marquee cyan. The window now takes the =--scr-*= vars with the shipped colours as fallbacks, so the default is pixel-identical until a chip is clicked (=--gold-hi= IS the amber family's =--scr-hi=, which is what makes that exact rather than close). Both the glass and the ink recolour — a screen that changes its text and keeps its backlight isn't a screen. R57 offers all six families where its siblings each carry five: a passphrase window has no reason to prefer one phosphor. + +Found a real defect while adding the checks, and a nasty one: *probe-fams.mjs had no try/finally*, so a failing check orphaned its headless browser. The next run then connected to the survivor on the same port and reported results from a STALE page — old widgets.js, chips already clicked. It cost a confusing debug loop: R10's default ink read as green and R57's chips read as absent, neither of which was true of the actual page. A probe that answers from the previous run is worse than one that crashes. Wrapped the checks in try/finally like its two siblings already had, and swept nine orphaned chromes (the pattern is bracketed on the debug port, so it cannot touch Craig's daily browser, which has none). + +Craig called the ABC entry keypad done at 110 cards. + +Banked from the survey, not cards yet: CDU/MCDU scratchpad + line-select keys (a staged-commit *flow*, not a new selector), joystick scroll-and-fire alphabet, multi-tap/T9 letter cycling (the most practical small-panel password entry), trackball gesture (Atari Quantum), keypunch program drum (IBM 029), Enigma lampboard (a *feedback* idiom — steal the 26-lamp grid as a readout, not as entry), Teletype Model 15 tape perforator. + +Two flags carried from the survey: no true A-Z *thumbwheel switch* could be verified (Digitran/Grayhill mil-spec wheels are 0-9 or hex only), so the letter-drum reference is a *combination lock* and a card must say so rather than imply a switch that may not exist; and the trackball-gesture details are single-sourced. + +*** TODO Taxonomy audit — verify the first-pass cell assignments :design: +The cross's cell assignments were read off card names and spec sheets, not audited card by card, so "confidence of completeness" isn't earned yet. Walk all 109 against elemental task x cardinality x set stability, cell the display side (left uncelled deliberately), and argue the four untouched axes (grammar, state authority, persistence, era). Would either confirm the two empty cells or find more. +** DOING [#B] Retro widget catalogue :feature:design: :PROPERTIES: -:LAST_REVIEWED: 2026-07-09 +:SPEC_ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba +:LAST_REVIEWED: 2026-07-13 :END: -=FS_PROFILE=zfs make test-vm-base= fails inside the VM at initramfs time: archangel reports "ZFS module not found! DKMS build may have failed" against the installed kernel (linux-lts 6.18.38 at the 2026-07-08 attempt). Likely a kernel/OpenZFS version skew — the archzfs zfs-dkms may not support 6.18 yet. Consequences: the maint scenario harness's zfs lane (Phase 12) is filtered but unexercised, and a real zfs bare-metal install via archangel would plausibly hit the same wall. Diagnosis needs the in-VM pacman/DKMS logs (rerun create-base-vm.sh with =--keep=-style access or pull /var/log from the ISO session before teardown). Priority per the bug matrix: Major severity (zfs install path broken) × some-users-sometimes = P3. When fixed, run =FS_PROFILE=zfs bash scripts/testing/run-maint-scenarios.sh --list= and add zfs scenario files (zpool scrub / autotrim / snapshot destroy) to the harness. - -** TODO [#C] Consistent keybinding family for the panel console :feature:hyprland: +The panel widget gallery ([[file:docs/prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]]) grows into a retro-instrument component catalogue: reference photos of period hardware → gallery cards (the visual + behavioral spec) → reusable components for three targets (emacs svg.el, web/React, waybar). Tokens single-sourced in [[file:docs/prototypes/tokens.json][tokens.json]] (gen_tokens.py emits web/waybar/elisp); svg.el proof widget shipped (gallery-widget.el, needle gauge). Reference photos live in [[file:working/retro-stereo-widgets/][working/retro-stereo-widgets/]]. Collection converged at R56, then reopened at R57 as the taxonomy found empty cells (110 cards, all behaviorally verified; probes in [[file:tests/gallery-probes/][tests/gallery-probes/]]). + +Build runs per the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DOING; reviewed + decomposed 2026-07-12): web extraction first (ungated, lossless), then demand-gated Emacs/waybar ports. Banked variant/composition ledger lives in the 2026-07-11/12 session archive. +*** 2026-07-12 Sun @ 10:14:10 -0500 Wrote the component-generation spec (DRAFT) +[[file:docs/specs/2026-07-12-component-generation-spec.org][2026-07-12-component-generation-spec.org]] via spec-create: full spine (summary, problem, goals, two-altitude design, alternatives, 8 decisions with 1 open, 5 phases, acceptance criteria, readiness dimensions, risks). Gallery cited as the prototype evidence per the ui-prototyping rule (filed references + R01-R31 iteration history + final at 52a43ec). vNext items logged as the "Widget catalogue vNext" task. +*** 2026-07-12 Sun @ 20:24:37 -0500 Web library packaging approved — classic-script widgets.js + GW namespace +Craig approved with the componentization go-ahead (option 1): =widgets.js= as a classic script exposing one =GW= namespace, relative =<script src>= so =file://= keeps working, shared helpers inside, framework wrappers vNext. Decision flipped DONE in the spec (cookie 8/8). Gate per the same approval: extraction proceeds ungated (lossless transform); the validation lamps gate only per-widget Emacs ports and the final blessing. +*** 2026-07-12 Sun @ 20:57:50 -0500 Spec reviewed and decomposed (DRAFT → READY → DOING) +spec-review passed (Ready): 3 findings, all accepted and folded same pass — option-1 supersession of the demand-gated extraction order (Phases 1-2 swapped: extraction first, ungated), the card-record refactor named in Phase 1, stale counts refreshed (109 cards / R56). Probe baseline repaired first (753380e — two stale assertions from the evening sprint). Phase tasks below; =:SPEC_ID:= stamped on this parent. +*** 2026-07-12 Sun @ 22:56:40 -0500 Phase 1 complete — all 109 widgets extracted into widgets.js +All 109 card builders lifted into [[file:docs/prototypes/widgets.js][docs/prototypes/widgets.js]] (classic script, =GW= namespace, shared engine: svgEl/polar/vuDb/drag helpers/seg7/SCREEN_FAMS/gradient defs); every card is now a declarative record rendered by one =card()= path, all wiring blocks deleted. Widget CSS (incl. pulse/flipdrop/reelspin keyframes) moved from the gallery =<style>= block into =GW_CSS=, injected by widgets.js — verified pixel-identical under forced reduced motion (masked live-date cards N26/R35). Tick contract settled: the page owns the clock + demo signal; live meters expose value-driven handles; widget-owned animation lives in builders behind reduced-motion gates. Finale: slide toggle (card 01) is the first fully-realized component — its four option groups are =GW.slideToggle= constructor opts backed by =STYLES=, the gallery chips a demo rig on =handle.setStyle=. README consumers section documents the GW API + tick contract. Per-batch gate stayed green throughout (probe.mjs, probe-fams.mjs, a 239-check behavioral suite, reduced-motion smoke). Commits acee657 → 7b3bc47 (18 batches), all pushed. +*** TODO Phase 2 — demand inventory (Emacs/waybar) :design: +Widget-to-target matrix for the scripted-port targets: walk the live waybar panels (net/bt/audio/maint) and the Emacs surfaces Craig names; record which cards each actually wants. Lands in the spec's appendix; Craig approves. Tree untouched. Not =:solo:= — the matrix is his call. +*** TODO Phase 3 — Emacs ports of demanded widgets :feature: +Extend the =gallery-widget.el= pattern per demanded widget: ERT (tokens, geometry normal/boundary/error, SVG structure, state-tracks-value) + rsvg-convert side-by-side against the card; keymap/click-region interaction per widget; wired into =make test-elisp=. Gated on the Phase 2 matrix and Craig's green lamp per widget. +*** TODO Phase 4 — waybar pilot: audio panel :feature:dotfiles: +Restyle the audio panel's GTK CSS onto =tokens-waybar.css= + the banked composition idioms. Lives in =~/.dotfiles=; archsetup drives edit/test/commit/push end to end + inbox note. Visual result gets a manual-testing checklist entry (daily-driver panel). +*** TODO Phase 5 — Level-2 generator go/no-go :design: +After ~5 hand ports, weigh widget-level codegen with evidence (mechanical duplication vs judgment per port). Recorded as a dated decision in the spec; go spawns its own spec. +*** TODO Flip the spec to IMPLEMENTED +When the phases above close: status heading keyword → =IMPLEMENTED=, dated history line with the reason, Metadata =Status= mirror. Three lines, one file. +** TODO [#C] Gallery probe: the fader-drag check is flaky :bug:test:design: +=probe.mjs= check 3 ("fader drag tracks at 3x") intermittently reports =level 68 -> level 68=, i.e. the synthetic drag never registers. It has presumably been doing this all along unnoticed, since the suite is normally run once per batch. + +Grading: *Minor* severity (a false FAIL costs a re-run and a few minutes, and never ships a defect) x *most users, frequently* = P3 = =[#C]=. + +Frequency measured 2026-07-16, not estimated: 1 failure in 6 consecutive runs, having already fired twice in about fifteen that afternoon. The first grading guessed "some users, sometimes" (~1 in 10); at ~1 in 6, both people who run this suite hit it most sessions, so the row is "most users, frequently". The letter lands on =[#C]= either way, but the input was wrong and the matrix is only worth anything if its inputs are measured. + +Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, waits a fixed 200ms, then reads =getBoundingClientRect= and dispatches the drag against those coordinates. If the zoom relayout or the smooth scroll hasn't settled, the rect is stale and the press lands off the fader — so the drag is a no-op and the readout never moves. The other timing-sensitive checks share the same fixed-sleep shape. + +*Do not fix this by raising the sleep.* That hides the race rather than removing it and leaves the check failing again on a slower run. Wait on the actual condition instead: poll until the rect stops changing between frames, or assert the press landed on the fader before dispatching the drag (the probes' own README already warns that a =find()= miss dispatches into nothing and reports as a widget bug). + +Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression. + +Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns). Both were the session's first probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep). + +** TODO [#C] Maint live-refresh hairline replacement :feature:maint: :PROPERTIES: -:LAST_REVIEWED: 2026-07-09 +:LAST_REVIEWED: 2026-07-14 :END: -Consider putting every panel (net, bluetooth, audio, timer, and the coming maintenance console) on one consistent chord family — a shared modifier set (Super+Shift, Control+Alt, or similar) plus a mnemonic letter per panel (N/B/A/T/M). Today the panels open via waybar clicks only; a uniform chord family makes them keyboard-reachable and predictable. Watch for collisions with existing binds: Super+Shift+A is already PTT toggle, and the hold-to-talk grave bind is load-bearing. Decide the family, audit current hyprland binds for conflicts, wire via the dotfiles hyprland config, and document in the keybind reference. Both machines (velox can't QMK-remap, so chords must work on a plain laptop keyboard). +From the roam inbox (routed 2026-07-13): the memory-killer section seemed to update too often, and "it's a bit unclear what the line is doing; consider something else." Diagnosis (2026-07-14): the data cadence is already the requested 3s (gui live tier, _LIVE_SECONDS); the perceived churn is the live-refresh hairline — the 2px bar under the live sections that drains full-to-empty over each 3s window, redrawn at 150ms (gui._hair_tick, viewmodel.refresh_fraction). It exists to tell a stale board from a frozen one (2026-07-09), but it reads as constant unexplained motion. Design call for Craig: replace the draining line with something whose meaning is legible — candidates: a dot that blinks once per refresh, a "3s" age caption that only appears when refresh is overdue, slowing the drain redraw, or dropping the indicator on live tiers and keeping it only when data goes stale. Keep the stale-vs-frozen distinguishability that motivated the hairline. -** TODO [#C] Realtime lamp output for the net + bt doctors :feature:solo: +** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network: :PROPERTIES: -:LAST_REVIEWED: 2026-07-09 +:SPEC_ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3 +:LAST_REVIEWED: 2026-07-13 :END: -Retrofit the net doctor (=~/.dotfiles/net/src/net/doctor.py=) and bluetooth doctor (=~/.dotfiles/bluetooth/src/bt/doctor.py=) to stream results as a live output wall — one lamp per escalation step, amber while running, green on success, red on failure — instead of a final summary. Matches the maintenance-console doctor design (see [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]], "Doctor = live output wall"). Goal: every doctor in the system reads the same way. Both doctors already step through an escalation chain re-probing after each, so the steps are natural lamp boundaries. +Build the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion]] (IMPLEMENTED). Adds the control-plane cluster (rival-manager / nm-masked / keyfile-perms) and a sharper auth verdict to the shipped net doctor (=~/.dotfiles/net/=). Archsetup owns the dotfiles work end to end — edit, test, commit, and push in =~/.dotfiles=, then drop an inbox note. All build phases shipped and fake-verified; the one open piece is the VM live verification below. +*** 2026-07-11 Sat @ 02:47:47 -0500 Built the read-only control-plane probe +New module =net/src/net/control_plane.py= plus =diag.py= wiring, on dotfiles main (=21ca3ff=, pushed). =net diagnose= now carries a =control_plane= key in its envelope with three read-only signals: NetworkManager state (masked/failed/stopped/active, finer than the plain =is-active= the ladder used), any rival manager active alongside NM (dhcpcd, systemd-networkd, iwd via =systemctl is-active=), and the active profile's keyfile permissions (=stat= under an env-overridable =NET_NM_CONNECTIONS= root, default =/etc/NetworkManager/system-connections=). Detection only: no classifier change, no new step, no bearing on =overall=. Surfaced via =net diag --json=. The masked/failed read also runs in the nmcli-down early-return path, or a masked NM would short-circuit before the probe. Every read bounded through =cmd.run='s timeout; degrades to unknown / no-rival / None on an unreadable tool or unstattable file, so a probe that can't see never invents a fault. As a normal user the real 0700 root:root dir is unstattable, so the keyfile signal reads unknown (readable only under root, e.g. the doctor's privileged path). 21 new tests, full =make test= green; =/review-code= approved (two Minor items, both Phase 1 concerns already tracked in the spec: link-scoping the rivals and guarding the keyfile path in the chmod fix). Inbox note sent to dotfiles. +*** DOING [#B] Phase 1 — control-plane verdicts + privilege model +Unblocked now that panelkit ships. =classify= gains =rival-manager=/=nm-masked=/=keyfile-perms= (all =fixable=), ordered ahead of the generic not-running rule, with the masked check reachable in the NM-down early-return path. Fixes register as =VERBS= + =ACTIONS= (=disable-rival=/=unmask-nm=/=chmod-keyfile=), each narrowly scoped. Live verification needs a real rival/masked/bad-keyfile state (a VM or a deliberately-broken host), so not solo. +**** 2026-07-11 Sat @ 04:32:06 -0500 nm-masked verdict shipped + first panelkit integration +On dotfiles main (=d73eba6=, pushed). The clean, unambiguous verdict of the three, done first to prove the panelkit-in-net rails: =classify= reads =control_plane.nm_state=; on =masked= it returns a fixable =unmask-nm= verdict (=remedy_class="privileged"=) ahead of the generic nm-service rule (=nm-restart= can't start a masked unit). New priv verb =unmask-nm= (=systemctl unmask NetworkManager=), =repair_unmask_nm= (unmask → start → verify), narration entry. The doctor resolves a privileged verdict through =panelkit.resolve()= before running: can't-elevate (GUI, no passwordless, no tty) degrades to the manual guide instead of attempting; existing tiers (no remedy_class) run unchanged. The =net= shim + test add =panelkit/src= to sys.path. Design call shipped (flagged for Craig): CLI =net doctor --fix= is the deliberate act satisfying the Confirm floor, so no CLI prompt added; panelkit contributes the run/prompt/guide degradation on the CLI; the GUI arm-press stays its confirm (panel wiring pending). 765 net tests + 65 suites green, review-code clean. Inbox note sent. +**** 2026-07-11 Sat @ 05:16:24 -0500 rival-manager + keyfile-perms verdicts shipped +On dotfiles main (=c9f8604=, pushed). Craig approved proceeding with the default precedence. =classify= adds =disable-rival= (rival dhcpcd/networkd/iwd active + a failing link) and =chmod-keyfile= (bad-perms keyfile + a failing link), both =remedy_class="privileged"= through the same panelkit gate. PRECEDENCE settled: both gate on a local-link symptom (never nag next to a working link), sit behind rfkill/service (more fundamental) and ahead of the generic reset (naming the cause beats a blind reconnect); tested (rfkill beats rival, rival beats reset). Priv verbs =disable-rival= (three-unit allowlist), =chmod-keyfile=/=chown-keyfile= (paths validated under system-connections, no traversal). Repairs re-derive their target (=active_rivals= / the active connection). =fake-systemctl= gained a state dir so a disable flips the re-check. 782 net tests + 65 suites green, review-code clean. Two coverage edges deferred to the VM: the keyfile fix's root-owned pass (only reproduces under root), and whether disable-rival needs a follow-on reset to restore the link. All three Phase 1 verdicts now fake-complete; VM live-verification is the remaining piece (see the sub-task). +**** TODO Net Phase 1 live verification — run the scenario harness against a VM +The harness is BUILT (archsetup =a364c1e=): =scripts/testing/net-scenarios/{10-nm-masked,20-rival-manager,30-keyfile-perms}.sh= (break/fix/assert + the expected diagnose verdict) and =run-net-scenarios.sh= (rsyncs net+panelkit into a target over ssh, drives the scenarios). Scenario logic reviewed; the ssh transport plumbing is UNEXERCISED (marked first-draft) and wants a shakeout on the first real run. What remains needs Craig: a booted VM (a real kernel + network stack — NM does NOT run reliably in nspawn, so a container only shows the mask-symlink/keyfile-mode half, not "NM active") with NetworkManager + dhcpcd installed, reachable over ssh as root. Then =run-net-scenarios.sh --target root@HOST --profile <saved-profile>=. The two live questions: disable-rival was already chained to a follow-on reset (=be15a81=), so the run confirms whether the reset fires or NM auto-recovers; and the keyfile root-owned pass (only reproduces under root). The by-hand equivalent is the "Manual testing and validation" → "Net doctor privileged fixes" checklist. +*** 2026-07-11 Sat @ 06:31:29 -0500 Built the sharpened auth verdict +On dotfiles main (=12e3e76=, pushed). =gather_context= derives an auth cause on an auth failure from the saved profile's key-mgmt + the scanned SECURITY flags for the SSID (not GENERAL.REASON, which only marks *that* auth failed): sae / hidden / enterprise / generic. =classify= turns sae and hidden into =fixable= Privileged profile-modifies (=key-mgmt sae= + PMF, or the hidden flag) routed through =panelkit.resolve()= like the Phase 1 control-plane verdicts; enterprise and generic stay =needs-user-action= with a cause-named message. Two new priv verbs (=conn-sae=, =conn-hidden=, uuid-validated single =nmcli connection modify= commands) + =repair_auth_sae=/=repair_auth_hidden=, each chained into a reset (FIX_CHAINS) so NM reconnects with the corrected profile. Hidden fires only on a non-empty scan missing the SSID, so a failed scan can't fabricate it. Tightened the =fake-nmcli= key-mgmt hook to require the =-g= prefix so the conn-sae modify isn't read back as a get_value. Pairwise (reason × profile-state) covered in =TestAuthCauseDerivation=. 813 net tests + 65 suites green, review-code Approve, voice. Inbox note sent. Live half (real WPA3/hidden profile-modify) is the VM/manual checklist. +*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the net spec to IMPLEMENTED and logged the vNext items +Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (flaky/drops cluster, DoT/DNSSEC verdict, profile hygiene) logged as the "Net doctor vNext" task. The privileged-fix live halves remain with the VM live-verification sub-task and the manual-testing checklist — findings there come back as bugs. + +** TODO [#C] Net panel speedtest history :feature:dotfiles:network: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-14 +:END: +From the roam inbox (routed 2026-07-13): the networking panel should track speedtests over time with appropriate info. Shape: persist each SPEED TEST result (timestamp, down/up, latency, server) to a small local store and surface history in the net panel. Design questions for work time: retention window, which fields matter, and presentation within the panel's ~400px width (recent-results list vs trend readout). Point-in-time results exist today; the gap is comparison across days and venues. -Scope note (Craig, 2026-07-07): realtime lamp *behavior* only. The maintenance console's wider results-wall layout (date+time stamp column, COPY, persistent history) does NOT backport — the net/bt panels are ~400px wide and lack the horizontal real estate. Their existing output wells keep their compact layout; this task just makes them stream live. +** TODO [#D] Net doctor vNext :feature:dotfiles:network: +Deferred from the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion spec]] (IMPLEMENTED, v1 shipped): event-log correlation for the flaky/drops cluster (powersave, roaming stalls, USB autosuspend, no-reconnect-after-resume, firmware crashloop drop signatures — needs history a one-shot probe can't see); a DoT/DNSSEC-specific verdict distinguishing "the venue resolver mangles DNSSEC" from the generic DNS-not-resolving; per-profile autoconnect/duplicate-profile hygiene. -Addendum (Craig, 2026-07-07): DO backport the 3.5-entry height convention — every panel's output well caps at 3.5 visible entries, the half-visible entry being the scroll cue, with the dark slate-on-black scrollbar. Layout stays compact per above; only the height cap + scroll affordance carries over. +** TODO [#D] Bt doctor vNext :feature:dotfiles:bluetooth: +Deferred from the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion spec]] (IMPLEMENTED, v1 shipped): the stale-bond re-pair-offer signature (v1 keeps re-pair strictly user-initiated; the signature must be designed and validated before the doctor ever offers it); a connection-parameter/coexistence hint tail for a "keeps dropping" verdict; the bt-audio-profile expansion beyond the current a2dp repair (codec fallback, default-sink, absolute-volume) — wants coordination with the audio doctor so the two panels don't claim the same A2DP/HFP diagnosis with divergent verdicts. -** TODO [#B] One copy + close control pair on every output wall :feature:dotfiles:solo: -Converge all four instrument-console output walls on the net panel's well controls: a copy glyph and a ✕ close, as an overlay at the top right, hidden until content lands. Craig's call, 2026-07-10, while reviewing the audio doctor's wall: "network panels as standard across all others, make it consistent." +** TODO [#D] Widget catalogue vNext :feature:design: +Deferred from the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DRAFT): framework wrappers for the web library (React or otherwise — demand-gated, none until a real framework consumer exists); Level-2/3 widget codegen if the spec's Phase 5 decision point says go (a go spawns its own spec); ports beyond the demand matrix as new consumers appear. -Where they stand today, no two alike. net has copy + ✕ (=net/src/net/gui.py=, the =o-copy= / =o-clear= overlay). bluetooth has ✕ but no copy. maint has COPY + HIDE as keys in a header row, and no ✕. audio just gained copy + ✕ (dotfiles =bd33440=). +** TODO [#D] Maintenance console vNext :feature: +Deferred from the [[file:docs/specs/2026-07-07-maintenance-console-spec.org][spec]] (v1 ships determinate remedies only): AI/workflow assistance on read-only metrics (the vLater tier — failed-unit diagnosis, journal-error fix suggestions, OOM investigation); SIGKILL escalation for TERM-survivors on the KILL lever; live streaming meters on evidence rows where a count could be a level. -Work: give bluetooth a copy key, give maint the overlay pair, and lift the four hand-rolled =_copy_output= implementations into one shared helper rather than a fifth copy. maint keeps HIDE alongside close, because its wall is a persistent session action log you collapse and keep, where net's, bt's, and audio's are per-run results you dismiss. +** TODO [#D] Retention-repair remedy unreachable from the panel GUI :bug:dotfiles: +snapshot_retention_repair (WRITE SANE LIMITS — =snapper -c <config> set-config TIMELINE_CREATE=yes TIMELINE_LIMIT_*=<TOML values>=) sits in the maint remedy table but is wired into no GUI layer (no reference in panel.py, viewmodel.py, or gui.py) — it's reachable only via =maint doctor review= / =maint fix=. Worse, the REVIEW & FIX roster's wording for item-bearing remedies says "per-item — on its subpanel", which for this remedy points at a key that doesn't exist. Found 2026-07-08 while walking the SNAPSHOTS subpanel with Craig. -Copy text is per panel but one rule: it pastes as that panel's CLI prints, so the paste lines up with the terminal a user is already looking at. audio's =viewmodel.wall_copy_text()= is the worked example. +Two fix shapes, decide at work time: (1) wire a digest key — e.g. on the timeline row when the config's installed TIMELINE_LIMIT_* drift from the TOML values (needs the probe to read the installed limits for comparison); or (2) keep it CLI-only deliberately and special-case the roster wording for remedies with no panel key ("CLI only — maint fix"). Option 2 is a few lines; option 1 makes limit drift visible on the board, which is the 2026-05-26 pile-up's root cause. Severity minor × rare edge = P4 per the bug matrix. -Consistent with the 2026-07-07 scope note on the sibling task above: net's compact glyph overlay is what standardizes, not maint's wide COPY-key header row. +** TODO [#C] zfs base VM image build failure: ZFS DKMS module missing :bug:zfs: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +=FS_PROFILE=zfs make test-vm-base= fails inside the VM at initramfs time: archangel reports "ZFS module not found! DKMS build may have failed" against the installed kernel (linux-lts 6.18.38 at the 2026-07-08 attempt). Consequences: the maint scenario harness's zfs lane (Phase 12) is filtered but unexercised, and a real zfs bare-metal install via archangel would plausibly hit the same wall. Priority per the bug matrix: Major severity (zfs install path broken) × some-users-sometimes = P3. When fixed, run =FS_PROFILE=zfs bash scripts/testing/run-maint-scenarios.sh --list= and add zfs scenario files (zpool scrub / autotrim / snapshot destroy) to the harness. +*** 2026-07-14 Tue @ 01:40:48 -0500 Diagnosed: OpenZFS/kernel version skew, blocked on archzfs +Reproduced in ~1 minute of install: =dkms install zfs/2.3.3 -k 6.18.38-2-lts= exits 1 during pacstrap. Root cause confirmed: OpenZFS 2.3.3's META declares Linux-Maximum 6.15, and the VM installs linux-lts 6.18.38. The first release supporting 6.18 is 2.4.0 (2.4.1 covers 6.19), and archzfs currently serves only zfs-dkms 2.3.3-1 — nothing on our side to fix. Unblock condition: archzfs publishes zfs-dkms ≥2.4.0; recheck with =curl -s https://archzfs.com/archzfs/x86_64/ | grep -o 'zfs-dkms-[0-9.]*'=, then rerun =FS_PROFILE=zfs make test-vm-base=. -** TODO [#B] Run-time privilege model, standard across every panel doctor :feature:dotfiles: +** TODO [#B] Consistent keybinding family for the panel console :feature:hyprland: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +Consider putting every panel (net, bluetooth, audio, timer, and the coming maintenance console) on one consistent chord family — a shared modifier set (Super+Shift, Control+Alt, or similar) plus a mnemonic letter per panel (N/B/A/T/M). Today the panels open via waybar clicks only; a uniform chord family makes them keyboard-reachable and predictable. Watch for collisions with existing binds: Super+Shift+A is already PTT toggle, and the hold-to-talk grave bind is load-bearing. Decide the family, audit current hyprland binds for conflicts, wire via the dotfiles hyprland config, and document in the keybind reference. Both machines (velox can't QMK-remap, so chords must work on a plain laptop keyboard). +*** 2026-07-14 Tue @ 00:31:36 -0500 Folded Craig's ask for a maintenance-panel keybinding; bumped [#C] → [#B] +Craig asked (in session, 2026-07-14) for a maintenance keybinding specifically — the panel he's reaching for without one. Maintenance (M) is the priority chord when this task gets worked. The capture graduated the task from parking lot to active backlog. + +** DOING [#B] Run-time privilege model, standard across every panel doctor :feature:dotfiles: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-13 +:END: The audio input/output doctor is gaining a run-time privilege model (see [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]], decision "The doctor may use sudo, resolved by context at run time"). Craig's call, 2026-07-10: make it a standard, "revise the other panels to be consistent with these changes." The model: a doctor resolves its privilege at run time from three signals — passwordless sudo available (=sudo -n true=, which never hangs), a tty to prompt at, and whether it is the GUI panel. Four remedy classes: Auto (user-scope, reversible), Privileged (needs sudo — runs where passwordless, prompts on a CLI tty, degrades to Guide in a GUI with neither), Reboot-tail (run the applicable part, then instruct the reboot), and Guide (physical/BIOS/wait-for-upstream, nothing to run). Safety floor: every Privileged and Reboot-tail remedy defaults to Confirm or Arm tier, never silent Auto, because passwordless sudo is not consequence-free. -Work: factor the privilege resolution and the remedy-class machinery into a shared helper the net, bluetooth, maint, and audio doctors all use, and audit each panel's existing remedies against the Confirm/Arm-default floor. maint already has a =priv.py= verb table and sudo handling — reconcile it with the shared model rather than leaving two privilege paths. Depends on the audio input/output doctor landing the model first (it is the reference implementation). +The shared helper is built (see the dated entry below). What remains is per-panel adoption: wire each doctor's remedies through =panelkit.privmodel.resolve()= and audit them against the Confirm/Arm floor, and reconcile maint's =priv.py= build/fire table with the model rather than leaving its implicit always-passwordless assumption. That wiring lives in the per-panel fix phases (net Phase 1, bt Phase 2, audio input/output), each needing a real privileged host to verify =--fix= end to end, so none is agent-solo. +Craig's decision, 2026-07-12: maint's harmless-reclaim privileged remedies (the silent CLEAN UP set — paccache keep3, journal vacuum, coredump clean) STAY silent-auto. The reconciliation gives that class a sanctioned, documented exception to the confirm floor rather than forcing Confirm/Arm; the value of the floor holds for everything else. Where sudo is not passwordless, maint should degrade per the model (prompt on a tty, guide in a GUI) instead of hard-failing. + +*** 2026-07-11 Sat @ 03:48:57 -0500 Built the shared privilege model (panelkit) +On dotfiles main (=b987820=, pushed). Craig chose to share a library, not per-panel copies. New =panelkit= package (=panelkit/src/panelkit/=): pure library code in the dotfiles tree, no CLI, no stow package — a consumer reaches it by adding =panelkit/src= to =sys.path= alongside its own =<panel>/src= and =from panelkit import privmodel=. Self-contained (its own =cmd.py=) so a panel plus panelkit stays a coherent unit (keeps the pluggable-panel option open). =privmodel=: four classes (=auto=/=privileged=/=reboot-tail=/=guide=), =resolve(remedy_class, ctx)= → a =Resolution= (=RUN=/=PROMPT=/=GUIDE_ONLY=, =confirm=, =reboot_after=, =reason=) over a =PrivContext= (passwordless via =sudo -n true=, tty, is-GUI). Safety floor enforced and property-tested across all 8 context combos: every privileged/reboot-tail remedy that runs requires a deliberate confirmation, never silent auto. =PANELKIT_SUDO= test seam (="true"=/="false"=), =detect_context()= + =plan()= convenience. 19 tests, full =make test= green (65 suites), review-code clean. Inbox note sent to dotfiles. NOT wired to a consumer — that's the per-panel adoption above. + +*** 2026-07-12 Sun @ 09:01:25 -0500 Reconciled maint onto the shared privilege model (dotfiles 7937a9e) +maint's doctor now resolves every privileged step through =panelkit.privmodel= instead of the old always-=sudo -n= hard-fail: RUN where passwordless (unchanged), PROMPT on a CLI tty (plain sudo, preceded by a wall note), GUIDE events in a GUI with neither — the typable command on the wall, nothing executed. User-scope steps never resolve; a macro's user steps still run around guided privileged ones. Craig's silent-auto decision (above) is implemented as the documented exception: recorded in =maint/remedies.py= (tier semantics) and =panelkit/privmodel.py= (the floor's own docstring), waiving only the confirm gesture, only for the auto-tier reclaim set. TDD (15 new tests red→green incl. both wall renderers), full suite 65/65, live-verified RUN + GUIDE end-to-end on this host via the real launcher shim. Bonus root-cause fix caught by the live run: =probes/logs.py= crashed on journalctl MESSAGE=null entries (key present, so the =.get= default never applied), which broke every real fix's re-probe — normalized at =_journal_lines= with a regression test. Remaining under this task: net Phase 1 / bt Phase 2 / audio adoption (queue items 2-3), and the PROMPT-path + GUI-wall manual checks (see Manual testing and validation). ** TODO [#B] Scrolling/Carousel layout: frame fit + wrap-around :hyprland: :PROPERTIES: @@ -94,8 +376,10 @@ The support machinery was deliberately kept for this task: =layout-navigate= and ** TODO [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles: :PROPERTIES: -:LAST_REVIEWED: 2026-07-02 +:LAST_REVIEWED: 2026-07-14 :END: +*** 2026-07-14 Tue @ 00:51:51 -0500 Ratio calibre check passed; waypaper canonical decided (dark-lion) +Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow folded the whole dir), so the first-launch gap never existed there — check closed. Craig decided dark-lion.jpg is the canonical waypaper wallpaper; the repo config.ini updated from the that-one-up-there.jpg placeholder (the file is skip-worktree volatile, unskipped for the commit and re-flagged). Remaining: when velox is back online, run make conflicts / make reset there so its old conflict copy clears against the updated repo. *** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2) The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused). From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output). @@ -106,12 +390,14 @@ Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefil ** TODO [#B] Audit dotfiles/common directory :chore:dotfiles: :PROPERTIES: -:LAST_REVIEWED: 2026-07-02 +:LAST_REVIEWED: 2026-07-14 :END: Refiled from the archsetup task audit (2026-06-28), landed via ~/.dotfiles/inbox; the dotfiles content split into its own repo 2026-06-16 but the task tracking stays here per Craig (2026-07-02). Three parts: - Review all 50+ scripts in =~/.local/bin= and remove unused ones. - Check dotfiles for uninstalled packages and remove orphaned configs. - Verify all stowed files are actually used. +*** 2026-07-14 Tue @ 01:40:48 -0500 Built the audit evidence report +Shipped as =docs/2026-07-14-bin-audit-evidence.org= in the dotfiles repo (260752a). 150 scripts bucketed: 69 keep (referenced or cron-driven), 74 kill candidates (zero references in the tree), 7 flagged (all dwm-tier, expected on a hyprland host). Config sweep: audacious and wofi configs are orphan candidates, ranger needs an install-vs-delete call (declared in archsetup but not installed on ratio). Shell history was too shallow (~700 lines) to prove by-hand disuse either way — the kill pass stays Craig's call in the parent task. ** TODO [#B] Waybar network module — custom/net :feature:waybar:network: :PROPERTIES: @@ -231,13 +517,13 @@ Rescoped 2026-07-04 (audit): the tunnels track already shipped most of the origi ** TODO [#C] Waybar collapse control: replace the triangle glyph :feature:waybar: :PROPERTIES: -:LAST_REVIEWED: 2026-07-04 +:LAST_REVIEWED: 2026-07-14 :END: From the 2026-07-04 roam capture. The waybar collapse mechanism (click the triangle, the bar sections redisplay shortened) works, but the triangle glyph doesn't match the instrument-console aesthetic the panels now use. Replace it with something in keeping with the console look. Aesthetic decision — bring Craig two or three concrete glyph/style options (a machined chevron, a console-key style expander, an engraved caret) before wiring. Dotfiles waybar config (handled per the archsetup-owns-dotfiles rule). Raised alongside the net-panel/audio speedrun; deferred from it because the glyph choice is a taste call. ** TODO [#C] Net panel: driver-health diagnostic tier :feature:network: :PROPERTIES: -:LAST_REVIEWED: 2026-07-04 +:LAST_REVIEWED: 2026-07-14 :END: Follow-up from the 2026-07-04 net-panel hardening speedrun (Craig's cj question on the no-WiFi item). The shipped no-wifi-hardware verdict covers "no adapter at all." This tier covers "adapter present but the driver is wedged": read-only health signals — =ip link= (device present but no-carrier / down), =dmesg= / =journalctl -k= for firmware-load failures, =rfkill= for a hard block, =modinfo= / =lsmod= for the driver module — classified before a generic reset. Remedy actions: a privileged =modprobe -r <mod> && modprobe <mod>= reload of the wifi driver, and a firmware-package pointer when the failure is a missing/failed firmware load. Dotfiles net-package work (handled per the archsetup-owns-dotfiles rule). Design pass first to decide whether it's worth a repair tier vs a needs-user-action pointer. @@ -369,7 +655,7 @@ Consider: document as post-install step or create a sync script. ** TODO [#B] Test + CI infrastructure :test: :PROPERTIES: -:LAST_REVIEWED: 2026-06-28 +:LAST_REVIEWED: 2026-07-13 :END: Umbrella for the test-harness and CI-automation buildout. Consolidated from the 2026-06-28 task audit: these were scattered top-level tasks circling one effort, re-homed as children so the work reads as a unit. Each child ships independently and keeps the priority it carried before. No CI runner exists yet, so the CI/CD-pipeline child gates several of the others. @@ -486,6 +772,8 @@ Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; t :END: Triggered by the 2026-06-14 =make package-diff= run on ratio: 62 packages are installed but not declared in archsetup. Stripped of the structural buckets — pacstrap base/boot/kernel (base, linux*, grub, efibootmgr, sudo, btrfs-progs, fwupd, logrotate, ex-vi-compat, linux-lts-strix, zram-generator), the =make deps= VM set (qemu-full, virt-manager, virt-viewer, libguestfs, bridge-utils, dnsmasq, archiso), and the yay bootstrap — these 40 remain. Check the ones to add to the installer, then rerun =make package-diff= to confirm they clear. +Evidence report (2026-07-14, count now 64): [[file:docs/design/2026-07-14-undeclared-packages-evidence.org][docs/design/2026-07-14-undeclared-packages-evidence.org]] (archsetup 78081e4) — description, requirer, and install date per package, bucketed candidates (30) / dependency-pulled (6) / orphan libraries (7) / structural (21). The include/ignore pass reads the candidates bucket; the checklist below stays until Craig walks it. + Some entries are libraries likely pulled in as dependencies (blas-openblas, openblas, eigen, tk, lib32-openal, pkcs11-helper, gtk4-layer-shell, webkit2gtk, sane, freerdp, rust-bindgen) — check those only if you want them declared explicitly rather than left to dependency resolution. - [ ] aws-cli-v2 @@ -529,9 +817,21 @@ Some entries are libraries likely pulled in as dependencies (blas-openblas, open - [ ] webkit2gtk - [ ] whisper.cpp +** TODO [#B] Installed-package drift audit :chore:packages: +Compare the packages explicitly declared by =pacman_install= / =aur_install= +against the installed system, separately from the existing inventory's +unexpected-package review. Report missing declared packages and substitutions +where a provider is installed instead of the named package (the ratio +=emacs= versus =emacs-wayland= case). Decide whether the audit is on-demand, +timer-driven, or surfaced at startup; it must not make package changes. + +From the rulesets Emacs/package-audit handoffs, 2026-07-16. The existing +=scripts/audit-packages.sh= validates repository availability, not installed +machine state. + ** TODO [#B] Security hardening + audit :security: :PROPERTIES: -:LAST_REVIEWED: 2026-06-28 +:LAST_REVIEWED: 2026-07-14 :END: Umbrella for the security-hardening and audit effort. Consolidated from the 2026-06-28 task audit, re-homing the scattered security tasks as children so the work reads as a unit. Each child ships independently and keeps its prior priority. @@ -579,6 +879,9 @@ See Framework community notes on logind.conf and sleep.conf settings :END: archsetup installs =python-lyricsgenius= with =--mflags --skipinteg=, skipping makepkg integrity + PGP checks — a workaround originally for an expired-signature issue upstream (surfaced by the 2026-06-23 --noconfirm audit). Periodically test whether the cause has cleared: if a plain =aur_install python-lyricsgenius= builds without complaint, drop the =--skipinteg= workaround. Removal needs a real AUR build to confirm, so it isn't a blind change. +*** 2026-07-14 Tue @ 01:40:48 -0500 Rechecked: still needed, same cause +Fresh AUR clone, =makepkg --verifysource= on 3.7.0-1 (PKGBUILD unchanged): tarball passes, =LICENSE.txt= still FAILS its b2sum — the github-master pin, structural as diagnosed 2026-06-24. =--skipinteg= stays. + *** 2026-07-02 Thu @ 05:08:37 -0400 Rechecked: still needed, same cause =makepkg --verifysource= on 3.7.0-1: tarball passes, =LICENSE.txt= still FAILS its b2sum — the PKGBUILD still pins a hash for a file fetched from github @@ -644,6 +947,123 @@ Craig's standing checklist of everything that isn't agent-verifiable. Each child Priority and type tag added by that audit: the task carried neither, which kept the project's largest live container out of the agenda entirely. +*** Net panel: sticky error toast dismisses on the next click +What we're verifying: the stuck enterprise-error banner (dotfiles a157bed) now clears when you click anywhere else in the panel — the fix is a pointer-level gesture that AT-SPI can't drive. +- Open the net panel from the bar. +- Click an enterprise (802.1X) network in the NETWORKS list (Cox Mobile in the usual scan) so the red "Enterprise (802.1X) — join or edit it in nmtui/nmcli." banner appears. +- Click anywhere else in the panel: another network row, a meter card, or empty plate. +Expected: the red banner disappears on that click. A routine (non-red) toast still fades on its own 4s timer as before. + +*** maint privilege degrade: PROMPT path on a real tty +What we're verifying: with sudo not passwordless, a maint fix on a terminal prompts for the password (after the wall note) instead of hard-failing. Safe on ratio/velox — run from a real terminal; PANELKIT_SUDO=false makes the model treat the box as non-passwordless without touching sudoers, while the fix still fires through real (prompting) sudo. +#+begin_src sh :results output +PANELKIT_SUDO=false maint fix cache_clean +#+end_src +- Type your password if sudo asks. +Expected: a dim note line "no passwordless sudo — sudo will ask for your password on this terminal", then the RUN line shows plain "sudo paccache -r" (no -n) and the step lands OK with re-probe notes after. On ratio/velox the NOPASSWD grant means sudo won't actually prompt (only the seam is faked); the real password prompt is only observable on a box without the grant — a VM or a fresh install pre-sudoers. + +*** maint privilege degrade: GUIDE events on the GUI wall +What we're verifying: the maint panel's CLEAN UP degrades to guide instructions on the results wall (nothing executes) when the box can't elevate silently. +#+begin_src sh :results output +PANELKIT_SUDO=false setsid -f maint panel >/dev/null 2>&1 +#+end_src +- In the panel, press CLEAN UP (needs an off-nominal reclaim metric; if the board is quiet, expect the "nothing to clean" note instead). +Expected: each privileged reclaim lands on the wall as a dim instruction line — "cannot elevate here (...) — run yourself: $ sudo ..." — with no RUN/OK pair for it, and nothing actually reclaimed. Close the panel afterward (it inherited the fake PANELKIT_SUDO). + +*** Net doctor privileged fixes (net Phase 1) — run in a disposable VM/container +What we're verifying: the three control-plane fixes actually repair a real broken state under real sudo. These states are dangerous on a daily driver (masking NM kills the network), so run them in a throwaway VM or booted nspawn with NetworkManager installed and passwordless sudo, NOT on ratio/velox. Each: break the state, run =net doctor --fix=, confirm the repair. +**** nm-masked → unmask-nm +What we're verifying: a masked NetworkManager is unmasked and started, not met with a doomed nm-restart. +#+begin_src sh :results output +sudo systemctl mask NetworkManager +net doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], d.get("next_action"))' +#+end_src +- Expected (diagnose): the verdict names the masked NM (outcome fixable, action unmask-nm), not the generic "NetworkManager isn't running". +#+begin_src sh :results output +net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print("fixed=",d["fixed"],"attempts=",[a["id"] for a in d["attempts"]])' +systemctl is-enabled NetworkManager; systemctl is-active NetworkManager +#+end_src +Expected: the fix attempts unmask-nm, NetworkManager is no longer masked (is-enabled != masked) and is active. +**** rival-manager → disable-rival +What we're verifying: a rival manager active alongside NM is disabled, and the doctor names it rather than blindly resetting. +#+begin_src sh :results output +sudo systemctl enable --now dhcpcd # a rival that fights NM for the link +net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], [a["id"] for a in d["attempts"]])' +systemctl is-active dhcpcd +#+end_src +Expected: the verdict/fix is disable-rival, and dhcpcd reads inactive afterward. Note whether the link comes back on its own or needs a follow-up =net doctor --fix= (the un-chained-reset question — if it needs the reset, chain "disable-rival": ["disable-rival","reset"] in FIX_CHAINS). +**** keyfile-perms → chmod-keyfile +What we're verifying: a profile keyfile with unsafe perms is set back to 0600 root so NM stops ignoring it. Needs root (the system-connections dir is 0700 root:root), so run the doctor as root or via sudo. +- Pick an existing profile name (its keyfile is /etc/NetworkManager/system-connections/<name>.nmconnection). +#+begin_src sh :results output +sudo chmod 0644 /etc/NetworkManager/system-connections/<PROFILE>.nmconnection +sudo net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], [a["id"] for a in d["attempts"]])' +sudo stat -c '%a %U' /etc/NetworkManager/system-connections/<PROFILE>.nmconnection +#+end_src +Expected: the verdict/fix is chmod-keyfile and the keyfile reads =600 root= afterward. + +*** Net doctor sharpened auth verdict (net Phase 2) — needs a real WPA3 / hidden network +What we're verifying: the auth-cause classifier names the specific auth cluster (SAE / hidden / enterprise / generic) instead of a bare "password rejected", and the SAE / hidden one-line profile fixes actually run and take. These need a real network the machine can (fail to) associate with, so they can't be faked; run against an AP you control or a VM bridged to one. The classify logic is already unit-tested — this is the live half. +**** SAE cause named + auth-sae fix +What we're verifying: a WPA3-only network whose saved profile is still WPA2-PSK reports the SAE cause, and =--fix= sets key-mgmt sae so the association can complete. +- Set an AP (or its VM equivalent) to WPA3-Personal only (pure SAE, not WPA2/WPA3 transition). +- Save a profile for it as WPA2-PSK (=nmcli connection add type wifi ... wifi-sec.key-mgmt wpa-psk=), then try to connect so the association fails on auth. +#+begin_src sh :results output +net doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], d.get("message"), "|", d.get("next_action"))' +#+end_src +- Expected (diagnose): outcome fixable, action auth-sae, the message names WPA3 (not just "password rejected"). +#+begin_src sh :results output +net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print("fixed=",d["fixed"],"attempts=",[a["id"] for a in d["attempts"]])' +nmcli -g 802-11-wireless-security.key-mgmt connection show <PROFILE> +nmcli -g 802-11-wireless-security.pmf connection show <PROFILE> +#+end_src +Expected: attempts start with auth-sae (then reset), the profile now reads key-mgmt =sae=, and pmf is accepted (the =pmf optional= keyword took — this is the one value only a live nmcli can confirm). Note whether the link comes back after the chained reset. +**** hidden SSID cause named + auth-hidden fix +What we're verifying: a hidden (non-broadcast) network whose profile lacks the hidden flag reports the hidden cause, and =--fix= sets 802-11-wireless.hidden yes so NM probes for it. +- Set the AP SSID to non-broadcast (hidden). Save a profile for it without =802-11-wireless.hidden yes=, then try to connect. +#+begin_src sh :results output +net doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], d.get("message"))' +net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print([a["id"] for a in d["attempts"]])' +nmcli -g 802-11-wireless.hidden connection show <PROFILE> +#+end_src +Expected: diagnose names the hidden cause (outcome fixable, action auth-hidden); the fix attempts auth-hidden; the profile now reads hidden =yes=. +**** enterprise / generic stay terminal + named +What we're verifying: an enterprise (802.1X) auth failure stays needs-user-action and names the missing cert rather than offering a bogus fix; a plain wrong-password stays the generic "rejected" message. +#+begin_src sh :results output +# On an enterprise network with a broken/absent CA cert, or a WPA2 network with a wrong saved password: +net doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], "|", d.get("next_action"))' +#+end_src +Expected: enterprise → outcome needs-user-action, next_action names the CA certificate / identity (no auth-sae/auth-hidden action); wrong password → needs-user-action with the "re-enter the password" message. + +*** Bt doctor persistent-power fix (bt Phase 2) — proves out only across a real reboot +What we're verifying: a deliberately boot-disabled adapter reports =powered-off-persistent= (not the transient =power-on=), =bt doctor --fix= clears the cause and powers it on, and the adapter comes up on its own after a reboot. The reboot-survival is the whole point, so this can't be faked; run on a machine (or VM) you can reboot. Back up =/etc/bluetooth/main.conf= first. +**** AutoEnable=false → persistent verdict → fix → survives reboot +What we're verifying: the full loop — break persistence, confirm the verdict, fix, reboot, confirm it stuck. +#+begin_src sh :results output +sudo cp /etc/bluetooth/main.conf /etc/bluetooth/main.conf.bak +# Force the deliberate disable and power the adapter off: +printf '[Policy]\nAutoEnable=false\n' | sudo tee -a /etc/bluetooth/main.conf +bluetoothctl power off +bt doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); p=[s for s in d["steps"] if s["id"]=="powered"][0]; print(p["code"], "|", p["evidence"])' +#+end_src +- Expected (diagnose): the powered step reads code =powered-off-persistent= and the evidence names AutoEnable=false (not the plain "adapter is powered off"). +#+begin_src sh :results output +bt doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print("attempts=",[a["id"] for a in d["attempts"]])' +grep -i autoenable /etc/bluetooth/main.conf +bluetoothctl show | grep -i powered +#+end_src +- Expected (fix): attempts include persist-power; main.conf now reads =AutoEnable=true=; the adapter is powered on now. +- Reboot the machine. +#+begin_src sh :results output +bluetoothctl show | grep -i powered # after the reboot, before touching anything +#+end_src +Expected: the adapter is powered on straight out of the reboot (AutoEnable=true held). Restore the backup if desired: =sudo mv /etc/bluetooth/main.conf.bak /etc/bluetooth/main.conf=. +**** service-disabled and TLP causes (optional variants) +What we're verifying: the other two persistent causes are named and fixed the same way. +- Service variant: =sudo systemctl disable bluetooth= (leave it running this boot), power the adapter off, then =bt doctor --json= should read =powered-off-persistent=; =--fix= should re-enable it (check =systemctl is-enabled bluetooth=). +- TLP variant (only if TLP is installed): add =bluetooth= to =DEVICES_TO_DISABLE_ON_STARTUP= in =/etc/tlp.conf=, power off, diagnose (persistent), =--fix=, confirm bluetooth is dropped from the list. +Expected: each variant names the persistent verdict and the fix clears exactly that cause, leaving the others untouched. + *** Maintenance console — in-person checklist Re-homed here by the 2026-07-09 audit: this was a second top-level "Manual testing and validation" parent. One parent, per verification.md. Priority and the :test: tag now live on the parent. Promoted from the build parent when it closed 2026-07-08 — Craig's checklist, runs once in person. Collects everything not agent-verifiable; populate per verification.md as phases land. Known so far: panel look-and-feel vs the E5 prototype (Craig's eyeball); arm-press wording reads right at the moment of use; results-wall readability during a real doctor run; a real UPDATE through the armed guard (and the TTY path once); REBOOT offer after an update; velox in-person glyph check (battery %, charging glyph, low-charge red); SET 80% charge limit sticks across a charge cycle; KILL on a real memory hog. @@ -1144,32 +1564,10 @@ Rewrote the bare =if $var= boolean conditionals (=show_status_only=, =fresh_inst *** 2026-05-26 Tue @ 15:27:09 -0500 eval task moot — the line-434 eval is gone, the survivor is deliberate Verified: the only =eval= left in =archsetup= is line 578 in =retry_install=, and it's intentional and documented — it captures =$?= directly from =eval "$cmd"= to dodge the if-compound-swallows-exit-code trap. Replacing it with an array would reintroduce that bug. The line-434 eval this task pointed at no longer exists. Nothing to change. -** DONE [#B] Doctor button for the audio panel :feature:audio: -CLOSED: [2026-07-10 Fri] +** TODO [#B] The audio doctor never checks the microphone :bug:audio: :PROPERTIES: -:LAST_REVIEWED: 2026-07-09 +:LAST_REVIEWED: 2026-07-13 :END: -What happens when pipewire or pulseaudio is broken — would we know? Spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] — *READY*, all eight decisions closed 2026-07-09, Phase 0 shipped. Buildable: Phase 1 diag, Phase 2 classify, Phase 3 repair + doctor, Phase 4 the DOCTOR key and wall. Grounded in a live survey of ratio's stack: there is no PulseAudio (pipewire + wireplumber + pipewire-pulse, all user-scope, so no sudo anywhere), =pactl= hangs against a server that accepts and never answers, and the panel can't diagnose itself because =pactl= is the layer most likely to be down. Mirrors the net panel's diag/classify/repair/doctor split, not the maint console's priv-verb table. - -Re-graded =[#C]= → =[#B]= by the 2026-07-09 audit: the spec is written, Phase 0 shipped, and six decisions are all that stand between this and a build. Active backlog, not parking lot. - -*** 2026-07-09 Thu @ 14:12:00 -0500 Fixed: waybar's audio module died against a half-wedged server -My first read of this was wrong. =pactl.run()= already carries =timeout=8= and raises =PactlTimeout=, so the panel degrades and does not freeze. Correcting the record rather than leaving the claim standing. - -The real bug, found by driving it: =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard. A server that answered the lists and then stopped answering raised =PactlTimeout= straight out of a function whose whole contract is to degrade to =ok: False=. =waybar-audio= calls it with nothing catching it, so the bar's audio module died instead of dimming; the GTK panel survived only because its background worker catches everything. - -A server that hangs on the *first* call never exposes this — =list_sinks= raises inside the guard and masks the unguarded reads behind it — so the obvious regression test passes against broken code. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults". Verified end to end: =waybar-audio= now emits =class: degraded= / "Audio graph unavailable" and exits 0. dotfiles =4d42eb3=; full =make test= green. - -*** 2026-07-10 Fri @ 06:41:00 -0500 Shipped: the audio doctor, phases 1 through 4 -=audio diag=, =audio doctor=, =audio doctor --fix=, and the panel's DOCTOR section header with DIAGNOSE / FIX keys streaming into a results wall (copy + close). Spec is IMPLEMENTED. dotfiles =01a1d80=, =76857e3=, =7223c51=, =1550ac8=, =4320255=, =bd33440=. - -Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5, and found two bugs no fake could surface: =pactl get-default-sink= prints the literal =@DEFAULT_SINK@= placeholder when no default is set, and remedy 1 re-probed before WirePlumber had re-created the devices, calling a repaired stack broken. Both fixed. - -Left open, not a v1 gap: mpv played silently while the doctor read healthy. The stack was genuinely fine, and per-application stream routing is an explicit spec Non-Goal. Worth a task only if it recurs. - -Second sighting, 2026-07-10: Chrome stopped recognizing the microphone while the stack was healthy (Shure MV7+ default, unmuted, 82%, PTT off). Restarting Chrome fixed it; nothing on the machine was touched. Both sightings share a shape the doctor cannot see: the graph is fine and one client cannot use it. A third sighting turns this into a design question, namely whether the doctor should say "the stack is fine, the fault is in the application" rather than a bare healthy. - -** TODO [#B] The audio doctor never checks the microphone :bug:audio:solo: The classifier is output-only. =diag.probe_semantic= already collects =default_source= and =default_source_present=, and =classify.py= reads neither: the word "source" appears once in the whole module, in the graph row that counts them. So a muted mic, a default source naming an unplugged device, or a mic at zero volume all classify as =healthy=, and the verdict prints "the default output is present and audible" while the input side goes unexamined. Found 2026-07-10 while asking whether the doctor would have caught Chrome losing the mic. It would not have. Not a scope decision. The spec's Non-Goals never say the doctor is output-only, and its Summary calls it a doctor for "a broken sound stack". @@ -1195,665 +1593,62 @@ The maintenance console's coredump metric flagged telega-server on ratio (8 core * Archsetup Resolved -** DONE [#B] Right-click date/time: ntp sync + timezone update :feature:waybar: -CLOSED: [2026-07-02 Thu] -Right-click on the date updates the clock from ntpd (or whatever keeps the clock in sync); right-click on the time runs the update-timezone script. Neither opens a terminal — both run transparently in the background. Errors surface as notifications. The module should detect whether we're online and, if not, show a message instead of running the script. Origin: roam inbox capture 2026-07-02. - -Shipped 2026-07-02 (dotfiles 2f7993d). Date right-click = clock-sync (chronyc makestep behind a connectivity gate; offline/captive get explanatory notifications). Time right-click = timezone-set, rewritten to prefer WiFi geolocation (whereami → timeapi.io) over IP lookup — the hotel IP geolocated two timezones off, and ipapi.co is paywalled now (ipinfo.io is the fallback). Both promptless (sudo -n), all outcomes notify, worldclock gained signal 16 for an instant refresh. 13 new tests across clock-sync + timezone-set; live-verified on velox (WiFi path resolved Rhode Island → America/New_York correctly). -** DONE [#B] Screenshot "view image" option :feature:hyprland: -CLOSED: [2026-07-02 Thu] -The screenshot flow should also offer a "view image" selection: saves the shot, opens it in a viewer, and puts the path on the clipboard. Origin: roam inbox capture 2026-07-02. - -Shipped 2026-07-02 (dotfiles 10e5961). View Image entry in the post-capture fuzzel menu: opens the shot via xdg-open (default viewer, currently feh) and puts the path on the clipboard. Script gained env seams + a nine-test suite (menu dispatch, both capture modes, cancel, failure). Live check pending: take a shot and pick View Image once. -** DONE [#C] Collapse-triangle buttons: dimmer, inlaid styling :waybar: -CLOSED: [2026-07-02 Thu] -The triangle collapse buttons should look embedded (inlaid) and be slightly dimmed so they don't compete for eye attention with the other components. Origin: roam inbox capture 2026-07-02. - -Shipped 2026-07-02 (dotfiles 15cb93c): muted color + dark inset well + smaller glyph; hover still brightens. Hudson theme carries the same shape. Screenshot-verified. -** DONE [#C] Contrast button ignores the white=on / red=off paradigm :bug:waybar: -CLOSED: [2026-07-02 Thu] -The contrast button doesn't respect the white=on, red=off color paradigm the other waybar modules follow. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02. - -Shipped 2026-07-02 (dotfiles 15cb93c). The "contrast button" is the auto-dim module — its ON icon (nf-fa-adjust) is the classic contrast glyph. It showed gold when on and nothing when off; now on = default silver, off = terracotta, matching every other toggle. Screenshot-verified both states. -** DONE [#C] Off-state red inconsistent across waybar modules :bug:waybar: -CLOSED: [2026-07-02 Thu] -Terracotta red isn't applied uniformly for "off": the sleep icon is a different shade than the mouse/trackpad when off, and the dim indicator doesn't show red when off at all. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02. - -Shipped 2026-07-02 (dotfiles 15cb93c + 7f1f334). The touchpad script's Pango-markup red predated the terracotta theme pass (#d47c59 vs the CSS's #cb6b4d) — unified on #cb6b4d. Dim-off now red (see the contrast task). Bonus find: waybar hands every pulseaudio instance the sink's .muted class, so a muted speaker also painted the mic red — scoped with :not(.mic) so each glyph keys on its own device. -** DONE [#B] Waybar volume/mic toggle like the touchpad module :feature:waybar: -CLOSED: [2026-07-02 Thu] -Make the volume/mic waybar component look and behave like the touchpad/mouse toggle. -- Move the mic to the other side of the volume so the percentage isn't in the way. The mic and speaker icons sit the same distance apart as the hand and mouse. -- One keybinding cycles the four states: volume on / mic on, volume on / mic off (red), volume off (red) / mic on, volume off (red) / mic off (red). -- Move the trackpad/mouse toggle to another keybinding (discuss an open mnemonic, e.g. =d= for disable) and assign Super+M to this module (for mute). -Origin: roam inbox capture. - -Shipped 2026-07-02 (dotfiles 7f1f334). New audio-cycle script walks the four-state ring in the exact order above (wpctl-backed, explicit set-mute so the pair can't desync, 6 tests) on Super+M; live-verified the full ring on velox. Mic moved left of the speaker and hugs it via paired margins, percentage on the outside. Touchpad toggle moved to Super+Shift+I ("input devices" — d-for-disable was taken at both levels by removemaster and dim-toggle); its tooltip and tests follow. -** DONE [#C] Timer end sends no notification :bug:waybar: -CLOSED: [2026-07-02 Thu] -The end of a wtimer timer didn't fire a desktop notification. Needs reproduction to confirm frequency; priority follows the severity-by-frequency matrix once known (a reliably-missing timer-end alert would rate higher). Origin: roam inbox capture. - -Root-caused 2026-07-02 (dotfiles ca35642). The pipeline works (a live 3s timer fired and persisted), but notify sent everything --urgency=normal and dunstrc delays normal-urgency popups while a fullscreen window has focus — a timer ending mid-video sat invisible until fullscreen exit. Alarms now go critical urgency, which rides the fullscreen_show_critical rule; verified CRITICAL in dunst history. The alarm sound (paplay, separate from dunst) was never affected. -** DONE [#C] Keybind hints in waybar module tooltips :waybar: -CLOSED: [2026-07-02 Thu] -Every module's hover tooltip should list its keyboard shortcut(s), for discoverability. Audit the modules and add the bindings to each tooltip. Origin: roam inbox capture. - -Shipped 2026-07-02 (dotfiles 4c32aec). Audited every module: arrows (Super+[ / Super+]), sysmon (Super+R), net (Super+Shift+N), layout (Super+Shift+←/→), menu (Super+Space / Super+Shift+Q, tooltip enabled), plus the already-hinted dim/caffeine/touchpad/mic/volume. Date/time tooltips document their new right-click actions. Workspaces/window/tray don't take custom tooltips; timer has no keybind. -** CANCELLED [#C] Smooth waybar expansion animation :waybar: -CLOSED: [2026-07-02 Thu] -The cluster expansion jumps instead of animating, and a few systray icons pop in one-by-one afterward, which reads as glitchy. Animate the expansion smoothly if waybar allows it — width transitions are limited, so feasibility is uncertain (hence [#C]). Origin: roam inbox capture. - -Assessed infeasible 2026-07-02: collapse works by config rewrite + SIGUSR2 reload, which rebuilds every widget — nothing survives to transition, GTK3 can't animate add/remove without Revealer (an upstream waybar change), and the tray pop-in is async StatusNotifier re-registration. Full findings + revisit conditions: [[file:docs/design/2026-07-02-waybar-expansion-animation-feasibility.org]]. -** DONE [#C] Optional label on timer/alarm/stopwatch items :feature:waybar: -CLOSED: [2026-07-02 Thu] -Let each wtimer item carry an optional short text label. The data model already supports it (=add_timer/add_alarm/add_stopwatch/add_pomodoro= all take =label=""=, and =_describe= shows =label or type=); the gap is the fuzzel-driven creation flow, which doesn't prompt for a label. Add the optional label prompt on create. Origin: roam inbox capture. - -Shipped 2026-07-02 (dotfiles ca35642): =wtimer new= gained a "label (optional)" fuzzel prompt after the type/value prompts; empty keeps the unlabeled default. 2 new tests (89 total in the suite). -** DONE [#B] Pocketbook finish-or-cancel decision :pocketbook: -CLOSED: [2026-07-02 Thu] +** DONE [#C] Net panel: Enterprise error never dismisses :bug:dotfiles:network: +CLOSED: [2026-07-12 Sun] +Fixed in dotfiles =a157bed=. Root cause: error toasts are sticky by design (so background refreshes can't wipe an unread error), but the enterprise join hint's flow posts no follow-up status and row clicks post none either, so nothing ever replaced it. Fix: a window-wide capture-phase click gesture dismisses a sticky toast on the user's next interaction; policy in =viewmodel.toast_action_plan= (unit-tested), timed toasts and background clears unchanged. Panel smoke run confirms launch/doctor/close with the gesture installed. Pointer-level dismiss is a manual-testing child (AT-SPI can't drive pointer gestures). Repro screenshot: =~/pictures/screenshots/2026-07-10_195911.png=. +** DONE [#C] Net diagnostics leak connection names + SSIDs into copyable report and --json :bug:dotfiles:network:solo: +CLOSED: [2026-07-12 Sun] +Resolved in dotfiles =df1543a=: the =redact_ssid= toggle now scrubs saved profile names, active SSIDs, envelope-carried names, and =.nmconnection= keyfile basenames from the copyable report and the diag/doctor =--json= envelopes (one systemic pass in =redact.py=; MAC/IP scrub applies to those envelopes too). On-screen output and functional envelopes (status/list) unchanged. 15 new tests; live-verified on ratio (toggle on removes the active connection name from =diagnose --json=, default unchanged). +The net doctor's copyable report (=report.py=, =scrub_text=) scrubs only MAC/IP, and =net diag/doctor --json= (=cli.py=) dumps the raw dict with no redaction. SSID redaction lives only in the event log (=redact_event=, gated on =redact_ssid=, default off). So a connection name (usually the SSID) appears in the clear in the link-step evidence and in every =--json= consumer — the copyable report is exactly the text a user pastes into a bug report. Secrets (PSK/password/token/portal URL) are already stripped, so this is names, not credentials: Minor severity, graded on severity alone per the privacy carve-out. + +Split out of the 2026-07-11 net-doctor-expansion spec review: that spec's new rival-manager/keyfile-perms verdicts keep parity with this pre-existing behavior rather than half-solve it. Fix shape: extend redaction to cover the connection name + keyfile basename across the copyable report and =--json= (one systemic pass, not per-verdict), with a redaction test. Engine-wide, so it wants one coherent change rather than being bolted onto the expansion work. +** DONE [#B] Bt doctor expansion v1 — build the READY spec :feature:dotfiles:bluetooth: +CLOSED: [2026-07-12 Sun] :PROPERTIES: -:LAST_REVIEWED: 2026-06-24 +:SPEC_ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7 :END: -Decided by Craig 2026-07-02, ahead of the scheduled checkpoint: remove pocketbook altogether. Executed same day — pip package uninstalled (user site clean), running instance killed, launcher gone, =pocketbook/= tree removed from the repo, Super+P rebound to toggle-touchpad (P for Pointers; Super+Shift+I unbound, waybar tooltip hint updated — dotfiles a750cb4). The org-capture popup remains the quick-notes surface. -** DONE [#B] Provision Eask in archsetup :tooling:eask: -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-05-26 -:END: -Shipped 2026-07-02 (speedrun): npm global install block added after the nvm line — runs as $username with --prefix $HOME/.local, display/error_warn wrapped, output to $logfile, matching the claude-code block's shape. The npmrc decision went yes: dotfiles common/.npmrc pins prefix=${HOME}/.local (stowed; hand-linked live, npm config get prefix confirms ~/.local — dotfiles 01627cc). VM assertion added: ~/.local/bin/eask present + ~/.npmrc stowed. Live smoke: eask 0.12.9 on PATH. Full acceptance (fresh-install chime make setup/test) rides the next VM pass. -Add =@emacs-eask/cli= to archsetup's provisioning so fresh machines get it. Eask is installed by hand today and declared nowhere in archsetup or the dotfiles repo, yet both chime and linear-emacs depend on it (their =make setup/test/coverage= shell out to =eask=). Source: handoff from linear-emacs 2026-05-23. - -- Add a global npm install after the node block (=archsetup= ~2030, after =aur_install nvm=), modeled on the claude-code native-install block: run as =$username=, wrapped in =display=/=error_warn=, output to =$logfile=. Roughly =sudo -u "$username" bash -c 'npm install -g --prefix "$HOME/.local" @emacs-eask/cli'=. -- Pin the prefix to =~/.local= so eask lands at =~/.local/bin/eask= (already on PATH) and the install runs as the user, not root. On the current machine =npm config get prefix= returns =/usr=, so eask was installed with an explicit =--prefix=. -- Decision: also set a persistent user npm prefix (=~/.npmrc= with =prefix=${HOME}/.local=)? If yes, that =~/.npmrc= is a legitimate dotfile to stow; if no, rely on the explicit =--prefix= flag alone. =~/.eask/= is a regenerable cache — leave un-stowed. -- Acceptance: fresh run leaves =eask= on PATH at =~/.local/bin/eask= (no root); =cd ~/code/chime && make setup && make test= works. -** DONE [#C] Waybar timer dialog styling :waybar: -CLOSED: [2026-07-02 Thu] -From Craig's roam capture 2026-07-02: style the timer module dialogs like the screenshot dialog — tighter window, icons on the selections, colon+space after the prompt. - -Shipped 2026-07-02 (dotfiles 9ffcba7): dialogs size to content, type menu carries the kind glyphs, prompts end ": ". Three new tests; screenshot-verified live. -** DONE [#B] Waybar collapse jumps client windows :bug:waybar:hyprland: -CLOSED: [2026-07-02 Thu] -From Craig's roam capture 2026-07-02: collapsing/expanding (and any waybar teardown) snapped every tiled window up and back down; hold the clients still and let only the bar change. - -Shipped 2026-07-02 (dotfiles 4b1a4ec): waybar now runs exclusive:false and the new waybar-reserve script statically reserves the bar strip per monitor (wired as exec so config reloads re-apply it). Verified live: client geometry held constant through bar kill, relaunch, and a collapse round-trip. Eight new tests (script + pairing). -** DONE [#B] Bluetooth panel + bar module :feature:waybar:bluetooth: -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:SPEC_ID: 1271a845-4463-4831-9902-990eda6b2265 -:END: -Spec: [[file:docs/specs/2026-07-02-bluetooth-panel-spec.org]] (IMPLEMENTED 2026-07-02 — all five phases shipped same day: engine eb2230f, panel 76b2c05, bar module e372de3, bt-priv + blueman retirement 2a026b1/d8d8c53, install wiring proven by VM assertions). Residual: the phase 4-5 VM assertions run on the next VM pass; ratio picks up the package removal + hand-links on its trip list. - -A bluetooth panel driving a CLI underneath (bluetoothctl one-shot verbs), consistent in look and feel with the net panel (GTK4 + layer-shell + Blueprint, humble-object presenter, verify-everything). Minimalistic interface, full functionality, plus a diagnostics/troubleshooting section mirroring the net panel's Diagnostics tab. Bar module glyph opens it. Craig's ask (2026-07-02): follow UX/UI best practices; where the net panel's patterns conflict with best practices, file a net-panel bug task rather than clone the flaw. - -*** 2026-07-02 Thu @ 13:30:42 -0400 Shipped phase 1 — the bt engine package (dotfiles eb2230f) -=bluetooth/src/bt/= mirrors the net engine's layout: btctl parsing boundary (show/devices/info, connect-error classifier), sysio rfkill/airplane, audio module over pw-dump/wpctl (HSP probe + A2DP switch repair with verify-after), redacted eventlog, six repair tiers, and the doctor chain (adapter → rfkill → service → powered → devices → audio profile) with safe auto-repairs behind =--fix= (never auto-connects; airplane blocks are named, not fought). 101 tests over fake binaries; 42 suites green (=make test= glob auto-discovered =tests/bt/= — gate check verified). Live read-only on velox: =bt status= + =bt doctor= read the real adapter/devices/audio graph; =~/.local/bin/bt= hand-linked (no restow under running Hyprland). Ground truth vs spec: profile inventory needs =pw-dump= (wpctl can't enumerate), and the card's =bluez5.profile= prop is unreliable — sink node's =api.bluez5.profile= is authoritative. Deferred INTO phase 2: the shared dupre css factoring (net's css is an inline string in =gui.py=, not an asset — factoring it without the bt-panel consumer just risks the working net panel). - -*** 2026-07-02 Thu @ 14:15:27 -0400 Shipped phase 2 — the GTK panel (dotfiles 76b2c05) -Cloned the net panel's shape over the phase 1 engine: GTK-free PanelModel + viewmodel (69 new tests, display-free), Blueprint pages (Devices with the adapter power row + Paired/Nearby sub-views; Diagnostics with the doctor cascade, inline fix buttons mapped from step =repair= keys, Advanced tool selector), gui.py controller (layer-shell OVERLAY 380x520 TOP+RIGHT, Esc closes, single-instance toggle, bg worker, passkey + confirm dialogs), pairing pty state machine (=bt/pairing.py=: confirm-passkey yes/no with default-deny, display-passkey, bounded deadline, tested over the fake's interactive mode), manage.py op envelopes shared by CLI and panel (cli refactored onto it; power + discoverable verbs added), =bt panel= subcommand + =bt-panel= toggle wrapper (hand-linked into =~/.local/bin=, no restow under live Hyprland). Shared dupre css factored: net's inline =_CSS= → =hyprland/.config/themes/dupre/panel.css= with =dupre-*= classes, both panels load it (stowed path first, repo-relative fallback; hand-linked =~/.config/themes/dupre/panel.css=). Super+Shift+B rebound blueman-manager → bt-panel (hyprctl reloaded, live). 43 suites green (=make test= exit 0). DEFERRED pending Zoom ending: the AT-SPI smoke (=make test-panel-bt=, written and wired) and any visual check of either panel with the factored css — both need a visible window. Gotcha for posterity: the old =test_panel_stub_exits_two= CLI test ran =bt panel= for real once cmd_panel was wired and launched the panel on the live compositor mid-meeting for ~30s before the test timeout killed it; it now asserts parser wiring only — never run =bt panel= inside =make test=. +Build the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion]] (IMPLEMENTED). Adds a dmesg firmware-hint probe (names the missing blob on a no-adapter fault) and a boot-enablement probe (catches an adapter disabled at boot) to the shipped bt doctor (=~/.dotfiles/bluetooth/=). Archsetup owns the dotfiles work end to end. All phases shipped and fake-verified (d19fdca, f05a9b4, d7d859f); the live reboot-persistence half is on the manual-testing checklist. +*** 2026-07-11 Sat @ 03:06:32 -0500 Built the two read-only probes +New module =bluetooth/src/bt/probes.py= plus =doctor.py= wiring, on dotfiles main (=d19fdca=, pushed). Two reads the diagnose chain never did: =firmware_hint()= scans the current boot's kernel log for per-vendor firmware-load failures (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom .hcd, Qualcomm QCA), returning the named blob via a bounded =cmd.run(journalctl -k)= that reuses the =doctor.py:84= precedent; =boot_enablement()= reads three boot-persistence signals (bluez AutoEnable from main.conf [Policy], =systemctl is-enabled bluetooth=, whether TLP lists bluetooth in =DEVICES_TO_DISABLE_ON_STARTUP=). =diagnose()= gates the firmware read to the no-adapter branch and the boot read to the soft-blocked/powered-off branch, so a healthy run reads neither; the raw signals ride a new =probes= key that =doctor()= carries into =--json=. Detection only: no verdict, formatter, or repair change (that's Phase 1). Every read degrades to None on an unreadable tool/file, so a probe that can't see never invents a fault. AutoEnable absent/unset reads None, not false, matching bluez's compiled default of true, so only an explicit =AutoEnable=false= is the fault. New env roots for tests (=BT_MAIN_CONF=, =BT_TLP_CONF=, defaulting to absent temp paths in the Sandbox base so no test reads real /etc); =fake-journalctl= branches on =-k=, =fake-systemctl= answers =is-enabled bluetooth=. 125 bt tests, full =make test= green; =/review-code= approved (no Critical/Important; one Minor noting the firmware read also covers the btctl-unavailable branch, harmless). Inbox note sent to dotfiles. +*** 2026-07-11 Sat @ 03:16:59 -0500 Built the firmware-hint Guide verdict +On dotfiles main (=f05a9b4=, pushed). The no-adapter step now names the blob: a new =_no_adapter_step= consults =probes.firmware_hint()= on a genuine no-adapter fault and, on a per-vendor signature match, sets =evidence= to "no Bluetooth adapter found — <Vendor> firmware <blob> failed to load" and =next_action= to "update linux-firmware and reboot", tagged with a new =code="no-adapter-firmware"= so a =--json= consumer can branch without string-matching. A clean log keeps the generic hardware/driver verdict. A Guide, not a repair: the step carries no =repair= action, so =--fix= never touches it, and it needs no privilege model (so Phase 1 lands independently of the shared cross-panel model). A missing =bluetoothctl= (=BtctlError=) short-circuits before the firmware read, so the verdict fires only on a real no-adapter fault, not a broken install — this also tightened Phase 0 (which read the log on both None branches) to the genuine no-adapter case. =_mk= gained a uniform =code= key (default None) added to every diagnose step, mirroring the existing =repair= key; no test asserts an exact step key-set, verified. =format_doctor_human= already renders =evidence=/=next_action=, so no formatter change. 133 bt tests (+8), full =make test= green; =/review-code= clean. Inbox note sent to dotfiles. +*** 2026-07-11 Sat @ 06:48:21 -0500 Built the persistent-power verdict + fix +On dotfiles main (=d7d859f=, pushed). =_powered_step= consumes the bt Phase 0 boot-enablement probe: AutoEnable explicitly false, service disabled at boot, or TLP listing bluetooth → =powered-off-persistent= (code + evidence naming the cause) carrying the =persist-power= repair; absent/unset config reads as auto-enable-on (bluez default) → the plain =power-on=, so healthy machines can't false-positive. The =persist-power= repair fixes only the causes set (AutoEnable=true, =systemctl enable bluetooth=, drop bluetooth from the TLP list), powers the adapter on now, and verifies each cause cleared. Config edits are pure idempotent text transforms in a new =bootconf= module (comments preserved), staged and installed via a fixed-destination =cp= verb so the write needs root but the mutation is unit-testable. =priv.py= gained three narrow verbs (=enable-bluetooth=, =write-main-conf=, =write-tlp-conf=). The repair is Privileged and resolves through =panelkit= before running: can't-elevate degrades to the guide. The bt shim gained =panelkit= on its path. 287 bt tests + 65 make-test suites green, review-code Approve, voice. Inbox note sent. Live half (real reboot persistence) is the VM/manual checklist. +*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the bt spec to IMPLEMENTED and logged the vNext items +Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) logged as the "Bt doctor vNext" task. The live half (real reboot persistence) remains on the manual-testing checklist — findings there come back as bugs. +** DONE [#B] One copy + close control pair on every output wall :feature:dotfiles:solo: +CLOSED: [2026-07-12 Sun] +Resolved in dotfiles =dccd744=: every wall carries the o-copy/o-clear overlay pair. bluetooth gained the copy key (transcript via the new =viewmodel.step_copy_line=, CLI-shaped); maint traded its header COPY key for the overlay pair, kept HIDE, and its ✕ clears the session log via =PanelModel.wall_clear=; the four hand-rolled wl-copy calls collapsed into =panelkit.clipboard.copy_text= (PANELKIT_WLCOPY test seam), moving maint off the GTK clipboard so its copies survive the panel closing too. 15 new tests (8 clipboard, 4 step_copy_line, 3 wall_clear); full suite 66 green; all four panel smokes run live off-workspace — maint + audio fully pass, net + bt fail only the pre-existing state-word startup race. -*** 2026-07-02 Thu @ 15:06:00 -0400 Shipped phase 3 — the bar module + blueman retirement (dotfiles e372de3) -=custom/bluetooth= over the engine: waybar-bt shim + =bt/indicator.py= (state-following glyph — slashed off/blocked/absent, plain dim idle, connected mark white; low-battery <15% adds a red pango percentage to the glyph; tooltip = connected devices with battery + Super+Shift+B hint). Signal 10; the panel pokes =pkill -RTMIN+10 -x waybar= after each status reload. Blueman retired from the Hyprland session: exec-once + both windowrules removed, applet killed live; waybar relaunched on the runtime config and the module verified on the bar (connected glyph, blueman tray icon gone). Theme drift guard caught that themes/*/waybar.css edits must mirror into the live =waybar/style.css= — all three updated. 43 suites green. ALSO closed this pass: the deferred phase 2 visual batch (bt AT-SPI smoke green after fixing its Connect/Disconnect state-following assertion — c1a8219; net smoke green on the factored css; both panels eyeballed correct in dupre). Left for phase 4: package removal (blueman out of archsetup), sxhkdrc's dwm blueman-manager binding decision rides that pass. - -*** 2026-07-02 Thu @ 15:16:51 -0400 Shipped phase 4 — bt-priv shim, blueman out, VM assertions -Dotfiles =2a026b1=: the stowed =bt-priv= shim over the phase-2 =bt.priv= module (one verb, =restart-bluetooth=; verified end-to-end against the symlinked fake-systemctl — rc 0 with =BT_SUDO= empty, rc 2 on bad verb/usage; hand-linked into =~/.local/bin=), and the sxhkd =Super+Shift+B= bind repointed from the retired blueman-manager to =st -e bluetoothctl= (the decided terminal fallback — the GTK panel is Wayland-only, and the bt CLI is hyprland-tier so dwm never gets it). 43 dotfiles suites green. archsetup: blueman dropped from the =desktop_environment= bluetooth loop (bluez + bluez-utils stay, solaar untouched); VM assertions added to =test_packages.py= (bluez/bluez-utils installed, blueman NOT installed as the retirement regression guard — collected 15, exercised on the next VM run since VM tests run committed code); =bash -n= + =py_compile= + =make test-unit= green. SUDOERS: no new rule needed, same conclusion as net-priv (2026-07-01 entry) — archsetup:1089 grants the primary user blanket =NOPASSWD: ALL=, which covers =systemctl restart bluetooth=; a narrow bt-priv rule would be dead config under the blanket grant, so phase 5's "sudoers placed" item is satisfied by the existing grant. LIVE: blueman package removed from velox (=pacman -Rns=, decision "drop it outright, both machines"); ratio needs the same + the bt-priv hand-link on its trip list. - -*** 2026-07-02 Thu @ 15:19:58 -0400 Shipped phase 5 — install-default wiring proven by VM assertions -No new install code was needed: the waybar =custom/bluetooth= module, the =Super+Shift+B= → =bt-panel= bind (hyprland.conf), and the shared =themes/dupre/panel.css= all live in the dotfiles hyprland tier, so the existing clone + =make stow hyprland= step lands them on a fresh install; sudoers is covered by the blanket grant (phase 4 conclusion). The phase's substance is the proof: =test_desktop.py= gained hyprland-gated assertions that the four bt bins (=bt=, =bt-panel=, =bt-priv=, =waybar-bt=) are stowed executable in =~/.local/bin= (either stow shape — per-file symlink or folded dir), the waybar config carries =custom/bluetooth=, hyprland.conf carries the =bt-panel= bind, and the stowed theme has =panel.css=. Collected 30 in =test_desktop.py=; exercised on the next VM run (VM tests run committed code). - -*** 2026-07-02 Thu @ 15:19:58 -0400 Test surface complete across all phases -Everything the surface named exists and is green: engine suites over fake binaries (phase 1, 101 tests — btctl parse, doctor chain, A2DP repair verify), PanelModel presenter suite (phase 2, 69 tests), pairing state-machine suite (passkey confirm / NoInputNoOutput / timeout, over the fake's interactive mode), bar-module suite (phase 3), gated AT-SPI smoke (=make test-panel-bt=, run green live), and the phase 4-5 VM assertions (=test_packages.py= bluetooth stack + blueman-absent guard; =test_desktop.py= panel wiring). 43 dotfiles suites green; VM assertions await the next VM run. -** DONE [#B] All error messages should be actionable with recovery steps -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -Shipped 2026-07-02 (speedrun). Structural fix at the helper: =error_fatal= now takes an optional third recovery-hint arg and every fatal prints the last five log lines inline, the full log path, the per-site "Fix:" when given, and the resume pointer (step markers mean a re-run continues where it stopped) — so even a hint-less fatal is actionable. All 17 fatal call sites got specific hints (keyring reinit, mirrorlist switch, userdel/USERNAME conflict, base-devel for makepkg, DESKTOP_ENV values, dotfiles-dir cleanup, tmpfs sizing, aur.archlinux.org reachability). The end-of-run Error Summary now closes with the grep-the-log line and the fix-and-re-run pointer. =error_warn= already carried what-failed + exit code into the summary; unchanged. -** DONE [#B] Improve logging consistency -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -Shipped 2026-07-02 (speedrun), paired with the actionable-errors task. Audit result: the install helpers (pacman_install/aur_install/retry_install/run_task/git_install/pipx) and error helpers already tee/append everything to $logfile — the gaps were direct mutations whose stderr went to the console and vanished. Swept every =sed -i= and file-write mutation lacking capture (locale.gen uncomment, pacman.conf ParallelDownloads/Color + multilib, waybar battery removal x3, wireless-regdom, geoclue BeaconDB, paccache, BRIO udev rule, fstab fmask, mkinitcpio HOOKS, sudoers append, ufw status read): each now sends stderr to $logfile, and the previously-silent ones (locale.gen, pacman.conf, multilib, waybar, regdom, geoclue, paccache, udev) gained =error_warn= handlers so failures land in the summary instead of passing silently. Verified: bash -n clean, 10 unit suites green, shellcheck warning-diff vs HEAD empty (no new findings). -** DONE [#B] Add NVIDIA preflight check for Hyprland -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-05-21 -:END: -Shipped 2026-07-02 (speedrun), TDD. =nvidia_preflight_report= is a pure sed-extractable core (same harness pattern as zig-pin): modalias scan for vendor 10DE — DRM first, PCI display-class (bc03) fallback so an NVIDIA audio function can't false-trigger — then the repo's =nvidia-utils= candidate major checked against 535. Prints the Wayland guidance + env vars (LIBVA_DRIVER_NAME, GBM_BACKEND, __GLX_VENDOR_LIBRARY_NAME, ELECTRON_OZONE_PLATFORM_HINT) and the pre-Turing/AUR-legacy note. preflight_checks aborts on <535/unknown (rc 11), prompts continue/abort on a healthy NVIDIA box (rc 10), silent on non-NVIDIA (rc 0). 9 Normal/Boundary/Error tests over fake modalias trees + a fake pacman (=tests/nvidia-preflight/=, glob-discovered by test-unit — 10 suites green). -** DONE [#C] Wlogout exit-menu buttons are rectangular, not square -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -The wlogout exit menu renders its buttons taller than they are wide on velox, so the cells read as vertical rectangles instead of squares. They render square (centered) correctly on ratio, so this is a per-host / resolution difference, not a flat bug. Fix the button sizing in the wlogout style (=~/.dotfiles/hyprland/.config/wlogout/style.css=) so each cell is square on both hosts. Noticed 2026-05-21. Related: the [#D] VERIFY about wlogout sizing across displays. - -The wlogout config uses fixed pixel margins, which is the likely reason sizing differs across the two displays — adjusting them for the laptop screen is part of the fix (folded in from the former "Test wlogout menu on laptop" VERIFY, 2026-06-24). - -Add a regression test so the square-cell fix doesn't silently break on a resolution change: assert the rendered (or computed) wlogout button cells are square across ratio's and velox's resolutions. Dropped :quick: — the cross-host test pushes this past a spare-moment fix. - -Shipped 2026-07-02 (dotfiles 775771b). Keybind now calls a =wlogout-menu= wrapper computing centered margins from the focused monitor (the old fixed L/R 1200 exceeded velox's 1436 logical width). Also fixed two styling defects the geometry hid: invisible unfocused borders (now muted, so the square edge is visible) and hover/focus sharing one gold rule (lock button glowed at launch; focus is now a muted ring). Tests: unit margin-math suite across both hosts' resolutions + portrait + small + bad-geometry, CSS regression suite, and a compositor-gated =make test-wlogout= smoke that launches a no-op probe, screenshots, and measures squareness (velox: 361x361 px, PASS). Ratio's visual eyeball rides the pending ratio sync. -** DONE [#C] Net panel: error toasts auto-dismiss unread :bug:network:waybar: -CLOSED: [2026-07-02 Thu] -Fixed in dotfiles 0f017d4: viewmodel.toast_plan owns the toast policy — errors show sticky and ignore the post-op refresh's empty clear (worst case: a forget failure's error was wiped within ~2s by its own refresh), and the next real status replaces them. Successes keep the 4s fade. 7 policy tests added; 41 suites green. -** DONE [#C] Net panel: verify claimed keyboard navigation :test:network:waybar: -CLOSED: [2026-07-02 Thu] -Found during the bluetooth-panel UX pass (2026-07-02). The V2 spec claims tab-between-sections, arrow-key row navigation, and type-to-filter, but no custom keyboard code exists in the panel — arrows and type-ahead may ride GTK ListBox defaults, tab-between-sections likely doesn't. Verify each claim against the live panel (AT-SPI smoke can assert focus order); implement or strike the claims from the spec so spec and panel agree. - -*** 2026-07-02 Thu @ 13:05:00 -0400 Code-level pass done; live probe deferred (Craig in a Zoom meeting) -Code reality (dotfiles net/src/net): Esc close is wired (gui EventControllerKey); row-activated -> primary is wired for both connection lists (pages.py:122,126), so Enter-on-row rides the GTK ListBox activate binding; arrows ride ListBox defaults; NOTHING implements type-to-filter (no search/filter code exists — that claim is false as written); Tab is the plain GTK focus chain, widget by widget, not section jumps. Live AT-SPI probe plan: launch panel in test mode, drive keys via hyprctl dispatch sendshortcut targeted AT THE PANEL WINDOW (never the focused surface — wtype/ydotool absent anyway), gate every key on the panel holding focus, never send Enter on the available list (real connect risk). BLOCKED at 13:05: active window is zoom (meeting) — no test windows, no synthetic input until clear. Then: verify focus order + arrows + no-filter live, strike/reword the spec's keyboard bullet to match. - -*** 2026-07-02 Thu @ 14:57:18 -0400 Ran the live probe; spec bullet reworded to match reality -Zoom ended ~14:45; probe ran per plan (panel in test mode, hyprctl dispatch sendshortcut targeted at the panel address, every key gated on panel focus, Enter never sent). Verdicts: arrows move row focus and Enter rides the ListBox activate binding (TRUE — kept); Esc closes (TRUE — kept); Tab is the plain GTK widget-by-widget chain and inside a list crawls row by row, no section jumps (claim FALSE — struck); type-to-filter does not exist (claim FALSE — struck; typing into the 24-row Saved list filtered nothing). Spec's Keyboard bullet reworded with the live evidence and a note that section-jump Tab or filtering would be new work. Probe gotchas for reuse: AT-SPI list items have empty accessible names, so row identity needs get_index_in_parent(); a killed test panel can leave a windowless single-instance process that eats the next launch via D-Bus activation — pkill -9 -f 'net panel$' and wait before relaunching. -** CANCELLED [#C] Pocketbook development backlog :pocketbook: -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-05-26 -:END: -Cancelled with the 2026-07-02 remove-pocketbook decision — the app and its in-tree package are gone. -Pocketbook (GTK4 layer-shell notes panel, toggled via waybar) was pulled from publication 2026-05-26 — github repo + cjennings.net repo deleted, mirror hook removed — and folded into this repo at =pocketbook/= until it's ready to spin back out. Src-layout Python package with pytest tests and a Makefile. Develop it in-tree; the backing modules are =store/note/panel/layer_shell/app/note_widget= + =style.css=. - -Backlog (unordered; promote items to their own dated tasks as they're picked up): - -- Configurable options, possibly a dedicated configuration panel. -- Lose-focus hides pocketbook — configurable on/off. -- Configurable display order: chronological by creation date (asc/desc), manual, alphabetical (asc/desc). -- Search / filter notes. -- Global toggle keybind (Hyprland =bind=) alongside the waybar click; document the waybar integration. -- Note CRUD polish (create/edit/delete) + optional markdown rendering. -- Pin / favorite notes. -- Tags or notebooks / categories. -- Persistence: confirm store format + =~/.local/share/pocketbook/= location, add versioning/migration, decide a backup/sync story. -- Theming: track the dupre/hudson theme system so =style.css= follows =set-theme=. -- Layer-shell geometry config (anchor edge, width, margins) + HiDPI / multi-monitor behavior — ties into [[file:docs/PLAN-per-host-overrides.org][per-host overrides]] scaling work. -- Config file format (toml) + reload-without-restart. -- Expand test coverage (TDD per testing standards; =tests/= already exists). -- Release prep for the eventual spin-back-out: pyproject metadata, version, license. -- Re-wire the archsetup install (gtk4-layer-shell dep + install step + post-install clone) when pocketbook ships. Removed 2026-05-26 — see git history of =archsetup= / =scripts/post-install.sh=. -** CANCELLED [#C] Fn+F9 toggles pocketbook — source unlocated :hyprland:pocketbook: -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-23 -:END: -Retired with pocketbook itself (2026-07-02 removal) per this task's own exit condition — with the app uninstalled and unbound, whatever Fn+F9 emitted has nothing to toggle. -On velox, pressing Fn+F9 (physical function key) toggles the pocketbook panel. It shouldn't. Raised from a home-project session 2026-06-23. - -Investigated 2026-06-23 and could not locate the trigger in any config. Ruled out, three ways: -- No F9 bind (bare / $mod / keycode) in the live =hyprland.conf= (now a stow symlink), the velox host tier =conf.d/local.conf=, or the waybar config. -- =hyprctl binds= runtime (all 90 active binds, authoritative) execs pocketbook on ONLY =SUPER+P=. No F9/XF86 path reaches it. The old touchpad toggle that used to sit on =$mod+F9= was moved to =$mod+M=, so F9 is unbound in Hyprland. -- No input remapper (keyd/xremap/input-remapper) and no hotkey daemon (sxhkd/swhkd) running or configured; pocketbook's own source has no F9 / GlobalShortcuts / portal / dbus listener (its GTK ShortcutController binds only Esc/Ctrl-n/Ctrl-j/Ctrl-k/Del/Return). pocketbook is a single-instance Gtk.Application, so any path that re-runs =pocketbook= toggles it. - -Parked at Craig's call (not worth deeper investigation now). If it resurfaces, the one unfinished step is to capture what keysym Fn+F9 actually emits (=wev -f wl_keyboard:key=, press Fn+F9, read the =sym:= / =code:=) and grep for that. Most likely folds into removing pocketbook from the waybar setup — if pocketbook leaves the bar, retire this with it. -** CANCELLED [#C] Waybar emacs-service status + control :feature:waybar: -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -From the roam inbox (2026-06-22): with Emacs integrated into the system as file manager and instant note-taker, make bouncing it trivial. A waybar component showing the emacs service status, with detail on hover, that turns the server on / off / bounce via right-click. Pairs with running the Emacs daemon as a managed systemd user service. - -Cancelled 2026-07-02 per Craig during the task-batch pick: no current need. Re-add or pull back from Resolved if a need surfaces. -** DONE [#C] set-wallpaper detaches waypaper config from its stow symlink :bug:hyprland:quick:solo: -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-28 -:END: -=set-wallpaper= persists with =mv "$tmp" "$CONFIG"=, which replaces the =~/.config/waypaper/config.ini= stow symlink with a real file. After the first run the live config is detached from =~/.dotfiles/hyprland/.config/waypaper/config.ini=, so a later =git pull= + restow won't update it and set-wallpaper changes never flow back to the repo. Fix: write in place rather than =mv= over the symlink — e.g. =cp "$tmp" "$CONFIG"= (follows the symlink to the real dotfiles file), or resolve the link target and write there. Lives in =~/.dotfiles/hyprland/.local/bin/set-wallpaper=; it has a test suite, so add a Boundary case for "CONFIG is a symlink". - -Shipped 2026-07-02 (dotfiles d826be4): write-back now redirects through the symlink instead of mv-ing over it; two boundary tests pin the invariant (replace + append paths). velox's live config was still a healthy symlink, so no repair needed. -** DONE [#B] Instrument-console rebuild: net + bluetooth panels :feature:waybar:network:bluetooth:solo: -CLOSED: [2026-07-03 Fri] -:PROPERTIES: -:SPEC_ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5 -:END: -The no-approvals speedrun build of the console design Craig approved through five prototype iterations (2026-07-02/03). Spec: [[file:docs/specs/2026-07-03-instrument-console-panels-spec.org]] — the interactive prototype [[file:docs/prototypes/2026-07-03-instrument-console-panels-prototype.html][docs/prototypes/2026-07-03-instrument-console-panels-prototype.html]] is the normative design reference. Folds three open tasks: network panel redesign, bt switch placement + title, bt rename devices. Code in ~/.dotfiles (net/, bluetooth/, themes/dupre/panel.css). Final step: flip the spec to IMPLEMENTED, write the findings summary to file, finalize session context. - -*** 2026-07-03 Fri @ 03:20:00 -0400 Phase 2 shipped: net GTK-free console layer + engine verbs -Dotfiles =81ec9c3= (TDD, 52 new tests, 581 net green). Pure presenter logic for the single-screen console, no view code touched: =viewmodel.net_faceplate= (state word + lamp + TUNNEL/AIRPLANE badges, wired-link-wins precedence), =network_console_rows= (ethernet pinned, radio-off note, active-then-signal sort, per-row lamp/caption/ladder/forget), =channel_headline= (wired device+speed / SSID+ladder+dBm / not-connected placeholder), =tunnel_console_rows=, dial-meter geometry (=meter_needle_deg= + =meter_scale= 100→1000 auto-relabel), =signal_bars=/=mbps_label=, and =panel.ArmState= (two-click arm-to-fire for forget/disconnect). Engine verbs: =manage.wifi_radio= (nmcli radio wifi on|off), =manage.device_up= (ethernet take-the-route), =sysio.link_speed_mbps= (/sys wired speed), =connections.ethernet_devices=, hidden flag on =manage.add=. - -*** 2026-07-03 Fri @ 06:02:32 -0400 Phases 3+4 shipped: net view rebuilt as the instrument console -Dotfiles =800ef60= (1197+/250-). =gui.py= rewritten as the single-screen console — no tabs, no Blueprint template (the dial meters and arm-to-fire rows are too dynamic, so the tree is built in Python; =pages.py= + the =*.blp/*.ui= are now orphaned, Phase 6 dead-code). Faceplate (lamp/word/TUNNEL+AIRPLANE badges/wifi-radio switch/close), engraved CHANNEL headline, scrolled NETWORKS + TUNNELS lamp rows, CONSOLE keys, two cairo dial meters, output well + dismiss ✕, toast. Interactions all wired: open network joins / secured opens the password dialog, active row arm-disconnects (gold), ✕ arm-forgets (terracotta), tunnel toggles, ethernet row takes/yields the route, radio switch flips the wifi radio (refuses under airplane with the way out), + hidden joins a non-broadcast SSID, DOCTOR streams diagnose+repair into the well, SPEED TEST sweeps both dials with the live rate then pins the final with HOLD (location/ping/final/tips in the well). =panel.css= grew the console classes (lamps+glow, b-face, engrave, chan, lamp-row + arm tints, c-btn, meter/mode/hold, output steps, toast). AT-SPI smoke + driver rewritten (anchor on the DOCTOR key). Phases 3 and 4 landed together because a view-only intermediate is a non-functional panel. Verified live on velox: full render screenshotted, console smoke green (faceplate/keys/sections/tunnels/DOCTOR/dismiss/close), DOCTOR streams real diagnose steps, SPEED TEST drove RX 36.6↓ / TX 90.7↑ then HELD. 581 net tests + full make test green. - -*** 2026-07-03 Fri @ 06:55:00 -0400 Phase 5 shipped: bt panel rebuilt as the instrument console -Bluetooth's turn, two commits mirroring net. Phase-5a (dotfiles =5318b34=, 47 new console tests): the GTK-free layer — =viewmodel.bt_faceplate= (POWERED/OFF/AIRPLANE word + lamp + LOW BATT/AIRPLANE badges), =paired_console_rows= / =nearby_console_rows= (lamp rows with connect/forget/rename affordances), =discoverable_chip=, count labels, =battery_gauges= (two dial slots, one per connected device, red under 15%, dim NO DEVICE / ADAPTER OFF empties), =STEP_NARRATION=, and =panel.ArmState= (the forget latch). Engine gaps: =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (set-alias has no MAC-addressed one-shot; =device_path= discovers the controller node from the object tree), =manage.rename= wraps it with a verify-after read, =parse_info= reads the Alias as the display name (a rename lands there, not on Name; the MAC-shaped placeholder stays "unnamed"), and =doctor= grew =on_report=/=on_begin= callbacks. Phase-5b (dotfiles =66f03d9=): =gui.py= rewritten as the single-screen console — faceplate (lamp/word/LOW BATT+AIRPLANE badges/adapter-power switch/close), engraved ADAPTER line with the clickable discoverable chip, scrolled PAIRED + NEARBY lamp rows, CONSOLE keys DOCTOR / SCAN, two cairo battery dials, output well + toast. Interactions: paired rows toggle connect/disconnect, ✎ renames via a dialog, ✕ arm-forgets, nearby rows run the pair flow into a passkey-confirm dialog, the chip toggles discoverability, the switch powers the adapter, SCAN refreshes nearby, DOCTOR streams checks + repairs. =panel.css= gained =.chip= / =.pen= / =.o-passkey= (the rest already shared with net). AT-SPI smoke rewritten (anchor on the bt-only SCAN key). Verified live on velox: smoke green end to end, screenshot matches the prototype (POWERED faceplate, four paired audio devices, two NO DEVICE battery dials). 46 suites + full make test green. Phase 6 next: live both-panel verify, folded tasks closed, dead code removed, spec → IMPLEMENTED. - -*** 2026-07-03 Fri @ 06:49:45 -0400 Phase 6 shipped: build closed out, dead code removed, spec IMPLEMENTED -Live both-panel verify on velox: 46 suites + full make test green, and both AT-SPI smokes green end to end (net: faceplate NET·01/ONLINE, DOCTOR streams real diagnose steps, tunnels rows, close; bt: BT·01/POWERED, SCAN/DOCTOR keys, battery dials, close). The two =gui.py= files are byte-identical to their screenshot-verified commits (net =800ef60=, bt =66f03d9=), so the render carries over from the phase-3/4/5 screenshots — this pass touched no view code. Dead code removed (dotfiles =f4e688e=): both panels' orphaned =pages.py= + =ui/= (=*.blp/*.ui=) gone now that =gui.py= builds the tree in Python, the now-dead =make ui= Blueprint-compile target and its =.PHONY= entry dropped, and the stale =gui.py / pages.py= mention in bt =viewmodel.py= fixed; nothing imported the removed modules. Three folded tasks close with this build (network panel redesign, bt switch placement + title, bt rename devices). Build summary written to [[file:assets/2026-07-03-instrument-console-panels-build-summary.org][assets/2026-07-03-instrument-console-panels-build-summary.org]]. Spec =e73877f5= flipped DOING → IMPLEMENTED. Manual-test checklist for the real-device bt interactions filed under Manual testing and validation. -** DONE [#B] Net diagnostics: narrate every step :feature:network:solo: -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-02 -:END: -Follow-on 2 (dotfiles =ebf24fe=, Craig's decision 2026-07-02, option 1 of the discussed policies): mutating tiers pre-check whether we're online before acting. dns-test/dns-override short-circuit with an "already online" step and touch nothing (live-verified on hotel wifi: 100 ms skip, DNS untouched); reset/bounce/nm-restart/resolved-restart proceed (reset has a legit online use — fresh MAC) but carry "(was online before ...)" in their evidence; the panel's repair confirm warns via the cached probe verdict (=probe.cached_online=, file read only); rfkill/dns-revert/tunnel-down already verify their own state, unchanged. 492 net tests / 45 suites / panel smoke green. - -Follow-on (dotfiles =50a7239=, Craig's ask 2026-07-02): the same requirements now cover every Advanced-dropdown action — all repair ids + portal narrate in the panel's step rows, =net repair= / =net portal= human-by-default (=--json= kept, added to portal), doctor's attempts render like checks, and mutating steps keep their next_action on pass (the fail/warn-only rule was dropping dns-override's revert pointer, portal-login's login click, and cleanup-unverified's manual revert). 479 net tests / 45 suites green; panel smoke shows narration in live rows. - -Shipped (dotfiles =7772427=): every diagnose step id carries a one-line narration (what it tests and why) in a viewmodel table, and both human renderers print every step — status, title, narration, evidence with timing, and the fix pointer on fail/warn — in =net doctor= and =net diagnose= alike. Bare =net diagnose= now prints the narrated report (was raw JSON; =--json= keeps the machine envelope; =net-fix= already used =--json= explicitly, the panel calls the engine in-process). A completeness test walks diag.py's step ids against the narration table so a new step can't land unnarrated. 470 net tests / 45 suites green; verified live on velox hotel wifi — both commands narrate the full probe sequence. Ratio picks it up with its queued dotfiles pull (source-imported, no restow-only step). - -Original ask (roam inbox, 2026-07-02, from a real net doctor run on hotel wifi): the output isn't enough to know what's being tested, why, and whether it passed — a failing run printed only the verdict, the fix pointer, and the failing rows: - -#+begin_example -% net doctor -net doctor: fixable - DNS not resolving - -> net repair dns-test - diagnose: - fail: DNS resolution — no resolution (portal may be stalling DNS) - fail: Internet — link up but no clean internet (DNS or egress issue) -#+end_example -** DONE [#B] Network panel: identify tunnel backends + richer connection info :feature:waybar:network: -CLOSED: [2026-07-02 Thu] -Shipped (dotfiles =405235f=). Identification: every Tunnels row's caption now leads with its backend — "tailscale", "WireGuard (NetworkManager)", "openvpn (NetworkManager)" (from the profile's =vpn.service-type=, resolved on the panel path only), "Proton VPN CLI" — via =viewmodel.tunnel_kind_label=. Found and fixed a real gap: NM vpn-type profiles (openvpn etc.) weren't listed at all, only wireguard type. Active tunnels now carry their device's IP4 address. Info page: the active connection's live subtitle gains IP, gateway, and DNS via =build_status(full=True)= (panel poll only — the bar's one-nmcli hot path is untouched). Live-verified on velox: all 9 tunnel rows correctly labeled (tailscale w/ tailnet + peers, 7 WireGuard NM profiles, Proton CLI), live subtitle shows IP/gw/DNS on hotel wifi. 523 net tests / 45 suites / panel smoke green. - -Craig's ask (roam inbox, 2026-07-02): the Tunnels rows all look alike — no way to tell which is tailscale, which is an NM wireguard/openvpn profile, and which is proton CLI without prior knowledge (e.g. when you want to bounce tailscale specifically). Second half: improve the stats under each connection — the panel is effectively a connection's info page. -** DONE [#B] Timer: alarm am/pm input silently fails :bug:waybar:solo: -CLOSED: [2026-07-02 Thu] -Fixed (dotfiles =8dd36c4=). Two root causes: =parse_alarm= only accepted 24h =HH:MM=, and =cmd_new= suppressed the ValueError, so any 12h input silently created nothing. Now accepts 24h (=14:30=, bare =14=) and all common 12h shapes (=2:30pm=, =2:30 PM=, =7:15p=, =7p=; any case, optional space, bare a/p; 12am = midnight), and input that still doesn't parse fires a fail notification instead of vanishing. 107 wtimer tests green (10 new parse cases + notify-on-error CLI tests). Manual test filed (live dialog run). - -Craig's report (roam inbox, 2026-07-02): when setting an alarm, entering am or pm in any fashion makes the timer silently fail. It should accept 24h and 12h variants — capitalization, spaces, bare "a"/"p" — all common forms. -** DONE [#B] Timer: escape doesn't cancel the dialog flow :bug:waybar:solo: -CLOSED: [2026-07-02 Thu] -Fixed (dotfiles =8dd36c4=). Root cause: =_fuzzel= ignored fuzzel's exit code, so Escape (fuzzel exits 2 on a dmenu abort — confirmed in its changelog) returned "" and the flow fed it onward to the next prompt. =_fuzzel= now returns None on any non-zero exit and =cmd_new= aborts the whole flow on None at any step (type, duration/alarm, label). Escape-at-each-step covered by CLI tests against a fake fuzzel exiting 2. Manual test filed (real keyboard Escape). - -Craig's report (roam inbox, 2026-07-02): hitting cancel via escape at the step after choosing "timer" does nothing but proceed to the next step — likely the same for the other dialog steps. -** DONE [#B] Network panel: stream speedtest results live :feature:waybar:network:solo: -CLOSED: [2026-07-02 Thu] -FIX-UP (dotfiles =60707be=, 2026-07-03, caught by Craig): the first shipped version didn't actually stream — speedtest-go buffers all phase lines to process exit when piped (per-line arrival timestamps proved it: 25s of silence, then everything at once; the original "live" verification never checked arrival timing). The stream now runs the binary under a pty, where terminal mode redraws continuously: in-flight rates tick (download climbing like a speedometer), ANSI/spinner noise is stripped, and on_update fires per changed value. CLI closes with a "final:" settled-numbers line. Re-verified with timestamps (server +1s, ping +2s, download first tick +4s, upload +19s, final +29s) AND an AT-SPI probe of the live panel that sampled the results box mid-run: ping filled at 4s, download ticking at 12s, upload at 24s, final rows + conditioned tips at the end. 529 net tests / 45 suites green. - -Shipped (dotfiles =38171e8=). =run_speedtest_stream= runs speedtest-go's plain mode, whose lines land one per completed phase (parser written against a real captured hotel-wifi run). Panel: a checklist fills in as ping → download → upload arrive, final rows at the end. =net speedtest= streams the same lines at the terminal (=--json= keeps the one-shot envelope). Bonus from the text mode: jitter (rides the Ping row) and packet loss (own row, warns >1%) — the JSON mode never reported either. The static Tip is gone; =speedtest_tips= derives guidance from the numbers (high ping >100ms, download < half of upload, <10 Mbps both ways, loss >1%), each tip naming its trigger values — that's the answer to Craig's "what criteria" question: the old tip had none, the new ones are stated rules. 509 net tests / 45 suites green; live CLI run streamed correctly and fired the asymmetric-download tip on real numbers (33 down / 76 up). Manual test filed for the in-panel run. - -Craig's ask (roam inbox, 2026-07-02): the speedtest only shows results at the end; typical speedtest UIs report the numbers as they come in. Stream the CLI's progress into the results box as it arrives, then the final numbers at the end. Screenshot: ~/pictures/screenshots/2026-07-02_225441.png. -** DONE [#C] Bluetooth bar icon: gray instead of the bar's white :bug:waybar:bluetooth:solo: -CLOSED: [2026-07-03 Fri] -Fixed (dotfiles =27d8eda=). Root cause: the =on= state sat in the css dim group with off/absent/degraded (a deliberate "idle dims" choice that read as broken). Removed =.on= from the dim rule in all three css copies (dupre, hudson, live style.css — the theme-drift guard suite pins them together); indicator docstring updated. Live-verified: SIGUSR2 css reload, bar screenshot shows the bt glyph in the bar's resting white alongside battery/text. - -Craig's report (roam inbox, 2026-07-03): the bluetooth waybar icon renders gray, not the same white as the other bar module icons. -** DONE [#B] Bluetooth panel: close button like the net panel :feature:waybar:bluetooth:solo: -CLOSED: [2026-07-03 Fri] -Shipped (dotfiles =42c93d6=): a flat circular Close button right of the tab switcher (accessible "Close" label, "Close (Esc)" tooltip), wired to window.close. The bt smoke asserts it exists AND that clicking it exits the panel (run green live). Plot twist answered in-session: the net panel had no close button either — Craig's leaner-chrome pass removed it 2026-07-01 (787b475) on the Esc-suffices theory; he asked where it went, so it was restored with the same tab-row button (=6a0aff7=, net smoke extended the same way). Both panels match again. - -Craig's ask (roam inbox, 2026-07-03): the bt panel needs a close button matching the network panel's. -** DONE [#B] Bluetooth panel: switch placement + panel title :feature:waybar:bluetooth:solo: -CLOSED: [2026-07-03 Fri] -Delivered by the instrument-console rebuild (spec e73877f5, phase 5). The adapter-power switch now sits on the faceplate above every console key, and the engraved ADAPTER line is the panel's title row with the clickable discoverable chip right-justified on it. - -Craig's ask (roam inbox, 2026-07-02): move the bluetooth on/off switch above all the buttons. "Bluetooth" becomes the panel's title, with the on/off switch right-justified on that title row. Panel code in ~/.dotfiles =bluetooth/= (GTK4 + Blueprint, phase-2 PanelModel/presenter — see the shipped panel task in Resolved). Presenter tests + the AT-SPI smoke likely need their layout assertions updated. -** DONE [#B] Bluetooth panel: rename devices :feature:waybar:bluetooth:solo: -CLOSED: [2026-07-03 Fri] -Delivered by the instrument-console rebuild (spec e73877f5, phase 5). =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (no MAC-addressed one-shot exists; =device_path= finds the controller node from the object tree), =manage.rename= wraps it with a verify-after read, and =parse_info= reads the Alias as the display name. Each paired row carries a ✎ affordance opening a rename dialog. Live-probed the mechanism on velox before wiring it (rename + restore verified on the M650). - -Craig's ask (roam inbox, 2026-07-02): the panel should be able to rename a device. bluez supports per-device aliases (=bluetoothctl= device menu =set-alias=; the one-shot invocation shape needs verifying at the btctl boundary). Wire it through the engine (=bluetooth/src/bt/=) with a verify-after read, and surface a rename affordance on the device row consistent with the panel's existing patterns. -** DONE [#B] Network panel: other network interfaces (tailscale, VPNs, wireguard) :feature:waybar:network: -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:SPEC_ID: 79a1075a-4b56-4f25-a861-b69f120a636a -:END: -Spec: [[file:docs/specs/2026-07-02-net-panel-other-interfaces-spec.org]] (DOING — reviewed READY and decomposed 2026-07-02 evening; all four decisions were resolved same morning, claims re-verified live at review: protonvpn binary, tailscale JSON shape, seven importable wireguard configs). - -Tunnels visible and controllable in the net panel: tailscale + NM wireguard + proton-vpn-cli probes, a Tunnels group in Connections, diagnose/doctor route-ownership awareness, a bar badge when a tunnel owns the default route, archsetup operator flag + package swap, and the one-time NM import of the seven Proton configs. Origin: roam inbox capture 2026-07-02. - -*** 2026-07-02 Thu @ 18:47:05 -0400 Shipped phase 1 — overlay probes (dotfiles 2d9d060) -=net/src/net/overlays.py=: one probe per backend, shared row shape ={kind, name, state, addr, detail, can_toggle}=. tailscale parses =status --json= (up/down/needs-login/stopped, tailnet + N/M peers online + exit node detail, first TailscaleIP); wireguard rows filter =nmcli connection show= by type with uuids for the existing up/down wrappers; proton drives the official CLI — ground truth sampled live before writing the parser: the GUI-running refusal prints to stdout and EXITS 0 (text-detected, =can_toggle false=), disconnected = "Status: Disconnected", and the CLI's account store is separate from the GTK app's (=protonvpn info= → Account 'None' — sign-in is a phase 6 migration step for Craig). =net status= gained a fast-path overlays section (tailscale + wireguard only; the python CLI's ~300ms startup stays out of the indicator poll, and an active proton tunnel surfaces as its NM wireguard row anyway), guarded so a probe crash yields =[]= not a dead indicator. 19 new tests over fake-tailscale/fake-protonvpn/fake-nmcli (45 suites green); live check on velox: tailscale row up, 5/6 peers, hot path 149ms. proton-vpn-cli 1.0.1 installed on velox (GTK app stays until phase 5). - -*** 2026-07-02 Thu @ 19:02:45 -0400 Shipped phase 2 — panel Tunnels sub-view (dotfiles 21db05a) -Connections gained a third sub-view (Available | Saved | Tunnels — a StackSwitcher page, the natural landing for the spec's "fourth group" in this UI): rows from =overlays.collect(fast=False)= with the vpn glyph, name, and a =tunnel_caption= (state · addr · backend detail); one primary button follows the selected row via =PanelModel.tunnel_primary()= — Bring Up/Bring Down when toggleable, disabled explainers for needs-login ("Sign in first: tailscale up") and the Proton GUI-running case. =manage.tunnel_up/down= dispatch by kind (wireguard rides the existing nmcli up envelope + =connection down=; tailscale/protonvpn shell their tools into a =_tool_result= envelope carrying stderr on failure); ops run on the worker thread, rows + bar reload on land. gui grew =refresh_tunnels()= (bg, full probe set) kicked from the list load. AT-SPI smoke extended (Tunnels tab, action button, rows — POLLING for the bg load; a fixed sleep raced it and false-failed). 22 new tests (45 suites green). LIVE on velox: smoke fully green, rows eyeballed in dupre (tailscale up caption with peers count; proton app-running row), =tailscale set --operator=cjennings= applied and the user-mode =tailscale down/up= round-trip verified (Self.Online back true). Gotcha reconfirmed: stray test panels leave a windowless single-instance process — =pkill -9 -f '[n]et panel'= + wait before relaunch. - -*** 2026-07-02 Thu @ 19:11:47 -0400 Shipped phase 3 — diagnose/doctor tunnel awareness (dotfiles 31ba056) -=overlays.default_route_owner()= classifies the default route's owner (tailscale prefix, wg/pvpn/proton/tun/tap prefixes, else the active NM connection's type — imports can name a wireguard device anything). diag's route step went three-way: overlay owner = informational pass row ("internet flows through the tailscale tunnel tailscale0"), other physical link = the old multi-homing warn. When the HTTP probe fails while a tunnel owns the route, a new "tunnel" edge row LEADS the evidence and the classifier returns fixable/action tunnel-down (the deferred-vpn verdict is retired — it was look-don't-touch, and it never caught tailscale at all since NM lists it unmanaged; an NM VPN that doesn't own the route now falls through to normal classification instead of being blamed). =repair_tunnel_down= dispatches by owner (tailscale CLI / protonvpn CLI for pvpn-named devs / nmcli connection down via active-connection lookup), verifies route ownership actually moved, and registered in ACTIONS so Get Me Online drives it. fake-ip gained FAKE_IP_DEFAULT_DEV_SEQ (head-first line consume, the UP_RC_SEQ idiom) so tests watch the owner change across the verify. 11 new tests, 2 old deferred-vpn pins rewritten to the new contract; 45 suites green; live read-only diagnose on velox clean (wlan owns the route — no tunnel rows, as designed). - -*** 2026-07-02 Thu @ 19:14:58 -0400 Shipped phase 4 — bar tunnel badge (dotfiles b4010bf) -=net status= carries =tunnel_route= ({dev, kind} via =overlays.default_route_owner=, exception-guarded like the overlays list, present on the no-device path too). The indicator appends a small nf-md-vpn badge after the state glyph, emits =["<state>", "tunnel"]= as a waybar class list (string class unchanged when no tunnel), and the tooltip names the owner ("Tunnel: default route via tailscale0 (tailscale)"). No css edit — presence is the signal, themes can hook the class later, and the waybar/style.css drift test stays untouched. 4 new tests; StatusHarness gained fake-ip so the machine's real route can't leak into assertions (462 net tests, 45 suites green). Live payload on velox verified badge-free (wlp170s0 owns the route — correct); a badge render awaits the first real tunnel-owned route (phase 6's wg import or a tailscale exit node). - -*** 2026-07-02 Thu @ 21:56:00 -0400 Shipped phase 5 — installer proton CLI swap + tailscale operator (archsetup 0389790); GTK app retired live on velox -The feat commit landed at 19:16 (the session died before this close-out): installer enables tailscaled with =--now= and grants =tailscale set --operator= to the primary user (brief retry while the daemon's socket comes up), proton-vpn-cli replaces proton-vpn-gtk-app, VM asserts the vpn stack + the retirement + the OperatorUser pref (format verified against a live daemon). Live velox application finished 21:55: the =protonvpn-app --start-minimized= exec-once removed (dotfiles b5c8442 — nothing replaces it, the CLI is on-demand from the panel), the running app killed, =pacman -Rns proton-vpn-gtk-app= (proton-vpn-daemon stays — separate package the CLI uses). CLI verified unblocked: =protonvpn status= → "Status: Disconnected", =protonvpn info= → Account 'None' (sign-in is Craig's step, filed under Manual testing and validation). +Converge all four instrument-console output walls on the net panel's well controls: a copy glyph and a ✕ close, as an overlay at the top right, hidden until content lands. Craig's call, 2026-07-10, while reviewing the audio doctor's wall: "network panels as standard across all others, make it consistent." -*** 2026-07-02 Thu @ 21:57:00 -0400 Shipped phase 6 — wireguard import script + velox migration (scripts/import-wireguard-configs.sh) -The script stages each config through a =wgpvpn.conf= temp copy (NM's import name must be a valid <=15-char interface name; several config names are longer), renames by the UUID parsed from the import output (never by the transient name, so a stray same-named connection can't be hit), forces =autoconnect no= (full-tunnel AllowedIPs 0.0.0.0/0 must not arm itself at boot), skips already-imported names, and refuses to run past a stale =wgpvpn= connection (an earlier run that died between import and rename — it still has autoconnect on). =tests/import-wireguard-configs/=: 10 cases over a fake nmcli; writing them caught a real bug (under =set -e= the grep-for-UUID pipeline aborted before the error message printed). shellcheck clean; 11 unit suites green. Velox migration verified: the crashed session had already run the import, so tonight's run exercised the skip path live — all 7 connections confirmed wireguard type, autoconnect no, iface wgpvpn, no stale leftovers; =net status= overlays show tailscale + all 7 rows. Ratio runs the script on its trip (rides the archsetup pull). +Where they stand today, no two alike. net has copy + ✕ (=net/src/net/gui.py=, the =o-copy= / =o-clear= overlay). bluetooth has ✕ but no copy. maint has COPY + HIDE as keys in a header row, and no ✕. audio just gained copy + ✕ (dotfiles =bd33440=). -*** 2026-07-02 Thu @ 21:58:00 -0400 Test surface complete across the phases -Probe suites over fake tailscale/nmcli/protonvpn (19, phase 1), panel-model Tunnels coverage (22, phase 2), diag overlay-ownership cases (11, phase 3), badge suite (4, phase 4) — all in dotfiles; VM assertions for phase 5 in archsetup 0389790; the import-script suite (10, phase 6) closes the set. -** CANCELLED [#B] File-manager swallow pattern :feature:hyprland: -CLOSED: [2026-07-02 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-02 -:END: -Reassigned to .emacs.d 2026-07-02 (handoff: =~/.emacs.d/inbox/2026-07-02-2231-from-archsetup-dirvish-popup-swallow-handoff.org=). The "file manager" is the dirvish popup (Super+F, an Emacs frame), not nautilus — so the fix is elisp in dirvish's external-open path (=cj/xdg-open=): spawn the handler directly with =start-process=, hide the popup frame, restore it from the process sentinel, notify on non-zero exit. The spec drafted here first ([[file:docs/specs/2026-07-02-file-manager-swallow-spec.org]], now CANCELLED) records the feasibility finding that stays useful: gio/xdg-open launches double-fork, so no PID-ancestry approach (Hyprland native swallow included) can ever connect viewer to launcher. +Work: give bluetooth a copy key, give maint the overlay pair, and lift the four hand-rolled =_copy_output= implementations into one shared helper rather than a fifth copy. maint keeps HIDE alongside close, because its wall is a persistent session action log you collapse and keep, where net's, bt's, and audio's are per-run results you dismiss. -When the file manager launches another app, it should hide to a special workspace (the "swallow" pattern) and return when that process ends, rather than vanishing. Today it disappears with no signal of whether it's coming back, so the user can't tell success from failure — they should quit explicitly instead. Origin: roam inbox capture. +Copy text is per panel but one rule: it pastes as that panel's CLI prints, so the paste lines up with the terminal a user is already looking at. audio's =viewmodel.wall_copy_text()= is the worked example. -*** 2026-07-02 Thu @ 22:20:00 -0400 Feasibility ground truth: Hyprland native swallow ruled out -=misc:enable_swallow= would be the whole feature in two config lines, but it matches by PID ancestry, and nautilus's launch path (GLib =g_app_info_launch_default_for_uri=) orphans the handler — reproduced live on velox with a python-gi launcher: feh came up with PPID 1 while the launcher was still running. The spec's design is therefore an event-listener daemon (socket2 =openwindow=/=closewindow= while nautilus is active), the touchpad-auto shape. Handlers sampled: pdf → zathura, image → feh (X11 — flagged as a side task), video → mpv, text → emacsclient (exempt candidate, decision 2). -** DONE [#C] Open meeting links in the browser instead of the Zoom app :feature: -CLOSED: [2026-07-02 Thu] -Shipped 2026-07-02, mechanism per Craig ("the Linux zoom app is really terrible — one less dependency"): a =zoommtg://= URL handler, and the native app retired outright. =zoom-web= (dotfiles 187414a, 10 tests) registers as the xdg default for x-scheme-handler/zoommtg via =zoom-web.desktop=; Zoom's launch-page bounce rewrites deterministically to =https://<host>/wc/join/<confno>?pwd=…= in the default browser (subdomain hosts preserved, tracking params dropped, start action mapped, malformed URIs notify + exit 2). The registration landed in the stowed mimeapps.list, so it ships with dotfiles. Zoom uninstalled from velox (=pacman -Rns=), its windowrules removed from hyprland.conf, =aur_install zoom= dropped from archsetup, and the VM retired-package assertion now covers blueman + zoom. Known limit, accepted: a host who disabled join-from-browser blocks the web client — that meeting needs the native app installed ad hoc. Ratio trip: =pacman -Rns zoom= + the pull brings the handler; run =xdg-mime default zoom-web.desktop x-scheme-handler/zoommtg= if the stowed mimeapps.list doesn't take effect. -** DONE [#B] Network panel redesign — no terminals, verify-everything, full failure coverage :feature:waybar:network: -CLOSED: [2026-07-03 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-02 -:END: -Delivered by the instrument-console rebuild (spec e73877f5). The three locked decisions all landed: no terminals (the single-screen console renders every action and result in the output well — net-popup is gone), the passwordless privileged path (the net-priv helper + narrow NOPASSWD sudoers, shipped earlier and carried forward), and verify-every-action (arm-to-fire mutations plus doctor's re-probe). The failure-mode catalog below is the diagnose/repair contract, built out across the net-diagnostics tasks and this rebuild's DOCTOR path; the catalog stays here as the standing completeness reference for that path. - -Major evolution of the shipped =custom/net= module ([[file:docs/design/2026-06-29-waybar-network-module-spec.org]]). -Reverses the spec's "privileged tiers run in a net-popup terminal" decision. Origin: -design conversation 2026-06-30. - -*** Locked decisions -- *No terminals anywhere in the module.* Delete =net-popup= entirely. Every action and - every result renders in the panel. -- *Passwordless privileged path (the enabler).* A single root-owned helper runs net's - specific privileged commands (rfkill unblock, nmcli modify/up, networking off/on, - systemctl restart NetworkManager/systemd-resolved, resolvectl dns/revert, DoT toggle), - installed by archsetup with a narrow NOPASSWD sudoers rule scoped to that helper only - (never blanket mv/systemctl). =repair.py= calls =sudo <helper> <verb>=. This supersedes - and absorbs the earlier [#C] "Passwordless DoT toggle" follow-up. Without it an in-panel - worker thread can't prompt for a password, so this gates the whole no-terminal goal. -- *Verify every action.* Every mutating op confirms its effect before reporting success - (doctor already re-probes; generalize so each repair, connect, forget, add, and DNS - override re-checks and surfaces pass/fail in the panel). -- *Detect + respond to every failure mode below* (auto-fix where we can, else report the - helpful text), including the edge cases. - -*** Navigation (confirmed) -- Top tabs: =Connections= | =Diagnostics= | =Performance=. -- Connections: saved + in-range list, connect / add / forget. -- Diagnostics: sub-row =Diagnose= | =Get Me Online= | =Advanced=; shared area below shows - diagnose items AND streams repair progress (replacing the terminal). =Advanced= reveals - the individual repair buttons, renamed with tooltips describing each. -- Performance: Speedtest (+ live throughput later). - -*** Failure-mode catalog — detect / correct-or-report (the completeness backbone) -Organized by the connectivity stack, bottom-up. "Fix" = auto-correct + verify; "Report" = -the in-panel text when there's no safe auto-fix. Audit this list for completeness; it is the -contract for what diagnose must detect and what the panel must say. - -**** Radio / hardware -- rfkill soft block — Detect: rfkill soft. Fix: unblock + =nmcli radio wifi on=, verify radio unblocked. -- rfkill hard block — Detect: rfkill hard. Report: "WiFi is off at the hardware switch — flip the physical switch or Fn key." -- No WiFi adapter present — Detect: no wifi device in nmcli + rfkill absent. Report: "No WiFi adapter detected — use ethernet, or check the driver (dmesg | grep firmware)." -- Driver/firmware not loaded — Detect: device present but errored / no operational state. Report: "WiFi driver or firmware didn't load — check dmesg for the adapter." -- USB WiFi adapter unplugged — Detect: device disappeared since last scan. Report: "WiFi adapter was removed — reconnect it." -- Airplane mode on — Detect: airplane state file set. Fix: offer toggle off (Super+Shift+A), verify radios back. - -**** Association (L2 link) -- Not connected / disconnected — Detect: link down, device disconnected. Fix: reset (reconnect saved), verify link up. -- Stuck "connecting" — Detect: device state connecting > budget. Fix: reset, verify; if it persists Report: "Stuck connecting to <ssid> — the AP may be rejecting us." -- Weak signal / high loss — Detect: associated but signal below threshold (dBm) or heavy packet loss. Report: "Signal is weak (<dBm>) — move closer to the access point." -- Saved network not in range — Detect: profile active target not in scan. Report: "<ssid> isn't in range here." -- AP roaming flap — Detect: BSSID bouncing. Report: "Connection is unstable — switching between access points." - -**** Authentication -- Wrong WPA password / missing secret — Detect: NM state 120 (snapshot; live detection is a known limit). Report + in-panel re-enter: "Saved password for <ssid> was rejected — re-enter it." -- Enterprise / 802.1X cert or identity failure — Detect: 802.1X profile + activation failure. Report: "Enterprise auth failed — check the certificate or identity (edit the profile)." -- Randomized MAC rejected by AP — Detect: reset-with-random-MAC fails where a prior connect worked. Fix: retry reset with the permanent MAC, verify; else Report. -- WPA3/SAE incompatibility — Detect: SAE key-mgmt + association failure. Report: "This network needs WPA3 and the adapter or profile may not support it." - -**** IP / DHCP -- No IPv4 lease (DHCP timeout) — Detect: connected, no IP4.ADDRESS. Fix: reset → bounce, verify lease. -- APIPA / link-local only (169.254.x) — Detect: only a link-local IPv4. Fix: reset/bounce, verify real lease; else Report: "DHCP server didn't answer — switch network." -- IPv6-only network (no IPv4 by design) — Detect: no IPv4 but IPv6 address + online via v6. Report (not a failure): "Online over IPv6 (no IPv4 here)." Requires making diagnose IPv6-aware. -- IP but no gateway — Detect: IP4.ADDRESS present, IP4.GATEWAY empty. Fix: bounce, verify gateway; else Report. -- Duplicate IP / ARP conflict — Detect: kernel ARP-conflict signal. Report: "Another device is using our IP address — reconnect to get a new lease." (edge) - -**** Gateway (L3 local) -- Gateway unreachable — Detect: no route out, gateway no ICMP. Fix: try one bounce (renew route), verify online; else Report: "No route to the gateway — switch network." (closes the spec/code gap where bounce was never tried) - -**** DNS -- No resolver configured — Detect: IP4.DNS empty. Fix: bounce to re-pull DHCP DNS, verify; else Report. -- Venue DNS broken, public DNS works — Detect: name fails to resolve but 1.1.1.1 resolves (dns-test). Fix: set a PERSISTENT resolver override (1.1.1.1 / 9.9.9.9), verify resolution + online, offer revert. (closes gap #1 — today dns-test reverts and misreports as upstream.) -- DNS hijack (resolves to gateway / private IP) — Detect: classify_resolution hijack. Treat as captive → portal-login flow. -- DNSSEC validation failure — Detect: resolution fails with SERVFAIL where public resolver succeeds without DNSSEC. Report: "DNS security checks are failing on this network." (edge) -- Encrypted DNS (DoT/DoH) hiding the portal — Detect: captive suspected + DoT on. Fix: portal-login drops DoT, opens portal, auto-restores. (existing) - -**** Egress / internet -- Upstream / AP outage (no uplink) — Detect: link/IP/DNS fine, http-probe fail, not a redirect. Report: "This network has no internet — switch network or contact the venue." -- Captive portal (redirect) — Detect: probe redirected. Fix: portal-login opens the page; verify online after login. -- Captive blocked pre-auth (no portal URL) — Detect: probe blocked, no URL. Fix: fresh MAC + open trigger; verify. -- Proxy-required network — Detect: probe fails but a PAC/proxy is advertised (WPAD/env). Report: "This network requires a proxy — configure it in settings." (edge) -- MTU / MSS blackhole (PMTUD broken) — Detect: small probe ok, large transfer hangs. Fix: lower the interface MTU, verify; else Report. (edge) -- Clock skew breaking TLS — Detect: HTTPS/portal fails with cert-time errors + system clock far off. Fix: trigger a time sync, verify; else Report: "System clock is wrong — fix the date/time." (edge) - -**** Routing / multi-homing -- VPN owns the route, no internet through it — Detect: VPN device connected + http-probe fail. Report: "Internet is routed through a VPN (<dev>) — check the VPN, not WiFi." -- VPN up but dead — Detect: VPN device up, no traffic/handshake. Report: "The VPN is connected but not passing traffic." (Phase 5 territory) -- WiFi + tether/ethernet both active — Detect: which iface owns the default route + whether the system is online by any path. Report: "You're online through <other iface>; WiFi itself has no internet," or let the user pick. (closes gap #4) - -**** Infrastructure / system -- Wedged NetworkManager — Detect: nmcli fails / API unresponsive. Fix: restart NetworkManager (bounce escalation), verify. -- NetworkManager not running — Detect: service inactive. Fix: start it, verify; else Report. -- systemd-resolved down — Detect: resolved inactive / DNS via it fails. Fix: restart, verify. -- resolv.conf not resolved-managed — Detect: /etc/resolv.conf not the resolved stub. Report: "DNS isn't managed by systemd-resolved — manual resolv.conf in play." (edge) - -**** Tooling / environment -- nmcli / NM API unavailable — Detect: nmcli error or timeout. Report: "Can't reach NetworkManager — is it installed and running?" -- Slow / hung tool — Detect: step exceeds budget. Fix: degrade that step, retry within budget. -- Stale / corrupt cache — Detect: schema/age mismatch. Fix: self-heal (atomic write + invalidation). -- Missing speedtest backend — Detect: speedtest-go absent. Report: "Install speedtest-go to run a speed test." -- Privileged op fails (helper missing / sudo declined) — Detect: helper exits non-zero or absent. Report: "Couldn't get admin rights for this repair — <install/fix the helper>." - -*** 2026-07-01 Wed @ 13:02 -0400 net-priv helper landed (V2.1) -Craig's call: stowed (not root-owned), low security on locked-down single-user machines. -Shipped =net.priv= module + stowed =net-priv= bin (dotfiles =00aac1e=): a fixed 12-verb set -(rfkill/radio/mac-random/conn-up/net-off/net-on/restart-nm/dns-set/dns-revert/restart-resolved/ -dot-disable/dot-enable) with per-arg validation (uuid/iface/ipv4/resolved.conf.d-path, injection -rejected). =repair.py= now routes every privileged op through =priv.run(verb)= in-process instead -of scattered inline sudo — which also fixes the detached DoT-restore watcher (runs privileged ops -with no tty) and closes the gap where rfkill repair ran unprivileged. 244 net + 33 dotfiles suites -green. NO new sudoers needed: archsetup already grants =%<user> ALL=(ALL) NOPASSWD: ALL= -(archsetup:1089), so every build's primary user already runs net-priv's commands passwordless; -"replicate in archsetup" is already satisfied. net-priv rides =make stow hyprland=; hand-linked on -velox. The velox DoT-path reconcile (whether velox should run DoT at all) stays open — folded into -the deeper reconcile, low priority since the guard makes it a no-op. -*** 2026-07-01 Wed @ 14:05:47 -0400 Shipped V2.2 — merged Diagnostics panel + nav restructure, no terminals -Built the V2 panel (dotfiles =75ed825=, pushed): three top tabs Connections | -Diagnostics | Performance; Diagnostics merges the old Diagnose + Repair pages into a -sub-row (Diagnose | Get me online | Advanced) over a shared area that shows diagnose -rows AND streams repair progress in-panel. net-popup deleted entirely; repairs run on -a worker thread through net-priv (no tty). doctor grew an =on_step= callback so Get me -online streams each escalation step live. Connections groups Saved / Available now / -Wired with a golden group header and joins from a row (=join_plan= auth matrix + -=manage.join= one-step connect, secret to NM only); the Add modal became the hidden-network -affordance. Every diagnose/repair/speed run offers a Copy/Open redacted report -(=report.py=, MAC/IP scrubbed). Waybar visual contract applied (dark capsule, golden -border, monospace) via a CssProvider. =net-fix= opens the panel on Diagnostics instead of -a terminal; middle-click runs =net portal= directly. TDD: 34 new GTK-free tests (grouping, -join_plan, join, report, on_step, eventlog.tail); 278 net + 33 dotfiles suites green. -Live-verified: AT-SPI panel_smoke passes end-to-end + screenshots confirm both pages and -the visual contract. DAILY-DRIVER: waybar config + net-fix are stow symlinks (live on -disk); ratio needs =git pull= + waybar restart; velox waybar picks up on next restart. -**** 2026-06-30 Tue @ 17:36 -0400 Dispositioned the 4th-review findings into the spec -Codex's 9 fourth-review findings (8 accept, 1 modify) are folded into the spec's -"V2 panel UX — the target design" section (cookie [40/40]): single nav target, -saved-vs-available groups, join-from-row instead of Add, the auth-class join matrix, -progressive loading, future-tense + verified Forget, a findable redacted diagnostics -report, the Waybar visual contract, and a lightweight inline latency probe (full speed -test stays under Performance per decision 19). The V2 build below implements that -design: [[file:docs/design/2026-06-29-waybar-network-module-spec.org::*V2 panel UX][V2 panel UX]]. -*** 2026-07-01 Wed @ 22:01:38 -0400 Made diagnose IPv6-aware and multi-homing-aware (dotfiles c0d48e2) -IPv6-only networks pass the DHCP step ("IPv6 only: <addr>") with the v6 gateway standing in for the ping; a bare fe80:: doesn't count. A new route step fires only under multi-homing and names the interface that owns the default route (tether/ethernet/VPN). Also landed the adjacent IP-layer detects: APIPA 169.254 fails DHCP with a link-local explanation, address-without-gateway fails the gateway step as a bad DHCP answer, and a weak wifi signal (below fair) warns on the link step with the dBm. fake-nmcli grew IP6.* and a fake ip(8) serves the JSON route reads. TDD, 33 suites green. - -*** TODO Close every detect/correct gap in the catalog, with post-action verification -**** 2026-07-01 Wed @ 22:41:51 -0400 Closed the feasible edge rows (dotfiles d096b30, 241744b, fafefb6) -Three grouped commits, all TDD. Services/radio: dead NetworkManager and dead systemd-resolved get their own diagnose steps and verified restart repairs (resolved only when resolv.conf is resolved-managed; hand-managed DNS gets a heads-up row), airplane mode fails the link by name and classifies needs-user-action ahead of rfkill, and a missing WiFi adapter is named with the dmesg pointer. Association/auth: reset retries once with the permanent MAC when the randomized one is rejected (new mac-permanent net-priv verb), SAE/WPA3 activation failures classify sae-incompat, and stuck-connecting classifies fixable/reset. Egress edges (run only on an existing failure): DNSSEC validation failure named via resolvectl, clock skew off the probe's Date header, MTU/PMTUD blackhole via df-bit pings, and proxy detection (env vars or an advertised WPAD name). Deferred as infeasible without state the engine doesn't keep: AP roaming flap (needs BSSID history), duplicate-IP/ARP conflict (needs the kernel log), and the USB-unplug transition (its end state is the no-adapter row). Still open here: generalized post-action verification for connect/forget/add. - -**** 2026-07-01 Wed @ 22:01:38 -0400 Closed the two named correct gaps (dotfiles 7819f58) -Gateway unreachable now earns one bounce before the upstream verdict (classifier returns fixable/bounce on gateway warn/fail + probe fail; reachable-gateway keeps the honest upstream call, DNS failure still outranks it). Venue-DNS-broken-but-public-works now ends online: the dns-test chain escalates to a persistent dns-override (1.1.1.1 on the link, dies on reconnect, offered dns-revert undo; a useless override reverts itself) instead of auto-reverting into a misreported upstream outage. Override-aware getent/curl fakes model the venue end to end. Remaining: the edge rows (DNSSEC, proxy, MTU blackhole, clock skew, ARP conflict, roaming flap, stuck-connecting budget, USB-adapter unplug, driver/firmware, WPA3/SAE, randomized-MAC retry, NM-not-running, resolved-down, unmanaged resolv.conf) and the generalized post-action verification for connect/forget/add. -*** TODO Automatic diagnostic verbose-capture (failing diagnose + Advanced toggle) -On =overall: fail=, elevate the underlying stack (NM =WIFI,DHCP,DNS,CORE= / systemd-resolved / -wpa_supplicant) to debug at runtime, run the escalation, capture the journal + dmesg window + -=curl -v=, then restore every level. Also a manual "Debug on/off" toggle in Advanced for -reproducing intermittent failures. HARD: restore is guaranteed (try/finally) AND crash-guarded -(next run detects a left-elevated stack and restores it, like the DoT-restore watcher); the -captured journal is REDACTED before the bundle is written/shown (raw wpa_supplicant/NM debug -carries the PSK/EAP secret in cleartext) with a secret-leak test; log-level toggles run via the -V2 sudo-helper. Bonus: wpa_supplicant debug catches wrong-password/EAP failures the current NM -state-120 snapshot misses, so it also closes the auth live-detection gap. Spec: Observability → -"Automatic diagnostic verbose-capture". Origin: Craig 2026-06-30. - -*** VERIFY Dead-GUI console recovery vs "no terminals" — keep =make online= or replace it? :network: -The cj comment (2026-07-01) said scrub every terminal the module uses to report to or get input -from the user, and I folded that into decision 15 (all module UX is in-panel). The one place it -collides: the deliberate console-recovery path — =make online= / =net doctor --fix= run from a -bare TTY when waybar and the GUI are *down* — is the whole point of the CLI being usable with no -GUI. That's a terminal reporting to the user, but only because there's no panel to use. Keep it -as an explicit carve-out (recovery-only, not terminal-as-UI), or replace it with something else -(a TTY text UI still counts as a terminal)? Your call settles whether the Makefile/CLI recovery -targets stay in the spec. -** DONE [#B] Audio panel spec :feature:waybar:audio:solo: -CLOSED: [2026-07-03 Fri] +Consistent with the 2026-07-07 scope note on the sibling task above: net's compact glyph overlay is what standardizes, not maint's wide COPY-key header row. +** DONE [#C] Org-capture float popup grows too large :bug:hyprland:quick:solo: +CLOSED: [2026-07-14 Tue] :PROPERTIES: -:LAST_REVIEWED: 2026-07-02 +:LAST_REVIEWED: 2026-07-13 :END: -Went past the spec to a full build in a no-approvals speedrun. Spec is now IMPLEMENTED ([[file:docs/specs/2026-07-03-audio-panel-spec.org]], all 5 Decisions resolved). The panel shipped in the dotfiles repo (branch panel-bugfixing, commits 65e5bb0..9601420): pactl engine, GTK-free presenter, GTK instrument-console panel (OUTPUTS/INPUTS device rows with faders + per-device mute, LIVE/MUTED/PUSH·TALK mic keys, twin VU gauges, master quick-mute), Hyprland-bind push-to-talk, bar indicator, and the bar/keybind wiring (Super+A → panel, XF86AudioMute → master quick-mute). 102 unit tests + a passing AT-SPI smoke on velox. Live-eyeball validation filed under Manual testing and validation. Apply steps + follow-ups handed to the dotfiles project inbox. - -Original ask (roam inbox, 2026-07-02): net/bt-panel kin — change default output/input, volume for both, push-to-talk mic mode for meetings, master quick-mute, bar sound-glyph state. Related bindings: Super+M audio-cycle ring, Super+Shift+A mic-toggle. Prototype: =docs/prototypes/2026-07-03-sound-panel-prototype.html=. -** DONE [#B] Panels moveable + resizable by drag :feature:waybar:network:bluetooth: -CLOSED: [2026-07-04 Sat] -Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): both net + bt panels switched from anchored gtk4-layer-shell overlays to normal floating windows (set_decorated(False), positioned by the net.cjennings.netpanel window rule), so Hyprland moves them on drag and resizes on corner-drag natively. That was exactly the "switch to a normal floating window" approach the design note flagged as the required decision. - -Both the net and bluetooth instrument-console panels should be repositionable and resizable at runtime: click-drag to move the panel anywhere on screen, drag the corners to resize. Raised from roam capture 2026-07-03. - -Design note: the panels are gtk4-layer-shell overlays anchored TOP+RIGHT with fixed margins — layer-shell surfaces are compositor-positioned, so free drag-move/resize needs either dynamic margin updates on pointer motion or a switch to a normal floating window (Hyprland moves/resizes those natively). Approach decision required before build. -** CANCELLED [#B] Net panel wider initial width :waybar:network:quick: -CLOSED: [2026-07-04 Sat] -Superseded by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel is now a floating, user-resizable window (set_default_size(420, 560)), no longer a right-anchored layer-shell surface. The task's mechanic ("keep the right edge fixed, extend the left border leftward") assumed the old anchored surface, which no longer exists — the width is now drag-adjustable. Cancelled per the 2026-07-04 audit (Craig's call to close rather than re-file a "bump the 420px default" task). - -Start the network panel a bit wider — keep the right edge fixed (it's right-anchored), extend the left border leftward. Raised from roam capture 2026-07-03. -** DONE [#B] Net panel doctor results can't display :bug:waybar:network: -CLOSED: [2026-07-04 Sat] -Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel gained a streaming output well (gui.py) with a "Copy results" button (via wl-copy) and a dismiss control that collapses the well back to the panel's pre-open height (_shrink_to_compact asks Hyprland to resize back). Doctor/speed-test output streams into it as appended lines — matching the task's ask for a tall results box, copy button, and collapse-back. This capture (filed 2026-07-03 morning) predates the same-day 22:06 redesign that addressed it. - -The doctor diagnostic output is unreadable — the results well is too constrained to show the multi-line result. It should open a results box tall enough for several lines with a copy-results button; closing it via an X in the box's upper-right collapses the space back to what it occupied before. Raised from roam capture 2026-07-03. -** DONE [#B] Timer GTK panel :feature:waybar: -CLOSED: [2026-07-05 Sun] -Built and shipped to dotfiles 2026-07-05 in a no-approvals speedrun (4 commits =1f4f270=..=78d3cbb=): wtimer gained watch/lap/save; a new =timer/= package holds a GTK-free PanelModel (62 tests) and the GTK instrument-console panel; the bar's =custom/timer= now opens the panel and the fuzzel creation flow retired. Spec: [[file:docs/specs/2026-07-02-timer-panel-spec.org]] (IMPLEMENTED). Code-complete; live GTK verification filed under Manual testing and validation below. - -From Craig's roam capture 2026-07-02: give the timer a GTK UI/UX like the network panel. Scope expanded via a later cj comment (queue/output-wall auto-sorted by fire time, stopwatch lap/stop + saveable runs, 5/25 configurable defaults, up to 10 timers, widget-gallery elements) — folded into the spec's Build scope and shipped. - -*** 2026-07-05 Sun @ 07:20:20 -0500 Redesign shipped — hero-on-top rebuild -The UI/UX redesign (decided through the prototype process, final = [[file:docs/prototypes/2026-07-02-timer-panel-prototype-3.html]]) built and shipped to dotfiles in a no-approvals speedrun, 5 commits =c7ac193=..=5a863b5=: Phase 1 wtimer engine (timer repeat; recurring alarms with snooze/ringing/dismiss; =@half=/=@hour=/=+dur= alarm parse; the rebuilt configurable pomodoro cycle — work/rest short+long, long-every-N, auto vs awaiting); Phase 2 PanelModel view-data (=row_view=, ringing-first sort, per-type create options as wtimer flags, locked presets + half-past + named pomodoro cycles); Phase 3 GTK hero-on-top panel (Cairo progress ring + stopwatch analog sweep dial, per-type create strips, one transport row, close ✕/Esc); Phase 4 bar-tooltip parity. wtimer + timer suites 231 green, full =make test= green. Spec re-flipped DOING → IMPLEMENTED. Stopwatch run-save deferred to vNext. Live GTK render is the refreshed manual checklist below. -** CANCELLED [#B] Test each modernization thoroughly before replacing -CLOSED: [2026-07-04 Sat] -Retired in the 2026-07-04 audit (Craig's call): a standing-judgment umbrella with no completion criterion. The fleet is Hyprland-only now, and per-change test discipline is already carried by the actual work (TDD + the VM harness), so this adds nothing to track. Original intent: ensure new tools integrate with the Hyprland environment and don't break workflow (archsetup still supports DWM/X11 but no current machine uses it). -** DONE [#C] Window focus lost when unhiding stashed windows :bug:hyprland: -CLOSED: [2026-07-04 Sat] -Verified fixed live on ratio 2026-07-04 (Craig at the machine). Stash (Super+O) → restore (Super+Shift+O) left the restored window focused, and Super+J/K (layout-navigate) cycled focus normally afterward — both original symptoms gone. Resolved by two fixes that postdated the filing: dotfiles 5619342 (raise window on focus nav, stop float focus-follow, 2026-06-28) and 09815f3 (cycle focus by address so j/k works in monocle, 2026-06-29). Both confirmed present in ratio's HEAD and in the live layout-navigate script at test time. - -From the roam inbox: hiding a window (e.g. the org-capture popup) then unhiding it should leave the unhidden window focused, but another window typically takes focus. Also =ctrl+j/k= (layout-navigate) can't reach the unhidden window afterward — it should always reach any visible window except the waybar. Involves stash-restore + layout-navigate; needs interactive reproduction with Craig. (Note: the actual bind is Super+J/K, not ctrl+j/k as the capture said.) -** DONE [#C] Instrument-console panel bugs (net/bt/audio) :bug:dotfiles: -CLOSED: [2026-07-05 Sun] -Batch from the roam inbox (2026-07-05). Panel code lives in =~/.dotfiles= (net/, bluetooth/, audio/). All eight shipped 2026-07-05, each verified live and covered by the panel test suites. -*** 2026-07-05 Sun @ 17:49:51 -0400 Titled the panel windows Network/Bluetooth/Audio (was python3) -The GTK app set no window title, so it fell back to the process name. dotfiles 2d03451. -*** 2026-07-05 Sun @ 17:49:51 -0400 Faceplate word is the subsystem name, not live state (net/bt/audio) -NETWORKING / BLUETOOTH / AUDIO; state now reads off the lamp colour + badges. Bluetooth's DOCTOR feedback (CHECKING/FIXING) moved to the status line and output well. dotfiles 5c58833. -*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel flags a signed-out Proton CLI as needs-login -The proton probe reads =protonvpn info= first, since =protonvpn status= says Disconnected either way. The panel already blocks a needs-login row with a sign-in hint. dotfiles 2671472. -*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel sorts live tunnels to the top -Stable within each backend group. dotfiles 2671472. -*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel refuses a second full-tunnel VPN while one is active -tailscale is a mesh overlay, not a full tunnel, so it never conflicts. dotfiles 307a0fe. -*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel dedupes Proton's own wireguard row -The proton CLI's =ProtonVPN <server>= NM profile no longer shows alongside the proton backend row. dotfiles dbc9ee8. -** DONE [#B] Maintenance console build :feature: -CLOSED: [2026-07-08 Wed] +Craig answered the pre-flight (2026-07-14): cap at 120 wide, height proportional. Applied as 120 Emacs columns (11 px/col measured from the live daemon) = 1320 px wide, height 653 px from the old rule's aspect. Ratio's size rule shrank from the 1892x936 scratchpad match and both hosts gained a max_size growth cap (the field is max_size — bare "maxsize" is invalid and hyprctl reload won't say so; check hyprctl configerrors). Verified live: config clean, a probe window floats at exactly 1320x653. Dotfiles 9c4dc2f. +** DONE [#C] Panel smoke: faceplate state-word assertion fails on the live compositor :bug:dotfiles:test: +CLOSED: [2026-07-14 Tue] +Diagnosed and fixed within the session: not a race — test drift. Dotfiles b581d5d (2026-07-05) made the faceplate word the static subsystem identity (NETWORKING / BLUETOOTH / AUDIO) and updated the audio smoke, but the net and bt smokes kept asserting the retired live-state words and had failed on every run since. Both now assert the identity word like audio's (dotfiles 32cd99f); both smokes run RESULT: OK end to end, which also green-gates the doctor-streaming change. +** DONE [#C] Realtime lamp output for the net + bt doctors :feature:solo: +CLOSED: [2026-07-14 Tue] :PROPERTIES: -:SPEC_ID: 9d9df833-c592-4aec-a7df-50d588e943ce +:LAST_REVIEWED: 2026-07-09 :END: -Build the maintenance console per [[file:docs/specs/2026-07-07-maintenance-console-spec.org][maintenance-console-spec.org]] (DOING; review incorporated 2026-07-07 — two rounds, all 13 decisions DONE, all 10 findings DONE, verdict Ready; Craig approved config paths + sysmon right-click re-homing). Design source of truth: [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]]; pixel reference: [[file:docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5 prototype]]. CLI-first =maint= package in dotfiles (archsetup-owns-dotfiles applies: edit, test, commit, push, note dotfiles inbox per phase); thresholds TOML + VM harness in archsetup. Each phase gates on green tests before its commit. - -*** 2026-07-07 Tue @ 20:11:23 -0500 Built Phase 1 — package skeleton, thresholds, contract (dotfiles 43a39ac) -maint/ package + shim + tests/maint/ (48 tests, fake-tool harness); thresholds two-layer merge with disable flags + env overrides; capability probe (battery ⇔ type==Battery, verified against ratio's Mains+USB tree); =maint status --json= with disk-usage/pacman-cache/failed-units pilots. Seeded configs/maintenance-thresholds.toml in archsetup from the workflow's values and installed a copy to ~/.config/archsetup/ on ratio (installer wiring stays Phase 13). Full make test green (50 suites). Live on ratio: worst=warn from the real 10.75 GB cache; du grades on its printed total, not its exit code (root-owned partial-download dirs make it exit 1) — pinned in a test. - -*** 2026-07-07 Tue @ 20:49:05 -0500 Built Phase 2 — storage & snapshot collectors (dotfiles 94e8371) -All Phase 2 collectors TDD'd against the fake-tool harness (argv-matched cases added): btrfs unallocated/scrub-age/device-errors (SMART cross-check), per-disk SMART health + last self-test, fstrim posture (discard mount option = continuous trim, ratio's real setup), disk top-consumers on a new slow-local cache (=maint scan --slow=, ~/.local/state/maint), snapper counts split timeline·single·pre-post (split keys on cleanup, not type), full ZFS family. =priv.py= pulled forward from Phase 6 (read-only verbs =smart_json= + =btrfs_scrub_status=, validated args, MAINT_SUDO) so probes stay elevation-free. maint suites 48→118; make test green (51). Live read-only verified on BOTH hosts: ratio worst=warn (10.75 GB cache; scrub 0d, SMART ok), velox over tailscale shows the ZFS family live (ONLINE/autotrim-on/scrub 1d/231 snaps) with a genuine fstrim warn (timer off — Phase 6 Confirm remedy). TOML gained storage keys (btrfs_unalloc_warn_gb, smart_*, fstrim_stale_days, hog_*); installed copies refreshed on ratio + velox. - -*** 2026-07-07 Tue @ 21:30:59 -0500 Built Phase 3 — packages, security, systemd collectors (dotfiles 3eaab6e) -All Phase 3 collectors TDD'd: orphans name+size with [curation.kept_orphans], pacnew safe-delete vs needs-merge, keyring freshness, reboot-required (uname -r vs modules dir), -Qkk on =maint scan --slow= with a noise split (unverifiable-as-user reads, mtime-only, [curation.qkk_known] paths tallied but never counted — an unfiltered live run sat permanently red at 68 "altered" files; classified, ratio shows 10 real findings); =maint scan --net= writes checkupdates/yay/arch-audit/fwupd caches (yay rc-1-silent = AUR-clean zero; failures never clobber good cache; malformed payloads degrade to unprobed), readers grade with age + stale bump; is-system-running with state-aware cause, failed-unit roster since/exit/journalctl hint, maintenance-timers meta-metric (capability-derived expected set, @-prefix instance match), taint letters decoded. Prereq done: arch-audit added to archsetup deps + installed both hosts. Review subagent (live-verified) found 2 Important (yay AUR-clean rc quirk, cache-shape envelope kill) + 2 minor — all fixed red-first; archsetup now enables btrfs-scrub@-.timer on btrfs installs (was ratio-only out-of-band, the meta-metric expects it). maint suites 118→169; full make test green. Live on ratio: 23-metric envelope, worst=warn from real findings (10.75GB cache, 8 orphans, mirrorlist.pacnew, 713 repo updates, 32 CVEs, 1 firmware). - -*** 2026-07-07 Tue @ 21:59:27 -0500 Built Phase 4 — logs + mem·pwr collectors (dotfiles 40c448b) -All Phase 4 collectors TDD'd: journal error digest (identifier groups w/ first/last seen + journalctl hint, known-noise curation tallied but never counted, byte-array MESSAGE decoded), coredumps by binary over a TOML window, kernel/hw event classes (MCE/EDAC/IO/thermal/GPU — generic driver noise excluded), journald disk usage, app-log staleness mirroring the log-cleanup cron's filename-date rules; memory pressure + top-5 RSS, OOM kills (kill-verdict lines only — one event spans several matching lines), swap/zram, temps via sensors -j (k10temp Tctl / coretemp package / amdgpu edge), Intel thermal-throttle count, EPP read, battery capacity/health/charge-cap, unclean-shutdown rate from wtmp (last -x pairing). Throttle/EPP/battery self-gate on sysfs and drop out of the envelope. Review subagent (live-verified) found 1 Blocking — windowed-empty coredumpctl exits 1 (my earlier --since-now pin was wrong), which left the metric permanently unprobed on healthy hosts — plus OOM ~3× overcount and a malformed-curation-entry envelope crash; all fixed red-first, re-review Approve. maint suites 169→227; full make test green. Live read-only both hosts: ratio 34 metrics (telega-server crashing 3× in window — real find), velox 38 (genuine 75% battery-health warn; charge_control_end_threshold ABSENT on velox's cros_ec hardware → Phase 9's SET 80% lever must gate on the file). TOML +journal_disk_warn_gb/coredump_window_days, new [memory], [power] temp/health/unclean keys; installed both hosts. - -*** 2026-07-07 Tue @ 22:35:28 -0500 Built Phase 5 — network posture + services collectors (dotfiles 2e92a9f) -All Phase 5 collectors TDD'd after live-pinning every tool format on both hosts (velox presence sweep over tailscale: full tool set present): nm_state (passive — NM's own connectivity check covers DNS+HTTP, so status never generates traffic), chronyc offset vs [network].ntp_offset_warn_ms, tailscale Health + peer tally, fail2ban per-jail bans, listeners digest via priv ss (expected-entry curation tallied-not-counted, unexpected+wildcard=crit, v4/v6 dedup, multi-holder sockets match any name), firewall (priv ufw status; fallback requires unit-active AND ufw.conf ENABLED=yes; names "ufw down — N public binds exposed" from the digest), rsyncshot freshness (1MB log-tail parse; hourly graded only where the log shows hourly runs — velox is daily-only; daily ≥48h/missing = crit), docker df JSONL + stopped containers w/ [curation.expected_containers], libvirt roster (queried only when libvirtd already active — a read must not socket-activate), cron drift (root rsyncshot expectations gate on backup-source). priv.py +4 read-only verbs; thresholds.curation_entries() tolerant helper. Review subagent (live-verified): Request Changes — 1 Important (malformed fail2ban counts raise ValueError into the envelope) + ufw-disable unit-state false positive, docker unknown-unit sizes reading 0, unfloatable chrony offset; all fixed red-first, re-review Approve. maint suites 227→297; full make test green (54 suites, unpiped exit 0 — an earlier tail-piped run masked make's exit code, rerun properly). Live both hosts: ratio 45 metrics, velox 49. REAL FINDS: listeners crit on both (ratio postgres/8080 wildcard binds, velox syncthing *:8384/*:22000) — curation material for Craig; backups stale on both since 2026-07-06 (truenas down for repairs, Craig confirmed — clears when it returns). TOML +[network]/hourly-backup/cron keys, installed both hosts. - -*** 2026-07-07 Tue @ 23:14:56 -0500 Built Phase 6 — remedy layer, priv verbs, guard, doctor CLI (dotfiles e171c08) -The full remedy layer TDD'd in one pass (no 6a/6b split needed): remedies.py allowlisted table (~30 remedies — exact argv, tiers per the design tables as amended by the dated decisions, re_probe_ids, long flags, RECLAIM SPACE macro resolving steps against the envelope incl. per-config snapper expansion); priv.py +28 write verbs (closed builders, type-validated args, per-verb timeouts; =build()= returns the exact sudo-prefixed argv so dry-run and the GUI arm-press share one string); guard.py pure live-update matcher over new TOML =[updates] guard_patterns= (fnmatch, case-sensitive, pairwise-tested; cold pending-cache is surfaced as a note, never silent); curation.py user-layer writes (mark/unmark/clear + KEEP/MARK KNOWN/EXPECTED wrappers, disable-flags for shipped defaults, self-verifying TOML emitter); doctor.py clean|review + iter_fix streaming the event feed (begin/ok/fail/guard/note; failures red without aborting; re-probed truth after each action); cli =maint fix <id> [--dry-run] [--force]= + =maint doctor clean|review= wall renderer; status envelope now stamps =levers= (always-on for free controls: epp_set, charge_limit, update, topgrade). KILL four guards: pid:name victim, TERM-only verb, session-critical denylist ([curation.session_critical], new TOML table) at preview, comm revalidation + denylist re-check at fire. Live-pinned before coding: coredumpctl HAS NO clean verb (find -mtime +N -delete via priv), log-cleanup at ~/.local/bin/cron/, rsyncshot takes <mode> <keep>, ufw enable needs --force, EPP value enum. Review subagent (live-verified): Request Changes — 1 Blocking (json.dumps astral-char surrogate pairs = invalid TOML: one emoji in a MARK KNOWN example would brick thresholds.load() for the whole console → ensure_ascii=False + write self-verifies by re-parsing before replace) + 1 Important (KeyError from sparse installed TOML escapes the event stream → caught, degrades to a red wall entry) + 2 Minor (cold-cache guard note, comm-vs-denylist re-check); all fixed red-first (6 new tests), re-review Approve. maint suites 297→393 (96 in the new suite); full make test green ×2 (unpiped, exit 0). VERIFIED LIVE on ratio: doctor clean executed the Auto tier for real — paccache -ruk0 freed 223.91 MiB and the re-probe measured the cache drop exactly; the live-update guard trips on ratio's genuinely-pending mesa/vulkan-radeon set. TOML +guard_patterns/+session_critical/+timeline_quarterly installed both hosts. - -*** 2026-07-07 Tue @ 23:52:40 -0500 Built Phase 7 — GUI shell (dotfiles d28435e) -The GTK4 window lands as the fourth panel sibling (net.cjennings.maintpanel, humble-object layering: GTK-free PanelModel presenter + pure viewmodel formatters + thin gui.py view). Read-only shell: faceplate (worst lamp, CVE/ATTN badges, MNT·01, close), inert doctor/update keys, updates strip (pending/CVE/AUR/firmware from the strip ids), eight-band selector (lamp + fixable/watch split from the envelope's levers; updates/security categories fold into PACKAGES), metric-row subpanel per band with per-id value formatting (bytes/percent/days/°C/ms/bool). Hydration off-thread; 3s live tier (build_live: cheap mem·pwr reads) + 30s full rebuild, both visibility-gated; MAINT_PANEL_FIXTURE=good|bad renders shipped envelope fixtures (reshaped from the E5 prototype by tests/maint/gen_fixtures.py, deterministic, real remedy ids) and freezes both tiers so the degraded board stays put on a healthy machine. Review subagent: Request Changes — 1 Blocking (build_live returned bare metrics, so the 3s merge stripped the levers the full envelope established and the band splits oscillated between tiers → attach_levers in build_live) + 2 Minor (synthetic error envelope missing worst read as healthy green → panel.error_envelope with worst=unprobed; smoke leaked the panel process on mid-run assertion errors → try/finally reap); all fixed red-first, re-review Approve (+1 hardening: merge into an error envelope is a no-op). maint suites 393→447 (54 in test_panel.py); full make test green (56 suites, unpiped exit 0). AT-SPI smoke PASS on live Hyprland (faceplate, badges, strip, all 8 tiles, band switch, clean close; window parked on a special workspace, never the active one). Screenshots off-workspace via headless output: bad fixture + live board vs the E5 reference. Live board on ratio honest: 32 CVE / 13 ATTN, NETWORK red from the real listeners crit, SERVICES fixable from the truenas-down backup staleness. GOTCHAS: windowrules need hyprctl reload before first launch (panel opens tiled otherwise); pkill -f "maint panel" self-matches the invoking shell — anchor with "maint panel$". - -*** 2026-07-08 Wed @ 00:39:23 -0500 Built Phase 8 — GUI levers: digests, armed keys, rotary band, live strip (dotfiles 388521e) -The storage/snapshots/packages subpanels grew their evidence digests (disk hogs, per-drive SMART, device errors, snapper by-type, orphans KEEP/REMOVE, pacnew DELETE/MERGE, CVE advisories), packages got the E5 rotary band selector (ORPHANS/PACNEW/ADVISORIES), and the updates strip went live: state-tiered border, UPDATE/TOPGRADE behind the arm-to-override live-update guard, REBOOT offered when required or after an update lands. Every lever is arm-then-fire — first press shows the exact argv on the new act line (identical to =maint fix --dry-run=, the parity criterion), second press fires through =doctor.iter_fix= with the re-probe landing back on the act line; one key armed at a time, band/deck switch disarms; fixture boards always dry-run and never write curation, which is what lets the AT-SPI smoke exercise the full press-press path safely. Engine support: snapper probe emits =stale_singles= (DELETE STALE's candidates, newest kept), both scrub probes parse running-%, =doctor.pending_updates()= public for the arm-press guard read, fixtures regenerated with deterministic evidence. Review subagent (live-verified): Request Changes — 2 Important (refresh tiers raced a firing remedy: a 30s tick could probe the package DB mid-transaction and land stale data after the fire's own re-probe → tiers pause on =firing= + post-fire rebuild retries past an in-flight tick; digest builders KeyError'd on identity-less evidence rows replayed from cache files → skip-and-degrade, 8 new red-first tests) + 4 Minor (all taken: broadened strip-key except, scrub key offers first idle target instead of vanishing while any runs, busy-refused press no longer consumes the arm, fire-time guard refusal names the tripped set in GUI wording). Re-review: Approve. maint suites 447→516; full =make test= green (exit 0, unpiped); AT-SPI smoke PASS end-to-end on the bad fixture; headless-output screenshots vs E5; live board honest on ratio (real orphans w/ KEEP/REMOVE, 10.5 GB cache CLEAN, arm-press names the genuinely pending mesa set). Velox pulled to 388521e (no restow/TOML/windowrule changes). Scroll-position-on-arm check added to Manual testing. - -*** 2026-07-08 Wed @ 01:51:26 -0500 Built Phase 9 — GUI: systemd, logs, mem·pwr, network, services (dotfiles 9a2985f + a3da942) -Shipped in two gated halves. 9a (9a2985f): failed-unit + timer rosters (armed RESTART/RESET per unit; ENABLE only on present-but-inactive timers), logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, row levers VACUUM/CLEAR/RUN. 9b (a3da942): CPU-mode segmented control (current + "default" excluded — priv's closed verb refuses "default"), top-memory guarded KILL (session-critical disabled pre-press, arm refusal behind it, fire-time comm revalidation behind both), battery SET 80% gated on the sysfs knob (velox: unsupported → withheld, verified live), listeners curation (probe emits pid + owning unit from the cgroup v2 line; STOP for system.slice services, KILL otherwise, "?" watch-only), firewall ENABLE / tailscale UP / chrony STEP row levers, services deck (containers MARK EXPECTED + START, docker df, cron roster + backups w/ worst-of chip lamp). RECLAIM SPACE deferred to Phase 10's wall by design — the act line can't honestly render a 7-step stream. Reviews: 9a Request Changes (curation done-callbacks bypassed the busy-retry rebuild → routed through _refresh_after_fire) → Approve; 9b Request Changes (DEFAULT EPP dead control + cgroup hybrid-hierarchy, "?"-curation, cron chip lamp, unprobed ✓ minors — all fixed red-first) → Approve. maint 516→612 tests; make test 58 suites exit 0; AT-SPI smoke PASS across all 8 bands; headless screenshots vs E5; live envelopes verified both hosts. -**** 2026-07-08 Wed @ 01:13:48 -0500 Built 9a — systemd + logs subpanels (dotfiles 9a2985f) -Failed-unit roster (armed RESTART/RESET per unit, since/exit context, journalctl hint) + expected-timer roster (ENABLE only on present-but-inactive timers); logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, and row levers VACUUM / CLEAR / RUN. Review subagent (live-verified): Request Changes — 1 Important (curation done-callbacks called refresh_full, which silently no-ops while a tick refresh holds busy → a mid-tick write left a pre-write envelope on the board up to 30s while the act line claimed success; routed all four incl. Phase 8's keep through _refresh_after_fire) + minors taken (single _mark_token definition, three test gaps closed, docstring truthfulness, stale arm wording cleared on band/deck switch — my own find during screenshot eyeballing). Re-review: Approve. maint 516→562 tests; make test 58 suites exit 0 unpiped; AT-SPI smoke PASS incl. the new rosters/deck/mark path; headless screenshots vs E5. velox pulled (no restow). Accepted gap: KNOWN NOISE subsec lists the live curation table on a fixture board — fixture-sourced entries list is later polish. - -*** 2026-07-08 Wed @ 02:36:54 -0500 Built Phase 10 — results wall + doctor keys (dotfiles cc2fb5d) -Results wall shipped as the session action log: doctor events stream per-event (begin opens a running entry with the exact argv, ok/fail resolves in place, re-probe notes follow), 3.5-visible-row scroll cap with autoscroll, HIDE/COPY (COPY emits CLI-wall text), curation writes logged (fixture-suppressed as dim notes). CLEAN UP streams the auto tier with no arm press; REVIEW & FIX swaps the subpanel to the confirm-tier roster (item-less remedies get armed FIX keys, per-item ones point at their subpanel; review events carry item + worst severity). RECLAIM SPACE macro landed on the disk-usage row (deferred from 9b); macro arm line shows first step + remaining count; zero-step macros refuse by name. Review (live-verified): Request Changes — 1 Important: the roster's FIX for update/topgrade could never override the live-update guard (fire-time re-arm used the bare rid while the FIX key arms on "rid:", and _on_lever never passed force — an infinite arm→guard loop); fixed by threading the pressed token through _fire and sharing the strip's guard arming (_guard_arm_line + panel.guarded). Minors all taken: wall_abort resolves stranded running entries red when a stream dies, empty-macro IndexError → named RemedyError, test predicate tightened. Re-review: Approve, no residuals. maint 612→645; make test 59 suites exit 0 ×2; AT-SPI smoke extended (wall, HIDE/SHOW, CLEAN UP stream, review roster, 14-line RECLAIM stream) PASS; screenshots vs E5; LIVE verify on ratio: real CLEAN UP on the live board streamed "3 done" with real re-probes (coredumps warn 5, disk_usage ok 60). Velox pulled cc2fb5d. - -*** 2026-07-08 Wed @ 03:24:44 -0500 Built Phase 11 — waybar glyph + timers (dotfiles 10033be) -custom/maint replaced custom/sysmon: waybar-maint renders from the state file maint-scan.timer writes every 30 min; color tracks the worst diagnostic (lever-less) metric only, actionable findings never color the bar; missing/stale scan data dims to an honest unprobed state naming the timer. Battery hosts show live battery % + charging icon from sysfs on waybar's interval; low charge forces red. Left-click toggles the console (new maint-panel wrapper); right-click keeps the btop scratchpad. Bare =maint scan= became the glyph scan (envelope → state → RTMIN+12); maint-net-scan.timer drives =--net --slow= hourly. Retired same commit: waybar-sysmon, sysmon-cycle, both suites, #custom-sysmon CSS ×3 — plus unnamed collateral the grep caught: waybar-collapse's collapsed base set (maint glyph stays on battery hosts — it IS the battery display now) and a stale dotfiles todo. Review (live-verified): Approve with 4 Minor, all taken red-first — scope!=Device gate so a wireless mouse's battery can't masquerade as the machine's (shared capability.device_scope), unprobed diagnostics named in the tooltip instead of "all clear" under a dimmed glyph, comment truth, dead state field dropped; re-confirm clean (677 maint tests). Full make test exit 0; smoke PASS (first run's pipe masked a transient AT-SPI race — rerun unpiped clean). LIVE both hosts: ratio timer fired for real (glyph amber from genuine diagnostics: firmware update + journal errors; 11 actionable NOT coloring), velox pulled + one-time stow + battery 100% text w/ warning class. velox's user systemd manager found wedged (pre-existing) — timers enabled via manual symlinks + state seeded; Craig rebooting to clear. - -*** 2026-07-08 Wed @ 04:15:45 -0500 Built Phase 11b — prototype fidelity pass (dotfiles 3ee22a8) -Rebuilt the GUI presentation to the E5 instrument-card idiom. Each band renders a 4-column card grid: big-number readouts (viewmodel.card_spec registry — one builder per bespoke metric id, count-card fallback for the rest), cairo progress bars w/ threshold ticks (cache scaled to 1.5× the warn line), radial gauges (scrub cadence, keyring/topgrade age, temperatures — sweep scaled to the metric's own threshold), status chips (PASSED/FAILING/INACTIVE/YES; firewall chips the state word, exposure detail moves to the caption), and the CPU-mode segmented control promoted from digest section to card (current mode lit, alternatives as joined armed keys — same DIGEST_ONLY epp_set arm-then-fire). Evidence digests stay full-width below the grid. Selector became a two-row 4-col grid of wide tiles w/ count chips (3! · 7✓, crit reddens) + severity left borders; subpanel header carries the attention/ok/fixable/watch summary right-aligned beside a gold band title. The scoped NVMe wear dial was skipped honestly — no standalone wear metric exists (wear rides SMART evidence rows). Presentation only: engine/remedies/doctor/curation and the lever contracts unchanged. New 42-test suite (card kinds, threshold scales, running-scrub override, unprobed degrade incl. tick drop, seg exclusions, chips/subhead/band_counts, both-fixture sweep). Review (live-verified): Approve w/ 1 Important (pre-existing: chrony "not running" string crashed format_value's ms branch — fixed w/ card guard) + 4 Minor all taken red-first (disk None guard, _pct negative clamp, unprobed tick, dead row_detail/split_label/.maint-val removed); re-review Approve. make test 61 suites exit 0 ×2 unpiped; AT-SPI smoke 92 checks PASS (new gauge/chip/subhead assertions — DrawingAreas carry accessible_role=IMG + names so the smoke can see them); all 8 bands + review roster screenshotted on both fixtures + the live board against the settled headless-Chrome prototype render. Velox pulled (no restow). Gotcha pinned: a card body's vexpand propagates up a Gtk.Grid — a one-card band ballooned until grid.set_vexpand(False). - -*** 2026-07-08 Wed @ 05:05:20 -0500 Ran the granted /refactor sweep over the maint package (dotfiles a178470..73e9d94) -Four parallel read-only scans (complexity, duplication, dead-code, simplification) converged on the same hotspots; ten behavior-preserving commits applied and pushed. Headlines: six curation handlers folded into one _curation helper (~150 lines of repeated fixture/done plumbing, act/wall strings byte-identical), _on_lever/_on_strip_key merged into _press_lever, _on_fired split (_rearm_after_guard + _fire_summary), the "tool: not found / exit N" wording single-sited in cmd.why() across 20 probe sites, exact clones deduped (packages curation_entries, _parse_asctime, double VERSION), ten uniform digest sections folded into _evidence_digest, _card_body split per kind, listener key rule named, plus a small-simplifications batch. Deliberately skipped: format_value's flat chain (readable as-is), _journal_lines generalization (rc-semantics risk), test-panel-maint Makefile target (Phase 12 scope). Verification: suite parity exact pre/post (59 suites / 2490 tests, exit 0 unpiped), AT-SPI smoke PASS end-to-end, live ratio envelope 45 metrics zero unprobed, review subagent walked the full diff against 3ee22a8 and approved (no behavior change; accessible names untouched; its one latent-robustness minor pinned as an invariant comment). gui.py 1680→1605; net −103 lines. Velox pulled. - -*** 2026-07-08 Wed @ 05:46:02 -0500 Built Phase 12 — VM remedy scenarios + AT-SPI target (archsetup d6993d3, dotfiles 0636554) -Scenario orchestration landed as =run-maint-scenarios.sh= over the existing vm-utils.sh snapshot primitives: nine break → =maint fix= → assert scenarios in three groups (logs, packages, systemd), batched per Decision 11 (one boot per group, maint-ready snapshot restored between groups, clean-install restored at the end). =run-maint-nspawn.sh= is the fast lane — the packages group against a cached pacstrap rootfs in seconds. Plan layer is pure (=--list=, no KVM) with a 19-test unit suite; =make test-maint= wires it in. Dotfiles side: panel smoke now runs GOOD + BAD fixture passes (healthy render, badge tallies from the fixture, no-REBOOT, leak check) and =make test-panel-maint= is enumerated. Verified: full VM run 9/9 green from pristine snapshot ×2 (test-results/maint-20260708-054029), nspawn 4/4, smoke 100 checks exit 0, unit suite green under both FS_PROFILE values. Review subagent: Request Changes (FS_PROFILE env leak in the unit suite, journal-vacuum assert vacuous both directions) — all six findings fixed red-first, re-review Approve. FS_PROFILE=zfs scenarios: filtered but unexercised — the zfs base image fails to build (see the zfs DKMS task). - -*** 2026-07-08 Wed @ 06:18:58 -0500 Built Phase 13 — install wiring, workflow move, docs (archsetup bef7053 + 18c081f, dotfiles 9a3f0c7) -Installer: install_maintenance_config in user_customizations installs the shipped TOML to ~/.config/archsetup/ (re-run refreshes it, ~/.config/maint/ user layer never touched) and enables maint-scan/maint-net-scan timers via wants-symlinks (hyprland-only — the units live in that stow tier; no session bus during install, syncthing idiom); orchestrator + dispatch-branch tests pin the wiring. Deps swept from the probes' argv: expac, lm_sensors, fwupd added (arch-audit was already in from Phase 3; the rest ride base/archinstall or existing sections). maint/README.md in dotfiles covers user (CLI, panel, glyph, config layers, guard, privilege) + developer (module map, env seams, four test layers, add-a-metric recipe). system-health-check.org moved home → archsetup docs/workflows/ and rewired: TOML-authoritative severity section, threshold lines cite TOML keys (TOML wins on disagreement), inventory paths → docs/homelab-inventory/, maint-CLI fast-path note; only the four #+HOSTNAME: host inventories moved (+ the truenas specs asset they link) — the personal gear records stay in home per the domain split; discoverability via .ai/project-workflows symlink + notes.org entry; handoff note in home's inbox (2026-07-08-0607) lists home-side removals and flags strix-soak-watch as a candidate to move later. Verified: fresh-shell =maint status= green from installed paths on ratio; make test-unit exit 0; dotfiles make test exit 0; shellcheck count unchanged; review subagent Approve (2 Minor doc nits, both taken). - -*** 2026-07-08 Wed @ 06:18:58 -0500 Flipped the spec to IMPLEMENTED -Spec keyword DOING → IMPLEMENTED with a dated history line naming the closing commits (phases 1-13, dotfiles 43a39ac..9a3f0c7, archsetup d6993d3/bef7053/18c081f) and the Status mirror updated. Residuals tracked as their own tasks: manual-testing checklist, zfs base-image DKMS bug [#C], vNext [#D]. -** DONE [#B] Signal and known-noise counts disagree with the journal :bug:maint:solo: -CLOSED: [2026-07-09 Thu] -Not a counting bug — a semantics bug (dotfiles eded0c0). journal_errors grouped by identifier and summed lines, but displayed one sample message, and logs.py overwrote that sample on every iteration so it named the *last* error rather than the counted one. Live on ratio: systemd had 10 error lines this boot — 6 "Failed to start Emacs text editor." and 4 org-roam auto-sync — which is exactly the 10-vs-6. Now grouped by identifier + normalized message (pids, hex addresses, uuids, long integer runs blanked), so a row's count is that message's count, and each row's hint carries a =--grep= reproducing it (verified: 6 and 4). MARK KNOWN binds the volatile-free literal instead of the pid-bearing sample, so marking suppresses exactly what the row counted. The KNOWN NOISE headline counts matched lines while the deck lists patterns; the caption now names both units. -Logs > SIGNAL reports 10 journal errors and the signal item claims 10, but the group holds one item and the open journal shows it 6 times. KNOWN NOISE reports 19 against 12 visible entries. The counts are the point of the band, so a wrong number undermines the whole diagnostic. Find where the tally diverges from the rendered set — likely counting matched journal lines rather than distinct grouped entries, and counting curated entries against a different window than the one displayed. -** DONE [#B] Persist the CPU pill setting across reboot :feature:maint: -CLOSED: [2026-07-09 Thu] -The kernel resets energy_performance_preference to the driver default every boot. A successful epp_set now records the preference in the maint user config; maint-epp-restore.service replays it at login. No-op when nothing is remembered, the host lacks EPP, or the mode already matches. A hand-edited bogus value is refused by priv's closed verb. Installer enables the unit via the existing wants-symlink idiom. Verified live on ratio: unit enabled, ran clean, EPP untouched. (dotfiles a7f34bd, archsetup 430ef1a) - -The power/perf/balance pill sets EPP live, but the value reverts to its original setting after a reboot. Add a way to make a selection permanent. Needs a call on where persistence lives — a systemd unit, a udev rule, or a config file the maint package owns. -** DONE [#B] Preview the pacman + AUR update queue before upgrade :feature:maint:solo: -CLOSED: [2026-07-09 Thu] -A QUEUE key on the updates strip streams the pending pacman + AUR set onto the results wall, each package with its version move; `maint queue` prints the same in a terminal. The cap reports how many it dropped rather than truncating silently, and a cold cache says it has no pending set instead of rendering "nothing pending". Verified live against ratio's 105 pending packages. (dotfiles 8837a47) - -Show which pacman and AUR packages are queued before UPDATE or topgrade runs, so the armed live-update guard isn't the first place the package set becomes visible. -** DONE [#B] Audio PTT state desync across panel, waybar icon, and actual state :bug:audio:solo: -CLOSED: [2026-07-09 Thu] -Two state stores and two copies of the logic. The panel flipped an in-memory ptt_armed flag with its own enter/exit rules; the CLI toggle and the bar tag read a runtime state file. The file is now the only state and ptt.toggle_plan the only decision. The panel adopts the persisted state on every refresh, routes its toggle through the shared plan, and persists before signalling waybar (writing after would race the bar into re-rendering the pre-toggle class). Tests drive both entry points against one state across armed/disarmed x muted/live. (dotfiles 9524a44) - -The audio panel's push-to-talk, the waybar PTT icon, and the real PTT state disagree. All three must agree at all times. Cover every state transition with tests, not just the happy path — this is the second PTT bug after the pre-talk mute restore landed 2026-07-05 (dotfiles 1443b9e). -** DONE [#B] Publish the homelab inventory into the agent knowledge base :chore:docs: -CLOSED: [2026-07-09 Thu] -The four host inventories, the TrueNAS specs asset, and the ratio USB/xHCI record now live as roam nodes in =~/org/roam/hardware/=, linked from the "Homelab Hardware Inventory" index node. =docs/homelab-inventory/= is gone; roam is canonical. - -Carried rather than pointed, per home's argument, which beat mine. Pointing keeps one canonical copy but breaks the write path: a third project that discovers a durable hardware fact would have to write into archsetup's repo to record it, which the cross-project rules forbid, so the fact would sit in an inbox instead of on the device's page. Moving answers the canonical-copy worry without that cost. - -The =* Automated Capabilities= drawer moved with the pages. =system-health-check.org= now resolves a host's node by its =#+HOSTNAME:= keyword under =${ROAM_DIR:-$HOME/org/roam}/hardware/= — never by filename, since the timestamp prefix isn't stable. Verified: all four hosts resolve, an unknown host degrades to "NO INVENTORY FILE", and ratio's capability drawer still parses. A host without the roam clone (mybitch, truenas) takes the same no-inventory path it always did. - -The strix kernel watch stayed at =docs/workflows/strix-soak-watch.org= — it's a workflow, not an inventory page. -** DONE [#C] Optional ticking sound for the pomodoro :feature:timer: -CLOSED: [2026-07-09 Thu] -WTIMER_TICK=1 plays a quiet tick each second through a pomodoro's work phase; off by default, silent through rest (a tick there would undo the break), while paused, and while awaiting a manual advance. Rides the bar's existing once-a-second `wtimer render` call. Playback is fire-and-forget so it can never stall or break the bar. Sound is an 18ms 1.4kHz sine at a tenth of full scale (notify/tick.ogg); WTIMER_TICK_SOUND overrides. Craig still has to judge whether the tick sounds right. (dotfiles 886dafd) - -Timers module. Off by default; needs a sound choice and a volume decision. -** DONE [#C] Refresh indicator for the top-like items :feature:maint: -CLOSED: [2026-07-09 Thu] -A hairline under the top-memory section drains toward the next live refresh, so a slow board reads differently from a frozen one. Withheld on a fixture board, which never live-ticks — a countdown that never counts is worse than none. The fraction is a pure viewmodel function; only the cairo stroke lives in the GUI. (dotfiles 5d384d9) - -Show when the process/memory rows are about to refresh, so a stale-looking board is distinguishable from a frozen one. Shape is a design call — a countdown, a pulse, or a progress hairline. -** DONE [#C] CPU pill should not reposition on selection :bug:maint:quick:solo: -CLOSED: [2026-07-09 Thu] -epp_rows dropped the current preference from the key list, so the remaining segments slid sideways on every selection. Every preference now renders in a fixed order with the current one lit and unpressable in place, at full contrast rather than GTK's disabled dimming. The separate lit label is gone — the active segment is the lit one. Three separate tests encoded the old contract (unit, AT-SPI smoke, phase11b card); all moved with the behavior. The smoke caught a real gap the unit tests missed: the EPP row renders through the card path, which ignored the disabled flag. (dotfiles 5d384d9) +Shipped in dotfiles 0318a91. Both doctors stream: diagnose() emits each step as it completes (bt streams the first diagnosis only — the fix loop's re-diagnoses would replay the chain), and a repair's row goes up amber at attempt start and settles green/red with narration + evidence at completion, so the lamp blinks for the repair's real duration. The 3.5-entry height cap turned out to already be in both wells (it landed with the doctor expansions), so only the streaming half needed building. 5 new tests across net + bt; both suites green; AT-SPI smokes at parity with HEAD (one pre-existing state-word failure, filed separately). +Retrofit the net doctor (=~/.dotfiles/net/src/net/doctor.py=) and bluetooth doctor (=~/.dotfiles/bluetooth/src/bt/doctor.py=) to stream results as a live output wall — one lamp per escalation step, amber while running, green on success, red on failure — instead of a final summary. Matches the maintenance-console doctor design (see [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]], "Doctor = live output wall"). Goal: every doctor in the system reads the same way. Both doctors already step through an escalation chain re-probing after each, so the steps are natural lamp boundaries. -The perf/balance/power pill moves position depending on which option is selected. The segmented control should hold a fixed layout and mark the active segment in place. -** DONE [#C] Session identifier needs more width :bug:waybar:quick: -CLOSED: [2026-07-09 Thu] -Not waybar — the tmux status line. status-left-length was never set, so tmux clipped the drawn status-left to its default of 10 columns: "[aiv-archsetup] " came out "[aiv-archs", losing the closing bracket and the separating space, which is why the window list ran into it. Set to 40. Applied to the live tmux server too. (dotfiles dc3c27f) +Scope note (Craig, 2026-07-07): realtime lamp *behavior* only. The maintenance console's wider results-wall layout (date+time stamp column, COPY, persistent history) does NOT backport — the net/bt panels are ~400px wide and lack the horizontal real estate. Their existing output wells keep their compact layout; this task just makes them stream live. -The session section truncates; give it more characters, and keep the window sections that follow from running into it. Screenshot: [[file:/home/cjennings/pictures/screenshots/2026-07-07_145236.png][2026-07-07_145236.png]] +Addendum (Craig, 2026-07-07): DO backport the 3.5-entry height convention — every panel's output well caps at 3.5 visible entries, the half-visible entry being the scroll cue, with the dark slate-on-black scrollbar. Layout stays compact per above; only the height cap + scroll affordance carries over. +** DONE [#B] Absorb the clock-panel project into the dotfiles :feature:waybar:dotfiles: +CLOSED: [2026-07-18 Sat] +Absorbed into =~/.dotfiles= (commit 3fab11d): package =clock/src/clock/= (renamed from clock_panel), the six PNG watchface layers packaged inside the module at =clock/src/clock/assets/=, a stowed =clock-panel= shell shim (LD_PRELOADs gtk4-layer-shell), waybar left-click now =clock-panel toggle= with the absolute path dropped, tests converted pytest→unittest into =tests/clock/= plus an asset-load guard. Kept the layer-shell overlay and the socket toggle. The standalone repo is archived (ARCHIVED.md), kept for its design history. Verified live: the bar click renders the polished watchface. diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.20.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.20.png Binary files differnew file mode 100644 index 0000000..3133f55 --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.20.png diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.48.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.48.png Binary files differnew file mode 100644 index 0000000..7b3af65 --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.48.png diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.02.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.02.png Binary files differnew file mode 100644 index 0000000..c7b8eda --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.02.png diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.21.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.21.png Binary files differnew file mode 100644 index 0000000..4c84856 --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.21.png diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.18.11.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.18.11.png Binary files differnew file mode 100644 index 0000000..cdf701e --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.18.11.png diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.09.00.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.09.00.png Binary files differnew file mode 100644 index 0000000..9d55384 --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.09.00.png diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.12.27.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.12.27.png Binary files differnew file mode 100644 index 0000000..cd17101 --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.12.27.png diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.21.23.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.21.23.png Binary files differnew file mode 100644 index 0000000..d188fa9 --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.21.23.png diff --git a/working/retro-stereo-widgets/references/2021-cdm-cleanshot-1024x600.png b/working/retro-stereo-widgets/references/2021-cdm-cleanshot-1024x600.png Binary files differnew file mode 100644 index 0000000..6a967c9 --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-cleanshot-1024x600.png diff --git a/working/retro-stereo-widgets/references/2021-cdm-cleanshot-2.png b/working/retro-stereo-widgets/references/2021-cdm-cleanshot-2.png Binary files differnew file mode 100644 index 0000000..48e38fb --- /dev/null +++ b/working/retro-stereo-widgets/references/2021-cdm-cleanshot-2.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-battery-selector.png b/working/retro-stereo-widgets/references/2026-07-12-battery-selector.png Binary files differnew file mode 100644 index 0000000..400c82a --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-battery-selector.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-chrome-slot-fader.png b/working/retro-stereo-widgets/references/2026-07-12-chrome-slot-fader.png Binary files differnew file mode 100644 index 0000000..2251bf7 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-chrome-slot-fader.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-comfort-meter.png b/working/retro-stereo-widgets/references/2026-07-12-comfort-meter.png Binary files differnew file mode 100644 index 0000000..3e2de30 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-comfort-meter.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-daydate-discs.png b/working/retro-stereo-widgets/references/2026-07-12-daydate-discs.png Binary files differnew file mode 100644 index 0000000..f5402fd --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-daydate-discs.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-electric-telegraph-indicator.png b/working/retro-stereo-widgets/references/2026-07-12-electric-telegraph-indicator.png Binary files differnew file mode 100644 index 0000000..498f3c5 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-electric-telegraph-indicator.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-eq-drum-rollers.png b/working/retro-stereo-widgets/references/2026-07-12-eq-drum-rollers.png Binary files differnew file mode 100644 index 0000000..38034b5 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-eq-drum-rollers.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-four-way-rocker.png b/working/retro-stereo-widgets/references/2026-07-12-four-way-rocker.png Binary files differnew file mode 100644 index 0000000..fc71218 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-four-way-rocker.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-four-way-toggle.png b/working/retro-stereo-widgets/references/2026-07-12-four-way-toggle.png Binary files differnew file mode 100644 index 0000000..9e86013 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-four-way-toggle.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-guarded-toggles.png b/working/retro-stereo-widgets/references/2026-07-12-guarded-toggles.png Binary files differnew file mode 100644 index 0000000..fe76c9b --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-guarded-toggles.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-industrial-keypad.png b/working/retro-stereo-widgets/references/2026-07-12-industrial-keypad.png Binary files differnew file mode 100644 index 0000000..9a291fe --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-industrial-keypad.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-ki57-right-panel.jpg b/working/retro-stereo-widgets/references/2026-07-12-ki57-right-panel.jpg Binary files differnew file mode 100644 index 0000000..493231b --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-ki57-right-panel.jpg diff --git a/working/retro-stereo-widgets/references/2026-07-12-led-dot-matrix.png b/working/retro-stereo-widgets/references/2026-07-12-led-dot-matrix.png Binary files differnew file mode 100644 index 0000000..e76adc0 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-led-dot-matrix.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-lexicon-224.png b/working/retro-stereo-widgets/references/2026-07-12-lexicon-224.png Binary files differnew file mode 100644 index 0000000..6d0b117 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-lexicon-224.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-mechanical-timer-dial.png b/working/retro-stereo-widgets/references/2026-07-12-mechanical-timer-dial.png Binary files differnew file mode 100644 index 0000000..aed748d --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-mechanical-timer-dial.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png b/working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png Binary files differnew file mode 100644 index 0000000..cb0174f --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpg b/working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpg Binary files differnew file mode 100644 index 0000000..c1c0b14 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpg diff --git a/working/retro-stereo-widgets/references/2026-07-12-radar-ppi.png b/working/retro-stereo-widgets/references/2026-07-12-radar-ppi.png Binary files differnew file mode 100644 index 0000000..7d22420 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-radar-ppi.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-round-crt-scope.png b/working/retro-stereo-widgets/references/2026-07-12-round-crt-scope.png Binary files differnew file mode 100644 index 0000000..4ea6ea1 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-round-crt-scope.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-thumb-slide-attenuators.png b/working/retro-stereo-widgets/references/2026-07-12-thumb-slide-attenuators.png Binary files differnew file mode 100644 index 0000000..8cf2ea3 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-thumb-slide-attenuators.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-timing-marker-dial.png b/working/retro-stereo-widgets/references/2026-07-12-timing-marker-dial.png Binary files differnew file mode 100644 index 0000000..a2a35f0 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-timing-marker-dial.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-twin-trem-pedal.png b/working/retro-stereo-widgets/references/2026-07-12-twin-trem-pedal.png Binary files differnew file mode 100644 index 0000000..88c4829 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-twin-trem-pedal.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-variation-slide.png b/working/retro-stereo-widgets/references/2026-07-12-variation-slide.png Binary files differnew file mode 100644 index 0000000..e3cc5cd --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-variation-slide.png diff --git a/working/retro-stereo-widgets/references/2026-07-12-waves-alpha.png b/working/retro-stereo-widgets/references/2026-07-12-waves-alpha.png Binary files differnew file mode 100644 index 0000000..3adccf8 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-waves-alpha.png diff --git a/working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg b/working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg Binary files differnew file mode 100644 index 0000000..c97a9a9 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg diff --git a/working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp b/working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp Binary files differnew file mode 100644 index 0000000..0a9705a --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp diff --git a/working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-color.png b/working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-color.png Binary files differnew file mode 100644 index 0000000..c7cf6c8 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-color.png diff --git a/working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-fkeys.png b/working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-fkeys.png Binary files differnew file mode 100644 index 0000000..95ea12f --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-16-abc-keypad-membrane-fkeys.png diff --git a/working/retro-stereo-widgets/references/2026-07-16-abc-keypad-stainless.png b/working/retro-stereo-widgets/references/2026-07-16-abc-keypad-stainless.png Binary files differnew file mode 100644 index 0000000..4e2df87 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-16-abc-keypad-stainless.png diff --git a/working/retro-stereo-widgets/references/2026-07-16-letter-drum-bank.png b/working/retro-stereo-widgets/references/2026-07-16-letter-drum-bank.png Binary files differnew file mode 100644 index 0000000..1a55298 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-16-letter-drum-bank.png diff --git a/working/retro-stereo-widgets/references/2026-07-16-mignon-4-index-typewriter.jpg b/working/retro-stereo-widgets/references/2026-07-16-mignon-4-index-typewriter.jpg Binary files differnew file mode 100644 index 0000000..f0dee72 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-16-mignon-4-index-typewriter.jpg diff --git a/working/retro-stereo-widgets/references/2026-07-16-mignon-CREDITS.txt b/working/retro-stereo-widgets/references/2026-07-16-mignon-CREDITS.txt new file mode 100644 index 0000000..7e53ad8 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-16-mignon-CREDITS.txt @@ -0,0 +1,9 @@ +2026-07-16-mignon-4-index-typewriter.jpg + Source: https://commons.wikimedia.org/wiki/File:Mignon_4,_index_typewriter.jpg + Licence: Public domain + +2026-07-16-mignon-aeg-detail.jpg + Source: https://commons.wikimedia.org/wiki/File:AEG-MIGNON-typewriter-02.jpg + Credit: Photo by CEphoto, Uwe Aranas + Licence: CC BY-SA 3.0 — attribution required if this image is reproduced. + Reference only; the widget is drawn from scratch, not traced. diff --git a/working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg b/working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg Binary files differnew file mode 100644 index 0000000..1ee47cd --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg |
