aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/2026-06-10-tool-evaluation-criteria.org2
-rw-r--r--docs/design/2026-06-29-waybar-timer-module-spec.org11
-rw-r--r--docs/design/2026-06-29-zfs-pre-snapshot-installer.org8
-rw-r--r--docs/design/2026-07-10-net-bt-failure-taxonomy.org434
-rw-r--r--docs/design/2026-07-12-control-grammars-reference.org196
-rw-r--r--docs/design/2026-07-14-undeclared-packages-evidence.org84
-rw-r--r--docs/design/system-monitor-design-ideas.org2
-rw-r--r--docs/prototypes/2026-07-02-desktop-settings-panel-prototype-1.html88
-rw-r--r--docs/prototypes/2026-07-02-desktop-settings-panel-prototype-2.html99
-rw-r--r--docs/prototypes/2026-07-02-desktop-settings-panel-prototype-3.html130
-rw-r--r--docs/prototypes/2026-07-02-desktop-settings-panel-prototype-4.html166
-rw-r--r--docs/prototypes/2026-07-02-desktop-settings-panel-prototype-5.html149
-rw-r--r--docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html338
-rw-r--r--docs/prototypes/README.org120
-rw-r--r--docs/prototypes/desktop-settings-shared.css358
-rw-r--r--docs/prototypes/desktop-settings-shared.js261
-rw-r--r--docs/prototypes/gallery-tokens.el41
-rw-r--r--docs/prototypes/gallery-widget.el166
-rw-r--r--docs/prototypes/gen_tokens.py184
-rw-r--r--docs/prototypes/panel-widget-gallery.html1854
-rw-r--r--docs/prototypes/reference/slide-toggle.jpgbin0 -> 8023 bytes
-rw-r--r--docs/prototypes/tokens-waybar.css34
-rw-r--r--docs/prototypes/tokens.json46
-rw-r--r--docs/prototypes/waybar-redesign-prototype.html853
-rw-r--r--docs/prototypes/widgets.js6142
-rw-r--r--docs/specs/2026-07-02-desktop-settings-panel-spec.org107
-rw-r--r--docs/specs/2026-07-02-timer-panel-spec.org2
-rw-r--r--docs/specs/2026-07-07-maintenance-console-spec.org8
-rw-r--r--docs/specs/2026-07-10-audio-doctor-input-side-spec.org33
-rw-r--r--docs/specs/2026-07-11-bt-doctor-expansion-spec.org251
-rw-r--r--docs/specs/2026-07-11-net-doctor-expansion-spec.org267
-rw-r--r--docs/specs/2026-07-12-component-generation-spec.org207
-rw-r--r--docs/specs/2026-07-19-display-format-single-source-of-truth-spec.org63
33 files changed, 12318 insertions, 386 deletions
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-02-desktop-settings-panel-prototype-1.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-1.html
new file mode 100644
index 0000000..e57d13e
--- /dev/null
+++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-1.html
@@ -0,0 +1,88 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Desktop Settings — Prototype 1 (tile grid)</title>
+<link rel="stylesheet" href="desktop-settings-shared.css">
+</head>
+<body class="ds-stage">
+ <div class="ds-caption">
+ <b>Direction 1 — Tile grid.</b> The quick-settings idiom (Android / GNOME 43):
+ scenes on top, a 2-column tile grid for toggles, sliders and power below.
+ Fast to scan, one tap per control, the wallpaper tile opens a sub-view.
+ </div>
+
+ <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div>
+
+ <div class="ds-panel" id="panel">
+ <div class="ds-face">
+ <span class="glyph">⚙</span>
+ <span class="title">Settings</span>
+ <span class="sub" id="scene-sub"></span>
+ <span class="x">✕</span>
+ </div>
+ <div id="body"></div>
+ <div class="ds-toast" id="toast"></div>
+ <div class="ds-subview" id="subview"></div>
+ </div>
+
+<script src="desktop-settings-shared.js"></script>
+<script>
+const body = document.getElementById("body");
+const subview = document.getElementById("subview");
+DS.bindToast(document.getElementById("toast"));
+
+function openWallpaper() {
+ subview.innerHTML = "";
+ subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open")));
+ subview.classList.add("open");
+}
+
+function render() {
+ body.innerHTML = "";
+ const col = DS.h("div", "ds-col");
+
+ // Scenes strip
+ col.appendChild(DS.engrave("Scenes"));
+ const scenes = DS.h("div", "ds-grid4");
+ Object.keys(DS.SCENES).forEach((n) => scenes.appendChild(DS.sceneKey(n)));
+ col.appendChild(scenes);
+
+ // Toggle tiles
+ col.appendChild(DS.engrave("Toggles"));
+ const grid = DS.h("div", "ds-grid2");
+ ["autodim", "caffeine", "touchpad", "mouse", "nightlight", "dnd"].forEach((k) =>
+ grid.appendChild(DS.toggleTile(k)));
+ if (DS.state.laptop) grid.appendChild(DS.toggleTile("airplane"));
+ col.appendChild(grid);
+
+ // Sliders
+ col.appendChild(DS.engrave("Levels"));
+ const levels = DS.h("div", "ds-well");
+ levels.appendChild(DS.slider("brightness"));
+ levels.appendChild(DS.slider("kbd"));
+ col.appendChild(levels);
+
+ // Power profile
+ col.appendChild(DS.engrave("Power"));
+ col.appendChild(DS.profileSeg());
+
+ // Actions + wallpaper entry
+ col.appendChild(DS.engrave("Actions"));
+ const acts = DS.h("div", "ds-grid3");
+ acts.appendChild(DS.actionBtn("Lock", "\u{f033e}", false));
+ acts.appendChild(DS.actionBtn("Suspend", "\u{f04b2}", false));
+ const wp = DS.actionBtn("Wallpaper", "\u{f021c}", false, openWallpaper);
+ acts.appendChild(wp);
+ col.appendChild(acts);
+
+ body.appendChild(col);
+ document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : "";
+}
+
+DS.onChange(render);
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-2.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-2.html
new file mode 100644
index 0000000..af2160c
--- /dev/null
+++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-2.html
@@ -0,0 +1,99 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Desktop Settings — Prototype 2 (sectioned rack)</title>
+<link rel="stylesheet" href="desktop-settings-shared.css">
+</head>
+<body class="ds-stage">
+ <div class="ds-caption">
+ <b>Direction 2 — Sectioned rack.</b> Full-width console keys stacked under
+ engraved section labels, closest to the net/bt panel idiom. Densest and most
+ scannable; every control is one row you read top to bottom.
+ </div>
+
+ <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div>
+
+ <div class="ds-panel" id="panel">
+ <div class="ds-face">
+ <span class="glyph">⚙</span>
+ <span class="title">Settings</span>
+ <span class="sub" id="scene-sub"></span>
+ <span class="x">✕</span>
+ </div>
+ <div id="body"></div>
+ <div class="ds-toast" id="toast"></div>
+ <div class="ds-subview" id="subview"></div>
+ </div>
+
+<script src="desktop-settings-shared.js"></script>
+<script>
+const body = document.getElementById("body");
+const subview = document.getElementById("subview");
+DS.bindToast(document.getElementById("toast"));
+
+function openWallpaper() {
+ subview.innerHTML = "";
+ subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open")));
+ subview.classList.add("open");
+}
+
+function section(label, nodes) {
+ const wrap = DS.h("div", "ds-col");
+ wrap.appendChild(DS.engrave(label));
+ nodes.forEach((n) => wrap.appendChild(n));
+ return wrap;
+}
+
+// A wallpaper entry row styled like a console key that opens the sub-view.
+function wallpaperRow() {
+ const el = DS.h("div", "ds-key");
+ el.innerHTML =
+ `<span class="ds-lamp off"></span><span class="k-glyph">\u{f021c}</span>` +
+ `<span class="k-name">Wallpaper</span><span class="k-state">manage ›</span>`;
+ el.onclick = openWallpaper;
+ return el;
+}
+
+function render() {
+ body.innerHTML = "";
+ const col = DS.h("div", "ds-col");
+
+ col.appendChild(section("Display", [
+ DS.toggleKey("autodim"),
+ DS.toggleKey("nightlight"),
+ (() => { const w = DS.h("div", "ds-well"); w.appendChild(DS.slider("brightness")); w.appendChild(DS.slider("kbd")); return w; })(),
+ ]));
+
+ col.appendChild(section("Input", [
+ DS.toggleKey("touchpad"),
+ DS.toggleKey("mouse"),
+ ]));
+
+ const powerNodes = [];
+ if (DS.state.laptop) powerNodes.push(DS.toggleKey("airplane"));
+ powerNodes.push(DS.profileSeg());
+ const acts = DS.h("div", "ds-grid2");
+ acts.appendChild(DS.actionBtn("Lock", "\u{f033e}", false));
+ acts.appendChild(DS.actionBtn("Suspend", "\u{f04b2}", false));
+ powerNodes.push(acts);
+ col.appendChild(section("Power", powerNodes));
+
+ col.appendChild(section("Notifications", [DS.toggleKey("dnd")]));
+
+ const scenes = DS.h("div", "ds-grid4");
+ Object.keys(DS.SCENES).forEach((n) => scenes.appendChild(DS.sceneKey(n)));
+ col.appendChild(section("Scenes", [scenes]));
+
+ col.appendChild(section("Wallpaper", [wallpaperRow()]));
+
+ body.appendChild(col);
+ document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : "";
+}
+
+DS.onChange(render);
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-3.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-3.html
new file mode 100644
index 0000000..8c383f1
--- /dev/null
+++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-3.html
@@ -0,0 +1,130 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Desktop Settings — Prototype 3 (scenes-first)</title>
+<link rel="stylesheet" href="desktop-settings-shared.css">
+<style>
+ /* Scenes-first: the hero scene grid + the active-scene readout. */
+ .ds-scene.hero { min-height: 74px; padding: 12px 8px; }
+ .ds-scene.hero .sc-glyph { font-size: 24px; }
+ .ds-scene.hero .sc-name { font-size: 12.5px; }
+ .scene-readout {
+ background: var(--well); border: 1px solid var(--line); border-radius: 10px;
+ padding: 8px 11px; font-size: 11.5px; color: var(--steel); min-height: 20px;
+ }
+ .scene-readout b { color: var(--gold); font-weight: 700; }
+ .scene-readout .sets { color: var(--silver); }
+ .manual-head { display: flex; align-items: center; gap: 8px; }
+ .manual-head .hint { color: var(--dim); font-size: 10px; margin-left: auto; }
+</style>
+</head>
+<body class="ds-stage">
+ <div class="ds-caption">
+ <b>Direction 3 — Scenes-first.</b> The scene selector is the hero: one tap sets
+ the whole desktop (the iOS-Focus idea). Individual controls sit below as the
+ manual-override layer, and touching one drops you out of the active scene.
+ </div>
+
+ <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div>
+
+ <div class="ds-panel" id="panel">
+ <div class="ds-face">
+ <span class="glyph">⚙</span>
+ <span class="title">Settings</span>
+ <span class="sub" id="scene-sub"></span>
+ <span class="x">✕</span>
+ </div>
+ <div id="body"></div>
+ <div class="ds-toast" id="toast"></div>
+ <div class="ds-subview" id="subview"></div>
+ </div>
+
+<script src="desktop-settings-shared.js"></script>
+<script>
+const body = document.getElementById("body");
+const subview = document.getElementById("subview");
+DS.bindToast(document.getElementById("toast"));
+
+const NAMEOF = {
+ autodim: "auto-dim", caffeine: "caffeine", touchpad: "touchpad", mouse: "mouse",
+ airplane: "airplane", nightlight: "night light", dnd: "DND",
+};
+function sceneSummary(name) {
+ const sets = DS.SCENES[name].sets;
+ const parts = Object.entries(sets).map(([k, v]) => {
+ if (k === "powerprofile") return v;
+ return (v ? "" : "no ") + (NAMEOF[k] || k);
+ });
+ return parts.join(", ");
+}
+
+function openWallpaper() {
+ subview.innerHTML = "";
+ subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open")));
+ subview.classList.add("open");
+}
+
+function render() {
+ body.innerHTML = "";
+ const col = DS.h("div", "ds-col");
+
+ // Hero scenes
+ col.appendChild(DS.engrave("Scene"));
+ const grid = DS.h("div", "ds-grid2");
+ Object.keys(DS.SCENES).forEach((n) => {
+ const k = DS.sceneKey(n);
+ k.classList.add("hero");
+ k.addEventListener("mouseenter", () => setReadout(n));
+ grid.appendChild(k);
+ });
+ col.appendChild(grid);
+
+ const readout = DS.h("div", "scene-readout");
+ readout.id = "readout";
+ col.appendChild(readout);
+
+ // Manual override layer
+ const mh = DS.h("div", "manual-head");
+ mh.appendChild(DS.engrave("Manual"));
+ const hint = DS.h("span", "hint", "overrides the scene");
+ mh.appendChild(hint);
+ col.appendChild(mh);
+
+ const grid2 = DS.h("div", "ds-grid2");
+ ["autodim", "caffeine", "touchpad", "mouse", "nightlight", "dnd"].forEach((key) =>
+ grid2.appendChild(DS.toggleTile(key)));
+ if (DS.state.laptop) grid2.appendChild(DS.toggleTile("airplane"));
+ col.appendChild(grid2);
+
+ const levels = DS.h("div", "ds-well");
+ levels.appendChild(DS.slider("brightness"));
+ levels.appendChild(DS.slider("kbd"));
+ col.appendChild(levels);
+
+ col.appendChild(DS.profileSeg());
+
+ const acts = DS.h("div", "ds-grid3");
+ acts.appendChild(DS.actionBtn("Lock", "\u{f033e}", false));
+ acts.appendChild(DS.actionBtn("Suspend", "\u{f04b2}", false));
+ acts.appendChild(DS.actionBtn("Wallpaper", "\u{f021c}", false, openWallpaper));
+ col.appendChild(acts);
+
+ body.appendChild(col);
+ setReadout(DS.state.scene);
+ document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : "";
+}
+
+function setReadout(name) {
+ const r = document.getElementById("readout");
+ if (!r) return;
+ if (!name) { r.innerHTML = `<span>No scene active — controls are manual.</span>`; return; }
+ r.innerHTML = `<b>${name}</b> — <span class="sets">${sceneSummary(name)}</span>`;
+}
+
+DS.onChange(render);
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-4.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-4.html
new file mode 100644
index 0000000..23d72fb
--- /dev/null
+++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-4.html
@@ -0,0 +1,166 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Desktop Settings — Prototype 4 (instrument cluster)</title>
+<link rel="stylesheet" href="desktop-settings-shared.css">
+<style>
+ /* Instrument cluster: ring gauges for the levels, a rotary deck for power. */
+ .gauge-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+ .gauge {
+ background: var(--well); border: 1px solid var(--line); border-radius: 12px;
+ padding: 10px; display: flex; flex-direction: column; align-items: center; gap: 6px;
+ }
+ .g-ring {
+ width: 92px; height: 92px; border-radius: 50%;
+ display: flex; align-items: center; justify-content: center;
+ position: relative;
+ }
+ .g-ring::before {
+ content: ""; position: absolute; inset: 9px; border-radius: 50%;
+ background: var(--ground); border: 1px solid var(--plate-edge);
+ }
+ .g-val { position: relative; z-index: 1; color: var(--cream); font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
+ .g-val .u { color: var(--dim); font-size: 11px; }
+ .g-name { color: var(--steel); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; }
+ .g-fader { width: 100%; }
+ .g-fader input[type=range] {
+ -webkit-appearance:none; appearance:none; width: 100%; height: 5px; border-radius: 5px;
+ background: var(--ground); border: 1px solid var(--line); outline: none;
+ }
+ .g-fader input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance:none; width: 12px; height: 12px; border-radius: 50%;
+ background: var(--cream); border: 1px solid var(--gold); cursor: pointer;
+ }
+ .rotary { display: flex; align-items: center; gap: 10px; background: var(--well); border: 1px solid var(--line); border-radius: 12px; padding: 8px 12px; }
+ .rotary .dial { width: 40px; height: 40px; border-radius: 50%; border: 2px solid var(--key-edge); position: relative; background: linear-gradient(180deg,#23211e,#191715); }
+ .rotary .dial .tick { position: absolute; top: 3px; left: 50%; width: 2px; height: 12px; background: var(--gold); transform-origin: 50% 17px; }
+ .rotary .opts { display: flex; gap: 6px; flex: 1; }
+ .rotary .opt { flex: 1; text-align: center; font-size: 11px; color: var(--dim); cursor: pointer; padding: 4px 2px; border-radius: 6px; }
+ .rotary .opt.cur { color: var(--gold); font-weight: 700; }
+ .rotary .opt:hover { color: var(--silver); }
+</style>
+</head>
+<body class="ds-stage">
+ <div class="ds-caption">
+ <b>Direction 4 — Instrument cluster.</b> The most literal control panel:
+ brightness and keyboard read as needle-ring gauges, power profile is a rotary
+ selector, toggles are lamp rows. Leans hardest into the faceplate aesthetic.
+ </div>
+
+ <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div>
+
+ <div class="ds-panel" id="panel">
+ <div class="ds-face">
+ <span class="glyph">⚙</span>
+ <span class="title">Settings</span>
+ <span class="sub" id="scene-sub"></span>
+ <span class="x">✕</span>
+ </div>
+ <div id="body"></div>
+ <div class="ds-toast" id="toast"></div>
+ <div class="ds-subview" id="subview"></div>
+ </div>
+
+<script src="desktop-settings-shared.js"></script>
+<script>
+const h = DS.h;
+const body = document.getElementById("body");
+const subview = document.getElementById("subview");
+DS.bindToast(document.getElementById("toast"));
+
+function openWallpaper() {
+ subview.innerHTML = "";
+ subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open")));
+ subview.classList.add("open");
+}
+
+function gauge(key) {
+ const meta = DS.SLIDERS[key];
+ const v = DS.state[key];
+ const g = h("div", "gauge");
+ const ring = h("div", "g-ring");
+ ring.style.background = `conic-gradient(var(--gold) ${v * 3.6}deg, #1a1917 0)`;
+ ring.innerHTML = `<span class="g-val">${v}<span class="u">%</span></span>`;
+ g.appendChild(ring);
+ g.appendChild(h("div", "g-name", meta.name));
+ const fw = h("div", "g-fader");
+ const inp = h("input"); inp.type = "range"; inp.min = meta.floor; inp.max = 100; inp.value = v;
+ inp.oninput = () => { ring.style.background = `conic-gradient(var(--gold) ${inp.value*3.6}deg, #1a1917 0)`; ring.querySelector(".g-val").innerHTML = `${inp.value}<span class="u">%</span>`; };
+ inp.onchange = () => DS.setSlider(key, +inp.value, g);
+ fw.appendChild(inp); g.appendChild(fw);
+ return g;
+}
+
+function rotary() {
+ const r = h("div", "rotary");
+ const idx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile);
+ const dial = h("div", "dial");
+ const angle = -50 + idx * 50; // three positions
+ dial.innerHTML = `<span class="tick" style="transform:translateX(-50%) rotate(${angle}deg)"></span>`;
+ r.appendChild(dial);
+ const opts = h("div", "opts");
+ DS.PROFILES.forEach((p) => {
+ const o = h("div", "opt" + (p.id === DS.state.powerprofile ? " cur" : ""), p.label);
+ o.onclick = () => DS.setProfile(p.id, r);
+ opts.appendChild(o);
+ });
+ r.appendChild(opts);
+ return r;
+}
+
+function lampRow(key) {
+ const meta = DS.TOGGLES[key];
+ const on = DS.state[key];
+ const el = h("div", "ds-key" + (on ? " on" : ""));
+ el.innerHTML =
+ `<span class="ds-lamp ${on ? "gold" : "off"}"></span>` +
+ `<span class="k-glyph">${meta.glyph}</span>` +
+ `<span class="k-name">${meta.name}</span>` +
+ `<span class="k-state">${on ? meta.on : meta.off}</span>`;
+ el.onclick = () => DS.setToggle(key, el);
+ return el;
+}
+
+function render() {
+ body.innerHTML = "";
+ const col = h("div", "ds-col");
+
+ col.appendChild(DS.engrave("Levels"));
+ const gr = h("div", "gauge-row");
+ gr.appendChild(gauge("brightness"));
+ gr.appendChild(gauge("kbd"));
+ col.appendChild(gr);
+
+ col.appendChild(DS.engrave("Power profile"));
+ col.appendChild(rotary());
+
+ col.appendChild(DS.engrave("Toggles"));
+ const well = h("div", "ds-well ds-col");
+ ["autodim", "nightlight", "touchpad", "mouse", "dnd"].forEach((k) => well.appendChild(lampRow(k)));
+ if (DS.state.laptop) well.appendChild(lampRow("airplane"));
+ well.appendChild(lampRow("caffeine"));
+ col.appendChild(well);
+
+ col.appendChild(DS.engrave("Scenes"));
+ const scenes = h("div", "ds-grid4");
+ Object.keys(DS.SCENES).forEach((n) => scenes.appendChild(DS.sceneKey(n)));
+ col.appendChild(scenes);
+
+ col.appendChild(DS.engrave("Actions"));
+ const acts = h("div", "ds-grid3");
+ acts.appendChild(DS.actionBtn("Lock", "\u{f033e}", false));
+ acts.appendChild(DS.actionBtn("Suspend", "\u{f04b2}", false));
+ acts.appendChild(DS.actionBtn("Wallpaper", "\u{f021c}", false, openWallpaper));
+ col.appendChild(acts);
+
+ body.appendChild(col);
+ document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : "";
+}
+
+DS.onChange(render);
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-5.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-5.html
new file mode 100644
index 0000000..662e9f6
--- /dev/null
+++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-5.html
@@ -0,0 +1,149 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Desktop Settings — Prototype 5 (compact expandable rows)</title>
+<link rel="stylesheet" href="desktop-settings-shared.css">
+<style>
+ /* Compact rows: one dense column; rows with depth expand inline. */
+ .crow {
+ display: flex; align-items: center; gap: 10px;
+ padding: 8px 8px; border-radius: 8px; cursor: pointer;
+ border: 1px solid transparent;
+ }
+ .crow:hover { background: #16191b; }
+ .crow .glyph { color: var(--steel); font-size: 15px; width: 18px; text-align: center; }
+ .crow.on .glyph { color: var(--gold); }
+ .crow .name { color: var(--silver); font-size: 13px; }
+ .crow.on .name { color: var(--cream); }
+ .crow .state { margin-left: auto; color: var(--dim); font-size: 11px; }
+ .crow.on .state { color: var(--gold); }
+ .crow .chev { color: var(--dim); font-size: 12px; margin-left: auto; transition: transform .15s; }
+ .crow.exp .chev { transform: rotate(90deg); color: var(--gold); }
+ .crow.verified { animation: dsVerify 0.9s ease-out; }
+ .reveal { overflow: hidden; max-height: 0; transition: max-height .2s ease; }
+ .reveal.open { max-height: 260px; }
+ .reveal-inner { padding: 6px 6px 10px 36px; }
+ .sep { height: 1px; background: var(--line); margin: 3px 6px; }
+ .inline-slider { padding: 4px 8px 4px 0; }
+</style>
+</head>
+<body class="ds-stage">
+ <div class="ds-caption">
+ <b>Direction 5 — Compact expandable rows.</b> Minimal by default: one slim row
+ per control in a single column. Rows with depth (power, scenes, wallpaper)
+ expand inline — progressive disclosure, the long-press pattern made visible.
+ </div>
+
+ <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div>
+
+ <div class="ds-panel" id="panel">
+ <div class="ds-face">
+ <span class="glyph">⚙</span>
+ <span class="title">Settings</span>
+ <span class="sub" id="scene-sub"></span>
+ <span class="x">✕</span>
+ </div>
+ <div id="body"></div>
+ <div class="ds-toast" id="toast"></div>
+ <div class="ds-subview" id="subview"></div>
+ </div>
+
+<script src="desktop-settings-shared.js"></script>
+<script>
+const h = DS.h;
+const body = document.getElementById("body");
+const subview = document.getElementById("subview");
+DS.bindToast(document.getElementById("toast"));
+
+function openWallpaper() {
+ subview.innerHTML = "";
+ subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open")));
+ subview.classList.add("open");
+}
+
+function toggleRow(key) {
+ const meta = DS.TOGGLES[key];
+ const on = DS.state[key];
+ const el = h("div", "crow" + (on ? " on" : ""));
+ el.innerHTML =
+ `<span class="ds-lamp ${on ? "gold" : "off"}"></span>` +
+ `<span class="glyph">${meta.glyph}</span>` +
+ `<span class="name">${meta.name}</span>` +
+ `<span class="state">${on ? meta.on : meta.off}</span>`;
+ el.onclick = () => DS.setToggle(key, el);
+ return el;
+}
+
+function sliderRow(key) {
+ const meta = DS.SLIDERS[key];
+ const el = h("div", "crow inline-slider");
+ el.style.cursor = "default";
+ el.appendChild(Object.assign(h("span", "glyph"), { textContent: meta.glyph }));
+ el.appendChild(Object.assign(h("span", "name"), { textContent: meta.name }));
+ const s = DS.slider(key);
+ s.style.flex = "1";
+ // strip the redundant leading glyph/name from the shared slider
+ s.removeChild(s.children[0]); s.removeChild(s.children[0]);
+ el.appendChild(s);
+ return el;
+}
+
+// A row that expands an inline panel (built by contentFn) below it.
+function expandRow(glyph, name, stateText, contentFn) {
+ const wrap = h("div");
+ const row = h("div", "crow");
+ row.innerHTML =
+ `<span class="ds-lamp off"></span><span class="glyph">${glyph}</span>` +
+ `<span class="name">${name}</span><span class="state">${stateText || ""}</span><span class="chev">›</span>`;
+ const rev = h("div", "reveal");
+ const inner = h("div", "reveal-inner");
+ inner.appendChild(contentFn());
+ rev.appendChild(inner);
+ row.onclick = () => { row.classList.toggle("exp"); rev.classList.toggle("open"); };
+ wrap.appendChild(row); wrap.appendChild(rev);
+ return wrap;
+}
+
+function render() {
+ body.innerHTML = "";
+ const col = h("div");
+
+ ["autodim", "nightlight"].forEach((k) => col.appendChild(toggleRow(k)));
+ col.appendChild(sliderRow("brightness"));
+ col.appendChild(sliderRow("kbd"));
+ col.appendChild(h("div", "sep"));
+
+ ["touchpad", "mouse"].forEach((k) => col.appendChild(toggleRow(k)));
+ if (DS.state.laptop) col.appendChild(toggleRow("airplane"));
+ col.appendChild(toggleRow("dnd"));
+ col.appendChild(h("div", "sep"));
+
+ col.appendChild(expandRow("\u{f0241}", "Power profile", DS.state.powerprofile, () => DS.profileSeg()));
+ col.appendChild(expandRow("\u{f0210}", "Scenes", DS.state.scene || "none", () => {
+ const g = h("div", "ds-grid4");
+ Object.keys(DS.SCENES).forEach((n) => g.appendChild(DS.sceneKey(n)));
+ return g;
+ }));
+
+ const wp = h("div", "crow");
+ wp.innerHTML = `<span class="ds-lamp off"></span><span class="glyph">\u{f021c}</span><span class="name">Wallpaper</span><span class="state">manage ›</span>`;
+ wp.onclick = openWallpaper;
+ col.appendChild(wp);
+ col.appendChild(h("div", "sep"));
+
+ const acts = h("div", "ds-grid2");
+ acts.appendChild(DS.actionBtn("Lock", "\u{f033e}", false));
+ acts.appendChild(DS.actionBtn("Suspend", "\u{f04b2}", false));
+ col.appendChild(acts);
+
+ body.appendChild(col);
+ document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : "";
+}
+
+DS.onChange(render);
+render();
+</script>
+</body>
+</html>
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 &amp;
- gauges</b> show a live analog value; <b>indicators &amp; 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 &amp; gauges — live analog value</h2>
-<div class="grid" id="meters"></div>
-
-<h2>Indicators &amp; 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/desktop-settings-shared.css b/docs/prototypes/desktop-settings-shared.css
new file mode 100644
index 0000000..95aec22
--- /dev/null
+++ b/docs/prototypes/desktop-settings-shared.css
@@ -0,0 +1,358 @@
+/* desktop-settings-shared.css — the shared engine styling for every
+ * desktop-settings panel prototype. Translates the dupre instrument-console
+ * palette (themes/dupre/panel.css) into browser-friendly component classes so
+ * all five direction prototypes read as one system and port near-1:1 to svg.el.
+ *
+ * Palette: warm near-black ground, gold border/accent, slate blue selection,
+ * silver/cream text, olive pass, terracotta fail. */
+
+:root {
+ --ground: #100f0f;
+ --well: #0a0c0d;
+ --well2: #101214;
+ --plate: #1a1917;
+ --plate-edge: #262320;
+ --gold: #dab53d;
+ --gold-bright: #f0d879;
+ --slate: #424f5e;
+ --slate-hi: #54677d;
+ --silver: #bfc4d0;
+ --cream: #f3e7c5;
+ --steel: #969385;
+ --dim: #7c838a;
+ --line: #2c2f32;
+ --pass: #74932f;
+ --fail: #cb6b4d;
+ --key-top: #23211e;
+ --key-bot: #191715;
+ --key-edge: #33302b;
+ --mono: "BerkeleyMono Nerd Font", "JetBrains Mono", "DejaVu Sans Mono", ui-monospace, monospace;
+}
+
+* { box-sizing: border-box; }
+
+body.ds-stage {
+ margin: 0;
+ min-height: 100vh;
+ background:
+ radial-gradient(1200px 600px at 80% -10%, #17151322, transparent),
+ #050505;
+ color: var(--silver);
+ font-family: var(--mono);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 14px;
+ padding: 28px 16px 48px;
+}
+
+/* A caption above each prototype naming the direction (prototype chrome only,
+ not part of the panel). */
+.ds-caption {
+ color: var(--dim);
+ font-size: 12px;
+ letter-spacing: 0.04em;
+ text-align: center;
+ max-width: 360px;
+}
+.ds-caption b { color: var(--gold); font-weight: 700; }
+
+/* The bar stub the panel drops from, so the prototype shows the anchor. */
+.ds-bar {
+ width: 360px;
+ height: 30px;
+ border-radius: 8px;
+ background: linear-gradient(180deg, #17151388, #0d0c0b);
+ border: 1px solid #201d17;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ padding: 0 12px;
+ color: var(--steel);
+ font-size: 13px;
+}
+.ds-bar .gear { color: var(--gold); }
+
+/* ---- the panel capsule --------------------------------------------------- */
+.ds-panel {
+ width: 360px;
+ background-color: var(--ground);
+ border: 1.5px solid var(--gold);
+ border-radius: 16px;
+ padding: 14px 16px 16px;
+ box-shadow: 0 18px 50px #000a, 0 0 0 1px #0006;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ position: relative;
+ overflow: hidden;
+}
+
+/* Faceplate header: raised ID plate with title, gear glyph, close. */
+.ds-face {
+ background-color: var(--plate);
+ border: 1px solid var(--plate-edge);
+ border-radius: 12px;
+ padding: 9px 12px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.ds-face .glyph { color: var(--gold); font-size: 18px; }
+.ds-face .title { color: var(--gold); font-weight: 700; font-size: 16px; letter-spacing: 0.02em; }
+.ds-face .sub { color: var(--steel); font-size: 11px; margin-left: auto; }
+.ds-face .x {
+ color: var(--dim); cursor: pointer; font-size: 15px; padding: 2px 5px;
+ border-radius: 50%; line-height: 1;
+}
+.ds-face .x:hover { color: var(--silver); background: var(--line); }
+
+/* Engraved section label: steel small-caps + a thin rule. */
+.ds-engrave {
+ color: var(--steel);
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 4px 2px 2px;
+}
+.ds-engrave::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: linear-gradient(90deg, var(--line), transparent);
+}
+
+/* Status lamp: a colored dot with a soft glow; off is a dim wash. */
+.ds-lamp {
+ width: 9px; height: 9px; border-radius: 50%;
+ background-color: var(--pass);
+ box-shadow: 0 0 6px 1px rgba(116,147,47,.55);
+ flex: none;
+}
+.ds-lamp.gold { background-color: var(--gold); box-shadow: 0 0 6px 1px rgba(218,181,61,.55); }
+.ds-lamp.red { background-color: var(--fail); box-shadow: 0 0 6px 1px rgba(203,107,77,.55); }
+.ds-lamp.off { background-color: var(--line); box-shadow: none; }
+
+/* Console key: a raised mechanical button that lights gold on hover/active. */
+.ds-key {
+ color: var(--silver);
+ background-image: linear-gradient(180deg, var(--key-top), var(--key-bot));
+ background-color: var(--key-bot);
+ border: 1px solid var(--key-edge);
+ border-radius: 8px;
+ padding: 8px 10px;
+ font-family: var(--mono);
+ font-size: 13px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ transition: border-color .12s, color .12s, background-color .12s;
+}
+.ds-key:hover { color: var(--gold); border-color: var(--gold); }
+.ds-key .k-glyph { font-size: 15px; color: var(--steel); }
+.ds-key:hover .k-glyph { color: var(--gold); }
+.ds-key .k-name { color: var(--silver); }
+.ds-key .k-state { margin-left: auto; color: var(--dim); font-size: 11px; }
+
+/* Console key in the ON state: gold cast + lit lamp glyph. */
+.ds-key.on {
+ border-color: var(--gold);
+ background-image: linear-gradient(180deg, #2a2618, #1d1a12);
+}
+.ds-key.on .k-glyph { color: var(--gold); }
+.ds-key.on .k-name { color: var(--cream); }
+.ds-key.on .k-state { color: var(--gold); }
+
+/* A brief verify flash: after a change, the control confirms the readback. */
+.ds-key.verified { animation: dsVerify 0.9s ease-out; }
+@keyframes dsVerify {
+ 0% { box-shadow: 0 0 0 0 rgba(116,147,47,.0); }
+ 20% { box-shadow: 0 0 0 2px rgba(116,147,47,.55); }
+ 100% { box-shadow: 0 0 0 0 rgba(116,147,47,0); }
+}
+
+/* Tile (grid layout): a squarer console key with the lamp top-left. */
+.ds-tile {
+ background-image: linear-gradient(180deg, var(--key-top), var(--key-bot));
+ border: 1px solid var(--key-edge);
+ border-radius: 10px;
+ padding: 10px 11px;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-height: 62px;
+ transition: border-color .12s, background-color .12s;
+}
+.ds-tile:hover { border-color: var(--gold); }
+.ds-tile .t-top { display: flex; align-items: center; gap: 8px; }
+.ds-tile .t-glyph { font-size: 17px; color: var(--steel); }
+.ds-tile .t-name { color: var(--silver); font-size: 12.5px; }
+.ds-tile .t-state { color: var(--dim); font-size: 11px; margin-top: auto; }
+.ds-tile.on { border-color: var(--gold); background-image: linear-gradient(180deg, #2a2618, #1d1a12); }
+.ds-tile.on .t-glyph { color: var(--gold); }
+.ds-tile.on .t-name { color: var(--cream); }
+.ds-tile.on .t-state { color: var(--gold); }
+.ds-tile.verified { animation: dsVerify 0.9s ease-out; }
+.ds-tile .t-detail {
+ margin-left: auto; color: var(--dim); font-size: 11px; padding: 0 3px;
+}
+.ds-tile .t-detail:hover { color: var(--gold); }
+
+/* Slider row: engraved name + machined fader + % readout. */
+.ds-slider {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 4px;
+}
+.ds-slider .s-glyph { color: var(--steel); font-size: 15px; width: 18px; text-align: center; }
+.ds-slider .s-name { color: var(--silver); font-size: 12.5px; width: 92px; }
+.ds-slider input[type=range] {
+ -webkit-appearance: none; appearance: none;
+ flex: 1;
+ height: 6px;
+ border-radius: 6px;
+ background: var(--well);
+ border: 1px solid var(--line);
+ outline: none;
+}
+.ds-slider input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance: none; appearance: none;
+ width: 14px; height: 14px; border-radius: 50%;
+ background: var(--cream);
+ border: 1px solid var(--gold);
+ cursor: pointer;
+ box-shadow: -200px 0 0 194px var(--gold); /* gold fill to the left of the knob */
+}
+.ds-slider input[type=range]::-moz-range-thumb {
+ width: 14px; height: 14px; border-radius: 50%;
+ background: var(--cream); border: 1px solid var(--gold); cursor: pointer;
+}
+.ds-slider input[type=range]::-moz-range-progress { background: var(--gold); height: 6px; border-radius: 6px; }
+.ds-slider .s-pct { color: var(--cream); font-size: 12px; width: 40px; text-align: right; font-variant-numeric: tabular-nums; }
+
+/* Segmented selector (power profile): joined keys, the current one lit gold. */
+.ds-seg { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid var(--key-edge); }
+.ds-seg button {
+ flex: 1;
+ background-image: linear-gradient(180deg, var(--key-top), var(--key-bot));
+ color: var(--silver);
+ border: none;
+ border-right: 1px solid var(--key-edge);
+ padding: 7px 6px;
+ font-family: var(--mono);
+ font-size: 11.5px;
+ cursor: pointer;
+}
+.ds-seg button:last-child { border-right: none; }
+.ds-seg button:hover { color: var(--gold); }
+.ds-seg button.cur {
+ background-image: linear-gradient(180deg, var(--gold-bright), var(--gold));
+ color: var(--ground);
+ font-weight: 700;
+}
+
+/* Scene key: a wider console key with the scene name; active is gold-lit. */
+.ds-scene {
+ background-image: linear-gradient(180deg, var(--key-top), var(--key-bot));
+ border: 1px solid var(--key-edge);
+ border-radius: 9px;
+ padding: 9px 8px;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ transition: border-color .12s, background-color .12s;
+}
+.ds-scene:hover { border-color: var(--gold); }
+.ds-scene .sc-glyph { font-size: 18px; color: var(--steel); }
+.ds-scene .sc-name { font-size: 11px; color: var(--silver); letter-spacing: 0.03em; }
+.ds-scene.cur { border-color: var(--gold); background-image: linear-gradient(180deg, #2a2618, #1d1a12); }
+.ds-scene.cur .sc-glyph { color: var(--gold); }
+.ds-scene.cur .sc-name { color: var(--cream); }
+
+/* Momentary action buttons (lock / suspend). */
+.ds-act {
+ background: transparent;
+ border: 1px solid var(--slate);
+ color: var(--silver);
+ border-radius: 8px;
+ padding: 8px 10px;
+ font-family: var(--mono);
+ font-size: 12.5px;
+ cursor: pointer;
+ display: flex; align-items: center; justify-content: center; gap: 7px;
+}
+.ds-act:hover { border-color: var(--gold); color: var(--gold); background: #dab53d12; }
+.ds-act.danger { border-color: var(--fail); color: var(--fail); }
+.ds-act.danger:hover { background: var(--fail); color: var(--ground); }
+
+/* Generic grid + row helpers. */
+.ds-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.ds-grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
+.ds-grid4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; }
+.ds-col { display: flex; flex-direction: column; gap: 6px; }
+.ds-well { background: var(--well); border: 1px solid var(--line); border-radius: 10px; padding: 8px; }
+
+/* Toast: a slate feedback strip that names what was applied + verified. */
+.ds-toast {
+ color: var(--cream);
+ background-color: var(--slate);
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 12px;
+ min-height: 18px;
+ transition: opacity .3s;
+}
+.ds-toast .ok { color: #cfe08c; }
+.ds-toast.empty { background: transparent; color: var(--dim); }
+
+/* Wallpaper sub-view overlay: slides over the panel body, Back returns. */
+.ds-subview {
+ position: absolute;
+ inset: 0;
+ background: var(--ground);
+ border-radius: 16px;
+ padding: 14px 16px 16px;
+ display: none;
+ flex-direction: column;
+ gap: 10px;
+ z-index: 5;
+}
+.ds-subview.open { display: flex; }
+.ds-sub-head { display: flex; align-items: center; gap: 10px; }
+.ds-sub-head .back { color: var(--dim); cursor: pointer; font-size: 13px; }
+.ds-sub-head .back:hover { color: var(--gold); }
+.ds-sub-head .title { color: var(--gold); font-weight: 700; font-size: 14px; }
+.ds-thumbs { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
+.ds-thumb {
+ aspect-ratio: 16/10;
+ border-radius: 8px;
+ border: 1.5px solid var(--line);
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+}
+.ds-thumb.cur { border-color: var(--gold); }
+.ds-thumb .tag {
+ position: absolute; bottom: 3px; left: 4px;
+ font-size: 9px; color: #fff; text-shadow: 0 1px 2px #000;
+}
+.ds-daynight { display: flex; gap: 8px; }
+.ds-daynight .dn {
+ flex: 1; border-radius: 8px; border: 1px solid var(--line);
+ padding: 8px; display: flex; flex-direction: column; gap: 4px; align-items: center;
+}
+.ds-daynight .dn .lbl { font-size: 10px; color: var(--steel); text-transform: uppercase; letter-spacing: 0.08em; }
+.ds-daynight .dn .sw { width: 100%; aspect-ratio: 16/9; border-radius: 6px; }
+.ds-dirs { display: flex; flex-direction: column; gap: 4px; }
+.ds-dir { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--silver); padding: 3px 4px; }
+.ds-dir .path { color: var(--dim); }
+.ds-note { color: var(--dim); font-size: 11px; }
diff --git a/docs/prototypes/desktop-settings-shared.js b/docs/prototypes/desktop-settings-shared.js
new file mode 100644
index 0000000..39bc76a
--- /dev/null
+++ b/docs/prototypes/desktop-settings-shared.js
@@ -0,0 +1,261 @@
+/* desktop-settings-shared.js — the shared engine behind every direction
+ * prototype. One mock state model, one widget factory, and the net panel's
+ * verify-everything contract simulated: a control applies, then a mock backing
+ * "reads back" the real value and confirms (a green verify flash + a toast).
+ *
+ * Each prototype builds a different LAYOUT from these same widgets and binds to
+ * this same state, so the five directions are genuinely comparable. */
+
+const DS = (() => {
+ // ---- mock state (what the real settings.py engine would read/write) ------
+ const state = {
+ autodim: false,
+ caffeine: false,
+ touchpad: true,
+ mouse: true,
+ airplane: false,
+ nightlight: false,
+ dnd: false,
+ brightness: 70,
+ kbd: 40,
+ powerprofile: "balanced", // performance | balanced | saver
+ scene: null, // Focus | Presentation | Battery | Night | null
+ wallpaper: {
+ dirs: ["~/pictures/wallpaper", "~/pictures/wallpaper/day-night"],
+ current: 2,
+ day: 1,
+ night: 4,
+ },
+ laptop: true, // gates the airplane row
+ };
+
+ // control metadata: glyph (nerd-font), label, and the "on/off" wording.
+ const TOGGLES = {
+ autodim: { glyph: "\u{f0335}", name: "Auto-dim", on: "dimming", off: "off" },
+ caffeine: { glyph: "\u{f0176}", name: "Caffeine", on: "awake", off: "idle ok" },
+ touchpad: { glyph: "\u{f0168}", name: "Touchpad", on: "on", off: "off" },
+ mouse: { glyph: "\u{f037d}", name: "Mouse", on: "on", off: "off" },
+ airplane: { glyph: "\u{f001d}", name: "Airplane", on: "on", off: "off" },
+ nightlight:{ glyph: "\u{f0594}", name: "Night light", on: "warm", off: "off" },
+ dnd: { glyph: "\u{f009b}", name: "Do Not Disturb",on:"silenced",off: "notifying" },
+ };
+ const SLIDERS = {
+ brightness: { glyph: "\u{f00df}", name: "Brightness", floor: 5 },
+ kbd: { glyph: "\u{f60f}", name: "Keyboard", floor: 0 },
+ };
+ const PROFILES = [
+ { id: "performance", label: "Perf" },
+ { id: "balanced", label: "Balanced" },
+ { id: "saver", label: "Saver" },
+ ];
+ // scenes flip several toggles + a profile at once (the iOS-Focus pattern).
+ const SCENES = {
+ Focus: { glyph: "\u{f0210}", sets: { dnd: true, autodim: false, nightlight: false, powerprofile: "balanced" } },
+ Presentation: { glyph: "\u{f0379}", sets: { caffeine: true, autodim: false, dnd: true, powerprofile: "performance" } },
+ Battery: { glyph: "\u{f0079}", sets: { autodim: true, powerprofile: "saver", nightlight: false } },
+ Night: { glyph: "\u{f0594}", sets: { nightlight: true, dnd: true, autodim: true, powerprofile: "saver" } },
+ };
+
+ let toastEl = null;
+ const listeners = [];
+ const onChange = (fn) => listeners.push(fn);
+ const emit = () => listeners.forEach((f) => f(state));
+
+ function toast(msg, ok) {
+ if (!toastEl) return;
+ toastEl.classList.remove("empty");
+ toastEl.innerHTML = ok ? `${msg} <span class="ok">✓ verified</span>` : msg;
+ clearTimeout(toast._t);
+ toast._t = setTimeout(() => {
+ toastEl.classList.add("empty");
+ toastEl.textContent = "waiting…";
+ }, 2600);
+ }
+
+ // The verify-everything contract: apply, then a mock backing reads back the
+ // actual value and confirms. cb runs on the confirmed readback.
+ function applyVerified(label, mutate, el, cb) {
+ mutate();
+ emit();
+ setTimeout(() => {
+ if (el) {
+ el.classList.remove("verified");
+ void el.offsetWidth;
+ el.classList.add("verified");
+ }
+ toast(label, true);
+ if (cb) cb();
+ }, 140);
+ }
+
+ function setToggle(key, el) {
+ const meta = TOGGLES[key];
+ applyVerified(
+ `${meta.name}: ${state[key] ? meta.off : meta.on}`,
+ () => { state[key] = !state[key]; state.scene = null; },
+ el
+ );
+ }
+ function setSlider(key, value, el) {
+ const meta = SLIDERS[key];
+ const v = Math.max(meta.floor, Math.min(100, Math.round(value)));
+ applyVerified(`${meta.name}: ${v}%`, () => { state[key] = v; }, el);
+ }
+ function setProfile(id, el) {
+ applyVerified(`Power profile: ${id}`, () => { state.powerprofile = id; state.scene = null; }, el);
+ }
+ function applyScene(name, el) {
+ const sc = SCENES[name];
+ applyVerified(`Scene: ${name}`, () => {
+ Object.assign(state, sc.sets);
+ state.scene = name;
+ }, el);
+ }
+ function momentary(label) { toast(`${label}…`, false); }
+
+ // ---- small render helpers the prototypes call ----------------------------
+ const h = (tag, cls, txt) => {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (txt != null) e.textContent = txt;
+ return e;
+ };
+
+ function toggleKey(key) {
+ const meta = TOGGLES[key];
+ const on = state[key];
+ const el = h("div", "ds-key" + (on ? " on" : ""));
+ el.innerHTML =
+ `<span class="ds-lamp ${on ? "gold" : "off"}"></span>` +
+ `<span class="k-glyph">${meta.glyph}</span>` +
+ `<span class="k-name">${meta.name}</span>` +
+ `<span class="k-state">${on ? meta.on : meta.off}</span>`;
+ el.onclick = () => setToggle(key, el);
+ return el;
+ }
+
+ function toggleTile(key, detailCb) {
+ const meta = TOGGLES[key];
+ const on = state[key];
+ const el = h("div", "ds-tile" + (on ? " on" : ""));
+ el.innerHTML =
+ `<div class="t-top"><span class="ds-lamp ${on ? "gold" : "off"}"></span>` +
+ `<span class="t-glyph">${meta.glyph}</span>` +
+ (detailCb ? `<span class="t-detail" title="details">⋯</span>` : "") +
+ `</div>` +
+ `<div class="t-name">${meta.name}</div>` +
+ `<div class="t-state">${on ? meta.on : meta.off}</div>`;
+ el.onclick = (ev) => {
+ if (detailCb && ev.target.classList.contains("t-detail")) { detailCb(); return; }
+ setToggle(key, el);
+ };
+ return el;
+ }
+
+ function slider(key) {
+ const meta = SLIDERS[key];
+ const row = h("div", "ds-slider");
+ row.innerHTML =
+ `<span class="s-glyph">${meta.glyph}</span>` +
+ `<span class="s-name">${meta.name}</span>`;
+ const inp = h("input");
+ inp.type = "range"; inp.min = meta.floor; inp.max = 100; inp.value = state[key];
+ const pct = h("span", "s-pct", state[key] + "%");
+ inp.oninput = () => { pct.textContent = inp.value + "%"; };
+ inp.onchange = () => setSlider(key, +inp.value, row);
+ row.appendChild(inp); row.appendChild(pct);
+ return row;
+ }
+
+ function profileSeg() {
+ const seg = h("div", "ds-seg");
+ PROFILES.forEach((p) => {
+ const b = h("button", state.powerprofile === p.id ? "cur" : "", p.label);
+ b.onclick = () => setProfile(p.id, seg);
+ seg.appendChild(b);
+ });
+ return seg;
+ }
+
+ function sceneKey(name) {
+ const sc = SCENES[name];
+ const el = h("div", "ds-scene" + (state.scene === name ? " cur" : ""));
+ el.innerHTML = `<span class="sc-glyph">${sc.glyph}</span><span class="sc-name">${name}</span>`;
+ el.onclick = () => applyScene(name, el);
+ return el;
+ }
+
+ function actionBtn(label, glyph, danger, fn) {
+ const b = h("button", "ds-act" + (danger ? " danger" : ""));
+ b.innerHTML = `<span>${glyph}</span><span>${label}</span>`;
+ b.onclick = fn || (() => momentary(label));
+ return b;
+ }
+
+ // Wallpaper sub-view: directory list, thumbnails, day/night pair, sun-time.
+ const SWATCHES = [
+ "linear-gradient(135deg,#2a3d5c,#101b2e)",
+ "linear-gradient(135deg,#5c3a2a,#2e1810)",
+ "linear-gradient(135deg,#3a5c2a,#16240f)",
+ "linear-gradient(135deg,#40364f,#1b1626)",
+ "linear-gradient(135deg,#1a1e2e,#05070d)",
+ "linear-gradient(135deg,#5c5030,#2e2810)",
+ ];
+ function wallpaperSubview(closeCb) {
+ const sv = h("div", "ds-subview");
+ const head = h("div", "ds-sub-head");
+ head.innerHTML = `<span class="back">‹ Back</span><span class="title">Wallpaper</span>`;
+ head.querySelector(".back").onclick = closeCb;
+ sv.appendChild(head);
+
+ sv.appendChild(engrave("Current"));
+ const thumbs = h("div", "ds-thumbs");
+ SWATCHES.forEach((g, i) => {
+ const t = h("div", "ds-thumb" + (state.wallpaper.current === i ? " cur" : ""));
+ t.style.background = g;
+ if (i === state.wallpaper.day) t.appendChild(tag("day"));
+ if (i === state.wallpaper.night) t.appendChild(tag("night"));
+ t.onclick = () => {
+ state.wallpaper.current = i;
+ applyVerified(`Wallpaper #${i + 1}`, () => {}, t);
+ sv.querySelectorAll(".ds-thumb").forEach((x, j) => x.classList.toggle("cur", j === i));
+ };
+ thumbs.appendChild(t);
+ });
+ sv.appendChild(thumbs);
+
+ sv.appendChild(engrave("Day / night pair"));
+ const dn = h("div", "ds-daynight");
+ ["day", "night"].forEach((k) => {
+ const cell = h("div", "dn");
+ const sw = h("div", "sw"); sw.style.background = SWATCHES[state.wallpaper[k]];
+ cell.appendChild(sw);
+ cell.appendChild(h("div", "lbl", k === "day" ? "☀ sunup" : "☾ sundown"));
+ dn.appendChild(cell);
+ });
+ sv.appendChild(dn);
+ sv.appendChild(h("div", "ds-note", "Swaps at local sunrise/sundown (sun-time from lat/long)."));
+
+ sv.appendChild(engrave("Directories"));
+ const dirs = h("div", "ds-dirs");
+ state.wallpaper.dirs.forEach((d) => {
+ const r = h("div", "ds-dir");
+ r.innerHTML = `<span class="ds-lamp"></span><span class="path">${d}</span>`;
+ dirs.appendChild(r);
+ });
+ sv.appendChild(dirs);
+ return sv;
+ }
+ function tag(t) { const e = h("span", "tag", t); return e; }
+ function engrave(txt) { return h("div", "ds-engrave", txt); }
+
+ function bindToast(el) { toastEl = el; el.classList.add("empty"); el.textContent = "waiting…"; }
+
+ return {
+ state, TOGGLES, SLIDERS, PROFILES, SCENES,
+ onChange, toast, bindToast,
+ toggleKey, toggleTile, slider, profileSeg, sceneKey, actionBtn,
+ wallpaperSubview, engrave, h,
+ setToggle, setSlider, setProfile, applyScene, momentary,
+ };
+})();
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 &amp; gauges</b> show a live analog value; <b>indicators &amp;
+ 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 &amp; gauges</a>
+ <a href="#sec-indicators">Indicators &amp; 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 &amp; gauges — live analog value</h2>
+<div class="grid" id="meters"></div>
+
+<h2 id="sec-indicators">Indicators &amp; 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,'&quot;')}">${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
new file mode 100644
index 0000000..c1c0b14
--- /dev/null
+++ b/docs/prototypes/reference/slide-toggle.jpg
Binary files differ
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/waybar-redesign-prototype.html b/docs/prototypes/waybar-redesign-prototype.html
new file mode 100644
index 0000000..2307b69
--- /dev/null
+++ b/docs/prototypes/waybar-redesign-prototype.html
@@ -0,0 +1,853 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Waybar redesign — six directions</title>
+<style>
+/* ============ kit tokens (from tokens.json) ============ */
+:root{
+ --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-warn:#f0b552; --amber-edge:#7d5c16;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+ --chrome:linear-gradient(180deg,#f2f4f6 0%,#a9b1b8 26%,#62686d 48%,#cfd4da 72%,#7e858b 100%);
+ --chrome-soft:linear-gradient(180deg,#d9dde1 0%,#8f969c 40%,#565b60 55%,#aab0b6 100%);
+ --brush:repeating-linear-gradient(90deg,#c7cbd0 0 2px,#b2b7bd 2px 3px,#bfc4ca 3px 5px);
+}
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:var(--ground);color:var(--silver);font-family:var(--mono);padding:34px 4vw 120px}
+h1{color:var(--cream);font-size:21px;letter-spacing:.04em;margin-bottom:6px}
+.sub{color:var(--steel);font-size:12.5px;max-width:900px;line-height:1.55;margin-bottom:8px}
+.swatches{display:flex;gap:6px;margin:14px 0 34px}
+.swatches i{width:22px;height:14px;border-radius:3px;border:1px solid #000}
+section.var{margin:0 0 58px}
+h2{font-size:14px;color:var(--gold-hi);letter-spacing:.12em;text-transform:uppercase;margin-bottom:2px}
+h2 .n{color:var(--steel);margin-right:8px}
+.cap{color:var(--steel);font-size:12px;line-height:1.55;max-width:980px;margin:6px 0 12px}
+.cap b{color:var(--silver);font-weight:600}
+/* true-proportion full-bar miniature */
+.minibar{width:100%;aspect-ratio:3440/54;min-height:26px;border-radius:999px;display:flex;align-items:center;
+ padding:0 1.1%;gap:.55%;font-size:9.5px;overflow:hidden;margin-bottom:14px}
+.minibar .sp{flex:1}
+.minibar span{white-space:nowrap;line-height:1}
+/* the large right-cluster stage */
+.stage{border-radius:14px;padding:26px 28px 22px;display:inline-flex;align-items:center;gap:18px;max-width:100%;overflow-x:auto}
+.lbl{font-size:8.5px;letter-spacing:.22em;text-transform:uppercase;text-align:center;margin-bottom:6px;white-space:nowrap}
+.mod{display:flex;flex-direction:column;align-items:center}
+.jewel{width:9px;height:9px;border-radius:50%;display:inline-block;border:1.5px solid #05050588;
+ box-shadow:inset 0 -2px 3px #0009, inset 0 1px 2px #fff5, 0 0 6px 0 currentColor}
+/* drum counters */
+.dgroup{display:flex;align-items:center;border-radius:11px;padding:5px 7px}
+.drum{min-width:26px;padding:7px 5px;text-align:center;font-size:19px;font-weight:700;color:#e9e9e9;
+ background:linear-gradient(180deg,#000 0%,#181818 12%,#3d3d3d 46%,#2b2b2b 60%,#0c0c0c 88%,#000 100%);
+ border-left:1px solid #000;border-right:1px solid #333;
+ text-shadow:0 1px 1px #000;line-height:1}
+.drum:first-child{border-radius:6px 0 0 6px}.drum:last-child{border-radius:0 6px 6px 0}
+.knurl{width:7px;align-self:stretch;border-radius:3px;margin:2px 1px;
+ background:repeating-linear-gradient(0deg,#101010 0 2px,#3c3c3c 2px 4px)}
+.sep-dot{width:7px;height:7px;border-radius:50%;background:radial-gradient(circle at 35% 30%,var(--gold-hi),#7d5c16 70%);margin:0 4px}
+/* knobs */
+.knob{width:30px;height:30px;border-radius:50%;
+ background:radial-gradient(circle at 34% 28%,#e8ecef,#9aa1a7 45%,#4d5257 78%,#31353a);
+ border:2px solid #1c1e20;position:relative;box-shadow:0 2px 5px #000a, inset 0 1px 1px #fff8}
+.knob::after{content:"";position:absolute;left:50%;top:3px;width:2px;height:8px;background:#17191b;transform:translateX(-50%);border-radius:1px}
+.knob.brass{background:radial-gradient(circle at 34% 28%,#ffdf9a,var(--gold) 45%,#8f671f 80%,#5c4212)}
+/* vfd window */
+.vfdwin{background:#031311;border-radius:6px;padding:7px 12px;color:var(--vfd);
+ box-shadow:inset 0 2px 8px #000, inset 0 0 24px #063a31, 0 0 1px #000;
+ text-shadow:0 0 7px #2ee6c266;font-size:18px;letter-spacing:.08em}
+svg{display:block}
+.note{margin-top:44px;border-top:1px solid #2a2724;padding-top:16px;color:var(--steel);font-size:12px;line-height:1.6;max-width:980px}
+.note b{color:var(--cream)}
+</style>
+</head>
+<body>
+
+<!-- ===== custom weather glyph set (stroke/fill = currentColor, recolour per module) =====
+ Live snapshot 2026-07-18: New Orleans, clear, 82°F (feels 92°F), humidity 88%, wind 6 WNW.
+ A real module maps the provider's condition code to one of these symbols; the sun is
+ the current reading. Drawn custom (not Nerd Font / emoji) so they sit in the kit. -->
+<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
+ <symbol id="wx-sun" viewBox="0 0 24 24">
+ <circle cx="12" cy="12" r="4.3" fill="currentColor"/>
+ <g stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
+ <line x1="12" y1="1.5" x2="12" y2="4.6"/><line x1="12" y1="19.4" x2="12" y2="22.5"/>
+ <line x1="1.5" y1="12" x2="4.6" y2="12"/><line x1="19.4" y1="12" x2="22.5" y2="12"/>
+ <line x1="4.6" y1="4.6" x2="6.8" y2="6.8"/><line x1="17.2" y1="17.2" x2="19.4" y2="19.4"/>
+ <line x1="4.6" y1="19.4" x2="6.8" y2="17.2"/><line x1="17.2" y1="6.8" x2="19.4" y2="4.6"/>
+ </g>
+ </symbol>
+ <symbol id="wx-partly" viewBox="0 0 24 24">
+ <circle cx="8.5" cy="8" r="3.2" fill="currentColor"/>
+ <g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
+ <line x1="8.5" y1="1.5" x2="8.5" y2="3.4"/><line x1="1.7" y1="8" x2="3.6" y2="8"/>
+ <line x1="3.7" y1="3.2" x2="5" y2="4.5"/><line x1="13.3" y1="3.2" x2="12" y2="4.5"/>
+ </g>
+ <g fill="currentColor"><circle cx="12" cy="15" r="3.6"/><circle cx="16.5" cy="13" r="4.4"/><circle cx="20" cy="15.6" r="3.1"/><rect x="11" y="15" width="10" height="4" rx="2"/></g>
+ </symbol>
+ <symbol id="wx-cloud" viewBox="0 0 24 24">
+ <g fill="currentColor"><circle cx="7.5" cy="13" r="4"/><circle cx="12.5" cy="10.5" r="5.2"/><circle cx="17" cy="13.5" r="3.6"/><rect x="6.5" y="13" width="11.5" height="4.2" rx="2.1"/></g>
+ </symbol>
+ <symbol id="wx-rain" viewBox="0 0 24 24">
+ <g fill="currentColor"><circle cx="7.5" cy="9" r="3.6"/><circle cx="12.5" cy="7" r="4.6"/><circle cx="16.5" cy="9.6" r="3.1"/><rect x="6.5" y="9" width="10.5" height="3.8" rx="1.9"/></g>
+ <g stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="8" y1="15.5" x2="6.6" y2="19.5"/><line x1="12" y1="15.5" x2="10.6" y2="19.5"/><line x1="16" y1="15.5" x2="14.6" y2="19.5"/></g>
+ </symbol>
+ <symbol id="wx-storm" viewBox="0 0 24 24">
+ <g fill="currentColor"><circle cx="7.5" cy="9" r="3.6"/><circle cx="12.5" cy="7" r="4.6"/><circle cx="16.5" cy="9.6" r="3.1"/><rect x="6.5" y="9" width="10.5" height="3.8" rx="1.9"/></g>
+ <path d="M12.5 14 L9 19.5 h3 L10.5 23.5 L15.5 17.5 h-3 L14 14 Z" fill="currentColor"/>
+ </symbol>
+ <symbol id="wx-snow" viewBox="0 0 24 24">
+ <g fill="currentColor"><circle cx="7.5" cy="9" r="3.6"/><circle cx="12.5" cy="7" r="4.6"/><circle cx="16.5" cy="9.6" r="3.1"/><rect x="6.5" y="9" width="10.5" height="3.8" rx="1.9"/></g>
+ <g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
+ <g transform="translate(9,17.5)"><line x1="-2" y1="0" x2="2" y2="0"/><line x1="0" y1="-2" x2="0" y2="2"/><line x1="-1.4" y1="-1.4" x2="1.4" y2="1.4"/><line x1="-1.4" y1="1.4" x2="1.4" y2="-1.4"/></g>
+ <g transform="translate(15,18.5)"><line x1="-2" y1="0" x2="2" y2="0"/><line x1="0" y1="-2" x2="0" y2="2"/><line x1="-1.4" y1="-1.4" x2="1.4" y2="1.4"/><line x1="-1.4" y1="1.4" x2="1.4" y2="-1.4"/></g>
+ </g>
+ </symbol>
+ <symbol id="wx-fog" viewBox="0 0 24 24">
+ <g stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
+ <line x1="3" y1="7" x2="21" y2="7"/><line x1="5" y1="11.5" x2="19" y2="11.5"/><line x1="3" y1="16" x2="18" y2="16"/><line x1="7" y1="20.5" x2="21" y2="20.5"/>
+ </g>
+ </symbol>
+ <!-- wind vane: arrow points UP = north at 0deg; rotate by the source bearing (WNW = 293deg) -->
+ <symbol id="wx-vane" viewBox="0 0 24 24">
+ <path d="M12 2.4 L15.6 9 L12.9 9 L12.9 21 L11.1 21 L11.1 9 L8.4 9 Z" fill="currentColor" stroke="currentColor" stroke-width=".8" stroke-linejoin="round"/>
+ </symbol>
+ <!-- wind (windy condition): flowing gust lines, each ending in a curl -->
+ <symbol id="wx-wind" viewBox="0 0 24 24">
+ <g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
+ <path d="M2.5 8 H13.5 a2.6 2.6 0 1 0 -2.55 -3.1"/>
+ <path d="M2.5 12.5 H18 a2.9 2.9 0 1 1 -2.85 3.45"/>
+ <path d="M2.5 17 H10.5 a2.3 2.3 0 1 1 -2.25 2.75"/>
+ </g>
+ </symbol>
+</defs></svg>
+
+<h1>Waybar redesign — six directions</h1>
+<p class="sub">Chronograph date/time as the anchor, whole-bar aesthetic around it. Each direction shows the full bar at true
+proportion (3440×54), then the right cluster enlarged. Left-side functionality is unchanged everywhere — only the skin varies.
+Right cluster everywhere: AUX (the future desktop-settings module) · network · audio · bluetooth · maint · timer · tray · <b>weather</b> · date · time.
+The <b>weather module is new</b>: current conditions for the location, drawn with a custom glyph set (below) rather than Nerd Font icons.
+The reading is a live snapshot — New Orleans, clear, 82°/feels 92°. All colours are kit tokens from the widget gallery.</p>
+<div class="swatches">
+ <i style="background:#100f0f" title="panel"></i><i style="background:#e2a038" title="gold"></i>
+ <i style="background:#f3e7c5" title="cream"></i><i style="background:#bfc4d0" title="silver"></i>
+ <i style="background:#63e6c8" title="vfd"></i><i style="background:#7fe0a0" title="phos"></i>
+ <i style="background:#ffb43a" title="jewel-a"></i><i style="background:#6fce33" title="jewel-g"></i>
+ <i style="background:#ff5b45" title="jewel-r"></i><i style="background:#e2543f" title="sevred"></i>
+</div>
+<div style="display:flex;gap:22px;align-items:center;flex-wrap:wrap;background:linear-gradient(180deg,#1a1917,#100f0f);border:1px solid #262320;border-radius:11px;padding:14px 20px;margin:0 0 34px">
+ <div style="color:var(--steel);font-size:11px;letter-spacing:.24em;text-transform:uppercase;line-height:1.5">Custom weather<br>glyph set</div>
+ <div style="display:flex;gap:20px;color:var(--cream)">
+ <div style="text-align:center"><svg width="28" height="28" style="color:var(--gold-hi)"><use href="#wx-sun"/></svg><div style="color:var(--steel);font-size:9px;letter-spacing:.1em;margin-top:4px">CLEAR</div></div>
+ <div style="text-align:center"><svg width="28" height="28" style="color:var(--cream)"><use href="#wx-partly"/></svg><div style="color:var(--steel);font-size:9px;letter-spacing:.1em;margin-top:4px">PARTLY</div></div>
+ <div style="text-align:center"><svg width="28" height="28" style="color:var(--silver)"><use href="#wx-cloud"/></svg><div style="color:var(--steel);font-size:9px;letter-spacing:.1em;margin-top:4px">CLOUD</div></div>
+ <div style="text-align:center"><svg width="28" height="28" style="color:var(--slate-hi)"><use href="#wx-rain"/></svg><div style="color:var(--steel);font-size:9px;letter-spacing:.1em;margin-top:4px">RAIN</div></div>
+ <div style="text-align:center"><svg width="28" height="28" style="color:var(--jewel-a)"><use href="#wx-storm"/></svg><div style="color:var(--steel);font-size:9px;letter-spacing:.1em;margin-top:4px">STORM</div></div>
+ <div style="text-align:center"><svg width="28" height="28" style="color:var(--vfd)"><use href="#wx-snow"/></svg><div style="color:var(--steel);font-size:9px;letter-spacing:.1em;margin-top:4px">SNOW</div></div>
+ <div style="text-align:center"><svg width="28" height="28" style="color:var(--dim)"><use href="#wx-fog"/></svg><div style="color:var(--steel);font-size:9px;letter-spacing:.1em;margin-top:4px">FOG</div></div>
+ <div style="text-align:center"><svg width="28" height="28" style="color:var(--silver)"><use href="#wx-wind"/></svg><div style="color:var(--steel);font-size:9px;letter-spacing:.1em;margin-top:4px">WINDY</div></div>
+ </div>
+</div>
+
+<!-- ================= W WEATHER WIDGET DIRECTIONS ================= -->
+<section class="var">
+<h2><span class="n">W</span>Weather widget — directions</h2>
+<p class="cap">Standalone takes on the weather widget, every one showing the same live reading (<b>New Orleans · clear · 82° / feels 92° · wind 6</b>). Same custom glyph set, six different instrument idioms. The bar modules below use the compact chip; the others are candidates for a fuller panel or a click-to-expand.
+<br><b>Wind collapses into one slot, constant width:</b> below ~15 mph it shows the vane + speed (direction is the interesting part when it's calm); at ~15 mph and up it flips to the gust glyph + speed (when it's windy, "windy" is the headline, not the compass point). The two minimal chips show the calm and windy states at identical footprint.</p>
+<div style="display:flex;flex-wrap:wrap;gap:30px 34px;align-items:flex-start;background:linear-gradient(180deg,#151413,#0e0d0d);border:1px solid #262320;border-radius:14px;padding:30px 30px 26px">
+
+ <!-- 1. compact chip -->
+ <figure style="margin:0;display:flex;flex-direction:column;align-items:center;gap:11px">
+ <div style="display:flex;align-items:center;gap:9px;background:#080808;border-radius:9px;padding:8px 13px;box-shadow:inset 0 2px 8px #000, 0 0 0 1px #33290f">
+ <svg width="30" height="30" style="color:var(--gold-hi)"><use href="#wx-sun"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:#e9e9e9;font-size:19px">82°</span><span style="color:var(--gold);font-size:8px;letter-spacing:.12em">CLEAR</span>
+ </div>
+ <div style="width:1px;align-self:stretch;background:#33290f;margin:2px 0"></div>
+ <div style="display:flex;align-items:center;gap:5px">
+ <svg width="20" height="20" style="color:var(--gold-hi);transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05"><span style="color:#e9e9e9;font-size:12px">WNW</span><span style="color:var(--gold);font-size:8px">6 mph</span></div>
+ </div>
+ </div>
+ <figcaption style="color:var(--steel);font-size:10px;letter-spacing:.16em;text-transform:uppercase">Compact chip</figcaption>
+ </figure>
+
+ <!-- 1b. minimal chip — symbol · degrees · direction-arrow · mph -->
+ <figure style="margin:0;display:flex;flex-direction:column;align-items:center;gap:11px">
+ <div style="display:flex;align-items:center;gap:7px;background:#080808;border-radius:8px;padding:6px 11px;box-shadow:inset 0 2px 8px #000, 0 0 0 1px #33290f">
+ <svg width="24" height="24" style="color:var(--gold-hi)"><use href="#wx-sun"/></svg>
+ <span style="color:#e9e9e9;font-size:17px">82°</span>
+ <svg width="17" height="17" style="color:var(--gold-hi);transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <span style="color:var(--gold);font-size:13px">6</span>
+ </div>
+ <figcaption style="color:var(--steel);font-size:10px;letter-spacing:.16em;text-transform:uppercase">Chip — calm · 6 mph</figcaption>
+ </figure>
+
+ <!-- 1c. minimal chip, windy state — gust glyph replaces the vane above the threshold -->
+ <figure style="margin:0;display:flex;flex-direction:column;align-items:center;gap:11px">
+ <div style="display:flex;align-items:center;gap:7px;background:#080808;border-radius:8px;padding:6px 11px;box-shadow:inset 0 2px 8px #000, 0 0 0 1px #33290f">
+ <svg width="24" height="24" style="color:var(--gold-hi)"><use href="#wx-sun"/></svg>
+ <span style="color:#e9e9e9;font-size:17px">82°</span>
+ <svg width="20" height="20" style="color:var(--gold-hi)"><use href="#wx-wind"/></svg>
+ <span style="color:var(--gold);font-size:13px">22</span>
+ </div>
+ <figcaption style="color:var(--steel);font-size:10px;letter-spacing:.16em;text-transform:uppercase">Chip — windy · 22 mph</figcaption>
+ </figure>
+
+ <!-- 2. round instrument gauge -->
+ <figure style="margin:0;display:flex;flex-direction:column;align-items:center;gap:11px">
+ <svg width="130" height="130" viewBox="0 0 130 130">
+ <circle cx="65" cy="65" r="62" fill="#100f0e" stroke="var(--amber-edge)" stroke-width="1.4"/>
+ <circle cx="65" cy="65" r="57" fill="#0c0b0a" stroke="#241c14" stroke-width="1"/>
+ <g stroke="var(--gold)" stroke-width="1.6">
+ <line x1="65" y1="13" x2="65" y2="7"/><line x1="106" y1="24" x2="101.8" y2="28.2"/>
+ <line x1="123" y1="65" x2="117" y2="65"/><line x1="106" y1="106" x2="101.8" y2="101.8"/>
+ <line x1="65" y1="123" x2="65" y2="117"/><line x1="24" y1="106" x2="28.2" y2="101.8"/>
+ <line x1="7" y1="65" x2="13" y2="65"/><line x1="24" y1="24" x2="28.2" y2="28.2"/>
+ </g>
+ <use href="#wx-sun" x="53" y="21" width="24" height="24" style="color:var(--amber-warn)"/>
+ <line x1="65" y1="65" x2="85" y2="31" stroke="var(--sevred)" stroke-width="2" stroke-linecap="round"/>
+ <line x1="65" y1="65" x2="59" y2="75" stroke="var(--sevred)" stroke-width="2" stroke-linecap="round"/>
+ <circle cx="65" cy="65" r="3.2" fill="#1c1c1c" stroke="#000"/>
+ <text x="65" y="90" text-anchor="middle" fill="var(--cream)" font-family="monospace" font-size="17">82°</text>
+ <g transform="rotate(293 55 106)"><use href="#wx-vane" x="49" y="100" width="12" height="12" style="color:var(--gold-hi)"/></g>
+ <text x="72" y="110" text-anchor="middle" fill="var(--steel)" font-family="monospace" font-size="9">WNW 6</text>
+ </svg>
+ <figcaption style="color:var(--steel);font-size:10px;letter-spacing:.16em;text-transform:uppercase">Round gauge</figcaption>
+ </figure>
+
+ <!-- 3. split-flap board -->
+ <figure style="margin:0;display:flex;flex-direction:column;align-items:center;gap:11px">
+ <div style="display:flex;flex-direction:column;align-items:center;gap:5px;background:#070707;border-radius:9px;padding:11px 13px;box-shadow:inset 0 0 16px #000, 0 0 0 1px #222">
+ <div style="display:flex;gap:4px;align-items:center">
+ <svg width="26" height="26" style="color:var(--cream)"><use href="#wx-sun"/></svg>
+ <span style="position:relative;display:inline-grid;place-items:center;width:22px;height:30px;background:linear-gradient(180deg,#242424,#0b0b0b);color:var(--cream);font-size:19px;border-radius:3px;box-shadow:inset 0 0 0 1px #000"><span style="position:absolute;left:1px;right:1px;top:50%;height:1px;background:#000;box-shadow:0 -1px 0 #ffffff10"></span>8</span>
+ <span style="position:relative;display:inline-grid;place-items:center;width:22px;height:30px;background:linear-gradient(180deg,#242424,#0b0b0b);color:var(--cream);font-size:19px;border-radius:3px;box-shadow:inset 0 0 0 1px #000"><span style="position:absolute;left:1px;right:1px;top:50%;height:1px;background:#000;box-shadow:0 -1px 0 #ffffff10"></span>2</span>
+ <span style="position:relative;display:inline-grid;place-items:center;width:16px;height:30px;background:linear-gradient(180deg,#242424,#0b0b0b);color:var(--cream);font-size:15px;border-radius:3px;box-shadow:inset 0 0 0 1px #000"><span style="position:absolute;left:1px;right:1px;top:50%;height:1px;background:#000"></span>°</span>
+ </div>
+ <div style="display:flex;gap:2px">
+ <span style="position:relative;display:inline-grid;place-items:center;width:15px;height:21px;background:linear-gradient(180deg,#242424,#0b0b0b);color:var(--cream);font-size:12px;border-radius:2px;box-shadow:inset 0 0 0 1px #000"><span style="position:absolute;left:1px;right:1px;top:50%;height:1px;background:#000"></span>C</span>
+ <span style="position:relative;display:inline-grid;place-items:center;width:15px;height:21px;background:linear-gradient(180deg,#242424,#0b0b0b);color:var(--cream);font-size:12px;border-radius:2px;box-shadow:inset 0 0 0 1px #000"><span style="position:absolute;left:1px;right:1px;top:50%;height:1px;background:#000"></span>L</span>
+ <span style="position:relative;display:inline-grid;place-items:center;width:15px;height:21px;background:linear-gradient(180deg,#242424,#0b0b0b);color:var(--cream);font-size:12px;border-radius:2px;box-shadow:inset 0 0 0 1px #000"><span style="position:absolute;left:1px;right:1px;top:50%;height:1px;background:#000"></span>E</span>
+ <span style="position:relative;display:inline-grid;place-items:center;width:15px;height:21px;background:linear-gradient(180deg,#242424,#0b0b0b);color:var(--cream);font-size:12px;border-radius:2px;box-shadow:inset 0 0 0 1px #000"><span style="position:absolute;left:1px;right:1px;top:50%;height:1px;background:#000"></span>A</span>
+ <span style="position:relative;display:inline-grid;place-items:center;width:15px;height:21px;background:linear-gradient(180deg,#242424,#0b0b0b);color:var(--cream);font-size:12px;border-radius:2px;box-shadow:inset 0 0 0 1px #000"><span style="position:absolute;left:1px;right:1px;top:50%;height:1px;background:#000"></span>R</span>
+ </div>
+ <div style="display:flex;gap:5px;align-items:center;color:var(--steel);font-size:9px;letter-spacing:.08em;margin-top:1px">
+ <svg width="13" height="13" style="color:var(--cream);transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>WNW · 6 MPH
+ </div>
+ </div>
+ <figcaption style="color:var(--steel);font-size:10px;letter-spacing:.16em;text-transform:uppercase">Split-flap</figcaption>
+ </figure>
+
+ <!-- 4. VFD screen -->
+ <figure style="margin:0;display:flex;flex-direction:column;align-items:center;gap:11px">
+ <div style="position:relative;overflow:hidden;min-width:158px;background:linear-gradient(180deg,#041210,#020a09);border:1px solid #0e3028;border-radius:8px;padding:12px 16px;box-shadow:inset 0 0 22px #063a31, inset 0 2px 8px #000">
+ <div style="display:flex;align-items:center;gap:11px;color:var(--vfd);text-shadow:0 0 8px #2ee6c2aa">
+ <svg width="30" height="30" style="color:var(--vfd)"><use href="#wx-sun"/></svg>
+ <div style="line-height:1.15"><div style="font-size:21px;letter-spacing:.05em">82°F</div><div style="font-size:10px;letter-spacing:.16em;opacity:.85">CLEAR</div></div>
+ </div>
+ <div style="margin-top:8px;display:flex;align-items:center;gap:6px;color:var(--vfd);text-shadow:0 0 8px #2ee6c2aa;font-size:11px;letter-spacing:.1em">
+ <svg width="14" height="14" style="color:var(--vfd);transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>WIND WNW 6 MPH
+ </div>
+ <div style="position:absolute;inset:0;background:repeating-linear-gradient(0deg,transparent 0 2px,#00000030 2px 3px);pointer-events:none"></div>
+ </div>
+ <figcaption style="color:var(--steel);font-size:10px;letter-spacing:.16em;text-transform:uppercase">VFD screen</figcaption>
+ </figure>
+
+ <!-- 5. nixie tubes -->
+ <figure style="margin:0;display:flex;flex-direction:column;align-items:center;gap:11px">
+ <div style="display:flex;align-items:center;gap:11px;background:radial-gradient(120% 100% at 50% 0%,#171310,#0a0806);border-radius:9px;padding:10px 14px;box-shadow:inset 0 2px 10px #000, 0 0 0 1px #241a10">
+ <svg width="30" height="30" style="color:#ff8a3a"><use href="#wx-sun"/></svg>
+ <div style="display:flex;gap:4px">
+ <span style="display:inline-grid;place-items:center;width:25px;height:37px;border-radius:5px;background:radial-gradient(circle at 50% 38%,#2c1708,#0a0604);border:1px solid #1c0f06;color:#ff8a3a;font-size:23px;text-shadow:0 0 11px #ff6a1ecc, 0 0 4px #ffb070">8</span>
+ <span style="display:inline-grid;place-items:center;width:25px;height:37px;border-radius:5px;background:radial-gradient(circle at 50% 38%,#2c1708,#0a0604);border:1px solid #1c0f06;color:#ff8a3a;font-size:23px;text-shadow:0 0 11px #ff6a1ecc, 0 0 4px #ffb070">2</span>
+ <span style="display:inline-grid;place-items:center;width:18px;height:37px;border-radius:5px;background:radial-gradient(circle at 50% 38%,#2c1708,#0a0604);border:1px solid #1c0f06;color:#ff8a3a;font-size:16px;text-shadow:0 0 10px #ff6a1ecc">°</span>
+ </div>
+ <div style="display:flex;flex-direction:column;align-items:center;gap:2px;color:#ff8a3a;text-shadow:0 0 8px #ff6a1e99">
+ <svg width="18" height="18" style="color:#ff8a3a;transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <span style="font-size:8px;letter-spacing:.06em">WNW 6</span>
+ </div>
+ </div>
+ <figcaption style="color:var(--steel);font-size:10px;letter-spacing:.16em;text-transform:uppercase">Nixie</figcaption>
+ </figure>
+
+ <!-- 6. barometer-style condition dial -->
+ <figure style="margin:0;display:flex;flex-direction:column;align-items:center;gap:9px">
+ <svg width="140" height="140" viewBox="0 0 140 140">
+ <circle cx="70" cy="70" r="66" fill="#100f0e" stroke="var(--gold)" stroke-width="1.4"/>
+ <circle cx="70" cy="70" r="61" fill="#0b0a09" stroke="#241c14" stroke-width="1"/>
+ <circle cx="70" cy="70" r="52" fill="none" stroke="#241c14" stroke-width=".8" stroke-dasharray="1.5 3.4"/>
+ <use href="#wx-sun" x="60" y="8" width="20" height="20" style="color:var(--gold-hi)"/>
+ <use href="#wx-partly" x="100.7" y="27.6" width="20" height="20" style="color:#5a5650"/>
+ <use href="#wx-cloud" x="110.7" y="71.6" width="20" height="20" style="color:#5a5650"/>
+ <use href="#wx-rain" x="82.6" y="106.8" width="20" height="20" style="color:#5a5650"/>
+ <use href="#wx-storm" x="37.4" y="106.8" width="20" height="20" style="color:#5a5650"/>
+ <use href="#wx-snow" x="9.3" y="71.6" width="20" height="20" style="color:#5a5650"/>
+ <use href="#wx-fog" x="19.3" y="27.6" width="20" height="20" style="color:#5a5650"/>
+ <line x1="70" y1="70" x2="70" y2="26" stroke="var(--gold-hi)" stroke-width="2.4" stroke-linecap="round"/>
+ <line x1="70" y1="70" x2="70" y2="80" stroke="var(--gold-hi)" stroke-width="2.4" stroke-linecap="round"/>
+ <circle cx="70" cy="70" r="3.4" fill="var(--gold)" stroke="#000"/>
+ </svg>
+ <div style="display:flex;align-items:center;gap:8px;color:var(--cream);font-family:var(--mono);font-size:13px">
+ 82°F<span style="color:#3a3630">·</span>
+ <svg width="14" height="14" style="color:var(--gold-hi);transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <span style="color:var(--steel)">WNW 6</span>
+ </div>
+ <figcaption style="color:var(--steel);font-size:10px;letter-spacing:.16em;text-transform:uppercase">Condition dial</figcaption>
+ </figure>
+
+</div>
+</section>
+
+<!-- ================= V1 GOLD PINSTRIPE ODOMETER ================= -->
+<section class="var">
+<h2><span class="n">1</span>Gold-pinstripe odometer console</h2>
+<div class="minibar" style="background:#0c0b0b;border:1px solid var(--amber-edge);box-shadow:0 0 0 2px #000,inset 0 0 0 1.5px #00000000, inset 0 0 18px #000;outline:1px solid #3a2c10;outline-offset:-4px">
+ <span style="color:var(--steel)">▶ ✦</span><span style="color:var(--gold-hi)">1 2 3 4 5</span>
+ <span style="color:var(--steel)">▦ emacs — widgets.js</span><span class="sp"></span>
+ <span style="color:var(--steel)">AUX ᯤ ♪ ᛒ ⛭ ⏱ ⋮⋮</span>
+ <span style="color:#e9e9e9;background:#000;border-radius:3px;padding:1px 4px">SAT JUL 18</span>
+ <span style="color:#e9e9e9;background:#000;border-radius:3px;padding:1px 4px">07:55 PM CDT</span>
+</div>
+<div class="stage" style="background:linear-gradient(180deg,#141212,#0d0c0c);border:1px solid var(--amber-edge);box-shadow:inset 0 0 0 3px #060505, inset 0 0 0 4px #4a3814, inset 0 1px 14px #000, 0 4px 18px #000">
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Aux</div><div class="knob brass"></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Net</div>
+ <div style="display:flex;gap:3px;align-items:flex-end;height:26px;padding-top:4px">
+ <i style="width:5px;height:8px;background:var(--gold)"></i><i style="width:5px;height:13px;background:var(--gold)"></i>
+ <i style="width:5px;height:18px;background:var(--gold-hi)"></i><i style="width:5px;height:23px;background:#3a332a"></i>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Volume</div>
+ <svg width="52" height="30" viewBox="0 0 52 30">
+ <path d="M4 28 A25 25 0 0 1 48 28" fill="none" stroke="#3a332a" stroke-width="3"/>
+ <path d="M4 28 A25 25 0 0 1 34 7.5" fill="none" stroke="var(--gold)" stroke-width="3"/>
+ <line x1="26" y1="28" x2="37" y2="8" stroke="var(--sevred)" stroke-width="2"/>
+ <circle cx="26" cy="28" r="3" fill="#2b2b2b" stroke="#000"/>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Bluetooth</div>
+ <div style="display:flex;gap:7px;align-items:center;padding-top:5px">
+ <span style="color:var(--silver);font-size:17px">ᛒ</span><span class="jewel" style="color:var(--jewel-g);background:var(--jewel-g)"></span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Maint</div>
+ <div style="display:flex;gap:7px;align-items:center;padding-top:5px">
+ <span style="color:var(--silver);font-size:16px">⛭</span><span class="jewel" style="color:var(--jewel-a);background:var(--jewel-a)"></span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Timer</div>
+ <div class="dgroup" style="background:#080808;box-shadow:inset 0 2px 8px #000, 0 0 0 1px #33290f">
+ <div class="drum" style="font-size:15px;min-width:20px;padding:5px 4px">12</div>
+ <div class="drum" style="font-size:15px;min-width:20px;padding:5px 4px">34</div>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Tray</div>
+ <div style="display:flex;gap:6px;padding-top:9px"><i style="width:6px;height:6px;border-radius:50%;background:#3f3a32"></i><i style="width:6px;height:6px;border-radius:50%;background:#3f3a32"></i><i style="width:6px;height:6px;border-radius:50%;background:#3f3a32"></i></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Weather</div>
+ <div style="display:flex;align-items:center;gap:9px;background:#080808;border-radius:9px;padding:6px 11px;box-shadow:inset 0 2px 8px #000, 0 0 0 1px #33290f">
+ <svg width="28" height="28" style="color:var(--gold-hi)"><use href="#wx-sun"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:#e9e9e9;font-size:17px">82°</span>
+ <span style="color:var(--gold);font-size:8px;letter-spacing:.12em">CLEAR</span>
+ </div>
+ <div style="width:1px;align-self:stretch;background:#33290f;margin:2px 0"></div>
+ <div style="display:flex;align-items:center;gap:5px">
+ <svg width="18" height="18" style="color:var(--gold-hi);transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:#e9e9e9;font-size:11px;letter-spacing:.06em">WNW</span>
+ <span style="color:var(--gold);font-size:8px">6 mph</span>
+ </div>
+ </div></div></div>
+ <div style="width:1px;align-self:stretch;background:linear-gradient(180deg,transparent,var(--amber-edge),transparent)"></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold-hi)">Day&nbsp;&nbsp;·&nbsp;&nbsp;Month&nbsp;&nbsp;·&nbsp;&nbsp;Date</div>
+ <div style="display:flex;align-items:center;gap:10px">
+ <div class="dgroup" style="background:#080808;box-shadow:inset 0 2px 10px #000, 0 0 0 1px #33290f, 0 1px 0 #ffffff10">
+ <div class="knurl"></div><div class="drum">S</div><div class="drum">A</div><div class="drum">T</div><div class="knurl"></div>
+ </div>
+ <div class="dgroup" style="background:#080808;box-shadow:inset 0 2px 10px #000, 0 0 0 1px #33290f">
+ <div class="knurl"></div><div class="drum">J</div><div class="drum">U</div><div class="drum">L</div><div class="knurl"></div>
+ </div>
+ <div class="dgroup" style="background:#080808;box-shadow:inset 0 2px 10px #000, 0 0 0 1px #33290f">
+ <div class="knurl"></div><div class="drum">1</div><div class="drum">8</div><div class="knurl"></div>
+ </div>
+ </div></div>
+ <div class="sep-dot"></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold-hi)">Hour&nbsp;·&nbsp;Min&nbsp;·&nbsp;AM/PM&nbsp;·&nbsp;TZ</div>
+ <div style="display:flex;align-items:center;gap:10px">
+ <div class="dgroup" style="background:#080808;box-shadow:inset 0 2px 10px #000, 0 0 0 1px #33290f">
+ <div class="knurl"></div><div class="drum">0</div><div class="drum">7</div>
+ <div style="color:#777;font-size:17px;padding:0 2px">:</div>
+ <div class="drum">5</div><div class="drum">5</div><div class="knurl"></div>
+ </div>
+ <div class="dgroup" style="background:#080808;box-shadow:inset 0 2px 10px #000, 0 0 0 1px #33290f">
+ <div class="drum" style="font-size:15px">P</div><div class="drum" style="font-size:15px">M</div>
+ </div>
+ <div class="dgroup" style="background:#080808;box-shadow:inset 0 2px 10px #000, 0 0 0 1px #33290f">
+ <div class="drum" style="font-size:15px">C</div><div class="drum" style="font-size:15px">D</div><div class="drum" style="font-size:15px">T</div>
+ </div>
+ </div></div>
+</div>
+<p class="cap"><b>Your 11:46 concept, extended to the whole bar.</b> One continuous black panel with the double gold pinstripe;
+engraved gold labels over every module so the bar reads as a single machined faceplate. Date and time are drum groups with
+knurled end-caps; the timer is a smaller drum pair; volume is a needle meter; statuses are jewel lamps. Warmest and most
+unified of the six — the pinstripe frames the entire bar, not each module.</p>
+</section>
+
+<!-- ================= V2 CHROME BEZEL CONSOLE ================= -->
+<section class="var">
+<h2><span class="n">2</span>Chrome-bezel instrument row</h2>
+<div class="minibar" style="background:#111010;border:1px solid #2e3134;box-shadow:inset 0 1px 8px #000">
+ <span style="color:var(--steel)">▶ ✦ 1 <b style="color:var(--cream)">2</b> 3 4 5 ▦ emacs — widgets.js</span><span class="sp"></span>
+ <span style="color:var(--steel)">AUX ᯤ ♪ ᛒ ⛭ ⏱ ⋮⋮</span>
+ <span style="color:#e9e9e9;background:#000;border:1px solid #888;border-radius:4px;padding:1px 4px">SAT JUL 18</span>
+ <span style="color:#e9e9e9;background:#000;border:1px solid #888;border-radius:4px;padding:1px 4px">07:55 PM</span>
+</div>
+<div class="stage" style="background:linear-gradient(180deg,#171515,#0e0d0d 60%,#131211);border:1px solid #2c2e30;box-shadow:inset 0 1px 10px #000, 0 4px 18px #000">
+ <div class="mod"><div class="lbl" style="color:var(--steel)">Aux</div><div class="knob"></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--steel)">Net</div>
+ <div style="border-radius:9px;padding:3px;background:var(--chrome-soft);box-shadow:0 2px 5px #000a">
+ <div style="background:#050607;border-radius:6px;padding:5px 9px;color:var(--vfd);font-size:14px;text-shadow:0 0 6px #2ee6c255">▂▄▆█</div>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--steel)">Volume</div>
+ <div style="border-radius:9px;padding:3px;background:var(--chrome-soft);box-shadow:0 2px 5px #000a">
+ <svg width="56" height="26" viewBox="0 0 56 26" style="background:#0a0a09;border-radius:6px">
+ <path d="M6 22 A26 26 0 0 1 50 22" fill="none" stroke="#2c2a24" stroke-width="2.5"/>
+ <path d="M6 22 A26 26 0 0 1 38 5.5" fill="none" stroke="var(--cream)" stroke-width="2.5"/>
+ <line x1="28" y1="23" x2="40" y2="6" stroke="var(--sevred)" stroke-width="1.8"/>
+ </svg></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--steel)">Blue</div>
+ <div style="border-radius:9px;padding:3px;background:var(--chrome-soft);box-shadow:0 2px 5px #000a">
+ <div style="background:#050607;border-radius:6px;padding:4px 8px;display:flex;gap:6px;align-items:center">
+ <span style="color:var(--silver);font-size:15px">ᛒ</span><span class="jewel" style="color:var(--vfd);background:var(--vfd);width:7px;height:7px"></span>
+ </div></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--steel)">Maint</div>
+ <div style="border-radius:9px;padding:3px;background:var(--chrome-soft);box-shadow:0 2px 5px #000a">
+ <div style="background:#050607;border-radius:6px;padding:4px 8px;display:flex;gap:6px;align-items:center">
+ <span style="color:var(--silver);font-size:14px">⛭</span><span class="jewel" style="color:var(--jewel-g);background:var(--jewel-g);width:7px;height:7px"></span>
+ </div></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--steel)">Timer</div>
+ <div style="border-radius:9px;padding:3px;background:var(--chrome-soft);box-shadow:0 2px 5px #000a">
+ <div style="background:#050607;border-radius:6px;padding:5px 8px;color:var(--cream);font-size:14px">--:--</div>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--steel)">Tray</div>
+ <div style="display:flex;gap:6px;padding-top:9px"><i style="width:6px;height:6px;border-radius:50%;background:#43464a"></i><i style="width:6px;height:6px;border-radius:50%;background:#43464a"></i></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--steel)">Weather</div>
+ <div style="border-radius:11px;padding:4px;background:var(--chrome);box-shadow:0 3px 7px #000c, inset 0 1px 1px #fff">
+ <div style="display:flex;align-items:center;gap:9px;background:#000;border-radius:7px;padding:6px 10px">
+ <svg width="26" height="26" style="color:var(--cream)"><use href="#wx-sun"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:var(--cream);font-size:16px">82°</span>
+ <span style="color:var(--steel);font-size:8px;letter-spacing:.1em">CLEAR</span>
+ </div>
+ <div style="width:1px;align-self:stretch;background:#2a2a2a;margin:2px 0"></div>
+ <div style="display:flex;align-items:center;gap:5px">
+ <svg width="18" height="18" style="color:var(--cream);transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:var(--cream);font-size:11px;letter-spacing:.06em">WNW</span>
+ <span style="color:var(--steel);font-size:8px">6 mph</span>
+ </div>
+ </div></div></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--silver)">Sat · Jul · 18</div>
+ <div style="display:flex;gap:8px">
+ <div style="border-radius:11px;padding:4px;background:var(--chrome);box-shadow:0 3px 7px #000c, inset 0 1px 1px #fff">
+ <div class="dgroup" style="background:#000;border-radius:7px;box-shadow:inset 0 3px 9px #000">
+ <div class="drum">S</div><div class="drum">A</div><div class="drum">T</div></div></div>
+ <div style="border-radius:11px;padding:4px;background:var(--chrome);box-shadow:0 3px 7px #000c, inset 0 1px 1px #fff">
+ <div class="dgroup" style="background:#000;border-radius:7px;box-shadow:inset 0 3px 9px #000">
+ <div class="drum">J</div><div class="drum">U</div><div class="drum">L</div></div></div>
+ <div style="border-radius:11px;padding:4px;background:var(--chrome);box-shadow:0 3px 7px #000c, inset 0 1px 1px #fff">
+ <div class="dgroup" style="background:#000;border-radius:7px;box-shadow:inset 0 3px 9px #000">
+ <div class="drum">1</div><div class="drum">8</div></div></div>
+ </div>
+ <div style="width:60%;height:2px;background:var(--vfd);opacity:.55;border-radius:2px;margin-top:5px;box-shadow:0 0 6px var(--vfd)"></div>
+ </div>
+ <div class="mod"><div class="lbl" style="color:var(--silver)">07 : 55 · PM · CDT</div>
+ <div style="display:flex;gap:8px">
+ <div style="border-radius:11px;padding:4px;background:var(--chrome);box-shadow:0 3px 7px #000c, inset 0 1px 1px #fff">
+ <div class="dgroup" style="background:#000;border-radius:7px;box-shadow:inset 0 3px 9px #000">
+ <div class="drum">0</div><div class="drum">7</div>
+ <div style="color:#888;font-size:17px;padding:0 2px">:</div>
+ <div class="drum">5</div><div class="drum">5</div></div></div>
+ <div style="border-radius:11px;padding:4px;background:var(--chrome);box-shadow:0 3px 7px #000c, inset 0 1px 1px #fff">
+ <div class="dgroup" style="background:#000;border-radius:7px;box-shadow:inset 0 3px 9px #000">
+ <div class="drum" style="font-size:15px">P</div><div class="drum" style="font-size:15px">M</div></div></div>
+ <div style="border-radius:11px;padding:4px;background:var(--chrome);box-shadow:0 3px 7px #000c, inset 0 1px 1px #fff">
+ <div class="dgroup" style="background:#000;border-radius:7px;box-shadow:inset 0 3px 9px #000">
+ <div class="drum" style="font-size:15px">C</div><div class="drum" style="font-size:15px">D</div><div class="drum" style="font-size:15px">T</div></div></div>
+ </div>
+ <div style="width:60%;height:2px;background:var(--vfd);opacity:.55;border-radius:2px;margin-top:5px;box-shadow:0 0 6px var(--vfd)"></div>
+ </div>
+</div>
+<p class="cap"><b>Your first concept sheet, with every module earning a chrome bezel.</b> Individual polished frames on a
+leather-black ground; silver engraved labels; the lume underline (vfd family — Super-LumiNova blue-green) marks the two
+time modules as the "hands" of the bar. Cooler and more mechanical than V1: each module is its own instrument, the way
+the drum-variation sheet grouped them. Net becomes a VFD signal ladder to put one lit window on the bar's left half.</p>
+</section>
+
+<!-- ================= V3 TOOL WATCH ================= -->
+<section class="var">
+<h2><span class="n">3</span>Tool-watch dial (Speedmaster steel)</h2>
+<div class="minibar" style="background:var(--brush);border:1px solid #55595e;box-shadow:inset 0 -2px 6px #0008">
+ <span style="color:#26282a">▶ ✦ 1 <b>2</b> 3 4 5 ▦ emacs — widgets.js</span><span class="sp"></span>
+ <span style="color:#26282a">AUX ᯤ ♪ ᛒ ⛭ ⏱ ⋮⋮</span>
+ <span style="color:#e9e9e9;background:#0b0b0b;border-radius:3px;padding:1px 5px">SAT 18 · 19:55</span>
+</div>
+<div class="stage" style="background:var(--brush);border-radius:16px;border:1px solid #595e63;box-shadow:inset 0 1px 1px #fff8, inset 0 -3px 8px #0007, 0 5px 18px #000">
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Aux</div><div class="knob"></div></div>
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Net</div>
+ <div style="background:#0c0d0d;border-radius:8px;padding:6px 9px;box-shadow:inset 0 2px 6px #000, 0 1px 0 #fff6">
+ <div style="display:flex;gap:3px;align-items:flex-end;height:18px">
+ <i style="width:4px;height:6px;background:#e9e9e9"></i><i style="width:4px;height:10px;background:#e9e9e9"></i>
+ <i style="width:4px;height:14px;background:#e9e9e9"></i><i style="width:4px;height:18px;background:#3a3d3f"></i>
+ </div></div></div>
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Volume</div>
+ <svg width="46" height="46" viewBox="0 0 46 46">
+ <circle cx="23" cy="23" r="21" fill="#0c0d0d" stroke="#585d62" stroke-width="1.6"/>
+ <g stroke="#e9e9e9" stroke-width="1.4">
+ <line x1="23" y1="4" x2="23" y2="9"/><line x1="42" y1="23" x2="37" y2="23"/>
+ <line x1="23" y1="42" x2="23" y2="37"/><line x1="4" y1="23" x2="9" y2="23"/>
+ </g>
+ <line x1="23" y1="23" x2="33" y2="10" stroke="#e9e9e9" stroke-width="2"/>
+ <circle cx="23" cy="23" r="2.2" fill="#e9e9e9"/>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Blue</div>
+ <div style="background:#0c0d0d;border-radius:8px;padding:6px 8px;display:flex;gap:6px;align-items:center;box-shadow:inset 0 2px 6px #000, 0 1px 0 #fff6">
+ <span style="color:#e9e9e9;font-size:15px">ᛒ</span><span class="jewel" style="color:var(--phos);background:var(--phos);width:7px;height:7px"></span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Maint</div>
+ <div style="background:#0c0d0d;border-radius:8px;padding:6px 8px;display:flex;gap:6px;align-items:center;box-shadow:inset 0 2px 6px #000, 0 1px 0 #fff6">
+ <span style="color:#e9e9e9;font-size:14px">⛭</span><span class="jewel" style="color:var(--jewel-a);background:var(--jewel-a);width:7px;height:7px"></span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Timer 30-min register</div>
+ <svg width="46" height="46" viewBox="0 0 46 46">
+ <circle cx="23" cy="23" r="21" fill="#0c0d0d" stroke="#585d62" stroke-width="1.6"/>
+ <g stroke="#9aa0a5" stroke-width="1">
+ <line x1="23" y1="5" x2="23" y2="10"/><line x1="38.6" y1="14" x2="34.3" y2="16.5"/>
+ <line x1="38.6" y1="32" x2="34.3" y2="29.5"/><line x1="23" y1="41" x2="23" y2="36"/>
+ <line x1="7.4" y1="32" x2="11.7" y2="29.5"/><line x1="7.4" y1="14" x2="11.7" y2="16.5"/>
+ </g>
+ <line x1="23" y1="23" x2="29" y2="9" stroke="var(--sevred)" stroke-width="1.8"/>
+ <circle cx="23" cy="23" r="2" fill="#e9e9e9"/>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Tray</div>
+ <div style="display:flex;gap:6px;padding-top:9px"><i style="width:6px;height:6px;border-radius:50%;background:#53585d"></i><i style="width:6px;height:6px;border-radius:50%;background:#53585d"></i><i style="width:6px;height:6px;border-radius:50%;background:#53585d"></i></div></div>
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Weather</div>
+ <div style="background:#0c0d0d;border-radius:8px;padding:6px 11px;display:flex;align-items:center;gap:9px;box-shadow:inset 0 2px 6px #000, 0 1px 0 #fff6">
+ <svg width="26" height="26" style="color:#e9e9e9"><use href="#wx-sun"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:#e9e9e9;font-size:16px">82°</span>
+ <span style="color:#8b9095;font-size:8px;letter-spacing:.1em">CLEAR</span>
+ </div>
+ <div style="width:1px;align-self:stretch;background:#2a2c2e;margin:2px 0"></div>
+ <div style="display:flex;align-items:center;gap:5px">
+ <svg width="18" height="18" style="color:#e9e9e9;transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:#e9e9e9;font-size:11px;letter-spacing:.06em">WNW</span>
+ <span style="color:#8b9095;font-size:8px">6 mph</span>
+ </div>
+ </div></div></div>
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Date aperture</div>
+ <div style="background:#0c0d0d;border-radius:9px;padding:8px 10px;display:flex;gap:8px;align-items:center;box-shadow:inset 0 2px 8px #000, 0 1px 0 #fff6">
+ <span style="color:#e9e9e9;letter-spacing:.14em;font-size:15px">SAT</span>
+ <div style="background:#f2f2ee;color:#111;border-radius:3px;padding:2px 7px;font-size:17px;font-weight:700;border:1.5px solid #63686d;box-shadow:inset 0 -2px 3px #0003">18</div>
+ <span style="color:#9aa0a5;letter-spacing:.14em;font-size:13px">JUL</span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:#2b2d2f">Time dial</div>
+ <div style="display:flex;align-items:center;gap:10px">
+ <svg width="62" height="62" viewBox="0 0 62 62">
+ <circle cx="31" cy="31" r="29" fill="#0c0d0d" stroke="#63686d" stroke-width="2"/>
+ <g stroke="#e9e9e9" stroke-width="2">
+ <line x1="31" y1="5" x2="31" y2="12"/><line x1="57" y1="31" x2="50" y2="31"/>
+ <line x1="31" y1="57" x2="31" y2="50"/><line x1="5" y1="31" x2="12" y2="31"/>
+ </g>
+ <g stroke="#8b9095" stroke-width="1.2">
+ <line x1="44" y1="8.5" x2="40.5" y2="14.6"/><line x1="53.5" y1="18" x2="47.4" y2="21.5"/>
+ <line x1="53.5" y1="44" x2="47.4" y2="40.5"/><line x1="44" y1="53.5" x2="40.5" y2="47.4"/>
+ <line x1="18" y1="53.5" x2="21.5" y2="47.4"/><line x1="8.5" y1="44" x2="14.6" y2="40.5"/>
+ <line x1="8.5" y1="18" x2="14.6" y2="21.5"/><line x1="18" y1="8.5" x2="21.5" y2="14.6"/>
+ </g>
+ <g fill="var(--phos)">
+ <circle cx="31" cy="14" r="1.5"/><circle cx="48" cy="31" r="1.5"/><circle cx="31" cy="48" r="1.5"/><circle cx="14" cy="31" r="1.5"/>
+ </g>
+ <line x1="31" y1="31" x2="20" y2="21" stroke="#e9e9e9" stroke-width="3.4" stroke-linecap="round"/>
+ <line x1="31" y1="31" x2="42" y2="13" stroke="#e9e9e9" stroke-width="2.2" stroke-linecap="round"/>
+ <line x1="31" y1="31" x2="26" y2="45" stroke="var(--sevred)" stroke-width="1.4" stroke-linecap="round"/>
+ <circle cx="31" cy="31" r="2.6" fill="#e9e9e9"/>
+ </svg>
+ <div style="background:#0c0d0d;border-radius:8px;padding:6px 9px;color:#e9e9e9;font-size:16px;box-shadow:inset 0 2px 6px #000, 0 1px 0 #fff6">19:55<span style="color:#8b9095;font-size:11px"> CDT</span></div>
+ </div></div>
+</div>
+<p class="cap"><b>The Speedmaster direction: brushed-steel bar, black dial insets, lume dots.</b> Time is an actual analog
+dial with baton hands plus a small digital repeater; the date is a framed white aperture (the Submariner cyclops date, flattened).
+Audio and timer become chronograph sub-registers — the timer literally is a 30-minute register. The steel ground makes this
+the lightest bar of the six; icons engrave dark into the metal rather than glowing.</p>
+</section>
+
+<!-- ================= V4 NAVITIMER ================= -->
+<section class="var">
+<h2><span class="n">4</span>Navitimer registers (cream on bronze)</h2>
+<div class="minibar" style="background:linear-gradient(180deg,#2b2119,#1c1611);border:1px solid #4a3a28;box-shadow:inset 0 1px 6px #000">
+ <span style="color:#c9b795">▶ ✦ 1 <b style="color:var(--cream)">2</b> 3 4 5 ▦ emacs — widgets.js</span><span class="sp"></span>
+ <span style="color:#c9b795">AUX ᯤ ♪ ᛒ ⛭ ⏱ ⋮⋮</span>
+ <span style="background:var(--cream);color:#1c1611;border-radius:3px;padding:1px 5px">SAT JUL 18 · 07:55 PM</span>
+</div>
+<div class="stage" style="background:linear-gradient(180deg,#2e231a,#1e1812 65%,#241c14);border-radius:16px;border:1px solid #574430;box-shadow:inset 0 1px 2px #ffedc411, inset 0 -4px 12px #000, 0 5px 18px #000">
+ <div class="mod"><div class="lbl" style="color:#c9b795">Aux</div><div class="knob brass"></div></div>
+ <div class="mod"><div class="lbl" style="color:#c9b795">Net · slide rule</div>
+ <div style="background:var(--cream);border-radius:7px;padding:5px 8px;box-shadow:inset 0 -2px 4px #0004, 0 2px 5px #000a">
+ <svg width="58" height="16" viewBox="0 0 58 16">
+ <g stroke="#241c14" stroke-width="1"><line x1="3" y1="2" x2="3" y2="14"/><line x1="12" y1="5" x2="12" y2="14"/><line x1="21" y1="2" x2="21" y2="14"/><line x1="30" y1="5" x2="30" y2="14"/><line x1="39" y1="2" x2="39" y2="14"/><line x1="48" y1="5" x2="48" y2="14"/><line x1="55" y1="2" x2="55" y2="14"/></g>
+ <line x1="33" y1="0" x2="33" y2="16" stroke="var(--sevred)" stroke-width="1.6"/>
+ </svg></div></div>
+ <div class="mod"><div class="lbl" style="color:#c9b795">Volume</div>
+ <svg width="48" height="48" viewBox="0 0 48 48">
+ <circle cx="24" cy="24" r="22" fill="var(--cream)" stroke="#574430" stroke-width="1.6"/>
+ <g stroke="#241c14" stroke-width="1.2">
+ <line x1="24" y1="4" x2="24" y2="10"/><line x1="44" y1="24" x2="38" y2="24"/>
+ <line x1="24" y1="44" x2="24" y2="38"/><line x1="4" y1="24" x2="10" y2="24"/>
+ </g>
+ <line x1="24" y1="24" x2="34" y2="11" stroke="#241c14" stroke-width="2"/>
+ <circle cx="24" cy="24" r="2" fill="#241c14"/>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:#c9b795">Blue</div>
+ <svg width="34" height="34" viewBox="0 0 34 34">
+ <circle cx="17" cy="17" r="15" fill="var(--cream)" stroke="#574430" stroke-width="1.4"/>
+ <text x="17" y="22" text-anchor="middle" fill="#241c14" font-size="14" font-family="monospace">ᛒ</text>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:#c9b795">Maint</div>
+ <svg width="34" height="34" viewBox="0 0 34 34">
+ <circle cx="17" cy="17" r="15" fill="var(--cream)" stroke="#574430" stroke-width="1.4"/>
+ <text x="17" y="22" text-anchor="middle" fill="#241c14" font-size="13" font-family="monospace">⛭</text>
+ <circle cx="26" cy="8" r="3" fill="var(--jewel-a)" stroke="#241c14" stroke-width=".8"/>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:#c9b795">Timer</div>
+ <svg width="48" height="48" viewBox="0 0 48 48">
+ <circle cx="24" cy="24" r="22" fill="var(--cream)" stroke="#574430" stroke-width="1.6"/>
+ <g stroke="#241c14" stroke-width="1">
+ <line x1="24" y1="4" x2="24" y2="9"/><line x1="41.3" y1="14" x2="37" y2="16.5"/>
+ <line x1="41.3" y1="34" x2="37" y2="31.5"/><line x1="24" y1="44" x2="24" y2="39"/>
+ <line x1="6.7" y1="34" x2="11" y2="31.5"/><line x1="6.7" y1="14" x2="11" y2="16.5"/>
+ </g>
+ <line x1="24" y1="24" x2="31" y2="10" stroke="var(--sevred)" stroke-width="1.8"/>
+ <circle cx="24" cy="24" r="2" fill="#241c14"/>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:#c9b795">Tray</div>
+ <div style="display:flex;gap:6px;padding-top:9px"><i style="width:6px;height:6px;border-radius:50%;background:#574430"></i><i style="width:6px;height:6px;border-radius:50%;background:#574430"></i></div></div>
+ <div class="mod"><div class="lbl" style="color:#c9b795">Weather</div>
+ <div style="background:var(--cream);border-radius:9px;padding:6px 11px;display:flex;align-items:center;gap:9px;box-shadow:inset 0 -2px 4px #0004, 0 2px 5px #000a">
+ <svg width="26" height="26" style="color:#241c14"><use href="#wx-sun"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:#241c14;font-size:16px;font-weight:700">82°</span>
+ <span style="color:#574430;font-size:8px;letter-spacing:.1em">CLEAR</span>
+ </div>
+ <div style="width:1px;align-self:stretch;background:#b8ac8c;margin:2px 0"></div>
+ <div style="display:flex;align-items:center;gap:5px">
+ <svg width="18" height="18" style="color:#241c14;transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:#241c14;font-size:11px;letter-spacing:.06em">WNW</span>
+ <span style="color:#574430;font-size:8px">6 mph</span>
+ </div>
+ </div></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--cream)">Date · beads-of-rice frame</div>
+ <div style="display:flex;align-items:center;gap:8px;background:#170f09;border-radius:9px;padding:7px 12px;box-shadow:inset 0 2px 8px #000, 0 0 0 1px #574430">
+ <span style="color:var(--cream);letter-spacing:.12em;font-size:15px">SAT</span>
+ <div style="background:var(--cream);color:#170f09;border-radius:3px;padding:2px 7px;font-size:17px;font-weight:700;box-shadow:inset 0 -2px 3px #0004">18</div>
+ <span style="color:#c9b795;letter-spacing:.12em;font-size:13px">JUL</span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--cream)">Time · big register</div>
+ <div style="display:flex;align-items:center;gap:10px">
+ <svg width="60" height="60" viewBox="0 0 60 60">
+ <circle cx="30" cy="30" r="28" fill="var(--cream)" stroke="#574430" stroke-width="2"/>
+ <circle cx="30" cy="30" r="21" fill="none" stroke="#241c14" stroke-width=".8" stroke-dasharray="1.5 3.2"/>
+ <g fill="#241c14" font-size="8" font-family="monospace" text-anchor="middle">
+ <text x="30" y="12">12</text><text x="49" y="33">3</text><text x="30" y="54">6</text><text x="11" y="33">9</text>
+ </g>
+ <line x1="30" y1="30" x2="20" y2="20" stroke="#241c14" stroke-width="3" stroke-linecap="round"/>
+ <line x1="30" y1="30" x2="41" y2="13" stroke="#241c14" stroke-width="2" stroke-linecap="round"/>
+ <line x1="30" y1="30" x2="26" y2="44" stroke="var(--sevred)" stroke-width="1.3" stroke-linecap="round"/>
+ <circle cx="30" cy="30" r="2.4" fill="#241c14"/>
+ </svg>
+ <div style="background:#170f09;border-radius:8px;padding:6px 9px;color:var(--cream);font-size:16px;box-shadow:inset 0 2px 6px #000, 0 0 0 1px #574430">07:55<span style="color:#c9b795;font-size:11px"> PM</span></div>
+ </div></div>
+</div>
+<p class="cap"><b>The Navitimer direction: cream registers on a dark bronze dial.</b> Every instrument is a cream sub-dial with
+black markings — the inverse polarity of every other variation, so statuses read as printed gauge faces, not glowing lamps.
+Net is a slide-rule strip with the red index needle; the date sits in a dark framed aperture like the Navitimer's 3-o'clock
+window. Warm, aviation-flavored, and the busiest of the six — closest to a real cockpit cluster.</p>
+</section>
+
+<!-- ================= V5 DASHBOARD ================= -->
+<section class="var">
+<h2><span class="n">5</span>Auto dashboard (gauges + tell-tales + odometer)</h2>
+<div class="minibar" style="background:linear-gradient(180deg,#191715,#0f0e0d);border-top:2px solid #8a8f94;border-bottom:2px solid #6a6f74;box-shadow:inset 0 0 12px #000">
+ <span style="color:var(--steel)">▶ ✦ <b style="color:var(--jewel-a)">1 2 3 4 5</b> ▦ emacs — widgets.js</span><span class="sp"></span>
+ <span style="color:var(--jewel-a)">AUX NET VOL BT SVC ⏱ ⋮⋮</span>
+ <span style="color:#e9e9e9;background:#000;border-radius:3px;padding:1px 4px">SAT 18 JUL · 07:55 PM</span>
+</div>
+<div class="stage" style="background:linear-gradient(180deg,#1b1917,#100f0e 70%);border-radius:16px;border-top:3px solid #90959a;border-bottom:3px solid #5e6368;box-shadow:inset 0 0 18px #000, 0 5px 18px #000">
+ <div class="mod"><div class="lbl" style="color:var(--jewel-a)">Aux</div><div class="knob"></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--jewel-a)">Net signal</div>
+ <svg width="52" height="52" viewBox="0 0 52 52">
+ <circle cx="26" cy="26" r="24" fill="#0d0c0b" stroke="#90959a" stroke-width="2.4"/>
+ <g stroke="var(--jewel-a)" stroke-width="1.3" opacity=".9">
+ <line x1="26" y1="6" x2="26" y2="12"/><line x1="43" y1="13" x2="38.5" y2="17"/>
+ <line x1="46" y1="26" x2="40" y2="26"/><line x1="9" y1="13" x2="13.5" y2="17"/><line x1="6" y1="26" x2="12" y2="26"/>
+ </g>
+ <text x="26" y="45" text-anchor="middle" fill="var(--jewel-a)" font-size="7" font-family="monospace" letter-spacing="1">SIG</text>
+ <line x1="26" y1="26" x2="37" y2="13" stroke="var(--sevred)" stroke-width="2"/>
+ <circle cx="26" cy="26" r="3" fill="#1c1c1c" stroke="#000"/>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:var(--jewel-a)">Volume</div>
+ <svg width="52" height="52" viewBox="0 0 52 52">
+ <circle cx="26" cy="26" r="24" fill="#0d0c0b" stroke="#90959a" stroke-width="2.4"/>
+ <g stroke="var(--jewel-a)" stroke-width="1.3" opacity=".9">
+ <line x1="26" y1="6" x2="26" y2="12"/><line x1="43" y1="13" x2="38.5" y2="17"/>
+ <line x1="46" y1="26" x2="40" y2="26"/><line x1="9" y1="13" x2="13.5" y2="17"/><line x1="6" y1="26" x2="12" y2="26"/>
+ </g>
+ <text x="26" y="45" text-anchor="middle" fill="var(--jewel-a)" font-size="7" font-family="monospace" letter-spacing="1">VOL</text>
+ <line x1="26" y1="26" x2="14" y2="14" stroke="var(--sevred)" stroke-width="2"/>
+ <circle cx="26" cy="26" r="3" fill="#1c1c1c" stroke="#000"/>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:var(--jewel-a)">Tell-tales</div>
+ <div style="display:grid;grid-template-columns:auto auto;gap:5px 10px;background:#0d0c0b;border:2px solid #4b5055;border-radius:9px;padding:7px 10px;box-shadow:inset 0 2px 8px #000">
+ <span style="color:var(--jewel-g);font-size:11px;text-shadow:0 0 6px var(--jewel-g)">ᛒ BT</span>
+ <span style="color:var(--jewel-a);font-size:11px;text-shadow:0 0 6px var(--jewel-a)">⛭ SVC</span>
+ <span style="color:#3c3a36;font-size:11px">🔇 MUTE</span>
+ <span style="color:#3c3a36;font-size:11px">⚠ NET</span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--jewel-a)">Timer</div>
+ <div style="background:#0d0c0b;border:2px solid #4b5055;border-radius:9px;padding:6px 9px;color:var(--jewel-a);font-size:15px;text-shadow:0 0 7px var(--jewel-a);box-shadow:inset 0 2px 8px #000">12:34</div></div>
+ <div class="mod"><div class="lbl" style="color:var(--jewel-a)">Tray</div>
+ <div style="display:flex;gap:6px;padding-top:9px"><i style="width:6px;height:6px;border-radius:50%;background:#44403a"></i><i style="width:6px;height:6px;border-radius:50%;background:#44403a"></i></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--jewel-a)">Weather</div>
+ <div style="border-radius:10px;padding:4px;background:var(--chrome-soft);box-shadow:0 3px 7px #000c">
+ <div style="background:#0d0c0b;border-radius:7px;padding:6px 11px;display:flex;align-items:center;gap:9px;color:var(--jewel-a);text-shadow:0 0 8px var(--jewel-a);box-shadow:inset 0 3px 9px #000">
+ <svg width="26" height="26" style="color:var(--jewel-a)"><use href="#wx-sun"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="font-size:17px">82°</span>
+ <span style="font-size:8px;letter-spacing:.1em;color:#8a6f33;text-shadow:none">CLEAR</span>
+ </div>
+ <div style="width:1px;align-self:stretch;background:#3a2f14;margin:2px 0"></div>
+ <div style="display:flex;align-items:center;gap:5px">
+ <svg width="18" height="18" style="color:var(--jewel-a);transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="font-size:11px;letter-spacing:.06em">WNW</span>
+ <span style="font-size:8px;color:#8a6f33;text-shadow:none">6 mph</span>
+ </div>
+ </div></div></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--jewel-a)">Odometer date</div>
+ <div style="border-radius:10px;padding:4px;background:var(--chrome-soft);box-shadow:0 3px 7px #000c">
+ <div class="dgroup" style="background:#000;border-radius:7px;box-shadow:inset 0 3px 9px #000">
+ <div class="drum" style="font-size:16px">S</div><div class="drum" style="font-size:16px">A</div><div class="drum" style="font-size:16px">T</div>
+ <div class="drum" style="font-size:16px;background:linear-gradient(180deg,#eee,#bbb 50%,#999);color:#111">1</div>
+ <div class="drum" style="font-size:16px;background:linear-gradient(180deg,#eee,#bbb 50%,#999);color:#111">8</div>
+ <div class="drum" style="font-size:16px">J</div><div class="drum" style="font-size:16px">U</div><div class="drum" style="font-size:16px">L</div>
+ </div></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--jewel-a)">Clock · backlit</div>
+ <div style="border-radius:10px;padding:4px;background:var(--chrome-soft);box-shadow:0 3px 7px #000c">
+ <div style="background:#0d0c0b;border-radius:7px;padding:6px 11px;color:var(--jewel-a);font-size:19px;text-shadow:0 0 9px var(--jewel-a);box-shadow:inset 0 3px 9px #000">
+ 07:55 <span style="font-size:12px">PM</span> <span style="font-size:11px;color:#8a6f33">CDT</span>
+ </div></div></div>
+</div>
+<p class="cap"><b>The dashboard direction: amber-backlit gauge cluster with chrome top/bottom trim rails.</b> Net and volume are
+round needle gauges; bluetooth/maint/mute/net-fail live in a tell-tale block (lit = active, dark = off) exactly like a
+cluster's warning lamps; the date is a mixed odometer — white digits on the day drums (a real trip-counter trick); the clock
+glows amber behind chrome. The bar's top and bottom edges get the chrome rails, which is what makes the whole strip read
+"dashboard" at a glance. The amber family is the kit's existing panel amber, so this is the variation closest to the widget gallery.</p>
+</section>
+
+<!-- ================= V6 PERPETUAL CALENDAR ================= -->
+<section class="var">
+<h2><span class="n">6</span>Perpetual-calendar dress (guilloché + apertures)</h2>
+<div class="minibar" style="background:radial-gradient(circle at 50% 120%,#1d1a16,#0d0c0b 70%);border:1px solid #6e5a2e;box-shadow:inset 0 0 14px #000">
+ <span style="color:var(--steel)">▶ ✦ 1 <b style="color:var(--gold-hi)">2</b> 3 4 5 ▦ emacs — widgets.js</span><span class="sp"></span>
+ <span style="color:var(--steel)">AUX ᯤ ♪ ᛒ ⛭ ☾ ⋮⋮</span>
+ <span style="color:var(--cream);border:1px solid #6e5a2e;border-radius:3px;padding:1px 5px">SAT JUL · 18 · 7:55</span>
+</div>
+<div class="stage" style="background:repeating-radial-gradient(circle at 50% 260%,#161311 0 3px,#0f0d0b 3px 6px);border-radius:16px;border:1px solid #6e5a2e;box-shadow:inset 0 0 0 3px #060505, inset 0 1px 16px #000, 0 5px 18px #000">
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Aux</div><div class="knob brass" style="width:26px;height:26px"></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Net</div>
+ <div style="display:flex;gap:4px;padding-top:6px;align-items:center">
+ <span style="color:var(--cream);font-size:14px">ᯤ</span>
+ <span class="jewel" style="color:var(--gold-hi);background:radial-gradient(circle at 35% 30%,#ffe9b0,var(--gold) 60%,#7d5c16);width:8px;height:8px"></span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Volume</div>
+ <svg width="44" height="44" viewBox="0 0 44 44">
+ <circle cx="22" cy="22" r="20" fill="#0b0a09" stroke="#6e5a2e" stroke-width="1.4"/>
+ <circle cx="22" cy="22" r="14" fill="none" stroke="#3a301c" stroke-width=".8"/>
+ <line x1="22" y1="22" x2="31" y2="10" stroke="var(--gold-hi)" stroke-width="1.6"/>
+ <circle cx="22" cy="22" r="1.8" fill="var(--gold-hi)"/>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Blue</div>
+ <div style="display:flex;gap:4px;padding-top:6px;align-items:center">
+ <span style="color:var(--cream);font-size:14px">ᛒ</span>
+ <span class="jewel" style="color:var(--slate-hi);background:radial-gradient(circle at 35% 30%,#9db4cc,var(--slate-hi) 60%,#232c36);width:8px;height:8px"></span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Maint</div>
+ <div style="display:flex;gap:4px;padding-top:6px;align-items:center">
+ <span style="color:var(--cream);font-size:13px">⛭</span>
+ <span class="jewel" style="color:var(--jewel-a);background:radial-gradient(circle at 35% 30%,#ffd48a,var(--jewel-a) 60%,#6b4a12);width:8px;height:8px"></span>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Timer · moonphase</div>
+ <svg width="58" height="34" viewBox="0 0 58 34">
+ <defs><clipPath id="moonwin"><path d="M6 30 A24 24 0 0 1 52 30 Z"/></clipPath></defs>
+ <path d="M4 31 A26 26 0 0 1 54 31 Z" fill="#0b0a09" stroke="#6e5a2e" stroke-width="1.4"/>
+ <g clip-path="url(#moonwin)">
+ <rect x="0" y="0" width="58" height="34" fill="var(--slate)"/>
+ <circle cx="36" cy="18" r="9" fill="var(--cream)"/>
+ <circle cx="12" cy="10" r="1" fill="var(--cream)"/><circle cx="20" cy="22" r="1" fill="var(--cream)"/>
+ <circle cx="50" cy="9" r="1" fill="var(--cream)"/>
+ </g>
+ <text x="29" y="32.5" text-anchor="middle" fill="#8f7b48" font-size="6.5" font-family="monospace" letter-spacing="1">12:34</text>
+ </svg></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Tray</div>
+ <div style="display:flex;gap:6px;padding-top:9px"><i style="width:5px;height:5px;border-radius:50%;background:#4a3d22"></i><i style="width:5px;height:5px;border-radius:50%;background:#4a3d22"></i></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold)">Weather</div>
+ <div style="background:#0b0a09;border:1.6px solid var(--gold);border-radius:5px;padding:5px 11px;display:flex;align-items:center;gap:9px;box-shadow:inset 0 2px 8px #000">
+ <svg width="26" height="26" style="color:var(--gold-hi)"><use href="#wx-sun"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:var(--cream);font-size:16px">82°</span>
+ <span style="color:var(--gold);font-size:8px;letter-spacing:.1em">CLEAR</span>
+ </div>
+ <div style="width:1px;align-self:stretch;background:#4a3d22;margin:2px 0"></div>
+ <div style="display:flex;align-items:center;gap:5px">
+ <svg width="18" height="18" style="color:var(--gold-hi);transform:rotate(293deg);transform-origin:center"><use href="#wx-vane"/></svg>
+ <div style="display:flex;flex-direction:column;line-height:1.05">
+ <span style="color:var(--cream);font-size:11px;letter-spacing:.06em">WNW</span>
+ <span style="color:var(--gold);font-size:8px">6 mph</span>
+ </div>
+ </div></div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold-hi)">Day · Month apertures</div>
+ <div style="display:flex;gap:7px">
+ <div style="background:#0b0a09;border:1.6px solid var(--gold);border-radius:4px;padding:4px 9px;color:var(--cream);font-size:15px;letter-spacing:.1em;box-shadow:inset 0 2px 6px #000">SAT</div>
+ <div style="background:#0b0a09;border:1.6px solid var(--gold);border-radius:4px;padding:4px 9px;color:var(--cream);font-size:15px;letter-spacing:.1em;box-shadow:inset 0 2px 6px #000">JUL</div>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold-hi)">Big date</div>
+ <div style="display:flex;gap:3px">
+ <div style="background:var(--cream);color:#171310;border:1.6px solid var(--gold);border-radius:4px 0 0 4px;padding:3px 8px;font-size:20px;font-weight:700;box-shadow:inset 0 -2px 3px #0004">1</div>
+ <div style="background:var(--cream);color:#171310;border:1.6px solid var(--gold);border-radius:0 4px 4px 0;padding:3px 8px;font-size:20px;font-weight:700;box-shadow:inset 0 -2px 3px #0004">8</div>
+ </div></div>
+ <div class="mod"><div class="lbl" style="color:var(--gold-hi)">Time</div>
+ <div style="background:#0b0a09;border:1.6px solid var(--gold);border-radius:5px;padding:5px 11px;color:var(--cream);font-size:18px;letter-spacing:.06em;box-shadow:inset 0 2px 8px #000">
+ 7:55 <span style="font-size:11px;color:var(--gold)">PM CDT</span>
+ </div></div>
+</div>
+<p class="cap"><b>The Patek/AP direction: guilloché ground, gold-framed apertures, a moonphase.</b> Day and month are twin
+apertures (the SAM · NOV window pair), the date is a big-date double disc in cream, and the timer becomes the moonphase —
+the moon crosses the arc as the countdown runs, with the digits small beneath. Statuses are cabochon jewels, not lamps.
+The most jewelry-like and the most restrained: nothing glows except what matters. Least "stereo", most "watch".</p>
+</section>
+
+<div class="note">
+<b>Shared decisions across all six, for discussion:</b>
+(1) AUX knob at the head of the right cluster is the future desktop-settings module (caffeine + dim + touchpad fold in) —
+a knob in every variation since "settings" is the one module that IS a knob.
+(2) Left half stays functionally identical everywhere; it takes each variation's ground treatment only.
+(3) Date and time stay two separate modules (your current click/scroll contracts), even where they visually pair.
+(4) The drum content is the ordinal-free format — drums can't render "18th" gracefully; worth deciding whether the
+chronograph keeps ordinal at all.
+(5) True-proportion strips show the honest 3440×54 balance: the right cluster occupies roughly a quarter of the bar.
+(6) Weather module: the <b>compact chip is the chosen bar form</b> (2026-07-18) — it's the only one of the six weather takes legible at bar scale. The gauge, split-flap, VFD, nixie, and condition dial are kept as candidates for a larger panel or a click-to-expand view, not the bar.
+</div>
+
+</body>
+</html>
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..3aef647 100644
--- a/docs/specs/2026-07-02-desktop-settings-panel-spec.org
+++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org
@@ -14,6 +14,7 @@
:PROPERTIES:
:ID: fb7eec22-a214-4568-82c4-903612f4832f
:END:
+- [2026-07-19 Sun] DRAFT — folded Craig's control-set decisions (home->archsetup handoff): added night-light, DND, lock/suspend, power profile, and scenes to the controls table; recorded volume and theme as out-of-scope with reasons; pulled the format pickers into a separate display-format sibling spec (stub filed). The "few other things" scoping decision and the scenes decision are now closed. One decision stays open — the wallpaper manager's shape (row-that-opens-a-sub-view vs its own sub-spec), to resolve at spec-review. UI prototyping (ui-prototyping rule: research -> 5 directions -> iterate) is not done yet and is the gate before READY.
- [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (dim + airplane collapse into the panel, touchpad + caffeine stay on the bar; Super+Shift+G keybind; code in dotfiles settings/ beside net/; 5% brightness floor). The four collapse/keybind/location/floor decisions are closed; one open scoping question remains ("few other things" — see Decisions) before a spec-review can flip it READY.
- [2026-07-03 Fri] DRAFT update — from the waybar/panel-family design
discussion: adopt the instrument-console faceplate aesthetic net + bt
@@ -52,7 +53,8 @@ gathers them.
1. One panel with every desktop toggle + slider: auto-dim, idle/caffeine,
touchpad, mouse, airplane (laptop-only), screen brightness, keyboard
- backlight.
+ backlight, night-light, Do Not Disturb, power profile, lock/suspend
+ actions, scenes, and a wallpaper manager (see the controls table).
2. Conditional rows appear only when the hardware/context applies (mouse
present, trackpad present, battery present) — reuse the detection the
airplane/touchpad indicators already do.
@@ -84,27 +86,47 @@ gathers them.
** Controls and their backings
-| Control | Backing |
-|--------------------+----------------------------------------------|
-| Auto-dim toggle | hyprctl decoration:dim_inactive (dim-toggle) |
-|--------------------+----------------------------------------------|
-| Idle / caffeine | hypridle start/stop (caffeine-toggle) |
-|--------------------+----------------------------------------------|
-| Touchpad toggle | toggle-touchpad + touchpad-state file |
-|--------------------+----------------------------------------------|
-| Mouse toggle | same mechanism, mouse-state file |
-|--------------------+----------------------------------------------|
-| Airplane mode | airplane-mode script (laptop-only row) |
-|--------------------+----------------------------------------------|
-| Screen brightness | brightnessctl (backlight class), slider + % |
-|--------------------+----------------------------------------------|
-| Keyboard backlight | brightnessctl (kbd_backlight class), slider |
-|--------------------+----------------------------------------------|
+| Control | Kind | Backing |
+|--------------------+----------+----------------------------------------------|
+| Auto-dim toggle | toggle | hyprctl decoration:dim_inactive (dim-toggle) |
+|--------------------+----------+----------------------------------------------|
+| Idle / caffeine | toggle | hypridle start/stop (caffeine-toggle) |
+|--------------------+----------+----------------------------------------------|
+| Touchpad toggle | toggle | toggle-touchpad + touchpad-state file |
+|--------------------+----------+----------------------------------------------|
+| Mouse toggle | toggle | same mechanism, mouse-state file |
+|--------------------+----------+----------------------------------------------|
+| Airplane mode | toggle | airplane-mode script (laptop-only row) |
+|--------------------+----------+----------------------------------------------|
+| Screen brightness | slider | brightnessctl (backlight class), slider + % |
+|--------------------+----------+----------------------------------------------|
+| Keyboard backlight | slider | brightnessctl (kbd_backlight class), slider |
+|--------------------+----------+----------------------------------------------|
+| Night light / | toggle | color-temperature shift (candidate: |
+| color temperature | | hyprsunset; confirm the tool at build) |
+|--------------------+----------+----------------------------------------------|
+| Do Not Disturb | toggle | dunstctl set-paused (pause notifications) |
+|--------------------+----------+----------------------------------------------|
+| Power profile | selector | powerprofilesctl (performance / balanced / |
+| | | power-saver, three-way) |
+|--------------------+----------+----------------------------------------------|
+| Lock / suspend | actions | hyprlock / systemctl suspend (momentary |
+| | | buttons, not stateful toggles) |
+|--------------------+----------+----------------------------------------------|
+| Scenes / profiles | composite| one control that flips several toggles at |
+| | | once: Focus, Presentation, Battery-saver, |
+| | | Night (see the Scenes decision below) |
+|--------------------+----------+----------------------------------------------|
+| Wallpaper manager | sub-view | still-open shape (row-that-opens-a-sub-view |
+| | | vs its own sub-spec) — resolve at spec-review|
+|--------------------+----------+----------------------------------------------|
Slider changes apply live (throttled) and read back the actual level after
apply — verify-everything. Toggles re-read their source of truth after
firing, same as the bar indicators do, and the bar modules get their refresh
-signals so both surfaces agree.
+signals so both surfaces agree. The lock/suspend buttons are momentary
+actions with no persistent state to reflect. A scene reads back each toggle it
+sets and reports the composite as applied only when every member verified.
** Open/close behavior
@@ -130,23 +152,50 @@ Resolved (Craig, 2026-07-04): dotfiles =settings/= sibling to =net/= (same src-l
CLOSED: [2026-07-04 Sat]
Resolved (Craig, 2026-07-04): 5% floor on the brightness slider, so a dark-room drag can't black the screen out and lock you out. brightnessctl's 0-100% range clamps to a 5% minimum.
-** TODO What are the "few other things" beyond the toggles?
-The 2026-07-03 discussion named consolidating the toggle buttons "and a few
-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
-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.
+** DONE What are the "few other things" beyond the toggles?
+CLOSED: [2026-07-19 Sun]
+Resolved (Craig, 2026-07-19, via the home->archsetup handoff). Beyond the seven
+base toggles/sliders, the panel adds: night-light / color temperature; Do Not
+Disturb (dunst); lock / suspend quick actions; power profile
+(performance/balanced/saver); and scenes/profiles (see the Scenes decision).
+Plus the wallpaper manager already confirmed 2026-07-04 (its shape stays open,
+see the Wallpaper decision). The controls table above now carries all of them.
+
+OUT of this panel, with reasons: volume / master-mute stays with the audio
+panel (this panel does not mirror it — one owner per control); theme light/dark
+goes to the separate theme-studio task, not here.
+
+Format pickers (time/date/weather display format) are pulled out entirely into
+their own future sibling spec. The real problem there is a single source of
+truth — many programs store their own format, so the design question is where
+the canonical time/date/weather format lives and how every consumer (clock
+module, weather chip) reads it without drift. Tracked as a stub:
+[[file:2026-07-19-display-format-single-source-of-truth-spec.org][display-format single-source-of-truth]].
*** 2026-07-04 Sat — Craig's input (roam capture): the set includes a wallpaper manager
Confirmed the panel gathers the mouse/trackpad toggle, a no-sleep (idle-inhibit) toggle, and the auto-dim toggle, and adds a *wallpaper manager* (this is where the displaced waypaper functionality lands — see the media/keybind change that freed Super+Shift+P). The wallpaper manager needs its own depth:
- take a number of directories to look in;
- switch the wallpaper with the change persisting across sessions;
- switch between two pictures at sunup / sundown (a day/night pair).
-That last one implies a sun-time source (a lat/long or a sunrise/sunset lookup). The wallpaper manager is sizable enough it may want its own sub-spec rather than a single panel row; decide during the spec-review whether it's a row that opens a sub-view or a separate panel. Remaining "few other things" candidates above (night-light, theme switch, DND, lock/suspend) still await Craig's yes/no.
+That last one implies a sun-time source (a lat/long or a sunrise/sunset lookup).
+
+** DONE Scenes / profiles — one control that flips several toggles at once
+CLOSED: [2026-07-19 Sun]
+Resolved (Craig, 2026-07-19): the panel offers named scenes that set several
+controls in one action — Focus, Presentation, Battery-saver, Night. Scenes are
+the payoff of consolidating every desktop control in one panel. The exact
+toggle set each scene applies is a build-time detail (design it against the
+control table); the spec commitment is that scenes exist and each verifies
+every member it sets (see Controls note above).
+
+** DONE Wallpaper manager — a panel row that opens a sub-view, or its own sub-spec?
+CLOSED: [2026-07-19 Sun]
+Resolved (Craig, 2026-07-19): a row in the panel that opens a sub-view, kept
+inside the one panel (the "consolidate everything" thesis). The wallpaper
+manager's depth (directories to scan, persistent selection, a day/night
+sunup/sundown pair needing a sun-time source) lives in that sub-view, not a
+separate surface. Sun-time source (lat/long or sunrise/sunset lookup) is a
+build detail for the sub-view.
* Implementation phases
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/docs/specs/2026-07-19-display-format-single-source-of-truth-spec.org b/docs/specs/2026-07-19-display-format-single-source-of-truth-spec.org
new file mode 100644
index 0000000..02e043c
--- /dev/null
+++ b/docs/specs/2026-07-19-display-format-single-source-of-truth-spec.org
@@ -0,0 +1,63 @@
+#+TITLE: Display-Format Single Source of Truth
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-19
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Display-Format Single Source of Truth
+:PROPERTIES:
+:ID: ae247f1a-e48f-4d5e-8556-dd1f0aa34a69
+:END:
+- [2026-07-19 Sun] DRAFT — stub. Split out of the desktop-settings panel spec (Craig, 2026-07-19): format pickers do not belong in that panel. Filed to hold the problem until it gets its own design pass. Not started.
+
+* Status
+:PROPERTIES:
+:ID: a76a41df-85c2-4137-9ba5-eff04db529a5
+:END:
+- [2026-07-19 Sun] DRAFT — stub only. Problem framed below; goals, decisions, and phases to be developed when Craig picks this up.
+
+* Metadata
+
+| Field | Value |
+|--------+-----------------------------------------|
+| Status | draft |
+|--------+-----------------------------------------|
+| Owner | Craig Jennings |
+|--------+-----------------------------------------|
+| Repo | dotfiles |
+|--------+-----------------------------------------|
+| Kin | desktop-settings panel (parent), clock |
+| | module, weather chip (consumers) |
+|--------+-----------------------------------------|
+
+* Problem
+
+Display-format settings for time, date, and weather are stored independently by
+each program that renders them. The clock module has its own format ring, the
+weather chip its own, and any future consumer would carry a third. There is no
+canonical place a user sets "this is how I want the time shown" that every
+surface reads.
+
+The design problem is a single source of truth: where the canonical
+time/date/weather format lives, and how every consumer reads it without drift.
+Solve that first; the pickers (the UI that edits the format) are downstream of
+where the value is stored.
+
+This was pulled out of the desktop-settings panel spec
+([[file:2026-07-02-desktop-settings-panel-spec.org][desktop-settings panel]]) on
+2026-07-19 — format editing does not belong in that panel, and the real work is
+the storage/consumption model, not a picker widget.
+
+* Goals
+
+(To be developed. At minimum: a canonical store for time/date/weather format; a
+read path every consumer uses; a migration for the existing per-consumer
+formats.)
+
+* Decisions
+
+(None yet — Craig has not started this.)
+
+* Implementation phases
+
+(To be developed once the goals and the storage model are settled.)