aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--CLAUDE.md130
-rw-r--r--Makefile64
-rw-r--r--archive/task-archive.org1432
-rwxr-xr-xarchsetup726
-rw-r--r--archsetup.conf.example5
-rw-r--r--assets/2026-06-18-collapsible-waybar-sides-spike-findings.org (renamed from working/collapsible-waybar-sides/spike-findings.org)0
-rw-r--r--assets/2026-06-19-collapsible-waybar-sides-spec.org (renamed from working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org)2
-rw-r--r--assets/2026-07-03-instrument-console-panels-build-summary.org106
-rwxr-xr-xassets/color-themes/generate-palette.sh1
-rwxr-xr-xassets/easyeffects-eq-presets.sh1
-rw-r--r--assets/outbox/2025-11-08-keyring-fix-next-steps.org3
-rw-r--r--assets/outbox/2025-11-08-test-failure-analysis.org2
-rw-r--r--assets/outbox/2026-06-24-2314-from-.emacs.d-delivered-side-pointed-dirvish-bg-cj.org5
-rw-r--r--assets/outbox/2026-06-24-lint-followups-resolved.org6
-rw-r--r--assets/outbox/2026-06-25-1248-from-archangel-accepted-the-stale-baked-archzfs-db-zfs.org5
-rw-r--r--assets/outbox/2026-06-25-1347-from-archangel-delivered-you-re-unblocked-the-stale.org5
-rw-r--r--assets/outbox/2026-06-25-1359-from-archangel-got-the-heads-up-everything-it-flags-is.org5
-rw-r--r--assets/outbox/2026-06-27-2148-from-archangel-accepted-both-archangel-bug-handoffs.org5
-rw-r--r--assets/outbox/2026-06-27-2301-from-archangel-delivered-both-archangel-bugs-fixed-and.org15
-rw-r--r--assets/outbox/2026-06-28-lint-followups-task-review-health.org2
-rw-r--r--assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt5
-rw-r--r--assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt12
-rw-r--r--assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org5
-rw-r--r--assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org5
-rw-r--r--assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org5
-rw-r--r--assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org5
-rw-r--r--assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org4
-rw-r--r--assets/wireguard-config/USCALA.conf15
-rw-r--r--assets/wireguard-config/USCASF.conf16
-rw-r--r--assets/wireguard-config/USDC.conf15
-rw-r--r--assets/wireguard-config/USGAAT.conf15
-rw-r--r--assets/wireguard-config/USNY.conf16
-rw-r--r--assets/wireguard-config/switzerlan-zurich1.conf15
-rw-r--r--assets/wireguard-config/switzerlan-zurich2.conf15
-rw-r--r--assets/wireguard-config/wg-NL-781.conf19
-rw-r--r--assets/wireguard-config/wg-US-CA-144.conf19
-rw-r--r--assets/wireguard-config/wg-US-TX-714.conf19
-rw-r--r--configs/maintenance-thresholds.toml197
-rw-r--r--docs/PLAN-dotfiles-separation.org6
-rw-r--r--docs/PLAN-per-host-overrides.org2
-rw-r--r--docs/design/2026-06-25-testinfra-validation.org240
-rw-r--r--docs/design/2026-06-25-zfs-vm-test-coverage.org139
-rw-r--r--docs/design/2026-06-29-waybar-network-module-spec.org2094
-rw-r--r--docs/design/2026-06-29-waybar-timer-module-spec.org217
-rw-r--r--docs/design/2026-06-29-zfs-pre-snapshot-installer.org106
-rw-r--r--docs/design/2026-06-30-captive-portal-login.org89
-rw-r--r--docs/design/2026-07-02-waybar-expansion-animation-feasibility.org53
-rw-r--r--docs/design/2026-07-10-audio-failure-taxonomy.org444
-rw-r--r--docs/design/2026-07-10-net-bt-failure-taxonomy.org434
-rw-r--r--docs/design/2026-07-12-control-grammars-reference.org48
-rw-r--r--docs/design/2026-07-14-undeclared-packages-evidence.org84
-rw-r--r--docs/design/maintenance-console-design-ideas.org527
-rw-r--r--docs/design/system-monitor-design-ideas.org1008
-rw-r--r--docs/prototypes/2026-07-02-timer-panel-prototype-1.html693
-rw-r--r--docs/prototypes/2026-07-02-timer-panel-prototype-2.html553
-rw-r--r--docs/prototypes/2026-07-02-timer-panel-prototype-3.html556
-rw-r--r--docs/prototypes/2026-07-03-instrument-console-panels-prototype.html1359
-rw-r--r--docs/prototypes/2026-07-03-net-panel-rescan-prototype.html251
-rw-r--r--docs/prototypes/2026-07-03-sound-panel-prototype.html417
-rw-r--r--docs/prototypes/2026-07-03-waybar-redesign-prototype.html321
-rw-r--r--docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html133
-rw-r--r--docs/prototypes/2026-07-07-maint-console-A-two-column.html335
-rw-r--r--docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html382
-rw-r--r--docs/prototypes/2026-07-07-maint-console-C-severity-feed.html410
-rw-r--r--docs/prototypes/2026-07-07-maint-console-D-doctor-first.html364
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html475
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html443
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html488
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E4-interactive.html546
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html1696
-rw-r--r--docs/prototypes/README.org92
-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.html1616
-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/widgets.js4491
-rw-r--r--docs/specs/2026-07-02-bluetooth-panel-spec.org476
-rw-r--r--docs/specs/2026-07-02-desktop-settings-panel-spec.org161
-rw-r--r--docs/specs/2026-07-02-file-manager-swallow-spec.org147
-rw-r--r--docs/specs/2026-07-02-net-panel-other-interfaces-spec.org195
-rw-r--r--docs/specs/2026-07-02-timer-panel-spec.org221
-rw-r--r--docs/specs/2026-07-03-audio-panel-spec.org166
-rw-r--r--docs/specs/2026-07-03-instrument-console-panels-spec.org174
-rw-r--r--docs/specs/2026-07-06-audio-panel-signal-metering-spec.org232
-rw-r--r--docs/specs/2026-07-07-maintenance-console-spec.org350
-rw-r--r--docs/specs/2026-07-09-audio-doctor-spec.org291
-rw-r--r--docs/specs/2026-07-10-audio-doctor-input-side-spec.org494
-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/workflows/strix-soak-watch.org144
-rw-r--r--docs/workflows/system-health-check.org1038
-rwxr-xr-xinit1
-rw-r--r--pocketbook/.gitignore6
-rw-r--r--pocketbook/Makefile16
-rw-r--r--pocketbook/pyproject.toml21
-rw-r--r--pocketbook/src/pocketbook/__init__.py0
-rw-r--r--pocketbook/src/pocketbook/__main__.py26
-rw-r--r--pocketbook/src/pocketbook/app.py243
-rw-r--r--pocketbook/src/pocketbook/layer_shell.py24
-rw-r--r--pocketbook/src/pocketbook/note.py28
-rw-r--r--pocketbook/src/pocketbook/note_widget.py93
-rw-r--r--pocketbook/src/pocketbook/panel.py97
-rw-r--r--pocketbook/src/pocketbook/store.py73
-rw-r--r--pocketbook/src/pocketbook/style.css102
-rw-r--r--pocketbook/tests/__init__.py0
-rw-r--r--pocketbook/tests/conftest.py17
-rw-r--r--pocketbook/tests/test_app_toggle.py96
-rw-r--r--pocketbook/tests/test_note.py69
-rw-r--r--pocketbook/tests/test_panel.py40
-rw-r--r--pocketbook/tests/test_store.py103
-rwxr-xr-xscripts/arch-distrobox1
-rwxr-xr-xscripts/audit-packages.sh1
-rwxr-xr-xscripts/cmail-setup-finish.sh1
-rwxr-xr-xscripts/games.sh1
-rwxr-xr-xscripts/hypr-live-update-guard139
-rwxr-xr-xscripts/import-wireguard-configs.sh65
-rwxr-xr-xscripts/normalize-notify-sounds.sh1
-rwxr-xr-xscripts/package-inventory1
-rwxr-xr-xscripts/post-install.sh1
-rwxr-xr-xscripts/setup-chess.sh1
-rw-r--r--scripts/testing/archsetup-test-zfs.conf21
-rwxr-xr-xscripts/testing/cleanup-tests.sh1
-rwxr-xr-xscripts/testing/create-base-vm.sh12
-rwxr-xr-xscripts/testing/debug-vm.sh1
-rwxr-xr-xscripts/testing/lib/logging.sh1
-rw-r--r--scripts/testing/lib/network-diagnostics.sh97
-rw-r--r--scripts/testing/lib/testinfra.sh120
-rw-r--r--scripts/testing/lib/validation.sh845
-rwxr-xr-xscripts/testing/lib/vm-utils.sh69
-rw-r--r--scripts/testing/maint-scenarios/10-journal-vacuum.sh30
-rw-r--r--scripts/testing/maint-scenarios/11-coredump-age-out.sh21
-rw-r--r--scripts/testing/maint-scenarios/20-cache-keep3.sh24
-rw-r--r--scripts/testing/maint-scenarios/21-cache-uninstalled.sh20
-rw-r--r--scripts/testing/maint-scenarios/22-orphan-remove.sh22
-rw-r--r--scripts/testing/maint-scenarios/23-pacnew-delete.sh22
-rw-r--r--scripts/testing/maint-scenarios/30-unit-reset.sh28
-rw-r--r--scripts/testing/maint-scenarios/31-fstrim-enable.sh22
-rw-r--r--scripts/testing/maint-scenarios/32-cron-enable.sh20
-rw-r--r--scripts/testing/net-scenarios/10-nm-masked.sh29
-rw-r--r--scripts/testing/net-scenarios/20-rival-manager.sh27
-rw-r--r--scripts/testing/net-scenarios/30-keyfile-perms.sh30
-rw-r--r--scripts/testing/run-maint-nspawn.sh268
-rw-r--r--scripts/testing/run-maint-scenarios.sh374
-rwxr-xr-xscripts/testing/run-net-scenarios.sh115
-rwxr-xr-xscripts/testing/run-test-baremetal.sh60
-rwxr-xr-xscripts/testing/run-test.sh45
-rwxr-xr-xscripts/testing/setup-testing-env.sh1
-rw-r--r--scripts/testing/tests/conftest.py111
-rw-r--r--scripts/testing/tests/test_archsetup.py26
-rw-r--r--scripts/testing/tests/test_backups.py44
-rw-r--r--scripts/testing/tests/test_boot.py83
-rw-r--r--scripts/testing/tests/test_config_applied.py55
-rw-r--r--scripts/testing/tests/test_desktop.py140
-rw-r--r--scripts/testing/tests/test_dotfiles.py19
-rw-r--r--scripts/testing/tests/test_hardening.py50
-rw-r--r--scripts/testing/tests/test_keyring.py35
-rw-r--r--scripts/testing/tests/test_packages.py109
-rw-r--r--scripts/testing/tests/test_services.py103
-rw-r--r--scripts/testing/tests/test_users.py34
-rw-r--r--scripts/wipedisk1
-rwxr-xr-xscripts/zfs-pre-snapshot43
-rwxr-xr-xscripts/zfs-replicate1
-rw-r--r--tests/backup-system-file/test_backup_system_file.py161
-rw-r--r--tests/gallery-probes/README.md26
-rw-r--r--tests/gallery-probes/probe-fams.mjs64
-rw-r--r--tests/gallery-probes/probe.mjs120
-rw-r--r--tests/gallery-tokens/test_gen_tokens.py278
-rw-r--r--tests/gallery-widgets/test-gallery-widget.el114
-rw-r--r--tests/hypr-live-update-guard/test_hypr_live_update_guard.py165
-rw-r--r--tests/import-wireguard-configs/fake-nmcli45
-rw-r--r--tests/import-wireguard-configs/test_import_wireguard_configs.py167
-rw-r--r--tests/installer-steps/test_orchestrators.py149
-rw-r--r--tests/installer-steps/test_pacman_install.py95
-rw-r--r--tests/maint-scenarios/test_scenario_plan.py273
-rw-r--r--tests/network-diagnostics/test_network_diagnostics.py215
-rw-r--r--tests/nvidia-preflight/test_nvidia_preflight.py162
-rw-r--r--tests/run-task/test_run_task.py172
-rwxr-xr-xtests/zfs-pre-snapshot/fake-zfs14
-rw-r--r--tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py116
-rw-r--r--todo.org2116
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.20.pngbin0 -> 189371 bytes
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.48.pngbin0 -> 201347 bytes
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.02.pngbin0 -> 358528 bytes
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.21.pngbin0 -> 292682 bytes
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.18.11.pngbin0 -> 248846 bytes
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.09.00.pngbin0 -> 278989 bytes
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.12.27.pngbin0 -> 345322 bytes
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.21.23.pngbin0 -> 160203 bytes
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-cleanshot-1024x600.pngbin0 -> 545512 bytes
-rw-r--r--working/retro-stereo-widgets/references/2021-cdm-cleanshot-2.pngbin0 -> 355447 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-battery-selector.pngbin0 -> 114165 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-chrome-slot-fader.pngbin0 -> 76439 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-comfort-meter.pngbin0 -> 113179 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-daydate-discs.pngbin0 -> 1092234 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-electric-telegraph-indicator.pngbin0 -> 205045 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-eq-drum-rollers.pngbin0 -> 55483 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-four-way-rocker.pngbin0 -> 75190 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-four-way-toggle.pngbin0 -> 140527 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-guarded-toggles.pngbin0 -> 280025 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-industrial-keypad.pngbin0 -> 274145 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-ki57-right-panel.jpgbin0 -> 207484 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-led-dot-matrix.pngbin0 -> 175068 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-lexicon-224.pngbin0 -> 194576 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-mechanical-timer-dial.pngbin0 -> 91265 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.pngbin0 -> 213597 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpgbin0 -> 8023 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-radar-ppi.pngbin0 -> 169803 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-round-crt-scope.pngbin0 -> 111687 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-thumb-slide-attenuators.pngbin0 -> 76160 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-timing-marker-dial.pngbin0 -> 123793 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-twin-trem-pedal.pngbin0 -> 485090 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-variation-slide.pngbin0 -> 7810 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-12-waves-alpha.pngbin0 -> 68190 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpgbin0 -> 17271 bytes
-rw-r--r--working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webpbin0 -> 26004 bytes
220 files changed, 34937 insertions, 3284 deletions
diff --git a/.gitignore b/.gitignore
index d08398a..83bd6a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,7 @@ __pycache__/
# Dated local backups of secret files (kept on disk, never tracked)
*.bak-[0-9]*
+
+# Claude Code per-project tooling (swept 2026-07-01)
+/CLAUDE.md
+/AGENTS.md
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index a35c2e9..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,130 +0,0 @@
-# Archsetup Project Context
-
-## Overview
-Arch Linux installation and configuration scripts with dotfiles managed via GNU Stow. Supports both X11 (DWM) and Wayland (Hyprland) setups.
-
-## Project Structure
-```
-archsetup Main installation script
-archsetup.conf.example Configuration template
-init Bootstrap script for live ISO
-Makefile VM integration testing + project dependencies
-todo.org Active task list
-
-scripts/ Utility scripts (wireguard, post-install, wipedisk, etc.)
-assets/ Reference documents and archived files
- color-themes/ Theme palettes, generation scripts
- outbox/ Processed inbox items
- wireguard-config/ Proton VPN configs
-.ai/ Claude tooling: session docs, workflows, protocols (gitignored)
-inbox/ Unprocessed documents for session review
-reference-repos/ External dotfile references
-scripts/testing/ VM-based integration test framework
-test-results/ Test run output
-vm-images/ VM disk images for testing
-```
-
-## Makefile Targets
-```
-make deps # Install VM-testing dependencies
-make test-unit # Run fast unit tests for installer helpers (no VM)
-make test # Run full VM integration test (creates base VM if needed)
-make test-keep # Run test and keep the VM up for manual poking
-make test-vm-base # Create the base VM only
-```
-Dotfile stow operations live in the dotfiles repo's own Makefile, not here.
-Run them from `~/.dotfiles`:
-```
-cd ~/.dotfiles && make stow hyprland # common + hyprland + host tier
-cd ~/.dotfiles && make restow hyprland # refresh links after git pull
-cd ~/.dotfiles && make reset hyprland # fix conflicts, keep repo version
-cd ~/.dotfiles && make unstow hyprland # remove symlinks
-cd ~/.dotfiles && make import common # fzf select → import to common/
-cd ~/.dotfiles && make test # run the dotfile-script unit suites
-```
-Per-host overrides live in a stow tier named after the machine (`ratio/`,
-`velox/`), auto-included by every stow target when the directory exists
-(`HOST=<name>` overrides detection; hostname binary is absent — `uname -n`).
-The tier holds only files no shared package owns: hypr `conf.d/local.conf`
-(velox: HiDPI scale + XWayland toolkit env), `pypr/config.toml`, and foot's
-`host.ini` (the shared foot.ini sets no font — it's per-host via include).
-
-## Dotfiles Repository
-
-Dotfiles live in their own repo at `git.cjennings.net/dotfiles.git`
-(read: `https://git.cjennings.net/dotfiles.git`, push: `git@cjennings.net:dotfiles.git`).
-A fresh `archsetup` install clones it to `~/.dotfiles` and stows per `DESKTOP_ENV`:
-
-```
-dwm → common/ + dwm/
-hyprland → common/ + hyprland/
-none → minimal/ (standalone headless tree, not common/)
-```
-
-Config keys (in `archsetup.conf`, all optional — defaults shown):
-```
-DOTFILES_REPO=https://git.cjennings.net/dotfiles.git
-DOTFILES_BRANCH=main
-DOTFILES_DIR= # defaults to the target user's ~/.dotfiles
-```
-
-Pull dotfile changes after install:
-```
-cd ~/.dotfiles && git pull
-cd ~/.dotfiles && make restow hyprland
-```
-
-Setting up a new machine by hand (outside the installer):
-```
-git clone https://git.cjennings.net/dotfiles.git ~/.dotfiles
-cd ~/.dotfiles && make stow hyprland
-```
-**Quit Hyprland before an unstow/restow.** Unstowing while Hyprland runs makes
-it write a stub `hyprland.conf` into the gap, which then blocks the restow. Do
-it from a TTY, or with the session stopped.
-
-## Dotfile Script Counts
-The scripts live in the dotfiles repo (`~/.dotfiles`), not here:
-```
-~/.dotfiles/common/.local/bin/ → 33 universal scripts
-~/.dotfiles/dwm/.local/bin/ → 8 X11/DWM scripts
-~/.dotfiles/hyprland/.local/bin/ → 7 Hyprland scripts
-```
-
-## Shell Configuration
-```
-~/.profile → Environment variables only (POSIX compatible)
-~/.bash_profile → Sources .profile + .bashrc for login shells
-~/.bashrc → Bash-specific settings, sources .bashrc.d/
-~/.zshrc → Zsh-specific settings, sources .zshrc.d/
-~/.profile.d/ → Modular env vars (display, framework, auto-tmux, hyprland)
-~/.bashrc.d/ → Modular bash configs (aliases, fzf, git, media, utilities, emacs)
-~/.zshrc.d/ → Modular zsh configs (same + arch-downgrade)
-```
-
-## Theme System
-Two themes available: dupre (default) and hudson. Switched via `set-theme` script.
-
-Full palette reference: `assets/color-themes/dupre/dupre-palette.org`
-
-- **GTK**: Adwaita-dark
-- **Qt**: Adwaita-Dark (via adwaita-qt5/qt6, QT_STYLE_OVERRIDE)
-- **Icons**: Papirus-Dark
-- **Cursors**: Bibata-Modern-Ice (size 24)
-- **Font**: BerkeleyMono Nerd Font (13pt terminal, 14px waybar)
-- **Theme configs**: `~/.dotfiles/hyprland/.config/themes/dupre/`
-
-## Key Configuration Files
-- `~/.dotfiles/hyprland/.config/hypr/hyprland.conf` - Main Hyprland config
-- `~/.dotfiles/hyprland/.config/themes/dupre/` - Dupre theme files (foot, fuzzel, waybar, dunst, hyprlock, Xresources)
-- `~/.dotfiles/common/.profile.d/` - Shell environment scripts
-- `~/.dotfiles/common/.config/qt5ct/qt5ct.conf` - Qt5 theming
-- `~/.dotfiles/common/.config/qt6ct/qt6ct.conf` - Qt6 theming
-
-## Notes
-- Desktop file overrides go in `~/.dotfiles/hyprland/.local/share/applications/`
-- MPD is configured but mpv handles audio file associations
-- Firewall is ufw (configured in `archsetup`, default-deny incoming, explicit allow list). Tailscale traffic **does** traverse ufw on ratio — a probe from a tailnet IP is still blocked unless a rule covers the port. Don't assume tailnet-only services bypass the firewall; they need an explicit ufw rule like any other.
-- Never assume which machine this is — always run `uname -n` to find the hostname (the `hostname` binary is absent, so `uname -n` is the source of truth; `uname -r` is the kernel release, not the host). The fleet is **ratio** (workstation) and **velox** (laptop), both Hyprland (Wayland). archsetup still supports dwm/X11, but no current machine uses it.
-- Remote repository on cjennings.net
-- .ai/ is gitignored; living project context is in .ai/notes.org
diff --git a/Makefile b/Makefile
index 629c750..a08ddb5 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,18 @@
# (https://git.cjennings.net/dotfiles.git). Run them from there:
# cd ~/.dotfiles && make stow|restow|reset|unstow|import <de>
-.PHONY: help deps test-unit test test-keep test-vm-base package-diff
+.PHONY: help deps test-unit test-elisp test test-keep test-vm-base test-maint package-diff
+
+# Filesystem profile for the VM harness: btrfs (default) or zfs. Selects the
+# base image the scripts build/use; exported so create-base-vm.sh + run-test.sh
+# pick the matching archangel config and image. e.g. make test FS_PROFILE=zfs
+FS_PROFILE ?= btrfs
+export FS_PROFILE
+ifeq ($(FS_PROFILE),btrfs)
+BASE_IMAGE := vm-images/archsetup-base.qcow2
+else
+BASE_IMAGE := vm-images/archsetup-base-$(FS_PROFILE).qcow2
+endif
# Default target - show help
help:
@@ -13,12 +24,17 @@ help:
@echo ""
@echo "Targets:"
@echo " deps Install VM-testing dependencies"
- @echo " test-unit Run fast unit tests for installer helpers (no VM)"
+ @echo " test-unit Run fast unit tests (python + elisp, no VM)"
+ @echo " test-elisp Run just the elisp (ERT) suites"
@echo " test Run full VM test suite (creates base VM if needed)"
@echo " test-keep Run test and keep VM running for manual testing"
@echo " test-vm-base Create base VM only (runs archangel)"
+ @echo " test-maint Run maint remedy scenarios in the VM (break/fix/assert)"
@echo " package-diff Compare archsetup's declared packages vs this system"
@echo ""
+ @echo "Filesystem profile (test, test-keep, test-vm-base):"
+ @echo " FS_PROFILE=btrfs (default) or zfs, e.g. make test FS_PROFILE=zfs"
+ @echo ""
@echo "Dotfile stow operations now live in the dotfiles repo:"
@echo " cd ~/.dotfiles && make stow|restow|reset|unstow|import <de>"
@echo ""
@@ -27,7 +43,8 @@ help:
deps:
@echo "Installing VM testing dependencies..."
sudo pacman -S --needed qemu-full virt-manager virt-viewer libguestfs \
- bridge-utils dnsmasq archiso sshpass socat
+ bridge-utils dnsmasq archiso sshpass socat \
+ python-pytest python-pytest-testinfra
@echo ""
@echo "Done. For VM testing, also ensure libvirtd is running:"
@echo " sudo systemctl enable --now libvirtd"
@@ -35,6 +52,8 @@ deps:
# Run the fast unit-test suites for installer helpers (no VM needed).
# One suite per tests/<name>/ dir; `unittest discover` can't find them
# because the dir names are hyphenated, so each suite is run explicitly.
+# Elisp suites (tests/*/test-*.el, ERT) run in the same pass so one green
+# `make test-unit` covers the whole fast tier.
test-unit:
@fail=0; \
for t in tests/*/test_*.py; do \
@@ -42,6 +61,21 @@ test-unit:
echo "==> $$mod"; \
python3 -m unittest "$$mod" || fail=1; \
done; \
+ for t in tests/*/test-*.el; do \
+ [ -e "$$t" ] || continue; \
+ echo "==> $$t"; \
+ emacs --batch -l ert -l "$$t" -f ert-run-tests-batch-and-exit || fail=1; \
+ done; \
+ exit $$fail
+
+# Run just the elisp (ERT) suites
+test-elisp:
+ @fail=0; \
+ for t in tests/*/test-*.el; do \
+ [ -e "$$t" ] || continue; \
+ echo "==> $$t"; \
+ emacs --batch -l ert -l "$$t" -f ert-run-tests-batch-and-exit || fail=1; \
+ done; \
exit $$fail
# Create base VM for testing (runs archangel only, no archsetup)
@@ -50,22 +84,34 @@ test-vm-base:
# Test - run full VM integration test suite (creates base VM if needed)
test:
- @if [ ! -f vm-images/archsetup-base.qcow2 ] || \
- ! qemu-img snapshot -l vm-images/archsetup-base.qcow2 2>/dev/null | grep -q "clean-install"; then \
- echo "Base VM not found or missing snapshot, creating..."; \
+ @if [ ! -f $(BASE_IMAGE) ] || \
+ ! qemu-img snapshot -l $(BASE_IMAGE) 2>/dev/null | grep -q "clean-install"; then \
+ echo "Base VM not found or missing snapshot, creating ($(FS_PROFILE))..."; \
bash scripts/testing/create-base-vm.sh; \
fi
@bash scripts/testing/run-test.sh
# Test and keep VM running (for manual testing after archsetup)
test-keep:
- @if [ ! -f vm-images/archsetup-base.qcow2 ] || \
- ! qemu-img snapshot -l vm-images/archsetup-base.qcow2 2>/dev/null | grep -q "clean-install"; then \
- echo "Base VM not found or missing snapshot, creating..."; \
+ @if [ ! -f $(BASE_IMAGE) ] || \
+ ! qemu-img snapshot -l $(BASE_IMAGE) 2>/dev/null | grep -q "clean-install"; then \
+ echo "Base VM not found or missing snapshot, creating ($(FS_PROFILE))..."; \
bash scripts/testing/create-base-vm.sh; \
fi
@bash scripts/testing/run-test.sh --keep
+# Maintenance-console remedy scenarios: break the VM, run the real
+# `maint fix`, assert the post-state. Grouped batches over the clean-install
+# snapshot; the base image is restored afterwards. `--list` variant to
+# preview: bash scripts/testing/run-maint-scenarios.sh --list
+test-maint:
+ @if [ ! -f $(BASE_IMAGE) ] || \
+ ! qemu-img snapshot -l $(BASE_IMAGE) 2>/dev/null | grep -q "clean-install"; then \
+ echo "Base VM not found or missing snapshot, creating ($(FS_PROFILE))..."; \
+ bash scripts/testing/create-base-vm.sh; \
+ fi
+ @bash scripts/testing/run-maint-scenarios.sh
+
# Compare the packages archsetup declares against what's installed here.
# Shows declared-but-missing and installed-but-undeclared (AUR vs official).
package-diff:
diff --git a/archive/task-archive.org b/archive/task-archive.org
new file mode 100644
index 0000000..f1a6938
--- /dev/null
+++ b/archive/task-archive.org
@@ -0,0 +1,1432 @@
+#+TITLE: Task Archive
+#+FILETAGS: :archive:
+
+* Resolved (archived)
+** DONE [#B] Full install logs should contain timestamps
+CLOSED: [2026-02-23 Sun]
+Log filename includes timestamp via =date +'%Y-%m-%d-%H-%M-%S'=.
+Functions =error_warn()=, =error_fatal()=, and =display()= all output timestamps via =date +'%T'=.
+** DONE [#B] Validate DESKTOP_ENV default behavior
+CLOSED: [2026-02-23 Sun]
+Defaults to =hyprland= silently via =desktop_env="${desktop_env:-hyprland}"=.
+Overridable via config file or =DESKTOP_ENV= environment variable.
+** DONE [#B] Test archsetup username/password prompts
+CLOSED: [2026-02-23 Sun]
+Username prompt with regex validation (lines 320-332) and password prompt
+with confirmation (lines 339-353) implemented and functional.
+** DONE [#B] Verify SSH to remote server works
+CLOSED: [2026-02-02 Mon]
+Tested 2026-02-02: ssh cjennings.net returns "connected" successfully.
+SSH key authentication working, no password required.
+** DONE [#B] Verify Proton Mail Bridge retrieves email
+CLOSED: [2026-02-02 Mon]
+Verified 2026-02-02: Proton Mail Bridge running, ports 1143 (IMAP) and 1025 (SMTP)
+listening on 127.0.0.1. mu4e email retrieval functional.
+** DONE [#B] Fix unsafe sed patterns with user input
+CLOSED: [2026-02-23 Sun]
+Quoted =$username= in sed replacement, switched locale and wireless-regdom sed
+patterns to pipe delimiter to avoid conflicts with path/encoding characters.
+** DONE [#B] Fix unsafe heredoc variable expansion
+CLOSED: [2026-02-23 Sun]
+Quoted =UDEVEOF= heredoc and used placeholder + sed replacement pattern (same as hyprpm hook).
+** DONE [#C] Add mountpoint check before ramdisk mount
+CLOSED: [2026-02-23 Sun]
+Added =mountpoint -q= guard before mount; skips with info message if already mounted.
+** DONE [#C] Improve error handling in chained commands :chore:
+CLOSED: [2026-05-07 Thu]
+Line 820: three operations chained with =&&= reported as single failure.
+Broken into separate error-handled steps.
+** DONE [#C] Add comments on complex logic
+CLOSED: [2026-02-23 Sun]
+Added comments explaining wireless region locale-to-ISO3166 mapping and
+archsetup clone strategy (why symlinks need user-owned repo).
+** DONE [#D] Validate reserved usernames
+CLOSED: [2026-02-23 Sun]
+Added check against list of reserved system usernames (root, bin, daemon, sys, etc.).
+** DONE Review: Hyprland conf.d source ordering :chore:
+CLOSED: [2026-05-07 Thu]
+~source = $HOME/.config/hypr/conf.d/*.conf~ was at top of hyprland.conf (line 9).
+Machine-local overrides (gaps, monitor scale) were overwritten by defaults later in the file.
+Fixed by moving source line to end of file. Update stowed hyprland.conf.
+** DONE Review: natural_scroll not set for mouse (only touchpad) :chore:
+CLOSED: [2026-05-07 Thu]
+~input:natural_scroll~ was missing; only ~touchpad:natural_scroll~ was set.
+Added ~natural_scroll = true~ to input block.
+** DONE [#B] Extend layout-navigate to escape special workspaces
+CLOSED: [2026-04-19 Sun]
+With the =special:stash= overlay visible and focus on a window inside it,
+=$mod+J= was trapped because =layoutmsg cyclenext= only operates within the
+current workspace. The 2026-04-09 fix handled floating→tiled but not
+special-workspace→regular.
+
+Fix in =dotfiles/hyprland/.local/bin/layout-navigate=: when the active
+window's =workspace.name= begins with =special:= and the user is navigating
+focus (not moving), dispatch =togglespecialworkspace <name>= first, re-read
+activewindow state, then fall through to the existing floating/layout
+branches. Move variant (=$mod SHIFT J=) is intentionally left untouched so
+moving a window out of a scratchpad remains a deliberate separate action.
+
+Unit tests live in =tests/layout-navigate/= (stdlib =unittest=, fakes
+=hyprctl= via PATH). Run with:
+=python3 -m unittest tests.layout-navigate.test_layout_navigate=
+** DONE Check linux-lts version until 6.18+
+CLOSED: [2026-03-07 Sat]
+Run =topgrade= and check =pacman -Q linux-lts=. Once 6.18+, remove =/etc/modprobe.d/amdgpu.conf= and mark this DONE.
+Background: AMD Strix Halo VPE power gating bug causes system freeze. Workaround disables power gating. Fix is in kernel 6.15+.
+Running linux-lts 6.18.16-1. amdgpu.conf workaround already removed.
+** DONE [#D] Find or create a monocle layout for Hyprland
+CLOSED: [2026-03-07 Sat]
+Both existing monocle plugins (zakk4223/hyprlandMonocle, pianocomposer321/hyprland-monocle) are
+abandoned and broken against current Hyprland. Options: fork and fix hyprlandMonocle (more features),
+script a pseudo-monocle using fullscreen 1, or wait for a maintained plugin. Lower priority since
+stash-window ($mod+O / $mod+Shift+O) covers the main use case. More important for laptop installs.
+Resolved: Hyprland 0.54 added native monocle layout. Bound to $mod SHIFT M.
+** DONE [#B] Investigate rlwrap not installed after archsetup run
+CLOSED: [2026-05-11 Mon]
+rlwrap was declared in archsetup (Emacs Dependencies) but missing after a run on ratio (2026-02-06).
+The 2026-05-11 VM test run shows it installs cleanly in a fresh install (=...installing rlwrap via pacman @ 15:36:55=; =rlwrap 0.48-1= in the captured package list), so it doesn't reproduce — likely a one-off / machine-specific glitch on ratio, not a systemic skip. Closing; reopen if it recurs.
+** DONE [#C] Remove stale hyprpm/plugins validations; make run-test.sh tolerant of validation failures
+CLOSED: [2026-05-11 Mon]
+The 2026-05-11 VM test aborted because =validate_hyprland_plugins= in =scripts/testing/lib/validation.sh= checked for =~/.local/bin/hyprland-plugins-setup=, which was deliberately removed in dd543e3 (=feat(hyprland): remove plugins, add layout cycling=; Hyprland 0.54 brings the layouts into core). The function's =return 1= under run-test.sh's =set -e= killed the run before the test report was written or the VM cleaned up.
+Fix: deleted =validate_hyprland_plugins= and =validate_hyprpm_hook= (the hyprpm pacman hook was removed in the same commit) plus their calls in =validate_window_manager=; disabled errexit in =run-test.sh= from the validation phase onward so a failed check is counted (=VALIDATION_FAILED=) instead of fatal — the script signals pass/fail via its exit code at the end. Verified with =bash -n=; the next =make test= run confirms the count-not-abort behavior.
+** DONE [#B] toggle key for touchpad on/off
+CLOSED: [2026-05-20 Wed]
+*** 2026-05-20 Wed @ 18:18:30 -0400 Spec: touchpad toggle + waybar indicator
+
+**** Current state
+A toggle mechanism already exists in the live home dir but is only partly committed.
+- =~/.local/bin/toggle-touchpad= (live, NOT in repo): reads/writes a state file at =${XDG_RUNTIME_DIR:-/tmp}/touchpad-state= (values "enabled"/"disabled"), flips =hyprctl keyword "device[$TOUCHPAD]:enabled" true|false=, and fires a =notify info "Touchpad" ...= toast. Hardcodes =TOUCHPAD="pixa3854:00-093a:0274-touchpad"=.
+- =~/.local/bin/touchpad-auto= (live, NOT in repo): daemon watching Hyprland's =.socket2.sock= for mouseadded/mouseremoved/configreloaded, auto-disables the touchpad when an external mouse is present, writes the same state file. Same hardcoded device name.
+- Keybinding already committed: =bind = $mod, F9, exec, toggle-touchpad= (=hyprland.conf:315=).
+- State file confirmed live at =/run/user/1000/touchpad-state= (reads "enabled").
+
+**** Gap
+1. No waybar indicator — nothing in modules-right shows touchpad state; no =custom/touchpad= module exists.
+2. Neither =toggle-touchpad= nor =touchpad-auto= is committed into the repo. They live only in =~/.local/bin=, so a fresh stow won't install them. They belong in =dotfiles/hyprland/.local/bin/= (the =dotfiles/dwm/.local/bin/toggle-touchpad= is the old X11/xinput version, unrelated).
+3. =touchpad-auto= is never started — no =exec-once= launches it.
+4. The toggle doesn't refresh waybar, so an indicator would lag until its poll interval.
+
+**** Proposed implementation
+1. New status script =dotfiles/hyprland/.local/bin/waybar-touchpad= mirroring =waybar-layout= / =waybar-netspeed= (emit one JSON line: text + tooltip + class). Reads the state file the toggle already writes — single source of truth, no extra hyprctl call. Emits a "disabled" class + off-icon when the state file reads "disabled", else "enabled" + on-icon.
+2. Waybar module in =dotfiles/hyprland/.config/waybar/config=, using "signal" so the toggle pushes an instant refresh (no polling — state only changes on toggle or mouse hotplug):
+ =, "custom/touchpad": { "exec": "waybar-touchpad", "return-type": "json", "signal": 9, "on-click": "toggle-touchpad" }=
+ Add =custom/touchpad= to modules-right, near =idle_inhibitor=.
+3. Refresh-on-toggle: have =toggle-touchpad= (and =touchpad-auto='s set function) run =pkill -RTMIN+9 waybar= after each write to the state file (RTMIN+N ⇄ waybar "signal": N). Alternative: drop "signal", use "interval": 2 (simpler, ~2s lag, constant poll). Signal is the cleaner fit.
+4. =style.css= (=dotfiles/hyprland/.config/waybar/style.css=): add =#custom-touchpad= to the shared padding/hover selector lists; add =#custom-touchpad.disabled { color: #d47c59; }= (the dupre orange already used for warnings). Enabled state inherits the default color.
+5. Keybinding: keep =$mod+F9= (=hyprland.conf:315=). The waybar on-click gives a mouse path to the same action.
+6. Commit the live scripts so stow installs them: =toggle-touchpad= and =touchpad-auto= into =dotfiles/hyprland/.local/bin/= (plus the =pkill= line), and =waybar-touchpad= (new). If the auto-disable-on-external-mouse behavior is wanted at boot, add =exec-once = touchpad-auto= near the other daemon exec-once lines.
+
+**** Decisions (Craig, 2026-05-20)
+1. Icons: 󰍽 enabled / 󰍾 disabled (the mouse / mouse-off pair).
+2. Waybar on-click toggles the touchpad.
+3. Commit =touchpad-auto= and add its =exec-once= so it runs at login.
+4. Signal-driven refresh (=pkill -RTMIN+9 waybar=).
+Note: the hardcoded device name =pixa3854:00-093a:0274-touchpad= is Framework-laptop-specific — a portability concern for other machines, not a blocker for this task.
+
+*** 2026-05-20 Wed @ 18:29:06 -0400 Implemented the toggle + waybar indicator (in repo)
+Built per spec + decisions above. Committed the two formerly-live-only scripts into the repo and added the indicator:
+- =dotfiles/hyprland/.local/bin/waybar-touchpad= (new) — reads =$XDG_RUNTIME_DIR/touchpad-state=, emits JSON (text/tooltip/class), fail-safe to "enabled". Unit-tested in =tests/waybar-touchpad/= (6 Normal/Boundary cases).
+- =dotfiles/hyprland/.local/bin/toggle-touchpad= — copied from =~/.local/bin=, added =pkill -RTMIN+9 waybar= so the indicator refreshes on toggle.
+- =dotfiles/hyprland/.local/bin/touchpad-auto= — copied in, =pkill -RTMIN+9 waybar= inside =set_touchpad= so auto on/off events refresh too. Added =exec-once = touchpad-auto= to =hyprland.conf=.
+- =waybar/config= — =custom/touchpad= module (signal:9, on-click toggle-touchpad), placed in modules-right before idle_inhibitor.
+- =waybar/style.css= — =#custom-touchpad= in padding + hover lists; =.disabled { color: #d47c59 }= (dupre orange).
+- =$mod+F9= bind already present (=hyprland.conf=), left as-is.
+
+*** 2026-05-20 Wed @ 18:36:26 -0400 Deployed + verified on velox
+Discovered =.local/bin= is stow-symlinked (waybar-layout/netspeed point into the repo); the two touchpad scripts were real files only because they weren't committed. Replaced both real files with repo symlinks and symlinked the new =waybar-touchpad= (matching the existing relative-symlink form). velox needed no hyprland.conf change — =exec-once = touchpad-auto= and the =$mod+F9= bind were already present. waybar =config= / =style.css= are real local files on velox (config diverges: standalone battery, no sysmonitor group), so applied targeted edits there rather than a copy.
+
+Verified end-to-end after a waybar restart: config loads with no parse errors; toggle round-trips state enabled → disabled (󰍾, class disabled) → enabled (󰍽), and the =pkill -RTMIN+9 waybar= refresh fires into the running bar. Touchpad left enabled. Visual confirmation (icon in bar, orange when off) is Craig's to eyeball. Other machines (ratio) pick this up on =git pull && make restow hyprland= — their =.local/bin= and waybar configs are symlinks, so no real-file conflict there.
+** DONE [#B] Airplane-mode toggle + waybar indicator
+CLOSED: [2026-05-21 Thu]
+Laptop-only low-power toggle, modeled on the touchpad indicator. Wifi off (bluetooth left alone for earbuds), CPU EPP → power, brightness → 35%, and stops network-only services. Disengage restores only what it recorded, so anything already off stays off.
+*** 2026-05-21 Thu @ 17:43:07 -0400 Built the toggle, indicator, and tests
+- =dotfiles/hyprland/.local/bin/airplane-mode= (new) — toggle. Engage records prior state (wifi enabled/disabled, EPP value, brightness, which services were active) to =$XDG_RUNTIME_DIR/airplane-state=, then applies low-power: =nmcli radio wifi off=, EPP → power on all CPUs (sudo sysfs write), =brightnessctl set 35%=, and stops Tier 1+2 services (tailscaled, proton.VPN, avahi-daemon, cups, wsdd, geoclue, sshd, fail2ban + user syncthing). Disengage replays the recorded state — only re-enables wifi if it was on, only restarts services it stopped. Refreshes the bar via =pkill -RTMIN+10 waybar=.
+- =dotfiles/hyprland/.local/bin/waybar-airplane= (new) — indicator. Reads =mode= from the state file; fail-safe to inactive. Laptop-gated: exits silently (module hidden) when no battery is present (=/sys/class/power_supply/BAT*=). One clear plane glyph (FA U+F072) for both states; color carries state (gold active / gray inactive).
+- =waybar/config= — =custom/airplane= module (signal 10, on-click airplane-mode), placed after custom/touchpad. =waybar/style.css= — =#custom-airplane= in padding + hover lists; =.active { color: #d7af5f }= (dupre gold).
+- Tests: =tests/airplane-mode/= (20 — engage/disengage/preserve-existing-state/dispatch, via command stubs + fake EPP sysfs) and =tests/waybar-airplane/= (10 — states/boundary/laptop-gating). All green; shellcheck clean.
+- Deployed + live-verified on velox (engage → disengage round-trip works). Other machines pick it up via git pull && make restow hyprland.
+** DONE [#C] super+e emacs launch doesn't grab focus from tiled browser :quick:
+CLOSED: [2026-05-22 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-22
+:END:
+Launching emacs with super+e while a browser window is open in tiled mode leaves focus on the browser instead of moving it to the newly opened emacs window in the main (left) portion of the screen. Expected: the new emacs window takes focus. Noticed 2026-05-22.
+
+Resolved 2026-05-22: not a focus *failure* but a focus *fight*. Live socket2 capture showed the new (XWayland, non-pgtk Emacs 30.2) frame does get focus on open, then Firefox reclaims it via an activation request because =misc:focus_on_activate=true=. Set it =false= in the dotfiles repo (=3bfba5a=) — new-window focus is a separate path so emacs still focuses on open, but the browser can no longer steal it back. Verified by Craig.
+** DONE [#C] Dim inactive windows in Hyprland :hyprland:
+CLOSED: [2026-05-27 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-26
+:END:
+Shipped in the =~/.dotfiles= repo (=66124e8=): =dim_inactive = true=, =dim_strength = 0.4= (tuned by eye), =dim_special = 0.2= for pyprland scratchpads, and a =no_dim true= window rule for Zoom. The opt-out rule is =no_dim= (underscore), not =nodim= — the latter throws a config-error banner. Config uses Hyprland 0.55's =windowrule = match:class ...= grammar.
+** CANCELLED [#A] Prevent X termination and VT switching (security risk)
+CLOSED: [2026-05-21 Thu]
+If someone grabs laptop at cafe and hits ctrl+alt+backspace, they kill screensaver/X and get console access
+Need to disable: ctrl+alt+backspace (zap X) and ctrl+alt+F# (VT switching)
+Previous attempts to configure in xorg.conf.d failed - need to investigate what's overriding the settings
+Tried: /etc/X11/xorg.conf.d/00-no-vt-or-zap.conf with DontVTSwitch and DontZap options
+Removed conflicting setxkbmap statements, gdm, and keyd configs - still didn't work
+** DONE [#B] Add Rust installation via rustup instead of pacman package :quick:
+CLOSED: [2026-05-26 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Already implemented — =archsetup= lines 1976-1979 (Programming Languages and Utilities) =pacman_install rustup= then =rustup default stable= as the user. Closing on verification; the task predated that work.
+
+The =rust= package has been removed from archsetup. Need to add Rust installation using =rustup= (the official Rust toolchain manager) instead of the Arch package.
+
+Steps:
+- Install rustup: =pacman -S rustup=
+- Initialize default toolchain: =rustup default stable=
+- Consider adding to archsetup or post-install script
+
+Reference: Removed from archsetup on 2025-11-15
+** CANCELLED [#D] Add cpupower installation and enabling to archsetup :quick:
+CLOSED: [2026-05-26 Tue]
+Implemented, VM-verified, then removed — wrong tool for this fleet. Both machines run active-mode pstate drivers (ratio amd-pstate-epp, velox intel_pstate) where only performance/powersave exist and the driver self-manages frequency via EPP; both correctly sit on powersave. cpupower's governor-forcing only helps older acpi-cpufreq systems, which we don't run. Forcing performance would pin max clocks (worse on the laptop, pointless on the desktop). Dropped from archsetup rather than ship a backwards default.
+
+cpupower service configures the default CPU scheduler (powersave or performance)
+Install cpupower, configure /etc/default/cpupower, enable service: ~systemctl enable --now cpupower.service~
+** DONE [#C] Airplane-mode toggle robustness follow-ups :quick:solo:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Shipped 2026-06-10 as dotfiles commit =16fbe4e=, TDD'd (23 tests green). Both gaps closed: the toggle now no-ops without a BAT* (same check as waybar-airplane, AIRPLANE_POWER_SUPPLY_DIR override for tests), and an empty recorded brightness at disengage falls back to 100% (AIRPLANE_BRIGHTNESS_DEFAULT) instead of stranding the screen at 35%.
+** DONE [#B] protonmail-bridge package service conflicts with Hyprland autostart :cmail:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Craig confirmed resolved 2026-06-10 — the per-machine fix (disable the packaged user service, Hyprland exec-once as sole launcher) has held since 2026-05-22 with no recurrence.
+
+The =protonmail-bridge= package ships an enabled systemd user service (=/usr/lib/systemd/user/protonmail-bridge.service=, =--noninteractive=, =Restart=always=) that double-launches with the Hyprland =exec-once = protonmail-bridge --no-window= GUI autostart. Two symptoms: (1) no tray icon — the headless service grabs ports 127.0.0.1:1143/:1025 before the GUI =--no-window= instance can bind; (2) TLS cert mismatch — the headless service can't reach gnome-keyring (starts outside the graphical session), falls back to its own self-signed cert, so =mbsync=/mu4e and cmail-action.py fail STARTTLS against =~/.config/protonbridge.pem= with SSL CERTIFICATE_VERIFY_FAILED.
+
+Fix applied per-machine 2026-05-22: =systemctl --user disable --now protonmail-bridge.service=, leaving the Hyprland exec-once GUI as the sole bridge (tray icon returns, served cert matches, =mbsync -a= clean). A fresh install re-enables the package service, so make it durable: mask/disable =protonmail-bridge.service= during install (likely in =scripts/cmail-setup-finish.sh=) and document that the Hyprland exec-once is the intended launcher — never run both. Source: handoff from .emacs.d 2026-05-22.
+** DONE [#B] Add signal-cli to the standard install :tooling:signal:solo:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Shipped 2026-06-10 as archsetup commit =1229fb2= — =aur_install signal-cli= beside signal-desktop, with the JRE/update-cadence/manual-linking caveats as comments.
+
+Add =signal-cli= (AUR) to the regular package set so every provisioned machine has it. It's the headless JSON-RPC engine for an in-Emacs Signal client (a =signel= fork) that's the same across all machines. Source: handoff from .emacs.d 2026-05-26.
+
+- =aur_install signal-cli= in the appropriate section (comms/messaging or AUR utilities).
+- Runtime needs a JRE (OpenJDK 17+) — already satisfied by =jdk-openjdk=; note it as a dependency if the install set is ever trimmed.
+- Keep-current caveat: signal-cli must update roughly every 3 months or Signal-Server rejects it (client-version floor moves). It belongs in the regularly-updated AUR set, not pinned.
+- Linking is per-machine and interactive (QR scan from phone's Linked Devices), so that stays manual. archsetup only guarantees the binary is present.
+** DONE [#B] Mic-mute keybind + waybar indicator :waybar:hyprland:solo:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Shipped 2026-06-10 as dotfiles commit =07d056c= (script + 5 unit tests + bind + waybar module + CSS in all three theme files; old CTRL+ALT+SPACE bind removed). Verified live on ratio: state flips in wpctl, indicator renders both states with correct glyphs and colors, notifications fire. velox picks it up via pull + restow.
+
+A single mute state in PipeWire, reachable from a keybind and a waybar indicator, each reflecting the other. Agreed design (2026-06-10):
+
+- *Keybind*: Super+Shift+A (=bindl= so it works on the lock screen), running a =mic-toggle= script in =hyprland/.local/bin/=: =wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle=, then read the new state and fire =notify= (alert "Mic muted" / success "Mic live"). wpctl targets PipeWire's default source, so the bind keeps working if the default mic changes (ratio has three capture devices).
+- *Waybar indicator*: a second pulseaudio module instance (=pulseaudio#mic=) using =format-source= / =format-source-muted= — waybar subscribes to PipeWire events natively, so the keybind and the click both update the icon with no signal plumbing (unlike =custom/dim=). =on-click= runs the same wpctl toggle.
+- *Icons*: Nerd Font MD glyphs — mic (U+F036C) live, mic-off (U+F036D) muted — matching the MD volume glyphs already in the pulseaudio block. Verify by rendering, not by name (BerkeleyMono remaps codepoints; see the 2026-06-10 glyph lesson).
+- *Coloring* (dupre): default =#969385= when live; =#d47c59= when muted — same semantic as =#custom-touchpad.disabled= (an input device turned off). The gold =#d7af5f= stays reserved for active/attention states (airplane, dim). Mirror the rule in the hudson theme's waybar css with its palette equivalent.
+- *Remove the old mechanism entirely*: the =CTRL ALT, SPACE= amixer Capture-toggle bind in =hyprland.conf= (~line 325) — ALSA-level, fragile with multiple capture devices, brittle notify grep chain.
+
+Lives in the dotfiles repo (=hyprland/.config/hypr/hyprland.conf=, =hyprland/.config/waybar/=, =hyprland/.local/bin/=). TDD the =mic-toggle= script per the dotfiles suite. velox picks it up via pull + restow.
+** DONE [#B] Waybar theme-CSS drift — live style.css ahead of theme copies :waybar:hyprland:solo:
+CLOSED: [2026-06-11 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-11
+:END:
+Shipped 2026-06-10/11 across two dotfiles commits: =1589734= reconciled dupre to a byte-copy of the live style.css, rebuilt hudson with the full live selector set in its palette, and added the guard suite (dupre must equal live; hudson must cover every live selector). The same guards were extended to the foot.ini family in =c5e699b= when the per-host work touched it (set-theme overwrites foot.ini the same way). The symlink-instead-of-cp alternative wasn't needed — the test guard catches drift at =make test= time.
+** DONE [#B] Add =uv= to the install playbook :tooling:python:solo:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Shipped 2026-06-10 as archsetup commit =3e22b06= — =pacman_install uv= in the Python tooling block (uv 0.11.19 in extra). Exercised by the same-day hyprland VM run.
+
+Add =uv= (Astral's Python package + script runner) to archsetup so fresh machines pick it up automatically. Currently installed by hand on ratio + velox (=/usr/bin/uv= 0.11.15), not in the standard set — a fresh install would skip it, and project scripts using PEP 723 inline-script metadata (=#!/usr/bin/env -S uv run --script= shebangs) would fail with =env: uv: No such file or directory=. Source: handoff from health 2026-05-29 ([[file:assets/outbox/2026-05-29-1127-from-health-todo-a-add-uv-to-the-install-playbook.org][outbox copy]]).
+
+Health requested [#A] (load-bearing for the PEP 723 pattern they're promoting + the rulesets template-script proposal). Demoted to [#B] for archsetup: no current install is broken (uv is pre-installed everywhere it's needed), and the shape matches the existing [#B] tooling-codification tasks (eask, signal-cli) — load-bearing for other projects, manually installed today, codify so fresh installs pick it up.
+
+- *Install via pacman* — =uv= is in extra (=pacman -S uv=). Cleanest path; auto-updates with the rest of the system. AUR =uv-bin= and Astral's official installer are alternatives but add a non-pacman path to maintain.
+- *Placement* — alongside the existing language-tooling block in =archsetup= (near =rustup=, =nvm=, or the Python set). Decide the exact section at implementation time.
+- *Verification* — post-install =which uv && uv --version=; PEP 723 end-to-end check per the health handoff (=/tmp/uv-test.py= shebang script with inline =requests= dep).
+
+Related: the new [#B] LLM task above may grow scripts that benefit from PEP 723 (e.g. =scripts/llm-smoke-test.sh= if Python-based). =uv= landing here removes that friction.
+** DONE [#A] Separate dotfiles from archsetup
+CLOSED: [2026-06-09 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-09
+:END:
+*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Dotfile separation plan
+Approach: keep =dotfiles/= committed in this repo as the working default (Craig's machines and CI keep functioning untouched), but make the *source location* a config variable. The install script learns one new conf key — =DOTFILES_REPO= / =DOTFILES_BRANCH= — and when set, clones that repo into =~/.dotfiles= and stows from there instead of from =dotfiles/= inside archsetup. The Makefile gets a =DOTFILES= override env var so the same stow targets work whether dotfiles live in-repo or elsewhere. No submodule (adds fragility for a curl|bash installer); a separate published =archsetup-dotfiles= repo is optional follow-up, not a blocker.
+
+1. Add conf keys to =archsetup.conf.example= under the "Git Repositories" block (after line 57): =DOTFILES_REPO= (commented, with note "leave unset to use the dotfiles bundled with archsetup"), =DOTFILES_BRANCH= (default =main=), and =DOTFILES_DIR= (target clone path, default =~/.dotfiles=). Document that a user's repo must have =common/= plus optionally =dwm/= and =hyprland/= subdirs that stow cleanly to =~=.
+2. In =archsetup= lines 114-122, map =DOTFILES_REPO=/=DOTFILES_BRANCH=/=DOTFILES_DIR= to lowercase vars. At lines 136-146, leave =dotfiles_dir="$archsetup_dir/dotfiles"= as the fallback default and add =dotfiles_repo="${dotfiles_repo:-}"=.
+3. In =user_customizations()= (lines 828-854): after the archsetup clone (line 838-841), branch — if =dotfiles_repo= is non-empty, =git clone --depth 1 --branch "$dotfiles_branch" "$dotfiles_repo" "$dotfiles_clone_dir"= (chown to user) and set =dotfiles_dir="$dotfiles_clone_dir"=; else keep =dotfiles_dir="$user_archsetup_dir/dotfiles"= (line 844). The stow calls at lines 847-854 stay as-is since they just =cd "$dotfiles_dir"=. Guard the hyprland stow (851) so it no-ops if the user repo has no =hyprland/= dir.
+4. The waybar-battery sed block (lines 856-865) and the =git restore= step (lines 896-902) both assume Craig's exact files — wrap each in an existence check (=[[ -f "$waybar_config" ]]=, and only =git -C "$dotfiles_dir" restore .= when =dotfiles_dir= is a git repo). Right now they'd error on a foreign dotfiles tree.
+5. =Makefile= line 5: change =DOTFILES := $(shell pwd)/dotfiles= to =DOTFILES ?= $(shell pwd)/dotfiles= so a user with external dotfiles runs =make stow hyprland DOTFILES=~/.dotfiles=. =reset= (line 123, =git checkout -- dotfiles/=) and =import= (writes to =$(DOTFILES)/$(DEST)=) already key off =$(DOTFILES)= except that one hardcoded path — fix line 123 to =git -C $(DOTFILES) checkout -- .=. Update the =help= text (lines 16-45) to mention the =DOTFILES== override.
+6. Migration: this is purely additive. Default behavior = today's behavior, so Craig's existing machines and =make test= VMs are unaffected. Craig can later extract =dotfiles/= to =git.cjennings.net/archsetup-dotfiles= and set =DOTFILES_REPO= in his own =archsetup.conf= — but that's his choice and a separate commit; the in-repo copy stays as the canonical default. Update =CLAUDE.md= "Project Structure" + "Makefile Targets" sections to document the override.
+7. Simple-UX summary to put in =archsetup.conf.example= and README: "Accept the defaults → you get the bundled dotfiles (DWM or Hyprland). Bring your own → set =DOTFILES_REPO= to your git URL; it gets cloned to =~/.dotfiles= and stowed automatically." Optional stretch: ship a tiny =dotfiles/minimal/= (bash/zsh/git/tmux only) and a conf option =DESKTOP_ENV=none= path that stows just that.
+
+Open questions for Craig:
+- Extract =dotfiles/= to a standalone repo now, or defer? (Plan above defers — keeps it as the in-repo default.)
+- Clone target: =~/.dotfiles= vs =~/code/dotfiles= vs let the user pick via =DOTFILES_DIR=?
+- Should =DOTFILES_REPO= unset mean "bundled dotfiles" (proposed) or "no dotfiles at all"?
+- Do you want the minimal/headless starter set (=dotfiles/minimal/=), or out of scope?
+- Keep =git restore=/=git checkout= conflict-recovery behavior only when dotfiles are a git checkout, and silently skip otherwise — acceptable?
+
+*** 2026-05-13 Wed @ 13:07:54 -0500 AI Response: Open questions answered; full spec written
+All 5 questions resolved: extract now (Q1), clone to =~/.dotfiles= (Q2), no opt-out — =DOTFILES_REPO= always has a default (Q3), include =minimal/= as Tier B with TUI apps (Q4), error out if dotfiles dir isn't a git checkout (Q5). Full spec at [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]] — covers the =minimal/= tree, SSH/GPG availability, three-phase implementation plan (Phase 1: extract + populate new repo at =cjennings.net/archsetup-dotfiles.git=; Phase 2: wire archsetup + VM test; Phase 3: migrate machines + remove =dotfiles/=), commit map, and open observations. Implementation gated on spec review.
+
+*** 2026-05-14 Thu Review docs/PLAN-dotfiles-separation.org
+CLOSED: [2026-05-14 Thu]
+Review the spec for accuracy, edge cases, and scope. Flag changes before implementation starts. See [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]].
+
+*** 2026-05-14 Thu @ 21:43:41 -0500 AI Response: Review resolved; spec locked for Phase 1
+Walked the spec's 5 open questions plus my 5 review concerns. Locked: URL =https://git.cjennings.net/dotfiles.git= (anonymous HTTPS read confirmed against existing repos at the same host), bare repo path =/var/git/dotfiles.git=, scope = Phase 1 only (~30 min). Added =environment.d/envvars.conf= (with rofi path stripped) and =systemd/user/emacs.service= to the =minimal/= tree; skipped =ncmpcpp= and =systemd/user/geoclue-agent.service=. Phase 2/3 constraints folded into the spec body for the executor: =DESKTOP_ENV=none= VM test required (was optional), clone uses =sudo -u "$username"= to avoid chown-after races, Phase 3 unstow/restow runs without an intermediate Hyprland reload, dotfiles repo can't go on GitHub until secrets cleanup ships, and Step 3.3 documents the post-install update flow. Latest spec at =docs/PLAN-dotfiles-separation.org= (=817d939=). End-of-day Phase 1 session reads from there and executes.
+
+*** 2026-05-22 Fri @ 13:41:08 -0500 Phase 1 executed — dotfiles repo live on cjennings.net
+Created the bare repo at =/var/git/dotfiles.git=, extracted =dotfiles/= from archsetup with =git filter-repo --subdirectory-filter= (229 commits, per-file history preserved), built the =minimal/= stow target per the spec, and pushed to =git@cjennings.net:dotfiles.git= (HEAD =68daeab=). Anonymous read at =https://git.cjennings.net/dotfiles.git= confirmed. Two spec corrections committed in archsetup (=7c26495=): push URL switched to SSH (HTTPS is read-only), and =minimal/.profile.d/= now ships 5 files including =claude.sh= (added on Craig's call, post-dated the spec lock). Phase 2 (wire archsetup config + VM test, ~2-3 hrs) and Phase 3 (migrate machines, remove =dotfiles/= from archsetup) remain.
+
+*** 2026-05-22 Fri @ 17:05 -0500 Phase 2 shipped — archsetup clones the dotfiles repo
+Wired archsetup to the external dotfiles repo: clones =DOTFILES_REPO= to =~/.dotfiles= and stows per =DESKTOP_ENV= (dwm/hyprland → common + that DE; none → minimal). Added =DOTFILES_REPO=/=BRANCH=/=DIR= config keys + validation; test harness serves the repo to the VM as =/tmp/dotfiles-test=. Commits =bab6901= (feat) + =68172c8= (test infra), pushed to origin/main. Spec-directed =sudo -u= clone hit a real bug — =useradd -m= skips the home-dir chown when =/home/$username= pre-exists (root-owned), so the user-clone failed with Permission denied; fixed by cloning as root + =chown -R= (mirrors the archsetup clone). git restore now runs for all DE paths (minimal ships skel-colliding .bashrc etc.).
+
+*** 2026-05-22 Fri @ 18:10 -0500 Phase 3.1 + 3.3 done — this machine on ~/.dotfiles
+Migrated this workstation: cloned the dotfiles repo to =~/.dotfiles=, committed the gpg-agent SSH routing (=.zshenv= + =envvars.conf=) that was uncommitted in the live tree as =888a599= in the dotfiles repo, then =make unstow hyprland= + =make stow hyprland DOTFILES=~/.dotfiles=. Snag: unstowing while Hyprland ran made it write a stub hyprland.conf that blocked the restow — quit Hyprland, removed the stub, restowed clean. All symlinks now resolve into =~/.dotfiles=. CLAUDE.md updated with the external-repo docs + migration steps + the quit-Hyprland gotcha (=e1810ce=). Remaining: 3.2 (=git rm dotfiles/=) blocked until ratio + velox migrate the same way.
+
+*** 2026-05-22 Fri @ 21:20 -0500 velox migrated to ~/.dotfiles (laptop overrides preserved)
+ratio is THIS machine (was "fractal" pre-reinstall) — migrated in 3.1. velox migrated over SSH (Craig quit its Hyprland): cloned ~/.dotfiles, stowed common+hyprland from it. velox carries deliberate laptop-local real-file overrides (foot.ini font 12, pypr config.toml laptop scratchpad sizing, waybar config battery module) that shadow stow — preserved them as local real files (backed up, restowed the rest, restored the overrides). All machines now on ~/.dotfiles.
+
+*** 2026-06-02 Tue @ 12:16:54 -0500 Phase 3.2 done — removed in-repo dotfiles/ from archsetup
+git rm'd the in-repo =dotfiles/= tree (831 files) now that ratio + velox both stow from =~/.dotfiles=; the installer already clones DOTFILES_REPO so nothing read it at install time. Stripped the stow targets from archsetup's Makefile (kept VM-integration + the safe-rm-rf installer-helper suite). Updated CLAUDE.md (Project Structure, Makefile Targets, Dotfiles Repository, Script Counts, Theme/Key-Config path refs) and README.md (dotfile-management, theme, DE, unit-test sections) to point at =~/.dotfiles=; the README had been describing the pre-Phase-2 in-repo model. Commit b10cba5 on archsetup origin/main. velox + ratio local clones drop dotfiles/ on their next archsetup pull (ratio: see the "Pull Phase 3.2 changes onto ratio" task). 4 untracked calibre cache/annotation files that were never committed got moved aside to /tmp/archsetup-dotfiles-orphan-untracked-20260602 (disposable reading-position markers).
+
+*** 2026-06-02 Tue @ 12:16:54 -0500 Migrated script unit-test suites + a Makefile into ~/.dotfiles
+Gave =~/.dotfiles= its own Makefile rather than repointing archsetup's =DOTFILES= default — the dotfiles repo now owns its stow tooling and tests, so it manages and validates standalone (relevant to the open-source release too). Authored =~/.dotfiles/Makefile= with the stow family (=stow/restow/reset/unstow/import= + check-de/check-dest + DE/DEST machinery) plus a =make test= target (mirrors archsetup's hyphenated-dir test-unit loop). Moved-Makefile fixups: =DOTFILES := $(shell pwd)= (trees at repo root), =reset='s revert scoped to =git checkout -- common $(DE)= (not the whole repo — caught in review), import header/path "dotfiles/$(DEST)" → "$(DEST)", =minimal= added to the import DEST filter only.
+
+Moved 6 suites (=airplane-mode=, =layout-navigate=, =notify=, =tmux-util=, =waybar-airplane=, =waybar-touchpad=) into =~/.dotfiles/tests/=, dropping the =dotfiles/= =SCRIPT=-path prefix (=REPO_ROOT= is now the dotfiles root), and copied their fixtures (=layout-navigate/fake-hyprctl=, =tmux-util/fake-{fzf,kill,sleep,tmux}=). =waybar-netspeed='s suite was already there. =safe-rm-rf= stayed in archsetup (it tests the installer, not a dotfile). =make test= green: 7 suites, 124 tests. Committed 59b10c4 + pushed to the dotfiles repo. =minimal= is a standalone tree (stowed alone, not =common + minimal=), so a =make stow minimal= target needs its own branch — deferred as a small follow-up; the move kept stow/restow/reset/unstow behavior-identical to archsetup (dwm/hyprland).
+
+*** 2026-06-09 Tue @ 19:21:36 -0500 Pulled Phase 3.2 onto ratio + cleaned dangling links
+ratio's archsetup clone was already current with origin/main (Phase 3.2 pulled), but the migration had left stale symlinks pointing into the now-deleted =~/code/archsetup/dotfiles=: =~/.config/calibre= plus a manual =~/music/radio/= playlist farm (73 broken =.m3u= links) and one dead reference under =~/projects/home/reconciliation=. Re-pointed calibre into =~/.dotfiles/common/.config/calibre=. Deleted the 73 radio links — dead and redundant, since the same playlists already stow correctly to =~/music/*.m3u=, which is what mpd reads (=music_directory=/=playlist_directory= both =~/music=) — and removed the reconciliation link. ratio now has zero archsetup-dangling symlinks. (The ~3400 other dangling links in =~= are unrelated system/flatpak noise: ca-certificates, =/run/host=, =/bin=.)
+** DONE [#B] Cleaner per-machine override mechanism for the dotfiles repo
+CLOSED: [2026-06-11 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-11
+:END:
+Shipped 2026-06-11 as dotfiles =c5e699b= after spec review (all five questions decided — see the spec's Status table). Host tiers =ratio/= + =velox/= auto-included by every stow target; first tenants: hypr local.conf (velox HiDPI scale + XWayland toolkit env, replacing the Zoom per-app hack), pypr whole-file split, foot font via per-host host.ini include. waybar stays shared (velox's was stale, not divergent). velox restows cleanly for the first time and caught up on all pending dotfiles work. Drift guards extended to foot.ini; Makefile host logic unit-tested (15 suites green). Phase 5 (installer) filed as a follow-on below. velox needs a Hyprland restart for the env vars — see Manual testing.
+
+velox keeps laptop-specific configs (foot font, pypr scratchpad sizing for 2256x1504, waybar battery) as local REAL files shadowing the stow symlinks. That's fragile: any =make restow= on velox re-conflicts (hit exactly this during the 2026-05-22 migration — stow aborts on the real files). The =~/.dotfiles= model needs a real per-machine override story (a =minimal/=-style per-host package, a documented local-override convention with =.stow-local-ignore=, or host-conditional includes) so overrides survive restows without manual backup/restore.
+
+Spec: [[file:docs/PLAN-per-host-overrides.org][docs/PLAN-per-host-overrides.org]] — gated on review before implementation.
+
+*** 2026-06-11 Wed @ 04:40:00 -0500 AI Response: Second restow-conflict instance on velox
+The roam-units rollout hit this again: =make restow hyprland= on velox aborted on three real files (foot.ini font size 12, the velox-tuned pypr config.toml, an older waybar config). The roam systemd units were linked manually to unblock; everything else velox should have picked up from recent dotfiles work (mic-toggle, quick-capture, airplane-mode hardening, ranger plugin) is still NOT stowed there — velox's tree stays partially stale until this mechanism ships. That raises this task's practical urgency: velox can no longer cleanly receive dotfiles changes at all.
+
+*** 2026-05-26 Tue @ 10:21:08 -0500 AI Response: Spec written, gated on review
+Surfaced by a HiDPI scaling failure: a per-app =QT_SCALE_FACTOR=1.5= in the shared =Zoom.desktop= (meant for velox) made Zoom open enormous on ratio. Reverted that patch to plain =/usr/bin/zoom %U=; the durable fix is this mechanism. Proposed approach: a per-host stow tier (=ratio/=, =velox/=) stowed as =common + hyprland + $(uname -n)=, with the existing =conf.d/*.conf= glob as the first clean tenant — move =local.conf= out of the shared =hyprland/= tier into per-host tiers so each machine gets its own (HiDPI monitor scale + =env = QT_SCALE_FACTOR/GDK_SCALE= on velox, minimal on ratio). XWayland apps don't scale via the compositor (=force_zero_scaling=true=), so toolkit env vars set in =conf.d= are the right layer — kills per-app =.desktop= hacks. Open question in the spec: whole-file configs with no include directive (waybar JSON, pypr toml) need a separate strategy. Full design + 5 open questions for Craig in the spec.
+** DONE [#B] Verify Phase 2 in the VM (hyprland + none) — pending clean run :solo:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Both runs clean on 2026-06-10. Hyprland (=make test=, results =20260610-151228=): 52 passed / 0 failed, and the same-day uv + signal-cli install additions were exercised in-run. None (results =20260610-165438=-ish, second attempt): 50 passed / 0 failed — the minimal/ tree stowed correctly. The first none attempt failed on a test-harness bug, not the installer: validation.sh hardcoded the common/ symlink target, fixed in =1754a94= (expected path now follows DESKTOP_ENV). The only attributed issue in both runs is the Proton-VPN-daemon-fails-in-VM known noise. The Phase 2 none/minimal path is now verified end-to-end.
+** DONE [#C] Investigate the 2026-05-11 VM-test warnings
+CLOSED: [2026-06-11 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-11
+:END:
+All five resolved. Four were environment-impossible checks converted to uncounted skips (=ced91c4= + the portal refinement =19015c7=) — socket, portal, mDNS-on-slirp, docker-pre-reboot — and all four skips verified firing in the 2026-06-11 12:56 run (52/0, 1 warning). The fifth (lingering) turned out to be a harness quoting bug, not a logind issue — fixed in =5b51900=, dated entry below. The next clean run should report zero warnings. The 18:36 =make test= run that filed this passed 52/0/5; the sub-entries below carry each investigation.
+
+*** 2026-06-10 Wed @ 19:07:54 -0500 Hyprland-socket warning converted to a skip
+Shipped in =ced91c4=: the check now passes when the socket exists, skips (uncounted) when no Hyprland process is running — the headless-VM state — and warns only in the genuinely odd case of a running compositor with no socket. Verified live: the skip fired in the 2026-06-10 19:06 run.
+
+*** 2026-06-10 Wed @ 19:07:54 -0500 Portal-query warning converted to a skip
+Shipped in =ced91c4= + a follow-up refinement: the first condition (portal process absent) didn't fire because a socket-activated =xdg-desktop-portal= exists even headless; the precondition is really a running compositor, so the skip now keys on =pgrep -x Hyprland= like the socket check. The conf-file checks (the part install controls) still pass/fail normally. The dconf-write angle stays tracked under =[#B] Fix install errors=.
+
+*** 2026-06-10 Wed @ 19:07:54 -0500 mDNS-ping warning converted to a slirp-aware skip
+Shipped in =ced91c4=: when the VM is on QEMU slirp (a =10.0.2.x= address), the =.local= ping is skipped — multicast genuinely can't pass there — and the =is-enabled= check stands alone. On real networking the full ping test still runs and still warns on failure. Verified live: the skip fired in the 2026-06-10 19:06 run.
+
+*** 2026-06-11 Thu @ 12:58:19 -0500 Lingering warning was a harness quoting bug — fixed, hypothesis disproven
+make test-keep forensics on the kept VM: the linger file existed (created mid-install), =loginctl show-user cjennings -p Linger= said yes, logind active with zero errors — lingering was correctly enabled all along, so the logind-degraded hypothesis was wrong and archsetup's =enable-linger= calls were always fine. The actual bug was in the check itself (=validation.sh=): it captured =ls path && echo yes=, so a present file produced "path\nyes", which never string-equals "yes" — the check warned on every run regardless of state. Fixed in =5b51900= with =test -e=; the corrected expression verified returning "yes" against the live VM. With this, all five 2026-05-11 warnings are resolved and a clean run should report zero.
+
+*** 2026-06-10 Wed @ 19:07:54 -0500 Docker warning converted to a pre-reboot skip
+Shipped in =ced91c4=: =docker info= success still passes; enabled-but-inactive (the deliberate enable-not-now install state, validated pre-reboot) now skips; active-but-unresponsive still warns — that's the real failure case. Verified live: the skip fired in the 2026-06-10 19:06 run. The enable vs enable-now question for archsetup itself was left as-is (the daemon's weight makes enable-on-boot defensible).
+
+Note: the run also logged two log-diff meta-warnings — "Found 4 new error lines after archsetup" and "New failed services detected (before: 1, after: 2)". Those correspond to the post-install systemd noise (pam_systemd / logind / Proton VPN) already captured under =[#B] Fix install errors= above; not duplicated here.
+** DONE [#B] Enable TLP power management for laptops :quick:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Done live on velox 2026-06-10: tlp 1.10.1 installed, =/etc/tlp.d/01-custom.conf= written (EPP balance_performance/power + platform-profile per power source; 80% charge cap present but commented off), service enabled and active, systemd-rfkill masked per TLP docs. Verified: tlp-stat runs, EPP reads balance_performance on AC. Codified in archsetup commit =adb39f2= as a battery-gated block.
+** DONE [#B] Remove unnecessary linux-firmware packages (velox only) :quick:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Done live on velox 2026-06-10. Hardware re-verified first (i915 graphics, ath9k wifi), then removed the meta + 12 subpackages (the task's 9 plus liquidio/mellanox/nfp/qlogic from the finer 2026 split), keeping intel + atheros + whence. The meta needed =-Rdd= — mkinitcpio-firmware declares a dep on it; the dangling dep is cosmetic. Initramfs rebuilt clean (warnings only for absent hardware), wifi stayed connected. Codified in archsetup commit =adb39f2= as a DMI-gated Framework-Intel block. Full confidence needs the next reboot — see Manual testing below.
+** DONE [#B] Identify and replace packages no longer in repos
+CLOSED: [2026-06-11 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-11
+:END:
+Shipped 2026-06-11 as =1f89523=: =scripts/audit-packages.sh= (unit-tested) makes the check repeatable, and its first run over 420 packages found four casualties, all fixed in the same commit — libva-mesa-driver (folded into mesa), nvidia-dkms → nvidia-open-dkms, swww → awww (set-theme's stale swww call fixed in dotfiles =4ea35a1=), libappindicator-gtk3 → libayatana-appindicator. Re-run anytime: =scripts/audit-packages.sh=.
+** DONE [#B] Verify package origin for all packages
+CLOSED: [2026-06-11 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-11
+:END:
+Covered by the same auditor (=1f89523=): it flags movers in both directions. Current state: zero official packages wrongly routed through aur_install-only territory; 15 aur_install entries have graduated to official repos (duf, flameshot, gist, inxi, nsxiv, nvm, papirus-icon-theme, ptyxis, qt5ct, qt6ct, ttf-lato, ueberzug, warpinator, xcolor, xdg-desktop-portal-hyprland). Left as-is deliberately — yay resolves repo packages fine — but switching them to pacman_install is a clean :quick: cleanup whenever wanted; the auditor lists them on every run.
+** DONE [#B] Automate script usage tracking :solo:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Shipped 2026-06-10 as dotfiles commit =e5044b8=: =script-usage= in =common/.local/bin/= (10 unit tests). Reads zsh extended + bash history, reports last-used date per ~/.local/bin script, =--unused= lists the never-seen set. First run on ratio: 109 scripts, 98 unseen by the current (short) history window.
+** DONE [#B] Automate dotfile validation :solo:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Shipped 2026-06-10 as dotfiles commit =2054da4=: =dotfiles-validate= in =common/.local/bin/= (11 unit tests). Extracts commands from hypr exec/bind-exec lines, waybar exec/on-click/on-scroll values, and systemd user-unit Exec* lines, then verifies each resolves. First run found 4 real orphans — see the follow-up task below.
+
+*** 2026-06-11 Thu @ 00:44:41 -0500 All 4 orphaned references fixed; validator fully clean
+Both emacs.service units repointed to /usr/bin/emacs (dotfiles =cd15d9b=), and per Craig's call the tor-browser and virtualbox keybinds were dropped rather than backed by installs (dotfiles =e4cb4c2= — Ctrl+Alt+W and Super+V now free). dotfiles-validate: 102 references checked, all resolve.
+** DONE [#B] Document evaluation criteria and trade-offs
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Written 2026-06-10: [[file:docs/2026-06-10-tool-evaluation-criteria.org][docs/2026-06-10-tool-evaluation-criteria.org]] — four gating criteria (Wayland-native, actively maintained with live verification, automation-compatible, stowable config), five weighting criteria, the process, and the trade-offs accepted in the 2026-06-10 evaluation round.
+** DONE [#B] Add org-capture popup frame on keyboard shortcut
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Shipped 2026-06-10, all five spec steps: =quick-capture= script (dotfiles =08ae188=, 3 unit tests, notify-on-failure when the daemon's down), Hyprland window rules in current 0.53+ syntax (float, 900x500, center, stay_focused on title org-capture) + Super+Shift+N bind (same commit), and the auto-close hook in =org-capture-config.el= (.emacs.d =1a25fada=, .elc recompiled, loaded live). Verified end-to-end on ratio: popup opens floating/centered with the template menu (screenshot), frame auto-deletes on org-capture-kill — finalize uses the same hook. Existing capture templates untouched.
+** DONE [#C] Create Chrome theme with dupre colors :quick:solo:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Shipped 2026-06-10 as archsetup commit =4736058=: unpacked-extension theme at =assets/color-themes/dupre/chrome-theme/= (manifest.json + README with the color mapping and load-unpacked install steps). Visual check is yours — see Manual testing below.
+** DONE [#C] Install Zoxide integration into Ranger :quick:
+CLOSED: [2026-06-10 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-10
+:END:
+Shipped 2026-06-10 as dotfiles commit =220dde6=: jchook/ranger-zoxide vendored (with MIT license) into both =common/= and =minimal/= ranger plugin dirs — :z and :zi commands wherever ranger runs. Python syntax verified; live verification is yours (see Manual testing) and needs a machine with ranger installed — note neither Wayland box has it, and the same-day file-manager evaluation recommends yazi over porting ranger forward.
+** DONE [#D] Add retry logic to git_install function :quick:
+CLOSED: [2026-06-10 Wed]
+Already shipped before this review — commit =798b86f= gave git_install the same MAX_INSTALL_RETRIES loop as pacman/aur, with a clean-slate build dir per attempt. The task predates the fix; closing as done.
+** DONE [#B] Org-capture popup frame split (quick-task Super+Shift+N)
+CLOSED: [2026-06-13 Sat] SCHEDULED: <2026-06-12 Fri>
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-12
+:END:
+Resolved: .emacs.d fixed it config-side (single-window display + cj/quick-capture command); archsetup pointed the popup script at cj/quick-capture (8cc1be7). Verified end-to-end on ratio.
+The quick-capture popup opens split in two windows — a top sliver of the daemon's last-visited buffer plus the =*Org Select*= menu below — so the two stacked modelines read like tmux status bars. Root cause: =org-mks= displays the template menu via =org-switch-to-buffer-other-window=, splitting the fresh popup frame instead of taking it over.
+
+Coordinating with the .emacs.d project: handoff sent 2026-06-12 18:59 requesting a config-side fix scoped to frames named =org-capture= (handoff note + screenshot evidence delivered to .emacs.d's inbox, since processed and removed). Waiting on its reply in this project's inbox; then verify the popup end-to-end on ratio (Super+Shift+N → single-window menu → single-window capture buffer). Fallback if .emacs.d declines: carry the fix in the dotfiles =quick-capture= script's =-e= elisp.
+
+Related finding, no change needed: whole-desktop screenshot already exists at CTRL+Super+S (=screenshot fullscreen=, grim fires before the fuzzel menu so popups survive). Possible follow-up decision: rebind Super+Shift+S (currently layout-switch to scrolling) if Craig wants fullscreen capture there.
+
+*** 2026-06-12 Fri @ 20:21:00 -0500 Incorporated .emacs.d's fix and verified end-to-end
+.emacs.d replied same evening with two notes (now in [[file:assets/outbox/2026-06-12-1947-from-.emacs.d-org-capture-popup-singlewindow-reply.org][outbox]] and [[file:assets/outbox/2026-06-12-2006-from-.emacs.d-quick-capture-script-change.org][outbox]]): the single-window fix landed config-side (frame-scoped =display-buffer-alist=, 7 ERT tests, live in the daemon), plus a new =cj/quick-capture= command (Task/Bug/Event only, global-inbox targets, frame closes on every exit path, 12 ERT tests). Our side: test-first one-line change in the dotfiles =quick-capture= script — =(org-capture)= → =(cj/quick-capture)= — suite 15/15 green, live immediately via stow. Verified on ratio with sendshortcut-driven popups + grim: menu single-window with the 3-template subset, capture buffer single-window targeting =CAPTURE-inbox.org=, no orphan frames, nothing leaked into the inbox file. Verification reply + screenshot evidence sent back to .emacs.d. Remaining: commit the dotfiles change (Craig's gate) and the Super+Shift+S rebind decision.
+** DONE [#C] Silent notifications for the mic-mute toggle :quick:solo:
+CLOSED: [2026-06-11 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-11
+:END:
+Shipped 2026-06-11 as dotfiles =a4ae4a4=, minutes after filing: =--silent= on all four of mic-toggle's notify calls (Muted/Live/unknown/fail), tests assert the flag on every path (5/5, full suite 15 suites green), and a live round-trip on ratio confirmed the toggle works with the toast and without the chime. velox picks it up on next pull.
+** DONE [#B] Create package inventory system
+CLOSED: [2026-06-14 Sun]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-13
+:END:
+Satisfied by =scripts/package-inventory= (the same script that closes "Automate the inventory comparison" above). It lists archsetup's declared packages, lists the live system's packages, and prints the diff in both directions. Design note: it compares explicit-vs-explicit (=pacman -Qqe= against declared =pacman_install=/=aur_install=), which is the meaningful comparison — the original "including dependencies" framing was superseded, since transitive deps are pulled automatically and listing full closures would only add noise.
+*** 2026-06-14 Sun @ 22:13:48 -0500 Listed archsetup's declared packages — package-inventory extraction (pacman_install/aur_install + for-loop lists)
+*** 2026-06-14 Sun @ 22:13:48 -0500 Listed live-system packages — package-inventory via pacman -Qqe / -Qq / -Qqen / -Qqem
+*** 2026-06-14 Sun @ 22:13:48 -0500 Generated archsetup-vs-system diff — package-inventory, both directions, AUR/official split
+** DONE [#B] Automate the inventory comparison :test:solo:
+CLOSED: [2026-06-14 Sun]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-13
+:END:
+Make package diff a runnable script instead of manual process
+
+Resolved 2026-06-14: the runnable script already existed — =scripts/package-inventory= (built 2026-02-06) extracts archsetup's declared packages and diffs them against the live system (=--summary= / =--archsetup-only= / =--system-only= / full report). This pass added the missing coverage: 7 characterization tests in =tests/package-inventory/= pinning the extraction and both diff directions behind injectable =PKGINV_ARCHSETUP= / =PKGINV_PACMAN= seams, plus a =make package-diff= target for discoverability. Full unit suite green (26 tests, 3 suites).
+** DONE [#C] paru vs yay — evaluated, staying with yay
+CLOSED: [2026-06-10 Wed]
+Research done 2026-06-10: [[file:docs/2026-06-10-paru-vs-yay-evaluation.org][docs/2026-06-10-paru-vs-yay-evaluation.org]]. The maintenance picture inverted since the task was filed: yay released v12.6.0 on 2026-06-07 with active triage, while paru has had no release in 11 months, no commit in 5, and a stable that fails to build against current libalpm (issue #1468 open 6 months). For an installer that bootstraps the AUR helper unattended, paru is the riskier choice on every axis that matters. No decision needed — the evidence closes this one; revisit only if paru's maintenance resumes.
+** DONE [#B] Idle-inhibitor keybind + synced waybar indicator :hyprland:waybar:
+CLOSED: [2026-06-23 Tue]
+Shipped 2026-06-23 as dotfiles commit =a004201=. Super+I toggles the hypridle daemon (kill = inhibit, relaunch = restore). The built-in waybar =idle_inhibitor= module was replaced with a =custom/idle= module backed by a =waybar-idle= script, so the keybind, the bar click, and the icon share one source of truth (whether hypridle is running) and stay in sync. Icons 󰒳 inhibited / 󰒲 active, with a 5s poll safety net. Freed =Super+I= by pruning the unused ai-term pyprland scratchpad from both host configs. TDD'd (=waybar-idle= + =hypridle-toggle= suites); dupre/hudson theme CSS updated. From a home-project handoff 2026-06-23; Craig confirmed it works live.
+** DONE [#B] Verify package signature verification not bypassed by --noconfirm
+CLOSED: [2026-06-23 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Audited 2026-06-23. =--noconfirm= does not bypass signature verification — it only auto-answers interactive prompts. Signature checking is governed by =SigLevel= in =/etc/pacman.conf=, which archsetup leaves at the Arch default (=Required DatabaseOptional=): its only pacman.conf edits are ParallelDownloads, Color, and enabling multilib (=archsetup:913,917=), none of which touch =SigLevel=. So every repo package stays signature-verified regardless of =--noconfirm=.
+
+One real integrity bypass exists, and it is not =--noconfirm=: =archsetup:2403= runs =yay -S --noconfirm --mflags --skipinteg python-lyricsgenius=, where =--skipinteg= skips makepkg's checksum and PGP-signature checks for that one AUR package (a documented workaround for an expired-signature issue upstream). It's scoped to a single package, not global. Tracked for periodic re-check below.
+** DONE [#C] Harden sshd in the installer (explicit prohibit-password) :solo:
+CLOSED: [2026-06-24 Wed]
+Done 2026-06-24: the openssh block (=archsetup:1271-1277=) now writes =/etc/ssh/sshd_config.d/10-hardening.conf= with =PermitRootLogin prohibit-password= and reloads sshd, right after starting the service. =PasswordAuthentication= left untouched so ssh-copy-id to the user still works. Makes the posture intentional rather than dependent on the upstream default. Velox and ratio (which carried an explicit =PermitRootLogin yes= at =sshd_config:33= from earlier provisioning) were already fixed by hand 2026-06-23. Verified =bash -n= + =shellcheck -S error= clean; full drop-in-on-fresh-install confirmation is VM-deferred (the unit harness covers helpers, not inline install steps).
+** DONE [#C] Build security dashboard command :solo:
+CLOSED: [2026-06-23 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Shipped 2026-06-23 as dotfiles commit =1b9b205=: =security-status= (=common/.local/bin=, on PATH). Read-only dashboard showing disk encryption (LUKS *and* ZFS native — the fleet runs ZFS, so a LUKS-only check would have falsely reported "no encryption"), ufw state, externally-reachable ports (counts all listening, lists only the non-loopback exposures), and running/failed service counts. Command lookups are env-overridable; parsing covered by unit tests against canned output. New file, so ratio needs =git pull && make stow hyprland= to link it.
+** DONE [#C] Teach archsetup to stow the host tier :solo:
+CLOSED: [2026-06-23 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-11
+:END:
+Already implemented in =user_customizations()= (=archsetup:1049-1058=): after stowing =common= + the DE package, it derives =host_tier="$(cat /etc/hostname 2>/dev/null || uname -n)"= and stows that package when =$dotfiles_dir/$host_tier= exists, else prints "no host tier for '<host>' — skipping". The =/etc/hostname=-first detection is the right call for install time (=uname -n= still reports the ISO's name until reboot), and it's the same skip-if-absent semantics as the dotfiles Makefile. Verified by reading the installer 2026-06-23; no code change needed.
+** DONE [#C] Waybar indicators unevenly spaced :quick:solo:waybar:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+The right-side module icons don't sit at even intervals — spacing reads as inconsistent across the group. Noticed 2026-05-21 after adding the airplane indicator.
+
+Done 2026-06-24: a screenshot showed the standalone module icons were already even — the unevenness was the tray, whose icons clustered tight (tray =spacing: 4= vs the ~0.3rem margins on every other module). Bumped tray =spacing= 4 → 10 in the waybar =config=; restarting waybar and re-screenshotting confirmed the row reads even. The lever was the tray spacing, not the per-module CSS the original body guessed at.
+** DONE [#B] Separate mpd playlist_directory from music_directory :mpd:music:quick:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+Done 2026-06-24 (dotfiles a9bfdf3): set =playlist_directory= to =~/.local/share/mpd/playlists= (separate from =music_directory= ~/music). git-moved the 73 radio-stream playlists from =common/music/= into =common/.local/share/mpd/playlists/= (history preserved); dropped the empty =60s Sounds.m3u= (Craig's call); git rm'd the stray =Black Flamingos - Space Bar.m4a= and moved the real track into the music library. Curated playlists left flat in ~/music (Craig's call — avoids rewriting the 7 relative-path ones). The ~/music/radio orphan was already gone. Relinked surgically (a pre-existing =whereami= stow conflict blocked a full =stow common=). mpd restarted clean: 73 radio playlists load from playlist_directory (verified SomaFM stream URLs), 24 curated browsable from the music tree. ratio needs the same restow + mpd restart on its next pull (reminder filed). Decisions answered: 60s dropped, curated flat.
+Spec written and approved (option 1), pinned before execution on 2026-06-03. Root issue: mpd.conf has =playlist_directory= == =music_directory= == ~/music, so the whole audio library is the playlist store and radio streams mix with curated playlists. Option 1: radio stream playlists (portable, 73 in the dotfiles repo) move to a dedicated =playlist_directory= (=~/.local/share/mpd/playlists=) via stow; the 22 curated local playlists (machine-specific track refs) live in the music tree. Also removes the broken ~/music/radio/ orphan (73 dead symlinks).
+
+Full step-by-step spec (mpd.conf edit, repo restructure of =common/music/= → =common/.local/share/mpd/playlists/=, curated relocation, restow, verification incl. the 7 relative-path curated playlists, ratio propagation) is in the 2026-06-03 session record under .ai/sessions/. Two open decisions before executing: (1) drop the empty =60s Sounds.m3u= or refill with the SomaFM 60s URL; (2) curated playlists into =~/music/playlists/= subdir vs leave flat in ~/music/. Side cleanup surfaced: a stray audio file =Black Flamingos - Space Bar.m4a= is wrongly committed in the dotfiles repo's =common/music/= — git rm it and move to the synced library.
+** DONE [#C] Install adopted modern CLI tools :tooling:solo:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+Done 2026-06-24: added bat/dust/hyperfine/doggo to archsetup General Utilities (tealdeer was already declared), installed all five on velox, set =BAT_THEME=ansi= in =common/.profile.d/tools.sh= (tracks the dupre terminal palette), seeded the tldr cache. ratio still needs the =pacman -S= (additive; lands on its next archsetup run).
+Decision (Craig, 2026-06-24): adopt all five recommended tools — =bat=, =dust=, =hyperfine=, =tealdeer=, =doggo= (all in extra). Add them to archsetup's package list and install on both machines. Optional candidates (=xh=/=jless=/=sd=/=ouch=) declined for now. Full evaluation: [[file:docs/2026-06-10-modern-cli-tools-evaluation.org][docs/2026-06-10-modern-cli-tools-evaluation.org]].
+
+- Add the five to the appropriate pacman package section in =archsetup=.
+- =pacman -S bat dust hyperfine tealdeer doggo= on velox + ratio.
+- =bat=: set =BAT_THEME= to match the dupre palette once installed.
+- =tealdeer=: run =tldr --update= to seed the cache after install.
+** DONE [#C] Review file manager options for Wayland
+CLOSED: [2026-06-24 Wed]
+Decision (Craig, 2026-06-24): keep nautilus only; skip yazi. File management lives in Emacs dired plus the Super+F dirvish popup, so a TUI file manager has no daily user here. ranger was already ruled out (frozen upstream). Full evaluation: [[file:docs/2026-06-10-file-manager-evaluation.org][docs/2026-06-10-file-manager-evaluation.org]]. Follow-on surfaced: nautilus needs dark theming (filed as its own task).
+** DONE [#B] Theme nautilus to a dark theme :bug:solo:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+nautilus rendered blindingly white (Craig, 2026-06-24). As a GTK4/libadwaita app it follows the appearance portal's =org.freedesktop.appearance color-scheme=, which mirrors =org.gnome.desktop.interface color-scheme=. Two stacked causes:
+
+1. velox had no system-wide dconf db at all — no =/etc/dconf/profile/user=, no =/etc/dconf/db/site.d/00-archsetup-defaults=, no compiled =site= db — so archsetup's declared default (=color-scheme='prefer-dark'=, =archsetup:1109-1119=) never reached the machine (velox predates that block). Created the profile + site defaults as archsetup writes them and ran =dconf update=. =gsettings get= then returned =prefer-dark=.
+
+2. That alone did NOT fix the running session: a system-db default emits no GSettings change signal, so the appearance portal kept reporting =0= (no-preference → light), and libadwaita reads the portal, not =GTK_THEME=. (An early screenshot looked dark only because the shell env carries =GTK_THEME=Adwaita:dark=, which Hyprland-launched apps don't inherit — masking the real state.) Fix: a user-level =gsettings set org.gnome.desktop.interface color-scheme prefer-dark=, which signals the portal live. It now reports =1=, and a portal-driven nautilus (GTK_THEME unset) renders dark — screenshot-verified.
+
+Durable: the user value persists in =~/.config/dconf/user=; archsetup's system-db handles fresh installs (the portal reads the default fresh at login, so no signal is needed there). No archsetup change. ratio may need the same one-two — see the Active Reminder.
+** CANCELLED [#D] Test wlogout menu on laptop
+CLOSED: [2026-06-24 Wed]
+Merged into the "Wlogout exit-menu buttons are rectangular, not square" task ([#C]) — same effort (per-host wlogout button sizing across velox/ratio). The fixed-pixel-margins hint was folded into that task's body.
+** DONE [#B] Enlarge org-capture popup to scratchpad size :hyprland:
+CLOSED: [2026-06-24 Wed]
+From a .emacs.d inbox handoff (2026-06-15, captured via roam): the quick-capture / org-protocol popup is too small to be effective — it should be about the size of a terminal scratchpad.
+
+*** 2026-06-24 Wed @ 17:21:11 -0400 Sized the popup to the scratchpad, per-host in pixels
+The 06-15 read was wrong: the real size lever is the Hyprland window rule, not the quick-capture char-cell count. The =size 900 500= rule on the org-capture window pinned it to 900x500 regardless of the frame's requested geometry (demoing 120x24 vs 180x32 looked identical because both clamped to 900x500). Tried a percentage rule (=size 75% 70%=) to auto-adapt per host like the pyprland scratchpad — native window rules do NOT honor percentages (only pyprland does), so the frame fell back to char-cell geometry and overflowed the screen. Fix: absolute pixels matching each host's terminal scratchpad, placed in the host tier (=<host>/conf.d/local.conf=) since pixels don't adapt across monitors. velox = 1078x671 (75%x70% of its 1437x958 logical desktop) — verified on-screen. ratio = 1892x936 (55%x65% of 3440x1440) — set but not yet eyeballed on ratio (tracked as an Active Reminder in notes.org). The shared hyprland.conf keeps float/center/stay_focused and a comment pointing at the per-host size. dotfiles change — needs commit in =~/.dotfiles=.
+
+*** 2026-06-15 Mon @ 19:19:55 -0500 AI Response: popup size is the frame's char-cell count, not the Hyprland rule
+Triaged under auto inbox-zero. The popup is the emacsclient frame named "org-capture", created by =~/.dotfiles/hyprland/.local/bin/quick-capture= with =(width . 90) (height . 22)= — 90 columns by 22 lines. Emacs sizes by character cells and overrides the Hyprland rule =windowrule = match:title ^(org-capture)$, size 900 500= (hyprland.conf:182). The live frame measured ~889x860 px; the width tracks the 90-column count, not the window rule. Setting the Hyprland rule to =size 55% 65%= (the scratchpad's pyprland spec) did not change the frame width, so I reverted it — dotfiles left clean.
+
+Real lever: the column/line count in the quick-capture script. Scratchpad reference on ratio (DP-4, 3440x1440) is 55% 65% ~= 1892x936 px ~= 190 cols by 24 lines. Why this isn't a solo auto-fix — it needs a tradeoff decision:
+- The script lives in the shared =hyprland/= stow tier, so a fixed ~190 columns overflows velox's 1920-wide laptop, and 24+ lines overflows velox's 1080 height (22 lines ~= 860 px is already near the safe max there).
+- Emacs char-cell sizing doesn't adapt to the monitor the way pyprland's percentage does, so "scratchpad-size on both machines" needs one of: a fixed compromise count, a per-host override via the ratio/velox tiers, or a script that computes columns from the active monitor.
+Options to weigh: (a) a safe-on-both compromise like width 120-130 / height 24; (b) per-host width through the ratio/velox tiers; (c) dynamic sizing in quick-capture from =hyprctl monitors=. Pick the tradeoff and I'll implement.
+** DONE [#C] Highlight current month and year in the calendar hover :feature:waybar:quick:solo:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+From the roam inbox (2026-06-24): the waybar clock's calendar tooltip highlights today's date in goldenrod; the current month and year header should be goldenrod too.
+
+Done 2026-06-24: the date module is the custom =waybar-date= script (not the built-in clock), so the highlight lives in its tooltip markup. Added a sed wrapping line 1 of the current-month =cal= output (the centered "Month Year") in the same =#daa520= goldenrod the day highlight uses. Verified the tooltip JSON carries =<span color='#daa520'><b>June 2026</b></span>= with today's highlight intact and waybar live; the on-hover look is Craig's spot-check.
+** DONE [#C] Wallpaper-set from dirvish doesn't work on Wayland :hyprland:
+CLOSED: [2026-06-24 Wed]
+From the roam inbox (2026-06-24, claimed for archsetup by Craig): typing =bg= in the dirvish popup doesn't change the wallpaper — Craig's read is it may still be wired to feh/X11 instead of a Wayland utility.
+
+Findings (2026-06-24): the Wayland wallpaper utility on this setup is =awww= (waypaper's configured =backend = awww=; =set-theme= sets the default via =awww img <file>=). There was no shared wallpaper script (=bg= on PATH is just the shell builtin), and the dirvish =bg= command lives in the Emacs config, so it was calling the wrong (or no Wayland) setter.
+
+Done 2026-06-24 (dotfiles 8be2484): added =set-wallpaper <image>= to the hyprland tier — sets live via =awww img= and persists the choice into =waypaper/config.ini=, the single Wayland-correct entry point. Resolves relative paths, validates the file, exits non-zero without persisting if awww fails. 8 Normal/Boundary/Error tests green; live-verified (awww set it, config rewrote). Notified =.emacs.d= to point the dirvish =bg= command at =set-wallpaper <file>= — that wiring is its piece (dependency cleared, =:blocker:= dropped).
+
+Follow-up (separate, small): the login restore =exec-once= in =hyprland.conf= is hardcoded to =trondheim-norway.jpg=, so a wallpaper set via =set-wallpaper= shows live but won't survive a relogin until the exec-once becomes =waypaper --restore= (which reads the now-persisted config). Filed below.
+** DONE [#C] Proton Mail Bridge font size :chore:quick:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+From the roam inbox (2026-06-22): adjust the Proton Mail Bridge UI font to a comfortable size. The bridge is a Qt app, so it likely keys off Qt scaling or the qt5ct/qt6ct config like the other Qt apps (QT_SCALE_FACTOR or a font setting).
+
+Done 2026-06-24 (dotfiles =hyprland.conf:47=): the bridge is a Qt6 *QML* app, so it ignores the qt6ct General font — bumped the UI font via =QT_FONT_DPI= on the autostart instead. Changed the exec-once to =env QT_FONT_DPI=108 protonmail-bridge --no-window= (default DPI is 96; 108 = 1.125x). Iterated live with Craig: 120 too big, 108 comfortable. hyprland.conf is a stow symlink so the change is already live; applies at every login. The =~/.config/autostart/Proton Mail Bridge.desktop= entry is dormant under Hyprland (no XDG-autostart), so it was left as-is.
+** DONE [#C] Wallpaper login-restore is hardcoded, not waypaper --restore :hyprland:quick:solo:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+The Hyprland =exec-once= (=hyprland.conf:26=) restores the wallpaper with a hardcoded =awww img ~/pictures/wallpaper/trondheim-norway.jpg=, so any wallpaper set later (via =set-wallpaper=, waypaper, or the dirvish =bg=) reverts on relogin. =set-wallpaper= now persists the choice to =waypaper/config.ini=, so switch the exec-once to =waypaper --restore= (after =awww-daemon= is up) to make set wallpapers survive a relogin. Small, dotfiles-only; verify by setting a different wallpaper, relogging, and confirming it sticks.
+
+Done 2026-06-24 (dotfiles): swapped the line-26 exec-once from the hardcoded =awww img …/trondheim-norway.jpg= to =awww-daemon & sleep 1 && waypaper --restore=. waypaper has a real =awww= backend (in its =--backend= list), the stowed =waypaper/config.ini= carries =backend = awww= plus a default =wallpaper == line, so =--restore= works on a fresh install too. Mechanism verified live: =waypaper --restore= reapplied the persisted wallpaper via awww, exit 0. Relogin confirmation filed under "Manual testing and validation". Follow-up filed: =set-wallpaper='s =mv= detached the live =waypaper/config.ini= from its stow symlink, so set-wallpaper changes no longer flow back to dotfiles.
+** DONE [#B] Add backup before system file modifications :solo:
+CLOSED: [2026-06-25 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+Safety net for /etc/X11/xorg.conf.d and other system file edits
+Files like ~/etc/sudoers~, ~/etc/pacman.conf~, ~/etc/default/grub~ modified without backup
+If modifications fail or are incorrect, difficult to recover - should backup files to ~.backup~ before modifying
+
+Done 2026-06-25: added a =backup_system_file <path>= helper next to =safe_rm_rf= — it snapshots a pre-existing file to =<path>.archsetup.bak= before an in-place edit, idempotent (never clobbers an existing backup, so the pristine original survives repeated edits and re-runs), =cp -p= to preserve mode/ownership, no-op when the file is absent. Took the narrow scope (Craig's call): route only the in-place =sed -i= / append edits to *pre-existing* files through it — locale.gen, makepkg.conf, pacman.conf, sudoers, conf.d/wireless-regdom, geoclue.conf, conf.d/pacman-contrib, fstab, mkinitcpio.conf, vconsole.conf — and skip the brand-new drop-in files archsetup fully owns (nothing to back up; recovery is just deleting them). Tests: =tests/backup-system-file/= (7 Normal/Boundary/Error, incl. mode-preserved, existing-backup-not-overwritten, missing-target no-op, cp-failure). =make test-unit= green across all 5 suites; =bash -n= clean; only shellcheck note is the known SC2329 false positive (indirect STEPS dispatch). Integration verification is the next VM run.
+** DONE [#B] Migrate bare-metal test runner to Testinfra, then delete the shell sweep :test:
+CLOSED: [2026-06-25 Thu]
+Plan + ZFS-coverage expansion: [[file:docs/design/2026-06-25-zfs-vm-test-coverage.org]] (build a ZFS base VM via archangel + a =FS_PROFILE= selector so =make test= covers the ZFS path, then migrate this runner to key auth + Testinfra against it, then delete the dead =validation.sh= functions = phase E here).
+=run-test.sh= (VM) now uses the Testinfra/pytest sweep as its authoritative validator, but =run-test-baremetal.sh= (lines ~243-244) still calls the old =run_all_validations= / =validate_all_services= from =scripts/testing/lib/validation.sh=. Migrate the bare-metal runner to =run_testinfra_validation= too (same key + ssh-config approach, adapted for a real host), then delete the now-dead shell-sweep functions from =validation.sh=. Keep the live helpers: =ssh_cmd=, =attribute_issue=, =capture_pre/post_install_state=, =analyze_log_diff=, =categorize_errors=, =generate_issue_report=, and the =VALIDATION_*= counters/arrays. Deferred from the Testinfra cutover because it needs a bare-metal test loop to validate, out of scope for the VM-only autonomous run.
+*** 2026-06-25 Thu @ 12:37:02 -0400 P-A/P-B shipped (FS_PROFILE selector); P-C blocked on archangel ZFS-install bug
+P-A + P-B landed in =353b179=: =archsetup-test-zfs.conf= (archangel ZFS config) + an =FS_PROFILE= (btrfs default / zfs) selector across =vm-utils.sh= (=init_vm_paths= derives a per-profile image + validates the profile), =create-base-vm.sh= (selects the archangel config), =run-test.sh= (--help + profile display), and the Makefile (=make test FS_PROFILE=zfs=). Design simplification recorded: no =archsetup-vm-zfs.conf= needed — archsetup auto-detects ZFS from the live root via =is_zfs_root()=, so the archsetup run config is shared; only the archangel base config + base image differ. Open Q1 resolved: archangel supports ZFS root natively (it's the default FS).
+
+P-C (build the ZFS base image) is BLOCKED on archangel. =create-base-vm.sh FS_PROFILE=zfs= built the disk + booted the archangel ISO fine, but the archangel install died: =dkms install zfs/2.3.3 -k 6.18.36-1-lts= exited 1, ZFS module not built. Root cause is in archangel, not archsetup: it appends the [archzfs] experimental repo then runs =pacstrap -K= with no =pacman -Sy= refresh, so it uses the archzfs sync db baked into the Feb-2026 ISO (zfs-dkms 2.3.3) while linux-lts is pulled fresh (6.18.36). 2.3.3 doesn't build against 6.18. velox runs zfs-dkms 2.4.2 on the same kernel from the same channel, so the fix exists upstream — archangel just needs to refresh the db before pacstrap (+ a fresh ISO). Bug + dependency handoff sent to archangel inbox (=2026-06-25-1236-from-archsetup-bug-zfs-install-fails-stale-baked.org=). Retry P-C once a fixed archangel ISO is available. P-D (bare-metal migration code) is still workable in the meantime against the btrfs VM / velox.
+
+*** 2026-06-25 Thu @ 16:05:07 -0400 archangel unblocked; ZFS base built; 3 archsetup bugs fixed (local); re-run paused
+archangel shipped the fix (archangel =89691a0=: =pacman -Syy= before pacstrap) + rebuilt the ISO. With it, =create-base-vm.sh FS_PROFILE=zfs= built a verified ZFS-root base (=archsetup-base-zfs.qcow2=, clean-install snapshot, kernel 6.18.36). =make test FS_PROFILE=zfs= then surfaced three real archsetup bugs against the current archangel base, each fixed in a LOCAL (unpushed) commit:
+- =8ed42b9= informant: the base ships informant; its pacman PreTransaction hook (AbortOnFail) blocked archsetup's first transaction. Fix: =informant read --all= up front (guarded). PROVEN.
+- =66caeb5= pacman.conf perms: the base ships =/etc/pacman.conf= 0600 (archangel =strip_repo_stanza= mktemp+mv clobbers perms), breaking user =makepkg=/=yay=. Fix: =chmod 644= after archsetup's edits. PROVEN (run reached 75 min deep).
+- =05ec096= reflector: archsetup configured reflector's timer but never ran it, so installs used the base's 425-mirror worldwide list and pacman stalled ~15 min on a slow/unresponsive mirror. Fix: run reflector once before the heavy installs (=timeout=-bounded, non-fatal). NOT yet integration-proven — the next re-run validates it.
+Second archangel handoff sent for the pacman.conf-0600 root cause (=2026-06-25-1440-...=); archsetup's chmod is defensive, archangel should ship 0644. Paused before the re-run at Craig's request (he starts =sudo make test FS_PROFILE=zfs= from the laptop). Possible harness-side factor on the stall: slirp IPv6 blackholing (one stalled conn was IPv6) — watch if it recurs despite reflector.
+
+*** 2026-06-25 Thu @ 21:56:12 -0400 P-C GREEN — ZFS VM test path passes end to end
+=make test FS_PROFILE=zfs= PASSED: archsetup exit 0 (full ~68-min ZFS install, reflector held — no stall), pytest =95 passed, 0 failed, 11 skipped=. The ZFS-conditional checks now run the ZFS branch instead of skipping: =test_bootloader_installed= (ZFSBootMenu EFI binary at /efi/EFI/ZBM), =test_mkinitcpio_hooks= (zfs udev hook), =test_console_font_configured= (vconsole.conf), =test_zfs_has_sanoid= all PASS; =test_backup_created_for_mkinitcpio= correctly SKIPs (ZFS+virtio edits nothing). The 3 archsetup issues (gamemode, mu, signal-cli AUR) are the known non-critical residuals, same as on btrfs. Four commits pushed to main: =8ed42b9= informant news-hook, =66caeb5= pacman.conf 0644, =05ec096= reflector-during-install, =eb379c3= ZFS-aware boot/backup tests. P-C (ZFS coverage, design phases A-C) is DONE. Remaining on this task: P-D (migrate run-test-baremetal.sh to inject_root_key + run_testinfra_validation) and P-E (delete the dead validation.sh shell sweep).
+*** 2026-06-25 Thu @ 23:26:02 -0400 P-D + P-E done — whole epic closed
+P-D (=771b92e=): migrated =run-test-baremetal.sh= to key auth + Testinfra. =inject_root_key= generalized to =root@$VM_IP= (vm-utils) so it serves both runners; the bare-metal runner now injects the key after the genesis rollback, threads =SSH_KEY_OPT= + a new =--port= through every ssh/scp, and validates via =run_testinfra_validation= instead of the shell sweep. Follow-up fix =fb495d4=: =set +e= around the validator (it returns pytest's rc, which under =set -e= aborted before the report) — caught by the smoke test. Validated against the ZFS VM (=--validate-only=, localhost:2222): connectivity, ZFS check, key auth, Testinfra connect+run, report all work; a green bare-metal install still needs real ZFS hardware.
+
+P-E (=a4a339b=): deleted the dead shell sweep from =validation.sh= now both runners use Testinfra — run_all_validations, validate_all_services, run_full_validation, the ~35 validate_* checks, validation_pass/fail/warn/skip. Kept the live helpers (ssh_cmd, attribute_issue, capture_pre/post_install_state, analyze_log_diff, categorize_errors, generate_issue_report, VALIDATION_* counters + arrays). 1156 → 314 lines. Verified: no dangling refs, both runners parse + smoke-run clean, unit suite green.
+
+Known follow-ups (not blockers): (1) archangel still owes the pacman.conf-0600 root-cause fix (handoff in its inbox; archsetup's chmod is the defensive layer). (2) The bare-metal runner runs =bash archsetup= with no --config-file — pre-existing, would prompt on real hardware; out of this epic's scope. (3) A true green bare-metal run needs real ZFS hardware (ratio).
+** DONE [#B] Implement Testinfra test suite for archsetup
+CLOSED: [2026-06-25 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+*** 2026-06-25 Thu @ Final fresh make test GREEN — Testinfra is the validator
+=make test= (fresh build, 150-min cap) PASSED: =TEST PASSED=, =Validation: PASSED=, pytest =96 passed, 10 skipped, 0 failed, 0 errors=, pytest as the authoritative gate. ParallelDownloads now =10= on the fixed build. End-state: the VM test runner validates post-install via the Testinfra/pytest sweep (=scripts/testing/tests/=, 88 tests + conftest fixtures) — full parity with the old shell sweep plus expansion coverage (sshd hardening, =backup_system_file= .bak files, applied pacman/makepkg/NM/fail2ban/reflector config). Three real bugs surfaced + fixed by this work: (1) the 2026-06-24 sshd hardening had silently broken =make test= (root password SSH died mid-run → key auth, f50fc1d); (2) =ParallelDownloads= stuck at Arch's default 5 (sed only matched the commented form → fixed, 2d63802); (3) install monitor cap too tight at 90 min (→ 150, fe84b71). Follow-up filed: migrate =run-test-baremetal.sh= off the shell sweep, then delete the dead =validation.sh= functions (P5).
+*** 2026-06-25 Thu @ Decision: port to Testinfra + expand coverage, design doc first
+Reviewed against the existing harness: =scripts/testing/lib/validation.sh= already runs ~14 post-install checks (=run_all_validations=), so this isn't net-new capability — it's porting that shell validation to Testinfra/pytest for better expressiveness + reporting, then growing coverage. Craig's call (prioritizes test investment over feature speed): do the port and expand. Starting with a design doc in =docs/design/= per the task's own "design doc not yet written" note. Stale slice to drop/rescope: the X11/startx end-to-end tests (fleet is Wayland/Hyprland now).
+*** 2026-06-25 Thu @ 00:54:22 -0400 P1 scaffold landed (advisory, alongside shell sweep)
+Built the Testinfra harness skeleton: =scripts/testing/tests/= (conftest.py with the attribution marker + report hook + =target_user= fixture; 3 parity checks — user exists/shell, ufw enabled, dotfiles stowed+readable), =scripts/testing/lib/testinfra.sh= (=run_testinfra_validation=: ephemeral-key injection, ssh-config, pytest-over-SSH; advisory + non-fatal, =RUN_TESTINFRA= toggle), wired into run-test.sh after the shell sweep, and added =python-pytest python-pytest-testinfra= to =make deps=. Verified on host: py_compile clean, =pytest --collect-only= green in a throwaway venv (4 tests, fixtures resolve), =bash -n= + shellcheck clean, unit suite still green. Integration (the pytest sweep actually running against a VM) is unverified here — needs a =make test= run. Decisions locked: inject test key; run both through parity; full expansion (P4) in this task after the P3 cutover.
+*** 2026-06-25 Thu @ 01:12:09 -0400 P2 full parity port (88 tests)
+Ported the whole shell sweep to pytest: test_users (exists/shell/15 groups parametrized), test_packages (yay+functional, pacman, terminus-font, emacs+config readable, git, 5 dev tools), test_services (required enabled/active, enabled-only, timers, optional skip-if-absent, DoT drop-in, fail2ban/nmcli responds, log-cleanup cron, syncthing lingering, DNS/mDNS/docker skips), test_desktop (Hyprland tools+configs+portal+socket gated on install/compositor, DWM suckless, autologin), test_boot (grub, mkinitcpio hooks branched on zfs_root, console-font-in-initramfs, nvme gated, zfs/sanoid), test_keyring (dir 700/owner/default=login), test_archsetup (log no Error:, ≥12 state markers). conftest fixtures: target_user/home/zfs_root/has_nvme/hyprland_installed/dwm_installed/compositor_running/on_slirp. 88 tests collected, py_compile clean. Correctness fix vs the shell sweep: check =awww= not the stale =swww=. Installed python-pytest-testinfra on velox so the harness gate passes. Next: VM run to diff pytest vs shell sweep for parity.
+*** 2026-06-25 Thu @ 01:24:11 -0400 Fixed: sshd hardening had silently broken =make test=
+VM run #1 aborted ~6 min in (Error 5), before any validation ran. Root cause (pre-existing, not the Testinfra work): the 2026-06-24 sshd hardening sets =PermitRootLogin prohibit-password= + reloads sshd mid-install, and the harness SSHes as root by *password* throughout — so every op after that step got "Permission denied" and run-test.sh fataled before validations. Fix: =inject_root_key= authorizes a throwaway root key right after first SSH (before archsetup runs) and all helpers (=wait_for_ssh=/=vm_exec=/=copy_to_vm=/=copy_from_vm=/=ssh_cmd=) gained =$SSH_KEY_OPT= so they use key auth, which =prohibit-password= still allows. testinfra.sh reuses that key. Additive (password stays as fallback). bash -n + shellcheck clean. Re-running the VM suite to confirm it now reaches the validation + pytest phases.
+*** 2026-06-25 Thu @ 03:33:33 -0400 Parity proven + P4 expansion validated on a live VM
+VM run #3 (=make test-keep=, kept VM up): pytest parity = 78 passed / 10 skipped / 0 fail / 0 err — matches & exceeds the shell sweep (53/0/0). Then built P4 expansion against the live VM (iterating in ~30s, no rebuild): test_hardening (sshd prohibit-password, sysctl printk, /etc/issue emptied, vconsole font, /efi fmask), test_config_applied (pacman ParallelDownloads/Color/multilib, makepkg MAKEFLAGS/OPTIONS, NM dns+wifi-privacy drop-ins, fail2ban jail, reflector), test_backups (=.archsetup.bak= present for pacman.conf/makepkg.conf/sudoers/mkinitcpio.conf — end-to-end proof of the backup feature). Full suite vs live VM: 95 passed / 10 skipped / 1 fail. The 1 fail = a REAL archsetup bug the tests caught: =ParallelDownloads= stayed at the Arch default 5 because the sed only matched a commented =#ParallelDownloads=, but current Arch ships it uncommented — fixed the sed to match both (=^#\?ParallelDownloads=). Also fixed a test bug (=grep -qx '[multilib]'= → =grep -Fxq=, the brackets were a regex char class). Remaining: P3 cutover (pytest authoritative) + P5 retire shell sweep, then a final fresh =make test=.
+*** 2026-06-25 Thu @ 03:38:28 -0400 P3 cutover: Testinfra is now the authoritative validator
+run-test.sh dropped the =run_all_validations= + =validate_all_services= shell-sweep calls; =run_testinfra_validation= now drives =TEST_PASSED= (returns pytest's rc; "couldn't run" = fail, not a silent pass). It surfaces pytest's pass/skip/fail counts through the shared =VALIDATION_*= counters and parses =testinfra-attribution.txt= into the issue arrays so =generate_issue_report= still buckets failures archsetup/base/unknown. Validated the failure path against the still-up VM: pytest rc=1, failure correctly bucketed to [archsetup]. P5 (physically delete the dead shell-sweep functions) is NOT done here — =run-test-baremetal.sh= still calls =run_all_validations=/=validate_all_services=, so deletion must wait until the bare-metal runner is migrated too (filed below). Final step: fresh =make test= to confirm the pass path (ParallelDownloads now 10) with pytest as the gate.
+*** 2026-06-25 Thu @ 08:35:26 -0400 Final run hit the harness 90-min install cap (not a regression)
+The fresh =make test= timed out at 9/12 steps while building =vagrant= from AUR (=ARCHSETUP timed out after 90 minutes=, exit 124), so validation ran against a half-installed system → 10 pytest failures, all late-step (issue/sysctl/vconsole/mkinitcpio/docker/state-markers). The suite worked correctly — it caught an incomplete install. Verified my ParallelDownloads sed is clean (no pacman corruption) and archsetup logged 0 errors. Root cause: =MAX_POLLS=180= (90 min) is too tight for a full install with heavy AUR builds; bumped to 300 (150 min). Re-running.
+Create comprehensive integration tests using Testinfra (Python + pytest) to validate archsetup installations
+
+Tests should cover:
+- Smoke tests: user created, key packages installed, dotfiles present
+- Integration tests: services running, configs valid, X11 starts, apps launch
+- End-to-end tests: login as user, startx, open terminal, run emacs, verify workflows
+
+Framework: Testinfra with pytest (SSH-native, built-in modules for files/packages/services/commands)
+Location: scripts/testing/tests/ directory
+Integration: Run via pytest against test VMs after archsetup completes
+Benefits: Expressive Python tests, excellent reporting, can test interactive scenarios
+
+A design doc (not yet written) should cover:
+- Complete example test suite (test_integration.py)
+- Tiered testing strategy (smoke/integration/end-to-end)
+- How to run tests and integrate with run-test.sh
+- Comparison with alternatives (Goss)
+** DONE [#B] VM test harness shared one NVRAM file across filesystem profiles :bug:test:
+CLOSED: [2026-06-27 Sat]
+The harness shared one OVMF NVRAM file (=vm-images/OVMF_VARS.fd=) across the btrfs
+and zfs profiles (=init_vm_paths= suffixed the disk image per profile but not the
+NVRAM). NVRAM lives outside the qcow2, so a disk-snapshot revert can't restore it,
+and a zfs run's ZFSBootMenu boot entries clobbered the btrfs GRUB entry. With no
+removable =\EFI\BOOT\BOOTX64.EFI= fallback on the base ESP, the next btrfs run
+booted into UEFI with no bootable device ("BdsDxe: No bootable option or device
+was found", then PXE/HTTP, then SSH timeout before archsetup ran). Found
+2026-06-27 trying to VM-validate the installer refactor.
+
+Fixed: =OVMF_VARS= now carries the same per-profile suffix as the disk image
+(=OVMF_VARS${img_suffix}.fd=) in =vm-utils.sh init_vm_paths=, so btrfs and zfs keep
+separate NVRAM. Validated by a full green zfs run 2026-06-27 (ArchSetup exit 0,
+Testinfra 96 passed / 0 failed). Remaining hardening tracked below.
+** DONE [#B] Collapsible waybar sides :waybar:
+CLOSED: [2026-06-27 Sat]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-09
+:END:
+Let either side of the waybar collapse horizontally to a minimal base set, toggled by a click. Each collapsible side carries a small triangle / arrowhead pointing toward the screen edge it collapses into (away from center). Clicking it collapses that side to its base set and flips the arrow to point back toward center; clicking again restores the full side. Same shape-changes-with-state idea as the auto-dim indicator.
+
+Spec (2026-06-19): [[file:assets/2026-06-19-collapsible-waybar-sides-spec.org]]. Spike that settled the mechanism: [[file:assets/2026-06-18-collapsible-waybar-sides-spike-findings.org]].
+
+Decisions locked: right base set = date + worldclock + tray; left base set = menu + workspaces; per-side independent; host-agnostic (base set constant, full set is each host's existing config). Mechanism = config-swap + SIGUSR2 reload via an active-config copy in =$XDG_RUNTIME_DIR= (the CSS/state-file approach was disproven — GTK3 can't reflow-hide native modules). Lives in =~/.dotfiles/hyprland/=.
+
+Shipped per spec (dotfiles 804bef6): 3 TDD'd scripts (=waybar-active-config=, =waybar-collapse=, =waybar-arrow=; 22 cases), arrow modules wired into the config (left arrow innermost-left, right arrow innermost-right), CSS ×3, =$mod+[= / =$mod+]= keybinds, and =waybar-toggle= relaunch updated to load the active config so a crash preserves collapse state. Verified live: click, keybind, and per-side independence all work; expand round-trips exactly to canonical.
+** DONE [#C] Collapse waybar sysmonitor to a single icon + hover :feature:waybar:
+CLOSED: [2026-06-27 Sat]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+From the roam inbox (2026-06-22): replace the spread-out sysmonitor readouts (temp, cpu, mem, storage) with one visible icon showing a single chosen metric, the rest in the hover tooltip. Open question: fold it into the battery component instead of a standalone module. Implementation lives in the waybar config under ~/.dotfiles.
+
+Shipped as a standalone =custom/sysmon= module (Craig's call: host-dependent primary — battery on laptop, disk on desktop — rather than fold into battery, which is laptop-only). Backing script =waybar-sysmon= gathers cpu/temp/mem/disk/battery, shows the host-appropriate metric, rest in tooltip; 13-case TDD suite; removed the 5 native modules + their CSS across all 3 themes. Dotfiles be7469b.
+** DONE [#C] Rename idle inhibitor to something more intuitive :chore:waybar:
+CLOSED: [2026-06-27 Sat]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+From the roam inbox (2026-06-24): the "idle inhibitor" name doesn't work as a mnemonic — something like "sleep" (i.e. "keep awake" / "no-sleep") would land better. Decide the new name, then rename across the touchpoints: the =custom/idle= waybar module, the keybind mnemonic, and the backing script names (=hypridle-toggle= / =waybar-idle= from the 2026-06-24 idle-inhibitor work). Needs Craig's call on the name first, so not solo.
+
+Renamed to "caffeine" (Craig's call, 2026-06-27): =custom/caffeine= module, =waybar-caffeine= + =caffeine-toggle= scripts, tooltip "Caffeine: ON/OFF", CSS + test suites updated. Keybind stays =$mod+I= (=$mod+C= is hyprpicker). Shipped in dotfiles 8b45b51.
+** DONE [#B] Guard against live mesa/hyprland/wayland-runtime updates :hyprland:
+CLOSED: [2026-06-28 Sun]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-09
+:END:
+A live =pacman -Syu= that swaps mesa/hyprland/wayland runtime libs out from under a running Hyprland session can crash the compositor: the next GPU-lib call hits a now-"(deleted)" library and SIGABRTs, taking the Wayland clients down with it. Hit ratio 2026-06-07 (mesa 26.0.6 -> 26.1.2 + hyprland upgraded live; Hyprland SIGABRT took down awww/insync/emacs). Likely the driver behind ratio's high lifetime unsafe-shutdown ratio — a crashed compositor forces a hard reset.
+
+Shipped as a pacman PreTransaction hook rather than a wrapper, so it fires no matter how the upgrade is launched (pacman, yay, topgrade). =scripts/hypr-live-update-guard= aborts the transaction before any package is swapped when the GPU/compositor runtime set is being upgraded AND Hyprland is running, pointing the user to re-run from a TTY with the session stopped; it stays quiet when Hyprland isn't running (the safe from-a-TTY path). Override via =HYPR_ALLOW_LIVE_UPDATE=1= or by touching the sentinel file named in the abort message. archsetup installs the script to =/usr/local/bin= and the hook to =/etc/pacman.d/hooks/= in the hyprland path. Decision logic unit-tested (=tests/hypr-live-update-guard=, 9 cases). Live firing test filed under Manual testing and validation. Commits: archsetup (this session).
+** DONE [#B] ZFS pre-pacman snapshot installer step (ZFS-root) :feature:zfs:
+CLOSED: [2026-06-30 Tue]
+Add a ZFS-root-gated installer step that installs the pre-pacman snapshot pacman hook plus a self-pruning =/usr/local/bin/zfs-pre-snapshot= (KEEP=10). The script is hand-placed on velox, not authored by archsetup, so a reinstall loses it; snapshots accumulated unbounded (53 since April) because nothing prunes them and Sanoid ignores non-autosnap_ names. Gate to ZFS-root (velox; ratio is btrfs). Also correct the stale 2026-01-17 security-doc line claiming it's "already in install-archzfs". Needs the hook file (source from velox) and a ZFS-root VM test.
+
+Shipped: =configure_pre_pacman_snapshots()= in boot_ux (late, ZFS-gated) + =scripts/zfs-pre-snapshot=; unit tests for pruning + a Testinfra assertion. VM-verified ZFS install passed 97/0 (test_zfs_pre_pacman_snapshot_hook PASSED). The "stale doc" turned out accurate (it's an install-archzfs archive) — left as-is. Design notes and the KEEP=10 script: [[file:docs/design/2026-06-29-zfs-pre-snapshot-installer.org]]. Origin: home handoff 2026-06-29.
+** DONE [#B] Waybar timer module :waybar:
+CLOSED: [2026-06-29 Mon]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-26
+:END:
+Shipped as =wtimer= in the dotfiles repo (=134d61e=), a single always-visible module right of the battery/resource readout, non-collapsible. Covers all four modes (timer / alarm / stopwatch / pomodoro) with multiple running at once: the bar shows the most urgent item with a per-type glyph + "+N" badge, the tooltip lists them all. Left-click creates (fuzzel), middle-click pauses, right-click cancels, scroll cycles the primary; notify fires on completion and pomodoro phase changes. Pure-functions-over-injected-clock design; CLI serializes state with flock + atomic write so the 1s render and click handlers never lose an update or double-fire. TDD: 86 cases, 95% line coverage. Design spec: [[file:docs/design/2026-06-29-waybar-timer-module-spec.org][docs/design/2026-06-29-waybar-timer-module-spec.org]]. Live-verified on velox (glyph renders, position, countdown); the color states + click interactions filed under Manual testing and validation.
+
+A custom waybar module providing three time-keeping functions, surfaced in the bar with click/scroll controls and dunst notifications on completion.
+
+- *Alarm* — fire a notification at a wall-clock time (e.g. 2:00pm). Builds on the existing =notify= + =at= pattern from protocols.org.
+- *Timer* — count down a duration (e.g. 25m) and notify when it elapses.
+- *Pomodoro* — alternating work/break cycles (default 25/5, long break after 4) with the bar showing phase + remaining time.
+
+Implementation notes (to flesh out when picked up): waybar =custom= module(s) with =exec= polling or a persistent =exec= script emitting JSON; click actions to start/pause/reset; a small state file under =~/.local/state= or =~/.local/var=. Lives in the hyprland tier (=dotfiles/hyprland/.config/waybar/= + a backing script in =hyprland/.local/bin/=). TDD the backing script per testing.md.
+
+*** 2026-06-24 Wed @ 17:32:37 -0400 Scope expansion from roam capture (folded duplicate)
+A roam-inbox capture asked for the same widget and expands the scope, so folding it in here rather than duplicating:
+- *One panel, mode-selectable* — a single component where you choose timer / stopwatch / alarm; the icon changes to reflect the selected mode.
+- *Stopwatch* — a count-up (the third function alongside the alarm/timer/pomodoro above), hover shows start time ("Stopwatch started: 12:22pm").
+- Hover text per mode: timer "Timer: 5 min", alarm "Alarm: 12:15pm", stopwatch "Stopwatch started: 12:22pm".
+- *Multiple simultaneous* — several timers/alarms/stopwatches set and displayed at once, in one panel.
+- Deliverable includes proposing a few panel designs and recommending one before building.
+** DONE [#B] Sysmon module right-click cycles the visible metric :feature:waybar:solo:
+CLOSED: [2026-06-28 Sun]
+Shipped in the dotfiles repo (=f7b6896=, implemented from this archsetup session per Craig). =waybar-sysmon= reads a selected metric from =$XDG_RUNTIME_DIR/waybar/sysmon-metric= (absent = host default, so the old behavior is preserved); the new =sysmon-cycle= helper advances through a host-appropriate ring (battery only on a laptop), wraps, and refreshes waybar via signal 12 wired to =on-click-right=. Left-click stays the btop popup. Added cpu/temp/mem icons + thresholds. TDD: 13 new =waybar-sysmon= selection cases + a 9-case =sysmon-cycle= suite, full dotfiles suite green (29 suites). =sysmon-cycle= symlinked into =~/.local/bin= on velox. Live visual/relogin check filed under "Manual testing and validation". Handoff sent to the dotfiles inbox.
+Builds on the just-shipped =custom/sysmon= collapse (dotfiles be7469b). Right-clicking the module rotates which metric is the visible one, in a fixed order: battery → cpu → temp → mem → disk → back to battery. Each click advances one step and wraps around. The host default (battery on a laptop, disk on a desktop) is the starting/reset metric; the tooltip keeps showing all metrics regardless. Left-click stays =pypr toggle monitor= (the btop popup) — the cycle lives on =on-click-right=.
+
+Implementation notes: =waybar-sysmon= needs a persisted selection (a state file in =$XDG_RUNTIME_DIR/waybar/=, absent = host default) that it reads to pick the visible metric. A new =sysmon-cycle= helper bumps the index and signals the module to refresh (add a =signal= to =custom/sysmon=, like the other custom modules; wire =sysmon-cycle= to =on-click-right=). TDD both — extend =tests/waybar-sysmon= for selection-driven output, add a =tests/sysmon-cycle= for the index advance/wrap and the signal.
+** DONE [#B] Network module: enterprise WiFi add/edit deferred to vNext :waybar:network:
+CLOSED: [2026-06-29 Mon]
+Decided 2026-06-29 (Craig): keep v1 to open + WPA-PSK add/edit; the
+WPA-Enterprise / 802.1X add/edit form is vNext, not a v1 phase. v1 still
+*activates* any saved enterprise profile and points editing at nmtui/nmcli.
+Evidence that settled it: 24 saved profiles on velox, 18 WPA-PSK, 0 enterprise —
+no 802.1X network in Craig's history, so the form would be unused UI. If one ever
+appears, nmtui adds it once and the module activates it thereafter. Spec:
+[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]].
+** CANCELLED [#B] Migrate terminal emulator from foot to ghostty :tooling:
+CLOSED: [2026-06-28 Sun 13:58]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+Decision (Craig, 2026-06-24): switch from foot to ghostty. Drivers: ligatures (foot won't add them) and kitty-graphics + sixel image support (foot is sixel-only, no kitty-graphics plans). ghostty is pure-Wayland on Hyprland, declarative config that fits the theme system, runtime config reload (keybind / SIGUSR2 since 1.2). Trade-off accepted: slightly higher input latency than foot. Already in use as Emacs's terminal renderer, so the config + rendering are familiar and the 06-18 tmux theme was tuned against that surface. Full evaluation: [[file:docs/2026-06-10-terminal-emulator-evaluation.org][docs/2026-06-10-terminal-emulator-evaluation.org]].
+
+Migration scope:
+- archsetup: add =ghostty= to the package list; decide whether to keep =foot= installed as a fallback or drop it.
+- dotfiles: port =foot.ini= → ghostty config (flat key=value). The shared foot.ini sets no font (per-host via =host.ini= include) — replicate that per-host font split for ghostty.
+- Themes: the dupre/hudson =themes/<name>/= dirs hold foot configs; add ghostty theme files and teach =set-theme= to write + reload the ghostty config. Watch the reload-clobbers-OSC-10/11 bug (ghostty #2795) when wiring runtime theme switch.
+- hyprland.conf: default-terminal keybind, pyprland scratchpad terminals, and any other =foot= references → ghostty.
+- Verify on velox + ratio: ligatures render, latency acceptable in tmux+vterm use, dupre theme correct, sixel/kitty-graphics previews work.
+** DONE [#C] Scratchpad launch turns on focus-follows-mouse :bug:hyprland:
+CLOSED: [2026-06-28 Sun]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-28
+:END:
+Root cause: =float_switch_override_focus = 1= in hyprland.conf. With =follow_mouse = 0=, focus still jumped to the window under the pointer when it crossed a floating-tiled boundary, so launching a floating scratchpad re-enabled focus-follows-mouse onto tiled windows. Fixed by setting it to 0 (dotfiles =5619342=). Not a pyprland side effect.
+
+Imported from roam inbox 2026-06-25. Repro: with two tiled windows, moving the mouse over the other tile does nothing (focus-follows-mouse off, as expected). Then launch a terminal (scratchpad), move the mouse over a tile, and focus now switches to the window under the pointer. Something about the scratchpad/terminal launch flips focus-follows-mouse on. Find what re-enables it (likely a Hyprland focus/input setting or a pyprland scratchpad side effect) and keep it off.
+** DONE [#B] mod+J/K focus navigation: raise to front, reach floating, monocle fix :feature:bug:hyprland:
+CLOSED: [2026-06-29 Mon]
+Three improvements to =layout-navigate= (mod+J/K), validated live on velox:
+- Raise the focused window to the front on focus navigation, so focusing a window behind an overlapping floating one brings it forward (dotfiles =5619342=, bundled with the =float_switch_override_focus = 0= scratchpad fix tracked above).
+- Cycle into floating windows, so you can navigate back to a scratchpad like any window instead of it being a one-way trip (dotfiles =f2107f7=).
+- Fixed a monocle regression from that change: the =cyclenext= dispatcher no-ops between monocle-stacked tiles, so focus navigation now computes the workspace window list and focuses the next/prev by address — layout-independent and floating-inclusive (dotfiles =09815f3=).
+** CANCELLED [#C] archsetup Waybar Wi-Fi module should show no-internet state :feature:waybar:
+CLOSED: [2026-06-29 Mon]
+Consolidated, not dropped: the no-internet/captive indicator + the diagnostics/
+bounce/speed-test scope are now Phase 1 + Phase 3 of the unified
+[[*Waybar network module — custom/net][Waybar network module — custom/net]] parent. The work continues there;
+this separate entry is retired so it's tracked in one place. Spec:
+[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]].
+** CANCELLED [#B] Audit dotfiles/common directory
+CLOSED: [2026-06-28 Sun]
+Refiled to the standalone =~/.dotfiles= repo, which owns this content since the 2026-06-16 split. Handoff sent 2026-06-28: =~/.dotfiles/inbox/2026-06-28-1335-from-archsetup-refiled-from-archsetup-task-audit-2026.org=. The three sub-tasks (review ~/.local/bin scripts, remove orphaned configs, verify stowed files are used) travel with it. Cancelled here, not abandoned.
+** CANCELLED [#C] Zoom launches in a tiny window :bug:hyprland:
+CLOSED: [2026-06-28 Sun 13:56]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+From the roam inbox: Zoom opens at a tiny size. Needs diagnosis (HiDPI scaling vs a window rule vs XWayland) and live verification with Zoom actually running — held for a Craig-driven debug pass, not a blind fix.
+** DONE [#B] btrfs base VM unbuildable — archangel ISO bakes zfs-auto-snapshot :bug:test:
+CLOSED: [2026-06-28 Sun]
+Resolved: archangel shipped a fixed ISO (2026-06-27) that conditions the baked AUR list on the filesystem, so a btrfs install no longer drags in =zfs-auto-snapshot=. The btrfs base rebuilt and went green in the 2026-06-28 VM run (97/0, zero attributed issues). The EFI removable-fallback hardening is archangel-side and optional.
+=make test-vm-base= (btrfs) fails in archangel's installer: the ISO bakes a fixed
+AUR list ("downgrade yay informant zrepl pacman-cleanup-hook zfs-auto-snapshot
+topgrade ventoy-bin") into every install regardless of =FILESYSTEM=. On a btrfs
+install =zfs= isn't present, so =zfs-auto-snapshot='s =zfs= dependency can't
+resolve and the unattended pacstrap aborts ("unable to satisfy dependency 'zfs'
+required by zfs-auto-snapshot"). This is an archangel ISO bug (the baked list isn't
+controllable from =archsetup-test.conf=), so it blocks btrfs-profile VM testing
+until archangel ships an ISO that conditions the AUR list on the filesystem (or
+drops zfs tooling from non-zfs installs). The 2026-06-27 btrfs base regen attempt
+also wiped the prior (unbootable) btrfs base, so there's no btrfs base image until
+this is fixed. zfs-profile testing works (=make test FS_PROFILE=zfs=).
+
+Companion hardening (defense-in-depth, archangel-side): install the bootloader
+with a removable =\EFI\BOOT\BOOTX64.EFI= fallback so a base boots even from
+fresh/empty NVRAM, and real installs survive firmware that drops boot entries.
+** DONE [#B] Network panel UI — review findings :feature:waybar:network:
+CLOSED: [2026-07-01 Wed]
+Full UI review 2026-07-01 (visual walk of every state + code pass over the view logic), 30 findings: 21 from the agent review + color audit, 9 from Craig. All fixed the same night in a no-approvals speedrun, five commits, each test-gated (33 suites) and the whole panel re-verified via AT-SPI smoke + screenshots.
+
+*** 2026-07-02 Wed @ 00:00 -0400 Theme pass: contrast, hierarchy, focus, hover, destructive, dialogs (dotfiles 82aad0b, 998829b)
+Selected-row captions turned cream (the dim gray measured 2.2:1 on the slate fill, a WCAG fail on the auto-selected active row). Names gained weight and captions dropped a size (type hierarchy beyond color). Focus rings gold, scrollbars slim slate, rows got a hover wash. Disconnect and Forget became terracotta destructive-action (red = off). The Join/Add dialogs carry the dupre contract now (ground, mono, styled entries with a gold focus border, no stock blue anywhere), the Add dialog disables its action button until the SSID is non-empty, its password field gained the peek icon, and long SSIDs ellipsize.
+
+*** 2026-07-02 Wed @ 00:00 -0400 Connections logic: lies, gaps, races (dotfiles 693f820)
+Saved profiles stopped claiming "open" (security shows only when the scan knows it; subtitles are view-aware — Available adds signal %, Saved says just "out of range"). An active wired connection pins above the wifi scan in Available. Add connects immediately (Craig's decision), both add dialogs' action reads Connect. Rescan went through the op state machine (the dead guard let double-clicks double-scan). A failed initial load shows in the boxes with a retry instead of stranding "Loading…". Available's first message is "Scanning networks…". Live-info ages humanize (7m, not 445s). A held portal replaces the internet line (stable row height under the poll). The poll pauses on other tabs. VPN profiles got a VPN glyph. Forgetting the active network warns it will disconnect you.
+
+*** 2026-07-02 Wed @ 00:00 -0400 Diagnostics restructure: selector, live speed test, streamed verdicts, leaner chrome (dotfiles 787b475)
+The six-button wall became a dropdown tool selector (full tool names, a description of the selection, one Run button) revealed under Advanced. Speed Test became Network Performance: a reveal with Run Speedtest + Stop (no button-flips-to-Cancel), a once-a-second elapsed ticker while running, and Download / Upload / Ping (high-latency warn) / Server / Tip rows in the diagnose aesthetic. Get Me Online streams the diagnose rows first, announces each attempt ("Trying Reset Connection…"), then its result, and closes with a bold verdict row — as do diagnose, the single tools, and the portal login; verdicts left the status line. Get Me Online at a held captive portal opens the login flow (doctor runs the safe, reversible portal-login when fix is requested). Connecting to a network that turns out captive sends a desktop heads-up, waits a beat, then opens the login page. Diagnose evidence humanized ("open internet (HTTP 204)", "names resolve (captive.apple.com)"). The title row and Close button are gone (Esc + focus-loss auto-hide cover a transient popup) and the status line became a self-clearing toast. The AT-SPI driver anchors on the Diagnostics tab now.
+** DONE [#B] Advanced repair buttons: half width, two per row :feature:waybar:network:quick:
+CLOSED: [2026-07-01 Wed]
+The wide Advanced buttons shrink the panel and leave the diagnostics output impossible to read. Make each half width, two to a row, and rename where needed to fit. Origin: roam inbox capture.
+
+Done 2026-07-01 (dotfiles aca6827): the Advanced reveal became a 3-column, 2-row grid (Diagnose, Unblock WiFi, Reset / Restart, Test DNS, Force Portal) per Craig's follow-up; labels shortened, tooltips carry the full descriptions. Verified live + AT-SPI smoke.
+** DONE [#B] Panel action-button rows fill the panel width :feature:waybar:network:quick:
+CLOSED: [2026-07-01 Wed]
+Disconnect / Rescan / Add / Add Hidden, and the Saved row, should be as wide as the panel and the buttons above them. Apply the same homogeneous full-width treatment used on the Available / Saved sub-tabs. Origin: roam inbox capture.
+
+Done 2026-07-01 (dotfiles aca6827): both Connections action rows are homogeneous full-width, which also fixed the panel resizing when Connect flips to Disconnect. Verified live.
+** DONE [#B] Live connection info in the row subtitle :feature:waybar:network:
+CLOSED: [2026-07-01 Wed]
+The live connection information shown in the row hover should also appear in the small print under the connection name, updated in realtime like the hover. Origin: roam inbox capture.
+
+Done 2026-07-01 (dotfiles aca6827): a 1.5s poll fills the active row's subtitle with the bar-tooltip fields minus the SSID (signal, interface, internet + age, portal note, throughput), sharing the bar's per-field formatters. Verified live.
+** DONE [#B] Bake captive-portal login into the net panel :feature:network:
+CLOSED: [2026-07-01 Wed]
+Make the captive-portal login a first-class net-panel feature instead of the one-off =~/.local/bin/hotel-wifi= script. When the engine sees a held portal, offer "Log in to this network" that runs the plain-DNS + clean-browser flow reversibly (disable DoT -> recover the portal URL from the redirect -> open a clean Chrome profile -> restore DoT when online). Reconcile with the existing =net portal= / =captive= helper, whose DNS-hijack-to-gateway model did NOT match the real Hyatt portal.
+
+Full mechanism writeup, the working script, and the integration plan: [[file:docs/design/2026-06-30-captive-portal-login.org]]. From the 2026-06-30 Hyatt saga.
+
+*** 2026-06-30 Tue @ 11:40 -0400 Engine core landed (dotfiles a7d7559)
+Replaced =net portal='s old captive-helper hand-off with a =portal-login= repair tier: drop DoT to plain DNS, probe the portal URL (302 / meta-refresh), open a throwaway browser profile, spawn a detached watcher that restores DoT once online (or on timeout). =net portal --restore= is the manual fallback. 7 tests. So =net doctor= / the bar's =net portal= hookups already run the real flow now. Remaining: (1) name the DoT-blocking cause in =net diagnose=; (2) a dedicated "Log in to this network" button in the panel's Diagnose/Repair tab (today it rides the generic =net portal=); (3) live validation against a real captive portal (unit-tested only — didn't run it live to avoid disrupting a meeting).
+
+*** 2026-07-01 Wed @ 22:41:51 -0400 Live-validated end to end against a local captive simulator (dotfiles c1401db)
+The last remainder. tests/net/captive_sim.py is a local redirect portal (302s to a login page until "logged in", then a clean 204). NET_PROBE_URL and NET_PORTAL_TRIGGERS point the whole flow at it (an overridden probe skips the interface binding, which can't reach loopback). Ran live on velox, both restore paths verified: online-detect (login click, watcher saw the 204, DoT drop-in restored within ~2s, clean exit) and the timeout fallback (a watcher that never saw online restored DoT at its 300s deadline). Real sudo mv, real resolved restarts, real redirect URL recovery, real clean-profile Chrome — against a temp drop-in dir, so live DNS was untouched. All three remainders are done; the task is closed. The remaining what-if is a real venue's walled-garden quirks, which only an actual portal exercises.
+
+*** 2026-07-01 Wed @ 21:44:05 -0400 Diagnose names the DoT block; panel gained Log in to This Network (dotfiles 51e0e2d)
+Remainders 1 and 2 landed. The dns-resolve step names the DoT pin when resolution is dead and the drop-in exists (sysio.dot_forced), and routes next_action to the portal login. The panel's hidden Open Portal button became a first-class suggested-action "Log in to This Network", shown whenever the report holds a portal signal (portal step with or without a URL, or the DoT-blocked resolution) via the unit-tested viewmodel.wants_portal_login. TDD, 33 suites green. Remainder 3 (live validation against a real portal) still open.
+
+*** 2026-06-30 Tue @ 14:59:53 -0400 Live test on velox surfaced two fixed bugs + a deeper follow-up
+Force portal (panel Repair tab) = =net-popup net portal= = the same portal-login tier. Tested live on @Hyatt_WiFi (already authorized, so no real intercept). Two bugs fixed in dotfiles (TDD, full suite green):
+- Chrome first-run wizard fired on every launch — =_open_portal= made a fresh tempfile profile but passed no first-run flags. Added =--no-first-run --no-default-browser-check= + a unit test.
+- Flashing sudo prompt for the DoT drop + pointless resolved restart on velox, where the DoT drop-in the code looks for (=/etc/systemd/resolved.conf.d/dns-over-tls.conf=) doesn't exist. Guarded =_disable_dot=/=_restore_dot= to be true no-ops (no sudo, no restart) when there's no DoT drop-in to move; tests assert no systemctl call fires.
+** DONE [#B] Consistent red=off across waybar toggle modules :waybar:
+CLOSED: [2026-07-01 Wed]
+Extend the red=off convention (just added to the touchpad/mouse indicator) to the other toggles — sound volume, microphone mute, and caffeine — so a disabled / muted / off state reads red across the board. Skip the "cross"/slash; the color alone carries it. Origin: roam inbox capture.
+
+Already implemented (verified 2026-07-01): =style.css= gives =#pulseaudio.muted=, =#pulseaudio.mic.source-muted=, and =#custom-caffeine.inhibited= the off-state color =#d47c59=, matching =#custom-touchpad.disabled=. Note: caffeine's red fires on =.inhibited= (caffeine ON / staying awake), which is arguably the inverse of "off" — leave as-is unless you want strict off=red semantics there.
+** DONE [#B] Microphone-mute keybind :feature:waybar:quick:
+CLOSED: [2026-07-01 Wed]
+A keyboard shortcut to toggle the mic mute. The pulseaudio#mic module shows the state but there's no hotkey to flip it. Wire a hyprland bind to a mic-mute toggle. Origin: roam inbox capture.
+
+Already implemented (verified 2026-07-01): hyprland.conf binds both =XF86AudioMicMute= and =Super+Shift+A= to =mic-toggle= (no conflict — airplane is Super+Shift+X).
+** DONE [#C] Alarm tooltip shows time remaining, not alarm time :bug:waybar:quick:
+CLOSED: [2026-07-01 Wed]
+The =wtimer= alarm tooltip displays the countdown (time remaining) instead of the alarm's wall-clock fire time. For an alarm set to 2:00pm, the tooltip should name the target time, not "1h 23m left". Fix the tooltip rendering in =wtimer= (dotfiles repo). Origin: roam inbox capture.
+
+Fixed 2026-07-01 (dotfiles): =_describe= now renders an alarm's wall-clock target via a new =format_clock= helper instead of =format_time(remaining)=. TDD test added; full wtimer suite (87) green.
+** DONE [#C] Waybar right-cluster module order :waybar:quick:
+CLOSED: [2026-07-01 Wed]
+Move the timer module to the rightmost position, just left of the systray, and move the battery/sysmonitor module to second-to-rightmost. Config edit in the waybar config (dotfiles hyprland tier). Origin: roam inbox capture.
+
+Done 2026-07-01 (dotfiles waybar config): =custom/timer= now sits just left of =tray= with =custom/sysmon= second-to-rightmost. waybar regenerated + reloaded live on velox; visual confirmation pending Craig.
+** DONE [#B] Right-click date/time: ntp sync + timezone update :feature:waybar:
+CLOSED: [2026-07-02 Thu]
+Right-click on the date updates the clock from ntpd (or whatever keeps the clock in sync); right-click on the time runs the update-timezone script. Neither opens a terminal — both run transparently in the background. Errors surface as notifications. The module should detect whether we're online and, if not, show a message instead of running the script. Origin: roam inbox capture 2026-07-02.
+
+Shipped 2026-07-02 (dotfiles 2f7993d). Date right-click = clock-sync (chronyc makestep behind a connectivity gate; offline/captive get explanatory notifications). Time right-click = timezone-set, rewritten to prefer WiFi geolocation (whereami → timeapi.io) over IP lookup — the hotel IP geolocated two timezones off, and ipapi.co is paywalled now (ipinfo.io is the fallback). Both promptless (sudo -n), all outcomes notify, worldclock gained signal 16 for an instant refresh. 13 new tests across clock-sync + timezone-set; live-verified on velox (WiFi path resolved Rhode Island → America/New_York correctly).
+** DONE [#B] Screenshot "view image" option :feature:hyprland:
+CLOSED: [2026-07-02 Thu]
+The screenshot flow should also offer a "view image" selection: saves the shot, opens it in a viewer, and puts the path on the clipboard. Origin: roam inbox capture 2026-07-02.
+
+Shipped 2026-07-02 (dotfiles 10e5961). View Image entry in the post-capture fuzzel menu: opens the shot via xdg-open (default viewer, currently feh) and puts the path on the clipboard. Script gained env seams + a nine-test suite (menu dispatch, both capture modes, cancel, failure). Live check pending: take a shot and pick View Image once.
+** DONE [#C] Collapse-triangle buttons: dimmer, inlaid styling :waybar:
+CLOSED: [2026-07-02 Thu]
+The triangle collapse buttons should look embedded (inlaid) and be slightly dimmed so they don't compete for eye attention with the other components. Origin: roam inbox capture 2026-07-02.
+
+Shipped 2026-07-02 (dotfiles 15cb93c): muted color + dark inset well + smaller glyph; hover still brightens. Hudson theme carries the same shape. Screenshot-verified.
+** DONE [#C] Contrast button ignores the white=on / red=off paradigm :bug:waybar:
+CLOSED: [2026-07-02 Thu]
+The contrast button doesn't respect the white=on, red=off color paradigm the other waybar modules follow. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02.
+
+Shipped 2026-07-02 (dotfiles 15cb93c). The "contrast button" is the auto-dim module — its ON icon (nf-fa-adjust) is the classic contrast glyph. It showed gold when on and nothing when off; now on = default silver, off = terracotta, matching every other toggle. Screenshot-verified both states.
+** DONE [#C] Off-state red inconsistent across waybar modules :bug:waybar:
+CLOSED: [2026-07-02 Thu]
+Terracotta red isn't applied uniformly for "off": the sleep icon is a different shade than the mouse/trackpad when off, and the dim indicator doesn't show red when off at all. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02.
+
+Shipped 2026-07-02 (dotfiles 15cb93c + 7f1f334). The touchpad script's Pango-markup red predated the terracotta theme pass (#d47c59 vs the CSS's #cb6b4d) — unified on #cb6b4d. Dim-off now red (see the contrast task). Bonus find: waybar hands every pulseaudio instance the sink's .muted class, so a muted speaker also painted the mic red — scoped with :not(.mic) so each glyph keys on its own device.
+** DONE [#B] Waybar volume/mic toggle like the touchpad module :feature:waybar:
+CLOSED: [2026-07-02 Thu]
+Make the volume/mic waybar component look and behave like the touchpad/mouse toggle.
+- Move the mic to the other side of the volume so the percentage isn't in the way. The mic and speaker icons sit the same distance apart as the hand and mouse.
+- One keybinding cycles the four states: volume on / mic on, volume on / mic off (red), volume off (red) / mic on, volume off (red) / mic off (red).
+- Move the trackpad/mouse toggle to another keybinding (discuss an open mnemonic, e.g. =d= for disable) and assign Super+M to this module (for mute).
+Origin: roam inbox capture.
+
+Shipped 2026-07-02 (dotfiles 7f1f334). New audio-cycle script walks the four-state ring in the exact order above (wpctl-backed, explicit set-mute so the pair can't desync, 6 tests) on Super+M; live-verified the full ring on velox. Mic moved left of the speaker and hugs it via paired margins, percentage on the outside. Touchpad toggle moved to Super+Shift+I ("input devices" — d-for-disable was taken at both levels by removemaster and dim-toggle); its tooltip and tests follow.
+** DONE [#C] Timer end sends no notification :bug:waybar:
+CLOSED: [2026-07-02 Thu]
+The end of a wtimer timer didn't fire a desktop notification. Needs reproduction to confirm frequency; priority follows the severity-by-frequency matrix once known (a reliably-missing timer-end alert would rate higher). Origin: roam inbox capture.
+
+Root-caused 2026-07-02 (dotfiles ca35642). The pipeline works (a live 3s timer fired and persisted), but notify sent everything --urgency=normal and dunstrc delays normal-urgency popups while a fullscreen window has focus — a timer ending mid-video sat invisible until fullscreen exit. Alarms now go critical urgency, which rides the fullscreen_show_critical rule; verified CRITICAL in dunst history. The alarm sound (paplay, separate from dunst) was never affected.
+** DONE [#C] Keybind hints in waybar module tooltips :waybar:
+CLOSED: [2026-07-02 Thu]
+Every module's hover tooltip should list its keyboard shortcut(s), for discoverability. Audit the modules and add the bindings to each tooltip. Origin: roam inbox capture.
+
+Shipped 2026-07-02 (dotfiles 4c32aec). Audited every module: arrows (Super+[ / Super+]), sysmon (Super+R), net (Super+Shift+N), layout (Super+Shift+←/→), menu (Super+Space / Super+Shift+Q, tooltip enabled), plus the already-hinted dim/caffeine/touchpad/mic/volume. Date/time tooltips document their new right-click actions. Workspaces/window/tray don't take custom tooltips; timer has no keybind.
+** CANCELLED [#C] Smooth waybar expansion animation :waybar:
+CLOSED: [2026-07-02 Thu]
+The cluster expansion jumps instead of animating, and a few systray icons pop in one-by-one afterward, which reads as glitchy. Animate the expansion smoothly if waybar allows it — width transitions are limited, so feasibility is uncertain (hence [#C]). Origin: roam inbox capture.
+
+Assessed infeasible 2026-07-02: collapse works by config rewrite + SIGUSR2 reload, which rebuilds every widget — nothing survives to transition, GTK3 can't animate add/remove without Revealer (an upstream waybar change), and the tray pop-in is async StatusNotifier re-registration. Full findings + revisit conditions: [[file:docs/design/2026-07-02-waybar-expansion-animation-feasibility.org]].
+** DONE [#C] Optional label on timer/alarm/stopwatch items :feature:waybar:
+CLOSED: [2026-07-02 Thu]
+Let each wtimer item carry an optional short text label. The data model already supports it (=add_timer/add_alarm/add_stopwatch/add_pomodoro= all take =label=""=, and =_describe= shows =label or type=); the gap is the fuzzel-driven creation flow, which doesn't prompt for a label. Add the optional label prompt on create. Origin: roam inbox capture.
+
+Shipped 2026-07-02 (dotfiles ca35642): =wtimer new= gained a "label (optional)" fuzzel prompt after the type/value prompts; empty keeps the unlabeled default. 2 new tests (89 total in the suite).
+** DONE [#B] Pocketbook finish-or-cancel decision :pocketbook:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+Decided by Craig 2026-07-02, ahead of the scheduled checkpoint: remove pocketbook altogether. Executed same day — pip package uninstalled (user site clean), running instance killed, launcher gone, =pocketbook/= tree removed from the repo, Super+P rebound to toggle-touchpad (P for Pointers; Super+Shift+I unbound, waybar tooltip hint updated — dotfiles a750cb4). The org-capture popup remains the quick-notes surface.
+** DONE [#B] Provision Eask in archsetup :tooling:eask:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-26
+:END:
+Shipped 2026-07-02 (speedrun): npm global install block added after the nvm line — runs as $username with --prefix $HOME/.local, display/error_warn wrapped, output to $logfile, matching the claude-code block's shape. The npmrc decision went yes: dotfiles common/.npmrc pins prefix=${HOME}/.local (stowed; hand-linked live, npm config get prefix confirms ~/.local — dotfiles 01627cc). VM assertion added: ~/.local/bin/eask present + ~/.npmrc stowed. Live smoke: eask 0.12.9 on PATH. Full acceptance (fresh-install chime make setup/test) rides the next VM pass.
+Add =@emacs-eask/cli= to archsetup's provisioning so fresh machines get it. Eask is installed by hand today and declared nowhere in archsetup or the dotfiles repo, yet both chime and linear-emacs depend on it (their =make setup/test/coverage= shell out to =eask=). Source: handoff from linear-emacs 2026-05-23.
+
+- Add a global npm install after the node block (=archsetup= ~2030, after =aur_install nvm=), modeled on the claude-code native-install block: run as =$username=, wrapped in =display=/=error_warn=, output to =$logfile=. Roughly =sudo -u "$username" bash -c 'npm install -g --prefix "$HOME/.local" @emacs-eask/cli'=.
+- Pin the prefix to =~/.local= so eask lands at =~/.local/bin/eask= (already on PATH) and the install runs as the user, not root. On the current machine =npm config get prefix= returns =/usr=, so eask was installed with an explicit =--prefix=.
+- Decision: also set a persistent user npm prefix (=~/.npmrc= with =prefix=${HOME}/.local=)? If yes, that =~/.npmrc= is a legitimate dotfile to stow; if no, rely on the explicit =--prefix= flag alone. =~/.eask/= is a regenerable cache — leave un-stowed.
+- Acceptance: fresh run leaves =eask= on PATH at =~/.local/bin/eask= (no root); =cd ~/code/chime && make setup && make test= works.
+** DONE [#C] Waybar timer dialog styling :waybar:
+CLOSED: [2026-07-02 Thu]
+From Craig's roam capture 2026-07-02: style the timer module dialogs like the screenshot dialog — tighter window, icons on the selections, colon+space after the prompt.
+
+Shipped 2026-07-02 (dotfiles 9ffcba7): dialogs size to content, type menu carries the kind glyphs, prompts end ": ". Three new tests; screenshot-verified live.
+** DONE [#B] Waybar collapse jumps client windows :bug:waybar:hyprland:
+CLOSED: [2026-07-02 Thu]
+From Craig's roam capture 2026-07-02: collapsing/expanding (and any waybar teardown) snapped every tiled window up and back down; hold the clients still and let only the bar change.
+
+Shipped 2026-07-02 (dotfiles 4b1a4ec): waybar now runs exclusive:false and the new waybar-reserve script statically reserves the bar strip per monitor (wired as exec so config reloads re-apply it). Verified live: client geometry held constant through bar kill, relaunch, and a collapse round-trip. Eight new tests (script + pairing).
+** DONE [#B] Bluetooth panel + bar module :feature:waybar:bluetooth:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:SPEC_ID: 1271a845-4463-4831-9902-990eda6b2265
+:END:
+Spec: [[file:docs/specs/2026-07-02-bluetooth-panel-spec.org]] (IMPLEMENTED 2026-07-02 — all five phases shipped same day: engine eb2230f, panel 76b2c05, bar module e372de3, bt-priv + blueman retirement 2a026b1/d8d8c53, install wiring proven by VM assertions). Residual: the phase 4-5 VM assertions run on the next VM pass; ratio picks up the package removal + hand-links on its trip list.
+
+A bluetooth panel driving a CLI underneath (bluetoothctl one-shot verbs), consistent in look and feel with the net panel (GTK4 + layer-shell + Blueprint, humble-object presenter, verify-everything). Minimalistic interface, full functionality, plus a diagnostics/troubleshooting section mirroring the net panel's Diagnostics tab. Bar module glyph opens it. Craig's ask (2026-07-02): follow UX/UI best practices; where the net panel's patterns conflict with best practices, file a net-panel bug task rather than clone the flaw.
+
+*** 2026-07-02 Thu @ 13:30:42 -0400 Shipped phase 1 — the bt engine package (dotfiles eb2230f)
+=bluetooth/src/bt/= mirrors the net engine's layout: btctl parsing boundary (show/devices/info, connect-error classifier), sysio rfkill/airplane, audio module over pw-dump/wpctl (HSP probe + A2DP switch repair with verify-after), redacted eventlog, six repair tiers, and the doctor chain (adapter → rfkill → service → powered → devices → audio profile) with safe auto-repairs behind =--fix= (never auto-connects; airplane blocks are named, not fought). 101 tests over fake binaries; 42 suites green (=make test= glob auto-discovered =tests/bt/= — gate check verified). Live read-only on velox: =bt status= + =bt doctor= read the real adapter/devices/audio graph; =~/.local/bin/bt= hand-linked (no restow under running Hyprland). Ground truth vs spec: profile inventory needs =pw-dump= (wpctl can't enumerate), and the card's =bluez5.profile= prop is unreliable — sink node's =api.bluez5.profile= is authoritative. Deferred INTO phase 2: the shared dupre css factoring (net's css is an inline string in =gui.py=, not an asset — factoring it without the bt-panel consumer just risks the working net panel).
+
+*** 2026-07-02 Thu @ 14:15:27 -0400 Shipped phase 2 — the GTK panel (dotfiles 76b2c05)
+Cloned the net panel's shape over the phase 1 engine: GTK-free PanelModel + viewmodel (69 new tests, display-free), Blueprint pages (Devices with the adapter power row + Paired/Nearby sub-views; Diagnostics with the doctor cascade, inline fix buttons mapped from step =repair= keys, Advanced tool selector), gui.py controller (layer-shell OVERLAY 380x520 TOP+RIGHT, Esc closes, single-instance toggle, bg worker, passkey + confirm dialogs), pairing pty state machine (=bt/pairing.py=: confirm-passkey yes/no with default-deny, display-passkey, bounded deadline, tested over the fake's interactive mode), manage.py op envelopes shared by CLI and panel (cli refactored onto it; power + discoverable verbs added), =bt panel= subcommand + =bt-panel= toggle wrapper (hand-linked into =~/.local/bin=, no restow under live Hyprland). Shared dupre css factored: net's inline =_CSS= → =hyprland/.config/themes/dupre/panel.css= with =dupre-*= classes, both panels load it (stowed path first, repo-relative fallback; hand-linked =~/.config/themes/dupre/panel.css=). Super+Shift+B rebound blueman-manager → bt-panel (hyprctl reloaded, live). 43 suites green (=make test= exit 0). DEFERRED pending Zoom ending: the AT-SPI smoke (=make test-panel-bt=, written and wired) and any visual check of either panel with the factored css — both need a visible window. Gotcha for posterity: the old =test_panel_stub_exits_two= CLI test ran =bt panel= for real once cmd_panel was wired and launched the panel on the live compositor mid-meeting for ~30s before the test timeout killed it; it now asserts parser wiring only — never run =bt panel= inside =make test=.
+
+*** 2026-07-02 Thu @ 15:06:00 -0400 Shipped phase 3 — the bar module + blueman retirement (dotfiles e372de3)
+=custom/bluetooth= over the engine: waybar-bt shim + =bt/indicator.py= (state-following glyph — slashed off/blocked/absent, plain dim idle, connected mark white; low-battery <15% adds a red pango percentage to the glyph; tooltip = connected devices with battery + Super+Shift+B hint). Signal 10; the panel pokes =pkill -RTMIN+10 -x waybar= after each status reload. Blueman retired from the Hyprland session: exec-once + both windowrules removed, applet killed live; waybar relaunched on the runtime config and the module verified on the bar (connected glyph, blueman tray icon gone). Theme drift guard caught that themes/*/waybar.css edits must mirror into the live =waybar/style.css= — all three updated. 43 suites green. ALSO closed this pass: the deferred phase 2 visual batch (bt AT-SPI smoke green after fixing its Connect/Disconnect state-following assertion — c1a8219; net smoke green on the factored css; both panels eyeballed correct in dupre). Left for phase 4: package removal (blueman out of archsetup), sxhkdrc's dwm blueman-manager binding decision rides that pass.
+
+*** 2026-07-02 Thu @ 15:16:51 -0400 Shipped phase 4 — bt-priv shim, blueman out, VM assertions
+Dotfiles =2a026b1=: the stowed =bt-priv= shim over the phase-2 =bt.priv= module (one verb, =restart-bluetooth=; verified end-to-end against the symlinked fake-systemctl — rc 0 with =BT_SUDO= empty, rc 2 on bad verb/usage; hand-linked into =~/.local/bin=), and the sxhkd =Super+Shift+B= bind repointed from the retired blueman-manager to =st -e bluetoothctl= (the decided terminal fallback — the GTK panel is Wayland-only, and the bt CLI is hyprland-tier so dwm never gets it). 43 dotfiles suites green. archsetup: blueman dropped from the =desktop_environment= bluetooth loop (bluez + bluez-utils stay, solaar untouched); VM assertions added to =test_packages.py= (bluez/bluez-utils installed, blueman NOT installed as the retirement regression guard — collected 15, exercised on the next VM run since VM tests run committed code); =bash -n= + =py_compile= + =make test-unit= green. SUDOERS: no new rule needed, same conclusion as net-priv (2026-07-01 entry) — archsetup:1089 grants the primary user blanket =NOPASSWD: ALL=, which covers =systemctl restart bluetooth=; a narrow bt-priv rule would be dead config under the blanket grant, so phase 5's "sudoers placed" item is satisfied by the existing grant. LIVE: blueman package removed from velox (=pacman -Rns=, decision "drop it outright, both machines"); ratio needs the same + the bt-priv hand-link on its trip list.
+
+*** 2026-07-02 Thu @ 15:19:58 -0400 Shipped phase 5 — install-default wiring proven by VM assertions
+No new install code was needed: the waybar =custom/bluetooth= module, the =Super+Shift+B= → =bt-panel= bind (hyprland.conf), and the shared =themes/dupre/panel.css= all live in the dotfiles hyprland tier, so the existing clone + =make stow hyprland= step lands them on a fresh install; sudoers is covered by the blanket grant (phase 4 conclusion). The phase's substance is the proof: =test_desktop.py= gained hyprland-gated assertions that the four bt bins (=bt=, =bt-panel=, =bt-priv=, =waybar-bt=) are stowed executable in =~/.local/bin= (either stow shape — per-file symlink or folded dir), the waybar config carries =custom/bluetooth=, hyprland.conf carries the =bt-panel= bind, and the stowed theme has =panel.css=. Collected 30 in =test_desktop.py=; exercised on the next VM run (VM tests run committed code).
+
+*** 2026-07-02 Thu @ 15:19:58 -0400 Test surface complete across all phases
+Everything the surface named exists and is green: engine suites over fake binaries (phase 1, 101 tests — btctl parse, doctor chain, A2DP repair verify), PanelModel presenter suite (phase 2, 69 tests), pairing state-machine suite (passkey confirm / NoInputNoOutput / timeout, over the fake's interactive mode), bar-module suite (phase 3), gated AT-SPI smoke (=make test-panel-bt=, run green live), and the phase 4-5 VM assertions (=test_packages.py= bluetooth stack + blueman-absent guard; =test_desktop.py= panel wiring). 43 dotfiles suites green; VM assertions await the next VM run.
+** DONE [#B] All error messages should be actionable with recovery steps
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+Shipped 2026-07-02 (speedrun). Structural fix at the helper: =error_fatal= now takes an optional third recovery-hint arg and every fatal prints the last five log lines inline, the full log path, the per-site "Fix:" when given, and the resume pointer (step markers mean a re-run continues where it stopped) — so even a hint-less fatal is actionable. All 17 fatal call sites got specific hints (keyring reinit, mirrorlist switch, userdel/USERNAME conflict, base-devel for makepkg, DESKTOP_ENV values, dotfiles-dir cleanup, tmpfs sizing, aur.archlinux.org reachability). The end-of-run Error Summary now closes with the grep-the-log line and the fix-and-re-run pointer. =error_warn= already carried what-failed + exit code into the summary; unchanged.
+** DONE [#B] Improve logging consistency
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+Shipped 2026-07-02 (speedrun), paired with the actionable-errors task. Audit result: the install helpers (pacman_install/aur_install/retry_install/run_task/git_install/pipx) and error helpers already tee/append everything to $logfile — the gaps were direct mutations whose stderr went to the console and vanished. Swept every =sed -i= and file-write mutation lacking capture (locale.gen uncomment, pacman.conf ParallelDownloads/Color + multilib, waybar battery removal x3, wireless-regdom, geoclue BeaconDB, paccache, BRIO udev rule, fstab fmask, mkinitcpio HOOKS, sudoers append, ufw status read): each now sends stderr to $logfile, and the previously-silent ones (locale.gen, pacman.conf, multilib, waybar, regdom, geoclue, paccache, udev) gained =error_warn= handlers so failures land in the summary instead of passing silently. Verified: bash -n clean, 10 unit suites green, shellcheck warning-diff vs HEAD empty (no new findings).
+** DONE [#B] Add NVIDIA preflight check for Hyprland
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Shipped 2026-07-02 (speedrun), TDD. =nvidia_preflight_report= is a pure sed-extractable core (same harness pattern as zig-pin): modalias scan for vendor 10DE — DRM first, PCI display-class (bc03) fallback so an NVIDIA audio function can't false-trigger — then the repo's =nvidia-utils= candidate major checked against 535. Prints the Wayland guidance + env vars (LIBVA_DRIVER_NAME, GBM_BACKEND, __GLX_VENDOR_LIBRARY_NAME, ELECTRON_OZONE_PLATFORM_HINT) and the pre-Turing/AUR-legacy note. preflight_checks aborts on <535/unknown (rc 11), prompts continue/abort on a healthy NVIDIA box (rc 10), silent on non-NVIDIA (rc 0). 9 Normal/Boundary/Error tests over fake modalias trees + a fake pacman (=tests/nvidia-preflight/=, glob-discovered by test-unit — 10 suites green).
+** DONE [#C] Wlogout exit-menu buttons are rectangular, not square
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+The wlogout exit menu renders its buttons taller than they are wide on velox, so the cells read as vertical rectangles instead of squares. They render square (centered) correctly on ratio, so this is a per-host / resolution difference, not a flat bug. Fix the button sizing in the wlogout style (=~/.dotfiles/hyprland/.config/wlogout/style.css=) so each cell is square on both hosts. Noticed 2026-05-21. Related: the [#D] VERIFY about wlogout sizing across displays.
+
+The wlogout config uses fixed pixel margins, which is the likely reason sizing differs across the two displays — adjusting them for the laptop screen is part of the fix (folded in from the former "Test wlogout menu on laptop" VERIFY, 2026-06-24).
+
+Add a regression test so the square-cell fix doesn't silently break on a resolution change: assert the rendered (or computed) wlogout button cells are square across ratio's and velox's resolutions. Dropped :quick: — the cross-host test pushes this past a spare-moment fix.
+
+Shipped 2026-07-02 (dotfiles 775771b). Keybind now calls a =wlogout-menu= wrapper computing centered margins from the focused monitor (the old fixed L/R 1200 exceeded velox's 1436 logical width). Also fixed two styling defects the geometry hid: invisible unfocused borders (now muted, so the square edge is visible) and hover/focus sharing one gold rule (lock button glowed at launch; focus is now a muted ring). Tests: unit margin-math suite across both hosts' resolutions + portrait + small + bad-geometry, CSS regression suite, and a compositor-gated =make test-wlogout= smoke that launches a no-op probe, screenshots, and measures squareness (velox: 361x361 px, PASS). Ratio's visual eyeball rides the pending ratio sync.
+** DONE [#C] Net panel: error toasts auto-dismiss unread :bug:network:waybar:
+CLOSED: [2026-07-02 Thu]
+Fixed in dotfiles 0f017d4: viewmodel.toast_plan owns the toast policy — errors show sticky and ignore the post-op refresh's empty clear (worst case: a forget failure's error was wiped within ~2s by its own refresh), and the next real status replaces them. Successes keep the 4s fade. 7 policy tests added; 41 suites green.
+** DONE [#C] Net panel: verify claimed keyboard navigation :test:network:waybar:
+CLOSED: [2026-07-02 Thu]
+Found during the bluetooth-panel UX pass (2026-07-02). The V2 spec claims tab-between-sections, arrow-key row navigation, and type-to-filter, but no custom keyboard code exists in the panel — arrows and type-ahead may ride GTK ListBox defaults, tab-between-sections likely doesn't. Verify each claim against the live panel (AT-SPI smoke can assert focus order); implement or strike the claims from the spec so spec and panel agree.
+
+*** 2026-07-02 Thu @ 13:05:00 -0400 Code-level pass done; live probe deferred (Craig in a Zoom meeting)
+Code reality (dotfiles net/src/net): Esc close is wired (gui EventControllerKey); row-activated -> primary is wired for both connection lists (pages.py:122,126), so Enter-on-row rides the GTK ListBox activate binding; arrows ride ListBox defaults; NOTHING implements type-to-filter (no search/filter code exists — that claim is false as written); Tab is the plain GTK focus chain, widget by widget, not section jumps. Live AT-SPI probe plan: launch panel in test mode, drive keys via hyprctl dispatch sendshortcut targeted AT THE PANEL WINDOW (never the focused surface — wtype/ydotool absent anyway), gate every key on the panel holding focus, never send Enter on the available list (real connect risk). BLOCKED at 13:05: active window is zoom (meeting) — no test windows, no synthetic input until clear. Then: verify focus order + arrows + no-filter live, strike/reword the spec's keyboard bullet to match.
+
+*** 2026-07-02 Thu @ 14:57:18 -0400 Ran the live probe; spec bullet reworded to match reality
+Zoom ended ~14:45; probe ran per plan (panel in test mode, hyprctl dispatch sendshortcut targeted at the panel address, every key gated on panel focus, Enter never sent). Verdicts: arrows move row focus and Enter rides the ListBox activate binding (TRUE — kept); Esc closes (TRUE — kept); Tab is the plain GTK widget-by-widget chain and inside a list crawls row by row, no section jumps (claim FALSE — struck); type-to-filter does not exist (claim FALSE — struck; typing into the 24-row Saved list filtered nothing). Spec's Keyboard bullet reworded with the live evidence and a note that section-jump Tab or filtering would be new work. Probe gotchas for reuse: AT-SPI list items have empty accessible names, so row identity needs get_index_in_parent(); a killed test panel can leave a windowless single-instance process that eats the next launch via D-Bus activation — pkill -9 -f 'net panel$' and wait before relaunching.
+** CANCELLED [#C] Pocketbook development backlog :pocketbook:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-26
+:END:
+Cancelled with the 2026-07-02 remove-pocketbook decision — the app and its in-tree package are gone.
+Pocketbook (GTK4 layer-shell notes panel, toggled via waybar) was pulled from publication 2026-05-26 — github repo + cjennings.net repo deleted, mirror hook removed — and folded into this repo at =pocketbook/= until it's ready to spin back out. Src-layout Python package with pytest tests and a Makefile. Develop it in-tree; the backing modules are =store/note/panel/layer_shell/app/note_widget= + =style.css=.
+
+Backlog (unordered; promote items to their own dated tasks as they're picked up):
+
+- Configurable options, possibly a dedicated configuration panel.
+- Lose-focus hides pocketbook — configurable on/off.
+- Configurable display order: chronological by creation date (asc/desc), manual, alphabetical (asc/desc).
+- Search / filter notes.
+- Global toggle keybind (Hyprland =bind=) alongside the waybar click; document the waybar integration.
+- Note CRUD polish (create/edit/delete) + optional markdown rendering.
+- Pin / favorite notes.
+- Tags or notebooks / categories.
+- Persistence: confirm store format + =~/.local/share/pocketbook/= location, add versioning/migration, decide a backup/sync story.
+- Theming: track the dupre/hudson theme system so =style.css= follows =set-theme=.
+- Layer-shell geometry config (anchor edge, width, margins) + HiDPI / multi-monitor behavior — ties into [[file:docs/PLAN-per-host-overrides.org][per-host overrides]] scaling work.
+- Config file format (toml) + reload-without-restart.
+- Expand test coverage (TDD per testing standards; =tests/= already exists).
+- Release prep for the eventual spin-back-out: pyproject metadata, version, license.
+- Re-wire the archsetup install (gtk4-layer-shell dep + install step + post-install clone) when pocketbook ships. Removed 2026-05-26 — see git history of =archsetup= / =scripts/post-install.sh=.
+** CANCELLED [#C] Fn+F9 toggles pocketbook — source unlocated :hyprland:pocketbook:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-23
+:END:
+Retired with pocketbook itself (2026-07-02 removal) per this task's own exit condition — with the app uninstalled and unbound, whatever Fn+F9 emitted has nothing to toggle.
+On velox, pressing Fn+F9 (physical function key) toggles the pocketbook panel. It shouldn't. Raised from a home-project session 2026-06-23.
+
+Investigated 2026-06-23 and could not locate the trigger in any config. Ruled out, three ways:
+- No F9 bind (bare / $mod / keycode) in the live =hyprland.conf= (now a stow symlink), the velox host tier =conf.d/local.conf=, or the waybar config.
+- =hyprctl binds= runtime (all 90 active binds, authoritative) execs pocketbook on ONLY =SUPER+P=. No F9/XF86 path reaches it. The old touchpad toggle that used to sit on =$mod+F9= was moved to =$mod+M=, so F9 is unbound in Hyprland.
+- No input remapper (keyd/xremap/input-remapper) and no hotkey daemon (sxhkd/swhkd) running or configured; pocketbook's own source has no F9 / GlobalShortcuts / portal / dbus listener (its GTK ShortcutController binds only Esc/Ctrl-n/Ctrl-j/Ctrl-k/Del/Return). pocketbook is a single-instance Gtk.Application, so any path that re-runs =pocketbook= toggles it.
+
+Parked at Craig's call (not worth deeper investigation now). If it resurfaces, the one unfinished step is to capture what keysym Fn+F9 actually emits (=wev -f wl_keyboard:key=, press Fn+F9, read the =sym:= / =code:=) and grep for that. Most likely folds into removing pocketbook from the waybar setup — if pocketbook leaves the bar, retire this with it.
+** CANCELLED [#C] Waybar emacs-service status + control :feature:waybar:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-24
+:END:
+From the roam inbox (2026-06-22): with Emacs integrated into the system as file manager and instant note-taker, make bouncing it trivial. A waybar component showing the emacs service status, with detail on hover, that turns the server on / off / bounce via right-click. Pairs with running the Emacs daemon as a managed systemd user service.
+
+Cancelled 2026-07-02 per Craig during the task-batch pick: no current need. Re-add or pull back from Resolved if a need surfaces.
+** DONE [#C] set-wallpaper detaches waypaper config from its stow symlink :bug:hyprland:quick:solo:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-28
+:END:
+=set-wallpaper= persists with =mv "$tmp" "$CONFIG"=, which replaces the =~/.config/waypaper/config.ini= stow symlink with a real file. After the first run the live config is detached from =~/.dotfiles/hyprland/.config/waypaper/config.ini=, so a later =git pull= + restow won't update it and set-wallpaper changes never flow back to the repo. Fix: write in place rather than =mv= over the symlink — e.g. =cp "$tmp" "$CONFIG"= (follows the symlink to the real dotfiles file), or resolve the link target and write there. Lives in =~/.dotfiles/hyprland/.local/bin/set-wallpaper=; it has a test suite, so add a Boundary case for "CONFIG is a symlink".
+
+Shipped 2026-07-02 (dotfiles d826be4): write-back now redirects through the symlink instead of mv-ing over it; two boundary tests pin the invariant (replace + append paths). velox's live config was still a healthy symlink, so no repair needed.
+** DONE [#B] Instrument-console rebuild: net + bluetooth panels :feature:waybar:network:bluetooth:solo:
+CLOSED: [2026-07-03 Fri]
+:PROPERTIES:
+:SPEC_ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5
+:END:
+The no-approvals speedrun build of the console design Craig approved through five prototype iterations (2026-07-02/03). Spec: [[file:docs/specs/2026-07-03-instrument-console-panels-spec.org]] — the interactive prototype [[file:docs/prototypes/2026-07-03-instrument-console-panels-prototype.html][docs/prototypes/2026-07-03-instrument-console-panels-prototype.html]] is the normative design reference. Folds three open tasks: network panel redesign, bt switch placement + title, bt rename devices. Code in ~/.dotfiles (net/, bluetooth/, themes/dupre/panel.css). Final step: flip the spec to IMPLEMENTED, write the findings summary to file, finalize session context.
+
+*** 2026-07-03 Fri @ 03:20:00 -0400 Phase 2 shipped: net GTK-free console layer + engine verbs
+Dotfiles =81ec9c3= (TDD, 52 new tests, 581 net green). Pure presenter logic for the single-screen console, no view code touched: =viewmodel.net_faceplate= (state word + lamp + TUNNEL/AIRPLANE badges, wired-link-wins precedence), =network_console_rows= (ethernet pinned, radio-off note, active-then-signal sort, per-row lamp/caption/ladder/forget), =channel_headline= (wired device+speed / SSID+ladder+dBm / not-connected placeholder), =tunnel_console_rows=, dial-meter geometry (=meter_needle_deg= + =meter_scale= 100→1000 auto-relabel), =signal_bars=/=mbps_label=, and =panel.ArmState= (two-click arm-to-fire for forget/disconnect). Engine verbs: =manage.wifi_radio= (nmcli radio wifi on|off), =manage.device_up= (ethernet take-the-route), =sysio.link_speed_mbps= (/sys wired speed), =connections.ethernet_devices=, hidden flag on =manage.add=.
+
+*** 2026-07-03 Fri @ 06:02:32 -0400 Phases 3+4 shipped: net view rebuilt as the instrument console
+Dotfiles =800ef60= (1197+/250-). =gui.py= rewritten as the single-screen console — no tabs, no Blueprint template (the dial meters and arm-to-fire rows are too dynamic, so the tree is built in Python; =pages.py= + the =*.blp/*.ui= are now orphaned, Phase 6 dead-code). Faceplate (lamp/word/TUNNEL+AIRPLANE badges/wifi-radio switch/close), engraved CHANNEL headline, scrolled NETWORKS + TUNNELS lamp rows, CONSOLE keys, two cairo dial meters, output well + dismiss ✕, toast. Interactions all wired: open network joins / secured opens the password dialog, active row arm-disconnects (gold), ✕ arm-forgets (terracotta), tunnel toggles, ethernet row takes/yields the route, radio switch flips the wifi radio (refuses under airplane with the way out), + hidden joins a non-broadcast SSID, DOCTOR streams diagnose+repair into the well, SPEED TEST sweeps both dials with the live rate then pins the final with HOLD (location/ping/final/tips in the well). =panel.css= grew the console classes (lamps+glow, b-face, engrave, chan, lamp-row + arm tints, c-btn, meter/mode/hold, output steps, toast). AT-SPI smoke + driver rewritten (anchor on the DOCTOR key). Phases 3 and 4 landed together because a view-only intermediate is a non-functional panel. Verified live on velox: full render screenshotted, console smoke green (faceplate/keys/sections/tunnels/DOCTOR/dismiss/close), DOCTOR streams real diagnose steps, SPEED TEST drove RX 36.6↓ / TX 90.7↑ then HELD. 581 net tests + full make test green.
+
+*** 2026-07-03 Fri @ 06:55:00 -0400 Phase 5 shipped: bt panel rebuilt as the instrument console
+Bluetooth's turn, two commits mirroring net. Phase-5a (dotfiles =5318b34=, 47 new console tests): the GTK-free layer — =viewmodel.bt_faceplate= (POWERED/OFF/AIRPLANE word + lamp + LOW BATT/AIRPLANE badges), =paired_console_rows= / =nearby_console_rows= (lamp rows with connect/forget/rename affordances), =discoverable_chip=, count labels, =battery_gauges= (two dial slots, one per connected device, red under 15%, dim NO DEVICE / ADAPTER OFF empties), =STEP_NARRATION=, and =panel.ArmState= (the forget latch). Engine gaps: =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (set-alias has no MAC-addressed one-shot; =device_path= discovers the controller node from the object tree), =manage.rename= wraps it with a verify-after read, =parse_info= reads the Alias as the display name (a rename lands there, not on Name; the MAC-shaped placeholder stays "unnamed"), and =doctor= grew =on_report=/=on_begin= callbacks. Phase-5b (dotfiles =66f03d9=): =gui.py= rewritten as the single-screen console — faceplate (lamp/word/LOW BATT+AIRPLANE badges/adapter-power switch/close), engraved ADAPTER line with the clickable discoverable chip, scrolled PAIRED + NEARBY lamp rows, CONSOLE keys DOCTOR / SCAN, two cairo battery dials, output well + toast. Interactions: paired rows toggle connect/disconnect, ✎ renames via a dialog, ✕ arm-forgets, nearby rows run the pair flow into a passkey-confirm dialog, the chip toggles discoverability, the switch powers the adapter, SCAN refreshes nearby, DOCTOR streams checks + repairs. =panel.css= gained =.chip= / =.pen= / =.o-passkey= (the rest already shared with net). AT-SPI smoke rewritten (anchor on the bt-only SCAN key). Verified live on velox: smoke green end to end, screenshot matches the prototype (POWERED faceplate, four paired audio devices, two NO DEVICE battery dials). 46 suites + full make test green. Phase 6 next: live both-panel verify, folded tasks closed, dead code removed, spec → IMPLEMENTED.
+
+*** 2026-07-03 Fri @ 06:49:45 -0400 Phase 6 shipped: build closed out, dead code removed, spec IMPLEMENTED
+Live both-panel verify on velox: 46 suites + full make test green, and both AT-SPI smokes green end to end (net: faceplate NET·01/ONLINE, DOCTOR streams real diagnose steps, tunnels rows, close; bt: BT·01/POWERED, SCAN/DOCTOR keys, battery dials, close). The two =gui.py= files are byte-identical to their screenshot-verified commits (net =800ef60=, bt =66f03d9=), so the render carries over from the phase-3/4/5 screenshots — this pass touched no view code. Dead code removed (dotfiles =f4e688e=): both panels' orphaned =pages.py= + =ui/= (=*.blp/*.ui=) gone now that =gui.py= builds the tree in Python, the now-dead =make ui= Blueprint-compile target and its =.PHONY= entry dropped, and the stale =gui.py / pages.py= mention in bt =viewmodel.py= fixed; nothing imported the removed modules. Three folded tasks close with this build (network panel redesign, bt switch placement + title, bt rename devices). Build summary written to [[file:assets/2026-07-03-instrument-console-panels-build-summary.org][assets/2026-07-03-instrument-console-panels-build-summary.org]]. Spec =e73877f5= flipped DOING → IMPLEMENTED. Manual-test checklist for the real-device bt interactions filed under Manual testing and validation.
+** DONE [#B] Net diagnostics: narrate every step :feature:network:solo:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-02
+:END:
+Follow-on 2 (dotfiles =ebf24fe=, Craig's decision 2026-07-02, option 1 of the discussed policies): mutating tiers pre-check whether we're online before acting. dns-test/dns-override short-circuit with an "already online" step and touch nothing (live-verified on hotel wifi: 100 ms skip, DNS untouched); reset/bounce/nm-restart/resolved-restart proceed (reset has a legit online use — fresh MAC) but carry "(was online before ...)" in their evidence; the panel's repair confirm warns via the cached probe verdict (=probe.cached_online=, file read only); rfkill/dns-revert/tunnel-down already verify their own state, unchanged. 492 net tests / 45 suites / panel smoke green.
+
+Follow-on (dotfiles =50a7239=, Craig's ask 2026-07-02): the same requirements now cover every Advanced-dropdown action — all repair ids + portal narrate in the panel's step rows, =net repair= / =net portal= human-by-default (=--json= kept, added to portal), doctor's attempts render like checks, and mutating steps keep their next_action on pass (the fail/warn-only rule was dropping dns-override's revert pointer, portal-login's login click, and cleanup-unverified's manual revert). 479 net tests / 45 suites green; panel smoke shows narration in live rows.
+
+Shipped (dotfiles =7772427=): every diagnose step id carries a one-line narration (what it tests and why) in a viewmodel table, and both human renderers print every step — status, title, narration, evidence with timing, and the fix pointer on fail/warn — in =net doctor= and =net diagnose= alike. Bare =net diagnose= now prints the narrated report (was raw JSON; =--json= keeps the machine envelope; =net-fix= already used =--json= explicitly, the panel calls the engine in-process). A completeness test walks diag.py's step ids against the narration table so a new step can't land unnarrated. 470 net tests / 45 suites green; verified live on velox hotel wifi — both commands narrate the full probe sequence. Ratio picks it up with its queued dotfiles pull (source-imported, no restow-only step).
+
+Original ask (roam inbox, 2026-07-02, from a real net doctor run on hotel wifi): the output isn't enough to know what's being tested, why, and whether it passed — a failing run printed only the verdict, the fix pointer, and the failing rows:
+
+#+begin_example
+% net doctor
+net doctor: fixable
+ DNS not resolving
+ -> net repair dns-test
+ diagnose:
+ fail: DNS resolution — no resolution (portal may be stalling DNS)
+ fail: Internet — link up but no clean internet (DNS or egress issue)
+#+end_example
+** DONE [#B] Network panel: identify tunnel backends + richer connection info :feature:waybar:network:
+CLOSED: [2026-07-02 Thu]
+Shipped (dotfiles =405235f=). Identification: every Tunnels row's caption now leads with its backend — "tailscale", "WireGuard (NetworkManager)", "openvpn (NetworkManager)" (from the profile's =vpn.service-type=, resolved on the panel path only), "Proton VPN CLI" — via =viewmodel.tunnel_kind_label=. Found and fixed a real gap: NM vpn-type profiles (openvpn etc.) weren't listed at all, only wireguard type. Active tunnels now carry their device's IP4 address. Info page: the active connection's live subtitle gains IP, gateway, and DNS via =build_status(full=True)= (panel poll only — the bar's one-nmcli hot path is untouched). Live-verified on velox: all 9 tunnel rows correctly labeled (tailscale w/ tailnet + peers, 7 WireGuard NM profiles, Proton CLI), live subtitle shows IP/gw/DNS on hotel wifi. 523 net tests / 45 suites / panel smoke green.
+
+Craig's ask (roam inbox, 2026-07-02): the Tunnels rows all look alike — no way to tell which is tailscale, which is an NM wireguard/openvpn profile, and which is proton CLI without prior knowledge (e.g. when you want to bounce tailscale specifically). Second half: improve the stats under each connection — the panel is effectively a connection's info page.
+** DONE [#B] Timer: alarm am/pm input silently fails :bug:waybar:solo:
+CLOSED: [2026-07-02 Thu]
+Fixed (dotfiles =8dd36c4=). Two root causes: =parse_alarm= only accepted 24h =HH:MM=, and =cmd_new= suppressed the ValueError, so any 12h input silently created nothing. Now accepts 24h (=14:30=, bare =14=) and all common 12h shapes (=2:30pm=, =2:30 PM=, =7:15p=, =7p=; any case, optional space, bare a/p; 12am = midnight), and input that still doesn't parse fires a fail notification instead of vanishing. 107 wtimer tests green (10 new parse cases + notify-on-error CLI tests). Manual test filed (live dialog run).
+
+Craig's report (roam inbox, 2026-07-02): when setting an alarm, entering am or pm in any fashion makes the timer silently fail. It should accept 24h and 12h variants — capitalization, spaces, bare "a"/"p" — all common forms.
+** DONE [#B] Timer: escape doesn't cancel the dialog flow :bug:waybar:solo:
+CLOSED: [2026-07-02 Thu]
+Fixed (dotfiles =8dd36c4=). Root cause: =_fuzzel= ignored fuzzel's exit code, so Escape (fuzzel exits 2 on a dmenu abort — confirmed in its changelog) returned "" and the flow fed it onward to the next prompt. =_fuzzel= now returns None on any non-zero exit and =cmd_new= aborts the whole flow on None at any step (type, duration/alarm, label). Escape-at-each-step covered by CLI tests against a fake fuzzel exiting 2. Manual test filed (real keyboard Escape).
+
+Craig's report (roam inbox, 2026-07-02): hitting cancel via escape at the step after choosing "timer" does nothing but proceed to the next step — likely the same for the other dialog steps.
+** DONE [#B] Network panel: stream speedtest results live :feature:waybar:network:solo:
+CLOSED: [2026-07-02 Thu]
+FIX-UP (dotfiles =60707be=, 2026-07-03, caught by Craig): the first shipped version didn't actually stream — speedtest-go buffers all phase lines to process exit when piped (per-line arrival timestamps proved it: 25s of silence, then everything at once; the original "live" verification never checked arrival timing). The stream now runs the binary under a pty, where terminal mode redraws continuously: in-flight rates tick (download climbing like a speedometer), ANSI/spinner noise is stripped, and on_update fires per changed value. CLI closes with a "final:" settled-numbers line. Re-verified with timestamps (server +1s, ping +2s, download first tick +4s, upload +19s, final +29s) AND an AT-SPI probe of the live panel that sampled the results box mid-run: ping filled at 4s, download ticking at 12s, upload at 24s, final rows + conditioned tips at the end. 529 net tests / 45 suites green.
+
+Shipped (dotfiles =38171e8=). =run_speedtest_stream= runs speedtest-go's plain mode, whose lines land one per completed phase (parser written against a real captured hotel-wifi run). Panel: a checklist fills in as ping → download → upload arrive, final rows at the end. =net speedtest= streams the same lines at the terminal (=--json= keeps the one-shot envelope). Bonus from the text mode: jitter (rides the Ping row) and packet loss (own row, warns >1%) — the JSON mode never reported either. The static Tip is gone; =speedtest_tips= derives guidance from the numbers (high ping >100ms, download < half of upload, <10 Mbps both ways, loss >1%), each tip naming its trigger values — that's the answer to Craig's "what criteria" question: the old tip had none, the new ones are stated rules. 509 net tests / 45 suites green; live CLI run streamed correctly and fired the asymmetric-download tip on real numbers (33 down / 76 up). Manual test filed for the in-panel run.
+
+Craig's ask (roam inbox, 2026-07-02): the speedtest only shows results at the end; typical speedtest UIs report the numbers as they come in. Stream the CLI's progress into the results box as it arrives, then the final numbers at the end. Screenshot: ~/pictures/screenshots/2026-07-02_225441.png.
+** DONE [#C] Bluetooth bar icon: gray instead of the bar's white :bug:waybar:bluetooth:solo:
+CLOSED: [2026-07-03 Fri]
+Fixed (dotfiles =27d8eda=). Root cause: the =on= state sat in the css dim group with off/absent/degraded (a deliberate "idle dims" choice that read as broken). Removed =.on= from the dim rule in all three css copies (dupre, hudson, live style.css — the theme-drift guard suite pins them together); indicator docstring updated. Live-verified: SIGUSR2 css reload, bar screenshot shows the bt glyph in the bar's resting white alongside battery/text.
+
+Craig's report (roam inbox, 2026-07-03): the bluetooth waybar icon renders gray, not the same white as the other bar module icons.
+** DONE [#B] Bluetooth panel: close button like the net panel :feature:waybar:bluetooth:solo:
+CLOSED: [2026-07-03 Fri]
+Shipped (dotfiles =42c93d6=): a flat circular Close button right of the tab switcher (accessible "Close" label, "Close (Esc)" tooltip), wired to window.close. The bt smoke asserts it exists AND that clicking it exits the panel (run green live). Plot twist answered in-session: the net panel had no close button either — Craig's leaner-chrome pass removed it 2026-07-01 (787b475) on the Esc-suffices theory; he asked where it went, so it was restored with the same tab-row button (=6a0aff7=, net smoke extended the same way). Both panels match again.
+
+Craig's ask (roam inbox, 2026-07-03): the bt panel needs a close button matching the network panel's.
+** DONE [#B] Bluetooth panel: switch placement + panel title :feature:waybar:bluetooth:solo:
+CLOSED: [2026-07-03 Fri]
+Delivered by the instrument-console rebuild (spec e73877f5, phase 5). The adapter-power switch now sits on the faceplate above every console key, and the engraved ADAPTER line is the panel's title row with the clickable discoverable chip right-justified on it.
+
+Craig's ask (roam inbox, 2026-07-02): move the bluetooth on/off switch above all the buttons. "Bluetooth" becomes the panel's title, with the on/off switch right-justified on that title row. Panel code in ~/.dotfiles =bluetooth/= (GTK4 + Blueprint, phase-2 PanelModel/presenter — see the shipped panel task in Resolved). Presenter tests + the AT-SPI smoke likely need their layout assertions updated.
+** DONE [#B] Bluetooth panel: rename devices :feature:waybar:bluetooth:solo:
+CLOSED: [2026-07-03 Fri]
+Delivered by the instrument-console rebuild (spec e73877f5, phase 5). =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (no MAC-addressed one-shot exists; =device_path= finds the controller node from the object tree), =manage.rename= wraps it with a verify-after read, and =parse_info= reads the Alias as the display name. Each paired row carries a ✎ affordance opening a rename dialog. Live-probed the mechanism on velox before wiring it (rename + restore verified on the M650).
+
+Craig's ask (roam inbox, 2026-07-02): the panel should be able to rename a device. bluez supports per-device aliases (=bluetoothctl= device menu =set-alias=; the one-shot invocation shape needs verifying at the btctl boundary). Wire it through the engine (=bluetooth/src/bt/=) with a verify-after read, and surface a rename affordance on the device row consistent with the panel's existing patterns.
+** DONE [#B] Network panel: other network interfaces (tailscale, VPNs, wireguard) :feature:waybar:network:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:SPEC_ID: 79a1075a-4b56-4f25-a861-b69f120a636a
+:END:
+Spec: [[file:docs/specs/2026-07-02-net-panel-other-interfaces-spec.org]] (DOING — reviewed READY and decomposed 2026-07-02 evening; all four decisions were resolved same morning, claims re-verified live at review: protonvpn binary, tailscale JSON shape, seven importable wireguard configs).
+
+Tunnels visible and controllable in the net panel: tailscale + NM wireguard + proton-vpn-cli probes, a Tunnels group in Connections, diagnose/doctor route-ownership awareness, a bar badge when a tunnel owns the default route, archsetup operator flag + package swap, and the one-time NM import of the seven Proton configs. Origin: roam inbox capture 2026-07-02.
+
+*** 2026-07-02 Thu @ 18:47:05 -0400 Shipped phase 1 — overlay probes (dotfiles 2d9d060)
+=net/src/net/overlays.py=: one probe per backend, shared row shape ={kind, name, state, addr, detail, can_toggle}=. tailscale parses =status --json= (up/down/needs-login/stopped, tailnet + N/M peers online + exit node detail, first TailscaleIP); wireguard rows filter =nmcli connection show= by type with uuids for the existing up/down wrappers; proton drives the official CLI — ground truth sampled live before writing the parser: the GUI-running refusal prints to stdout and EXITS 0 (text-detected, =can_toggle false=), disconnected = "Status: Disconnected", and the CLI's account store is separate from the GTK app's (=protonvpn info= → Account 'None' — sign-in is a phase 6 migration step for Craig). =net status= gained a fast-path overlays section (tailscale + wireguard only; the python CLI's ~300ms startup stays out of the indicator poll, and an active proton tunnel surfaces as its NM wireguard row anyway), guarded so a probe crash yields =[]= not a dead indicator. 19 new tests over fake-tailscale/fake-protonvpn/fake-nmcli (45 suites green); live check on velox: tailscale row up, 5/6 peers, hot path 149ms. proton-vpn-cli 1.0.1 installed on velox (GTK app stays until phase 5).
+
+*** 2026-07-02 Thu @ 19:02:45 -0400 Shipped phase 2 — panel Tunnels sub-view (dotfiles 21db05a)
+Connections gained a third sub-view (Available | Saved | Tunnels — a StackSwitcher page, the natural landing for the spec's "fourth group" in this UI): rows from =overlays.collect(fast=False)= with the vpn glyph, name, and a =tunnel_caption= (state · addr · backend detail); one primary button follows the selected row via =PanelModel.tunnel_primary()= — Bring Up/Bring Down when toggleable, disabled explainers for needs-login ("Sign in first: tailscale up") and the Proton GUI-running case. =manage.tunnel_up/down= dispatch by kind (wireguard rides the existing nmcli up envelope + =connection down=; tailscale/protonvpn shell their tools into a =_tool_result= envelope carrying stderr on failure); ops run on the worker thread, rows + bar reload on land. gui grew =refresh_tunnels()= (bg, full probe set) kicked from the list load. AT-SPI smoke extended (Tunnels tab, action button, rows — POLLING for the bg load; a fixed sleep raced it and false-failed). 22 new tests (45 suites green). LIVE on velox: smoke fully green, rows eyeballed in dupre (tailscale up caption with peers count; proton app-running row), =tailscale set --operator=cjennings= applied and the user-mode =tailscale down/up= round-trip verified (Self.Online back true). Gotcha reconfirmed: stray test panels leave a windowless single-instance process — =pkill -9 -f '[n]et panel'= + wait before relaunch.
+
+*** 2026-07-02 Thu @ 19:11:47 -0400 Shipped phase 3 — diagnose/doctor tunnel awareness (dotfiles 31ba056)
+=overlays.default_route_owner()= classifies the default route's owner (tailscale prefix, wg/pvpn/proton/tun/tap prefixes, else the active NM connection's type — imports can name a wireguard device anything). diag's route step went three-way: overlay owner = informational pass row ("internet flows through the tailscale tunnel tailscale0"), other physical link = the old multi-homing warn. When the HTTP probe fails while a tunnel owns the route, a new "tunnel" edge row LEADS the evidence and the classifier returns fixable/action tunnel-down (the deferred-vpn verdict is retired — it was look-don't-touch, and it never caught tailscale at all since NM lists it unmanaged; an NM VPN that doesn't own the route now falls through to normal classification instead of being blamed). =repair_tunnel_down= dispatches by owner (tailscale CLI / protonvpn CLI for pvpn-named devs / nmcli connection down via active-connection lookup), verifies route ownership actually moved, and registered in ACTIONS so Get Me Online drives it. fake-ip gained FAKE_IP_DEFAULT_DEV_SEQ (head-first line consume, the UP_RC_SEQ idiom) so tests watch the owner change across the verify. 11 new tests, 2 old deferred-vpn pins rewritten to the new contract; 45 suites green; live read-only diagnose on velox clean (wlan owns the route — no tunnel rows, as designed).
+
+*** 2026-07-02 Thu @ 19:14:58 -0400 Shipped phase 4 — bar tunnel badge (dotfiles b4010bf)
+=net status= carries =tunnel_route= ({dev, kind} via =overlays.default_route_owner=, exception-guarded like the overlays list, present on the no-device path too). The indicator appends a small nf-md-vpn badge after the state glyph, emits =["<state>", "tunnel"]= as a waybar class list (string class unchanged when no tunnel), and the tooltip names the owner ("Tunnel: default route via tailscale0 (tailscale)"). No css edit — presence is the signal, themes can hook the class later, and the waybar/style.css drift test stays untouched. 4 new tests; StatusHarness gained fake-ip so the machine's real route can't leak into assertions (462 net tests, 45 suites green). Live payload on velox verified badge-free (wlp170s0 owns the route — correct); a badge render awaits the first real tunnel-owned route (phase 6's wg import or a tailscale exit node).
+
+*** 2026-07-02 Thu @ 21:56:00 -0400 Shipped phase 5 — installer proton CLI swap + tailscale operator (archsetup 0389790); GTK app retired live on velox
+The feat commit landed at 19:16 (the session died before this close-out): installer enables tailscaled with =--now= and grants =tailscale set --operator= to the primary user (brief retry while the daemon's socket comes up), proton-vpn-cli replaces proton-vpn-gtk-app, VM asserts the vpn stack + the retirement + the OperatorUser pref (format verified against a live daemon). Live velox application finished 21:55: the =protonvpn-app --start-minimized= exec-once removed (dotfiles b5c8442 — nothing replaces it, the CLI is on-demand from the panel), the running app killed, =pacman -Rns proton-vpn-gtk-app= (proton-vpn-daemon stays — separate package the CLI uses). CLI verified unblocked: =protonvpn status= → "Status: Disconnected", =protonvpn info= → Account 'None' (sign-in is Craig's step, filed under Manual testing and validation).
+
+*** 2026-07-02 Thu @ 21:57:00 -0400 Shipped phase 6 — wireguard import script + velox migration (scripts/import-wireguard-configs.sh)
+The script stages each config through a =wgpvpn.conf= temp copy (NM's import name must be a valid <=15-char interface name; several config names are longer), renames by the UUID parsed from the import output (never by the transient name, so a stray same-named connection can't be hit), forces =autoconnect no= (full-tunnel AllowedIPs 0.0.0.0/0 must not arm itself at boot), skips already-imported names, and refuses to run past a stale =wgpvpn= connection (an earlier run that died between import and rename — it still has autoconnect on). =tests/import-wireguard-configs/=: 10 cases over a fake nmcli; writing them caught a real bug (under =set -e= the grep-for-UUID pipeline aborted before the error message printed). shellcheck clean; 11 unit suites green. Velox migration verified: the crashed session had already run the import, so tonight's run exercised the skip path live — all 7 connections confirmed wireguard type, autoconnect no, iface wgpvpn, no stale leftovers; =net status= overlays show tailscale + all 7 rows. Ratio runs the script on its trip (rides the archsetup pull).
+
+*** 2026-07-02 Thu @ 21:58:00 -0400 Test surface complete across the phases
+Probe suites over fake tailscale/nmcli/protonvpn (19, phase 1), panel-model Tunnels coverage (22, phase 2), diag overlay-ownership cases (11, phase 3), badge suite (4, phase 4) — all in dotfiles; VM assertions for phase 5 in archsetup 0389790; the import-script suite (10, phase 6) closes the set.
+** CANCELLED [#B] File-manager swallow pattern :feature:hyprland:
+CLOSED: [2026-07-02 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-02
+:END:
+Reassigned to .emacs.d 2026-07-02 (handoff: =~/.emacs.d/inbox/2026-07-02-2231-from-archsetup-dirvish-popup-swallow-handoff.org=). The "file manager" is the dirvish popup (Super+F, an Emacs frame), not nautilus — so the fix is elisp in dirvish's external-open path (=cj/xdg-open=): spawn the handler directly with =start-process=, hide the popup frame, restore it from the process sentinel, notify on non-zero exit. The spec drafted here first ([[file:docs/specs/2026-07-02-file-manager-swallow-spec.org]], now CANCELLED) records the feasibility finding that stays useful: gio/xdg-open launches double-fork, so no PID-ancestry approach (Hyprland native swallow included) can ever connect viewer to launcher.
+
+When the file manager launches another app, it should hide to a special workspace (the "swallow" pattern) and return when that process ends, rather than vanishing. Today it disappears with no signal of whether it's coming back, so the user can't tell success from failure — they should quit explicitly instead. Origin: roam inbox capture.
+
+*** 2026-07-02 Thu @ 22:20:00 -0400 Feasibility ground truth: Hyprland native swallow ruled out
+=misc:enable_swallow= would be the whole feature in two config lines, but it matches by PID ancestry, and nautilus's launch path (GLib =g_app_info_launch_default_for_uri=) orphans the handler — reproduced live on velox with a python-gi launcher: feh came up with PPID 1 while the launcher was still running. The spec's design is therefore an event-listener daemon (socket2 =openwindow=/=closewindow= while nautilus is active), the touchpad-auto shape. Handlers sampled: pdf → zathura, image → feh (X11 — flagged as a side task), video → mpv, text → emacsclient (exempt candidate, decision 2).
+** DONE [#C] Open meeting links in the browser instead of the Zoom app :feature:
+CLOSED: [2026-07-02 Thu]
+Shipped 2026-07-02, mechanism per Craig ("the Linux zoom app is really terrible — one less dependency"): a =zoommtg://= URL handler, and the native app retired outright. =zoom-web= (dotfiles 187414a, 10 tests) registers as the xdg default for x-scheme-handler/zoommtg via =zoom-web.desktop=; Zoom's launch-page bounce rewrites deterministically to =https://<host>/wc/join/<confno>?pwd=…= in the default browser (subdomain hosts preserved, tracking params dropped, start action mapped, malformed URIs notify + exit 2). The registration landed in the stowed mimeapps.list, so it ships with dotfiles. Zoom uninstalled from velox (=pacman -Rns=), its windowrules removed from hyprland.conf, =aur_install zoom= dropped from archsetup, and the VM retired-package assertion now covers blueman + zoom. Known limit, accepted: a host who disabled join-from-browser blocks the web client — that meeting needs the native app installed ad hoc. Ratio trip: =pacman -Rns zoom= + the pull brings the handler; run =xdg-mime default zoom-web.desktop x-scheme-handler/zoommtg= if the stowed mimeapps.list doesn't take effect.
+** DONE [#B] Network panel redesign — no terminals, verify-everything, full failure coverage :feature:waybar:network:
+CLOSED: [2026-07-03 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-02
+:END:
+Delivered by the instrument-console rebuild (spec e73877f5). The three locked decisions all landed: no terminals (the single-screen console renders every action and result in the output well — net-popup is gone), the passwordless privileged path (the net-priv helper + narrow NOPASSWD sudoers, shipped earlier and carried forward), and verify-every-action (arm-to-fire mutations plus doctor's re-probe). The failure-mode catalog below is the diagnose/repair contract, built out across the net-diagnostics tasks and this rebuild's DOCTOR path; the catalog stays here as the standing completeness reference for that path.
+
+Major evolution of the shipped =custom/net= module ([[file:docs/design/2026-06-29-waybar-network-module-spec.org]]).
+Reverses the spec's "privileged tiers run in a net-popup terminal" decision. Origin:
+design conversation 2026-06-30.
+
+*** Locked decisions
+- *No terminals anywhere in the module.* Delete =net-popup= entirely. Every action and
+ every result renders in the panel.
+- *Passwordless privileged path (the enabler).* A single root-owned helper runs net's
+ specific privileged commands (rfkill unblock, nmcli modify/up, networking off/on,
+ systemctl restart NetworkManager/systemd-resolved, resolvectl dns/revert, DoT toggle),
+ installed by archsetup with a narrow NOPASSWD sudoers rule scoped to that helper only
+ (never blanket mv/systemctl). =repair.py= calls =sudo <helper> <verb>=. This supersedes
+ and absorbs the earlier [#C] "Passwordless DoT toggle" follow-up. Without it an in-panel
+ worker thread can't prompt for a password, so this gates the whole no-terminal goal.
+- *Verify every action.* Every mutating op confirms its effect before reporting success
+ (doctor already re-probes; generalize so each repair, connect, forget, add, and DNS
+ override re-checks and surfaces pass/fail in the panel).
+- *Detect + respond to every failure mode below* (auto-fix where we can, else report the
+ helpful text), including the edge cases.
+
+*** Navigation (confirmed)
+- Top tabs: =Connections= | =Diagnostics= | =Performance=.
+- Connections: saved + in-range list, connect / add / forget.
+- Diagnostics: sub-row =Diagnose= | =Get Me Online= | =Advanced=; shared area below shows
+ diagnose items AND streams repair progress (replacing the terminal). =Advanced= reveals
+ the individual repair buttons, renamed with tooltips describing each.
+- Performance: Speedtest (+ live throughput later).
+
+*** Failure-mode catalog — detect / correct-or-report (the completeness backbone)
+Organized by the connectivity stack, bottom-up. "Fix" = auto-correct + verify; "Report" =
+the in-panel text when there's no safe auto-fix. Audit this list for completeness; it is the
+contract for what diagnose must detect and what the panel must say.
+
+**** Radio / hardware
+- rfkill soft block — Detect: rfkill soft. Fix: unblock + =nmcli radio wifi on=, verify radio unblocked.
+- rfkill hard block — Detect: rfkill hard. Report: "WiFi is off at the hardware switch — flip the physical switch or Fn key."
+- No WiFi adapter present — Detect: no wifi device in nmcli + rfkill absent. Report: "No WiFi adapter detected — use ethernet, or check the driver (dmesg | grep firmware)."
+- Driver/firmware not loaded — Detect: device present but errored / no operational state. Report: "WiFi driver or firmware didn't load — check dmesg for the adapter."
+- USB WiFi adapter unplugged — Detect: device disappeared since last scan. Report: "WiFi adapter was removed — reconnect it."
+- Airplane mode on — Detect: airplane state file set. Fix: offer toggle off (Super+Shift+A), verify radios back.
+
+**** Association (L2 link)
+- Not connected / disconnected — Detect: link down, device disconnected. Fix: reset (reconnect saved), verify link up.
+- Stuck "connecting" — Detect: device state connecting > budget. Fix: reset, verify; if it persists Report: "Stuck connecting to <ssid> — the AP may be rejecting us."
+- Weak signal / high loss — Detect: associated but signal below threshold (dBm) or heavy packet loss. Report: "Signal is weak (<dBm>) — move closer to the access point."
+- Saved network not in range — Detect: profile active target not in scan. Report: "<ssid> isn't in range here."
+- AP roaming flap — Detect: BSSID bouncing. Report: "Connection is unstable — switching between access points."
+
+**** Authentication
+- Wrong WPA password / missing secret — Detect: NM state 120 (snapshot; live detection is a known limit). Report + in-panel re-enter: "Saved password for <ssid> was rejected — re-enter it."
+- Enterprise / 802.1X cert or identity failure — Detect: 802.1X profile + activation failure. Report: "Enterprise auth failed — check the certificate or identity (edit the profile)."
+- Randomized MAC rejected by AP — Detect: reset-with-random-MAC fails where a prior connect worked. Fix: retry reset with the permanent MAC, verify; else Report.
+- WPA3/SAE incompatibility — Detect: SAE key-mgmt + association failure. Report: "This network needs WPA3 and the adapter or profile may not support it."
+
+**** IP / DHCP
+- No IPv4 lease (DHCP timeout) — Detect: connected, no IP4.ADDRESS. Fix: reset → bounce, verify lease.
+- APIPA / link-local only (169.254.x) — Detect: only a link-local IPv4. Fix: reset/bounce, verify real lease; else Report: "DHCP server didn't answer — switch network."
+- IPv6-only network (no IPv4 by design) — Detect: no IPv4 but IPv6 address + online via v6. Report (not a failure): "Online over IPv6 (no IPv4 here)." Requires making diagnose IPv6-aware.
+- IP but no gateway — Detect: IP4.ADDRESS present, IP4.GATEWAY empty. Fix: bounce, verify gateway; else Report.
+- Duplicate IP / ARP conflict — Detect: kernel ARP-conflict signal. Report: "Another device is using our IP address — reconnect to get a new lease." (edge)
+
+**** Gateway (L3 local)
+- Gateway unreachable — Detect: no route out, gateway no ICMP. Fix: try one bounce (renew route), verify online; else Report: "No route to the gateway — switch network." (closes the spec/code gap where bounce was never tried)
+
+**** DNS
+- No resolver configured — Detect: IP4.DNS empty. Fix: bounce to re-pull DHCP DNS, verify; else Report.
+- Venue DNS broken, public DNS works — Detect: name fails to resolve but 1.1.1.1 resolves (dns-test). Fix: set a PERSISTENT resolver override (1.1.1.1 / 9.9.9.9), verify resolution + online, offer revert. (closes gap #1 — today dns-test reverts and misreports as upstream.)
+- DNS hijack (resolves to gateway / private IP) — Detect: classify_resolution hijack. Treat as captive → portal-login flow.
+- DNSSEC validation failure — Detect: resolution fails with SERVFAIL where public resolver succeeds without DNSSEC. Report: "DNS security checks are failing on this network." (edge)
+- Encrypted DNS (DoT/DoH) hiding the portal — Detect: captive suspected + DoT on. Fix: portal-login drops DoT, opens portal, auto-restores. (existing)
+
+**** Egress / internet
+- Upstream / AP outage (no uplink) — Detect: link/IP/DNS fine, http-probe fail, not a redirect. Report: "This network has no internet — switch network or contact the venue."
+- Captive portal (redirect) — Detect: probe redirected. Fix: portal-login opens the page; verify online after login.
+- Captive blocked pre-auth (no portal URL) — Detect: probe blocked, no URL. Fix: fresh MAC + open trigger; verify.
+- Proxy-required network — Detect: probe fails but a PAC/proxy is advertised (WPAD/env). Report: "This network requires a proxy — configure it in settings." (edge)
+- MTU / MSS blackhole (PMTUD broken) — Detect: small probe ok, large transfer hangs. Fix: lower the interface MTU, verify; else Report. (edge)
+- Clock skew breaking TLS — Detect: HTTPS/portal fails with cert-time errors + system clock far off. Fix: trigger a time sync, verify; else Report: "System clock is wrong — fix the date/time." (edge)
+
+**** Routing / multi-homing
+- VPN owns the route, no internet through it — Detect: VPN device connected + http-probe fail. Report: "Internet is routed through a VPN (<dev>) — check the VPN, not WiFi."
+- VPN up but dead — Detect: VPN device up, no traffic/handshake. Report: "The VPN is connected but not passing traffic." (Phase 5 territory)
+- WiFi + tether/ethernet both active — Detect: which iface owns the default route + whether the system is online by any path. Report: "You're online through <other iface>; WiFi itself has no internet," or let the user pick. (closes gap #4)
+
+**** Infrastructure / system
+- Wedged NetworkManager — Detect: nmcli fails / API unresponsive. Fix: restart NetworkManager (bounce escalation), verify.
+- NetworkManager not running — Detect: service inactive. Fix: start it, verify; else Report.
+- systemd-resolved down — Detect: resolved inactive / DNS via it fails. Fix: restart, verify.
+- resolv.conf not resolved-managed — Detect: /etc/resolv.conf not the resolved stub. Report: "DNS isn't managed by systemd-resolved — manual resolv.conf in play." (edge)
+
+**** Tooling / environment
+- nmcli / NM API unavailable — Detect: nmcli error or timeout. Report: "Can't reach NetworkManager — is it installed and running?"
+- Slow / hung tool — Detect: step exceeds budget. Fix: degrade that step, retry within budget.
+- Stale / corrupt cache — Detect: schema/age mismatch. Fix: self-heal (atomic write + invalidation).
+- Missing speedtest backend — Detect: speedtest-go absent. Report: "Install speedtest-go to run a speed test."
+- Privileged op fails (helper missing / sudo declined) — Detect: helper exits non-zero or absent. Report: "Couldn't get admin rights for this repair — <install/fix the helper>."
+
+*** 2026-07-01 Wed @ 13:02 -0400 net-priv helper landed (V2.1)
+Craig's call: stowed (not root-owned), low security on locked-down single-user machines.
+Shipped =net.priv= module + stowed =net-priv= bin (dotfiles =00aac1e=): a fixed 12-verb set
+(rfkill/radio/mac-random/conn-up/net-off/net-on/restart-nm/dns-set/dns-revert/restart-resolved/
+dot-disable/dot-enable) with per-arg validation (uuid/iface/ipv4/resolved.conf.d-path, injection
+rejected). =repair.py= now routes every privileged op through =priv.run(verb)= in-process instead
+of scattered inline sudo — which also fixes the detached DoT-restore watcher (runs privileged ops
+with no tty) and closes the gap where rfkill repair ran unprivileged. 244 net + 33 dotfiles suites
+green. NO new sudoers needed: archsetup already grants =%<user> ALL=(ALL) NOPASSWD: ALL=
+(archsetup:1089), so every build's primary user already runs net-priv's commands passwordless;
+"replicate in archsetup" is already satisfied. net-priv rides =make stow hyprland=; hand-linked on
+velox. The velox DoT-path reconcile (whether velox should run DoT at all) stays open — folded into
+the deeper reconcile, low priority since the guard makes it a no-op.
+*** 2026-07-01 Wed @ 14:05:47 -0400 Shipped V2.2 — merged Diagnostics panel + nav restructure, no terminals
+Built the V2 panel (dotfiles =75ed825=, pushed): three top tabs Connections |
+Diagnostics | Performance; Diagnostics merges the old Diagnose + Repair pages into a
+sub-row (Diagnose | Get me online | Advanced) over a shared area that shows diagnose
+rows AND streams repair progress in-panel. net-popup deleted entirely; repairs run on
+a worker thread through net-priv (no tty). doctor grew an =on_step= callback so Get me
+online streams each escalation step live. Connections groups Saved / Available now /
+Wired with a golden group header and joins from a row (=join_plan= auth matrix +
+=manage.join= one-step connect, secret to NM only); the Add modal became the hidden-network
+affordance. Every diagnose/repair/speed run offers a Copy/Open redacted report
+(=report.py=, MAC/IP scrubbed). Waybar visual contract applied (dark capsule, golden
+border, monospace) via a CssProvider. =net-fix= opens the panel on Diagnostics instead of
+a terminal; middle-click runs =net portal= directly. TDD: 34 new GTK-free tests (grouping,
+join_plan, join, report, on_step, eventlog.tail); 278 net + 33 dotfiles suites green.
+Live-verified: AT-SPI panel_smoke passes end-to-end + screenshots confirm both pages and
+the visual contract. DAILY-DRIVER: waybar config + net-fix are stow symlinks (live on
+disk); ratio needs =git pull= + waybar restart; velox waybar picks up on next restart.
+**** 2026-06-30 Tue @ 17:36 -0400 Dispositioned the 4th-review findings into the spec
+Codex's 9 fourth-review findings (8 accept, 1 modify) are folded into the spec's
+"V2 panel UX — the target design" section (cookie [40/40]): single nav target,
+saved-vs-available groups, join-from-row instead of Add, the auth-class join matrix,
+progressive loading, future-tense + verified Forget, a findable redacted diagnostics
+report, the Waybar visual contract, and a lightweight inline latency probe (full speed
+test stays under Performance per decision 19). The V2 build below implements that
+design: [[file:docs/design/2026-06-29-waybar-network-module-spec.org::*V2 panel UX][V2 panel UX]].
+*** 2026-07-01 Wed @ 22:01:38 -0400 Made diagnose IPv6-aware and multi-homing-aware (dotfiles c0d48e2)
+IPv6-only networks pass the DHCP step ("IPv6 only: <addr>") with the v6 gateway standing in for the ping; a bare fe80:: doesn't count. A new route step fires only under multi-homing and names the interface that owns the default route (tether/ethernet/VPN). Also landed the adjacent IP-layer detects: APIPA 169.254 fails DHCP with a link-local explanation, address-without-gateway fails the gateway step as a bad DHCP answer, and a weak wifi signal (below fair) warns on the link step with the dBm. fake-nmcli grew IP6.* and a fake ip(8) serves the JSON route reads. TDD, 33 suites green.
+
+*** TODO Close every detect/correct gap in the catalog, with post-action verification
+**** 2026-07-01 Wed @ 22:41:51 -0400 Closed the feasible edge rows (dotfiles d096b30, 241744b, fafefb6)
+Three grouped commits, all TDD. Services/radio: dead NetworkManager and dead systemd-resolved get their own diagnose steps and verified restart repairs (resolved only when resolv.conf is resolved-managed; hand-managed DNS gets a heads-up row), airplane mode fails the link by name and classifies needs-user-action ahead of rfkill, and a missing WiFi adapter is named with the dmesg pointer. Association/auth: reset retries once with the permanent MAC when the randomized one is rejected (new mac-permanent net-priv verb), SAE/WPA3 activation failures classify sae-incompat, and stuck-connecting classifies fixable/reset. Egress edges (run only on an existing failure): DNSSEC validation failure named via resolvectl, clock skew off the probe's Date header, MTU/PMTUD blackhole via df-bit pings, and proxy detection (env vars or an advertised WPAD name). Deferred as infeasible without state the engine doesn't keep: AP roaming flap (needs BSSID history), duplicate-IP/ARP conflict (needs the kernel log), and the USB-unplug transition (its end state is the no-adapter row). Still open here: generalized post-action verification for connect/forget/add.
+
+**** 2026-07-01 Wed @ 22:01:38 -0400 Closed the two named correct gaps (dotfiles 7819f58)
+Gateway unreachable now earns one bounce before the upstream verdict (classifier returns fixable/bounce on gateway warn/fail + probe fail; reachable-gateway keeps the honest upstream call, DNS failure still outranks it). Venue-DNS-broken-but-public-works now ends online: the dns-test chain escalates to a persistent dns-override (1.1.1.1 on the link, dies on reconnect, offered dns-revert undo; a useless override reverts itself) instead of auto-reverting into a misreported upstream outage. Override-aware getent/curl fakes model the venue end to end. Remaining: the edge rows (DNSSEC, proxy, MTU blackhole, clock skew, ARP conflict, roaming flap, stuck-connecting budget, USB-adapter unplug, driver/firmware, WPA3/SAE, randomized-MAC retry, NM-not-running, resolved-down, unmanaged resolv.conf) and the generalized post-action verification for connect/forget/add.
+*** TODO Automatic diagnostic verbose-capture (failing diagnose + Advanced toggle)
+On =overall: fail=, elevate the underlying stack (NM =WIFI,DHCP,DNS,CORE= / systemd-resolved /
+wpa_supplicant) to debug at runtime, run the escalation, capture the journal + dmesg window +
+=curl -v=, then restore every level. Also a manual "Debug on/off" toggle in Advanced for
+reproducing intermittent failures. HARD: restore is guaranteed (try/finally) AND crash-guarded
+(next run detects a left-elevated stack and restores it, like the DoT-restore watcher); the
+captured journal is REDACTED before the bundle is written/shown (raw wpa_supplicant/NM debug
+carries the PSK/EAP secret in cleartext) with a secret-leak test; log-level toggles run via the
+V2 sudo-helper. Bonus: wpa_supplicant debug catches wrong-password/EAP failures the current NM
+state-120 snapshot misses, so it also closes the auth live-detection gap. Spec: Observability →
+"Automatic diagnostic verbose-capture". Origin: Craig 2026-06-30.
+
+*** VERIFY Dead-GUI console recovery vs "no terminals" — keep =make online= or replace it? :network:
+The cj comment (2026-07-01) said scrub every terminal the module uses to report to or get input
+from the user, and I folded that into decision 15 (all module UX is in-panel). The one place it
+collides: the deliberate console-recovery path — =make online= / =net doctor --fix= run from a
+bare TTY when waybar and the GUI are *down* — is the whole point of the CLI being usable with no
+GUI. That's a terminal reporting to the user, but only because there's no panel to use. Keep it
+as an explicit carve-out (recovery-only, not terminal-as-UI), or replace it with something else
+(a TTY text UI still counts as a terminal)? Your call settles whether the Makefile/CLI recovery
+targets stay in the spec.
+** DONE [#B] Audio panel spec :feature:waybar:audio:solo:
+CLOSED: [2026-07-03 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-02
+:END:
+Went past the spec to a full build in a no-approvals speedrun. Spec is now IMPLEMENTED ([[file:docs/specs/2026-07-03-audio-panel-spec.org]], all 5 Decisions resolved). The panel shipped in the dotfiles repo (branch panel-bugfixing, commits 65e5bb0..9601420): pactl engine, GTK-free presenter, GTK instrument-console panel (OUTPUTS/INPUTS device rows with faders + per-device mute, LIVE/MUTED/PUSH·TALK mic keys, twin VU gauges, master quick-mute), Hyprland-bind push-to-talk, bar indicator, and the bar/keybind wiring (Super+A → panel, XF86AudioMute → master quick-mute). 102 unit tests + a passing AT-SPI smoke on velox. Live-eyeball validation filed under Manual testing and validation. Apply steps + follow-ups handed to the dotfiles project inbox.
+
+Original ask (roam inbox, 2026-07-02): net/bt-panel kin — change default output/input, volume for both, push-to-talk mic mode for meetings, master quick-mute, bar sound-glyph state. Related bindings: Super+M audio-cycle ring, Super+Shift+A mic-toggle. Prototype: =docs/prototypes/2026-07-03-sound-panel-prototype.html=.
+** DONE [#B] Panels moveable + resizable by drag :feature:waybar:network:bluetooth:
+CLOSED: [2026-07-04 Sat]
+Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): both net + bt panels switched from anchored gtk4-layer-shell overlays to normal floating windows (set_decorated(False), positioned by the net.cjennings.netpanel window rule), so Hyprland moves them on drag and resizes on corner-drag natively. That was exactly the "switch to a normal floating window" approach the design note flagged as the required decision.
+
+Both the net and bluetooth instrument-console panels should be repositionable and resizable at runtime: click-drag to move the panel anywhere on screen, drag the corners to resize. Raised from roam capture 2026-07-03.
+
+Design note: the panels are gtk4-layer-shell overlays anchored TOP+RIGHT with fixed margins — layer-shell surfaces are compositor-positioned, so free drag-move/resize needs either dynamic margin updates on pointer motion or a switch to a normal floating window (Hyprland moves/resizes those natively). Approach decision required before build.
+** CANCELLED [#B] Net panel wider initial width :waybar:network:quick:
+CLOSED: [2026-07-04 Sat]
+Superseded by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel is now a floating, user-resizable window (set_default_size(420, 560)), no longer a right-anchored layer-shell surface. The task's mechanic ("keep the right edge fixed, extend the left border leftward") assumed the old anchored surface, which no longer exists — the width is now drag-adjustable. Cancelled per the 2026-07-04 audit (Craig's call to close rather than re-file a "bump the 420px default" task).
+
+Start the network panel a bit wider — keep the right edge fixed (it's right-anchored), extend the left border leftward. Raised from roam capture 2026-07-03.
+** DONE [#B] Net panel doctor results can't display :bug:waybar:network:
+CLOSED: [2026-07-04 Sat]
+Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel gained a streaming output well (gui.py) with a "Copy results" button (via wl-copy) and a dismiss control that collapses the well back to the panel's pre-open height (_shrink_to_compact asks Hyprland to resize back). Doctor/speed-test output streams into it as appended lines — matching the task's ask for a tall results box, copy button, and collapse-back. This capture (filed 2026-07-03 morning) predates the same-day 22:06 redesign that addressed it.
+
+The doctor diagnostic output is unreadable — the results well is too constrained to show the multi-line result. It should open a results box tall enough for several lines with a copy-results button; closing it via an X in the box's upper-right collapses the space back to what it occupied before. Raised from roam capture 2026-07-03.
+** DONE [#B] Timer GTK panel :feature:waybar:
+CLOSED: [2026-07-05 Sun]
+Built and shipped to dotfiles 2026-07-05 in a no-approvals speedrun (4 commits =1f4f270=..=78d3cbb=): wtimer gained watch/lap/save; a new =timer/= package holds a GTK-free PanelModel (62 tests) and the GTK instrument-console panel; the bar's =custom/timer= now opens the panel and the fuzzel creation flow retired. Spec: [[file:docs/specs/2026-07-02-timer-panel-spec.org]] (IMPLEMENTED). Code-complete; live GTK verification filed under Manual testing and validation below.
+
+From Craig's roam capture 2026-07-02: give the timer a GTK UI/UX like the network panel. Scope expanded via a later cj comment (queue/output-wall auto-sorted by fire time, stopwatch lap/stop + saveable runs, 5/25 configurable defaults, up to 10 timers, widget-gallery elements) — folded into the spec's Build scope and shipped.
+
+*** 2026-07-05 Sun @ 07:20:20 -0500 Redesign shipped — hero-on-top rebuild
+The UI/UX redesign (decided through the prototype process, final = [[file:docs/prototypes/2026-07-02-timer-panel-prototype-3.html]]) built and shipped to dotfiles in a no-approvals speedrun, 5 commits =c7ac193=..=5a863b5=: Phase 1 wtimer engine (timer repeat; recurring alarms with snooze/ringing/dismiss; =@half=/=@hour=/=+dur= alarm parse; the rebuilt configurable pomodoro cycle — work/rest short+long, long-every-N, auto vs awaiting); Phase 2 PanelModel view-data (=row_view=, ringing-first sort, per-type create options as wtimer flags, locked presets + half-past + named pomodoro cycles); Phase 3 GTK hero-on-top panel (Cairo progress ring + stopwatch analog sweep dial, per-type create strips, one transport row, close ✕/Esc); Phase 4 bar-tooltip parity. wtimer + timer suites 231 green, full =make test= green. Spec re-flipped DOING → IMPLEMENTED. Stopwatch run-save deferred to vNext. Live GTK render is the refreshed manual checklist below.
+** CANCELLED [#B] Test each modernization thoroughly before replacing
+CLOSED: [2026-07-04 Sat]
+Retired in the 2026-07-04 audit (Craig's call): a standing-judgment umbrella with no completion criterion. The fleet is Hyprland-only now, and per-change test discipline is already carried by the actual work (TDD + the VM harness), so this adds nothing to track. Original intent: ensure new tools integrate with the Hyprland environment and don't break workflow (archsetup still supports DWM/X11 but no current machine uses it).
+** DONE [#C] Window focus lost when unhiding stashed windows :bug:hyprland:
+CLOSED: [2026-07-04 Sat]
+Verified fixed live on ratio 2026-07-04 (Craig at the machine). Stash (Super+O) → restore (Super+Shift+O) left the restored window focused, and Super+J/K (layout-navigate) cycled focus normally afterward — both original symptoms gone. Resolved by two fixes that postdated the filing: dotfiles 5619342 (raise window on focus nav, stop float focus-follow, 2026-06-28) and 09815f3 (cycle focus by address so j/k works in monocle, 2026-06-29). Both confirmed present in ratio's HEAD and in the live layout-navigate script at test time.
+
+From the roam inbox: hiding a window (e.g. the org-capture popup) then unhiding it should leave the unhidden window focused, but another window typically takes focus. Also =ctrl+j/k= (layout-navigate) can't reach the unhidden window afterward — it should always reach any visible window except the waybar. Involves stash-restore + layout-navigate; needs interactive reproduction with Craig. (Note: the actual bind is Super+J/K, not ctrl+j/k as the capture said.)
+** DONE [#C] Instrument-console panel bugs (net/bt/audio) :bug:dotfiles:
+CLOSED: [2026-07-05 Sun]
+Batch from the roam inbox (2026-07-05). Panel code lives in =~/.dotfiles= (net/, bluetooth/, audio/). All eight shipped 2026-07-05, each verified live and covered by the panel test suites.
+*** 2026-07-05 Sun @ 17:49:51 -0400 Titled the panel windows Network/Bluetooth/Audio (was python3)
+The GTK app set no window title, so it fell back to the process name. dotfiles 2d03451.
+*** 2026-07-05 Sun @ 17:49:51 -0400 Faceplate word is the subsystem name, not live state (net/bt/audio)
+NETWORKING / BLUETOOTH / AUDIO; state now reads off the lamp colour + badges. Bluetooth's DOCTOR feedback (CHECKING/FIXING) moved to the status line and output well. dotfiles 5c58833.
+*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel flags a signed-out Proton CLI as needs-login
+The proton probe reads =protonvpn info= first, since =protonvpn status= says Disconnected either way. The panel already blocks a needs-login row with a sign-in hint. dotfiles 2671472.
+*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel sorts live tunnels to the top
+Stable within each backend group. dotfiles 2671472.
+*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel refuses a second full-tunnel VPN while one is active
+tailscale is a mesh overlay, not a full tunnel, so it never conflicts. dotfiles 307a0fe.
+*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel dedupes Proton's own wireguard row
+The proton CLI's =ProtonVPN <server>= NM profile no longer shows alongside the proton backend row. dotfiles dbc9ee8.
diff --git a/archsetup b/archsetup
index 38fac89..92a1e4a 100755
--- a/archsetup
+++ b/archsetup
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# ArchSetup - Craig Jennings <craigmartinjennings@gmail.com>
# https://cjennings.net/archsetup
# License: GNU GPLv3
@@ -35,6 +36,7 @@ show_status_only=false
skip_gpu_drivers=false
enable_autologin="" # empty=auto-detect, true=force enable, false=skip
install_claude_code=true # false to skip the claude-code native (curl|sh) install
+install_device_udev_rules=true # false to skip device-specific udev rules (the Logitech BRIO camera rule)
while [ $# -gt 0 ]; do
case "$1" in
@@ -123,6 +125,8 @@ load_config() {
[[ "$NO_GPU_DRIVERS" == "yes" ]] && skip_gpu_drivers=true
[[ "$INSTALL_CLAUDE_CODE" == "yes" ]] && install_claude_code=true
[[ "$INSTALL_CLAUDE_CODE" == "no" ]] && install_claude_code=false
+ [[ "$INSTALL_DEVICE_UDEV_RULES" == "yes" ]] && install_device_udev_rules=true
+ [[ "$INSTALL_DEVICE_UDEV_RULES" == "no" ]] && install_device_udev_rules=false
# Repository overrides
[[ -n "$DWM_REPO" ]] && dwm_repo="$DWM_REPO"
@@ -188,6 +192,10 @@ validate_config() {
echo "ERROR: INSTALL_CLAUDE_CODE must be 'yes' or 'no'. Got: '$INSTALL_CLAUDE_CODE'" >&2
exit 1
fi
+ if [[ -n "$INSTALL_DEVICE_UDEV_RULES" && "$INSTALL_DEVICE_UDEV_RULES" != "yes" && "$INSTALL_DEVICE_UDEV_RULES" != "no" ]]; then
+ echo "ERROR: INSTALL_DEVICE_UDEV_RULES must be 'yes' or 'no'. Got: '$INSTALL_DEVICE_UDEV_RULES'" >&2
+ exit 1
+ fi
if [[ -n "$locale" && ! "$locale" =~ ^[a-z]{2,3}(_[A-Z]{2})?(\.[A-Za-z0-9-]+)?(@[A-Za-z]+)?$ ]]; then
echo "ERROR: LOCALE looks malformed: '$locale'. Expected e.g. en_US.UTF-8" >&2
@@ -373,18 +381,105 @@ safe_rm_rf() {
rm -rf "$target"
}
+# backup_system_file <path>
+# Snapshot a pre-existing system file to <path>.archsetup.bak before archsetup
+# edits it in place, so a botched in-place edit (fstab, mkinitcpio.conf,
+# sudoers, ...) is recoverable. Idempotent: never overwrites an existing
+# backup, so the pristine original survives repeated edits within a run and
+# across re-runs of the installer. Returns 0 and does nothing when <path>
+# does not exist (nothing to back up) or when a backup is already present.
+# Uses `cp -p` so a restored sudoers/fstab keeps its mode and ownership.
+# Prints its own warning to stderr and returns non-zero only when the copy
+# itself fails. Self-contained — no dependency on error_warn.
+backup_system_file() {
+ local target="$1"
+ local backup="${target}.archsetup.bak"
+
+ if [ -z "$target" ]; then
+ echo "backup_system_file: empty target" >&2
+ return 1
+ fi
+ [ -f "$target" ] || return 0 # nothing to back up
+ [ -e "$backup" ] && return 0 # pristine backup already captured
+ if ! cp -p -- "$target" "$backup"; then
+ echo "backup_system_file: failed to back up '$target'" >&2
+ return 1
+ fi
+}
+
# Handle --status flag (must be after state_dir is defined)
-if $show_status_only; then
+if [ "$show_status_only" = "true" ]; then
show_status
fi
# Handle --fresh flag
-if $fresh_install; then
+if [ "$fresh_install" = "true" ]; then
echo "Starting fresh installation (removing previous state)..."
safe_rm_rf "$state_dir" "/var/lib/archsetup"
fi
### Pre-flight Checks
+# NVIDIA/Wayland preflight core — pure report + status; the caller prompts.
+# Scans DRM (then PCI display-class) modalias files for the NVIDIA vendor id
+# (10DE); on a match prints the Wayland guidance + required env vars and
+# checks the repo's candidate nvidia-utils major version. Globs overridable
+# for tests: NVIDIA_DRM_GLOB, NVIDIA_PCI_GLOB.
+# Returns: 0 = no NVIDIA GPU; 10 = NVIDIA, driver requirement met (>= 535);
+# 11 = NVIDIA, requirement not met (repo driver too old or unknown).
+nvidia_preflight_report() {
+ local drm_glob="${NVIDIA_DRM_GLOB:-/sys/class/drm/card*/device/modalias}"
+ local pci_glob="${NVIDIA_PCI_GLOB:-/sys/bus/pci/devices/*/modalias}"
+ local found=false modalias modalias_file
+
+ # shellcheck disable=SC2086 # the unquoted vars ARE the globs
+ for modalias_file in $drm_glob; do
+ [[ -r "$modalias_file" ]] || continue
+ modalias=$(cat "$modalias_file" 2>/dev/null)
+ case "$modalias" in
+ *v000010DE*|*v000010de*) found=true ;;
+ esac
+ done
+ if [ "$found" != "true" ]; then
+ # shellcheck disable=SC2086
+ for modalias_file in $pci_glob; do
+ [[ -r "$modalias_file" ]] || continue
+ modalias=$(cat "$modalias_file" 2>/dev/null)
+ # Only display-class devices (bc03), so an NVIDIA audio/usb
+ # function on the same card doesn't trigger the check.
+ if [[ "$modalias" == *bc03* ]]; then
+ case "$modalias" in
+ *v000010DE*|*v000010de*) found=true ;;
+ esac
+ fi
+ done
+ fi
+ [ "$found" = "true" ] || return 0
+
+ echo " [!!] NVIDIA GPU detected."
+ echo " Wayland/Hyprland on NVIDIA needs driver 535+ and explicit"
+ echo " environment variables; expect rougher edges than AMD/Intel:"
+ echo " LIBVA_DRIVER_NAME=nvidia"
+ echo " GBM_BACKEND=nvidia-drm"
+ echo " __GLX_VENDOR_LIBRARY_NAME=nvidia"
+ echo " ELECTRON_OZONE_PLATFORM_HINT=auto"
+ echo " archsetup installs nvidia-open-dkms (Turing+); pre-Turing"
+ echo " cards need an AUR legacy driver instead."
+
+ local ver major
+ ver=$(pacman -Si nvidia-utils 2>/dev/null | awk '/^Version/ {print $3; exit}')
+ major="${ver%%.*}"
+ if [[ "$major" =~ ^[0-9]+$ ]] && [ "$major" -ge 535 ]; then
+ echo " [OK] NVIDIA driver candidate: ${ver} (>= 535)"
+ return 10
+ fi
+ echo "ERROR: NVIDIA driver requirement not met"
+ echo " Required: driver 535 or newer for usable Wayland"
+ echo " Repo offers: ${ver:-unknown (pacman -Si nvidia-utils failed)}"
+ echo " Fix: refresh the package database (pacman -Syy) and retry, or"
+ echo " install with DESKTOP_ENV=dwm (X11) instead."
+ return 11
+}
+
preflight_checks() {
echo "Running pre-flight checks..."
@@ -426,6 +521,20 @@ preflight_checks() {
fi
echo " [OK] System: Arch Linux detected"
+ # NVIDIA + Wayland preflight: abort on a too-old driver, confirm on a
+ # detected card (the report prints the guidance + env vars).
+ local nvidia_rc=0
+ nvidia_preflight_report || nvidia_rc=$?
+ if [ "$nvidia_rc" = "11" ]; then
+ exit 1
+ elif [ "$nvidia_rc" = "10" ]; then
+ read -r -p "Continue installing for Wayland on NVIDIA? [Y/n]: " nvidia_go
+ case "$nvidia_go" in
+ [nN]*) echo "Aborted at NVIDIA preflight."; exit 1 ;;
+ *) echo " [OK] Continuing on NVIDIA." ;;
+ esac
+ fi
+
# Check locale configuration
if grep -q "^LANG=" /etc/locale.conf 2>/dev/null; then
current_locale=$(grep "^LANG=" /etc/locale.conf | cut -d= -f2)
@@ -514,7 +623,7 @@ intro() {
# count the arch packages before install
pacman -Q > "$packages_before" || \
- error_fatal "generating pre-install package list" "$?"
+ error_fatal "generating pre-install package list" "$?" "confirm pacman works on this system: pacman -Q | head"
}
### Error Handling
@@ -530,10 +639,20 @@ error_warn() {
}
# Fatal error - log and exit
-# Usage: error_fatal "what failed" "$?"
+# Usage: error_fatal "what failed" "$?" ["recovery hint"]
+# Every fatal points at the log (with its last lines inline), prints the
+# per-site recovery hint when one is given, and names the resume path —
+# archsetup's step markers mean a re-run continues where it stopped.
error_fatal() {
printf "CRASH: %s (error: %s) @ %s. Halting.\n" \
"$1" "$2" "$(date +'%T')" | tee -a "$logfile"
+ if [ -f "$logfile" ]; then
+ printf " Last log lines:\n"
+ tail -n 5 "$logfile" | sed 's/^/ | /'
+ printf " Full log: %s\n" "$logfile"
+ fi
+ [ -n "${3:-}" ] && printf " Fix: %s\n" "$3" | tee -a "$logfile"
+ printf " Then re-run archsetup - completed steps are tracked, so it resumes here.\n" | tee -a "$logfile"
exit 1
}
@@ -561,6 +680,29 @@ display() {
### Installation Helpers
+# Describe-run-warn primitive. Announces a task, runs the command with
+# stdout+stderr appended to $logfile, and on failure logs a non-fatal
+# warning carrying the command's real exit code. Replaces the recurring
+# action="desc" && display "task" "$action"
+# cmd >> "$logfile" 2>&1 || error_warn "$action" "$?"
+# idiom with a single call:
+# run_task "desc" cmd arg...
+run_task() {
+ local desc="$1"
+ shift
+ display "task" "$desc"
+ "$@" >> "$logfile" 2>&1 || error_warn "$desc" "$?"
+}
+
+# Enable one or more systemd units with the conventional wording.
+# Each unit is announced and warned independently via run_task.
+enable_service() {
+ local unit
+ for unit in "$@"; do
+ run_task "enabling $unit service" systemctl enable "$unit"
+ done
+}
+
MAX_INSTALL_RETRIES=3
retry_install() {
local pkg="$1"
@@ -590,7 +732,12 @@ retry_install() {
# Pacman Install
pacman_install() {
- retry_install "$1" "pacman" "pacman --noconfirm --needed -S \"$1\""
+ retry_install "$1" "pacman" "pacman --noconfirm --needed -S \"$1\"" || return $?
+ # --needed skips a package already present as a dependency and leaves
+ # its install reason alone; the package can then surface as an orphan
+ # later and get swept away (expac/lm_sensors nearly went this way,
+ # 2026-07-08). Every declared package is wanted explicitly.
+ pacman -D --asexplicit "$1" >> "$logfile" 2>&1 || true
}
# AUR Install
@@ -743,7 +890,7 @@ EOF
# GPU Driver Installation
install_gpu_drivers() {
- if $skip_gpu_drivers; then
+ if [ "$skip_gpu_drivers" = "true" ]; then
display "task" "Skipping GPU driver installation (--no-gpu-drivers)"
return 0
fi
@@ -770,7 +917,7 @@ install_gpu_drivers() {
done
# Fallback: check PCI bus modalias if DRM not available (early boot/chroot)
- if ! $detected_intel && ! $detected_amd && ! $detected_nvidia; then
+ if [ "$detected_intel" != "true" ] && [ "$detected_amd" != "true" ] && [ "$detected_nvidia" != "true" ]; then
for modalias_file in /sys/bus/pci/devices/*/modalias; do
if [[ -r "$modalias_file" ]]; then
modalias=$(cat "$modalias_file" 2>/dev/null)
@@ -787,25 +934,25 @@ install_gpu_drivers() {
fi
# Install drivers based on detected hardware
- if $detected_intel; then
+ if [ "$detected_intel" = "true" ]; then
display "task" "Intel GPU detected (via modalias) - installing drivers"
pacman_install mesa
pacman_install intel-media-driver # hardware video acceleration
pacman_install vulkan-intel # Vulkan support
fi
- if $detected_amd; then
+ if [ "$detected_amd" = "true" ]; then
display "task" "AMD GPU detected (via modalias) - installing drivers"
pacman_install mesa # includes VAAPI drivers (libva-mesa-driver was folded in)
pacman_install xf86-video-amdgpu
pacman_install vulkan-radeon
fi
- if $detected_nvidia; then
+ if [ "$detected_nvidia" = "true" ]; then
display "task" "NVIDIA GPU detected (via modalias) - installing drivers"
# nvidia-dkms left the repos; nvidia-open-dkms is the packaged driver
- # (Turing and newer — pre-Turing cards need an AUR legacy variant,
- # see the NVIDIA preflight task).
+ # (Turing and newer — pre-Turing cards need an AUR legacy variant;
+ # nvidia_preflight_report warns about this at preflight).
pacman_install nvidia-open-dkms
pacman_install nvidia-utils
pacman_install nvidia-settings
@@ -813,7 +960,7 @@ install_gpu_drivers() {
fi
# Fallback for VMs or unknown hardware
- if ! $detected_intel && ! $detected_amd && ! $detected_nvidia; then
+ if [ "$detected_intel" != "true" ] && [ "$detected_amd" != "true" ] && [ "$detected_nvidia" != "true" ]; then
display "task" "No GPU detected via modalias - installing generic drivers"
pacman_install mesa
pacman_install xf86-video-vesa
@@ -840,16 +987,35 @@ prerequisites() {
display "title" "Prerequisites"
+ bootstrap_pacman_keyring
+ install_required_software
+ configure_build_environment
+ configure_package_mirrors
+}
+
+bootstrap_pacman_keyring() {
display "subtitle" "Bootstrapping"
+ # If the base ships informant (e.g. an archangel-installed system), it
+ # registers a pacman PreTransaction hook (AbortOnFail) that blocks every
+ # package transaction while Arch news is unread. Mark it all read up front
+ # so the keyring/refresh/install steps below don't abort. --all marks
+ # without printing or prompting; a bare `informant read` is interactive and
+ # would hang an unattended run. No-op when informant isn't installed.
+ if command -v informant >/dev/null 2>&1; then
+ action="marking Arch news read (informant)" && display "task" "$action"
+ informant read --all >> "$logfile" 2>&1 || true
+ fi
+
action="ensuring current Arch Linux keyring" && display "task" "$action"
- (pacman -Syy) >> "$logfile" 2>&1 || error_fatal "$action" "$?"
+ (pacman -Syy) >> "$logfile" 2>&1 || error_fatal "$action" "$?" \
+ "check network and mirrors: ping archlinux.org; cat /etc/pacman.d/mirrorlist"
(pacman -S --noconfirm archlinux-keyring) >> "$logfile" 2>&1 || \
- error_fatal "$action" "$?"
+ error_fatal "$action" "$?" "reinitialize the keyring: pacman-key --init && pacman-key --populate archlinux"
display "task" "verifying Arch Linux keys"
(pacman-key --populate archlinux >> "$logfile" 2>&1) || \
- error_fatal "verifying Arch Linux keys" "$?"
+ error_fatal "verifying Arch Linux keys" "$?" "reinitialize the keyring: pacman-key --init && pacman-key --populate archlinux"
# The bulk refresh gets the same retry budget as per-package installs —
# a single slow mirror ("Operation too slow") halted a full install at
@@ -865,8 +1031,11 @@ prerequisites() {
[ "$attempt" -lt "$MAX_INSTALL_RETRIES" ] && \
display "task" "retrying package cache refresh (attempt $((attempt + 1))/$MAX_INSTALL_RETRIES)"
done
- $refresh_ok || error_fatal "$action" "$?"
+ $refresh_ok || error_fatal "$action" "$?" "run pacman -Syu manually to see the failure, or switch mirrors in /etc/pacman.d/mirrorlist"
+}
+
+install_required_software() {
display "subtitle" "Required Software"
for software in linux-firmware wireless-regdb base-devel ca-certificates \
@@ -875,6 +1044,9 @@ prerequisites() {
pacman_install "$software"
done
+}
+
+configure_build_environment() {
display "subtitle" "Environment Configuration"
# configure locale (must happen before package installs that depend on locale)
@@ -883,7 +1055,9 @@ prerequisites() {
action="configuring locale ($locale)" && display "task" "$action"
# Uncomment the selected locale in locale.gen (format: "en_US.UTF-8 UTF-8")
locale_entry="${locale} ${locale##*.}" # e.g., "en_US.UTF-8 UTF-8"
- sed -i "s|^#${locale_entry}|${locale_entry}|" /etc/locale.gen
+ backup_system_file /etc/locale.gen
+ sed -i "s|^#${locale_entry}|${locale_entry}|" /etc/locale.gen 2>> "$logfile" || \
+ error_warn "uncommenting $locale_entry in locale.gen" "$?"
(locale-gen >> "$logfile" 2>&1) || error_warn "$action" "$?"
echo "LANG=$locale" > /etc/locale.conf
export LANG="$locale"
@@ -903,6 +1077,7 @@ prerequisites() {
systemctl enable chronyd.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
action="configuring compiler to use all processor cores" && display "task" "$action"
+ backup_system_file /etc/makepkg.conf
sed -i "s/-j2/-j$(nproc)/;s/^#MAKEFLAGS/MAKEFLAGS/" /etc/makepkg.conf >> "$logfile" 2>&1
action="disabling debug packages in makepkg" && display "task" "$action"
@@ -910,13 +1085,28 @@ prerequisites() {
# enable pacman concurrent downloads and color
action="enabling concurrent downloads" && display "task" "$action"
- sed -i "s/^#ParallelDownloads.*$/ParallelDownloads = 10/;s/^#Color$/Color/" /etc/pacman.conf
+ backup_system_file /etc/pacman.conf
+ # Match a commented OR already-uncommented ParallelDownloads: current Arch
+ # ships it uncommented at 5, so a "^#"-only match silently leaves it at 5.
+ sed -i "s/^#\?ParallelDownloads.*$/ParallelDownloads = 10/;s/^#Color$/Color/" /etc/pacman.conf 2>> "$logfile" || \
+ error_warn "configuring pacman.conf (ParallelDownloads/Color)" "$?"
# enable multilib repository (required for 32-bit libraries, Steam, etc.)
action="enabling multilib repository" && display "task" "$action"
- sed -i '/^#\[multilib\]/{s/^#//;n;s/^#//}' /etc/pacman.conf
+ sed -i '/^#\[multilib\]/{s/^#//;n;s/^#//}' /etc/pacman.conf 2>> "$logfile" || \
+ error_warn "$action" "$?"
+
+ # Keep pacman.conf world-readable. User-level makepkg/yay reads it to
+ # resolve dependencies, so a root-only file makes every AUR build fail with
+ # "/etc/pacman.conf could not be read: Permission denied". Some base images
+ # (a current archangel ISO) ship it 0600; 0644 is the Arch default.
+ chmod 644 /etc/pacman.conf
+
pacman -Sy >> "$logfile" 2>&1
+}
+
+configure_package_mirrors() {
action="Package Mirrors" && display "subtitle" "$action"
pacman_install reflector
@@ -933,6 +1123,21 @@ prerequisites() {
--save /etc/pacman.d/mirrorlist
EOF
+ # Run reflector now, not only on the timer. The base image ships the full
+ # unsorted worldwide mirrorlist (hundreds of mirrors), so the heavy package
+ # installs below can stall for many minutes on a slow or unresponsive one.
+ # Curate to a few fast, recently-synced HTTPS mirrors up front, then refresh
+ # the databases against them. Bounded by `timeout` and non-fatal: reflector's
+ # own per-probe timeouts cap it, and the base list still works if it can't
+ # finish, so a flaky mirror-status service never blocks the install.
+ action="selecting fast mirrors (reflector)" && display "task" "$action"
+ backup_system_file /etc/pacman.d/mirrorlist
+ if timeout 120 reflector @/etc/xdg/reflector/reflector.conf >> "$logfile" 2>&1; then
+ pacman -Syy >> "$logfile" 2>&1 || error_warn "refreshing databases after reflector" "$?"
+ else
+ error_warn "selecting fast mirrors (reflector)" "$?"
+ fi
+
action="enabling the reflector timer" && display "task" "$action"
(systemctl enable reflector.timer >> "$logfile" 2>&1) || \
error_warn "$action" "$?"
@@ -941,7 +1146,7 @@ EOF
[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers >> "$logfile" 2>&1
action="creating a directory to build/install software from git/AUR."
- (mkdir -p "$source_dir") || error_fatal "creating the directory $source_dir" "$?"
+ (mkdir -p "$source_dir") || error_fatal "creating the directory $source_dir" "$?" "check permissions and free space: df -h"
}
@@ -952,12 +1157,12 @@ create_user() {
display "task" "checking if user exists"
# halt if $username exists
( id -u "$username" >/dev/null 2>&1; ) && \
- error_fatal "user '$username' already exists" "user exists"
+ error_fatal "user '$username' already exists" "user exists" "pick a different USERNAME in archsetup.conf, or remove the user first: userdel -r $username"
# create $username with home, group, shell, password
action="creating user and home directory" && display "task" "$action"
(useradd -m -G wheel -s /bin/zsh "$username" >> "$logfile" 2>&1) || \
- error_fatal "adding user '$username'" "$?"
+ error_fatal "adding user '$username'" "$?" "run the useradd manually to see why it failed"
display "task" "assigning the password"
echo "$username:$password" | chpasswd # any text is allowable! be careful!
@@ -966,27 +1171,28 @@ create_user() {
display "task" "adding to appropriate groups"
(usermod -aG \
sys,adm,network,scanner,power,uucp,audio,lp,rfkill,video,storage,optical,users \
- "$username" >> "$logfile" 2>&1) || error_fatal "adding $username to groups" "$?"
+ "$username" >> "$logfile" 2>&1) || error_fatal "adding $username to groups" "$?" "confirm the groups exist: getent group network rfkill video"
display "task" "configuring shell"
# zsh cache required: $username will install via yay; zsh will run those commands
mkdir -p "/home/$username/.cache/zsh/"
# give $username sudo nopasswd rights (required for aur installs)
- display "task" "granting permissions"
- (echo "%$username ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers) \
+ action="granting permissions" && display "task" "$action"
+ backup_system_file /etc/sudoers
+ (echo "%$username ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers) 2>> "$logfile" \
|| error_warn "$action" "$?"
# mount as ramdisk to speed aur/git build/installs
if ! mountpoint -q "$source_dir" 2>/dev/null; then
(mount -t tmpfs -o size=4G archsetup "$source_dir" >> "$logfile" 2>&1) || \
- error_fatal "mounting the RAM disk for archsetup" "$?"
+ error_fatal "mounting the RAM disk for archsetup" "$?" "check available memory (free -h) or mount a smaller tmpfs at $source_dir by hand"
else
display "task" "ramdisk already mounted at $source_dir"
fi
(chown -R "$username":wheel "$source_dir" >> "$logfile" 2>&1) || \
- error_fatal "changing ownership of $source_dir" "$?"
+ error_fatal "changing ownership of $source_dir" "$?" "run the chown manually to see the failure"
# Bootstrap DNS for git clones and AUR installs (full config in essential_services)
if [ ! -L /etc/resolv.conf ] || [ "$(readlink /etc/resolv.conf)" != "/run/systemd/resolve/stub-resolv.conf" ]; then
@@ -1003,6 +1209,17 @@ create_user() {
user_customizations() {
action="User Customizations" && display "title" "$action"
+ clone_user_repos
+ stow_dotfiles
+ prune_waybar_battery
+ refresh_desktop_caches
+ configure_dconf_defaults
+ finalize_dotfiles
+ install_maintenance_config
+ create_user_directories
+}
+
+clone_user_repos() {
# Clone archsetup to user's home directory so dotfile symlinks are accessible.
# This ensures symlinks point to a user-readable location regardless of how
# archsetup was invoked (curl|bash, from /root, etc.)
@@ -1030,11 +1247,14 @@ user_customizations() {
# Q5: the --adopt/restore conflict handling below needs a real git checkout.
# Refuse to continue if the clone didn't produce one (bad URL, network, a
# tarball drop) rather than silently skipping the restore step.
- [[ -d "$dotfiles_dir/.git" ]] || error_fatal "dotfiles dir is not a git checkout: $dotfiles_dir" 1
+ [[ -d "$dotfiles_dir/.git" ]] || error_fatal "dotfiles dir is not a git checkout: $dotfiles_dir" 1 "move that directory aside so archsetup can clone fresh, or point DOTFILES_DIR at a real clone"
# root runs stow/restore against the user-owned clone; mark it safe.
git config --global --add safe.directory "$dotfiles_dir" >> "$logfile" 2>&1 || true
+}
+
+stow_dotfiles() {
# Stow the universal layer plus the per-environment layer. Headless installs
# (none) get the standalone minimal/ tree instead of common/.
case "$desktop_env" in
@@ -1065,24 +1285,29 @@ user_customizations() {
;;
esac
+}
+
+prune_waybar_battery() {
# Remove battery module from waybar config on desktops with no battery
# (hyprland only — waybar isn't part of the dwm or minimal trees).
if [[ "$desktop_env" == "hyprland" ]] && ! ls /sys/class/power_supply/BAT* &>/dev/null; then
action="removing waybar battery module (no battery detected)" && display "task" "$action"
waybar_config="/home/$username/.config/waybar/config"
# Remove "battery" from sysmonitor modules array and fix trailing comma
- sed -i '/"battery"$/d' "$waybar_config"
- sed -i 's/"custom\/disk",/"custom\/disk"/' "$waybar_config"
+ sed -i '/"battery"$/d' "$waybar_config" 2>> "$logfile" || error_warn "$action" "$?"
+ sed -i 's/"custom\/disk",/"custom\/disk"/' "$waybar_config" 2>> "$logfile" || error_warn "$action" "$?"
# Remove the battery config block
- sed -i '/"battery": {/,/^ },$/d' "$waybar_config"
+ sed -i '/"battery": {/,/^ },$/d' "$waybar_config" 2>> "$logfile" || error_warn "$action" "$?"
fi
+}
+
+refresh_desktop_caches() {
# install fontconfig before refreshing cache (provides fc-cache)
pacman_install fontconfig
# Refresh font cache for any fonts in dotfiles
- action="refreshing font cache" && display "task" "$action"
- fc-cache -f >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "refreshing font cache" fc-cache -f
# install desktop-file-utils before updating database (provides update-desktop-database)
pacman_install desktop-file-utils
@@ -1092,6 +1317,9 @@ user_customizations() {
(sudo -u "$username" update-desktop-database "/home/$username/.local/share/applications" \
>> "$logfile" 2>&1 ) || true
+}
+
+configure_dconf_defaults() {
# GTK and GNOME desktop interface settings — read by GTK apps and
# xdg-desktop-portal-gtk. Written as a system-wide dconf db rather than
# per-user dbus-launch dconf writes: the system path needs no session
@@ -1120,6 +1348,9 @@ EOF
dconf update
) >> "$logfile" 2>&1 || error_warn "$action" "$?"
+}
+
+finalize_dotfiles() {
action="marking archsetup dir as safe.directory" && display "task" "$action"
git config --global --add safe.directory "$user_archsetup_dir" >> "$logfile" 2>&1 \
|| error_warn "$action" "$?"
@@ -1129,9 +1360,55 @@ EOF
# (e.g. the /etc/skel .bashrc/.bash_profile a fresh user starts with). Runs
# for every desktop_env, including none — minimal/ ships those skel-colliding
# files too, so its --adopt needs the same restore.
- action="restoring dotfile versions" && display "task" "$action"
- git -C "$dotfiles_dir" restore . >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "restoring dotfile versions" git -C "$dotfiles_dir" restore .
+
+}
+
+install_maintenance_config() {
+
+ display "subtitle" "Maintenance Console"
+
+ install_maintenance_thresholds
+ if [[ "$desktop_env" == "hyprland" ]]; then
+ enable_maint_timers
+ fi
+}
+install_maintenance_thresholds() {
+ # Shipped severity thresholds read by the maint console and the
+ # system-health-check workflow. This file is the shipped layer — a re-run
+ # refreshes it. User curation and overrides live in ~/.config/maint/,
+ # which archsetup never touches, so a reinstall can't eat curation.
+ action="installing maintenance thresholds" && display "task" "$action"
+ local src="${user_archsetup_dir:-/home/$username/code/archsetup}/configs/maintenance-thresholds.toml"
+ (install -d "/home/$username/.config/archsetup" && \
+ install -m 0644 "$src" "/home/$username/.config/archsetup/maintenance-thresholds.toml" && \
+ chown -R "$username:$username" "/home/$username/.config/archsetup") \
+ >> "$logfile" 2>&1 || error_warn "$action" "$?"
+
+}
+
+enable_maint_timers() {
+ # The hyprland tier stows maint-scan.timer + maint-net-scan.timer as user
+ # units under ~/.config/systemd/user/. systemctl --user can't run during
+ # install (no user session bus), so create the enablement symlinks
+ # directly — same idiom as the syncthing user service.
+ #
+ # maint-epp-restore.service rides the same idiom but hangs off
+ # default.target: it replays the remembered CPU mode once at login,
+ # because the kernel resets EPP to the driver default on every boot.
+ action="enabling maint scan timers" && display "task" "$action"
+ local unit_dir="/home/$username/.config/systemd/user"
+ (mkdir -p "$unit_dir/timers.target.wants" "$unit_dir/default.target.wants" && \
+ ln -sf "$unit_dir/maint-scan.timer" "$unit_dir/timers.target.wants/maint-scan.timer" && \
+ ln -sf "$unit_dir/maint-net-scan.timer" "$unit_dir/timers.target.wants/maint-net-scan.timer" && \
+ ln -sf "$unit_dir/maint-epp-restore.service" "$unit_dir/default.target.wants/maint-epp-restore.service" && \
+ chown -R "$username:$username" "/home/$username/.config/systemd") \
+ >> "$logfile" 2>&1 || error_warn "$action" "$?"
+
+}
+
+create_user_directories() {
action="creating common directories" && display "task" "$action"
# Create default directories and grant permissions
{
@@ -1168,35 +1445,49 @@ aur_installer() {
if ! (sudo -u "$username" git clone --depth 1 "$yay_repo" "$build_dir" >> "$logfile" 2>&1); then
error_warn "cloning source code for yay - directory may exist, removing and retrying" "$?"
(safe_rm_rf "$build_dir" "$source_dir" >> "$logfile" 2>&1) || \
- error_fatal "removing existing directory for yay" "$?"
+ error_fatal "removing existing directory for yay" "$?" "remove the build dir manually: rm -rf $source_dir/yay"
(sudo -u "$username" git clone --depth 1 "$yay_repo" "$build_dir" >> "$logfile" 2>&1) || \
- error_fatal "re-cloning source code for yay after cleanup" "$?"
+ error_fatal "re-cloning source code for yay after cleanup" "$?" "check that aur.archlinux.org is reachable"
fi
action="packaging and installing yay"; display "task" "$action"
(cd "$build_dir" && sudo -u "$username" makepkg --noconfirm -si >> "$logfile" 2>&1) || \
- error_fatal "$action" "$?"
+ error_fatal "$action" "$?" "confirm base-devel is installed (pacman -S base-devel), then re-run"
}
### Essential Services
essential_services() {
display "title" "Essential Services"
+ configure_randomness
+ configure_networking
+ configure_power
+ configure_ssh_server
+ configure_fail2ban
+ configure_firewall
+ configure_service_discovery
+ configure_job_scheduling
+ configure_package_cache
+ configure_snapshots
+ configure_user_lingering
+}
+
+configure_randomness() {
# Randomness
display "subtitle" "Randomness"
pacman_install rng-tools
- action="enabling rngd service" && display "task" "$action"
- systemctl enable rngd >> "$logfile" 2>&1 || error_warn "$action" "$?"
- action="starting rngd service" && display "task" "$action"
- systemctl start rngd >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ enable_service rngd
+ run_task "starting rngd service" systemctl start rngd
+}
+
+configure_networking() {
# Networking
display "subtitle" "Networking"
pacman_install networkmanager
- action="enabling NetworkManager" && display "task" "$action"
- systemctl enable NetworkManager.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling NetworkManager" systemctl enable NetworkManager.service
action="configuring MAC address randomization" && display "task" "$action"
mkdir -p /etc/NetworkManager/conf.d
@@ -1219,7 +1510,9 @@ EOF
current_lang="${LANG:-en_US.UTF-8}"
wireless_region="${current_lang:3:2}" # extract country code (positions 3-4)
action="configuring wireless regulatory domain ($wireless_region)" && display "task" "$action"
- sed -i "s|^#WIRELESS_REGDOM=\"${wireless_region}\"|WIRELESS_REGDOM=\"${wireless_region}\"|" /etc/conf.d/wireless-regdom
+ backup_system_file /etc/conf.d/wireless-regdom
+ sed -i "s|^#WIRELESS_REGDOM=\"${wireless_region}\"|WIRELESS_REGDOM=\"${wireless_region}\"|" /etc/conf.d/wireless-regdom 2>> "$logfile" || \
+ error_warn "$action" "$?"
# Encrypted DNS (DNS over TLS)
@@ -1245,28 +1538,29 @@ EOF
# Note: If Docker containers have DNS issues, systemd-resolved's stub resolver
# (127.0.0.53) may be the cause. Fix: configure Docker to use direct DNS, or
# disable systemd-resolved and use /etc/resolv.conf directly. (2026-01-18)
- action="enabling systemd-resolved" && display "task" "$action"
- systemctl enable systemd-resolved >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling systemd-resolved" systemctl enable systemd-resolved
# Create resolv.conf symlink to systemd-resolved
- action="linking resolv.conf to systemd-resolved" && display "task" "$action"
- ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "linking resolv.conf to systemd-resolved" ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
+}
+
+configure_power() {
# Power
display "subtitle" "Power"
pacman_install upower
- action="enabling upower service" && display "task" "$action"
- systemctl enable upower >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ enable_service upower
+}
+
+configure_ssh_server() {
# Secure Shell
display "subtitle" "Secure Shell"
pacman_install openssh
- action="enabling the openssh service to run at boot" && display "task" "$action"
- systemctl enable sshd >> "$logfile" 2>&1 || error_warn "$action" "$?"
- action="starting the openssh service" && display "task" "$action"
- systemctl start sshd >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling the openssh service to run at boot" systemctl enable sshd
+ run_task "starting the openssh service" systemctl start sshd
action="hardening sshd (root login by key only)" && display "task" "$action"
cat << 'EOF' > /etc/ssh/sshd_config.d/10-hardening.conf
@@ -1275,6 +1569,9 @@ EOF
PermitRootLogin prohibit-password
EOF
systemctl reload sshd >> "$logfile" 2>&1 || error_warn "$action" "$?"
+}
+
+configure_fail2ban() {
# SSH Brute Force Protection
@@ -1299,16 +1596,16 @@ maxretry = 3
bantime = 1h
EOF
- action="enabling fail2ban service" && display "task" "$action"
- systemctl enable fail2ban >> "$logfile" 2>&1 || error_warn "$action" "$?"
- action="starting fail2ban service" && display "task" "$action"
- systemctl start fail2ban >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ enable_service fail2ban
+ run_task "starting fail2ban service" systemctl start fail2ban
+}
+
+configure_firewall() {
display "subtitle" "Firewall"
pacman_install ufw
- action="configuring ufw to deny by default" && display "task" "$action"
- ufw default deny incoming >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "configuring ufw to deny by default" ufw default deny incoming
# Firewall rules - only open ports for services we actually run
for protocol in \
@@ -1336,21 +1633,22 @@ EOF
action="rate-limiting SSH to protect from brute force attacks" && display "task" "$action"
(ufw limit 22/tcp >> "$logfile" 2>&1) || error_warn "$action" "$?"
- action="enabling firewall" && display "task" "$action"
- ufw --force enable >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling firewall" ufw --force enable
- action="enabling firewall service to launch on boot" && display "task" "$action"
- systemctl enable ufw.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling firewall service to launch on boot" systemctl enable ufw.service
# Verify firewall is actually active
# Note: In VM environments, UFW may show inactive due to missing kernel
# netfilter modules. This is a test environment limitation, not a bug.
# On real hardware with proper kernel support, UFW activates correctly.
action="verifying firewall is active" && display "task" "$action"
- if ! ufw status | grep -q "Status: active"; then
+ if ! ufw status 2>> "$logfile" | grep -q "Status: active"; then
error_messages=("FIREWALL NOT ACTIVE - run: sudo ufw enable" "${error_messages[@]}")
error_warn "$action" "1"
fi
+}
+
+configure_service_discovery() {
# Service Discovery
@@ -1362,27 +1660,27 @@ EOF
display "task" "skipping avahi (already running)"
else
pacman_install avahi # service discovery on a local network using mdns
- action="enabling avahi for mDNS discovery" && display "task" "$action"
- systemctl enable avahi-daemon.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling avahi for mDNS discovery" systemctl enable avahi-daemon.service
fi
pacman_install wsdd
- action="enabling wsdd for Windows network discovery" && display "task" "$action"
- systemctl enable wsdd.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling wsdd for Windows network discovery" systemctl enable wsdd.service
pacman_install geoclue # geolocation service for location-aware apps
- action="enabling geoclue geolocation service" && display "task" "$action"
- systemctl enable geoclue.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling geoclue geolocation service" systemctl enable geoclue.service
# Enable BeaconDB as geoclue wifi location provider (default MLS/Ichnaea API is defunct)
action="configuring geoclue to use BeaconDB location service" && display "task" "$action"
if grep -q '^#url=https://api.beacondb.net/v1/geolocate' /etc/geoclue/geoclue.conf 2>/dev/null; then
- sed -i 's|^#url=https://api.beacondb.net/v1/geolocate|url=https://api.beacondb.net/v1/geolocate|' /etc/geoclue/geoclue.conf
+ backup_system_file /etc/geoclue/geoclue.conf
+ sed -i 's|^#url=https://api.beacondb.net/v1/geolocate|url=https://api.beacondb.net/v1/geolocate|' /etc/geoclue/geoclue.conf 2>> "$logfile" || \
+ error_warn "$action" "$?"
fi
# Whitelist gammastep in geoclue config (geoclue demo agent is started via hyprland.conf exec-once)
action="whitelisting gammastep in geoclue" && display "task" "$action"
if ! grep -q "^\[gammastep\]" /etc/geoclue/geoclue.conf 2>/dev/null; then
+ backup_system_file /etc/geoclue/geoclue.conf
cat >> /etc/geoclue/geoclue.conf << 'EOF'
[gammastep]
@@ -1400,36 +1698,55 @@ EOF
After=systemd-sysusers.service
EOF
+}
+
+configure_job_scheduling() {
# Job Scheduling
display "subtitle" "Job Scheduling"
pacman_install cronie
- action="enabling cronie to launch at boot" && display "task" "$action"
- systemctl enable cronie >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling cronie to launch at boot" systemctl enable cronie
pacman_install at
- action="enabling the batch delayed command scheduler" && display "task" "$action"
- systemctl enable atd >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling the batch delayed command scheduler" systemctl enable atd
action="installing log cleanup cron job" && display "task" "$action"
(sudo -u "$username" crontab -l 2>/dev/null; \
echo "0 12 * * * \$HOME/.local/bin/cron/log-cleanup") \
| sudo -u "$username" crontab - \
>> "$logfile" 2>&1 || error_warn "$action" "$?"
+}
+
+configure_package_cache() {
# Package Repository Cache Maintenance
display "subtitle" "Package Repository Cache Maintenance"
pacman_install pacman-contrib
- action="enabling the package cache cleanup timer" && display "task" "$action"
- systemctl enable --now paccache.timer >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ pacman_install arch-audit # CVE report from Arch Security Team data (maintenance console)
+ pacman_install expac # package metadata queries (maintenance console)
+ run_task "enabling the package cache cleanup timer" systemctl enable --now paccache.timer
action="configuring paccache to keep 3 versions" && display "task" "$action"
- sed -i 's/^PACCACHE_ARGS=.*/PACCACHE_ARGS=-k3/' /etc/conf.d/pacman-contrib
+ backup_system_file /etc/conf.d/pacman-contrib
+ sed -i 's/^PACCACHE_ARGS=.*/PACCACHE_ARGS=-k3/' /etc/conf.d/pacman-contrib 2>> "$logfile" || \
+ error_warn "$action" "$?"
+
+}
+
+configure_snapshots() {
- # Snapshot Service - filesystem-aware
display "subtitle" "Snapshot Service"
if is_zfs_root; then
+ configure_zfs_snapshots
+ elif is_btrfs_root; then
+ configure_btrfs_snapshots
+ else
+ display "task" "ext4/other filesystem detected"
+ fi
+}
+
+configure_zfs_snapshots() {
# ZFS: Install sanoid for snapshot management
display "task" "ZFS detected - installing sanoid"
aur_install sanoid
@@ -1533,8 +1850,7 @@ Persistent=true
WantedBy=timers.target
EOF
- action="enabling sanoid timer" && display "task" "$action"
- systemctl enable sanoid.timer >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling sanoid timer" systemctl enable sanoid.timer
action="enabling weekly ZFS scrub" && display "task" "$action"
# Get pool name dynamically (usually zroot)
@@ -1546,7 +1862,9 @@ EOF
# systemctl enable --now zfs-replicate.timer
display "task" "zfs-replicate timer created (enable after SSH key setup to TrueNAS)"
- elif is_btrfs_root; then
+}
+
+configure_btrfs_snapshots() {
# Btrfs: Install snapper for snapshot management
display "task" "btrfs detected - installing snapper and grub-btrfs"
pacman_install snapper
@@ -1588,16 +1906,18 @@ EOF
snapper -c root set-config "TIMELINE_LIMIT_MONTHLY=1" >> "$logfile" 2>&1
snapper -c root set-config "TIMELINE_LIMIT_YEARLY=0" >> "$logfile" 2>&1
- action="enabling snapper timeline timer" && display "task" "$action"
- systemctl enable snapper-timeline.timer >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling snapper timeline timer" systemctl enable snapper-timeline.timer
systemctl enable snapper-cleanup.timer >> "$logfile" 2>&1 || error_warn "$action" "$?"
- action="enabling grub-btrfsd for boot menu snapshots" && display "task" "$action"
- systemctl enable grub-btrfsd >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ # Monthly data scrub — the maintenance console's scrub-age and
+ # expected-timers checks assume this runs (mirrors the ZFS branch's
+ # zfs-scrub-weekly@). The "-" instance is systemd escaping for "/".
+ run_task "enabling monthly btrfs scrub timer" systemctl enable btrfs-scrub@-.timer
+
+ run_task "enabling grub-btrfsd for boot menu snapshots" systemctl enable grub-btrfsd
# Allow user to use snapper without root (required for snapper-gui)
- action="allowing wheel group to use snapper" && display "task" "$action"
- snapper -c root set-config "ALLOW_GROUPS=wheel" >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "allowing wheel group to use snapper" snapper -c root set-config "ALLOW_GROUPS=wheel"
snapper -c root set-config "SYNC_ACL=yes" >> "$logfile" 2>&1 || error_warn "$action" "$?"
# Set ACL on .snapshots directory for wheel group access
setfacl -m g:wheel:rx /.snapshots >> "$logfile" 2>&1 || error_warn "$action" "$?"
@@ -1605,9 +1925,42 @@ EOF
# Install snapper GUI (AUR)
aur_install snapper-gui-git
- else
- display "task" "ext4/other filesystem detected"
- fi
+}
+
+configure_pre_pacman_snapshots() {
+ # ZFS only: a pacman PreTransaction hook that snapshots the root dataset
+ # before every transaction, with a self-pruning script that keeps the most
+ # recent 10 (sanoid ignores these — they aren't autosnap_ names). This is
+ # the transaction-triggered complement to the scheduled sanoid snapshots in
+ # configure_zfs_snapshots.
+ #
+ # Called from boot_ux (the last step), NOT from configure_zfs_snapshots
+ # (which runs early), so the hook doesn't fire during the install's own
+ # package operations — the first pre-pacman snapshot is the fresh system.
+ is_zfs_root || return 0
+
+ action="installing pre-pacman snapshot script" && display "task" "$action"
+ cp "$user_archsetup_dir/scripts/zfs-pre-snapshot" /usr/local/bin/zfs-pre-snapshot
+ chmod +x /usr/local/bin/zfs-pre-snapshot
+
+ action="installing pre-pacman snapshot hook" && display "task" "$action"
+ mkdir -p /etc/pacman.d/hooks
+ cat << 'EOF' > /etc/pacman.d/hooks/zfs-snapshot.hook
+[Trigger]
+Operation = Upgrade
+Operation = Install
+Operation = Remove
+Type = Package
+Target = *
+
+[Action]
+Description = Creating ZFS snapshot before pacman transaction...
+When = PreTransaction
+Exec = /usr/local/bin/zfs-pre-snapshot
+EOF
+}
+
+configure_user_lingering() {
# User Services Lingering
# Keeps user-level systemd services (e.g., protonmail-bridge) running without
@@ -1615,8 +1968,7 @@ EOF
# user-level IMAP/SMTP daemons over SSH or from remote agents.
display "subtitle" "User Services"
- action="enabling user-services lingering for $username" && display "task" "$action"
- loginctl enable-linger "$username" >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling user-services lingering for $username" loginctl enable-linger "$username"
}
### Xorg Display Manager
@@ -1641,8 +1993,7 @@ Section "ServerFlags"
Option "DontZap" "True"
EndSection
EOF
- action="configuring xorg server" && display "task" "$action"
- chmod 644 /etc/X11/xorg.conf.d/00-no-vt-or-zap.conf >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "configuring xorg server" chmod 644 /etc/X11/xorg.conf.d/00-no-vt-or-zap.conf
# Install GPU-specific drivers
install_gpu_drivers
@@ -1697,6 +2048,10 @@ hyprland() {
action="Hyprland Utilities" && display "subtitle" "$action"
aur_install pyprland # scratchpads, magnify, expose (fixes special workspace issues)
pacman_install waybar # status bar
+ pacman_install gtk4-layer-shell # custom/net connection panel (GTK4 layer-shell)
+ pacman_install python-gobject # PyGObject for the net panel
+ pacman_install blueprint-compiler # regenerate the net panel's .ui from .blp (dev; committed .ui runs without it)
+ aur_install speedtest-go-bin # net panel speed test backend
pacman_install fuzzel # app launcher (native Wayland, pinentry support)
pacman_install awww # wallpaper daemon (swww successor; provides swww)
aur_install waypaper # wallpaper GUI (awww backend)
@@ -1722,14 +2077,61 @@ hyprland() {
pacman_install wev # wayland event debugger (xev equivalent)
- # Logitech BRIO webcam auto-configuration
+ # Device-specific udev rules (opt-out via INSTALL_DEVICE_UDEV_RULES=no).
+ # Currently the Logitech BRIO webcam auto-configuration; tied to hardware not
+ # everyone has, so it's gated for the open-source release.
+ if [ "$install_device_udev_rules" = "true" ]; then
action="creating Logitech BRIO udev rule" && display "task" "$action"
cat > /etc/udev/rules.d/99-logitech-brio.rules << 'UDEVEOF'
# Apply camera settings when Logitech BRIO is connected
ACTION=="add", SUBSYSTEM=="video4linux", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="085e", ATTR{index}=="0", RUN+="/home/ARCHSETUP_USERNAME/.local/bin/logitech-brio-settings.sh /dev/%k"
UDEVEOF
- sed -i "s/ARCHSETUP_USERNAME/${username}/" /etc/udev/rules.d/99-logitech-brio.rules
+ sed -i "s/ARCHSETUP_USERNAME/${username}/" /etc/udev/rules.d/99-logitech-brio.rules 2>> "$logfile" || \
+ error_warn "personalizing the Logitech BRIO udev rule" "$?"
chmod 644 /etc/udev/rules.d/99-logitech-brio.rules
+ fi
+
+ # Live-update guard: a pacman PreTransaction hook that aborts an upgrade of
+ # GPU/compositor runtime libraries while a Hyprland session is running, so
+ # the live compositor doesn't SIGABRT when a library is swapped underneath
+ # it (hit ratio 2026-06-07: live mesa + hyprland upgrade crashed Hyprland and
+ # its clients). Re-run the upgrade from a TTY with Hyprland stopped and the
+ # guard stays quiet.
+ action="Live-Update Guard" && display "subtitle" "$action"
+ run_task "installing the live GPU/compositor update guard" \
+ cp "$user_archsetup_dir/scripts/hypr-live-update-guard" /usr/local/bin/hypr-live-update-guard
+ chmod 755 /usr/local/bin/hypr-live-update-guard
+
+ action="installing the live-update guard pacman hook" && display "task" "$action"
+ mkdir -p /etc/pacman.d/hooks
+ cat > /etc/pacman.d/hooks/hypr-live-update-guard.hook << 'HOOKEOF'
+[Trigger]
+Operation = Upgrade
+Type = Package
+Target = mesa
+Target = mesa-*
+Target = wayland
+Target = libdrm
+Target = libglvnd
+Target = hyprland
+Target = aquamarine
+Target = hyprutils
+Target = hyprgraphics
+Target = vulkan-radeon
+Target = vulkan-intel
+Target = vulkan-mesa-layers
+Target = nvidia-utils
+Target = lib32-nvidia-utils
+Target = xorg-xwayland
+
+[Action]
+Description = Checking for a live Hyprland session before swapping GPU/compositor libs...
+When = PreTransaction
+Exec = /usr/local/bin/hypr-live-update-guard
+AbortOnFail
+NeedsTargets
+HOOKEOF
+ chmod 644 /etc/pacman.d/hooks/hypr-live-update-guard.hook
}
### Display Server (conditional)
@@ -1746,7 +2148,7 @@ display_server() {
action="Skipping display server (DESKTOP_ENV=none)" && display "task" "$action"
;;
*)
- error_fatal "Unknown DESKTOP_ENV: $desktop_env. Valid options: dwm, hyprland, none" "1"
+ error_fatal "Unknown DESKTOP_ENV: $desktop_env. Valid options: dwm, hyprland, none" "1" "set DESKTOP_ENV in archsetup.conf (or --desktop-env) to one of the valid options"
;;
esac
}
@@ -1765,7 +2167,7 @@ window_manager() {
action="Skipping window manager (DESKTOP_ENV=none)" && display "task" "$action"
;;
*)
- error_fatal "Unknown DESKTOP_ENV: $desktop_env. Valid options: dwm, hyprland, none" "1"
+ error_fatal "Unknown DESKTOP_ENV: $desktop_env. Valid options: dwm, hyprland, none" "1" "set DESKTOP_ENV in archsetup.conf (or --desktop-env) to one of the valid options"
;;
esac
}
@@ -1883,12 +2285,12 @@ desktop_environment() {
# Bluetooth Devices
action="Bluetooth System" && display "subtitle" "$action"
- for software in bluez bluez-utils blueman; do
+ # blueman retired 2026-07-02: the dotfiles bt panel + bar module replace it
+ for software in bluez bluez-utils; do
pacman_install "$software"
done
pacman_install solaar # Logitech device manager
- action="enabling bluetooth to launch at boot" && display "task" "$action"
- systemctl enable bluetooth.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling bluetooth to launch at boot" systemctl enable bluetooth.service
# Command Line Utilities
@@ -2004,8 +2406,7 @@ gaming() {
pacman_install steam
# Enable gamemode service for user
- action="enabling gamemode for user" && display "task" "$action"
- sudo -u "$username" systemctl --user enable gamemoded.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling gamemode for user" sudo -u "$username" systemctl --user enable gamemoded.service
}
### Zig Toolchain Pin
@@ -2111,6 +2512,14 @@ developer_workstation() {
action="Developer Workstation" && display "title" "$action"
+ install_programming_languages
+ install_editors
+ install_android_utilities
+ install_vpn_tools
+ install_devops_utilities
+}
+
+install_programming_languages() {
action="Programming Languages and Utilities" && display "subtitle" "$action"
# Rust (via rustup — must precede AUR packages that compile with rust)
pacman_install rustup # Rust toolchain manager
@@ -2151,10 +2560,18 @@ developer_workstation() {
pacman_install npm # Node-js package manager
aur_install nvm # Node-js version manager
+ # Eask (Emacs package build/test CLI) — chime and linear-emacs shell out to
+ # it. Global npm install as the user with a ~/.local prefix so eask lands
+ # on PATH at ~/.local/bin/eask without root (the stowed ~/.npmrc pins the
+ # same prefix for future npm -g installs).
+ action="installing eask via npm" && display "task" "$action"
+ (sudo -u "$username" bash -c 'npm install -g --prefix "$HOME/.local" @emacs-eask/cli' >> "$logfile" 2>&1) || \
+ error_warn "$action" "$?"
+
# AI coding assistant (native install to ~/.local/bin), opt-out via
# INSTALL_CLAUDE_CODE=no / --no-claude-code. Gated because it's curl|sh from
# a third party and not every user wants AI tooling.
- if [ "$install_claude_code" = true ]; then
+ if [ "$install_claude_code" = "true" ]; then
action="installing claude-code via native installer" && display "task" "$action"
(sudo -u "$username" bash -c 'curl -fsSL https://claude.ai/install.sh | sh' >> "$logfile" 2>&1) || \
error_warn "$action" "$?"
@@ -2178,6 +2595,9 @@ developer_workstation() {
pacman_install hyperfine # statistical command-line benchmarking
pacman_install doggo # modern dig: readable DNS client, DoH/DoT/DoQ
+}
+
+install_editors() {
action="Programming Editors" && display "subtitle" "$action"
pacman_install mg # mini emacs
@@ -2236,19 +2656,45 @@ developer_workstation() {
>> "$logfile" 2>&1 || error_warn "$action" "$?"
fi
+}
+
+install_android_utilities() {
action="Android Utilities" && display "subtitle" "$action"
pacman_install android-file-transfer
pacman_install android-tools
+}
+
+install_vpn_tools() {
action="VPN Tools" && display "subtitle" "$action"
pacman_install wireguard-tools # VPN - add configs to /etc/wireguard/
pacman_install systemd-resolvconf # resolvconf for wg-quick DNS integration
- pacman_install proton-vpn-gtk-app # Proton VPN GUI client with system tray
+ # proton-vpn-gtk-app retired 2026-07-02: the net panel's Tunnels view
+ # drives the official CLI, and the two can't run concurrently
+ pacman_install proton-vpn-cli # Proton VPN official CLI
pacman_install tailscale # mesh VPN - run 'tailscale up' to authenticate
- action="enabling tailscale service" && display "task" "$action"
- systemctl enable tailscaled >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ # --now: the operator grant below needs the daemon answering (an unstarted
+ # tailscaled refuses `tailscale set`). Starting pre-auth is harmless.
+ run_task "enabling tailscale service" systemctl enable --now tailscaled
+
+ # Operator mode lets $username run tailscale up/down without sudo — the
+ # net panel's Tunnels view relies on it. Brief retry: the daemon can take
+ # a moment to accept its socket right after the start.
+ action="granting tailscale operator to $username" && display "task" "$action"
+ operator_ok=false
+ for _attempt in 1 2 3 4 5; do
+ if tailscale set --operator="$username" >> "$logfile" 2>&1; then
+ operator_ok=true
+ break
+ fi
+ sleep 2
+ done
+ $operator_ok || error_warn "$action" "1"
+
+}
+install_devops_utilities() {
action="DevOps Utilities" && display "subtitle" "$action"
action="installing devops virtualization and automation tools" && display "task" "$action"
@@ -2276,8 +2722,7 @@ developer_workstation() {
}
EOF
fi
- action="enabling docker service to launch on boot" && display "task" "$action"
- systemctl enable docker.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling docker service to launch on boot" systemctl enable docker.service
# podman (rootless containers for winvm)
pacman_install podman
@@ -2345,11 +2790,13 @@ supplemental_software() {
else
aur_install slack-desktop # team messaging
fi
- aur_install zoom # video conferencing
+ # zoom retired 2026-07-02: zoom-web (dotfiles) opens meetings in the browser
pacman_install iperf3 # network bandwidth testing
pacman_install bind # DNS utilities (dig, host, nslookup)
pacman_install net-tools # network tools (netstat for security auditing)
pacman_install smartmontools # monitors hard drives
+ pacman_install lm_sensors # temperature sensors (maintenance console)
+ pacman_install fwupd # firmware update checks (maintenance console)
pacman_install lynis # security auditing tool
pacman_install telegram-desktop # messenger application
# LaTeX - minimal set for document compilation with latexmk
@@ -2415,8 +2862,7 @@ supplemental_software() {
# makepkg's integrity check fails on that file even though the package tarball
# itself verifies. Rechecked 2026-06-24 — the original expired-PGP-signature
# cause is gone, but this LICENSE-drift keeps the workaround necessary.
- action="installing python-lyricsgenius (integrity workaround)" && display "task" "$action"
- yay -S --noconfirm --mflags --skipinteg python-lyricsgenius >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "installing python-lyricsgenius (integrity workaround)" yay -S --noconfirm --mflags --skipinteg python-lyricsgenius
aur_install tidal-dl # tidal-dl:tidal as yt-dlp:youtube
aur_install tidaler # tidal downloader (tidal-dl-ng fork)
aur_install freetube # privacy-focused YouTube desktop client
@@ -2426,6 +2872,17 @@ supplemental_software() {
boot_ux() {
action="Boot UX" && display "title" "$action"
+ tighten_efi_permissions
+ add_nvme_early_module
+ configure_initramfs_hook
+ configure_encrypted_autologin
+ configure_tlp_power
+ trim_firmware
+ configure_grub
+ configure_pre_pacman_snapshots # ZFS only; late so it skips the install itself
+}
+
+tighten_efi_permissions() {
# Tighten /efi mount permissions so kernel images, initramfs, and
# bootloader config aren't world-readable. archinstall's defaults leave
# them at 0755; fmask/dmask below makes files 0600 and dirs 0700.
@@ -2433,14 +2890,19 @@ boot_ux() {
if grep -qE "^[^#].*[[:space:]]/efi[[:space:]]+vfat[[:space:]]" /etc/fstab \
&& ! grep -E "^[^#].*[[:space:]]/efi[[:space:]]+vfat[[:space:]]" /etc/fstab | grep -q "fmask="; then
action="tightening /efi mount permissions in fstab" && display "task" "$action"
- sed -i -E '/^[^#].*[[:space:]]\/efi[[:space:]]+vfat[[:space:]]/ s/([[:space:]]+vfat[[:space:]]+)([^[:space:]]+)/\1\2,fmask=0177,dmask=0077/' /etc/fstab \
+ backup_system_file /etc/fstab
+ sed -i -E '/^[^#].*[[:space:]]\/efi[[:space:]]+vfat[[:space:]]/ s/([[:space:]]+vfat[[:space:]]+)([^[:space:]]+)/\1\2,fmask=0177,dmask=0077/' /etc/fstab 2>> "$logfile" \
|| error_warn "$action" "$?"
fi
+}
+
+add_nvme_early_module() {
# Add nvme module for early loading on NVMe systems
# Ensures NVMe devices are available when ZFS/other hooks try to access them
if has_nvme_drives; then
action="adding nvme to mkinitcpio MODULES for early loading" && display "task" "$action"
+ backup_system_file /etc/mkinitcpio.conf
if grep -q "^MODULES=()" /etc/mkinitcpio.conf; then
sed -i 's/^MODULES=()/MODULES=(nvme)/' /etc/mkinitcpio.conf
elif grep -q "^MODULES=(" /etc/mkinitcpio.conf && ! grep -q "nvme" /etc/mkinitcpio.conf; then
@@ -2456,17 +2918,22 @@ boot_ux() {
error_warn "$action" "$?"
action="configuring console font" && display "task" "$action"
+ backup_system_file /etc/vconsole.conf
if grep -q "^FONT=" /etc/vconsole.conf 2>/dev/null; then
sed -i 's/^FONT=.*/FONT=ter-132n/' /etc/vconsole.conf
else
echo "FONT=ter-132n" >> /etc/vconsole.conf
fi
+}
+
+configure_initramfs_hook() {
# Only switch to systemd hook for non-ZFS systems
# ZFS initramfs hook is busybox-based and incompatible with systemd hook
if ! is_zfs_root; then
action="delegating fsck messages from udev to systemd" && display "task" "$action"
- sed -i '/^HOOKS=/ s/\budev\b/systemd/' /etc/mkinitcpio.conf || error_warn "$action" "$?"
+ backup_system_file /etc/mkinitcpio.conf
+ sed -i '/^HOOKS=/ s/\budev\b/systemd/' /etc/mkinitcpio.conf 2>> "$logfile" || error_warn "$action" "$?"
mkinitcpio -P >> "$logfile" 2>&1 || error_warn "running mkinitcpio -P to silence fsck messages" "$?"
fi
@@ -2485,6 +2952,9 @@ StandardOutput=null
StandardError=journal+console
EOF
+}
+
+configure_encrypted_autologin() {
# Automatic login for encrypted systems (prompts if no CLI flag and root is encrypted)
configure_autologin
@@ -2507,6 +2977,9 @@ HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
EOF
+}
+
+configure_tlp_power() {
# TLP power management — laptops only (battery present). Manages wifi,
# USB, PCIe, and CPU power policy on AC/battery transitions. systemd-rfkill
# is masked per TLP's docs (it fights TLP's radio-state handling).
@@ -2525,12 +2998,14 @@ PLATFORM_PROFILE_ON_BAT=low-power
# Off by default — uncomment (and match the BAT name) to enable.
#STOP_CHARGE_THRESH_BAT1=80
EOF
- action="enabling TLP service" && display "task" "$action"
- systemctl enable tlp.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "enabling TLP service" systemctl enable tlp.service
systemctl mask systemd-rfkill.service systemd-rfkill.socket >> "$logfile" 2>&1 || \
error_warn "masking systemd-rfkill for TLP" "$?"
fi
+}
+
+trim_firmware() {
# Firmware trim — Framework 13 Intel only (matched by DMI), where the
# hardware set is known: i915 graphics (linux-firmware-intel), ath9k wifi
# (linux-firmware-atheros, firmware-free driver but kept for safety), and
@@ -2548,10 +3023,12 @@ EOF
linux-firmware-mellanox linux-firmware-nfp linux-firmware-nvidia \
linux-firmware-other linux-firmware-qlogic linux-firmware-radeon \
>> "$logfile" 2>&1 || error_warn "$action" "$?"
- action="rebuilding initramfs after firmware trim" && display "task" "$action"
- mkinitcpio -P >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "rebuilding initramfs after firmware trim" mkinitcpio -P
fi
+}
+
+configure_grub() {
# GRUB: reset timeouts, adjust log levels, larger menu for HiDPI screens, and show splashscreen
# Note: nvme.noacpi=1 disables NVMe ACPI power management to prevent freezes on some drives.
# Safe to keep on newer drives (minor power cost), remove if battery life is critical.
@@ -2569,8 +3046,7 @@ EOF
# Regenerate GRUB config after all modifications
if [ -f /etc/default/grub ]; then
- action="generating grub configuration" && display "task" "$action"
- grub-mkconfig -o /boot/grub/grub.cfg >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ run_task "generating grub configuration" grub-mkconfig -o /boot/grub/grub.cfg
fi
}
@@ -2606,6 +3082,8 @@ outro() {
for msg in "${error_messages[@]}"; do
printf " - %s\n" "$msg" | tee -a "$logfile"
done
+ printf " Each error's command output is in the log: grep -n 'ERROR:' %s\n" "$logfile" | tee -a "$logfile"
+ printf " Fix the cause and re-run archsetup for anything that must succeed.\n" | tee -a "$logfile"
fi
printf "\n"
diff --git a/archsetup.conf.example b/archsetup.conf.example
index 962d05f..82f6898 100644
--- a/archsetup.conf.example
+++ b/archsetup.conf.example
@@ -39,6 +39,11 @@
# Set to "no" to skip it (no AI tooling, avoids the curl-pipe-bash step).
#INSTALL_CLAUDE_CODE=yes
+# Install device-specific udev rules (default: yes)
+# Currently the Logitech BRIO webcam auto-configuration rule. These target
+# specific hardware; set to "no" if your machine doesn't have those devices.
+#INSTALL_DEVICE_UDEV_RULES=yes
+
# System locale (default: prompt if not already configured)
# Common options: en_US.UTF-8, en_GB.UTF-8, de_DE.UTF-8, es_ES.UTF-8,
# fr_FR.UTF-8, pt_BR.UTF-8, ja_JP.UTF-8, zh_CN.UTF-8
diff --git a/working/collapsible-waybar-sides/spike-findings.org b/assets/2026-06-18-collapsible-waybar-sides-spike-findings.org
index 4d45ed1..4d45ed1 100644
--- a/working/collapsible-waybar-sides/spike-findings.org
+++ b/assets/2026-06-18-collapsible-waybar-sides-spike-findings.org
diff --git a/working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org b/assets/2026-06-19-collapsible-waybar-sides-spec.org
index b9ddc0d..a80d755 100644
--- a/working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org
+++ b/assets/2026-06-19-collapsible-waybar-sides-spec.org
@@ -1,5 +1,5 @@
#+TITLE: Collapsible waybar sides — implementation spec
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-19
* Goal
diff --git a/assets/2026-07-03-instrument-console-panels-build-summary.org b/assets/2026-07-03-instrument-console-panels-build-summary.org
new file mode 100644
index 0000000..6e975bf
--- /dev/null
+++ b/assets/2026-07-03-instrument-console-panels-build-summary.org
@@ -0,0 +1,106 @@
+#+TITLE: Instrument-Console Panel Rebuild — Build Summary
+#+DATE: 2026-07-03
+#+AUTHOR: Craig Jennings
+Findings summary for the no-approvals speedrun that rebuilt the net and
+bluetooth waybar panels as single-screen instrument consoles. Spec:
+[[file:../docs/specs/2026-07-03-instrument-console-panels-spec.org][2026-07-03-instrument-console-panels-spec.org]] (ID e73877f5, IMPLEMENTED).
+Normative design: [[file:../docs/prototypes/2026-07-03-instrument-console-panels-prototype.html][docs/prototypes/2026-07-03-instrument-console-panels-prototype.html]]
+(Craig approved through five prototype iterations).
+
+* What shipped
+
+Both panels went from a tabbed Blueprint UI (Connections / Diagnostics /
+Performance style tabs) to one always-visible instrument console: a faceplate
+with a state lamp and word, engraved section labels, scrolled lamp rows for
+the live entities, a row of console keys, cairo dial meters, and an output
+well that streams diagnostics in place. No terminals, no tabs.
+
+Six phases, each committed and pushed on landing:
+
+| Phase | Commit (dotfiles) | What landed |
+|-------+----------------------+-------------------------------------------------|
+| 1 | (spec + task wiring) | Spec authored, parent task wired with :SPEC_ID: |
+|-------+----------------------+-------------------------------------------------|
+| 2 | 81ec9c3 | Net GTK-free presenter layer + engine verbs |
+| | | (52 new tests) |
+|-------+----------------------+-------------------------------------------------|
+| 3+4 | 800ef60 | Net view rebuilt as the console + all |
+| | | interactions wired |
+|-------+----------------------+-------------------------------------------------|
+| 5a | 5318b34 | Bt GTK-free layer + engine gaps (47 new tests) |
+|-------+----------------------+-------------------------------------------------|
+| 5b | 66f03d9 | Bt view rebuilt as the console + all |
+| | | interactions wired |
+|-------+----------------------+-------------------------------------------------|
+| 6 | f4e688e | Dead-code removal, build close-out |
+|-------+----------------------+-------------------------------------------------|
+
+* Engine gaps closed
+
+The console needed capabilities the tabbed panels never had:
+
+- Net: =manage.wifi_radio= (nmcli radio wifi on/off), =manage.device_up=
+ (ethernet takes the route), =sysio.link_speed_mbps= (/sys wired speed),
+ =connections.ethernet_devices=, and a hidden-SSID flag on =manage.add=.
+- Bt: =btctl.set_alias= renames a device through the bluez D-Bus Alias via
+ busctl (there is no MAC-addressed one-shot for set-alias, so =device_path=
+ discovers the controller node from the object tree), =manage.rename= wraps
+ it with a verify-after read, =parse_info= reads the Alias as the display
+ name, and =doctor= grew =on_report= / =on_begin= callbacks so its checks and
+ repairs stream into the output well.
+
+* Tests added
+
+- Phase 2: 52 new net presenter tests (581 net total at the time).
+- Phase 5a: 47 new bt console tests.
+- Both panels' AT-SPI smokes rewritten to drive the single-screen console and
+ anchor on stable engraved labels + the panel-unique console key (net DOCTOR,
+ bt SCAN) rather than the flaky count labels.
+- Full suite through phase 6: 46 suites, zero failures.
+
+* Live-verify results (velox, 2026-07-03)
+
+- =make test=: 46 suites green, zero FAILED/ERROR.
+- =make test-panel= (net): green end to end — faceplate NET·01 / ONLINE, one
+ Close, DOCTOR + SPEED TEST keys, engraved CHANNEL/CONSOLE/NETWORKS/TUNNELS,
+ live route line, tunnel rows (tailscale + 7 WireGuard NM), DOCTOR streamed
+ real diagnose steps, output-well dismiss, panel closed on Close.
+- =make test-panel-bt= (bt): green end to end — faceplate BT·01 / POWERED, one
+ Close, adapter-power switch, DOCTOR + SCAN keys, engraved
+ ADAPTER/CONSOLE/NEARBY/PAIRED, discoverable chip, battery gauge slots, DOCTOR
+ streamed real checks, output-well dismiss, panel closed on Close.
+- Both =gui.py= files are byte-identical to their screenshot-verified commits
+ (net 800ef60, bt 66f03d9), so the phase-3/4/5 screenshots (render matching
+ the prototype) still stand — phase 6 touched no view code.
+
+* Phase-6 dead code removed (dotfiles f4e688e)
+
+The console builds its widget tree in Python, so the old Gtk.Template page
+classes and Blueprint sources had no caller:
+
+- =net/src/net/pages.py=, =bluetooth/src/bt/pages.py=
+- both panels' =ui/= dirs (=window_content=, =diagnostics_page=,
+ =connections_page= / =devices_page= — the =.blp= sources and compiled =.ui=)
+- the =make ui= Blueprint-compile target and its =.PHONY= entry (no =.blp=
+ files remain to compile)
+- a stale =gui.py / pages.py= mention in the bt =viewmodel.py= docstring
+
+Confirmed nothing imported the removed modules before deleting; 46 suites and
+both smokes stayed green after.
+
+* Folded tasks closed with this build
+
+- Network panel redesign — no terminals, verify-everything, full failure
+ coverage (its failure-mode catalog stays as the standing diagnose/repair
+ completeness reference).
+- Bluetooth panel: switch placement + panel title.
+- Bluetooth panel: rename devices.
+
+* Deferrals
+
+Interactions that mutate Craig's real bluetooth state can't be auto-driven —
+they need a human at the keyboard with real devices. Filed as a manual-test
+checklist under "Manual testing and validation" in todo.org: pair-passkey
+flow, rename a real device, connect/disconnect, forget, discoverable toggle,
+power toggle, and the LOW BATT badge with a real sub-15% device. Net's
+in-panel speedtest and timer-dialog manual tests were already pending there.
diff --git a/assets/color-themes/generate-palette.sh b/assets/color-themes/generate-palette.sh
index 456d1a4..5a11264 100755
--- a/assets/color-themes/generate-palette.sh
+++ b/assets/color-themes/generate-palette.sh
@@ -1,4 +1,5 @@
#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
# Generate dupre-palette.png from color definitions using ImageMagick.
# Output: assets/color-themes/dupre/dupre-palette.png
diff --git a/assets/easyeffects-eq-presets.sh b/assets/easyeffects-eq-presets.sh
index 40e9cd9..9a2ecef 100755
--- a/assets/easyeffects-eq-presets.sh
+++ b/assets/easyeffects-eq-presets.sh
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Install EasyEffects parametric EQ presets (Harman target)
#
# Presets:
diff --git a/assets/outbox/2025-11-08-keyring-fix-next-steps.org b/assets/outbox/2025-11-08-keyring-fix-next-steps.org
index e478ac3..c378335 100644
--- a/assets/outbox/2025-11-08-keyring-fix-next-steps.org
+++ b/assets/outbox/2025-11-08-keyring-fix-next-steps.org
@@ -1,7 +1,6 @@
#+TITLE: GNOME Keyring Authentication Fix - Next Steps
#+DATE: 2025-11-08
-#+AUTHOR: Craig Jennings & Claude
-
+#+AUTHOR: Craig Jennings
* URGENT: Complete Keyring Fix on Next Login
** What Was Done
diff --git a/assets/outbox/2025-11-08-test-failure-analysis.org b/assets/outbox/2025-11-08-test-failure-analysis.org
index 56453c3..93377f3 100644
--- a/assets/outbox/2025-11-08-test-failure-analysis.org
+++ b/assets/outbox/2025-11-08-test-failure-analysis.org
@@ -1,5 +1,5 @@
#+TITLE: Test Failure Analysis - VM Test Run 20251108-204202
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2025-11-08
* Test Overview
diff --git a/assets/outbox/2026-06-24-2314-from-.emacs.d-delivered-side-pointed-dirvish-bg-cj.org b/assets/outbox/2026-06-24-2314-from-.emacs.d-delivered-side-pointed-dirvish-bg-cj.org
new file mode 100644
index 0000000..32d8940
--- /dev/null
+++ b/assets/outbox/2026-06-24-2314-from-.emacs.d-delivered-side-pointed-dirvish-bg-cj.org
@@ -0,0 +1,5 @@
+#+TITLE: Delivered side: pointed dirvish 'bg' (cj/set-wallpaper in mo
+#+SOURCE: from .emacs.d
+#+DATE: 2026-06-24 23:14:07 -0400
+
+Delivered side: pointed dirvish 'bg' (cj/set-wallpaper in modules/dirvish-config.el) at the set-wallpaper script for the Wayland branch, replacing the dead swww call. Test updated + green, live-reloaded into the daemon, set-wallpaper confirmed on PATH (your dotfiles 8be2484 symlink). The wallpaper dependency is closed — you can drop the :blocker:. The separate 'dirvish doesn't preview images' item stays open on my side.
diff --git a/assets/outbox/2026-06-24-lint-followups-resolved.org b/assets/outbox/2026-06-24-lint-followups-resolved.org
new file mode 100644
index 0000000..5f3b06f
--- /dev/null
+++ b/assets/outbox/2026-06-24-lint-followups-resolved.org
@@ -0,0 +1,6 @@
+* 2026-06-24 Wed — Task-review health: 34 top-level [#A]/[#B]/[#C] tasks unreviewed for >30 days (daily review may have slipped)
+
+* lint-org follow-ups — todo.org (2026-06-24)
+** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 138)
+
+* 2026-06-24 Wed — Task-review health: 27 top-level [#A]/[#B]/[#C] tasks unreviewed for >30 days (daily review may have slipped)
diff --git a/assets/outbox/2026-06-25-1248-from-archangel-accepted-the-stale-baked-archzfs-db-zfs.org b/assets/outbox/2026-06-25-1248-from-archangel-accepted-the-stale-baked-archzfs-db-zfs.org
new file mode 100644
index 0000000..1e0ebf4
--- /dev/null
+++ b/assets/outbox/2026-06-25-1248-from-archangel-accepted-the-stale-baked-archzfs-db-zfs.org
@@ -0,0 +1,5 @@
+#+TITLE: Accepted: the stale-baked-archzfs-db ZFS install bug. Diagno
+#+SOURCE: from archangel
+#+DATE: 2026-06-25 12:48:41 -0400
+
+Accepted: the stale-baked-archzfs-db ZFS install bug. Diagnosis confirmed against source — install_base (installer/archangel:759) appends [archzfs] at ~771 and runs pacstrap -K at 805 with no db refresh between, so pacstrap resolves zfs-dkms from the baked ISO db (2.3.3) instead of the current channel (2.4.2). Filed as archangel [#A] :bug:blocker: 'Refresh archzfs db before pacstrap in install_base', tagged :blocker: so it surfaces as priority work. Fix direction: pacman -Sy after the repo appends and before pacstrap -K. A fresh ISO rebuild still needed for aged ISOs, but the refresh is the durable fix. Will notify you when a fixed ISO is available so you can retry make test FS_PROFILE=zfs.
diff --git a/assets/outbox/2026-06-25-1347-from-archangel-delivered-you-re-unblocked-the-stale.org b/assets/outbox/2026-06-25-1347-from-archangel-delivered-you-re-unblocked-the-stale.org
new file mode 100644
index 0000000..296e37c
--- /dev/null
+++ b/assets/outbox/2026-06-25-1347-from-archangel-delivered-you-re-unblocked-the-stale.org
@@ -0,0 +1,5 @@
+#+TITLE: Delivered — you're unblocked. The stale-baked-archzfs-db ZFS
+#+SOURCE: from archangel
+#+DATE: 2026-06-25 13:47:29 -0400
+
+Delivered — you're unblocked. The stale-baked-archzfs-db ZFS install bug is fixed and verified. installer/archangel now runs pacman -Syy before pacstrap (commit 89691a0, pushed to main). I rebuilt the ISO and ran the single-disk ZFS install in QEMU (scripts/test-install.sh single-disk = FILESYSTEM=zfs, NO_ENCRYPT=yes, the same scenario you reproduced): pacstrap resolved zfs-dkms-2.4.2-1 / zfs-utils-2.4.2-2 (not the stale 2.3.3), DKMS built zfs/2.4.2 against kernel 6.18.36-1-lts, the system rebooted, and the ZFS pool came up healthy. TEST PASSED. Fixed ISO: out/archangel-2026-06-25-vmlinuz-6.18.36-lts-x86_64.iso (kernel 6.18.36-lts). Retry make test FS_PROFILE=zfs against this ISO. Note: build.sh clears the host pacoloco archzfs cache on each build, so a fresh build is what guarantees current packages — if you ever see 2.3.3 again, rebuild rather than reusing an aged ISO.
diff --git a/assets/outbox/2026-06-25-1359-from-archangel-got-the-heads-up-everything-it-flags-is.org b/assets/outbox/2026-06-25-1359-from-archangel-got-the-heads-up-everything-it-flags-is.org
new file mode 100644
index 0000000..e437d7d
--- /dev/null
+++ b/assets/outbox/2026-06-25-1359-from-archangel-got-the-heads-up-everything-it-flags-is.org
@@ -0,0 +1,5 @@
+#+TITLE: Got the heads-up — everything it flags is already handled. I
+#+SOURCE: from archangel
+#+DATE: 2026-06-25 13:59:17 -0400
+
+Got the heads-up — everything it flags is already handled. I adopted the staged pacman -Syy edit as-is and committed it (89691a0, pushed to main), so no mystery edit remains. My own ISO rebuild ran clean (your work/ teardown left out/ intact; the new build created and tore down its own work/). Verified the ZFS single-disk install end-to-end before the unblock note I sent at 13:47 — that note supersedes this thread. You're clear to retry create-base-vm FS_PROFILE=zfs against out/archangel-2026-06-25-vmlinuz-6.18.36-lts-x86_64.iso.
diff --git a/assets/outbox/2026-06-27-2148-from-archangel-accepted-both-archangel-bug-handoffs.org b/assets/outbox/2026-06-27-2148-from-archangel-accepted-both-archangel-bug-handoffs.org
new file mode 100644
index 0000000..213017d
--- /dev/null
+++ b/assets/outbox/2026-06-27-2148-from-archangel-accepted-both-archangel-bug-handoffs.org
@@ -0,0 +1,5 @@
+#+TITLE: Accepted both archangel bug handoffs (pacman.conf 0600, 2026
+#+SOURCE: from archangel
+#+DATE: 2026-06-27 21:48:03 -0400
+
+Accepted both archangel bug handoffs (pacman.conf 0600, 2026-06-25; baked AUR list breaks btrfs, 2026-06-27). Both diagnoses confirmed against archangel source. Filed as tasks; the baked-AUR-list one is tagged :blocker: since it blocks your btrfs base creation. Starting the fixes now — will ping you with a fixed ISO once both land and verify so you can rebuild the btrfs base and re-add btrfs to the green-test matrix.
diff --git a/assets/outbox/2026-06-27-2301-from-archangel-delivered-both-archangel-bugs-fixed-and.org b/assets/outbox/2026-06-27-2301-from-archangel-delivered-both-archangel-bugs-fixed-and.org
new file mode 100644
index 0000000..9384f88
--- /dev/null
+++ b/assets/outbox/2026-06-27-2301-from-archangel-delivered-both-archangel-bugs-fixed-and.org
@@ -0,0 +1,15 @@
+#+TITLE: Delivered — both archangel bugs fixed and verified, fixed IS
+#+SOURCE: from archangel
+#+DATE: 2026-06-27 23:01:40 -0400
+
+Delivered — both archangel bugs fixed and verified, fixed ISO ready.
+
+ISO: /home/cjennings/code/archangel/out/archangel-2026-06-27-vmlinuz-6.18.36-lts-x86_64.iso (2.1G, built 2026-06-27 22:36)
+
+Bug #2 (the blocker — baked AUR list broke btrfs): fixed. install_base now filters the baked AUR set by target filesystem, dropping zfs-only tooling (zfs-auto-snapshot, zrepl) on a non-zfs install. The ISO still bakes the full set; only the install selection is filtered. Verified end-to-end in a VM: a btrfs-single unattended install now completes cleanly (the 266-pkg pacstrap transaction no longer includes zfs-auto-snapshot, so the 'unable to satisfy dependency zfs' abort is gone). A zfs single-disk install still passes, so the zfs path is unaffected.
+
+Bug #1 (installed /etc/pacman.conf landing 0600): fixed. strip_repo_stanza now writes through the existing config instead of mv-ing a 0600 mktemp over it, so the installed pacman.conf keeps its 0644. Unit-tested for mode preservation; the btrfs/zfs installs both completed past the strip step.
+
+You're unblocked: rebuild the btrfs base from this ISO and re-add btrfs to the green-test matrix. Fix is committed on archangel main (2ead674) and pushed.
+
+Note: the companion EFI \EFI\BOOT\BOOTX64.EFI removable-fallback hardening you mentioned is filed on the archangel side but not done in this pass — separate, optional, not part of this unblock.
diff --git a/assets/outbox/2026-06-28-lint-followups-task-review-health.org b/assets/outbox/2026-06-28-lint-followups-task-review-health.org
new file mode 100644
index 0000000..28c2e38
--- /dev/null
+++ b/assets/outbox/2026-06-28-lint-followups-task-review-health.org
@@ -0,0 +1,2 @@
+
+* 2026-06-28 Sun — Task-review health: 27 top-level [#A]/[#B]/[#C] tasks unreviewed for >30 days (daily review may have slipped)
diff --git a/assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt b/assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt
new file mode 100644
index 0000000..6f62d17
--- /dev/null
+++ b/assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt
@@ -0,0 +1,5 @@
+Security check from rulesets — action needed: your CLAUDE.md is tracked.
+
+Same broadcast as the tooling-check note also in your inbox, plus a project-specific finding: archsetup is gitignore-mode (anchored /.ai/ style) but CLAUDE.md is currently TRACKED in git. The fixed sweep just added /CLAUDE.md and /AGENTS.md to your .gitignore, but an ignore line never untracks an already-tracked file.
+
+Action: git rm --cached CLAUDE.md (keeps the file on disk), then commit. Your only remote is private cjennings.net, so no public exposure is known — but verify with Craig that the bare repo carries no mirror hook, since .emacs.d's exposure rode exactly that.
diff --git a/assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt b/assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt
new file mode 100644
index 0000000..7b10753
--- /dev/null
+++ b/assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt
@@ -0,0 +1,12 @@
+Security check request from rulesets: tracked tooling vs public reachability.
+
+Background: .emacs.d found its entire tooling layer (CLAUDE.md + .claude/ — 22 files, including rules with personal data) tracked in git and mirrored to PUBLIC GitHub by a server-side post-receive hook on its cjennings.net bare repo. The mirror was invisible from the client; the history is now scrubbed. The sweep script that should have caught it silently skipped any project using the anchored /.ai/ gitignore style — that bug is fixed as of rulesets 909b21b.
+
+Action for this project's next session:
+1. Check whether .ai/, .claude/, CLAUDE.md, or AGENTS.md is tracked: git ls-files .ai .claude CLAUDE.md AGENTS.md
+2. Check reachability: git remote -v. A non-cjennings.net remote with tracked tooling is an exposure — gitignore the set, git rm --cached the paths (files stay on disk), and consider a history scrub (see .emacs.d's 2026-06-30 filter-repo precedent).
+3. Even with only a cjennings.net remote, a server-side mirror hook can republish invisibly. If this project might be mirrored, ask Craig to check the bare repo's hooks/ on the server.
+
+Convention update (protocols.org): any repo whose remotes include a non-cjennings.net host gitignores the tooling set; a deliberate, explicitly-decided team-shared config is the only exception. Track-mode on the private server (history-is-the-project repos) is unchanged.
+
+The fixed sweep has already backfilled missing ignore lines across gitignore-mode projects (2026-07-01 run). No reply needed unless you find tracked tooling with public reach.
diff --git a/assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org b/assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org
new file mode 100644
index 0000000..055e635
--- /dev/null
+++ b/assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org
@@ -0,0 +1,5 @@
+#+TITLE: Accepted: your spec-review UI-traps checklist is promoted in
+#+SOURCE: from rulesets
+#+DATE: 2026-07-01 21:44:52 -0400
+
+Accepted: your spec-review UI-traps checklist is promoted into the canonical spec-review.org (rulesets 9814b94). It landed as a conditional Phase 4 dimension — 'Operational-panel UI traps', applied when a spec covers a user-facing panel/dialog/control surface, skipped otherwise — with all six checks and a provenance note crediting the 2026-06-30 Waybar network-panel review. Every project picks it up on its next startup sync; you can drop your local copy's divergence next time it syncs.
diff --git a/assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org b/assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org
new file mode 100644
index 0000000..3ead015
--- /dev/null
+++ b/assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org
@@ -0,0 +1,5 @@
+#+TITLE: Your roam-routed handoff (2026-07-02 0110) is processed: ite
+#+SOURCE: from rulesets
+#+DATE: 2026-07-02 01:31:58 -0400
+
+Your roam-routed handoff (2026-07-02 0110) is processed: item 1 (template pull with gitignored-only changes) filed as a [#C] feature task in rulesets todo.org; item 2 (ai-term colors) forwarded to .emacs.d — ai-term is its module; item 3 (wrap-it-up summary keep-or-cut) filed as a [#C] task for a think-through with Craig — the teardown-by-default half already shipped 2026-07-01.
diff --git a/assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org b/assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org
new file mode 100644
index 0000000..675d73b
--- /dev/null
+++ b/assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org
@@ -0,0 +1,5 @@
+#+TITLE: Auto-flush is canonicalized: self-inject.sh now lives in the
+#+SOURCE: from rulesets
+#+DATE: 2026-07-02 01:36:19 -0400
+
+Auto-flush is canonicalized: self-inject.sh now lives in the synced .ai/scripts/ (6-test bats suite, tmux stubbed at the boundary), the flush skill gained an auto mode (checkpoint gate unchanged, then derive-pane-first + tmux run-shell -b arm + end-turn, your two gotchas documented), and work-the-backlog.org (the speedrun's home) auto-flushes between tasks when context grows heavy. Your design note is preserved at rulesets docs/design/2026-07-02-auto-flush-mechanism-note.org. Every project gets the script on its next template sync.
diff --git a/assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org b/assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org
new file mode 100644
index 0000000..f5aac52
--- /dev/null
+++ b/assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org
@@ -0,0 +1,5 @@
+#+TITLE: Both your 0543 handoffs are shipped. inbox-send now never ov
+#+SOURCE: from rulesets
+#+DATE: 2026-07-02 05:55:34 -0400
+
+Both your 0543 handoffs are shipped. inbox-send now never overwrites: a colliding filename gets a -2/-3 stem suffix (4 new tests reproduce your lost-message case, 30/30 green — thanks for the wild find). Pages are info-level now: page-me.org and the work-the-backlog end-of-set page use notify info --persist instead of alarm, per Craig's too-alarming verdict. The dupre-blue color item went to .emacs.d with the hex reference — ai-term owns instance colors.
diff --git a/assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org b/assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org
new file mode 100644
index 0000000..bdca099
--- /dev/null
+++ b/assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org
@@ -0,0 +1,4 @@
+* lint-org follow-ups — todo.org (2026-07-03)
+** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 1317)
+** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 1292)
+** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 1202)
diff --git a/assets/wireguard-config/USCALA.conf b/assets/wireguard-config/USCALA.conf
deleted file mode 100644
index 7d902d4..0000000
--- a/assets/wireguard-config/USCALA.conf
+++ /dev/null
@@ -1,15 +0,0 @@
-[Interface]
-# Bouncing = 8
-# NetShield = 1
-# Moderate NAT = on
-# NAT-PMP (Port Forwarding) = off
-# VPN Accelerator = on
-PrivateKey = aDhBPBlyRGAtWz2eaP6mPmEC5e6uNJj/YFleWACZdEk=
-Address = 10.2.0.2/32
-DNS = 10.2.0.1
-
-[Peer]
-# US-CA#187
-PublicKey = fXtINk5LcWvNoCxNwx9WkmHieyyw+zIcLiiRM6eyECc=
-AllowedIPs = 0.0.0.0/0
-Endpoint = 146.70.174.162:51820 \ No newline at end of file
diff --git a/assets/wireguard-config/USCASF.conf b/assets/wireguard-config/USCASF.conf
deleted file mode 100644
index 7948ae4..0000000
--- a/assets/wireguard-config/USCASF.conf
+++ /dev/null
@@ -1,16 +0,0 @@
-[Interface]
-# Key for velox
-# Bouncing = 26
-# NetShield = 1
-# Moderate NAT = on
-# NAT-PMP (Port Forwarding) = off
-# VPN Accelerator = on
-PrivateKey = 4Al9epK8qlWSiASFx1D8YPtqaqdUKUA6SRQhfhmL81g=
-Address = 10.2.0.2/32
-DNS = 10.2.0.1
-
-[Peer]
-# US-CA#75
-PublicKey = xRu4XSIeCCNh4wQqit2w0PwAqzAs7JVA4zQqxGOhSSY=
-AllowedIPs = 0.0.0.0/0
-Endpoint = 79.127.185.222:51820 \ No newline at end of file
diff --git a/assets/wireguard-config/USDC.conf b/assets/wireguard-config/USDC.conf
deleted file mode 100644
index 62ede76..0000000
--- a/assets/wireguard-config/USDC.conf
+++ /dev/null
@@ -1,15 +0,0 @@
-[Interface]
-# Bouncing = 1
-# NetShield = 1
-# Moderate NAT = on
-# NAT-PMP (Port Forwarding) = off
-# VPN Accelerator = on
-PrivateKey = ODgff/xOftY7+v64+J9vPs9C2ZK83xepaM9+OdJUong=
-Address = 10.2.0.2/32
-DNS = 10.2.0.1
-
-[Peer]
-# US-DC#29
-PublicKey = 3Lz5VpqnS7wfnOWVYFNCFHl+JuuanJ/hB2TqOKQZxVI=
-AllowedIPs = 0.0.0.0/0
-Endpoint = 185.247.68.50:51820 \ No newline at end of file
diff --git a/assets/wireguard-config/USGAAT.conf b/assets/wireguard-config/USGAAT.conf
deleted file mode 100644
index b4cfc7d..0000000
--- a/assets/wireguard-config/USGAAT.conf
+++ /dev/null
@@ -1,15 +0,0 @@
-[Interface]
-# Bouncing = 0
-# NetShield = 1
-# Moderate NAT = on
-# NAT-PMP (Port Forwarding) = off
-# VPN Accelerator = on
-PrivateKey = gMms305eLQY1Q/GTC1/nTffFh9ou4tIVzpQuWo0P6XU=
-Address = 10.2.0.2/32
-DNS = 10.2.0.1
-
-[Peer]
-# US-GA#319
-PublicKey = vrQlzOff8/CWCDVaesXMZLfQaOE4qrdY2BJUjWeRHyA=
-AllowedIPs = 0.0.0.0/0
-Endpoint = 149.22.94.113:51820 \ No newline at end of file
diff --git a/assets/wireguard-config/USNY.conf b/assets/wireguard-config/USNY.conf
deleted file mode 100644
index ddf43a6..0000000
--- a/assets/wireguard-config/USNY.conf
+++ /dev/null
@@ -1,16 +0,0 @@
-[Interface]
-# Key for New York
-# Bouncing = 8
-# NetShield = 1
-# Moderate NAT = off
-# NAT-PMP (Port Forwarding) = off
-# VPN Accelerator = on
-PrivateKey = APAkVGvrTIXjgSCy9fUM7q4B9Fgj4M8PVbakpVEQQnE=
-Address = 10.2.0.2/32
-DNS = 10.2.0.1
-
-[Peer]
-# US-NY#524
-PublicKey = 8NeySGpnCMtwtgwVARpoCNonu9qxQxrE6hFztMcMDkA=
-AllowedIPs = 0.0.0.0/0
-Endpoint = 146.70.72.130:51820 \ No newline at end of file
diff --git a/assets/wireguard-config/switzerlan-zurich1.conf b/assets/wireguard-config/switzerlan-zurich1.conf
deleted file mode 100644
index 4d7908e..0000000
--- a/assets/wireguard-config/switzerlan-zurich1.conf
+++ /dev/null
@@ -1,15 +0,0 @@
-[Interface]
-# Bouncing = 18
-# NetShield = 1
-# Moderate NAT = off
-# NAT-PMP (Port Forwarding) = off
-# VPN Accelerator = on
-PrivateKey = CJSPw7zcMMBDJbQDYlwFvdVcXvvsGns592PiDHmhTks=
-Address = 10.2.0.2/32
-DNS = 10.2.0.1
-
-[Peer]
-# CH#185
-PublicKey = XPVCz7LndzqWe7y3+WSo51hvNOX8nX5CTwVTWhzg8g8=
-AllowedIPs = 0.0.0.0/0
-Endpoint = 149.88.27.234:51820 \ No newline at end of file
diff --git a/assets/wireguard-config/switzerlan-zurich2.conf b/assets/wireguard-config/switzerlan-zurich2.conf
deleted file mode 100644
index c2d390f..0000000
--- a/assets/wireguard-config/switzerlan-zurich2.conf
+++ /dev/null
@@ -1,15 +0,0 @@
-[Interface]
-# Bouncing = 10
-# NetShield = 1
-# Moderate NAT = off
-# NAT-PMP (Port Forwarding) = off
-# VPN Accelerator = on
-PrivateKey = ACCwCDY+Y+RlSH2dSt+IumCBYAo5Sk4an9eXZKt8jEE=
-Address = 10.2.0.2/32
-DNS = 10.2.0.1
-
-[Peer]
-# CH#177
-PublicKey = XPVCz7LndzqWe7y3+WSo51hvNOX8nX5CTwVTWhzg8g8=
-AllowedIPs = 0.0.0.0/0
-Endpoint = 149.88.27.234:51820 \ No newline at end of file
diff --git a/assets/wireguard-config/wg-NL-781.conf b/assets/wireguard-config/wg-NL-781.conf
new file mode 100644
index 0000000..bc6696d
--- /dev/null
+++ b/assets/wireguard-config/wg-NL-781.conf
@@ -0,0 +1,19 @@
+[Interface]
+# Bouncing = 15
+# NetShield = 0
+# Moderate NAT = off
+# NAT-PMP (Port Forwarding) = off
+# VPN Accelerator = on
+PrivateKey = OOJnyB7Mtr6DIZ+NTv6y3Qv8kV/urmfB8efw/mEEoEA=
+Address = 10.2.0.2/32, 2a07:b944::2:2/128
+DNS = 10.2.0.1, 2a07:b944::2:1
+
+[Peer]
+# NL#781
+PublicKey = lqb+ofGYNsfYfvGBefHDrYR6BdDrgoY6QwN4QF//gwc=
+AllowedIPs = 0.0.0.0/0, ::/0
+Endpoint = 169.150.196.131:51820
+
+# Uncomment the following line (delete the # symbol) to connect to Proton VPN using IPv6.
+# Endpoint = [2a02:6ea0:c041:6654::10]:51820
+PersistentKeepalive = 25 \ No newline at end of file
diff --git a/assets/wireguard-config/wg-US-CA-144.conf b/assets/wireguard-config/wg-US-CA-144.conf
new file mode 100644
index 0000000..9de4f10
--- /dev/null
+++ b/assets/wireguard-config/wg-US-CA-144.conf
@@ -0,0 +1,19 @@
+[Interface]
+# Bouncing = 1
+# NetShield = 0
+# Moderate NAT = off
+# NAT-PMP (Port Forwarding) = off
+# VPN Accelerator = on
+PrivateKey = sIdZ8nXgMTczLC+QKF8pHEVwEP6CgPAYyCuZ9VWAGlk=
+Address = 10.2.0.2/32, 2a07:b944::2:2/128
+DNS = 10.2.0.1, 2a07:b944::2:1
+
+[Peer]
+# US-CA#144
+PublicKey = WLIgAxtnNNGoiPqZLeQ398sBR3Kubzzf52zcpP6Is3c=
+AllowedIPs = 0.0.0.0/0, ::/0
+Endpoint = 89.45.4.2:51820
+
+# Uncomment the following line (delete the # symbol) to connect to Proton VPN using IPv6.
+# Endpoint = [2a0d:5600:8:109::10]:51820
+PersistentKeepalive = 25 \ No newline at end of file
diff --git a/assets/wireguard-config/wg-US-TX-714.conf b/assets/wireguard-config/wg-US-TX-714.conf
new file mode 100644
index 0000000..f37c96d
--- /dev/null
+++ b/assets/wireguard-config/wg-US-TX-714.conf
@@ -0,0 +1,19 @@
+[Interface]
+# Bouncing = 6
+# NetShield = 0
+# Moderate NAT = off
+# NAT-PMP (Port Forwarding) = off
+# VPN Accelerator = on
+PrivateKey = aNotkjCHmfWQoaEYm9GjVaxW8d3VN8b47NOxWnav3FU=
+Address = 10.2.0.2/32, 2a07:b944::2:2/128
+DNS = 10.2.0.1, 2a07:b944::2:1
+
+[Peer]
+# US-TX#714
+PublicKey = OlXK5sTOUzxjTnzBAH3uIEvl3IuiUL1DKTKurQYddRc=
+AllowedIPs = 0.0.0.0/0, ::/0
+Endpoint = 79.127.183.238:51820
+
+# Uncomment the following line (delete the # symbol) to connect to Proton VPN using IPv6.
+# Endpoint = [2a02:6ea0:e00a:8077::10]:51820
+PersistentKeepalive = 25 \ No newline at end of file
diff --git a/configs/maintenance-thresholds.toml b/configs/maintenance-thresholds.toml
new file mode 100644
index 0000000..340c66c
--- /dev/null
+++ b/configs/maintenance-thresholds.toml
@@ -0,0 +1,197 @@
+# maintenance-thresholds.toml — every severity value the maintenance console
+# and the system-health-check workflow grade against, plus the shipped
+# curation defaults. Owned and installed by archsetup (canonical:
+# configs/maintenance-thresholds.toml in the archsetup repo; installed to
+# ~/.config/archsetup/maintenance-thresholds.toml). Both consumers read the
+# installed path, so severity rules can never drift between them.
+#
+# User curation and overrides live in ~/.config/maint/curation.toml and merge
+# over this file (scalars: user wins; [curation.*]: entries − disable + add).
+# Never record user marks here — an archsetup update replaces this file.
+#
+# Seed values migrated 2026-07-07 from the system-health-check workflow's
+# hard-won prose rules (the 2026-05-26 /home snapshot lesson, the 10 GB cache
+# trigger, the 35/60-day scrub bands) and the maintenance-console design doc.
+
+[storage]
+df_warn_pct = 80
+df_crit_pct = 90
+btrfs_scrub_warn_days = 35
+btrfs_scrub_crit_days = 60
+btrfs_unalloc_warn_gb = 10 # chunk headroom floor before ENOSPC weirdness
+zfs_scrub_warn_days = 35
+zfs_scrub_crit_days = 60
+zfs_capacity_warn_pct = 80 # ZFS performance degrades past 80
+zfs_capacity_crit_pct = 90
+zfs_frag_info_pct = 50
+smart_wear_warn_pct = 80 # NVMe percentage_used (100 = rated life)
+smart_temp_warn_c = 70
+fstrim_stale_days = 10 # weekly timer + slack (continuous discard exempts)
+hog_roots = ["/home/cjennings", "/var"] # disk top-consumers scan roots (slow-local); depth-1 of /home is just the user dir, so scan inside it
+hog_top = 8 # digest rows kept
+
+[snapshots]
+# Sane snapper TIMELINE limits — the 2026-05-26 /home pile-up fix.
+timeline_hourly = 6
+timeline_daily = 7
+timeline_weekly = 2
+timeline_monthly = 2
+timeline_quarterly = 0
+timeline_yearly = 0
+timeline_slack = 2 # grade at summed limits + slack: absorbs the
+ # hourly create/:45-cleanup gap; a dead cleanup
+ # timer still clears it within a few hours
+single_keep = 2 # DELETE STALE keeps the newest N singles
+zfs_count_warn = 1000 # runaway retention
+zfs_space_warn_pct = 20 # snapshot space vs pool capacity
+
+[packages]
+cache_warn_gb = 20 # above keep-3 saturation (~17-19GB on ratio);
+ # fires within ~2 months if the paccache timer dies
+keyring_warn_days = 60 # stale archlinux-keyring silently breaks updates
+
+# [security] cve_min_warn retired 2026-07-08: the cve grade now keys on
+# actionability (a fixed release exists and isn't installed), not severity.
+# Unfixable advisories are informational and never warn.
+
+[updates]
+pending_warn = 50 # "a lot" — reddens the strip border
+cache_stale_hours = 6 # network-tier cache older than this warns
+topgrade_warn_days = 14 # topgrade freshness
+# The live-update guard (never -Syu the graphics/wayland runtime under a
+# live session — the 2026-06-07 mesa+hyprland crash). fnmatch patterns
+# against pending package names; a hit arms UPDATE/TOPGRADE instead of
+# running ("press again to run anyway — or apply from a TTY").
+guard_patterns = [
+ "mesa", "mesa-*", "lib32-mesa*",
+ "hyprland", "hyprland-*", "aquamarine", "hyprutils", "hyprlang",
+ "hyprcursor", "hyprgraphics",
+ "*wayland*", "wlroots*",
+ "vulkan-radeon", "lib32-vulkan-radeon", # ratio's GPU driver
+ "vulkan-intel", "lib32-vulkan-intel", # velox's GPU driver
+]
+
+[systemd]
+# The capability-derived expected set (paccache/reflector always;
+# btrfs-scrub@ + snapper timers on btrfs; sanoid + zfs-scrub-weekly@ on ZFS)
+# lives in code — this list adds host-agnostic extras on top.
+expected_timers_extra = []
+
+[logs]
+app_log_warn_days = 7 # log-cleanup cron should hold ~7 days
+journal_digest_top = 10 # groups shown in the journal digest
+journal_disk_warn_gb = 2 # journald footprint before vacuum suggested
+coredump_window_days = 14 # forensic window (matches systemd's 2w
+ # tmpfiles aging): the value/grade counts
+ # corefiles PAST it — CLEAR's reclaim set;
+ # recent crashes are digest-visible only
+
+[memory]
+used_warn_pct = 90 # 1 - available/total (reclaimable-aware)
+used_crit_pct = 97
+oom_window_days = 7 # kernel-journal OOM lookback
+
+[power]
+battery_low_pct = 15 # feeds the diagnostic state (glyph red)
+charge_limit_pct = 80 # the SET 80% longevity cap on velox
+cpu_temp_warn_c = 90 # k10temp Tctl / coretemp package
+gpu_temp_warn_c = 95 # amdgpu edge
+battery_health_warn_pct = 80 # charge_full vs design floor
+unclean_warn_pct = 25 # unclean-shutdown rate over recent boots
+unclean_boot_sample = 10 # boots assessed for the rate
+
+[network]
+ntp_offset_warn_ms = 100 # chrony last-offset bound (typical is sub-ms)
+# Tailscale health messages that are standing noise on this fleet (substring
+# match). tailscaled can't read resolved's stub/DoT config, so its
+# DNS-unreachable complaint fires permanently while DNS works — it kept the
+# lamp amber on every machine. A real DNS outage still surfaces elsewhere
+# (clock sync, the net-tier scans). Ignored messages stay visible in the
+# metric's evidence as health_ignored.
+tailscale_health_ignore = ["can't reach the configured DNS servers"]
+
+[backups]
+rsyncshot_daily_crit_hours = 48 # daily backup older than this is a fire
+rsyncshot_hourly_warn_hours = 3 # graded only on hosts whose log shows hourly runs
+
+[services]
+docker_reclaim_warn_gb = 5
+docker_reclaim_warn_pct = 50
+# Expected crontab entries (substring match, comment lines ignored). Root
+# expectations apply only on backup sources (hosts with a rsyncshot log).
+cron_expected_root = ["rsyncshot"]
+cron_expected_user = ["log-cleanup"]
+
+[refresh]
+# Cadence knobs (seconds unless named otherwise) — spec Decision 8.
+live_secs = 3
+fast_secs = 30
+glyph_scan_mins = 30
+net_scan_mins = 60
+
+# --- shipped curation defaults (user layer adds/disables, never edits) ------
+
+[curation.known_noise]
+# Journal-noise patterns bind to identifier + message snippet, never a whole
+# unit — a muted service's new errors still surface.
+entries = [
+ { identifier = "bluetoothd", pattern = "HFP" },
+ { identifier = "pixman", pattern = "Invalid glyph" },
+ { identifier = "xkbcomp", pattern = "Could not resolve keysym" },
+]
+
+[curation.expected_listeners]
+entries = ["sshd", "mpd", "tailscaled"]
+
+[curation.session_critical]
+# Process names KILL must never fire on (the lever renders disabled) —
+# systemd, the compositor and its plumbing, the audio stack, the panel
+# itself, and sshd (a remote session's lifeline). fnmatch patterns.
+entries = [
+ "systemd*", "Hyprland", "Xwayland", "waybar",
+ "dbus-broker*", "dbus-daemon",
+ "pipewire*", "wireplumber",
+ "maint*", "sshd",
+]
+
+[curation.expected_containers]
+entries = ["winvm"]
+
+[curation.pacnew_safe_delete]
+# Regenerated files whose .pacnew is safe to drop (reflector-managed / local).
+entries = ["/etc/pacman.d/mirrorlist", "/etc/locale.gen"]
+
+[curation.kept_orphans]
+# Packages pacman calls orphaned that are deliberate keeps (the rust lesson):
+# listed in evidence but never counted or warned on. KEEP/UNMARK levers write
+# the user layer; this shipped list stays empty.
+entries = []
+
+[curation.qkk_known]
+# pacman -Qkk mismatches that are config knowledge, not integrity findings
+# (fnmatch patterns). Unverifiable reads and mtime-only mismatches are
+# already excluded in code; this covers managed symlinks, regenerated
+# caches, and files archsetup itself edits.
+entries = [
+ "/etc/resolv.conf", # resolver-managed symlink
+ "/usr/lib/jvm/default", # archlinux-java symlinks
+ "/usr/lib/jvm/default-runtime",
+ "/usr/lib/ghc-*/lib/package.conf.d/package.cache", # regenerated by ghc-pkg
+ "/usr/lib/vlc/plugins/plugins.dat", # regenerated plugin cache
+ "/etc/conf.d/pacman-contrib", # archsetup sets PACCACHE_ARGS
+ # Runtime perm/owner drift the system re-applies within seconds of a
+ # reinstall (verified on ratio 2026-07-08: reinstall reset these, the
+ # daemons re-drifted them before the next -Qkk):
+ "/etc/cups/classes.conf", # cupsd rewrites mode 600
+ "/etc/cups/printers.conf", # (may carry device creds)
+ "/etc/cups/subscriptions.conf",
+ "/var/lib/passim*", # StateDirectory re-chowns
+ "/var/log/journal", # tmpfiles re-groups
+ # /etc/ssl/private deliberately NOT here: both hosts had it world-listable
+ # (755 vs packaged 700) — a real misconfig the metric caught. pacman warns
+ # but never resets existing dir perms; fixed by chmod 2026-07-08.
+ "/usr/lib/utempter/utempter", # setgid-helper ownership;
+ # NOTE: also silences a
+ # content change on this
+ # one binary (accepted)
+]
diff --git a/docs/PLAN-dotfiles-separation.org b/docs/PLAN-dotfiles-separation.org
index 8ef583f..5ce47d5 100644
--- a/docs/PLAN-dotfiles-separation.org
+++ b/docs/PLAN-dotfiles-separation.org
@@ -1,5 +1,5 @@
#+TITLE: Plan — Separate dotfiles from archsetup
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-13
* Overview
@@ -154,7 +154,7 @@ verified against existing repos at the same host on 2026-05-14).
In a temp working dir (does NOT touch the live archsetup repo):
#+begin_src bash
-git clone --no-local /home/cjennings/code/archsetup /tmp/extract-dotfiles
+git clone --no-local "$(git rev-parse --show-toplevel)" /tmp/extract-dotfiles
cd /tmp/extract-dotfiles
git filter-repo --subdirectory-filter dotfiles/
#+end_src
@@ -212,7 +212,7 @@ Document that the user's repo must contain =common/= plus =dwm/=,
*** Step 2.2 — Update archsetup script
-Edits to =/home/cjennings/code/archsetup/archsetup=:
+Edits to the =archsetup= script at the repo root:
1. *Read config* (around line 114-122): map =DOTFILES_REPO= / =DOTFILES_BRANCH=
/ =DOTFILES_DIR= env vars to lowercase script variables.
diff --git a/docs/PLAN-per-host-overrides.org b/docs/PLAN-per-host-overrides.org
index 6816a21..9eafb4d 100644
--- a/docs/PLAN-per-host-overrides.org
+++ b/docs/PLAN-per-host-overrides.org
@@ -1,5 +1,5 @@
#+TITLE: Per-Host Override Mechanism for the Dotfiles Repo
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-26
* Status
diff --git a/docs/design/2026-06-25-testinfra-validation.org b/docs/design/2026-06-25-testinfra-validation.org
new file mode 100644
index 0000000..0f81231
--- /dev/null
+++ b/docs/design/2026-06-25-testinfra-validation.org
@@ -0,0 +1,240 @@
+#+TITLE: Design: Testinfra Post-Install Validation for archsetup
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-25
+#+STATUS: Accepted (2026-06-25)
+
+* Problem
+
+The VM integration harness (=scripts/testing/run-test.sh=) runs archsetup in a
+QEMU VM, then verifies the result two ways:
+
+1. Parses archsetup's own install log for its Error Summary and the
+ =ARCHSETUP_EXECUTION_COMPLETE= marker (did the script finish, did it log
+ errors).
+2. Runs =run_all_validations= from =scripts/testing/lib/validation.sh= — a
+ hand-rolled, shell-based post-install assertion sweep of ~26 checks over SSH.
+
+The shell sweep works, but each check is 6-40 lines of =ssh_cmd= +
+=validation_pass/fail= + =attribute_issue= boilerplate, the pass/fail counters
+are hand-maintained globals, and the reporting is bespoke. Adding or reading a
+check is heavier than it should be, and growing the suite (archsetup configures
+far more than the 26 checks cover) compounds that weight.
+
+This doc proposes porting the post-install validation to Testinfra (Python +
+pytest) for more expressive checks and better reporting, then growing coverage.
+
+* Decision
+
+Port the post-install validation layer to Testinfra + pytest, reaching parity
+with the existing =validation.sh= sweep, then expand coverage. Recorded
+rationale: the up-front port cost (parity rewrite + a test-only dependency) is
+an accepted trade — the priority is a robust, well-reported, growing validation
+suite over feature speed. The framework swap alone buys ergonomics and
+reporting, not coverage, so it is paired with real new coverage (below).
+
+This replaces the shell sweep; it does not touch archsetup's own install-log
+parsing (that stays as a separate signal). The full coverage expansion (P4)
+lands in this task too, sequenced strictly after the parity cutover so the
+parity verification stays clean.
+
+* Current harness (what exists today)
+
+** Flow (run-test.sh)
+1. Revert VM to base snapshot, boot, wait for SSH.
+2. =capture_pre_install_state=.
+3. Bundle + copy archsetup + dotfiles into the VM, run archsetup in background,
+ poll to completion.
+4. =capture_post_install_state=.
+5. =run_all_validations= (the shell sweep).
+6. =analyze_log_diff= + =generate_issue_report= (issue attribution).
+7. Explicit pass/fail exit code; cleanup.
+
+** The shell sweep (validation.sh)
+~26 checks under =run_all_validations=: user created / shell / groups, dotfiles,
+yay, pacman working, window manager, firewall, DNS, avahi, fail2ban,
+NetworkManager, emacs, git config, dev tools, zfs, boot config, autologin,
+gnome-keyring, terminus font, mkinitcpio hooks, initramfs consolefont, nvme
+module, archsetup log, state markers.
+
+** Issue attribution
+=attribute_issue <msg> <bucket>= sorts each failure into one of three arrays —
+=ARCHSETUP_ISSUES=, =BASE_INSTALL_ISSUES=, =UNKNOWN_ISSUES= — and
+=generate_issue_report= writes them out (base-install issues route to the
+archzfs inbox). This is domain logic Testinfra has no equivalent for; the port
+must preserve it.
+
+** Connection
+=ssh_cmd= uses =sshpass -p "$ROOT_PASSWORD" ssh ... -p "$SSH_PORT" root@$VM_IP=,
+with =VM_IP=localhost=, =SSH_PORT=2222=, =ROOT_PASSWORD=archsetup=.
+
+* Design
+
+** Where Testinfra fits
+Replace the =run_all_validations= call (step 5) with a pytest invocation against
+the running VM. Steps 1-4 and 6-7 are unchanged; =analyze_log_diff= stays.
+Testinfra connects over the same SSH the harness already exposes.
+
+** Connection model
+Testinfra's paramiko/ssh backend targets the live VM via its host spec:
+
+#+begin_src sh
+pytest scripts/testing/tests/ \
+ --hosts="ssh://root@localhost:2222" \
+ --ssh-config=<generated> \
+ --json-report --json-report-file="$TEST_RESULTS_DIR/testinfra.json"
+#+end_src
+
+Password auth: generate a throwaway ssh-config (or reuse sshpass via a
+=--ssh-identity= once archsetup drops the key, but at validation time we only
+have the root password). Simplest: a tiny generated ssh config + sshpass
+wrapper, or switch the test VM to a known test key injected pre-run. Open
+question below.
+
+** Test layout
+#+begin_example
+scripts/testing/tests/
+ conftest.py # host fixture, markers, attribution hook, report glue
+ test_users.py # user created / shell / groups
+ test_dotfiles.py # stow symlinks, readable by user
+ test_packages.py # yay, pacman working, dev tools, key packages
+ test_services.py # firewall, dns, avahi, fail2ban, networkmanager
+ test_boot.py # zfs, mkinitcpio hooks, nvme, consolefont, terminus
+ test_desktop.py # window manager, autologin, gnome-keyring
+ test_archsetup.py # install log, state markers
+ test_hardening.py # NEW: sshd drop-in, sysctl, /etc fstab perms, backups
+#+end_example
+
+** Example tests (parity)
+#+begin_src python
+TEST_USER = os.environ.get("ARCHSETUP_TEST_USER", "archie")
+
+def test_ufw_enabled(host):
+ assert host.service("ufw").is_enabled
+
+def test_primary_user_exists(host):
+ u = host.user(TEST_USER)
+ assert u.exists
+ assert u.shell == "/usr/bin/zsh"
+
+def test_zshrc_stowed_and_readable(host):
+ f = host.file(f"/home/{TEST_USER}/.zshrc")
+ assert f.is_symlink
+ assert ".dotfiles/" in f.linked_to
+ assert f.exists # not broken
+ assert host.run("sudo -u cjennings test -r %s" % f.path).rc == 0
+
+def test_mkinitcpio_systemd_hook(host):
+ # non-ZFS systems delegate fsck from udev to systemd
+ conf = host.file("/etc/mkinitcpio.conf").content_string
+ assert "systemd" in conf
+#+end_src
+
+Compare =test_ufw_enabled= (1 line) to the current =validate_firewall= (8 lines
+of ssh_cmd + branch + counters).
+
+** Preserving issue attribution
+Map the three buckets to pytest markers and collect them in a =conftest.py=
+hook:
+
+#+begin_src python
+@pytest.mark.attribution("archsetup") # or "base_install" / "unknown"
+def test_ufw_enabled(host): ...
+#+end_src
+
+A =pytest_runtest_makereport= hook records each failure under its marker's
+bucket and writes the same three-way report =generate_issue_report= produces
+(base-install failures still route to the archzfs inbox). Default bucket =
+archsetup when unmarked.
+
+** Tiered strategy
+Markers =@pytest.mark.smoke= (user, key packages, dotfiles present) and
+=@pytest.mark.integration= (services, configs, boot). =pytest -m smoke= for a
+fast gate, full run otherwise. Drop the task's original X11/startx end-to-end
+slice — the fleet is Wayland/Hyprland and headless GUI e2e is flaky and
+expensive; a Wayland-session smoke check can be reconsidered later as its own
+task.
+
+** Reporting
+=pytest-json-report= (or junit-xml) → =$TEST_RESULTS_DIR/=, surfaced in the
+test report alongside the install-log analysis. pytest's own per-test
+pass/fail/skip output replaces the hand-maintained counters.
+
+* Coverage
+
+** Parity (port all current checks)
+All ~26 =validation.sh= checks, grouped per the layout above.
+
+** Expansion (new — the coverage win)
+archsetup configures much that isn't validated today. Candidates:
+- sshd hardening drop-in (=/etc/ssh/sshd_config.d/10-hardening.conf=,
+ PermitRootLogin prohibit-password).
+- =backup_system_file= behavior — assert =.archsetup.bak= exists for files
+ archsetup edited in place (fstab, mkinitcpio.conf, sudoers, …).
+- pacman.conf (ParallelDownloads, Color, multilib) and makepkg.conf (MAKEFLAGS,
+ OPTIONS) settings actually applied.
+- systemd-resolved DNS-over-TLS drop-in; NetworkManager wifi-privacy.
+- fail2ban jail.local present; reflector config; sysctl printk; /etc/issue
+ emptied; vconsole font; fstab /efi fmask/dmask perms.
+- sanoid / zfs-replicate units (ZFS hosts).
+
+* Dependencies
+
+Add =python-pytest=, =python-pytest-testinfra= (pulls paramiko), and a JSON
+reporter to =make deps= (test host only — not installed by archsetup itself).
+Note: the existing unit suites run under =python3 -m unittest=; the integration
+layer runs under pytest. Two runners, both Python; =make test-unit= unchanged,
+=make test= gains the pytest step.
+
+* Goss comparison (the task asked)
+
+- *Goss* — YAML-declarative health specs, a single Go binary executed *on the
+ target*. Fast, no Python. But the spec must be pushed into the VM and run
+ there, the assertions are less programmable, and it adds a Go binary to the
+ flow.
+- *Testinfra* — Python, runs *on the host* over SSH (nothing installed in the
+ VM), assertions are full Python with rich built-in modules
+ (File/Package/Service/User/Command), integrates with pytest's tooling.
+
+Choose Testinfra: it runs from the host (the VM stays clean), it's far more
+programmable for the conditional checks archsetup needs (DESKTOP_ENV branches,
+ZFS-vs-not), and it aligns with the repo's existing Python test tooling.
+
+* Migration plan (phased, TDD where the helper logic is ours)
+
+- *P1 — Scaffold.* conftest.py (host fixture + connection), the attribution
+ marker + report hook, and 3 parity checks (firewall, user, dotfiles). Wire a
+ pytest step into run-test.sh behind a flag so the shell sweep still runs.
+- *P2 — Full parity.* Port all ~26 checks; diff a real VM run's results against
+ the shell sweep to confirm no check was lost.
+- *P3 — Cut over.* Make pytest the primary sweep in run-test.sh; keep
+ =analyze_log_diff= and the install-log signal.
+- *P4 — Expand.* Add the new coverage (hardening, backups, applied settings).
+- *P5 — Retire.* Remove =run_all_validations= from validation.sh (keep the
+ capture/analyze helpers that pytest doesn't replace).
+
+* Acceptance criteria
+
+- =make test= runs archsetup in a VM, then a pytest sweep over SSH, and a real
+ run reports parity with (or a superset of) the current shell checks.
+- Failures still sort into archsetup / base-install / unknown, with base-install
+ issues routed to the archzfs inbox as today.
+- =make deps= installs the test dependencies; the VM has nothing extra installed.
+- A documented =pytest -m smoke= fast path exists.
+
+* Resolved decisions (2026-06-25)
+
+1. *Auth at validation time — inject a throwaway test key.* Pre-run, generate
+ an ephemeral keypair, push the pubkey into the VM's
+ =/root/.ssh/authorized_keys= over the existing sshpass channel, and point
+ Testinfra at the private key via a generated ssh-config. No password in the
+ pytest invocation; paramiko key auth just works; the keypair is discarded
+ after the run. (Chosen over wrapping sshpass around Testinfra, which is
+ awkward since Testinfra spawns its own ssh connections.)
+2. *Cut over — run both through parity, then switch.* Keep the shell sweep
+ running alongside pytest through P2 so a real VM run can diff pytest's
+ results against the shell sweep and prove no check was dropped. pytest
+ becomes primary at P3; =run_all_validations= is deleted at P5 after the
+ expanded suite proves out.
+3. *Expansion scope — full, in this task, after cutover.* All of P4 lands here,
+ sequenced strictly after the P3 parity cutover so the parity diff is clean
+ before new checks are added.
diff --git a/docs/design/2026-06-25-zfs-vm-test-coverage.org b/docs/design/2026-06-25-zfs-vm-test-coverage.org
new file mode 100644
index 0000000..d9625e0
--- /dev/null
+++ b/docs/design/2026-06-25-zfs-vm-test-coverage.org
@@ -0,0 +1,139 @@
+#+TITLE: Design: ZFS VM Test Coverage + Bare-Metal Runner Migration
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-25
+#+STATUS: Draft — for review
+
+* Problem
+
+Two gaps, one root:
+
+1. *The ZFS install path is untested in automation.* The VM harness
+ (=make test=) uses a single non-ZFS base image, so every ZFS-conditional
+ check skips (mkinitcpio udev hook on ZFS, sanoid, zfs-scrub timer, the whole
+ ZFS branch of archsetup). ZFS is exercised *only* by =run-test-baremetal.sh=
+ against real hardware.
+
+2. *=run-test-baremetal.sh= is latently broken by the sshd hardening.* It SSHes
+ to the target as root *by password* throughout the run, exactly the pattern
+ archsetup's =PermitRootLogin prohibit-password= (shipped 2026-06-24) kills
+ mid-install. The VM runner already hit and fixed this (=inject_root_key= +
+ key auth, commit f50fc1d); the bare-metal runner never got that fix, so it
+ almost certainly aborts mid-install now, the same way the VM runner did.
+
+The fix for both is the same shape: a ZFS base VM gives a safe, repeatable,
+snapshot-rollback ZFS target (no sacrificial hardware), which both fills the
+coverage gap *and* provides a target to migrate + validate the bare-metal
+runner against. This also unblocks P5 (deleting the dead shell-sweep functions
+from validation.sh), which is gated on the bare-metal runner leaving the shell
+sweep.
+
+* Decision
+
+Build a ZFS base VM via archangel, add a filesystem-profile selector to the VM
+harness so =make test= can target zfs or non-zfs, then migrate
+=run-test-baremetal.sh= to key auth + the Testinfra sweep and validate it
+against the ZFS VM. Finish by deleting the now-dead shell-sweep functions (P5).
+
+Explicitly rejected: loosening =PermitRootLogin= (or adding a skip-hardening
+test flag). That trades a real security feature for harness convenience and
+would mean never validating the hardened config. Key auth is the correct fix,
+already proven in the VM runner.
+
+* Current state (grounded)
+
+- =create-base-vm.sh= boots an =archangel-*.iso=, copies =archsetup-test.conf=
+ into the live env, runs =archangel --config-file /root/archsetup-test.conf=
+ (the base-OS install — partitioning/filesystem live here), powers off, and
+ snapshots =clean-install= onto =vm-images/archsetup-base.qcow2=.
+- =run-test.sh= hardcodes that one image + snapshot, and copies
+ =scripts/testing/archsetup-vm.conf= (DESKTOP_ENV=hyprland, non-ZFS) into the
+ VM as the archsetup config.
+- =run-test-baremetal.sh= takes =--host= / =--password=, SSHes as root by
+ password, rolls back ZFS =@genesis= snapshots, transfers + runs archsetup,
+ then calls =run_all_validations= / =validate_all_services= (overriding
+ =VM_IP= to the target). It is the only remaining caller of the shell sweep.
+- Key auth machinery already exists and is reusable: =inject_root_key= and
+ =SSH_KEY_OPT= in =vm-utils.sh=, and =run_testinfra_validation= in
+ =testinfra.sh= (drives connection from a generated ssh-config keyed on
+ =VM_IP= / =SSH_PORT=).
+
+* Design
+
+** A. ZFS archangel base
+Add a ZFS archangel config (a =archsetup-test-zfs.conf= or equivalent) that
+installs a ZFS root. Confirm archangel supports a ZFS-root config (it's a
+separate project — verify its config options first). Unencrypted ZFS for the
+test VM (skip the passphrase prompt; encryption isn't what we're validating).
+
+** B. Per-profile base images + selector
+- =create-base-vm.sh= takes a profile (e.g. =FS_PROFILE=zfs|ext4=, default
+ current/non-ZFS), picks the matching archangel config, and writes a
+ profile-named image: =vm-images/archsetup-base.qcow2= (default) vs
+ =vm-images/archsetup-base-zfs.qcow2=. Same =clean-install= snapshot name.
+- =run-test.sh= + Makefile take the same =FS_PROFILE= and select the image (via
+ =init_vm_paths=). The archsetup run config (=archsetup-vm.conf=) is *shared* —
+ archsetup auto-detects ZFS from the live root, so no per-profile run config is
+ needed. =make test FS_PROFILE=zfs=.
+
+** C. Bare-metal runner migration
+Mirror the VM runner's fix in =run-test-baremetal.sh=:
+- After the first successful SSH to =TARGET_HOST=, call =inject_root_key= (it
+ authorizes a key over the password session; set =VM_IP=TARGET_HOST=,
+ =SSH_PORT=22= so the helpers + ssh-config target the real host).
+- Replace =run_all_validations= / =validate_all_services= with
+ =run_testinfra_validation= (now authoritative).
+- Everything downstream already routes through =$SSH_KEY_OPT= (the vm-utils
+ helpers) and the ssh-config, so it survives the hardening.
+
+** D. Validate
+- =make test FS_PROFILE=zfs= → the ZFS-conditional pytest checks now *run*
+ (not skip): mkinitcpio uses the udev hook, sanoid installed, zfs-scrub timer,
+ zfs root. Fix any real ZFS-path findings archsetup has.
+- Point =run-test-baremetal.sh= at the ZFS VM (or real hardware) → confirm the
+ key-auth migration carries it through the hardening to a green pytest sweep.
+
+** E. Delete the shell sweep (P5)
+Once both runners use =run_testinfra_validation=, delete the dead functions from
+=validation.sh= (run_all_validations, validate_all_services, the ~26 validate_*
+checks, validate_service*, run_full_validation, validation_pass/fail/warn/skip).
+Keep the live helpers: ssh_cmd, attribute_issue, capture_pre/post_install_state,
+analyze_log_diff, categorize_errors, generate_issue_report, VALIDATION_*.
+
+* Phases
+- *P-A* archangel ZFS config (verify archangel ZFS support first).
+- *P-B* create-base-vm.sh + run-test.sh + Makefile profile selector; build the
+ ZFS base image + snapshot.
+- *P-C* =make test FS_PROFILE=zfs= green (ZFS-conditional tests run; fix
+ findings). VM-validatable here.
+- *P-D* migrate run-test-baremetal.sh to key auth + Testinfra; validate against
+ the ZFS VM.
+- *P-E* delete the dead shell-sweep functions (the standing P5 follow-up).
+
+* Open questions
+1. *Does archangel support a ZFS-root config out of the box?* RESOLVED (yes).
+ ZFS is archangel's *default* filesystem (=FILESYSTEM=zfs=, validated by
+ =installer/lib/config.sh:validate_filesystem=), with =NO_ENCRYPT=yes= for an
+ unattended unencrypted install and a ready =installer/velox-zfs.conf.example=
+ to model. No archangel work needed.
+2. *Two images vs one image + two snapshots?* RESOLVED — two images. ZFS vs
+ btrfs are different on-disk layouts; cleaner than juggling snapshots on one
+ disk. =btrfs= keeps the legacy unsuffixed =archsetup-base.qcow2=; =zfs= gets
+ =archsetup-base-zfs.qcow2=.
+3. *Profile on run-test.sh vs a separate run-test-zfs.sh?* RESOLVED —
+ =FS_PROFILE= env param on the existing runner + Makefile, no duplicate
+ harness.
+4. *Disk size / RAM for the ZFS VM* — start at the 4G RAM / 50G disk defaults;
+ bump =VM_RAM= only if the ZFS install OOMs (decide at P-C build time).
+5. *Should the bare-metal runner stay at all once a ZFS VM exists*, or does the
+ ZFS VM profile make it redundant for everything except real-hardware smoke?
+ Defer until after P-D.
+
+* Design corrections (found during P-A/P-B grounding)
+- The "non-ZFS" base is *btrfs*, not ext4 — =archsetup-test.conf= sets
+ =FILESYSTEM=btrfs=. The profile axis is zfs vs btrfs throughout.
+- *No =archsetup-vm-zfs.conf= is needed.* archsetup reads no filesystem key; it
+ auto-detects ZFS from the live root via =is_zfs_root()= (=findmnt -n -o FSTYPE
+ /=, archsetup:688). The ZFS branch (sanoid, zfs-scrub timer, mkinitcpio udev
+ hook, docker zfs storage driver) fires whenever the running root is ZFS. So
+ only the *archangel* base config and the base *image* differ per profile; the
+ archsetup run config (=archsetup-vm.conf=) is shared.
diff --git a/docs/design/2026-06-29-waybar-network-module-spec.org b/docs/design/2026-06-29-waybar-network-module-spec.org
new file mode 100644
index 0000000..3a1260c
--- /dev/null
+++ b/docs/design/2026-06-29-waybar-network-module-spec.org
@@ -0,0 +1,2094 @@
+#+TITLE: Waybar Network Module — Design Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-29
+
+* Status
+
+*Phases 1-3 SHIPPED* (2026-06-29 → 2026-06-30, dotfiles). The core module is live:
+the =net= engine (=status/probe/list/up/down/add/edit/remove/rescan/diagnose/repair/
+doctor/portal/speedtest=), the =waybar-net= indicator (split-cadence cache, redacted
+event log, display-only airplane absorption per decision 12), and the GTK4
+layer-shell panel (Connections / Diagnose / Repair / Speed test) with the settled bar
+clicks (left = panel, middle = =net portal=, right = =net-fix=; airplane on
+Super+Shift+A). 230+ net tests; full dotfiles suite green. Live-verified on velox.
+
+Built on top since the original spec:
+- *Captive-portal login engine* (2026-06-30, dotfiles =a7d7559=) — =net portal= now
+ runs a native =portal-login= repair tier (drop DoT → recover the portal URL from
+ the redirect → open a throwaway browser profile → auto-restore DoT once online),
+ replacing the old shell-out to =captive= for the force-portal flow. =net portal
+ --restore= is the manual fallback.
+- *Portal UX fixes from live testing* (2026-06-30, dotfiles =eef6b0b=) — removed a
+ polkit-gated =resolvectl flush-caches= that popped an auth dialog (the DoT-drop
+ restart already clears the cache); added an already-online short-circuit so a
+ forced run on a working connection opens nothing; suppressed Chrome's first-run
+ wizard; moved =net portal= off the terminal into the panel status line; hardened
+ the portal-URL extractor against Firefox's detection page.
+- *Panel auto-hide + Close button* (2026-06-30, dotfiles =450b7f0=) — the panel
+ closes on focus-out (popup behavior, suppressed while a child dialog holds focus)
+ and carries a Close button bottom-right.
+
+*V2 redesign in flight* (designed 2026-06-30, not yet built — see todo.org "Network
+panel redesign — no terminals, verify-everything, full failure coverage"). It
+reverses two earlier choices and widens coverage:
+- *No terminals anywhere.* =net-popup= is removed; every action and result renders
+ in the panel. This depends on a passwordless privileged path — a root-owned helper
+ plus a narrow NOPASSWD sudoers rule, archsetup-installed — because an in-panel
+ worker thread has no tty to prompt for a password. Reverses decision 11's
+ "privileged tiers run in a terminal".
+- *New navigation* — top tabs Connections | Diagnostics | Performance. Diagnostics
+ merges Diagnose + Repair (sub-row Diagnose | Get Me Online | Advanced; a shared
+ area below shows diagnose items and streams repair progress; Advanced reveals the
+ individual repair buttons, renamed with tooltips). Speed test lives under
+ Performance.
+- *Verify every action* (each mutating op confirms its effect before reporting
+ success) and *detect + respond to every failure mode* — the full ~44-mode catalog,
+ edge cases included, lives in the redesign task and supersedes the table below.
+
+Phase 4 (docs / rollout) and Phase 5 (VPN) remain. Review incorporated (Codex,
+2026-06-30): four review rounds + Craig's cj comments are all dispositioned
+([40/40], no open findings) — the fourth round reshaped the V2 panel UX (single nav
+target, saved-vs-available groups, join-from-row, the auth matrix, progressive
+loading, a findable diagnostics report, and the Waybar visual contract; see "V2 panel
+UX"). Phases 1-3's manual live checks are under todo.org "Manual testing and
+validation".
+
+* Goal
+
+One waybar network component that does the whole job: shows connection state
+(including the missing "associated but no internet / captive portal" state),
+manages connections from a dropdown (nmcli-backed; secrets stay in
+NetworkManager's own store, no separate credential file), and runs the network
+diagnostics and remediation off the same place
+(captive-portal detection + forcing, bounce/reset, gateway/DNS checks, speed
+test).
+
+It unifies three todo tasks that are really one feature:
+- =[#C]= "archsetup Waybar Wi-Fi module should show no-internet state" — the
+ indicator state plus the 2026-06-22 roam expansion (bounce, diagnostics, speed
+ test off the component).
+- =[#B]= "Network-manager dropdown, nmcli-backed" — the management dropdown. (The
+ todo task's original "GPG-stored secrets" framing is superseded: secrets stay in
+ NM's own store, decision 5.)
+- The network diagnostics already shipped in =captive= (the hotel/captive-portal
+ tool, formerly =login-page=) become this module's diagnostics engine rather
+ than a standalone CLI.
+
+* Scope
+
+** In
+- *Indicator* — wifi/ethernet icon + signal + SSID, plus an internet sub-state:
+ online / captive / no-internet / connecting / disconnected / airplane.
+- *Absorbs the airplane module* — the airplane state + toggle move into
+ =custom/net= (airplane is a network concern). Once this ships, the standalone
+ =custom/airplane= module, the =waybar-airplane= + =airplane-mode= scripts, their
+ =tests/=, and the css are deleted (listed under Files touched). The
+ desktop-settings panel (sibling =[#B]=) no longer needs an airplane row.
+- *Interface-correct* — targets the wifi (or chosen) device, not the
+ default-route interface, so an active USB tether or wired link can't mask
+ wifi state. (Same lesson =captive= fixed; the current =custom/netspeed= keys
+ off the default route and has the bug.)
+- *Connection management (panel)* — list saved connections most-recently-used
+ first, live signal for in-range wifi, click to switch; add / edit / remove for
+ open + WPA-PSK; activate any existing saved profile (including enterprise ones
+ NM already stores); ethernet↔wifi and wifi↔wifi switching even when a link
+ appears mid-session.
+- *Diagnostics (panel)* — read-only Diagnose (captive probe 204-vs-portal with
+ the extracted portal URL, gateway ping, DNS config) separated from mutating
+ Repair. Repair has tiers, lightest first: rfkill-unblock, per-connection reset
+ (fresh MAC), full-stack bounce (=nmcli networking off/on=, then restart
+ NetworkManager if that fails), and the temporary 1.1.1.1 override test. Each
+ Repair action confirms and verifies cleanup.
+- *Speed test (panel)* — down/up/ping with a progress indicator and last-result
+ shown, via the already-installed =speedtest-go --json=.
+- *Connection secrets* — none of our own. Settings and passwords live where NM
+ already keeps them: =/etc/NetworkManager/system-connections/*.nmconnection=
+ (root-only =0600=, the PSK/EAP secret stored inline). We read/write them through
+ nmcli, which handles the privilege. No separate file, no GPG, no gpg-agent — one
+ fewer dependency, and NM's store is already the secure-at-rest source of truth.
+- *Persistence* — connectivity probe result cached in the runtime dir so the
+ bar reads it cheaply between probes.
+- *Observability* — a redacted JSONL event log so a post-failure session can
+ diagnose without re-running destructive actions.
+
+** Out (v1, note for later)
+- No replacement of NetworkManager's connection engine. NM stays the thing that
+ connects; we drive it via nmcli.
+- No add/edit *form* for WPA-Enterprise / 802.1X in v1. The reason is effort vs
+ payoff: 802.1X has many interdependent fields (CA cert, client cert, identity,
+ anonymous identity, phase-2 auth) where a wrong entry silently fails auth, so a
+ trustworthy form is a lot of UI for connections Craig rarely adds (open +
+ WPA-PSK covers home, hotels, and phone hotspots). v1 still *activates* existing
+ saved enterprise profiles and points editing at =nmtui=/=nmcli=. Settled
+ (Craig, 2026-06-29): enterprise add/edit is vNext — 24 saved profiles on velox,
+ 0 enterprise, so the form would be unused UI; if one ever appears nmtui adds it
+ once and the module activates it thereafter.
+- No per-connection captive-portal *auto-login* in v1. (That would mean storing a
+ portal's login form answers — room number, surname, a checkbox — and replaying
+ them automatically when a known portal is detected, so the page never appears.
+ Out for v1 because every portal's form differs and it means storing per-venue
+ answers; v1 just opens the portal for you.)
+- No graphing/history of speed-test results beyond the last run.
+- No static-IP / proxy / metered / MAC-randomization editing in v1 (activate
+ existing, edit elsewhere).
+- No VPN / WireGuard management in v1, but it's a planned later phase (Phase 5),
+ not a permanent exclusion — it folds the existing archsetup wireguard tooling
+ into the same panel/CLI.
+- The desktop-settings dropdown (sibling =[#B]=) is a separate module, but it
+ shares the GTK4 layer-shell panel shell built here.
+
+* Architecture
+
+Three layers. Keep the bar cheap, the panel rich, the logic in one tested place.
+
+1. *Engine* — a =net= Python package (src-layout, unittest), exposing a CLI. Wraps
+ every nmcli op and owns the diagnostics. Emits JSON. This is the testable
+ core (fake =nmcli= / =curl= / =speedtest-go= on PATH, like the existing
+ =waybar-netspeed= and =waybar-sysmon= test harnesses). Precedent: pocketbook is
+ Python in the dotfiles repo; =wtimer= is Python for the same testability
+ reason.
+2. *Indicator* — a thin =waybar-net= script that calls =net status --json= and
+ renders icon + signal + state + tooltip. Replaces =custom/netspeed=
+ (throughput folds into the tooltip).
+3. *Panel* — a GTK4 + gtk4-layer-shell app (mirrors pocketbook's structure)
+ that imports the engine. Hosts connection management, diagnostics, and the
+ speed test.
+
+How the existing pieces map in:
+- =captive= (bash, shipped) — its cheap portal-detection logic is mirrored natively
+ in the engine for the fast status path so the bar never blocks on a subprocess,
+ and it still exposes a =--probe-json= mode the engine reuses. *As built (2026-06-30):
+ the force-portal flow is now native too* — =repair.py='s =portal-login= tier does
+ the DoT drop, portal-URL recovery, clean-browser launch, and auto-restore in
+ Python, so =net portal= no longer shells out to =captive= for it. =captive= stays a
+ usable standalone CLI.
+- =waybar-netspeed= (sh, shipped) — retired; its throughput sampling moves into
+ the engine's status output and renders in the indicator tooltip only.
+- =nmcli= — the connection backend for every op.
+
+Language note: the engine is Python; the indicator is a thin Python or sh
+wrapper over =net status --json=. The bar path must stay fast (see Performance
+budgets), so the indicator does no network I/O itself — it reads link state and
+the cached connectivity result.
+
+Privileged-path model (v2, planned): repairs that need root (rfkill unblock, nmcli
+modify/up, networking off/on, =systemctl restart NetworkManager/systemd-resolved=,
+resolvectl dns/revert, the DoT toggle) go through a single root-owned helper
+installed by archsetup, with a narrow NOPASSWD sudoers rule scoped to that helper
+only (never a blanket =mv=/=systemctl= rule). =repair.py= calls =sudo <helper>
+<verb>=. This is what lets every action run in-panel with no terminal: a GTK worker
+thread has no tty, so without a passwordless path it can't prompt. It also fixes a
+latent bug in the shipped portal flow — the detached DoT-restore watcher runs with
+no tty and silently fails to restore encrypted DNS when sudo creds aren't cached.
+
+* Repository + dependencies
+
+- *Code lives in the dotfiles repo* (=~/.dotfiles=), not archsetup. The =net=
+ package sits in-tree like pocketbook (src-layout, unittest, Makefile target);
+ =waybar-net= and the =net= CLI entry live in the hyprland tier
+ (=hyprland/.local/bin/=). Tests under =tests/net/= and =tests/waybar-net/=.
+ archsetup owns only the *dependency install*, not the code.
+- *archsetup installs the deps* in its Hyprland step: =gtk4-layer-shell=,
+ =python-gobject=, plus =nmcli=/=curl=/=resolvectl=/=rfkill= (already present via
+ NetworkManager/curl/systemd/util-linux). Speed test uses =speedtest-go= (AUR
+ =speedtest-go-bin=, already installed on velox); archsetup adds it to the AUR
+ list. librespeed-cli is the documented fallback if a self-hosted LibreSpeed
+ server is ever wanted. No =gpg= dependency (secrets live in NM's own store).
+- *Daily-drivers*: a stowed-script + AUR-dep feature, so ratio needs the same
+ =git pull= + stow + the archsetup-added deps. Note the manual dep step in the
+ rollout.
+
+** Makefile targets (console recovery is a first-class path)
+=net doctor= and the diagnostics are reachable from a bare TTY when waybar and
+the GUI are down — that's the case where you most need them. The dotfiles
+Makefile carries targets that wrap the =net= CLI so "get back online" is one make
+command from the console:
+- =make online= — =net doctor --fix= (diagnose, then apply the lightest repair:
+ rfkill-unblock → reset → bounce → open portal). The headline recovery target.
+- =make net-doctor= — =net doctor= (read-only diagnose + recommendation).
+- =make net-status= / =make net-diagnose= / =make net-portal= / =make net-reset=
+ / =make net-bounce= — the individual ops.
+- =make test= — already runs =tests/*=; the =net= package's unittest suites are
+ collected the same way.
+These intentionally need only nmcli/curl/rfkill (no GUI, no waybar, no Python
+GTK), so they work from a TTY on a broken graphical session.
+
+* Connectivity model — split cadence
+
+The indicator polls every ~2s, but a real internet/captive probe every 2s wastes
+battery and can re-trigger a captive portal. So split it:
+
+- *Fast path (every poll, cheap, no network)* — interface, type, SSID, signal,
+ IPv4 presence, throughput sample. From nmcli / sysfs only. No network I/O.
+- *Slow path (cached, TTL ~45s)* — the actual internet/captive probe (the 204
+ check + meta-refresh portal extraction). Result cached at
+ =$XDG_RUNTIME_DIR/waybar/net-connectivity.json= with a timestamp.
+
+The indicator reads the cache each poll. When the cache is older than the TTL,
+=net status= kicks =net probe= in the background (spawn + detach, never awaited)
+and renders the last cached sub-state meanwhile. A user-triggered
+diagnose/reconnect refreshes the cache immediately. This keeps the bar
+responsive and the portal un-poked.
+
+** Concurrency, atomicity, staleness
+- *Single-flight* — =net probe= takes a lock file at
+ =$XDG_RUNTIME_DIR/waybar/net-probe.lock= (flock, non-blocking). A second probe
+ while one runs is a no-op, so a flapping 2s poll can't pile up overlapping
+ probes.
+- *Atomic writes* — the cache is written to a temp file + =os.replace= (atomic
+ rename), so a reader never sees a half-written cache. Same pattern as =wtimer=.
+- *Max probe runtime* — the probe has a hard timeout (≤ 6s total: curl
+ =--max-time 5= + slack). On timeout it writes an =unknown= result, never hangs.
+- *Stale classes* the indicator distinguishes: fresh (< TTL), stale (TTL..3×TTL,
+ shown with a subdued/aging hint), expired (> 3×TTL → treat as unknown),
+ unknown (no cache / probe failed). The bar never shows a confident "online"
+ past the expired threshold.
+- *Invalidation* — the cache records the iface + SSID + active-connection UUID it
+ was taken under; a change in any of them invalidates it immediately (a
+ reconnect must not show the old network's verdict).
+- *Crash cleanup* — a stale lock older than the max runtime is ignored/reclaimed.
+
+* Performance budgets (hot path)
+
+The bar exec path (=waybar-net= → =net status=) must stay responsive:
+- *Budget*: =net status= returns in < 100ms typical, < 250ms worst case.
+- *No sleeping in the bar path.* Throughput is sampled from two reads of
+ =/sys/class/net/<iface>/statistics/{rx,tx}_bytes= across the *waybar poll
+ interval itself* (delta since the last cached sample + timestamp), not via an
+ in-process =sleep= like the old =waybar-netspeed=. The cache holds the prior
+ counters.
+- *Subprocess cap*: at most one =nmcli= invocation on the hot path (a single
+ =nmcli -t -f ...= multi-field query), plus sysfs reads. Never a per-field
+ nmcli call.
+- *Every subprocess has a timeout* (=nmcli --wait 2=, =subprocess timeout=). On
+ timeout or error the indicator emits a degraded JSON state (class
+ =net-degraded=, a neutral glyph) rather than blocking or crashing waybar.
+- *Benchmark test*: a fake slow =nmcli= asserts =net status= still returns within
+ budget by falling back to the degraded state.
+
+* Engine — =net= CLI surface
+
+All subcommands take =--json= where a machine reads them. Pure formatting/state
+functions under the CLI; IO (nmcli, curl, file) at the edges. Every subcommand
+exits non-zero with a JSON error envelope (see JSON schemas) on failure.
+
+- =net status [--json] [--iface IF]= — fast link state + cached connectivity
+ sub-state + throughput. The indicator's source. Never does network I/O.
+- =net probe [--iface IF]= — run the connectivity/captive probe now, update the
+ cache (single-flight, atomic), print online | captive (+ portal URL) |
+ no-internet | unknown. Mirrors =captive='s cheap detection natively.
+- =net list [--json]= — saved connections, MRU order, active flag, plus in-range
+ wifi with signal.
+- =net up <uuid>= / =net down [--iface IF]= — switch / disconnect. Operates on
+ UUID, not name (see nmcli contract).
+- =net add= / =net edit <uuid>= / =net remove <uuid>= — manage connections
+ (open + WPA-PSK) through nmcli; the secret lands in NM's own
+ =.nmconnection=. Enterprise profiles are activate-only.
+- =net rescan [--iface IF]= — wifi rescan.
+- =net diagnose [--json]= — read-only report: gateway ping, DNS config, captive
+ probe. The structured contract below. Doubles as the post-failure snapshot.
+- =net repair <action> [--json]= — mutating remediation, lightest first:
+ =rfkill= (unblock + radio on), =reset= (fresh MAC), =bounce= (full-stack:
+ =nmcli networking off/on=, escalating to =systemctl restart NetworkManager=),
+ =dns-test= (temporary 1.1.1.1 override, auto-reverted). Each confirms via the
+ caller and verifies cleanup.
+- =net doctor [--json] [--fix]= — one-shot "get me online" mode for the console:
+ runs the full diagnose, then applies the lightest repair that fits (unblock
+ rfkill, reset, bounce, open portal) — read-only without =--fix=, acting with
+ it. The TTY recovery path when waybar/the GUI is down (see the Makefile
+ targets).
+- =net portal [--restore]= — the native captive-login flow (=repair.py= =portal-login=
+ tier): short-circuits if already online, else drops DoT to plain DNS, recovers the
+ portal URL from the redirect, opens it in a throwaway browser profile, and spawns a
+ detached watcher that restores DoT once online. =--restore= forces the restore now.
+- =net speedtest [--json]= — =speedtest-go --json= run; down/up/ping.
+
+* nmcli contract
+
+The command wrapper is the reliability boundary; SSIDs and connection names
+contain spaces, colons, duplicates, hidden names, and non-ASCII. Rules:
+
+- *Terse, field-selected output*: =nmcli -t -f <fields> --escape yes ...= and
+ =nmcli -g <fields> ...= (get-values) for single-value reads. Parse with the
+ documented escaping (=\:= and =\\=); never naive =cut -d:=.
+- *UUID is the handle.* Every saved-profile op (=up=, =down=, =modify=, =delete=)
+ uses the connection UUID, never the display name — names duplicate and contain
+ separators. =net list= surfaces UUIDs; the panel maps row → UUID.
+- *Wait budgets*: activation/deactivation use =nmcli --wait <n>= with an explicit
+ budget (hot-path reads =--wait 2=; activation =--wait 30=). No unbounded waits.
+- *Connectivity*: NM's own =nmcli networking connectivity= can return
+ =none/portal/limited/full/unknown=. Use it as a *cheap hint* on the fast path
+ when present, but the authoritative captive verdict is still our own probe
+ (NM's portal detection is coarser and config-dependent).
+- *Parser tests* (fake nmcli fixtures): escaped colons and backslashes in SSIDs,
+ embedded newlines, duplicate connection names, hidden SSID (empty name),
+ non-ASCII SSID, the wired-appears-mid-session case, and the multi-active case
+ (wifi + tether both up).
+
+* JSON schemas
+
+Versioned (="v": 1=) envelopes so tests lock the contract. Sketches (fields
+nullable unless noted):
+
+- =status=: ={v, iface, type: wifi|ethernet|none, ssid, signal, ipv4,
+ gateway, throughput: {rx_bps, tx_bps}, connectivity: online|captive|no-internet|unknown,
+ connectivity_age_s, connectivity_class: fresh|stale|expired|unknown, state:
+ online|captive|no-internet|connecting|disconnected|airplane|wired|degraded}=.
+- =probe=: ={v, result: online|captive|no-internet|unknown, portal_url, http_code,
+ redirect_host, elapsed_ms, ts}=.
+- =list=: ={v, connections: [{uuid, name, type, active, last_used, signal,
+ in_range, security}]}=.
+- =diagnose=: ={v, steps: [<diagnostic step, see contract>], overall:
+ ok|warn|fail}=.
+- =speedtest=: ={v, down_mbps, up_mbps, ping_ms, server, elapsed_ms, ts}=.
+- error envelope (any command): ={v, error: {code, message, detail, partial:
+ bool}}= with a non-zero exit.
+
+* Diagnostics contract
+
+=net diagnose --json= returns an ordered list of steps. Each step is the unit the
+panel renders and the log records:
+
+- =id= — stable identifier (e.g. =link=, =dhcp=, =gateway-ping=, =dns-config=,
+ =dns-resolve=, =http-probe=, =portal=).
+- =status= — =pending | running | pass | warn | fail | skipped=.
+- =title= — short human label.
+- =evidence= — redacted detail (the value seen), per the redaction rules.
+- =elapsed_ms=.
+- =safety= — =read-only= or =mutating= (diagnose steps are all read-only).
+- =next_action= — what the user/agent should do on warn/fail (e.g. "open portal",
+ "reset connection", "switch network").
+
+Repair actions (=net repair=) carry the same shape but =safety: mutating=, plus a
+=cleanup_verified: bool= field (e.g. the DNS override was reverted) and a
+terminal =cleanup-unverified= status when revert can't be confirmed.
+
+** Diagnose vs Repair (read-only vs mutating)
+The panel separates them visually and behaviorally:
+- *Diagnose* — probe, gateway ping, DNS config read, captive check. No state
+ change, no sudo, runnable freely.
+- *Repair* — reset (fresh MAC, deletes+recreates the NM profile), DNS override
+ test (mutates resolver, auto-reverts), portal force. Each needs an explicit
+ confirm, shows that it's privacy/state-changing, and verifies cleanup. A
+ Repair whose cleanup can't be verified ends in a visible =cleanup-unverified=
+ state, never a silent success.
+
+* Failure states, messages, recovery
+
+Each row below gives the *exact, final* user-facing string (not a template) with
+=<placeholders>= for redacted evidence, plus the evidence field included and the
+next action. The string is canonical: every surface renders the same text, so
+there's one source of truth.
+
+Per-surface rendering of the canonical string:
+- *Indicator* — the matching glyph + CSS class; the string is the tooltip
+ (untruncated).
+- *Notification* (=notify=) — title = "Networking"; body = the failure label on
+ its own line, then the canonical string.
+- *CLI* — the string on stderr; =--json= puts it in =error.message= with the
+ evidence in =error.detail= and a stable =error.code=.
+- *Panel* — the string as the section banner, with the diagnostic step's evidence
+ shown beneath.
+Evidence is always redacted per the redaction rules (SSID/host shown; PSK/EAP/
+portal tokens never).
+
+- *associated, no DHCP* — "Connected to <SSID>, no IP (DHCP failed)" →
+ evidence: SSID, iface → reset / reconnect.
+- *no-internet* — "On <SSID>, no internet (gateway reachable, no route out)" →
+ diagnose / switch network.
+- *captive* — "Captive portal at <host> — login required" → Open portal.
+- *DNS hijack* — "DNS is being redirected (portal)" → Open portal.
+- *DNS broken* — "DNS not resolving (hotel DNS down); 1.1.1.1 works" → use
+ override / report.
+- *HTTP intercepted* — "Traffic is being intercepted before it leaves" → Open
+ portal.
+- *sudo declined* — "Reset needs admin; it was declined — nothing changed" →
+ retry with auth.
+- *command timed out* — "<op> timed out; the system was left unchanged" → retry.
+- *partial mutation* — "<op> partially applied: <what>; rolled back to <state>"
+ → review.
+- *missing speedtest-go* — "speedtest-go not installed" → install hint.
+- *no wifi hardware* (desktop) — wifi rows hidden; ethernet-only view.
+- *wifi rfkill-blocked* — "WiFi is blocked (rfkill)" → unblock. The indicator
+ detects a soft-blocked radio (=rfkill list= shows the radio off though hardware
+ is present) and shows this distinct from disconnected. =net repair rfkill= (and
+ =net doctor --fix= as its first step) runs =rfkill unblock wifi= + =nmcli radio
+ wifi on= and reconnects. This is the framework-laptop case: an out-of-power
+ shutdown sometimes leaves wifi soft-blocked at next boot, and yes — the module
+ recovers it (the rfkill state is the indicator; the rfkill repair / doctor is
+ the one-step fix). A *hard* block (physical switch) is reported as
+ not-recoverable-in-software with that message.
+- *wifi rfkill hard-blocked* — "WiFi is blocked by the hardware switch" →
+ evidence: rfkill hard state → flip the physical switch.
+- *wrong password / missing secret* — "Saved password for <SSID> was rejected" →
+ evidence: SSID, NM auth-failure reason → re-enter the password.
+- *enterprise auth/cert failure* — "Enterprise login failed for <SSID> (802.1X)"
+ → evidence: SSID, EAP failure reason → edit the profile in nmtui/nmcli.
+- *upstream / AP / provider* — "On <SSID>, link is fine but the network has no
+ uplink" → evidence: gateway reachable, no route out, not a portal → switch
+ network or contact the venue.
+- *VPN-routed* — "Connected; internet is routed through a VPN (<dev>)" →
+ evidence: default route on a tun/wg device or non-NM DNS owner → check the VPN,
+ not WiFi.
+- *HTTP interception, no parseable portal URL* — "A portal is intercepting
+ traffic but didn't give a login link" → evidence: HTTP code, redirect host →
+ opens neverssl + the gateway page to log in manually.
+- *DNS override cleanup unverified* — "Couldn't confirm DNS was restored after the
+ test" → evidence: iface, attempted revert → revert DNS manually
+ (=resolvectl revert <iface>=).
+- *VPN kill-switch blocking* — "A VPN kill-switch is blocking all traffic, and the
+ VPN itself is down" → evidence: a block artifact present with no tunnel up → bring
+ the VPN back, or clear the kill-switch (the exact root command surfaced, not
+ auto-run).
+
+*VPN kill-switch detection + correction.* A kill-switch blocks all non-VPN egress when
+the tunnel drops, so the link looks up (wifi, IP, gateway) but nothing reaches the
+internet. This extends the =deferred-vpn= branch: when a VPN is active and the probe
+fails, run a rootless cascade to tell a working tunnel from a kill-switch that's
+blocking because the tunnel is down —
+- =ip rule= for wg-quick's =not fwmark 0xca6c= + =suppress_prefixlength 0= (and the
+ PostUp =REJECT ! -o %i= rule that makes it leak-proof);
+- =wg show= for an up tunnel interface;
+- =nmcli connection show= for Proton's =pvpn-killswitch= / =pvpn-ipv6leak-protection=
+ (device =pvpnksintrf0=);
+- =nft list ruleset= / =iptables -S OUTPUT= for a drop/reject table (=killswitch=,
+ =protonvpn=, =oifname != "wg0" ... drop=);
+- =nmcli -f connection.zone= for a firewalld =drop= zone.
+Classify *kill-switch-blocking* only when a block artifact exists AND no tunnel
+interface is up — that's what distinguishes it from a healthy VPN. Correction is tiered
+by artifact and every option needs root, so surface the exact command rather than
+auto-running it: =wg-quick down <iface>=, =nmcli connection delete pvpn-killswitch
+pvpn-ipv6leak-protection=, =nft delete table inet killswitch=, or =nmcli connection
+modify <con> connection.zone ''=. (Sits alongside the Phase 5 VPN work; detection can
+land earlier since =deferred-vpn= already exists.)
+
+Each message names whether the system was left unchanged, partially changed (with
+what), or fully changed, so the user knows the residue.
+
+* Doctor: escalation, classification, terminal states
+
+=net doctor= diagnoses, classifies the failure, then (with =--fix=) applies the
+*lightest* repair that fits and re-checks — it never loops destructive repairs
+against a failure they can't fix. Each failure resolves to one of four outcomes,
+and the doctor stops at any terminal one:
+
+- =fixable= — a local repair should help. Escalate lightest-first: rfkill-unblock
+ → reset (fresh MAC) → bounce (full stack) → portal, re-probing after each, and
+ stop as soon as the probe returns online.
+- =needs-user-action= (terminal) — no reset/bounce will help; doctor stops and
+ names the exact next step. Covers: wrong WPA password / missing NM secret
+ (enter the password), locked keyring or polkit denial (retry with auth),
+ enterprise 802.1X cert/identity failure (edit the profile in =nmtui=/=nmcli=),
+ captive portal login-required (open the portal + accept terms). Doctor must not
+ delete/recreate the profile against these — that loses the saved password and
+ makes things worse.
+- =upstream-not-local= (terminal) — the local link is up but the problem is past
+ it: AP has no uplink, gateway down/dropping traffic, DHCP server broken, ISP
+ outage, portal backend failing. =diagnose= proves it (link up + IP + gateway
+ reachable, but no route out and not a captive redirect), and =doctor --fix=
+ stops after local repairs are exhausted with "local repairs tried; likely
+ upstream/AP/provider" + the evidence. Next action: switch networks or contact
+ the venue.
+- =deferred/vpn= (terminal for v1) — an active VPN / policy route / non-NM
+ resolver owns the default route or DNS, so "no internet" may be the VPN's fault,
+ not WiFi's. v1 *detects* this (default route on a =tun/wg= device, or DNS owned
+ by something other than the NM link) and classifies it separately — "link is
+ fine; internet is VPN-routed" — rather than misclassifying it as a WiFi failure.
+ v1 does not repair it (VPN management is Phase 5); it names the VPN as the likely
+ owner and stops.
+
+** DNS handling in doctor (explicit per class)
+- *Captive DNS hijack* — open the portal (the hijack clears on login). No DNS
+ mutation.
+- *Broken resolver, 1.1.1.1 works* — the shipped =dns-test= repair is *diagnostic*:
+ it sets 1.1.1.1, confirms the venue resolver is the culprit, then auto-reverts
+ (=cleanup_verified=). Because it reverts, =doctor --fix= does not currently leave
+ you online in this case — it falls through to =upstream-not-local=, which
+ misreports a locally-fixable problem. *V2 fix (planned):* on a dns-test *pass*
+ (public DNS works), set a PERSISTENT resolver override and verify online, with an
+ offered revert — and classify it as its own outcome rather than upstream.
+- *Port-53 / egress blocked* (even 1.1.1.1 fails) — terminal =upstream-not-local=;
+ doctor stops, since it's not locally fixable.
+
+* Failure-mode coverage
+
+*V2 note (2026-06-30):* the authoritative, exhaustive catalog (~44 modes across 10
+connectivity layers, edge cases included, each tagged fix-and-verify or report-text)
+now lives in the redesign task (todo.org "Network panel redesign"). The table below is
+the v1 baseline; two rows reflect intent the shipped code doesn't yet match, and the
+v2 catalog closes them: =gateway unreachable= claims a bounce that doctor never
+actually reaches (a no-route failure goes straight to =upstream-not-local=), and
+=broken DNS, 1.1.1.1 works= auto-reverts so the user is left offline and misreported
+as upstream (the v2 persistent-override fix closes this).
+
+For each common field failure: does =net diagnose= detect it, can =net doctor
+--fix= repair it, and what terminal user action remains when it can't. (The
+=needs-user-action= / =upstream-not-local= / =deferred/vpn= outcomes are defined
+above.)
+
+| Failure mode | diagnose detects | doctor --fix | terminal user action |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| rfkill soft block | yes | yes (unblock) | none |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| rfkill hard block | yes | no | flip the physical switch |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| no wifi hardware | yes | n/a | use ethernet |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| associated, no DHCP | yes | yes (reset/bounce) | none, else switch network |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| gateway unreachable | yes | yes (bounce) | switch network if it |
+| | | | persists |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| captive DNS hijack | yes | opens portal | log in at the portal |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| broken DNS, 1.1.1.1 works | yes | yes (temp override, | report the venue's DNS |
+| | | auto-reverted) | |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| HTTP captive portal | yes | opens portal | log in at the portal |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| HTTP interception, no | yes | opens neverssl + gateway | log in manually |
+| parseable URL | | | |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| upstream / AP outage | yes (link up, no route out) | no (stops after local) | switch network / contact |
+| | | | venue |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| wrong WPA password / | yes | no | enter the password |
+| missing secret | | | |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| enterprise auth / cert | yes | no | edit the profile in |
+| failure | | | nmtui/nmcli |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| duplicate SSID / | yes (UUID-keyed) | yes (activate by UUID) | none |
+| connection-name | | | |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| hidden SSID | yes | yes (connect by name) | enter SSID + password |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| multiple active links | yes | n/a | pick the interface |
+| (wifi+tether) | | | |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| wedged NetworkManager | yes | yes (bounce → restart NM) | none, else reboot |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| slow / hung command | yes (degraded) | retries within budget | retry |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| stale / corrupt cache | yes | self-heals (atomic + | none |
+| | | invalidation) | |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| DNS cleanup failure | yes | flags cleanup-unverified | revert DNS manually |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| missing speedtest backend | yes | n/a | install speedtest-go |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+| VPN / policy-routing | yes (route/DNS ownership) | no (deferred to Phase 5) | check the VPN |
+| interference | | | |
+|----------------------------+-----------------------------+-----------------------------+-----------------------------|
+
+* Observability — logging + redaction
+
+- *Event log*: JSONL at =$XDG_STATE_HOME/net/events.jsonl= (fallback
+ =~/.local/state/net/events.jsonl=), size-rotated (e.g. 1 MB × 3). Every
+ mutating op and probe appends an event: =ts, op, argv (redacted), exit_code,
+ stderr_tail, elapsed_ms, iface, nm_uuid, probe_url_class, http_code,
+ redirect_host, cache_event=.
+- *Redaction (always on)*: PSKs, EAP identities/passwords, NM secrets, and
+ portal query tokens are never logged. MAC addresses, full IPs, and SSID are
+ redacted when configured (=redact_mac=, =redact_ip=, =redact_ssid= in config).
+- *Post-failure diagnosis*: =net doctor --json= is the snapshot + recommendation
+ (diagnose plus the suggested repair), =net diagnose --json= the raw report, and
+ the event log the history. =net doctor= is the console-recoverable entry point
+ (reachable as =make online= / =make net-doctor=).
+- *Secret-leak tests*: assert no PSK/EAP/portal-token ever appears in any JSON
+ output, log line, or error message.
+
+** Automatic diagnostic verbose-capture (V2)
+
+A distinct layer from the event log above: that log records what =net= did;
+this captures what the *underlying stack* did at debug verbosity during a run, so a
+failed diagnosis leaves real ground-truth instead of relying on memory. Two triggers,
+one mechanism:
+
+- *Automatic — on a failing diagnose.* When =net diagnose= ends =overall: fail=, the
+ next escalation (or =Get Me Online=) runs inside a verbose-capture session.
+- *Manual — a debug on/off toggle in the panel's Advanced section.* "Debug on"
+ elevates and leaves it elevated (with a visible "debug capturing" indicator) so the
+ user can reproduce an intermittent problem over time; "Debug off" restores and
+ writes the bundle. Useful when the failure doesn't reproduce inside one diagnose.
+
+Mechanism (shared):
+1. *Snapshot* the current log levels (=nmcli general logging=, resolved's level,
+ wpa_supplicant's).
+2. *Elevate* the relevant components to debug at runtime, no restarts, scoped to the
+ domains that matter (NM: =WIFI,DHCP,DNS,CORE=; resolved; wpa_supplicant).
+3. *Run* the diagnostics / repair.
+4. *Capture the window*: =journalctl= for NetworkManager + systemd-resolved +
+ wpa_supplicant since the run started, a =dmesg= tail (driver / firmware / rfkill),
+ and any =curl -v= probe output.
+5. *Restore* every level to its snapshot.
+6. *Write a redacted support bundle* to =$XDG_STATE_HOME/net/bundles/<ts>/= and
+ surface it in the panel.
+
+Hard requirements:
+- *Restore is guaranteed and idempotent.* A =try/finally= restores even on error,
+ and a crash-recovery guard detects "a prior run left NM/resolved/wpa_supplicant
+ elevated" on the next run and puts it back — the same shape as the DoT-restore
+ watcher. A crash must never strand the stack at debug verbosity.
+- *Redaction before anything leaves.* Raw wpa_supplicant and NM debug logs carry the
+ PSK and EAP credentials in cleartext. The captured journal is scrubbed before the
+ bundle is written, shown, or shared; the secret-leak test asserts no passphrase or
+ EAP secret survives into a bundle.
+- *Privilege via the V2 sudo-helper.* The log-level toggles need root, so they become
+ verbs on the passwordless helper (decision 16) — no extra prompt.
+
+Bonus — this closes a real detection gap, not just observability: the spec notes live
+auth-failure detection is a v1 limit (it leans on a one-shot NM state-120 snapshot).
+wpa_supplicant at debug during the run is exactly how a wrong-password or EAP failure
+is caught properly, so the capture feeds back into classification.
+
+* Indicator (task #C — Phase 1, the fast win)
+
+** States (internet sub-state on top of link state)
+- online — associated and the probe returned 204. Normal icon.
+- captive — associated, probe hit a portal. Distinct glyph + warning CSS class;
+ tooltip names the portal host; left-click opens diagnostics with the portal
+ ready to open (Phase 2+; see interactions for the Phase-1 interim).
+- no-internet — associated, probe failed (no portal, no 204). Distinct glyph +
+ warning class.
+- degraded — =net status= couldn't read link state within budget (slow/failed
+ nmcli). Neutral glyph, =net-degraded= class. Never blocks the bar.
+- rfkill-blocked — the radio is soft-blocked (=rfkill=), distinct from
+ disconnected. Distinct glyph; the fix is =net repair rfkill= / =net doctor=.
+- connecting / disconnected / airplane / wired — as today, plus wired shown
+ correctly even when it appears after session start. (airplane is now this
+ module's state, absorbed from the retired airplane module.)
+
+** Glyphs
+Nerd-font codepoints, final values verified live before merge (same discipline
+as wtimer). Reuse the signal-strength ramp already in =waybar-netspeed=; add a
+captive / no-internet / degraded overlay glyph.
+
+** Tooltip
+SSID + signal + IPv4 + gateway + the throughput readout (absorbed from
+netspeed) + the last probe result and its age (stale/expired hinted).
+
+** Interactions (no keyboard-modifier clicks — waybar can't qualify clicks by
+modifier, so the rich actions live in the panel, not ctrl/super-click)
+Clicks never block the bar: each dispatches a detached background job, single-flight
+per action. *As built (settled live with Craig, 2026-06-29):*
+- *left* — =net-panel= toggle (pkill-or-launch the GTK panel).
+- *middle* — =net portal= (the captive-login flow).
+- *right* — =net-fix= (=net doctor= with =--notify=: reports the result when the
+ outcome is one-way, opens a terminal only when it's fixable; the v2 redesign moves
+ even that into the panel).
+- airplane toggle moved off the bar to Super+Shift+A.
+
+* Panel (tasks #B + #C diagnostics — Phases 2-3)
+
+GTK4 + gtk4-layer-shell, pocketbook scaffold (src-layout package, unittest,
+Makefile, gtk4-layer-shell anchored dropdown under the bar). One panel shell,
+reused by the future desktop-settings panel.
+
+Sections as built (Phases 1-3, a four-page stack switcher):
+1. *Connections* — list, MRU-first, active marked, live signal bars for in-range
+ wifi; row click switches; buttons for add / edit / remove; a rescan control.
+2. *Diagnose* (read-only) — Probe (204/captive, shows portal URL + Open), Gateway
+ ping, DNS config. Streaming step output (the diagnostics contract).
+3. *Repair* (mutating, confirmed) — tiered lightest-first: Unblock rfkill, Reset
+ (fresh MAC), Bounce (full stack), DNS override test, Force portal. A "Get me
+ online" button runs =net doctor --fix= (the auto-escalating sequence).
+4. *Speed test* — Run button, progress, down/up/ping result + last-run line.
+As built, the panel also auto-hides on focus-out (popup behavior, suppressed while a
+child dialog holds focus) and carries a Close button bottom-right (2026-06-30).
+
+*V2 nav (planned):* three top tabs — Connections | Diagnostics | Performance.
+Diagnostics merges the Diagnose and Repair pages into one: a sub-row
+=Diagnose= | =Get Me Online= | =Advanced= over a shared area that shows diagnose
+items and streams repair progress in-panel (no terminal). =Advanced= reveals the
+individual repair tiers (renamed, with tooltips) plus a *Debug capture on/off*
+toggle (the manual side of the verbose-capture feature; a failing diagnose triggers
+it automatically). Speed test moves under Performance.
+
+** V2 panel UX — the target design
+
+The shipped four-page stack (Connections / Diagnose / Repair / Speed test) is
+*history*, not active design. V2 is the sole current target: one panel opened from
+the bar, three top tabs — Connections | Diagnostics | Performance — and the page
+model below is the contract for what gets built and what gets deleted, not just for
+labels.
+
+*** Connections — saved vs available, join-from-row
+Three labelled groups, never one merged list:
+- *Saved* — saved NM profiles, MRU-first, rendered instantly without a scan.
+- *Available now* — scan-backed in-range SSIDs with signal + security; may carry a
+ loading/stale hint; unsaved networks appear here.
+- *Wired* — ethernet when a wired device is present.
+=net list= already yields this (=connections.py= lists saved MRU-first, merges live
+signal/security for in-range saved profiles, then appends unsaved in-range SSIDs with
+=uuid: nil=); the panel groups and labels it. *Rescan refreshes only the
+availability/signal layer* — it never gates or reloads the Saved list.
+
+*Progressive loading:* render the Saved group immediately on open, then overlay
+availability, signal, and the unsaved Available-now rows when the scan returns. Show a
+small scan-in-progress state (elapsed + last-scan age). A slow or bad radio scan must
+not make the whole panel feel stuck — this is the direct answer to "why does it take
+so long to see my connections?"
+
+*Join-from-row (no Add page):* selecting an unsaved Available-now row *is* the join
+flow — SSID and security come prefilled from the scan, never retyped. Open networks
+connect (confirm only if needed); WPA/WPA2/WPA3-Personal ask only for the password.
+The standalone Add button + modal are deleted for visible networks. A hidden/manual
+SSID join lives behind an Advanced "Join hidden network" affordance.
+
+*** Supported authentication classes (the join matrix)
+From the scanned NM =SECURITY= value, V2 handles:
+- *Inline-supported* — open, open-with-captive-portal, WPA/WPA2/WPA3-Personal
+ (PSK/SAE), and WPA2/WPA3 transition mode. The row shows the security label so the
+ user knows why a password is or isn't asked.
+- *Activate-only* — 802.1X / enterprise: connect if already saved, else "edit in
+ nmtui/nmcli" (no add form in v1/V2, per decision 9).
+- *Hidden / manual* — behind the Advanced "Join hidden network" affordance.
+- *Rare / unsupported* — WEP, OWE/enhanced-open, MAC-registration, voucher, or
+ proxy-required: a clear in-panel explanation ("not supported here yet") plus a
+ non-terminal next step, never a hand-off to a terminal tool.
+
+*** Diagnostics owns the diagnostic story
+Diagnostics holds the read-only checks, the repair stream, Get Me Online, debug
+capture (Advanced), and the doctor report. A *lightweight* latency/throughput probe
+runs inline as a Diagnose evidence row when internet is available (skipped offline, on
+a metered/hotspot warning, or with no backend), and its result is stored in the doctor
+report. The *full* speed test stays under Performance (decision 19) — which is also
+the home for future throughput history, so Performance earns its tab rather than being
+a lone button.
+
+*** Forget confirmation — future tense + verified
+The destructive copy is future tense and names the scope: "This will remove the saved
+NetworkManager profile and its stored password from this machine." After the op,
+verify the UUID is gone, refresh the Saved list, and report "Forgot <SSID>" or "Could
+not forget <SSID>; nothing changed / partial <evidence>" — the verify-every-action
+decision applied to a destructive op.
+
+*** Findable diagnostics report
+Every diagnose, repair, and speed/performance run ends with a "Copy report" / "Open
+report" action in Diagnostics. The report carries the step statuses + elapsed, the
+final classification, the last speed/latency result when available, scan age,
+route/interface owner, the redacted event-log tail, and the bundle path when verbose
+capture ran. It states explicitly whether any repair mutated state and whether
+cleanup/verification passed. "Logs exist somewhere" isn't enough when the network is
+already down — the report is the one artifact the user copies to hand over.
+
+*** Visual contract — a Waybar-attached popup
+The panel reads as part of the bar, not a separate app. Match the live Waybar theme:
+the dark rounded capsule (=border-radius: 1rem=), the golden border, compact monospace
+text, and the =custom/net= state colors. Avoid square corners next to rounded UI, keep
+cards out of cards, and use compact icon+label controls with tooltips for the advanced
+repairs. Reuse any existing archsetup-owned GTK/panel conventions. (Non-blocking for
+engine work; blocks final V2 UX acceptance.)
+
+** Panel state, cancellation, permissions
+State machines for: connection-list loading, rescan-in-progress,
+activation-in-progress, diagnose-running, repair-running, speedtest-running. Plus
+the real terminal states on this two-machine fleet: no-wifi-hardware (desktop →
+ethernet-only view) and missing speedtest-go. (No GPG-key state — there's no
+credential store; secrets live in NM.) ("No NetworkManager" is not a modeled
+state — NM is always present
+on these machines; if nmcli is somehow absent the panel shows a single hard-error
+and exits.) Long operations show elapsed time and are cancellable where the
+underlying op allows (rescan, speedtest, probe); clearly non-cancellable ones
+(an in-flight activation) show elapsed + a disabled control. Permission-denied
+(sudo/polkit declined) is a first-class outcome with the "nothing changed"
+message, never a silent failure.
+
+Interaction-pattern catalog (=~/code/rulesets/patterns/=) principles that apply:
+- transient-state-buttons — all the network levers in one place, reachable by
+ one chord (the bar click), state visible.
+- default-most-common-friction-proportional — connections MRU-ordered so the
+ common pick is first; destructive ops (remove) and privacy-changing ones
+ (reset, override) get a confirm, switching does not.
+- one-prompt-picker-typed-prefix — if the connection picker ever goes
+ keyboard-driven, kind (wifi/eth/saved/in-range) + name in one typed picker.
+
+** Panel UX flow (settle before Phase 2)
+The concrete interaction defaults, so the GTK build isn't inventing them:
+- *Default focus*: the Connections section, current connection's row selected. If
+ the indicator opened the panel because of a captive/no-internet state, focus
+ Diagnose instead with the relevant action highlighted.
+- *Row content*: glyph (signal bars / wired / active check) + name + a secondary
+ line (security type, "active"/last-used). The active row is visually pinned at
+ top of its group.
+- *Buttons*: one *primary* per section (Connections: Connect to the selected row;
+ Diagnose: Run diagnose; Repair: "Get me online"; Speed test: Run). Secondary
+ actions (add / edit / remove / rescan; individual repair tiers) are smaller and
+ grouped.
+- *Disabled rules*: Connect disabled on the already-active row; Repair tiers
+ disabled while one runs; Speed test disabled while running; add/edit disabled
+ for enterprise (with the "edit in nmtui/nmcli" hint).
+- *Confirmations* (exact wording): Reset → "Reset <SSID>? This drops the
+ connection and reconnects with a new MAC."; Bounce → "Restart networking? All
+ links drop briefly."; DNS override → "Temporarily set DNS to 1.1.1.1 for the
+ test? It reverts automatically."; Remove → "Forget <SSID>? The saved password is
+ deleted."
+- *"Get me online" reporting*: shows each escalation step live (Unblock rfkill →
+ Reset → Bounce → Portal) with per-step pass/fail and stops at the first that
+ restores internet or at a terminal state, naming the next action.
+- *After close*: the bar reflects the new state immediately (signal/refresh on
+ next poll); a running speedtest/diagnose keeps running and notifies on finish
+ (panel close doesn't cancel it).
+- *Keyboard*: Esc closes (wired); arrows move row focus and Enter activates a
+ row (GTK ListBox defaults — row-activate connects, never disconnects); Tab is
+ the plain GTK focus chain, widget by widget (inside a list it crawls row by
+ row — no section jumps); there is NO type-to-filter. Verified live via
+ targeted-key AT-SPI probe 2026-07-02; the earlier tab-between-sections and
+ type-to-filter claims were aspirational and are struck. If section-jump Tab
+ or filtering is ever wanted, it's a new task, not an existing behavior.
+
+* Connection management (nmcli)
+
+- Every op via nmcli per the nmcli contract above (terse, escaped, UUID-keyed,
+ bounded =--wait=).
+- MRU ordering from NM's =connection.timestamp= (last activated), descending.
+- Ethernet appears in the list whenever a wired device is present, selectable at
+ any time; switching just brings the chosen connection up.
+- *Mutation safety + rollback*: switching keeps the current connection up until
+ the new one activates successfully (=nmcli --wait 30=); on failure it does not
+ tear down the working link, surfaces the failure, and leaves the prior
+ connection active. =net down= notes that NM may auto-reactivate a profile and
+ reports the post-op active connection so the user isn't surprised. A switch that
+ needs a password it doesn't have prompts (or fails with "password required"),
+ never silently strands. The exact NM command sequence (preflight active-state
+ read → activate target → verify default route → on failure, confirm prior
+ still up) is pinned in the engine and tested against fake nmcli.
+- *Add/edit scope*: open + WPA-PSK only in v1. Existing saved profiles of any
+ type (including enterprise) can be *activated*; editing an enterprise profile
+ shows "edit via nmtui/nmcli" rather than a broken partial form.
+
+* Connection secrets (no separate store)
+
+Per Craig's call: don't build a parallel credential store. Settings and secrets
+live where NetworkManager already keeps them, so there's one source of truth and
+no extra dependency (no GPG, no gpg-agent, no =~/.config/net/connections=).
+
+- *Where secrets live*: =/etc/NetworkManager/system-connections/<name>.nmconnection=,
+ root-owned =0600=, with the PSK/EAP secret stored inline (the default
+ =secret-flags=0= "owned by NM"). That's already secure-at-rest (root-only) and
+ is what =nmcli= reads/writes.
+- *How we touch them*: every add/edit/remove goes through =nmcli= (=connection add
+ / modify / delete=), which writes the =.nmconnection= with the right ownership
+ and perms. We never read or write =system-connections= files directly (root) and
+ never copy a secret out of them.
+- *No export / import / sync* — there's nothing to sync. A new machine gets its
+ connections the way it always has (the user joins, or restores NM profiles),
+ not from a tool-specific vault.
+- *config file*: =~/.config/net/config= still exists, but only for non-secret
+ preferences (speedtest server, redaction flags, probe TTL). It holds no
+ credentials.
+- *No secret leakage*: PSK/EAP never appear in =net=' =--json= output, the event
+ log, or error text (tested) — even though NM is the store, our surfaces must not
+ echo a secret =nmcli= happens to return.
+
+* Speed test
+
+- Backend: *=speedtest-go=* (=--json=, =--server=, =--no-download/--no-upload=),
+ already installed on velox (AUR =speedtest-go-bin=). No new dependency for v1.
+ librespeed-cli is the documented fallback for a self-hosted LibreSpeed server.
+- =net speedtest --json= parses speedtest-go's JSON into the =speedtest= schema.
+- *Server policy*: auto-select nearest by default; allow a pinned server id in
+ =~/.config/net/config=.
+- *Timeout + cancellation*: a hard run timeout (e.g. 60s); the panel run is
+ cancellable (kills the child). Offline / rate-limited / no-server errors map to
+ the failure-message table.
+- *Tests*: fixture JSON (success) and fixture stderr (offline, no server,
+ malformed output) drive =net speedtest= parsing without touching the network.
+
+* Help + documentation
+
+In-app help has three layers, each reachable in the situation it's needed:
+
+- *CLI help (works from a dead-GUI TTY)*: =net --help= lists the subcommands in
+ one screen; =net <cmd> --help= documents each (flags, what it mutates, the
+ console-recovery targets). The Makefile targets are self-describing (=make help=
+ lists =online= / =net-doctor= / etc. with one-line descriptions). This is the
+ layer that matters most when you're at a console with no network.
+- *Panel help (in the GUI)*: a small =?= affordance in the panel header opens an
+ inline help pane — what each section does, which Repair actions mutate state,
+ what the indicator glyphs/colors mean. Per-control tooltips on the less-obvious
+ buttons (rfkill, bounce, DNS override). No external help browser.
+- *User guide (the durable doc)*: a README / docs page covering every command,
+ the indicator states + glyphs, the panel sections, the config file keys, the
+ recovery make targets, troubleshooting (the failure-message table), and
+ rollback. Written so a future session — or Craig six months out — can operate
+ and recover the module from the doc alone.
+
+The failure-message table above is the single source of truth for the
+troubleshooting text; the guide and the panel help both render from it rather
+than restating it.
+
+* Enhancement radar
+
+Low-cost adjacent affordances, each dispositioned so cheap wins aren't lost and
+the v1 panel stays focused. (Several are already in v1 by virtue of other
+sections; marked here so the consideration is visible.)
+
+| Enhancement | Disposition | Reason |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Open / copy portal URL | v1 | already in the captive flow; trivial Open + Copy |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Forget network | v1 | it's the remove op, already specced |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Rescan now | v1 | already a Connections control |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Retry with hardware MAC | v1 | captive already has --hardware-mac; expose in Repair |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Pin speedtest server | v1 | already a config key |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Copy redacted doctor report | v1 | cheap, serves the observability/support goal |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Show last good network / result | vNext | needs small history persistence |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Watch mode for net doctor | vNext | a --watch loop; handy at a TTY, not v1-critical |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Actionable desktop notifications | vNext | dunst supports actions; extra wiring |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| Keyboard connection picker (fuzzel) | vNext | the typed-prefix pattern; panel covers v1 |
+|-------------------------------------+-------------+--------------------------------------------------------|
+| QR-code share / import WiFi | rejected | low value for a personal 2-machine setup; phones do QR |
+|-------------------------------------+-------------+--------------------------------------------------------|
+
+* Waybar wiring
+
+- Replace =custom/netspeed= with =custom/net= in the bar's module list (same
+ slot).
+- Module def: =exec: waybar-net=, =return-type: json=, =interval: 2=, a =signal=
+ for on-demand refresh (next free signal after wtimer's 14), =on-click=,
+ =on-click-right=, =on-click-middle= per the phase-aware interactions (each
+ dispatches a detached job, never blocks).
+- Remove the old =on-click: pypr toggle network= scratchpad only once the panel
+ replaces it (Phase 2); Phase 1 keeps it as the interim manager.
+
+* Testing plan (TDD)
+
+- *Engine (normal)* — fake =nmcli= + =curl= + =speedtest-go= on PATH; assert
+ command sequences and parsed/emitted JSON for status, list, up/down,
+ add/edit/remove, probe, diagnose, repair, speedtest. Pure state/format
+ functions tested directly. JSON schemas locked by example.
+- *Portal parser* — already covered in =tests/captive= (Normal/Boundary/Error +
+ the real SONIFI body). The engine's native probe reuses the same cases.
+- *nmcli parsing* — escaped colon/backslash/newline in SSID, duplicate names,
+ hidden SSID, non-ASCII, wired-mid-session, multi-active (wifi+tether).
+- *Failure + concurrency (the risky classes)* — slow/hung nmcli/curl/speedtest
+ (degraded state within budget), concurrent =net status= probe refresh
+ (single-flight), corrupt cache (recovered), stale cache after SSID change
+ (invalidated), permission denied / sudo declined, DNS-override cleanup failure
+ (=cleanup-unverified=), NM partial activation (rollback keeps prior link),
+ secret redaction, missing speedtest-go, no wifi hardware, rfkill soft/hard
+ block.
+- *Doctor classification* — fixture-driven =net doctor= over fake nmcli/curl
+ asserting the right terminal classification + that =--fix= stops before
+ destructive repairs: auth failures (=needs-user-action=), upstream/AP failure
+ (=upstream-not-local=), VPN-routed failure (=deferred/vpn=), and the DNS classes
+ (hijack → portal, broken-but-1.1.1.1-works → offered override, egress-blocked →
+ upstream). Assert the failure-mode coverage table's "detects / repairs / terminal
+ action" holds for each row.
+- *Indicator* — drive =net status --json= through =waybar-net=, assert the JSON
+ per state (online / captive / no-internet / degraded / wired / disconnected /
+ rfkill), iface override via env.
+- *Panel* — pocketbook-style: backing logic (list ordering, op dispatch,
+ state-machine transitions), not GTK widgets.
+- *NM secrets / no-leak* — add/edit writes the secret into NM via nmcli (asserted
+ against fake nmcli, never to a tool-owned file); assert no PSK/EAP appears in any
+ =--json=, log line, or error (there is no credential store to round-trip).
+- *Live checklist (gated out of the suite)* — a "Manual testing and validation"
+ task per phase for the real-network states (captive at a hotel, no-internet,
+ switch under load, reset, speedtest) that can't be faked.
+
+** Harness + coverage gate
+The concrete contract, matching the repo's existing convention (not pytest — the
+dotfiles suites are =unittest=, run by =make test= as =python3 -m unittest= over
+=tests/*/test_*.py=; 33 suites today):
+- *Framework*: =unittest=. Each suite is =tests/<name>/test_<name>.py=
+ (=tests/net/=, =tests/waybar-net/=), collected by the existing =make test= loop
+ — no new runner, no pytest dependency.
+- *Fakes on a temp PATH*: =fake-nmcli=, =fake-curl=, =fake-speedtest-go=,
+ =fake-rfkill=, =fake-resolvectl= live as executable stubs in =tests/<name>/=
+ (the =tests/layout-navigate/fake-hyprctl= pattern). A fixture file encodes the
+ command→canned-output map and the stub appends each invocation to a log the test
+ asserts against. Subprocess timeouts are simulated by a stub that sleeps past the
+ budget; =net status= must still return the degraded state.
+- *Waybar wrappers end-to-end*: =waybar-net= is run as a subprocess with the fake
+ PATH and the env overrides (iface, cache path), asserting the emitted JSON — same
+ as =tests/waybar-netspeed=.
+- *Coverage*: coverage.py is absent system-wide (and not importable), so coverage
+ runs in a throwaway venv (=python3 -m venv=, =pip install coverage=, =coverage
+ run -m unittest=, =coverage report=) — the method the wtimer suite used (95%).
+ Target: *branch* coverage over =net/= and the wrapper, ≥ 90% on the pure
+ classifier/parser modules.
+
+** Coverage as a gap-finder, not a number (per phase)
+Line coverage alone misses the branches that matter here, so each phase ends with
+a *coverage-gap pass*, not just a percentage:
+- After the first green run, read the branch report and map every uncovered branch
+ to either a new test or a consciously-excluded live-only behavior (with a comment
+ or a Manual-testing entry naming it).
+- *Branch coverage is required* for the pure logic: the doctor classifier (every
+ outcome — fixable / needs-user-action / upstream-not-local / deferred-vpn), the
+ cleanup-unverified path, the redaction paths, the degraded hot-path fallback, the
+ timeout branches, and the portal/nmcli parsers.
+- A phase isn't "done" until its coverage-gap pass is recorded — uncovered logic is
+ either tested or explicitly excused, never silently uncovered.
+
+* Files touched (planned, all in =~/.dotfiles=)
+
+- =net/= package (src-layout, like pocketbook) — engine + panel.
+- =hyprland/.local/bin/waybar-net= — the indicator (replaces =waybar-netspeed=).
+- =hyprland/.local/bin/net= — engine CLI entry (console-script shim).
+- =hyprland/.config/waybar/config= — swap =custom/netspeed= → =custom/net=;
+ remove =custom/airplane=.
+- =hyprland/.config/waybar/style.css= — captive / no-internet / degraded /
+ rfkill classes; remove airplane classes.
+- =tests/net/=, =tests/waybar-net/= — suites.
+- =captive= — refactor: extract probe + reset into functions callable
+ non-interactively (a =--json= probe mode) so the engine reuses them.
+- =~/.config/net/config= — seed config (probe TTL, speedtest server, redaction
+ flags). No secrets; not a credential store.
+- dotfiles =Makefile= — add the console-recovery targets (=online=, =net-doctor=,
+ =net-status=, =net-diagnose=, =net-portal=, =net-reset=, =net-bounce=).
+- *Deletions once net ships* (the airplane module is absorbed):
+ =hyprland/.local/bin/waybar-airplane=, =hyprland/.local/bin/airplane-mode=,
+ =tests/waybar-airplane/=, =tests/airplane-mode/=, and the =custom/airplane=
+ module + its css.
+- archsetup Hyprland step — add =gtk4-layer-shell=, =python-gobject=,
+ =speedtest-go-bin= to the install lists (the only archsetup change; no =gpg=
+ added, secrets stay in NM's store).
+
+* Resolved decisions (Craig's calls + this response)
+
+1. Panel UI tech → GTK4 + gtk4-layer-shell, shared pocketbook scaffold (one
+ panel shell, reused by the desktop-settings sibling).
+2. Engine language → Python =net= package; shells out to =captive= for the
+ portal-force flow, native cheap probe for the bar path.
+3. Connectivity probe → split cadence (fast link poll every 2s + slow cached
+ internet/captive probe, TTL ~45s) with single-flight + atomic cache.
+4. No keyboard-modifier clicks (waybar can't qualify them) — the panel hosts the
+ rich actions; bar clicks dispatch detached jobs (phase-aware).
+5. No separate credential store (Craig's call, cj). Secrets live in NM's own
+ =system-connections= (root =0600=, inline), touched via nmcli. No GPG, no
+ gpg-agent, no =~/.config/net/connections=. Supersedes the earlier GPG-store
+ design.
+6. =custom/netspeed= absorbed into =custom/net=; throughput moves to the tooltip.
+7. Speed-test backend → =speedtest-go= (already installed), not a new
+ librespeed-cli dependency; librespeed-cli is the self-hosted fallback.
+8. Code lives in the dotfiles repo; archsetup only installs deps.
+9. v1 add/edit scope = open + WPA-PSK; enterprise/802.1X is activate-only,
+ add/edit is vNext (settled by Craig 2026-06-29 — no enterprise networks in his
+ history, so the form would be unused UI).
+10. =net doctor= is in v1 (Craig's call, cj) — a one-shot diagnose+fix mode,
+ reachable from a TTY via =make online= / =make net-doctor=. (The earlier
+ "defer the doctor/bundle command" decision is reversed.)
+11. Diagnose (read-only) and Repair (mutating, confirmed) are separated in the
+ panel and the CLI; Repair is tiered lightest-first (rfkill → reset → bounce).
+12. =custom/net= absorbs the airplane module (Craig's call, cj). *As built
+ (2026-06-29, option 1): display-only.* net shows the airplane state (reads
+ the airplane-mode state file); the =airplane-mode= low-power toggle is kept
+ (radios + CPU + brightness + services is not a network concern) and moved to
+ =custom/net='s right-click + signal 15. Only the redundant display pieces —
+ =waybar-airplane=, =custom/airplane=, and the retired =waybar-netspeed= —
+ plus their tests/css were deleted. The earlier "delete airplane-mode" framing
+ is superseded.
+13. Repair includes a full-stack bounce and an rfkill-unblock (Craig's calls,
+ cj) — the latter recovers the framework-laptop post-power-loss soft-block.
+14. VPN / WireGuard is a planned Phase 5 (Craig's call, cj), not a permanent
+ exclusion.
+
+V2 redesign decisions (Craig, 2026-06-30):
+
+15. *No terminals anywhere in the module* — =net-popup= is removed; every action and
+ result renders in the panel. No terminal is ever used to report information to the
+ user or to collect input from them: every prompt, confirmation, repair stream, and
+ result lives in the panel UI (Craig, cj, 2026-06-30). Reverses the part of decision
+ 11 that ran privileged repairs in a terminal "so sudo/polkit can prompt". (Unrelated
+ to the doctor's "terminal states" — that word means a final outcome, not a tty. The
+ one open question is the dead-GUI console-recovery path; see the VERIFY in todo.org.)
+16. *Passwordless privileged path* — a root-owned helper + a narrow NOPASSWD sudoers
+ rule scoped to it, archsetup-installed, run as =sudo <helper> <verb>=. This gates
+ decision 15 (a worker thread can't prompt). Absorbs the earlier DoT-toggle
+ follow-up and fixes the detached-restore-watcher bug.
+17. *Verify every action* — each mutating op (repair, connect, forget, add, DNS
+ override) re-checks its effect and surfaces pass/fail in the panel.
+18. *Detect + respond to every failure mode, edges included* — the full ~44-mode
+ catalog (todo.org redesign task) is the contract; auto-fix where safe, else report
+ the exact in-panel text. Includes IPv6-only awareness and multi-homing, which need
+ diagnose to stop being IPv4-only and single-iface.
+19. *Navigation* — top tabs Connections | Diagnostics | Performance; Diagnostics
+ merges Diagnose + Repair (Diagnose | Get Me Online | Advanced over a shared
+ streaming area); Speed test under Performance.
+20. *Automatic diagnostic verbose-capture* (Craig, 2026-06-30) — on a failing
+ diagnose, elevate the underlying stack (NM / resolved / wpa_supplicant) to debug,
+ capture the journal + dmesg window, restore (guaranteed + crash-guarded), and
+ write a redacted bundle. Plus a manual Debug on/off toggle in Advanced. Restore
+ bulletproof, secrets scrubbed before the bundle, log-level toggles via the V2
+ helper. See Observability.
+
+* Implementation phases
+
+*Phases 1-3 are SHIPPED* (2026-06-29 → 2026-06-30, dotfiles); their acceptance
+criteria passed and the work is live on velox. Phase 4 (docs/rollout) and Phase 5
+(VPN) remain. The V2 redesign phases at the end are designed, not yet built.
+
+- *Phase 1 — Indicator + console recovery (task #C).* =net status= + =net probe=
+ (native cheap probe, reusing captive's logic) + the =captive= probe refactor +
+ =waybar-net= + the split-cadence cache (single-flight, atomic, stale classes) +
+ CSS states (incl. rfkill) + performance budget. Plus the CLI-only recovery path:
+ =net repair= tiers (rfkill / reset / bounce), =net doctor [--fix]=, and the
+ Makefile targets (=make online= etc.) — all testable without the GTK panel.
+ Absorbs the airplane state and removes the standalone airplane module. Interim
+ left-click keeps the existing scratchpad until the panel lands.
+ - *Acceptance*: fresh-login waybar smoke test shows correct state on
+ online/captive/no-internet/wired/rfkill; =net status= stays within budget
+ under a fake slow nmcli (degraded state); =net doctor --fix= recovers a
+ soft-blocked radio from a TTY; the live captive checklist passes at a real
+ portal; the airplane state works and the old airplane module is gone;
+ reverting = swap =custom/netspeed= + =custom/airplane= back.
+- *Phase 2 — Panel shell + connection management (task #B core).* GTK4
+ layer-shell scaffold + =net list/up/down/add/edit/remove/rescan= + MRU list +
+ mutation safety/rollback + panel state machines.
+ - *Acceptance*: switch wifi↔wifi and ethernet↔wifi without stranding; a failed
+ switch leaves the prior link up; add/edit open + WPA-PSK writes the secret to
+ NM; remove confirms; panel states render for loading/rescan/activation.
+- *Phase 3 — Diagnostics + speed test in the panel.* Wire =net diagnose= /
+ =net repair= / =net doctor= / =net portal= / =net speedtest= into the Diagnose
+ vs Repair sections; the "Get me online" button; portal Open button; speedtest
+ progress + cancel.
+ - *Acceptance*: diagnose runs read-only; each repair tier confirms + verifies
+ cleanup (DNS override reverts, shown); speedtest result parses from
+ speedtest-go and a fixture-driven failure shows the right message.
+- *Phase 4 — Docs + rollout.* In-app help (=net --help= / per-command help, the
+ panel help affordance), README/user-guide (commands, panel, config,
+ troubleshooting, the make targets, rollback), and the manual dep step on ratio.
+ - *Acceptance*: =net --help= and each subcommand's help are complete; the
+ user-guide covers every command + the recovery targets; ratio rollout
+ documented.
+- *Phase 5 — VPN / WireGuard (future).* Fold the existing archsetup wireguard
+ tooling into the same panel + CLI (=net vpn ...=). Out of the v1 milestone;
+ specced separately when picked up.
+
+V2 redesign phases (designed 2026-06-30, dependency order):
+- *V2.1 — Sudo helper + NOPASSWD sudoers (gates everything).* Root-owned helper
+ dispatching net's fixed privileged verbs, archsetup-installed, narrow sudoers.
+ Also fixes the detached DoT-restore-watcher bug.
+ - *Acceptance*: every repair runs passwordless in-panel on a non-NOPASSWD machine;
+ the sudoers rule is scoped to the helper only.
+- *V2.2 — Merged Diagnostics panel + nav restructure.* Connections | Diagnostics |
+ Performance; the Diagnostics sub-row + shared streaming area; Advanced reveal +
+ tooltips; delete =net-popup=.
+ - *Acceptance*: no terminal opens for any action; repair progress streams in the
+ panel; Speed test lives under Performance.
+- *V2.3 — IPv6-aware and multi-homing-aware diagnose.* Stop treating no-IPv4 as a
+ failure when online over IPv6; identify which interface owns the default route.
+- *V2.4 — Close every detect/correct gap in the catalog, with post-action
+ verification.* Work the redesign-task catalog mode by mode.
+- *V2.5 — Automatic diagnostic verbose-capture.* Snapshot/elevate/capture/restore
+ around a failing diagnose + the Advanced Debug on/off toggle; guaranteed +
+ crash-guarded restore; redacted support bundle; helper log-level verbs.
+ - *Acceptance*: a failing diagnose leaves a redacted bundle (NM/resolved/
+ wpa_supplicant journal + dmesg) and restores every log level; a crash mid-capture
+ is detected and restored on the next run; the secret-leak test finds no PSK/EAP in
+ a bundle; the toggle elevates and restores on demand.
+
+* Open items / risks
+
+- gtk4-layer-shell dropdown anchoring under a waybar module needs the same
+ positioning work pocketbook solved; reuse it. (Phase 2.)
+- The =captive= refactor must keep the standalone CLI behavior identical while
+ exposing a non-interactive =--json= probe; covered by the existing
+ =tests/captive= suite plus new probe-mode tests. (Phase 1.)
+- speedtest-go server selection variance (nearest-server flor) — pin a server in
+ config if results are noisy. (Phase 3.)
+- The background-probe kick from =net status= must be truly non-blocking (spawn +
+ detach); enforced by the single-flight lock and the performance benchmark test.
+
+* Rollback
+
+Each phase is independent. The indicator (Phase 1) is a drop-in replacement for
+=custom/netspeed= (and =custom/airplane=); reverting is swapping those modules
+back in the config and restoring their scripts. The panel is additive — not
+wiring its clicks leaves the bar working as before. No credential store to roll
+back (secrets stay in NM throughout).
+
+* Review findings [40/40]
+
+** DONE Define the structured diagnostics contract :blocking:
+The spec says the engine "emits JSON" and that diagnostics "reuse =captive=
+verbatim", but the current =~/.dotfiles/common/.local/bin/captive= flow is a
+human-readable bash script that mixes diagnostics, sudo prompts, DNS mutation,
+browser launch, and terminal prose. A GTK panel cannot reliably turn that into
+clear state, progress, cancellation, or useful error messages. Define the
+machine contract before implementation: every diagnostic step should have a
+stable id, status (=pending/running/pass/warn/fail/skipped=), redacted evidence,
+elapsed time, safety outcome, and next action. Keep =captive= as the interactive
+CLI, but either refactor reusable probe/reset functions behind =net diagnose
+--json= or make =captive= expose a non-interactive JSON mode. This blocks the
+panel and logging work because otherwise the implementer must invent the
+boundary.
+
+Disposition: accept — added the "Diagnostics contract" section (per-step id /
+status / evidence / elapsed / safety / next_action) and the =captive= =--json=
+probe-mode refactor under Architecture + Files touched.
+
+** DONE Specify user-facing failure messages and recovery actions :blocking:
+The spec names failure states like =no-internet=, =captive=, failed probe,
+failed reset, missing DNS, and missing speed-test backend, but it does not define
+the messages the user sees or what each message tells them to do next. For this
+feature, "error" is not enough: a user needs to know whether WiFi is associated,
+whether DHCP succeeded, whether DNS is hijacked/broken, whether HTTP is
+intercepted, whether sudo was declined, whether a command timed out, and whether
+the system was left unchanged or partially changed. Add a message table for the
+indicator, panel, and CLI with: failure class, visible text, evidence included,
+redaction rule, and next action. This is blocking because UX quality here is the
+product, not an implementation detail.
+
+Disposition: accept — added the "Failure states, messages, recovery" section
+covering each class, the visible message, the "what changed" residue note, and
+the next action across indicator/panel/CLI.
+
+** DONE Define the debug log and redacted support bundle :blocking:
+There is no observability section. When this fails in a hotel or cafe, an agent
+needs enough evidence to diagnose it without rerunning destructive actions. Add
+log location, rotation/retention, JSONL event schema, command argv logging,
+exit-code/stderr capture, elapsed time, selected iface, NM active connection
+UUID, probe URL class, HTTP code, redirect host, DNS servers, and cache
+read/write events. Also define a =net doctor --json= or =net debug-bundle=
+command that emits redacted status, recent log events, dependency versions, and
+a reproduction command. Redact SSID if configured, MAC addresses, portal query
+tokens, PSKs, EAP identities/passwords, IPs when requested, and all GPG/NM
+secrets. This blocks implementation readiness because post-failure diagnosis is
+currently left to ad hoc terminal spelunking.
+
+Disposition: modify — accepted the JSONL event log, the schema, and the redaction
+rules in full (new "Observability" section). Deferred the dedicated =net
+debug-bundle= / =net doctor= command to vNext: for a single-user tool =net
+diagnose --json= (the snapshot) plus the event log (the history) cover
+post-failure diagnosis; a bundle command is gold-plating for v1. Recorded under
+Out + Resolved decision 10.
+
+** DONE Pin the nmcli parsing and timeout contract :blocking:
+The spec lists nmcli operations but not the exact fields, output modes, escaping
+rules, ID semantics, or timeouts. This is risky because SSIDs and connection
+names can contain spaces, colons, duplicates, hidden names, and non-ASCII; the
+current =waybar-netspeed= already had an SSID parsing bug. The nmcli manual
+documents =--terse=, =--get-values=, =--escape=, =--wait=, ID/UUID/path
+selection, =passwd-file=, and built-in connectivity states
+(=none/portal/limited/full/unknown=) at
+https://man.archlinux.org/man/nmcli.1.en. The spec should require UUIDs for
+saved-profile operations, explicit =--wait= budgets, parser tests for escaped
+colons/backslashes/newlines/duplicate names/hidden SSIDs, and a decision on when
+to use or ignore =nmcli networking connectivity [check]=. This is blocking
+because the command wrapper is the core reliability boundary.
+
+Disposition: accept — added the "nmcli contract" section: terse + =--escape= +
+=--get-values=, UUID-keyed ops, explicit =--wait= budgets, NM connectivity as a
+cheap hint (our probe authoritative), and the parser test matrix.
+
+** DONE Define cache concurrency, atomicity, and stale-state behavior :blocking:
+=net status= may spawn =net probe= whenever the cache is stale, but the spec
+does not define locking, process coalescing, atomic writes, crash cleanup, or
+what happens when the probe hangs. With a 2s Waybar interval, a bad network could
+start overlapping probes, corrupt the runtime cache, or keep showing stale
+"online" while the link is gone. Add a single-flight lock under
+=$XDG_RUNTIME_DIR/waybar=, atomic write+rename for cache updates, max probe
+runtime, stale age classes (fresh/stale/expired/unknown), cache invalidation on
+iface/SSID/connection UUID change, and tests for concurrent =net status= calls.
+This blocks the fast-path design because it is the main performance and
+correctness risk.
+
+Disposition: accept — added "Concurrency, atomicity, staleness" under the
+Connectivity model: flock single-flight, temp+rename atomic write, ≤6s probe
+timeout, fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation, stale
+lock reclaim, plus concurrency tests in the test plan.
+
+** DONE Bound hot-path performance with measured budgets :blocking:
+The spec says the cheap poll should be sub-100ms, but the proposed fast path
+still may call multiple =nmcli= commands every two seconds, read sysfs, parse
+throughput, and maybe spawn a background probe. The existing =waybar-netspeed=
+had a deliberate sleep for throughput sampling; replacing it must define how
+throughput is sampled without sleeping in the bar path. Add a per-command budget
+for =waybar-net= and =net status=, a maximum number of subprocesses on the hot
+path, a timeout for every subprocess, benchmark tests with fake slow =nmcli=,
+and a rule that the indicator emits a degraded JSON state rather than blocking.
+This is blocking because Waybar custom modules can visibly freeze or lag when
+their exec path stalls.
+
+Disposition: accept — added the "Performance budgets" section: <100ms typical /
+<250ms worst, throughput sampled across the poll interval (no in-process sleep),
+one nmcli call max on the hot path, timeouts on every subprocess, the degraded
+state, and a fake-slow-nmcli benchmark test.
+
+** DONE Make click actions non-blocking and visible :blocking:
+Waybar right-click runs =net reset= and middle-click runs =net portal= directly.
+Those operations can require sudo, open browsers, mutate DNS, delete/recreate NM
+profiles, or hang on network commands, but Waybar click handlers provide no
+panel, terminal, progress, or cancellation surface by default. Define whether
+right/middle click instead opens the panel focused on the action, dispatches a
+background job with notifications, or is removed from v1. If kept, specify
+single-flight behavior, how sudo/polkit prompts surface, how success/failure is
+reported, and how the user can inspect logs. This blocks UX readiness because
+the fastest remediation path is currently the easiest place to hide failure.
+
+Disposition: modify — accepted the concern; made the interactions phase-aware and
+non-blocking. Every click dispatches a detached, single-flight background job and
+reports via =notify=; sudo surfaces through polkit/the normal prompt; failures go
+to the notify + the event log. In Phase 1 (no panel) left-click runs probe +
+notify and keeps the scratchpad; from Phase 2 left-click opens the panel focused
+on the action. Recorded in the Indicator "Interactions" subsection.
+
+** DONE Specify connection mutation safety and rollback :blocking:
+The spec says row click switches connections and remove gets a confirm, but it
+does not define what happens when a switch partially succeeds, disconnects the
+current working link, needs a password, loses the default route, or triggers
+auto-activation. The nmcli manual warns that =connection down= does not prevent
+future auto-activation and may internally block a profile until user action.
+Define preflight, the exact NM command sequence, whether the old active
+connection is kept until the new one proves usable, when rollback is attempted,
+how long activation waits, and what the panel says when rollback fails. This is
+blocking because the module can strand the user offline.
+
+Disposition: accept — added "Mutation safety + rollback" under Connection
+management: keep the prior link up until the target activates (=--wait 30=), no
+teardown on failure, password-required surfaced not stranded, =net down= reports
+post-op active state + the auto-reactivation caveat, and the pinned NM command
+sequence is tested against fake nmcli.
+
+** DONE Define the credential-store security model :blocking:
+The GPG store is described as optional and default-unencrypted, but the spec does
+not define file modes, schema, secret-source rules, import/export prompts,
+recipient verification, stale secret handling, or what is logged. It also says
+NM remains source of truth while the user-owned store contains PSK/EAP secrets,
+which creates two truth sources for sensitive data. Add a precise schema,
+=0600= file creation with parent-dir permissions, encrypted-recipient checks,
+plaintext warning text, explicit opt-in flow, redaction requirements, behavior
+when NM has a secret not in the store, behavior when the store has a secret NM
+rejects, and tests for no secret leakage in JSON/logs/errors. This blocks Phase
+4 and the full spec because otherwise the implementer must make security
+decisions mid-code.
+
+Disposition: accept — rewrote "Credential storage" with the versioned schema,
+=0600= file / =0700= dir, recipient verification on opt-in, the plaintext
+warning, secret-source rule (entered/exported, never harvested from root store),
+the two-source reconciliation policy (NM wins live, store wins for what NM
+lacks, stale-secret flagging), and the no-leak tests.
+
+** DONE Define EAP, enterprise WiFi, and unsupported connection behavior :blocking:
+The store says "PSK/EAP" and connection management says add/edit, but there is
+no v1 contract for WPA-Enterprise fields, certificates, identity vs anonymous
+identity, hidden networks, static IP, proxy settings, metered flags, MAC
+randomization, or 802.1X prompt behavior. Either scope v1 to open/WPA-PSK plus
+existing saved-profile activation, or define the minimum EAP form and the
+unsupported-state messages. This blocks add/edit/import because enterprise WiFi
+is too sensitive to hand-wave.
+
+Disposition: modify (scope) — scoped v1 to open + WPA-PSK add/edit, with
+*activation* of any existing saved profile (including enterprise). Enterprise /
+802.1X add/edit, static-IP, proxy, metered, and MAC-randomization editing are
+vNext, shown as "edit via nmtui/nmcli". Recorded in Scope/Out, Connection
+management, and Resolved decision 9.
+
+** DONE Split read-only diagnostics from mutating remediation :blocking:
+The panel's diagnostics section includes probe, bounce/reset, gateway ping, and
+DNS override test in one area, while =captive= currently performs resets and
+temporary DNS changes as part of its flow. Users need to know which buttons are
+read-only and which mutate NM profiles, MAC mode, DNS, or browser state. Add
+separate "Diagnose" and "Repair" actions, confirmations for destructive or
+privacy-changing operations, explicit cleanup verification for DNS override, and
+a terminal state when cleanup is unverified. This blocks readiness because
+network repair must not surprise the user or leave hidden residue.
+
+Disposition: accept — split the panel into a read-only Diagnose section and a
+confirmed, mutating Repair section (and split the CLI into =net diagnose= vs =net
+repair=). Added =cleanup_verified= + a terminal =cleanup-unverified= state to the
+diagnostics contract.
+
+** DONE Define panel state, cancellation, and permissions UX :blocking:
+The panel sections list buttons and a streaming output area, but not loading
+states, disabled states, empty states, keyboard/focus behavior, cancellation, or
+permission-denied handling. Add panel state machines for connection list loading,
+rescan in progress, activation in progress, diagnostics running, speedtest
+running, and no NetworkManager/no WiFi/no permissions/no GPG key/no
+librespeed-cli. Each long operation should be cancellable where possible or
+clearly non-cancellable with an elapsed-time display. This blocks the GTK work
+because without it the implementer must invent the user flow.
+
+Disposition: modify — accepted the state-machine requirement (added "Panel state,
+cancellation, permissions"), but scoped the state set to what can actually occur
+on the two-machine fleet: dropped "no NetworkManager" as a modeled state (NM is
+always present; a missing nmcli is a single hard-error exit) and kept
+no-wifi-hardware, missing speedtest-go, no-GPG-key, plus the in-progress states
+with elapsed-time + cancellation where the op allows.
+
+** DONE Verify speed-test dependency, server choice, and failure contract :blocking:
+The spec chooses =librespeed-cli= and notes availability/default-server research
+as an open risk, but Phase 3 still depends on parsing its JSON and showing
+progress. I checked the upstream project page
+(https://github.com/librespeed/speedtest-cli) and the AUR URL named by search is
+not sufficient as a verified package/install contract in this spec. Add the
+exact package name/source to install, command version expected, JSON shape,
+server-selection policy, timeout, cancellation behavior, offline/rate-limited
+messages, and tests with fixture JSON and fixture stderr. This blocks Phase 3
+because speed-test failure modes are otherwise undefined.
+
+Disposition: modify — verified live and changed the backend: =speedtest-go= (AUR
+=speedtest-go-bin=, 1.x) is already installed on velox and supports =--json=,
+=--server=, =--no-download/--no-upload=, so v1 needs no new dependency.
+librespeed-cli (AUR =librespeed-cli= / =-bin=) is the documented self-hosted
+fallback. Added the "Speed test" section with server policy, timeout,
+cancellation, the failure-message mapping, and fixture-JSON/stderr tests.
+
+** DONE Define dependency installation and repo boundaries :blocking:
+The files touched section alternates between archsetup paths and the external
+dotfiles repo, while pocketbook has been folded into this repo and its previous
+archsetup provisioning was intentionally removed. The spec should state where
+the =net= package actually lives, which repository owns the scripts/tests,
+whether =gtk4-layer-shell=, =python-gobject=, =librespeed-cli=, =gpg=, =nmcli=,
+=curl=, and =resolvectl= are installed by archsetup or assumed present, and the
+Makefile targets for test/lint/install. This blocks implementation because the
+current path plan can produce code that is not installed on a fresh machine.
+
+Disposition: accept — added the "Repository + dependencies" section: all code in
+=~/.dotfiles= (=net/= package in-tree like pocketbook, scripts in the hyprland
+tier, tests under =tests/=), archsetup owns only the dep install
+(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=; nmcli/curl/resolvectl
+already present), Makefile =make test= collects the package suite, and a
+daily-drivers note for ratio. Rewrote Files touched to match.
+
+** DONE Expand the test plan for failure, concurrency, and live verification :blocking:
+The testing plan covers normal parsing and fake command sequences, but it misses
+the riskiest behaviors: slow/hung =nmcli=/=curl=/=librespeed=, concurrent
+=net status= cache refresh, corrupt cache, stale cache after SSID change,
+permission denied, sudo declined, DNS override cleanup failure, NM partial
+activation, duplicate connection names, secret redaction, missing optional
+dependencies, no WiFi hardware, wired+tether+WiFi ambiguity, portal redirect
+tokens, and Waybar click handlers. Add unit/fixture tests for each class plus a
+manual/live checklist gated out of the normal suite. This is blocking because
+the current plan would leave the exact "things that can go wrong here" mostly
+untested.
+
+Disposition: accept — rewrote the Testing plan with the "Failure + concurrency"
+class (slow/hung commands, single-flight, corrupt/stale cache, perm-denied,
+cleanup-failure, partial activation, redaction, missing deps, no-wifi,
+multi-active) and a per-phase live checklist gated out of the suite.
+
+** DONE Define status JSON schemas and compatibility rules
+The spec says all subcommands take =--json= but does not define schemas. Add
+versioned JSON examples for =status=, =probe=, =list=, =diagnose=, =speedtest=,
+and error envelopes, including nullable fields and unknown/degraded states. This
+is non-blocking for product direction but should be fixed before code so tests
+can lock the CLI contract.
+
+Disposition: accept — added the "JSON schemas" section with versioned (=v:1=)
+envelopes for status / probe / list / diagnose / speedtest and a shared error
+envelope, including the degraded/unknown states.
+
+** DONE Rename or alias the phasing section for workflow compatibility
+The spec has a usable =Phasing= section, but the spec-review workflow expects an
+=Implementation phases= section that can be lifted into =todo.org=. Rename it or
+add an alias heading during response. This is non-blocking because the existing
+phase decomposition is understandable, but aligning the heading prevents future
+workflow friction.
+
+Disposition: accept — renamed =Phasing= → =Implementation phases= and added
+per-phase acceptance criteria.
+
+** DONE Add documentation and rollout acceptance checks
+Rollback is described, but docs and rollout are thin. Add README/user-guide
+updates for commands, panel behavior, config file, GPG opt-in, troubleshooting,
+and rollback; add acceptance checks for each phase, including a fresh-login
+Waybar smoke test and restoring =custom/netspeed=. This is non-blocking but
+important for handing the feature to a future session without re-discovery.
+
+Disposition: accept — added per-phase acceptance criteria under Implementation
+phases (incl. the fresh-login waybar smoke test and the =custom/netspeed=
+restore), a Phase 4 "Docs + rollout", and (answering Craig's cj follow-up) a
+dedicated "Help + documentation" section with the three help layers (CLI help,
+panel help affordance, user guide).
+
+** DONE Add a failure-mode coverage table :blocking:
+The spec now names many individual network failures, but it still does not carry
+one compact coverage matrix that says, for each common failure mode, whether
+=net diagnose= detects it, whether =net doctor --fix= can repair it, and what
+terminal user action remains when it cannot. Add a table covering at least:
+rfkill soft block, rfkill hard block, no WiFi hardware, associated/no DHCP,
+gateway unreachable, captive DNS hijack, broken DNS where 1.1.1.1 works, HTTP
+portal, HTTP interception without a parseable portal URL, upstream/AP outage,
+wrong WPA password or missing secret, enterprise auth/cert failure, duplicate
+SSID/connection-name ambiguity, hidden SSID, multiple active links, wedged
+NetworkManager, slow/hung command, stale/corrupt cache, DNS cleanup failure,
+missing speedtest backend, and VPN/routing interference. This blocks because
+Craig asked for confidence that the diagnostics and doctor cover the real field
+failures, and prose scattered across sections is too easy to misread.
+
+Disposition: accept — added the "Failure-mode coverage" section: a 22-row table
+(every mode the finding named) with detect / doctor-fix / terminal-action
+columns, conformed to the org-table standard (rules under every row, ≤120).
+
+** DONE Pin DNS repair semantics in doctor :blocking:
+The spec diagnoses DNS hijack, broken hotel DNS, and the temporary 1.1.1.1
+override test, but =net doctor --fix= does not say whether it merely recommends
+the override, applies a temporary override during recovery, or leaves DNS alone
+after diagnosis. Define the exact behavior for each DNS class: captive hijack
+should open the portal, broken DNS where 1.1.1.1 works should either offer an
+explicit temporary repair with cleanup verification or recommend the command,
+and port-53/egress blocking should stop as upstream/not locally fixable. This is
+blocking because DNS is one of the most common "connected but unusable" failures
+and the current doctor contract is ambiguous.
+
+Disposition: accept — added "DNS handling in doctor (explicit per class)" under
+the new Doctor section: hijack → open portal (no DNS mutation); broken-but-1.1.1.1
+→ explicit temporary override with cleanup verification under =--fix=, recommend
+otherwise; egress-blocked → terminal =upstream-not-local=.
+
+** DONE Make auth failures terminal user-action states :blocking:
+Wrong WPA password, missing NM secret, locked keyring/polkit denial, enterprise
+802.1X certificate/identity failure, and portal login-required are not fixed by
+resetting or bouncing NetworkManager. The doctor sequence should classify these
+as =needs-user-action= terminal states, stop before looping through destructive
+repairs, and tell the user the exact next action (enter password, edit profile in
+=nmtui=/=nmcli=, accept portal terms, provide cert/identity, or retry with
+admin auth). This blocks because repeated reset/bounce against auth failures is
+slow, noisy, and can make the network state worse without helping.
+
+Disposition: accept — added the =needs-user-action= terminal outcome to the
+Doctor section: wrong password / missing secret / keyring-or-polkit denial /
+802.1X cert-or-identity failure / portal-login-required all stop the doctor before
+any destructive repair and name the exact next step.
+
+** DONE Define upstream/AP/provider failure terminal states :blocking:
+Some failures are not client-repairable: AP has no uplink, hotel gateway is
+down, DHCP server is broken, gateway drops traffic, ISP outage, or captive
+portal backend is failing. The spec should define how =diagnose= proves "local
+link is up but upstream is broken" and how =doctor --fix= stops after local
+repairs are exhausted with a clear message like "local repairs tried; likely
+upstream/AP/provider" plus the evidence. This blocks because users need to know
+when to stop poking the laptop and switch networks or contact the venue.
+
+Disposition: accept — added the =upstream-not-local= terminal outcome: diagnose
+proves link-up + IP + gateway-reachable but no route out and no captive redirect;
+=doctor --fix= stops after local repairs with "local repairs tried; likely
+upstream/AP/provider" + evidence → switch network / contact venue.
+
+** DONE Decide how VPN and policy routing affect v1 diagnosis
+VPN/WireGuard management is Phase 5, but active VPNs, policy routes, DNS
+overrides, and firewall killswitches can break apparent internet access in v1.
+The current spec does not say whether v1 detects active VPN/policy routing and
+classifies "network is fine, VPN route/DNS is broken" separately from WiFi
+failure. Add either a v1 diagnostic check for active VPN/default-route/DNS
+ownership with a "deferred repair" outcome, or explicitly state that VPN-routed
+failures are out of scope and may be misclassified. This is blocking if Craig
+expects the module to diagnose normal daily-driver network failures while VPN
+tooling remains separate.
+
+Disposition: accept (chose the detect-and-classify option) — v1 detects an active
+VPN / non-NM default route / non-NM DNS owner and classifies =deferred/vpn= ("link
+is fine; internet is VPN-routed"), distinct from a WiFi failure. v1 does not
+repair it (VPN management is Phase 5); it names the VPN as the likely owner and
+stops. Added to the Doctor section + the coverage table + a doctor-classification
+test.
+
+** DONE Remove stale GPG-store references from the resolved spec
+The spec now decides "no separate credential store; secrets live in
+NetworkManager", but the Testing plan still mentions =gpg round-trip= and =GPG
+store= tests, and the panel-state list still mentions a no-GPG-key state. Remove
+those stale references and replace them with NM-secret/no-secret-leak tests.
+This is non-blocking for product behavior but blocking for implementation
+clarity: otherwise tests will be written for a credential store that no longer
+exists.
+
+Disposition: accept — replaced the Testing-plan =gpg round-trip= / =GPG store=
+bullets with an "NM secrets / no-leak" test (add/edit writes the secret via nmcli;
+assert no PSK/EAP in any JSON/log/error; no store to round-trip) and dropped the
+=no-GPG-key= panel state. Residue from the cj-comment pass that dropped the store.
+
+** DONE Reconcile status, goal, and task text before implementation :blocking:
+The spec status says "Implementation-ready with caveats" and "Phase 1 ready to
+build", but the body still has an unresolved enterprise add/edit VERIFY, the
+Goal still says "optional GPG-encrypted secret store", and the unified task title
+still names "GPG-stored secrets" even though the accepted design removed the
+store. Before implementation, make the top-level status, goal, scope, task
+mapping, and resolved decisions agree with the current design. This blocks
+readiness because a developer starting from the top of the file would still build
+or plan around abandoned GPG-store behavior.
+
+Disposition: accept — fixed the Goal ("secrets stay in NM's own store"), the
+=[#B]= task-mapping line (notes the "GPG-stored secrets" framing is superseded by
+decision 5), the enterprise VERIFY (now resolved → Status updated), and corrected
+the stale =pytest= mentions to =unittest= (the repo's actual harness). Top-of-file
+status/goal/scope/decisions now agree with the design.
+
+** DONE Resolve enterprise add/edit scope or make the caveat explicit :blocking:
+The spec still says "One open question for Craig: pull enterprise add/edit into
+v1?" and points to a VERIFY in =todo.org=. That is a real product-scope decision:
+if enterprise add/edit is in v1, panel forms, nmcli command sequences, tests,
+error messages, and docs change materially; if it is out, the UI must consistently
+show activate-only with "edit in nmtui/nmcli". Decide it in the spec before
+implementation, or downgrade the status to =Ready with caveats= with this exact
+accepted caveat. As written, the spec cannot be plain =Ready=.
+
+Disposition: accept — Craig decided (2026-06-29): enterprise add/edit is vNext,
+activate-only in v1. Settled in the Status line, the Scope/Out bullet, decision 9,
+and the VERIFY (now DONE in todo.org). The UI shows activate-only with "edit in
+nmtui/nmcli" consistently. Evidence: 24 saved profiles, 0 enterprise.
+
+** DONE Define the concrete test harness and coverage gate :blocking:
+The spec says TDD, fake binaries on PATH, and benchmark tests, but it does not
+define the actual harness contract: pytest vs unittest for the =net= package,
+where fake =nmcli=/=curl=/=speedtest-go=/=rfkill=/=resolvectl= live, how test
+fixtures encode command histories, how subprocess timeouts are simulated, how
+Waybar scripts are executed end-to-end, and how coverage is run. Add the exact
+Makefile targets (=test=, =test-unit= or package-local =pytest=), pytest config,
+coverage command (e.g. branch coverage over =net/= and =waybar-net= wrappers),
+minimum threshold, and the rule for reading the coverage report to add missing
+tests before declaring a phase done. This blocks readiness because "what is the
+test harness?" is still answerable only by analogy to older suites.
+
+Disposition: accept — added the "Harness + coverage gate" section. Corrected the
+premise: the repo is =unittest= (=make test= → =python3 -m unittest=, 33 suites),
+not pytest. Pinned the fake-binary stub convention (=tests/<name>/fake-*= on a
+temp PATH), the fixture command→output map, timeout simulation, the end-to-end
+=waybar-net= subprocess run, and coverage via a throwaway venv (coverage.py is
+absent system-wide) with a ≥90% branch target on the pure modules.
+
+** DONE Use coverage to find missing behavior, not just report a percentage :blocking:
+The spec does not say how coverage findings affect implementation. For this
+feature, line coverage alone can miss the important holes: doctor classification
+branches, cleanup-unverified paths, redaction paths, degraded hot-path fallbacks,
+timeout branches, and auth/upstream/VPN terminal states. Define coverage review
+criteria per phase: branch coverage for pure classifiers and parsers, named
+untested branches allowed only with comments or manual-check entries, and a
+required "coverage gap pass" after the first green test run that maps uncovered
+logic back to tests or consciously excluded live-only behavior. This blocks
+readiness because the current test plan is broad but does not force the suite to
+expose missing edge tests.
+
+Disposition: accept — added the "Coverage as a gap-finder, not a number (per
+phase)" subsection: branch coverage required for the doctor classifier (every
+outcome), cleanup-unverified, redaction, degraded-fallback, timeout, and the
+parsers; a mandatory coverage-gap pass after the first green run mapping each
+uncovered branch to a test or a named live-only exclusion; a phase isn't done
+until that pass is recorded.
+
+** DONE Convert error classes into exact user-facing strings and evidence fields :blocking:
+The failure table and doctor outcomes classify errors well, but many messages
+are still templates or descriptions rather than final text. Add exact strings
+for indicator tooltip, notification, CLI stderr, JSON =error.message=, and panel
+banner/step text for every failure-mode row, including cases doctor cannot fix:
+wrong password, missing secret, enterprise cert failure, upstream/AP/provider
+failure, VPN-routed failure, hard rfkill block, DNS cleanup failure, speedtest
+missing, and HTTP interception without parseable URL. For each string, specify
+the redacted evidence included and the next action. This blocks UX readiness
+because "useful error" is only testable once the actual text and evidence are
+defined.
+
+Disposition: accept — rewrote the Failure states section: each row now carries the
+exact final string (with =<placeholder>= evidence), the evidence field, and the
+next action, plus a per-surface rendering rule (indicator tooltip / notify /
+CLI+JSON error.message+detail+code / panel banner all render the one canonical
+string). Added the missing doctor-unfixable rows: hard rfkill, wrong password /
+missing secret, enterprise cert failure, upstream/AP/provider, VPN-routed, HTTP
+interception without a parseable URL, and DNS cleanup-unverified.
+
+** DONE Add an enhancement disposition table
+The spec captures several good enhancements (doctor, Makefile recovery, rfkill,
+airplane absorption, VPN phase), but it does not show that low-cost adjacent
+enhancements were considered and accepted/deferred/rejected. Add a small radar
+table for likely affordances: copy redacted doctor report, open/copy portal URL,
+retry with hardware MAC, forget network, rescan now, pin speedtest server, show
+last good network/result, watch mode for =net doctor=, desktop notification
+actions, QR-code/share WiFi import/export, and keyboard picker. Mark each
+=v1=, =vNext=, or =rejected= with a one-line reason. This is non-blocking, but it
+prevents accidental loss of cheap UX wins and keeps the v1 panel focused.
+
+Disposition: accept — added the "Enhancement radar" table dispositioning all the
+named affordances: open/copy portal URL, forget network, rescan, hardware-MAC
+retry, pin speedtest server, copy redacted doctor report = v1; last-good
+network/result, doctor watch mode, actionable notifications, keyboard picker =
+vNext; QR-share = rejected (low value for a 2-machine personal setup).
+
+** DONE Tighten the panel UX flow before Phase 2
+The panel has sections and state machines, but not a concrete interaction flow:
+default focused section, row content, primary/secondary buttons, disabled-state
+rules, confirmation wording for reset/bounce/DNS override, how "Get me online"
+reports each escalation, what stays visible after the panel closes, and keyboard
+navigation. Add a short UX flow spec or wire-level outline before Phase 2. This
+is non-blocking for Phase 1, but it blocks Phase 2 implementation because a GTK
+panel can easily become noisy or surprising if these defaults are invented while
+coding.
+
+Disposition: accept — added the "Panel UX flow (settle before Phase 2)"
+subsection: default focus (Connections, or Diagnose when opened from a captive
+state), row content, one primary button per section, disabled-state rules, exact
+confirmation wording for reset/bounce/DNS-override/remove, the live "Get me
+online" escalation reporting, what survives panel close, and keyboard nav.
+
+** DONE Reconcile the panel navigation source of truth :blocking:
+Disposition: accept — folded into "V2 panel UX". V2 (Connections | Diagnostics |
+Performance) is the sole current target; the shipped four-page stack is marked history,
+not active design.
+The spec now names at least three navigation shapes: the shipped four-page stack
+(Connections / Diagnose / Repair / Speed test), the V2 three-tab plan
+(Connections / Diagnostics / Performance), and the redesign task's Diagnostics
+sub-row (Diagnose / Get Me Online / Advanced). That leaves an implementer free
+to keep extra pages and buttons even though Craig is explicitly asking for the
+opposite. Make V2 the sole current target: one panel opened from the bar, top
+tabs =Connections | Diagnostics | Performance=, with Diagnostics owning the
+read-only checks, repair stream, debug capture, doctor report, and related
+diagnostic evidence. Mark the old four-page stack as shipped history only, not
+active design. This blocks the redesign because the page model determines what
+code is deleted, not just labels.
+
+** DONE Fold speed tests into the diagnostic story :blocking:
+Disposition: modify — Craig pre-decided Speed test lives under Performance (decision
+19), and Performance carries future throughput history, which meets this finding's own
+"keep the tab only if it carries ongoing throughput" condition. Accepted the rest:
+Diagnostics runs a lightweight inline latency/throughput probe as a Diagnose evidence
+row (with skip conditions for offline / metered / no-backend), and the full speed
+result is stored in the doctor report. Folded into "V2 panel UX → Diagnostics owns the
+diagnostic story".
+Speed test is currently isolated under =Performance=, while the Goal and user
+mental model treat speed, latency, and packet loss as part of "diagnostics."
+That split risks another top-level button/page whose only job is a diagnostic
+measurement. Keep the top-level =Performance= tab only if it carries ongoing
+throughput/history later; for V2, specify that Diagnostics can run a lightweight
+performance check from the same Diagnose/Get Me Online flow when internet is
+available, and that the full speed test is presented as a diagnostic evidence
+row or secondary action rather than a separate repair-adjacent workflow. Define
+when it is skipped (offline, metered/hotspot warning, missing backend) and how
+the result is stored in the doctor report. This is blocking because otherwise
+the implementation preserves avoidable navigation and misses a useful failure
+signal.
+
+** DONE Define saved-list vs available-scan semantics :blocking:
+Disposition: accept — folded into "V2 panel UX → Connections". Saved / Available now /
+Wired groups; Rescan refreshes only the availability/signal layer, never the Saved
+list.
+=net list= merges saved profiles with in-range scanned networks, while the panel
+copy calls the page "Connections" and the control "Rescan." It is not clear to a
+user whether they are looking at saved connections, currently available
+networks, or both. The current implementation confirms the ambiguity:
+=connections.py= lists saved profiles MRU-first, merges live signal/security for
+saved profiles that are in range, then appends unsaved in-range SSIDs with
+=uuid: nil=. Rename and specify the groups: e.g. =Saved= (instant, does not
+require scan), =Available now= (scan-backed, may still be loading/stale), and
+=Wired=. =Scan= should refresh only the availability/signal layer, not gate the
+saved profile list. This blocks readiness because it affects loading behavior,
+button enablement, and whether unsaved rows can be selected.
+
+** DONE Replace the Add page with join-from-row behavior :blocking:
+Disposition: accept — folded into "V2 panel UX → Connections". Selecting an unsaved
+Available-now row is the join flow (SSID/security prefilled); the standalone Add modal
+is deleted for visible networks; hidden/manual join lives behind Advanced.
+The current Add dialog asks for an SSID as free text even though a scan usually
+already found the SSID and security type. That is redundant UI and a common
+network-manager mistake: it turns "join this visible network" into "copy a name
+from the list and type it again." V2 should remove the standalone Add button and
+modal for normal visible networks. Selecting an unsaved available row should
+become the join flow: the SSID/security are prefilled from the row, open
+networks connect with a confirmation only if needed, WPA/WPA2/WPA3-Personal ask
+only for the password, and hidden/manual SSID is tucked behind an Advanced
+"Join hidden network" affordance. Keep edit/create for enterprise profiles out
+of v1/V2 unless explicitly added later. This blocks the redesign because it
+changes the primary connection workflow and deletes a whole page/control.
+
+** DONE Pin the supported authentication types in the join flow :blocking:
+Disposition: accept — folded into "V2 panel UX → Supported authentication classes".
+The spec says "open + WPA-PSK" and "enterprise activate-only," but cafe/hotel
+networks also commonly appear as open captive portals, WPA/WPA2/WPA3-Personal
+(PSK/SAE), and sometimes transition-mode networks; less commonly they use
+enterprise/802.1X, WEP, OWE/enhanced-open, MAC registration, voucher portals, or
+proxy-required networks. Define the V2 join matrix from the scanned NM
+=SECURITY= value: supported inline (open, captive/open, WPA/WPA2/WPA3 Personal),
+activate-only if already saved (802.1X/enterprise), hidden-manual behind
+Advanced, and unsupported/rare types with a clear in-panel explanation plus a
+non-terminal next step. If an auth type is common enough to support, support it
+in the panel; if it is too rare for V2, say "not supported here yet" and keep
+the user in the same UI rather than sending them to a terminal tool. Also define
+what security label appears in the row so the user knows why a password is or is
+not requested. This blocks because the Add/Join deletion above cannot be
+implemented safely without knowing which auth classes the simplified flow covers.
+
+** DONE Fix destructive confirmation tense and verification
+Disposition: accept — folded into "V2 panel UX → Forget confirmation".
+The Forget confirmation says "The saved password is deleted" before the user has
+clicked Forget. That reads as if the destructive action already happened. Change
+the copy to future tense and name the scope, e.g. "This will remove the saved
+NetworkManager profile and its stored password from this machine." After the
+operation, verify the UUID is gone, refresh the Saved list, and report either
+"Forgot <SSID>" or "Could not forget <SSID>; nothing changed / partial state
+<evidence>." This is non-blocking because the existing confirm prevents an
+accidental click, but the wording is misleading and the V2 "verify every action"
+decision should cover it.
+
+** DONE Make connection loading progressive and observable :blocking:
+Disposition: accept — folded into "V2 panel UX → Connections (progressive loading)".
+Opening the panel currently says "Loading connections..." while =net list=
+collects both saved profiles and the WiFi scan. Saved profiles do not require a
+network scan, so a slow scan should not delay the saved list. Split loading into
+two phases: render saved NM profiles immediately, then overlay availability,
+signal, and unsaved in-range rows when the scan completes. Show a small
+scan-in-progress state with elapsed time and stale-last-scan age, and make
+Rescan update only the scan-backed fields. This blocks because it is the direct
+answer to "why does it take so long to see the list of connections?" and keeps a
+bad radio scan from making the whole panel feel broken.
+
+** DONE Define the visual contract with Waybar and existing Archsetup UI
+Disposition: accept — folded into "V2 panel UX → Visual contract".
+The panel is a layer-shell popup anchored under Waybar, but the spec does not
+state the visual contract. The live Waybar theme uses a dark rounded capsule
+(=border-radius: 1rem=), golden border, compact monospace text, and state colors
+for =custom/net=; the GTK panel currently has a generic title, stack switcher,
+default GTK controls, and square-ish/default widget corners. Add a short style
+section: panel should read as a Waybar-attached popup, not a separate app; match
+Waybar's palette, border/radius, spacing density, and state colors; avoid square
+corners where surrounding UI is rounded; keep cards out of cards; use compact
+icon+label controls with tooltips for advanced repairs. Also cite any existing
+Archsetup-owned GTK/panel conventions that should be reused. This is
+non-blocking for engine work but should block final V2 UX acceptance.
+
+** DONE Add a diagnostics report affordance that users can actually find
+Disposition: accept — folded into "V2 panel UX → Findable diagnostics report".
+The observability design has a JSONL event log, =diagnose --json=, automatic
+verbose capture, and redacted bundles, but the panel flow does not yet define
+the user affordance that turns those into an inspectable diagnosis. Add a
+Diagnostics-side "Copy report" / "Open report" action after every diagnose,
+repair, and speed/performance run. The report should include the current step
+statuses, elapsed time, final classification, last speed/latency result when
+available, scan age, route/interface owner, relevant redacted event-log tail,
+and bundle path when verbose capture ran. It must explicitly say whether any
+repair mutated state and whether cleanup/verification passed. This is blocking
+for observability because "logs exist somewhere" is not enough when the network
+is already failing.
+
+* Review and iteration history
+
+** 2026-06-29 Mon @ 17:00:39 -0400 — Codex — reviewer
+
+- *What changed or was recommended:* Rubric: =Not ready=. Applied the
+ spec-review workflow and added blocking findings for diagnostics structure,
+ user-facing errors, observability, nmcli contracts, cache concurrency,
+ hot-path performance, Waybar click actions, mutation rollback, credential
+ security, unsupported WiFi types, panel states, speed-test dependency
+ verification, install boundaries, and test coverage.
+- *Why:* The spec has the right broad shape, but this feature combines a status
+ indicator, NetworkManager mutation, sudo-backed remediation, captive-portal
+ probing, secret storage, and GTK UI. Too many failure modes were still left for
+ implementers to invent during coding.
+- *Artifacts:* Findings recorded in =Review findings=. Local code read included
+ =~/.dotfiles/hyprland/.local/bin/waybar-netspeed=,
+ =~/.dotfiles/common/.local/bin/captive=,
+ =~/.dotfiles/tests/waybar-netspeed/test_waybar_netspeed.py=,
+ =~/.dotfiles/tests/captive/test_captive.py=, pocketbook's GTK layer-shell
+ scaffold, current Waybar config, and the linked =todo.org= tasks. External
+ checks included the Arch nmcli man page and upstream LibreSpeed CLI page.
+
+** 2026-06-29 Mon @ 17:35:00 -0400 — Claude Code (archsetup) — responder
+
+- *What changed:* Processed all 18 findings — 13 accepted, 5 modified, 0
+ rejected. Accepts added new sections: Repository + dependencies, Performance
+ budgets, nmcli contract, JSON schemas, Diagnostics contract (+ Diagnose vs
+ Repair), Failure states/messages/recovery, Observability, cache
+ concurrency/atomicity/staleness, mutation safety + rollback, the rewritten
+ credential security model, the expanded test plan, JSON schemas, the renamed
+ Implementation phases with per-phase acceptance criteria, and a Status line.
+ Modifies: deferred the =net debug-bundle= command (log + =diagnose --json=
+ cover it), made click actions phase-aware + non-blocking, scoped enterprise
+ WiFi to activate-only (add/edit vNext), trimmed the panel state set to states
+ that occur on the two-machine fleet, and switched the speed-test backend to the
+ already-installed =speedtest-go= (verified live) instead of a new
+ librespeed-cli dependency.
+- *Why:* Codex's review was strong and mostly right; the modifies are scope calls
+ for a single-user, two-machine tool (avoid gold-plating, use installed deps,
+ don't model impossible states) and one phasing reconciliation (the panel is
+ Phase 2, so Phase-1 clicks can't open it).
+- *Artifacts:* All 18 findings completed =DONE= in place with dispositions. Live
+ check: =pacman -Qq | grep speed= (speedtest-go-bin installed; =speedtest-go
+ --json= confirmed). Status now "review incorporated (Codex, 2026-06-29)",
+ implementation-ready with the named Phase-2/3 caveats; Phase 1 ready to build.
+
+** 2026-06-29 Mon @ 17:37:58 -0400 — Claude Code (archsetup) — responder (cj comments)
+
+- *What changed:* Folded in Craig's 10 cj comments on the spec. Design changes:
+ (1) dropped the separate credential store entirely — secrets stay in NM's own
+ =system-connections= (root =0600=, inline), touched via nmcli; no GPG, no
+ gpg-agent (rewrote the secrets section, decision 5, dropped Phase 4 + the gpg
+ dep). (2) Added =net doctor [--fix]= + Makefile console-recovery targets
+ (=make online= etc.) as a first-class TTY path; reversed the earlier
+ defer-the-doctor call (decision 10). (3) Added a full-stack =bounce= repair and
+ an =rfkill= unblock repair + indicator state — the rfkill one recovers the
+ framework-laptop post-power-loss soft-block Craig hit. (4) =custom/net= absorbs
+ the airplane module; the standalone airplane scripts/tests/module are deleted on
+ ship. (5) Moved VPN/WireGuard from "out" to a planned Phase 5. (6) Added a
+ "Help + documentation" section (CLI help / panel help / user guide). Answered
+ the enterprise-defer rationale and the captive-auto-login explanation inline.
+- *Why:* Craig's comments simplify (NM-only secrets, fewer deps) and harden the
+ recovery story (doctor + make targets + rfkill/bounce reachable from a dead-GUI
+ console — his stated need). Enterprise add/edit in v1 is the one open call,
+ raised as a VERIFY.
+- *Artifacts:* All 10 cj blocks removed. Live checks: airplane scripts/tests
+ present (confirmed deletion targets); =/etc/NetworkManager/system-connections/=
+ is root =0600= with inline secrets (confirms the NM-only secret model);
+ =rfkill= present. VERIFY filed in =todo.org= for the enterprise-scope decision.
+
+** 2026-06-29 Mon @ 17:49:24 -0400 — Codex — reviewer
+
+- *What changed or was recommended:* Added a second feedback batch from the
+ common network failure-mode review: require a diagnose/doctor coverage table,
+ pin DNS repair semantics, make auth failures terminal user-action states,
+ define upstream/AP/provider terminal states, decide how active VPN/policy
+ routing affects v1 diagnosis, and remove stale GPG-store references.
+- *Why:* The design now has a solid recovery shape, but the field question is
+ sharper: for each common failure, does =diagnose= catch it, does =doctor --fix=
+ fix it, or does the user need to switch networks/provide credentials/contact
+ the venue? That mapping needs to live in the spec, not in chat.
+- *Artifacts:* Findings appended under =Review findings= after reviewing the
+ current spec's Diagnostics, Failure states, Doctor, Testing plan, and
+ responder history.
+
+** 2026-06-29 Mon @ 18:06:22 -0400 — Codex — reviewer
+
+- *What changed or was recommended:* Added a readiness pass focused on
+ development-time questions, missed enhancements, UX cleanliness, error wording,
+ and test/coverage mechanics. New findings require reconciling stale status/goal
+ text, resolving or explicitly caveating enterprise add/edit, defining the
+ concrete harness and coverage gate, making coverage actionable, turning failure
+ classes into exact user-facing strings, adding an enhancement disposition
+ table, and tightening the Phase-2 panel UX flow.
+- *Why:* The network failure model is now strong, but implementation would still
+ force developers to invent test infrastructure, coverage expectations, final
+ error copy, and several UX details. The spec should decide those up front or
+ explicitly mark them as caveats.
+- *Artifacts:* Findings appended under =Review findings= after reading the
+ updated spec, current archsetup Makefile test targets, pocketbook's pyproject,
+ and the linked =todo.org= enterprise VERIFY.
+
+** 2026-06-29 Mon @ 17:55:08 -0400 — Claude Code (archsetup) — responder (2nd review)
+
+- *What changed:* Processed all 6 second-batch findings — 6 accepted, 0 modified,
+ 0 rejected. Added a "Doctor: escalation, classification, terminal states"
+ section with four outcomes (=fixable= escalating lightest-first; =needs-user-
+ action=, =upstream-not-local=, =deferred/vpn= terminal) and explicit per-class
+ DNS handling. Added a "Failure-mode coverage" table (22 rows: detect / doctor-fix
+ / terminal action, org-table-standard conformant, ≤120). Cleaned the last
+ GPG-store residue (Testing plan → NM-secret/no-leak test; dropped the no-GPG-key
+ panel state). Added a doctor-classification test class.
+- *Why:* The findings sharpen the doctor from "escalate repairs" to "classify,
+ then stop at the right terminal state" — the safety property that keeps
+ =doctor --fix= from looping destructive repairs against a wrong password or an
+ upstream outage. The VPN finding got the detect-and-classify option (not repair;
+ that's Phase 5) so v1 doesn't misread a VPN-routed failure as WiFi down. The
+ coverage table is the single artifact Craig asked for to trust the field
+ coverage.
+- *Artifacts:* All 6 findings =DONE= in place; cookie =[24/24]=. Table conformed
+ via =wrap-org-table.el= (max line 120). No new findings introduced; no scope
+ expansion past what the findings asked. Spec remains implementation-ready, the
+ enterprise add/edit VERIFY still the one open decision.
+
+** 2026-06-29 Mon @ 18:24:51 -0400 — Claude Code (archsetup) — responder (3rd review)
+
+- *What changed:* Processed the third review (7 findings). The enterprise-scope
+ finding closed first on Craig's call (vNext, activate-only). The other 6, all
+ accepted: (1) reconciled the top-of-file text — fixed the Goal's GPG-store
+ wording, the =[#B]= task-mapping line, the resolved enterprise VERIFY, and
+ corrected the stale =pytest= mentions to =unittest= (the repo's real harness).
+ (2) Added a "Harness + coverage gate" section (unittest, fake-binary stubs on a
+ temp PATH, venv coverage, ≥90% branch on pure modules). (3) Added a per-phase
+ "coverage as a gap-finder" pass. (4) Rewrote the Failure states section to exact
+ final strings + evidence fields + a per-surface rendering rule, and added the
+ missing doctor-unfixable rows. (5) Added the "Enhancement radar" table
+ (v1/vNext/rejected). (6) Added the "Panel UX flow" subsection.
+- *Why:* The findings close the gap between "design decided" and "a developer can
+ start": the harness/coverage contract, the exact UX strings, and the panel flow
+ are the things otherwise invented mid-code. The =pytest=→=unittest= correction
+ was a real defect — the spec contradicted the repo's actual test convention.
+- *Artifacts:* All 31 findings =DONE=; cookie =[31/31]=. Both new tables conformed
+ via =wrap-org-table.el= (coverage 120, radar 110). Harness verified against the
+ live repo (33 unittest suites, =make test=, coverage.py absent → venv). Status
+ raised to "Ready for Phase 1; Ready-with-caveats overall" — no open decisions
+ remain.
+
+** 2026-06-30 Tue @ 16:32:07 -0400 — Claude Code (archsetup) — responder (build + V2 redesign)
+
+- *What changed:* Brought the spec current with what shipped and what got decided
+ across 2026-06-29/30. Recorded Phases 1-3 as SHIPPED (engine, indicator, GTK4
+ panel, bar clicks). Added the native captive-login engine (=portal-login= repair
+ tier replacing the =captive= shell-out), the live-testing portal UX fixes (removed
+ the polkit-gated flush, already-online short-circuit, Chrome first-run suppression,
+ in-panel portal, extractor hardening), and the panel auto-hide + Close button.
+ Then folded in the V2 redesign Craig directed: no terminals anywhere, a passwordless
+ root-helper + NOPASSWD sudoers as the enabler, verify-every-action, the full
+ ~44-mode failure catalog (now the authoritative coverage, in todo.org), and the
+ Connections | Diagnostics | Performance nav. Added decisions 15-19, V2 phases
+ 2.1-2.4, and corrected the two failure-table rows the shipped code never matched
+ (gateway-bounce, venue-DNS auto-revert).
+- *Why:* The spec had drifted behind the code (still said "Phase 1 shipped, terminal
+ for privileged ops") and behind Craig's redesign calls. The doc is the contract for
+ the V2 build, so it has to state the no-terminal reversal, the sudo-helper
+ dependency, and where coverage now lives.
+- *Artifacts:* Updated Status, Architecture, Engine CLI surface, Doctor DNS handling,
+ Failure-mode coverage, Interactions, Panel, Resolved decisions, Implementation
+ phases. Shipped commits referenced: dotfiles =a7d7559=, =eef6b0b=, =450b7f0=. Full
+ catalog + redesign children in todo.org "Network panel redesign".
+
+** 2026-06-30 Tue @ 17:29:29 -0400 — Codex — reviewer
+
+- *What changed or was recommended:* Rubric: =Not ready= for the V2 redesign until
+ the new blocking findings are dispositioned. Added a fourth review batch focused on
+ diagnostics/observability and the simple UX questions Craig raised: reconcile the
+ active panel nav, fold performance/speed testing into the diagnostics story, make
+ saved-vs-available list semantics explicit, replace the Add page with join-from-row,
+ pin supported WiFi authentication classes, fix Forget confirmation tense, make
+ connection loading progressive, define the Waybar/Archsetup visual contract, and add
+ a findable redacted diagnostics report affordance.
+- *Why:* The spec has strong engine and failure-mode coverage, but the current doc and
+ shipped implementation still replicate common network-manager UX mistakes: ambiguous
+ mixed lists, scan-gated saved data, redundant SSID entry, misleading destructive
+ copy, diagnostics split across too many pages, and observability that exists in logs
+ more than in the user's workflow.
+- *Artifacts:* Findings appended under =Review findings= after reading the
+ spec-review workflow, the target spec, todo.org's redesign task, and current
+ dotfiles implementation files =net/src/net/connections.py=,
+ =net/src/net/gui.py=, =net/src/net/panel.py=, and Waybar =style.css=.
+
+** 2026-06-30 Tue @ 17:36:40 -0400 — Claude Code (archsetup) — responder (4th review)
+
+- *What changed:* Dispositioned all nine fourth-review findings (8 accept, 1 modify,
+ 0 reject) and wove them into a new "V2 panel UX — the target design" section.
+ Accepts: V2's three-tab nav is the sole target (the four-page stack is history);
+ Connections splits into Saved / Available now / Wired with Rescan touching only the
+ scan layer; selecting an unsaved row is the join flow (the Add modal is deleted);
+ a join matrix pins which NM SECURITY classes are inline-supported / activate-only /
+ hidden / unsupported; loading renders Saved first and overlays the scan; the Forget
+ copy goes future-tense + verified; every run ends with a Copy/Open redacted report;
+ and a Waybar visual contract (rounded capsule, golden border, state colors). Modify:
+ the speed-test finding kept Craig's decision-19 placement (full speed test under
+ Performance, which carries future history) while accepting a lightweight inline
+ latency probe as Diagnose evidence stored in the doctor report. Cookie [40/40].
+- *Why:* Codex read the live implementation and caught the UX places where the module
+ still replicated common network-manager mistakes — mixed lists, scan-gated saved
+ data, redundant SSID entry, misleading destructive copy, diagnostics scattered
+ across pages, observability that lived in logs more than the workflow.
+- *Artifacts:* Findings 32-40 completed in place with dispositions; the modify reason
+ on the speed-test finding. New "V2 panel UX" section under Panel. todo.org redesign
+ task updated to point the V2 build at the dispositioned design.
+
+** 2026-07-01 Wed @ 10:43:18 -0400 — Claude Code (archsetup) — responder (cj comments)
+
+- *What changed:* Folded in Craig's three cj comments from his review. (1) Notification
+ rendering: title = "Networking", body = the failure label on its own line then the
+ canonical string. (2) VPN kill-switch: added a "kill-switch blocking" failure state
+ plus a detection-and-correction strategy off the =deferred-vpn= branch (rootless
+ cascade over =ip rule= fwmark 0xca6c / =wg show= / Proton =pvpn-*= NM connections /
+ =nft=/=iptables= drop tables / firewalld =drop= zone; classify blocking only when a
+ block artifact exists AND no tunnel is up; correction surfaces the exact root command
+ per artifact). (3) Terminals: strengthened decision 15 to "no terminal ever reports
+ to or collects input from the user", disambiguated from the doctor's "terminal
+ states" wording.
+- *Why:* Craig's review annotations. The kill-switch closes a real gap in the
+ VPN-routed classification; the terminal directive makes the no-terminal rule
+ absolute for the module UX.
+- *Artifacts:* Three cj blocks removed. VPN research subagent cited wg-quick man page,
+ Pro Custodibus, System76/Proton killswitch docs, and local =doctor.py:42= /
+ =classify.py:60= / =USNY.conf:15=. One open tension filed as a VERIFY in todo.org:
+ the dead-GUI console-recovery path (=make online= from a TTY) vs the no-terminal
+ directive.
diff --git a/docs/design/2026-06-29-waybar-timer-module-spec.org b/docs/design/2026-06-29-waybar-timer-module-spec.org
new file mode 100644
index 0000000..e8eecbe
--- /dev/null
+++ b/docs/design/2026-06-29-waybar-timer-module-spec.org
@@ -0,0 +1,217 @@
+#+TITLE: Waybar Timer Module (wtimer) — Design Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-29
+
+* Goal
+
+One always-visible waybar module that keeps time four ways — countdown timer,
+wall-clock alarm, count-up stopwatch, and pomodoro — with several items running
+at once. The bar shows the most urgent item with a per-type glyph; the tooltip
+lists them all. Backed by a single =wtimer= script over a small JSON state file.
+notify fires on completion. fuzzel drives creation. No GTK app.
+
+Source task: archsetup =todo.org= "Waybar timer module" (=:waybar:=), including
+the folded roam-capture scope expansion (mode-selectable single panel,
+stopwatch, multiple simultaneous, per-mode hover text).
+
+* Scope
+
+** In
+- *Timer* — count down a duration, notify on elapse, then remove.
+- *Alarm* — fire at a wall-clock time, notify, then remove.
+- *Stopwatch* — count up from start; pause/resume; manual stop.
+- *Pomodoro* — work/break cycles (25/5, long break 15 after 4 works), auto-advance with a notify at each phase change, runs until cancelled.
+- *Multiple simultaneous* — N items of any mix held in state. Bar shows one primary item plus a =+N= badge; tooltip lists every item with its remaining/elapsed and label.
+- *Pause / resume* per item; *cancel* one or all.
+- *Interactions* — click to create (fuzzel), middle-click pause/resume primary, right-click cancel (fuzzel pick), scroll to cycle which item is primary.
+- *Per-type glyph + CSS state classes* (running / paused / urgent / break).
+- *Persistence across waybar restarts* (state file in the runtime dir).
+
+** Out (v1, note for later)
+- No GTK panel — waybar module + tooltip + fuzzel only.
+- No persistence across *reboot* (runtime-dir state clears). Alarms set before a reboot won't survive. Acceptable v1; revisit with =~/.local/state= + a catch-up-on-boot pass if wanted.
+- No sound selection per item (uses notify's type sound).
+- No history/stats of completed pomodoros beyond the current run's cycle count.
+
+* Architecture
+
+- =wtimer= — a single executable Python script in =hyprland/.local/bin/=. Chosen over POSIX sh (the other waybar backings) deliberately: the multi-item state machine, time arithmetic, pomodoro FSM, and JSON I/O are cleaner in Python, and it gives real line/branch *coverage numbers* (Craig asked for them). Precedent: pocketbook is Python in this repo.
+- *Pure core + thin IO shell.* All logic is pure functions taking =now= as a parameter (dependency-injected clock — satisfies testing.md: no recursion, no scope-shadowing, production reads =time.time()=, tests pass an explicit instant). The CLI layer does the IO: read state, call pure fns, write state, emit JSON, shell out to notify/fuzzel.
+- *State file*: =$XDG_RUNTIME_DIR/waybar/wtimer.json= (env override =WTIMER_STATE= for tests). Same runtime-dir convention as =sysmon-metric=.
+- *Heartbeat*: waybar calls =wtimer render= every 1s. =render= runs the tick logic first (detect elapsed items, fire notify, advance pomodoro, drop finished timers/alarms), then prints the waybar JSON. One entry point waybar polls; no separate daemon.
+- *Concurrency (BLOCKER from review).* The 1s =render= and the click/scroll handlers (=add=, =toggle=, =cancel=, =cycle=) are separate processes doing read-modify-write on the same state file. Without serialization, last-writer-wins drops a click's =add=, or clobbers render's "item removed/advanced" write so the same item ticks and notifies again next second. So every read-modify-write takes an exclusive =flock= on the state file for the whole cycle, and writes go through a temp file + =os.replace= (atomic), so a concurrent render never reads a half-written file. This is what actually makes "notify fires once" true — the mutation is only authoritative under the lock.
+- *State dir*: =render= and the mutating commands =mkdir -p= the state dir first (=$XDG_RUNTIME_DIR/waybar/= may not exist on a fresh boot).
+- *Clock injection everywhere*: =now= comes from =WTIMER_NOW= (epoch) if set, else =time.time()=. Pure fns take =now= as a parameter; the CLI seeds it from the env. This lets the CLI integration tests hit boundary instants (exactly-at-target), not just the pure-fn tests.
+- *Instant refresh*: after any mutating command, send waybar =SIGRTMIN+14= (the module's signal) so the bar updates immediately instead of lagging up to 1s. Faked in tests (=WTIMER_REFRESH= override, default =pkill -RTMIN+14 waybar=).
+
+* State model
+
+#+begin_src json
+{
+ "items": [
+ {"id": "1", "type": "timer", "label": "tea", "target": 1751240400, "duration": 300, "paused_left": null},
+ {"id": "2", "type": "alarm", "label": "", "target": 1751251200, "paused_left": null},
+ {"id": "3", "type": "stopwatch", "label": "", "start": 1751240000, "paused_elapsed": null},
+ {"id": "4", "type": "pomodoro", "label": "", "target": 1751241900, "phase": "work",
+ "cycle": 1, "work": 1500, "short": 300, "long": 900, "interval": 4, "paused_left": null}
+ ],
+ "primary": "1",
+ "seq": 4
+}
+#+end_src
+
+- =seq= is the monotonic id source (string ids).
+- *Paused* timer/pomodoro: =paused_left= holds seconds remaining; =target= ignored while paused; resume sets =target = now + paused_left=, =paused_left = null=.
+- *Paused* stopwatch: =paused_elapsed= holds elapsed seconds; resume sets =start = now - paused_elapsed=.
+- =primary= is the id the bar shows; =null= or stale → auto-select (below).
+
+* Display logic
+
+** Primary selection (bar text)
+1. If =primary= names a live item, show it.
+2. Else the running countdown (timer/alarm/pomodoro) with the smallest remaining.
+3. Else the first running stopwatch.
+4. Else idle (no items).
+
+** Bar text
+- =<glyph> <time>= for the primary, plus = +N= when N other items exist.
+- Idle: a dim timer glyph alone (or empty — decide at render; lean dim glyph so the module has a stable click target).
+- =time= formatting: =M:SS= under 1h, =H:MM:SS= at/over 1h. Stopwatch counts up; timer/alarm/pomodoro count down to target.
+- Paused item: prefix a pause glyph or rely on the =paused= class (CSS dims it).
+
+** Glyphs (nerd font; final codepoints verified live before merge)
+- timer 󰔛, alarm 󰀠, stopwatch 󱎫, pomodoro-work , pomodoro-break (coffee), paused 󰏤, idle 󰔛 (dim).
+- One glyph table at the top of the script so a live-render tweak is one edit.
+
+** Tooltip (all items)
+One line per item: =<glyph> <label-or-type> <remaining/elapsed> (<state>)=. Pomodoro line shows phase + cycle (e.g. =work 2/4=). Header line summarizes count. Empty state: "No timers".
+
+** CSS classes (the =alt=/=class= field)
+=timer= / =alarm= / =stopwatch= / =pomodoro-work= / =pomodoro-break=, plus =paused= and =urgent= (remaining < 60s). Drives color in style.css + both themes.
+
+* Commands (CLI)
+
+| Command | Effect |
+|---------------------------------+---------------------------------------------------------------------|
+| =wtimer render= | tick + emit waybar JSON (the heartbeat) |
+| =wtimer add timer <dur> [label]=| add a countdown (=dur= like =25m=, =90s=, =1h30m=, =5= → minutes) |
+| =wtimer add alarm <HH:MM> [lbl]=| add a wall-clock alarm (next occurrence of that time) |
+| =wtimer add stopwatch [label]= | start a count-up |
+| =wtimer add pomodoro [label]= | start a pomodoro at work phase |
+| =wtimer new= | fuzzel: pick type, prompt value, dispatch to =add= (thin wrapper) |
+| =wtimer toggle [id]= | pause/resume the item (default: primary) |
+| =wtimer cancel <id>= | remove one item |
+| =wtimer pick-cancel= | fuzzel: choose an item to cancel (right-click handler) |
+| =wtimer cancel-all= | clear all |
+| =wtimer cycle [next|prev]= | move the primary pointer across all items (incl. paused), state-list order, wrapping |
+
+Duration parse: =Nh=, =Nm=, =Ns= combos, or a bare integer = minutes. Reject
+unparseable input (exit non-zero, notify nothing). Alarm parse: =HH:MM= 24h; if
+that time today already passed, target tomorrow.
+
+* Notifications
+
+- Timer elapse: =notify alarm "Timer" "<label or duration> done" --persist=.
+- Alarm fire: =notify alarm "Alarm" "<HH:MM><, label>" --persist=.
+- Pomodoro phase change: =notify info "Pomodoro" "Work → short break (3/4)"= (no =--persist=; phase nudges shouldn't pile up), long-break and work-resume worded accordingly.
+- notify is faked on PATH in tests; assert type + that it fired once per event.
+
+* Pomodoro semantics
+
+- Defaults: work 25m, short 5m, long 15m, interval 4 (long break after every 4th work).
+- FSM: work → short → work → short → work → short → work → long → work …
+- =cycle= counts completed works in the current set (1..interval); resets after a long break.
+- Each phase elapse advances =phase=, recomputes =target=, fires the phase notify. Pomodoro never auto-removes; cancel ends it.
+
+* Waybar wiring
+
+** Module def (config) — signal 14 (next free; 8–13 used)
+#+begin_src json
+"custom/timer": {
+ "exec": "wtimer render",
+ "return-type": "json",
+ "interval": 1,
+ "signal": 14,
+ "on-click": "wtimer new",
+ "on-click-middle": "wtimer toggle",
+ "on-click-right": "wtimer pick-cancel",
+ "on-scroll-up": "wtimer cycle next",
+ "on-scroll-down": "wtimer cycle prev"
+}
+#+end_src
+
+** Position — right of the sysmon (battery/resource) module
+Insert =custom/timer= into =modules-right= immediately after =custom/sysmon=
+(between =custom/sysmon= and =custom/netspeed=). On screen that places it just
+right of the battery/resource readout.
+
+** Not collapsible — survives the right-side collapse
+The module *definition* lives in the canonical config object, and =waybar-collapse=
+only swaps the =modules-right= *array* in the runtime copy (which it seeds from
+canonical, so the def is always present). So making the timer non-collapsible is
+purely an array-membership change: add =custom/timer= to the =waybar-collapse=
+right *base set* so it stays listed when the right side collapses:
+- laptop: =["custom/arrow-right","custom/sysmon","custom/timer","tray","custom/date","custom/worldclock"]=
+- desktop: =["custom/arrow-right","custom/timer","tray","custom/date","custom/worldclock"]=
+Update the =tests/waybar-collapse= base-set expectations to match (TDD the change).
+
+* 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.
+- =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.
+
+* Testing plan (TDD)
+
+- Suite: =tests/wtimer/test_wtimer.py= (auto-discovered by =make test='s =tests/*/test_*.py= glob — no enumeration gap).
+- *Pure-function tests* (fast, the bulk), explicit injected =now=:
+ - =parse_duration=: =25m=, =90s=, =1h30m=, =5= (→min), =0=, negative, garbage, empty (Normal/Boundary/Error).
+ - =parse_alarm=: future today, already-passed-today → tomorrow, =00:00=, =23:59=, =24:00=/=12:60= invalid, non-=HH:MM=.
+ - =format_time=: 0, 59s, 60s, 3599s, 3600s, multi-hour, negative clamps to 0.
+ - =add_item= for each type; =seq= increments; ids unique.
+ - =tick=: timer not-yet-elapsed (no change), exactly-at-target, past-target (fires once, removed); alarm same; pomodoro work→short→…→long→work advance + cycle counting + the 4th-work→long boundary; paused items never tick; multiple items in one tick.
+ - =select_primary=: explicit primary, stale primary falls back, soonest-remaining rule, stopwatch-only, empty.
+ - =render_payload=: text/tooltip/class for each type + paused + urgent + =+N= badge + idle.
+ - =toggle= pause then resume round-trips remaining/elapsed exactly; =cycle= wraps; =cancel= / =cancel-all=.
+- *CLI integration tests* (subprocess, fakes on PATH, =WTIMER_NOW= to hit boundaries): =add= then =render= round-trip; =render= fires the faked =notify= once on an elapsed item and drops it; state file created if absent; *missing parent dir* created (fresh-boot case); corrupt/empty state file → treated as empty, no crash; mutating command sends the faked refresh signal.
+- *Concurrency test*: spawn overlapping =render= + a mutating command against one state file; assert no lost update (the added item survives) and exactly-once notify (no double-fire from a clobbered tick). This is the regression guard for the flock/atomic-write fix.
+- *Mocking boundary*: fake =notify=, =fuzzel=, =killall= on PATH (record calls); never mock the wtimer logic. Clock injected as a parameter.
+- *Coverage*: measure with =coverage.py= if present (target 90%+ on the logic per testing.md business-logic bar); report the actual number. If =coverage= is absent, report per-command/per-branch case coverage explicitly and flag the tool gap (verification.md).
+- =tests/waybar-collapse= base-set expectations updated for the new module.
+- =tests/= theme-drift check stays green (CSS parity).
+
+* Files touched
+
+dotfiles branch =waybar-timer-module=:
+- =hyprland/.local/bin/wtimer= (new, executable)
+- =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)
+
+archsetup (main, at the end):
+- this spec
+- =todo.org= task closure
+
+* Resolved decisions (no approvals — my calls)
+
+- Python, not sh — testability + coverage; pocketbook precedent.
+- One =render= heartbeat (no daemon) — simplest, waybar already polls.
+- notify fires from =render='s tick, mutation guarantees once-only.
+- Primary = user-cycled, else soonest-remaining; =+N= badge for the rest.
+- Multiple simultaneous via tooltip list + badge (not a GTK panel) — keeps it "cool yet simple".
+- Pomodoro is one self-advancing item, not four chained timers.
+- Runtime-dir state (waybar-restart durable, not reboot durable) — v1.
+
+* Rollback
+
+All code on the dotfiles =waybar-timer-module= branch off =09815f3=. Squash-merge
+at the end; =git switch main && git branch -D waybar-timer-module= reverts cleanly
+if it goes sideways.
diff --git a/docs/design/2026-06-29-zfs-pre-snapshot-installer.org b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org
new file mode 100644
index 0000000..e5a339e
--- /dev/null
+++ b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org
@@ -0,0 +1,106 @@
+#+TITLE: ZFS pre-pacman snapshot installer step (durable retention)
+#+DATE: 2026-06-29
+#+SOURCE: handoff from the home project, 2026-06-29
+
+* Problem
+
+A pacman =PreTransaction= hook snapshots =zroot/ROOT/default@pre-pacman_<ts>=
+before every transaction, but nothing prunes them. Sanoid doesn't manage them
+(they aren't =autosnap_= names), so they accumulated to 53 on velox between
+April and the 2026-06-29 health check. Unbounded, they fill the pool over time.
+
+* What's actually on velox vs. archsetup
+
+The live =/usr/local/bin/zfs-pre-snapshot= is *not* authored by archsetup —
+=git grep= for its content (=MIN_INTERVAL=, the pre-pacman =LOCKFILE= logic)
+finds nothing tracked. The =PreTransaction= hooks in the archsetup monolith
+(~lines 910, 1907, 1942) are the live-update guard, a different hook. The
+script appears hand-placed on velox.
+
+The 2026-01-17 security doc line "ZFS pre-pacman snapshots (already in
+install-archzfs)" is therefore *out of date* — archsetup does not install this.
+Incorporating the fix is a NET-NEW installer step, not a patch to an existing
+one. Correct that stale doc line as part of the work.
+
+velox was patched live (pruned to 10, script replaced with the self-pruning
+version below); live backup at =/usr/local/bin/zfs-pre-snapshot.bak-2026-06-29=.
+
+* Proposed installer step
+
+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
+ runs the script. *Not included in the handoff* — source it from velox
+ (=/etc/pacman.d/hooks/zfs-snapshot.hook=) or write it.
+2. =/usr/local/bin/zfs-pre-snapshot= — the =KEEP=10= self-pruning version
+ below.
+
+Tests live in archsetup, so this wants an archsetup session and a ZFS-root VM
+test (=make test FS_PROFILE=zfs=), not a cross-project edit from home.
+
+* The script (KEEP=10 self-pruning version)
+
+#+begin_src bash
+#!/bin/bash
+POOL="zroot"
+DATASET="$POOL/ROOT/default"
+LOCKFILE="/tmp/.zfs-pre-snapshot.lock"
+MIN_INTERVAL=60
+KEEP=10 # how many pre-pacman snapshots to retain (rollback safety for recent transactions)
+
+# Skip if a snapshot was created within the last 60 seconds
+if [ -f "$LOCKFILE" ]; then
+ last=$(stat -c %Y "$LOCKFILE" 2>/dev/null || echo 0)
+ now=$(date +%s)
+ if (( now - last < MIN_INTERVAL )); then
+ exit 0
+ fi
+fi
+
+TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
+SNAPSHOT_NAME="pre-pacman_$TIMESTAMP"
+
+if zfs snapshot "$DATASET@$SNAPSHOT_NAME"; then
+ echo "Created snapshot: $DATASET@$SNAPSHOT_NAME"
+ touch "$LOCKFILE"
+
+ # Retention: keep only the most recent $KEEP pre-pacman snapshots, destroy older ones.
+ # Sanoid does not manage these (they aren't autosnap_), so prune them here at creation time.
+ zfs list -H -o name -t snapshot -s creation "$DATASET" 2>/dev/null \
+ | grep '@pre-pacman_' \
+ | head -n -"$KEEP" \
+ | while read -r old; do
+ zfs destroy "$old" && echo "Pruned old snapshot: $old"
+ done
+else
+ echo "Warning: Failed to create snapshot" >&2
+fi
+#+end_src
+
+* Implementation (2026-06-30)
+
+- Hook sourced from velox (=/etc/pacman.d/hooks/zfs-snapshot.hook=) and embedded
+ as a heredoc in =configure_pre_pacman_snapshots()=.
+- 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
+ the fresh system. The script ships as =scripts/zfs-pre-snapshot= (the
+ =zfs-replicate= pattern), made =ZFS_PRE_*=-env-overridable for testability.
+- Tests: =tests/zfs-pre-snapshot/= unit-tests the pruning logic against a fake
+ =zfs= (creates, prunes oldest-past-KEEP, ignores non-=pre-pacman_= snapshots,
+ honors the lockfile, warns on snapshot failure); =test_boot.py= asserts the
+ hook + script land on a ZFS install; the orchestrator test pins the new
+ =boot_ux= substep.
+
+* Note on the "stale security doc"
+
+The 2026-01-17 line "ZFS pre-pacman snapshots (already in install-archzfs)" is
+*not* stale: that file is an archive generated by install-archzfs (see its
+header and footer), and the claim is accurate for install-archzfs. The real gap
+was that archsetup took sanoid from install-archzfs but never ported the
+pre-pacman hook. This change ports it. The archive is left untouched.
+
+* Remaining
+
+- ZFS-root VM verification (=make test FS_PROFILE=zfs=) before the task closes.
diff --git a/docs/design/2026-06-30-captive-portal-login.org b/docs/design/2026-06-30-captive-portal-login.org
new file mode 100644
index 0000000..1739689
--- /dev/null
+++ b/docs/design/2026-06-30-captive-portal-login.org
@@ -0,0 +1,89 @@
+#+TITLE: Captive-portal login — learnings + baking it into the net panel
+#+DATE: 2026-06-30
+#+SOURCE: the 2026-06-30 Hyatt wifi saga (velox)
+
+* Why this exists
+
+On a locked-down-DNS laptop, captive portals never show their login page, even
+though phones get on fine. We spent hours on a Hyatt portal before finding the
+mechanism; this captures it so the fix becomes a panel feature instead of a
+one-off script.
+
+* The mechanism (what actually blocks the login)
+
+A redirect portal works by *DNS hijack*: you query a name, the hotel's resolver
+hands back the portal, you get the login page. Two things on velox stop that:
+
+- *System resolver forces DNS-over-TLS.* =/etc/systemd/resolved.conf.d/dns-over-tls.conf=
+ hardcodes =DNS=1.1.1.1#... 9.9.9.9#...= with =DNSOverTLS=yes=. The system never
+ queries the hotel's resolver at all. The hotel blocks 853 (DoT) and external
+ 53, so system DNS is simply dead on the portal — only 443 (DoH) gets out.
+- *Browser DoH.* Chrome "secure DNS" on bypasses the hotel DNS too, so the
+ browser never gets redirected either.
+
+A phone works because it uses *plain DNS* from the hotel plus a built-in
+captive-portal popper. The laptop has neither.
+
+Confirmed facts from the saga:
+- Front desk: it's a normal redirect-to-login portal. Phone: connects fine.
+- No DHCP option 114 (RFC 8910) — the portal doesn't advertise its URL. But the
+ URL is recoverable from the HTTP 302 once you're on plain DNS.
+- The walled garden whitelists OS captive-detection endpoints
+ (=captive.apple.com= returns "Success") — a *misleading* signal, not real
+ internet. Don't trust it.
+- 443/DoH egress works broadly on the portal; only port-53 DNS is held. So
+ "system DNS fails" never means "no internet" here.
+
+* The working fix (=~/.local/bin/hotel-wifi=, to be folded in)
+
+Temporarily disable DoT → plain hotel DNS → discover the portal URL from the
+redirect → open it in a clean browser profile (no DoH, no stale HSTS/cookies) →
+click the button → restore DoT. Reversible; tested to restore cleanly.
+
+#+begin_src sh
+#!/bin/sh
+# hotel-wifi disable DoT -> find the portal login URL -> open it
+# hotel-wifi off restore normal encrypted DNS (run once online)
+conf=/etc/systemd/resolved.conf.d/dns-over-tls.conf
+if [ "${1:-on}" = "off" ]; then
+ [ -f "$conf.captive-disabled" ] && sudo mv "$conf.captive-disabled" "$conf"
+ sudo systemctl restart systemd-resolved
+ echo "Encrypted DNS (DoT) restored."; exit 0
+fi
+[ -f "$conf" ] && sudo mv "$conf" "$conf.captive-disabled"
+sudo systemctl restart systemd-resolved; sleep 1
+resolvectl flush-caches 2>/dev/null || true
+portal=""
+for t in http://captive.apple.com/hotspot-detect.html http://neverssl.com \
+ http://detectportal.firefox.com/canonical.html; do
+ loc=$(curl -sS -m 6 -o /dev/null -w '%{redirect_url}' "$t" 2>/dev/null)
+ [ -n "$loc" ] && { portal="$loc"; break; }
+ url=$(curl -sS -m 6 "$t" 2>/dev/null | grep -ioE 'https?://[^"'"'"' >]+' \
+ | grep -ivE 'apple\.com|neverssl|firefox|w3\.org|gstatic' | head -1)
+ [ -n "$url" ] && { portal="$url"; break; }
+done
+prof=$(mktemp -d)
+setsid -f google-chrome-stable --user-data-dir="$prof" "${portal:-http://neverssl.com}" >/dev/null 2>&1
+echo "Click the login button. When online: hotel-wifi off"
+#+end_src
+
+* Baking it into the net panel (the task)
+
+- The net engine already diagnoses captive / no-internet. When it sees a held
+ portal, the panel should offer a first-class *"Log in to this network"*
+ action that runs the plain-DNS + clean-browser flow above, reversibly, and
+ auto-restores DoT when connectivity returns (or on a timeout).
+- Reconcile with the existing =net portal= command and the =captive= helper —
+ they assumed a DNS-hijack-to-gateway model that did NOT match this portal
+ (gateway served no web; DNS was held, not hijacked-to-portal). The plain-DNS
+ approach is the one that worked; make it the engine's portal path.
+- The DoT toggle must be safe and reversible (the =off= step). Consider a
+ per-connection or time-boxed DoT-off that can't strand encrypted DNS.
+- Surface the misleading-"Success" lesson: a whitelisted captive-check passing
+ is not "online" — gate on a real, non-whitelisted fetch.
+
+* Related fix that unblocked the panel (already shipped)
+
+The panel could never switch networks because =net up= placed =--wait= after the
+nmcli subcommand (it's a global option). Fixed in dotfiles 2432311; fake-nmcli
+now rejects the misplaced flag so it can't regress.
diff --git a/docs/design/2026-07-02-waybar-expansion-animation-feasibility.org b/docs/design/2026-07-02-waybar-expansion-animation-feasibility.org
new file mode 100644
index 0000000..cb195c6
--- /dev/null
+++ b/docs/design/2026-07-02-waybar-expansion-animation-feasibility.org
@@ -0,0 +1,53 @@
+#+TITLE: Waybar Expansion Animation — Feasibility Assessment
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-02
+
+* Question
+
+The todo.org task "Smooth waybar expansion animation" [#C]: the collapse/expand
+jump is abrupt, and a few systray icons pop in one-by-one afterward. Can the
+expansion animate smoothly?
+
+* How the collapse actually works
+
+=waybar-collapse= rewrites the module arrays in a runtime copy of the config
+(=$XDG_RUNTIME_DIR/waybar/config=) and sends waybar SIGUSR2. Waybar reloads:
+it tears down every module widget and rebuilds the bar from the new config.
+
+* Findings
+
+1. *No widget survives the reload, so nothing can transition.* GTK3 CSS
+ transitions animate property changes on live widgets. The collapse
+ mechanism replaces the whole widget tree; there is no widget on both sides
+ of the change to interpolate. The jump is structural, not stylistic.
+2. *GTK3 doesn't animate widget add/remove anyway.* Smooth insert/remove needs
+ =GtkRevealer= wrapping, which waybar does not use for modules. Making it do
+ so is an upstream waybar patch, not a config or CSS matter.
+3. *The layer surface resize isn't animatable either.* Hyprland layerrules can
+ animate map/unmap (slide/fade), but the bar stays mapped through a collapse
+ — the same surface changes width. No compositor-side hook exists for that.
+4. *A CSS-only fake covers custom modules at best.* Custom modules could emit
+ a "collapsed" class and transition font-size/padding toward zero (GTK3 CSS
+ can animate those). But the collapsed set includes built-ins — tray,
+ pulseaudio, workspaces — which take no script-driven classes. The result
+ would be half the modules gliding and half popping: worse than the clean
+ jump.
+5. *Tray icons popping in one-by-one is separate and unfixable here.* That's
+ asynchronous StatusNotifier re-registration after the reload; each app
+ answers on its own schedule. Only keeping the tray alive across the change
+ (i.e. not reloading) avoids it.
+
+* Conclusion
+
+Not feasible with the current collapse mechanism, and no acceptable partial
+measure exists. A real animation requires waybar itself to support dynamic
+module sets with Revealer-style transitions (an upstream feature), or
+replacing the collapse-by-reload design entirely.
+
+* Recommendation
+
+Close the task as infeasible-for-now (or park at [#D] with a pointer here).
+Revisit only if waybar upstream gains dynamic module visibility (worth a
+check at major waybar releases) or if the bar ever migrates to a custom
+GTK4 shell — the Blueprint pipeline from the net panel would make Revealer
+transitions natural there.
diff --git a/docs/design/2026-07-10-audio-failure-taxonomy.org b/docs/design/2026-07-10-audio-failure-taxonomy.org
new file mode 100644
index 0000000..efb6e0a
--- /dev/null
+++ b/docs/design/2026-07-10-audio-failure-taxonomy.org
@@ -0,0 +1,444 @@
+#+TITLE: Linux Audio Failure Taxonomy — for the audio doctor
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+
+* Purpose
+
+A catalogue of real, user-reported ways Linux audio input (microphone) and output (speakers/headphones) fail, grounded in forum and issue-tracker reports. Built to feed the audio doctor: each entry will later be triaged into build (an auto/confirm remedy the doctor applies), guide (a diagnosis plus the exact fix the user runs), or out (beyond the doctor's reach). Triage is a later pass — for now the goal is breadth.
+
+Target: 50 input failure modes and 50 output failure modes, or exhaustion. Each entry names the layer, the symptom as a user reports it, the root cause, the known resolution, and a source.
+
+** Saturation test (2026-07-10)
+
+To check whether the nine-cluster-per-direction taxonomy was representative or an artifact of how we first sampled, a blind resample: ten agents drew 108 fresh failure reports through five different doors per direction (specific hardware lines, external devices, non-Arch distros, broad community Q&A, and call/gaming apps), weighted toward 2024-2026 and non-Arch sources, none of them shown the clusters or the existing entries. Every result was then classified against the existing 18 clusters.
+
+Result: zero of 108 needed a new cluster. Roughly 70 re-found entries already in the catalogue (many hitting the exact same bug and source URL we had cited independently), which is entry-level saturation. The other ~34 were genuinely new distinct root causes, and all of them slotted into existing clusters. The new results spread across every cluster and concentrated where the original catalogue did (cluster 1 firmware/driver, and the app cluster). The app cluster grew densest, which confirms the Chrome incident that started this was representative, not a fluke.
+
+Conclusion: the clusters — what the doctor's probe ladder and verdict structure rest on — are complete and stable. Entries will keep growing (hardware × kernel × app is unbounded), but the design can rest on the taxonomy. The ~34 new entries the sweep surfaced are folded in below, under a "Saturation resample additions" heading in each direction, each carrying its cluster and remedy class inline.
+
+* Input (microphone) failure modes
+
+Built 2026-07-10 from a parallel research sweep across eight layers (kernel/driver/firmware, ALSA, PipeWire/WirePlumber, Bluetooth, USB, app/portal, hardware, config/misc). 64 raw entries deduped to 58 distinct root causes. Each names the layer, the user-reported symptom, the cause, the known fix (with the concrete command where one exists), whether the fix needs root or a reboot, and a source. The =sudo/reboot= field is the first triage signal: a no-sudo, no-reboot fix is a candidate for an auto or guide remedy; a both/physical fix can only ever be a guide.
+
+** Kernel / driver / firmware
+
+- Missing SOF firmware for a DSP-attached mic (both). Internal mic not recognised on a modern Intel laptop; speakers fine. Digital mic hangs off the Intel DSP and needs Sound Open Firmware. Fix: =pacman -S sof-firmware alsa-ucm-conf=, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=287361][arch 287361]]
+- Stale =dmic_detect=0= / =dsp_driver=1= modprobe override (both). Mic dead after an old "fix"; no DMIC input. A leftover =/etc/modprobe.d= option forces the legacy HDA path and bypasses the SOF driver that owns the digital mic. Fix: delete the override, install sof-firmware, rebuild initramfs, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=260827][arch 260827]]
+- Missing codec model / ADC quirk, ALC236/ALC256 (both). Capture controls visible and unmuted, but =arecord= records silence. =snd_hda_intel= lacks the board-specific pin/ADC-gain quirk, so the ADC is never routed. Fix: =options snd-hda-intel model=...= workaround; real fix is a kernel patch_realtek entry. [[https://bugs.launchpad.net/ubuntu/+source/alsa-driver/+bug/2142994][launchpad 2142994]]
+- Kernel-version regression broke a working mic (both). Internal mic recorded fine until a kernel update, then stopped. A regression between builds broke ALC256 capture routing. Fix: boot the last-good kernel or install linux-lts until a fixed build lands. [[https://bugs.launchpad.net/ubuntu/+source/linux/+bug/2117316][launchpad 2117316]]
+- AMD ACP6x DMIC needs a DMI quirk, not firmware (both). Ryzen laptop internal array mic absent or silent; sof-firmware changes nothing. The acp6x mic array needs the laptop's DMI entry in the =SND_SOC_AMD_YC_MACH= quirk table. Fix: newer kernel with the board's DMI quirk, or add the entry and rebuild. [[https://bbs.archlinux.org/viewtopic.php?id=311093][arch 311093]]
+- Missing NHLT ACPI table in firmware (both). No DMIC; dmesg shows "NHLT table not found". The platform firmware omits the NHLT table, so SOF can't discover the digital-mic count. Fix: flash firmware that supplies NHLT, or override the dmic count via SOF modprobe params. [[https://github.com/MrChromebox/firmware/issues/920][mrchromebox 920]]
+- Brand-new AMD ACP7.0 (Strix Halo) has no mic driver/UCM yet (both). Internal DMIC non-functional on very recent AMD hardware. The kernel ACP7.0 SOF driver and UCM profile weren't upstreamed yet. Fix: wait for the kernel + alsa-ucm-conf that add ACP7.0 support. [[https://github.com/alsa-project/alsa-ucm-conf/issues/745][ucm-conf 745]]
+
+** ALSA (profiles, UCM, mixer, pins, state)
+
+- Wrong card profile, no input route (no). Speakers work, no capture device appears, only a ".monitor". The card sits on an output-only profile so ALSA never exposes the capture PCM. Fix: =pactl set-card-profile <card> output:analog-stereo+input:analog-stereo=. [[https://bbs.archlinux.org/viewtopic.php?id=269173][arch 269173]]
+- ACP vs UCM detection mismatch (no). alsa-card-profiles fixes the mic but PipeWire breaks it again. WirePlumber's ACP probing (or an auto-selected UCM) mis-detects the capture ports. Fix: set =api.alsa.use-acp = false= (or =use-ucm = true=) in WirePlumber, restart wireplumber. [[https://bbs.archlinux.org/viewtopic.php?id=291807][arch 291807]]
+- Capture / Internal Mic switch muted in mixer (no). Mic recognized but records silence. The "Capture" or "Internal Mic" mixer switch is toggled off in ALSA state. Fix: =amixer set Capture cap= (or Space on the item in alsamixer). [[https://wiki.archlinux.org/title/Advanced_Linux_Sound_Architecture/Troubleshooting][archwiki alsa]]
+- Input Source enum on the wrong pin (no). Plugged-in mic silent; the wrong jack is live. The "Input Source" control selects a different pin (Rear vs Front Mic). Fix: =amixer set 'Input Source' 'Front Mic'=. [[https://bbs.archlinux.org/viewtopic.php?id=145531][arch 145531]]
+- alsactl restores a muted capture at boot (yes). Mic works after manual unmute, muted again every reboot. alsa-restore replays a saved state with capture muted. Fix: unmute, then =sudo alsactl store=. [[https://bbs.archlinux.org/viewtopic.php?id=183211][arch 183211]]
+- Internal Mic Boost at zero (no). Mic works but far too quiet to use. The "Mic Boost" gain sits at 0. Fix: =amixer set 'Internal Mic Boost' 100%=. [[https://forum.manjaro.org/t/microphone-array-input-too-quiet-alsamixer-mic-boost-not-changing-anything/90904][manjaro 90904]]
+- Auto-Mute Mode disabling capture (no). Internal mic cuts out, often tied to headphone-jack state. The codec's "Auto-Mute Mode" mutes internal capture when a jack is sensed. Fix: =amixer set 'Auto-Mute Mode' Disabled=. [[https://wiki.archlinux.org/title/Advanced_Linux_Sound_Architecture/Troubleshooting][archwiki alsa]]
+- Wrong default capture in .asoundrc (no). Apps with no picker record from the wrong card. =pcm.!default= points at the wrong capture card. Fix: set =pcm.!default= to the right =plughw= in =~/.asoundrc=; find it with =arecord -l=. [[https://bbs.archlinux.org/viewtopic.php?id=274238][arch 274238]]
+- Missing UCM capture profile for the codec (no). No input profile offered at all for the built-in mic. alsa-ucm-conf lacks a UCM definition for the card. Fix: fall back to ACP (=use-ucm = false=) or install/patch the card's UCM config. [[https://github.com/alsa-project/alsa-ucm-conf/issues/594][ucm-conf 594]]
+
+** PipeWire / WirePlumber
+
+- WirePlumber fails to start on old/empty config (no; sudo only if under /etc). No audio devices at all after an update; wpctl lists nothing. WirePlumber won't parse a leftover 0.4-era config or empty drop-in and dies. Fix: remove the offending config, =systemctl --user restart wireplumber=. [[https://bbs.archlinux.org/viewtopic.php?id=294649][arch 294649]]
+- Capture source muted on startup, stale WP state (no). Mic dead every boot until you mute then unmute it. Stale state in =~/.local/state/wireplumber= restores the source muted. Fix: stop wireplumber, =rm -r ~/.local/state/wireplumber=, start; or =wpctl set-mute <id> 0=. [[https://bbs.archlinux.org/viewtopic.php?id=297727][arch 297727]]
+- Default source is a monitor / wrong device, and not persisted (no). Apps pick up silence or loopback; the "mic" is a sink Monitor, or the default resets to the wrong card each reboot. WirePlumber's auto-selection picks the wrong node and doesn't persist a manual default. Fix: =wpctl set-default <mic-id>=, and pin it with a node.name rule to survive reboot. [[https://bbs.archlinux.org/viewtopic.php?id=285076][arch 285076]]
+- pipewire-pulse dead or wrong socket (no). pactl apps report "connection refused"; pw-cat may still work. pipewire-pulse isn't running, or XDG_RUNTIME_DIR is wrong so =/run/user/1000/pulse/native= is missing. Fix: correct XDG_RUNTIME_DIR, =systemctl --user restart pipewire pipewire-pulse wireplumber=. [[https://bbs.archlinux.org/viewtopic.php?id=281062][arch 281062]]
+- Node suspended, never resumes (no). Mic works once, then silent when idle; drop when an app reopens it. =session.suspend-timeout-seconds= parks the source and it fails to wake on some 0.5.x builds. Fix: drop-in setting =session.suspend-timeout-seconds = 0=, restart wireplumber. [[https://bbs.archlinux.org/viewtopic.php?id=309630][arch 309630]]
+- A filter/virtual source shadows the real mic (no). Only a virtual "echo-cancel" or EasyEffects source shows; apps capture nothing, or apps get the raw unfiltered mic instead of the processed one. module-echo-cancel / filter-chain / EasyEffects creates a virtual source with the wrong node.target or default. Fix: fix node.target, or =wpctl set-default <right-id>=. [[https://docs.pipewire.org/page_module_echo_cancel.html][pw echo-cancel]]
+- Broken ~/.config/wireplumber override disables the node (no). Internal mic stops defaulting after a config edit; system configs seem ignored. A malformed or disabling user drop-in shadows the system defaults. Fix: remove/repair the drop-in, restart wireplumber. [[https://discussion.fedoraproject.org/t/wireplumber-not-connecting-microphone/63354][fedora 63354]]
+- Forced or mismatched sample rate locks the source (no). Mic stuck at 48 kHz, or silent, or apps fail to open it; also seen on USB interfaces that only do 48k. A global =default.clock.rate= / =node.force-rate= pins the graph to a rate the device or app can't negotiate, with no allowed-rates fallback. Fix: set =default.clock.allowed-rates= to a list rather than forcing one, restart pipewire; runtime =pw-metadata -n settings 0 clock.force-rate 0=. [[https://bbs.archlinux.org/viewtopic.php?id=288932][arch 288932]]
+
+** Bluetooth
+
+- Headset stuck in A2DP, no mic input (no). Headphones play great but the mic never appears. A2DP is sink-only, no microphone source. Fix: =pactl set-card-profile <bt-card> headset-head-unit=. [[https://bbs.archlinux.org/viewtopic.php?id=280718][arch 280718]]
+- oFono installed but unconfigured, HFP profile absent (yes, to remove pkg). No HFP/HSP profile listed after moving to PipeWire. oFono grabs the HFP backend but is never configured. Fix: remove oFono (=pacman -R ofono=) or set =bluez5.hfphsp-backend = "native"=, restart pipewire. [[https://bbs.archlinux.org/viewtopic.php?id=269195][arch 269195]]
+- mSBC codec not enabled / unavailable (no). Headset won't offer mSBC; mic is muffled CVSD or missing. mSBC role/codec not enabled in WirePlumber. Fix: set =bluez5.codecs = [ sbc sbc_xq msbc ]= and enable =hfp_hf=, restart wireplumber. [[https://bbs.archlinux.org/viewtopic.php?id=266252][arch 266252]]
+- Auto-switch to headset profile never triggers (no). Open a call app and the mic stays dead; nothing flips to HFP. WirePlumber's autoswitch isn't firing. Fix: confirm autoswitch is on, or switch manually with set-card-profile. [[https://forum.endeavouros.com/t/my-bluetooth-headphone-mic-not-reconisied-after-switching-to-pipewire/17586][endeavour 17586]]
+- Codec negotiation silently drops to baseline (no). Mic works but quality is terrible; preferred codec never applies. Negotiation fails and PipeWire quietly falls back. Fix: pin codec order in a WirePlumber =bluez5.codecs= rule, restart wireplumber. [[https://bbs.archlinux.org/viewtopic.php?id=300230][arch 300230]]
+- Adapter/headset rejects hsp_ag and hfp_ag together (no). Enabling both roles breaks the profile on some headsets (Sony WH-1000XM3). Fix: restrict roles to =hfp_hf=/=hfp_ag= (drop =hsp_ag=), restart wireplumber. [[https://pipewire.pages.freedesktop.org/wireplumber/daemon/configuration/bluetooth.html][wp bluetooth]]
+- BT mic invisible to arecord / /proc/asound (no). ALSA tools show no Bluetooth mic, so the user thinks it's undetected. The bluez5 mic is a PipeWire node, not an ALSA card, by design. Fix: list it with =wpctl status=, record with =pw-record=. [[https://wiki.archlinux.org/title/PipeWire][archwiki pipewire]]
+- Switching to the mic profile tanks playback quality (no; expected). Music turns to tinny phone-call audio the moment the mic works. HFP shares narrow bandwidth for duplex. Fix: expected tradeoff; switch back to A2DP off-call, or enable mSBC to soften it. [[https://webcodr.io/2024/10/fixing-no-a2dp-with-bluetooth-headsets-on-linux/][webcodr]]
+
+** USB microphones
+
+- USB autosuspend powers the mic down while idle (both). Mic works at first, then silent or vanishes after sitting unused. Kernel autosuspend puts the idle device to sleep and it never wakes cleanly. Fix: a udev rule pinning =power/control=on= for the device, or =usbcore.autosuspend=-1=. [[https://bbs.archlinux.org/viewtopic.php?id=199504][arch 199504]]
+- Focusrite Scarlett needs a quirk / device_setup (both). XLR interface enumerates but capture is broken or wrong-channel. The device mis-reports config; snd-usb-audio needs a quirk (or the scarlett2 driver, built-in from 6.7). Fix: =options snd_usb_audio ... device_setup=1= on older kernels, or run kernel >= 6.7. [[https://blog.rtrace.io/posts/fedora-support-focusrite-scarlett/][rtrace scarlett]]
+- Intermittent disconnect with URB resets (no). Mic drops mid-use; dmesg shows disconnect and "cannot submit urb". A flaky bus/hub/cable link causing URB failures. Fix: use a direct motherboard port or powered hub, swap the cable. [[https://linux-usb.vger.kernel.narkive.com/MdlWpDZG/cannot-submit-urb-0-error-22-internal-error-followed-by-usb-hung-tasks][linux-usb list]]
+- Bus/hub can't supply enough power (no). Interface fails to power up or drops out, often through an unpowered hub. A bus-powered interface exceeds what a USB2 port or passive hub delivers. Fix: powered hub or the interface's PSU; a direct rear port. [[https://bbs.archlinux.org/viewtopic.php?id=267926][arch 267926]]
+- Mic dead after boot until physically replugged (both). USB mic gives no input after cold boot; replug fixes it every time. Ports stay powered through shutdown, so the device never power-cycles to enumerate at boot. Fix: enable ErP / "power off USB on shutdown" in BIOS. [[https://bbs.archlinux.org/viewtopic.php?id=280206][arch 280206]]
+- WirePlumber cached a no-input card profile for the USB device (no). Card present but no capture source appears. WirePlumber remembers a profile with input off, or a corrupt state cache. Fix: =pactl set-card-profile <card> input:...=; if it won't stick, clear =~/.local/state/wireplumber=. [[https://bbs.archlinux.org/viewtopic.php?id=294444][arch 294444]]
+
+** Application / browser / portal / sandbox
+
+- Wayland browser returns an empty mic list to WebRTC (no). Google Meet shows no microphone, no permission prompt (Firefox/Chrome/Zen on Wayland). The WebRTC PipeWire capturer isn't active so enumerateDevices() yields nothing. Fix: launch with =--enable-features=WebRTCPipeWireCapturer --ozone-platform=wayland= (Chromium) or the portal prefs (Firefox). [[https://github.com/zen-browser/desktop/issues/9745][zen 9745]]
+- Flatpak app lacks device / pulse permission (no). Discord (Flatpak) can't hear the mic. The sandbox withholds the mic; no =--device= or pulse access. Fix: =flatpak override --user --device=all <app>= (or Flatseal). [[https://lionir.ca/flathub-discord-permissions-explained/][flathub discord]]
+- Flatpak app has no audio socket exposed (no). Zoom/Viber Flatpak sees no mic at all. The sandbox lacks the audio socket (=--socket=pulseaudio= / =xdg-run/pipewire-0=). Fix: =flatpak override --user --socket=pulseaudio <app>=. [[https://forums.linuxmint.com/viewtopic.php?t=456895][mint 456895]]
+- Snap audio-record interface not connected, or AppArmor mediation (yes). Mumble/OBS snap gets no mic; or pipewire-pulse breaks on a confined client's connect. The =audio-record= interface is off by default and doesn't auto-connect; the AppArmor/LSM path can block or crash. Fix: =sudo snap connect <snap>:audio-record=, or the pipewire-pulse SystemCallFilter drop-in. [[https://forum.snapcraft.io/t/i-am-unable-to-record-audio-from-several-snaps/18703][snapcraft 18703]]
+- xdg-desktop-portal not started under Hyprland (no; relogin). Screen/mic capture silently fails, no portal prompt on Hyprland. The portal needs =graphical-session.target=, which a bare Hyprland exec never raises. Fix: import the environment and raise the session target in the Hyprland startup. [[https://github.com/hyprwm/Hyprland/discussions/10841][hyprland 10841]]
+- Permission-store caches a stale "deny" (no). An app was denied once and never re-prompts. The portal permission store persists the earlier "no". Fix: =flatpak permission-reset <app-id>= (or =permission-remove devices microphone=). [[https://man.archlinux.org/man/flatpak-permission-reset.1.en][flatpak permission-reset]]
+- Browser per-site mic permission set to Block (no). A site says mic blocked though the OS mic works. Chrome stores a per-origin "Not allowed" rule. Fix: Site settings, reset Microphone to Ask/Allow. [[https://support.google.com/chrome/answer/2693767][chrome mic]]
+- An app grabs the mic exclusively, starving others (no). While OBS/Discord runs, other apps get no mic; or arecord reports the device busy. The app opens the node (or raw ALSA hw) in exclusive mode. Fix: point the app at shared pipewire capture instead of the raw device source. [[https://obsproject.com/forum/threads/mic-echos-into-all-outputs-arch-pipewire.150207/][obs 150207]]
+
+** Hardware / physical / BIOS
+
+- Hardware mic-mute / privacy kill-switch (physical). All ALSA levels up and unmuted, still no signal. A physical slider or EC-level mute holds capture off below software. Fix: toggle the physical switch; some models need a BIOS re-enable. [[https://bbs.archlinux.org/viewtopic.php?id=182671][arch 182671]]
+- Laptop mic-mute key not honored, e.g. ThinkPad Fn+F4 (no). The LED toggles but the key never mutes/unmutes, and pactl is out of sync. The key maps to F20 and the EC mute isn't wired to the audio stack. Fix: bind keycode 190 (F20) to a pactl source-mute toggle. [[https://bbs.archlinux.org/viewtopic.php?id=268269][arch 268269]]
+- Combo jack keeps the internal mic selected (yes, if model option). Headset plugged in but the internal mic stays active. The shared combo jack doesn't expose "Headset Microphone"; an auto-mute/model quirk. Fix: pick "Headset Microphone" in pavucontrol, or =model=headset-mode= / disable Auto-Mute. [[https://bbs.archlinux.org/viewtopic.php?id=252594][arch 252594]]
+- Jack retasked to the wrong function (both). A mic port never registers a device, or is seen as line-out. A buggy BIOS pin-config assigns the jack the wrong default function. Fix: =hdajackretask=, override the pin to Microphone, install boot override, reboot. [[https://fossies.org/linux/alsa-tools/hdajackretask/README][hdajackretask]]
+- TRRS wiring mismatch, CTIA vs OMTP (physical). Audio plays but the headset mic is dead. The headset is wired to the opposite standard (mic/ground swapped). Fix: an OMTP-to-CTIA adapter, or a USB dongle. [[https://forums.linuxmint.com/viewtopic.php?t=336989][mint 336989]]
+- Broken / loose 3.5mm jack (physical). Mic cuts in/out when the cable is flexed, or nothing detected. Bent contacts or a cracked solder joint. Fix: confirm on another device; use a USB dongle if the jack is dead. [[https://forums.linuxmint.com/viewtopic.php?t=452191][mint 452191]]
+- A webcam's built-in mic is a separate USB device (yes, for udev rule). The system switches to low-quality audio from the webcam the user didn't know had a mic. The webcam enumerates as its own USB Audio Class interface. Fix: select the intended mic as default, or blacklist the webcam's audio interface with a udev rule. [[https://www.mjt.me.uk/posts/blacklisting-certain-microphones-linux/][mjt blacklist]]
+- BIOS/UEFI disables the internal mic or audio controller (both). No capture device present at all in Linux. A firmware setting turns off the mic or the audio controller. Fix: re-enable it in BIOS setup. [[https://www.dell.com/community/en/conversations/inspiron/bios-setting-to-disable-onboard-microphone/7331179][dell inspiron]]
+
+** Config / permissions / misc
+
+- PulseAudio and pipewire-pulse both installed (yes). Mic worked on PulseAudio, took no input after switching; both seem to run. Leftover pulseaudio auto-spawns and grabs the device, fighting pipewire-pulse. Fix: remove pulseaudio, set =autospawn = no=, enable =pipewire-pulse.socket=. [[https://bbs.archlinux.org/viewtopic.php?id=283345][arch 283345]]
+- User lacks realtime privileges / RTKit denied (both). Mic crackles or drops; logs show "could not make thread realtime using RTKit". No polkit session and user not in the realtime group. Fix: =pacman -S realtime-privileges=, add user to =realtime=, re-login. [[https://forum.manjaro.org/t/pulseeffects-cant-get-realtime-priority/42584][manjaro 42584]]
+- XDG_RUNTIME_DIR unset in ssh/cron/non-login session (no). Recording works in the desktop terminal but fails from ssh or cron. Non-login sessions skip pam_systemd, so the socket in =/run/user/<uid>= isn't found. Fix: run as the seat user with =XDG_RUNTIME_DIR= set. [[https://bbs.archlinux.org/viewtopic.php?id=286176][arch 286176]]
+- Auto-gain (AGC) pulling the mic level toward zero (no). The mic gets progressively quieter; something keeps lowering the input volume. An app or WirePlumber is allowed to auto-adjust the source gain and only ratchets down. Fix: a WirePlumber access rule blocking the client from changing gain. [[https://www.lumeh.org/wiki/audio/stop-adjusting-my-microphone/][lumeh]]
+
+** Saturation resample additions (2026-07-10)
+
+Genuinely-new input root causes from the blind resample, each tagged with the cluster it fits and its remedy class (so this doubles as their triage). Zero forced a new cluster.
+
+- Mic boost too high, records static/distortion (cluster 3 · AUTO). The inverse of boost-at-zero. Fix: lower Mic Boost. [[https://bbs.archlinux.org/viewtopic.php?id=211318][arch 211318]]
+- App auto-gain zeroes the mic, Zoom/Discord (cluster 7 · GUIDE). The app's own auto-input-adjust drives the level to 0. Fix: disable "automatically adjust microphone volume" / input-sensitivity. [[https://community.zoom.com/t5/Meetings/Zoom-for-Linux-not-working-well-with-newer-distributions/m-p/22805][zoom community]]
+- App selects the wrong input, "monitor of output", Teams (cluster 7 · GUIDE). Fix: set the device before launch, avoid the in-call chooser. [[https://learn.microsoft.com/en-us/answers/questions/183216/in-ms-teams-for-linux-microphone-stopped-working][ms teams]]
+- App requires PulseAudio, fails on bare ALSA, Signal/Webex (cluster 7 · GUIDE). WebRTC won't enumerate devices without the Pulse layer. Fix: install pipewire-pulse. [[https://github.com/signalapp/Signal-Desktop/issues/5099][signal 5099]]
+- Browser WebRTC captures one channel only, Jitsi/Firefox (cluster 7 · GUIDE). Firefox-specific channel handling. Fix: Chrome as workaround. [[https://github.com/jitsi/jitsi-meet/issues/16828][jitsi 16828]]
+- Mic classified as an output device, Pop COSMIC (cluster 2 · GUIDE). A desktop-environment device-role bug. Fix: none confirmed; tracked upstream. [[https://github.com/pop-os/cosmic-epoch/issues/1368][cosmic 1368]]
+- SOF topology-file mismatch, DMIC visible but silent (cluster 1 · PRIV, REBOOT-TAIL). The firmware is present but the wrong .tplg loads. Fix: swap the topology file in firmware-sof. [[https://bugzilla.redhat.com/show_bug.cgi?id=1772498][rh 1772498]]
+- Webcam mic needs a USB init-delay quirk, Logitech C922 (cluster 1 · REBOOT-TAIL). Fix: kernel with USB_QUIRK_DELAY_INIT for the device. [[https://bugs.launchpad.net/bugs/1885259][lp 1885259]]
+- USB interface implicit-feedback / clock-source stutter, Behringer UMC (cluster 4 · REBOOT-TAIL). Fix: =implicit_fb= modprobe, or kernel 5.19+. [[https://nandakumar.org/blog/2023/01/upgrade-linux-for-umc202hd.html][nandakumar]]
+- USB dual-channel device won't do input and output at once until a profile toggle, Elgato Wave (cluster 4 · AUTO). Fix: toggle the Pro Audio vs Duplex profile. [[https://forum.level1techs.com/t/elgato-wave-3-fails-to-work-as-microphone/221807][level1techs]]
+- USB mic not reliably enumerated, a port/controller issue or a PipeWire add-race (cluster 4 · AUTO). Fix: restart PipeWire, or a different port. [[https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/1534][pw 1534]]
+- PipeWire buffer/scheduling failure, source active but no samples (cluster 5 · GUIDE). "port_use_buffers" / "scheduling stopped node" in the log. Fix: unresolved upstream. [[https://github.com/NixOS/nixpkgs/issues/271847][nixpkgs 271847]]
+- Sudo-run audio tools corrupt the WirePlumber state (cluster 9 · AUTO). Fix: =rm -r ~/.local/state/wireplumber=; never run audio tools under sudo. [[https://www.kubuntuforums.net/forum/currently-supported-releases/kubuntu-24-04-nitpick-noble-lts/hardware-support-bg/681510-kubuntu-24-04-microphone-volume-at-100-per-cent-on-every-reboot][kubuntu]]
+- Audio-group membership blocks per-session access, older udev/ConsoleKit distros (cluster 9 · PRIV). Fix: remove the user from the =audio= group. [[https://wiki.ubuntu.com/Audio/TheAudioGroup][ubuntu audio group]]
+- Windows fast-startup wedges audio on a dual-boot (cluster 9 · GUIDE). Fix: disable Windows fast boot, or reload the sof module. [[https://bbs.archlinux.org/viewtopic.php?id=279824][arch 279824]]
+- Dead mic transducer / faulty mic module (cluster 8 · GUIDE). Fix: hardware defect, replaced under warranty. [[https://community.frame.work/t/fedora-41-13th-gen-intel-core-microphone-not-working/64010][framework 64010]]
+
+* Input triage
+
+The 58 input failure modes sorted into nine symptom clusters, each owned by a probe the doctor runs (or would run). Every entry carries a remedy class:
+
+- *AUTO* — user-scope, reversible, runs anywhere and on any machine. The existing Auto/Confirm tiers apply.
+- *PRIV* — needs sudo. Runs silently where passwordless sudo exists (every archsetup install), prompts on a CLI with a tty, degrades to Guide only in a GUI with neither. Defaults to Confirm/Arm, never silent Auto.
+- *REBOOT-TAIL* — the applicable part runs (often PRIV), then the doctor instructs the reboot it cannot complete or verify past.
+- *GUIDE* — nothing to run: a physical action, a BIOS setting, an app-permission the doctor can't grant, or a wait-for-upstream fix. Diagnose and print the instruction.
+
+Rough distribution: ~26 AUTO, ~6 PRIV, ~6 REBOOT-TAIL, ~20 GUIDE (a handful are AUTO now plus PRIV to persist). The buildable share (AUTO plus the runnable part of PRIV/REBOOT-TAIL) is a little over half. Triage is a proposal — correct the class on any line.
+
+** Cluster 1 — no capture device at all
+Probe: kernel =/proc/asound= plus the unit tier. New work: dmesg-pattern hints would let the doctor name the specific cause (firmware-load-fail, NHLT-missing) rather than a generic "no hardware".
+
+- Missing SOF firmware :: REBOOT-TAIL — install =sof-firmware= (PRIV), reboot to finish, verify after.
+- Stale dmic_detect / dsp_driver modprobe override :: REBOOT-TAIL — remove the override, rebuild initramfs (PRIV), reboot.
+- Missing codec model / ADC quirk :: REBOOT-TAIL — the =model=...= modprobe workaround (PRIV) plus reboot; GUIDE the real kernel-quirk fix.
+- Kernel regression broke the mic :: GUIDE — boot the previous kernel or install linux-lts; the doctor can't choose your boot kernel.
+- AMD ACP6x DMIC DMI quirk :: GUIDE — needs a newer kernel carrying the board quirk; nothing to run.
+- Missing NHLT ACPI table :: GUIDE — flash firmware that supplies NHLT; out of reach.
+- New AMD ACP7.0 unsupported :: GUIDE — wait for the kernel plus UCM support.
+- Focusrite Scarlett needs a quirk :: REBOOT-TAIL — =device_setup= modprobe (PRIV) plus reboot on old kernels; GUIDE "run kernel >= 6.7" otherwise.
+
+** Cluster 2 — device present but records silence
+Probe: semantic tier (source mute, volume, pin, default). This is the input mirror of the output side's silenced and stale-default rules.
+
+- Wrong card profile, no input route :: AUTO — =set-card-profile= to a duplex/input profile.
+- Capture / Internal Mic switch muted :: AUTO — unmute via wpctl/pactl (and the ALSA capture switch via amixer).
+- Input Source enum on the wrong pin :: AUTO — set Input Source to the connected pin.
+- alsactl restores a muted capture at boot :: AUTO + PRIV — unmute now (AUTO); persist with =sudo alsactl store= (PRIV).
+- Auto-Mute Mode disabling capture :: AUTO — set Auto-Mute Disabled.
+- Wrong default capture in .asoundrc :: AUTO — set the right default source (the PipeWire-era fix, not the legacy file).
+- Default source is a monitor / wrong device :: AUTO — =set-default= to the real mic, pin it.
+- Capture source muted on startup (stale WP state) :: AUTO — unmute; clear the stale WirePlumber state.
+- Filter / virtual source shadows the real mic :: AUTO — =set-default= to the real source, or fix node.target.
+
+** Cluster 3 — works but too quiet
+Probe: semantic volume/gain.
+
+- Internal Mic Boost at zero :: AUTO — raise Mic Boost.
+- Auto-gain (AGC) pulling the level to zero :: AUTO — a WirePlumber rule blocking the client from changing gain.
+
+** Cluster 4 — works then dies or drops out
+Probe: node state over time / suspend. New work: a re-probe-after-idle check to catch the suspend cases.
+
+- Node suspended, never resumes :: AUTO — =session.suspend-timeout-seconds = 0= drop-in plus restart.
+- Forced or mismatched sample rate :: AUTO — =allowed-rates= config plus restart.
+- USB autosuspend powers the mic down :: PRIV — a udev =power/control=on= rule (or =usbcore.autosuspend=-1=, REBOOT-TAIL).
+- Intermittent USB disconnect (URB resets) :: GUIDE — swap the cable or use a direct port; physical.
+- USB bus/hub can't supply power :: GUIDE — a powered hub or the interface PSU; physical.
+- Mic dead after boot until replug :: GUIDE — enable ErP in BIOS so the port power-cycles; physical/BIOS.
+
+** Cluster 5 — the sound server lost a device the hardware has
+Probe: kernel-vs-graph (the mic-unrecognized rule) plus WirePlumber liveness. This is where the spec's coarse set-emptiness rule lives.
+
+- ACP vs UCM detection mismatch :: AUTO — set =use-acp=/=use-ucm= plus restart wireplumber.
+- Missing UCM capture profile :: AUTO — fall back to ACP; GUIDE the UCM patch/install.
+- WirePlumber fails to start on old config :: AUTO — remove the user drop-in plus restart (PRIV if the file is under =/etc=).
+- pipewire-pulse dead or wrong socket :: AUTO — restart the user services; check XDG_RUNTIME_DIR.
+- Broken ~/.config/wireplumber override :: AUTO — remove/repair the drop-in plus restart.
+- WirePlumber cached a no-input profile (USB) :: AUTO — =set-card-profile input:...=; clear stale state if it won't stick.
+
+** Cluster 6 — Bluetooth mic
+Probe: bluez card profile plus codec. Needs the graph-first gate from the review so a BT mic never reads as no-mic-hardware.
+
+- Headset stuck in A2DP, no mic :: AUTO — =set-card-profile headset-head-unit=.
+- oFono installed but unconfigured :: PRIV — remove oFono (or set the native backend in config, AUTO).
+- mSBC codec not enabled :: AUTO — codecs config plus restart wireplumber.
+- Auto-switch to headset profile never triggers :: AUTO — set-card-profile / autoswitch config.
+- Codec negotiation drops to baseline :: AUTO — pin codec order.
+- Adapter rejects hsp_ag plus hfp_ag together :: AUTO — restrict roles in config.
+- BT mic invisible to arecord :: GUIDE — not a fault; it's a PipeWire node. The graph-first gate prevents a false no-mic-hardware here.
+- Switching to the mic profile tanks playback :: GUIDE — expected A2DP/HFP tradeoff.
+
+** Cluster 7 — stack healthy, an app can't hear me
+Probe: none in-stack. This is the tail on a healthy verdict: name the likely app/portal cause and print the fix. The Chrome incident lives here.
+
+- Wayland browser returns an empty mic list :: GUIDE — the WebRTC/PipeWire launch flags or browser prefs.
+- Flatpak app lacks device permission :: GUIDE — print =flatpak override --user --device=all <app>=.
+- Flatpak app has no audio socket :: GUIDE — print =flatpak override --user --socket=pulseaudio <app>=.
+- Snap audio-record interface not connected :: GUIDE — print =sudo snap connect <snap>:audio-record=.
+- xdg-desktop-portal not started under Hyprland :: GUIDE — fix the Hyprland startup env / session target.
+- Permission-store cached a deny :: GUIDE — print =flatpak permission-reset <app>=.
+- Browser per-site mic block :: GUIDE — the browser's site settings.
+- An app grabs the mic exclusively :: GUIDE — change the app to shared capture.
+
+** Cluster 8 — hardware / physical
+Probe: kernel tier plus a signature test (issue an unmute, re-probe; a mute that doesn't clear is a hardware switch).
+
+- Hardware mic-mute / privacy kill-switch :: GUIDE — flip the physical switch; the doctor detects the unmute-doesn't-stick signature.
+- Laptop mic-mute key not honored (F20) :: GUIDE — bind the key to a pactl toggle (a config change).
+- Combo jack keeps the internal mic selected :: AUTO + PRIV — select Headset Mic (AUTO); the =model== option to persist (PRIV, reboot).
+- Jack retasked to the wrong function :: REBOOT-TAIL — hdajackretask override plus install plus reboot.
+- TRRS wiring mismatch (CTIA/OMTP) :: GUIDE — a wiring adapter; physical.
+- Broken / loose 3.5mm jack :: GUIDE — physical/hardware.
+- Webcam's built-in mic is a separate device :: AUTO + PRIV — =set-default= away from the webcam (AUTO); a udev blacklist to persist (PRIV).
+- BIOS/UEFI disables the internal mic :: GUIDE — re-enable it in BIOS; physical/firmware.
+
+** Cluster 9 — environment / config
+Probe: process plus environment checks (a leftover pulseaudio process, XDG_RUNTIME_DIR, group membership).
+
+- PulseAudio and pipewire-pulse both installed :: PRIV — remove pulseaudio, set =autospawn = no=.
+- User lacks realtime privileges / RTKit :: PRIV — =limits.d= plus the realtime group (relogin/reboot).
+- XDG_RUNTIME_DIR unset in ssh/cron :: GUIDE — set it in the non-login context; environment-specific.
+
+* Output (speakers / headphones) failure modes
+
+Built 2026-07-10 from a parallel sweep across eight output-specific layers (kernel/driver/firmware, ALSA mixer/pin, PipeWire routing, Bluetooth, HDMI/DisplayPort, USB DAC + hardware/BIOS, app/browser, and audio quality: crackle/xrun/latency). 64 raw entries deduped to 59 distinct root causes. Same fields and =sudo/reboot= triage signal as the input section.
+
+** Kernel / driver / firmware
+
+- SOF DSP firmware not installed (both). Fresh install shows only "Dummy Output"; nothing in =/proc/asound/cards=; dmesg "failed to load DSP firmware". The Intel DSP needs Sound Open Firmware to boot. Fix: =pacman -S sof-firmware=, reboot. [[https://github.com/basecamp/omarchy/issues/6110][omarchy 6110]]
+- Cirrus CS35L41 smart amp, missing ACPI _DSD (both). Card present, speakers dead on 2023 ASUS/HP; dmesg "ACPI _DSD Properties are missing for HID CSC3551". BIOS omits the props the =cs35l41-hda= driver needs, and the amp firmware is required. Fix: install =firmware-cirrus=, apply the _DSD override, or kernel >= 6.9 / BIOS update. [[https://asus-linux.org/guides/cirrus-amps/][asus-linux cirrus]]
+- TI TAS2781 smart amp, driver/firmware absent (both). Lenovo Legion speakers silent; "Direct firmware load for TIAS2781RCA2.bin failed". The =SND_HDA_SCODEC_TAS2781= driver isn't built and the RCA firmware is undistributable. Fix: kernel with the option enabled + TI firmware in =/lib/firmware=. [[https://bbs.archlinux.org/viewtopic.php?id=289379][arch 289379]]
+- Wrong HDA model autodetection, speaker dead (both). ALSA works but the internal speaker is silent; only headphone/HDMI plays. =snd_hda_intel= picked the wrong codec model so the speaker pin/amp isn't enabled. Fix: =options snd-hda-intel model=<quirk>= in =/etc/modprobe.d/=. [[https://bbs.archlinux.org/viewtopic.php?pid=1109929][arch 1109929]]
+- snd_soc_skl grabs the card, only Dummy Output (both). After a kernel update, sound gone; only "Dummy Output". The SST driver claims the controller and the DMIC probe fails, leaving no playback path. Fix: =options snd-hda-intel dmic_detect=0= + =blacklist snd_soc_skl=. [[https://www.linuxuprising.com/2018/06/fix-no-sound-dummy-output-issue-in.html][linuxuprising]]
+- AMD Ryzen HDA codec probe timeout (both). No internal speakers; =/proc/asound= shows only the HDMI codec. The analog codec probe fails ("no codecs initialized") on Renoir/Cezanne. Fix: newer kernel/firmware; =probe_mask=1= / =dmic_detect=0= workaround. [[https://forums.linuxmint.com/viewtopic.php?t=448906][mint 448906]]
+- Kernel regression killed HDA output (both). Speakers worked, went silent after a kernel bump. An HDA/codec regression in the new kernel. Fix: boot the previous kernel or linux-lts until a fix lands. [[https://bbs.archlinux.org/viewtopic.php?id=278370][arch 278370]]
+
+** ALSA (mixer, pins, jack, state)
+
+- Master / PCM channel muted, MM in alsamixer (no). No sound from any output. The mixer channel is left muted. Fix: =amixer sset Master unmute; amixer sset PCM unmute=. [[https://bbs.archlinux.org/viewtopic.php?id=200955][arch 200955]]
+- Auto-Mute Mode silences speakers (no). Speakers muted when headphones are (un)plugged, or stay muted with nothing plugged. The codec's Auto-Mute ties speaker output to jack-sense. Fix: =amixer sset 'Auto-Mute Mode' Disabled=. [[https://bbs.archlinux.org/viewtopic.php?id=185098][arch 185098]]
+- alsactl restores a muted master at boot (yes). Sound works after manual unmute, muted every reboot. alsa-restore replays a muted state, or runs before the card enumerates. Fix: unmute, =sudo alsactl store=; add a delay to alsa-restore if it races. [[https://bugs.archlinux.org/task/35438][arch bug 35438]]
+- Front/Surround/Center/LFE channel muted (no). Only some speakers play. Per-channel mixer controls muted by default on multi-channel codecs. Fix: =amixer set Surround/Center/LFE 100% unmute=; verify =speaker-test -c 6=. [[https://bbs.archlinux.org/viewtopic.php?id=172793][arch 172793]]
+- Dead headphone pin needs hdajackretask (both). Speakers work, headphone jack silent and undetected. A BIOS pin-config leaves the headphone pin unconnected. Fix: =hdajackretask=, override the pin to Headphone, install boot override, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=229296][arch 229296]]
+- Phantom jack-sense keeps speakers muted (yes). Speakers stay muted because the codec reports headphones plugged when nothing is. A spurious/stuck jack-detect state. Fix: force re-detect via hdajackretask reconfigure, or disable jack-detect for the speaker path. [[https://bbs.archlinux.org/viewtopic.php?id=265363][arch 265363]]
+- Wrong codec model quirk, no headphone or speaker (both). The auto-parser misdetects the board and picks the wrong pin routing. Fix: =options snd-hda-intel model=<quirk>= in =/etc/modprobe.d/=, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=252295][arch 252295]]
+- Headphone path maps volume to the wrong control (yes). Headphone slider does nothing / headphones silent. The card-profile mixer path sets the Speaker element =volume = off=, breaking routing. Fix: edit =analog-output-headphones.conf=, change =volume = off= to =merge=, restart audio. [[https://bbs.archlinux.org/viewtopic.php?id=237456][arch 237456]]
+
+** PipeWire / WirePlumber routing
+
+- Default sink lands on HDMI instead of speakers (no). Boot with silence; playback goes to HDMI while speakers idle. WirePlumber auto-selects the HDMI node as default. Fix: =wpctl set-default <speaker-id>=. [[https://bbs.archlinux.org/viewtopic.php?id=309588][arch 309588]]
+- Default keeps reverting to the wrong sink (no). set-default holds until reboot, then flips back every session. Each sink's =priority.session= re-picks the wrong default. Fix: set =priority.session= per node in a WirePlumber rule, restart wireplumber. [[https://gist.github.com/DasBen/df9c2f6c18fff54a0ad6d19304915429][dasben gist]]
+- Sink suspended on idle, doesn't resume, and pops on start/stop (no). After a few seconds of silence the sink stays suspended; a loud pop/click and a delay when it wakes. =session.suspend-timeout-seconds= parks the node and the codec power-cycles. Fix: drop-in =session.suspend-timeout-seconds = 0=, restart wireplumber. [[https://bbs.archlinux.org/viewtopic.php?id=264337][arch 264337]]
+- WirePlumber fails to start or a stale config jams routing (no; sudo if under /etc). Total silence; wpctl reports no sinks. The service crashes on a bad/incompatible config (user drop-in or a leftover =/etc/wireplumber/wireplumber.conf=). Fix: =journalctl --user -u wireplumber=, remove/move the offending config, restart. [[https://bbs.archlinux.org/viewtopic.php?id=294649][arch 294649]]
+- Corrupted WirePlumber restore state (no). No output after an update; sink present but never usable, or wrong routing persists. A corrupt =~/.local/state/wireplumber/= wedges default-node selection. Fix: =rm -rf ~/.local/state/wireplumber/=, restart pipewire pipewire-pulse wireplumber. [[https://bbs.archlinux.org/viewtopic.php?id=290825][arch 290825]]
+- pipewire-pulse dead, Pulse apps silent (no). Some apps play, but pactl/Pulse clients fail "Connection refused". The pipewire-pulse socket/service isn't running. Fix: =systemctl --user enable --now pipewire-pulse.socket pipewire-pulse.service=. [[https://bbs.archlinux.org/viewtopic.php?id=281062][arch 281062]]
+- Sink present but silent until a mute/unmute toggle (no). Correct default, un-suspended, but no sound until volume is touched or mute toggled. WirePlumber restores a muted / 0-volume state, or the node never opens until a mute change forces activation. Fix: =wpctl set-mute @DEFAULT_AUDIO_SINK@ 0= and =set-volume ... 1.0=. [[https://bbs.archlinux.org/viewtopic.php?id=300427][arch 300427]]
+
+** Bluetooth output
+
+- A2DP Sink profile deselects itself / unavailable (yes). A2DP switches back off when picked; only HSP/HFP shows. Stale pairing state in =/var/lib/bluetooth= from a PulseAudio-era config confuses bluez negotiation. Fix: stop bluetooth, remove/rename =/var/lib/bluetooth=, restart, re-pair. [[https://bbs.archlinux.org/viewtopic.php?id=280717][arch 280717]]
+- LDAC/aptX never negotiates, falls to SBC (no). Headphones connect but sound bad, stuck on SBC. Decoder libs absent, or =bluez5.codecs= order drops to baseline. Fix: install the codec packages, set =bluez5.codecs = [ ldac aptx_hd aptx aac sbc ]=, re-pair. [[https://bbs.archlinux.org/viewtopic.php?id=290413][arch 290413]]
+- Playback drops to HSP/HFP mid-song (no). Music goes mono/tinny when an app opens the mic. WirePlumber autoswitch-to-headset-profile fires on any input stream. Fix: =wpctl settings --save bluetooth.autoswitch-to-headset-profile false=; force =a2dp-sink=. [[https://wiki.archlinux.org/title/Bluetooth_headset][archwiki bt]]
+- BT sink not made default on connect (no). Headphones connect but audio keeps playing from laptop speakers. No switch-on-connect; the new sink's priority doesn't beat the built-in. Fix: =wpctl set-default <bt-sink-id>=, or a priority.session rule, or module-switch-on-connect. [[https://bbs.archlinux.org/viewtopic.php?id=304269][arch 304269]]
+- Silent sink after suspend/resume despite reconnect (no). After waking, headphones reconnect but running apps play no sound. The stream isn't re-initialized on the resumed sink; a seat-monitoring race. Fix: =monitor.bluez.seat-monitoring = disabled= drop-in; workaround =systemctl --user restart wireplumber pipewire=. [[https://forums.linuxmint.com/viewtopic.php?p=2781321][mint 2781321]]
+- Volume slider does nothing, absolute-volume conflict (no). OS volume has no effect on the headphones. PipeWire's absolute-volume passthrough hands volume to the device. Fix: =bluez5.enable-hw-volume = false= in a WirePlumber rule to restore software volume. [[https://wiki.debian.org/BluetoothUser/a2dp][debian a2dp]]
+- Adapter/dongle never exposes an audio sink (both). Dongle pairs but no A2DP sink; dmesg shows a firmware load failure. Controller firmware missing/outdated (e.g. MediaTek MT7921). Fix: install/update linux-firmware, reload btusb or reboot. [[https://bbs.archlinux.org/viewtopic.php?id=296026][arch 296026]]
+
+** HDMI / DisplayPort
+
+- GPU HDMI codec never initializes (both). No HDMI sink appears; aplay shows no HDMI device. The DSP/legacy driver-path selection is wrong so the HDMI codec is skipped. Fix: =options snd_intel_dspcfg dsp_driver=3=, rebuild initramfs. [[https://bbs.archlinux.org/viewtopic.php?id=290824][arch 290824]]
+- NVIDIA GPU audio removed by a runtime-PM udev rule (both). The NVIDIA HDMI card is missing or "inactive," only profile "off". An old NVIDIA runtime-PM udev rule powers down the audio function. Fix: comment out the audio-removal lines in the udev rule, reload/reboot. [[https://forums.developer.nvidia.com/t/no-audio-over-hdmi-linux-only/236341][nvidia 236341]]
+- NVIDIA HDMI audio needs DRM modesetting (both). Video works over HDMI, no audio sink. Without nvidia-drm modeset the HDMI audio function isn't exposed. Fix: =options nvidia_drm modeset=1=, rebuild initramfs. [[https://bbs.archlinux.org/viewtopic.php?id=264504][arch 264504]]
+- HDMI audio dies after suspend/resume, codec state (yes). Sound before suspend, silent after; dmesg "spurious response". The HD-Audio codec state isn't restored across resume. Fix: =sudo alsa force-reload= after resume, or =alsactl store=/=restore=. [[https://forums.developer.nvidia.com/t/nvidia-hdmi-audio-errors-after-resume-snd-hda-intel-spurious-response-last-cmd/149003][nvidia 149003]]
+- Empty ELD after resume marks the port disconnected (yes). HDMI port shows unavailable post-resume; WirePlumber logs "ELD info empty". A kernel regression fails to re-read the ELD on resume. Fix: boot linux-lts until fixed; replug the cable to force an ELD re-read. [[https://bbs.archlinux.org/viewtopic.php?id=306343][arch 306343]]
+- Wrong monitor is the audio sink, multi-monitor (no). Two displays on one GPU; audio goes to the wrong one. Only one HDMI/DP profile is active and the default picks the wrong port. Fix: =pactl set-card-profile <card> output:hdmi-stereo-extraN= for the right port. [[https://bbs.archlinux.org/viewtopic.php?id=295404][arch 295404]]
+- HDMI sink present but silent until daemon restart (no). HDMI profile selectable but no sound until PipeWire restarts. WirePlumber didn't pick up the sink on hotplug. Fix: =systemctl --user restart pipewire pipewire-pulse wireplumber=. [[https://bbs.archlinux.org/viewtopic.php?id=280352][arch 280352]]
+- HDMI sample-rate mismatch, display rejects the stream (no). HDMI audio crackles or drops on some content; the TV/monitor rejects non-48k rates. PipeWire negotiates a rate the display's ELD doesn't accept. Fix: pin the rate, =pw-metadata -n settings 0 clock.force-rate 48000= or set allowed-rates. [[https://bbs.archlinux.org/viewtopic.php?id=288932][arch 288932]]
+
+** USB DAC and hardware / BIOS
+
+- USB autosuspend powers the DAC down when idle (both). DAC plays, then silent after inactivity; sometimes needs a replug. The kernel autosuspends the port and severs the stream. Fix: =usbcore.autosuspend=-1=, or a udev rule setting =power/control=on=, or a silent keep-alive stream. [[https://destinmoulton.com/notes/howto/linux-usb-audio-keep-alive-service/][destinmoulton]]
+- USB DAC not auto-selected as default output (no). Sound keeps coming from onboard after the DAC is plugged in. WirePlumber gives the DAC an equal/lower node priority. Fix: a WirePlumber rule raising =priority.session=/=priority.driver= for the device, restart wireplumber. [[https://bbs.archlinux.org/viewtopic.php?id=290341][arch 290341]]
+- Kernel USB-audio regression, DAC has only an "Off" profile (both). After an upgrade the DAC (MOTU M4) enumerates but the only profile is "Off". A snd-usb-audio regression breaks stream setup for some devices. Fix: boot linux-lts or wait for the fixed kernel; downgrade as a stopgap. [[https://bbs.archlinux.org/viewtopic.php?id=279889][arch 279889]]
+- DAC needs an implicit-feedback quirk to sync (both). Playback stutters or drops to silence and won't recover; the device has no explicit feedback endpoint. snd-usb-audio doesn't apply generic implicit-feedback sync. Fix: =options snd_usb_audio implicit_fb=1= (or the per-device quirk_flags bit) in =/etc/modprobe.d/=. [[https://lkml.iu.edu/hypermail/linux/kernel/2206.0/08494.html][lkml usb-audio]]
+- Headphone jack not detected, speakers keep playing (both). Plugging headphones doesn't mute the speakers; no auto-switch on insert. The HDA auto-parser picks the wrong model so jack events aren't wired to the switch. Fix: =options snd-hda-intel model=<board>= in =/etc/modprobe.d/=, reboot. [[https://bbs.archlinux.org/viewtopic.php?id=265363][arch 265363]]
+- Front-panel headphone jack not wired by BIOS pin config (both). The front jack produces nothing and never appears; rear jacks work. The BIOS marks that codec pin "not connected". Fix: =hdajackretask= override the pin to Headphone/Line-out, install the boot override, reboot. [[https://github.com/alsa-project/alsa-tools/blob/master/hdajackretask/README][hdajackretask]]
+- BIOS-disabled onboard audio, or a GPU HDA controller hides it (both). No card detected (empty =aplay -l=), or only the GPU/HDMI device appears. Onboard audio is disabled in BIOS, or the GPU's HDA controller takes over ordering. Fix: re-enable onboard audio in BIOS; =options snd-hda-intel enable=1,0= to disable the GPU's audio function. [[https://bbs.archlinux.org/viewtopic.php?id=221296][arch 221296]]
+
+** Application / browser / sandbox
+
+- Flatpak app has no PulseAudio socket (no). A Flatpak app is silent while native apps play. The manifest was built without =--socket=pulseaudio=. Fix: =flatpak override --user --socket=pulseaudio <app>=. [[https://github.com/flatpak/flatpak/issues/878][flatpak 878]]
+- Flatpak silent on a PipeWire-only system (no). App with pulse permission still has no sound on pure PipeWire. Flatpak has no native PipeWire socket permission. Fix: =flatpak override --user --filesystem=xdg-run/pipewire-0 <app>=. [[https://github.com/flathub/com.valvesoftware.Steam/issues/1451][flathub steam 1451]]
+- Snap app can't play, audio-playback interface unconnected (no). A snap (often =--dangerous=) produces no sound. Locally-installed snaps skip the auto-connect assertion. Fix: =snap connect <snap>:audio-playback=. [[https://bugzilla.mozilla.org/show_bug.cgi?id=1685284][mozilla 1685284]]
+- Browser per-tab mute zeroes the app stream (no). Firefox/Chrome plays nothing; the app stream shows muted, device fine. A tab was muted, muting the per-stream sink-input. Fix: Unmute Tab, or un-mute the app's stream in pavucontrol. [[https://bbs.archlinux.org/viewtopic.php?id=275694][arch 275694]]
+- Wine/Proton game silent, wrong audio driver (no; sudo for 32-bit libs). A Wine/Proton game has no output. The Wine driver is set wrong, or 32-bit audio libs are missing. Fix: winecfg output device to System default; install =lib32-libpulse= / =lib32-pipewire=. [[https://bbs.archlinux.org/viewtopic.php?id=135032][arch 135032]]
+- App hard-coded to raw hw:0, device busy (no). MPD/shairport errors "Device or resource busy" and stays silent, blocking others. The app points at raw =hw:0=, grabbing the card exclusively. Fix: change the app's device to =default= or =plughw=; =fuser -v /dev/snd/*= to find the holder. [[https://community.volumio.com/t/fix-mpd-failed-to-open-alsa-device-hw-0-0-device-busy/686][volumio mpd]]
+- Chromium doesn't follow the default-sink change (no). After switching the default output, Chrome keeps playing to the old sink. Chromium latches its stream to the start-time sink. Fix: reassign the Chromium stream in pavucontrol; PipeWire remembers it. [[https://bbs.archlinux.org/viewtopic.php?id=116888][arch 116888]]
+- Electron app routed to the wrong sink (no). Discord is silent while YouTube works; its stream went elsewhere. Electron apps share a generic stream name so a prior routing rule misdirects it. Fix: move the app's stream to the right sink in pavucontrol while it plays. [[https://bbs.archlinux.org/viewtopic.php?id=277447][arch 277447]]
+
+** Audio quality (crackle, xruns, latency)
+
+- Quantum drops too low under load (no). Crackling/popping that worsens under CPU load. PipeWire's dynamic quantum negotiates a buffer that underruns the sink. Fix: =default.clock.min-quantum = 1024= (raise as needed) in a pipewire.conf.d drop-in, restart pipewire. [[https://bbs.archlinux.org/viewtopic.php?id=303024][arch 303024]]
+- Realtime scheduling denied, RTKit (yes). Xruns/dropouts uncorrelated with CPU load; the audio thread misses deadlines. PipeWire can't get SCHED_FIFO (RTKit unavailable or capped). Fix: rtkit-daemon running, user in =realtime=, =/etc/security/limits.d/= granting rtprio + memlock; re-login. [[https://bbs.archlinux.org/viewtopic.php?id=283334][arch 283334]]
+- Timer-based scheduling on a fussy USB DAC (no). Periodic crackle on a USB interface. tsched mis-estimates the batch USB device's buffer position. Fix: a WirePlumber ALSA rule with =api.alsa.disable-tsched = true= and a modest headroom, or the Pro Audio profile. [[https://bbs.archlinux.org/viewtopic.php?id=284888][arch 284888]]
+- Forced global sample rate causes resample artifacts (no). Distortion on 44.1k content, or a stream stuck upsampling; also seen on USB DACs that only accept a limited rate set. A hard =default.clock.rate= forces everything through the resampler. Fix: set =default.clock.allowed-rates= to a list so the device follows the source, restart pipewire. [[https://bbs.archlinux.org/viewtopic.php?id=288932][arch 288932]]
+- Small quantum fine for music, too tight for browser/games (no). Audio crackles only in Chrome/Firefox or games, clean elsewhere. The heavy client can't keep the small global quantum fed. Fix: launch with =PIPEWIRE_LATENCY=2048/48000=, or a WirePlumber stream rule setting =node.latency= for that app. [[https://forum.endeavouros.com/t/pipewire-guide-audio-crackling-popping-and-latency/69602][endeavour 69602]]
+- HDA codec power-save clicks and BT-latency hits (yes). Loud click/pop on first play after idle on internal HDA; also wrecks Bluetooth latency. =snd_hda_intel power_save= cuts codec power and clicks on wake. Fix: =options snd_hda_intel power_save=0 power_save_controller=N= in =/etc/modprobe.d/=. [[https://forums.linuxmint.com/viewtopic.php?t=186531][mint 186531]]
+- Insufficient ALSA headroom on an emulated/quirky device (no). Constant fine crackle from a sink whose hardware pointer PipeWire can't track (VM audio, cheap onboard). The default small =api.alsa.headroom= leaves no slack. Fix: a WirePlumber ALSA rule adding =api.alsa.headroom = 2048=. [[https://pipewire.pages.freedesktop.org/wireplumber/daemon/configuration/alsa.html][wp alsa config]]
+
+* Output triage
+
+The 59 output failure modes sorted into nine symptom clusters, same remedy classes as the input triage (AUTO / PRIV / REBOOT-TAIL / GUIDE — see that section's legend). The clusters shift for output: no mic-specific groups, but HDMI/DisplayPort and degraded-audio (crackle/xrun) each earn a dedicated cluster, and speaker-vs-headphone jack routing replaces the input pin cluster.
+
+Rough distribution: ~24 AUTO, ~8 PRIV, ~11 REBOOT-TAIL, ~16 GUIDE. Output leans more REBOOT-TAIL than input, because its hardware tail (smart-amp firmware, HDA model quirks, hdajackretask, GPU modeset) is heavier. Triage is a proposal — correct the class on any line.
+
+** Cluster 1 — no sound at all / dummy output
+Probe: units + kernel =/proc/asound= + =dsp_driver=. The output mirror of input cluster 1.
+
+- SOF DSP firmware not installed :: REBOOT-TAIL — install =sof-firmware= (PRIV), reboot.
+- Cirrus CS35L41 smart amp, missing _DSD :: REBOOT-TAIL — =firmware-cirrus= + the _DSD override (PRIV), reboot; GUIDE the BIOS-update path.
+- TI TAS2781 smart amp absent :: GUIDE — needs a kernel built with the driver + TI firmware; nothing to run on a stock stack.
+- Wrong HDA model autodetection :: REBOOT-TAIL — =model== modprobe (PRIV) + reboot.
+- snd_soc_skl grabs the card, only Dummy Output :: REBOOT-TAIL — =dmic_detect=0= + blacklist =snd_soc_skl= (PRIV) + reboot.
+- AMD Ryzen HDA codec probe timeout :: REBOOT-TAIL — the =probe_mask=/=dmic_detect= workaround (PRIV) + reboot; GUIDE a newer kernel.
+- Kernel regression killed HDA output :: GUIDE — boot the previous kernel / linux-lts.
+- Kernel USB-audio regression, DAC "Off" profile :: GUIDE — boot linux-lts / wait for the fix.
+- BIOS-disabled onboard, or a GPU HDA controller steals ordering :: GUIDE + PRIV — re-enable in BIOS (GUIDE); =enable=1,0= modprobe to demote the GPU's audio (PRIV, reboot).
+
+** Cluster 2 — sound plays but from the wrong device
+Probe: default sink + priority. The output side of routing.
+
+- Default sink lands on HDMI instead of speakers :: AUTO — =set-default= to the speakers.
+- Default keeps reverting to the wrong sink :: AUTO — a =priority.session= rule.
+- WirePlumber fails to start / stale config jams routing :: AUTO — remove the drop-in + restart (PRIV if under =/etc=).
+- Corrupted WirePlumber restore state :: AUTO — =rm= the state + restart.
+- pipewire-pulse dead, Pulse apps silent :: AUTO — restart the user services.
+- USB DAC not auto-selected as default :: AUTO — a =priority.session= rule.
+
+** Cluster 3 — right default but silent (muted or zero)
+Probe: semantic sink mute/volume. The output mirror of input cluster 2.
+
+- Master / PCM channel muted :: AUTO — unmute.
+- Auto-Mute Mode silences speakers :: AUTO — disable Auto-Mute.
+- alsactl restores a muted master at boot :: AUTO + PRIV — unmute now (AUTO); =sudo alsactl store= to persist (PRIV).
+- Front/Surround/Center/LFE channel muted :: AUTO — unmute the channels.
+- Sink present but silent until a mute/unmute toggle :: AUTO — =set-mute 0= / =set-volume=.
+
+** Cluster 4 — speakers work but headphones don't (or the reverse)
+Probe: jack-sense + pin config. Mostly hdajackretask/model-quirk territory, so this cluster leans REBOOT-TAIL.
+
+- Dead headphone pin needs hdajackretask :: REBOOT-TAIL — hdajackretask override + install + reboot.
+- Phantom jack-sense keeps speakers muted :: REBOOT-TAIL — hdajackretask reconfigure; GUIDE if it needs a codec quirk.
+- Wrong codec model quirk, no headphone or speaker :: REBOOT-TAIL — =model== modprobe + reboot.
+- Headphone path maps volume to the wrong control :: PRIV — edit the alsa-card-profile mixer path (a system file), restart audio.
+- Headphone jack not detected, speakers keep playing :: REBOOT-TAIL — =model== modprobe + reboot.
+- Front-panel headphone jack not wired by BIOS :: REBOOT-TAIL — hdajackretask override + install + reboot.
+
+** Cluster 5 — works then dies or pops
+Probe: node state / suspend + re-probe-after-idle.
+
+- Sink suspended on idle, pops on start/stop :: AUTO — =session.suspend-timeout-seconds = 0= drop-in + restart.
+- USB autosuspend powers the DAC down :: PRIV — a udev =power/control=on= rule (or =usbcore.autosuspend=-1=, REBOOT-TAIL).
+- DAC needs an implicit-feedback quirk :: REBOOT-TAIL — =implicit_fb= modprobe (PRIV) + reboot.
+
+** Cluster 6 — Bluetooth output
+Probe: bluez profile + codec.
+
+- A2DP Sink profile deselects / unavailable :: PRIV — clear =/var/lib/bluetooth= + re-pair (or a config fix, AUTO).
+- LDAC/aptX never negotiates, falls to SBC :: PRIV — install the codec packages; AUTO for the codecs config.
+- Playback drops to HSP/HFP mid-song :: AUTO — autoswitch off; force =a2dp-sink=.
+- BT sink not made default on connect :: AUTO — =set-default=; a switch-on-connect rule.
+- Silent sink after suspend/resume :: AUTO — a seat-monitoring drop-in; restart as the workaround.
+- Volume slider does nothing (absolute volume) :: AUTO — =enable-hw-volume = false=.
+- Adapter never exposes a sink (firmware) :: REBOOT-TAIL — update linux-firmware, reload/reboot.
+
+** Cluster 7 — HDMI / DisplayPort
+Probe: GPU audio codec + ELD. Output-only cluster.
+
+- GPU HDMI codec never initializes :: REBOOT-TAIL — =dsp_driver= modprobe + initramfs + reboot.
+- NVIDIA GPU audio removed by a PM udev rule :: PRIV — edit the udev rule (reload/reboot).
+- NVIDIA HDMI audio needs DRM modesetting :: REBOOT-TAIL — =modeset=1= modprobe + initramfs + reboot.
+- HDMI audio dies after suspend/resume :: PRIV — =sudo alsa force-reload= (or alsactl store/restore).
+- Empty ELD after resume (kernel regression) :: GUIDE — boot linux-lts; replug to force an ELD re-read.
+- Wrong monitor is the audio sink :: AUTO — =set-card-profile= to the right hdmi-extra port.
+- HDMI sink present but silent until daemon restart :: AUTO — restart the user services.
+- HDMI sample-rate mismatch, display rejects :: AUTO — pin the rate.
+
+** Cluster 8 — degraded audio (crackle, xrun, latency)
+Probe: xrun counters (pw-top) + config. Output-only cluster; sound plays but is degraded.
+
+- Quantum drops too low under load :: AUTO — a =min-quantum= drop-in + restart.
+- Realtime scheduling denied (RTKit) :: PRIV — =limits.d= + the realtime group (relogin).
+- Timer-based scheduling on a fussy USB DAC :: AUTO — a =disable-tsched= rule.
+- Forced global sample rate causes resample artifacts :: AUTO — =allowed-rates= config.
+- Small quantum too tight for browser/games :: AUTO — per-app =PIPEWIRE_LATENCY= / a stream rule.
+- HDA power-save clicks (and BT-latency hits) :: PRIV — =power_save=0= modprobe.
+- Insufficient ALSA headroom :: AUTO — a =headroom= rule.
+
+** Cluster 9 — stack healthy, an app is silent
+Probe: none in-stack; the tail on a healthy verdict. The output mirror of input cluster 7.
+
+- Flatpak app has no PulseAudio socket :: GUIDE — print =flatpak override --user --socket=pulseaudio <app>=.
+- Flatpak silent on a PipeWire-only system :: GUIDE — print =flatpak override --user --filesystem=xdg-run/pipewire-0 <app>=.
+- Snap audio-playback interface unconnected :: GUIDE — print =snap connect <snap>:audio-playback=.
+- Browser per-tab mute zeroes the stream :: GUIDE — unmute the tab, or the app's stream.
+- Wine/Proton game silent, wrong audio driver :: GUIDE — winecfg output device; install the 32-bit audio libs.
+- App hard-coded to raw hw:0, device busy :: GUIDE — change the app's device to =default= / =plughw=.
+- Chromium doesn't follow the default-sink change :: GUIDE — reassign the stream in pavucontrol.
+- Electron app routed to the wrong sink :: GUIDE — move the stream in pavucontrol.
+
+** Saturation resample additions (2026-07-10)
+
+Genuinely-new output root causes from the blind resample, each tagged with the cluster it fits and its remedy class. Zero forced a new cluster.
+
+- App picks the wrong audio backend, Spotify (cluster 9 · GUIDE). The app's built-in backend mis-routes. Fix: force the pulseaudio backend in the launch flags (=--audio-api=pulseaudio=). [[https://bbs.archlinux.org/viewtopic.php?id=285730][arch 285730]]
+- A PipeWire regression breaks game audio, Proton XAudio2 titles (cluster 9 · GUIDE). Fix: downgrade PipeWire, or update to the fixed release. [[https://github.com/ValveSoftware/Proton/issues/8730][proton 8730]]
+- App muted in the desktop per-app mixer, KDE Chrome (cluster 9 · AUTO). The Flatpak permission is fine; the app's stream is muted in the DE volume applet. Fix: unmute the app there. [[https://github.com/flathub/com.google.Chrome/issues/297][chrome 297]]
+- App connection stale after an audio-stack update, Firefox (cluster 9 · AUTO). Fix: restart the app or the audio daemon. [[https://forum.endeavouros.com/t/solved-no-audio-in-firefox-from-pipewire-or-pulseaudio-update/26032][endeavour 26032]]
+- Output defaults to the wrong digital out, needs alsactl init, Framework 16 (cluster 2 · AUTO). Fix: =alsactl init=, then select the analog output. [[https://community.frame.work/t/solved-framework-16-ai-300-series-ubuntu-24-04-speakers-work-but-not-headphones/81363][framework 81363]]
+- WirePlumber disabled in config, no enumeration, NixOS (cluster 2 · AUTO). Fix: enable wireplumber in the config, rebuild. [[https://github.com/NixOS/nixpkgs/issues/402999][nixpkgs 402999]]
+- A distro update drops an audio package, openSUSE =wireplumber-audio= (cluster 1 · PRIV). Fix: reinstall and lock the package. [[https://forums.opensuse.org/t/no-output-or-input-devices-after-zypper-dup-only-dummy-output-in-pipewire-on-tumbleweed/185154][opensuse 185154]]
+- USB audio module not auto-loaded after a kernel update (cluster 1 · PRIV). Fix: add =snd_usb_audio= to =modules-load.d=. [[https://forums.linuxmint.com/viewtopic.php?t=443590][mint 443590]]
+- Stray =model=generic= modprobe forces the wrong codec (cluster 1 · REBOOT-TAIL). Left by OEM/installer tooling. Fix: delete the leftover =/etc/modprobe.d= line. [[https://bugs.launchpad.net/bugs/2092728][lp 2092728]]
+- SOF driver aborts when the codec is hda_generic, Acer Swift (cluster 1 · REBOOT-TAIL). Fix: kernel 5.8+; =dmic_detect=0= workaround. [[https://bugs.launchpad.net/ubuntu/+source/linux/+bug/1877757][lp 1877757]]
+- Cirrus CS35L56 smart-amp firmware not applied, ROG G14 2024 (cluster 1 · REBOOT-TAIL). A newer amp than the CS35L41 already catalogued. Fix: newer kernel + firmware. [[https://www.mail-archive.com/debian-kernel@lists.debian.org/msg139811.html][debian-kernel]]
+- Creative CA0132 card needs a kernel quirk, AE-5 Plus (cluster 1 · GUIDE). Reuses an existing PCI id with a different config. Fix: no userspace fix; kernel patch. [[https://forum.manjaro.org/t/soundblaster-creative-ae-5-plus-no-sound/60516][manjaro 60516]]
+- ALC4080 enumerates as USB not HDA, front-panel node not exposed (cluster 4 · AUTO). Fix: an explicit ALSA sink in =pipewire.conf= pointing at the FP device. [[https://discussion.fedoraproject.org/t/headphone-audio-not-working-asus-rog-strix-z590-f-alc4080/76380][fedora 76380]]
+- GPU HDMI audio KAE regression, Intel Arc DG2 (cluster 7 · GUIDE). Fix: kernel fix disables KAE for DG2; update kernel. [[https://www.phoronix.com/news/Intel-DG2-Audio-Fix-Linux-KAE][phoronix]]
+- USB DAC silent at a specific sample rate, FiiO 44.1k (cluster 8 · AUTO). Fix: set =allowed-rates=. [[https://bbs.archlinux.org/viewtopic.php?id=180561][arch 180561]]
+- BIOS firmware bug, crackle or dead speakers fixed by a BIOS update, MSI GF63 (cluster 1 · GUIDE). Broke on Windows too. Fix: flash the vendor BIOS update. [[https://forums.linuxmint.com/viewtopic.php?t=424474][mint 424474]]
+- USB sound device not reliably enumerated after boot, an init race, Creative/Schiit (cluster 5 · GUIDE). Fix: replug; pin the profile. [[https://forum.zorin.com/t/usb-soundcard-creative-sound-blaster-hd-not-always-recognized-after-boot/66177][zorin 66177]]
+- Audio-group membership blocks access, older udev/ConsoleKit distros (cluster 9 · PRIV). The same cross-cutting trap the input side hit. Fix: remove the user from the =audio= group. [[https://wiki.ubuntu.com/Audio/TheAudioGroup][ubuntu audio group]]
+
+* Sources
+
+Every entry carries its source inline as an org link. Reports span the Arch/Manjaro/EndeavourOS/Fedora/Ubuntu/Mint/openSUSE forums, askubuntu, the PipeWire and WirePlumber docs, ALSA and Flatpak issue trackers, kernel bug trackers, and vendor forums (NVIDIA, Dell). Collected 2026-07-10.
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..4d5e028
--- /dev/null
+++ b/docs/design/2026-07-12-control-grammars-reference.org
@@ -0,0 +1,48 @@
+#+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:
+
+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.
+
+* Related
+
+- Gallery: [[file:../prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]] (100 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/maintenance-console-design-ideas.org b/docs/design/maintenance-console-design-ideas.org
new file mode 100644
index 0000000..066c25d
--- /dev/null
+++ b/docs/design/maintenance-console-design-ideas.org
@@ -0,0 +1,527 @@
+#+TITLE: Maintenance Console — Design Ideas
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-06
+
+* Direction
+
+A single-host maintenance console (GUI, waybar-launched) that surfaces every
+health metric for this Arch workstation and, where the remediation is safe,
+runs it. It reshapes the earlier install-health/workstation-contract framing
+(=system-monitor-design-ideas.org=) into a maintenance surface with a doctor
+action.
+
+The console is the glanceable, single-host version of the home project's
+=system-health-check.org= workflow (~1000 lines, capability-dispatched across
+ratio/velox/mybitch/truenas). The console owns the routine-maintenance and
+at-a-glance-status slice; the workflow stays as the escalation path for
+forensic investigation and as the only sanctioned way to run updates.
+
+* Scope: hosts (Q2 resolved 2026-07-06)
+
+v1 targets *both* daily drivers — ratio and velox — as first-class hosts, not
+ratio-first-velox-later. Consequences for v1:
+
+- *Capability dispatch is mandatory from day one*, not deferred. The console
+ probes the live host and runs only applicable checks (btrfs RAID1 on ratio vs
+ ZFS-primary on velox; AMD amd-pstate on ratio vs Intel intel_pstate on velox).
+ This mirrors the home workflow's Phase 0 probe.
+- *velox-only metrics ship in v1*: battery health (capacity / cycles) and the
+ unclean-shutdown / suspend-failure rate — both currently GAPs, both driven by
+ velox being the machine that travels and suspends.
+- The ZFS-primary remediation paths (scrub, snapshot retention, pool health) are
+ built and tested, not stubbed.
+
+* Thresholds source of truth (Q3 resolved 2026-07-06)
+
+One machine-readable file, =maintenance-thresholds.toml=, holds every severity
+value (cache-size trigger, disk %, scrub-age warn/crit, snapshot retention
+limits, temp bands, backup-staleness windows, etc.). archsetup owns and ships
+it; both consumers read the *installed* path, so neither reaches into the
+other's repo:
+
+- the maintenance console (dotfiles code) reads it at runtime,
+- the system-health-check workflow reads it instead of hardcoding severity
+ rules in prose.
+
+The workflow's hard-won values migrate into the TOML as the seed content (the
+snapshot MONTHLY limit that bit /home, the 10 GB cache trigger, the scrub-age
+bands). Install path is an implementation sub-question — a stable location both
+consumers reach (e.g. =~/.config/archsetup/maintenance-thresholds.toml=).
+
+* Workflow ownership — move system-health-check into archsetup (proposed 2026-07-06)
+
+Craig's call: the =system-health-check.org= workflow should live in archsetup,
+not the home project. Rationale — the home project is scoped to finances,
+health, and personal matters; system design, execution, and maintenance are
+archsetup's domain. Home only owns the workflow by inherited accident, not by
+fit. Moving it here also collapses the Q3 coupling: the TOML source-of-truth and
+its workflow consumer end up in the same project.
+
+Move scope (home → archsetup), to sequence when Craig gives the go:
+- =system-health-check.org= (the workflow itself)
+- =homelab-inventory/*.org= (ratio/velox/mybitch/truenas capability inventories
+ it cross-references)
+- any home-project references to the workflow (startup, project-workflows index)
+
+Cross-project mechanics: the archsetup side (receiving the files, wiring the
+TOML) is in-scope here; the home side (removing the originals, updating home's
+references) is the home project's scope — handled by a handoff note to home's
+inbox or a home session, not edited blind from here.
+
+* Panel shape
+
+Three regions, driven by the Automation column of the metrics table below:
+
+- *Actionable* (left) — metrics with a lever. Automation = Auto or Confirm.
+ Two doctor actions serve this column: "Clean up" fires every Auto metric
+ unattended; "Review & fix" opens a preview for the Confirm metrics and acts
+ only on approval.
+- *Diagnostic* (right) — read-only telemetry. Automation = None or Human. A
+ state color (green/amber/red) and the value; no button. Red here is the
+ signal to run the workflow.
+- *Updates* (quarantined strip) — Automation = Workflow. Shown as a count with
+ notable packages named; the only affordance is "run the workflow." The panel
+ never applies updates in place.
+
+*Bar glyph* tracks the worst *Diagnostic* state only — not the actionable
+count. A big package cache is boring; a SMART failure is a fire. Actionable
+clutter must not turn the bar red or it trains you to ignore it.
+
+** Doctor = live output wall (Q1 resolved 2026-07-06)
+
+"Clean up" and "Review & fix" are not fire-and-forget buttons. Running a doctor
+opens an *output wall* — one lamp per action, streaming in realtime as each
+runs:
+
+- *amber* while the action is running,
+- *green* on success (with the reclaimed amount / result inline),
+- *red* on failure.
+
+Feedback is always shown (not just when something was reclaimed) — you watch it
+happen. This is the same live-results shape that should back the other doctors
+(net, bluetooth), so every doctor in the system reads the same way. See the
+todo task to retrofit the net + bt doctors to realtime lamp output.
+
+* Automation legend
+
+| Value | Meaning |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| Auto | Doctor "Clean up" button — fire unattended, reversible or harmless |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| Confirm | Doctor "Review & fix" — preview the change, act only on one click |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| Human | Panel nudges; a human decides and acts (no button, not the agent) |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| Workflow | vLater — metric shown read-only; no in-panel button. Agent-workflow assistance deferred (decided |
+| | 2026-07-07, see below) |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| None | Diagnostic only — no software remediation exists (hardware, telemetry) |
+|----------+-----------------------------------------------------------------------------------------------------------|
+
+*Workflow buttons removed (decided 2026-07-07, prototyping E5).* The panel
+carries no "run workflow" affordances — not on failed units, unclean boots,
+updates, or CVEs. The metrics stay on the board as read-only telemetry; AI
+assistance via the system-health-check workflow is a vLater feature. v1 ships
+only determinate remedies (fixed, scriptable actions with predictable
+outcomes).
+
+*Determinate remedies adopted (decided 2026-07-07).* Every remedy below is
+Confirm-tier; contextual levers appear only when the metric is off-nominal:
+
+- /Service restarts & enables/: failed units (systemctl restart + reset-failed),
+ fail2ban, cronie, chronyd (+ makestep), tailscaled, snapper timers, zram
+ config re-apply. DNS/NetworkManager delegates to the net panel's doctor
+ (deep-link, not a duplicate repair chain).
+- /Deterministic maintenance ops/: btrfs balance -dusage=50 (unallocated low),
+ reinstall owning packages on pacman -Qkk failures, rsyncshot RUN NOW (result
+ streams to the output well), smartctl -t short self-test, snapshot-retention
+ repair (write sane TIMELINE limits + cleanup — the /home lesson as a
+ one-press fix). btrfs device-error counter reset stays manual: resetting
+ without diagnosis masks a dying drive.
+- /Composite macro/: RECLAIM SPACE on the disk-usage cell — runs every reclaim
+ lever (cache, journal, coredumps, app logs, docker tier-1, snapper cleanup)
+ as one output-wall stream.
+- /Disruptive but determinate/: REBOOT behind arm-to-fire, offered when
+ running kernel != installed.
+- /Still read-only/: temps, throttling, battery, memory/OOM, taint, journal
+ error content, kernel/hw events, listeners, unclean-shutdown rate — physical
+ or investigative; vLater AI territory.
+
+*Updates join the Confirm layer (decided 2026-07-07).* Two levers on the
+updates strip, both behind the live-update guard (mesa/hyprland/wayland
+runtime in the pending set):
+
+- UPDATE — repo + AUR system update.
+- TOPGRADE — full ecosystem run. The panel's wrapper always passes
+ --disable git (topgrade's git step rebase-autostashes ~/code/*/ — never
+ under a live session).
+
+*MEM·PWR: evidence + two levers + expectation tags (decided 2026-07-07).*
+Mostly physics, so the category leans watch-only — with these additions:
+
+- /CPU mode selector/: a free segmented control (PERF · BAL · POWER) writing
+ the EPP hint — set the active mode to anything, not drift-repair against a
+ declared default. amd-pstate on ratio, intel_pstate on velox.
+- /Battery charge limit/ (velox-only, capability-gated): SET 80% writes
+ charge_control_end_threshold — the standard longevity cap. Battery *health*
+ (capacity vs design, cycles) stays watch-only hardware telemetry.
+- /Evidence drill-downs/ (digest idiom): top-5 RAM consumers under memory,
+ recent boots listed clean/unclean under the unclean-rate, and throttle/OOM
+ events with timestamps. Evidence makes the numbers actionable even where no
+ button exists.
+- /KILL on top-memory items/ (revised 2026-07-07 — Craig): arm-to-fire, four
+ guards: the arm shows the exact victim (name + size); SIGTERM not SIGKILL,
+ with the outcome reported to the wall; PID + process name revalidated at
+ fire time so a recycled PID can't be hit; session-critical names (systemd,
+ the compositor, the panel itself) render a disabled key — protected. A
+ SIGKILL escalation for TERM-survivors is vLater.
+- /Expectation-setting, panel-wide/: every leverless cell's sub-line carries an
+ explicit tag ("hardware — watch only", "evidence below"), and each subpanel
+ header shows the split — "N fixable · M watch" — so the user knows their
+ agency before reading a single cell.
+
+*Refresh cadence (decided 2026-07-07).* Four tiers, matching probe cost:
+
+- /Live group, panel open/: temps, memory free + top consumers, throttle
+ state — re-read every ~3 s while their subpanel is visible, gated on
+ panel-open exactly like the audio panel's meters. Stop when hidden.
+- /Fast local tier, panel open/: re-probed every ~30 s while the panel is up;
+ additionally, any metric re-probes immediately after an action that touches
+ it (fire CLEAN → cache re-measured, not assumed).
+- /On open/: the hydration tiers re-run (fast reads first, process probes
+ behind them — sub-second perceived).
+- /Network tier/: checkupdates / arch-audit / AUR / firmware stay on the
+ hourly systemd-timer cache with age shown; refreshed on demand only.
+- /Panel closed/: the waybar glyph is fed by a light background scan every
+ ~30 min (systemd timer writing the state file the glyph reads) — the bar
+ stays honest without the panel running.
+
+*Journal errors get a digest, not a fix (decided 2026-07-07).* No generic
+remedy exists — an error-priority line is a symptom of an arbitrary subsystem —
+so the panel ships four determinate assists instead:
+
+- /Digest/: the cell expands to errors grouped by syslog identifier — count,
+ message snippet, first/last seen, and the exact next command (journalctl -u
+ <unit> -b) when the identifier maps to a unit. Top-10 groups, read-only.
+- /MARK KNOWN with a full lifecycle/: arm-to-fire showing the exact pattern
+ before it stores; marked groups move to a dim KNOWN section (never vanish)
+ with per-row UNMARK; every mark/unmark logs to the results wall; marks carry
+ date + example. Patterns bind to identifier + message, never a whole unit —
+ a muted service's *new* errors still surface.
+- /Two noise layers/: shipped defaults (bluetoothd HFP, pixman, xkbcomp) in
+ the packaged TOML, user marks in a separate user file merged over it (a
+ template sync never eats curation). CLEAR MARKS (arm-to-fire) empties the
+ user layer and re-enables shipped defaults; unmarking a shipped default
+ records a disable flag in the user layer.
+- /OPEN JOURNAL/: launches a terminal running journalctl -p err -b — the same
+ delegation pattern as NET DOCTOR.
+
+Ruled out: auto-restarting units that log errors (error ≠ failed) and
+keyword-driven fix suggestions (vLater AI territory).
+
+*Full-sweep findings — all committed to v1 (decided 2026-07-07).* Every metric
+was audited against the converged checklist (honest label, evidence digest
+where a count hides detail, curation lifecycle where "expected" is config
+knowledge, guarded per-item remedies, cross-links, watch-only tags). Adopted,
+all prototyped:
+
+- /Storage/: disk top-consumers digest (evidence only — no file deletion
+ keys); per-device error rows on RAID1 when counters are nonzero,
+ cross-checked against SMART; SMART sub-line carries the last self-test
+ result. Spec note: a real scrub runs hours — the ring needs a running-%
+ state, not an instant reset.
+- /Packages/: orphan digest (name + size) with per-package REMOVE (armed) and
+ KEEP — the curation lifecycle encoding "intentional, not orphaned" (the
+ rust lesson); batch REMOVE ALL skips kept packages. Per-file pacnew rows
+ tagged safe-delete (reflector-managed) vs needs-merge, MERGE delegating to
+ a terminal diff. CVEs named: package · CVE id · severity. AUR and firmware
+ names spelled out.
+- /systemd/: failed units upgraded from a count-lever to a per-unit roster
+ (name · since · exit code · journalctl hint) with per-row RESTART + RESET;
+ is-system-running names its cause ("degraded — N failed units below");
+ taint letters decoded.
+- /Logs/: coredumps grouped by binary (count · last · coredumpctl hint),
+ cleared with the CLEAR action; kernel/hw events listed when not clean
+ (hardware — watch only).
+- /Services/: docker system df breakdown (images / containers / volumes /
+ build cache with per-type reclaimable); stopped containers upgraded to the
+ full signal/expected curation lifecycle (MARK EXPECTED / UNMARK, shipped
+ default: winvm) with per-container START; cron expected-entries drift
+ roster.
+- /Snapshots/: count split by type — timeline (auto-pruned) · single
+ (manual — escapes timeline cleanup, the pile-up risk) · pre/post — with
+ oldest-single named and DELETE STALE (armed, keeps newest 2) when singles
+ accumulate.
+- /Network minors/: fail2ban shows recent-ban count; NTP shows offset.
+
+Rationale for prototyping everything (Craig): real estate and complexity have
+bitten before — surface those limits in the disposable prototype, not after
+functionality exists behind the UI.
+
+*Vertical compression → rotary band selector (2026-07-07).* First attempt —
+the MEM·PWR three-column layout on Packages/Logs/Services — lost too much row
+detail to third-width truncation (Craig's verdict after use). Replaced by a
+*rotary band selector*: the amplifier input-selector idiom. A machined knob
+(click to cycle) whose needle swings to engraved band labels, one per evidence
+section (ORPHANS · PACNEW · ADVISORIES; SIGNAL · KNOWN NOISE · COREDUMPS ·
+KERNEL/HW; CONTAINERS · DOCKER DISK · CRON & BACKUPS). Each band carries its
+own status lamp (section health at a glance without switching) plus a count;
+the selected band gets a gold underline and the needle. One section renders at
+a time at full width, restoring complete row detail. Deliberately distinct
+from the category tiles, console keys, and the CPU-mode segmented control —
+each selection idiom in the panel now has its own visual voice. MEM·PWR keeps
+its three-column evidence strip (short rows fit fine at third-width).
+
+*Listeners get the same treatment (decided 2026-07-07).* The count becomes
+"unexpected listeners" — evidence digest (process · port · bind address from
+ss -tlnp), expected-list curation with the full MARK EXPECTED / UNMARK /
+CLEAR lifecycle (shipped defaults: sshd, mpd, tailscaled; user marks in the
+user layer), and guarded per-socket remedies: STOP (systemctl stop, armed)
+when a unit owns the socket, KILL (SIGTERM, armed) otherwise. Severity keys
+on unexpected AND public-bind (0.0.0.0/::) — a loopback listener warns, an
+exposed one fails — and when ufw is down the signal header names the exposure
+("ufw down — N public binds exposed"). Stopped containers likewise gained a
+contextual START lever (allowlist: winvm), and the firewall its ENABLE.
+
+*Updates strip border is state-tiered (decided 2026-07-07).* Green when
+nothing pending, amber for ordinary pending/AUR/firmware counts, red when
+CVEs exist or pending exceeds the "a lot" threshold (or the update cache has
+gone stale — staleness window in the TOML). The CVE badge renders only when
+the count is nonzero.
+
+*Guard arms instead of blocking (revised 2026-07-07).* When the guard trips,
+the key arms (red, "press again to run anyway — or apply from a TTY") rather
+than hard-refusing. The user decides; the footgun is acknowledged and
+deliberately handed over. After a system update lands, the panel offers a
+reboot: a REBOOT key (arm-to-fire) appears on the updates strip and the
+reboot-required metric flips.
+
+*No per-ecosystem update metrics.* Topgrade's step set (yay, rustup, cargo,
+pipx, npm/pnpm, gem, go, flatpak, fwupd, tmux/zsh/nvim plugins, git repos) has
+no cheap offline "updates available?" probe — mirroring it means a network
+round-trip per registry at panel-open. Instead: one *topgrade freshness*
+metric (wrapper stamps last-run time; threshold in the TOML) whose remedy is
+the TOPGRADE lever, plus a *firmware updates* count in the updates strip
+(fwupd refreshes metadata on its own timer; the panel reads the cache).
+
+Rationale for the hard lines: system updates are Workflow, never Auto — the
+2026-06-07 Hyprland crash was a live -Syu swapping mesa+hyprland under the
+running session, and the standing rule is never -Syu live under Hyprland when
+the mesa/hyprland/wayland runtime is in the set. Hardware findings (SMART, MCE,
+thermal) are None — the fix is replacing a drive or clearing a fan, not
+software.
+
+* Metrics — Storage & filesystem integrity
+
+| Metric | Fix / lever | Automation | Notes |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| Package cache size | paccache -r / -ruk0 | Auto | Reclaim, all re-downloadable |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| paccache -rk1 (keep 1 version) | paccache -rk1 | Confirm | Frees most; kills downgrade |
+| | | | headroom |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| Disk usage (df) | — | None | Fix is cache/snapshot/prune |
+| | | | levers |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| Btrfs unallocated space | — | None | Chunk headroom; diagnostic |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| Btrfs scrub age | btrfs scrub start | Confirm | GAP; ZFS has this, btrfs doesn't. |
+| | | | IO-heavy, on-demand |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| Btrfs device stats (per-drive | btrfs device stats --reset | None | GAP; RAID1 early-warning ahead of |
+| error counters) | | | SMART; reset after review |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| ZFS pool health / errors | — | None | CRITICAL if state != ONLINE or |
+| | | | errors > 0 |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| ZFS scrub age | zpool scrub | Confirm | Covered |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| ZFS capacity | — | None | Perf degrades > 80%; the ZFS |
+| | | | headroom metric (no unallocated |
+| | | | concept) |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| ZFS fragmentation (FRAG) | — | None | GAP; no defrag exists — the |
+| | | | remedy is snapshot pruning + |
+| | | | staying under 80% |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| ZFS autotrim (SSD TRIM) | zpool set autotrim=on / zpool | Confirm | GAP; velox; the fstrim.timer |
+| | trim | | counterpart on ZFS |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| fstrim.timer enabled + firing | systemctl enable --now | Confirm | GAP; standard SSD hygiene |
+| | fstrim.timer | | |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| pacman file integrity (Qkk) | reinstall package | Workflow | GAP; modified/missing files need |
+| | | | judgment |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| SMART health | — | None | Hardware; replace drive |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+| SMART wear / temperature | — | None | Diagnostic |
+|----------------------------------+----------------------------------+------------+-----------------------------------|
+
+* Metrics — Snapshots
+
+| Metric | Fix / lever | Automation | Notes |
+|--------------------------------+--------------------------+------------+---------------------------------------------|
+| Snapper count / retention | snapper cleanup / delete | Confirm | Manual (single) snapshots need explicit |
+| | | | choice |
+|--------------------------------+--------------------------+------------+---------------------------------------------|
+| ZFS snapshot count / retention | zfs destroy | Confirm | Runaway retention |
+|--------------------------------+--------------------------+------------+---------------------------------------------|
+| Snapshot auto-timer running | systemctl enable timer | Confirm | Is the auto-snapshot service firing |
+|--------------------------------+--------------------------+------------+---------------------------------------------|
+
+* Metrics — Packages & security
+
+| Metric | Fix / lever | Automation | Notes |
+|-----------------------------------+------------------------------+------------+--------------------------------------|
+| Orphaned packages | pacman -Rns (named args) | Confirm | Review first (rust looked orphaned, |
+| | | | was intentional) |
+|-----------------------------------+------------------------------+------------+--------------------------------------|
+| Pending updates count | — | Workflow | Never auto; workflow-only |
+|-----------------------------------+------------------------------+------------+--------------------------------------|
+| .pacnew files | diff + merge / delete | Confirm | Allowlist auto-deletes mirrorlist / |
+| | | | locale.gen |
+|-----------------------------------+------------------------------+------------+--------------------------------------|
+| arch-audit CVEs | reviewed update | Workflow | GAP; top-priority add. Fix is an |
+| | | | update |
+|-----------------------------------+------------------------------+------------+--------------------------------------|
+| Keyring freshness | pacman -Sy archlinux-keyring | Confirm | GAP; stale keyring breaks update |
+| | | | signatures |
+|-----------------------------------+------------------------------+------------+--------------------------------------|
+| AUR / foreign staleness (Qm) | paru -Sua | Workflow | GAP; AUR updates reviewed, not auto |
+|-----------------------------------+------------------------------+------------+--------------------------------------|
+| Reboot required (kernel mismatch) | reboot | Human | GAP; uname -r vs /usr/lib/modules. |
+| | | | You pick when |
+|-----------------------------------+------------------------------+------------+--------------------------------------|
+
+* Metrics — systemd & boot
+
+| Metric | Fix / lever | Automation | Notes |
+|--------------------------------+------------------------+------------+-----------------------------------------------|
+| is-system-running (degraded?) | — | None | GAP; one-token whole-system verdict; |
+| | | | candidate for the bar glyph |
+|--------------------------------+------------------------+------------+-----------------------------------------------|
+| Failed systemd units | restart / investigate | Workflow | Depends why it failed |
+|--------------------------------+------------------------+------------+-----------------------------------------------|
+| Maintenance timers enabled + | systemctl enable timer | Confirm | GAP; meta-metric — are |
+| firing | | | paccache/btrfs-scrub/fstrim/reflector/snapper |
+| | | | firing |
+|--------------------------------+------------------------+------------+-----------------------------------------------|
+| Kernel taint flag | — | None | GAP; tainted != 0 = proprietary module or |
+| | | | prior crash |
+|--------------------------------+------------------------+------------+-----------------------------------------------|
+
+* Metrics — Logs & coredumps
+
+| Metric | Fix / lever | Automation | Notes |
+|------------------------------------+---------------------------------+------------+----------------------------------|
+| Coredump count | coredumpctl clean (keep recent) | Auto | Keep last few days for forensics |
+|------------------------------------+---------------------------------+------------+----------------------------------|
+| Journald disk usage | journalctl --vacuum-size/time | Auto | Bounded journal |
+|------------------------------------+---------------------------------+------------+----------------------------------|
+| App-log cleanup (>7d) | log-cleanup cron trigger | Auto | Already a cron; manual trigger |
+|------------------------------------+---------------------------------+------------+----------------------------------|
+| Journal error count (real vs | — | Workflow | Forensic; noise-filtered count |
+| noise) | | | |
+|------------------------------------+---------------------------------+------------+----------------------------------|
+| Kernel/hardware events | — | None | Forensic, hardware |
+| (MCE/USB/thermal/GPU) | | | |
+|------------------------------------+---------------------------------+------------+----------------------------------|
+
+* Metrics — Memory, thermal, power
+
+| Metric | Fix / lever | Automation | Notes |
+|-----------------------------------------+-------------+------------+--------------------------------------------|
+| Memory free / OOM kills | — | Workflow | OOM = investigate |
+|-----------------------------------------+-------------+------------+--------------------------------------------|
+| Swap / zram present + healthy | — | None | Diagnostic |
+|-----------------------------------------+-------------+------------+--------------------------------------------|
+| CPU / GPU temperatures | — | None | Hardware |
+|-----------------------------------------+-------------+------------+--------------------------------------------|
+| Thermal throttling active | — | None | Cooling issue |
+|-----------------------------------------+-------------+------------+--------------------------------------------|
+| Battery health (capacity / cycles) | — | None | GAP; laptop; ties to open suspend todo |
+|-----------------------------------------+-------------+------------+--------------------------------------------|
+| Unclean-shutdown / suspend-failure rate | — | Workflow | GAP; ratio flagged ~75% unclean 2026-06-08 |
+|-----------------------------------------+-------------+------------+--------------------------------------------|
+
+* Metrics — Network & security posture
+
+| Metric | Fix / lever | Automation | Notes |
+|-----------------------------------+---------------------------+------------+-----------------------|
+| DNS / NetworkManager reachability | restart NM | Workflow | CRITICAL if down |
+|-----------------------------------+---------------------------+------------+-----------------------|
+| Firewall active (ufw / nftables) | ufw enable | Confirm | GAP; security posture |
+|-----------------------------------+---------------------------+------------+-----------------------|
+| Unexpected listeners (ss -tlnp) | — | None | GAP; security review |
+|-----------------------------------+---------------------------+------------+-----------------------|
+| Tailscale peers | tailscale up | Confirm | Covered |
+|-----------------------------------+---------------------------+------------+-----------------------|
+| fail2ban running + bans | systemctl start | Confirm | Covered |
+|-----------------------------------+---------------------------+------------+-----------------------|
+| NTP sync (chrony) | systemctl restart chronyd | Confirm | Covered |
+|-----------------------------------+---------------------------+------------+-----------------------|
+
+* Metrics — Services, backups, virt
+
+| Metric | Fix / lever | Automation | Notes |
+|---------------------------------+--------------------------+------------+--------------------------------------------|
+| rsyncshot backup freshness | — | Workflow | CRITICAL if daily > 48h; investigate |
+| | | | failure |
+|---------------------------------+--------------------------+------------+--------------------------------------------|
+| Docker/podman reclaimable | prune tier 1 / tiers 2-3 | Confirm | Tier 1 nearly Auto; 2-3 destructive |
+|---------------------------------+--------------------------+------------+--------------------------------------------|
+| Docker stopped containers | — | None | Mostly expected (WinVM on-demand) |
+|---------------------------------+--------------------------+------------+--------------------------------------------|
+| libvirt VM state | — | None | Expected off |
+|---------------------------------+--------------------------+------------+--------------------------------------------|
+| Cron running + expected entries | systemctl enable cronie | Confirm | rsyncshot + log-cleanup entries |
+|---------------------------------+--------------------------+------------+--------------------------------------------|
+
+* Architecture & testing (decided 2026-07-07)
+
+*CLI-first, GUI as a face.* The console ships as the fourth panel sibling: a
+=maint= Python package in dotfiles (like =net/=, =bt/=, =audio/=) with probe
+modules (read-only collectors), a remedies module, =cli.py=, and =gui.py=
+driving the same code. =maint status --json= is the contract; =maint fix
+<thing>= is every lever. The GUI never does anything the CLI can't.
+
+Safety mechanics baked into the CLI:
+- global =--dry-run= prints the exact command instead of executing — free test
+ surface, and the GUI's arm-press can display it ("this will run: …").
+- hard read/write split: collectors never elevate; every remedy is an
+ allowlisted exact argv in one small auditable module.
+
+*Four test layers (safest → scariest):*
+
+1. /Unit, fake binaries — no VM, no root (~90% of surface)./ Probes are
+ parsers over command output: feed canned smartctl/btrfs/journalctl/pacman/
+ ss/docker output via fakes on PATH (the net suite's fake-curl and audio's
+ fake-parec pattern). Remedies tested as command construction (assert the
+ argv, don't run it). The live-update guard is a pure function over a
+ package list.
+2. /Read-only integration on the live machine./ All collectors are read-only
+ by design — =maint status --json= runs safely against real hosts.
+3. /Remedies in a VM — archsetup's existing harness./
+ =scripts/testing/run-test.sh= boots the installer VM; a maint scenario
+ breaks things deliberately over ssh (stop cronie, mask fstrim, orphan
+ packages, fill the cache), runs =maint fix …=, asserts post-state. No GUI
+ in the VM. Add qcow2 snapshot/restore between remedy tests so each starts
+ pristine and destructive remedies can't contaminate each other. Pure
+ pacman-level tests may use a throwaway systemd-nspawn container instead
+ (lighter); the VM stays for systemd/btrfs/reboot territory.
+4. /GUI on the host, never in the VM./ AT-SPI smoke like the sibling panels,
+ driven by fixture data. The prototype's GOOD/BAD snapshots become those
+ fixtures — =MAINT_PANEL_FIXTURE=bad= renders the degraded board without a
+ degraded machine, conforming to the =maint status --json= schema.
+
+* Open questions
+
+- RESOLVED 2026-07-06 — live output wall (amber running / green done / red fail),
+ realtime, always shown. See "Doctor = live output wall" above.
+- RESOLVED 2026-07-06 — velox is a first-class v1 target alongside ratio. See
+ "Scope: hosts" below.
+- RESOLVED 2026-07-06 — single machine-readable thresholds file
+ (=maintenance-thresholds.toml=) is the source of truth, *owned by archsetup*.
+ Both the console and the system-health-check workflow read it, so they can
+ never drift. See "Thresholds source of truth" and "Workflow ownership" below.
diff --git a/docs/design/system-monitor-design-ideas.org b/docs/design/system-monitor-design-ideas.org
new file mode 100644
index 0000000..26619a0
--- /dev/null
+++ b/docs/design/system-monitor-design-ideas.org
@@ -0,0 +1,1008 @@
+#+TITLE: System Monitor Design Ideas
+#+DATE: 2026-07-04
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Status
+:PROPERTIES:
+:ID: system-monitor-design-ideas
+:END:
+- [2026-07-04 Sat] DRAFT — initial design sketch for a health monitor covering
+ Archangel ISO/base-install health, Archsetup workstation health, and the local
+ laptop as the daily canary.
+
+* Metadata
+
+| Field | Value |
+|--------+--------------------------------------------|
+| Status | draft |
+|--------+--------------------------------------------|
+| Owner | Craig Jennings |
+|--------+--------------------------------------------|
+| Repos | archsetup, archangel, dotfiles |
+|--------+--------------------------------------------|
+| Kin | net panel, bluetooth panel, audio panel |
+|--------+--------------------------------------------|
+
+* Problem
+
+Archangel and Archsetup can fail in ways that are individually obvious only
+after the damage is done: an ISO build goes stale against Arch or archzfs,
+ZFSBootMenu or GRUB boots once but not after the first upgrade, a snapshot
+hook silently disappears, a package database ages out, systemd services fail
+after a reboot, or the desktop contract is technically installed but not
+usable.
+
+The health surface should compress those risks into one operational question:
+"Can I trust a fresh install, and is this current workstation drifting away
+from the known-good install contract?"
+
+This monitor is not a generic CPU/RAM graph. It is an install-health and
+workstation-contract console. CPU, memory, and temperature belong only as
+secondary context unless they block install/test operations.
+
+* Priority Model
+
+Rank metrics by the cost of blindness: what happens if Craig never sees the
+metric, no one mitigates it, and the next install/upgrade/reboot simply happens.
+
+Severity:
+
+- =P0= — can cause data loss, unbootable systems, or loss of rollback path.
+- =P1= — can break fresh installs, upgrades, remote access, or core
+ workstation use.
+- =P2= — causes degraded workstation behavior, security drift, or accumulating
+ maintenance debt.
+- =P3= — useful context, not a release gate by itself.
+
+The panel should sort by live severity first, then by this priority. A red =P2=
+row appears above a green =P0= row, but in the steady state the layout keeps the
+P0/P1 rows in the first viewport.
+
+* Priority Ranking
+
+| Rank | Priority | Metric | Why this rank exists |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 1 | P0 | Storage health | Silent pool/filesystem degradation is the nearest thing to data loss. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 2 | P0 | Snapshot safety coverage | Without snapshots, upgrades lose their rollback safety net. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 3 | P0 | Bootloader and EFI redundancy | A machine that cannot boot is operationally dead. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 4 | P1 | First-upgrade bootability | Catches the classic "installed fine, broke after update" failure. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 5 | P1 | End-to-end VM install pass rate | Best release gate for the whole Archangel + Archsetup chain. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 6 | P1 | Package sync and repo freshness | Arch, archzfs, keyring, and mirror drift are leading break signals. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 7 | P1 | Archangel ISO reproducibility | If current inputs cannot build an ISO, recovery/install confidence is stale. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 8 | P1 | Post-install service health | Network, DNS, SSH, and user services decide whether the system is usable. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 9 | P1 | Archsetup state/log cleanliness | Prevents "half-installed but looks fine" machines. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 10 | P2 | Workstation contract checks | Confirms this is Craig's workstation, not just generic Arch. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 11 | P2 | Backup and rollback readiness | Catches loss of off-machine recovery and edited-file backups. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+| 12 | P2 | Security and hardening drift | Important, but usually less immediately destructive than boot/storage. |
+|------+----------+---------------------------------+------------------------------------------------------------------------------|
+
+This order intentionally puts ZFS/Btrfs health above VM install evidence. A
+broken future install is expensive; silent damage to the current root or backup
+chain is worse.
+
+* Consequence Matrix
+
+This is the design justification for every metric. A row earns panel space only
+if blindness has a clear failure mode and Doctor has at least a useful
+diagnostic or mitigation.
+
+| Rank | Metric | If never seen / never mitigated | Typical failure | Worst plausible failure | Doctor posture |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 1 | Storage health | Disk, pool, or metadata degradation accumulates silently. | Correctable ZFS/Btrfs errors, low EFI/root space, stale scrub. | Data loss, degraded root, failed import/mount during boot. | Diagnose + scrub/cleanup with confirmation. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 2 | Snapshot safety coverage | Upgrades proceed without a rollback point. | Missing recent snapshot or missing pre-pacman hook. | Bad upgrade cannot be rolled back cleanly; manual repair required. | Create snapshot; restore hook; never rollback. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 3 | Bootloader and EFI redundancy | Boot path rots until the next reboot or disk failure. | Missing GRUB/ZBM file on one EFI partition. | Unbootable machine after update, firmware reset, or disk loss. | Diagnose; regenerate config; advanced reinstall only. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 4 | First-upgrade bootability | Installs look good until the first real system update. | Kernel/initramfs/bootloader mismatch in VM. | Fresh bare-metal install dies on first reboot after upgrade. | VM-only upgrade test; collect boot evidence. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 5 | End-to-end VM install pass rate | Unit tests give false confidence about the real workflow. | Current branch fails one filesystem path or desktop assertion. | Bare-metal install fails mid-flight after disks are wiped. | Run/schedule VM test; clean stale VM artifacts. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 6 | Package sync/repo freshness | Arch/keyring/archzfs drift surprises the next install. | 404s, stale keyring, bad mirror, stale archzfs DB. | Installer cannot pacstrap or installs mismatched ZFS/kernel bits. | Refresh DB; update keyring; reflector. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 7 | ISO build reproducibility | Recovery/install media confidence becomes historical. | AUR package fails to build; mkarchiso or DKMS breaks. | Need rescue/install media and discover no current ISO can be built. | Parse logs; clean work; explicit rebuild. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 8 | Post-install service health | System is installed but degraded in daily operation. | DNS, NetworkManager, fail2ban, tailscale, or user service down. | No remote access, no network, broken sync, or security tooling off. | Restart/re-enable classified services only. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 9 | Archsetup state/log cleanliness | Half-completed provisioning masquerades as success. | Missing marker, log error, skipped step after resume. | Fresh workstation lacks critical config but looks mostly usable. | Summarize; rerun resumable archsetup. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 10 | Workstation contract checks | The box drifts from "Craig's workstation" to generic Arch. | Dotfile symlink broken, keyring wrong, missing tool/package. | Desktop/session workflow is broken during real work. | Restow, repair perms, reinstall with confirm. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 11 | Backup and rollback readiness | Recovery assumptions go stale. | Missing =.archsetup.bak=, backup timer stale, dry-run fails. | Local rollback works but important personal/system state is gone. | Dry-run, start configured job, no deletes. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+| 12 | Security and hardening drift | Small protective edits quietly regress. | SSH/firewall/fail2ban/sysctl/EFI mask drift. | Exposed service or weakened local boot/config protections. | Restore owned snippets; no broad rewrite. |
+|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------|
+
+The worst cases are intentionally conservative, not dramatic. The monitor is
+useful because it catches the boring early signal: stale, missing, not recently
+tested, or silently degraded.
+
+* Product Shape
+
+Use the existing instrument-console panel language rather than a dashboard page:
+lamps for pass/warn/fail state, engraved metric groups, dense rows, physical
+console keys for explicit actions, and an output well for the last diagnosis.
+
+The bar module should be tiny:
+
+- icon: a pulse/terminal glyph or compact =SYS= label.
+- state lamp: green/yellow/red/grey.
+- text: one word only: =OK=, =WARN=, =FAIL=, =STALE=, =RUNNING=, =UNKNOWN=.
+- click: opens the system monitor panel.
+- middle click or secondary action: run a cheap refresh only, never repairs.
+
+The panel should be one screen with internal scroll only where needed. The
+first viewport should show the decision state and the Doctor controls without
+scrolling.
+
+* Layout
+
+** Faceplate
+
+Top row:
+
+- State lamp + state word: =OK= / =WARN= / =FAIL= / =STALE= / =RUNNING= /
+ =UNKNOWN=.
+- Unit label: =SYS·01=.
+- Scope segmented control: =HOST= / =INSTALL= / =BUILD=.
+- Badges: =ZFS= or =BTRFS=, =VM STALE=, =DB STALE=, =SNAPSHOT=, =BACKUP=,
+ =ROOT= when elevated actions are available.
+- Close button.
+
+The scope control changes the metric emphasis, not the underlying data model:
+
+- =HOST= is this laptop/workstation right now.
+- =INSTALL= is the last Archangel+Archsetup VM install result.
+- =BUILD= is ISO, AUR repo, archzfs, and test artifact health.
+
+** Health Stack
+
+Arrange metrics as four horizontal bands. Each band has a section title, a
+summary lamp, two to four row lamps, and a short "age" or "count" value.
+
+1. =BOOT + STORAGE=
+ - bootloader
+ - EFI redundancy
+ - pool/filesystem
+ - snapshots
+
+2. =INSTALL PIPELINE=
+ - first-upgrade reboot
+ - ZFS VM install
+ - Btrfs VM install
+ - ISO build
+
+3. =PACKAGES + SERVICES=
+ - pacman sync freshness
+ - archzfs/AUR health
+ - failed services
+ - journal errors
+
+4. =WORKSTATION CONTRACT=
+ - Archsetup markers/logs
+ - user/dotfiles
+ - desktop/session
+ - backups/replication
+
+Each row is clickable. Clicking a row opens the evidence drawer in the output
+well with:
+
+- last command run
+- normalized verdict
+- raw excerpt, redacted where needed
+- suggested Doctor action, if any
+
+** Console Keys
+
+Use physical console-key buttons, same family as net/bt:
+
+| Key | Purpose |
+|--------------+------------------------------------------------------------|
+| REFRESH | Cheap read-only probe of host state |
+|--------------+------------------------------------------------------------|
+| DOCTOR | Diagnose, classify, run safe mitigations, re-check |
+|--------------+------------------------------------------------------------|
+| TEST VM | Run or schedule Archsetup VM validation |
+|--------------+------------------------------------------------------------|
+| BUILD ISO | Run or schedule Archangel ISO build |
+|--------------+------------------------------------------------------------|
+| CLEAN | Clean old test artifacts, package cache, stale logs |
+|--------------+------------------------------------------------------------|
+| SNAPSHOT | Create a manual pre-change snapshot |
+|--------------+------------------------------------------------------------|
+
+Keys that can take a long time stream progress into the output well. Mutating
+keys must use arm-first behavior:
+
+- first click arms for 3 seconds and explains the action.
+- second click runs.
+- destructive cleanup names what will be deleted before it runs.
+
+* Metric Details
+
+Details below are grouped by workflow rather than priority. The authoritative
+importance order is the priority table above.
+
+** 1. End-to-end VM install pass rate =P1/rank 5=
+
+Problem overcome: unit tests can pass while the real install is broken by
+mirrors, bootloader state, pacstrap, SSH, disk layout, or the actual desktop
+contract. This metric fights false confidence.
+
+Representation:
+
+- Lamp row in =INSTALL PIPELINE=.
+- Two child lamps: =ZFS= and =BTRFS=.
+- Age chip: =last pass 2d= / =never= / =stale 14d=.
+- Red if either required filesystem has no recent pass.
+
+Tools:
+
+- =scripts/testing/run-test.sh=
+- =scripts/testing/create-base-vm.sh=
+- =pytest= testinfra suite under =scripts/testing/tests/=
+- =qemu-img=, =qemu-system-x86_64=, =sshpass=
+
+Doctor:
+
+- read-only first: summarize last =test-results/*/test-report.txt= and failing
+ test names.
+- mitigation: offer =TEST VM= for the failed filesystem.
+- cleanup: remove stale temporary VM overlays before retrying.
+- no automatic retry loop if the failure is in the installer itself.
+
+** 2. First-upgrade bootability =P1/rank 4=
+
+Problem overcome: the machine can boot immediately after install but fail after
+the first =pacman -Syu= because initramfs hooks, ZFS modules, GRUB, ZFSBootMenu,
+or kernel packages drift.
+
+Representation:
+
+- Lamp row: =first-upgrade reboot=.
+- Badge: =not run=, =passed=, =failed=.
+- Evidence drawer includes boot count, kernel version, and last reachable SSH
+ timestamp.
+
+Tools:
+
+- VM harness
+- =pacman -Syu=
+- =reboot=
+- =ssh= reachability checks
+- =journalctl -b -1= where available
+
+Doctor:
+
+- run upgrade-in-VM only, never on host without explicit confirmation.
+- if failure is ZFS, collect =zpool import=, =lsinitcpio=, =mkinitcpio.conf=,
+ and EFI files.
+- if failure is Btrfs, collect =grub.cfg=, =crypttab=, =fstab=, and snapper
+ config.
+
+** 3. Package database freshness and sync health =P1/rank 6=
+
+Problem overcome: Arch rolling-release state changes faster than installer
+assumptions. Stale sync databases, stale keyrings, archzfs drift, or broken
+mirrors are leading indicators of a failing install.
+
+Representation:
+
+- Row lamp in =PACKAGES + SERVICES=.
+- Small meter: newest sync DB age vs threshold.
+- Child chips: =core=, =extra=, =multilib=, =archzfs=.
+- Yellow over 48 hours, red over 7 days or failed sync.
+
+Tools:
+
+- =find /var/lib/pacman/sync=
+- =pacman -Syyu --needed archlinux-keyring=
+- =checkupdates=
+- =reflector=
+- =pacman-conf=
+
+Doctor:
+
+- safe: refresh package databases.
+- safe: update =archlinux-keyring= before full upgrades.
+- mitigation: run =reflector= with the configured country/age policy.
+- no unattended full system upgrade from the panel unless separately approved.
+
+** 4. Archangel ISO build reproducibility =P1/rank 7=
+
+Problem overcome: an old "good" ISO can hide broken current inputs. Archiso,
+archzfs, DKMS, AUR package recipes, and pacoloco cache state can all break the
+next install.
+
+Representation:
+
+- Row lamp in =INSTALL PIPELINE= or =BUILD= scope.
+- Shows latest ISO date, kernel version, and AUR manifest age.
+- Red if latest build failed or no ISO exists.
+- Yellow if latest successful ISO is older than the configured freshness
+ window.
+
+Tools:
+
+- =make build= in =~/code/archangel=
+- =build.sh --skip-aur= for fast non-AUR iteration
+- =build-aur.sh=
+- =mkarchiso=
+- =pacoloco= status if installed
+
+Doctor:
+
+- read-only: parse latest =out/*.log= for pacman, DKMS, archzfs, AUR, and
+ mkarchiso failures.
+- cleanup: safe build-work cleanup only through Archangel's cleanup function
+ or =make clean=.
+- mitigation: suggest =--skip-aur= when the failure is unrelated to baked AUR.
+- build retry is explicit via =BUILD ISO=, not automatic.
+
+** 5. ZFS/Btrfs storage health =P0/rank 1=
+
+Problem overcome: the root filesystem can degrade silently before the user
+notices. For ZFS this means pool errors or degraded vdevs; for Btrfs this means
+device stats, scrub failures, metadata pressure, or degraded RAID.
+
+Representation:
+
+- =BOOT + STORAGE= band.
+- Filesystem-specific lamp grammar:
+ - ZFS green: =zpool status -x= healthy.
+ - Btrfs green: device stats clean and recent scrub clean.
+- Capacity strip for root/home/EFI.
+
+Tools:
+
+- ZFS: =zpool status -x=, =zpool list=, =zfs list=.
+- Btrfs: =btrfs device stats=, =btrfs filesystem usage=,
+ =btrfs scrub status=.
+- Common: =df -h=, =findmnt=, =lsblk=.
+
+Doctor:
+
+- safe: start a scrub only with arm-first confirmation.
+- safe: clear stale Btrfs stats only after a clean scrub and explicit
+ confirmation.
+- mitigation: warn on low EFI/root space and offer package cache cleanup.
+- never destroy snapshots, pools, subvolumes, or datasets from Doctor.
+
+** 6. Snapshot safety coverage =P0/rank 2=
+
+Problem overcome: rollback safety is assumed during upgrades but can disappear
+when hooks, services, or snapshot tools drift.
+
+Representation:
+
+- Row lamp: =snapshots=.
+- Child chips: =genesis=, =pre-pacman=, =recent=, =pruned=.
+- Yellow if no recent snapshot.
+- Red if genesis or pre-transaction hook is missing.
+
+Tools:
+
+- ZFS: =zfs list -t snapshot=, =zfs-pre-snapshot=,
+ =/etc/pacman.d/hooks/zfs-snapshot.hook=.
+- Btrfs: =snapper list=, =snap-pac=, =grub-btrfs-mkconfig=,
+ =/.snapshots=.
+- Common: =pacman -Q= for snapshot packages.
+
+Doctor:
+
+- safe: create a manual snapshot.
+- safe: reinstall or re-enable missing hook only if the expected script exists.
+- cleanup: prune only snapshots matching the tool-owned policy and prefix.
+- mitigation: show exact command for manual rollback; do not perform rollback
+ from the panel.
+
+** 7. Bootloader and EFI redundancy =P0/rank 3=
+
+Problem overcome: single-disk bootloader success can mask missing redundant EFI
+installs on multi-disk systems. A system can also pass install but lose a boot
+entry or generate an invalid config.
+
+Representation:
+
+- Row lamp: =bootloader=.
+- Child chips: =ZBM= or =GRUB=, =EFI=, =entries=, =all disks=.
+- Yellow if redundancy cannot be proven.
+- Red if the expected loader/config is missing.
+
+Tools:
+
+- =bootctl status=
+- =efibootmgr -v=
+- =findmnt /efi /boot=
+- ZFS: check =/efi/EFI/ZBM/zfsbootmenu.efi=.
+- Btrfs: check =/boot/grub/grub.cfg= and grub-btrfs entries.
+
+Doctor:
+
+- read-only by default.
+- mitigation: regenerate GRUB config for Btrfs with arm-first confirmation.
+- mitigation: rebuild initramfs with arm-first confirmation.
+- no automatic EFI reinstall without an explicit advanced flow.
+
+** 8. Post-install service health =P1/rank 8=
+
+Problem overcome: the install can complete while the real workstation is
+degraded: DNS broken, NetworkManager failed, fail2ban not responding, user
+services not lingering, or Docker/Tailscale/Syncthing not in their expected
+state.
+
+Representation:
+
+- =PACKAGES + SERVICES= band.
+- Count badge: =0 failed= or =3 failed=.
+- Child lamps: =network=, =dns=, =security=, =user services=.
+
+Tools:
+
+- =systemctl --failed=
+- =systemctl is-enabled/is-active=
+- =resolvectl status=
+- =nmcli general status=
+- =fail2ban-client status=
+- =loginctl show-user=
+
+Doctor:
+
+- safe: restart known flaky non-destructive services such as
+ =NetworkManager= only after classifying the failure.
+- safe: re-enable expected services from Archsetup's contract.
+- mitigation: bounce DNS resolver and re-check.
+- no blanket =systemctl restart --failed=.
+
+** 9. Archsetup state and log cleanliness =P1/rank 9=
+
+Problem overcome: a resumable installer can leave a half-finished system that
+looks usable until a missing marker or skipped step matters later.
+
+Representation:
+
+- =WORKSTATION CONTRACT= band.
+- Step-progress mini bar: completed markers / expected markers.
+- Red if =archsetup --status= reports incomplete required steps.
+- Red if latest log contains fatal errors.
+
+Tools:
+
+- =./archsetup --status=
+- =/var/log/archsetup-*.log=
+- marker files from the Archsetup state directory
+- existing testinfra assertions in =scripts/testing/tests/test_archsetup.py=
+
+Doctor:
+
+- read-only: summarize incomplete steps and latest log errors.
+- mitigation: offer to rerun =archsetup= in normal resumable mode.
+- cleanup: archive old logs, keep the latest N.
+- never run =--fresh= from Doctor.
+
+** 10. Workstation contract checks =P2/rank 10=
+
+Problem overcome: a fresh Arch system is not the goal. The goal is Craig's
+working machine: user, shell, groups, dotfiles, Emacs, Hyprland/DWM, keyring,
+VPN tools, Bluetooth tools, and local scripts.
+
+Representation:
+
+- =WORKSTATION CONTRACT= band.
+- Child lamps: =user=, =dotfiles=, =desktop=, =tools=.
+- Evidence drawer mirrors the testinfra checks.
+
+Tools:
+
+- =id=, =getent passwd=
+- =test -L ~/.zshrc=
+- =stow= via dotfiles Makefile
+- =pacman -Q=, =yay -Qi yay=
+- =hyprctl=, =gdbus= portal checks when session is running
+
+Doctor:
+
+- safe: restow dotfiles with the selected profile.
+- safe: repair keyring directory permissions.
+- mitigation: reinstall missing official packages.
+- AUR package rebuilds require confirmation and stream output.
+
+** 11. Security and hardening drift =P2/rank 12=
+
+Problem overcome: security settings are easy to regress because they are small
+file edits: SSH root login, EFI mount masks, firewall, issue banner, fail2ban,
+quiet printk.
+
+Representation:
+
+- Compact row under =WORKSTATION CONTRACT= or =PACKAGES + SERVICES=.
+- Red only for high-risk drift, yellow for unknown/unreadable state.
+
+Tools:
+
+- =sshd -T= or config file checks
+- =ufw status=
+- =fail2ban-client status=
+- =findmnt /efi=
+- =sysctl kernel.printk=
+
+Doctor:
+
+- safe: restore known Archsetup-owned config snippets.
+- safe: re-enable firewall if policy file is present.
+- mitigation: write missing drop-ins only from version-controlled templates.
+- no broad hardening rewrite from panel state.
+
+** 12. Backup and rollback readiness =P2/rank 11=
+
+Problem overcome: rollback only helps local state. The install also needs
+backups of edited system files and confidence that personal data replication is
+not silently stale.
+
+Representation:
+
+- Row lamp: =backups=.
+- Chips: =system-file .bak=, =replication=, =last run=.
+- Yellow if last replication exceeds policy.
+- Red if expected backup files for edited system config are missing.
+
+Tools:
+
+- Archsetup backup assertions in =scripts/testing/tests/test_backups.py=.
+- =zfs-replicate= if configured.
+- =systemctl list-timers= for backup timers.
+- =journalctl -u= relevant backup units.
+
+Doctor:
+
+- safe: create missing =.archsetup.bak= for files before editing.
+- safe: run dry-run replication check.
+- mitigation: start a configured backup timer/unit with confirmation.
+- never delete backup targets from Doctor.
+
+* Doctor Model
+
+Doctor is a classifier with bounded mitigations, not a magic repair button.
+
+Flow:
+
+1. Probe the selected scope.
+2. Normalize each metric to =ok=, =warn=, =fail=, =unknown=, or =running=.
+3. Classify failures as:
+ - =safe-fix= — local, reversible, low risk.
+ - =safe-cleanup= — removes only known generated artifacts.
+ - =mitigation= — improves the chance of success but does not claim repair.
+ - =needs-confirmation= — mutating, long-running, or system-wide.
+ - =manual= — too dangerous or context-heavy for Doctor.
+4. Run only safe actions automatically after the user presses Doctor.
+5. Arm-first for anything mutating beyond safe local cleanup.
+6. Re-run the affected probe.
+7. Stream a verdict into the output well.
+
+Doctor should say exactly what it did:
+
+#+BEGIN_EXAMPLE
+doctor: package db stale
+ check: core.db age 4d, archzfs.db age 4d
+ action: refreshed sync databases
+ action: updated archlinux-keyring
+ result: ok, newest db age 2m
+#+END_EXAMPLE
+
+* Common Tool Drivers
+
+** Host probes
+
+| Area | Commands |
+|------------+----------------------------------------------------------------|
+| systemd | =systemctl --failed=, =systemctl is-active=, =journalctl= |
+|------------+----------------------------------------------------------------|
+| packages | =pacman=, =checkupdates=, =pacman-conf=, =yay= |
+|------------+----------------------------------------------------------------|
+| storage | =zpool=, =zfs=, =btrfs=, =df=, =findmnt=, =lsblk= |
+|------------+----------------------------------------------------------------|
+| boot | =bootctl=, =efibootmgr=, =mkinitcpio=, =grub-mkconfig= |
+|------------+----------------------------------------------------------------|
+| network | =nmcli=, =resolvectl=, =ping= or HTTPS probe |
+|------------+----------------------------------------------------------------|
+| desktop | =hyprctl=, =gdbus=, =loginctl=, dotfiles Makefile |
+|------------+----------------------------------------------------------------|
+
+** Project probes
+
+| Area | Commands |
+|------------+----------------------------------------------------------------|
+| archangel | =make test=, =make build=, =build.sh --skip-aur= |
+|------------+----------------------------------------------------------------|
+| archsetup | =make test-unit=, =make test=, =scripts/testing/run-test.sh= |
+|------------+----------------------------------------------------------------|
+| VM | =qemu-img=, =qemu-system-x86_64=, =sshpass=, =pytest= |
+|------------+----------------------------------------------------------------|
+| artifacts | latest =out/*.log=, =out/*aur-manifest.tsv=, =test-results/*= |
+|------------+----------------------------------------------------------------|
+
+* Top-family Comparison
+
+This monitor should borrow the mature display ideas from =top=-style tools
+without becoming another CPU/process viewer. The domain objects are install
+contracts, boot/storage health, package freshness, snapshots, services, and
+artifacts. The interaction model is still the same: sort the thing that hurts,
+filter to the thing you care about, expand one row for evidence, and act only
+when the diagnosis is clear.
+
+** Comparison table
+
+| Tool | What it represents well | Sorting/filtering model | Useful pattern for system monitor | Gaps for our domain |
+|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------|
+| =htop= | Dense live table plus configurable meters; process tree; direct process actions. | Interactive sort by column, search, filter, tree toggle. | Metric table should support column sort, search, filter, and tree/group mode. | No historical artifact model; actions are process-centric. |
+|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------|
+| =btop++= | Boxed dashboard: CPU, memory, disks, network, processes, battery, GPU; strong graph language; selected process detail. | Easy switching between process sort modes; filter; tree view; pause. | Use boxed bands, mini time-series, detail pane, pause/freeze, and clickable controls. | Graph-first layout can overemphasize volatile values over install risk. |
+|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------|
+| =bottom/btm= | Custom widget layout, per-widget focus/expand, zoomable time windows, basic mode. | Process widget supports sort, search, tree; widgets can be filtered/configured. | Every health band should be expandable; stale/history windows should be zoomable. | Mostly resource telemetry, not remediation workflow. |
+|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------|
+| =atop= | Interval deltas, critical-resource highlighting, all active processes including exited ones, long-term logs. | Resource views and interval replay; emphasizes deviations and active load. | Add history/replay for health events and show "new since last good" changes. | Lower immediate visual polish; Linux-performance scoped. |
+|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------|
+| =Glances= | Broad plugin dashboard, thresholds, remote/web/API modes, export to JSON/CSV/time-series backends. | Configurable visible plugins; API/stdout selectors instead of only interactive sorting. | Use plugin architecture, threshold config, JSON output, remote/headless mode. | Too broad; can become a generic monitoring surface. |
+|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------|
+| =procs= | Modern table ergonomics: custom columns, keyword search across selected fields, sort by named column, tree view. | CLI sort asc/desc by partial column name; watch mode cycles sort columns; AND/OR/NAND/NOR search. | Use named metric columns, saved views, multi-keyword filters, and value-aware coloring. | Process-only; no graphs or remediation model. |
+|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------|
+| =gotop/gtop= family | Fast glanceable terminal dashboard with compact graphs and gauges. | Usually lighter than htop/btop; less important than presentation density. | Use compact sparklines/gauges for "age", "last pass", and "failure count". | Not enough evidence/action depth for this monitor. |
+|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------|
+
+** What to pull in
+
+*** htop: table discipline
+
+Pull:
+
+- Column headers that are real controls: click or key-cycle to sort by
+ =priority=, =state=, =age=, =last_checked=, =last_pass=, =failure_count=,
+ =scope=, and =doctor_class=.
+- Search and filter as first-class actions, not hidden debug commands.
+- Tree mode for ownership:
+ - =system= → =boot/storage= → =bootloader= → =efi entries=.
+ - =archangel= → =iso build= → =aur repo= → =manifest rows=.
+ - =archsetup= → =state markers= → =desktop= → =dotfiles=.
+- Horizontal detail access for long evidence, like htop's horizontal scrolling
+ for full commands.
+
+Equivalent-or-better requirement:
+
+- htop sorts processes; this monitor sorts risk. The default sort is
+ live-state severity, then priority rank, then age.
+
+*** btop++: instrument boxes and live graphs
+
+Pull:
+
+- Boxed bands with stable geometry.
+- Small time-series graphs, but only where history matters:
+ - package DB age over time
+ - failed-service count
+ - journal error count
+ - snapshot count / newest snapshot age
+ - VM pass/fail history
+ - ISO build duration/result history
+- Selected-row detail pane with the last command, verdict, and raw excerpt.
+- Pause/freeze button so a failure does not scroll away while reading.
+- Mouse-clickable controls where every visible key has the same keyboard path.
+
+Equivalent-or-better requirement:
+
+- btop's graphs answer "what is hot right now?" Our graphs answer "is the
+ safety margin shrinking?" Trend charts should be muted unless the threshold
+ is crossed.
+
+*** bottom: focus/expand and layout presets
+
+Pull:
+
+- Expand one band full-height:
+ - =BOOT + STORAGE= expands into boot files, EFI entries, pools, snapshots.
+ - =INSTALL PIPELINE= expands into last VM runs and build artifacts.
+ - =PACKAGES + SERVICES= expands into DB ages, repo status, failed units.
+ - =WORKSTATION CONTRACT= expands into Archsetup markers and testinfra-style
+ checks.
+- Zoomable history windows: 24h / 7d / 30d / all artifacts.
+- Layout presets:
+ - =compact= for bar dropdown.
+ - =full= for terminal/TUI.
+ - =host-only= for laptop health.
+ - =release-gate= for Archangel/Archsetup changes.
+
+Equivalent-or-better requirement:
+
+- bottom expands widgets; this monitor expands evidence and remediation state.
+ The expanded view must show "what changed since last good" before raw logs.
+
+*** atop: history and vanished failures
+
+Pull:
+
+- Permanent, compact health-event log.
+- Interval deltas instead of only current values:
+ - new failed services since last check
+ - new journal errors since last check
+ - packages/repos newly stale
+ - snapshot hook present before, missing now
+ - bootloader file changed since last known-good
+- "Show active/deviating only" mode. In normal use, hide green rows unless
+ their age is approaching threshold.
+- Replay mode: inspect the state at the time an install/test/build failed.
+
+Equivalent-or-better requirement:
+
+- atop can report processes that already exited. This monitor should report
+ failures that already passed through: a transient failed unit, a VM test that
+ failed last night, an ISO build that failed before the current successful
+ build, or a package DB that was stale until Doctor fixed it.
+
+*** Glances: plugin/API/export model
+
+Pull:
+
+- Plugin-like probes. Each metric owns:
+ - =probe=
+ - =normalize=
+ - =thresholds=
+ - =doctor_actions=
+ - =redaction=
+ - =evidence=
+- JSON output as a stable contract before GTK work.
+- Optional stdout selectors:
+ - =system-monitor --stdout packages.state,storage.state=
+ - =system-monitor --json boot,snapshots=
+- Remote/headless mode for VMs and bare-metal test targets.
+- Threshold config in one file, not hardcoded in the UI.
+
+Equivalent-or-better requirement:
+
+- Glances is broad; this must stay opinionated. A plugin is accepted only if it
+ maps to install health, rollback safety, workstation contract, or recovery
+ readiness.
+
+*** procs: custom columns and query grammar
+
+Pull:
+
+- Named columns and saved views:
+ - =risk=: state, priority, age, doctor class.
+ - =install=: last pass, filesystem, artifact, branch, commit.
+ - =host=: state, source, last checked, command.
+ - =doctor=: action class, requires root, reversible, last run.
+- Multi-keyword search:
+ - =zfs failed=
+ - =doctor safe-fix=
+ - =archangel stale=
+ - =service red=
+- Boolean query modes:
+ - AND default for narrowing.
+ - OR for "show any boot or storage issue".
+ - NOT for "hide green".
+- Value-aware coloring for age, severity, and units.
+
+Equivalent-or-better requirement:
+
+- procs lets the user build a process table. This monitor should let Craig
+ build a risk table without editing code.
+
+*** gotop/gtop: glance density
+
+Pull:
+
+- Small sparklines for trend, not full charts.
+- Big obvious state words.
+- Compact gauges for bounded values:
+ - EFI usage
+ - root/home usage
+ - DB age as percent of freshness window
+ - VM evidence age
+ - snapshot age
+- Simple default screen that is useful without learning keys.
+
+Equivalent-or-better requirement:
+
+- The first screen should answer "am I safe to upgrade or install?" in under
+ two seconds.
+
+* Sorting and Views
+
+The monitor needs two sorting layers: global row ordering and per-band evidence
+tables.
+
+** Global row ordering
+
+Default:
+
+1. =state= severity: red, yellow, unknown, running, green.
+2. =priority= rank: P0 before P1 before P2.
+3. =age= or =staleness=, descending.
+4. =last_changed=, newest first.
+
+Alternate sorts:
+
+| Sort key | Use case |
+|-----------------+------------------------------------------------------|
+| =priority= | Release-gate review; keep P0/P1 at the top. |
+|-----------------+------------------------------------------------------|
+| =state= | Triage; show all red/yellow rows first. |
+|-----------------+------------------------------------------------------|
+| =age= | Find stale tests, stale package DBs, old backups. |
+|-----------------+------------------------------------------------------|
+| =doctor_class= | Find what Doctor can safely fix now. |
+|-----------------+------------------------------------------------------|
+| =scope= | Group host vs install vs build. |
+|-----------------+------------------------------------------------------|
+| =last_changed= | See what recently regressed. |
+|-----------------+------------------------------------------------------|
+| =source= | Group by archangel, archsetup, dotfiles, host. |
+|-----------------+------------------------------------------------------|
+
+** Per-band sorts
+
+| Band | Sorts |
+|------------------------+------------------------------------------------------------|
+| =BOOT + STORAGE= | severity, mountpoint, filesystem, capacity, last scrub, newest snapshot age |
+|------------------------+------------------------------------------------------------|
+| =INSTALL PIPELINE= | result, filesystem, duration, artifact age, commit age, last pass |
+|------------------------+------------------------------------------------------------|
+| =PACKAGES + SERVICES= | severity, unit name, repo name, DB age, error count, enabled/active state |
+|------------------------+------------------------------------------------------------|
+| =WORKSTATION CONTRACT= | severity, check name, owner repo, last pass, doctor class |
+|------------------------+------------------------------------------------------------|
+
+** Filters
+
+Quick filters should be visible as chips:
+
+- =red=
+- =yellow=
+- =doctorable=
+- =needs-root=
+- =stale=
+- =zfs=
+- =btrfs=
+- =host=
+- =install=
+- =build=
+- =changed=
+- =hidden-green=
+
+The default view can hide healthy low-priority rows, but it must show enough
+green P0/P1 summary state to prove the monitor is working.
+
+* Display Requirements Borrowed from Tops
+
+1. Every table has sortable columns and a visible sort indicator.
+2. Every visible metric row has a filterable state, priority, age, and source.
+3. Every row can expand to evidence without losing the list context.
+4. Every graph has a threshold marker; trend without threshold is decoration.
+5. Every long-running action can be paused/frozen in the display.
+6. Every mutating action has an equivalent CLI command shown in the output well.
+7. Every Doctor action records before/after state so fixed failures remain
+ visible in history.
+8. Every band has a compact mode and an expanded mode.
+9. Green rows are quiet; new regressions are loud.
+10. The system must be useful over SSH/TUI before GTK polish.
+
+* Source Notes
+
+- =htop=: upstream README describes configurable system/process display,
+ interactive sorting/filtering/search, tree view, and process actions.
+- =btop++=: upstream README describes resource boxes, detailed process stats,
+ filter, sort switching, tree view, mouse support, auto-scaling network graphs,
+ disk IO, battery, GPU support, and themes.
+- =bottom/btm=: upstream README describes customizable widgets, process sort
+ and search, tree mode, expand/focus, zoomable graph intervals, filters, and
+ basic mode.
+- =atop=: upstream README describes interval resource accounting, critical
+ highlighting, long-term compressed logs, exited-process visibility, cgroup
+ views, and active/deviation-focused output.
+- =Glances=: upstream README describes plugin-style broad monitoring, web/API
+ modes, stdout JSON/CSV, remote monitoring, exports, and threshold-oriented
+ dashboard use.
+- =procs=: upstream README describes configurable columns, named-column sort,
+ watch mode, tree view, logical keyword search, value-aware coloring, and
+ pager behavior.
+
+* Data Model
+
+Emit JSON from a CLI first; the panel is a client.
+
+#+BEGIN_SRC json
+{
+ "v": 1,
+ "scope": "host",
+ "state": "warn",
+ "ts": "2026-07-04T12:00:00-04:00",
+ "metrics": [
+ {
+ "id": "packages.sync_freshness",
+ "label": "package databases",
+ "state": "warn",
+ "summary": "archzfs.db age 4d",
+ "evidence": [
+ {"command": "find /var/lib/pacman/sync", "excerpt": "archzfs.db 2026-06-30"}
+ ],
+ "doctor": {
+ "class": "safe-fix",
+ "actions": ["refresh-sync-db", "update-keyring"]
+ }
+ }
+ ]
+}
+#+END_SRC
+
+* Implementation Notes
+
+- Start with a CLI: =system-monitor status --json=, =system-monitor doctor
+ --json=, =system-monitor refresh=.
+- Keep probes read-only by default. Actions live in separate verbs.
+- Cache slow probes. The bar should read a cache, not run VM tests.
+- VM/build actions should create job records and stream logs; the panel follows
+ the job rather than blocking the UI process.
+- Reuse the net/bt panel architecture if this becomes a GTK panel: GTK-free
+ model + fake-command unit tests + one AT-SPI smoke.
+- Redact secrets from logs and JSON: WiFi PSKs, tokens, private repo URLs with
+ credentials, SSH material, and backup target credentials.
+
+* Open Decisions
+
+** TODO Where should the first implementation live?
+
+Recommendation: dotfiles owns the user-facing panel and CLI wrapper because it
+is workstation UI. Archsetup owns reusable install-contract probes and testinfra
+assertions. Archangel owns ISO/build probes.
+
+** TODO Should Doctor run elevated actions through polkit or terminal?
+
+Recommendation: read-only checks run unprivileged; elevated actions launch a
+terminal or polkit prompt with the exact command visible. Do not hide long
+privileged operations inside the panel process.
+
+** TODO How fresh must VM evidence be?
+
+Recommendation: host checks go stale after 1 hour; package DB after 48 hours;
+VM install evidence after 7 days; ISO build evidence after 14 days or whenever
+Archangel/Archsetup has changed since the last successful artifact.
+
+** TODO Which actions are allowed on bare metal?
+
+Recommendation: host Doctor may refresh databases, update keyring, restow
+dotfiles, create snapshots, run scrub, and restart narrowly classified services.
+It may not perform full upgrades, bootloader reinstalls, destructive snapshot
+prune, or filesystem repair without a separate advanced flow.
+
+* First Build Slice
+
+1. CLI read-only host status:
+ - package DB freshness
+ - failed services
+ - ZFS/Btrfs health
+ - snapshot presence
+ - Archsetup status/log check
+2. Doctor safe actions:
+ - refresh package DB
+ - update keyring
+ - restow dotfiles
+ - create manual snapshot
+3. Artifact parser:
+ - latest Archsetup VM test result
+ - latest Archangel ISO build result
+4. Panel prototype:
+ - faceplate
+ - four health bands
+ - evidence output well
+ - REFRESH and DOCTOR keys only
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-1.html b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html
new file mode 100644
index 0000000..6b199f9
--- /dev/null
+++ b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html
@@ -0,0 +1,693 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Timer panel — three redesigns · 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","Symbols Nerd Font",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 6rem;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:92ch}
+.masthead p b{color:var(--silver)}
+h2{color:var(--steel);font-size:.8rem;letter-spacing:.22em;text-transform:uppercase;
+ margin:2.6rem 0 .2rem;display:flex;align-items:center;gap:12px}
+h2 .tag{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.62rem;padding:1px 7px;letter-spacing:.12em}
+h2::after{content:"";height:1px;background:var(--wash);flex:1}
+.blurb{color:var(--dim);font-size:.82rem;max-width:90ch;margin:.5rem 0 1.1rem}
+.blurb b{color:var(--steel);font-weight:400}
+.desk{display:flex;justify-content:center;padding:1.2rem 0 .4rem}
+
+/* ---------- faceplate ---------- */
+.panel{width:396px;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:14px;padding:15px 15px 16px;position:relative;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)}
+.panel.wide{width:660px}
+.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase}
+.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em}
+.phead .pcount b{color:var(--cream)}
+
+/* ---------- shared primitives (from the widget gallery) ---------- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none}
+.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}}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:7px 11px;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.sm{padding:5px 8px;font-size:10.5px;border-radius:7px}
+.key.icon{padding:6px 9px;font-size:14px;line-height:1}
+.key.wide{width:100%;text-align:center;padding:9px}
+
+.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;
+ border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em}
+.chip:hover{color:var(--silver);border-color:var(--slate)}
+.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.chip .x{color:inherit;opacity:.5;margin-left:5px}
+.chip .x:hover{opacity:1;color:var(--fail)}
+
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+.badge.dim{background:var(--wash);color:var(--steel)}
+
+.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 0;cursor:pointer;flex:1;letter-spacing:.02em}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+.seg.vert{flex-direction:column}
+.seg.vert button{border-right:0;border-bottom:1px solid #33302b;padding:8px 10px}
+.seg.vert button:last-child{border-bottom:0}
+
+.engrave{color:var(--steel);font-size:.6rem;letter-spacing:.28em;text-transform:uppercase;
+ display:flex;align-items:center;gap:9px;margin:2px 0}
+.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}
+
+.readout{color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em}
+.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;
+ padding:7px 9px;width:100%;outline:none}
+.tin:focus{border-color:var(--gold)}
+.tin::placeholder{color:var(--dim)}
+.tin.bad{border-color:var(--fail);color:var(--fail)}
+
+.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+ border-radius:7px;padding:6px 9px}
+.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)}
+
+/* radial ring */
+.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative}
+.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)}
+.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;z-index:1;text-align:center}
+
+/* linear bar */
+.bar{height:8px;background:#0d0f10;border:1px solid #231f18;border-radius:5px;overflow:hidden;position:relative}
+.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));transition:width .25s linear}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+
+/* create strip common */
+.create{margin-top:13px;background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px}
+.create .row{display:flex;gap:7px;align-items:center;margin-top:8px;flex-wrap:wrap}
+.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
+
+/* toast */
+.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5}
+.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;
+ box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease}
+.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}
+.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+/* empty state */
+.empty{color:var(--dim);font-size:12px;text-align:center;padding:18px 6px 12px}
+
+/* =============== A · RACK UNIT =============== */
+.qlist{display:flex;flex-direction:column;gap:8px}
+.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px}
+.qrow.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)}
+.qrow.fire{animation:firef .6s ease-in-out 3}
+@keyframes firef{50%{background:rgba(203,107,77,.22)}}
+.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none}
+.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:1px}
+.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
+.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.16em;text-transform:uppercase}
+.qrow .rd{margin-left:auto;font-size:19px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+.qrow.paused .rd{color:var(--steel)}
+.qrow .pomo{font-size:.56rem;color:var(--steel);letter-spacing:.1em;text-transform:uppercase}
+.qrow .ctrls{display:flex;gap:5px;flex:none}
+
+/* =============== B · TRANSPORT DECK =============== */
+.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center}
+.hero.fire{animation:firef .6s ease-in-out 3}
+.hero .lhs{flex:none}
+.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:6px}
+.hero .htype{display:flex;align-items:center;gap:8px}
+.hero .htype .g{color:var(--gold);font-size:17px}
+.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.hero .hbig{color:var(--cream);font-size:40px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.02em}
+.hero.paused .hbig{color:var(--steel)}
+.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.06em}
+.transport{display:flex;gap:7px;margin-top:2px}
+.tracks{margin-top:11px;display:flex;flex-direction:column;gap:5px}
+.track{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;border:1px solid #1c1a16;cursor:pointer;font-size:12px}
+.track:hover{background:var(--wash)}
+.track.prim{outline:1px solid var(--gold);outline-offset:-1px}
+.track .g{color:var(--gold);font-size:14px;width:16px;text-align:center}
+.track b{color:var(--cream);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.track .trd{margin-left:auto;color:var(--silver);font-variant-numeric:tabular-nums;font-weight:700}
+.track.paused .trd{color:var(--dim)}
+.track .tx{color:var(--dim);font-size:14px;padding:0 2px}
+.track .tx:hover{color:var(--fail)}
+
+/* =============== C · CHANNEL STRIP BOARD =============== */
+.board{display:flex;gap:9px;overflow-x:auto;padding:4px 2px 10px}
+.strip{flex:none;width:96px;background:#141210;border:1px solid #201d17;border-radius:10px;padding:9px 8px;
+ display:flex;flex-direction:column;align-items:center;gap:8px}
+.strip.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)}
+.strip.fire{animation:firef .6s ease-in-out 3}
+.strip .stitle{width:100%;display:flex;align-items:center;gap:5px;cursor:pointer}
+.strip .stitle .g{color:var(--gold);font-size:13px}
+.strip .stitle b{color:var(--cream);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.strip .styp{color:var(--dim);font-size:.5rem;letter-spacing:.14em;text-transform:uppercase;width:100%;text-align:left}
+.column{width:26px;height:120px;position:relative;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden}
+.column .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#8a7524,var(--gold));transition:height .25s linear}
+.column.warn .fill{background:linear-gradient(0deg,#a35a3f,var(--fail))}
+.column .cap{position:absolute;left:-2px;right:-2px;height:3px;background:var(--gold-hi);box-shadow:0 0 5px rgba(255,215,95,.6);transition:bottom .25s linear}
+.column.sw .fill{background:linear-gradient(0deg,#3a4a5e,var(--slate-hi));animation:swpulse 1.6s ease-in-out infinite}
+@keyframes swpulse{50%{opacity:.6}}
+.strip .srd{color:var(--cream);font-size:14px;font-weight:700;font-variant-numeric:tabular-nums}
+.strip.paused .srd{color:var(--steel)}
+.strip .skeys{display:flex;gap:4px}
+.strip.addstrip{justify-content:flex-start;width:150px;background:var(--well)}
+
+@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 · timer</div>
+ <h1>Timer panel — three redesigns</h1>
+ <p>Three ways to shape the timer dropdown, all in the shipped instrument-console faceplate language
+ (same tokens, lamps, console keys, engraved labels, and tabular readouts as the net / bt / sound panels).
+ Each is a <b>working prototype over one shared engine</b> that mirrors wtimer + the PanelModel: add / cancel / pause /
+ resume, promote to the bar slot, per-type presets (add and delete chips), freeform entry with the same validation,
+ stopwatch lap + stop-and-save, the soonest-fire queue sort, the 10-item cap, and a real completion + notify on fire.
+ Try each: add a timer, watch one count down and fire, promote a row, pause a stopwatch, delete a preset chip.</p>
+</header>
+
+<h2><span class="tag">A</span> Rack unit — the faithful list</h2>
+<p class="blurb">The closest sibling to the net / audio panels: a vertical stack you scan top-down. Header with the live
+ count and <b>CLEAR ALL</b>; one output-well row per item, soonest-firing on top; each row carries a lamp, glyph, label,
+ the big countdown, and inline pause / promote / cancel keys. Create strip lives at the bottom — pick a type, tap a preset
+ or type a duration, name it, ADD. Safest port of what already shipped.</p>
+<div class="desk"><div class="panel" id="panelA"></div></div>
+
+<h2><span class="tag">B</span> Transport deck — one hero, a track list</h2>
+<p class="blurb">A cassette-transport shape. The <b>primary</b> item (the one in the bar glyph slot) gets a hero readout with a
+ progress ring and chunky transport keys; everything else is a compact track list underneath. Click a track to promote it into
+ the hero seat; the ‹ › keys cycle the primary. Puts the timer you care about front-and-centre, the rest one glance away.</p>
+<div class="desk"><div class="panel" id="panelB"></div></div>
+
+<h2><span class="tag">C</span> Channel-strip board — a mixing desk of timers</h2>
+<p class="blurb">The mixing-console metaphor: every item is a vertical channel strip on a board, its fader draining from the top
+ as time runs out (a stopwatch fills instead, tinted slate). Read all your timers at once like meters on a desk. Click a strip
+ header to promote it; the trailing <b>+ NEW</b> strip is the create surface. The most spatial, most stereo of the three.</p>
+<div class="desk"><div class="panel wide" id="panelC"></div></div>
+
+</div>
+
+<script>
+"use strict";
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const el = (tag, cls, html) => { const n=document.createElement(tag); if(cls)n.className=cls; if(html!=null)n.innerHTML=html; return n; };
+
+/* nerd-font glyphs (mirrors timer/viewmodel.py GLYPH) */
+const GL = {
+ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}',
+ pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}',
+ play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}'
+};
+
+/* ---------- parsers (mirror parse.py behaviour) ---------- */
+function parseDuration(v){
+ if(v==null) return null;
+ v=String(v).trim().toLowerCase();
+ if(v==='') return null;
+ if(/^\d+$/.test(v)) return parseInt(v,10)*60; // bare number = minutes
+ if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null; // only h/m/s tokens
+ let m, tot=0; const re=/(\d+)\s*([hms])/g;
+ while((m=re.exec(v))) tot += m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1];
+ return tot>0?tot:null;
+}
+function resolveAlarm(v, now){
+ v=String(v||'').trim().toLowerCase();
+ if(v.startsWith('+')){ const s=parseDuration(v.slice(1)); return s==null?null:now+s; }
+ if(v==='@hour'||v==='top of hour'){ const d=new Date(now*1000); d.setMinutes(0,0,0); d.setHours(d.getHours()+1); return d.getTime()/1000; }
+ const t=v.match(/^(\d{1,2}):(\d{2})$/);
+ if(t){ const hh=+t[1], mm=+t[2]; if(hh>23||mm>59) return null;
+ const d=new Date(now*1000); d.setHours(hh,mm,0,0); let e=d.getTime()/1000; if(e<=now) e+=86400; return e; }
+ return null;
+}
+function fmtTime(secs){
+ secs=Math.max(0,Math.floor(secs));
+ const h=Math.floor(secs/3600), m=Math.floor((secs%3600)/60), s=secs%60;
+ return h ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` : `${m}:${String(s).padStart(2,'0')}`;
+}
+function fmtClock(epoch){ const d=new Date(epoch*1000); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; }
+
+/* ---------- default presets (mirror panel._default_presets) ---------- */
+const DEFAULT_PRESETS = () => ({
+ timer:[{label:'5m',value:'5m'},{label:'25m',value:'25m'},{label:'10m',value:'10m'},
+ {label:'15m',value:'15m'},{label:'30m',value:'30m'},{label:'60m',value:'60m'},{label:'2h',value:'2h'}],
+ alarm:[{label:'+30m',value:'+30m'},{label:'top of hour',value:'@hour'},{label:'07:00',value:'07:00'}],
+ pomodoro:[{label:'default cycle',value:''}],
+ stopwatch:[]
+});
+const TYPES=['timer','alarm','stopwatch','pomodoro'];
+const COUNTDOWN=['timer','alarm','pomodoro'];
+const MAX=10;
+const POMO={work:25*60, short:5*60, long:15*60, interval:4};
+
+/* ---------- the engine (mirrors PanelModel + wtimer state) ---------- */
+class Engine{
+ constructor(){ this.items=[]; this.seq=0; this.primary=null; this.presets=DEFAULT_PRESETS(); this.onEvent=()=>{}; }
+ now(){ return Date.now()/1000; }
+ count(){ return this.items.length; }
+ full(){ return this.items.length>=MAX; }
+
+ add(type,value,label){
+ if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`};
+ const now=this.now(); this.seq++; const id='t'+this.seq;
+ const it={id,type,label:label||''};
+ if(type==='timer'){ const s=parseDuration(value); if(s==null) return {ok:false,reason:`bad duration: “${value}”`}; it.target=now+s; it.total=s; }
+ else if(type==='alarm'){ const e=resolveAlarm(value,now); if(e==null) return {ok:false,reason:`bad time: “${value}”`}; it.target=e; it.total=Math.max(1,e-now); }
+ else if(type==='pomodoro'){ it.phase='work'; it.cycle=1; it.interval=POMO.interval; it.target=now+POMO.work; it.total=POMO.work; }
+ else if(type==='stopwatch'){ it.start=now; it.laps=[]; }
+ else return {ok:false,reason:`unknown type: ${type}`};
+ this.items.push(it); if(!this.primary) this.primary=id;
+ return {ok:true, id};
+ }
+ find(id){ return this.items.find(i=>i.id===id); }
+ isPaused(it){ return it.type==='stopwatch' ? it.paused_elapsed!=null : it.paused_left!=null; }
+ remaining(it,ref){
+ ref=ref==null?this.now():ref;
+ if(it.type==='stopwatch') return this.isPaused(it)? it.paused_elapsed : ref-it.start;
+ return this.isPaused(it)? it.paused_left : it.target-ref;
+ }
+ toggle(id){ const it=this.find(id); if(!it) return; const now=this.now();
+ if(it.type==='stopwatch'){
+ if(this.isPaused(it)){ it.start=now-it.paused_elapsed; it.paused_elapsed=null; }
+ else it.paused_elapsed=now-it.start;
+ } else {
+ if(this.isPaused(it)){ it.target=now+it.paused_left; it.paused_left=null; }
+ else it.paused_left=it.target-now;
+ }
+ }
+ cancel(id){ const i=this.items.findIndex(x=>x.id===id); if(i<0) return; this.items.splice(i,1);
+ if(this.primary===id) this.primary=null; }
+ cancelAll(){ this.items=[]; this.primary=null; }
+ promote(id){ if(this.find(id)) this.primary=id; }
+ cycle(dir){ const ids=this.items.map(i=>i.id); if(!ids.length) return;
+ let cur=ids.indexOf(this.effectivePrimary()); cur=cur<0?0:cur;
+ const n=dir==='prev' ? (cur-1+ids.length)%ids.length : (cur+1)%ids.length;
+ this.primary=ids[n]; }
+ lap(id,name){ const it=this.find(id); if(!it||it.type!=='stopwatch') return;
+ it.laps.push({t:this.remaining(it), name:name||''}); }
+ stopSave(id){ const it=this.find(id); if(!it||it.type!=='stopwatch') return null;
+ const run={label:it.label||'run', total:this.remaining(it), laps:it.laps.slice()}; this.cancel(id); return run; }
+
+ effectivePrimary(){
+ const items=this.items; if(!items.length) return null;
+ const ids=items.map(i=>i.id);
+ if(ids.includes(this.primary)) return this.primary;
+ const now=this.now();
+ const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i));
+ if(acd.length) return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id;
+ const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));
+ if(asw.length) return asw[0].id;
+ return ids[0];
+ }
+ /* 4-bucket sort: active countdown < paused countdown < active sw < paused sw */
+ sortKey(it){ const now=this.now(), paused=this.isPaused(it), sw=it.type==='stopwatch', rem=this.remaining(it,now);
+ return sw ? [paused?3:2, -rem, +it.id.slice(1)] : [paused?1:0, rem, +it.id.slice(1)]; }
+ rows(){ const prim=this.effectivePrimary(); const now=this.now();
+ return this.items.slice().sort((a,b)=>{ const ka=this.sortKey(a),kb=this.sortKey(b);
+ for(let i=0;i<ka.length;i++){ if(ka[i]<kb[i])return -1; if(ka[i]>kb[i])return 1; } return 0; })
+ .map(it=>this.row(it,prim,now)); }
+ row(it,prim,now){
+ const rem=this.remaining(it,now), paused=this.isPaused(it);
+ let disp, sub='', warn=false, prog=null, glyph;
+ if(it.type==='alarm'){ disp=fmtClock(it.target); sub='at '+fmtClock(it.target); prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.alarm; }
+ else if(it.type==='pomodoro'){ disp=fmtTime(rem); sub=`${it.phase} · cycle ${it.cycle}/${it.interval}`; prog=Math.max(0,Math.min(1,rem/it.total));
+ glyph=(it.phase==='work')?GL.pomo_work:GL.pomo_break; }
+ else if(it.type==='stopwatch'){ disp=fmtTime(rem); sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running'; glyph=GL.stopwatch; }
+ else { disp=fmtTime(rem); sub='timer'; prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.timer; }
+ if(prog!=null && rem<=Math.min(30, it.total*0.15)) warn=true;
+ if(paused) glyph=GL.paused;
+ return {id:it.id, type:it.type, glyph, label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type],
+ typeLabel:it.type, disp, sub, paused, primary:it.id===prim, prog, warn, laps:it.laps?it.laps.length:0};
+ }
+
+ /* advance fired items; returns list of fire events for the view to flash/notify */
+ tick(){
+ const now=this.now(); const fired=[];
+ for(const it of this.items.slice()){
+ if(!COUNTDOWN.includes(it.type) || this.isPaused(it)) continue;
+ if(it.target-now>0) continue;
+ if(it.type==='pomodoro'){
+ if(it.phase==='work'){
+ const isLong = it.cycle % it.interval === 0;
+ fired.push({id:it.id, kind:'pomo', title:`Pomodoro · ${isLong?'long':'short'} break`, body:it.label||`cycle ${it.cycle}`});
+ it.phase = isLong?'long':'short'; const len = isLong?POMO.long:POMO.short; it.target=now+len; it.total=len;
+ } else if(it.phase==='long'){
+ fired.push({id:it.id, kind:'done', title:'Pomodoro complete', body:it.label||`${it.interval} cycles done`});
+ this.cancel(it.id);
+ } else { // short break over → next work
+ it.cycle+=1; fired.push({id:it.id, kind:'pomo', title:'Pomodoro · back to work', body:it.label||`cycle ${it.cycle}`});
+ it.phase='work'; it.target=now+POMO.work; it.total=POMO.work;
+ }
+ } else {
+ fired.push({id:it.id, kind:'done', title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label|| (it.type==='alarm'?fmtClock(it.target):'done')), body:'time’s up'});
+ this.cancel(it.id);
+ }
+ }
+ return fired;
+ }
+ /* presets */
+ presetsFor(t){ return (this.presets[t]||[]).map(p=>({...p})); }
+ addPreset(t,label,value){ if(!TYPES.includes(t)) return {ok:false,reason:'bad type'};
+ if(t==='timer' && parseDuration(value)==null) return {ok:false,reason:'bad duration'};
+ (this.presets[t]||(this.presets[t]=[])).push({label,value}); return {ok:true}; }
+ deletePreset(t,label){ const a=this.presets[t]||[]; const i=a.findIndex(p=>p.label===label); if(i<0) return {ok:false}; a.splice(i,1); return {ok:true}; }
+}
+
+/* ---------- browser notification (best-effort, mirrors the notify path) ---------- */
+let notifPerm = (typeof Notification!=='undefined') ? Notification.permission : 'denied';
+function tryNotify(title, body){
+ if(typeof Notification==='undefined') return;
+ if(notifPerm==='granted'){ try{ new Notification(title,{body}); }catch(e){} }
+ else if(notifPerm==='default'){ Notification.requestPermission().then(p=>notifPerm=p); }
+}
+
+/* ---------- toast helper ---------- */
+function toaster(host){
+ const wrap=el('div','toasts'); host.appendChild(wrap);
+ return (msg,kind)=>{ const t=el('div','toast'+(kind?' '+kind:''),msg); wrap.appendChild(t);
+ setTimeout(()=>{ t.style.transition='opacity .3s'; t.style.opacity='0'; setTimeout(()=>t.remove(),300); }, 2600); };
+}
+
+/* ---------- create-strip controller (shared by all three views) ---------- */
+function makeCreate(engine, toast, rerender, opts){
+ opts=opts||{};
+ const box=el('div','create');
+ const seg=el('div','seg');
+ TYPES.forEach(t=>{ const b=el('button',t==='timer'?'on':'', t[0].toUpperCase()+t.slice(1)); b.dataset.t=t; seg.appendChild(b); });
+ const chips=el('div','chips');
+ const row=el('div','row');
+ const val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2';
+ const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2';
+ const addk=el('button','key on', GL.add+' ADD'); addk.style.flex='1';
+ row.append(val,lab,addk);
+ box.append(seg,chips,row);
+
+ let selType='timer';
+ function paintChips(){
+ chips.innerHTML='';
+ engine.presetsFor(selType).forEach(p=>{
+ const c=el('span','chip', p.label + (opts.editablePresets?` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`:''));
+ c.dataset.val=p.value; chips.appendChild(c);
+ });
+ if(opts.editablePresets){ const c=el('span','chip','+ chip'); c.dataset.newchip='1'; c.style.opacity='.7'; chips.appendChild(c); }
+ // type-specific value affordance
+ val.disabled = (selType==='stopwatch'||selType==='pomodoro');
+ val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour'
+ : selType==='stopwatch' ? 'no value — just ADD'
+ : selType==='pomodoro' ? 'default 25/5 cycle' : '5m · 1h30m · 90s';
+ if(val.disabled) val.value='';
+ }
+ seg.addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b) return;
+ selType=b.dataset.t; [...seg.children].forEach(x=>x.classList.toggle('on',x===b)); val.classList.remove('bad'); paintChips(); });
+ chips.addEventListener('click', e=>{
+ const del=e.target.closest('[data-del]');
+ if(del){ engine.deletePreset(selType, decodeURIComponent(del.dataset.del)); paintChips(); toast('preset removed','gold'); return; }
+ if(e.target.closest('[data-newchip]')){
+ const lb=prompt('Chip label (e.g. 45m):'); if(!lb) return;
+ let vv=lb; if(selType==='timer'||selType==='alarm'){ vv=prompt('Value for “'+lb+'” (e.g. 45m):', lb)||lb; }
+ const r=engine.addPreset(selType, lb, vv); paintChips(); toast(r.ok?'preset added':('preset: '+r.reason), r.ok?'gold':'red'); return;
+ }
+ const c=e.target.closest('.chip'); if(!c||c.dataset.val==null) return;
+ val.classList.remove('bad'); val.value=c.dataset.val;
+ doAdd();
+ });
+ function doAdd(){
+ const r=engine.add(selType, val.value, lab.value.trim());
+ if(!r.ok){ val.classList.add('bad'); toast(r.reason,'red'); return; }
+ val.classList.remove('bad'); if(!val.disabled) val.value=''; lab.value='';
+ toast('added '+selType, 'gold'); rerender();
+ }
+ addk.addEventListener('click', doAdd);
+ val.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); });
+ lab.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); });
+ paintChips();
+ return box;
+}
+
+/* =================================================================== */
+/* VIEW A — RACK UNIT */
+/* =================================================================== */
+function mountRack(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',
+ `<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm', GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px';
+ head.appendChild(clear);
+ clear.addEventListener('click', ()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); });
+ const list=el('div','qlist');
+ host.append(head,list);
+ const create=makeCreate(engine, toast, ()=>render(), {editablePresets:true});
+ host.appendChild(create);
+
+ const flashing=new Set();
+ list.addEventListener('click', e=>{
+ const b=e.target.closest('[data-act]'); if(!b) return;
+ const id=b.dataset.id, act=b.dataset.act;
+ if(act==='toggle') engine.toggle(id);
+ else if(act==='promote') engine.promote(id);
+ else if(act==='cancel'){
+ if(b.dataset.armed){ engine.cancel(id); toast('cancelled'); }
+ else { b.dataset.armed='1'; b.classList.add('armed'); b.textContent='sure?'; setTimeout(()=>{ if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;} },2000); return; }
+ }
+ else if(act==='lap'){ engine.lap(id); toast('lap recorded'); }
+ else if(act==='stop'){ const run=engine.stopSave(id); if(run) toast(`saved “${run.label}” · ${run.laps.length} laps → org`,'gold'); }
+ render();
+ });
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows();
+ list.innerHTML='';
+ if(!rows.length){ list.appendChild(el('div','empty','No timers running — pick a type below and ADD.')); return; }
+ rows.forEach(r=>{
+ const row=el('div','qrow'+(r.primary?' prim':'')+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':''));
+ const ctrls = r.type==='stopwatch'
+ ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button>
+ <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>`
+ : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`;
+ row.innerHTML=
+ `<span class="lamp ${r.paused?'off':(r.primary?'gold':(r.warn?'red':''))}"></span>
+ <span class="g">${r.glyph}</span>
+ <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span>
+ <span class="rd">${r.disp}</span>
+ <span class="ctrls">
+ ${ctrls}
+ <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot" ${r.primary?'disabled style=opacity:.4':''}>${GL.promote}</button>
+ <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button>
+ </span>`;
+ list.appendChild(row);
+ });
+ }
+ engine._render=render;
+ engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>{flashing.delete(id);},1800); };
+ engine._toast=toast;
+ render();
+}
+
+/* =================================================================== */
+/* VIEW B — TRANSPORT DECK */
+/* =================================================================== */
+function mountTransport(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',
+ `<span class="brand">Timer · Transport</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR'); clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); });
+ head.appendChild(clear);
+ const hero=el('div','hero');
+ const tracks=el('div','tracks');
+ host.append(head,hero,tracks);
+ const create=makeCreate(engine,toast,()=>render(),{editablePresets:true});
+ host.appendChild(create);
+
+ const flashing=new Set();
+ function act(fn){ return e=>{ fn(); render(); }; }
+ hero.addEventListener('click', e=>{ const b=e.target.closest('[data-act]'); if(!b) return; const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');}
+ else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');}
+ else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}” · ${r.laps.length} laps`,'gold');}
+ render(); });
+ tracks.addEventListener('click', e=>{
+ const x=e.target.closest('[data-cancel]'); if(x){ engine.cancel(x.dataset.cancel); toast('cancelled'); render(); return; }
+ const t=e.target.closest('[data-id]'); if(t){ engine.promote(t.dataset.id); render(); } });
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows(); const primId=engine.effectivePrimary();
+ // hero = the primary row
+ const h = rows.find(r=>r.id===primId);
+ hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':'');
+ if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers — add one below to load the deck.</div>'; }
+ else {
+ const ringP = h.prog!=null ? Math.round(h.prog*100) : (h.type==='stopwatch'? 100 : 0);
+ const ringInner = h.type==='stopwatch'
+ ? `<b style="color:var(--slate-hi);font-size:11px">SW</b>`
+ : `<b style="color:var(--cream);font-size:15px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`;
+ const transport = h.type==='stopwatch'
+ ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button>
+ <button class="key" data-act="lap" data-id="${h.id}">LAP</button>
+ <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>`
+ : `<button class="key icon" data-act="cycle" data-dir="prev">${'‹'}</button>
+ <button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button>
+ <button class="key red icon" data-act="cancel" data-id="${h.id}">${GL.cancel}</button>
+ <button class="key icon" data-act="cycle" data-dir="next">${'›'}</button>`;
+ hero.innerHTML=
+ `<div class="lhs"><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:88px;height:88px">${ringInner}</span></div>
+ <div class="rhs">
+ <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>
+ ${h.primary?'<span class="badge">BAR SLOT</span>':''}</div>
+ <div class="hlabel">${h.label}</div>
+ <div class="hbig">${h.disp}</div>
+ <div class="hsub">${h.sub}</div>
+ <div class="transport">${transport}</div>
+ </div>`;
+ }
+ // track list = everything except the hero
+ tracks.innerHTML='';
+ const rest=rows.filter(r=>r.id!==primId);
+ if(rest.length){ tracks.appendChild(el('div','engrave','up next <span class="cnt">· '+rest.length+'</span>')); }
+ rest.forEach(r=>{
+ const t=el('div','track'+(r.paused?' paused':''),
+ `<span class="g">${r.glyph}</span><b>${r.label}</b>
+ <span class="trd">${r.disp}</span>
+ <span class="tx" data-cancel="${r.id}" title="cancel">${GL.cancel}</span>`);
+ t.dataset.id=r.id; tracks.appendChild(t);
+ });
+ }
+ engine._render=render;
+ engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); };
+ engine._toast=toast;
+ render();
+}
+
+/* =================================================================== */
+/* VIEW C — CHANNEL STRIP BOARD */
+/* =================================================================== */
+function mountBoard(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',
+ `<span class="brand">Timer · Board</span><span class="pcount">channels <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); });
+ head.appendChild(clear);
+ const board=el('div','board');
+ host.append(head,board);
+
+ // create controls live in the trailing add-strip; build once, reuse the shared controller inside it
+ const addStrip=el('div','strip addstrip');
+ const create=makeCreate(engine,toast,()=>render(),{editablePresets:true});
+ create.style.margin='0'; create.style.background='transparent'; create.style.border='0'; create.style.padding='0'; create.style.width='100%';
+ addStrip.append(el('div','styp','+ new channel'), create);
+
+ board.addEventListener('click', e=>{
+ const b=e.target.closest('[data-act]');
+ if(b){ const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');}
+ else if(a==='lap'){engine.lap(id);toast('lap');} else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}”`,'gold');}
+ render(); return; }
+ const h=e.target.closest('[data-promote]'); if(h){ engine.promote(h.dataset.promote); render(); }
+ });
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows();
+ board.innerHTML='';
+ rows.forEach(r=>{
+ const strip=el('div','strip'+(r.primary?' prim':'')+(r.paused?' paused':''));
+ const pct = r.prog!=null ? Math.round(r.prog*100) : 100;
+ const colCls = 'column'+(r.type==='stopwatch'?' sw':'')+(r.warn?' warn':'');
+ const fillH = r.type==='stopwatch' ? 100 : pct;
+ const keys = r.type==='stopwatch'
+ ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button>
+ <button class="key sm red" data-act="stop" data-id="${r.id}">${GL.cancel}</button>`
+ : `<button class="key icon" data-act="toggle" data-id="${r.id}">${r.paused?GL.play:GL.paused}</button>
+ <button class="key icon red" data-act="cancel" data-id="${r.id}">${GL.cancel}</button>`;
+ strip.innerHTML=
+ `<div class="stitle" data-promote="${r.id}" title="promote to bar slot">
+ <span class="g">${r.glyph}</span><b>${r.label}</b></div>
+ <div class="styp">${r.typeLabel}${r.primary?' · bar':''}</div>
+ <div class="${colCls}"><div class="fill" style="height:${fillH}%"></div>
+ ${r.type!=='stopwatch'?`<div class="cap" style="bottom:${fillH}%"></div>`:''}</div>
+ <div class="srd">${r.disp}</div>
+ <div class="skeys">${keys}</div>`;
+ board.appendChild(strip);
+ });
+ board.appendChild(addStrip);
+ }
+ engine._render=render;
+ engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); };
+ const flashing=new Set();
+ engine._flashSet=flashing;
+ engine._toast=toast;
+ render();
+}
+
+/* ---------- seed + wire the three panels ---------- */
+function seed(engine){
+ engine.add('pomodoro','', 'Deep work');
+ engine.add('timer','5m','Tea');
+ engine.add('timer','45s','Egg'); // fires ~45s in, demonstrates completion + notify
+ const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id);
+ engine.add('alarm','@hour','Standup');
+}
+
+const engines=[];
+function boot(){
+ const A=new Engine(), B=new Engine(), C=new Engine();
+ seed(A); seed(B); seed(C);
+ mountRack(document.getElementById('panelA'), A);
+ mountTransport(document.getElementById('panelB'), B);
+ mountBoard(document.getElementById('panelC'), C);
+ engines.push(A,B,C);
+}
+boot();
+
+/* ---------- global tick: fire timers, flash + notify, re-render ---------- */
+function loop(){
+ for(const e of engines){
+ const fired=e.tick();
+ for(const f of fired){
+ e._flash && e._flash(f.id);
+ if(e._flashSet) e._flashSet.add(f.id), setTimeout(()=>e._flashSet.delete(f.id),1800);
+ e._toast && e._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold');
+ tryNotify(f.title, f.body);
+ }
+ e._render && e._render();
+ }
+}
+setInterval(loop, reduced?1000:250);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-2.html b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html
new file mode 100644
index 0000000..ffd4521
--- /dev/null
+++ b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html
@@ -0,0 +1,553 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Timer panel — hero + rack (iteration 2) · 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","Symbols Nerd Font",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:1100px;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:92ch}
+.masthead p b{color:var(--silver)}
+.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.6rem;flex-wrap:wrap}
+.side{flex:1;min-width:300px}
+.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem;
+ display:flex;align-items:center;gap:10px}
+.side h2::after{content:"";height:1px;background:var(--wash);flex:1}
+.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px}
+.side li{display:flex;gap:9px}
+.side li::before{content:"›";color:var(--gold);flex:none}
+.side li b{color:var(--silver);font-weight:400}
+
+/* ---------- faceplate ---------- */
+.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:14px;padding:15px;position:relative;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)}
+.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase}
+.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em}
+.phead .pcount b{color:var(--cream)}
+
+/* ---------- primitives ---------- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none}
+.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}}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:7px 11px;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:disabled{opacity:.4;cursor:default}
+.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.sm{padding:5px 8px;font-size:10.5px;border-radius:7px}
+.key.icon{padding:6px 9px;font-size:14px;line-height:1}
+
+.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;
+ border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center}
+.chip:hover{color:var(--silver);border-color:var(--slate)}
+.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.chip.lock{padding-right:10px}
+.chip .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px}
+.chip .x:hover{opacity:1;color:var(--fail)}
+
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.dim{background:var(--wash);color:var(--steel)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+
+.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 0;cursor:pointer;flex:1;letter-spacing:.02em}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+
+.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:20px;background:var(--gold)}
+
+.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;
+ display:flex;align-items:center;gap:9px;margin:2px 0}
+.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}
+
+.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;
+ padding:7px 9px;width:100%;outline:none}
+.tin:focus{border-color:var(--gold)}
+.tin::placeholder{color:var(--dim)}
+.tin.bad{border-color:var(--fail);color:var(--fail)}
+.tin:disabled{opacity:.45}
+.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px;
+ padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums}
+.numin:focus{border-color:var(--gold)}
+
+.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+ border-radius:7px;padding:6px 9px}
+.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)}
+
+.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative}
+.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)}
+.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;z-index:1;text-align:center}
+
+.dots{display:flex;gap:4px;align-items:center}
+.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none}
+.dots i.on{background:var(--steel)}
+.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.dots i.long{outline:1px solid var(--gold);outline-offset:1px}
+
+/* ---------- HERO (top) ---------- */
+.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center;margin-bottom:12px}
+.hero.fire{animation:firef .6s ease-in-out 3}
+@keyframes firef{50%{background:rgba(203,107,77,.22)}}
+.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px}
+.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.hero .htype .g{color:var(--gold);font-size:17px}
+.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums}
+.hero.paused .hbig{color:var(--steel)}
+.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em}
+.transport{display:flex;gap:7px;margin-top:3px;flex-wrap:wrap}
+
+/* ---------- CREATE (middle) ---------- */
+.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px}
+.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap}
+.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px}
+.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px}
+.cfg .crow{display:flex;align-items:center;gap:8px}
+.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none}
+.cfg .crow .u{color:var(--dim);font-size:10px}
+.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px}
+
+/* ---------- LIST (bottom) ---------- */
+.qlist{display:flex;flex-direction:column;gap:8px}
+.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px}
+.qrow.fire{animation:firef .6s ease-in-out 3}
+.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none}
+.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px}
+.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px}
+.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase}
+.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+.qrow.paused .rd{color:var(--steel)}
+.qrow .ctrls{display:flex;gap:5px;flex:none}
+.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px}
+
+/* toast */
+.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5}
+.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease}
+.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}
+.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+@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 · timer · iteration 2</div>
+ <h1>Timer panel — hero + rack</h1>
+ <p>The rack unit reshaped: the <b>hero</b> from the transport deck rides on top (the primary / bar-slot item, big),
+ the <b>create strip</b> sits under it, and the <b>queue list</b> runs below. Pomodoro is now a real configurable cycle —
+ work and rest each with a short and a long duration, a long break every N cycles, auto-advance, and progress dots —
+ with its default preset locked so it can't be deleted. Everything is live: add, count down, fire + notify, pause, promote,
+ lap / stop-save. Ideas pulled from Pomofocus, Todoist, and the classic technique (see the notes column).</p>
+</header>
+
+<div class="cols">
+ <div class="panel" id="panel"></div>
+ <div class="side">
+ <h2>What changed this pass</h2>
+ <ul>
+ <li><b>Layout flipped:</b> hero on top → create strip → list (was list → create).</li>
+ <li><b>Pomodoro is configurable:</b> Work short/long, Rest short/long, long break every N, auto-advance toggle.</li>
+ <li><b>Deep cycle:</b> every Nth pomodoro uses the long work + long rest; the rest fill mark the long dots.</li>
+ <li><b>Default cycle is locked</b> — shipped presets have no ×; only chips you add are deletable.</li>
+ <li><b>Cycle dots</b> in the hero + row show where you are in the set.</li>
+ </ul>
+ <h2 style="margin-top:1.6rem">Borrowed from good pomodoro apps</h2>
+ <ul>
+ <li><b>Pomofocus:</b> separate work / short-break / long-break lengths + long-break interval.</li>
+ <li><b>Auto-start next</b> (Pomofocus, Pomodo): auto-advance rolls into the next phase; off = wait and press start.</li>
+ <li><b>Todoist / the technique:</b> long break of 15–30m after 4 pomodoros; all durations adjustable.</li>
+ <li><b>Preset cycles:</b> Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10 — one tap loads the fields.</li>
+ <li><b>Task label</b> on every item; cycle progress shown as dots.</li>
+ </ul>
+ </div>
+</div>
+
+</div>
+
+<script>
+"use strict";
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;};
+
+const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}',
+ pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}',
+ play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}' };
+
+/* ---- parsers ---- */
+function parseDuration(v){
+ if(v==null) return null; v=String(v).trim().toLowerCase(); if(v==='') return null;
+ if(/^\d+$/.test(v)) return parseInt(v,10)*60;
+ if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null;
+ let m,tot=0; const re=/(\d+)\s*([hms])/g;
+ while((m=re.exec(v))) tot+= m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1];
+ return tot>0?tot:null;
+}
+function resolveAlarm(v,now){
+ v=String(v||'').trim().toLowerCase();
+ if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;}
+ if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;}
+ const t=v.match(/^(\d{1,2}):(\d{2})$/);
+ if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;}
+ return null;
+}
+const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60;
+ return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;};
+const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;};
+
+/* ---- presets: shipped defaults are locked (no delete) ---- */
+const DEFAULT_PRESETS=()=>({
+ timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true},
+ {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}],
+ alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'07:00',value:'07:00',locked:true}],
+ stopwatch:[]
+});
+/* pomodoro preset = a full config (minutes). "Classic" is the default cycle — locked. */
+const POMO_PRESETS=[
+ {label:'Classic', ws:25, wl:50, rs:5, rl:15, iv:4, locked:true},
+ {label:'Deep', ws:50, wl:50, rs:10, rl:30, iv:3, locked:true},
+ {label:'Sprint', ws:15, wl:25, rs:3, rl:10, iv:4, locked:true},
+];
+const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true};
+
+const TYPES=['timer','alarm','stopwatch','pomodoro'];
+const COUNTDOWN=['timer','alarm','pomodoro'];
+const MAX=10;
+
+class Engine{
+ constructor(){ this.items=[]; this.seq=0; this.primary=null;
+ this.presets=DEFAULT_PRESETS(); this.pomoPresets=POMO_PRESETS.map(p=>({...p})); }
+ now(){return Date.now()/1000;}
+ count(){return this.items.length;}
+ full(){return this.items.length>=MAX;}
+ add(type,value,label,cfg){
+ if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`};
+ const now=this.now(); this.seq++; const id='t'+this.seq; const it={id,type,label:label||''};
+ if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;}
+ else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`};it.target=e;it.total=Math.max(1,e-now);}
+ else if(type==='pomodoro'){
+ const c=cfg||POMO_DEFAULT;
+ it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false};
+ it.phase='work'; it.cycle=1; const deep=(1%it.cfg.iv===0);
+ const len=deep?it.cfg.wl:it.cfg.ws; it.target=now+len; it.total=len;
+ }
+ else if(type==='stopwatch'){it.start=now;it.laps=[];}
+ else return {ok:false,reason:`unknown type: ${type}`};
+ this.items.push(it); if(!this.primary) this.primary=id; return {ok:true,id};
+ }
+ find(id){return this.items.find(i=>i.id===id);}
+ isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;}
+ remaining(it,ref){ref=ref==null?this.now():ref;
+ if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start;
+ return this.isPaused(it)?it.paused_left:it.target-ref;}
+ toggle(id){const it=this.find(id);if(!it)return;const now=this.now();
+ if(it.type==='stopwatch'){ if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;} else it.paused_elapsed=now-it.start; }
+ else { if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;} else it.paused_left=it.target-now; } }
+ cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;}
+ cancelAll(){this.items=[];this.primary=null;}
+ promote(id){if(this.find(id))this.primary=id;}
+ cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c;
+ this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];}
+ lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});}
+ stopSave(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return null;const run={label:it.label||'run',total:this.remaining(it),laps:it.laps.slice()};this.cancel(id);return run;}
+ effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id);
+ if(ids.includes(this.primary))return this.primary;const now=this.now();
+ const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i));
+ if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id;
+ const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];}
+ sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now);
+ return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];}
+ rows(){const prim=this.effectivePrimary(),now=this.now();
+ return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b);
+ for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));}
+ row(it,prim,now){
+ const rem=this.remaining(it,now),paused=this.isPaused(it);
+ let disp,sub='',warn=false,prog=null,glyph,pomo=null;
+ if(it.type==='alarm'){disp=fmtClock(it.target);sub='fires '+fmtClock(it.target);prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;}
+ else if(it.type==='pomodoro'){
+ disp=fmtTime(rem); prog=Math.max(0,Math.min(1,rem/it.total));
+ const deep=(it.cycle%it.cfg.iv===0);
+ const phLabel = it.phase==='work' ? (deep?'long work':'work')
+ : it.phase==='rest' ? ((it.cycle%it.cfg.iv===0)?'long break':'short break') : it.phase;
+ sub = it.awaiting ? `ready · start ${it.phase==='work'?'work':'break'}` : `${phLabel} · cycle ${it.cycle}/${it.cfg.iv}`;
+ glyph = it.phase==='work' ? GL.pomo_work : GL.pomo_break;
+ pomo={cycle:it.cycle, iv:it.cfg.iv, phase:it.phase, awaiting:!!it.awaiting, deep};
+ }
+ else if(it.type==='stopwatch'){disp=fmtTime(rem);sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;}
+ else {disp=fmtTime(rem);sub='timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;}
+ if(prog!=null && rem<=Math.min(30,it.total*0.15)) warn=true;
+ if(paused) glyph=GL.paused;
+ return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type],
+ typeLabel:it.type,disp,sub,paused,primary:it.id===prim,prog,warn,pomo,laps:it.laps?it.laps.length:0};
+ }
+ tick(){
+ const now=this.now(),fired=[];
+ for(const it of this.items.slice()){
+ if(!COUNTDOWN.includes(it.type)||this.isPaused(it))continue;
+ if(it.target-now>0)continue;
+ if(it.type==='pomodoro'){
+ const c=it.cfg;
+ if(it.phase==='work'){
+ const deep=(it.cycle%c.iv===0);
+ fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`});
+ it.phase='rest'; const len=deep?c.rl:c.rs; it.total=len;
+ if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; }
+ } else { // rest over → next work
+ it.cycle+=1; const deep=(it.cycle%c.iv===0);
+ fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`});
+ it.phase='work'; const len=deep?c.wl:c.ws; it.total=len;
+ if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; }
+ }
+ } else {
+ fired.push({id:it.id,kind:'done',title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label||(it.type==='alarm'?fmtClock(it.target):'done')),body:'time’s up'});
+ this.cancel(it.id);
+ }
+ }
+ return fired;
+ }
+ presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));}
+ addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom chip here'};
+ if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'};
+ (this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};}
+ deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label);
+ if(i<0)return {ok:false,reason:'not found'}; if(a[i].locked)return {ok:false,reason:'default — locked'};
+ a.splice(i,1);return {ok:true};}
+}
+
+/* ---- notifications ---- */
+let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied';
+function tryNotify(title,body){ if(typeof Notification==='undefined')return;
+ if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}}
+ else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);} }
+function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap);
+ return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t);
+ setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};}
+
+function dotsHTML(p){ if(!p)return ''; let h='<span class="dots">'; const pos=(p.cycle-1)%p.iv;
+ for(let i=0;i<p.iv;i++){ const isLong=(i===p.iv-1); let cls=''; if(i<pos)cls='on'; if(i===pos)cls='now'; if(isLong)cls+=' long';
+ h+=`<i class="${cls.trim()}"></i>`; } return h+'</span>'; }
+
+/* =================================================================== */
+/* THE PANEL: hero (top) · create (middle) · list (bottom) */
+/* =================================================================== */
+function mount(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();});
+ head.appendChild(clear);
+ const hero=el('div','hero');
+ const create=buildCreate();
+ const list=el('div','qlist');
+ host.append(head,hero,create.box,list);
+
+ const flashing=new Set();
+
+ /* ---- hero + list interactions ---- */
+ hero.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');}
+ else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');}
+ else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');}
+ render();});
+ list.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='promote'){engine.promote(id);toast('to bar slot');}
+ else if(a==='lap'){engine.lap(id);toast('lap recorded');} else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');}
+ else if(a==='cancel'){ if(b.dataset.armed){engine.cancel(id);toast('cancelled');}
+ else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;} }
+ render();});
+
+ /* ---- create strip (swaps body by type) ---- */
+ function buildCreate(){
+ const box=el('div','create');
+ const seg=el('div','seg');
+ TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);});
+ const body=el('div');
+ box.append(seg,body);
+ let selType='timer';
+ // shared value/label/add controls (rebuilt per type)
+ seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
+ selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();});
+ function labelAdd(hasVal){
+ const row=el('div','row');
+ let val=null;
+ if(hasVal){ val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2'; }
+ const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD'); addk.style.flex='1';
+ if(val) row.append(val); row.append(lab,addk); return {row,val,lab,addk};
+ }
+ function paintBody(){
+ body.innerHTML='';
+ if(selType==='timer'||selType==='alarm'){
+ const chips=el('div','chips');
+ engine.presetsFor(selType).forEach(p=>{
+ const c=el('span','chip'+(p.locked?' lock':''), p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));
+ c.dataset.val=p.value; chips.appendChild(c);
+ });
+ const addch=el('span','chip','+ chip'); addch.dataset.newchip='1'; addch.style.opacity='.7'; chips.appendChild(addch);
+ const {row,val,lab,addk}=labelAdd(true);
+ val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour' : '5m · 1h30m · 90s';
+ body.append(chips,row);
+ chips.addEventListener('click',e=>{
+ const del=e.target.closest('[data-del]');
+ if(del){const r=engine.deletePreset(selType,decodeURIComponent(del.dataset.del));toast(r.ok?'chip removed':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ if(e.target.closest('[data-newchip]')){const lb=prompt('Chip label:');if(!lb)return;
+ const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset(selType,lb,vv);toast(r.ok?'chip added':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ const c=e.target.closest('.chip');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;doAdd(selType,val,lab);
+ });
+ addk.addEventListener('click',()=>doAdd(selType,val,lab));
+ [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd(selType,val,lab);}));
+ }
+ else if(selType==='stopwatch'){
+ const {row,lab,addk}=labelAdd(false); // no time entry — stopwatches count up from zero
+ body.append(row);
+ addk.addEventListener('click',()=>doAdd('stopwatch',null,lab));
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);});
+ }
+ else { // pomodoro config
+ const chips=el('div','chips');
+ engine.pomoPresets.forEach(p=>{const c=el('span','chip lock',p.label);c.dataset.pp=p.label;chips.appendChild(c);});
+ const cfg=el('div','cfg');
+ const mk=(v)=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;};
+ const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv);
+ const auto=el('span','switch on'); auto.dataset.on='1';
+ const rW=el('div','crow'); rW.append(el('span','lbl','Work'), el('span','sl','S'), ws, el('span','sl','L'), wl, el('span','u','min'));
+ const rR=el('div','crow'); rR.append(el('span','lbl','Rest'), el('span','sl','S'), rs, el('span','sl','L'), rl, el('span','u','min'));
+ const rI=el('div','crow'); rI.append(el('span','lbl','Long ev.'), iv, el('span','u','cycles → long work + long break'));
+ const rA=el('div','crow'); rA.append(el('span','lbl','Auto'), auto, el('span','u','advance into the next phase'));
+ cfg.append(rW,rR,rI,rA);
+ const {row,lab,addk}=labelAdd(false); lab.placeholder='label (optional)'; // pomodoro has no single value entry — config fields above
+ addk.innerHTML=GL.add+' ADD CYCLE';
+ body.append(chips,cfg,row);
+ auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';});
+ chips.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return;
+ const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return;
+ ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv;
+ [...chips.children].forEach(x=>x.classList.toggle('on',x===c));toast('loaded “'+p.label+'”','gold');});
+ function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;}
+ addk.addEventListener('click',()=>{
+ const cfgv={ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on};
+ const r=engine.add('pomodoro','',lab.value.trim(),cfgv);
+ if(!r.ok){toast(r.reason,'red');return;} lab.value=''; toast('pomodoro added','gold'); render();
+ });
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();});
+ }
+ }
+ function doAdd(type,val,lab){
+ const r=engine.add(type,val?val.value:'',lab.value.trim());
+ if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;}
+ if(val){val.classList.remove('bad'); if(!val.disabled)val.value='';} lab.value='';
+ toast('added '+type,'gold'); render();
+ }
+ paintBody();
+ return {box};
+ }
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows(), primId=engine.effectivePrimary();
+ // HERO = primary
+ const h=rows.find(r=>r.id===primId);
+ hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':'');
+ if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>'; }
+ else {
+ const ringP=h.prog!=null?Math.round(h.prog*100):(h.type==='stopwatch'?100:0);
+ const inner=h.type==='stopwatch'?`<b style="color:var(--slate-hi);font-size:11px">SW</b>`
+ :`<b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`;
+ const startLabel = h.pomo&&h.pomo.awaiting ? (GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')) : (h.paused?GL.play+' RESUME':GL.paused+' PAUSE');
+ const transport = h.type==='stopwatch'
+ ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button>
+ <button class="key" data-act="lap" data-id="${h.id}">LAP</button>
+ <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>`
+ : `<button class="key icon" data-act="cycle" data-dir="prev" title="prev primary">‹</button>
+ <button class="key" data-act="toggle" data-id="${h.id}">${startLabel}</button>
+ <button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button>
+ <button class="key icon" data-act="cycle" data-dir="next" title="next primary">›</button>`;
+ hero.innerHTML=
+ `<div><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px">${inner}</span></div>
+ <div class="rhs">
+ <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>
+ <span class="badge">BAR SLOT</span>${h.pomo?dotsHTML(h.pomo):''}</div>
+ <div class="hlabel">${h.label}</div>
+ <div class="hbig">${h.disp}</div>
+ <div class="hsub">${h.sub}</div>
+ <div class="transport">${transport}</div>
+ </div>`;
+ }
+ // LIST = the rest
+ list.innerHTML='';
+ const rest=rows.filter(r=>r.id!==primId);
+ if(!rest.length){ list.appendChild(el('div','empty', h?'Only the hero is queued — add more below.':'')); }
+ list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>'));
+ rest.forEach(r=>{
+ const row=el('div','qrow'+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':''));
+ const ctrls = r.type==='stopwatch'
+ ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button>
+ <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>`
+ : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`;
+ row.innerHTML=
+ `<span class="lamp ${r.paused?'off':(r.warn?'red':'')}"></span>
+ <span class="g">${r.glyph}</span>
+ <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}${r.pomo?' ':''}</span></span>
+ <span class="rd">${r.disp}</span>
+ <span class="ctrls">${ctrls}
+ <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button>
+ <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button></span>`;
+ list.appendChild(row);
+ });
+ }
+ engine._render=render; engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);}; engine._toast=toast;
+ render();
+}
+
+/* ---- seed ---- */
+const engine=new Engine();
+const pomo=engine.add('pomodoro','', 'Deep work', {ws:25,wl:50,rs:5,rl:15,iv:4,auto:true});
+engine.add('timer','45s','Egg');
+engine.add('timer','5m','Tea');
+const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id);
+engine.add('alarm','@hour','Standup');
+engine.promote(pomo.id); // show the pomodoro in the hero
+mount(document.getElementById('panel'), engine);
+
+/* ---- global tick ---- */
+function loop(){
+ const fired=engine.tick();
+ for(const f of fired){ engine._flash(f.id);
+ engine._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold'); tryNotify(f.title,f.body); }
+ engine._render();
+}
+setInterval(loop, reduced?1000:250);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-3.html b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html
new file mode 100644
index 0000000..98778fa
--- /dev/null
+++ b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html
@@ -0,0 +1,556 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Timer panel — iteration 3 (waybar + hero-right) · 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; --sage:#8a9a5b;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",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:1120px;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:94ch}
+.masthead p b{color:var(--silver)}
+.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.4rem;flex-wrap:wrap}
+.side{flex:1;min-width:300px}
+.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem;display:flex;align-items:center;gap:10px}
+.side h2::after{content:"";height:1px;background:var(--wash);flex:1}
+.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px}
+.side li{display:flex;gap:9px}.side li::before{content:"›";color:var(--gold);flex:none}
+.side li b{color:var(--silver);font-weight:400}
+
+/* ---------- waybar preview ---------- */
+.barcap{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;margin-bottom:7px;display:flex;align-items:center;gap:9px}
+.barcap::after{content:"";height:1px;background:var(--wash);flex:1}
+.wbar{width:420px;background:linear-gradient(180deg,#141312,#0e0d0c);border:1px solid #262320;border-radius:16px;
+ padding:6px 10px;display:flex;align-items:center;gap:8px;box-shadow:0 8px 20px rgba(0,0,0,.5);margin-bottom:6px}
+.wbar .fillspace{flex:1;color:var(--dim);font-size:10px;letter-spacing:.1em;padding-left:4px}
+.wmod{display:inline-flex;align-items:center;gap:7px;color:var(--silver);background:transparent;border:1.5px solid var(--gold);
+ border-radius:14px;padding:4px 12px;cursor:pointer;font-size:12.5px;white-space:nowrap;min-width:78px;justify-content:center}
+.wmod:hover{background:var(--wash)}
+.wmod .wg{font-size:16px;line-height:1}
+.wmod .wt{font-variant-numeric:tabular-nums;font-weight:700}
+.wmod .wp{color:var(--dim);font-size:11px}
+.wmod.urgent{color:var(--fail)} .wmod.paused{color:var(--dim)}
+.wmod.pomodoro-work{color:var(--gold)} .wmod.pomodoro-break{color:var(--sage)}
+.wmod.idle{color:var(--silver);min-width:0;border-color:#4a463c}
+.wtip{width:420px;background:var(--well);border:1px solid #201d17;border-radius:8px;padding:7px 10px;font-size:11px;color:var(--dim);margin-bottom:16px}
+.wtip .th{color:var(--steel);letter-spacing:.14em;text-transform:uppercase;font-size:.56rem;margin-bottom:3px}
+.wtip .tl{color:var(--silver);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
+/* ---------- faceplate ---------- */
+.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:14px;padding:15px;position:relative;box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)}
+.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase}
+.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em}
+.phead .pcount b{color:var(--cream)}
+.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto}
+.x-btn:hover{background:var(--wash);color:var(--silver)}
+.panel.closed{display:none}
+.wbar.reopen{outline:1px dashed var(--slate);outline-offset:2px}
+
+/* ---------- primitives ---------- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none}
+.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}
+.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:7px 11px;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:disabled{opacity:.4;cursor:default}
+.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.sm{padding:5px 8px;font-size:10.5px;border-radius:7px}
+.key.icon{padding:6px 9px;font-size:14px;line-height:1}
+.preset{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center}
+.preset:hover{color:var(--silver);border-color:var(--slate)}
+.preset.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.preset .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px}.preset .x:hover{opacity:1;color:var(--fail)}
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase}
+.badge.red{background:var(--fail);color:var(--cream)}.badge.dim{background:var(--wash);color:var(--steel)}.badge.sage{background:var(--sage);color:var(--panel)}
+.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 0;cursor:pointer;flex:1;letter-spacing:.02em}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}.switch.on::after{left:20px;background:var(--gold)}
+.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;display:flex;align-items:center;gap:9px;margin:2px 0}
+.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}
+.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;padding:7px 9px;width:100%;outline:none}
+.tin:focus{border-color:var(--gold)}.tin::placeholder{color:var(--dim)}.tin.bad{border-color:var(--fail);color:var(--fail)}
+.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px;padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums}
+.numin:focus{border-color:var(--gold)}
+@keyframes fieldflash{0%{border-color:var(--gold-hi);background:rgba(218,181,61,.22)}100%{border-color:#231f18;background:#0d0f10}}
+.tin.flash,.numin.flash{animation:fieldflash .7s ease}
+.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;border-radius:7px;padding:6px 9px}
+.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)}
+.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative}
+.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)}
+.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;z-index:1;text-align:center}
+.dots{display:flex;gap:4px;align-items:center}
+.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none}
+.dots i.on{background:var(--steel)}.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}.dots i.long{outline:1px solid var(--gold);outline-offset:1px}
+.days7{display:flex;gap:4px}
+.days7 button{font:inherit;font-size:10px;width:22px;height:22px;border-radius:50%;border:1px solid #33302b;background:#141210;color:var(--dim);cursor:pointer;padding:0}
+.days7 button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);border-color:var(--gold-hi);font-weight:700}
+
+/* ---------- HERO (info left, donut right) ---------- */
+.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;flex-direction:column;gap:13px;margin-bottom:12px}
+.hero .htop{display:flex;gap:15px;align-items:center}
+.hero.fire{animation:firef .6s ease-in-out 3}
+.hero.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)}
+@keyframes firef{50%{background:rgba(203,107,77,.22)}}
+@keyframes ringf{50%{background:rgba(203,107,77,.16)}}
+.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px}
+.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.hero .htype .g{color:var(--gold);font-size:17px}
+.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;display:flex;align-items:center;gap:11px}
+.hero.paused .hbig{color:var(--steel)}
+.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em}
+.hero .lapbadge{font-size:11px;letter-spacing:.12em;color:var(--silver);border:1px solid var(--slate);border-radius:6px;
+ padding:2px 8px;font-weight:400;font-variant-numeric:tabular-nums;background:transparent;line-height:1;align-self:center}
+/* stopwatch sweep dial — analog second-hand, one revolution per minute */
+.swdial{width:86px;height:86px;border-radius:50%;background:var(--well);border:1px solid #201d17;position:relative;display:block}
+.swdial::before{content:"";position:absolute;inset:7px;border-radius:50%;border:2px solid var(--wash)}
+.swtick{position:absolute;top:5px;left:50%;width:2px;height:8px;margin-left:-1px;background:var(--steel);border-radius:1px;transform-origin:50% 38px}
+.swtick.q{transform:rotate(90deg)}.swtick.h{transform:rotate(180deg)}.swtick.t{transform:rotate(270deg)}
+.swhand{position:absolute;left:calc(50% - 1px);bottom:50%;width:2px;height:31px;background:var(--gold-hi);
+ transform-origin:50% 100%;border-radius:1px;box-shadow:0 0 5px rgba(255,215,95,.5)}
+.swhub{position:absolute;left:50%;top:50%;width:9px;height:9px;margin:-4.5px 0 0 -4.5px;border-radius:50%;
+ background:var(--gold);box-shadow:0 0 0 2px var(--well),0 0 6px rgba(218,181,61,.5)}
+.hero .donut{flex:none}
+.transport{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-start}
+
+/* ---------- CREATE (middle) ---------- */
+.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px}
+.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap}
+.presets{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px}
+.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px}
+.cfg .crow{display:flex;align-items:center;gap:8px}
+.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none}
+.cfg .crow .u{color:var(--dim);font-size:10px}
+.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px}
+
+/* ---------- LIST (bottom) ---------- */
+.qlist{display:flex;flex-direction:column;gap:8px}
+.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px}
+.qrow.fire{animation:firef .6s ease-in-out 3}.qrow.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)}
+.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none}
+.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px}
+.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:104px}
+.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.1em;text-transform:uppercase}
+.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+.qrow.paused .rd{color:var(--steel)}
+.qrow .ctrls{display:flex;gap:5px;flex:none}
+.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px}
+.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5}
+.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease}
+.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+@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 · timer · iteration 3</div>
+ <h1>Timer panel — waybar + hero-right</h1>
+ <p>Third pass. The <b>hero donut moved to the right</b> of the readout; the redundant "bar slot" badge is gone
+ (the hero <i>is</i> the bar slot). Above the panel sits a <b>live, accurate preview of the actual waybar module</b> —
+ the same glyph + countdown + "+N" and state colours <code>wtimer render</code> emits, with its hover tooltip.
+ Presets (renamed from "chips") <b>flash the fields</b> on load instead of toasting, alarms gain a <b>half-past</b> preset,
+ and each type picked up create-strip ideas from its category's best apps: timers <b>repeat</b>, alarms carry
+ <b>recurring days + snooze</b> (with a ringing state), pomodoro keeps its configurable cycle.</p>
+</header>
+
+<div class="cols">
+ <div>
+ <div class="barcap">the waybar module · live</div>
+ <div class="wbar" id="wbar"></div>
+ <div class="wtip" id="wtip"></div>
+ <div class="panel" id="panel"></div>
+ </div>
+ <div class="side">
+ <h2>This pass</h2>
+ <ul>
+ <li><b>Donut on the right</b> of the hero info; <b>no "bar slot" label</b>.</li>
+ <li><b>Live waybar preview</b> — mirrors <code>wtimer render</code>: glyph + countdown + "+N", state colour, tooltip.</li>
+ <li><b>"Preset"</b> replaces "chip"; loading one <b>flashes the field(s)</b>, no toast.</li>
+ <li><b>Half-past</b> alarm preset — next X:30, the sibling of top-of-hour's X:00.</li>
+ </ul>
+ <h2 style="margin-top:1.5rem">Borrowed per category</h2>
+ <ul>
+ <li><b>Timer</b> (MultiTimer, Multi Timer): auto-<b>repeat</b> — restart on finish. Toggle in the create row.</li>
+ <li><b>Alarm</b> (Alarm Clock Xtreme, Alarmy): <b>recurring weekdays</b> + <b>snooze</b>; fires into a ringing state with SNOOZE / DISMISS.</li>
+ <li><b>Stopwatch</b> (Stopwatch Timer): sweep dial + infinite <b>laps</b> with the last lap beside the count; run-save deferred to a vNext.</li>
+ <li><b>Pomodoro</b> (Pomofocus): configurable work/rest short+long, long-break interval, auto-advance, cycle dots.</li>
+ </ul>
+ </div>
+</div>
+</div>
+
+<script>
+"use strict";
+const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
+const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;};
+
+const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', idle:'\u{F051B}',
+ pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}',
+ play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}', repeat:'\u{F0456}', bell:'\u{F0020}' };
+
+function parseDuration(v){ if(v==null)return null;v=String(v).trim().toLowerCase();if(v==='')return null;
+ if(/^\d+$/.test(v))return parseInt(v,10)*60; if(!/^(\s*\d+\s*[hms])+$/.test(v))return null;
+ let m,tot=0;const re=/(\d+)\s*([hms])/g; while((m=re.exec(v)))tot+=m[2]==='h'?+m[1]*3600:m[2]==='m'?+m[1]*60:+m[1]; return tot>0?tot:null; }
+function resolveAlarm(v,now){ v=String(v||'').trim().toLowerCase();
+ if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;}
+ if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;}
+ if(v==='@half'||v==='half past'||v==='half-past'){const d=new Date(now*1000);d.setSeconds(0,0);d.setMinutes(30);let e=d.getTime()/1000;if(e<=now){d.setHours(d.getHours()+1);e=d.getTime()/1000;}return e;}
+ const t=v.match(/^(\d{1,2}):(\d{2})$/); if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;} return null; }
+function nextAlarm(hh,mm,days,now){ const base=new Date(now*1000);
+ for(let d=0;d<=7;d++){const c=new Date(base);c.setDate(base.getDate()+d);c.setHours(hh,mm,0,0);const e=c.getTime()/1000;
+ if(e<=now)continue; if(!days.length||days.includes(c.getDay()))return e;} return now+86400; }
+const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60;
+ return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;};
+const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;};
+const DAYNAMES=['S','M','T','W','T','F','S'];
+
+const DEFAULT_PRESETS=()=>({
+ timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true},
+ {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}],
+ alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'half-past',value:'@half',locked:true},{label:'07:00',value:'07:00',locked:true}],
+ stopwatch:[]
+});
+const POMO_PRESETS=[{label:'Classic',ws:25,wl:50,rs:5,rl:15,iv:4,locked:true},{label:'Deep',ws:50,wl:50,rs:10,rl:30,iv:3,locked:true},{label:'Sprint',ws:15,wl:25,rs:3,rl:10,iv:4,locked:true}];
+const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true};
+const TYPES=['timer','alarm','stopwatch','pomodoro'];
+const COUNTDOWN=['timer','alarm','pomodoro'];
+const MAX=10;
+
+class Engine{
+ constructor(){this.items=[];this.seq=0;this.primary=null;this.presets=DEFAULT_PRESETS();this.pomoPresets=POMO_PRESETS.map(p=>({...p}));}
+ now(){return Date.now()/1000;}
+ count(){return this.items.length;}
+ full(){return this.items.length>=MAX;}
+ add(type,value,label,opts){
+ opts=opts||{};
+ if(this.full())return {ok:false,reason:`queue full (${MAX}/${MAX})`};
+ const now=this.now();this.seq++;const id='t'+this.seq;const it={id,type,label:label||''};
+ if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;it.repeat=!!opts.repeat;}
+ else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`};
+ const d=new Date(e*1000);it.hh=d.getHours();it.mm=d.getMinutes();it.days=(opts.days||[]).slice();it.snooze=opts.snooze||9;
+ it.target=it.days.length?nextAlarm(it.hh,it.mm,it.days,now):e;it.total=Math.max(1,it.target-now);}
+ else if(type==='pomodoro'){const c=opts.ws?opts:POMO_DEFAULT;it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false};
+ it.phase='work';it.cycle=1;const deep=(1%it.cfg.iv===0);const len=deep?it.cfg.wl:it.cfg.ws;it.target=now+len;it.total=len;}
+ else if(type==='stopwatch'){it.start=now;it.laps=[];}
+ else return {ok:false,reason:`unknown type: ${type}`};
+ this.items.push(it);if(!this.primary)this.primary=id;return {ok:true,id};
+ }
+ find(id){return this.items.find(i=>i.id===id);}
+ isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;}
+ remaining(it,ref){ref=ref==null?this.now():ref;if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start;return this.isPaused(it)?it.paused_left:it.target-ref;}
+ toggle(id){const it=this.find(id);if(!it)return;const now=this.now();
+ if(it.type==='stopwatch'){if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;}else it.paused_elapsed=now-it.start;}
+ else{if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;}else it.paused_left=it.target-now;}}
+ cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;}
+ cancelAll(){this.items=[];this.primary=null;}
+ promote(id){if(this.find(id))this.primary=id;}
+ cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c;this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];}
+ lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});}
+ snooze(id){const it=this.find(id);if(!it)return;it.ringing=false;const m=it.snooze>0?it.snooze:9;it.target=this.now()+m*60;it.total=m*60;}
+ dismiss(id){const it=this.find(id);if(!it)return;if(it.days&&it.days.length){it.ringing=false;it.target=nextAlarm(it.hh,it.mm,it.days,this.now());it.total=Math.max(1,it.target-this.now());}else this.cancel(id);}
+ effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id);
+ const ring=items.find(i=>i.ringing);if(ring)return ring.id;
+ if(ids.includes(this.primary))return this.primary;const now=this.now();
+ const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i));
+ if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id;
+ const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];}
+ sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now);
+ if(it.ringing)return [-1,0,+it.id.slice(1)]; return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];}
+ rows(){const prim=this.effectivePrimary(),now=this.now();
+ return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b);for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));}
+ daysLabel(it){ if(!it.days||!it.days.length)return 'once'; if(it.days.length===7)return 'daily';
+ const wk=[1,2,3,4,5],we=[0,6];
+ if(wk.every(d=>it.days.includes(d))&&it.days.length===5)return 'weekdays';
+ if(we.every(d=>it.days.includes(d))&&it.days.length===2)return 'weekends';
+ return it.days.slice().sort().map(d=>DAYNAMES[d]).join(''); }
+ row(it,prim,now){
+ const rem=this.remaining(it,now),paused=this.isPaused(it);
+ let disp,sub='',warn=false,prog=null,glyph,pomo=null,ringing=!!it.ringing,badges=[],lastLap=null,sweep=null;
+ if(it.type==='alarm'){
+ if(ringing){disp='RING';sub='alarm ringing';warn=true;prog=1;glyph=GL.alarm;}
+ else{disp=fmtClock(it.target);sub=`${this.daysLabel(it)} · fires ${fmtClock(it.target)}`;prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;}
+ if(it.days&&it.days.length)badges.push({t:this.daysLabel(it),c:'sage'});
+ }
+ else if(it.type==='pomodoro'){disp=fmtTime(rem);prog=Math.max(0,Math.min(1,rem/it.total));const deep=(it.cycle%it.cfg.iv===0);
+ const ph=it.phase==='work'?(deep?'long work':'work'):(deep?'long break':'short break');
+ sub=it.awaiting?`ready · start ${it.phase==='work'?'work':'break'}`:`${ph} · cycle ${it.cycle}/${it.cfg.iv}`;
+ glyph=it.phase==='work'?GL.pomo_work:GL.pomo_break;pomo={cycle:it.cycle,iv:it.cfg.iv,phase:it.phase,awaiting:!!it.awaiting};}
+ else if(it.type==='stopwatch'){disp=fmtTime(rem);lastLap=it.laps.length?it.laps[it.laps.length-1].t:null;
+ sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;sweep=(Math.max(0,rem)%60)/60;}
+ else {disp=fmtTime(rem);sub=it.repeat?'timer · repeats':'timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;if(it.repeat)badges.push({t:'repeat',c:''});}
+ if(prog!=null&&!ringing&&rem<=Math.min(30,it.total*0.15))warn=true;
+ if(paused)glyph=GL.paused;
+ return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type],
+ typeLabel:it.type,disp,sub,paused,ringing,primary:it.id===prim,prog,warn,pomo,badges,lastLap,sweep,laps:it.laps?it.laps.length:0};
+ }
+ /* mirror wtimer render_payload for the bar */
+ barPayload(){ const now=this.now(); const items=this.items;
+ if(!items.length)return {glyph:GL.idle,text:'',plus:0,cls:'idle',tip:['No timers']};
+ const pid=this.effectivePrimary(); const p=this.find(pid);
+ let cls; if(p.ringing)cls='urgent'; else if(this.isPaused(p))cls='paused';
+ else if(p.type==='pomodoro')cls=(p.phase==='work'?'pomodoro-work':'pomodoro-break');
+ else if((p.type==='timer'||p.type==='alarm')&&this.remaining(p,now)<60)cls='urgent'; else cls=p.type;
+ const glyph=this.isPaused(p)?GL.paused:(p.type==='pomodoro'?(p.phase==='work'?GL.pomo_work:GL.pomo_break):GL[p.type]);
+ const text=p.ringing?'RING':fmtTime(this.remaining(p,now));
+ const tip=[items.length!==1?`${items.length} active`:'1 timer'];
+ for(const i of items){const g=this.isPaused(i)?GL.paused:(i.type==='pomodoro'?(i.phase==='work'?GL.pomo_work:GL.pomo_break):GL[i.type]);
+ const lb=i.label||i.type;const st=this.isPaused(i)?' (paused)':(i.ringing?' (ringing)':'');
+ const val=i.type==='alarm'?(i.ringing?'RING':fmtClock(i.target)):(i.type==='pomodoro'?`${i.cycle}/${i.cfg.iv} ${fmtTime(this.remaining(i,now))}`:fmtTime(this.remaining(i,now)));
+ tip.push(`${g} ${lb} ${val}${st}`);}
+ return {glyph,text,plus:items.length-1,cls,tip};
+ }
+ presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));}
+ addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom preset here'};
+ if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'};(this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};}
+ deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label);if(i<0)return {ok:false,reason:'not found'};if(a[i].locked)return {ok:false,reason:'default — locked'};a.splice(i,1);return {ok:true};}
+}
+
+let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied';
+function tryNotify(title,body){if(typeof Notification==='undefined')return;if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}}else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);}}
+function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap);return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t);setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};}
+function flash(...inputs){for(const i of inputs){if(!i)continue;i.classList.remove('flash');void i.offsetWidth;i.classList.add('flash');}}
+function dotsHTML(p){if(!p)return '';let h='<span class="dots">';const pos=(p.cycle-1)%p.iv;for(let i=0;i<p.iv;i++){const isLong=(i===p.iv-1);let cls='';if(i<pos)cls='on';if(i===pos)cls='now';if(isLong)cls+=' long';h+=`<i class="${cls.trim()}"></i>`;}return h+'</span>';}
+function badgesHTML(bs){return (bs||[]).map(b=>`<span class="badge ${b.c}">${b.t}</span>`).join(' ');}
+
+function mount(host, engine, bar, tip){
+ const toast=toaster(host);
+ const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR ALL');clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();});
+ head.appendChild(clear);
+ // close button — flat circular ✕ like the net/bt/audio panels (Close/Esc); the
+ // waybar module reopens it, mirroring the real on-click: timer-panel toggle.
+ const closeBtn=el('button','x-btn','✕');closeBtn.title='Close (Esc)';
+ const barcap=document.querySelector('.barcap');
+ function setClosed(c){host.classList.toggle('closed',c);if(barcap)barcap.textContent=c?'the waybar module · click it to reopen the panel':'the waybar module · live';}
+ closeBtn.addEventListener('click',()=>setClosed(true));
+ head.appendChild(closeBtn);
+ bar.addEventListener('click',()=>setClosed(!host.classList.contains('closed')));
+ document.addEventListener('keydown',e=>{if(e.key==='Escape')setClosed(true);});
+ const hero=el('div','hero');
+ const create=buildCreate();
+ const list=el('div','qlist');
+ host.append(head,hero,create,list);
+ const flashing=new Set();
+
+ function itemClick(e){const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id);
+ else if(a==='promote'){engine.promote(id);toast('to bar slot');}
+ else if(a==='cycle')engine.cycle(b.dataset.dir);
+ else if(a==='lap'){engine.lap(id);toast('lap recorded');}
+ else if(a==='stop'){engine.cancel(id);toast('stopped');}
+ else if(a==='snooze'){engine.snooze(id);toast('snoozed','gold');}
+ else if(a==='dismiss'){engine.dismiss(id);toast('dismissed');}
+ else if(a==='cancel'){if(b.dataset.armed){engine.cancel(id);toast('cancelled');}
+ else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;}}
+ render();}
+ hero.addEventListener('click',itemClick);
+ list.addEventListener('click',itemClick);
+
+ function buildCreate(){
+ const box=el('div','create');
+ const seg=el('div','seg');
+ TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);});
+ const body=el('div');box.append(seg,body);
+ let selType='timer';
+ seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();});
+ function doAdd(type,val,lab,opts){const r=engine.add(type,val?val.value:'',lab.value.trim(),opts);
+ if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;}
+ if(val){val.classList.remove('bad');if(!val.disabled)val.value='';}lab.value='';toast('added '+type,'gold');render();}
+ function paintBody(){
+ body.innerHTML='';
+ if(selType==='timer'){
+ const ps=el('div','presets');
+ engine.presetsFor('timer').forEach(p=>{const c=el('span','preset'+(p.locked?'':''),p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);});
+ ps.appendChild(Object.assign(el('span','preset','+ preset'),{}) );ps.lastChild.dataset.newp='1';ps.lastChild.style.opacity='.7';
+ const row=el('div','row');
+ const val=el('input','tin');val.placeholder='5m · 1h30m · 90s';val.style.flex='2';
+ const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const rep=el('span','switch');const reptag=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">repeat</span>');
+ const addk=el('button','key on',GL.add+' ADD');
+ const repwrap=el('span',null,'');repwrap.style.display='inline-flex';repwrap.style.alignItems='center';repwrap.style.gap='6px';repwrap.append(rep,reptag);
+ row.append(val,lab,repwrap,addk);
+ body.append(ps,row);
+ rep.addEventListener('click',()=>rep.classList.toggle('on'));
+ ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]');
+ if(del){const r=engine.deletePreset('timer',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ if(e.target.closest('[data-newp]')){const lb=prompt('Preset label:');if(!lb)return;const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset('timer',lb,vv);toast(r.ok?'preset added':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);});
+ addk.addEventListener('click',()=>doAdd('timer',val,lab,{repeat:rep.classList.contains('on')}));
+ [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('timer',val,lab,{repeat:rep.classList.contains('on')});}));
+ }
+ else if(selType==='alarm'){
+ const ps=el('div','presets');
+ engine.presetsFor('alarm').forEach(p=>{const c=el('span','preset',p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);});
+ const row=el('div','row');
+ const val=el('input','tin');val.placeholder='HH:MM · +30m · @hour · @half';val.style.flex='2';
+ const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD');
+ row.append(val,lab,addk);
+ const drow=el('div','row');const days=el('div','days7');const sel=new Set();
+ DAYNAMES.forEach((d,i)=>{const b=el('button',null,d);b.dataset.d=i;b.title=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][i];days.appendChild(b);});
+ const quick=el('span',null,'');const wkd=el('button','key sm','weekdays');const evd=el('button','key sm','daily');
+ quick.style.display='inline-flex';quick.style.gap='5px';quick.append(wkd,evd);
+ const srow=el('div','row');const slbl=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">snooze</span>');
+ const sn=el('input','numin');sn.value='9';sn.inputMode='numeric';const smin=el('span',null,'<span style="color:var(--dim);font-size:10px">min</span>');
+ srow.append(slbl,sn,smin);
+ drow.append(days,quick);
+ body.append(ps,row,drow,srow);
+ function paintDays(){[...days.children].forEach(b=>b.classList.toggle('on',sel.has(+b.dataset.d)));}
+ days.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;const d=+b.dataset.d;sel.has(d)?sel.delete(d):sel.add(d);paintDays();});
+ wkd.addEventListener('click',()=>{sel.clear();[1,2,3,4,5].forEach(d=>sel.add(d));paintDays();});
+ evd.addEventListener('click',()=>{sel.clear();[0,1,2,3,4,5,6].forEach(d=>sel.add(d));paintDays();});
+ function opts(){return {days:[...sel],snooze:parseInt(sn.value,10)||9};}
+ ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]');
+ if(del){const r=engine.deletePreset('alarm',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);});
+ addk.addEventListener('click',()=>doAdd('alarm',val,lab,opts()));
+ [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('alarm',val,lab,opts());}));
+ }
+ else if(selType==='stopwatch'){
+ const row=el('div','row');
+ const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD');
+ row.append(lab,addk);
+ const note=el('div','engrave','counts up from zero · lap while running');
+ body.append(row,note);
+ addk.addEventListener('click',()=>doAdd('stopwatch',null,lab));
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);});
+ }
+ else {
+ const ps=el('div','presets');
+ engine.pomoPresets.forEach(p=>{const c=el('span','preset',p.label);c.dataset.pp=p.label;ps.appendChild(c);});
+ const cfg=el('div','cfg');const mk=v=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;};
+ const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv);
+ const auto=el('span','switch on');auto.dataset.on='1';
+ const rW=el('div','crow');rW.append(el('span','lbl','Work'),el('span','sl','S'),ws,el('span','sl','L'),wl,el('span','u','min'));
+ const rR=el('div','crow');rR.append(el('span','lbl','Rest'),el('span','sl','S'),rs,el('span','sl','L'),rl,el('span','u','min'));
+ const rI=el('div','crow');rI.append(el('span','lbl','Long ev.'),iv,el('span','u','cycles → long work + long break'));
+ const rA=el('div','crow');rA.append(el('span','lbl','Auto'),auto,el('span','u','advance into the next phase'));
+ cfg.append(rW,rR,rI,rA);
+ const row=el('div','row');const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD CYCLE');row.append(lab,addk);
+ body.append(ps,cfg,row);
+ auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';});
+ ps.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return;const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return;
+ ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv;[...ps.children].forEach(x=>x.classList.toggle('on',x===c));flash(ws,wl,rs,rl,iv);});
+ function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;}
+ addk.addEventListener('click',()=>{const r=engine.add('pomodoro','',lab.value.trim(),{ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on});
+ if(!r.ok){toast(r.reason,'red');return;}lab.value='';toast('pomodoro added','gold');render();});
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();});
+ }
+ }
+ paintBody();
+ return box;
+ }
+
+ function renderBar(){
+ const p=engine.barPayload();
+ bar.className='wmod '+p.cls;bar.title=p.tip.join('\n');
+ bar.innerHTML=`<span class="wg">${p.glyph}</span>`+(p.text?`<span class="wt">${p.text}</span>`:'')+(p.plus>0?`<span class="wp">+${p.plus}</span>`:'');
+ tip.innerHTML=`<div class="th">hover tooltip</div>`+p.tip.map((l,i)=>`<div class="tl" style="${i===0?'color:var(--steel)':''}">${l}</div>`).join('');
+ }
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows(),primId=engine.effectivePrimary();
+ const h=rows.find(r=>r.id===primId);
+ hero.className='hero'+(h&&h.paused?' paused':'')+(h&&h.ringing?' ringing':'')+(h&&flashing.has(h.id)?' fire':'');
+ if(!h){hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>';}
+ else{
+ const ringP=h.prog!=null?Math.round(h.prog*100):0;
+ const donutHTML = h.type==='stopwatch'
+ ? `<span class="swdial" title="seconds sweep"><span class="swtick"></span><span class="swtick q"></span><span class="swtick h"></span><span class="swtick t"></span><span class="swhand" style="transform:rotate(${Math.round((h.sweep||0)*360)}deg)"></span><span class="swhub"></span></span>`
+ : `<span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px"><b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b></span>`;
+ let transport;
+ if(h.ringing) transport=`<button class="key" data-act="snooze" data-id="${h.id}">${GL.play} SNOOZE ${engine.find(h.id).snooze}m</button><button class="key red" data-act="dismiss" data-id="${h.id}">DISMISS</button>`;
+ else if(h.type==='stopwatch') transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button><button class="key" data-act="lap" data-id="${h.id}">LAP</button><button class="key red" data-act="stop" data-id="${h.id}">STOP</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`;
+ else {const start=h.pomo&&h.pomo.awaiting?(GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')):(h.paused?GL.play+' RESUME':GL.paused+' PAUSE');
+ transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${start}</button><button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`;}
+ hero.innerHTML=
+ `<div class="htop">
+ <div class="rhs">
+ <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>${badgesHTML(h.badges)}${h.pomo?dotsHTML(h.pomo):''}</div>
+ <div class="hlabel">${h.label}</div>
+ <div class="hbig">${h.disp}${h.type==='stopwatch'&&h.lastLap!=null?`<span class="lapbadge">LAP ${fmtTime(h.lastLap)}</span>`:''}</div>
+ <div class="hsub">${h.sub}</div>
+ </div>
+ <div class="donut">${donutHTML}</div>
+ </div>
+ <div class="transport">${transport}</div>`;
+ }
+ list.innerHTML='';
+ const rest=rows.filter(r=>r.id!==primId);
+ list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>'));
+ if(!rest.length)list.appendChild(el('div','empty',h?'Only one item is queued. Add more above.':''));
+ rest.forEach(r=>{
+ const row=el('div','qrow'+(r.paused?' paused':'')+(r.ringing?' ringing':'')+(flashing.has(r.id)?' fire':''));
+ let ctrls;
+ if(r.ringing)ctrls=`<button class="key sm" data-act="snooze" data-id="${r.id}">SNOOZE</button><button class="key sm red" data-act="dismiss" data-id="${r.id}">OFF</button>`;
+ else if(r.type==='stopwatch')ctrls=`<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button><button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button>`;
+ else ctrls=`<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button><button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button>`;
+ row.innerHTML=`<span class="lamp ${r.ringing?'red':(r.paused?'off':(r.warn?'red':''))}"></span><span class="g">${r.glyph}</span><span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span><span class="rd">${r.disp}</span><span class="ctrls">${ctrls}</span>`;
+ list.appendChild(row);
+ });
+ renderBar();
+ }
+ engine._render=render;engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);};engine._toast=toast;
+ render();
+}
+
+const engine=new Engine();
+const pomo=engine.add('pomodoro','','Deep work',{ws:25,wl:50,rs:5,rl:15,iv:4,auto:true});
+engine.add('timer','45s','Egg',{repeat:false});
+engine.add('timer','5m','Tea',{repeat:true});
+const sw=engine.add('stopwatch','','Debug run');
+{const s=engine.find(sw.id);s.start=engine.now()-215;s.laps=[{t:72},{t:158}];} // ~3:35 elapsed, last lap 2:38
+engine.add('alarm','07:00','Wake',{days:[1,2,3,4,5],snooze:9});
+engine.promote(sw.id); // show the new stopwatch sweep-dial + lap badge in the hero
+mount(document.getElementById('panel'), engine, document.getElementById('wbar'), document.getElementById('wtip'));
+
+function loop(){const fired=engine.tick?engine.tick():tickEngine(engine);
+ for(const f of fired){engine._flash(f.id);engine._toast((f.kind==='done'?GL.bell+' ':'')+f.title,f.kind==='done'?'red':'gold');tryNotify(f.title,f.body);}
+ engine._render();}
+/* engine.tick lives on the class below via prototype patch to keep add()/tick together readable */
+Engine.prototype.tick=function(){const now=this.now(),fired=[];
+ for(const it of this.items.slice()){
+ if(!COUNTDOWN.includes(it.type)||this.isPaused(it)||it.ringing)continue;
+ if(it.target-now>0)continue;
+ if(it.type==='pomodoro'){const c=it.cfg;
+ if(it.phase==='work'){const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`});
+ it.phase='rest';const len=deep?c.rl:c.rs;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}}
+ else{it.cycle+=1;const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`});
+ it.phase='work';const len=deep?c.wl:c.ws;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}}}
+ else if(it.type==='alarm'){fired.push({id:it.id,kind:'done',title:'Alarm · '+(it.label||fmtClock(it.target)),body:'alarm ringing'});it.ringing=true;}
+ else{if(it.repeat){fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done')+' · repeating',body:'restarted'});it.target=now+it.total;}
+ else{fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done'),body:'time’s up'});this.cancel(it.id);}}
+ }
+ return fired;};
+setInterval(loop, reduced?1000:250);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html
new file mode 100644
index 0000000..0258f20
--- /dev/null
+++ b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html
@@ -0,0 +1,1359 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Instrument consoles — network + bluetooth</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 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.masthead{max-width:1280px;margin:0 auto 1.8rem}
+.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:80ch}
+.masthead b{color:var(--silver);font-weight:700}
+
+.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start}
+.slot{width:400px}
+.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem}
+.aside{flex:1 1 300px;min-width:280px}
+.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.24em;text-transform:uppercase;margin:1.1rem 0 .5rem}
+.aside h3:first-child{margin-top:.2rem}
+.aside ul{list-style:none}
+.aside li{font-size:.82rem;padding:.22rem 0 .22rem 1.1rem;position:relative}
+.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.aside li em{color:var(--dim);font-style:normal}
+.aside li b{color:var(--cream);font-weight:700}
+.demo-box{border:1px dashed var(--wash);border-radius:10px;padding:.8rem 1rem;margin-top:1rem}
+.demo-box label{display:flex;gap:.6rem;align-items:center;font-size:.82rem;cursor:pointer;color:var(--silver);margin-top:.45rem}
+.demo-box label:first-child{margin-top:0}
+.demo-box input{accent-color:#dab53d}
+.demo-box .hint{color:var(--dim);font-size:.73rem;margin:.15rem 0 0 1.5rem}
+.reset{font:inherit;font-size:.78rem;color:var(--silver);background:transparent;border:1px solid var(--wash);
+ border-radius:8px;padding:.4rem .9rem;cursor:pointer;margin-top:.8rem}
+.reset:hover{background:var(--wash)}
+
+.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative;
+ transition:opacity .25s,transform .25s}
+.panel.closed{opacity:0;transform:translateY(-8px);pointer-events:none}
+.reopen{display:none;font:inherit;font-size:.75rem;color:var(--gold);background:transparent;
+ border:1px dashed var(--gold);border-radius:8px;padding:.5rem 1rem;cursor:pointer;margin-top:.6rem}
+
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;
+ 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,.55)}
+.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}}
+
+.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px}
+.b-id{display:flex;align-items:center;gap:9px}
+.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em}
+.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto}
+.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);
+ border-radius:4px;padding:1px 6px;display:none}
+.badge.show{display:inline-block}
+.badge.red{background:var(--fail);color:var(--cream)}
+.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;
+ cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto}
+.x-btn:hover{background:var(--wash);color:var(--silver)}
+.switch{width:38px;height:20px;border-radius:10px;background:var(--wash);
+ border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;
+ border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:19px;background:var(--gold)}
+
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;margin:12px 0 6px}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:12px}
+.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer}
+.engrave .act:hover{color:var(--gold)}
+
+.chan .line1{display:flex;align-items:baseline;gap:9px}
+.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px}
+.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px}
+.chan .chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash)}
+.chan .chip:hover{color:var(--gold)}
+.chan .chip.on{color:var(--gold)}
+.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px}
+.ladder i{width:4px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px}
+.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px}
+.ladder.l1 i:nth-child(-n+1){background:var(--gold)}
+.ladder.l2 i:nth-child(-n+2){background:var(--gold)}
+.ladder.l3 i:nth-child(-n+3){background:var(--gold)}
+.ladder.l4 i{background:var(--gold)}
+
+/* section row budgets: lists never grow the panel — they scroll inside it,
+ cut at a half row so the peek says "there's more" */
+.sec-scroll{overflow-y:auto;overscroll-behavior:contain}
+#networks.sec-scroll{max-height:160px}
+#tunnels.sec-scroll{max-height:131px}
+#b-paired.sec-scroll{max-height:160px}
+#b-nearby.sec-scroll{max-height:131px}
+.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px}
+.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer;position:relative}
+.lamp-row:hover{background:var(--wash)}
+.lamp-row .who{color:var(--silver);white-space:nowrap}
+.lamp-row .who b{color:var(--cream)}
+.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.lamp-row.busy{pointer-events:none}
+.lamp-row .zap,.lamp-row .pen{display:none;color:var(--dim);border:0;background:transparent;font:inherit;font-size:.85rem;
+ cursor:pointer;border-radius:5px;padding:0 5px;flex:0 0 auto}
+.lamp-row:hover .zap,.lamp-row:hover .pen{display:inline-block}
+.lamp-row .zap:hover{color:var(--fail)}
+.lamp-row .pen:hover{color:var(--gold)}
+.lamp-row.armed-soft{background:rgba(218,181,61,.10)}
+.lamp-row.armed-soft .what{color:var(--gold)}
+.lamp-row.armed{background:rgba(203,107,77,.12)}
+.lamp-row.armed .what{color:var(--fail)}
+.lamp-row.armed .zap{display:inline-block;color:var(--fail)}
+
+.console-btns{display:flex;gap:8px;margin-top:2px}
+.c-btn{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11.5px;
+ background:linear-gradient(180deg,#23211e,#191715);color:var(--silver);
+ border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.c-btn:hover{color:var(--gold);border-color:var(--gold)}
+.c-btn:active{transform:translateY(1px)}
+.c-btn:disabled{opacity:.4;pointer-events:none}
+
+.meters{display:flex;gap:12px;margin-top:10px}
+.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;cursor:default;position:relative}
+.meter.testing{animation:flash 1s ease-in-out infinite;border-color:var(--gold)}
+@keyframes flash{50%{box-shadow:0 0 10px 1px rgba(218,181,61,.35)}}
+.meter.held{border-color:var(--gold);cursor:pointer}
+.meter .hold-tag{display:none;position:absolute;top:6px;right:8px;font-size:.56rem;letter-spacing:.2em;
+ color:var(--panel);background:var(--gold);border-radius:3px;padding:0 4px}
+.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)}
+.meter .mode-tag.test{color:var(--gold)}
+.meter .mode-tag.off{color:var(--dim)}
+.meter.held .hold-tag{display:block}
+.meter .dial{position:relative;height:52px;overflow:hidden;margin-top:13px}
+.meter .arc{position:absolute;inset:0 0 -52px 0;border:2px solid var(--wash);border-radius:50%}
+.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:10px;background:var(--steel);transform-origin:50% 52px}
+.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:44px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(-60deg);border-radius:2px;
+ box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .45s cubic-bezier(.3,1.3,.5,1)}
+.meter .needle.dead{background:var(--wash);box-shadow:none}
+.meter .needle.low{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)}
+.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)}
+.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums}
+.meter .m-value small{color:var(--dim);font-weight:400}
+.meter .m-value.low{color:var(--fail)}
+.meter .m-label{color:var(--steel);font-size:.62rem;letter-spacing:.26em;text-align:center;margin-top:2px;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.meter-note{color:var(--dim);font-size:10px;text-align:center;margin-top:5px;min-height:1.2em}
+
+.well{border:1px solid var(--wash);border-radius:10px;background:var(--well)}
+.outwrap{position:relative;margin-top:10px}
+.outwrap .output{margin-top:0}
+.o-clear{display:none;position:absolute;top:3px;right:5px;z-index:2;color:var(--dim);
+ border:0;background:var(--well);font:inherit;font-size:.8rem;cursor:pointer;
+ border-radius:50%;width:20px;height:20px;line-height:1}
+.o-clear:hover{color:var(--silver);background:var(--wash)}
+.outwrap.has .o-clear{display:block}
+.output{margin-top:10px;padding:8px 10px;max-height:170px;overflow-y:auto;font-size:11.5px}
+.output:empty{padding:4px 10px;min-height:10px}
+.o-step{display:flex;gap:8px;align-items:flex-start;padding:2.5px 0}
+.o-step .lamp{margin-top:4px;width:7px;height:7px}
+.o-step .t b{color:var(--cream);font-weight:700}
+.o-step .t .why{color:var(--dim);display:block;font-size:10.5px}
+.o-step .t .ev{color:var(--steel);display:block;font-size:11px}
+.o-step.repair .t b{color:var(--gold)}
+.o-line{padding:2px 0;color:var(--silver)}
+.o-line b{color:var(--steel);font-weight:400}
+.o-verdict{margin-top:5px;padding-top:5px;border-top:1px solid var(--wash);color:var(--gold);font-weight:700}
+.o-verdict.ok{color:var(--pass)}
+.o-tip{color:var(--dim);font-size:10.5px;margin-top:4px}
+
+.toast{margin-top:9px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;
+ padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em}
+.toast.show{opacity:1}
+.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)}
+
+.overlay{position:absolute;inset:0;background:rgba(10,12,13,.82);border-radius:14px;display:none;
+ align-items:center;justify-content:center;z-index:5}
+.overlay.show{display:flex}
+.dlg{background:var(--panel);border:1px solid var(--gold);border-radius:12px;padding:16px 18px;width:300px}
+.dlg h4{color:var(--cream);font-size:13px;margin-bottom:4px}
+.dlg .sub{color:var(--dim);font-size:11px;margin-bottom:10px}
+.dlg .passkey{color:var(--gold-hi);font-size:22px;font-weight:700;letter-spacing:.18em;
+ text-align:center;margin:6px 0 12px;font-variant-numeric:tabular-nums}
+.dlg input{width:100%;font:inherit;font-size:12.5px;color:var(--silver);background:var(--well);
+ border:1px solid var(--wash);border-radius:7px;padding:7px 9px;margin-bottom:8px;caret-color:var(--gold)}
+.dlg input:focus{outline:none;border-color:var(--gold)}
+.dlg .dlg-btns{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
+.btn{font:inherit;font-size:12px;cursor:pointer;background:var(--slate);color:var(--cream);
+ border:1px solid var(--gold);border-radius:8px;padding:5px 12px}
+.btn:hover{background:var(--slate-hi)}
+.btn.quiet{background:transparent;border-color:var(--wash);color:var(--silver)}
+.btn.quiet:hover{background:var(--wash)}
+
+*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent}
+::-webkit-scrollbar{width:6px;height:6px}
+::-webkit-scrollbar-track{background:transparent}
+::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px}
+::-webkit-scrollbar-thumb:hover{background:var(--slate-hi)}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div>
+ <h1>Network + Bluetooth — the pair</h1>
+ <p>Same faceplate, same idioms: lamp rows act on click, hover reveals ✎ rename and the
+ arm-to-fire ✕, gauges under the console keys (throughput needles on NET, battery fuel
+ gauges on BT), doctor streams and repairs in the output well. <b>Try the power switch
+ on BT·01</b> — everything follows it.</p>
+</header>
+
+<div class="stage">
+
+ <!-- ============================ NET·01 ============================ -->
+ <div class="slot">
+ <div class="slot-label">net·01 — as iterated</div>
+ <div class="panel" id="p">
+ <div class="overlay" id="ov">
+ <div class="dlg">
+ <h4 id="dlg-title">Join network</h4>
+ <div class="sub" id="dlg-sub">WPA2 — password required</div>
+ <input id="dlg-ssid" placeholder="SSID" style="display:none">
+ <input id="dlg-pass" type="password" placeholder="password">
+ <div class="dlg-btns">
+ <button class="btn quiet" onclick="dlgClose()">Cancel</button>
+ <button class="btn" id="dlg-go" onclick="dlgGo()">Join</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp" id="lamp"></span>
+ <span class="state-word" id="state">ONLINE</span>
+ <span class="badge" id="badge">TUNNEL</span>
+ <span class="badge" id="air-badge">AIRPLANE</span>
+ <span class="unit">NET·01</span>
+ <span class="switch on" id="n-power" onclick="wifiPower()" title="WiFi radio"></span>
+ <button class="x-btn" onclick="closePanel()" title="Close (Esc)">✕</button>
+ </div>
+ </div>
+
+ <div class="engrave">channel</div>
+ <div class="chan">
+ <div class="line1"><span class="ssid" id="ch-ssid">@Hyatt_WiFi</span>
+ <span class="ladder l3" id="ch-ladder"><i></i><i></i><i></i><i></i></span>
+ <span class="dim" style="font-size:11px;white-space:nowrap" id="ch-dbm">-59 dBm · 44 ms</span></div>
+ <div class="line2" id="ch-route">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div>
+ </div>
+
+ <div class="engrave">networks<span class="cnt" id="net-count"></span><span class="act" onclick="dlgHidden()" title="Join a hidden SSID">+ hidden</span></div>
+ <div id="networks" class="sec-scroll"></div>
+
+ <div class="engrave">tunnels<span class="cnt" id="tun-count"></span></div>
+ <div id="tunnels" class="sec-scroll"></div>
+
+ <div class="engrave">console</div>
+ <div class="console-btns">
+ <button class="c-btn" id="b-doctor" onclick="runDoctor()">DOCTOR</button>
+ <button class="c-btn" id="b-speed" onclick="runSpeed()">SPEED TEST</button>
+ </div>
+
+ <div class="meters">
+ <div class="meter" id="m-rx" onclick="release('rx')">
+ <span class="mode-tag" id="mt-rx">LIVE</span>
+ <span class="hold-tag">HOLD</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-rx"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-rx">0.1</span> <small>Mbps</small></div>
+ <div class="m-label">RX · DOWN</div>
+ </div>
+ <div class="meter" id="m-tx" onclick="release('tx')">
+ <span class="mode-tag" id="mt-tx">LIVE</span>
+ <span class="hold-tag">HOLD</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-tx"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-tx">0.1</span> <small>Mbps</small></div>
+ <div class="m-label">TX · UP</div>
+ </div>
+ </div>
+ <div class="meter-note" id="m-note"></div>
+
+ <div class="outwrap" id="outwrap">
+ <button class="o-clear" onclick="clearOut('out')" title="Dismiss results">✕</button>
+ <div class="well output" id="out"></div>
+ </div>
+ <div class="toast" id="toastEl"></div>
+ </div>
+ <button class="reopen" id="reopen" onclick="openPanel()">reopen NET·01 (bar click)</button>
+ </div>
+
+ <!-- ============================ BT·01 ============================ -->
+ <div class="slot">
+ <div class="slot-label">bt·01 — new, same idioms</div>
+ <div class="panel" id="bp">
+ <div class="overlay" id="bov">
+ <div class="dlg">
+ <h4 id="bdlg-title">Pair device</h4>
+ <div class="sub" id="bdlg-sub">confirm the passkey matches the device</div>
+ <div class="passkey" id="bdlg-key" style="display:none">847 291</div>
+ <input id="bdlg-name" placeholder="device name" style="display:none">
+ <div class="dlg-btns">
+ <button class="btn quiet" onclick="bdlgClose()">Cancel</button>
+ <button class="btn" id="bdlg-go" onclick="bdlgGo()">Confirm</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp" id="b-lamp"></span>
+ <span class="state-word" id="b-state">POWERED</span>
+ <span class="badge red" id="b-badge">LOW BATT</span>
+ <span class="badge" id="b-air-badge">AIRPLANE</span>
+ <span class="unit">BT·01</span>
+ <span class="switch on" id="b-power" onclick="btPower()" title="Adapter power"></span>
+ <button class="x-btn" onclick="closeBt()" title="Close (Esc)">✕</button>
+ </div>
+ </div>
+
+ <div class="engrave">adapter</div>
+ <div class="chan">
+ <div class="line1"><span class="ssid">intel ax211</span>
+ <span class="dim" style="font-size:11px;margin-left:auto">hci0</span></div>
+ <div class="line2" id="b-adapter-line">
+ <span class="chip" id="b-disco" onclick="btDisco()">discoverable off</span>
+ <span> · </span><span id="b-conn-count">1 device connected</span></div>
+ </div>
+
+ <div class="engrave">paired<span class="cnt" id="b-paired-count"></span></div>
+ <div id="b-paired" class="sec-scroll"></div>
+
+ <div class="engrave">nearby<span class="cnt" id="b-nearby-count"></span><span class="act" id="b-scan-note"></span></div>
+ <div id="b-nearby" class="sec-scroll"></div>
+
+ <div class="engrave">console</div>
+ <div class="console-btns">
+ <button class="c-btn" id="bb-doctor" onclick="btDoctor()">DOCTOR</button>
+ <button class="c-btn" id="bb-scan" onclick="btScan()">SCAN</button>
+ </div>
+
+ <div class="meters">
+ <div class="meter" id="bm-0">
+ <span class="mode-tag" id="bmt-0">LIVE</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="bn-0"></div><div class="hub"></div></div>
+ <div class="m-value" id="bvw-0"><span id="bv-0">72</span> <small>%</small></div>
+ <div class="m-label" id="bl-0">LOGI M650</div>
+ </div>
+ <div class="meter" id="bm-1">
+ <span class="mode-tag off" id="bmt-1">—</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle dead" id="bn-1"></div><div class="hub"></div></div>
+ <div class="m-value" id="bvw-1"><span id="bv-1">—</span></div>
+ <div class="m-label" id="bl-1">NO DEVICE</div>
+ </div>
+ </div>
+ <div class="meter-note">battery · connected devices</div>
+
+ <div class="outwrap" id="b-outwrap">
+ <button class="o-clear" onclick="clearOut('b-out')" title="Dismiss results">✕</button>
+ <div class="well output" id="b-out"></div>
+ </div>
+ <div class="toast" id="b-toastEl"></div>
+ </div>
+ <button class="reopen" id="b-reopen" onclick="openBt()">reopen BT·01 (bar click)</button>
+ </div>
+
+ <!-- ============================ NOTES ============================ -->
+ <div class="aside">
+ <h3>The bt mapping</h3>
+ <ul>
+ <li><b>Power switch on the faceplate</b> — flip it: devices drop, gauges die, keys disable, state goes OFF. Flip back: the mouse auto-reconnects. <em>(the switch-placement ask, in console form)</em></li>
+ <li><b>Battery fuel gauges</b> are BT's meters — one per connected device, needle at charge, red under 15% with a LOW BATT badge on the faceplate.</li>
+ <li><b>Paired rows toggle on click</b> (connect/disconnect), exactly like tunnels. Hover: ✎ rename <em>(the rename ask)</em>, ✕ arm-to-forget.</li>
+ <li><b>Nearby rows pair on click</b> — passkey confirm dialog, then the device moves up to PAIRED and connects. SCAN refreshes the neighborhood.</li>
+ <li><b>discoverable off</b> in the adapter line is a click-toggle (gold when on).</li>
+ <li><b>Disconnect is arm-first on the active row</b> — first click arms in gold ("disconnect? click again"), second fires. Gold, not terracotta: disruptive, not destructive.</li>
+ <li><b>NET·01 grew the wifi radio switch</b> (faceplate, same spot as BT's). Airplane mode is system-level: both switches drop, AIRPLANE badges light, and a switch flipped under airplane refuses with the way out. A plugged ethernet cable keeps NET·01 online through it.</li>
+ <li><b>DOCTOR does it all here too</b>: adapter → radio → service → powered → devices → audio profile. Tick the degraded-audio switch and run it: it finds HSP, flips to A2DP, verifies the sink followed.</li>
+ </ul>
+ <div class="demo-box">
+ <label><input type="checkbox" id="cafe" onchange="setScenario(this.checked)"> net: walk into a new café</label>
+ <label><input type="checkbox" id="breakdns"> net: broken hotel DNS (then DOCTOR)</label>
+ <label><input type="checkbox" id="ethercb" onchange="setEther(this.checked)"> net: plug in an ethernet cable</label>
+ <label><input type="checkbox" id="aircb" onchange="setAirplane(this.checked)"> both: airplane mode (Super+Shift+A)</label>
+ <label><input type="checkbox" id="airportcb" onchange="setAirport(this.checked)"> both: airport terminal (crowded airspace)</label>
+ <label><input type="checkbox" id="lowbatt" onchange="btLowBatt(this.checked)"> bt: mouse battery low</label>
+ <label><input type="checkbox" id="badaudio"> bt: degraded audio profile (then DOCTOR)</label>
+ <div class="hint">the audio one needs the headphones connected — click WH-1000XM4 first.</div>
+ <button class="reset" onclick="location.search=''">reset prototypes</button>
+ </div>
+ </div>
+</div>
+
+<script>
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const $ = id => document.getElementById(id);
+const T = f => reduced ? Math.max(10, f*0.02) : f;
+
+/* =========================================================== NET·01 */
+let busy = false;
+const HOTEL_NETS = () => [
+ {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:true, sig:3, active:true},
+ {id:'meeting', ssid:'Hyatt_Meeting', sec:'WPA2', stored:false, range:true, sig:3, active:false},
+ {id:'roku', ssid:'DIRECT-roku-882',sec:'WPA2',stored:false, range:true, sig:2, active:false},
+ {id:'xfinity', ssid:'xfinitywifi', sec:null, stored:false, range:true, sig:1, active:false},
+ {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+];
+const CAFE_NETS = () => [
+ {id:'cafe5g', ssid:'CafeAmore_5G', sec:'WPA2', stored:false, range:true, sig:4, active:false, ip:'10.11.4.27/22 · gw 10.11.4.1'},
+ {id:'cafeg', ssid:'CafeAmore_Guest',sec:null, stored:false, range:true, sig:3, active:false, ip:'10.11.8.102/22 · gw 10.11.8.1'},
+ {id:'iot', ssid:'Neighbor_IoT', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+ {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+];
+const AIRPORT_NETS = () => [
+ {id:'ord', ssid:'ORD Free Wi-Fi', sec:null, stored:false, range:true, sig:4, active:false, ip:'10.40.2.19/18 · gw 10.40.0.1'},
+ {id:'boingo',ssid:'Boingo Hotspot', sec:null, stored:false, range:true, sig:3, active:false},
+ {id:'united',ssid:'United_Club', sec:'WPA2', stored:false, range:true, sig:3, active:false},
+ {id:'sky', ssid:'SkyClub_5G', sec:'WPA2', stored:false, range:true, sig:3, active:false},
+ {id:'aa', ssid:'AmericanAir-Lounge',sec:'WPA2', stored:false, range:true, sig:2, active:false},
+ {id:'sbux', ssid:'Starbucks WiFi', sec:null, stored:false, range:true, sig:2, active:false},
+ {id:'tom', ssid:"Tom's iPhone", sec:'WPA2', stored:false, range:true, sig:2, active:false},
+ {id:'hp', ssid:'HP-Print-88-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'gate', ssid:'Gate B12 Display', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'clear', ssid:'CLEAR-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'tsa', ssid:'TSA-Ops', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'dfw', ssid:'ORD-Employee', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+ {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+];
+const AIRPORT_NEARBY = () => [
+ {id:'ap1', name:'AirPods Pro', kind:'audio', passkey:'118 402'},
+ {id:'ap2', name:'AirPods (3rd gen)', kind:'audio', passkey:'220 981'},
+ {id:'gb2', name:'Galaxy Buds2', kind:'audio', passkey:'914 555'},
+ {id:'pbp', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'},
+ {id:'xm5', name:'Sony WF-1000XM5', kind:'audio', passkey:'774 210'},
+ {id:'jab', name:'Jabra Elite 7', kind:'audio', passkey:'333 190'},
+ {id:'aw', name:'Apple Watch', kind:'wearable',passkey:'602 118'},
+ {id:'gar', name:'Garmin Fenix 8', kind:'wearable',passkey:'488 007'},
+ {id:'tile', name:'Tile Tracker', kind:'tracker', passkey:'150 129'},
+ {id:'jbl2', name:'JBL Charge 5', kind:'audio', passkey:'847 291'},
+ {id:'bose', name:'Bose QC Ultra', kind:'audio', passkey:'962 340'},
+ {id:'gate2',name:'[TV] Gate B12', kind:'display', passkey:'302 118'},
+];
+let NETS = HOTEL_NETS();
+const WG = (id, who) => ({id, who, upWhat:'10.2.0.2/32 · route owner',
+ downWhat:'wireguard (NM) · down', up:false, ownsRoute:true, dev:'wgpvpn'});
+const TUNNELS = [
+ {id:'ts', who:'tailscale · velox', upWhat:'100.127.238.103 · 4/6 peers', downWhat:'down',
+ up:true, ownsRoute:false, dev:'tailscale0'},
+ WG('usny','USNY'), WG('usdc','USDC'), WG('uscala','USCALA'), WG('uscasf','USCASF'),
+ WG('usgaat','USGAAT'), WG('szur1','switzerlan-zurich1'), WG('szur2','switzerlan-zurich2'),
+ {id:'proton', who:'Proton VPN CLI', upWhat:'', downWhat:'down', up:false, needsLogin:true},
+];
+const tinit = () => ({ts:true, usny:false, usdc:false, uscala:false, uscasf:false,
+ usgaat:false, szur1:false, szur2:false, proton:false});
+const tstate = tinit();
+let connected = true;
+let routeBase = '172.20.2.108/20 · gw 172.20.0.1';
+let ether = { present:false, routed:false,
+ ip:'172.20.7.44/20 · gw 172.20.0.1', dev:'enp3s0' };
+let armed = null, armTimer = null;
+let wifiOn = true, airplane = false;
+let armedDisc = null, armDiscTimer = null;
+let lastSsid = '@Hyatt_WiFi';
+
+function renderNets(){
+ const host = $('networks'); host.innerHTML = '';
+ if (ether.present){
+ const row = document.createElement('div');
+ row.className = 'lamp-row';
+ row.innerHTML = `<span class="${ether.routed?'lamp':'lamp gold'}" id="eth-lamp"></span>`+
+ `<span class="who">${ether.routed?'<b>'+ether.dev+'</b>':ether.dev}</span>`+
+ `<span class="what" id="eth-what">${ether.routed?'active · wired · 1.0 Gbps':'wired · standby'}</span>`;
+ row.onclick = () => toggleEther();
+ host.appendChild(row);
+ }
+ if (!wifiOn){
+ const note = document.createElement('div');
+ note.className = 'lamp-row';
+ note.style.cursor = 'default';
+ note.innerHTML = `<span class="lamp off"></span><span class="who dim">wifi radio off</span>`+
+ `<span class="what">${airplane ? 'airplane mode' : 'flip the switch to scan'}</span>`;
+ host.appendChild(note);
+ tips('networks');
+ return;
+ }
+ const inRange = NETS.filter(n => n.range).sort((a,b) => (b.active-a.active) || (b.sig-a.sig));
+ const out = NETS.filter(n => !n.range);
+ $('net-count').textContent = '· ' + inRange.length + ' in range';
+ for (const n of [...inRange, ...out]){
+ const row = document.createElement('div');
+ row.className = 'lamp-row' + (armed===n.id ? ' armed' : '')
+ + (armedDisc===n.id ? ' armed-soft' : '');
+ const lamp = n.active ? 'lamp' : (n.range ? 'lamp gold' : 'lamp off');
+ const what = armed===n.id ? 'forget? click ✕ again'
+ : armedDisc===n.id ? 'disconnect? click again'
+ : n.active ? (ether.present && ether.routed ? 'connected · standby · ' : 'active · ') + (n.sec || 'open')
+ : !n.range ? 'stored · out of range'
+ : (n.stored ? 'stored · ' : '') + (n.sec || 'open') + ' · ' + [null,'22%','44%','61%','78%'][n.sig];
+ row.innerHTML = `<span class="${lamp}"></span>`+
+ `<span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+
+ (n.range && !n.active ? `<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>` : '')+
+ `<span class="what" id="nw-${n.id}">${what}</span>`+
+ (n.stored ? `<button class="zap" title="Forget ${n.ssid}">✕</button>` : '');
+ if (n.stored) row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); armForget(n.id); };
+ row.onclick = n.active ? () => armDisconnect(n.id) : () => joinNet(n.id);
+ host.appendChild(row);
+ }
+ tips('networks');
+}
+function armDisconnect(id){
+ if (busy) return;
+ const n = NETS.find(x=>x.id===id);
+ if (armedDisc === id){ // second click: disconnect
+ clearTimeout(armDiscTimer); armedDisc = null;
+ busy = true;
+ const what = $('nw-'+id);
+ if (what) what.textContent = 'disconnecting…';
+ setTimeout(() => {
+ busy = false;
+ n.active = false; connected = false;
+ if (!(ether.present && ether.routed)) tstate.ts = false;
+ netFace();
+ renderTunnels(); renderNets();
+ toast('disconnected from ' + n.ssid);
+ }, T(1100));
+ return;
+ }
+ armedDisc = id; renderNets(); // first click: arm (gold — disruptive, not destructive)
+ clearTimeout(armDiscTimer);
+ armDiscTimer = setTimeout(() => { armedDisc = null; renderNets(); }, 3000);
+}
+function armForget(id){
+ const n = NETS.find(x=>x.id===id);
+ if (armed === id){
+ clearTimeout(armTimer); armed = null;
+ NETS.splice(NETS.indexOf(n), 1); renderNets();
+ toast(`${n.ssid} forgotten`);
+ return;
+ }
+ armed = id; renderNets();
+ clearTimeout(armTimer);
+ armTimer = setTimeout(() => { armed = null; renderNets(); }, 3000);
+}
+let joining = null;
+function joinNet(id){
+ if (busy) return;
+ if (!wifiOn){ toast('wifi radio is off', true); return; }
+ const n = NETS.find(x=>x.id===id);
+ if (!n.range){ toast(n.ssid + ' is out of range', true); return; }
+ if (n.sec && !n.stored){ joining = n; dlgJoin(n); return; }
+ doJoin(n);
+}
+function doJoin(n){
+ busy = true;
+ const what = $('nw-'+n.id);
+ if (what) what.textContent = 'joining…';
+ lampState('JOINING','gold');
+ setTimeout(() => {
+ NETS.forEach(x => x.active = false);
+ n.active = true; n.stored = true;
+ busy = false;
+ $('ch-ssid').textContent = n.ssid;
+ $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][n.sig] + ' · 41 ms';
+ $('ch-ladder').className = 'ladder l'+n.sig;
+ connected = true;
+ lastSsid = n.ssid;
+ routeBase = n.ip || '172.20.2.108/20 · gw 172.20.0.1';
+ netFace();
+ if (!tstate.ts) tstate.ts = true;
+ renderTunnels(); renderNets();
+ toast('joined ' + n.ssid + ' — saved for next time');
+ }, T(1600));
+}
+function dlgJoin(n){
+ $('dlg-title').textContent = 'Join ' + n.ssid;
+ $('dlg-sub').textContent = n.sec + ' — password required';
+ $('dlg-ssid').style.display = 'none';
+ $('dlg-pass').value = '';
+ $('ov').classList.add('show');
+ $('dlg-pass').focus();
+}
+function dlgHidden(){
+ joining = 'hidden';
+ $('dlg-title').textContent = 'Join hidden network';
+ $('dlg-sub').textContent = 'SSID is not broadcast — enter it exactly';
+ $('dlg-ssid').style.display = 'block'; $('dlg-ssid').value = '';
+ $('dlg-pass').value = '';
+ $('ov').classList.add('show');
+ $('dlg-ssid').focus();
+}
+function dlgClose(){ $('ov').classList.remove('show'); joining = null; }
+function dlgGo(){
+ if (joining === 'hidden'){
+ const ssid = $('dlg-ssid').value.trim() || 'hidden-net';
+ const n = {id:'h'+Date.now(), ssid, sec:'WPA2', stored:true, range:true, sig:2, active:false};
+ NETS.splice(0,0,n); renderNets(); dlgClose(); doJoin(n);
+ return;
+ }
+ const n = joining; dlgClose(); if (n) doJoin(n);
+}
+
+function renderTunnels(){
+ const host = $('tunnels'); host.innerHTML = '';
+ for (const t of TUNNELS){
+ const up = tstate[t.id];
+ const row = document.createElement('div');
+ row.className = 'lamp-row';
+ row.innerHTML = `<span class="${up?'lamp':'lamp off'}" id="tl-${t.id}"></span>`+
+ `<span class="who">${t.who}</span><span class="what" id="tw-${t.id}">${up?(t.upWhat||'up'):t.downWhat}</span>`;
+ row.onclick = () => toggleTunnel(t.id);
+ host.appendChild(row);
+ }
+ $('tun-count').textContent = '· ' + TUNNELS.filter(t=>tstate[t.id]).length + ' up of ' + TUNNELS.length;
+ tips('tunnels');
+ updateRoute();
+}
+function updateRoute(){
+ if (!connected && !(ether.present && ether.routed)){
+ $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off';
+ $('ch-ladder').style.display = 'none';
+ $('ch-dbm').textContent = '';
+ $('ch-route').textContent = !wifiOn ? (airplane ? 'airplane mode' : 'flip the radio switch to scan')
+ : 'join a network below';
+ $('badge').classList.remove('show');
+ return;
+ }
+ const owner = TUNNELS.find(t => t.ownsRoute && tstate[t.id]);
+ const wired = ether.present && ether.routed;
+ const base = wired ? ether.ip : routeBase;
+ const dev = wired ? ether.dev : 'wlp170s0';
+ $('ch-route').textContent = base + ' · route ' + (owner ? owner.dev + ' (tunnel)' : dev);
+ $('badge').classList.toggle('show', !!owner);
+ /* channel headline follows the routed link */
+ if (wired){
+ $('ch-ssid').textContent = ether.dev;
+ $('ch-ladder').style.display = 'none';
+ $('ch-dbm').textContent = 'wired · 1.0 Gbps full-duplex';
+ } else if (connected){
+ const act = NETS.find(n => n.active);
+ if (act){
+ $('ch-ssid').textContent = act.ssid;
+ $('ch-ladder').style.display = '';
+ $('ch-ladder').className = 'ladder l' + act.sig;
+ $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][act.sig] + ' · 44 ms';
+ }
+ } else {
+ $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off';
+ $('ch-ladder').style.display = 'none';
+ $('ch-dbm').textContent = '';
+ }
+}
+/* the faceplate state word, derived from one place */
+function netFace(){
+ const wired = ether.present && ether.routed;
+ $('air-badge').classList.toggle('show', airplane);
+ if (wired){ lampState('ONLINE'); return; }
+ if (connected){ lampState('ONLINE'); return; }
+ if (airplane){ lampState('AIRPLANE','gold'); return; }
+ if (!wifiOn){ lampState('OFF','off'); $('lamp').className='lamp off'; return; }
+ lampState('OFFLINE','red');
+}
+function toggleTunnel(id){
+ if (busy) return;
+ const t = TUNNELS.find(x=>x.id===id), up = tstate[id];
+ const lamp = $('tl-'+id), what = $('tw-'+id);
+ busy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = t.needsLogin && !up ? 'connecting…' : up ? 'bringing down…' : 'bringing up…';
+ setTimeout(() => {
+ busy = false;
+ if (t.needsLogin && !up){
+ lamp.className = 'lamp red';
+ what.textContent = 'sign in first: protonvpn login';
+ toast('Proton: no account signed in — run: protonvpn login', true);
+ setTimeout(() => { lamp.className = 'lamp off'; what.textContent = t.downWhat; }, 2600);
+ return;
+ }
+ tstate[id] = !up;
+ renderTunnels();
+ toast(tstate[id] ? `${t.who} up` + (t.ownsRoute ? ' — default route moved to '+t.dev : '')
+ : `${t.who} down` + (t.ownsRoute ? ' — route back on wlp170s0' : ''));
+ }, T(1500));
+}
+
+let toastTimer;
+function toast(msg, err){
+ const el = $('toastEl');
+ el.textContent = msg; el.className = 'toast show' + (err ? ' err' : '');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600);
+}
+function lampState(word, cls){
+ $('state').textContent = word;
+ $('lamp').className = 'lamp' + (cls && cls !== 'off' ? ' '+cls : cls === 'off' ? ' off' : '');
+}
+
+const held = {rx:false, tx:false};
+let testing = false, amb = 0;
+function meter(side, val){
+ const deg = -60 + Math.min(1, val/100) * 120;
+ $('n-'+side).style.transform = `rotate(${deg}deg)`;
+ $('v-'+side).textContent = val.toFixed(1);
+}
+function setMeterMode(side, mode){
+ const m = $('m-'+side), t = $('mt-'+side);
+ m.classList.toggle('testing', mode==='test');
+ m.classList.toggle('held', mode==='hold');
+ held[side] = mode==='hold';
+ t.textContent = mode==='live' ? 'LIVE' : 'TEST';
+ t.className = 'mode-tag' + (mode==='live' ? '' : ' test');
+}
+function release(side){
+ if (!held[side]) return;
+ setMeterMode(side, 'live');
+ if (!held.rx && !held.tx) $('m-note').textContent = '';
+}
+
+function runSpeed(){
+ if (busy) return;
+ busy = true; testing = true;
+ $('b-doctor').disabled = $('b-speed').disabled = true;
+ const out = $('out'); out.innerHTML = '';
+ setMeterMode('rx','test'); setMeterMode('tx','test');
+ $('m-note').textContent = 'measuring — needles follow the live rate';
+ const line = (k, v) => {
+ const el = document.createElement('div');
+ el.className = 'o-line'; el.innerHTML = `<b>${k}</b> ${v}`;
+ out.appendChild(el); out.scrollTop = out.scrollHeight;
+ };
+ setTimeout(() => line('location', 'Tulsa, OK (US) by Encore Communications'), T(900));
+ setTimeout(() => line('ping', '44.5 ms (jitter 2.1 ms)'), T(1800));
+ let dv = 0; const dT = 25.3;
+ setTimeout(() => {
+ const dTick = setInterval(() => {
+ dv = Math.min(dT, dv + 1.3 + Math.random()*2.1);
+ meter('rx', dv);
+ if (dv >= dT){
+ clearInterval(dTick);
+ setMeterMode('rx','hold'); meter('rx', dT);
+ let uv = 0; const uT = 90.8;
+ const uTick = setInterval(() => {
+ uv = Math.min(uT, uv + 4.5 + Math.random()*7);
+ meter('tx', uv);
+ if (uv >= uT){
+ clearInterval(uTick);
+ setMeterMode('tx','hold'); meter('tx', uT);
+ line('final', '25.3↓ 90.8↑ Mbps · 44.5 ms · loss 0.0%');
+ const tip = document.createElement('div');
+ tip.className = 'o-tip';
+ tip.textContent = 'tip: download well below upload — typical of a congested or shaped venue network. Try 5 GHz, move closer, or retest off-peak.';
+ out.appendChild(tip); out.scrollTop = out.scrollHeight;
+ $('m-note').textContent = 'result held — click a meter to go back to live';
+ testing = false; busy = false;
+ $('b-doctor').disabled = $('b-speed').disabled = false;
+ }
+ }, T(200));
+ }
+ }, T(185));
+ }, T(2100));
+}
+
+const CHECKS = [
+ {t:'Link', why:'is the adapter connected to a network (every later check rides the link)',
+ ev:'wlp170s0 connected (@Hyatt_WiFi)'},
+ {t:'DHCP / IPv4', why:'did the network lease us an IP address (nothing routes without one)',
+ ev:'172.20.2.108/20'},
+ {t:'Gateway', why:'does the router (first hop) answer a ping', ev:'172.20.0.1 [5 ms]'},
+ {t:'DNS config', why:'is a DNS resolver configured on the link', ev:'172.20.0.1'},
+ {t:'DNS resolution', why:'does a known hostname resolve (catches dead DNS and portal hijacks)',
+ ev:'names resolve (captive.apple.com) [48 ms]',
+ evBroken:'no resolution (portal may be stalling DNS)', canBreak:true},
+ {t:'Internet', why:'does an HTTP probe reach the open internet (the online/captive verdict)',
+ ev:'open internet (HTTP 204) [112 ms]',
+ evBroken:'link up but no clean internet (DNS or egress issue)', canBreak:true},
+];
+const REPAIR = {t:'repair: dns-test', why:'points DNS at 1.1.1.1, tests resolution, then reverts (tells a broken venue resolver from blocked egress)',
+ ev:'1.1.1.1 resolved captive.apple.com — the hotel resolver is broken, not the link'};
+const REPAIR2 = {t:'repair: dns-override', why:'sets 1.1.1.1 on the link until reconnect (works around the broken venue resolver)',
+ ev:'DNS set to 1.1.1.1 on wlp170s0'};
+
+function addStep(host, step, repair){
+ const el = document.createElement('div');
+ el.className = 'o-step' + (repair ? ' repair' : '');
+ el.innerHTML = `<span class="lamp busy"></span><span class="t"><b>${step.t}</b>`+
+ `<span class="why">${step.why}</span><span class="ev">…</span></span>`;
+ host.appendChild(el); host.scrollTop = host.scrollHeight;
+ return el;
+}
+function landStep(el, ev, cls){
+ el.querySelector('.lamp').className = 'lamp' + (cls ? ' '+cls : '');
+ el.querySelector('.ev').textContent = ev;
+ el.closest('.output').scrollTop = 1e6;
+}
+function verdict(host, text, ok){
+ const v = document.createElement('div');
+ v.className = 'o-verdict' + (ok ? ' ok' : '');
+ v.textContent = text;
+ host.appendChild(v); host.scrollTop = host.scrollHeight;
+}
+
+function runDoctor(){
+ if (busy) return;
+ busy = true;
+ $('b-doctor').disabled = $('b-speed').disabled = true;
+ const brokenDNS = $('breakdns').checked;
+ const out = $('out'); out.innerHTML = '';
+ lampState('CHECKING','gold');
+ const gap = T(620);
+ let i = 0;
+ const next = () => {
+ if (i >= CHECKS.length){ return brokenDNS ? repairPhase() : finish('Overall — everything checks out'); }
+ const c = CHECKS[i];
+ const el = addStep(out, c);
+ setTimeout(() => {
+ const broken = brokenDNS && c.canBreak;
+ landStep(el, broken ? c.evBroken : c.ev, broken ? 'red' : '');
+ i++; setTimeout(next, gap*0.35);
+ }, gap);
+ };
+ const repairPhase = () => {
+ lampState('FIXING','gold');
+ verdict(out, 'DNS not resolving — trying the lightest repair');
+ const r1 = addStep(out, REPAIR, true);
+ setTimeout(() => {
+ landStep(r1, REPAIR.ev);
+ const r2 = addStep(out, REPAIR2, true);
+ setTimeout(() => {
+ landStep(r2, REPAIR2.ev);
+ const re = addStep(out, {t:'re-check: Internet', why:'probe again through the repaired resolver', ev:''});
+ setTimeout(() => {
+ landStep(re, 'open internet (HTTP 204) [96 ms]');
+ $('breakdns').checked = false;
+ finish('fixed — back online after dns-override');
+ }, gap);
+ }, gap*1.2);
+ }, gap*1.4);
+ };
+ const finish = (text) => {
+ verdict(out, text, true);
+ lampState('ONLINE');
+ busy = false;
+ $('b-doctor').disabled = $('b-speed').disabled = false;
+ };
+ next();
+}
+
+function setEther(present){
+ if (busy) return;
+ ether.present = present;
+ if (present){
+ ether.routed = true; // cable wins the route by metric
+ lampState('ONLINE');
+ toast('link detected on ' + ether.dev + ' — route moved to wired');
+ } else {
+ ether.routed = false;
+ toast(connected ? 'cable unplugged — route back on wlp170s0'
+ : 'cable unplugged', !connected);
+ if (!connected) lampState('OFFLINE','red');
+ }
+ renderTunnels(); renderNets();
+}
+function toggleEther(){
+ if (busy) return;
+ const lamp = $('eth-lamp'), what = $('eth-what');
+ busy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = ether.routed ? 'standing by…' : 'taking the route…';
+ setTimeout(() => {
+ busy = false;
+ ether.routed = !ether.routed;
+ renderTunnels(); renderNets();
+ toast(ether.routed ? 'route moved to ' + ether.dev + ' (wired)'
+ : 'route back on wlp170s0 — ' + ether.dev + ' standing by');
+ }, T(1200));
+}
+
+function wifiPower(){
+ if (busy) return;
+ if (airplane){ toast('airplane mode is on — Super+Shift+A to leave it', true); return; }
+ setWifi(!wifiOn);
+}
+function setWifi(on, quiet){
+ wifiOn = on;
+ $('n-power').classList.toggle('on', on);
+ if (!on){
+ const act = NETS.find(n => n.active);
+ if (act){ lastSsid = act.ssid; act.active = false; }
+ connected = false;
+ if (!(ether.present && ether.routed)) tstate.ts = false;
+ netFace(); renderTunnels(); renderNets();
+ if (!quiet) toast('wifi radio off');
+ } else {
+ netFace(); renderTunnels(); renderNets();
+ if (!quiet) toast('wifi radio on — rejoining ' + lastSsid);
+ const n = NETS.find(x => x.ssid === lastSsid && x.range);
+ if (n) setTimeout(() => doJoin(n), T(700));
+ }
+}
+function setAirplane(on){
+ airplane = on;
+ if (on){
+ if (wifiOn) setWifi(false, true);
+ if (bpower) btPowerSet(false, true);
+ netFace();
+ $('b-air-badge').classList.add('show');
+ toast('airplane mode — all radios off');
+ } else {
+ $('b-air-badge').classList.remove('show');
+ setWifi(true, true);
+ btPowerSet(true, true);
+ netFace();
+ toast('airplane mode off — radios back up');
+ }
+}
+
+function setAirport(on){
+ if (busy || bbusy) return;
+ if (on && $('cafe').checked){ $('cafe').checked = false; }
+ NETS = on ? AIRPORT_NETS() : HOTEL_NETS();
+ NEARBY.length = 0;
+ NEARBY.push(...(on ? AIRPORT_NEARBY()
+ : [{id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'},
+ {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'}]));
+ if (on){
+ tstate.ts = false; connected = false;
+ lampState('OFFLINE','red');
+ toast('ORD concourse B — pick a network');
+ } else {
+ tstate.ts = true; connected = true;
+ routeBase = '172.20.2.108/20 · gw 172.20.0.1';
+ NETS.find(n=>n.id==='hyatt').active = true;
+ lampState('ONLINE');
+ }
+ renderTunnels(); renderNets(); renderBt();
+}
+
+function setScenario(cafe){
+ if (busy) return;
+ if (cafe && $('airportcb') && $('airportcb').checked){ $('airportcb').checked = false; }
+ NETS = cafe ? CAFE_NETS() : HOTEL_NETS();
+ if (cafe){
+ tstate.ts = false; connected = false;
+ $('ch-ssid').textContent = '— not connected';
+ $('ch-dbm').textContent = '';
+ $('ch-ladder').className = 'ladder';
+ lampState('OFFLINE','red');
+ } else {
+ tstate.ts = true; connected = true;
+ routeBase = '172.20.2.108/20 · gw 172.20.0.1';
+ $('ch-ssid').textContent = '@Hyatt_WiFi';
+ $('ch-dbm').textContent = '-59 dBm · 44 ms';
+ $('ch-ladder').className = 'ladder l3';
+ lampState('ONLINE');
+ }
+ renderTunnels(); renderNets();
+}
+
+function closePanel(){ $('p').classList.add('closed'); $('reopen').style.display='inline-block'; }
+function openPanel(){ $('p').classList.remove('closed'); $('reopen').style.display='none'; }
+
+/* =========================================================== BT·01 */
+let bbusy = false, bpower = true, bdisco = false, blow = false;
+const DEVS = [
+ {id:'m650', name:'Logi M650', kind:'mouse', paired:true, conn:true, batt:72, audio:false},
+ {id:'xm4', name:'WH-1000XM4', kind:'audio', paired:true, conn:false, batt:58, audio:true},
+ {id:'k3', name:'Keychron K3', kind:'keyboard', paired:true, conn:false, batt:34, audio:false},
+];
+const NEARBY = [
+ {id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'},
+ {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'},
+];
+let barmed = null, barmTimer = null;
+
+function btConnected(){ return DEVS.filter(d => d.conn); }
+
+function renderBt(){
+ /* paired rows */
+ const host = $('b-paired'); host.innerHTML = '';
+ for (const d of DEVS){
+ const row = document.createElement('div');
+ row.className = 'lamp-row' + (barmed===d.id ? ' armed' : '');
+ const lamp = !bpower ? 'lamp off' : d.conn ? 'lamp' : 'lamp off';
+ const battTxt = d.batt !== null && d.conn ? ` · battery ${d.batt}%` : '';
+ const what = barmed===d.id ? 'forget? click ✕ again'
+ : !bpower ? 'adapter off'
+ : d.conn ? d.kind + battTxt : d.kind + ' · not connected';
+ row.innerHTML = `<span class="${lamp}" id="bl-${d.id}"></span>`+
+ `<span class="who">${d.conn && bpower ? '<b>'+d.name+'</b>' : d.name}</span>`+
+ `<span class="what" id="bw-${d.id}">${what}</span>`+
+ `<button class="pen" title="Rename ${d.name}">✎</button>`+
+ `<button class="zap" title="Forget ${d.name}">✕</button>`;
+ row.querySelector('.pen').onclick = (e) => { e.stopPropagation(); bdlgRename(d.id); };
+ row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); btArmForget(d.id); };
+ if (bpower) row.onclick = () => btToggleDev(d.id);
+ host.appendChild(row);
+ }
+ /* nearby rows */
+ const nb = $('b-nearby'); nb.innerHTML = '';
+ for (const n of NEARBY){
+ const row = document.createElement('div');
+ row.className = 'lamp-row';
+ row.innerHTML = `<span class="${bpower?'lamp gold':'lamp off'}" id="bnl-${n.id}"></span>`+
+ `<span class="who">${n.name}</span><span class="what" id="bnw-${n.id}">${bpower ? n.kind+' · pairable' : 'adapter off'}</span>`;
+ if (bpower) row.onclick = () => btPair(n.id);
+ nb.appendChild(row);
+ }
+ /* adapter line + faceplate */
+ const c = btConnected().length;
+ $('b-conn-count').textContent = bpower ? (c === 1 ? '1 device connected' : c + ' devices connected') : 'adapter off';
+ $('b-paired-count').textContent = '· ' + DEVS.length;
+ $('b-nearby-count').textContent = '· ' + NEARBY.length;
+ $('b-disco').textContent = 'discoverable ' + (bdisco && bpower ? 'on' : 'off');
+ $('b-disco').className = 'chip' + (bdisco && bpower ? ' on' : '');
+ renderGauges();
+ updateBtBadge();
+ tips('b-paired'); tips('b-nearby');
+ for (const el of document.querySelectorAll('#bp .m-label')) el.title = el.textContent;
+}
+
+function renderGauges(){
+ const conns = btConnected();
+ for (let i = 0; i < 2; i++){
+ const d = bpower ? conns[i] : null;
+ const needle = $('bn-'+i), val = $('bv-'+i), label = $('bl-'+i),
+ tag = $('bmt-'+i), wrap = $('bvw-'+i);
+ if (!d){
+ needle.className = 'needle dead';
+ needle.style.transform = 'rotate(-60deg)';
+ val.textContent = '—'; wrap.className = 'm-value';
+ label.textContent = bpower ? 'NO DEVICE' : 'ADAPTER OFF';
+ tag.textContent = '—'; tag.className = 'mode-tag off';
+ continue;
+ }
+ const low = d.batt < 15;
+ needle.className = 'needle' + (low ? ' low' : '');
+ needle.style.transform = `rotate(${-60 + (d.batt/100)*120}deg)`;
+ val.innerHTML = d.batt; wrap.className = 'm-value' + (low ? ' low' : '');
+ wrap.innerHTML = `<span id="bv-${i}">${d.batt}</span> <small>%</small>`;
+ label.textContent = d.name.toUpperCase();
+ tag.textContent = 'LIVE'; tag.className = 'mode-tag';
+ }
+}
+
+function updateBtBadge(){
+ const low = bpower && btConnected().some(d => d.batt < 15);
+ $('b-badge').classList.toggle('show', low);
+}
+
+function btToggleDev(id){
+ if (bbusy || !bpower) return;
+ const d = DEVS.find(x=>x.id===id);
+ const lamp = $('bl-'+id), what = $('bw-'+id);
+ bbusy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = d.conn ? 'disconnecting…' : 'connecting…';
+ setTimeout(() => {
+ bbusy = false;
+ d.conn = !d.conn;
+ renderBt();
+ btToast(d.conn ? `${d.name} connected` : `${d.name} disconnected`);
+ }, T(1300));
+}
+
+function btArmForget(id){
+ const d = DEVS.find(x=>x.id===id);
+ if (barmed === id){
+ clearTimeout(barmTimer); barmed = null;
+ DEVS.splice(DEVS.indexOf(d), 1); renderBt();
+ btToast(`${d.name} forgotten`);
+ return;
+ }
+ barmed = id; renderBt();
+ clearTimeout(barmTimer);
+ barmTimer = setTimeout(() => { barmed = null; renderBt(); }, 3000);
+}
+
+/* pairing + rename dialogs */
+let bdlgMode = null, bdlgTarget = null;
+function btPair(id){
+ if (bbusy) return;
+ const n = NEARBY.find(x=>x.id===id);
+ const lamp = $('bnl-'+id), what = $('bnw-'+id);
+ bbusy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = 'pairing…';
+ setTimeout(() => {
+ bdlgMode = 'pair'; bdlgTarget = n;
+ $('bdlg-title').textContent = 'Pair ' + n.name;
+ $('bdlg-sub').textContent = 'confirm this passkey shows on the device';
+ $('bdlg-key').style.display = 'block';
+ $('bdlg-key').textContent = n.passkey;
+ $('bdlg-name').style.display = 'none';
+ $('bdlg-go').textContent = 'Confirm';
+ $('bov').classList.add('show');
+ }, T(1200));
+}
+function bdlgRename(id){
+ const d = DEVS.find(x=>x.id===id);
+ bdlgMode = 'rename'; bdlgTarget = d;
+ $('bdlg-title').textContent = 'Rename device';
+ $('bdlg-sub').textContent = 'the alias lives on this machine (bluez set-alias)';
+ $('bdlg-key').style.display = 'none';
+ $('bdlg-name').style.display = 'block';
+ $('bdlg-name').value = d.name;
+ $('bdlg-go').textContent = 'Rename';
+ $('bov').classList.add('show');
+ $('bdlg-name').focus();
+}
+function bdlgClose(){
+ $('bov').classList.remove('show');
+ if (bdlgMode === 'pair'){ bbusy = false; renderBt(); btToast('pairing cancelled'); }
+ bdlgMode = null; bdlgTarget = null;
+}
+function bdlgGo(){
+ if (bdlgMode === 'pair'){
+ const n = bdlgTarget;
+ $('bov').classList.remove('show');
+ const what = $('bnw-'+n.id);
+ if (what) what.textContent = 'connecting…';
+ setTimeout(() => {
+ bbusy = false;
+ NEARBY.splice(NEARBY.indexOf(n), 1);
+ DEVS.push({id:n.id, name:n.name, kind:n.kind, paired:true, conn:true,
+ batt:n.kind==='audio' ? 91 : null, audio:n.kind==='audio'});
+ renderBt();
+ btToast(`${n.name} paired and connected`);
+ }, T(1100));
+ } else if (bdlgMode === 'rename'){
+ const d = bdlgTarget;
+ const name = $('bdlg-name').value.trim() || d.name;
+ d.name = name;
+ $('bov').classList.remove('show');
+ renderBt();
+ btToast(`renamed to ${name}`);
+ }
+ bdlgMode = null; bdlgTarget = null;
+}
+
+/* scan */
+const MORE_NEARBY = [
+ {id:'buds', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'},
+];
+function btScan(){
+ if (bbusy || !bpower) return;
+ $('b-scan-note').textContent = 'scanning…';
+ $('bb-scan').disabled = true;
+ setTimeout(() => {
+ if (MORE_NEARBY.length){
+ NEARBY.push(MORE_NEARBY.shift());
+ renderBt();
+ }
+ $('b-scan-note').textContent = '';
+ $('bb-scan').disabled = false;
+ btToast('scan complete — ' + NEARBY.length + ' nearby');
+ }, T(2200));
+}
+
+/* discoverable + power */
+function btDisco(){
+ if (!bpower) return;
+ bdisco = !bdisco;
+ renderBt();
+ btToast(bdisco ? 'discoverable for 2 minutes' : 'discoverable off');
+}
+function btPower(){
+ if (bbusy) return;
+ if (airplane){ btToast('airplane mode is on — Super+Shift+A to leave it', true); return; }
+ btPowerSet(!bpower);
+}
+function btPowerSet(on, quiet){
+ if (bbusy) return;
+ bpower = on;
+ $('b-power').classList.toggle('on', bpower);
+ if (!bpower){
+ bdisco = false;
+ DEVS.forEach(d => d._wasConn = d.conn);
+ DEVS.forEach(d => d.conn = false);
+ $('b-state').textContent = airplane ? 'AIRPLANE' : 'OFF';
+ $('b-lamp').className = airplane ? 'lamp gold' : 'lamp off';
+ $('bb-doctor').disabled = $('bb-scan').disabled = true;
+ renderBt();
+ if (!quiet) btToast('adapter powered off');
+ } else {
+ $('b-state').textContent = 'POWERED';
+ $('b-lamp').className = 'lamp';
+ $('bb-doctor').disabled = $('bb-scan').disabled = false;
+ renderBt();
+ if (quiet === true) { /* airplane restore: quiet */ }
+ /* the mouse auto-reconnects, like real life */
+ const mouse = DEVS.find(d => d._wasConn);
+ if (mouse){
+ setTimeout(() => {
+ const lamp = $('bl-'+mouse.id), what = $('bw-'+mouse.id);
+ if (lamp){ lamp.className = 'lamp busy'; what.textContent = 'reconnecting…'; }
+ setTimeout(() => { mouse.conn = true; renderBt(); btToast(mouse.name + ' reconnected'); }, T(1200));
+ }, T(600));
+ }
+ }
+}
+
+let btoastTimer;
+function btToast(msg, err){
+ const el = $('b-toastEl');
+ el.textContent = msg; el.className = 'toast show' + (err ? ' err' : '');
+ clearTimeout(btoastTimer);
+ btoastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600);
+}
+
+/* bt doctor — the real chain: adapter → radio → service → powered → devices → audio */
+function btChecks(){
+ const badAudio = $('badaudio').checked && DEVS.some(d => d.audio && d.conn);
+ return [
+ {t:'Adapter', why:'is a bluetooth adapter visible to the stack', ev:'intel ax211 (hci0)'},
+ {t:'Radio', why:'rfkill can block the radio in software or hardware', ev:'unblocked'},
+ {t:'Service', why:'is the bluez daemon running', ev:'bluetooth.service active'},
+ {t:'Powered', why:'radio on and ready to connect', ev:'powered on'},
+ {t:'Devices', why:'are paired devices reachable',
+ ev: btConnected().length ? btConnected().map(d=>d.name).join(', ') + ' connected' : 'none connected'},
+ {t:'Audio profile', why:'is a connected audio device on the high-quality profile (A2DP)',
+ ev: DEVS.some(d=>d.audio && d.conn) ? 'a2dp-sink' : 'no audio device connected',
+ evBroken:'stuck on headset-head-unit (HSP) — phone-call-grade audio', canBreak:badAudio},
+ ];
+}
+const BT_REPAIR = {t:'repair: a2dp-switch', why:'flips the card profile to A2DP and verifies the sink followed',
+ ev:'card profile set to a2dp-sink — sink followed'};
+
+function btDoctor(){
+ if (bbusy || !bpower) return;
+ bbusy = true;
+ $('bb-doctor').disabled = $('bb-scan').disabled = true;
+ const checks = btChecks();
+ const willRepair = checks.some(c => c.canBreak);
+ const out = $('b-out'); out.innerHTML = '';
+ $('b-state').textContent = 'CHECKING'; $('b-lamp').className = 'lamp gold';
+ const gap = T(620);
+ let i = 0;
+ const next = () => {
+ if (i >= checks.length){ return willRepair ? repairPhase() : finish('Overall — everything checks out'); }
+ const c = checks[i];
+ const el = addStep(out, c);
+ setTimeout(() => {
+ landStep(el, c.canBreak ? c.evBroken : c.ev, c.canBreak ? 'red' : '');
+ i++; setTimeout(next, gap*0.35);
+ }, gap);
+ };
+ const repairPhase = () => {
+ $('b-state').textContent = 'FIXING';
+ verdict(out, 'audio degraded — trying the lightest repair');
+ const r = addStep(out, BT_REPAIR, true);
+ setTimeout(() => {
+ landStep(r, BT_REPAIR.ev);
+ const re = addStep(out, {t:'re-check: Audio profile', why:'read the sink profile again after the switch', ev:''});
+ setTimeout(() => {
+ landStep(re, 'a2dp-sink [verified]');
+ $('badaudio').checked = false;
+ finish('fixed — high-quality audio restored');
+ }, gap);
+ }, gap*1.4);
+ };
+ const finish = (text) => {
+ verdict(out, text, true);
+ $('b-state').textContent = 'POWERED'; $('b-lamp').className = 'lamp';
+ bbusy = false;
+ $('bb-doctor').disabled = $('bb-scan').disabled = false;
+ };
+ next();
+}
+
+function btLowBatt(low){
+ blow = low;
+ const m = DEVS.find(d => d.id === 'm650');
+ if (m) m.batt = low ? 9 : 72;
+ renderBt();
+ if (low) btToast('Logi M650 battery low (9%)', true);
+}
+
+function closeBt(){ $('bp').classList.add('closed'); $('b-reopen').style.display='inline-block'; }
+function openBt(){ $('bp').classList.remove('closed'); $('b-reopen').style.display='none'; }
+
+/* shared ambience + keys */
+if (!reduced) setInterval(() => {
+ amb++;
+ if (!testing){
+ if (!held.rx) meter('rx', 0.1 + Math.abs(Math.sin(amb/3))*0.35);
+ if (!held.tx) meter('tx', 0.08 + Math.abs(Math.cos(amb/4))*0.25);
+ }
+}, 900);
+document.addEventListener('keydown', e => {
+ if (e.key === 'Escape'){
+ if ($('ov').classList.contains('show')) return dlgClose();
+ if ($('bov').classList.contains('show')) return bdlgClose();
+ closePanel(); closeBt();
+ }
+});
+
+function clearOut(id){ $(id).innerHTML = ''; }
+function tips(hostId){
+ for (const el of $(hostId).querySelectorAll('.what,.who,.m-label'))
+ el.title = el.textContent;
+}
+[['out','outwrap'],['b-out','b-outwrap']].forEach(([oid, wid]) => {
+ new MutationObserver(() => {
+ $(wid).classList.toggle('has', $(oid).childElementCount > 0);
+ }).observe($(oid), {childList: true});
+});
+
+renderNets(); renderTunnels(); renderBt();
+
+/* headless hooks */
+const auto = new URLSearchParams(location.search).get('auto');
+if (auto === 'btdoctor') btDoctor();
+if (auto === 'btdoctorfix'){
+ const xm4 = DEVS.find(d=>d.id==='xm4'); xm4.conn = true; renderBt();
+ $('badaudio').checked = true; btDoctor();
+}
+if (auto === 'btpair'){ btPair('jbl'); setTimeout(() => bdlgGo(), T(1800)); }
+if (auto === 'btpower'){ btPower(); }
+if (auto === 'btpoweron'){ btPower(); setTimeout(() => btPower(), T(600)); }
+if (auto === 'btlow'){ $('lowbatt').checked = true; btLowBatt(true); }
+if (auto === 'speed') runSpeed();
+if (auto === 'ether'){ $('ethercb').checked = true; setEther(true); }
+if (auto === 'air'){ $('aircb').checked = true; setAirplane(true); }
+if (auto === 'airether'){ $('ethercb').checked = true; setEther(true); $('aircb').checked = true; setAirplane(true); }
+if (auto === 'disc'){ armDisconnect('hyatt'); setTimeout(() => armDisconnect('hyatt'), T(600)); }
+if (auto === 'wifioff'){ setWifi(false); }
+if (auto === 'airport'){ $('airportcb').checked = true; setAirport(true); }
+if (auto === 'doctorfix'){ $('breakdns').checked = true; runDoctor(); }
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html
new file mode 100644
index 0000000..3329cdb
--- /dev/null
+++ b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html
@@ -0,0 +1,251 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Net panel — rescan affordance</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 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.masthead{max-width:1200px;margin:0 auto 1.8rem}
+.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:82ch}
+.masthead p b{color:var(--silver)}
+
+.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1200px;margin:0 auto;align-items:flex-start}
+.slot{width:400px}
+.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem}
+.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px}
+
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;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,.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}}
+
+.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px}
+.b-id{display:flex;align-items:center;gap:9px}
+.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em}
+.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto}
+
+.chan{margin-top:12px}
+.chan .line1{display:flex;align-items:baseline;gap:9px}
+.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px}
+.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px}
+.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px}
+.ladder i{width:4px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px}
+.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px}
+.ladder.l1 i:nth-child(-n+1){background:var(--gold)}.ladder.l2 i:nth-child(-n+2){background:var(--gold)}
+.ladder.l3 i:nth-child(-n+3){background:var(--gold)}.ladder.l4 i{background:var(--gold)}
+
+/* engrave header with the rescan action */
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.24em;text-transform:uppercase;
+ display:flex;align-items:center;gap:8px;margin:14px 0 6px}
+.engrave::before{content:"";height:1px;background:var(--wash);width:10px;flex:0 0 auto}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;cursor:pointer;
+ border-bottom:1px dotted transparent}
+.engrave .cnt:hover{color:var(--gold);border-bottom-color:var(--wash)}
+.engrave .cnt.scanning{color:var(--gold);animation:breathe 1.1s ease-in-out infinite;cursor:default;border-bottom-color:transparent}
+.engrave .spacer{flex:1;height:1px;background:var(--wash)}
+/* compact rescan glyph — sits right after the count, spins while scanning */
+.engrave .ricon{color:var(--dim);cursor:pointer;font-size:.82rem;display:inline-flex;line-height:1}
+.engrave .ricon:hover{color:var(--gold)}
+.engrave .ricon.spin{color:var(--gold);cursor:default;animation:spin .9s linear infinite}
+.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer}
+.engrave .act:hover{color:var(--gold)}
+@keyframes spin{to{transform:rotate(360deg)}}
+@keyframes breathe{0%,100%{opacity:1}50%{opacity:.35}}
+
+/* the list; section-breathe busy style pulses the whole well */
+#networks{border-radius:8px;transition:background .3s}
+#networks.breathe{animation:sectionbreathe 1.4s ease-in-out infinite}
+@keyframes sectionbreathe{0%,100%{background:transparent}50%{background:rgba(218,181,61,.06)}}
+
+.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer}
+.lamp-row:hover{background:var(--wash)}
+.lamp-row .who{color:var(--silver);white-space:nowrap}
+.lamp-row .who b{color:var(--cream)}
+.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.lamp-row.fresh{animation:fadein .5s ease}
+@keyframes fadein{from{opacity:0;transform:translateY(-3px);background:rgba(218,181,61,.12)}to{opacity:1}}
+
+.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;
+ padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em}
+.toast.show{opacity:1}
+
+.aside{flex:1 1 320px;min-width:290px}
+.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem}
+.aside h3:first-child{margin-top:.2rem}
+.aside ul{list-style:none}
+.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative}
+.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.aside li b{color:var(--cream);font-weight:700}
+.aside li em{color:var(--dim);font-style:normal}
+.demo{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:1rem}
+.demo .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.2em;text-transform:uppercase;margin-bottom:.5rem}
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;margin-bottom:.7rem}
+.seg button{flex:1;font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 6px;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}
+.demo .go{font:inherit;font-size:.8rem;color:var(--silver);background:transparent;border:1px solid var(--gold);
+ border-radius:8px;padding:.45rem 1rem;cursor:pointer}
+.demo .go:hover{background:rgba(218,181,61,.12)}
+.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem}
+.rec b{color:var(--gold)}
+@media (prefers-reduced-motion:reduce){*{animation:none!important}}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · net·01</div>
+ <h1>Networks — the rescan affordance</h1>
+ <p>Where does a WiFi rescan live, and how does it show it's working? The count "· N in range" is
+ really a live status field — the natural home for "scanning…". My lean: an explicit <b>⟳ rescan</b>
+ action in the engrave line (discoverable, same slot as "+ hidden"), with a <b>flash-and-fade</b>
+ busy state — the glyph spins, the count breathes, new rows fade in as found. The count is also
+ click-to-rescan as a shortcut. Use the selector on the right to feel each busy treatment.</p>
+</header>
+
+<div class="stage">
+ <div class="slot">
+ <div class="slot-label">net·01 — networks section</div>
+ <div class="panel">
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp"></span>
+ <span class="state-word">ONLINE</span>
+ <span class="unit">NET·01</span>
+ </div>
+ </div>
+
+ <div class="engrave">channel</div>
+ <div class="chan">
+ <div class="line1"><span class="ssid">@Hyatt_WiFi</span>
+ <span class="ladder l3"><i></i><i></i><i></i><i></i></span>
+ <span class="dim" style="font-size:11px">-59 dBm · 44 ms</span></div>
+ <div class="line2">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div>
+ </div>
+
+ <div class="engrave">networks<span class="cnt" id="cnt" onclick="rescan()">· 5 in range</span>
+ <span class="ricon" id="rescan" onclick="rescan()" title="Rescan for networks">⟳</span>
+ <span class="spacer"></span>
+ <span class="act" onclick="toast('would open the hidden-SSID dialog')">+ hidden</span>
+ </div>
+ <div id="networks"></div>
+
+ <div class="toast" id="toast"></div>
+ </div>
+ </div>
+
+ <div class="aside">
+ <div class="demo">
+ <div class="lbl">busy feedback style</div>
+ <div class="seg" id="styleSeg">
+ <button class="on" data-s="all">all (rec)</button>
+ <button data-s="spin">spinner</button>
+ <button data-s="count">count</button>
+ <button data-s="section">section</button>
+ </div>
+ <button class="go" onclick="rescan()">▶ run a rescan</button>
+ </div>
+
+ <h3>The three busy signals</h3>
+ <ul>
+ <li><b>Spinner</b> — the ⟳ glyph rotates while scanning. The clearest "working" cue; universal.</li>
+ <li><b>Count breathe</b> — "· 5 in range" becomes "scanning…" and slow-pulses. Your idea: the status field animates in place, no extra chrome.</li>
+ <li><b>Section breathe</b> — the whole list gives a faint gold breath while the scan runs; found rows fade in gold. Ambient, ties the animation to what's changing.</li>
+ <li><b>All (recommended)</b> — spinner + count + fade-in together. The section breathe is optional; it can read as busy on a small panel, so it's off in "all" by default and its own option to try.</li>
+ </ul>
+
+ <h3>Why an explicit action, not only the count</h3>
+ <div class="rec">
+ Overloading the count as the sole trigger is elegant but a first look doesn't know it's clickable.
+ An explicit <b>⟳ rescan</b> in the engrave line is discoverable, sits in the same slot as "+ hidden"
+ (consistent), and doesn't cost a heavy console key. Keeping the count clickable too gives power users
+ the shortcut without hiding the affordance. A dedicated <b>RESCAN console key</b> (next to DOCTOR /
+ SPEED TEST) is the third option — heavier, and rescan is a list action, not a diagnostic, so it fits
+ the engrave line better.
+ </div>
+ </div>
+</div>
+
+<script>
+const $=id=>document.getElementById(id);
+let busy=false, style='all';
+let NETS=[
+ {ssid:'@Hyatt_WiFi', sig:3, sec:'WPA2', stored:true, active:true},
+ {ssid:'Hyatt_Meeting', sig:3, sec:'WPA2', stored:false, active:false},
+ {ssid:'DIRECT-roku-882',sig:2, sec:'WPA2', stored:false, active:false},
+ {ssid:'xfinitywifi', sig:1, sec:null, stored:false, active:false},
+ {ssid:'HomeNet', sig:0, sec:'WPA2', stored:true, active:false, oor:true},
+];
+const NEWFOUND=[
+ {ssid:'Hyatt_Guest', sig:2, sec:null, stored:false, active:false},
+ {ssid:'Marriott_CONF', sig:1, sec:'WPA2', stored:false, active:false},
+];
+const pctFor=[null,'22%','44%','61%','78%'];
+
+$('styleSeg').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
+ style=b.dataset.s;[...$('styleSeg').children].forEach(x=>x.classList.toggle('on',x===b));});
+
+function rowEl(n,fresh){
+ const r=document.createElement('div'); r.className='lamp-row'+(fresh?' fresh':'');
+ const lamp=n.active?'lamp':(n.oor?'lamp off':'lamp gold');
+ const what=n.active?'active · '+(n.sec||'open')
+ :n.oor?'stored · out of range'
+ :(n.stored?'stored · ':'')+(n.sec||'open')+' · '+pctFor[n.sig];
+ r.innerHTML=`<span class="${lamp}"></span><span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+
+ (!n.active&&!n.oor?`<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>`:'')+
+ `<span class="what">${what}</span>`;
+ r.onclick=()=>{ if(busy)return; toast(n.active?'already on '+n.ssid:'would join '+n.ssid); };
+ return r;
+}
+function render(freshSet){
+ const host=$('networks'); host.innerHTML='';
+ const inRange=NETS.filter(n=>!n.oor).sort((a,b)=>(b.active-a.active)||(b.sig-a.sig));
+ const oor=NETS.filter(n=>n.oor);
+ [...inRange,...oor].forEach(n=>host.appendChild(rowEl(n,freshSet&&freshSet.has(n.ssid))));
+ if(!busy) $('cnt').textContent='· '+inRange.length+' in range';
+}
+function rescan(){
+ if(busy) return; busy=true;
+ const useSpin = style==='all'||style==='spin';
+ const useCount= style==='all'||style==='count';
+ const useSec = style==='section';
+ if(useSpin) $('rescan').classList.add('spin');
+ if(useCount){ $('cnt').classList.add('scanning'); $('cnt').textContent='scanning…'; }
+ if(useSec) $('networks').classList.add('breathe');
+ toast('scanning for networks…');
+ // networks trickle in as "found"
+ const fresh=new Set();
+ setTimeout(()=>{ NETS.splice(1,0,NEWFOUND[0]); fresh.add(NEWFOUND[0].ssid); render(fresh); },900);
+ setTimeout(()=>{ NETS.splice(3,0,NEWFOUND[1]); fresh.add(NEWFOUND[1].ssid); render(fresh); },1700);
+ setTimeout(()=>{
+ busy=false;
+ $('rescan').classList.remove('spin');
+ $('cnt').classList.remove('scanning');
+ $('networks').classList.remove('breathe');
+ const n=NETS.filter(x=>!x.oor).length;
+ $('cnt').textContent='· '+n+' in range';
+ toast('scan complete — '+n+' networks in range');
+ // reset for the next run so the demo is repeatable
+ NETS=NETS.filter(x=>x.ssid!=='Hyatt_Guest'&&x.ssid!=='Marriott_CONF');
+ },2600);
+}
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-sound-panel-prototype.html b/docs/prototypes/2026-07-03-sound-panel-prototype.html
new file mode 100644
index 0000000..d75f566
--- /dev/null
+++ b/docs/prototypes/2026-07-03-sound-panel-prototype.html
@@ -0,0 +1,417 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Sound — instrument console (pulsemixer)</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 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.masthead{max-width:1280px;margin:0 auto 1.8rem}
+.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:82ch}
+.masthead p b{color:var(--silver);font-weight:700}
+
+.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start}
+.slot{width:400px}
+.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem}
+
+.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative;overflow:hidden}
+
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+
+.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px}
+.b-id{display:flex;align-items:center;gap:9px}
+.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em}
+.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto}
+.b-id .g{font-size:17px;color:var(--cream)}
+.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);
+ border-radius:4px;padding:1px 6px;display:none}
+.badge.show{display:inline-block}
+.badge.red{background:var(--fail);color:var(--cream)}
+.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;
+ cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto}
+.x-btn:hover{background:var(--wash);color:var(--silver)}
+/* faceplate master quick-mute — same switch idiom as net wifi / bt power */
+.switch{width:38px;height:20px;border-radius:10px;background:var(--wash);
+ border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;
+ border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:19px;background:var(--gold)}
+.switch.muted{background:rgba(203,107,77,.2);border-color:var(--fail)}
+.switch.muted::after{background:var(--fail);left:2px}
+
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;margin:13px 0 6px}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:12px}
+.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px;text-transform:none;font-size:.62rem}
+
+/* device row — a FIXED grid so nothing can overflow the plate:
+ [signal] [name] [fader] [pct] [mute]. minmax(0,1fr) lets the name shrink
+ and ellipsis instead of forcing the row wider than the panel. */
+.dev{display:grid;grid-template-columns:15px minmax(0,1fr) 84px 32px 20px;
+ align-items:center;gap:8px;padding:6px 5px;border-radius:8px;cursor:default}
+.dev:hover{background:var(--wash)}
+.dev .who{min-width:0;display:flex;align-items:center;gap:6px;color:var(--silver)}
+.dev .who .g{font-size:14px;color:var(--dim);flex:0 0 auto}
+.dev.active .who .g{color:var(--gold)}
+.dev .who .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.dev.active .who .nm{color:var(--cream);font-weight:700}
+.dev.muted .who .nm{color:var(--dim)}
+.dev .def{font-size:.5rem;letter-spacing:.14em;color:var(--panel);background:var(--gold);
+ border-radius:3px;padding:0 4px;flex:0 0 auto;display:none}
+.dev.active .def{display:inline-block}
+/* fader — machined slot + gold cap; width-based fill, bounded to its cell */
+.fader{height:16px;position:relative;cursor:pointer}
+.fader .trk{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:var(--well);
+ 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)}
+.dev.muted .fill{background:var(--wash)}
+.dev.muted .cap{background:linear-gradient(180deg,#6a6a6a,#3f3f3f);border-color:#2a2a2a}
+.pct{color:var(--cream);font-size:11.5px;font-variant-numeric:tabular-nums;text-align:right}
+.dev.muted .pct{color:var(--fail)}
+.mute-b{color:var(--dim);border:0;background:transparent;font:inherit;font-size:.9rem;cursor:pointer;
+ border-radius:5px;padding:0;justify-self:center;line-height:1}
+.mute-b:hover{color:var(--silver)}
+.dev.muted .mute-b{color:var(--fail)}
+/* per-row signal mini-meter — 4 bars that light when THIS device has a live
+ stream, so the sink/source actually playing is visible before you pick it */
+.sig{display:flex;align-items:flex-end;gap:1.5px;height:14px;justify-self:center}
+.sig i{width:2.5px;background:var(--wash);border-radius:1px}
+.sig i:nth-child(1){height:4px}.sig i:nth-child(2){height:7px}
+.sig i:nth-child(3){height:10px}.sig i:nth-child(4){height:13px}
+.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)}
+
+/* mic mode — three console keys */
+.modes{display:flex;gap:8px;margin-top:2px}
+.mode{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11px;letter-spacing:.06em;
+ background:linear-gradient(180deg,#23211e,#191715);color:var(--silver);
+ border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.mode:hover{color:var(--gold);border-color:var(--gold)}
+.mode.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.mode.on.mute{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream)}
+.mode .k{display:block;font-size:.54rem;letter-spacing:.14em;color:var(--dim);margin-top:2px}
+.mode.on .k{color:rgba(16,15,15,.7)}
+.ptt-hint{color:var(--dim);font-size:10.5px;text-align:center;margin-top:6px;min-height:1.2em}
+.ptt-hint.live{color:var(--gold)}
+
+/* meter row 1 — the volume dials you set (needles), OUT + IN */
+.meters{display:flex;gap:12px;margin-top:10px}
+.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;position:relative}
+.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)}
+.meter .mode-tag.mut{color:var(--fail)}
+.meter .dial{position:relative;height:50px;overflow:hidden;margin-top:13px}
+.meter .arc{position:absolute;inset:0 0 -50px 0;border:2px solid var(--wash);border-radius:50%}
+.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:9px;background:var(--steel);transform-origin:50% 50px}
+.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:42px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(20deg);border-radius:2px;
+ box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .3s cubic-bezier(.3,1.3,.5,1)}
+.meter .needle.mut{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)}
+.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)}
+.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums}
+.meter .m-value small{color:var(--dim);font-weight:400}
+.meter .m-label{color:var(--steel);font-size:.6rem;letter-spacing:.2em;text-align:center;margin-top:2px;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
+/* meter row 2 — the stereo VU pair: the live SIGNAL through the selected
+ output. Confirms which device is actually carrying the audio. */
+.vupair{margin-top:10px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 11px 8px}
+.vuhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:7px}
+.vuhead .t{color:var(--steel);font-size:.56rem;letter-spacing:.2em;text-transform:uppercase}
+.vuhead .src{color:var(--cream);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
+.vurow{display:flex;align-items:center;gap:8px;margin:4px 0}
+.vurow .ch{color:var(--steel);font-size:.62rem;width:9px;flex:0 0 auto}
+.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}
+.vupair.mute .vubar i{background:var(--wash);opacity:.22}
+.vupair.mute .vuhead .src{color:var(--fail)}
+
+.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;
+ padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em}
+.toast.show{opacity:1}
+.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)}
+
+.aside{flex:1 1 320px;min-width:300px}
+.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem}
+.aside h3:first-child{margin-top:.2rem}
+.aside ul{list-style:none}
+.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative}
+.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.aside li b{color:var(--cream);font-weight:700}
+.aside li em{color:var(--dim);font-style:normal}
+.barbits{display:flex;gap:14px;margin:.4rem 0 .2rem;align-items:center;flex-wrap:wrap}
+.barbits span{display:flex;align-items:center;gap:7px;color:var(--silver);font-size:.82rem}
+.barbits .g{font-size:19px;color:var(--cream)}
+.barbits .g.mut{color:var(--fail)}
+.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem;color:var(--silver)}
+.rec b{color:var(--gold)}
+*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent}
+::-webkit-scrollbar{width:6px;height:6px}
+::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px}
+@media (prefers-reduced-motion:reduce){.needle{transition:none}}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div>
+ <h1>Sound — the pulsemixer console</h1>
+ <p>Same faceplate as net + bluetooth. The bar's <b>sound glyph</b> opens this. Every sink and
+ source is a row: <b>click the row body to make it default</b>, drag the fader for its volume,
+ hit the glyph to mute just it. Each row has a <b>live-signal meter</b> — the device actually
+ carrying audio dances even when it isn't the default, so you can <b>find the one playing the
+ meeting</b> and click it. The faceplate switch is the <b>master quick-mute</b>; the mic carries
+ <b>live · muted · push-to-talk</b> (hold Space). Row 1 of gauges is the volume you set; row 2 is
+ the <b>stereo VU</b> of the selected output's live signal.</p>
+</header>
+
+<div class="stage">
+ <div class="slot">
+ <div class="slot-label">snd·01 — pulsemixer in console form</div>
+ <div class="panel">
+
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp" id="lamp"></span>
+ <span class="g" id="face-g">󰕾</span>
+ <span class="state-word" id="state">PLAYBACK</span>
+ <span class="badge red" id="mute-badge">MUTED</span>
+ <span class="unit">SND·01</span>
+ <span class="switch on" id="master" onclick="masterMute()" title="Master quick-mute (Super+Shift+M)"></span>
+ <button class="x-btn" title="Close (Esc)">✕</button>
+ </div>
+ </div>
+
+ <div class="engrave">outputs<span class="cnt" id="out-cnt"></span></div>
+ <div id="outputs"></div>
+
+ <div class="engrave">inputs<span class="cnt" id="in-cnt"></span></div>
+ <div id="inputs"></div>
+
+ <div class="engrave">mic mode</div>
+ <div class="modes">
+ <button class="mode" id="md-toggle" onclick="micToggle()">LIVE<span class="k">Super+Shift+A</span></button>
+ <button class="mode" id="md-ptt" onclick="micPtt()">PUSH·TALK<span class="k">hold Space</span></button>
+ </div>
+ <div class="ptt-hint" id="ptt-hint"></div>
+
+ <!-- meter row 1 — volume you set -->
+ <div class="meters">
+ <div class="meter">
+ <span class="mode-tag" id="vt-out">OUT VOL</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-out"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-out">68</span> <small>%</small></div>
+ <div class="m-label" id="l-out">SPEAKERS</div>
+ </div>
+ <div class="meter">
+ <span class="mode-tag" id="vt-in">IN VOL</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-in"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-in">54</span> <small>%</small></div>
+ <div class="m-label" id="l-in">BUILT-IN MIC</div>
+ </div>
+ </div>
+
+ <!-- meter row 2 — stereo VU of the selected output's live signal -->
+ <div class="vupair" id="vupair">
+ <div class="vuhead"><span class="t">signal · VU peak</span><span class="src" id="vu-src">SPEAKERS</span></div>
+ <div class="vurow"><span class="ch">L</span><span class="vubar" id="vu-l"></span></div>
+ <div class="vurow"><span class="ch">R</span><span class="vubar" id="vu-r"></span></div>
+ </div>
+
+ <div class="toast" id="toast"></div>
+ </div>
+ </div>
+
+ <div class="aside">
+ <h3>The bar glyph</h3>
+ <div class="barbits">
+ <span><span class="g">󰕾</span> normal — speaker + arcs</span>
+ <span><span class="g mut">󰝟</span> muted — speaker ✕</span>
+ <span><span class="g" style="color:var(--gold)">󰢮</span> ptt armed</span>
+ </div>
+ <h3>Idiom map (same as net / bt)</h3>
+ <ul>
+ <li><b>Faceplate switch = master quick-mute</b> — the net wifi / bt power switch, here muting all output. Flip: state → MUTED, lamp red, bar glyph → speaker-✕.</li>
+ <li><b>Rows are devices</b> — every sink + source. <b>Click the row body</b> to set default (gold DEF moves); the fader sets that device's volume; the trailing glyph mutes just it.</li>
+ <li><b>Per-row signal meter</b> — the 4 bars at the left of each row read that device's <b>live</b> level. A sink can carry a stream without being default, so the one playing lights up — <em>demo: the music is on WH-1000XM4 while Speakers is still default. Click WH-1000XM4 to move to it.</em></li>
+ <li><b>Two meter rows</b> — row 1 = the volume you set (OUT + IN needles); row 2 = the <b>stereo VU</b> (L/R) of the selected output's live signal, red when muted.</li>
+ <li><b>Mic = two console keys</b> — one toggles LIVE↔MUTED (the label flips to show the state), the other is PUSH·TALK: the mic sits muted and un-mutes only while Space is held.</li>
+ <li><b>Verify-everything</b> — every action re-reads pactl/wpctl state after firing, like net/bt.</li>
+ </ul>
+ <h3>Push-to-talk — the one hard part</h3>
+ <div class="rec">
+ Hold-to-talk needs a global key grab under Wayland. Two routes to spec: <b>(a)</b> a Hyprland
+ <b>bind pair</b> — <em>bindp</em> Space press → unmute, release → re-mute, armed only while PTT
+ mode is active (so it doesn't steal Space everywhere); or <b>(b)</b> an <b>evdev/libinput
+ listener</b> reading the key directly. (a) is lighter; (b) survives focus changes but needs
+ input-group permissions. Feasibility research is phase 1 of the spec.
+ </div>
+ </div>
+</div>
+
+<script>
+const $ = id => document.getElementById(id);
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+/* sig = base live level 0..1 (what stream is on this device right now).
+ Note the demo: music is on WH-1000XM4 (sig .72) while Speakers is default. */
+let OUT = [
+ {id:'spk', name:'Built-in Speakers', g:'󰓃', vol:68, mute:false, def:true, sig:0, ph:0.0},
+ {id:'xm4', name:'WH-1000XM4', g:'󰋋', vol:80, mute:false, def:false, sig:0.72,ph:1.7},
+ {id:'hdmi',name:'HDMI · Dell U2720', g:'󰡁', vol:100,mute:true, def:false, sig:0, ph:2.9},
+];
+let IN = [
+ {id:'bmic', name:'Built-in Mic', g:'󰍬', vol:54, mute:false, def:true, sig:0.16,ph:0.6},
+ {id:'yeti', name:'Blue Yeti USB', g:'󰦉', vol:70, mute:false, def:false, sig:0, ph:2.1},
+ {id:'xm4m', name:'WH-1000XM4 Headset',g:'󰋎', vol:60, mute:true, def:false, sig:0, ph:3.3},
+];
+let master = false, micMode = 'live', pttHeld = false;
+const pkL={v:0}, pkR={v:0};
+
+function outMutedOf(d){ return master || d.mute; }
+function inMutedOf(d){ return d.mute || (d.def && (micMode==='mute' || (micMode==='ptt' && !pttHeld))); }
+
+function fader(dev){
+ const w=document.createElement('div'); w.className='fader';
+ w.innerHTML=`<div class="trk"><div class="fill" style="width:${dev.vol}%"></div></div>`+
+ `<div class="cap" style="left:${dev.vol}%"></div>`;
+ w.onclick=(e)=>{e.stopPropagation();
+ const r=w.getBoundingClientRect();
+ dev.vol=Math.max(0,Math.min(100,Math.round((e.clientX-r.left)/r.width*100)));
+ if(dev.vol>0) dev.mute=false;
+ render(); toast(`${dev.name} → ${dev.vol}%`);
+ };
+ return w;
+}
+function row(dev,kind){
+ const muted = kind==='out'?outMutedOf(dev):inMutedOf(dev);
+ const r=document.createElement('div');
+ r.className='dev'+(dev.def?' active':'')+(muted?' muted':'');
+ r.title=dev.name;
+ const sig=document.createElement('span'); sig.className='sig'; sig.id='sig-'+dev.id;
+ sig.innerHTML='<i></i><i></i><i></i><i></i>';
+ const who=document.createElement('div'); who.className='who';
+ who.innerHTML=`<span class="g">${dev.g}</span><span class="nm">${dev.name}</span><span class="def">DEF</span>`;
+ const pct=document.createElement('span'); pct.className='pct'; pct.textContent=dev.mute?'mute':dev.vol+'%';
+ const mb=document.createElement('button'); mb.className='mute-b'; mb.textContent=dev.mute?'󰝟':'󰕾';
+ mb.title='mute '+dev.name;
+ mb.onclick=(e)=>{e.stopPropagation();dev.mute=!dev.mute;render();toast(`${dev.name} ${dev.mute?'muted':'unmuted'}`);};
+ r.append(sig,who,fader(dev),pct,mb);
+ r.onclick=()=>{ if(dev.def) return;
+ (kind==='out'?OUT:IN).forEach(d=>d.def=false); dev.def=true; render();
+ toast(`default ${kind==='out'?'output':'input'} → ${dev.name}`);
+ };
+ return r;
+}
+function render(){
+ const o=$('outputs'); o.innerHTML=''; OUT.forEach(d=>o.appendChild(row(d,'out')));
+ const i=$('inputs'); i.innerHTML=''; IN.forEach(d=>i.appendChild(row(d,'in')));
+ $('out-cnt').textContent='· '+OUT.length;
+ $('in-cnt').textContent='· '+IN.length;
+ const od=OUT.find(d=>d.def), id=IN.find(d=>d.def);
+ const oM=outMutedOf(od), iM=inMutedOf(id);
+ $('master').className='switch'+(master?' muted':' on');
+ $('state').textContent=oM?'MUTED':'PLAYBACK';
+ $('lamp').className='lamp'+(oM?' red':'');
+ $('face-g').textContent=oM?'󰝟':'󰕾';
+ $('mute-badge').classList.toggle('show',oM);
+ setNeedle('out',oM?0:od.vol,oM); $('v-out').textContent=oM?0:od.vol; $('l-out').textContent=od.name.toUpperCase();
+ setNeedle('in',iM?0:id.vol,iM); $('v-in').textContent=iM?0:id.vol; $('l-in').textContent=id.name.toUpperCase();
+ $('vt-out').textContent=oM?'OUT·MUTE':'OUT VOL'; $('vt-out').className='mode-tag'+(oM?' mut':'');
+ $('vt-in').textContent=iM?'IN·MUTE':'IN VOL'; $('vt-in').className='mode-tag'+(iM?' mut':'');
+ $('vu-src').textContent=od.name.toUpperCase();
+ $('vupair').classList.toggle('mute',oM);
+ // mic controls: one live/muted toggle + one push-to-talk key
+ const tg=$('md-toggle');
+ tg.textContent = micMode==='mute' ? 'MUTED' : 'LIVE';
+ tg.appendChild(Object.assign(document.createElement('span'),{className:'k',textContent:'Super+Shift+A'}));
+ tg.className = 'mode' + (micMode==='mute' ? ' on mute' : micMode==='live' ? ' on' : '');
+ $('md-ptt').className='mode'+(micMode==='ptt'?' on':'');
+ $('ptt-hint').textContent = micMode==='ptt'
+ ? (pttHeld?'▸ transmitting — Space held':'mic muted — hold Space to talk')
+ : micMode==='mute' ? 'mic muted' : '';
+ $('ptt-hint').className='ptt-hint'+(micMode==='ptt'&&pttHeld?' live':'');
+}
+function setNeedle(side,val,muted){
+ const deg=-60+Math.max(0,Math.min(1,val/100))*120;
+ const n=$('n-'+side); n.style.transform=`rotate(${deg}deg)`;
+ n.className='needle'+(muted?' mut':'');
+}
+
+/* live-signal animation — the per-row minis + the stereo VU pair */
+let phase=0;
+function level(dev,muted){
+ if(muted||!dev.sig) return 0;
+ const env=0.5+0.5*Math.abs(Math.sin(phase*1.25+dev.ph));
+ const trans=Math.random()<0.14?Math.random()*0.4:0;
+ return Math.max(0,Math.min(1,dev.sig*env+trans-Math.random()*0.07));
+}
+function paintMini(el,lvl){
+ const b=el.children, lit=Math.round(lvl*4);
+ for(let k=0;k<4;k++){ b[k].className = k<lit ? (k>=3?'clip':k>=2?'hot':'on') : ''; }
+}
+function paintVU(el,lvl,pk){
+ const b=el.children, n=b.length, lit=Math.round(lvl*n);
+ pk.v=Math.max(lit,pk.v-0.35); 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; }
+}
+function buildVU(el,n){ el.innerHTML=''; for(let k=0;k<n;k++) el.appendChild(document.createElement('i')); }
+function tick(){
+ phase+=0.09;
+ OUT.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,outMutedOf(d)));});
+ IN.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,inMutedOf(d)));});
+ const od=OUT.find(d=>d.def), oM=outMutedOf(od);
+ const base=oM?0:level(od,false);
+ paintVU($('vu-l'),Math.min(1,base*(0.92+Math.random()*0.16)),pkL);
+ paintVU($('vu-r'),Math.min(1,base*(0.92+Math.random()*0.16)),pkR);
+}
+
+function masterMute(){ master=!master; render(); toast(master?'ALL OUTPUT MUTED':'output unmuted'); }
+/* two mic buttons: toggle flips live<->muted (and leaves ptt); ptt arms/disarms */
+function micToggle(){ micMode = micMode==='mute' ? 'live' : 'mute'; pttHeld=false; render();
+ toast(micMode==='mute'?'mic muted':'mic live'); }
+function micPtt(){ micMode = micMode==='ptt' ? 'live' : 'ptt'; pttHeld=false; render();
+ toast(micMode==='ptt'?'push-to-talk armed — hold Space':'mic live'); }
+let tT;
+function toast(msg,err){ const t=$('toast'); t.textContent=msg;
+ t.className='toast show'+(err?' err':''); clearTimeout(tT); tT=setTimeout(()=>t.className='toast',2200); }
+addEventListener('keydown',e=>{ if(e.code==='Space'&&micMode==='ptt'&&!e.repeat){e.preventDefault();pttHeld=true;render();}});
+addEventListener('keyup', e=>{ if(e.code==='Space'&&micMode==='ptt'){e.preventDefault();pttHeld=false;render();}});
+
+buildVU($('vu-l'),16); buildVU($('vu-r'),16);
+render();
+if(!reduced) setInterval(tick,70); else tick();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-waybar-redesign-prototype.html b/docs/prototypes/2026-07-03-waybar-redesign-prototype.html
new file mode 100644
index 0000000..3f3e7c1
--- /dev/null
+++ b/docs/prototypes/2026-07-03-waybar-redesign-prototype.html
@@ -0,0 +1,321 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Waybar redesign — 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:1400px;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:88ch}
+.masthead p b{color:var(--silver);font-weight:700}
+
+/* each variation sits on a "desk" — a strip of desktop so the bar reads as a
+ real top bar floating over a window, exactly how it looks on-screen */
+.desk{margin:1.9rem 0 0;border-radius:14px;overflow:hidden;
+ border:1px solid #211e1a;
+ background:
+ linear-gradient(180deg,#0e0d0c 0 62px,transparent 62px),
+ repeating-linear-gradient(135deg,#191613 0 14px,#171512 14px 28px);
+ position:relative}
+.desk-label{display:flex;align-items:baseline;gap:.7rem;padding:.5rem .2rem .1rem}
+.desk-label .n{color:var(--gold);font-size:.82rem;letter-spacing:.08em}
+.desk-label .d{color:var(--dim);font-size:.76rem}
+.desk-window{position:absolute;inset:70px 26px 22px;border:1px solid #262320;border-radius:10px;
+ background:linear-gradient(180deg,#131110,#0e0d0c);opacity:.6}
+.desk-window::before{content:"emacs — instrument-console.el";position:absolute;top:8px;left:14px;
+ color:#3a3630;font-size:.72rem}
+
+/* the bar frame: matches waybar's -54 top strip. two clusters, gold-bordered,
+ floating with a gap between them (modules-center is empty in the real config) */
+.bar{position:relative;z-index:2;display:flex;justify-content:space-between;align-items:flex-start;
+ gap:1rem;padding:10px 12px;height:132px}
+.cluster{display:flex;align-items:center;gap:2px;
+ background:var(--panel);border:1.4px solid var(--gold);border-radius:15px;
+ padding:2px 9px;box-shadow:0 4px 9px rgba(0,0,0,.5)}
+.mod{display:flex;align-items:center;gap:7px;color:var(--silver);
+ padding:7px 9px;border-radius:11px;font-size:14px;cursor:default;white-space:nowrap;position:relative}
+.mod .g{font-size:16px;line-height:1}
+.mod .g.xl{font-size:19px}
+.mod:hover{background:var(--wash)}
+.val{font-variant-numeric:tabular-nums}
+.cream{color:var(--cream)}.gold{color:var(--gold)}.dim{color:var(--dim)}
+.fail{color:var(--fail)}.pass{color:var(--pass)}.steel{color:var(--steel)}
+
+/* workspaces — circular tokens like the real ws-icons */
+.ws{display:flex;gap:5px;padding:0 3px}
+.ws b{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-size:13px;
+ color:var(--silver);border:1.4px solid var(--slate);background:#141210}
+.ws b.on{color:var(--panel);background:var(--gold);border-color:var(--gold);font-weight:700;
+ box-shadow:0 0 8px 1px rgba(218,181,61,.4)}
+.ws b.busy{border-color:var(--steel);color:var(--cream)}
+.menu{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;color:var(--gold);
+ font-size:17px;background:linear-gradient(180deg,#211e19,#151210);border:1px solid #33302b}
+.title{color:var(--dim);font-size:13px;max-width:230px;overflow:hidden;text-overflow:ellipsis}
+
+/* collapse arrows — recessed dim wells, per the current design */
+.arrow{color:var(--dim);font-size:12px;background:rgba(0,0,0,.35);
+ box-shadow:inset 0 1px 2px rgba(0,0,0,.7);border-radius:7px;padding:8px 7px}
+
+/* lamp — the panel's signature status dot, glow and all */
+.lamp{width:8px;height:8px;border-radius:50%;background:var(--pass);flex:0 0 auto;
+ 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}
+
+/* engraved hairline divider between functional groups (echoes .engrave rules) */
+.sep{width:1px;align-self:stretch;margin:6px 3px;
+ background:linear-gradient(180deg,transparent,var(--wash) 22%,var(--wash) 78%,transparent)}
+
+.notes{margin-top:2.4rem;display:flex;gap:2rem;flex-wrap:wrap}
+.note{flex:1 1 300px;min-width:280px}
+.note h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem}
+.note ul{list-style:none}
+.note li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative;color:var(--silver)}
+.note li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.note li b{color:var(--cream);font-weight:700}
+.note li em{color:var(--dim);font-style:normal}
+.rec{border:1px dashed var(--wash);border-radius:10px;padding:.9rem 1.1rem;margin-top:1.1rem;
+ font-size:.83rem;color:var(--silver)}
+.rec b{color:var(--gold)}
+
+/* ============ V1 · FACEPLATE ============ */
+/* machined faceplate: vertical gradient + a 1px top highlight + deeper shadow.
+ otherwise the current layout — lowest-risk, faithful to GTK CSS. */
+.v1 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel));
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)}
+.v1 .val.clock{color:var(--cream)}
+
+/* ============ V2 · INSTRUMENT SEGMENTS ============ */
+/* each functional group is its own recessed sub-faceplate with an engraved unit
+ label underneath, lamps on status modules, gold-hi active values */
+.v2 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel));padding:3px 6px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)}
+.v2 .seg{display:flex;flex-direction:column;align-items:center;gap:2px;
+ background:var(--well);border:1px solid #232019;border-radius:10px;padding:3px 5px 2px;margin:0 2px}
+.v2 .seg .row{display:flex;align-items:center;gap:6px}
+.v2 .seg .unit{color:var(--steel);font-size:.52rem;letter-spacing:.24em;text-transform:uppercase}
+.v2 .mod{padding:5px 7px}
+.v2 .mod:hover{background:var(--wash)}
+.v2 .menu{background:linear-gradient(180deg,#2a251d,#161310);border-color:var(--gold)}
+/* mini gauge for sysmon — a squat needle echoing the panel meters */
+.gauge{width:26px;height:15px;position:relative;overflow:hidden}
+.gauge .arc{position:absolute;inset:0 0 -26px 0;border:1.5px solid var(--wash);border-radius:50%}
+.gauge .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:13px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(18deg);border-radius:1px;
+ box-shadow:0 0 4px rgba(255,215,95,.5)}
+.gauge .hub{position:absolute;left:50%;bottom:-2px;width:5px;height:5px;margin-left:-2.5px;
+ border-radius:50%;background:var(--gold)}
+
+/* ============ V3 · FULL CONSOLE ============ */
+/* every module a recessed well with a lamp; console-key toggles with full
+ physical-key gradient + inset; sysmon as twin analog gauges; cream clock */
+.v3 .cluster{background:linear-gradient(180deg,#0d0c0b,#080807);border-width:1.8px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 8px 18px rgba(0,0,0,.6);padding:3px 8px}
+.v3 .mod{background:var(--well);border:1px solid #201d17;border-radius:9px;margin:0 2px;padding:6px 9px}
+.v3 .mod:hover{background:#141210;border-color:var(--slate)}
+/* console-key toggles — the physical key from the panels' .c-btn */
+.v3 .key{background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;
+ border-bottom-color:#0c0b0a;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.v3 .key.engaged{color:var(--gold);border-color:var(--gold)}
+.v3 .key.off{color:var(--fail)}
+.v3 .menu{background:linear-gradient(180deg,#2a251d,#141110);border:1px solid var(--gold);
+ box-shadow:0 0 8px rgba(218,181,61,.25)}
+.v3 .ws b{background:var(--well);border-color:#201d17}
+.v3 .ws b.on{background:var(--gold);border-color:var(--gold)}
+.v3 .val.clock{color:var(--cream);font-weight:700}
+.v3 .tz{color:var(--steel);font-size:.55rem;letter-spacing:.2em}
+.v3 .twin{display:flex;gap:6px}
+.v3 .gauge2{width:22px;height:14px;position:relative;overflow:hidden}
+.v3 .gauge2 .arc{position:absolute;inset:0 0 -22px 0;border:1.5px solid var(--wash);border-radius:50%}
+.v3 .gauge2 .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:12px;transform-origin:50% 100%;
+ border-radius:1px;background:var(--gold-hi);box-shadow:0 0 4px rgba(255,215,95,.5)}
+.v3 .gauge2 .ndl.warn{background:var(--gold)}
+.v3 .gauge2 .hub{position:absolute;left:50%;bottom:-2px;width:4px;height:4px;margin-left:-2px;
+ border-radius:50%;background:var(--gold)}
+</style>
+</head>
+<body>
+<div class="wrap">
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · waybar</div>
+ <h1>Waybar — three ways to spruce it</h1>
+ <p>The bar already runs the dupre palette and a gold border. These three push it toward the
+ <b>instrument-console faceplate</b> language of the net + bluetooth panels — machined
+ gradient plates, engraved unit labels, glowing status lamps, physical console keys, analog
+ gauges — dialing the intensity up from left-touch to full console. Same real module set in
+ each so you're comparing <b>treatment, not content</b>. All three stay inside what GTK3 CSS
+ (waybar's engine) can actually render.</p>
+</header>
+
+<!-- ============ CURRENT (reference) ============ -->
+<div class="desk">
+ <div class="bar" style="padding-top:14px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span>
+ <span class="mod"><span class="g">󰕰</span></span>
+ <span class="title">instrument-console.el</span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="mod"><span class="g gold">󰤨</span></span>
+ <span class="mod"><span class="g xl">󰍬</span></span>
+ <span class="mod"><span class="g xl">󰕾</span> <span class="val">62%</span></span>
+ <span class="mod"><span class="g">󰹇</span></span>
+ <span class="mod"><span class="g">󰃟</span></span>
+ <span class="mod"><span class="g">󰒲</span></span>
+ <span class="mod"><span class="g">󰂯</span></span>
+ <span class="mod"><span class="g">󰻠</span> <span class="val">8%</span></span>
+ <span class="mod"><span class="g">󰔛</span> <span class="val">24:10</span></span>
+ <span class="mod"><span class="g">󰃭</span> <span class="val">Fri Jul 3</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">current</span><span class="d">— flat pills, colour-only states. the baseline these three build on.</span></div>
+
+<!-- ============ V1 · FACEPLATE ============ -->
+<div class="desk v1">
+ <div class="bar" style="padding-top:14px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g">󰕰</span></span>
+ <span class="title">instrument-console.el</span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="mod"><span class="lamp gold"></span><span class="g gold">󰤨</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g xl">󰍬</span></span>
+ <span class="mod"><span class="g xl">󰕾</span> <span class="val cream">62%</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g">󰹇</span></span>
+ <span class="mod"><span class="g">󰃟</span></span>
+ <span class="mod"><span class="g gold">󰒲</span></span>
+ <span class="mod"><span class="lamp"></span><span class="g">󰂯</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g">󰻠</span> <span class="val">8%</span></span>
+ <span class="mod"><span class="g gold">󰔛</span> <span class="val gold">24:10</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g dim">󰃭</span> <span class="val clock">Fri Jul 3</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">variation 1 · faceplate</span><span class="d">— machined gradient + top highlight, engraved hairline dividers, status lamps on net/bt, cream clock. Nearest to today; drop-in GTK CSS.</span></div>
+
+<!-- ============ V2 · INSTRUMENT SEGMENTS ============ -->
+<div class="desk v2">
+ <div class="bar" style="padding-top:12px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="seg"><span class="row ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span><span class="unit">wksp</span></span>
+ <span class="seg"><span class="row"><span class="g">󰕰</span><span class="title" style="max-width:180px">instrument-console.el</span></span><span class="unit">layout · window</span></span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="seg"><span class="row"><span class="lamp gold"></span><span class="g gold">󰤨</span><span class="gold">CAPTIVE</span></span><span class="unit">net</span></span>
+ <span class="seg"><span class="row"><span class="g xl">󰍬</span><span class="g xl">󰕾</span><span class="val cream">62%</span></span><span class="unit">sound</span></span>
+ <span class="seg"><span class="row"><span class="g">󰹇</span><span class="g">󰃟</span><span class="g gold">󰒲</span></span><span class="unit">toggles</span></span>
+ <span class="seg"><span class="row"><span class="lamp"></span><span class="g">󰂯</span><span class="dim">M650</span></span><span class="unit">bt</span></span>
+ <span class="seg"><span class="row"><span class="gauge"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span><span class="val">8%</span></span><span class="unit">cpu</span></span>
+ <span class="seg"><span class="row"><span class="g gold">󰔛</span><span class="val gold">24:10</span></span><span class="unit">timer</span></span>
+ <span class="seg"><span class="row"><span class="val cream">Fri Jul 3</span><span class="val">11:23</span></span><span class="unit">clock</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">variation 2 · instrument segments</span><span class="d">— each group a recessed sub-plate with an engraved unit label; a squat needle gauge for cpu. Reads like a row of instruments. Taller; label row costs a few px.</span></div>
+
+<!-- ============ V3 · FULL CONSOLE ============ -->
+<div class="desk v3">
+ <div class="bar" style="padding-top:12px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span>
+ <span class="sep"></span>
+ <span class="mod key"><span class="g">󰕰</span></span>
+ <span class="title">instrument-console.el</span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="mod"><span class="lamp gold"></span><span class="g gold">󰤨</span> <span class="gold">CAPTIVE</span></span>
+ <span class="sep"></span>
+ <span class="mod key"><span class="g xl">󰍬</span></span>
+ <span class="mod"><span class="g xl">󰕾</span> <span class="val cream">62%</span></span>
+ <span class="sep"></span>
+ <span class="mod key engaged"><span class="g">󰹇</span></span>
+ <span class="mod key"><span class="g">󰃟</span></span>
+ <span class="mod key engaged"><span class="g">󰒲</span></span>
+ <span class="mod"><span class="lamp"></span><span class="g">󰂯</span> <span class="dim val">72%</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="twin">
+ <span class="gauge2"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span>
+ <span class="gauge2"><span class="arc"></span><span class="ndl warn" style="transform:rotate(38deg)"></span><span class="hub"></span></span>
+ </span></span>
+ <span class="mod key"><span class="g gold">󰔛</span> <span class="val gold">24:10</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g dim">󰃭</span> <span class="val clock">Fri Jul 3</span> <span class="val clock">11:23</span><span class="tz">&nbsp;EDT</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">variation 3 · full console</span><span class="d">— every module a recessed well, physical console keys for toggles (gold when engaged, terracotta when off), twin cpu/mem gauges, cream tabular clock with engraved TZ. Furthest from today; closest to the panels.</span></div>
+
+<!-- ============ NOTES ============ -->
+<div class="notes">
+ <div class="note">
+ <h3>What carries over from the panels</h3>
+ <ul>
+ <li><b>Lamps</b> — the glowing status dot lands on net + bt so health reads at a glance, not just by glyph colour <em>(gold = captive/engaged, green = ok, red = fail, dim = off)</em>.</li>
+ <li><b>Machined faceplate</b> — the cluster gets the b-face vertical gradient + 1px top highlight + deeper shadow, so it looks milled rather than printed.</li>
+ <li><b>Engraved dividers</b> — hairline separators group the right cluster into net · sound · toggles · system · clock, echoing the panels' engraved section rules.</li>
+ <li><b>Console keys</b> — the toggles (touchpad, dim, caffeine) borrow .c-btn: gradient fill, inset highlight, gold border when engaged.</li>
+ <li><b>Gauges</b> — sysmon becomes a squat needle (or twin needles for cpu/mem), the same instrument the panels use for throughput and battery.</li>
+ <li><b>Cream + tabular</b> — the clock and live values shift to cream with tabular-nums, matching the panels' readouts.</li>
+ </ul>
+ </div>
+ <div class="note">
+ <h3>GTK3 translation caveats</h3>
+ <ul>
+ <li><b>Dividers</b> need real separator modules or per-module borders — waybar can't inject <em>::before</em> content between modules the way this HTML does.</li>
+ <li><b>Lamps</b> render as a small pango glyph (● with colour + text-shadow glow) prepended in each script, or a tiny bordered box widget — both are GTK-safe.</li>
+ <li><b>Gauges</b> are the real work: GTK CSS can't draw a rotating needle. Options — a Cairo/GTK drawing area in a custom module, or fake it with a unicode gauge glyph that steps by load band. V2's single gauge is cheaper than V3's twin.</li>
+ <li><b>V2's unit labels</b> raise the bar height (the label row). Fine at 54px reserved, but worth eyeballing against the -54 margin strip.</li>
+ <li>Gradients, inset box-shadow, border colour states, tabular-nums — all already proven in the current stylesheet.</li>
+ </ul>
+ </div>
+ <div class="note">
+ <h3>My read</h3>
+ <div class="rec">
+ <b>Variation 1 (faceplate)</b> is the one I'd ship first: it lands ~80% of the instrument-console feel — lamps, milled plates, engraved grouping, cream clock — for pure CSS plus a lamp glyph in the net/bt scripts. No custom drawing, no height risk.
+ <br><br>
+ <b>Variation 3</b> is the aspirational target once a gauge-drawing module exists (it'd also upgrade the sysmon popup). <b>Variation 2</b> is the middle path if you want the unit labels' legibility but not the full recessed-well density. They're not exclusive — 1 can grow into 3.
+ </div>
+ </div>
+</div>
+
+</div>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html b/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html
new file mode 100644
index 0000000..0b5d6e8
--- /dev/null
+++ b/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html
@@ -0,0 +1,133 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>PTT waybar indicator — prototype</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --bar:#0d0c0b; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono","JetBrainsMono Nerd Font",monospace;
+}
+*{box-sizing:border-box}
+body{font-family:var(--mono);color:var(--silver);margin:0;padding:2.6rem 2rem 5rem;line-height:1.5;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+h1{font-size:1.05rem;letter-spacing:.02em;color:var(--cream);font-weight:700;margin:0 0 .3rem}
+.sub{color:var(--dim);font-size:.82rem;margin:0 0 2rem;max-width:60ch}
+h2{font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;color:var(--steel);
+ margin:2.2rem 0 .8rem;display:flex;align-items:center;gap:10px}
+h2::after{content:"";height:1px;background:var(--wash);flex:1}
+
+/* a slice of the real waybar: dark rounded bar, right-aligned modules */
+.bar{display:inline-flex;align-items:center;gap:2px;background:var(--bar);
+ border:1px solid #201d17;border-radius:10px;padding:5px 8px;
+ box-shadow:0 2px 10px rgba(0,0,0,.45)}
+.mod{display:flex;align-items:center;gap:7px;padding:4px 9px;border-radius:7px;color:var(--silver);
+ font-size:14px}
+.mod .g{font-size:17px;line-height:1}
+.mod.muted .g{color:var(--fail)}
+.mod.live .g{color:var(--silver)}
+.sep{width:1px;height:16px;background:#26231d;margin:0 2px}
+.neighbor{color:var(--dim);font-size:13px;padding:4px 8px}
+
+/* the PTT tag — outline only, ALWAYS present (so the bar never reflows), and
+ click-to-toggle. Off = barely-visible grey; on = glowing gold; transmitting
+ = brighter gold. Like the gallery 2.4G ghost tag, lit when engaged. */
+.badge{font-size:15px;line-height:1;border-radius:4px;padding:3px 7px;
+ background:transparent;border:1px solid #2a2723;color:#54585c;
+ text-shadow:none;box-shadow:none;cursor:pointer;transition:all .15s ease}
+.badge.txt{font-size:.62rem;letter-spacing:.18em;font-weight:700;padding:2px 7px}
+.badge.on{color:var(--gold);border-color:var(--gold);
+ text-shadow:0 0 6px rgba(218,181,61,.75);
+ box-shadow:0 0 6px rgba(218,181,61,.28),inset 0 0 5px rgba(218,181,61,.15)}
+.badge.talk{color:var(--gold-hi);border-color:var(--gold-hi);
+ text-shadow:0 0 10px rgba(255,215,95,.95);
+ box-shadow:0 0 11px rgba(255,215,95,.5),inset 0 0 6px rgba(255,215,95,.2)}
+.badge.ghost{border-color:var(--wash);color:var(--dim)}
+
+.row{display:flex;align-items:center;gap:22px;flex-wrap:wrap;margin:10px 0}
+.label{width:16rem;color:var(--dim);font-size:.8rem}
+.label b{color:var(--cream);font-weight:700}
+.note{color:var(--dim);font-size:.78rem;margin:.6rem 0 0;max-width:70ch}
+.opt{margin:2.4rem 0 0;padding:14px 16px;border:1px solid #262320;border-radius:10px;
+ background:linear-gradient(180deg,var(--raise),var(--panel));max-width:70ch}
+.opt h3{margin:0 0 .5rem;color:var(--cream);font-size:.86rem;letter-spacing:.04em}
+.opt p{margin:.3rem 0;color:var(--silver);font-size:.82rem}
+.k{color:var(--gold);font-weight:700}
+</style>
+</head>
+<body>
+
+<h1>Push-to-talk — waybar indicator</h1>
+<p class="sub">How the bar reads in each state, so you can tell at a glance whether you're in PTT mode.
+Two elements move together: the <b>mic glyph</b> (live vs muted, the module you already have) and a
+<b>PTT tag</b> — an outline flag that's <b>always present</b> (dim grey when off, so the bar never jumps),
+glows gold when PTT is engaged, and is itself <b>click-to-toggle</b> for PTT mode.</p>
+
+<h2>Not in PTT mode — the tag sits dim (present, barely visible)</h2>
+
+<div class="row">
+ <div class="label"><b>Mic live.</b> Tag dim grey.</div>
+ <div class="bar">
+ <span class="neighbor">󰂯</span><span class="sep"></span>
+ <div class="mod live"><span class="badge">&#xF05CB;</span> <span class="g">󰍬</span></div>
+ <span class="neighbor">85%</span>
+ </div>
+</div>
+
+<div class="row">
+ <div class="label"><b>Mic muted</b> (plain mic-toggle). Tag still dim.</div>
+ <div class="bar">
+ <span class="neighbor">󰂯</span><span class="sep"></span>
+ <div class="mod muted"><span class="badge">&#xF05CB;</span> <span class="g">󰍭</span></div>
+ <span class="neighbor">85%</span>
+ </div>
+</div>
+
+<p class="note">The tag is <b>always there</b> — dim grey when off — so the bar never reflows when PTT flips.
+<b>Click the tag to toggle PTT mode</b> (same as Super+Shift+A). A plain mic-mute leaves the tag dim; only PTT lights it gold.</p>
+
+<h2>In PTT mode — the tag lights gold</h2>
+
+<div class="row">
+ <div class="label"><b>Armed, not talking.</b> Mic muted, tag glows gold.</div>
+ <div class="bar">
+ <span class="neighbor">󰂯</span><span class="sep"></span>
+ <div class="mod muted"><span class="badge on">&#xF05CB;</span> <span class="g">󰍭</span></div>
+ <span class="neighbor">85%</span>
+ </div>
+</div>
+
+<div class="row">
+ <div class="label"><b>Holding the key — transmitting.</b> Mic live, tag brightens.</div>
+ <div class="bar">
+ <span class="neighbor">󰂯</span><span class="sep"></span>
+ <div class="mod live"><span class="badge talk">&#xF05CB;</span> <span class="g">󰍬</span></div>
+ <span class="neighbor">85%</span>
+ </div>
+</div>
+
+<p class="note">Only the letters and outline change colour — the box holds its place, so nothing shifts. While you hold to talk the mic flips live and the tag brightens, so you see you're transmitting, not just armed.</p>
+
+<h2>The four states side by side — the tag never moves</h2>
+<div class="row">
+ <div class="bar">
+ <div class="mod live"><span class="badge">&#xF05CB;</span> <span class="g">󰍬</span></div><span class="sep"></span>
+ <div class="mod muted"><span class="badge">&#xF05CB;</span> <span class="g">󰍭</span></div><span class="sep"></span>
+ <div class="mod muted"><span class="badge on">&#xF05CB;</span> <span class="g">󰍭</span></div><span class="sep"></span>
+ <div class="mod live"><span class="badge talk">&#xF05CB;</span> <span class="g">󰍬</span></div>
+ </div>
+</div>
+<p class="note">left → right: off + live · off + muted · PTT armed · PTT transmitting. The tag holds its position through all four — no jump.</p>
+
+<div class="opt">
+ <h3>Variants to consider</h3>
+ <p>• <span class="k">Tag icon</span> — the account-voice glyph 󰗋 (shown). Alternatives if it doesn't read right: radio-handheld 󰐺, broadcast 󱜠, or plain "PTT"/"TALK" text.</p>
+ <p>• <span class="k">Tag colour</span> — gold (shown, matches the panel's PUSH·TALK lamp). A red tag would read more like "mic hot/muted"; gold reads "mode engaged".</p>
+ <p>• <span class="k">Tag placement</span> — left of the mic glyph (shown) so the flag leads. Could sit right of it instead.</p>
+ <p>• <span class="k">Transmit cue</span> — brighten + glow the tag while holding (shown), or leave the tag steady and let only the mic glyph carry the live/muted change.</p>
+</div>
+
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-A-two-column.html b/docs/prototypes/2026-07-07-maint-console-A-two-column.html
new file mode 100644
index 0000000..23d0e60
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-A-two-column.html
@@ -0,0 +1,335 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Maintenance console — Approach A · two-column board</title>
+<style>
+:root{
+ --ground:#0a0c0d; --panel:#100f0f; --well:#0a0c0d;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32;
+ --ok:#74932f; --warn:#dab53d; --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);line-height:1.4;
+ padding:2.2rem 1.4rem 4rem;
+ background:radial-gradient(1100px 560px at 72% -12%,#141210 0%,transparent 60%),var(--ground);
+ display:flex;flex-direction:column;align-items:center;gap:1.5rem}
+
+/* ---- capsule ---- */
+.capsule{width:1000px;max-width:100%;background:var(--panel);border:1px solid var(--gold);
+ border-radius:16px;padding:18px 20px;box-shadow:0 20px 55px rgba(0,0,0,.6)}
+
+/* ---- header strip ---- */
+.hdr{display:flex;align-items:center;gap:14px;padding-bottom:12px;border-bottom:1px solid var(--wash)}
+.hdr .mark{color:var(--gold);font-weight:700;font-size:1.15rem;letter-spacing:.12em}
+.hdr .mark .host{color:var(--cream)}
+.hdr .verdict{margin-left:auto;display:flex;align-items:center;gap:8px;font-size:.92rem}
+.hdr .verdict .word{color:var(--ok);font-weight:700;letter-spacing:.08em;text-transform:uppercase}
+.subhdr{color:var(--dim);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase;margin-top:8px}
+
+/* ---- lamps ---- */
+.lamp{font-size:.72rem;line-height:1;flex:0 0 auto;position:relative;top:-.5px}
+.lamp.ok{color:var(--ok)}
+.lamp.warn{color:var(--warn)}
+.lamp.fail{color:var(--fail)}
+.lamp.off{color:var(--dim)}
+.lamp.run{color:var(--warn)}
+.lamp.big{font-size:.85rem}
+
+/* ---- board ---- */
+.board{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:16px}
+.col{min-width:0}
+.col-h{display:flex;align-items:baseline;gap:10px;margin-bottom:10px}
+.col-h .tag{color:var(--gold);font-weight:700;font-size:.86rem;letter-spacing:.22em}
+.col-h .sub{color:var(--dim);font-size:.68rem;letter-spacing:.04em}
+
+/* doctor buttons */
+.doctor{display:flex;gap:10px;margin-bottom:14px}
+.btn{font:inherit;cursor:pointer;border-radius:10px;border:1px solid transparent;
+ background:var(--slate);color:var(--cream);padding:.5rem .9rem;font-size:.82rem;letter-spacing:.02em;
+ transition:background .12s,border-color .12s}
+.btn:hover{background:var(--slate-hi)}
+.btn.big{flex:1;text-align:center;padding:.62rem .8rem;font-size:.86rem}
+.btn.big .k{display:block;color:var(--cream);font-weight:700}
+.btn.big .d{display:block;color:var(--silver);font-size:.66rem;letter-spacing:.02em;margin-top:2px;opacity:.85}
+.btn.ghost{background:transparent;border-color:var(--wash);color:var(--silver)}
+.btn.ghost:hover{border-color:var(--slate-hi);background:rgba(66,79,94,.18)}
+.btn.lever{padding:.28rem .66rem;font-size:.74rem;border:1px solid var(--slate);background:rgba(66,79,94,.32);color:var(--cream)}
+.btn.lever:hover{background:var(--slate);border-color:var(--slate-hi)}
+.btn.done{background:var(--slate);color:var(--cream);padding:.5rem 1.4rem}
+
+/* inset group */
+.group{background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:8px 10px 6px;margin-bottom:11px}
+.group-h{color:var(--gold);font-weight:700;font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;
+ display:flex;align-items:center;gap:8px;padding:1px 2px 7px}
+.group-h::after{content:"";height:1px;background:var(--wash);flex:1}
+
+/* metric row */
+.row{display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:9px;
+ padding:4px 4px;border-radius:6px;font-size:.82rem}
+.row:hover{background:rgba(44,47,50,.4)}
+.row .name{color:var(--silver);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.row .cls{color:var(--dim);font-size:.6rem;letter-spacing:.14em;border:1px solid var(--wash);
+ border-radius:4px;padding:0 4px;flex:0 0 auto;text-transform:uppercase}
+.row .val{color:var(--dim);text-align:right;white-space:nowrap;font-size:.78rem}
+.row .val.warn{color:var(--warn)}
+.row .val.fail{color:var(--fail)}
+.row .lev-slot{justify-self:end;min-width:0}
+.wf-link{font:inherit;background:transparent;border:0;cursor:pointer;color:var(--warn);
+ font-size:.72rem;letter-spacing:.02em;white-space:nowrap;padding:.1rem 0}
+.wf-link:hover{color:var(--gold-hi);text-decoration:underline}
+.wf-link.fail{color:var(--fail)}
+.dash{color:var(--wash);font-size:.8rem;padding-right:6px}
+
+/* ---- updates quarantine strip ---- */
+.updates{margin-top:6px;padding-top:15px;border-top:1px solid var(--gold);
+ display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+.updates .u-tag{color:var(--gold);font-weight:700;font-size:.8rem;letter-spacing:.22em}
+.updates .u-body{color:var(--silver);font-size:.82rem;flex:1;min-width:260px}
+.updates .u-body .n{color:var(--cream);font-weight:700}
+.updates .u-body .cve{color:var(--fail);font-weight:700}
+.updates .u-body .pk{color:var(--dim)}
+.updates .btn{white-space:nowrap}
+
+/* ---- running / output-wall capsule ---- */
+.run-cap .hdr .mark .state{color:var(--warn)}
+.wall{margin-top:14px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:12px 14px;
+ display:flex;flex-direction:column;gap:9px}
+.wall .act{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;font-size:.84rem}
+.wall .act .step{color:var(--silver)}
+.wall .act .dots{color:var(--wash);letter-spacing:.1em;overflow:hidden}
+.wall .act .res{color:var(--dim);font-size:.76rem;white-space:nowrap;text-align:right}
+.wall .act.running .step{color:var(--cream)}
+.wall .act.running .res{color:var(--warn)}
+.wall .act.done .res{color:var(--ok)}
+.wall .act.queued .step{color:var(--dim)}
+.run-foot{display:flex;align-items:center;justify-content:space-between;margin-top:14px}
+.run-foot .prog{color:var(--dim);font-size:.76rem;letter-spacing:.04em}
+.run-foot .prog b{color:var(--cream);font-weight:700}
+
+/* ---- legend ---- */
+.legend{width:1000px;max-width:100%;display:flex;gap:18px;flex-wrap:wrap;
+ font-size:.7rem;color:var(--dim);padding:0 4px}
+.legend .item b{color:var(--silver);font-weight:700}
+.legend .item{white-space:nowrap}
+.legend .swatch{color:var(--gold)}
+
+@media(max-width:1040px){
+ .capsule,.legend{width:100%}
+ .board{grid-template-columns:1fr}
+}
+</style>
+</head>
+<body>
+
+<!-- =================== RESTING BOARD =================== -->
+<section class="capsule">
+ <div class="hdr">
+ <div class="mark">MAINT · <span class="host">ratio</span></div>
+ <div class="verdict">
+ <span class="lamp ok big">&#9679;</span>
+ <span class="word">healthy</span>
+ </div>
+ </div>
+ <div class="subhdr">Approach A — Two-column board</div>
+
+ <div class="board">
+
+ <!-- ============ LEFT / FIX ============ -->
+ <div class="col">
+ <div class="col-h"><span class="tag">FIX</span><span class="sub">actionable — auto &amp; confirm</span></div>
+
+ <div class="doctor">
+ <button class="btn big">
+ <span class="k">Clean up</span><span class="d">runs the Auto set unattended</span>
+ </button>
+ <button class="btn big ghost">
+ <span class="k">Review &amp; fix</span><span class="d">preview the Confirm set</span>
+ </button>
+ </div>
+
+ <!-- Storage -->
+ <div class="group">
+ <div class="group-h">Storage</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">cache size</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Clean</button></span></div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">btrfs scrub age</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Scrub</button></span></div>
+ <div class="row"><span class="lamp fail">&#9679;</span><span class="name">fstrim.timer</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Enable</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">deep trim (keep-1)</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Deep trim</button></span></div>
+ </div>
+
+ <!-- Snapshots -->
+ <div class="group">
+ <div class="group-h">Snapshots</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">snapper count/retention</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Prune</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">snapshot auto-timer</span><span class="cls">Conf</span><span class="val">active</span></div>
+ </div>
+
+ <!-- Packages -->
+ <div class="group">
+ <div class="group-h">Packages</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">orphaned packages</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Review</button></span></div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">.pacnew files</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Review</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">keyring freshness</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Refresh</button></span></div>
+ </div>
+
+ <!-- Logs -->
+ <div class="group">
+ <div class="group-h">Logs &amp; coredumps</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">coredumps</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Clear</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">journald disk usage</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Vacuum</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">app-log cleanup</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Run</button></span></div>
+ </div>
+
+ <!-- systemd -->
+ <div class="group">
+ <div class="group-h">systemd</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">maintenance timers firing</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Enable</button></span></div>
+ </div>
+
+ <!-- Network confirm -->
+ <div class="group">
+ <div class="group-h">Network</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">firewall active</span><span class="cls">Conf</span><span class="val">active</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">tailscale peers</span><span class="cls">Conf</span><span class="val">4/4</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">fail2ban</span><span class="cls">Conf</span><span class="val">active</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">NTP sync</span><span class="cls">Conf</span><span class="val">synced</span></div>
+ </div>
+
+ <!-- Services confirm -->
+ <div class="group">
+ <div class="group-h">Services</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">docker reclaimable</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Prune</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">cron</span><span class="cls">Conf</span><span class="val">ok</span></div>
+ </div>
+ </div>
+
+ <!-- ============ RIGHT / WATCH ============ -->
+ <div class="col">
+ <div class="col-h"><span class="tag">WATCH</span><span class="sub">read-only — none, human &amp; forensic</span></div>
+
+ <!-- Hardware -->
+ <div class="group">
+ <div class="group-h">Hardware</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">SMART health</span><span class="cls">None</span><span class="val">PASSED</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">SMART wear</span><span class="cls">None</span><span class="val">0%</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">btrfs device errors</span><span class="cls">None</span><span class="val">0/0</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">CPU/GPU temps</span><span class="cls">None</span><span class="val">61&deg;/54&deg;</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">thermal throttling</span><span class="cls">None</span><span class="val">no</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">kernel/hardware events</span><span class="cls">None</span><span class="val">clean</span></div>
+ </div>
+
+ <!-- System -->
+ <div class="group">
+ <div class="group-h">System &amp; disk</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">is-system-running</span><span class="cls">None</span><span class="val">running</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">disk usage</span><span class="cls">None</span><span class="val">69%</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">btrfs unallocated</span><span class="cls">None</span><span class="val">118 GiB</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">kernel taint flag</span><span class="cls">None</span><span class="val">0</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">pacman file integrity</span><span class="cls">Wflow</span><span class="val">clean</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">reboot required</span><span class="cls">Human</span><span class="val">no</span></div>
+ </div>
+
+ <!-- Forensic / systemd -->
+ <div class="group">
+ <div class="group-h">Forensic (workflow)</div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">failed units</span><span class="cls">Wflow</span><span class="val warn">1</span><span class="lev-slot"><button class="wf-link">&rarr; workflow</button></span></div>
+ <div class="row"><span class="lamp warn">&#9679;</span><span class="name">unclean-shutdown rate</span><span class="cls">Wflow</span><span class="val warn">75%</span><span class="lev-slot"><button class="wf-link">&rarr; workflow</button></span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">journal error count</span><span class="cls">Wflow</span><span class="val">12 real</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">memory free / OOM</span><span class="cls">Wflow</span><span class="val">104 GB free</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">rsyncshot backup</span><span class="cls">Wflow</span><span class="val">3h ago</span></div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">DNS / NetworkManager</span><span class="cls">Wflow</span><span class="val">ok</span></div>
+ </div>
+
+ <!-- Power / memory -->
+ <div class="group">
+ <div class="group-h">Memory &amp; power</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">swap / zram</span><span class="cls">None</span><span class="val">16 GiB</span></div>
+ <div class="row"><span class="lamp off">&#9675;</span><span class="name">battery health</span><span class="cls">None</span><span class="val">n/a</span></div>
+ </div>
+
+ <!-- Network posture -->
+ <div class="group">
+ <div class="group-h">Network posture</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">unexpected listeners</span><span class="cls">None</span><span class="val">3</span></div>
+ </div>
+
+ <!-- Services / virt -->
+ <div class="group">
+ <div class="group-h">Services &amp; virt</div>
+ <div class="row"><span class="lamp ok">&#9679;</span><span class="name">docker stopped containers</span><span class="cls">None</span><span class="val">2</span></div>
+ <div class="row"><span class="lamp off">&#9675;</span><span class="name">libvirt VMs</span><span class="cls">None</span><span class="val">off</span></div>
+ </div>
+ </div>
+
+ </div><!-- /board -->
+
+ <!-- quarantined updates strip -->
+ <div class="updates">
+ <span class="u-tag">UPDATES</span>
+ <span class="u-body"><span class="n">47 pending</span> &middot; <span class="pk">mesa, linux-lts, systemd</span> &middot; <span class="cve">3 CVEs</span></span>
+ <button class="btn">Run workflow</button>
+ </div>
+</section>
+
+<!-- =================== RUNNING / OUTPUT WALL =================== -->
+<section class="capsule run-cap">
+ <div class="hdr">
+ <div class="mark">MAINT · <span class="host">ratio</span> · <span class="state">CLEANING</span></div>
+ <div class="verdict">
+ <span class="lamp run big">&#9680;</span>
+ <span class="word" style="color:var(--warn)">working</span>
+ </div>
+ </div>
+ <div class="subhdr">Doctor has the surface — Auto set streaming</div>
+
+ <div class="wall">
+ <div class="act running">
+ <span class="lamp run">&#9680;</span>
+ <span class="step">cache trim</span>
+ <span class="res">running</span>
+ </div>
+ <div class="act done">
+ <span class="lamp ok">&#9679;</span>
+ <span class="step">journal vacuum</span>
+ <span class="res">done &middot; reclaimed 0.9 GB</span>
+ </div>
+ <div class="act done">
+ <span class="lamp ok">&#9679;</span>
+ <span class="step">coredump clear</span>
+ <span class="res">done &middot; 12 cleared</span>
+ </div>
+ <div class="act queued">
+ <span class="lamp off">&#9675;</span>
+ <span class="step">app-log cleanup</span>
+ <span class="res">queued</span>
+ </div>
+ </div>
+
+ <div class="run-foot">
+ <span class="prog"><b>2</b> of 4 done &middot; 1 running &middot; 1 queued</span>
+ <button class="btn done">Done</button>
+ </div>
+</section>
+
+<!-- =================== AUTOMATION-CLASS LEGEND =================== -->
+<div class="legend">
+ <span class="item"><span class="swatch">&#9679;</span> <b>Auto</b> — doctor "Clean up", fires unattended</span>
+ <span class="item"><span class="swatch">&#9679;</span> <b>Confirm</b> — doctor "Review &amp; fix", preview then one click</span>
+ <span class="item"><span class="swatch">&#9679;</span> <b>Human</b> — panel nudges, you act</span>
+ <span class="item"><span class="swatch">&#9679;</span> <b>Workflow</b> — escalate to the agent health-check</span>
+ <span class="item"><span class="swatch">&#9679;</span> <b>None</b> — diagnostic only, no fix</span>
+</div>
+<div class="legend" style="margin-top:-8px">
+ <span class="item"><span class="lamp ok">&#9679;</span> ok/pass</span>
+ <span class="item"><span class="lamp warn">&#9679;</span> warn</span>
+ <span class="item"><span class="lamp fail">&#9679;</span> fail</span>
+ <span class="item"><span class="lamp run">&#9680;</span> running</span>
+ <span class="item"><span class="lamp off">&#9675;</span> off / n/a</span>
+</div>
+
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html b/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html
new file mode 100644
index 0000000..ebc3acf
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html
@@ -0,0 +1,382 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — Approach B</title>
+<style>
+ :root{
+ --bg:#0a0c0d; --surface:#100f0f; --gold:#dab53d; --silver:#bfc4d0;
+ --dim:#7c838a; --cream:#f3e7c5; --slate:#424f5e; --slate-h:#54677d;
+ --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d; --na:#7c838a;
+ --inset:#0a0c0d; --inset-bd:#2c2f32;
+ }
+ *{box-sizing:border-box}
+ body{
+ margin:0; padding:34px 16px; background:var(--bg);
+ font-family:"BerkeleyMono Nerd Font", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ color:var(--silver); font-size:13px; line-height:1.45;
+ display:flex; flex-direction:column; align-items:center; gap:20px;
+ }
+ .console{
+ width:1000px; max-width:100%; background:var(--surface);
+ border:1px solid var(--gold); border-radius:16px; padding:18px 20px 22px;
+ }
+ /* header */
+ .hdr{display:flex; align-items:baseline; justify-content:space-between; gap:16px;}
+ .hdr .title{color:var(--cream); font-size:16px; letter-spacing:.5px;}
+ .hdr .title b{color:var(--gold);}
+ .hdr .verdict{font-size:14px; color:var(--ok); font-weight:bold; letter-spacing:.5px;}
+ .sub{color:var(--dim); font-size:12px; margin-top:2px; margin-bottom:14px;}
+
+ /* global action bar */
+ .actionbar{
+ display:flex; align-items:center; gap:10px; flex-wrap:wrap;
+ padding:11px 13px; margin-bottom:16px;
+ background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px;
+ }
+ .actionbar .grp{display:flex; gap:8px;}
+ .actionbar .spacer{flex:1 1 auto;}
+ button{
+ font-family:inherit; font-size:12px; cursor:pointer;
+ background:var(--slate); color:var(--cream); border:1px solid #33404d;
+ border-radius:10px; padding:6px 12px;
+ }
+ button:hover{background:var(--slate-h);}
+ button.ghost{background:transparent; color:var(--silver); border-color:var(--inset-bd); padding:5px 10px;}
+ button.ghost:hover{background:#161a1d; color:var(--cream);}
+ button.tiny{font-size:11px; padding:3px 9px; border-radius:8px;}
+
+ /* updates chip */
+ .updates-chip{
+ display:flex; align-items:center; gap:12px; margin-left:auto;
+ padding:6px 12px; border:1px solid var(--fail); border-radius:999px;
+ background:rgba(203,107,77,.09);
+ }
+ .updates-chip .u-lamp{color:var(--fail);}
+ .updates-chip .u-txt{color:var(--cream); font-size:12px;}
+ .updates-chip .u-txt b{color:var(--warn);}
+
+ /* grid */
+ .grid{
+ display:grid; grid-template-columns:repeat(3, 1fr); gap:12px;
+ }
+ .tile{
+ background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px;
+ padding:11px 12px 12px; display:flex; flex-direction:column; gap:8px;
+ cursor:pointer; transition:border-color .12s;
+ }
+ .tile:hover{border-color:#3c4a58;}
+ .tile.expanded{grid-column:1 / -1; cursor:default; border-color:var(--slate-h);}
+ .tile-top{display:flex; align-items:flex-start; gap:9px;}
+ .rollup{font-size:16px; line-height:1.1; margin-top:1px;}
+ .tile-head{flex:1 1 auto; min-width:0;}
+ .tile-name{color:var(--gold); font-weight:bold; letter-spacing:.3px;}
+ .tile-finding{color:var(--silver); font-size:12px; margin-top:2px;
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
+ .tile-finding.warnt{color:var(--warn);}
+ .tile-finding.failt{color:var(--fail);}
+ .tile-foot{display:flex; align-items:center; gap:8px; justify-content:space-between;}
+ .counts{color:var(--dim); font-size:11px;}
+ .counts .fix{color:var(--silver);}
+ .expander{color:var(--dim); font-size:11px;}
+
+ /* lamps */
+ .lamp{font-size:13px;}
+ .l-ok{color:var(--ok);} .l-warn{color:var(--warn);} .l-fail{color:var(--fail);}
+ .l-na{color:var(--na);} .l-prog{color:var(--warn);}
+
+ /* expanded detail */
+ .detail{border-top:1px solid var(--inset-bd); padding-top:9px; margin-top:2px;}
+ .detail-hint{color:var(--dim); font-size:11px; margin-bottom:7px;}
+ .row{
+ display:grid; grid-template-columns:16px 1fr auto auto;
+ align-items:center; gap:10px; padding:4px 4px; border-radius:6px;
+ }
+ .row:hover{background:#141719;}
+ .row + .row{border-top:1px solid #17191b;}
+ .r-name{color:var(--silver);}
+ .r-val{color:var(--dim); font-size:12px; text-align:right; white-space:nowrap;}
+ .r-lever{justify-self:end; min-width:96px; text-align:right;}
+ .r-lever .none{color:#4d5358;}
+ .r-auto{font-size:10px; color:var(--dim); text-transform:uppercase; letter-spacing:.5px;
+ border:1px solid var(--inset-bd); border-radius:5px; padding:1px 5px; margin-left:6px;}
+
+ /* running capsule */
+ .capsule{
+ width:1000px; max-width:100%; background:var(--surface);
+ border:1px solid var(--gold); border-radius:16px; padding:14px 18px 16px;
+ }
+ .cap-hdr{display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;}
+ .cap-hdr .title{color:var(--cream); font-size:14px; letter-spacing:.5px;}
+ .cap-hdr .title b{color:var(--gold);}
+ .cap-hdr .running{color:var(--warn); font-weight:bold;}
+ .wall{
+ background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px;
+ padding:10px 12px; display:flex; flex-direction:column; gap:6px;
+ }
+ .wall .line{display:flex; align-items:center; gap:10px;}
+ .wall .line .glyph{width:14px; text-align:center;}
+ .wall .line .act{color:var(--silver); min-width:180px;}
+ .wall .line .st{color:var(--dim); font-size:12px;}
+ .wall .line .st b{color:var(--ok);}
+ .wall .line.done .act{color:var(--dim);}
+ .wall .line.queued .act{color:var(--dim);}
+ .cap-foot{display:flex; justify-content:flex-end; margin-top:11px;}
+ .legend{
+ width:1000px; max-width:100%; color:var(--dim); font-size:11px;
+ padding:0 6px; line-height:1.6;
+ }
+ .legend b{color:var(--silver); font-weight:normal;}
+</style>
+</head>
+<body>
+
+<div class="console">
+ <div class="hdr">
+ <div>
+ <div class="title">MAINT · <b>ratio</b></div>
+ </div>
+ <div class="verdict">● healthy</div>
+ </div>
+ <div class="sub">Approach B — Domain-tile grid</div>
+
+ <!-- global actions -->
+ <div class="actionbar">
+ <div class="grp">
+ <button>Clean up</button>
+ <button class="ghost">Review &amp; fix</button>
+ </div>
+ <div class="spacer"></div>
+ <div class="updates-chip">
+ <span class="u-lamp">●</span>
+ <span class="u-txt"><b>47 pending</b> · 3 CVE · mesa,linux-lts</span>
+ <button class="tiny">Run workflow</button>
+ </div>
+ </div>
+
+ <!-- domain grid -->
+ <div class="grid">
+
+ <!-- STORAGE (expanded) -->
+ <div class="tile expanded" id="t-storage">
+ <div class="tile-top">
+ <span class="rollup l-fail">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Storage</div>
+ <div class="tile-finding failt">fstrim off · scrub 34d</div>
+ </div>
+ <div class="tile-foot" style="flex-direction:column; align-items:flex-end; gap:6px;">
+ <button class="tiny">Clean</button>
+ <span class="counts"><span class="fix">4 fix</span> · 6 watch</span>
+ </div>
+ </div>
+ <div class="detail">
+ <div class="detail-hint">10 checks — lever shown where the doctor can act</div>
+
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">cache<span class="r-auto">auto</span></span><span class="r-val">8.8 GB</span><span class="r-lever"><button class="tiny">Clean</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">deep-trim<span class="r-auto">confirm</span></span><span class="r-val">keep-3</span><span class="r-lever"><button class="tiny">Deep trim</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">disk</span><span class="r-val">69%</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">btrfs unalloc</span><span class="r-val">118 GiB</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">btrfs scrub<span class="r-auto">confirm</span></span><span class="r-val">34 d</span><span class="r-lever"><button class="tiny">Scrub</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">btrfs device-err</span><span class="r-val">0 / 0</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">SMART</span><span class="r-val">PASSED</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">SMART wear</span><span class="r-val">0%</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-fail">●</span><span class="r-name">fstrim.timer<span class="r-auto">confirm</span></span><span class="r-val">off</span><span class="r-lever"><button class="tiny">Enable</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">pacman integrity<span class="r-auto">workflow</span></span><span class="r-val">clean</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- SNAPSHOTS -->
+ <div class="tile" onclick="toggle('snap')" id="t-snap">
+ <div class="tile-top">
+ <span class="rollup l-ok">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Snapshots</div>
+ <div class="tile-finding">42 snaps · timer active</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">1 fix</span> · 1 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-snap" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">snapper count<span class="r-auto">confirm</span></span><span class="r-val">42</span><span class="r-lever"><button class="tiny">Prune</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">snapshot timer<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- PACKAGES & SECURITY -->
+ <div class="tile" onclick="toggle('pkg')" id="t-pkg">
+ <div class="tile-top">
+ <span class="rollup l-fail">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Packages &amp; Security</div>
+ <div class="tile-finding failt">3 CVE · 47 updates</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">3 fix</span> · 4 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-pkg" style="display:none;">
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">orphans<span class="r-auto">confirm</span></span><span class="r-val">13</span><span class="r-lever"><button class="tiny">Review</button></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">.pacnew<span class="r-auto">confirm</span></span><span class="r-val">2</span><span class="r-lever"><button class="tiny">Review</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">keyring<span class="r-auto">confirm</span></span><span class="r-val">12 d</span><span class="r-lever"><button class="tiny">Refresh</button></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">pending updates<span class="r-auto">workflow</span></span><span class="r-val">47</span><span class="r-lever"><button class="tiny">Updates</button></span></div>
+ <div class="row"><span class="lamp l-fail">●</span><span class="r-name">arch-audit<span class="r-auto">workflow</span></span><span class="r-val">3 CVE</span><span class="r-lever"><button class="tiny">Updates</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">AUR staleness<span class="r-auto">workflow</span></span><span class="r-val">5</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">reboot-required<span class="r-auto">human</span></span><span class="r-val">no</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- SYSTEMD & BOOT -->
+ <div class="tile" onclick="toggle('sd')" id="t-sd">
+ <div class="tile-top">
+ <span class="rollup l-warn">●</span>
+ <div class="tile-head">
+ <div class="tile-name">systemd &amp; Boot</div>
+ <div class="tile-finding warnt">1 failed · timers 4/5</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">1 fix</span> · 3 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-sd" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">is-system-running</span><span class="r-val">running</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">failed units<span class="r-auto">workflow</span></span><span class="r-val">1</span><span class="r-lever"><button class="tiny">Run workflow</button></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">maintenance timers<span class="r-auto">confirm</span></span><span class="r-val">4 / 5</span><span class="r-lever"><button class="tiny">Enable</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">kernel taint</span><span class="r-val">0</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- LOGS & COREDUMPS -->
+ <div class="tile" onclick="toggle('log')" id="t-log">
+ <div class="tile-top">
+ <span class="rollup l-warn">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Logs &amp; Coredumps</div>
+ <div class="tile-finding warnt">18 coredumps / 7d</div>
+ </div>
+ <div class="tile-foot" style="flex-direction:column; align-items:flex-end; gap:6px;">
+ <button class="tiny" onclick="event.stopPropagation()">Clean</button>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">3 fix</span> · 2 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-log" style="display:none;">
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">coredumps<span class="r-auto">auto</span></span><span class="r-val">18 / 7d</span><span class="r-lever"><button class="tiny">Clear</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">journald<span class="r-auto">auto</span></span><span class="r-val">1.2 GB</span><span class="r-lever"><button class="tiny">Vacuum</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">app-log cleanup<span class="r-auto">auto</span></span><span class="r-val">ok</span><span class="r-lever"><button class="tiny">Run</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">journal errors<span class="r-auto">workflow</span></span><span class="r-val">12 real</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">kernel/hw events</span><span class="r-val">clean</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- MEMORY / THERMAL / POWER -->
+ <div class="tile" onclick="toggle('mem')" id="t-mem">
+ <div class="tile-top">
+ <span class="rollup l-warn">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Memory / Thermal / Power</div>
+ <div class="tile-finding warnt">unclean-shutdown 75%</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">0 fix</span> · 6 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-mem" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">memory<span class="r-auto">workflow</span></span><span class="r-val">104 GB free</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">swap / zram</span><span class="r-val">16 GiB</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">temps</span><span class="r-val">61° / 54°</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">thermal throttle</span><span class="r-val">no</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-na">○</span><span class="r-name">battery</span><span class="r-val">n/a</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">unclean-shutdown<span class="r-auto">workflow</span></span><span class="r-val">75%</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- NETWORK & POSTURE -->
+ <div class="tile" onclick="toggle('net')" id="t-net">
+ <div class="tile-top">
+ <span class="rollup l-ok">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Network &amp; Posture</div>
+ <div class="tile-finding">all posture green</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">4 fix</span> · 2 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-net" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">DNS / NM<span class="r-auto">workflow</span></span><span class="r-val">ok</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">firewall<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">listeners</span><span class="r-val">3</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">tailscale<span class="r-auto">confirm</span></span><span class="r-val">4 / 4</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">fail2ban<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">NTP<span class="r-auto">confirm</span></span><span class="r-val">synced</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- SERVICES / BACKUPS / VIRT -->
+ <div class="tile" onclick="toggle('svc')" id="t-svc">
+ <div class="tile-top">
+ <span class="rollup l-warn">●</span>
+ <div class="tile-head">
+ <div class="tile-name">Services &amp; Backups</div>
+ <div class="tile-finding warnt">docker reclaim 3 GB</div>
+ </div>
+ </div>
+ <div class="tile-foot">
+ <span class="counts"><span class="fix">2 fix</span> · 3 watch</span>
+ <span class="expander">▸ detail</span>
+ </div>
+ <div class="detail" id="d-svc" style="display:none;">
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">rsyncshot<span class="r-auto">workflow</span></span><span class="r-val">3h ago</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="r-name">docker reclaim<span class="r-auto">confirm</span></span><span class="r-val">3 GB</span><span class="r-lever"><button class="tiny">Prune</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">docker stopped</span><span class="r-val">2</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-na">○</span><span class="r-name">libvirt</span><span class="r-val">off</span><span class="r-lever"><span class="none">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="r-name">cron<span class="r-auto">confirm</span></span><span class="r-val">ok</span><span class="r-lever"><span class="none">—</span></span></div>
+ </div>
+ </div>
+
+ </div>
+</div>
+
+<!-- running / output-wall capsule -->
+<div class="capsule">
+ <div class="cap-hdr">
+ <div class="title">MAINT · <b>ratio</b> · <span class="running">CLEANING ◐</span></div>
+ <span style="color:var(--dim); font-size:12px;">4 Auto actions</span>
+ </div>
+ <div class="wall">
+ <div class="line"><span class="glyph l-prog">◐</span><span class="act">cache trim</span><span class="st">running…</span></div>
+ <div class="line done"><span class="glyph l-ok">●</span><span class="act">journal vacuum</span><span class="st">done · reclaimed <b>0.9 GB</b></span></div>
+ <div class="line done"><span class="glyph l-ok">●</span><span class="act">coredump clear</span><span class="st">done · <b>12 cleared</b></span></div>
+ <div class="line queued"><span class="glyph l-na">○</span><span class="act">app-log cleanup</span><span class="st">queued</span></div>
+ </div>
+ <div class="cap-foot"><button>Done</button></div>
+</div>
+
+<div class="legend">
+ Automation — <b>Auto</b> doctor cleans unattended · <b>Confirm</b> doctor previews then you click · <b>Human</b> panel nudges you to act · <b>Workflow</b> escalates to an agent workflow · <b>None</b> diagnostic only.
+</div>
+
+<script>
+ function toggle(id){
+ var t = document.getElementById('t-'+id);
+ var d = document.getElementById('d-'+id);
+ if(!d) return;
+ var open = d.style.display !== 'none';
+ d.style.display = open ? 'none' : 'block';
+ t.classList.toggle('expanded', !open);
+ var ex = t.querySelector('.expander');
+ if(ex) ex.textContent = open ? '▸ detail' : '▾ detail';
+ }
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html b/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html
new file mode 100644
index 0000000..90182d6
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html
@@ -0,0 +1,410 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — Approach C · Severity feed</title>
+<style>
+ :root{
+ --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d;
+ --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5;
+ --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32;
+ --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;padding:0}
+ body{
+ background:var(--page);
+ color:var(--silver);
+ font-family:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+ font-size:13px; line-height:1.45;
+ display:flex; flex-direction:column; align-items:center;
+ padding:28px 16px 60px;
+ -webkit-font-smoothing:antialiased;
+ }
+ .console{
+ width:1000px; max-width:100%;
+ background:var(--surface);
+ border:1px solid var(--gold);
+ border-radius:16px;
+ padding:18px;
+ margin-bottom:20px;
+ }
+
+ /* ---- header ---- */
+ .top{display:flex; align-items:baseline; justify-content:space-between; gap:12px}
+ .title{font-weight:700; color:var(--cream); font-size:16px; letter-spacing:.5px}
+ .title .sub-host{color:var(--dim); font-weight:400; font-size:13px}
+ .verdict{font-weight:700; font-size:14px; color:var(--ok)}
+ .subtitle{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.3px}
+
+ /* ---- action bar ---- */
+ .actionbar{
+ display:flex; align-items:center; gap:10px; flex-wrap:wrap;
+ margin:14px 0 6px;
+ }
+ .btn{
+ background:var(--slate); color:var(--cream);
+ border:1px solid #2f3a45; border-radius:10px;
+ padding:7px 14px; font-family:inherit; font-size:12px; font-weight:600;
+ cursor:pointer; letter-spacing:.3px;
+ }
+ .btn:hover{background:var(--slate-hi)}
+ .btn.small{padding:4px 10px; font-size:11px; font-weight:500}
+ .btn.wide{padding:7px 18px}
+ .spacer{flex:1 1 auto}
+
+ /* quarantined updates pill */
+ .updates-pill{
+ display:flex; align-items:center; gap:12px;
+ background:var(--inset); border:1px solid var(--line);
+ border-left:3px solid var(--warn);
+ border-radius:10px; padding:6px 8px 6px 12px;
+ }
+ .updates-pill .u-text{font-size:12px; color:var(--silver)}
+ .updates-pill .u-text b{color:var(--cream); font-weight:600}
+ .updates-pill .u-cve{color:var(--fail); font-weight:600}
+ .updates-pill .u-pkgs{color:var(--dim)}
+
+ /* ---- feed ---- */
+ .feed{margin-top:14px}
+ .band-head{
+ display:flex; align-items:center; gap:10px;
+ color:var(--gold); font-weight:700; font-size:12px;
+ letter-spacing:1.2px; text-transform:uppercase;
+ margin:16px 0 6px; padding-bottom:5px;
+ border-bottom:1px solid var(--line);
+ }
+ .band-head .count{color:var(--dim); font-weight:400; letter-spacing:.3px; text-transform:none}
+ .band-head .accent{
+ display:inline-block; width:8px; height:8px; border-radius:2px;
+ }
+ .band-fail .accent{background:var(--fail)}
+ .band-warn .accent{background:var(--warn)}
+ .band-ok .accent{background:var(--ok)}
+
+ .rows{
+ background:var(--inset); border:1px solid var(--line);
+ border-radius:10px; padding:4px 2px;
+ }
+
+ .row{
+ display:flex; align-items:center; gap:10px;
+ padding:6px 12px;
+ border-bottom:1px solid rgba(44,47,50,.5);
+ }
+ .row:last-child{border-bottom:none}
+ .row .lamp{flex:0 0 auto; font-size:13px; width:14px; text-align:center}
+ .row .name{flex:0 0 auto; color:var(--silver); white-space:nowrap}
+ .row .dots{
+ flex:1 1 auto; min-width:16px;
+ border-bottom:1px dotted #3a3f43;
+ transform:translateY(-3px);
+ margin:0 4px;
+ }
+ .row .value{flex:0 0 auto; color:var(--cream); white-space:nowrap; text-align:right}
+ .row .value.warnval{color:var(--warn)}
+ .row .value.failval{color:var(--fail)}
+ .row .chip{
+ flex:0 0 auto; width:64px; text-align:center;
+ font-size:10px; letter-spacing:.4px; text-transform:uppercase;
+ padding:2px 0; border-radius:6px; border:1px solid var(--line);
+ color:var(--dim);
+ }
+ .chip.auto{color:#a9c05a; border-color:#3c4a24}
+ .chip.confirm{color:var(--gold); border-color:#4a4023}
+ .chip.workflow{color:var(--slate-hi); border-color:#33414f}
+ .chip.human{color:var(--cream); border-color:#4a4436}
+ .chip.none{color:var(--dim); border-color:var(--line)}
+ .row .control{flex:0 0 auto; width:118px; text-align:right}
+ .row .control .lever{
+ background:var(--slate); color:var(--cream);
+ border:1px solid #2f3a45; border-radius:8px;
+ padding:3px 10px; font-family:inherit; font-size:11px; cursor:pointer;
+ }
+ .row .control .lever:hover{background:var(--slate-hi)}
+ .row .control .wflink{color:var(--slate-hi); font-size:11px; cursor:pointer}
+ .row .control .wflink:hover{color:var(--cream); text-decoration:underline}
+ .row .control .nolever{color:#4a5054; font-size:11px}
+
+ /* lamps */
+ .l-ok{color:var(--ok)}
+ .l-warn{color:var(--warn)}
+ .l-fail{color:var(--fail)}
+ .l-off{color:var(--dim)}
+ .l-prog{color:var(--warn)}
+
+ /* OK band quieting */
+ .band-ok-summary{
+ display:flex; align-items:center; gap:8px;
+ color:var(--dim); font-size:12px; cursor:pointer;
+ margin:16px 0 6px; padding-bottom:5px;
+ border-bottom:1px solid var(--line);
+ }
+ .band-ok-summary .tri{color:var(--gold)}
+ .band-ok .rows{opacity:.72}
+ .band-ok .row{padding:4px 12px}
+ .band-ok .row .name{color:var(--dim)}
+ .band-ok .row .value{color:var(--silver)}
+
+ /* ---- running capsule ---- */
+ .capsule{
+ width:1000px; max-width:100%;
+ background:var(--surface);
+ border:1px solid var(--gold);
+ border-radius:16px; padding:16px 18px;
+ }
+ .cap-head{
+ display:flex; align-items:center; justify-content:space-between;
+ color:var(--cream); font-weight:700; font-size:13px; letter-spacing:.5px;
+ margin-bottom:10px;
+ }
+ .cap-head .run{color:var(--warn)}
+ .wall{
+ background:var(--inset); border:1px solid var(--line); border-radius:10px;
+ padding:10px 14px; font-size:12px;
+ }
+ .wall .line{display:flex; align-items:center; gap:10px; padding:3px 0}
+ .wall .line .lamp{width:14px; text-align:center}
+ .wall .line .act{color:var(--silver)}
+ .wall .line .act .done{color:var(--dim)}
+ .wall .line .res{color:var(--ok); margin-left:auto}
+ .wall .line.queued .act{color:var(--dim)}
+ .cap-foot{display:flex; justify-content:flex-end; margin-top:12px}
+
+ .legend{
+ width:1000px; max-width:100%;
+ color:var(--dim); font-size:11px; line-height:1.6;
+ margin-top:16px; padding:0 4px;
+ }
+ .legend b{color:var(--silver); font-weight:600}
+</style>
+</head>
+<body>
+
+<div class="console">
+ <div class="top">
+ <div>
+ <div class="title">MAINT <span class="sub-host">· ratio</span></div>
+ <div class="subtitle">Approach C — Severity feed</div>
+ </div>
+ <div class="verdict">● healthy</div>
+ </div>
+
+ <!-- action bar -->
+ <div class="actionbar">
+ <button class="btn wide">Clean up <span style="color:var(--dim);font-weight:400">· 4 Auto</span></button>
+ <button class="btn wide">Review &amp; fix <span style="color:var(--dim);font-weight:400">· 15 Confirm</span></button>
+ <div class="spacer"></div>
+ <div class="updates-pill">
+ <span class="u-text"><b>47 pending</b> · <span class="u-cve">3 CVE</span> · <span class="u-pkgs">mesa, linux-lts</span></span>
+ <button class="btn small">Run workflow</button>
+ </div>
+ </div>
+
+ <div class="feed">
+
+ <!-- ===================== FAIL ===================== -->
+ <div class="band-head band-fail"><span class="accent"></span>Fail <span class="count">— act now · 2</span></div>
+ <div class="rows">
+ <div class="row">
+ <span class="lamp l-fail">●</span>
+ <span class="name">fstrim.timer</span>
+ <span class="dots"></span>
+ <span class="value failval">off</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Enable</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-fail">●</span>
+ <span class="name">arch-audit</span>
+ <span class="dots"></span>
+ <span class="value failval">3 CVE</span>
+ <span class="chip workflow">Workflow</span>
+ <span class="control"><span class="wflink">→ workflow</span></span>
+ </div>
+ </div>
+
+ <!-- ===================== WARN ===================== -->
+ <div class="band-head band-warn"><span class="accent"></span>Warn <span class="count">— review · 13</span></div>
+ <div class="rows">
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">cache</span>
+ <span class="dots"></span>
+ <span class="value warnval">8.8&nbsp;GB</span>
+ <span class="chip auto">Auto</span>
+ <span class="control"><button class="lever">Clean</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">btrfs scrub</span>
+ <span class="dots"></span>
+ <span class="value warnval">34d ago</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Scrub</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">orphans</span>
+ <span class="dots"></span>
+ <span class="value warnval">13</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Review</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">.pacnew files</span>
+ <span class="dots"></span>
+ <span class="value warnval">2</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Review</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">pending updates</span>
+ <span class="dots"></span>
+ <span class="value warnval">47</span>
+ <span class="chip workflow">Workflow</span>
+ <span class="control"><span class="wflink">→ workflow</span></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">failed units</span>
+ <span class="dots"></span>
+ <span class="value warnval">1</span>
+ <span class="chip workflow">Workflow</span>
+ <span class="control"><span class="wflink">→ workflow</span></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">maintenance timers</span>
+ <span class="dots"></span>
+ <span class="value warnval">4/5</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Enable</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">coredumps</span>
+ <span class="dots"></span>
+ <span class="value warnval">18 / 7d</span>
+ <span class="chip auto">Auto</span>
+ <span class="control"><button class="lever">Clear</button></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">unclean-shutdown</span>
+ <span class="dots"></span>
+ <span class="value warnval">75%</span>
+ <span class="chip workflow">Workflow</span>
+ <span class="control"><span class="wflink">→ workflow</span></span>
+ </div>
+ <div class="row">
+ <span class="lamp l-warn">●</span>
+ <span class="name">docker reclaim</span>
+ <span class="dots"></span>
+ <span class="value warnval">3&nbsp;GB</span>
+ <span class="chip confirm">Confirm</span>
+ <span class="control"><button class="lever">Prune</button></span>
+ </div>
+ </div>
+
+ <!-- ===================== OK (quieted) ===================== -->
+ <div class="band-ok" id="okband">
+ <div class="band-ok-summary" onclick="var b=document.getElementById('okrows');b.style.display=b.style.display==='none'?'block':'none';this.querySelector('.tri').textContent=b.style.display==='none'?'▸':'▾';">
+ <span class="tri">▾</span>
+ <span style="color:var(--ok)">●</span>
+ <span>29 OK — clean, no action</span>
+ <span style="flex:1"></span>
+ <span style="color:#565c60">collapse</span>
+ </div>
+ <div class="rows" id="okrows">
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">deep-trim keep-3</span><span class="dots"></span><span class="value">available</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Deep trim</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">disk usage</span><span class="dots"></span><span class="value">69%</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">btrfs unalloc</span><span class="dots"></span><span class="value">118&nbsp;GiB</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">btrfs device-err</span><span class="dots"></span><span class="value">0 / 0</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">SMART status</span><span class="dots"></span><span class="value">PASSED</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">SMART wear</span><span class="dots"></span><span class="value">0%</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">pacman integrity</span><span class="dots"></span><span class="value">clean</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">snapper count</span><span class="dots"></span><span class="value">42</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Prune</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">snapshot timer</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">keyring</span><span class="dots"></span><span class="value">12d</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Refresh</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">AUR staleness</span><span class="dots"></span><span class="value">5</span><span class="chip workflow">Workflow</span><span class="control"><span class="wflink">→ workflow</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">reboot-required</span><span class="dots"></span><span class="value">no</span><span class="chip human">Human</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">is-system-running</span><span class="dots"></span><span class="value">running</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">kernel taint</span><span class="dots"></span><span class="value">0</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">journald size</span><span class="dots"></span><span class="value">1.2&nbsp;GB</span><span class="chip auto">Auto</span><span class="control"><button class="lever">Vacuum</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">app-log cleanup</span><span class="dots"></span><span class="value">ok</span><span class="chip auto">Auto</span><span class="control"><button class="lever">Run</button></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">journal errors</span><span class="dots"></span><span class="value">12 real</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">kernel/hw events</span><span class="dots"></span><span class="value">clean</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">memory</span><span class="dots"></span><span class="value">104&nbsp;GB free</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">swap/zram</span><span class="dots"></span><span class="value">16&nbsp;GiB</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">temps</span><span class="dots"></span><span class="value">61° / 54°</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">thermal throttle</span><span class="dots"></span><span class="value">no</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">DNS / NM</span><span class="dots"></span><span class="value">ok</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">firewall</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">listeners</span><span class="dots"></span><span class="value">3</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">tailscale</span><span class="dots"></span><span class="value">4/4</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">fail2ban</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">NTP</span><span class="dots"></span><span class="value">synced</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">rsyncshot</span><span class="dots"></span><span class="value">3h ago</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">docker stopped</span><span class="dots"></span><span class="value">2</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="name">cron</span><span class="dots"></span><span class="value">ok</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div>
+ </div>
+ </div>
+
+ <!-- ===================== OFF / N-A ===================== -->
+ <div class="band-ok" style="margin-top:6px">
+ <div class="rows" style="opacity:.55">
+ <div class="row"><span class="lamp l-off">○</span><span class="name" style="color:var(--dim)">battery</span><span class="dots"></span><span class="value" style="color:var(--dim)">n/a</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ <div class="row"><span class="lamp l-off">○</span><span class="name" style="color:var(--dim)">libvirt</span><span class="dots"></span><span class="value" style="color:var(--dim)">off</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div>
+ </div>
+ </div>
+
+ </div>
+</div>
+
+<!-- =============== RUNNING / OUTPUT-WALL capsule =============== -->
+<div class="capsule">
+ <div class="cap-head">
+ <span>MAINT · ratio · CLEANING <span class="run">◐</span></span>
+ <span style="color:var(--dim); font-weight:400; font-size:12px">4 Auto actions</span>
+ </div>
+ <div class="wall">
+ <div class="line">
+ <span class="lamp l-prog">◐</span>
+ <span class="act">cache trim <span style="color:var(--dim)">running…</span></span>
+ </div>
+ <div class="line">
+ <span class="lamp l-ok">●</span>
+ <span class="act"><span class="done">journal vacuum — done</span></span>
+ <span class="res">reclaimed 0.9&nbsp;GB</span>
+ </div>
+ <div class="line">
+ <span class="lamp l-ok">●</span>
+ <span class="act"><span class="done">coredump clear — done</span></span>
+ <span class="res">12 cleared</span>
+ </div>
+ <div class="line queued">
+ <span class="lamp l-off">○</span>
+ <span class="act">app-log cleanup <span style="color:#565c60">queued</span></span>
+ </div>
+ </div>
+ <div class="cap-foot">
+ <button class="btn">Done</button>
+ </div>
+</div>
+
+<div class="legend">
+ <b>Automation classes:</b>
+ <b>Auto</b> — doctor "Clean up" runs unattended ·
+ <b>Confirm</b> — doctor "Review &amp; fix" preview then click ·
+ <b>Human</b> — you act ·
+ <b>Workflow</b> — escalate to an agent workflow ·
+ <b>None</b> — diagnostic only.
+ &nbsp;&nbsp;<b>Lamps:</b> ● ok / warn / fail · ◐ in-progress · ○ off-or-n/a. ZFS rows omitted (btrfs host).
+</div>
+
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html b/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html
new file mode 100644
index 0000000..7f2a86e
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html
@@ -0,0 +1,364 @@
+<!-- Approach D — Doctor-first maintenance console mockup (ratio) -->
+<title>MAINT · ratio — Approach D Doctor-first</title>
+<style>
+ :root{
+ --bg:#0a0c0d; --surface:#100f0f; --gold:#dab53d; --silver:#bfc4d0;
+ --dim:#7c838a; --cream:#f3e7c5; --slate:#424f5e; --slate-h:#54677d;
+ --inset:#0a0c0d; --line:#2c2f32;
+ --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;padding:0}
+ body{
+ background:var(--bg);
+ color:var(--silver);
+ font-family:"BerkeleyMono Nerd Font", ui-monospace, monospace;
+ font-size:13px; line-height:1.45;
+ display:flex; justify-content:center;
+ padding:28px 16px 60px;
+ -webkit-font-smoothing:antialiased;
+ }
+ .console{
+ width:1000px; max-width:100%;
+ background:var(--surface);
+ border:1px solid var(--gold);
+ border-radius:16px;
+ padding:18px;
+ }
+
+ /* ---- header ---- */
+ .head{display:flex; align-items:baseline; justify-content:space-between; gap:12px}
+ .head .title{color:var(--cream); font-size:15px; letter-spacing:.06em; font-weight:700}
+ .head .title .sub{display:block; color:var(--dim); font-size:11px; font-weight:400; letter-spacing:.02em; margin-top:2px}
+ .verdict{font-size:14px; font-weight:700; letter-spacing:.03em}
+ .verdict .lamp{font-size:13px}
+
+ .rule{height:1px; background:var(--line); border:0; margin:14px 0}
+
+ /* ---- lamps ---- */
+ .lamp{font-style:normal}
+ .ok{color:var(--olive)}
+ .warn{color:var(--amber)}
+ .fail{color:var(--terra)}
+ .na{color:var(--dim)}
+ .run{color:var(--amber)}
+
+ /* ---- generic inset ---- */
+ .inset{background:var(--inset); border:1px solid var(--line); border-radius:10px; padding:14px}
+ .sec-h{color:var(--gold); font-weight:700; letter-spacing:.05em; font-size:12px; text-transform:uppercase; margin:0 0 10px}
+
+ /* ---- buttons ---- */
+ button{font-family:inherit; cursor:pointer}
+ .btn{
+ background:var(--slate); color:var(--cream); border:1px solid #33414d;
+ border-radius:10px; padding:9px 16px; font-size:12px; letter-spacing:.03em;
+ transition:background .12s;
+ }
+ .btn:hover{background:var(--slate-h)}
+ .btn-lever{padding:4px 10px; font-size:11px; border-radius:8px}
+
+ /* ================= HERO ================= */
+ .hero{
+ background:linear-gradient(180deg,#141312,#0d0c0c);
+ border:1px solid var(--line); border-radius:14px;
+ padding:22px 22px 24px; text-align:center;
+ }
+ .hero .stethoscope{font-size:26px; color:var(--gold); letter-spacing:.3em; margin-bottom:6px}
+ .hero .stat-line{color:var(--silver); font-size:13px; letter-spacing:.04em}
+ .hero .stat-line b{color:var(--cream); font-weight:700}
+ .hero .stat-line .fail{font-weight:700}
+
+ /* summary lamp strip */
+ .strip{display:flex; flex-wrap:wrap; gap:3px; justify-content:center; margin:14px auto 4px; max-width:720px}
+ .strip .dot{font-size:11px; line-height:1}
+
+ .dbig{display:flex; gap:16px; justify-content:center; margin-top:20px; flex-wrap:wrap}
+ .doc-btn{
+ background:var(--slate); color:var(--cream);
+ border:1px solid #33414d; border-radius:12px;
+ padding:16px 26px; min-width:280px; text-align:center;
+ transition:background .12s;
+ }
+ .doc-btn:hover{background:var(--slate-h)}
+ .doc-btn .big{display:block; font-size:17px; font-weight:700; letter-spacing:.08em; color:var(--cream)}
+ .doc-btn .sub{display:block; font-size:11px; color:#cdd7cf; margin-top:6px; letter-spacing:.02em; opacity:.85}
+ .doc-btn.primary{border-color:var(--gold); box-shadow:0 0 0 1px rgba(218,181,61,.25) inset}
+ .doc-btn.primary .big{color:var(--cream)}
+
+ .hero-foot{color:var(--dim); font-size:11px; margin-top:18px; letter-spacing:.02em}
+
+ /* ================= DETAILS DISCLOSURE ================= */
+ .disc{margin-top:16px}
+ .disc-head{
+ display:flex; align-items:center; gap:8px;
+ color:var(--gold); font-weight:700; font-size:12px; letter-spacing:.05em;
+ cursor:pointer; user-select:none; padding:4px 2px;
+ }
+ .disc-head .caret{transition:transform .12s}
+ .disc-body{margin-top:12px}
+
+ .domain{margin-bottom:14px}
+ .domain-h{color:var(--dim); font-size:10px; letter-spacing:.14em; text-transform:uppercase; margin:0 0 6px; border-bottom:1px solid var(--line); padding-bottom:4px}
+ table.metrics{width:100%; border-collapse:collapse}
+ table.metrics td{padding:3px 8px; vertical-align:middle; white-space:nowrap}
+ table.metrics tr:hover{background:rgba(66,79,94,.14)}
+ td.mlamp{width:16px; text-align:center}
+ td.mname{color:var(--silver)}
+ td.mval{color:var(--dim); text-align:right; width:150px}
+ td.mlever{width:110px; text-align:right}
+ .auto-tag{font-size:9px; letter-spacing:.08em; color:var(--dim); text-transform:uppercase; padding-right:6px}
+
+ /* ================= UPDATES QUARANTINE ================= */
+ .updates{margin-top:16px; border:1px dashed #4a4535; background:#12100c}
+ .updates .sec-h{color:var(--amber)}
+ .updates-row{display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap}
+ .updates-info b{color:var(--cream)}
+ .updates-info .cve{color:var(--terra); font-weight:700}
+ .updates-info .pkgs{color:var(--dim)}
+ .updates-note{color:var(--dim); font-size:10px; margin-top:8px; letter-spacing:.02em}
+
+ /* ================= OUTPUT WALL ================= */
+ .wall{
+ margin-top:18px;
+ background:#0b0a09; border:1px solid var(--gold); border-radius:14px;
+ padding:18px 20px;
+ }
+ .wall-h{display:flex; align-items:center; justify-content:space-between; margin-bottom:4px}
+ .wall-h .t{color:var(--gold); font-weight:700; letter-spacing:.06em; font-size:13px}
+ .wall-h .meta{color:var(--dim); font-size:11px}
+ .wall-sub{color:var(--dim); font-size:11px; margin-bottom:12px}
+ .prog{height:6px; background:#1a1712; border:1px solid var(--line); border-radius:4px; overflow:hidden; margin-bottom:14px}
+ .prog i{display:block; height:100%; width:62%; background:linear-gradient(90deg,#74932f,#dab53d)}
+ .stream{font-size:12.5px; line-height:1.9}
+ .srow{display:flex; align-items:baseline; gap:10px}
+ .srow .l{width:14px; text-align:center; flex:0 0 auto}
+ .srow .task{color:var(--silver); flex:0 0 200px}
+ .srow .status{color:var(--dim)}
+ .srow.done .task{color:var(--silver)}
+ .srow.done .status{color:var(--olive)}
+ .srow.run .task{color:var(--cream)}
+ .srow.run .status{color:var(--amber)}
+ .srow.queued .task{color:var(--dim)}
+ .srow.queued .status{color:var(--dim)}
+ .srow .result{color:var(--cream); opacity:.85}
+ .wall-foot{display:flex; justify-content:space-between; align-items:center; margin-top:16px}
+ .wall-foot .summary{color:var(--dim); font-size:11px}
+ .wall-foot .summary b{color:var(--olive)}
+
+ .stagelabel{color:var(--dim); font-size:10px; letter-spacing:.18em; text-transform:uppercase; margin:26px 0 8px; text-align:center}
+</style>
+
+<div class="console">
+
+ <!-- ================= HEADER ================= -->
+ <div class="head">
+ <div class="title">MAINT · ratio
+ <span class="sub">Approach D — Doctor-first</span>
+ </div>
+ <div class="verdict ok"><span class="lamp">●</span> healthy</div>
+ </div>
+ <hr class="rule">
+
+ <div class="stagelabel">— resting state · the doctor is what you see first —</div>
+
+ <!-- ================= HERO (resting) ================= -->
+ <div class="hero">
+ <div class="stethoscope">✚ ✚ ✚</div>
+ <div class="stat-line">
+ <b>ratio</b> · <span class="fail">1 fail</span> · <span class="warn">8 warnings</span> · <span class="ok">34 ok</span>
+ </div>
+
+ <!-- summary lamp strip: one tiny lamp per metric -->
+ <div class="strip" title="one lamp per metric, colored by state">
+ <!-- 44 dots; mostly olive, some amber, one terracotta -->
+ <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot fail">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot warn">●</span><span class="dot warn">●</span><span class="dot ok">●</span>
+ <span class="dot warn">●</span><span class="dot fail">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot warn">●</span>
+ <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot warn">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot na">○</span><span class="dot warn">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span>
+ <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot na">○</span>
+ <span class="dot ok">●</span><span class="dot ok">●</span>
+ </div>
+
+ <!-- big doctor actions -->
+ <div class="dbig">
+ <button class="doc-btn primary" onclick="document.getElementById('wall').scrollIntoView({behavior:'smooth'})">
+ <span class="big">CLEAN UP</span>
+ <span class="sub">4 safe tasks · reclaims ~2 GB</span>
+ </button>
+ <button class="doc-btn">
+ <span class="big">REVIEW &amp; FIX</span>
+ <span class="sub">9 items need a look</span>
+ </button>
+ </div>
+
+ <div class="hero-foot">
+ "Should I run the doctor?" — press Clean up for the 4 unattended tasks, or Review &amp; fix to preview the 9 confirm items.
+ </div>
+ </div>
+
+ <!-- ================= DETAILS DISCLOSURE (shown expanded statically) ================= -->
+ <div class="disc inset">
+ <div class="disc-head" onclick="var b=document.getElementById('discbody'); var c=this.querySelector('.caret'); if(b.style.display==='none'){b.style.display='block'; c.textContent='▾';} else {b.style.display='none'; c.textContent='▸';}">
+ <span class="caret">▾</span> 44 metrics · the fine print
+ </div>
+ <div class="disc-body" id="discbody">
+
+ <!-- STORAGE / FILESYSTEM -->
+ <div class="domain">
+ <div class="domain-h">Storage &amp; filesystem</div>
+ <table class="metrics">
+ <tr><td class="mlamp warn">●</td><td class="mname">cache</td><td class="mval">8.8 GB</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Clean</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">deep-trim keep-3</td><td class="mval">ready</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Deep trim</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">disk</td><td class="mval">69%</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">btrfs unalloc</td><td class="mval">118 GiB</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">btrfs scrub</td><td class="mval">34 d ago</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Scrub</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">btrfs device-err</td><td class="mval">0 / 0</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">SMART</td><td class="mval">PASSED</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">SMART wear</td><td class="mval">0%</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp fail">●</td><td class="mname">fstrim.timer</td><td class="mval">off</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Enable</button></td></tr>
+ </table>
+ </div>
+
+ <!-- PACKAGES -->
+ <div class="domain">
+ <div class="domain-h">Packages &amp; snapshots</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">pacman integrity</td><td class="mval">clean</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">snapper count</td><td class="mval">42</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Prune</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">snapshot timer</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">orphans</td><td class="mval">13</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Review</button></td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">.pacnew</td><td class="mval">2</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Review</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">keyring</td><td class="mval">12 d</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Refresh</button></td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">pending updates</td><td class="mval">47</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr>
+ <tr><td class="mlamp fail">●</td><td class="mname">arch-audit</td><td class="mval">3 CVE</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">AUR staleness</td><td class="mval">5</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr>
+ </table>
+ </div>
+
+ <!-- SYSTEM STATE -->
+ <div class="domain">
+ <div class="domain-h">System state</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">reboot-required</td><td class="mval">no</td><td class="mlever"><span class="auto-tag">human</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">is-system-running</td><td class="mval">running</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">failed units</td><td class="mval">1</td><td class="mlever"><span class="auto-tag">workflow</span><button class="btn btn-lever">Run</button></td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">maintenance timers</td><td class="mval">4 / 5</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Enable</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">kernel taint</td><td class="mval">0</td><td class="mlever">—</td></tr>
+ </table>
+ </div>
+
+ <!-- LOGS -->
+ <div class="domain">
+ <div class="domain-h">Logs &amp; coredumps</div>
+ <table class="metrics">
+ <tr><td class="mlamp warn">●</td><td class="mname">coredumps</td><td class="mval">18 / 7 d</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Clear</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">journald</td><td class="mval">1.2 GB</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Vacuum</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">app-log cleanup</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Run</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">journal errors</td><td class="mval">12 real</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">kernel/hw events</td><td class="mval">clean</td><td class="mlever">—</td></tr>
+ </table>
+ </div>
+
+ <!-- RESOURCES -->
+ <div class="domain">
+ <div class="domain-h">Resources &amp; thermals</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">memory</td><td class="mval">104 GB free</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">swap / zram</td><td class="mval">16 GiB</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">temps</td><td class="mval">61° / 54°</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">thermal throttle</td><td class="mval">no</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp na">○</td><td class="mname na">battery</td><td class="mval">n/a</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">unclean-shutdown</td><td class="mval">75%</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ </table>
+ </div>
+
+ <!-- NETWORK / SECURITY -->
+ <div class="domain">
+ <div class="domain-h">Network &amp; security</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">DNS / NM</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">firewall</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">listeners</td><td class="mval">3</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">tailscale</td><td class="mval">4 / 4</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">fail2ban</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">NTP</td><td class="mval">synced</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ </table>
+ </div>
+
+ <!-- SERVICES / BACKUP -->
+ <div class="domain">
+ <div class="domain-h">Backup &amp; services</div>
+ <table class="metrics">
+ <tr><td class="mlamp ok">●</td><td class="mname">rsyncshot</td><td class="mval">3 h ago</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr>
+ <tr><td class="mlamp warn">●</td><td class="mname">docker reclaim</td><td class="mval">3 GB</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Prune</button></td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">docker stopped</td><td class="mval">2</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp na">○</td><td class="mname na">libvirt</td><td class="mval">off</td><td class="mlever">—</td></tr>
+ <tr><td class="mlamp ok">●</td><td class="mname">cron</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr>
+ </table>
+ </div>
+
+ </div>
+ </div>
+
+ <!-- ================= UPDATES QUARANTINE ================= -->
+ <div class="updates inset">
+ <div class="sec-h">Updates — quarantined from the doctor</div>
+ <div class="updates-row">
+ <div class="updates-info">
+ <b>47 pending</b> · <span class="cve">3 CVE</span> · <span class="pkgs">mesa, linux-lts, systemd</span>
+ </div>
+ <button class="btn">Run workflow</button>
+ </div>
+ <div class="updates-note">Updates run through the workflow, never the doctor. Clean up will not touch these.</div>
+ </div>
+
+ <div class="stagelabel">— running state · press CLEAN UP and the hero becomes the output wall —</div>
+
+ <!-- ================= OUTPUT WALL (the star) ================= -->
+ <div class="wall" id="wall">
+ <div class="wall-h">
+ <span class="t">✚ DOCTOR · CLEAN UP</span>
+ <span class="meta">4 tasks · unattended · started 14:22:07</span>
+ </div>
+ <div class="wall-sub">Running the 4 safe (auto) tasks. Confirm items and updates are untouched.</div>
+ <div class="prog"><i></i></div>
+
+ <div class="stream">
+ <div class="srow run">
+ <span class="l run">◐</span>
+ <span class="task">cache trim</span>
+ <span class="status">running… 8.8 GB → clearing</span>
+ </div>
+ <div class="srow done">
+ <span class="l ok">●</span>
+ <span class="task">journald vacuum</span>
+ <span class="status">done · <span class="result">reclaimed 0.9 GB</span></span>
+ </div>
+ <div class="srow done">
+ <span class="l ok">●</span>
+ <span class="task">coredump clear</span>
+ <span class="status">done · <span class="result">12 cleared</span></span>
+ </div>
+ <div class="srow queued">
+ <span class="l na">○</span>
+ <span class="task">app-log cleanup</span>
+ <span class="status">queued</span>
+ </div>
+ </div>
+
+ <div class="wall-foot">
+ <span class="summary">3 of 4 complete · <b>reclaimed 0.9 GB</b> so far · ~1.1 GB pending</span>
+ <button class="btn">Done</button>
+ </div>
+ </div>
+
+</div>
diff --git a/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html b/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html
new file mode 100644
index 0000000..52ecfad
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html
@@ -0,0 +1,475 @@
+<meta charset="utf-8">
+<title>MAINT · ratio — Approach E · Instrument Dashboard</title>
+<style>
+ :root{
+ --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d;
+ --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5;
+ --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32;
+ --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d;
+ --font:"BerkeleyMono Nerd Font", ui-monospace, monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;background:var(--page);}
+ body{
+ font-family:var(--font); color:var(--silver);
+ display:flex; justify-content:center; padding:26px 14px 60px;
+ -webkit-font-smoothing:antialiased;
+ }
+ .console{
+ width:1050px; max-width:100%;
+ background:var(--surface);
+ border:1px solid var(--gold); border-radius:16px;
+ padding:18px;
+ }
+
+ /* ---- header ---- */
+ .head{display:flex; align-items:baseline; justify-content:space-between; gap:16px;}
+ .head .title{font-size:19px; color:var(--cream); letter-spacing:.5px;}
+ .head .title b{color:var(--gold); font-weight:700;}
+ .verdict{font-size:15px; color:var(--olive); font-weight:700; white-space:nowrap;}
+ .sub{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.4px;}
+
+ /* ---- top action bar ---- */
+ .actionbar{
+ display:flex; align-items:center; gap:10px;
+ margin:14px 0 16px; padding:10px 12px;
+ background:var(--inset); border:1px solid var(--line); border-radius:10px;
+ }
+ .actionbar .lbl{color:var(--dim); font-size:11.5px; letter-spacing:.5px; text-transform:uppercase; margin-right:2px;}
+ .btn{
+ font-family:var(--font); cursor:pointer;
+ background:var(--slate); color:var(--cream);
+ border:none; border-radius:10px; padding:7px 14px; font-size:13px;
+ }
+ .btn:hover{background:var(--slate-hi);}
+ .btn.lever{padding:3px 9px; font-size:11px; border-radius:8px;}
+ .actionbar .spacer{flex:1;}
+ .legend{color:var(--dim); font-size:11px; line-height:1.5;}
+ .legend b{color:var(--silver); font-weight:400;}
+
+ /* ---- grid ---- */
+ .cluster{display:grid; grid-template-columns:repeat(12,1fr); gap:10px;}
+ .card{
+ background:var(--inset); border:1px solid var(--line); border-radius:10px;
+ padding:11px 12px 12px; position:relative; min-height:96px;
+ display:flex; flex-direction:column;
+ }
+ .card .lever{position:absolute; top:9px; right:9px;}
+ .card .cap{
+ color:var(--dim); font-size:10.5px; letter-spacing:.6px;
+ text-transform:uppercase; margin-bottom:8px; padding-right:56px;
+ }
+ .card .big{color:var(--cream); font-size:23px; line-height:1; font-weight:700;}
+ .card .unit{color:var(--dim); font-size:12px; font-weight:400;}
+ .card .note{color:var(--dim); font-size:11px; margin-top:6px;}
+
+ /* domain band label */
+ .band{
+ grid-column:1/-1; color:var(--gold); font-weight:700; font-size:12px;
+ letter-spacing:1.2px; text-transform:uppercase; margin:6px 2px 0;
+ display:flex; align-items:center; gap:10px;
+ }
+ .band::after{content:""; flex:1; height:1px; background:var(--line);}
+
+ /* span helpers */
+ .s3{grid-column:span 3;} .s4{grid-column:span 4;}
+ .s5{grid-column:span 5;} .s6{grid-column:span 6;}
+ .s7{grid-column:span 7;} .s8{grid-column:span 8;}
+ .s9{grid-column:span 9;} .s12{grid-column:span 12;}
+
+ /* ---- horizontal capacity meter ---- */
+ .meter{margin-top:auto;}
+ .meter .track{
+ position:relative; height:14px; background:#07090a;
+ border:1px solid var(--line); border-radius:7px; overflow:hidden;
+ }
+ .meter .fill{height:100%; border-radius:6px 0 0 6px;}
+ .fill.ok{background:linear-gradient(90deg,#5c7626,#74932f);}
+ .fill.warn{background:linear-gradient(90deg,#a98a2c,#dab53d);}
+ .fill.fail{background:linear-gradient(90deg,#a8543b,#cb6b4d);}
+ .meter .tick{position:absolute; top:-2px; bottom:-2px; width:2px; background:var(--gold);}
+ .meter .zone{position:absolute; top:0; bottom:0; opacity:.16;}
+ .zone.amber{background:var(--amber);} .zone.terra{background:var(--terra);}
+ .meter .scale{display:flex; justify-content:space-between; color:var(--dim); font-size:9.5px; margin-top:4px;}
+
+ /* ---- lamps ---- */
+ .lamp{display:inline-block; width:1em; text-align:center;}
+ .l-ok{color:var(--olive);} .l-warn{color:var(--amber);}
+ .l-fail{color:var(--terra);} .l-off{color:var(--dim);} .l-prog{color:var(--amber);}
+
+ /* ---- lamp board ---- */
+ .lampboard{
+ grid-column:1/-1; background:var(--inset); border:1px solid var(--line);
+ border-radius:10px; padding:12px 14px;
+ display:grid; grid-template-columns:repeat(4,1fr); gap:7px 18px;
+ }
+ .lampboard .row{display:flex; align-items:center; gap:8px; font-size:12px;}
+ .lampboard .row .nm{color:var(--silver);}
+ .lampboard .row .vv{color:var(--dim); margin-left:auto; font-size:11px;}
+
+ /* ---- counter tile ---- */
+ .tile .row{display:flex; align-items:center; gap:8px;}
+ .tile .big{font-size:26px;}
+
+ /* ---- updates quarantine ---- */
+ .updates{
+ grid-column:1/-1; border:1px solid var(--amber);
+ background:linear-gradient(180deg,#141210,#0a0c0d);
+ border-radius:10px; padding:13px 16px;
+ display:flex; align-items:center; gap:26px; flex-wrap:wrap;
+ }
+ .updates .q{
+ color:var(--amber); font-size:10.5px; letter-spacing:1.5px;
+ text-transform:uppercase; border:1px solid var(--amber);
+ padding:3px 8px; border-radius:6px; align-self:flex-start;
+ }
+ .updates .metric{display:flex; flex-direction:column; gap:2px;}
+ .updates .metric .n{font-size:30px; color:var(--cream); font-weight:700; line-height:1;}
+ .updates .metric .n.cve{color:var(--terra);}
+ .updates .metric .k{color:var(--dim); font-size:10.5px; text-transform:uppercase; letter-spacing:.6px;}
+ .updates .spacer{flex:1;}
+
+ /* ---- svg arc/dial labels ---- */
+ .gaugewrap{display:flex; gap:14px; margin-top:2px;}
+ .gauge{display:flex; flex-direction:column; align-items:center; gap:3px;}
+ .gauge .rd{color:var(--cream); font-size:13px; font-weight:700;}
+ .gauge .lb{color:var(--dim); font-size:10px;}
+ .dialwrap{display:flex; justify-content:center; margin-top:2px;}
+
+ /* ---- output wall ---- */
+ .wall{
+ margin-top:16px; background:var(--inset);
+ border:1px solid var(--line); border-radius:10px; padding:13px 15px;
+ }
+ .wall .wh{
+ display:flex; align-items:center; justify-content:space-between;
+ color:var(--cream); font-size:13px; letter-spacing:.5px; margin-bottom:10px;
+ }
+ .wall .wh .l{color:var(--gold);}
+ .wall .stream{font-size:12.5px; line-height:1.85;}
+ .wall .stream .ln{display:flex; gap:9px; align-items:baseline;}
+ .wall .stream .msg{color:var(--silver);}
+ .wall .stream .meta{color:var(--dim);}
+ .wall .stream .queued .msg{color:var(--dim);}
+ .wall .wfoot{margin-top:11px; display:flex; justify-content:flex-end;}
+
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
+ .spin{animation:pulse 1.1s ease-in-out infinite;}
+</style>
+
+<div class="console">
+
+ <!-- HEADER -->
+ <div class="head">
+ <div>
+ <div class="title">MAINT · <b>ratio</b></div>
+ <div class="sub">Approach E — Instrument dashboard</div>
+ </div>
+ <div class="verdict"><span class="lamp l-ok">●</span> healthy</div>
+ </div>
+
+ <!-- ACTION BAR -->
+ <div class="actionbar">
+ <span class="lbl">Doctor</span>
+ <button class="btn">Clean up</button>
+ <button class="btn">Review &amp; fix</button>
+ <span class="spacer"></span>
+ <span class="legend">
+ <b>Auto</b> unattended · <b>Confirm</b> preview+click · <b>Human</b> you act · <b>Workflow</b> escalate · <b>None</b> diagnostic
+ </span>
+ </div>
+
+ <!-- CLUSTER -->
+ <div class="cluster">
+
+ <!-- STORAGE & FILESYSTEM -->
+ <div class="band">Storage &amp; Filesystem</div>
+
+ <div class="card s4">
+ <div class="cap">Disk usage</div>
+ <div class="big">69<span class="unit">%</span></div>
+ <div class="note">root · btrfs</div>
+ <div class="meter">
+ <div class="track">
+ <div class="zone amber" style="left:80%;right:10%"></div>
+ <div class="zone terra" style="left:90%;right:0"></div>
+ <div class="fill ok" style="width:69%"></div>
+ </div>
+ <div class="scale"><span>0</span><span>80</span><span>90</span><span>100%</span></div>
+ </div>
+ </div>
+
+ <div class="card s4">
+ <button class="btn lever">Clean</button>
+ <div class="cap">Package cache <span class="lamp l-warn">●</span></div>
+ <div class="big">8.8<span class="unit"> GB</span></div>
+ <div class="note">Auto · threshold ~10 GB</div>
+ <div class="meter">
+ <div class="track">
+ <div class="tick" style="left:88%"></div>
+ <div class="fill warn" style="width:88%"></div>
+ </div>
+ <div class="scale"><span>0</span><span>threshold 10 GB</span></div>
+ </div>
+ </div>
+
+ <div class="card s4">
+ <button class="btn lever">Deep trim</button>
+ <div class="cap">Deep-trim keep-3 <span class="lamp l-ok">●</span></div>
+ <div class="big">keep 3</div>
+ <div class="note">Confirm · retain 3 versions</div>
+ <div class="meter">
+ <div class="track"><div class="fill ok" style="width:100%"></div></div>
+ <div class="scale"><span>staged &amp; ready</span></div>
+ </div>
+ </div>
+
+ <div class="card s3">
+ <button class="btn lever">Scrub</button>
+ <div class="cap">btrfs scrub</div>
+ <div class="dialwrap">
+ <svg width="98" height="98" viewBox="0 0 98 98">
+ <circle cx="49" cy="49" r="40" fill="none" stroke="#1a1d1f" stroke-width="8"/>
+ <!-- 34d of ~30d cadence => over => amber, ~0.62 of ring -->
+ <circle cx="49" cy="49" r="40" fill="none" stroke="#dab53d" stroke-width="8"
+ stroke-linecap="round" stroke-dasharray="251.3" stroke-dashoffset="95"
+ transform="rotate(-90 49 49)"/>
+ <text x="49" y="46" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="20" font-weight="700">34</text>
+ <text x="49" y="63" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="10">days</text>
+ </svg>
+ </div>
+ <div class="note" style="text-align:center"><span class="lamp l-warn">●</span> past 30d cadence</div>
+ </div>
+
+ <div class="card s3">
+ <div class="cap">btrfs unalloc <span class="lamp l-ok">●</span></div>
+ <div class="big">118<span class="unit"> GiB</span></div>
+ <div class="note">unallocated headroom</div>
+ <div class="meter">
+ <div class="track"><div class="fill ok" style="width:46%"></div></div>
+ <div class="scale"><span>ample slack</span></div>
+ </div>
+ </div>
+
+ <div class="card s3">
+ <div class="cap">btrfs device-err <span class="lamp l-ok">●</span></div>
+ <div class="big">0 / 0</div>
+ <div class="note">read / write errors</div>
+ </div>
+
+ <div class="card s3">
+ <button class="btn lever">Enable</button>
+ <div class="cap">fstrim.timer <span class="lamp l-fail">●</span></div>
+ <div class="big" style="color:var(--terra)">OFF</div>
+ <div class="note">Confirm · SSD discard disabled</div>
+ </div>
+
+ <!-- THERMAL & POWER -->
+ <div class="band">Thermal &amp; Power</div>
+
+ <div class="card s5">
+ <div class="cap">Temperatures <span class="lamp l-ok">●</span></div>
+ <div class="gaugewrap">
+ <div class="gauge">
+ <svg width="112" height="64" viewBox="0 0 112 64">
+ <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/>
+ <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/>
+ <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/>
+ <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/>
+ <!-- needle @ 61C (~61% of arc) -->
+ <line x1="56" y1="60" x2="86" y2="30" stroke="#f3e7c5" stroke-width="2.5"/>
+ <circle cx="56" cy="60" r="4" fill="#dab53d"/>
+ </svg>
+ <div class="rd">61°C</div><div class="lb">CPU package</div>
+ </div>
+ <div class="gauge">
+ <svg width="112" height="64" viewBox="0 0 112 64">
+ <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/>
+ <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/>
+ <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/>
+ <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/>
+ <!-- needle @ 54C (~54%) -->
+ <line x1="56" y1="60" x2="79" y2="34" stroke="#f3e7c5" stroke-width="2.5"/>
+ <circle cx="56" cy="60" r="4" fill="#dab53d"/>
+ </svg>
+ <div class="rd">54°C</div><div class="lb">NVMe</div>
+ </div>
+ </div>
+ </div>
+
+ <div class="card s3">
+ <div class="cap">Thermal throttle <span class="lamp l-ok">●</span></div>
+ <div class="big">NO</div>
+ <div class="note">no throttle events</div>
+ </div>
+
+ <div class="card s2 s3" style="grid-column:span 2;">
+ <div class="cap">Battery <span class="lamp l-off">○</span></div>
+ <div class="big" style="color:var(--dim)">n/a</div>
+ <div class="note">desktop</div>
+ </div>
+
+ <div class="card s2 s3" style="grid-column:span 2;">
+ <div class="cap">Swap / zram <span class="lamp l-ok">●</span></div>
+ <div class="big">16<span class="unit"> GiB</span></div>
+ <div class="note">zram active</div>
+ </div>
+
+ <!-- MEMORY -->
+ <div class="card s6">
+ <div class="cap">Memory free <span class="lamp l-ok">●</span></div>
+ <div class="big">104<span class="unit"> GB free</span></div>
+ <div class="note">of 125 GB total · Workflow</div>
+ <div class="meter">
+ <div class="track"><div class="fill ok" style="width:83%"></div></div>
+ <div class="scale"><span>0</span><span>free 104 / 125 GB</span></div>
+ </div>
+ </div>
+
+ <div class="card s6">
+ <div class="cap">Unclean-shutdown ratio <span class="lamp l-warn">●</span></div>
+ <div class="big" style="color:var(--amber)">75<span class="unit">%</span></div>
+ <div class="note">clean boots · Workflow — investigate</div>
+ <div class="meter">
+ <div class="track">
+ <div class="zone amber" style="left:0;right:0"></div>
+ <div class="fill warn" style="width:75%"></div>
+ </div>
+ <div class="scale"><span>0</span><span>clean-boot fraction</span><span>100%</span></div>
+ </div>
+ </div>
+
+ <!-- UPDATES QUARANTINE -->
+ <div class="band">Updates — quarantined</div>
+ <div class="updates">
+ <div class="q">Updates</div>
+ <div class="metric"><span class="n">47</span><span class="k">pending</span></div>
+ <div class="metric"><span class="n cve">3</span><span class="k">CVE · arch-audit</span></div>
+ <div class="metric"><span class="n">5</span><span class="k">AUR stale</span></div>
+ <div class="spacer"></div>
+ <div class="note" style="max-width:230px; margin:0;">
+ <span class="lamp l-fail">●</span> Security advisories present.
+ Applying updates escalates to the update workflow — not an unattended clean.
+ </div>
+ <button class="btn">Open updates</button>
+ </div>
+
+ <!-- PACKAGES & JOURNAL COUNTERS -->
+ <div class="band">Packages · Logs · Counters</div>
+
+ <div class="card s3 tile">
+ <button class="btn lever">Review</button>
+ <div class="cap">Orphans</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">13</span></div>
+ <div class="note">Confirm · unowned pkgs</div>
+ </div>
+
+ <div class="card s3 tile">
+ <button class="btn lever">Review</button>
+ <div class="cap">.pacnew files</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">2</span></div>
+ <div class="note">Confirm · config merges</div>
+ </div>
+
+ <div class="card s3 tile">
+ <button class="btn lever">Clear</button>
+ <div class="cap">Coredumps 7d</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">18</span></div>
+ <div class="note">Auto · clearable</div>
+ </div>
+
+ <div class="card s3 tile">
+ <button class="btn lever">Run workflow</button>
+ <div class="cap">Failed units</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">1</span></div>
+ <div class="note">Workflow · systemd</div>
+ </div>
+
+ <div class="card s3">
+ <button class="btn lever">Prune</button>
+ <div class="cap">Snapper snapshots <span class="lamp l-ok">●</span></div>
+ <div class="big">42</div>
+ <div class="note">Confirm · retention ok</div>
+ <div class="meter">
+ <div class="track"><div class="fill ok" style="width:52%"></div></div>
+ <div class="scale"><span>within budget</span></div>
+ </div>
+ </div>
+
+ <div class="card s3">
+ <button class="btn lever">Refresh</button>
+ <div class="cap">Keyring age</div>
+ <div class="dialwrap">
+ <svg width="90" height="90" viewBox="0 0 90 90">
+ <circle cx="45" cy="45" r="36" fill="none" stroke="#1a1d1f" stroke-width="7"/>
+ <circle cx="45" cy="45" r="36" fill="none" stroke="#74932f" stroke-width="7"
+ stroke-linecap="round" stroke-dasharray="226.2" stroke-dashoffset="176"
+ transform="rotate(-90 45 45)"/>
+ <text x="45" y="43" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="18" font-weight="700">12</text>
+ <text x="45" y="59" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="9">days</text>
+ </svg>
+ </div>
+ <div class="note" style="text-align:center"><span class="lamp l-ok">●</span> Confirm · fresh</div>
+ </div>
+
+ <div class="card s3 tile">
+ <div class="cap">Journal errors</div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="big">12</span></div>
+ <div class="note">Workflow · real errors</div>
+ </div>
+
+ <div class="card s3 tile">
+ <div class="cap">Listeners</div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="big">3</span></div>
+ <div class="note">None · open sockets</div>
+ </div>
+
+ <!-- LAMP BOARD -->
+ <div class="band">Status board — binary &amp; diagnostic</div>
+ <div class="lampboard">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">SMART</span><span class="vv">PASSED</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">SMART wear</span><span class="vv">0%</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">pacman integrity</span><span class="vv">clean</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">snapshot timer</span><span class="vv">active</span></div>
+
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">is-system-running</span><span class="vv">running</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">reboot-required</span><span class="vv">no</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">kernel taint</span><span class="vv">0</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">kernel/hw events</span><span class="vv">clean</span></div>
+
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">firewall</span><span class="vv">active</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">fail2ban</span><span class="vv">active</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">tailscale</span><span class="vv">4/4</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">NTP</span><span class="vv">synced</span></div>
+
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">DNS / NM</span><span class="vv">ok</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">rsyncshot</span><span class="vv">3h ago</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">cron</span><span class="vv">ok</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">app-log cleanup</span><span class="vv">ok</span></div>
+
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">journald size</span><span class="vv">1.2 GB</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm">docker stopped</span><span class="vv">2</span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="nm">maintenance timers</span><span class="vv">4/5</span></div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="nm">docker reclaim</span><span class="vv">3 GB · Prune</span></div>
+
+ <div class="row"><span class="lamp l-off">○</span><span class="nm">libvirt</span><span class="vv">off</span></div>
+ </div>
+
+ </div><!-- /cluster -->
+
+ <!-- RUNNING / OUTPUT WALL -->
+ <div class="wall">
+ <div class="wh">
+ <span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp l-prog spin">◐</span></span>
+ <span class="meta" style="font-size:11px">4 Auto actions</span>
+ </div>
+ <div class="stream">
+ <div class="ln"><span class="lamp l-prog spin">◐</span><span class="msg">cache trim</span><span class="meta">running…</span></div>
+ <div class="ln"><span class="lamp l-ok">●</span><span class="msg">journal vacuum</span><span class="meta">done · reclaimed 0.9 GB</span></div>
+ <div class="ln"><span class="lamp l-ok">●</span><span class="msg">coredump clear</span><span class="meta">done · 12 cleared</span></div>
+ <div class="ln queued"><span class="lamp l-off">○</span><span class="msg">app-log cleanup</span><span class="meta">queued</span></div>
+ </div>
+ <div class="wfoot"><button class="btn">Done</button></div>
+ </div>
+
+</div>
diff --git a/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html b/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html
new file mode 100644
index 0000000..b818006
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html
@@ -0,0 +1,443 @@
+<meta charset="utf-8">
+<title>MAINT · ratio — Approach E2 · Grouped Instrument Dashboard</title>
+<style>
+ :root{
+ --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d;
+ --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5;
+ --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32;
+ --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d;
+ --font:"BerkeleyMono Nerd Font", ui-monospace, monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;background:var(--page);}
+ body{
+ font-family:var(--font); color:var(--silver);
+ display:flex; justify-content:center; padding:26px 14px 60px;
+ -webkit-font-smoothing:antialiased;
+ }
+ .console{ width:1050px; max-width:100%; background:var(--surface);
+ border:1px solid var(--gold); border-radius:16px; padding:18px; }
+
+ /* header */
+ .head{display:flex; align-items:baseline; justify-content:space-between; gap:16px;}
+ .head .title{font-size:19px; color:var(--cream); letter-spacing:.5px;}
+ .head .title b{color:var(--gold); font-weight:700;}
+ .verdict{font-size:15px; color:var(--olive); font-weight:700; white-space:nowrap;}
+ .sub{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.4px;}
+
+ /* action bar */
+ .actionbar{display:flex; align-items:center; gap:10px; margin:14px 0 14px;
+ padding:10px 12px; background:var(--inset); border:1px solid var(--line); border-radius:10px;}
+ .actionbar .lbl{color:var(--dim); font-size:11.5px; letter-spacing:.5px; text-transform:uppercase; margin-right:2px;}
+ .btn{font-family:var(--font); cursor:pointer; background:var(--slate); color:var(--cream);
+ border:none; border-radius:10px; padding:7px 14px; font-size:13px;}
+ .btn:hover{background:var(--slate-hi);}
+ .btn.lever{padding:3px 9px; font-size:11px; border-radius:8px;}
+ .btn.mini{padding:2px 8px; font-size:10.5px; border-radius:7px;}
+ .actionbar .spacer{flex:1;}
+ .legend{color:var(--dim); font-size:11px; line-height:1.5;}
+ .legend b{color:var(--silver); font-weight:400;}
+
+ /* updates quarantine */
+ .updates{border:1px solid var(--amber);
+ background:linear-gradient(180deg,#141210,#0a0c0d);
+ border-radius:10px; padding:12px 16px; margin-bottom:14px;
+ display:flex; align-items:center; gap:26px; flex-wrap:wrap;}
+ .updates .q{color:var(--amber); font-size:10.5px; letter-spacing:1.5px; text-transform:uppercase;
+ border:1px solid var(--amber); padding:3px 8px; border-radius:6px; align-self:flex-start;}
+ .updates .metric{display:flex; flex-direction:column; gap:2px;}
+ .updates .metric .n{font-size:28px; color:var(--cream); font-weight:700; line-height:1;}
+ .updates .metric .n.cve{color:var(--terra);}
+ .updates .metric .k{color:var(--dim); font-size:10.5px; text-transform:uppercase; letter-spacing:.6px;}
+ .updates .spacer{flex:1;}
+
+ /* ---- category panel ---- */
+ .panel{border:1px solid var(--line); border-left:3px solid var(--line);
+ border-radius:11px; margin-bottom:10px; overflow:hidden; background:#0d0c0c;}
+ .panel.attn{border-left-color:var(--amber);}
+ .panel.crit{border-left-color:var(--terra);}
+ .panel.clear{border-left-color:var(--olive); opacity:.85;}
+ .phead{display:flex; align-items:center; gap:11px; padding:9px 14px; cursor:pointer;}
+ .phead:hover{background:#141313;}
+ .phead .nm{color:var(--gold); font-weight:700; letter-spacing:.9px; text-transform:uppercase; font-size:12.5px;}
+ .phead .cnt{color:var(--dim); font-size:11px; margin-left:auto;}
+ .phead .chev{color:var(--dim); font-size:12px; width:1em; text-align:center;}
+ .phead .attnword{color:var(--amber); font-size:11px;}
+ .phead .attnword.crit{color:var(--terra);}
+ .phead .okword{color:var(--olive); font-size:11px;}
+ .pbody{padding:2px 14px 13px;}
+
+ .heroes{display:grid; grid-template-columns:repeat(12,1fr); gap:10px; margin-bottom:11px;}
+ .card{background:var(--inset); border:1px solid var(--line); border-radius:10px;
+ padding:10px 12px 11px; position:relative; min-height:88px; display:flex; flex-direction:column;}
+ .card .lever{position:absolute; top:8px; right:8px;}
+ .card .cap{color:var(--dim); font-size:10.5px; letter-spacing:.6px; text-transform:uppercase;
+ margin-bottom:7px; padding-right:52px;}
+ .card .big{color:var(--cream); font-size:22px; line-height:1; font-weight:700;}
+ .card .unit{color:var(--dim); font-size:12px; font-weight:400;}
+ .card .note{color:var(--dim); font-size:11px; margin-top:6px;}
+ .s2{grid-column:span 2;} .s3{grid-column:span 3;} .s4{grid-column:span 4;}
+ .s5{grid-column:span 5;} .s6{grid-column:span 6;}
+
+ /* meter */
+ .meter{margin-top:auto;}
+ .meter .track{position:relative; height:13px; background:#07090a; border:1px solid var(--line);
+ border-radius:7px; overflow:hidden;}
+ .meter .fill{height:100%; border-radius:6px 0 0 6px;}
+ .fill.ok{background:linear-gradient(90deg,#5c7626,#74932f);}
+ .fill.warn{background:linear-gradient(90deg,#a98a2c,#dab53d);}
+ .fill.fail{background:linear-gradient(90deg,#a8543b,#cb6b4d);}
+ .meter .tick{position:absolute; top:-2px; bottom:-2px; width:2px; background:var(--gold);}
+ .meter .zone{position:absolute; top:0; bottom:0; opacity:.16;}
+ .zone.amber{background:var(--amber);} .zone.terra{background:var(--terra);}
+ .meter .scale{display:flex; justify-content:space-between; color:var(--dim); font-size:9.5px; margin-top:4px;}
+
+ /* lamps */
+ .lamp{display:inline-block; width:1em; text-align:center;}
+ .l-ok{color:var(--olive);} .l-warn{color:var(--amber);}
+ .l-fail{color:var(--terra);} .l-off{color:var(--dim);} .l-prog{color:var(--amber);}
+
+ .tile .row{display:flex; align-items:center; gap:8px;}
+ .tile .big{font-size:25px;}
+
+ .gaugewrap{display:flex; gap:14px; margin-top:2px;}
+ .gauge{display:flex; flex-direction:column; align-items:center; gap:3px;}
+ .gauge .rd{color:var(--cream); font-size:13px; font-weight:700;}
+ .gauge .lb{color:var(--dim); font-size:10px;}
+ .dialwrap{display:flex; justify-content:center; margin-top:2px;}
+
+ /* compact strip (the boring/green/binary metrics) */
+ .strip{display:grid; grid-template-columns:repeat(3,1fr); gap:5px 22px;
+ padding-top:9px; border-top:1px solid var(--line);}
+ .strip .row{display:flex; align-items:center; gap:8px; font-size:12px; min-height:22px;}
+ .strip .row .nm2{color:var(--silver);}
+ .strip .row .vv{color:var(--dim); margin-left:auto; font-size:11px;}
+ .strip .row .lever{margin-left:auto;}
+ .strip .row .lever + .vv{margin-left:8px;}
+
+ /* output wall */
+ .wall{margin-top:16px; background:var(--inset); border:1px solid var(--line);
+ border-radius:10px; padding:13px 15px;}
+ .wall .wh{display:flex; align-items:center; justify-content:space-between;
+ color:var(--cream); font-size:13px; letter-spacing:.5px; margin-bottom:10px;}
+ .wall .wh .l{color:var(--gold);}
+ .wall .stream{font-size:12.5px; line-height:1.85;}
+ .wall .stream .ln{display:flex; gap:9px; align-items:baseline;}
+ .wall .stream .msg{color:var(--silver);}
+ .wall .stream .meta{color:var(--dim);}
+ .wall .stream .queued .msg{color:var(--dim);}
+ .wall .wfoot{margin-top:11px; display:flex; justify-content:flex-end;}
+
+ .footnote{color:var(--dim); font-size:11px; margin-top:14px; line-height:1.6;}
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
+ .spin{animation:pulse 1.1s ease-in-out infinite;}
+</style>
+
+<div class="console">
+
+ <!-- HEADER -->
+ <div class="head">
+ <div>
+ <div class="title">MAINT · <b>ratio</b></div>
+ <div class="sub">Approach E2 — Grouped instrument dashboard (categories collapse when clean)</div>
+ </div>
+ <div class="verdict"><span class="lamp l-ok">●</span> healthy</div>
+ </div>
+
+ <!-- ACTION BAR -->
+ <div class="actionbar">
+ <span class="lbl">Doctor</span>
+ <button class="btn">Clean up</button>
+ <button class="btn">Review &amp; fix</button>
+ <span class="spacer"></span>
+ <span class="legend">
+ <b>Auto</b> unattended · <b>Confirm</b> preview+click · <b>Human</b> you act · <b>Workflow</b> escalate · <b>None</b> diagnostic
+ </span>
+ </div>
+
+ <!-- UPDATES (pulled up: the 3 CVEs are the one real fail) -->
+ <div class="updates">
+ <div class="q">Updates</div>
+ <div class="metric"><span class="n">47</span><span class="k">pending</span></div>
+ <div class="metric"><span class="n cve">3</span><span class="k">CVE · arch-audit</span></div>
+ <div class="metric"><span class="n">5</span><span class="k">AUR stale</span></div>
+ <div class="spacer"></div>
+ <div class="note" style="max-width:250px; margin:0; color:var(--dim); font-size:11px;">
+ <span class="lamp l-fail">●</span> Security advisories present. Updates run through the workflow — never an unattended clean.
+ </div>
+ <button class="btn">Run workflow</button>
+ </div>
+
+ <!-- ============ CATEGORY PANELS (severity-ordered) ============ -->
+
+ <!-- STORAGE (has a fail: fstrim) -->
+ <div class="panel crit">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-fail">●</span>
+ <span class="nm">Storage &amp; Filesystem</span>
+ <span class="cnt"><span class="attnword crit">3 need attention</span> · 6 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3">
+ <div class="cap">Disk usage</div>
+ <div class="big">69<span class="unit">%</span></div>
+ <div class="note">root · btrfs</div>
+ <div class="meter"><div class="track">
+ <div class="zone amber" style="left:80%;right:10%"></div>
+ <div class="zone terra" style="left:90%;right:0"></div>
+ <div class="fill ok" style="width:69%"></div></div>
+ <div class="scale"><span>0</span><span>80</span><span>90</span><span>100</span></div>
+ </div>
+ </div>
+ <div class="card s3">
+ <button class="btn lever">Clean</button>
+ <div class="cap">Cache <span class="lamp l-warn">●</span></div>
+ <div class="big">8.8<span class="unit"> GB</span></div>
+ <div class="note">Auto · thr ~10 GB</div>
+ <div class="meter"><div class="track"><div class="tick" style="left:88%"></div>
+ <div class="fill warn" style="width:88%"></div></div>
+ <div class="scale"><span>0</span><span>10 GB</span></div>
+ </div>
+ </div>
+ <div class="card s3">
+ <button class="btn lever">Scrub</button>
+ <div class="cap">btrfs scrub</div>
+ <div class="dialwrap">
+ <svg width="86" height="86" viewBox="0 0 98 98">
+ <circle cx="49" cy="49" r="40" fill="none" stroke="#1a1d1f" stroke-width="8"/>
+ <circle cx="49" cy="49" r="40" fill="none" stroke="#dab53d" stroke-width="8"
+ stroke-linecap="round" stroke-dasharray="251.3" stroke-dashoffset="95" transform="rotate(-90 49 49)"/>
+ <text x="49" y="46" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="20" font-weight="700">34</text>
+ <text x="49" y="63" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="10">days</text>
+ </svg>
+ </div>
+ <div class="note" style="text-align:center"><span class="lamp l-warn">●</span> past 30d</div>
+ </div>
+ <div class="card s3">
+ <button class="btn lever">Enable</button>
+ <div class="cap">fstrim.timer <span class="lamp l-fail">●</span></div>
+ <div class="big" style="color:var(--terra)">OFF</div>
+ <div class="note">Confirm · SSD discard off</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">SMART</span><span class="vv">PASSED</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">SMART wear</span><span class="vv">0%</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">device errors</span><span class="vv">0 / 0</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">btrfs unalloc</span><span class="vv">118 GiB</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">pacman integrity</span><span class="vv">clean</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">deep-trim</span><button class="btn mini lever" style="position:static">Deep trim</button><span class="vv">keep 3</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- PACKAGES & SECURITY -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">Packages &amp; Security</span>
+ <span class="cnt"><span class="attnword">2 need attention</span> · 2 ok · updates above</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3 tile">
+ <button class="btn lever">Review</button>
+ <div class="cap">Orphans</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">13</span></div>
+ <div class="note">Confirm · unowned pkgs</div>
+ </div>
+ <div class="card s3 tile">
+ <button class="btn lever">Review</button>
+ <div class="cap">.pacnew files</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">2</span></div>
+ <div class="note">Confirm · config merges</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">keyring age</span><button class="btn mini lever" style="position:static">Refresh</button><span class="vv">12d</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">reboot required</span><span class="vv">no</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SYSTEMD & BOOT -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">systemd &amp; Boot</span>
+ <span class="cnt"><span class="attnword">2 need attention</span> · 2 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3 tile">
+ <button class="btn lever">Run workflow</button>
+ <div class="cap">Failed units</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">1</span></div>
+ <div class="note">Workflow · systemd</div>
+ </div>
+ <div class="card s3 tile">
+ <button class="btn lever">Enable</button>
+ <div class="cap">Maintenance timers</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">4/5</span></div>
+ <div class="note">Confirm · one not firing</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">is-system-running</span><span class="vv">running</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">kernel taint</span><span class="vv">0</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- LOGS & COREDUMPS -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">Logs &amp; Coredumps</span>
+ <span class="cnt"><span class="attnword">1 needs attention</span> · 4 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3 tile">
+ <button class="btn lever">Clear</button>
+ <div class="cap">Coredumps 7d</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">18</span></div>
+ <div class="note">Auto · clearable</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">journald size</span><button class="btn mini lever" style="position:static">Vacuum</button><span class="vv">1.2 GB</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">app-log cleanup</span><button class="btn mini lever" style="position:static">Run</button><span class="vv">ok</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">journal errors</span><span class="vv">12 real</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">kernel/hw events</span><span class="vv">clean</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- MEMORY / THERMAL / POWER -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">Memory · Thermal · Power</span>
+ <span class="cnt"><span class="attnword">1 needs attention</span> · 4 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s5">
+ <div class="cap">Temperatures <span class="lamp l-ok">●</span></div>
+ <div class="gaugewrap">
+ <div class="gauge">
+ <svg width="104" height="60" viewBox="0 0 112 64">
+ <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/>
+ <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/>
+ <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/>
+ <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/>
+ <line x1="56" y1="60" x2="86" y2="30" stroke="#f3e7c5" stroke-width="2.5"/>
+ <circle cx="56" cy="60" r="4" fill="#dab53d"/>
+ </svg>
+ <div class="rd">61°C</div><div class="lb">CPU</div>
+ </div>
+ <div class="gauge">
+ <svg width="104" height="60" viewBox="0 0 112 64">
+ <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/>
+ <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/>
+ <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/>
+ <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/>
+ <line x1="56" y1="60" x2="79" y2="34" stroke="#f3e7c5" stroke-width="2.5"/>
+ <circle cx="56" cy="60" r="4" fill="#dab53d"/>
+ </svg>
+ <div class="rd">54°C</div><div class="lb">NVMe</div>
+ </div>
+ </div>
+ </div>
+ <div class="card s4">
+ <div class="cap">Memory free <span class="lamp l-ok">●</span></div>
+ <div class="big">104<span class="unit"> GB</span></div>
+ <div class="note">of 125 GB · Workflow</div>
+ <div class="meter"><div class="track"><div class="fill ok" style="width:83%"></div></div>
+ <div class="scale"><span>0</span><span>free 104 / 125</span></div></div>
+ </div>
+ <div class="card s3">
+ <div class="cap">Unclean boot <span class="lamp l-warn">●</span></div>
+ <div class="big" style="color:var(--amber)">75<span class="unit">%</span></div>
+ <div class="note">Workflow · investigate</div>
+ <div class="meter"><div class="track"><div class="zone amber" style="left:0;right:0"></div>
+ <div class="fill warn" style="width:75%"></div></div>
+ <div class="scale"><span>0</span><span>100</span></div></div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">swap / zram</span><span class="vv">16 GiB</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">thermal throttle</span><span class="vv">no</span></div>
+ <div class="row"><span class="lamp l-off">○</span><span class="nm2">battery</span><span class="vv">n/a · desktop</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SERVICES & BACKUPS -->
+ <div class="panel attn">
+ <div class="phead">
+ <span class="chev">▾</span><span class="lamp l-warn">●</span>
+ <span class="nm">Services &amp; Backups</span>
+ <span class="cnt"><span class="attnword">1 needs attention</span> · 3 ok</span>
+ </div>
+ <div class="pbody">
+ <div class="heroes">
+ <div class="card s3 tile">
+ <button class="btn lever">Prune</button>
+ <div class="cap">Docker reclaim</div>
+ <div class="row"><span class="lamp l-warn">●</span><span class="big">3<span class="unit"> GB</span></span></div>
+ <div class="note">Confirm · reclaimable</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">rsyncshot backup</span><span class="vv">3h ago</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">docker stopped</span><span class="vv">2</span></div>
+ <div class="row"><span class="lamp l-ok">●</span><span class="nm2">cron</span><span class="vv">ok</span></div>
+ <div class="row"><span class="lamp l-off">○</span><span class="nm2">libvirt VMs</span><span class="vv">off</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- ===== COLLAPSED (all-green) categories — the space win ===== -->
+
+ <div class="panel clear">
+ <div class="phead">
+ <span class="chev">▸</span><span class="lamp l-ok">●</span>
+ <span class="nm">Snapshots</span>
+ <span class="cnt"><span class="okword">all clear · 2 ✓</span></span>
+ </div>
+ </div>
+
+ <div class="panel clear">
+ <div class="phead">
+ <span class="chev">▸</span><span class="lamp l-ok">●</span>
+ <span class="nm">Network &amp; Posture</span>
+ <span class="cnt"><span class="okword">all clear · 6 ✓</span></span>
+ </div>
+ </div>
+
+ <div class="footnote">
+ Collapsed panels are all-green — one header line each (chevron ▸ to expand). On a fully-healthy day every
+ panel collapses to this and the console is ~4 header rows plus the updates strip. The board's height tracks
+ how much needs you, not how many metrics exist. Verdict lamp (top-right) reads the worst <i>diagnostic</i>
+ state only — the fstrim fail and the 3 CVEs are actionable/updates, so the box still reads "healthy."
+ </div>
+
+ <!-- RUNNING / OUTPUT WALL -->
+ <div class="wall">
+ <div class="wh">
+ <span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp l-prog spin">◐</span></span>
+ <span class="meta" style="font-size:11px">4 Auto actions</span>
+ </div>
+ <div class="stream">
+ <div class="ln"><span class="lamp l-prog spin">◐</span><span class="msg">cache trim</span><span class="meta">running…</span></div>
+ <div class="ln"><span class="lamp l-ok">●</span><span class="msg">journal vacuum</span><span class="meta">done · reclaimed 0.9 GB</span></div>
+ <div class="ln"><span class="lamp l-ok">●</span><span class="msg">coredump clear</span><span class="meta">done · 12 cleared</span></div>
+ <div class="ln queued"><span class="lamp l-off">○</span><span class="msg">app-log cleanup</span><span class="meta">queued</span></div>
+ </div>
+ <div class="wfoot"><button class="btn">Done</button></div>
+ </div>
+
+</div>
diff --git a/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html b/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html
new file mode 100644
index 0000000..fbfb03d
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html
@@ -0,0 +1,488 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — Approach E3 · Hi-fi drill-down 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; --amber:#dab53d; --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);line-height:1.45;
+ padding:2rem 1.4rem 5rem;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground);
+ display:flex;justify-content:center}
+.console{width:1080px;max-width:100%}
+
+/* ---- shared primitives (from the widget gallery) ---- */
+.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass);
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none}
+.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 7px 1px rgba(203,107,77,.6)}
+.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}}
+
+.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:6px 11px;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.sm{font-size:10.5px;padding:4px 9px;border-radius:7px}
+
+.badge{font-size:.62rem;letter-spacing:.16em;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)}
+
+.bar{width:100%;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))}
+.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)}
+
+.ring{width:58px;height:58px;border-radius:50%;
+ background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative;flex:none}
+.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;line-height:1}
+.ring small{position:relative;color:var(--dim);font-size:8.5px}
+
+.readout{color:var(--cream);font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em}
+.readout small{color:var(--dim);font-size:11px;font-weight:400}
+
+/* engraved section label */
+.engrave{color:var(--steel);font-size:.66rem;letter-spacing:.28em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;flex:1}
+.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.7rem}
+
+/* lamp row (list item) */
+.lrow{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;font-size:12.5px}
+.lrow .who{color:var(--silver)} .lrow .who b{color:var(--cream)}
+.lrow .what{margin-left:auto;color:var(--dim);font-size:11px;display:flex;align-items:center;gap:8px}
+
+/* faceplate card */
+.plate{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)}
+
+/* ---- masthead ---- */
+.mast{padding:15px 18px;margin-bottom:14px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
+.mast .brand{display:flex;flex-direction:column;gap:2px}
+.mast .brand .ey{color:var(--steel);font-size:.64rem;letter-spacing:.28em;text-transform:uppercase}
+.mast .brand .ti{color:var(--cream);font-size:19px;letter-spacing:.5px}
+.mast .brand .ti b{color:var(--gold)}
+.mast .verdict{display:flex;align-items:center;gap:8px;color:var(--pass);font-size:14px;font-weight:700}
+.mast .spacer{flex:1}
+.mast .doctor{display:flex;align-items:center;gap:8px}
+.mast .doctor .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;margin-right:2px}
+
+/* updates readout inline in masthead */
+.updbar{display:flex;align-items:center;gap:16px;padding:9px 18px;margin-bottom:16px;
+ border:1px solid var(--fail);border-radius:11px;
+ background:linear-gradient(180deg,#181210,#0a0c0d)}
+.updbar .q{color:var(--fail);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase;
+ border:1px solid var(--fail);border-radius:5px;padding:2px 7px}
+.updbar .rd{display:flex;flex-direction:column;line-height:1.05}
+.updbar .rd .n{color:var(--cream);font-size:20px;font-weight:700;font-variant-numeric:tabular-nums}
+.updbar .rd .n.cve{color:var(--fail)}
+.updbar .rd .k{color:var(--dim);font-size:.6rem;letter-spacing:.12em;text-transform:uppercase}
+.updbar .spacer{flex:1}
+.updbar .msg{color:var(--dim);font-size:11px;max-width:300px}
+
+/* ---- OVERVIEW: station grid ---- */
+.ovhead{margin:0 4px 9px;display:flex;align-items:center;gap:10px}
+.overview{display:grid;grid-template-columns:repeat(4,1fr);gap:11px;margin-bottom:22px}
+.station{padding:12px 13px;cursor:pointer;display:flex;flex-direction:column;gap:9px;
+ transition:border-color .12s,transform .06s;border-left:3px solid var(--wash)}
+.station:hover{border-color:#3a352c;transform:translateY(-1px)}
+.station.attn{border-left-color:var(--amber)}
+.station.crit{border-left-color:var(--fail)}
+.station.clear{border-left-color:var(--pass)}
+.station .top{display:flex;align-items:center;gap:9px}
+.station .top .nm{color:var(--cream);font-size:12.5px;font-weight:700;letter-spacing:.02em}
+.station .top .chev{margin-left:auto;color:var(--dim);font-size:11px}
+.station .st{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)}
+.station .st .att{color:var(--amber)} .station .st .att.crit{color:var(--fail)}
+.station .st .okc{color:var(--pass)}
+.station .spark{margin-top:1px}
+
+/* ---- CATEGORY drill-down sections ---- */
+.cat{margin-bottom:11px;overflow:hidden}
+.cathead{display:flex;align-items:center;gap:12px;padding:11px 16px;cursor:pointer;user-select:none}
+.cathead:hover{background:#161412}
+.cathead .chev{color:var(--gold);font-size:12px;width:1em;text-align:center;transition:transform .15s}
+.cat.open .cathead .chev{transform:rotate(90deg)}
+.cathead .nm{color:var(--gold);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:12.5px}
+.cathead .cnt{margin-left:auto;color:var(--dim);font-size:11px;display:flex;gap:10px;align-items:center}
+.cathead .cnt .att{color:var(--amber)} .cathead .cnt .att.crit{color:var(--fail)}
+.catbody{display:none;padding:2px 16px 15px}
+.cat.open .catbody{display:block}
+
+.heroes{display:grid;grid-template-columns:repeat(12,1fr);gap:11px;margin-bottom:12px}
+.inst{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:11px 12px;
+ display:flex;flex-direction:column;gap:8px;position:relative;min-height:84px}
+.inst .cap{color:var(--steel);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase;
+ display:flex;align-items:center;gap:7px}
+.inst .cap .key{position:absolute;top:9px;right:9px}
+.inst .val{display:flex;align-items:baseline;gap:8px;margin-top:auto}
+.inst.s3{grid-column:span 3} .inst.s4{grid-column:span 4} .inst.s5{grid-column:span 5} .inst.s6{grid-column:span 6}
+.inst .sub{color:var(--dim);font-size:10.5px}
+.gaugepair{display:flex;gap:16px;align-items:center;justify-content:center;margin-top:auto}
+.gaugepair .g{display:flex;flex-direction:column;align-items:center;gap:3px}
+.gaugepair .g .lb{color:var(--steel);font-size:9px;letter-spacing:.1em}
+
+.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 12px}
+
+/* ---- output well ---- */
+.owell{margin-top:18px;padding:13px 15px}
+.owell .oh{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px;
+ color:var(--cream);font-size:13px}
+.owell .oh .l{color:var(--gold)}
+.owell .ostep{display:flex;gap:9px;align-items:flex-start;padding:3px 0;font-size:12.5px}
+.owell .ostep .lamp{margin-top:3px;width:8px;height:8px}
+.owell .ostep b{color:var(--cream);font-weight:700}
+.owell .ostep .ev{color:var(--steel);font-size:11px}
+.owell .queued b{color:var(--dim)}
+.owell .ofoot{margin-top:11px;display:flex;justify-content:flex-end}
+
+.hint{color:var(--dim);font-size:11px;margin:0 4px 14px;display:flex;align-items:center;gap:8px}
+.controls-row{display:flex;gap:8px;margin:0 2px 12px}
+.footnote{color:var(--dim);font-size:11px;margin-top:16px;line-height:1.6;padding:0 4px}
+.footnote i{color:var(--steel);font-style:normal}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="console">
+
+ <!-- ===== MASTHEAD ===== -->
+ <div class="plate mast">
+ <div class="brand">
+ <div class="ey">archsetup · maintenance console</div>
+ <div class="ti">MAINT · <b>ratio</b></div>
+ </div>
+ <div class="verdict"><span class="lamp"></span> HEALTHY</div>
+ <div class="spacer"></div>
+ <div class="doctor">
+ <span class="lbl">Doctor</span>
+ <button class="key" onclick="runDoctor()">CLEAN UP</button>
+ <button class="key">REVIEW &amp; FIX</button>
+ </div>
+ </div>
+
+ <!-- ===== UPDATES (quarantined, top billing for the 3 CVEs) ===== -->
+ <div class="updbar">
+ <span class="q">Updates</span>
+ <div class="rd"><span class="n">47</span><span class="k">pending</span></div>
+ <div class="rd"><span class="n cve">3</span><span class="k">CVE</span></div>
+ <div class="rd"><span class="n">5</span><span class="k">AUR stale</span></div>
+ <span class="msg"><span class="lamp red" style="width:8px;height:8px"></span> Security advisories present — updates run through the workflow, never the doctor.</span>
+ <span class="spacer"></span>
+ <button class="key red">RUN WORKFLOW</button>
+ </div>
+
+ <!-- ===== OVERVIEW: all categories at a glance ===== -->
+ <div class="ovhead"><span class="engrave">Overview<span class="cnt">· 8 systems</span></span></div>
+ <div class="overview" id="overview"></div>
+
+ <!-- ===== DRILL-DOWN CATEGORY SECTIONS ===== -->
+ <div class="controls-row">
+ <button class="key sm" onclick="allCats(true)">EXPAND ALL</button>
+ <button class="key sm" onclick="allCats(false)">COLLAPSE ALL</button>
+ <span class="hint" style="margin:0 0 0 6px">click any category header — or its overview tile — to drill in</span>
+ </div>
+
+ <!-- STORAGE -->
+ <div class="cat plate open" id="cat-storage">
+ <div class="cathead" onclick="toggleCat('storage')">
+ <span class="chev">▸</span><span class="lamp red"></span>
+ <span class="nm">Storage &amp; Filesystem</span>
+ <span class="cnt"><span class="att crit">3 attention</span> · 6 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <div class="cap">Disk usage</div>
+ <div class="val"><span class="readout">69<small>%</small></span></div>
+ <div class="bar"><span style="width:69%"></span></div>
+ <div class="sub">root · btrfs · thr 80 / 90</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm">CLEAN</button>
+ <div class="cap"><span class="lamp gold"></span> Package cache</div>
+ <div class="val"><span class="readout">8.8<small> GB</small></span></div>
+ <div class="bar warn"><span style="width:88%"></span><span class="tk" style="left:100%"></span></div>
+ <div class="sub">Auto · threshold ~10 GB</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm">SCRUB</button>
+ <div class="cap"><span class="lamp gold"></span> btrfs scrub</div>
+ <div style="display:flex;justify-content:center;margin-top:auto">
+ <span class="ring" style="--p:62;--rc:var(--amber)"><b>34</b><small>days</small></span>
+ </div>
+ <div class="sub" style="text-align:center">past 30d cadence</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm red">ENABLE</button>
+ <div class="cap"><span class="lamp red"></span> fstrim.timer</div>
+ <div class="val"><span class="readout" style="color:var(--fail)">OFF</span></div>
+ <div class="sub">Confirm · SSD discard disabled</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">SMART</span><span class="what">PASSED</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">SMART wear</span><span class="what">0%</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">device errors</span><span class="what">0 / 0</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">btrfs unalloc</span><span class="what">118 GiB</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">pacman integrity</span><span class="what">clean</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">deep-trim</span><span class="what"><button class="key sm">DEEP TRIM</button> keep 3</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- PACKAGES -->
+ <div class="cat plate open" id="cat-packages">
+ <div class="cathead" onclick="toggleCat('packages')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">Packages &amp; Security</span>
+ <span class="cnt"><span class="att">2 attention</span> · 2 ok · updates above</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <button class="key sm">REVIEW</button>
+ <div class="cap"><span class="lamp gold"></span> Orphans</div>
+ <div class="val"><span class="readout">13</span></div>
+ <div class="sub">Confirm · unowned pkgs</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm">REVIEW</button>
+ <div class="cap"><span class="lamp gold"></span> .pacnew files</div>
+ <div class="val"><span class="readout">2</span></div>
+ <div class="sub">Confirm · config merges</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">keyring age</span><span class="what"><button class="key sm">REFRESH</button> 12d</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">reboot required</span><span class="what">no</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SYSTEMD -->
+ <div class="cat plate open" id="cat-systemd">
+ <div class="cathead" onclick="toggleCat('systemd')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">systemd &amp; Boot</span>
+ <span class="cnt"><span class="att">2 attention</span> · 2 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <button class="key sm">WORKFLOW</button>
+ <div class="cap"><span class="lamp gold"></span> Failed units</div>
+ <div class="val"><span class="readout">1</span></div>
+ <div class="sub">Workflow · systemd</div>
+ </div>
+ <div class="inst s3">
+ <button class="key sm">ENABLE</button>
+ <div class="cap"><span class="lamp gold"></span> Maint timers</div>
+ <div class="val"><span class="readout">4<small>/5</small></span></div>
+ <div class="sub">Confirm · one not firing</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">is-system-running</span><span class="what">running</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">kernel taint</span><span class="what">0</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- LOGS -->
+ <div class="cat plate open" id="cat-logs">
+ <div class="cathead" onclick="toggleCat('logs')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">Logs &amp; Coredumps</span>
+ <span class="cnt"><span class="att">1 attention</span> · 4 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <button class="key sm">CLEAR</button>
+ <div class="cap"><span class="lamp gold"></span> Coredumps 7d</div>
+ <div class="val"><span class="readout">18</span></div>
+ <div class="sub">Auto · clearable</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">journald size</span><span class="what"><button class="key sm">VACUUM</button> 1.2 GB</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">app-log cleanup</span><span class="what"><button class="key sm">RUN</button> ok</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">journal errors</span><span class="what">12 real</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">kernel/hw events</span><span class="what">clean</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- MEMORY/THERMAL/POWER -->
+ <div class="cat plate open" id="cat-power">
+ <div class="cathead" onclick="toggleCat('power')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">Memory · Thermal · Power</span>
+ <span class="cnt"><span class="att">1 attention</span> · 4 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s5">
+ <div class="cap"><span class="lamp"></span> Temperatures</div>
+ <div class="gaugepair">
+ <div class="g"><span class="ring" style="--p:61;--rc:var(--pass)"><b>61°</b></span><span class="lb">CPU</span></div>
+ <div class="g"><span class="ring" style="--p:54;--rc:var(--pass)"><b>54°</b></span><span class="lb">NVMe</span></div>
+ </div>
+ </div>
+ <div class="inst s4">
+ <div class="cap"><span class="lamp"></span> Memory free</div>
+ <div class="val"><span class="readout">104<small> GB</small></span></div>
+ <div class="bar"><span style="width:83%"></span></div>
+ <div class="sub">of 125 GB · 0 OOM</div>
+ </div>
+ <div class="inst s3">
+ <div class="cap"><span class="lamp gold"></span> Unclean boot</div>
+ <div class="val"><span class="readout" style="color:var(--amber)">75<small>%</small></span></div>
+ <div class="bar warn"><span style="width:75%"></span></div>
+ <div class="sub">Workflow · investigate</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">swap / zram</span><span class="what">16 GiB</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">thermal throttle</span><span class="what">no</span></div>
+ <div class="lrow"><span class="lamp off"></span><span class="who">battery</span><span class="what">n/a · desktop</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SERVICES -->
+ <div class="cat plate open" id="cat-services">
+ <div class="cathead" onclick="toggleCat('services')">
+ <span class="chev">▸</span><span class="lamp gold"></span>
+ <span class="nm">Services &amp; Backups</span>
+ <span class="cnt"><span class="att">1 attention</span> · 3 ok</span>
+ </div>
+ <div class="catbody">
+ <div class="heroes">
+ <div class="inst s3">
+ <button class="key sm">PRUNE</button>
+ <div class="cap"><span class="lamp gold"></span> Docker reclaim</div>
+ <div class="val"><span class="readout">3<small> GB</small></span></div>
+ <div class="sub">Confirm · reclaimable</div>
+ </div>
+ </div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot backup</span><span class="what">3h ago</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">docker stopped</span><span class="what">2</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">cron</span><span class="what">ok</span></div>
+ <div class="lrow"><span class="lamp off"></span><span class="who">libvirt VMs</span><span class="what">off</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- SNAPSHOTS (collapsed by default — all green) -->
+ <div class="cat plate" id="cat-snapshots">
+ <div class="cathead" onclick="toggleCat('snapshots')">
+ <span class="chev">▸</span><span class="lamp"></span>
+ <span class="nm">Snapshots</span>
+ <span class="cnt"><span class="okc" style="color:var(--pass)">all clear · 2 ✓</span></span>
+ </div>
+ <div class="catbody">
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">snapper count</span><span class="what"><button class="key sm">PRUNE</button> 42 · retention ok</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">snapshot timer</span><span class="what">active</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- NETWORK (collapsed by default — all green) -->
+ <div class="cat plate" id="cat-network">
+ <div class="cathead" onclick="toggleCat('network')">
+ <span class="chev">▸</span><span class="lamp"></span>
+ <span class="nm">Network &amp; Posture</span>
+ <span class="cnt"><span class="okc" style="color:var(--pass)">all clear · 6 ✓</span></span>
+ </div>
+ <div class="catbody">
+ <div class="strip">
+ <div class="lrow"><span class="lamp"></span><span class="who">firewall</span><span class="what">active</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">unexpected listeners</span><span class="what">3</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">tailscale</span><span class="what">4/4</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">fail2ban</span><span class="what">active</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">NTP sync</span><span class="what">synced</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">DNS / NM</span><span class="what">ok</span></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- ===== OUTPUT WELL (doctor running) ===== -->
+ <div class="plate owell" id="owell" style="display:none">
+ <div class="oh"><span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp busy"></span></span>
+ <span style="color:var(--dim);font-size:11px">4 Auto actions</span></div>
+ <div class="ostep"><span class="lamp busy"></span><span><b>cache trim</b> <span class="ev">running…</span></span></div>
+ <div class="ostep"><span class="lamp"></span><span><b>journal vacuum</b> <span class="ev">done · reclaimed 0.9 GB</span></span></div>
+ <div class="ostep"><span class="lamp"></span><span><b>coredump clear</b> <span class="ev">done · 12 cleared</span></span></div>
+ <div class="ostep queued"><span class="lamp off"></span><span><b>app-log cleanup</b> <span class="ev">queued</span></span></div>
+ <div class="ofoot"><button class="key" onclick="document.getElementById('owell').style.display='none'">DONE</button></div>
+ </div>
+
+ <div class="footnote">
+ Two levels: the <i>Overview</i> grid is every system at a glance — click a tile to jump to and open its section.
+ Each <i>category</i> below is a drill-down you expand or collapse (click the header, or use Expand/Collapse all).
+ All-green categories (Snapshots, Network) start collapsed — on a fully-healthy day every section collapses and the
+ console is the masthead + overview. Verdict lamp reads worst <i>diagnostic</i> state only — the fstrim fail and the
+ 3 CVEs are actionable/updates, so the box still reads HEALTHY.
+ </div>
+
+</div>
+
+<script>
+const CATS = [
+ {id:'storage', name:'Storage & FS', cls:'crit', att:'3', ok:'6', crit:true},
+ {id:'packages', name:'Packages & Sec', cls:'attn', att:'2', ok:'2'},
+ {id:'systemd', name:'systemd & Boot', cls:'attn', att:'2', ok:'2'},
+ {id:'logs', name:'Logs & Cores', cls:'attn', att:'1', ok:'4'},
+ {id:'power', name:'Mem·Therm·Power', cls:'attn', att:'1', ok:'4'},
+ {id:'services', name:'Services & Backup', cls:'attn', att:'1', ok:'3'},
+ {id:'snapshots',name:'Snapshots', cls:'clear', att:'0', ok:'2'},
+ {id:'network', name:'Network & Posture',cls:'clear', att:'0', ok:'6'},
+];
+const lampFor = c => c.cls==='crit'?'red':c.cls==='attn'?'gold':'';
+// build overview station tiles
+const ov = document.getElementById('overview');
+CATS.forEach(c=>{
+ const el=document.createElement('div');
+ el.className='plate station '+c.cls;
+ const att = c.att!=='0'
+ ? `<span class="att ${c.crit?'crit':''}">${c.att} attention</span> · <span class="okc">${c.ok} ok</span>`
+ : `<span class="okc">all clear · ${c.ok} ✓</span>`;
+ el.innerHTML =
+ `<div class="top"><span class="lamp ${lampFor(c)}"></span><span class="nm">${c.name}</span><span class="chev">▸</span></div>
+ <div class="st">${att}</div>`;
+ el.onclick=()=>openCat(c.id);
+ ov.appendChild(el);
+});
+function toggleCat(id){ document.getElementById('cat-'+id).classList.toggle('open'); }
+function openCat(id){
+ const el=document.getElementById('cat-'+id);
+ el.classList.add('open');
+ el.scrollIntoView({behavior:'smooth',block:'center'});
+}
+function allCats(open){ CATS.forEach(c=>{
+ document.getElementById('cat-'+c.id).classList.toggle('open',open); }); }
+function runDoctor(){ document.getElementById('owell').style.display='block';
+ document.getElementById('owell').scrollIntoView({behavior:'smooth',block:'center'}); }
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-E4-interactive.html b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html
new file mode 100644
index 0000000..c678a8d
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html
@@ -0,0 +1,546 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — Approach E4 · Interactive view-swap 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; --amber:#dab53d; --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);line-height:1.45;
+ padding:2rem 1.4rem 5rem;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground);
+ display:flex;justify-content:center}
+.console{width:1060px;max-width:100%}
+
+/* ---- primitives (widget-gallery kit) ---- */
+.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass);
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none}
+.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 7px 1px rgba(203,107,77,.6)}
+.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}}
+
+.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:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4);
+ white-space:nowrap}
+.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.armed{background:rgba(203,107,77,.15);border-color:var(--fail);color:var(--fail)}
+.key.sm{font-size:10.5px;padding:4px 9px;border-radius:7px}
+.key.done{opacity:.45;pointer-events:none}
+
+.bar{width:100%;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;
+ transition:width .5s ease}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+.bar.okc>span{background:linear-gradient(90deg,#5c7626,var(--pass))}
+.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)}
+
+.ring{width:58px;height:58px;border-radius:50%;
+ background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative;flex:none}
+.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;line-height:1}
+.ring small{position:relative;color:var(--dim);font-size:8.5px}
+
+.readout{color:var(--cream);font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em}
+.readout small{color:var(--dim);font-size:11px;font-weight:400}
+
+.engrave{color:var(--steel);font-size:.66rem;letter-spacing:.28em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;flex:1}
+.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.7rem}
+
+.lrow{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;font-size:12.5px}
+.lrow .who{color:var(--silver)} .lrow .who b{color:var(--cream)}
+.lrow .what{margin-left:auto;color:var(--dim);font-size:11px;display:flex;align-items:center;gap:8px}
+
+.plate{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)}
+
+/* masthead */
+.mast{padding:15px 18px;margin-bottom:14px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+.mast .brand{display:flex;flex-direction:column;gap:2px}
+.mast .brand .ey{color:var(--steel);font-size:.64rem;letter-spacing:.28em;text-transform:uppercase}
+.mast .brand .ti{color:var(--cream);font-size:19px;letter-spacing:.5px}
+.mast .brand .ti b{color:var(--gold)}
+.mast .verdict{display:flex;align-items:center;gap:8px;color:var(--pass);font-size:14px;font-weight:700}
+.mast .spacer{flex:1}
+.mast .doctor{display:flex;align-items:center;gap:8px}
+.mast .doctor .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;margin-right:2px}
+
+/* updates */
+.updbar{display:flex;align-items:center;gap:16px;padding:9px 18px;margin-bottom:16px;
+ border:1px solid var(--fail);border-radius:11px;
+ background:linear-gradient(180deg,#181210,#0a0c0d)}
+.updbar .q{color:var(--fail);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase;
+ border:1px solid var(--fail);border-radius:5px;padding:2px 7px}
+.updbar .rd{display:flex;flex-direction:column;line-height:1.05}
+.updbar .rd .n{color:var(--cream);font-size:20px;font-weight:700;font-variant-numeric:tabular-nums}
+.updbar .rd .n.cve{color:var(--fail)}
+.updbar .rd .k{color:var(--dim);font-size:.6rem;letter-spacing:.12em;text-transform:uppercase}
+.updbar .spacer{flex:1}
+.updbar .msg{color:var(--dim);font-size:11px;max-width:300px}
+
+/* overview stations */
+.overview{display:grid;grid-template-columns:repeat(4,1fr);gap:11px}
+.station{padding:12px 13px;cursor:pointer;display:flex;flex-direction:column;gap:9px;
+ transition:border-color .12s,transform .06s;border-left:3px solid var(--wash)}
+.station:hover{border-color:#3a352c;transform:translateY(-1px)}
+.station.attn{border-left-color:var(--amber)}
+.station.crit{border-left-color:var(--fail)}
+.station.clear{border-left-color:var(--pass)}
+.station .top{display:flex;align-items:center;gap:9px}
+.station .top .nm{color:var(--cream);font-size:12.5px;font-weight:700}
+.station .top .chev{margin-left:auto;color:var(--dim);font-size:11px}
+.station .st{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)}
+.station .st .att{color:var(--amber)} .station .st .att.crit{color:var(--fail)}
+.station .st .okc{color:var(--pass)}
+.ovhead{margin:0 4px 9px;display:flex;align-items:center;gap:10px}
+
+/* category detail view */
+.catplate{padding:0 0 15px}
+.cathead{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid #201d17;margin-bottom:13px}
+.cathead .nm{color:var(--gold);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:13px}
+.cathead .cnt{color:var(--dim);font-size:11px;display:flex;gap:10px;align-items:center}
+.cathead .cnt .att{color:var(--amber)} .cathead .cnt .att.crit{color:var(--fail)}
+.cathead .cnt .okc{color:var(--pass)}
+.cathead .navs{margin-left:auto;display:flex;gap:7px}
+.catbody{padding:0 16px}
+.heroes{display:grid;grid-template-columns:repeat(12,1fr);gap:11px;margin-bottom:12px}
+.inst{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:11px 12px;
+ display:flex;flex-direction:column;gap:8px;position:relative;min-height:86px}
+.inst .cap{color:var(--steel);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase;
+ display:flex;align-items:center;gap:7px;padding-right:70px}
+.inst .lever{position:absolute;top:9px;right:9px}
+.inst .val{display:flex;align-items:baseline;gap:8px;margin-top:auto}
+.inst.s3{grid-column:span 3} .inst.s4{grid-column:span 4} .inst.s5{grid-column:span 5} .inst.s6{grid-column:span 6}
+.inst .sub{color:var(--dim);font-size:10.5px}
+.gaugepair{display:flex;gap:16px;align-items:center;justify-content:center;margin-top:auto}
+.gaugepair .g{display:flex;flex-direction:column;align-items:center;gap:3px}
+.gaugepair .g .lb{color:var(--steel);font-size:9px;letter-spacing:.1em}
+.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 12px}
+
+/* doctor / review views */
+.owell{padding:13px 16px}
+.owell .oh{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px;
+ color:var(--cream);font-size:13px}
+.owell .oh .l{color:var(--gold)}
+.ostep{display:flex;gap:9px;align-items:flex-start;padding:4px 0;font-size:12.5px}
+.ostep .lamp{margin-top:3px;width:8px;height:8px}
+.ostep b{color:var(--cream);font-weight:700}
+.ostep .ev{color:var(--steel);font-size:11px;display:block}
+.ostep.queued b{color:var(--dim)}
+.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px}
+
+.rvrow{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;background:#141210;
+ font-size:12.5px;margin-bottom:6px}
+.rvrow .who{color:var(--cream);font-weight:700}
+.rvrow .why{color:var(--dim);font-size:11px}
+.rvrow .keys{margin-left:auto;display:flex;gap:7px;align-items:center}
+.rvrow .res{color:var(--pass);font-size:11px}
+.rvnote{color:var(--dim);font-size:11px;margin:10px 2px 0;line-height:1.5}
+
+/* toast */
+#toast{position:fixed;bottom:26px;right:26px;display:flex;flex-direction:column;gap:8px;z-index:9}
+.toastw{font-size:11.5px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 11px;
+ box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .18s ease}
+.toastw.err{background:#5e3a2e}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+.crumb{color:var(--dim);font-size:11px;margin:0 4px 9px;display:flex;gap:6px;align-items:center}
+.crumb a{color:var(--gold);cursor:pointer;text-decoration:none}
+.footnote{color:var(--dim);font-size:11px;margin-top:16px;line-height:1.6;padding:0 4px}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="console">
+
+ <!-- persistent masthead -->
+ <div class="plate mast">
+ <div class="brand">
+ <div class="ey">archsetup · maintenance console · E4 interactive</div>
+ <div class="ti">MAINT · <b>ratio</b></div>
+ </div>
+ <div class="verdict" id="verdict"><span class="lamp"></span> HEALTHY</div>
+ <div class="spacer"></div>
+ <div class="doctor">
+ <button class="key" id="homekey" onclick="go('home')">HOME</button>
+ <span class="lbl" style="margin-left:8px">Doctor</span>
+ <button class="key" onclick="go('doctor')">CLEAN UP</button>
+ <button class="key" onclick="go('review')">REVIEW &amp; FIX</button>
+ </div>
+ </div>
+
+ <!-- swapped view -->
+ <div id="view"></div>
+
+ <div class="footnote">
+ Fully interactive: views replace each other (no inline collapse). HOME = overview of all systems;
+ click a station to drill in; PREV/NEXT walk the categories; CLEAN UP streams the output well and
+ actually updates the metrics; REVIEW &amp; FIX lists every Confirm item — destructive ones arm on
+ first press, fire on second. Workflow items always escalate (toast) — the console never runs them.
+ </div>
+</div>
+<div id="toast"></div>
+
+<script>
+/* ================= state ================= */
+const S = {
+ cache:{v:8.8, st:'warn'}, scrub:{v:34, st:'warn'}, fstrim:{v:'OFF', st:'fail'},
+ disk:{v:69, st:'ok'}, smart:{v:'PASSED', st:'ok'}, wear:{v:'0%', st:'ok'},
+ deverr:{v:'0 / 0', st:'ok'}, unalloc:{v:'118 GiB', st:'ok'}, integ:{v:'clean', st:'ok'},
+ deeptrim:{v:'keep 3', st:'ok'},
+ orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'no', st:'ok'},
+ failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'},
+ cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'},
+ jerr:{v:'12 real', st:'ok'}, khw:{v:'clean', st:'ok'},
+ temps:{v:'61/54', st:'ok'}, mem:{v:104, st:'ok'}, unclean:{v:75, st:'warn'},
+ zram:{v:'16 GiB', st:'ok'}, throttle:{v:'no', st:'ok'}, battery:{v:'n/a', st:'off'},
+ docker:{v:3, st:'warn'}, rsync:{v:'3h ago', st:'ok'}, dstopped:{v:2, st:'ok'},
+ cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'},
+ snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'},
+ fw:{v:'active', st:'ok'}, listen:{v:3, st:'ok'}, ts:{v:'4/4', st:'ok'},
+ f2b:{v:'active', st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'},
+};
+
+const CATS = [
+ {id:'storage', name:'Storage & Filesystem', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']},
+ {id:'packages', name:'Packages & Security', ids:['orphans','pacnew','keyring','reboot']},
+ {id:'systemd', name:'systemd & Boot', ids:['failed','timers','sysrun','taint']},
+ {id:'logs', name:'Logs & Coredumps', ids:['cores','journald','applog','jerr','khw']},
+ {id:'power', name:'Memory · Thermal · Power', ids:['temps','mem','unclean','zram','throttle','battery']},
+ {id:'services', name:'Services & Backups', ids:['docker','rsync','dstopped','cron','libvirt']},
+ {id:'snapshots',name:'Snapshots', ids:['snapper','stimer']},
+ {id:'network', name:'Network & Posture', ids:['fw','listen','ts','f2b','ntp','dns']},
+];
+
+let view = 'home';
+let armed = null; // id of armed destructive key
+let doctorTimer = null;
+
+/* ================= helpers ================= */
+const $ = id => document.getElementById(id);
+function counts(cat){
+ let attn=0, ok=0, crit=false;
+ cat.ids.forEach(i=>{ const st=S[i].st;
+ if(st==='warn'){attn++} else if(st==='fail'){attn++;crit=true} else ok++; });
+ return {attn, ok, crit};
+}
+function lampCls(st){ return st==='ok'?'':st==='warn'?'gold':st==='fail'?'red':'off'; }
+function catLamp(cat){ const c=counts(cat); return c.crit?'red':c.attn?'gold':''; }
+function toast(msg, err){
+ const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg;
+ $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600);
+}
+function esc(id){ toast('escalates to the system-health-check workflow — not run from the console', true); }
+
+/* generic lever runner: busy → mutate → re-render */
+function fire(act){
+ const a = ACTIONS[act];
+ if(a.arm && armed!==act){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; }
+ armed=null;
+ toast(a.busy||'running…');
+ setTimeout(()=>{ a.run(); toast(a.doneMsg); render(); }, a.ms||800);
+}
+
+const ACTIONS = {
+ clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB',
+ run(){ S.cache={v:0.4, st:'ok'}; } },
+ scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion',
+ run(){ S.scrub={v:0, st:'ok'}; } },
+ fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active',
+ run(){ S.fstrim={v:'ON', st:'ok'}; S.timers={v:'5/5', st:'ok'}; } },
+ deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press DEEP TRIM again', busy:'paccache -rk1 …', ms:1100,
+ doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more', run(){ S.deeptrim={v:'keep 1', st:'ok'}; if(S.cache.st==='ok') S.cache={v:0.2,st:'ok'}; } },
+ orphans:{ arm:true, armMsg:'remove 13 orphaned packages — press again', busy:'pacman -Rns (13 pkgs) …', ms:1200,
+ doneMsg:'orphans removed — 13 pkgs', run(){ S.orphans={v:0, st:'ok'}; } },
+ pacnew:{ busy:'diffing 2 .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged',
+ run(){ S.pacnew={v:0, st:'ok'}; } },
+ keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed',
+ run(){ S.keyring={v:0, st:'ok'}; } },
+ timers:{ busy:'systemctl enable --now (1 timer) …', ms:800, doneMsg:'all 5 maintenance timers firing',
+ run(){ S.timers={v:'5/5', st:'ok'}; S.fstrim.st==='fail' && (S.fstrim={v:'ON',st:'ok'}); } },
+ cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared — 18 removed',
+ run(){ S.cores={v:0, st:'ok'}; } },
+ journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB',
+ run(){ S.journald={v:0.3, st:'ok'}; } },
+ applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d', run(){} },
+ docker:{ arm:true, armMsg:'docker prune tier 1+2 — press again', busy:'docker container/image prune …', ms:1200,
+ doneMsg:'docker pruned — reclaimed 2.8 GB', run(){ S.docker={v:0.2, st:'ok'}; } },
+ snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900,
+ doneMsg:'snapper — retention applied', run(){ S.snapper={v:31, st:'ok'}; } },
+};
+
+/* ================= view renderers ================= */
+function lever(act, label, cls){
+ const armedNow = armed===act;
+ return `<button class="key sm ${cls||''} ${armedNow?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${armedNow?label+'?':label}</button>`;
+}
+function wkey(label){ return `<button class="key sm" onclick="event.stopPropagation();esc()">${label||'WORKFLOW'}</button>`; }
+function lrow(id, name, extra){
+ const m=S[id];
+ return `<div class="lrow"><span class="lamp ${lampCls(m.st)}"></span><span class="who">${name}</span>
+ <span class="what">${extra||''}${m.v}</span></div>`;
+}
+
+function renderHome(){
+ const stations = CATS.map(c=>{
+ const k=counts(c);
+ const st = k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>`
+ : `<span class="okc">all clear · ${k.ok} ✓</span>`;
+ return `<div class="plate station ${k.crit?'crit':k.attn?'attn':'clear'}" onclick="go('cat:${c.id}')">
+ <div class="top"><span class="lamp ${catLamp(c)}"></span><span class="nm">${c.name}</span><span class="chev">▸</span></div>
+ <div class="st">${st}</div></div>`;
+ }).join('');
+ return `
+ <div class="updbar">
+ <span class="q">Updates</span>
+ <div class="rd"><span class="n">47</span><span class="k">pending</span></div>
+ <div class="rd"><span class="n cve">3</span><span class="k">CVE</span></div>
+ <div class="rd"><span class="n">5</span><span class="k">AUR stale</span></div>
+ <span class="msg"><span class="lamp red" style="width:8px;height:8px"></span> Security advisories present — updates run through the workflow, never the doctor.</span>
+ <span class="spacer"></span>
+ <button class="key red" onclick="esc()">RUN WORKFLOW</button>
+ </div>
+ <div class="ovhead"><span class="engrave">Overview<span class="cnt">· ${CATS.length} systems</span></span></div>
+ <div class="overview">${stations}</div>`;
+}
+
+function heroBar(id, cap, unit, pct, opts){
+ const m=S[id]; const o=opts||{};
+ return `<div class="inst s3">
+ ${o.lever||''}
+ <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div>
+ <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div>
+ <div class="bar ${m.st==='warn'?'warn':m.st==='ok'?'okc':''}">${o.tick?'<span class="tk" style="left:'+o.tick+'%"></span>':''}<span style="width:${pct}%"></span></div>
+ <div class="sub">${o.sub||''}</div></div>`;
+}
+function heroCount(id, cap, opts){
+ const m=S[id]; const o=opts||{};
+ return `<div class="inst s3">
+ ${o.lever||''}
+ <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div>
+ <div class="val"><span class="readout" ${m.st==='fail'?'style="color:var(--fail)"':m.st==='warn'&&o.tint?'style="color:var(--amber)"':''}>${m.v}${o.unit?'<small>'+o.unit+'</small>':''}</span></div>
+ <div class="sub">${o.sub||''}</div></div>`;
+}
+function heroRing(id, cap, pct, color, small, opts){
+ const m=S[id]; const o=opts||{};
+ return `<div class="inst s3">
+ ${o.lever||''}
+ <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div>
+ <div style="display:flex;justify-content:center;margin-top:auto">
+ <span class="ring" style="--p:${pct};--rc:${color}"><b>${m.v}</b><small>${small}</small></span></div>
+ <div class="sub" style="text-align:center">${o.sub||''}</div></div>`;
+}
+
+const CATVIEW = {
+ storage(){ return {
+ heroes:
+ heroBar('disk','Disk usage','%',S.disk.v,{sub:'root · btrfs · thr 80 / 90'})+
+ heroBar('cache','Package cache','GB',Math.min(100,S.cache.v/10*100),{lever:`<span class="lever">${lever('clean_cache','CLEAN')}</span>`,tick:100,sub:'Auto · threshold ~10 GB'})+
+ heroRing('scrub','btrfs scrub',Math.min(100,S.scrub.v/55*100),S.scrub.st==='ok'?'var(--pass)':'var(--amber)','days',{lever:`<span class="lever">${lever('scrub','SCRUB')}</span>`,sub:S.scrub.st==='ok'?'scrub fresh':'past 30d cadence'})+
+ heroCount('fstrim','fstrim.timer',{lever:`<span class="lever">${lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':'')}</span>`,sub:'Confirm · weekly SSD TRIM'}),
+ strip:
+ lrow('smart','SMART')+lrow('wear','SMART wear')+lrow('deverr','device errors')+
+ lrow('unalloc','btrfs unalloc')+lrow('integ','pacman integrity')+
+ lrow('deeptrim','deep-trim', lever('deeptrim','DEEP TRIM')+' ')
+ };},
+ packages(){ return {
+ heroes:
+ heroCount('orphans','Orphans',{lever:`<span class="lever">${lever('orphans','REMOVE')}</span>`,sub:'Confirm · unowned pkgs',tint:1})+
+ heroCount('pacnew','.pacnew files',{lever:`<span class="lever">${lever('pacnew','REVIEW')}</span>`,sub:'Confirm · config merges',tint:1}),
+ strip:
+ lrow('keyring','keyring age (d)', lever('keyring','REFRESH')+' ')+
+ lrow('reboot','reboot required')
+ };},
+ systemd(){ return {
+ heroes:
+ heroCount('failed','Failed units',{lever:`<span class="lever">${wkey()}</span>`,sub:'Workflow · investigate why',tint:1})+
+ heroCount('timers','Maint timers',{lever:`<span class="lever">${lever('timers','ENABLE')}</span>`,sub:'Confirm · paccache/scrub/fstrim/…',tint:1}),
+ strip: lrow('sysrun','is-system-running')+lrow('taint','kernel taint')
+ };},
+ logs(){ return {
+ heroes:
+ heroCount('cores','Coredumps 7d',{lever:`<span class="lever">${lever('cores','CLEAR')}</span>`,sub:'Auto · keeps last 3 days',tint:1}),
+ strip:
+ lrow('journald','journald size (GB)', lever('journald','VACUUM')+' ')+
+ lrow('applog','app-log cleanup', lever('applog','RUN')+' ')+
+ lrow('jerr','journal errors')+lrow('khw','kernel/hw events')
+ };},
+ power(){ return {
+ heroes:
+ `<div class="inst s5"><div class="cap"><span class="lamp"></span> Temperatures</div>
+ <div class="gaugepair">
+ <div class="g"><span class="ring" style="--p:61;--rc:var(--pass)"><b>61°</b></span><span class="lb">CPU</span></div>
+ <div class="g"><span class="ring" style="--p:54;--rc:var(--pass)"><b>54°</b></span><span class="lb">NVMe</span></div>
+ </div></div>`+
+ `<div class="inst s4"><div class="cap"><span class="lamp"></span> Memory free</div>
+ <div class="val"><span class="readout">104<small> GB</small></span></div>
+ <div class="bar okc"><span style="width:83%"></span></div><div class="sub">of 125 GB · 0 OOM</div></div>`+
+ `<div class="inst s3"><span class="lever">${wkey()}</span><div class="cap"><span class="lamp gold"></span> Unclean boot</div>
+ <div class="val"><span class="readout" style="color:var(--amber)">75<small>%</small></span></div>
+ <div class="bar warn"><span style="width:75%"></span></div><div class="sub">Workflow · investigate</div></div>`,
+ strip: lrow('zram','swap / zram')+lrow('throttle','thermal throttle')+lrow('battery','battery')
+ };},
+ services(){ return {
+ heroes:
+ heroCount('docker','Docker reclaim',{lever:`<span class="lever">${lever('docker','PRUNE')}</span>`,unit:' GB',sub:'Confirm · tiers 1+2',tint:1}),
+ strip:
+ lrow('rsync','rsyncshot backup')+lrow('dstopped','docker stopped')+
+ lrow('cron','cron')+lrow('libvirt','libvirt VMs')
+ };},
+ snapshots(){ return {
+ heroes:'',
+ strip:
+ lrow('snapper','snapper count', lever('snapper','PRUNE')+' ')+
+ lrow('stimer','snapshot timer')
+ };},
+ network(){ return {
+ heroes:'',
+ strip:
+ lrow('fw','firewall')+lrow('listen','unexpected listeners')+lrow('ts','tailscale')+
+ lrow('f2b','fail2ban')+lrow('ntp','NTP sync')+lrow('dns','DNS / NM')
+ };},
+};
+
+function renderCat(cid){
+ const idx = CATS.findIndex(c=>c.id===cid);
+ const cat = CATS[idx];
+ const k = counts(cat);
+ const prev = CATS[(idx+CATS.length-1)%CATS.length].id;
+ const next = CATS[(idx+1)%CATS.length].id;
+ const body = CATVIEW[cid]();
+ const cnt = k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>`
+ : `<span class="okc">all clear · ${k.ok} ✓</span>`;
+ return `
+ <div class="crumb"><a onclick="go('home')">overview</a> ▸ ${cat.name.toLowerCase()}</div>
+ <div class="plate catplate">
+ <div class="cathead">
+ <span class="lamp ${catLamp(cat)}"></span>
+ <span class="nm">${cat.name}</span>
+ <span class="cnt">${cnt}</span>
+ <span class="navs">
+ <button class="key sm" onclick="go('cat:${prev}')">◂ PREV</button>
+ <button class="key sm" onclick="go('home')">HOME</button>
+ <button class="key sm" onclick="go('cat:${next}')">NEXT ▸</button>
+ </span>
+ </div>
+ <div class="catbody">
+ ${body.heroes?`<div class="heroes">${body.heroes}</div>`:''}
+ <div class="strip">${body.strip}</div>
+ </div>
+ </div>`;
+}
+
+/* ---- doctor (clean up) ---- */
+let docSteps = [];
+function renderDoctor(){
+ const rows = docSteps.map(s=>{
+ const lamp = s.st==='run'?'busy':s.st==='done'?'':'off';
+ return `<div class="ostep ${s.st==='wait'?'queued':''}"><span class="lamp ${lamp}"></span>
+ <span><b>${s.name}</b> <span class="ev">${s.ev}</span></span></div>`;
+ }).join('');
+ const running = docSteps.some(s=>s.st!=='done');
+ return `
+ <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · clean up</div>
+ <div class="plate owell">
+ <div class="oh"><span>MAINT · <span class="l">ratio</span> · CLEANING ${running?'<span class="lamp busy"></span>':'<span class="lamp"></span>'}</span>
+ <span style="color:var(--dim);font-size:11px">${docSteps.length} Auto actions</span></div>
+ ${rows}
+ <div class="ofoot"><button class="key ${running?'done':''}" onclick="go('home')">DONE</button></div>
+ </div>`;
+}
+function startDoctor(){
+ clearTimeout(doctorTimer);
+ docSteps = [
+ {name:'cache trim', act:'clean_cache', st:'wait', ev:'queued',
+ res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'done · reclaimed 8.4 GB') : 'done · nothing to reclaim'},
+ {name:'journal vacuum',act:'journald', st:'wait', ev:'queued',
+ res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'done · reclaimed 0.9 GB') : 'done · already tight'},
+ {name:'coredump clear',act:'cores', st:'wait', ev:'queued',
+ res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return 'done · '+n+' cleared';})() : 'done · none to clear'},
+ {name:'app-log cleanup',act:'applog', st:'wait', ev:'queued',
+ res:()=> 'done · nothing older than 7d'},
+ ];
+ let i = 0;
+ function step(){
+ if(i>0){ docSteps[i-1].st='done'; docSteps[i-1].ev=docSteps[i-1].res(); }
+ if(i<docSteps.length){ docSteps[i].st='run'; docSteps[i].ev='running…'; i++;
+ if(view==='doctor') $('view').innerHTML=renderDoctor();
+ doctorTimer=setTimeout(step, 1000);
+ } else {
+ if(view==='doctor') $('view').innerHTML=renderDoctor();
+ toast('clean up complete');
+ }
+ }
+ step();
+}
+
+/* ---- review & fix ---- */
+const REVIEW = [
+ {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', okv:()=>S.fstrim.st==='ok'},
+ {act:'scrub', who:'btrfs scrub', why:'34d since last scrub (30d cadence)', id:'scrub', okv:()=>S.scrub.st==='ok'},
+ {act:'orphans', who:'orphans', why:'13 unowned packages', id:'orphans', okv:()=>S.orphans.st==='ok'},
+ {act:'pacnew', who:'.pacnew files', why:'2 configs awaiting merge', id:'pacnew', okv:()=>S.pacnew.st==='ok'},
+ {act:'timers', who:'maint timers', why:'1 of 5 not firing', id:'timers', okv:()=>S.timers.st==='ok'},
+ {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', okv:()=>S.keyring.st==='ok'},
+ {act:'docker', who:'docker reclaim', why:'3 GB reclaimable (tiers 1+2)', id:'docker', okv:()=>S.docker.st==='ok'},
+ {act:'snapper', who:'snapper', why:'retention within budget — optional', id:'snapper', okv:()=>S.snapper.v<42},
+ {act:'deeptrim',who:'deep-trim', why:'keep 1 version — frees most, less downgrade headroom', id:'deeptrim', okv:()=>S.deeptrim.v==='keep 1'},
+];
+function renderReview(){
+ const rows = REVIEW.map(r=>{
+ const done = r.okv();
+ return `<div class="rvrow">
+ <span class="lamp ${done?'':lampCls(S[r.id].st)}"></span>
+ <span class="who">${r.who}</span><span class="why">${r.why}</span>
+ <span class="keys">${done?'<span class="res">done ✓</span>':lever(r.act, ACTIONS[r.act].arm?'FIX':'FIX')}</span>
+ </div>`;
+ }).join('');
+ return `
+ <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · review &amp; fix</div>
+ <div class="plate owell">
+ <div class="oh"><span>MAINT · <span class="l">ratio</span> · REVIEW &amp; FIX</span>
+ <span style="color:var(--dim);font-size:11px">${REVIEW.filter(r=>!r.okv()).length} Confirm items</span></div>
+ ${rows}
+ <div class="rvnote">Destructive fixes (orphan removal, deep-trim, prunes) arm on first press and fire on the
+ second. Workflow items — failed units, unclean boots, updates, CVEs — are deliberately absent here: they
+ escalate to the health-check workflow.</div>
+ <div class="ofoot"><button class="key" onclick="go('home')">DONE</button></div>
+ </div>`;
+}
+
+/* ================= router ================= */
+function go(v){
+ armed = null;
+ view = v;
+ if(v==='doctor') startDoctor();
+ render();
+ window.scrollTo({top:0, behavior:'smooth'});
+}
+function render(){
+ $('homekey').classList.toggle('on', view==='home');
+ const el = $('view');
+ if(view==='home') el.innerHTML = renderHome();
+ else if(view==='doctor') el.innerHTML = renderDoctor();
+ else if(view==='review') el.innerHTML = renderReview();
+ else if(view.startsWith('cat:')) el.innerHTML = renderCat(view.slice(4));
+}
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html
new file mode 100644
index 0000000..95ec4c9
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html
@@ -0,0 +1,1696 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — E5 · Selector + dense subpanel</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; --amber:#dab53d; --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);line-height:1.45;
+ padding:1.6rem 1.2rem 4rem;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground);
+ display:flex;justify-content:center;align-items:flex-start}
+
+.capsule{width:960px;max-width:100%;background:var(--panel);color:var(--silver);
+ border:1.6px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);position:relative}
+
+/* ---- primitives ---- */
+.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass);
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none}
+.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 7px 1px rgba(203,107,77,.6)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite;
+ box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+@keyframes pulse{50%{opacity:.25}}
+
+.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:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4);
+ white-space:nowrap}
+.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.armed{background:rgba(203,107,77,.15);border-color:var(--fail);color:var(--fail)}
+.key.dis{opacity:.3;cursor:not-allowed}
+.key.dis:hover{color:var(--silver);border-color:#33302b}
+.key.sm{font-size:10px;padding:3px 8px;border-radius:7px}
+
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;white-space:nowrap}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+.badge.pass{background:var(--pass);color:var(--cream)}
+
+.bar{width:100%;height:11px;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;transition:width .5s ease}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+.bar.okc>span{background:linear-gradient(90deg,#5c7626,var(--pass))}
+.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)}
+
+.ring{width:54px;height:54px;border-radius:50%;
+ background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative;flex:none}
+.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;line-height:1}
+.ring small{position:relative;color:var(--dim);font-size:8px}
+
+.readout{color:var(--cream);font-size:21px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em;line-height:1}
+.readout small{color:var(--dim);font-size:11px;font-weight:400}
+
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.26em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px}
+.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.68rem}
+
+.lrow{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:7px;background:#141210;font-size:12px}
+.lrow .who{color:var(--silver)}
+.lrow .what{margin-left:auto;color:var(--dim);font-size:10.5px;display:flex;align-items:center;gap:7px}
+
+.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:16px}
+.ladder i{width:5px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:5px}.ladder i:nth-child(2){height:9px}
+.ladder i:nth-child(3){height:12px}.ladder i:nth-child(4){height:16px}
+.ladder i.on{background:var(--pass)}
+.ladder.bad i.on{background:var(--fail)}
+
+/* faceplate */
+.face{display:flex;align-items:center;gap:11px;padding:11px 13px;margin-bottom:11px;
+ background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:11px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}
+.face .word{color:var(--cream);font-size:15.5px;font-weight:700;letter-spacing:.22em}
+.face .spacer{flex:1}
+.face .unit{color:var(--steel);font-size:.72rem;letter-spacing:.1em}
+.face .xbtn{width:24px;height:24px;border-radius:50%;border:1px solid #33302b;background:transparent;
+ color:var(--dim);cursor:pointer;font-size:12px;line-height:1;display:grid;place-items:center;font-family:var(--mono)}
+.face .xbtn:hover{color:var(--fail);border-color:var(--fail)}
+
+.ctlrow{display:flex;align-items:center;gap:8px;margin-bottom:11px;padding:0 2px}
+.ctlrow .lbl{color:var(--steel);font-size:.6rem;letter-spacing:.22em;text-transform:uppercase;margin-right:3px}
+.ctlrow .spacer{flex:1}
+
+.updbar{display:flex;align-items:center;gap:13px;padding:7px 12px;margin-bottom:12px;
+ border:1px solid var(--wash);border-radius:9px;background:linear-gradient(180deg,#141310,#0a0c0d);font-size:11.5px}
+.updbar .q{color:var(--dim);font-size:.58rem;letter-spacing:.2em;text-transform:uppercase;
+ border:1px solid var(--wash);border-radius:5px;padding:1px 6px}
+.updbar.ok{border-color:var(--pass)} .updbar.ok .q{color:var(--pass);border-color:var(--pass)}
+.updbar.warn{border-color:var(--amber)} .updbar.warn .q{color:var(--amber);border-color:var(--amber)}
+.updbar.crit{border-color:var(--fail)} .updbar.crit .q{color:var(--fail);border-color:var(--fail)}
+.updbar b{color:var(--cream);font-variant-numeric:tabular-nums}
+.updbar .cve{color:var(--fail);font-weight:700}
+.updbar .spacer{flex:1}
+.updbar .dimtx{color:var(--dim);font-size:10.5px}
+
+/* selector */
+.selector{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-bottom:13px}
+.selbtn{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;border-radius:9px;
+ background:linear-gradient(180deg,#191715,#131110);border:1px solid #262320;border-left:3px solid var(--wash);
+ transition:border-color .1s}
+.selbtn:hover{border-color:#3a352c}
+.selbtn .nm{color:var(--silver);font-size:11.5px;font-weight:700;letter-spacing:.02em}
+.selbtn .ct{margin-left:auto;font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap}
+.selbtn .ct .a{color:var(--amber)} .selbtn .ct .a.crit{color:var(--fail)} .selbtn .ct .k{color:var(--pass)}
+.selbtn.attn{border-left-color:var(--amber)}
+.selbtn.crit{border-left-color:var(--fail)}
+.selbtn.clear{border-left-color:var(--pass)}
+.selbtn.loading{border-left-color:var(--amber)}
+.selbtn.sel{background:linear-gradient(180deg,#4a5768,var(--slate));border-color:var(--gold);border-left-width:3px}
+.selbtn.sel .nm{color:var(--gold)}
+.selbtn.sel .ct, .selbtn.sel .ct .a, .selbtn.sel .ct .k{color:var(--cream)}
+
+/* subpanel */
+.sub{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:13px 14px;min-height:330px}
+.subhead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.subhead .nm{color:var(--gold);font-weight:700;letter-spacing:.16em;text-transform:uppercase;font-size:12.5px}
+.subhead .cnt{color:var(--dim);font-size:11px;margin-left:auto;display:flex;gap:9px;align-items:center}
+.subhead .cnt .att{color:var(--amber)} .subhead .cnt .att.crit{color:var(--fail)} .subhead .cnt .okc{color:var(--pass)}
+
+.wgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-bottom:10px}
+.cell{background:#100f0e;border:1px solid #1f1c18;border-radius:9px;padding:9px 10px;
+ display:flex;flex-direction:column;gap:7px;position:relative;min-height:76px}
+.cell .cap{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;
+ display:flex;align-items:center;gap:6px;padding-right:56px}
+.cell .lever{position:absolute;top:7px;right:7px}
+.cell .val{display:flex;align-items:baseline;gap:7px;margin-top:auto}
+.cell .sub2{color:var(--dim);font-size:10px}
+.cell .center{display:flex;justify-content:center;margin-top:auto}
+.gpair{display:flex;gap:14px;align-items:center;justify-content:center;margin-top:auto}
+.gpair .g{display:flex;flex-direction:column;align-items:center;gap:2px}
+.gpair .g .lb{color:var(--steel);font-size:8.5px;letter-spacing:.1em}
+
+.striph{margin:9px 0 7px}
+.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:5px 9px}
+.strip.two{grid-template-columns:repeat(2,1fr)}
+
+/* segmented selector (gallery widget 06) */
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;width:max-content}
+.seg button{font:inherit;font-size:10px;color:var(--silver);background:#191715;border:0;
+ border-right:1px solid #33302b;padding:5px 9px;cursor:pointer;letter-spacing:.06em}
+.seg button:last-child{border-right:0}
+.seg button:hover{color:var(--gold)}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+
+/* evidence column headers */
+.evh{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;margin-bottom:4px}
+.colh{display:flex;align-items:center;gap:8px;min-width:0}
+.colh .engrave{flex:1;min-width:0}
+
+/* ===== rotary band selector (amplifier input-selector idiom) ===== */
+.deck{display:flex;align-items:center;gap:22px;margin:13px 4px 11px;padding:9px 14px;
+ background:linear-gradient(180deg,#161412,#100f0e);border:1px solid #201d17;border-radius:10px}
+.knob{width:46px;height:46px;border-radius:50%;position:relative;cursor:pointer;flex: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)}
+.knob:hover{border-color:var(--gold)}
+.knob .ind{position:absolute;left:50%;top:4px;width:2px;height:14px;background:var(--gold-hi);
+ margin-left:-1px;transform-origin:50% 19px;border-radius:1px;
+ box-shadow:0 0 5px rgba(255,215,95,.6);transition:transform .25s cubic-bezier(.3,1.3,.5,1)}
+.bands{display:flex;gap:26px;align-items:flex-end}
+.band{display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;user-select:none}
+.band .lamp{width:7px;height:7px}
+.band .bl{color:var(--steel);font-size:.6rem;letter-spacing:.18em;text-transform:uppercase;white-space:nowrap}
+.band:hover .bl{color:var(--silver)}
+.band.on .bl{color:var(--cream)}
+.band .ul{height:2px;width:100%;border-radius:1px;background:transparent;transition:background .15s}
+.band.on .ul{background:var(--gold);box-shadow:0 0 6px rgba(218,181,61,.5)}
+
+/* journal digest rows — capped at ~3.5 rows; the half row signals scrollability */
+/* rows are fixed 30px so the 3.5-row cap is exact: 3×30 + 3×4 gap + 15 half = 117 */
+.jlist{display:flex;flex-direction:column;gap:4px;max-height:117px;overflow-y:auto;padding-right:4px}
+.jlist .jrow{height:30px;min-height:30px;padding:2px 8px;flex:none}
+
+/* dark scrollbars, consistent with the panel family (slate thumb on dark track) */
+.capsule ::-webkit-scrollbar{width:8px;height:8px}
+.capsule ::-webkit-scrollbar-track{background:#0a0c0d;border-radius:4px}
+.capsule ::-webkit-scrollbar-thumb{background:#2c2f32;border-radius:4px}
+.capsule ::-webkit-scrollbar-thumb:hover{background:#54677d}
+.capsule *{scrollbar-width:thin;scrollbar-color:#2c2f32 #0a0c0d}
+.jrow{gap:8px}
+.jrow .jmsg{color:var(--dim);font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
+.jrow .what{margin-left:0;flex:none}
+.jrow.known .who{color:var(--dim)}
+
+.skel{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;
+ min-height:260px;color:var(--dim);font-size:11px;letter-spacing:.24em;text-transform:uppercase}
+
+/* doctor / review */
+.ostep{display:flex;gap:9px;align-items:flex-start;padding:4px 0;font-size:12.5px}
+.ostep .lamp{margin-top:3px;width:8px;height:8px}
+.ostep b{color:var(--cream);font-weight:700}
+.ostep .ev{color:var(--steel);font-size:11px;display:block}
+.ostep .ev.evfail{color:var(--fail)}
+.ostep.queued b{color:var(--dim)}
+.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px}
+/* review list: fixed 32px rows + 5px gap (37 stride); cap = 8.5 rows so the
+ panel holds its shape and the half row cues the scroll */
+.rvlist{max-height:312px;overflow-y:auto;padding-right:4px}
+.rvrow{display:flex;align-items:center;gap:10px;padding:2px 10px;border-radius:8px;background:#141210;
+ font-size:12px;margin-bottom:5px;height:32px;min-height:32px}
+.rvrow .who{color:var(--cream);font-weight:700}
+.rvrow .why{color:var(--dim);font-size:10.5px}
+.rvrow .why.errtx{color:var(--fail)}
+.rvrow .keys{margin-left:auto;display:flex;gap:7px;align-items:center}
+.rvrow .res{color:var(--pass);font-size:11px}
+.rvnote{color:var(--dim);font-size:10.5px;margin:9px 2px 0;line-height:1.5}
+
+.errline{color:var(--fail);font-size:10px;line-height:1.35;margin-top:3px}
+
+/* results wall (output-well idiom from the net/bt panels) */
+.rwall{margin-top:12px;background:var(--well);border:1px solid #201d17;border-radius:11px;padding:10px 14px 12px}
+.rwall .rhead{display:flex;align-items:center;gap:10px;margin-bottom:4px}
+.rwall .rhead .keys{margin-left:auto;display:flex;gap:6px}
+/* rows fixed 30px so the 3.5-entry cap is exact: 3.5 × 30 = 105 */
+.rwall .rbody{max-height:105px;overflow-y:auto;padding-right:4px}
+.rwall .rstep{display:flex;gap:9px;align-items:center;height:30px;min-height:30px;font-size:12px}
+.rwall .rstep .tm{color:var(--dim);font-size:10px;font-variant-numeric:tabular-nums;flex:none;
+ display:flex;flex-direction:column;line-height:1.2}
+.rwall .rstep .tm .dt{font-size:8.5px;color:#5d636a;letter-spacing:.04em}
+.rwall .rstep .lamp{width:8px;height:8px}
+.rwall .rstep .rtxt{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.rwall .rstep b{color:var(--cream);font-weight:700}
+.rwall .rstep .ev{color:var(--steel);font-size:11px}
+.rwall .rstep .ev.evfail{color:var(--fail)}
+
+#toast{position:absolute;bottom:16px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:9;
+ align-items:flex-end;pointer-events:none;max-width:70%}
+.toastw{font-size:11.5px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 11px;
+ box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .18s ease}
+.toastw.err{background:#5e3a2e}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+#closed{display:none;color:var(--dim);font-size:12px;border:1px dashed var(--wash);
+ border-radius:10px;padding:14px 20px;cursor:pointer}
+#closed:hover{color:var(--gold);border-color:var(--gold)}
+
+/* page-level prototype chrome (NOT part of the panel) */
+.pagectl{max-width:960px;margin:0 0 12px;display:flex;align-items:center;gap:8px;
+ color:var(--dim);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase}
+.footnote{color:var(--dim);font-size:10.5px;margin-top:14px;line-height:1.55;max-width:960px}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div>
+
+<!-- ===== prototype controls (page level, not panel UI) ===== -->
+<div class="pagectl">
+ prototype controls —
+ <button class="key sm" id="k-fail" onclick="toggleFail()" title="make every action fail so the error UX is visible">SIM FAIL</button>
+ <button class="key sm" id="k-bad" onclick="toggleBad()" title="load the degraded-machine snapshot: every metric in its warn/fail state">SIM BAD DAY</button>
+ <button class="key sm" id="k-zfs" onclick="toggleZfs()" title="show the velox/ZFS storage capability set instead of btrfs">SIM ZFS</button>
+ <button class="key sm" onclick="reloadPanel()" title="replay the open-and-hydrate sequence">SIM RELOAD</button>
+</div>
+
+<div class="capsule" id="capsule">
+
+ <!-- faceplate -->
+ <div class="face">
+ <span class="lamp" id="facelamp"></span>
+ <span class="word">MAINTENANCE</span>
+ <span class="badge red" id="cvebadge">3 CVE</span>
+ <span class="badge ghost" id="attbadge">9 ATTN</span>
+ <span class="badge red" id="errbadge" style="display:none">0 FAILED</span>
+ <span class="spacer"></span>
+ <span class="unit">MNT·01</span>
+ <button class="xbtn" title="Close (Esc)" onclick="closePanel()">✕</button>
+ </div>
+
+ <!-- doctor control row -->
+ <div class="ctlrow">
+ <span class="lbl">Doctor</span>
+ <button class="key" id="k-clean" onclick="go('doctor')">CLEAN UP</button>
+ <button class="key" id="k-review" onclick="go('review')">REVIEW &amp; FIX</button>
+ <span class="spacer"></span>
+ <span class="lbl" style="margin-right:0" id="lastscan">last scan 07:41 · 44 checks · 0.9s</span>
+ </div>
+
+ <!-- updates quarantine -->
+ <div class="updbar" id="updbar">
+ <span class="q">Updates</span>
+ <span><b id="upd-p">47</b> pending</span>
+ <span class="cve" id="upd-c">3 CVE</span>
+ <span><b id="upd-a">5</b> AUR stale</span>
+ <span><b id="upd-f">0</b> firmware</span>
+ <span class="dimtx">mesa · linux-lts · systemd — guard-checked before any apply</span>
+ <span class="spacer"></span>
+ <span id="upd-rb"></span>
+ <button class="key sm" id="k-update" onclick="fire('sysupdate')">UPDATE</button>
+ <button class="key sm" id="k-topgrade" onclick="fire('topgrade')">TOPGRADE</button>
+ </div>
+
+ <!-- persistent selector -->
+ <div class="selector" id="selector"></div>
+
+ <!-- swapped subpanel -->
+ <div class="sub" id="sub"></div>
+
+ <!-- results wall: appears on first action; toggle / copy / close -->
+ <div class="rwall" id="rwall" style="display:none">
+ <div class="rhead">
+ <span class="engrave" style="flex:1">Results<span class="cnt" id="rcount">· 0</span></span>
+ <span class="keys">
+ <button class="key sm" id="rtoggle" onclick="toggleWall()">HIDE</button>
+ <button class="key sm" onclick="copyWall()">COPY</button>
+ </span>
+ </div>
+ <div class="rbody" id="rbody"></div>
+ </div>
+
+ <div id="toast"></div>
+</div>
+
+<div id="closed" onclick="openPanel()">MNT·01 closed — click to reopen (waybar: click the maint tag)</div>
+
+<div class="footnote">
+ Prototype notes (not part of the panel): selector stays put — the gold/slate tile is the selection; the
+ subpanel below swaps. ✕ or Esc closes (chip reopens; reopening replays the hydrate sequence). CLEAN UP
+ streams into the subpanel and mutates real state; REVIEW &amp; FIX lists every Confirm lever, destructive
+ ones arm-then-fire. Metrics without a determinate lever are read-only telemetry (agent/workflow assistance
+ is vLater). SIM FAIL makes every action fail (red lamp, error line, RETRY key, FAILED badge); SIM BAD DAY
+ loads a degraded-machine snapshot so every metric shows its warn/fail face, doctor or not; SIM RELOAD
+ replays the open: tiles pulse amber per tier (local reads land first, process probes next, package scans
+ last) and settle individually — cached values would fill the board instantly in the real build, with lamps
+ confirming freshness as probes return.
+</div>
+</div>
+
+<script>
+/* ================= state snapshots ================= */
+function GOOD(){ return {
+ cache:{v:8.8, st:'warn'}, scrub:{v:34, st:'warn'}, fstrim:{v:'OFF', st:'fail'},
+ disk:{v:69, st:'ok'}, smart:{v:'PASSED', st:'ok'}, wear:{v:0, st:'ok'},
+ deverr:{v:'0 / 0', st:'ok'}, unalloc:{v:'118', st:'ok'}, integ:{v:'clean', st:'ok'},
+ deeptrim:{v:'keep 3', st:'ok'}, smartlast:'passed 2026-07-01', devrows:[],
+ dtop:[['~/videos','620 GB'],['~/music','240 GB'],['~/pictures','180 GB'],['/var/lib/docker','38 GB'],['~/.cache','22 GB']],
+ orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'NO', st:'ok'},
+ orphlist:[['rust','1.2 GB'],['electron28','210 MB'],['python-build','84 MB'],['go-tools','61 MB'],
+ ['libplacebo','44 MB'],['ocaml','38 MB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB'],
+ ['xcb-util-old','7 MB'],['libdazzle','6 MB'],['gtest','5 MB'],['orc-old','4 MB']],
+ pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'},
+ {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}],
+ cves:[['openssh','CVE-2026-2892','high'],['mesa','CVE-2026-1104','medium'],['libxml2','CVE-2026-0517','low']],
+ aurnames:'zoom · spotify · ttf-berkeley-mono +2', fwnames:'',
+ failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'},
+ funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'}],
+ cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'},
+ jerr:{v:12, st:'ok'}, khw:{v:'clean', st:'ok'},
+ coregroups:[['chrome',12,'13:02'],['hyprland',3,'06-07'],['hypridle',2,'11:40'],['dunst',1,'10:05']],
+ khwev:[],
+ jgroups:[
+ {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:26, first:'07:02', last:'13:58', unit:'bluetooth.service'},
+ {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:140, first:'07:02', last:'13:59', unit:null},
+ {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:8, first:'07:02', last:'12:10', unit:null},
+ {id:'insync', msg:'QSocketNotifier: invalid socket', n:6, first:'08:15', last:'13:20', unit:null},
+ {id:'gammastep', msg:'Wayland connection experienced a fatal error', n:3, first:'07:02', last:'07:03', unit:'gammastep.service'},
+ {id:'hypridle', msg:'Failed to inhibit idle', n:2, first:'09:41', last:'11:12', unit:'hypridle.service'},
+ {id:'dunst', msg:'gdk_pixbuf assertion failure', n:1, first:'10:05', last:'10:05', unit:'dunst.service'},
+ ],
+ temps:{v:'61/54', st:'ok'}, mem:{v:104, st:'ok'}, unclean:{v:75, st:'warn'},
+ zram:{v:'16 GiB', st:'ok'}, throttle:{v:'NO', st:'ok'}, battery:{v:'n/a', st:'off'},
+ cpumode:{v:'power', st:'ok'}, batt:{v:94, st:'ok'}, blimit:{v:'none', st:'warn'},
+ memtop:[['chrome','4.2 GB'],['emacs','1.9 GB'],['insync','0.9 GB'],['waybar','0.3 GB'],['mpd','0.2 GB']],
+ boots:[['07-07 07:01','clean'],['07-06 08:12','unclean'],['07-05 09:45','unclean'],['07-04 07:58','clean'],['07-03 08:30','unclean']],
+ pevents:[],
+ docker:{v:3, st:'warn'}, rsync:{v:'3h', st:'ok'}, dstopped:{v:2, st:'ok'},
+ cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'},
+ ddf:[['images','14.2 GB','1.8 GB'],['containers','0.4 GB','0.1 GB'],['volumes','6.8 GB','0.3 GB'],['build cache','0.8 GB','0.8 GB']],
+ ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0}],
+ snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'},
+ snaptypes:{timeline:31, single:8, prepost:3, oldsingle:'2026-06-12'},
+ oldest:{v:8, st:'ok'}, retention:{v:'H6 D7 W2 M2 Y0', st:'ok'},
+ fw:{v:'active', st:'ok'}, listen:{v:0, st:'ok'}, ts:{v:'4/4', st:'ok'}, tsdown:[],
+ lsn:[
+ {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'},
+ {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'},
+ {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'},
+ ],
+ f2b:{v:0, st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'},
+ tgage:{v:9, st:'warn'},
+ upd:{p:47, c:3, a:5, f:0},
+ zcap:{v:62, st:'ok'}, zfrag:{v:31, st:'ok'}, ztrim:{v:'OFF', st:'fail'},
+ zhealth:{v:'ONLINE', st:'ok'}, zerr:{v:'0 / 0 / 0', st:'ok'},
+};}
+function BAD(){ return {
+ cache:{v:17, st:'warn'}, scrub:{v:74, st:'fail'}, fstrim:{v:'OFF', st:'fail'},
+ disk:{v:92, st:'fail'}, smart:{v:'FAILING', st:'fail'}, wear:{v:91, st:'warn'},
+ deverr:{v:'14 / 2', st:'fail'}, unalloc:{v:'9', st:'warn'}, integ:{v:'3 modified', st:'warn'},
+ deeptrim:{v:'never run', st:'warn'}, smartlast:'failed 2026-07-06 — reallocated sectors',
+ devrows:[['nvme0n1','12 read · 2 write · 3 corruption'],['nvme1n1','2 read · 0 write · 1 corruption']],
+ dtop:[['~/videos','1.4 TB'],['/var/lib/docker','160 GB'],['~/music','240 GB'],['~/.cache','96 GB'],['~/downloads','88 GB']],
+ orphans:{v:51, st:'warn'}, pacnew:{v:7, st:'warn'}, keyring:{v:94, st:'warn'}, reboot:{v:'YES', st:'warn'},
+ orphlist:[['rust','1.2 GB'],['cuda-11','2.8 GB'],['electron28','210 MB'],['electron25','195 MB'],
+ ['python-build','84 MB'],['go-tools','61 MB'],['libplacebo','44 MB'],['ocaml','38 MB'],
+ ['texlive-extra','1.1 GB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB']],
+ pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'},
+ {f:'/etc/sudoers.pacnew', safe:false, why:'needs merge — review carefully'},
+ {f:'/etc/ssh/sshd_config.pacnew', safe:false, why:'needs merge'},
+ {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}],
+ cves:[['openssh','CVE-2026-2892','high'],['sudo','CVE-2026-3011','high'],['mesa','CVE-2026-1104','medium'],
+ ['curl','CVE-2026-2440','medium'],['libxml2','CVE-2026-0517','low']],
+ aurnames:'zoom · spotify · ttf-berkeley-mono · paru-git +8', fwnames:'UEFI 3.07→3.09 · NVMe fw',
+ failed:{v:4, st:'fail'}, timers:{v:'2/5', st:'fail'}, sysrun:{v:'degraded', st:'fail'}, taint:{v:'W', st:'warn'},
+ funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'},
+ {u:'fail2ban', since:'07:44', info:'exit 1 — bad jail'},
+ {u:'cronie', since:'07:02', info:'exit 203'},
+ {u:'smartd', since:'07:02', info:'exit 1'}],
+ cores:{v:112, st:'fail'}, journald:{v:3.8, st:'warn'}, applog:{v:'stale', st:'warn'},
+ jerr:{v:214, st:'warn'}, khw:{v:'MCE + I/O', st:'fail'},
+ coregroups:[['chrome',61,'13:41'],['hyprland',23,'12:15'],['electron',17,'11:03'],['dunst',11,'13:10']],
+ khwev:[['12:44','MCE: CPU 3 bank 5 — corrected'],['11:02','nvme0n1: I/O error, sector 88121'],['10:58','thermal: package throttle 91°C']],
+ jgroups:[
+ {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:31, first:'07:02', last:'13:58', unit:'bluetooth.service'},
+ {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:180, first:'07:02', last:'13:59', unit:null},
+ {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:9, first:'07:02', last:'12:10', unit:null},
+ {id:'systemd-oomd', msg:'Failed to acquire memory pressure info', n:88, first:'07:05', last:'13:55', unit:'systemd-oomd.service'},
+ {id:'wpa_supplicant', msg:'bgscan simple: Failed to enable signal monitoring', n:61, first:'07:02', last:'13:59', unit:'wpa_supplicant.service'},
+ {id:'hyprland', msg:'GLES2 framebuffer incomplete', n:31, first:'08:11', last:'13:40', unit:null},
+ {id:'rsyncshot', msg:'rsync error: partial transfer (code 23)', n:23, first:'02:30', last:'13:30', unit:'rsyncshot.service'},
+ {id:'dunst', msg:'gdk_pixbuf assertion failure', n:11, first:'07:20', last:'13:10', unit:'dunst.service'},
+ ],
+ temps:{v:'91/78', st:'fail'}, mem:{v:6, st:'warn'}, unclean:{v:82, st:'warn'},
+ zram:{v:'none', st:'warn'}, throttle:{v:'YES', st:'fail'}, battery:{v:'n/a', st:'off'},
+ cpumode:{v:'perf', st:'ok'}, batt:{v:71, st:'warn'}, blimit:{v:'none', st:'warn'},
+ memtop:[['chrome','38.4 GB'],['java (deepsat)','22.1 GB'],['emacs','2.1 GB'],['Hyprland','1.9 GB'],['insync','1.4 GB'],['dockerd','1.1 GB']],
+ boots:[['07-07 07:05','unclean'],['07-06 21:12','unclean'],['07-06 08:12','unclean'],['07-05 09:45','clean'],['07-04 07:58','unclean']],
+ pevents:[['13:41','OOM killed chrome (38 GB)'],['12:20','thermal throttle 91°C · 340 s'],['09:15','OOM killed java (22 GB)']],
+ docker:{v:22, st:'warn'}, rsync:{v:'52h', st:'fail'}, dstopped:{v:5, st:'warn'},
+ cron:{v:'down', st:'fail'}, libvirt:{v:'off', st:'off'},
+ ddf:[['images','38.1 GB','18.2 GB'],['containers','1.2 GB','0.4 GB'],['volumes','9.8 GB','2.1 GB'],['build cache','1.3 GB','1.3 GB']],
+ ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0},
+ {name:'jellyfin', when:'2h ago', code:137},{name:'pihole', when:'9h ago', code:1},
+ {name:'registry', when:'3d ago', code:0}],
+ snapper:{v:214, st:'warn'}, stimer:{v:'inactive', st:'fail'},
+ snaptypes:{timeline:62, single:140, prepost:12, oldsingle:'2026-01-03'},
+ oldest:{v:194, st:'fail'}, retention:{v:'H6 D7 W2 M10 Y2', st:'warn'},
+ fw:{v:'inactive', st:'fail'}, listen:{v:9, st:'warn'}, ts:{v:'1/4', st:'fail'},
+ tsdown:['truenas','worker','cjennings'],
+ lsn:[
+ {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'},
+ {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'},
+ {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'},
+ {proc:'python', port:8000, bind:'0.0.0.0', unit:null},
+ {proc:'ncat', port:4444, bind:'0.0.0.0', unit:null},
+ {proc:'smbd', port:445, bind:'0.0.0.0', unit:'smb'},
+ {proc:'node', port:3000, bind:'127.0.0.1', unit:null},
+ {proc:'postgres', port:5432, bind:'127.0.0.1', unit:'postgresql'},
+ {proc:'cupsd', port:631, bind:'127.0.0.1', unit:'cups'},
+ ],
+ f2b:{v:'down', st:'fail'}, ntp:{v:'unsynced', st:'warn'}, dns:{v:'FAIL', st:'fail'},
+ tgage:{v:34, st:'fail'},
+ upd:{p:214, c:9, a:12, f:2},
+ zcap:{v:91, st:'fail'}, zfrag:{v:68, st:'warn'}, ztrim:{v:'OFF', st:'fail'},
+ zhealth:{v:'DEGRADED', st:'fail'}, zerr:{v:'3 / 0 / 1', st:'fail'},
+};}
+let S = GOOD();
+
+const CATS = [
+ {id:'storage', name:'STORAGE', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']},
+ {id:'packages', name:'PACKAGES', ids:['orphans','pacnew','keyring','reboot','tgage']},
+ {id:'systemd', name:'SYSTEMD', ids:['failed','timers','sysrun','taint']},
+ {id:'logs', name:'LOGS', ids:['cores','journald','applog','jerr','khw']},
+ {id:'power', name:'MEM·PWR', ids:['temps','mem','unclean','zram','throttle','battery']},
+ {id:'services', name:'SERVICES', ids:['docker','rsync','dstopped','cron','libvirt']},
+ {id:'snapshots',name:'SNAPSHOTS', ids:['snapper','stimer','oldest','retention']},
+ {id:'network', name:'NETWORK', ids:['fw','listen','ts','f2b','ntp','dns']},
+];
+/* diagnostic (no lever here) set — drives the faceplate verdict lamp */
+const DIAG = ['disk','smart','wear','deverr','unalloc','integ','sysrun','taint','jerr','khw',
+ 'temps','mem','zram','throttle','battery','listen','dstopped','libvirt','oldest','retention',
+ 'failed','unclean','rsync','dns','zcap','zfrag','zhealth','zerr','batt'];
+
+let view = 'cat:storage';
+let armed = null;
+let doctorTimer = null;
+let failMode = false;
+let badMode = false;
+const ERR = {};
+const loaded = new Set(); // categories whose probes have landed
+let loadTimers = [];
+
+/* ================= helpers ================= */
+const $ = id => document.getElementById(id);
+const BTRFS_IDS = ['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim'];
+const ZFS_IDS = ['zcap','cache','scrub','ztrim','smart','wear','zhealth','zerr','zfrag','integ','deeptrim'];
+const PWR_RATIO = ['temps','mem','unclean','zram','throttle','battery','cpumode'];
+const PWR_VELOX = ['temps','mem','unclean','zram','throttle','batt','blimit','cpumode'];
+function catIds(cat){
+ if(cat.id==='storage') return zfsMode?ZFS_IDS:BTRFS_IDS;
+ if(cat.id==='power') return zfsMode?PWR_VELOX:PWR_RATIO;
+ return cat.ids;
+}
+/* fixable/watch split per category — sets expectations before a single cell is read */
+function fixwatch(cid){
+ switch(cid){
+ case 'storage': return zfsMode?[7,4]:[8,2];
+ case 'packages': return [5,0];
+ case 'systemd': return [2,2];
+ case 'logs': return [4,1];
+ case 'power': return zfsMode?[3,5]:[2,5];
+ case 'services': return [4,1];
+ case 'snapshots':return [3,1];
+ case 'network': return [6,0];
+ }
+}
+function counts(cat){
+ let attn=0, ok=0, crit=false;
+ catIds(cat).forEach(i=>{ const st=S[i].st;
+ if(st==='warn'){attn++} else if(st==='fail'){attn++;crit=true} else ok++; });
+ return {attn, ok, crit};
+}
+function lampCls(st){ return st==='ok'?'':st==='warn'?'gold':st==='fail'?'red':'off'; }
+function toast(msg, err){
+ const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg;
+ $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600);
+}
+
+/* ---- prototype toggles ---- */
+function toggleFail(){
+ failMode = !failMode;
+ const k=$('k-fail'); k.classList.toggle('red', failMode);
+ k.textContent = failMode ? 'SIM FAIL ON' : 'SIM FAIL';
+ toast(failMode ? 'failure simulation ON — every action will fail' : 'failure simulation off', failMode);
+}
+function toggleBad(){
+ badMode = !badMode;
+ const k=$('k-bad'); k.classList.toggle('red', badMode);
+ k.textContent = badMode ? 'BAD DAY ON' : 'SIM BAD DAY';
+ S = badMode ? BAD() : GOOD();
+ Object.keys(ERR).forEach(k2=>delete ERR[k2]);
+ render();
+ toast(badMode ? 'degraded snapshot loaded — every metric at its warn/fail face' : 'healthy snapshot restored', badMode);
+}
+/* ---- journal noise list: shipped defaults + user marks (two layers) ---- */
+const NOISE = [
+ {id:'bluetoothd', pat:'Hands-Free Voice gateway connect failed', date:'2026-02-27', shipped:true, disabled:false},
+ {id:'pixman', pat:'_pixman_log_error: BadDrawable', date:'2026-02-27', shipped:true, disabled:false},
+ {id:'xkbcomp', pat:'Errors from xkbcomp are not fatal', date:'2026-03-24', shipped:true, disabled:false},
+];
+const nkey = g => g.id+'|'+(g.pat||g.msg);
+function noiseFor(g){ return NOISE.find(n=>!n.disabled && n.id===g.id && n.pat===g.msg); }
+function jSignal(){ return S.jgroups.filter(g=>!noiseFor(g)).sort((a,b)=>b.n-a.n); }
+function jKnown(){ return S.jgroups.filter(g=>noiseFor(g)); }
+function recalcJerr(){
+ const sum = jSignal().reduce((s,g)=>s+g.n,0);
+ S.jerr = {v:sum, st: sum===0?'ok': sum<=25?'ok':'warn'};
+}
+function markKnown(key){
+ const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return;
+ if(armed!=='mark:'+key){ armed='mark:'+key; render();
+ toast('mark known: '+g.id+' · "'+g.msg.slice(0,40)+'…" — press again', true); return; }
+ armed=null;
+ NOISE.push({id:g.id, pat:g.msg, date:today(), shipped:false, disabled:false});
+ resAdd('mark known', g.id+' · '+g.msg.slice(0,50)+' ('+g.n+' this boot)', 'done');
+ render();
+}
+function unmarkKnown(key){
+ const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return;
+ const n = noiseFor(g); if(!n) return;
+ if(n.shipped) n.disabled = true;
+ else NOISE.splice(NOISE.indexOf(n),1);
+ resAdd('unmark', g.id+' — back in signal'+(n.shipped?' (shipped default disabled)':''), 'done');
+ render();
+}
+function clearMarks(){
+ if(armed!=='clearmarks'){ armed='clearmarks'; render();
+ toast('clear all user marks + re-enable shipped defaults — press again', true); return; }
+ armed=null;
+ const removed = NOISE.filter(n=>!n.shipped).length;
+ for(let i=NOISE.length-1;i>=0;i--){ if(!NOISE[i].shipped) NOISE.splice(i,1); else NOISE[i].disabled=false; }
+ resAdd('clear marks', removed+' user mark(s) removed · shipped defaults restored', 'done');
+ render();
+}
+
+/* ---- listeners: expected-list curation + guarded per-socket remedies ---- */
+const LEXPECT = [
+ {proc:'sshd', port:22, date:'2026-02-27', shipped:true, disabled:false},
+ {proc:'mpd', port:6600, date:'2026-02-27', shipped:true, disabled:false},
+ {proc:'tailscaled', port:41641, date:'2026-02-27', shipped:true, disabled:false},
+];
+const lkey = r => r.proc+'|'+r.port;
+const isPub = b => b==='0.0.0.0'||b==='::';
+function lexpFor(r){ return LEXPECT.find(p=>!p.disabled && p.proc===r.proc && p.port===r.port); }
+function lsnSignal(){ return S.lsn.filter(r=>!lexpFor(r)); }
+function recalcListen(){
+ const sig = lsnSignal();
+ const pub = sig.filter(r=>isPub(r.bind)).length;
+ S.listen = {v:sig.length, st: sig.length===0?'ok': pub?'fail':'warn'};
+}
+function markExpected(key){
+ const r = S.lsn.find(x=>lkey(x)===key); if(!r) return;
+ if(armed!=='lexp:'+key){ armed='lexp:'+key; render();
+ toast('mark expected: '+r.proc+' :'+r.port+' — press again', true); return; }
+ armed=null;
+ LEXPECT.push({proc:r.proc, port:r.port, date:today(), shipped:false, disabled:false});
+ resAdd('mark expected', r.proc+' :'+r.port+' ('+r.bind+')', 'done');
+ render();
+}
+function unmarkExpected(key){
+ const p = LEXPECT.find(x=>!x.disabled && x.proc+'|'+x.port===key); if(!p) return;
+ if(p.shipped) p.disabled = true; else LEXPECT.splice(LEXPECT.indexOf(p),1);
+ resAdd('unmark expected', p.proc+' :'+p.port+' — back in signal'+(p.shipped?' (shipped default disabled)':''), 'done');
+ render();
+}
+function clearLsn(){
+ if(armed!=='clearlsn'){ armed='clearlsn'; render();
+ toast('clear user expected-marks + re-enable shipped — press again', true); return; }
+ armed=null;
+ const removed = LEXPECT.filter(p=>!p.shipped).length;
+ for(let i=LEXPECT.length-1;i>=0;i--){ if(!LEXPECT[i].shipped) LEXPECT.splice(i,1); else LEXPECT[i].disabled=false; }
+ resAdd('clear expected', removed+' user mark(s) removed · shipped defaults restored', 'done');
+ render();
+}
+function lsnFix(key){
+ const r = S.lsn.find(x=>lkey(x)===key); if(!r) return;
+ const verb = r.unit ? 'systemctl stop '+r.unit : 'SIGTERM '+r.proc;
+ if(armed!=='lsnfix:'+key){ armed='lsnfix:'+key; render();
+ toast((r.unit?'STOP ':'KILL ')+r.proc+' :'+r.port+' — '+verb+' · press again', true); return; }
+ armed=null;
+ const e = resAdd(r.unit?'stop unit':'kill listener', verb+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail', r.unit?'systemctl: unit busy — stop refused':r.proc+' respawned — supervised? check its unit'); }
+ else { S.lsn.splice(S.lsn.indexOf(r),1);
+ resDone(e,'done', r.proc+' stopped — port '+r.port+' closed'); }
+ render();
+ }, 800);
+}
+
+/* ---- rotary sub-section selector state ---- */
+const SUBSEC = {packages:'orphans', logs:'signal', services:'containers'};
+const DECKDEFS = {};
+function setSubsec(cat,id){ SUBSEC[cat]=id; armed=null; render(); }
+function cycleSubsec(cat){
+ const defs = DECKDEFS[cat]; if(!defs||!defs.length) return;
+ const i = defs.findIndex(d=>d.id===SUBSEC[cat]);
+ setSubsec(cat, defs[(i+1)%defs.length].id);
+}
+function deck(cat, defs){
+ DECKDEFS[cat] = defs;
+ const cur = SUBSEC[cat];
+ const i = Math.max(0, defs.findIndex(d=>d.id===cur));
+ const ang = defs.length>1 ? -50 + i*(100/(defs.length-1)) : 0;
+ const bands = defs.map(d=>`<span class="band ${d.id===cur?'on':''}" onclick="setSubsec('${cat}','${d.id}')">
+ <span class="lamp ${d.lamp||''}"></span>
+ <span class="bl">${d.label}${d.count!==undefined&&d.count!==''?' · '+d.count:''}</span>
+ <span class="ul"></span></span>`).join('');
+ return `<div class="deck">
+ <span class="knob" onclick="cycleSubsec('${cat}')" title="cycle sections">
+ <span class="ind" style="transform:rotate(${ang}deg)"></span></span>
+ <span class="bands">${bands}</span></div>`;
+}
+
+/* ---- orphans: KEEP curation (the rust lesson) + per-package REMOVE ---- */
+const OKEEP = []; // user layer: packages marked intentional
+const okept = name => OKEEP.some(k=>k.name===name);
+function orphKeep(name){
+ if(okept(name)) return;
+ OKEEP.push({name, date:today()});
+ S.orphans.v = Math.max(0, S.orphans.v-1);
+ if(S.orphans.v===0) S.orphans.st='ok';
+ resAdd('keep', name+' marked intentional — excluded from removal', 'done');
+ render();
+}
+function orphUnkeep(name){
+ const k = OKEEP.find(x=>x.name===name); if(!k) return;
+ OKEEP.splice(OKEEP.indexOf(k),1);
+ S.orphans.v++; S.orphans.st='warn';
+ resAdd('unkeep', name+' — back in the orphan set', 'done');
+ render();
+}
+function orphRemove(name){
+ const row = S.orphlist.find(r=>r[0]===name); if(!row) return;
+ if(armed!=='orem:'+name){ armed='orem:'+name; render();
+ toast('pacman -Rns '+name+' ('+row[1]+') — press again', true); return; }
+ armed=null;
+ const e = resAdd('orphan remove', 'pacman -Rns '+name+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail','pacman: breaking dependency — '+name+' required by another package'); }
+ else { S.orphlist.splice(S.orphlist.indexOf(row),1);
+ S.orphans.v = Math.max(0, S.orphans.v-1);
+ if(S.orphans.v===0) S.orphans.st='ok';
+ resDone(e,'done',name+' removed — '+row[1]+' freed'); }
+ render();
+ }, 900);
+}
+
+/* ---- pacnew: per-file DELETE (safe) / MERGE (terminal delegation) ---- */
+function pacnewAct(file){
+ const p = S.pacnews.find(x=>x.f===file); if(!p) return;
+ const e = resAdd(p.safe?'pacnew delete':'pacnew merge',
+ p.safe ? 'rm '+file+' — '+p.why+' …' : 'opening diff in terminal — '+file+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail', p.safe?'rm: permission denied':'diff tool not found'); }
+ else { S.pacnews.splice(S.pacnews.indexOf(p),1);
+ S.pacnew.v = Math.max(0, S.pacnew.v-1);
+ if(S.pacnew.v===0) S.pacnew.st='ok';
+ resDone(e,'done', p.safe ? file+' deleted — current config is authoritative'
+ : file+' merged in terminal · pacnew deleted'); }
+ render();
+ }, 700);
+}
+
+/* ---- failed units: per-unit roster remedies ---- */
+function funitAct(u, reset){
+ const r = S.funits.find(x=>x.u===u); if(!r) return;
+ const e = resAdd(reset?'reset-failed':'unit restart',
+ (reset?'systemctl reset-failed ':'systemctl restart ')+u+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail', u+': start request repeated too quickly'); }
+ else { S.funits.splice(S.funits.indexOf(r),1);
+ S.failed.v = Math.max(0, S.failed.v-1);
+ if(S.failed.v===0){ S.failed.st='ok'; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; }
+ resDone(e,'done', u+(reset?' cleared from failed list':' restarted — running')); }
+ render();
+ }, 800);
+}
+
+/* ---- containers: expected-list curation + per-container START ---- */
+const CEXPECT = [
+ {name:'winvm', date:'2026-02-27', shipped:true, disabled:false},
+ {name:'deepsat-pg', date:'2026-06-20', shipped:false, disabled:false},
+];
+const cexp = name => CEXPECT.some(p=>!p.disabled && p.name===name);
+function recalcCtrs(){
+ const unexp = S.ctrs.filter(c=>!cexp(c.name)).length;
+ S.dstopped = {v:S.ctrs.length, st: unexp?'warn':'ok'};
+}
+function ctrStartOne(name){
+ const c = S.ctrs.find(x=>x.name===name); if(!c) return;
+ const e = resAdd('container start', 'docker start '+name+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail', name+' exits immediately (code 1) — see docker logs'); }
+ else { S.ctrs.splice(S.ctrs.indexOf(c),1);
+ resDone(e,'done', name+' running'); }
+ render();
+ }, 800);
+}
+function ctrMark(name){
+ if(armed!=='cexp:'+name){ armed='cexp:'+name; render();
+ toast('mark expected: '+name+' stays stopped by design — press again', true); return; }
+ armed=null;
+ CEXPECT.push({name, date:today(), shipped:false, disabled:false});
+ resAdd('mark expected', name+' — expected-stopped (on-demand)', 'done');
+ render();
+}
+function ctrUnmark(name){
+ const p = CEXPECT.find(x=>!x.disabled && x.name===name); if(!p) return;
+ if(p.shipped) p.disabled=true; else CEXPECT.splice(CEXPECT.indexOf(p),1);
+ resAdd('unmark expected', name+' — back in signal', 'done');
+ render();
+}
+
+/* ---- snapshots: stale manual (single) snapshot delete ---- */
+function snapDelStale(){
+ if(armed!=='snapstale'){ armed='snapstale'; render();
+ toast('delete '+(S.snaptypes.single-2)+' stale manual snapshots (keep newest 2) — press again', true); return; }
+ armed=null;
+ const n = S.snaptypes.single-2;
+ const e = resAdd('stale snapshots', 'snapper delete ('+n+' singles) …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail','snapper: config busy — snapshot in progress'); }
+ else { S.snaptypes.single=2; S.snaptypes.oldsingle='recent';
+ S.snapper.v = S.snaptypes.timeline+S.snaptypes.single+S.snaptypes.prepost;
+ if(S.snapper.v<100) S.snapper.st='ok';
+ resDone(e,'done', n+' manual snapshots deleted — space freed on next cleanup'); }
+ render();
+ }, 900);
+}
+
+/* kill process: arm-to-fire SIGTERM on a top-memory item; session-critical names protected */
+const PROTECTED = ['systemd','Hyprland','maintenance-panel'];
+function killProc(name){
+ const row = S.memtop.find(r=>r[0]===name); if(!row) return;
+ if(armed!=='kill:'+name){ armed='kill:'+name; render();
+ toast('KILL '+name+' ('+row[1]+') — SIGTERM · press again', true); return; }
+ armed=null;
+ const e = resAdd('kill process', 'SIGTERM '+name+' ('+row[1]+') …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail',name+' ignored SIGTERM — state D (uninterruptible IO)'); }
+ else {
+ S.memtop.splice(S.memtop.indexOf(row),1);
+ const gb = parseFloat(row[1])||0;
+ S.mem.v = Math.min(125, Math.round((S.mem.v+gb)*10)/10);
+ if(S.mem.v>16 && S.mem.st!=='ok') S.mem={v:S.mem.v, st:'ok'};
+ resDone(e,'done',name+' exited cleanly · '+row[1]+' freed');
+ }
+ render();
+ }, 700);
+}
+
+/* cpu mode: free selector — set the active EPP mode to anything */
+const MODE_LABELS = {perf:'performance', bal:'balance_performance', power:'power'};
+function setMode(m){
+ if(S.cpumode.v===m) return;
+ const e = resAdd('cpu mode', 'writing EPP hint: '+MODE_LABELS[m]+' …');
+ setTimeout(()=>{
+ if(failMode){ resDone(e,'fail','sysfs: energy_performance_preference write refused'); }
+ else { S.cpumode.v=m; resDone(e,'done','EPP → '+MODE_LABELS[m]+' (all cores)'); }
+ render();
+ }, 500);
+}
+
+let zfsMode = false;
+function toggleZfs(){
+ zfsMode = !zfsMode;
+ const k=$('k-zfs'); k.classList.toggle('on', zfsMode);
+ k.textContent = zfsMode ? 'ZFS ON' : 'SIM ZFS';
+ Object.keys(ERR).forEach(k2=>{ if(ACT2CAT[k2]==='storage') delete ERR[k2]; });
+ render();
+ toast(zfsMode ? 'storage shows the velox/ZFS capability set' : 'storage shows the ratio/btrfs capability set');
+}
+function reloadPanel(){ openPanel(); }
+
+/* ---- actions ---- */
+/* ---- results wall ---- */
+const RES = [];
+let wallHidden = false;
+const ANAMES = { clean_cache:'cache trim', scrub:'btrfs scrub', fstrim:'fstrim enable',
+ deeptrim:'deep trim', orphans:'orphan removal', pacnew:'pacnew merge', keyring:'keyring refresh',
+ timers:'timer enable', cores:'coredump clear', journald:'journal vacuum', applog:'app-log cleanup',
+ docker:'docker prune', snapper:'snapper cleanup', unit_restart:'unit restart',
+ f2b_fix:'fail2ban restart', cron_fix:'cronie start', ntp_fix:'chrony restart',
+ ts_fix:'tailscaled restart', stimer_fix:'snapshot timers', zram_fix:'zram enable',
+ netdoc:'net doctor', balance:'btrfs balance', reinstall:'pkg reinstall', rsyncnow:'backup run',
+ smarttest:'smart self-test', retention:'retention repair', reboot:'reboot',
+ sysupdate:'system update', topgrade:'topgrade', ztrim_fix:'zpool autotrim',
+ openjournal:'open journal', bat_limit:'charge limit',
+ ctr_start:'container start', fw_fix:'firewall enable' };
+function now(){ return new Date().toTimeString().slice(0,8); }
+function today(){ const d=new Date();
+ return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); }
+function resAdd(name, ev, st){
+ const e = {d:today(), t:now(), name, ev, st:st||'run'};
+ RES.push(e); renderWall(); return e;
+}
+function resDone(e, st, ev){ e.st=st; e.ev=ev; e.d=today(); e.t=now(); renderWall(); }
+function renderWall(){
+ const w = $('rwall');
+ if(!RES.length){ w.style.display='none'; return; }
+ w.style.display='block';
+ $('rcount').textContent = '· '+RES.length;
+ $('rtoggle').textContent = wallHidden ? 'SHOW' : 'HIDE';
+ const b = $('rbody');
+ b.style.display = wallHidden ? 'none' : 'block';
+ b.innerHTML = RES.map(e=>{
+ const lamp = e.st==='run'?'busy':e.st==='fail'?'red':'';
+ return `<div class="rstep"><span class="tm"><span class="dt">${e.d}</span>${e.t}</span><span class="lamp ${lamp}"></span>
+ <span class="rtxt" title="${e.name} — ${e.ev}"><b>${e.name}</b> <span class="ev ${e.st==='fail'?'evfail':''}">${e.ev}</span></span></div>`;
+ }).join('');
+ b.scrollTop = b.scrollHeight;
+}
+function toggleWall(){ wallHidden=!wallHidden; renderWall(); }
+function copyWall(){
+ const txt = RES.map(e=>`${e.d} ${e.t} ${e.st.toUpperCase().padEnd(4)} ${e.name} — ${e.ev}`).join('\n');
+ (navigator.clipboard ? navigator.clipboard.writeText(txt) : Promise.reject())
+ .then(()=>toast('copied '+RES.length+' result lines'))
+ .catch(()=>toast('clipboard unavailable', true));
+}
+
+function fire(act){
+ const a = ACTIONS[act];
+ // guard-armed updates: first press runs the guard check and arms; second press overrides
+ if(a.guardArm && armed!==act){
+ armed = act;
+ const e = resAdd(ANAMES[act]||act, a.busy||'checking guard…');
+ setTimeout(()=>{
+ resDone(e,'fail','guard: mesa in update set — press again to run anyway, or apply from a TTY');
+ render();
+ }, 600);
+ render();
+ return;
+ }
+ if(a.arm && armed!==act && !ERR[act]){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; }
+ const overridden = a.guardArm && armed===act;
+ armed=null;
+ const e = resAdd(ANAMES[act]||act, (overridden && a.busy2) ? a.busy2 : (a.busy||'running…'));
+ setTimeout(()=>{
+ if(failMode){ ERR[act]=a.err||'exited 1'; resDone(e,'fail',ERR[act]); }
+ else { delete ERR[act]; a.run(); resDone(e,'done',a.doneMsg);
+ if(a.guardArm && S.reboot.v==='YES')
+ resAdd('reboot offer','kernel/mesa updated — REBOOT key armed on the updates strip','done');
+ }
+ render();
+ }, a.ms||800);
+}
+const ACTIONS = {
+ clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB',
+ err:'paccache: /var/cache/pacman/pkg — permission denied (polkit dismissed)',
+ run(){ S.cache={v:0.4, st:'ok'}; } },
+ scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion',
+ err:'btrfs: scrub already running on / — see dmesg',
+ run(){ S.scrub={v:0, st:'ok'}; } },
+ fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active',
+ err:'systemctl: unit fstrim.timer is masked',
+ run(){ S.fstrim={v:'ON', st:'ok'}; if(S.timers.st==='warn') S.timers={v:'5/5', st:'ok'}; } },
+ deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press again', busy:'paccache -rk1 …', ms:1100,
+ doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more',
+ err:'paccache -rk1: exited 1 — cache dir busy (pacman lock held)',
+ run(){ S.deeptrim={v:'keep 1', st:'ok'}; if(S.cache.st==='ok') S.cache={v:0.2,st:'ok'}; } },
+ orphans:{ arm:true, armMsg:'remove all unkept orphans — press again', busy:'pacman -Rns (unkept set) …', ms:1200,
+ doneMsg:'orphans removed — kept packages skipped',
+ err:'pacman -Rns: breaking dependency — rust required by cargo-audit',
+ run(){ S.orphlist = S.orphlist.filter(r=>okept(r[0])); S.orphans={v:0, st:'ok'}; } },
+ pacnew:{ busy:'diffing .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged',
+ err:'merge aborted — locale.gen.pacnew conflicts, needs manual merge',
+ run(){ S.pacnew={v:0, st:'ok'}; } },
+ keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed',
+ err:'pacman -Sy: mirror timeout — no network?',
+ run(){ S.keyring={v:0, st:'ok'}; } },
+ timers:{ busy:'systemctl enable --now …', ms:800, doneMsg:'all 5 maintenance timers firing',
+ err:'systemctl: failed to enable — unit file not found',
+ run(){ S.timers={v:'5/5', st:'ok'}; if(S.fstrim.st==='fail') S.fstrim={v:'ON',st:'ok'}; } },
+ cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared',
+ err:'coredumpctl: permission denied — /var/lib/systemd/coredump',
+ run(){ S.cores={v:0, st:'ok'}; S.coregroups=[]; } },
+ journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB',
+ err:'journalctl: vacuum failed — rotation in progress, retry',
+ run(){ S.journald={v:0.3, st:'ok'}; } },
+ applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d',
+ err:'log-cleanup: exited 1 — ~/.local/var/log not writable',
+ run(){ S.applog={v:'ok', st:'ok'}; } },
+ docker:{ arm:true, armMsg:'docker prune tier 1+2 — press again', busy:'docker container/image prune …', ms:1200,
+ doneMsg:'docker pruned — reclaimed 2.8 GB',
+ err:'docker: cannot connect to daemon — is docker.service running?',
+ run(){ S.docker={v:0.2, st:'ok'}; S.ddf = S.ddf.map(r=>[r[0], r[1], '0 GB']); } },
+ snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900,
+ doneMsg:'snapper — retention applied',
+ err:'snapper: config busy — snapshot in progress',
+ run(){ S.snapper={v:Math.min(S.snapper.v,31), st:'ok'}; } },
+ /* --- group A: service restarts / enables --- */
+ unit_restart:{ busy:'systemctl restart (all failed units) …', ms:900, doneMsg:'units restarted — running',
+ err:'systemd-oomd: start request repeated too quickly',
+ run(){ S.funits=[]; S.failed={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } },
+ f2b_fix:{ busy:'systemctl restart fail2ban …', ms:800, doneMsg:'fail2ban running — sshd jail active',
+ err:'fail2ban: failed to start — bad jail config',
+ run(){ S.f2b={v:0, st:'ok'}; } },
+ cron_fix:{ busy:'systemctl start cronie …', ms:700, doneMsg:'cronie running — entries live',
+ err:'cronie: unit masked',
+ run(){ S.cron={v:'ok', st:'ok'}; } },
+ ntp_fix:{ busy:'systemctl restart chronyd + makestep …', ms:800, doneMsg:'clock stepped — synchronized',
+ err:'chronyd: no reachable sources',
+ run(){ S.ntp={v:'synced', st:'ok'}; } },
+ ts_fix:{ busy:'systemctl restart tailscaled …', ms:900, doneMsg:'tailscaled up — peers reconnecting',
+ err:'tailscaled: bind: address already in use',
+ run(){ S.ts={v:'4/4', st:'ok'}; S.tsdown=[]; } },
+ stimer_fix:{ busy:'systemctl enable --now snapper timers …', ms:700, doneMsg:'snapshot timers firing',
+ err:'systemctl: unit not found',
+ run(){ S.stimer={v:'active', st:'ok'}; } },
+ zram_fix:{ busy:'re-applying zram-generator config …', ms:800, doneMsg:'zram active — 16 GiB zstd',
+ err:'zram-generator: module not loaded',
+ run(){ S.zram={v:'16 GiB', st:'ok'}; } },
+ netdoc:{ busy:'opening NET·01 …', ms:400, doneMsg:'delegated to the net panel doctor (NET·01)',
+ err:'net panel not running',
+ run(){} },
+ /* --- group B: deterministic maintenance ops --- */
+ balance:{ arm:true, armMsg:'btrfs balance is IO-heavy and slow — press again', busy:'btrfs balance -dusage=50 …', ms:1500,
+ doneMsg:'balance complete — unallocated recovered',
+ err:'balance cancelled — ENOSPC during relocation',
+ run(){ S.unalloc={v:'118', st:'ok'}; } },
+ reinstall:{ busy:'pacman -S (owning packages) …', ms:1100, doneMsg:'modified files restored — integrity clean',
+ err:'pacman: failed to commit transaction',
+ run(){ S.integ={v:'clean', st:'ok'}; } },
+ rsyncnow:{ busy:'rsyncshot manual run …', ms:1400, doneMsg:'backup complete — synced to truenas',
+ err:'rsync exit 23 — partial transfer (permissions)',
+ run(){ S.rsync={v:'now', st:'ok'}; } },
+ smarttest:{ busy:'smartctl -t short (both NVMe) …', ms:900, doneMsg:'short self-test started — results in ~2 min',
+ err:'smartctl: device busy',
+ run(){ S.smartlast='passed just now (short test)'; } },
+ retention:{ busy:'writing sane retention (M2 Y0) + cleanup …', ms:1000, doneMsg:'retention repaired — old chain pruned',
+ err:'snapperd: config locked',
+ run(){ S.retention={v:'H6 D7 W2 M2 Y0', st:'ok'}; S.oldest={v:8, st:'ok'}; S.snapper={v:Math.min(S.snapper.v,42), st:'ok'}; } },
+ /* --- group D: disruptive but determinate --- */
+ reboot:{ arm:true, armMsg:'reboot the machine — press again', busy:'rebooting … (simulated)', ms:1200,
+ doneMsg:'rebooted — new kernel running, taint cleared',
+ err:'logind: inhibitor lock held (backup in progress)',
+ run(){ S.reboot={v:'NO', st:'ok'}; S.taint={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } },
+ ctr_start:{ busy:'docker start (all unexpected exited) …', ms:1000,
+ doneMsg:'containers started — running again',
+ err:'docker: container exits immediately (code 1) — see docker logs',
+ run(){ S.ctrs = S.ctrs.filter(c=>cexp(c.name)); } },
+ fw_fix:{ busy:'ufw enable …', ms:800,
+ doneMsg:'firewall active — mosh rule loaded',
+ err:'ufw: iptables lock held — retry',
+ run(){ S.fw={v:'active', st:'ok'}; } },
+ bat_limit:{ busy:'writing charge_control_end_threshold=80 …', ms:700,
+ doneMsg:'charge limit set — 80% (battery longevity)',
+ err:'sysfs: permission denied',
+ run(){ S.blimit={v:'80%', st:'ok'}; } },
+ openjournal:{ busy:'spawning terminal: journalctl -p err -b …', ms:400,
+ doneMsg:'opened in terminal — journalctl -p err -b',
+ err:'no terminal available',
+ run(){} },
+ /* --- zfs (velox capability set) --- */
+ ztrim_fix:{ busy:'zpool set autotrim=on + zpool trim …', ms:1000,
+ doneMsg:'autotrim enabled — initial trim running',
+ err:'zpool: pool is busy (resilver in progress)',
+ run(){ S.ztrim={v:'ON', st:'ok'}; } },
+ /* --- updates (Confirm; guard arms instead of blocking — user decides) --- */
+ sysupdate:{ guardArm:true, busy:'checking update set against the live-update guard …',
+ busy2:'pacman -Syu … (guard overridden)', ms:1400,
+ doneMsg:'system updated — 47 packages · reboot recommended',
+ err:'pacman: failed to commit transaction (conflicting files)',
+ run(){ S.upd.p=0; S.upd.c=0; S.reboot={v:'YES', st:'warn'}; } },
+ topgrade:{ guardArm:true, busy:'guard check, then topgrade (--disable git) …',
+ busy2:'topgrade full run … (guard overridden)', ms:1800,
+ doneMsg:'topgrade complete — system + ecosystems · reboot recommended',
+ err:'topgrade: system step failed — pacman lock held',
+ run(){ S.upd.p=0; S.upd.c=0; S.upd.a=0; S.tgage={v:0, st:'ok'}; S.reboot={v:'YES', st:'warn'}; } },
+};
+
+/* ================= widget builders ================= */
+function lever(act, label, cls){
+ const a = armed===act;
+ if(ERR[act]) return `<button class="key sm red" onclick="event.stopPropagation();fire('${act}')">RETRY</button>`;
+ return `<button class="key sm ${cls||''} ${a?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${a?label+'?':label}</button>`;
+}
+function errline(act){ return act && ERR[act] ? `<div class="errline">✗ ${ERR[act]}</div>` : ''; }
+function lampFor(id, act){ return (act && ERR[act]) ? 'red' : lampCls(S[id].st); }
+function lrow(id, name, extra, valOverride, act){
+ return `<div class="lrow"><span class="lamp ${lampFor(id,act)}"></span><span class="who">${name}</span>
+ <span class="what">${extra||''}${(act&&ERR[act])?'✗ failed':(valOverride!==undefined?valOverride:S[id].v)}</span></div>`;
+}
+function barCls(st){ return st==='ok'?'okc':'warn'; }
+function cellBar(id,cap,unit,pct,o){ o=o||{}; const m=S[id];
+ return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div>
+ <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div>
+ <div class="bar ${barCls(m.st)}">${o.tick?`<span class="tk" style="left:${o.tick}%"></span>`:''}<span style="width:${Math.min(100,pct)}%"></span></div>
+ <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; }
+function cellCount(id,cap,o){ o=o||{}; const m=S[id];
+ const col = m.st==='fail'?'style="color:var(--fail)"':(m.st==='warn'?'style="color:var(--amber)"':'');
+ return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div>
+ <div class="val"><span class="readout" ${col}>${m.v}${o.unit?`<small>${o.unit}</small>`:''}</span>${o.badge||''}</div>
+ <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; }
+function cellRing(id,cap,pct,small,o){ o=o||{}; const m=S[id];
+ const rc = (o.act&&ERR[o.act])?'var(--fail)':m.st==='ok'?'var(--pass)':m.st==='fail'?'var(--fail)':'var(--amber)';
+ return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div>
+ <div class="center"><span class="ring" style="--p:${Math.min(100,pct)};--rc:${rc}"><b>${m.v}</b><small>${small}</small></span></div>
+ <div class="sub2" style="text-align:center">${o.sub||''}</div>${errline(o.act)}</div>`; }
+function cellBadge(id,cap,o){ o=o||{}; const m=S[id];
+ const cls = m.st==='ok'?'pass':m.st==='fail'?'red':m.st==='warn'?'':'ghost';
+ return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div>
+ <div class="val"><span class="badge ${cls}">${String(m.v).toUpperCase()}</span></div>
+ <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; }
+
+/* dynamic rosters */
+function timerRoster(){
+ const st = S.timers.st;
+ const rows = [
+ ['paccache.timer','weekly', true],
+ ['btrfs-scrub@-.timer','monthly', st!=='fail'],
+ ['fstrim.timer','weekly', S.fstrim.st==='ok'],
+ ['reflector.timer','weekly', st!=='fail'],
+ ['snapper-cleanup.timer','daily', true],
+ ];
+ return rows.map(([n,c,ok])=>`<div class="lrow"><span class="lamp ${ok?'':'red'}"></span>
+ <span class="who">${n}</span><span class="what">${ok?c+' · ok':'disabled'}</span></div>`).join('');
+}
+function backupRoster(){
+ if(S.rsync.st==='ok') return `
+ <div class="lrow"><span class="lamp"></span><span class="who">hourly</span><span class="what">3h ago · ok</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">daily 02:30</span><span class="what">11h ago · ok</span></div>`;
+ return `
+ <div class="lrow"><span class="lamp red"></span><span class="who">hourly</span><span class="what">52h ago · stale</span></div>
+ <div class="lrow"><span class="lamp red"></span><span class="who">daily 02:30</span><span class="what">rsync exit 23 · failed</span></div>`;
+}
+function peerRoster(){
+ const down = S.tsdown||[];
+ return ['truenas','velox','worker','cjennings'].map(p=>{
+ const d = down.includes(p);
+ return `<div class="lrow"><span class="lamp ${d?'red':''}"></span><span class="who">${p}</span>
+ <span class="what">${d?'offline':'online'}</span></div>`;}).join('');
+}
+
+/* ================= subpanel templates ================= */
+const SUB = {
+storage(){
+ if(zfsMode) return `
+ <div class="wgrid">
+ ${cellBar('zcap','zpool capacity','%',S.zcap.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'CoW slows > 80 · best-fit > 90'})}
+ ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})}
+ ${cellRing('scrub','zpool scrub',S.scrub.v/60*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'warn 35d · crit 60d'})}
+ ${cellCount('ztrim','zpool autotrim',{lever:S.ztrim.st!=='ok'?lever('ztrim_fix','ENABLE',S.ztrim.st==='fail'?'red':''):'',act:'ztrim_fix',sub:'the fstrim counterpart on ZFS'})}
+ </div>
+ <div class="wgrid">
+ ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'NVMe'})}
+ ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0%':'high wear — plan replacement'})}
+ ${cellBadge('zhealth','Pool health',{sub:'zpool status -x'})}
+ ${cellCount('zerr','Pool errors',{sub:'read / write / cksum — no reset lever; investigate'})}
+ </div>
+ <div class="engrave striph">filesystem<span class="cnt">· 3</span></div>
+ <div class="strip">
+ ${lrow('zfrag','fragmentation', '', S.zfrag.v+'% — no defrag exists; keep < 80% full')}
+ ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')}
+ ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')}
+ </div>`;
+ return `
+ <div class="wgrid">
+ ${cellBar('disk','Disk usage','%',S.disk.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'root · btrfs · thr 80/90'})}
+ ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})}
+ ${cellRing('scrub','btrfs scrub',S.scrub.v/55*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'past 30d cadence'})}
+ ${cellCount('fstrim','fstrim.timer',{lever:lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':''),act:'fstrim',sub:'Confirm · weekly TRIM'})}
+ </div>
+ <div class="wgrid">
+ ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'last test: '+S.smartlast})}
+ ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0% both drives':'high wear — plan replacement'})}
+ ${cellCount('deverr','Device errors',{sub:S.devrows.length?'per-device below · reset stays manual':'read / write · RAID1 · watch only'})}
+ ${cellCount('unalloc','btrfs unallocated',{lever:S.unalloc.st!=='ok'?lever('balance','BALANCE'):'',act:'balance',unit:' GiB',sub:'chunk headroom — raw space not yet carved into chunks'})}
+ </div>
+ ${S.devrows.length?`
+ <div class="engrave striph">device errors — which drive<span class="cnt">· cross-check SMART</span></div>
+ <div class="jlist">${S.devrows.map(r=>`<div class="lrow jrow"><span class="lamp red"></span>
+ <span class="who"><b>${r[0]}</b></span><span class="what">${r[1]}</span></div>`).join('')}</div>`:''}
+ <div class="engrave striph">disk — top consumers<span class="cnt">· evidence only</span></div>
+ <div class="jlist">${S.dtop.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span>
+ <span class="what">${r[1]}</span></div>`).join('')}</div>
+ <div class="engrave striph">filesystem<span class="cnt">· 2</span></div>
+ <div class="strip two">
+ ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')}
+ ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')}
+ </div>`; },
+
+packages(){
+ const kept = OKEEP.map(k=>k.name);
+ const orows = S.orphlist.filter(r=>!kept.includes(r[0])).map(r=>{
+ const a = armed==='orem:'+r[0];
+ return `<div class="lrow jrow"><span class="lamp gold"></span>
+ <span class="who"><b>${r[0]}</b></span><span class="jmsg">${r[1]}</span>
+ <span class="what">
+ <button class="key sm" onclick="event.stopPropagation();orphKeep('${r[0]}')">KEEP</button>
+ <button class="key sm ${a?'armed':''}" onclick="event.stopPropagation();orphRemove('${r[0]}')">${a?'REMOVE?':'REMOVE'}</button>
+ </span></div>`;
+ }).join('');
+ const krows = OKEEP.map(k=>`<div class="lrow jrow known"><span class="lamp off"></span>
+ <span class="who">${k.name}</span><span class="what">kept ${k.date}
+ <button class="key sm" onclick="event.stopPropagation();orphUnkeep('${k.name}')">UNKEEP</button></span></div>`).join('');
+ const prows = S.pacnews.map(p=>`<div class="lrow jrow"><span class="lamp ${p.safe?'':'gold'}"></span>
+ <span class="who"><b>${p.f.split('/').pop()}</b></span><span class="jmsg">${p.f} · ${p.why}</span>
+ <span class="what"><button class="key sm" onclick="event.stopPropagation();pacnewAct('${p.f}')">${p.safe?'DELETE':'MERGE'}</button></span></div>`).join('');
+ const crows = S.upd.c ? S.cves.map(c=>`<div class="lrow jrow">
+ <span class="lamp ${c[2]==='high'?'red':c[2]==='medium'?'gold':''}"></span>
+ <span class="who"><b>${c[0]}</b></span><span class="jmsg">${c[1]}</span>
+ <span class="what" style="color:${c[2]==='high'?'var(--fail)':'var(--dim)'}">${c[2]} · fix: UPDATE</span></div>`).join('') : '';
+ return `
+ <div class="wgrid">
+ ${cellCount('orphans','Orphans',{lever:lever('orphans','REMOVE ALL'),act:'orphans',sub:'digest below · kept pkgs skipped'})}
+ ${cellCount('pacnew','.pacnew',{lever:lever('pacnew','REVIEW'),act:'pacnew',sub:'per-file below'})}
+ ${cellRing('keyring','Keyring age',S.keyring.v/30*100,'days',{lever:lever('keyring','REFRESH'),act:'keyring',sub:'refresh < 30d'})}
+ ${cellBadge('reboot','Reboot req.',{lever:S.reboot.v==='YES'?lever('reboot','REBOOT','red'):'',act:'reboot',sub:'kernel == modules'})}
+ </div>
+ <div class="wgrid">
+ ${cellRing('tgage','Topgrade age',S.tgage.v/14*100,'days',{lever:lever('topgrade','TOPGRADE'),act:'topgrade',sub:'ecosystems · guard-aware'})}
+ </div>
+ ${deck('packages',[
+ {id:'orphans', label:'ORPHANS', count:S.orphans.v, lamp:S.orphans.v?'gold':''},
+ {id:'pacnew', label:'PACNEW', count:S.pacnews.length, lamp:S.pacnews.length?'gold':''},
+ {id:'advis', label:'ADVISORIES', count:S.upd.c, lamp:S.upd.c?'red':''},
+ ])}
+ ${SUBSEC.packages==='orphans'?`
+ <div class="engrave striph">orphans — top by size<span class="cnt">· KEEP = intentional (the rust lesson)</span></div>
+ <div class="jlist">${orows || '<div class="rvnote">no unkept orphans.</div>'}</div>
+ ${OKEEP.length?`<div class="evh" style="margin-top:8px">kept · ${OKEEP.length}</div><div class="jlist">${krows}</div>`:''}`:''}
+ ${SUBSEC.packages==='pacnew'?`
+ <div class="engrave striph">pacnew files<span class="cnt">· safe-delete vs needs-merge</span></div>
+ <div class="jlist">${prows || '<div class="rvnote">none pending ✓</div>'}</div>`:''}
+ ${SUBSEC.packages==='advis'?`
+ <div class="engrave striph">security advisories<span class="cnt">· arch-audit</span></div>
+ <div class="jlist">${crows || '<div class="rvnote">clean ✓</div>'}</div>`:''}
+ <div class="engrave striph">update surface<span class="cnt">· informational</span></div>
+ <div class="strip">
+ <div class="lrow"><span class="lamp ${S.upd.c?'red':''}"></span><span class="who">arch-audit</span><span class="what">${S.upd.c?S.upd.c+' CVE — named above':'clean'}</span></div>
+ <div class="lrow"><span class="lamp gold"></span><span class="who">pending updates</span><span class="what">${S.upd.p}</span></div>
+ <div class="lrow"><span class="lamp ${S.upd.a>8?'gold':''}"></span><span class="who">AUR / foreign</span><span class="what">${S.upd.a?S.upd.a+' — '+S.aurnames:'fresh'}</span></div>
+ ${S.upd.f?`<div class="lrow"><span class="lamp gold"></span><span class="who">firmware</span><span class="what">${S.upd.f} — ${S.fwnames}</span></div>`:''}
+ </div>`; },
+
+systemd(){
+ const frows = S.funits.map(f=>`<div class="lrow jrow"><span class="lamp red"></span>
+ <span class="who"><b>${f.u}</b></span><span class="jmsg">since ${f.since} · ${f.info} · journalctl -u ${f.u}</span>
+ <span class="what">
+ <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}')">RESTART</button>
+ <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}',true)">RESET</button>
+ </span></div>`).join('');
+ return `
+ <div class="wgrid">
+ ${cellCount('failed','Failed units',{lever:S.failed.v>1?lever('unit_restart','RESTART ALL'):'',act:'unit_restart',sub:S.failed.v?'roster below · per-unit keys':'none failed'})}
+ ${cellCount('timers','Maint timers',{lever:lever('timers','ENABLE'),act:'timers',sub:'Confirm · enable missing'})}
+ ${cellBadge('sysrun','is-system-running',{sub:S.sysrun.st==='ok'?'whole-system verdict · watch':'degraded — '+S.failed.v+' failed unit'+(S.failed.v===1?'':'s')+' below'})}
+ ${cellCount('taint','Kernel taint',{sub:S.taint.st==='ok'?'0 = untainted · watch':'W = kernel warned once — clears on reboot'})}
+ </div>
+ ${S.funits.length?`<div class="engrave striph">failed units<span class="cnt">· ${S.funits.length}</span></div>
+ <div class="jlist">${frows}</div>`:''}
+ <div class="engrave striph">timer roster<span class="cnt">· 5</span></div>
+ <div class="strip">${timerRoster()}</div>`; },
+
+logs(){
+ const sig = jSignal(), kn = jKnown();
+ const sigRows = sig.slice(0,10).map(g=>{
+ const key = nkey(g);
+ const armedNow = armed==='mark:'+key;
+ return `<div class="lrow jrow"><span class="lamp gold"></span>
+ <span class="who"><b>${g.id}</b></span><span class="jmsg">${g.msg}</span>
+ <span class="what">×${g.n} · ${g.first}–${g.last}${g.unit?' · journalctl -u '+g.unit.replace('.service',''):''}
+ <button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();markKnown('${key}')">${armedNow?'MARK?':'MARK KNOWN'}</button></span></div>`;
+ }).join('');
+ const knRows = kn.map(g=>{
+ const key = nkey(g); const n = noiseFor(g);
+ return `<div class="lrow jrow known"><span class="lamp off"></span>
+ <span class="who">${g.id}</span><span class="jmsg">${g.msg}</span>
+ <span class="what">×${g.n} · ${n.shipped?'shipped default':'marked '+n.date}
+ <button class="key sm" onclick="event.stopPropagation();unmarkKnown('${key}')">UNMARK</button></span></div>`;
+ }).join('');
+ const cgrows = S.coregroups.map(g=>`<div class="lrow jrow"><span class="lamp gold"></span>
+ <span class="who"><b>${g[0]}</b></span><span class="jmsg">coredumpctl info ${g[0]}</span>
+ <span class="what">×${g[1]} · last ${g[2]}</span></div>`).join('');
+ const khrows = S.khwev.map(e=>`<div class="lrow jrow"><span class="who" style="color:var(--dim)">${e[0]}</span>
+ <span class="jmsg" style="color:var(--fail)">${e[1]}</span></div>`).join('');
+ return `
+ <div class="wgrid">
+ ${cellCount('cores','Coredumps 7d',{lever:lever('cores','CLEAR'),act:'cores',sub:S.coregroups.length?'by binary below · keep 3d':'Auto · keep 3d'})}
+ ${cellBar('journald','journald','GB',S.journald.v/2*100,{lever:lever('journald','VACUUM'),act:'journald',sub:'Auto · cap 2 GB'})}
+ ${cellCount('jerr','Journal errors',{sub:'this boot · noise-filtered'})}
+ ${cellBadge('khw','Kernel / HW',{sub:S.khw.st==='ok'?'MCE · USB · I/O · thermal — watch':'events below — hardware, watch only'})}
+ </div>
+ ${deck('logs',[
+ {id:'signal', label:'SIGNAL', count:S.jerr.v, lamp:S.jerr.st==='ok'?(S.jerr.v?'gold':''):'gold'},
+ {id:'noise', label:'KNOWN NOISE', count:kn.length, lamp:'off'},
+ {id:'cores', label:'COREDUMPS', count:S.cores.v, lamp:lampCls(S.cores.st)},
+ {id:'hw', label:'KERNEL/HW', count:S.khwev.length||'', lamp:lampCls(S.khw.st)},
+ ])}
+ ${SUBSEC.logs==='signal'?`
+ <div class="striph" style="display:flex;align-items:center;gap:10px">
+ <span class="engrave">journal — signal<span class="cnt">· ${sig.length} group${sig.length===1?'':'s'} · ${S.jerr.v} entries</span></span>
+ ${lever('openjournal','OPEN JOURNAL')}
+ </div>
+ <div class="jlist">${sigRows || '<div class="rvnote">no unexplained errors this boot ✓</div>'}</div>`:''}
+ ${SUBSEC.logs==='noise'?`
+ <div class="striph" style="display:flex;align-items:center;gap:10px">
+ <span class="engrave">known noise<span class="cnt">· ${kn.length} pattern${kn.length===1?'':'s'}</span></span>
+ <button class="key sm ${armed==='clearmarks'?'armed':''}" onclick="event.stopPropagation();clearMarks()">${armed==='clearmarks'?'CLEAR?':'CLEAR MARKS'}</button>
+ </div>
+ <div class="jlist">${knRows || '<div class="rvnote">no known-noise patterns match this boot.</div>'}</div>`:''}
+ ${SUBSEC.logs==='cores'?`
+ <div class="engrave striph">coredumps — by binary<span class="cnt">· ${S.cores.v} total · coredumpctl info</span></div>
+ <div class="jlist">${cgrows || '<div class="rvnote">none ✓</div>'}</div>
+ <div class="evh" style="margin-top:8px">rotation</div>
+ <div class="jlist">${lrow('applog','app-log cleanup', lever('applog','RUN')+' ', S.applog.v==='ok'?'7d rotation':'stale >7d', 'applog')}</div>`:''}
+ ${SUBSEC.logs==='hw'?`
+ <div class="engrave striph">kernel / hw events<span class="cnt">· hardware — watch only</span></div>
+ <div class="jlist">${khrows || '<div class="rvnote">clean — no MCE, I/O, USB, or thermal events ✓</div>'}</div>`:''}`; },
+
+power(){
+ const [tc,tg] = String(S.temps.v).split('/');
+ const trc = S.temps.st==='ok'?'var(--pass)':S.temps.st==='fail'?'var(--fail)':'var(--amber)';
+ const seg = ['perf','bal','power'].map(m=>
+ `<button class="${S.cpumode.v===m?'on':''}" onclick="event.stopPropagation();setMode('${m}')">${m.toUpperCase()}</button>`).join('');
+ const modeCell = `
+ <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.cpumode.st)}"></span> CPU mode</div>
+ <div class="val"><span class="seg">${seg}</span></div>
+ <div class="sub2">EPP hint · ${zfsMode?'intel_pstate':'amd-pstate'} active</div></div>`;
+ const veloxCells = zfsMode ? `
+ ${cellRing('batt','Battery health',S.batt.v,'%',{sub:S.batt.st==='ok'?'of design · 187 cycles · hardware — watch only':'degraded · plan replacement — watch only'})}
+ ${cellCount('blimit','Charge limit',{lever:S.blimit.st!=='ok'?lever('bat_limit','SET 80%'):'',act:'bat_limit',sub:'longevity cap · sysfs threshold'})}` : '';
+ const evRows = (rows,kind) => rows.length
+ ? rows.map(r=>{
+ if(kind==='boot') return `<div class="lrow jrow"><span class="lamp ${r[1]==='clean'?'':'red'}"></span><span class="who">${r[0]}</span><span class="what">${r[1]}</span></div>`;
+ if(kind==='ev') return `<div class="lrow jrow"><span class="who" style="color:var(--dim)">${r[0]}</span><span class="jmsg" style="color:var(--fail)">${r[1]}</span></div>`;
+ // top-memory rows: KILL (arm-to-fire) — disabled on session-critical names
+ const prot = PROTECTED.includes(r[0]);
+ const armedNow = armed==='kill:'+r[0];
+ const key = prot
+ ? `<button class="key sm dis" title="session-critical — protected">KILL</button>`
+ : `<button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();killProc('${r[0]}')">${armedNow?'KILL?':'KILL'}</button>`;
+ return `<div class="lrow jrow"><span class="who">${r[0]}</span><span class="what">${r[1]} ${key}</span></div>`;
+ }).join('')
+ : `<div class="lrow jrow"><span class="what" style="margin-left:0">none this boot ✓</span></div>`;
+ return `
+ <div class="wgrid">
+ <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.temps.st)}"></span> Temperatures</div>
+ <div class="gpair">
+ <div class="g"><span class="ring" style="--p:${tc};--rc:${trc}"><b>${tc}°</b></span><span class="lb">CPU</span></div>
+ <div class="g"><span class="ring" style="--p:${tg};--rc:${trc}"><b>${tg}°</b></span><span class="lb">NVME</span></div>
+ </div>
+ <div class="sub2" style="text-align:center">hardware — watch only</div></div>
+ ${cellBar('mem','Memory free','GB',S.mem.v/125*100,{sub:'of 125 GB · evidence below'})}
+ ${cellBar('unclean','Unclean boots','%',S.unclean.v,{sub:'evidence below — watch only'})}
+ ${cellCount('zram','Swap / zram',{lever:S.zram.st!=='ok'?lever('zram_fix','ENABLE'):'',act:'zram_fix',sub:S.zram.st==='ok'?'zstd · active':'no swap configured'})}
+ </div>
+ <div class="wgrid">
+ ${modeCell}
+ ${veloxCells}
+ ${!zfsMode?`<div class="cell"><div class="cap"><span class="lamp off"></span> Battery</div>
+ <div class="val"><span class="readout" style="color:var(--dim)">n/a</span></div>
+ <div class="sub2">desktop — no battery</div></div>`:''}
+ ${cellBadge('throttle','Thermal throttle',{sub:S.throttle.st==='ok'?'no events · hardware — watch only':'active — cooling issue · watch only'})}
+ </div>
+ <div class="strip striph" style="align-items:center">
+ <span class="engrave">top memory</span>
+ <span class="engrave" style="grid-column:span 2">evidence — watch only</span>
+ </div>
+ <div class="strip">
+ <div><div class="evh">kill: sigterm · arm-to-fire</div><div class="jlist">${evRows(S.memtop,'mem')}</div></div>
+ <div><div class="evh">recent boots</div><div class="jlist">${evRows(S.boots,'boot')}</div></div>
+ <div><div class="evh">throttle / oom</div><div class="jlist">${evRows(S.pevents,'ev')}</div></div>
+ </div>`; },
+
+services(){
+ const unexp = S.ctrs.filter(c=>!cexp(c.name));
+ const expd = S.ctrs.filter(c=>cexp(c.name));
+ const urows = unexp.map(c=>{
+ const aM = armed==='cexp:'+c.name;
+ return `<div class="lrow jrow"><span class="lamp gold"></span>
+ <span class="who"><b>${c.name}</b></span><span class="jmsg">exited ${c.when} · code ${c.code}${c.code?' · docker logs '+c.name:''}</span>
+ <span class="what">
+ <button class="key sm ${aM?'armed':''}" onclick="event.stopPropagation();ctrMark('${c.name}')">${aM?'MARK?':'MARK EXPECTED'}</button>
+ <button class="key sm" onclick="event.stopPropagation();ctrStartOne('${c.name}')">START</button>
+ </span></div>`;
+ }).join('');
+ const erows = expd.map(c=>{
+ const p = CEXPECT.find(x=>!x.disabled&&x.name===c.name)||{};
+ return `<div class="lrow jrow known"><span class="lamp off"></span>
+ <span class="who">${c.name}</span><span class="jmsg">exited ${c.when}</span>
+ <span class="what">${p.shipped?'shipped default':'marked '+(p.date||'')}
+ <button class="key sm" onclick="event.stopPropagation();ctrUnmark('${c.name}')">UNMARK</button></span></div>`;
+ }).join('');
+ const dfrows = S.ddf.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span>
+ <span class="what">${r[1]} · reclaimable ${r[2]}</span></div>`).join('');
+ const cronRows = S.cron.st==='ok' ? `
+ <div class="lrow"><span class="lamp"></span><span class="who">log-cleanup (12:00)</span><span class="what">present</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot hourly</span><span class="what">present</span></div>
+ <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot daily 02:30</span><span class="what">present</span></div>` : `
+ <div class="lrow"><span class="lamp red"></span><span class="who">log-cleanup (12:00)</span><span class="what">not running — cronie down</span></div>
+ <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot hourly</span><span class="what">not running — cronie down</span></div>
+ <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot daily 02:30</span><span class="what">not running — cronie down</span></div>`;
+ return `
+ <div class="wgrid">
+ ${cellCount('docker','Docker reclaim',{lever:lever('docker','PRUNE'),act:'docker',unit:' GB',sub:'breakdown below · tiers 1+2'})}
+ ${cellCount('rsync','Last backup',{lever:lever('rsyncnow','RUN NOW'),act:'rsyncnow',unit:' ago',sub:'rsyncshot → truenas'})}
+ ${cellCount('dstopped','Stopped containers',{lever:unexp.length>1?lever('ctr_start','START ALL'):'',act:'ctr_start',sub:unexp.length?unexp.length+' unexpected · '+expd.length+' expected':'all expected'})}
+ ${cellBadge('cron','cron',{lever:S.cron.st!=='ok'?lever('cron_fix','START'):'',act:'cron_fix',sub:'entry roster below'})}
+ </div>
+ ${deck('services',[
+ {id:'containers', label:'CONTAINERS', count:unexp.length?unexp.length+'!':expd.length, lamp:unexp.length?'gold':''},
+ {id:'docker', label:'DOCKER DISK', count:S.docker.v+' GB', lamp:lampCls(S.docker.st)},
+ {id:'cron', label:'CRON & BACKUPS', count:'', lamp:(S.cron.st!=='ok'||S.rsync.st!=='ok')?'red':''},
+ ])}
+ ${SUBSEC.services==='containers'?`
+ ${unexp.length?`<div class="engrave striph">containers — signal<span class="cnt">· ${unexp.length} unexpected</span></div>
+ <div class="jlist">${urows}</div>`:''}
+ <div class="engrave striph">containers — expected<span class="cnt">· ${expd.length}</span></div>
+ <div class="jlist">${erows || '<div class="rvnote">no expected-stopped containers present.</div>'}</div>`:''}
+ ${SUBSEC.services==='docker'?`
+ <div class="engrave striph">docker disk<span class="cnt">· system df · reclaimable by type</span></div>
+ <div class="jlist">${dfrows}</div>
+ <div class="evh" style="margin-top:8px">virt</div>
+ <div class="jlist">${lrow('libvirt','libvirt VMs','', 'shut off · expected')}</div>`:''}
+ ${SUBSEC.services==='cron'?`
+ <div class="engrave striph">cron entries — expected<span class="cnt">· 3</span></div>
+ <div class="jlist">${cronRows}</div>
+ <div class="evh" style="margin-top:8px">backups</div>
+ <div class="jlist">${backupRoster()}</div>`:''}`; },
+
+snapshots(){
+ const t = S.snaptypes;
+ const staleN = Math.max(0, t.single-2);
+ const aS = armed==='snapstale';
+ return `
+ <div class="wgrid">
+ ${cellCount('snapper','Snapper count',{lever:lever('snapper','PRUNE'),act:'snapper',sub:S.snapper.st==='ok'?'by type below':'pile-up — see singles below'})}
+ ${cellBadge('stimer','Auto-timer',{lever:S.stimer.st!=='ok'?lever('stimer_fix','ENABLE'):'',act:'stimer_fix',sub:'timeline + cleanup'})}
+ ${cellCount('oldest','Oldest',{unit:' days',sub:S.oldest.st==='ok'?'within budget':'chain hoards space'})}
+ <div class="cell">${S.retention.st!=='ok'?`<span class="lever">${lever('retention','FIX')}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor('retention','retention')}"></span> Retention</div>
+ <div class="val" style="font-size:12px;color:${S.retention.st==='ok'?'var(--cream)':'var(--amber)'}">${S.retention.v}</div>
+ <div class="sub2">${S.retention.st==='ok'?'monthly=2 (the /home lesson)':'monthly=10 — silently hoards space'}</div>${errline('retention')}</div>
+ </div>
+ <div class="engrave striph">by type<span class="cnt">· singles escape timeline cleanup</span></div>
+ <div class="jlist">
+ <div class="lrow jrow"><span class="lamp"></span><span class="who">timeline</span><span class="what">${t.timeline} · auto-pruned</span></div>
+ <div class="lrow jrow"><span class="lamp ${staleN>3?'gold':''}"></span><span class="who"><b>single (manual)</b></span>
+ <span class="jmsg">oldest ${t.oldsingle}</span>
+ <span class="what">${t.single}${staleN>3?` <button class="key sm ${aS?'armed':''}" onclick="event.stopPropagation();snapDelStale()">${aS?'DELETE?':'DELETE STALE'}</button>`:''}</span></div>
+ <div class="lrow jrow"><span class="lamp"></span><span class="who">pre / post</span><span class="what">${t.prepost} · paired with pacman ops</span></div>
+ </div>`; },
+
+network(){
+ const n = parseInt(S.ts.v)||0;
+ const bars = [1,2,3,4].map(i=>`<i class="${i<=n?'on':''}"></i>`).join('');
+ const sig = lsnSignal();
+ const exp = LEXPECT.filter(p=>!p.disabled);
+ const pubN = sig.filter(r=>isPub(r.bind)).length;
+ const sigRows = sig.map(r=>{
+ const key = lkey(r);
+ const aFix = armed==='lsnfix:'+key, aMark = armed==='lexp:'+key;
+ const bindTag = isPub(r.bind)
+ ? `<span class="badge red" style="font-size:.55rem">PUBLIC</span>`
+ : `<span style="color:var(--dim);font-size:10px">loopback</span>`;
+ return `<div class="lrow jrow"><span class="lamp ${isPub(r.bind)?'red':'gold'}"></span>
+ <span class="who"><b>${r.proc}</b></span>
+ <span class="jmsg">:${r.port} · ${r.bind}${r.unit?' · '+r.unit+'.service':''}</span>
+ <span class="what">${bindTag}
+ <button class="key sm ${aMark?'armed':''}" onclick="event.stopPropagation();markExpected('${key}')">${aMark?'MARK?':'MARK EXPECTED'}</button>
+ <button class="key sm ${aFix?'armed':''}" onclick="event.stopPropagation();lsnFix('${key}')">${aFix?(r.unit?'STOP?':'KILL?'):(r.unit?'STOP':'KILL')}</button>
+ </span></div>`;
+ }).join('');
+ const expRows = exp.map(p=>{
+ const key = p.proc+'|'+p.port;
+ return `<div class="lrow jrow known"><span class="lamp off"></span>
+ <span class="who">${p.proc}</span><span class="jmsg">:${p.port}</span>
+ <span class="what">${p.shipped?'shipped default':'marked '+p.date}
+ <button class="key sm" onclick="event.stopPropagation();unmarkExpected('${key}')">UNMARK</button></span></div>`;
+ }).join('');
+ const fwNote = S.fw.st!=='ok' && pubN
+ ? ` <span style="color:var(--fail);letter-spacing:0;text-transform:none">· ufw down — ${pubN} public bind${pubN===1?'':'s'} exposed</span>` : '';
+ return `
+ <div class="wgrid">
+ ${cellBadge('fw','Firewall',{lever:S.fw.st!=='ok'?lever('fw_fix','ENABLE',S.fw.st==='fail'?'red':''):'',act:'fw_fix',sub:'ufw · mosh rule'})}
+ ${cellCount('listen','Unexpected listeners',{sub:S.listen.st==='ok'?'all matched · '+exp.length+' expected':S.listen.v+' unmatched · '+pubN+' public-bind'})}
+ <div class="cell">${S.ts.st!=='ok'?`<span class="lever">${lever('ts_fix','RESTART')}</span>`:''}
+ <div class="cap"><span class="lamp ${lampFor('ts','ts_fix')}"></span> Tailscale</div>
+ <div class="val"><span class="ladder ${S.ts.st==='fail'?'bad':''}">${bars}</span>
+ <span class="readout" style="font-size:15px">${S.ts.v}</span></div>
+ <div class="sub2">peers online</div>${errline('ts_fix')}</div>
+ ${cellCount('f2b','fail2ban',{lever:S.f2b.st!=='ok'?lever('f2b_fix','RESTART'):'',act:'f2b_fix',sub:S.f2b.st==='ok'?'sshd jail · '+S.f2b.v+' bans 7d':'service down'})}
+ </div>
+ <div class="striph" style="display:flex;align-items:center;gap:10px">
+ <span class="engrave">listeners — signal<span class="cnt">· ${sig.length} unexpected</span>${fwNote}</span>
+ </div>
+ <div class="jlist">${sigRows || '<div class="rvnote">every listener matches the expected list.</div>'}</div>
+ <div class="striph" style="display:flex;align-items:center;gap:10px">
+ <span class="engrave">expected<span class="cnt">· ${exp.length} pattern${exp.length===1?'':'s'}</span></span>
+ <button class="key sm ${armed==='clearlsn'?'armed':''}" onclick="event.stopPropagation();clearLsn()">${armed==='clearlsn'?'CLEAR?':'CLEAR MARKS'}</button>
+ </div>
+ <div class="jlist">${expRows}</div>
+ <div class="engrave striph">peers<span class="cnt">· 4</span></div>
+ <div class="strip">${peerRoster()}</div>
+ <div class="engrave striph">resolution<span class="cnt">· 2</span></div>
+ <div class="strip two">
+ ${lrow('ntp','NTP (chrony)', S.ntp.st!=='ok'?lever('ntp_fix','RESTART')+' ':'', S.ntp.st==='ok'?'synced · +2 ms':undefined, 'ntp_fix')}
+ ${lrow('dns','DNS / NetworkManager', S.dns.st!=='ok'?lever('netdoc','NET DOCTOR')+' ':'', undefined, 'netdoc')}
+ </div>`; },
+};
+
+/* ---- doctor: streams into the results wall; the metrics subpanel stays put ---- */
+let doctorRunning = false;
+function startDoctor(mode){
+ if(doctorRunning){ toast('doctor already running'); return; }
+ const title = mode==='reclaim' ? 'reclaim space' : 'clean up';
+ const steps = [
+ {name:'cache trim', act:'clean_cache',
+ res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'reclaimed 8.4 GB') : 'nothing to reclaim'},
+ {name:'journal vacuum', act:'journald',
+ res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'reclaimed 0.9 GB') : 'already tight'},
+ {name:'coredump clear', act:'cores',
+ res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return n+' cleared';})() : 'none to clear'},
+ {name:'app-log cleanup', act:'applog',
+ res:()=> (S.applog={v:'ok',st:'ok'}, 'nothing older than 7d')},
+ ];
+ if(mode==='reclaim'){
+ steps.push(
+ {name:'docker prune (tier 1)', act:'docker',
+ res:()=> S.docker.st==='warn' ? (S.docker={v:0.2,st:'ok'}, 'reclaimed 2.8 GB') : 'nothing to prune'},
+ {name:'snapper cleanup', act:'snapper',
+ res:()=> (S.snapper={v:Math.min(S.snapper.v,31),st:'ok'}, 'retention applied')},
+ {name:'disk re-read', act:'applog',
+ res:()=> { S.disk={v:Math.max(60,S.disk.v-11), st:S.disk.v-11>90?'fail':S.disk.v-11>80?'warn':'ok'}; return 'now '+S.disk.v+'%'; }},
+ );
+ }
+ doctorRunning = true;
+ resAdd('doctor · '+title, 'started · '+steps.length+' actions', 'done');
+ render();
+ let i=0, entry=null, failed=0;
+ function step(){
+ if(entry){
+ const p = steps[i-1];
+ if(failMode){ ERR[p.act]=ACTIONS[p.act].err; failed++; resDone(entry,'fail',ERR[p.act]); }
+ else { delete ERR[p.act]; resDone(entry,'done',p.res()); }
+ render();
+ }
+ if(i<steps.length){
+ entry = resAdd(steps[i].name, 'running…'); i++;
+ doctorTimer = setTimeout(step, 1000);
+ } else {
+ doctorRunning = false;
+ resAdd('doctor · '+title,
+ `${steps.length-failed} done${failed?' · '+failed+' failed':''}`,
+ failed?'fail':'done');
+ render();
+ }
+ }
+ step();
+}
+
+/* ---- review & fix ---- */
+const REVIEW = [
+ {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', ok:()=>zfsMode||S.fstrim.st==='ok'},
+ {act:'ztrim_fix', who:'zpool autotrim', why:'TRIM disabled on the pool', id:'ztrim', ok:()=>!zfsMode||S.ztrim.st==='ok'},
+ {act:'scrub', who:'scrub', why:'past the scrub cadence', id:'scrub', ok:()=>S.scrub.st==='ok'},
+ {act:'orphans', who:'orphans', why:'unowned packages present', id:'orphans', ok:()=>S.orphans.st==='ok'},
+ {act:'pacnew', who:'.pacnew files', why:'configs awaiting merge', id:'pacnew', ok:()=>S.pacnew.st==='ok'},
+ {act:'timers', who:'maint timers', why:'timers not firing', id:'timers', ok:()=>S.timers.st==='ok'},
+ {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', ok:()=>S.keyring.st==='ok'},
+ {act:'docker', who:'docker reclaim', why:'space reclaimable (tiers 1+2)', id:'docker', ok:()=>S.docker.st==='ok'},
+ {act:'snapper', who:'snapper', why:'retention pass', id:'snapper', ok:()=>S.snapper.st==='ok'&&S.snapper.v<42},
+ {act:'deeptrim',who:'deep-trim', why:'keep 1 version — max reclaim, less downgrade headroom', id:'deeptrim', ok:()=>S.deeptrim.v==='keep 1'},
+ {act:'unit_restart', who:'failed units', why:'restart the failed unit(s)', id:'failed', ok:()=>S.failed.st==='ok'},
+ {act:'tgage', who:'topgrade', why:'ecosystem updates stale', id:'tgage', ok:()=>S.tgage.st==='ok', useAct:'topgrade'},
+ {act:'f2b_fix', who:'fail2ban', why:'service down', id:'f2b', ok:()=>S.f2b.st==='ok'},
+ {act:'cron_fix',who:'cronie', why:'service down', id:'cron', ok:()=>S.cron.st==='ok'},
+ {act:'ntp_fix', who:'NTP (chrony)', why:'clock unsynchronized', id:'ntp', ok:()=>S.ntp.st==='ok'},
+ {act:'ts_fix', who:'tailscaled', why:'peers unreachable', id:'ts', ok:()=>S.ts.st==='ok'},
+ {act:'stimer_fix', who:'snapshot timer', why:'auto-snapshots not firing', id:'stimer', ok:()=>S.stimer.st==='ok'},
+ {act:'zram_fix',who:'zram', why:'no swap configured', id:'zram', ok:()=>S.zram.st==='ok'},
+ {act:'balance', who:'btrfs balance', why:'unallocated headroom low', id:'unalloc', ok:()=>zfsMode||S.unalloc.st==='ok'},
+ {act:'reinstall', who:'file integrity', why:'modified package files', id:'integ', ok:()=>S.integ.st==='ok'},
+ {act:'retention', who:'snapshot retention', why:'monthly limit hoards space', id:'retention', ok:()=>S.retention.st==='ok'},
+ {act:'rsyncnow', who:'backup', why:'stale or failed — run now', id:'rsync', ok:()=>S.rsync.st==='ok'},
+ {act:'reboot', who:'reboot', why:'running kernel != installed', id:'reboot', ok:()=>S.reboot.v!=='YES'},
+ {act:'bat_limit', who:'charge limit', why:'no charge cap — battery longevity', id:'blimit', ok:()=>!zfsMode||S.blimit.st==='ok'},
+ {act:'ctr_start', who:'stopped containers', why:'unexpected exited containers', id:'dstopped', ok:()=>S.dstopped.st==='ok'},
+ {act:'fw_fix', who:'firewall', why:'ufw inactive — host exposed', id:'fw', ok:()=>S.fw.st==='ok'},
+];
+function renderReview(){
+ const items = REVIEW.filter(r=>!r.ok()||ERR[r.useAct||r.act]);
+ const rows = items.map(r=>{
+ const act = r.useAct||r.act;
+ const done = r.ok();
+ const failed = !!ERR[act];
+ return `<div class="rvrow">
+ <span class="lamp ${failed?'red':done?'':lampCls(S[r.id].st)}"></span>
+ <span class="who">${r.who}</span>
+ <span class="why ${failed?'errtx':''}">${failed?'✗ '+ERR[act]:r.why}</span>
+ <span class="keys">${done?'<span class="res">done ✓</span>':lever(act,'FIX')}</span></div>`;
+ }).join('');
+ return `
+ <div class="subhead"><span class="lamp gold"></span><span class="nm">Doctor · Review &amp; fix</span>
+ <span class="cnt">${items.length} Confirm items</span></div>
+ <div class="rvlist">${rows||'<div class="rvnote">nothing needs review — all Confirm items clear.</div>'}</div>
+ <div class="rvnote">Destructive fixes arm on first press, fire on the second. Metrics without a determinate
+ lever — failed units, unclean boots, updates, CVEs — stay on their subpanels as read-only telemetry.</div>
+ <div class="ofoot"><button class="key" onclick="go('cat:storage')">DONE</button></div>`;
+}
+
+/* ================= loading / hydration ================= */
+/* tiers mirror real probe cost: t0 = local file/daemon reads, t1 = process probes
+ (smartctl, docker df, snapper, du), t2 = package scans (cached in real build) */
+const TIERS = [
+ {ms:220, cats:['systemd','power','network','snapshots']},
+ {ms:650, cats:['storage','logs','services']},
+ {ms:1150, cats:['packages']},
+];
+function startLoad(){
+ loadTimers.forEach(clearTimeout); loadTimers=[];
+ loaded.clear();
+ $('lastscan').textContent = 'scanning…';
+ render();
+ TIERS.forEach(t=>{
+ loadTimers.push(setTimeout(()=>{
+ t.cats.forEach(c=>loaded.add(c));
+ if(loaded.size===CATS.length) $('lastscan').textContent='last scan just now · 44 checks · 1.2s';
+ render();
+ }, t.ms));
+ });
+}
+
+/* ================= render ================= */
+const ACT2CAT = { clean_cache:'storage', scrub:'storage', fstrim:'storage', deeptrim:'storage',
+ balance:'storage', reinstall:'storage', smarttest:'storage', ztrim_fix:'storage',
+ orphans:'packages', pacnew:'packages', keyring:'packages', reboot:'packages', topgrade:'packages',
+ timers:'systemd', unit_restart:'systemd',
+ cores:'logs', journald:'logs', applog:'logs', openjournal:'logs',
+ zram_fix:'power', bat_limit:'power',
+ docker:'services', cron_fix:'services', rsyncnow:'services', ctr_start:'services', fw_fix:'network',
+ snapper:'snapshots', stimer_fix:'snapshots', retention:'snapshots',
+ f2b_fix:'network', ntp_fix:'network', ts_fix:'network', netdoc:'network' };
+function catErr(cid){ return Object.keys(ERR).some(a=>ACT2CAT[a]===cid); }
+
+function renderSelector(){
+ $('selector').innerHTML = CATS.map(c=>{
+ const sel = view==='cat:'+c.id;
+ if(!loaded.has(c.id)){
+ return `<div class="selbtn loading ${sel?'sel':''}" onclick="go('cat:${c.id}')">
+ <span class="lamp busy"></span><span class="nm">${c.name}</span><span class="ct">···</span></div>`;
+ }
+ const k = counts(c);
+ const err = catErr(c.id);
+ const ct = err ? `<span class="a crit">✗ fail</span> · <span class="k">${k.ok}✓</span>`
+ : k.attn ? `<span class="a ${k.crit?'crit':''}">${k.attn}!</span> · <span class="k">${k.ok}✓</span>`
+ : `<span class="k">${k.ok} ✓</span>`;
+ return `<div class="selbtn ${(k.crit||err)?'crit':k.attn?'attn':'clear'} ${sel?'sel':''}" onclick="go('cat:${c.id}')">
+ <span class="lamp ${(k.crit||err)?'red':k.attn?'gold':''}"></span>
+ <span class="nm">${c.name}</span><span class="ct">${ct}</span></div>`;
+ }).join('');
+}
+function renderSub(){
+ const el = $('sub');
+ if(view==='review'){ el.innerHTML = renderReview(); return; }
+ const cid = view.slice(4);
+ if(!loaded.has(cid)){
+ const cat = CATS.find(c=>c.id===cid);
+ el.innerHTML = `<div class="subhead"><span class="lamp busy"></span><span class="nm">${cat.name}</span>
+ <span class="cnt">gathering…</span></div>
+ <div class="skel"><span class="lamp busy" style="width:14px;height:14px"></span>probing</div>`;
+ return;
+ }
+ const cat = CATS.find(c=>c.id===cid);
+ const k = counts(cat);
+ const [nfix,nwatch] = fixwatch(cid);
+ const cnt = (k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>`
+ : `<span class="okc">all clear · ${k.ok} ✓</span>`)
+ + ` · ${nfix} fixable · ${nwatch} watch`;
+ el.innerHTML = `<div class="subhead">
+ <span class="lamp ${k.crit?'red':k.attn?'gold':''}"></span>
+ <span class="nm">${cat.name}</span><span class="cnt">${cnt}</span></div>` + SUB[cid]();
+}
+function render(){
+ recalcJerr(); recalcListen(); recalcCtrs();
+ renderSelector(); renderSub();
+ // faceplate verdict = worst diagnostic state
+ let worst='ok';
+ DIAG.forEach(d=>{ const st=S[d].st;
+ if(st==='fail') worst='fail'; else if(st==='warn'&&worst!=='fail') worst='warn'; });
+ const fl=$('facelamp');
+ fl.className = 'lamp ' + (loaded.size<CATS.length?'busy':worst==='fail'?'red':worst==='warn'?'gold':'');
+ // badges
+ let attn=0; CATS.forEach(c=>{ if(loaded.has(c.id)){ attn+=counts(c).attn; } });
+ $('attbadge').textContent = loaded.size<CATS.length ? 'SCANNING' : (attn ? attn+' ATTN' : 'ALL CLEAR');
+ const errN = Object.keys(ERR).length;
+ $('errbadge').style.display = errN ? '' : 'none';
+ $('errbadge').textContent = errN + ' FAILED';
+ // updates strip: green when nothing, amber for a few, red for CVEs / a lot
+ const u = S.upd;
+ const ucls = (u.c>0 || u.p>100) ? 'crit' : (u.p>0 || u.a>0 || u.f>0) ? 'warn' : 'ok';
+ $('updbar').className = 'updbar ' + ucls;
+ // CVE badge: red and visible only when there are CVEs to show
+ $('cvebadge').style.display = S.upd.c ? '' : 'none';
+ $('cvebadge').textContent = S.upd.c + ' CVE';
+ $('upd-p').textContent = S.upd.p; $('upd-c').textContent = S.upd.c + ' CVE';
+ $('upd-c').classList.toggle('cve', S.upd.c>0);
+ $('upd-c').style.color = S.upd.c ? '' : 'var(--dim)';
+ $('upd-a').textContent = S.upd.a; $('upd-f').textContent = S.upd.f;
+ $('k-clean').classList.toggle('on', doctorRunning);
+ $('k-review').classList.toggle('on', view==='review');
+ // guard-armed update keys: red + '?' while awaiting the override press
+ $('k-update').classList.toggle('armed', armed==='sysupdate');
+ $('k-update').textContent = armed==='sysupdate' ? 'UPDATE?' : 'UPDATE';
+ $('k-topgrade').classList.toggle('armed', armed==='topgrade');
+ $('k-topgrade').textContent = armed==='topgrade' ? 'TOPGRADE?' : 'TOPGRADE';
+ // reboot offer key appears on the updates strip once an update lands
+ $('upd-rb').innerHTML = S.reboot.v==='YES' ? lever('reboot','REBOOT','red') : '';
+}
+function go(v){
+ armed=null;
+ if(v==='doctor'||v==='reclaim'){ startDoctor(v==='reclaim'?'reclaim':undefined); return; }
+ view=v; render();
+}
+function closePanel(){ $('capsule').style.display='none'; $('closed').style.display='block'; }
+function openPanel(){ $('capsule').style.display='block'; $('closed').style.display='none'; startLoad(); }
+document.addEventListener('keydown', e=>{ if(e.key==='Escape') closePanel(); });
+startLoad();
+
+/* live refresh sim: the thermal/memory group re-reads every 3 s while its
+ subpanel is visible (panel-open gated, like the audio panel's meters) */
+setInterval(()=>{
+ if($('capsule').style.display==='none') return;
+ if(view!=='cat:power' || !loaded.has('power')) return;
+ const j = (v,lo,hi)=>Math.max(lo,Math.min(hi, v + (Math.random()<0.5?-1:1)));
+ let [tc,tg] = String(S.temps.v).split('/').map(Number);
+ if(badMode){ tc=j(tc,88,94); tg=j(tg,74,80); } else { tc=j(tc,57,65); tg=j(tg,50,58); }
+ S.temps.v = tc+'/'+tg;
+ S.mem.v = Math.round((S.mem.v + (Math.random()-0.5)*0.6)*10)/10;
+ render();
+}, 3000);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/README.org b/docs/prototypes/README.org
new file mode 100644
index 0000000..651636a
--- /dev/null
+++ b/docs/prototypes/README.org
@@ -0,0 +1,92 @@
+#+TITLE: Panel & Waybar Design Prototypes
+#+AUTHOR: Craig Jennings
+
+Self-contained HTML/CSS design prototypes for the instrument-console panel
+family and the waybar redesign. Each opens standalone in a browser (no external
+assets). These are the normative visual references the specs in [[file:../specs/][docs/specs/]]
+point at.
+
+* Prototypes
+
+- [[file:2026-07-03-instrument-console-panels-prototype.html][2026-07-03-instrument-console-panels-prototype.html]] — the net + bluetooth
+ pair; the approved faceplate design that shipped. Normative reference for
+ [[file:../specs/2026-07-03-instrument-console-panels-spec.org][the instrument-console spec]].
+- [[file:2026-07-03-net-panel-rescan-prototype.html][2026-07-03-net-panel-rescan-prototype.html]] — the manual rescan/scan ⟳
+ 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:panel-widget-gallery.html][panel-widget-gallery.html]] — the shared instrument-console
+ widget kit (lamps, engraved sections, console keys, needle gauges). The
+ living catalogue: every widget here is the visual + behavioral spec for
+ its reusable-component ports. All widgets 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).
+
+* Widget library ([[file:widgets.js][widgets.js]])
+
+The whole kit lives in =widgets.js= — a classic script (no modules, no build
+step) exposing the =GW= namespace with one builder per gallery card. Load it
+with =<script src="widgets.js"></script>= and call builders directly.
+
+** Builder contract
+
+=GW.name(host, opts)= → handle ={el, get, set, ...}=. =host= is an empty
+element the widget renders into. =opts.onChange(value, text)= fires on every
+state change, including the initial paint; =text= is the widget's canonical
+readout string. (Exception: live meters whose original init was silent — they
+paint on the first =set=/=push=.)
+
+** Styling
+
+Widget-internal CSS ships inside =widgets.js= (the =GW_CSS= block, injected
+once as =<style id="gw-css">= at load), so widgets 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)
+
+Widget-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=.
+
+** Style options
+
+Widgets with named style axes take them as constructor opts backed by a
+=STYLES= table on the builder — e.g. =GW.slideToggle(host, {onStyle:'green',
+thumb:'brass'})= with the axes enumerated in =GW.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 widget: the needle gauge,
+gallery card 10) — it reads gallery-tokens.el and emits the widget as SVG via
+svg.el, so the same look renders inside Emacs. Tests:
+=tests/gallery-tokens/= (generator, unittest) and =tests/gallery-widgets/=
+(renderer, ERT), both in =make test-unit=.
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..1c96f45
--- /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 . "gw-glow") (x . "-75%") (y . "-75%")
+ (width . "250%") (height . "250%"))
+ (dom-node 'feGaussianBlur
+ '((in . "SourceGraphic")
+ (stdDeviation . "2"))))))
+ ;; arc: 2px wash stroke, centerline radius 47 (96px circle, 2px border)
+ (gallery-widget--node svg 'path
+ :d "M 1 48 A 47 47 0 0 1 95 48"
+ :fill "none"
+ :stroke (gallery-widget-token 'wash)
+ :stroke-width "2")
+ ;; ticks: 8px steel radials at -60 / 0 / +60
+ (dolist (deg '(-60.0 0.0 60.0))
+ (let ((outer (gallery-widget--polar cx cy 47.0 deg))
+ (inner (gallery-widget--polar cx cy 39.0 deg)))
+ (gallery-widget--node svg 'line
+ :class "tick"
+ :x1 (gallery-widget--fmt (car inner))
+ :y1 (gallery-widget--fmt (cdr inner))
+ :x2 (gallery-widget--fmt (car outer))
+ :y2 (gallery-widget--fmt (cdr outer))
+ :stroke (gallery-widget-token 'steel)
+ :stroke-width "1.5")))
+ ;; needle glow underlay (blurred, translucent), then the crisp needle
+ (gallery-widget--node svg 'line
+ :class "needle-glow"
+ :x1 (gallery-widget--fmt cx)
+ :y1 (gallery-widget--fmt cy)
+ :x2 (gallery-widget--fmt (car tip))
+ :y2 (gallery-widget--fmt (cdr tip))
+ :stroke (gallery-widget-token 'gold-hi)
+ :stroke-width "4"
+ :stroke-linecap "round"
+ :opacity "0.55"
+ :filter "url(#gw-glow)")
+ (gallery-widget--node svg 'line
+ :class "needle"
+ :x1 (gallery-widget--fmt cx)
+ :y1 (gallery-widget--fmt cy)
+ :x2 (gallery-widget--fmt (car tip))
+ :y2 (gallery-widget--fmt (cdr tip))
+ :stroke (gallery-widget-token 'gold-hi)
+ :stroke-width "2"
+ :stroke-linecap "round")
+ ;; hub: half-dome on the dial's bottom edge (the web card clips the
+ ;; lower half of its 8px circle; here the dome is drawn directly)
+ (gallery-widget--node svg 'path
+ :class "hub"
+ :d "M 44 48 A 4 4 0 0 1 52 48 Z"
+ :fill (gallery-widget-token 'gold))
+ ;; value readout, matching the web card's cream bold figure
+ (svg-text svg (format "%d%%" (round value))
+ :x 48 :y 62
+ :fill (gallery-widget-token 'cream)
+ ;; the token's CSS font stack uses double quotes, which would
+ ;; break the XML attribute — SVG accepts single-quoted names
+ :font-family (replace-regexp-in-string
+ "\"" "'" (gallery-widget-token 'mono))
+ :font-size "12"
+ :font-weight "700"
+ :text-anchor "middle")
+ svg))
+
+;;; --- output helpers ---
+
+(defun gallery-widget-svg-string (svg)
+ "Return SVG (an svg.el dom object) serialized as an XML string."
+ (with-temp-buffer
+ (svg-print svg)
+ (buffer-string)))
+
+(defun gallery-widget-write-svg (svg file)
+ "Write SVG to FILE and return FILE."
+ (with-temp-file file
+ (svg-print svg))
+ file)
+
+(provide 'gallery-widget)
+;;; gallery-widget.el ends here
diff --git a/docs/prototypes/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..9740c46
--- /dev/null
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -0,0 +1,1616 @@
+<!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 (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:28px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap}
+.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}
+/* size toggle scales the widget stage only; card text stays at its own (slightly raised) size */
+body[data-size="2"] .stagew{zoom:1.7}
+body[data-size="3"] .stagew{zoom:2.4}
+/* intrinsically tiny widgets get an extra bump so they do not float in an empty stage */
+.stagew.boost{zoom:1.5}
+body[data-size="2"] .stagew.boost{zoom:2.55}
+body[data-size="3"] .stagew.boost{zoom:3.6}
+body[data-size="2"] .grid{grid-template-columns:repeat(auto-fill,minmax(380px,1fr))}
+body[data-size="3"] .grid{grid-template-columns:repeat(auto-fill,minmax(520px,1fr))}
+body[data-size="2"] .wrap,body[data-size="3"] .wrap{max-width:none}
+
+.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 */
+.vlamp{margin-left:auto;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)}
+.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}
+
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body data-size="2">
+<div class="wrap">
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · live kit</div>
+ <h1>Widget gallery — the instrument-console kit, fully driveable</h1>
+ <div class="mastrow">
+ <div>
+ <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 widget 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">widget size</span>
+ <button class="key" data-sz="1">S</button>
+ <button class="key on" data-sz="2">M</button>
+ <button class="key" data-sz="3">L</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>
+ </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}=GW;
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+document.querySelectorAll('.szbar .key').forEach(b=>b.addEventListener('click',()=>{
+ document.body.dataset.size=b.dataset.sz;
+ localStorage.setItem('gv-size',b.dataset.sz);
+ document.querySelectorAll('.szbar .key').forEach(k=>k.classList.toggle('on',k===b));
+}));
+(function(){const s=localStorage.getItem('gv-size');
+ if(!s||!/^[123]$/.test(s))return;
+ document.body.dataset.size=s;
+ document.querySelectorAll('.szbar .key').forEach(k=>k.classList.toggle('on',k.dataset.sz===s));})();
+/* boost the intrinsically tiny widgets (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 widgets ----
+ 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 (GW.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 widgets port to every target (Emacs click-regions
+ included); drag widgets have no Emacs drag equivalent and need a click/key idiom there;
+ display widgets 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.'},
+'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:'Display only; flaps animate on change.',
+ 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'};
+/* 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};
+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=
+ `<div class="vrow" data-v="green"><span class="vdot" data-v="green"></span>done<span class="vn">${n.green}</span></div>`+
+ `<div class="vrow" data-v="amber"><span class="vdot" data-v="amber"></span>in progress<span class="vn">${n.amber}</span></div>`+
+ `<div class="vrow" data-v="off"><span class="vdot"></span>not done<span class="vn">${n.off}</span></div>`+
+ `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`;
+ el.querySelectorAll('.vrow[data-v]').forEach(row=>row.addEventListener('click',()=>{
+ VJUMP[row.dataset.v]=0; auditNext(row.dataset.v);
+ }));
+}
+/* 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 GW.* widget into the stage; rd(txt) writes the card readout. */
+function card(host, no, name, html, note){
+ const isBuild=typeof html==='function';
+ const c=document.createElement('div'); c.className='card'; c.id='card-'+no;
+ c.innerHTML=`<div class="wname"><span class="no">${no}</span>${name}<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)||'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='';
+ 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.gw=html(c.querySelector('.stagew'),txt=>setRd(no,txt));
+ return c;
+}
+function setRd(no,txt){const e=$('rd-'+no);if(e)e.textContent=txt;}
+/* drag helpers, seg7, buildBars live in widgets.js (GW) */
+
+/* ============ CONTROLS ============ */
+const C=$('controls');
+card(C,'01','Slide toggle',
+ (st,rd)=>GW.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)=>GW.consoleKeys(st,{onChange:(i,t)=>rd(t)}),
+ '<b>physical push button.</b> Mutually exclusive — click one to engage. Gold = live, terracotta = muted.');
+card(C,'03','Horizontal fader',
+ (st,rd)=>GW.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=GW.faderV(st,{value:60,onChange:upd});
+ const b=GW.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)=>GW.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)=>GW.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)=>GW.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}),
+ '<b>inline binary.</b> A soft toggle inside a line of text. Click to flip; gold when on.');
+card(C,'08','Arm-to-fire',
+ (st,rd)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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.');
+
+/* ============ METERS & GAUGES ============ */
+const M=$('meters');
+card(M,'10','Needle gauge',
+ (st,rd)=>GW.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)=>GW.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)=>GW.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)=>GW.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=GW.fuelBar(st,{value:72,onChange:(v,t)=>rd(t)});
+ const b=GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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,rd)=>GW.splitFlap(st,{animate:!reduced,onChange:(v,t)=>rd(t)}),
+ '<b>flips to the new value.</b> Each card drops to the next glyph with the mechanical clack. Auto-flips; click to flip now.');
+card(I,'N21','Seven-segment display',
+ (st,rd)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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)=>GW.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 (GW.*) — wired at their card records */
+
+/* ============ METER WIRING (drag-driven) ============ */
+/* 10-17, N11-N18 extracted to widgets.js (GW.*) — wired at their card records */
+
+/* ============ INDICATOR WIRING ============ */
+/* 18-23, 26 extracted to widgets.js (GW.*) — wired at their card records */
+/* N20-N28 extracted to widgets.js (GW.*) — wired at their card records */
+
+/* ============ REFERENCE-BATCH (R) WIDGETS — SVG builders ============ */
+/* svgEl, polar, VUDB live in widgets.js (GW) */
+
+/* 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 (GW.*) */
+
+
+
+
+
+
+
+
+
+/* R07-R09 extracted to widgets.js (GW.*) */
+
+/* R10, R11 extracted to widgets.js (GW.*) */
+
+
+
+
+/* R13, R17 extracted to widgets.js (GW.*) */
+
+/* R25, R26, R30, R31 extracted to widgets.js (GW.*) */
+
+
+
+
+/* ============ LIVE SIGNAL LOOPS ============ */
+/* extracted meters: the page keeps the clock + demo signal, drives card handles */
+const MH={vu:$('card-11').gw,mini:$('card-12').gw,spark:$('card-16').gw,wave:$('card-17').gw,
+ scope:$('card-N11').gw,eq:$('card-N12').gw,strip:$('card-N16').gw,mcvu:$('card-R01').gw};
+const IH={flap:$('card-N20').gw,seven:$('card-N21').gw,vfd:$('card-N22').gw,
+ counter:$('card-N25').gw,clock:$('card-N26').gw};
+
+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());}
+function tickFlap(){IH.flap.next();}
+function tickSeven(){IH.seven.tick();}
+function tickT(){$('card-20').gw.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();
+ setInterval(tickFlap,1500);
+ setInterval(tickVfd,32);
+ setInterval(tickCounter,1600);
+ setInterval(tickSeven,1000);
+ setInterval(tickT,1000);
+ document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='running');
+}else{
+ paintStatic();
+ document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='paused');
+}
+
+/* R35, R36 extracted to widgets.js (GW.*) */
+
+
+/* R43, R44 extracted to widgets.js (GW.*) */
+
+/* R45-R47, R52 extracted to widgets.js (GW.*) */
+
+
+
+
+/* R53-R56 extracted to widgets.js (GW.*) */
+
+/* ---- screen-family chips: first round, the clearly-screen widgets ----
+ Defaults match each widget'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').gw.el,['amber','green','red','blue','vfd'],'amber');
+screenChips('R17',$('card-R17').gw.el,['green','amber','red','blue','vfd'],'green');
+screenChips('R19',$('card-R19').gw.el,['white','green','amber','blue','vfd'],'white');
+screenChips('R31',$('card-R31').gw.el,['amber','green','red','blue','vfd'],'amber');
+screenChips('N11',$('card-N11').gw.el,['green','amber','red','blue','vfd'],'green');
+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 */
+function styleChips(no,STYLES,AXES){
+ const h=$('card-'+no)?.gw; if(!h)return;
+ const cardEl=h.el.closest('.card');
+ const row=document.createElement('div'); row.className='famchips';
+ for(const [label,axis,def] of AXES){
+ 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',()=>{h.setStyle(axis,name);
+ chips.forEach(x=>x.classList.toggle('on',x===b));});
+ g.appendChild(b);}
+ row.appendChild(g);}
+ cardEl.querySelector('.opts').appendChild(row);
+}
+styleChips('01',GW.slideToggle.STYLES,[['on','on','amber'],['off','off','dark'],['off text','offText','white'],['thumb','thumb','light']]);
+styleChips('R05',GW.filterBank.STYLES,[['panel','panel','silver'],['shape','caps','block'],['color','capColor','black']]);
+styleChips('06',GW.segmented.STYLES,[['accent','accent','amber']]);
+styleChips('25',GW.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();
+</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/widgets.js b/docs/prototypes/widgets.js
new file mode 100644
index 0000000..c12e5b7
--- /dev/null
+++ b/docs/prototypes/widgets.js
@@ -0,0 +1,4491 @@
+/* widgets.js — retro-instrument widget library (GW namespace).
+ Classic script: load after the token :root block (tokens.json → gen_tokens.py),
+ before any GW.* 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 GW = {};
+
+/* ================= 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' },
+};
+
+/* ================= widget builders =================
+ Every builder: GW.name(host, opts) → handle. host is an empty element the
+ widget renders into. opts.onChange(value, text) fires on every state change,
+ including the initial paint; text is the widget's canonical readout string. */
+
+const noop = () => {};
+
+/* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*).
+ opts.onStyle / offStyle / offText / thumb pick a named style per axis from
+ GW.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's
+ setStyle(axis, name) restyles a live instance (the gallery chips use it). */
+GW.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 = (GW.slideToggle.STYLES[axis] || {})[name];
+ if (!o) return;
+ for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v);
+ };
+ setStyle('on', opts.onStyle || 'amber');
+ setStyle('off', opts.offStyle || 'dark');
+ setStyle('offText', opts.offText || 'white');
+ setStyle('thumb', opts.thumb || 'light');
+ 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 };
+};
+/* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */
+GW.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)' } },
+ 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)' } },
+ },
+ offText: {
+ white: { dot: 'var(--cream)', vars: { '--sw-off-ink': 'var(--cream)' } },
+ red: { dot: '#e0523a', vars: { '--sw-off-ink': '#e0523a' } },
+ 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, red} per key */
+GW.consoleKeys = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const keys = opts.keys || [{ label: 'LIVE' }, { label: 'SCAN' }, { label: 'MUTED', red: true }];
+ const wrap = document.createElement('span'); host.appendChild(wrap);
+ const btns = keys.map(k => {
+ const b = document.createElement('button'); b.className = 'key'; b.textContent = k.label;
+ wrap.appendChild(b); return b;
+ });
+ let idx;
+ const set = i => {
+ idx = i;
+ btns.forEach((b, j) => { b.classList.remove('on', 'red'); if (j === i) b.classList.add(keys[i].red ? 'red' : 'on'); });
+ onChange(i, keys[i].label);
+ };
+ btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
+ set(opts.active || 0);
+ return { el: wrap, get: () => idx, set };
+};
+
+/* 03 horizontal fader — continuous 0-100 */
+GW.faderH = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const f = document.createElement('div'); f.className = 'fader';
+ f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>';
+ host.appendChild(f);
+ let val;
+ const set = p => {
+ val = Math.max(0, Math.min(100, p));
+ f.querySelector('.fill').style.width = val + '%';
+ f.querySelector('.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 — one channel-strip fader; compose per channel */
+GW.faderV = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const f = document.createElement('div'); f.className = 'vfader';
+ f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>';
+ host.appendChild(f);
+ let val;
+ const set = p => {
+ val = Math.max(0, Math.min(100, p));
+ f.querySelector('.fill').style.height = val + '%';
+ f.querySelector('.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 */
+GW.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 = 'knob';
+ k.innerHTML = '<span class="ind"></span>';
+ host.appendChild(k);
+ let val;
+ const set = v => {
+ val = Math.max(min, Math.min(max, v));
+ k.querySelector('.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 GW.segmented.STYLES (amber / green / red); defaults match the
+ stylesheet fallbacks. setStyle(axis, name) restyles a live instance. */
+GW.segmented = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const items = opts.items || ['TIMER', 'ALARM', 'POMO'];
+ const seg = document.createElement('div'); seg.className = 'seg'; host.appendChild(seg);
+ const setStyle = (axis, name) => {
+ const o = (GW.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 = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); };
+ 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 */
+GW.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 */
+GW.chipToggle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const chip = document.createElement('span'); chip.className = 'chip';
+ chip.textContent = opts.label || 'discoverable on';
+ host.appendChild(chip);
+ let on;
+ const set = v => { on = !!v; chip.classList.toggle('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 };
+};
+
+/* 08 arm-to-fire — two-stage confirm for destructive actions */
+GW.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 = 'arm'; a.textContent = label;
+ host.appendChild(a);
+ let armed = false;
+ a.addEventListener('click', () => {
+ if (!armed) { armed = true; a.classList.add('armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); }
+ else { armed = false; a.classList.remove('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 */
+GW.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 = 'lrow';
+ row.innerHTML = `<span class="lamp"></span><span class="who"><b></b></span><span class="what"></span>`;
+ row.querySelector('b').textContent = name;
+ host.appendChild(row);
+ let idx;
+ const set = i => {
+ idx = i;
+ row.querySelector('.lamp').className = 'lamp ' + states[i][0];
+ row.querySelector('.what').textContent = states[i][1];
+ onChange(i, states[i][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 */
+GW.rotarySelector = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const values = opts.values || [4, 6, 8, 10, 12];
+ 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 = 'rotsel';
+ values.forEach((v, i) => {
+ const p = document.createElement('span'); p.className = 'pos';
+ p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v;
+ rs.appendChild(p);
+ });
+ const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; rs.appendChild(k);
+ host.appendChild(rs);
+ let idx;
+ const set = i => {
+ idx = ((i % values.length) + values.length) % values.length;
+ rs.querySelector('.ind').style.transform = `rotate(${ANG[idx]}deg)`;
+ rs.querySelectorAll('.pos').forEach((p, j) => p.classList.toggle('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. set() takes a stop index; get() returns the value. opts.skin picks
+ a face from GW.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue);
+ setStyle(axis, name) restyles a live instance. */
+GW.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 = 'tuner';
+ t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value');
+ const setStyle = (axis, name) => {
+ const o = (GW.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="tick" style="left:${s.x}px"></span><span class="mk" style="left:${s.x}px">${s.v}</span>`
+ : `<span class="tick mn" style="left:${s.x}px"></span>`);
+ });
+ const ndl = document.createElement('span'); ndl.className = '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('.mk').forEach(m => m.classList.toggle('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. */
+GW.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 */
+GW.rocker = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const r = document.createElement('span'); r.className = 'rocker';
+ r.innerHTML = `<span class="half top">${opts.onLabel || 'ON'}</span><span class="half bot">${opts.offLabel || 'OFF'}</span>`;
+ host.appendChild(r);
+ let on;
+ const set = v => { on = !!v; r.classList.toggle('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.
+ opts.animate: reels obey play state (default: not prefers-reduced-motion) */
+GW.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="reels"><span class="reel spin"><i></i><i></i><i></i></span><span class="reel spin"><i></i><i></i><i></i></span></div><div class="transport"></div>`;
+ const bar = wrap.querySelector('.transport');
+ const btns = {};
+ for (const m of Object.keys(MODES)) {
+ const b = document.createElement('button'); b.className = 'tbtn' + (m === 'rec' ? ' 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('on', k === m);
+ wrap.querySelectorAll('.reel.spin').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('on');
+ onChange(mode, NAMES[mode]);
+ return { el: wrap, get: () => mode, set };
+};
+
+/* N03 radio preset bank — mechanically exclusive presets */
+GW.presetBank = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF'];
+ const bank = document.createElement('div'); bank.className = 'radiobank'; host.appendChild(bank);
+ const btns = items.map(t => { const b = document.createElement('button'); b.className = 'preset'; b.textContent = t; bank.appendChild(b); return b; });
+ let idx;
+ const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); };
+ 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 */
+GW.dualKnob = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const dk = document.createElement('span'); dk.className = 'dualknob';
+ dk.innerHTML = `<span class="outer"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span><span class="inner"><span class="ind"></span></span>`;
+ host.appendChild(dk);
+ const o = dk.querySelector('.outer'), n = dk.querySelector('.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 = opts.outer !== undefined ? opts.outer : 50;
+ iV = opts.inner !== undefined ? opts.inner : 50;
+ paint();
+ return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = a; if (b !== undefined) iV = b; paint(); } };
+};
+
+/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level */
+GW.encoder = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const enc = document.createElement('span'); enc.className = 'encoder'; host.appendChild(enc);
+ const R = 27, cx = 33, cy = 33;
+ for (let i = 0; i < 12; i++) {
+ const a = (i * 30 - 90) * Math.PI / 180;
+ const d = document.createElement('span'); d.className = 'led';
+ d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px';
+ enc.appendChild(d);
+ }
+ const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; enc.appendChild(k);
+ let val;
+ const set = v => {
+ val = v;
+ const lit = ((Math.floor(val) % 12) + 12) % 12;
+ enc.querySelectorAll('.led').forEach((l, i) => l.classList.toggle('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 */
+GW.keySwitch = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const items = opts.items || ['OFF', 'ON', 'RUN'];
+ const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]];
+ const kl = document.createElement('span'); kl.className = 'keylock';
+ items.forEach((t, i) => {
+ const p = document.createElement('span'); p.className = 'kpos';
+ p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = t;
+ kl.appendChild(p);
+ });
+ const body = document.createElement('span'); body.className = 'body';
+ body.innerHTML = '<span class="bit"></span><span class="barrel"></span>';
+ kl.appendChild(body); host.appendChild(kl);
+ let idx;
+ const set = i => {
+ idx = ((i % items.length) + items.length) % items.length;
+ body.querySelector('.bit').style.transform = `rotate(${ANG[idx]}deg)`;
+ kl.querySelectorAll('.kpos').forEach((p, j) => p.classList.toggle('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 */
+GW.crossfader = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const xf = document.createElement('div'); xf.className = 'xfader';
+ xf.innerHTML = `<div class="slot"></div><div class="detent"></div><span class="end" style="left:0">${opts.aLabel || 'A'}</span><span class="end" style="right:0">${opts.bLabel || 'B'}</span><div class="cap"></div>`;
+ host.appendChild(xf);
+ let pct;
+ const set = p => {
+ pct = Math.max(0, Math.min(100, p));
+ xf.querySelector('.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 */
+GW.thumbwheel = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const w = document.createElement('div'); w.className = 'thumbw';
+ w.innerHTML = '<span class="thumbwheel"></span><span class="win"></span>';
+ host.appendChild(w);
+ let val;
+ const set = v => {
+ val = Math.round(v);
+ const s = ((val % 100) + 100) % 100;
+ w.querySelector('.win').textContent = String(s).padStart(2, '0');
+ onChange(s, 'value ' + s);
+ };
+ dragDelta(w.querySelector('.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 */
+GW.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 = 'dip'; host.appendChild(bank);
+ const sws = bits.map(on => {
+ const s = document.createElement('span'); s.className = 'dipsw' + (on ? ' on' : '');
+ s.innerHTML = '<i></i>'; bank.appendChild(s); return s;
+ });
+ const word = () => sws.map(x => x.classList.contains('on') ? '1' : '0').join('');
+ const upd = () => onChange(word(), word());
+ sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('on'); upd(); }));
+ upd();
+ return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('on', w[i] === '1')); upd(); } };
+};
+
+/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit */
+GW.jogWheel = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const j = document.createElement('span'); j.className = 'jog';
+ j.innerHTML = `<span class="shuttle" style="--sh:40deg"></span><span class="inner"><span class="dimple"></span></span>`;
+ host.appendChild(j);
+ let val;
+ const set = v => {
+ val = v;
+ j.querySelector('.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 widgets. 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 */
+GW.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 = v; disc.setAttribute('transform', `rotate(${-v * 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 */
+GW.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 */
+GW.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 = v; idx.setAttribute('transform', `rotate(${v * 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. Period skins
+ as constructor opts backed by GW.filterBank.STYLES, three independent axes:
+ panel (silver hi-fi aluminum / studio black), caps — the cap SHAPE
+ (chrome T / short ribbed / tall block fader), and 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; setStyle(axis, name)
+ restyles live (values kept). After the Pioneer SG-9500, the Technics
+ SH-8065, and the Zaxcom Oasis block faders. */
+GW.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);
+ while (vals.length < freqs.length) vals.push(30);
+ const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
+ const ST = GW.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) => {
+ 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. */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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) => {
+ val = Math.max(0, Math.min(100, v)); band = b;
+ needle.setAttribute('transform', `rotate(${-70 + val / 100 * 140},62,62)`);
+ spread.setAttribute('transform', `rotate(${-45 + b * 30},142,56)`);
+ rings.forEach((g, i) => g.setAttribute('opacity', i === b ? '1' : '.4'));
+ const [lo, hi] = ranges[b]; const mc = lo + val / 100 * (hi - lo);
+ onChange({ band: b, mc }, `B${b + 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 */
+GW.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 (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 */
+GW.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 */
+GW.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));
+ 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 */
+GW.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 */
+GW.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 = i;
+ leds.forEach((l, k) => {
+ const on = k === i;
+ 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 : 5);
+ return { el: s, get: () => idx, set };
+};
+
+/* R22 three-position slide — chrome pill between detents, honest LED pair */
+GW.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 = i;
+ pill.setAttribute('transform', `translate(${detX[i]},0)`);
+ lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)'));
+ const lit = [i === 0 || i === 1, i === 2 || i === 1];
+ leds.forEach((l, k) => l.setAttribute('fill', lit[k] ? 'var(--jewel-r)' : '#3a0f0a'));
+ onChange(idx, positions[i]);
+ };
+ 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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 = i;
+ grp.style.transform = `rotate(${positions[i][1]}deg)`;
+ lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)'));
+ onChange(idx, positions[i][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 */
+GW.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. */
+GW.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 */
+GW.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 */
+GW.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 => {
+ 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(opts.position || 'C');
+ return { el: s, get: () => pos, set };
+};
+
+/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */
+GW.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'];
+ const pins = new Set(opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']); /* a legible default patch */
+ 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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 = 'dsky'; host.appendChild(el);
+ el.innerHTML =
+ `<div class="lampcol">${LAMPS.map(l => `<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>` +
+ `<div class="right"><div class="wins">` +
+ `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>` +
+ `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>` +
+ `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>` +
+ `</div><div class="pad"></div></div>`;
+ const win = w => el.querySelector(`[data-w="${w}"]`);
+ const setWin = (w, txt) => { win(w).querySelector('.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('.pad');
+ const hot = () => { el.querySelectorAll('.win').forEach(w => w.classList.toggle('hot', w.dataset.w === mode)); };
+ const oprErr = () => { lampEl('OPR ERR').classList.add('on'); setTimeout(() => lampEl('OPR ERR').classList.remove('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('on'));
+ setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('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 = '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('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. */
+GW.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 = i;
+ ptr.setAttribute('transform', `rotate(${i * 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 */
+GW.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 */
+GW.decadeBox = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1'];
+ const digs = (opts.digits || [3, 5, 0, 0]).slice();
+ 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 */
+GW.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 (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 */
+GW.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 = 'vloop'; host.appendChild(el);
+ const keys = LOOPS.map(l => {
+ const k = document.createElement('div'); k.className = 'vk';
+ k.innerHTML = l + '<span class="bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k;
+ });
+ /* a legible default: FD + A/G monitored, GNC talking */
+ keys[0].dataset.state = '1'; keys[0].classList.add('mon');
+ keys[4].dataset.state = '1'; keys[4].classList.add('mon');
+ keys[1].dataset.state = '2'; keys[1].classList.add('mon', '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('mon'); }
+ else if (st === '1') {
+ keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('tlk'); } });
+ k.dataset.state = '2'; k.classList.add('tlk');
+ }
+ else { k.dataset.state = '0'; k.classList.remove('mon', 'tlk', '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('act'); return; }
+ k.classList.toggle('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 */
+GW.needleGauge = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 50;
+ const el = document.createElement('div'); el.className = 'gauge';
+ el.innerHTML = `<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"></div><div class="hub"></div></div><div class="gv"><span>0</span>%</div>`;
+ host.appendChild(el);
+ const ndl = el.querySelector('.ndl'), num = el.querySelector('.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 */
+GW.vuPair = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const n = opts.bars || 16;
+ const el = document.createElement('div'); el.className = 'vu';
+ el.innerHTML = `<div class="vurow"><span class="ch">L</span><span class="vubar"></span></div>
+ <div class="vurow"><span class="ch">R</span><span class="vubar"></span></div>`;
+ host.appendChild(el);
+ const bars = el.querySelectorAll('.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 ? 'clip' : k >= n - 4 ? 'hot' : 'on') : '';
+ if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + '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) */
+GW.miniSig = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const el = document.createElement('span'); el.className = 'sig';
+ el.innerHTML = '<i></i><i></i><i></i><i></i>';
+ host.appendChild(el);
+ let v = 0;
+ function set(l) {
+ v = 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') : '';
+ onChange(l, Math.round(l * 100) + '%');
+ }
+ return { el, get: () => v, set };
+};
+
+/* 13 signal ladder — stepped 0-4 strength; click cycles */
+GW.signalLadder = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 3;
+ const el = document.createElement('span'); el.className = '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 */
+GW.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 = 'bar';
+ el.innerHTML = '<span></span>';
+ host.appendChild(el);
+ const fill = el.querySelector('span');
+ function set(p) {
+ v = p;
+ fill.style.width = p + '%';
+ el.classList.toggle('warn', p < warnAt);
+ onChange(p, Math.round(p) + '%');
+ }
+ dragX(el, set);
+ set(v);
+ return { el, get: () => v, set };
+};
+
+/* 15 radial ring — percentage donut; drag up/down to set */
+GW.radialRing = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 68;
+ const el = document.createElement('span'); el.className = 'ring';
+ el.innerHTML = '<b></b>';
+ host.appendChild(el);
+ const num = el.querySelector('b');
+ function set(nv) {
+ v = 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 */
+GW.sparkline = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const n = opts.samples || 40;
+ const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
+ const el = document.createElement('span'); el.className = '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');
+ const clamp = x => Math.max(0, Math.min(1, x));
+ function paint() {
+ line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - clamp(v) * 40 - 2}`).join(' '));
+ onChange(hist[n - 1], String(Math.round(clamp(hist[n - 1]) * 100)));
+ }
+ function push(v) { hist.push(v); hist.shift(); paint(); }
+ function fill(v) { hist.fill(v); paint(); }
+ return { el, get: () => hist[n - 1], push, fill };
+};
+
+/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1 */
+GW.waveStrip = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const el = document.createElement('span'); el.className = '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');
+ 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 + 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 */
+GW.scope = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const el = document.createElement('span'); el.className = 'scope';
+ el.innerHTML = '<span class="grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>';
+ host.appendChild(el);
+ const line = el.querySelector('polyline');
+ let vpp = 0;
+ function set(samples, v) {
+ vpp = v;
+ const n = samples.length;
+ line.setAttribute('points', samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + 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 */
+GW.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 = 'eq';
+ host.appendChild(el);
+ for (let b = 0; b < bands; b++) {
+ const band = document.createElement('span'); band.className = '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 = values[b], 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 ? 'clip' : k >= cells - 3 ? 'hot' : '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 */
+GW.crossNeedle = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 55;
+ const el = document.createElement('div'); el.className = 'crossm';
+ el.innerHTML = `<div class="face"><div class="arc"></div><div class="nA"></div><div class="nB"></div></div>
+ <div class="lbl"><span>FWD</span><span>RFL</span></div>`;
+ host.appendChild(el);
+ const face = el.querySelector('.face'), nA = el.querySelector('.nA'), nB = el.querySelector('.nB');
+ function set(nv) {
+ v = 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 */
+GW.thermometer = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 58;
+ const el = document.createElement('div'); el.className = 'thermo';
+ el.innerHTML = `<div class="scale"><span>90</span><span>60</span><span>30</span></div>
+ <div class="wrapcol"><div class="tube"><div class="fill"></div></div><div class="bulb"></div></div>`;
+ host.appendChild(el);
+ const fill = el.querySelector('.fill');
+ function set(nv) {
+ v = 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 */
+GW.bourdon = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let v = opts.value ?? 45;
+ const el = document.createElement('div'); el.className = '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="ndl"></div><div class="hub"></div><div class="cap">PSI</div>`;
+ host.appendChild(el);
+ const ndl = el.querySelector('.ndl');
+ function set(nv) {
+ v = 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;
+ push(v) appends, set(samples, current) replaces the trace (values 0..1) */
+GW.stripChart = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const n = opts.samples || 60;
+ const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
+ const el = document.createElement('span'); el.className = 'strip';
+ el.innerHTML = '<span class="rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="pen" style="top:31px"></span>';
+ host.appendChild(el);
+ const line = el.querySelector('polyline'), pen = el.querySelector('.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) {
+ 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 */
+GW.corrMeter = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ let p = opts.value ?? 58;
+ const el = document.createElement('div'); el.className = 'corr';
+ el.innerHTML = `<div class="face"><div class="arc"></div><div class="zero"></div><div class="ndl"></div></div>
+ <div class="lbl"><span>−1</span><span>0</span><span>+1</span></div>`;
+ host.appendChild(el);
+ const ndl = el.querySelector('.ndl');
+ function set(np) {
+ p = 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 */
+GW.battCells = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25;
+ let p = opts.value ?? 62;
+ const el = document.createElement('div'); el.className = 'batt';
+ el.innerHTML = '<div class="cells"></div><span class="nub"></span>';
+ host.appendChild(el);
+ const cc = el.querySelector('.cells');
+ for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'cell'; cc.appendChild(c); }
+ function set(np) {
+ p = np;
+ const lit = Math.round(p / 100 * cells);
+ for (let i = 0; i < cells; i++) cc.children[i].className = 'cell' + (i < lit ? ' on' : '') + ((p <= warnAt && i < lit) ? ' 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;
+ set(t) positions instantly (0..1.02); ballistics belong to the signal owner */
+GW.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';
+ const needle = svgEl(face, 'line', {
+ x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6,
+ transform: `rotate(${sweep(.35)},${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})` });
+ let t = opts.value ?? .35;
+ 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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 widget's own
+ animation, so it lives here with its reduced-motion gate */
+GW.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 widget'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 */
+GW.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 (widget-owned animation, reduced-motion gated) */
+GW.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, 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
+ (widget-owned clock, reduced-motion paints the full day once); click for fresh paper */
+GW.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 */
+GW.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 */
+GW.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 = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1;
+ 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);
+ draw();
+ return { el: s, get: () => [vF, vO], set };
+};
+
+/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */
+GW.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 = opts.temp ?? 72, vH = opts.humidity ?? 45;
+ 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);
+ draw();
+ return { el: s, get: () => [vT, vH], set };
+};
+
+/* ================= indicators & readouts ================= */
+
+/* 18 status lamps — one lamp per health state; click any lamp to cycle it */
+GW.statusLamps = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const CLS = ['lamp', 'lamp gold', 'lamp red', 'lamp off', 'lamp 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 */
+GW.badges = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const CLS = ['badge', 'badge red', 'badge ghost'];
+ const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]];
+ const vars = items.map(it => it[1]);
+ const wrap = document.createElement('span');
+ const els = items.map(([txt, v], i) => {
+ const b = document.createElement('span'); b.className = CLS[v]; 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] = ((v % CLS.length) + CLS.length) % CLS.length;
+ 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 */
+GW.tabularReadout = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const wrap = document.createElement('div'); wrap.style.textAlign = 'center';
+ wrap.innerHTML = '<div class="readout"></div><span class="u"></span>';
+ const out = wrap.querySelector('.readout');
+ wrap.querySelector('.u').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 */
+GW.engravedLabel = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const e = document.createElement('span'); e.className = 'engrave';
+ e.append(opts.label || 'outputs');
+ const c = document.createElement('span'); c.className = '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 */
+GW.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 = 'owell'; host.appendChild(w);
+ const add = s => {
+ const d = document.createElement('div'); d.className = 'ostep';
+ d.innerHTML = `<span class="lamp ${s[0]}"></span><span><b>${s[1]}</b><span class="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 */
+GW.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 = '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 */
+GW.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 = 'nixie';
+ for (let i = 0; i < digits; i++) {
+ const tu = document.createElement('span'); tu.className = '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('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 — windows flip to the next word with the mechanical clack;
+ the page drives next() on its own cadence; first paint is silent like the original */
+GW.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 cells = opts.cells || 3;
+ const f = document.createElement('span'); f.className = 'flap';
+ for (let i = 0; i < cells; i++) {
+ const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = '<b></b>'; f.appendChild(d);
+ }
+ host.appendChild(f);
+ let idx = 0;
+ const paint = () => {
+ const w = words[idx].padEnd(cells + 1, ' ');
+ [...f.children].forEach((dd, i) => {
+ const ch = w[i] || ' ', bel = dd.querySelector('b');
+ if (bel.textContent !== ch) {
+ bel.textContent = ch;
+ if (animate) { dd.classList.remove('flip'); void dd.offsetWidth; dd.classList.add('flip'); }
+ }
+ });
+ };
+ const set = i => { idx = ((i % words.length) + words.length) % words.length; paint(); onChange(idx, words[idx].trim()); };
+ f.addEventListener('click', () => set(idx + 1));
+ paint();
+ return { el: f, get: () => idx, set, next: () => set(idx + 1) };
+};
+
+/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick();
+ click adds a minute */
+GW.sevenSeg = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const sv = document.createElement('span'); sv.className = '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="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 */
+GW.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 = 'vfdm';
+ m.innerHTML = '<span class="txt"></span><span class="mesh"></span>';
+ const t = m.querySelector('.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 */
+GW.annunciator = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const CLS = ['acell', 'acell warn', 'acell fault'];
+ const NAMES = ['ok', 'warn', 'fault'];
+ 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 = 'annwrap';
+ const grid = document.createElement('div'); grid.className = 'annun'; wrap.appendChild(grid);
+ const bar = document.createElement('div'); bar.className = 'annbar';
+ const mc = document.createElement('span'); mc.className = 'mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc);
+ ['ACK', 'TEST', 'RESET'].forEach(a => {
+ const b = document.createElement('button'); b.className = '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[st]; c.textContent = label; grid.appendChild(c); return c;
+ });
+ let acked = false;
+ const active = () => grid.querySelectorAll('.warn,.fault').length;
+ const refresh = () => {
+ const n = active();
+ mc.classList.toggle('on', n > 0); mc.classList.toggle('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('.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 = 'acell'); acked = false; refresh(); }
+ else if (a === 'TEST') {
+ const prev = els.map(c => c.className);
+ els.forEach(c => c.className = 'acell fault');
+ mc.classList.add('on', '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[st]; if (st > 0) acked = false; refresh(); }
+ };
+};
+
+/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */
+GW.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 init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */
+ const wrap = document.createElement('span');
+ wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center';
+ init.forEach(st => {
+ const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : '');
+ if (st >= 0) j.style.setProperty('--jc', cols[st]);
+ j.addEventListener('click', () => {
+ if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; }
+ const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur);
+ if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); }
+ else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); }
+ });
+ wrap.appendChild(j);
+ });
+ host.appendChild(wrap);
+ onChange(null, 'click to cycle');
+ return { el: wrap };
+};
+
+/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */
+GW.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 = 'counter'; host.appendChild(ct);
+ for (let idx = 0; idx < wheels; idx++) {
+ const w = document.createElement('span'); w.className = 'cwheel' + (idx >= redFrom ? ' redw' : '');
+ const col = document.createElement('span'); col.className = '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('.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 */
+GW.analogClock = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const cl = document.createElement('span'); cl.className = 'clock';
+ for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); }
+ cl.insertAdjacentHTML('beforeend', '<span class="hh"></span><span class="mh"></span><span class="sh"></span><span class="pin"></span>');
+ host.appendChild(cl);
+ const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.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 */
+GW.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 = 'freqscale';
+ const band = document.createElement('span'); band.className = 'band'; band.textContent = unit; fs.appendChild(band);
+ marks.forEach(m => {
+ const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk);
+ const mk = document.createElement('span'); mk.className = '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 = '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 = '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 */
+GW.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 = 'patch';
+ const jacks = [];
+ for (let r = 0; r < rows; r++) {
+ const rowEl = document.createElement('div'); rowEl.className = 'row';
+ for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = '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('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('hot'); jacks[b].classList.add('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('sel'); return; }
+ if (pending === id) { pending = null; j.classList.remove('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('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) */
+GW.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 widget'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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 */
+GW.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 };
+};
+
+/* ---- widget CSS: injected once, grows as builders move in ---- */
+/* widget-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */
+const GW_CSS = `
+/* ---- 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(var(--glow-lo),.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 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}
+
+.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,var(--amber-grad-top),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;touch-action:none}
+.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,var(--amber-grad-bot),var(--gold))}
+.fader .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 */
+.vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none}
+.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,var(--amber-grad-bot),var(--gold))}
+.vfader .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 */
+.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)}
+.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(var(--glow-hi),.6)}
+
+/* needle gauge */
+.gauge{width:96px;cursor:ns-resize;touch-action:none}
+.gauge .dial{position:relative;height:48px;overflow:hidden}
+.gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%}
+.gauge .tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px}
+.gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(var(--glow-hi),.5);
+ transition:transform .12s cubic-bezier(.3,1.3,.5,1)}
+.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;cursor:pointer}
+.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}
+
+/* linear progress / fuel bar */
+.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none}
+.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),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%;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}
+.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;cursor:pointer}
+.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: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 */
+.engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer;
+ 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;cursor:pointer}
+
+/* output well (log step) */
+.owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer}
+.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}
+
+/* rotary selector */
+.rotsel{position:relative;width:118px;height:74px}
+.rotsel>.knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer}
+.rotsel .pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em}
+.rotsel .pos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+
+/* slide-rule tuner dial */
+.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)}
+.tuner .tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)}
+.tuner .tick.mn{height:6px;opacity:.5}
+.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px}
+.tuner .mk.on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))}
+.tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px}
+.tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
+.tuner .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 */
+.nixie{display:inline-flex;gap:5px;cursor:pointer}
+.nixie .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)}
+.nixie .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)}
+.nixie .tube.off b{color:#3a2a1c;text-shadow:none}
+.nixie .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 */
+.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}
+.rocker .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)}
+.rocker .top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)}
+.rocker .bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)}
+.rocker.on .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)}
+.rocker.on .bot{color:var(--steel)}
+.rocker:not(.on) .top{color:var(--steel)}
+.rocker:not(.on) .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 */
+.reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px}
+.reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative;
+ background:radial-gradient(circle at 45% 40%,#211d18,#0d0b09)}
+.reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310}
+.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}
+.reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)}
+.reel i:nth-child(2){transform:rotate(120deg)}.reel i:nth-child(3){transform:rotate(240deg)}
+.transport{display:flex;gap:5px}
+.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}
+.tbtn:hover{color:var(--gold);border-color:var(--gold)}
+.tbtn.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)}
+.tbtn.rec.on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+
+/* radio bank */
+.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)}
+.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)}
+.preset+.preset{margin-left:3px}
+.preset:hover{color:var(--silver)}
+.preset.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 */
+.dualknob{width:64px;height:64px;position:relative}
+.dualknob .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)}
+.dualknob .outer .tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px}
+.dualknob .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)}
+.dualknob .inner .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 */
+.encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none}
+.encoder .led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash);
+ left:50%;top:50%;margin:-2.5px}
+.encoder .led.on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)}
+.encoder .knob{width:40px;height:40px;cursor:ns-resize}
+.encoder .knob .ind{transform-origin:50% 15px;top:4px;height:12px}
+
+/* keyed mode switch */
+.keylock{position:relative;width:110px;height:70px}
+.keylock .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)}
+.keylock .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)}
+.keylock .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}
+.keylock .kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)}
+.keylock .kpos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)}
+
+/* crossfader */
+.xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none}
+.xfader .slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18}
+.xfader .detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7}
+.xfader .end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)}
+.xfader .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 */
+.thumbw{display:flex;align-items:center;gap:10px}
+.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)}
+.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))}
+.thumbw .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 */
+.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)}
+.dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42}
+.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}
+.dipsw.on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))}
+
+/* jog / shuttle */
+.jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none}
+.jog .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)}
+.jog .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)}
+.jog .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 */
+.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)}
+.scope .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}
+.scope .grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))}
+.scope svg{position:absolute;inset:0;width:100%;height:100%}
+.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 */
+.vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px}
+.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)}
+.vk .bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f}
+.vk.mon{color:var(--silver)}
+.vk.mon .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+.vk.mon.act .bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)}
+.vk.tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));
+ border-color:var(--gold-hi);font-weight:700}
+.vk.tlk .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)}
+
+/* spectrum / EQ */
+.eq{display:flex;align-items:flex-end;gap:3px;height:60px}
+.eq .band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%}
+.eq .band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3}
+.eq .band i.on{opacity:1;background:var(--pass)}
+.eq .band i.hot{opacity:1;background:var(--gold)}
+.eq .band i.clip{opacity:1;background:var(--fail)}
+
+/* crossed-needle */
+.crossm{width:120px}
+.crossm .face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none}
+.crossm .arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%}
+.crossm .nA{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)}
+.crossm .nB{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)}
+.crossm .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+
+/* thermometer */
+.thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none}
+.thermo .tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.thermo .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))}
+.thermo .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)}
+.thermo .scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)}
+.thermo .wrapcol{position:relative;padding-bottom:9px}
+
+/* bourdon */
+.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)}
+.bourdon svg{position:absolute;inset:0}
+.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)}
+.bourdon .hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)}
+.bourdon .cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)}
+
+/* strip-chart */
+.strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden;
+ background:#0c0e0f;border:1px solid #231f18}
+.strip .rule{position:absolute;inset:0;opacity:.4;
+ background-image:linear-gradient(90deg,rgba(150,147,133,.14) 1px,transparent 1px);background-size:16px 100%}
+.strip svg{position:absolute;inset:0;width:100%;height:100%}
+.strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4}
+.strip .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 */
+.corr{width:150px;cursor:pointer;touch-action:none}
+.corr .face{position:relative;height:44px;overflow:hidden}
+.corr .arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%}
+.corr .zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px}
+.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}
+.corr .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+
+/* battery */
+.batt{display:flex;align-items:center;cursor:pointer;touch-action:none}
+.batt .cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10}
+.batt .cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35}
+.batt .cell.on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)}
+.batt .cell.warn.on{background:linear-gradient(180deg,var(--fail),#a04a34)}
+.batt .nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0}
+
+/* split-flap */
+.flap{display:inline-flex;gap:4px;cursor:pointer}
+.flapd{width:30px;height:42px;border-radius:5px;position:relative;overflow:hidden;
+ background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5)}
+.flapd b{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)}
+.flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)}
+.flapd.flip b{animation:flipdrop .3s ease-in}
+@keyframes flipdrop{0%{transform:translateY(-42%) scaleY(.4);opacity:.3}100%{transform:none;opacity:1}}
+
+/* seven-segment */
+.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))}
+.seven .colon{align-self:center;display:flex;flex-direction:column;gap:9px}
+.seven .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 */
+.dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px}
+.dsky .lampcol{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px}
+.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}
+.dsky .sl.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)}
+.dsky .right{display:flex;flex-direction:column;gap:6px}
+.dsky .wins{display:flex;gap:8px}
+.dsky .win{display:flex;flex-direction:column;align-items:center;gap:2px}
+.dsky .win .wl{font-size:6px;letter-spacing:.14em;color:var(--steel)}
+.dsky .win .wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px}
+.dsky .win .wd .seg7{width:13px;height:24px}
+.dsky .win.hot .wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)}
+.dsky .pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
+.dsky .pad .key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em}
+
+/* VFD marquee */
+.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)}
+.vfdm .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)}
+.vfdm .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 */
+.annwrap{display:flex;flex-direction:column;gap:6px}
+.annbar{display:flex;gap:5px;align-items:center}
+.mc{font-size:7px;letter-spacing:.08em;padding:4px 8px;border-radius:3px;background:#141210;color:#5c574c;
+ border:1px solid #26221c;text-align:center}
+.mc.on{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream);font-weight:700;
+ box-shadow:0 0 8px rgba(203,107,77,.5)}
+.mc.on.fl{animation:pulse var(--pulse-rate) ease-in-out infinite}
+.annbar .key{padding:3px 7px;font-size:7.5px;border-radius:4px}
+.annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px}
+.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}
+.acell.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)}
+.acell.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 */
+.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)}
+.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}
+.jewel.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 */
+.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)}
+.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)}
+.cwheel .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)}
+.cwheel .col span{display:block;height:34px}
+.cwheel::before,.cwheel::after{content:"";position:absolute;left:0;right:0;height:9px;z-index:2;pointer-events:none}
+.cwheel::before{top:0;background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)}
+.cwheel::after{bottom:0;background:linear-gradient(0deg,rgba(0,0,0,.4),transparent)}
+.counter .redw{background:linear-gradient(180deg,#e7b46a,#cf9440)}
+
+/* analog clock */
+.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)}
+.clock .tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px}
+.clock .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}
+.clock .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}
+.clock .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)}
+.clock .pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)}
+
+/* frequency-dial scale */
+.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)}
+.freqscale .tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)}
+.freqscale .mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px}
+.freqscale .band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em}
+.freqscale .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 */
+.patch{padding:7px 9px;background:#141210;border:1px solid #2c261d;border-radius:5px;position:relative}
+.patch .row{display:flex;gap:9px}
+.patch .row+.row{margin-top:9px}
+.patch .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)}
+.patch .jack.hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)}
+.patch .jack.sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)}
+.patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%}
+.patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round}
+
+/* ===== reference-batch (R) widgets — 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)}}
+.reel.spin{animation:reelspin 2.6s linear infinite}
+`;
+function ensureCss() {
+ if (document.getElementById('gw-css') || !GW_CSS) return;
+ const st = document.createElement('style'); st.id = 'gw-css'; st.textContent = GW_CSS;
+ document.head.appendChild(st);
+}
+if (document.head) ensureCss();
+else document.addEventListener('DOMContentLoaded', ensureCss);
+
+Object.assign(GW, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
+window.GW = GW;
+})();
diff --git a/docs/specs/2026-07-02-bluetooth-panel-spec.org b/docs/specs/2026-07-02-bluetooth-panel-spec.org
new file mode 100644
index 0000000..f1b3ac1
--- /dev/null
+++ b/docs/specs/2026-07-02-bluetooth-panel-spec.org
@@ -0,0 +1,476 @@
+#+TITLE: Bluetooth Panel — CLI-Driven, Net-Panel Kin
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-02
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Bluetooth Panel — CLI-Driven, Net-Panel Kin
+:PROPERTIES:
+:ID: 8af6a76a-5665-4d20-9efd-ffdf7460c981
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Shipped through phase 3; build task DONE and manual tests filed.)
+
+* IMPLEMENTED Status
+:PROPERTIES:
+:ID: 1271a845-4463-4831-9902-990eda6b2265
+:END:
+- [2026-07-02 Thu] IMPLEMENTED — all five phases shipped the same day
+ (dotfiles eb2230f / 76b2c05 / e372de3 / 2a026b1; archsetup d8d8c53):
+ engine, panel, bar module + blueman retirement, bt-priv + package swap,
+ install wiring proven by VM assertions. 43 dotfiles suites green, both
+ AT-SPI smokes green, panels verified live; the phase 4-5 VM assertions
+ run on the next VM pass.
+- [2026-07-02 Thu] DOING — spec-response decomposed the five phases into
+ build sub-tasks under the todo.org parent (:SPEC_ID: bound); build
+ started same day per Craig ("4 first, then 1" — bugs then bluetooth).
+- [2026-07-02 Thu] READY — spec-review passed the gate: all four
+ decisions resolved, phases decomposable, CLI verbs verified against
+ bluez 5.86. Two non-blocking findings recorded and dispositioned in
+ the same pass (donor-pattern answers).
+- [2026-07-02 Thu] DRAFT — initial spec from Craig's request: a bluetooth
+ module driving a CLI underneath, consistent with the net panel, minimal
+ interface, full functionality, diagnostics section, visual mockups.
+
+* Metadata
+
+| Field | Value |
+|--------+---------------------------------------------------------------------------------|
+| Status | implemented |
+|--------+---------------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|--------+---------------------------------------------------------------------------------|
+| Repo | dotfiles (bt module); archsetup (packages, sudoers, keybind defaults) |
+|--------+---------------------------------------------------------------------------------|
+| Kin | net panel (architecture donor), desktop-settings panel (same donor, shared css) |
+|--------+---------------------------------------------------------------------------------|
+
+* Problem
+
+Bluetooth on both daily drivers runs through blueman: a tray applet plus a
+GTK3 manager window (Super+Shift+B). It's the odd one out on the desktop —
+a foreign visual style next to the dupre-themed panels, a tray icon where
+every other indicator is a first-class waybar module, and no diagnostics
+story at all. When the BT mouse fails to reconnect at boot (a recurring
+gotcha — touchpad-auto exists because of it) or headphones pair but route
+no audio, the fix is a terminal séance: bluetoothctl, rfkill, systemctl,
+wpctl, in whatever order folklore suggests.
+
+The net panel proved the shape that fixes this: a minimal layer-shell
+popup over a GTK-free engine that drives a CLI, with a diagnostics tab
+that names the failure and offers the repair. Bluetooth is the same
+problem with a smaller surface: one adapter, a handful of devices, a
+short list of well-known failure modes.
+
+* Goals
+
+1. Visibility: adapter power state and every known device with live state
+ (connected, battery, signal) in one glance — panel and bar module agree.
+2. Control: power, scan, pair, connect, disconnect, forget — full
+ functionality from the panel, zero terminals (the net panel's V2
+ contract).
+3. Diagnostics: a doctor that walks the known failure chain (adapter →
+ rfkill → service → power → device → audio profile), names the broken
+ link in evidence rows, and offers tiered repairs.
+4. Consistency: same stack, same window shape, same interaction grammar,
+ same palette as the net panel. A user who knows one panel knows both.
+
+Audio-profile switching is in scope for v1 (Craig, 2026-07-02 — "bitten
+by this too many times to count"): the doctor's audio-profile step
+carries a one-click repair, not just a diagnosis, and connected audio
+devices surface their active profile (details in the doctor chain below).
+
+Non-goals (this iteration): OBEX file transfer, multi-adapter support
+(both machines have one controller), BLE sensor/GATT browsing.
+
+* Design sketch
+
+** Architecture — the net panel's stack, verbatim
+
+- GTK4 + gtk4-layer-shell, Blueprint .blp compiled to committed .ui
+ (=make ui=), PyGObject at runtime.
+- Humble-object split: GTK-free =PanelModel= presenter (unit-tested like
+ net's), thin composite-widget pages, =bg(work, done)= worker-thread
+ helper for every slow call.
+- Engine: a new =bt= package in dotfiles (=bluetooth/src/bt/=, sibling of
+ =net/=), CLI entry =bt= with =bt status= / =bt panel= / =bt doctor= —
+ the same cmd/cli layout as net.
+- Layer-shell OVERLAY popup anchored TOP+RIGHT, 380x520, Esc closes,
+ focus-out auto-hides, single-instance toggle via a =bt-panel= wrapper.
+ Dupre palette css shared with the net panel (the factored css asset the
+ desktop-settings spec calls for — three consumers now, so the factoring
+ happens in this project's phase 1 if settings hasn't landed it).
+- Testing: engine TDD with fake binaries on a temp PATH (fake-bluetoothctl,
+ fake-rfkill, fake-systemctl, fake-wpctl); PanelModel unit suite; one
+ gated AT-SPI smoke (=make test-panel= pattern).
+
+** CLI backing — bluetoothctl one-shot verbs
+
+bluez 5.86 (installed) supports everything non-interactive:
+
+- Adapter: =bluetoothctl show= (powered, discoverable, pairable),
+ =bluetoothctl power on|off=.
+- Device lists: =bluetoothctl devices Paired|Connected|Trusted= — the
+ Paired view is a merge of Paired + Connected states; =bluetoothctl info
+ <mac>= per row fills caption detail (battery percentage rides bluez's
+ built-in Battery1 profile and appears in info output; RSSI appears
+ during discovery).
+- Scan: =bluetoothctl --timeout N scan on= (bounded discovery burst),
+ then =devices= diffed against Paired for the Nearby list. The panel
+ scans in 8s bursts with a live "Scanning…" state rather than an
+ unbounded scan.
+- Connect/disconnect/forget: =bluetoothctl connect|disconnect|remove <mac>=.
+- Pairing: the one interactive corner. =bluetoothctl pair <mac>= can demand
+ a passkey confirmation. The engine drives bluetoothctl's line protocol
+ over a pty with a bounded state machine (expect "Confirm passkey",
+ reply yes/no); a passkey prompt surfaces as a panel dialog showing the
+ six digits, mirroring the net panel's password dialog. NoInputNoOutput
+ devices (mice, most headphones) sail through without the dialog.
+- rfkill: the user is in the =rfkill= group, so block/unblock is
+ unprivileged (=rfkill unblock bluetooth=).
+- Privileged path: exactly one verb needs root — =systemctl restart
+ bluetooth= — so =bt-priv= is a one-verb closed helper with its own
+ NOPASSWD sudoers rule placed by archsetup, cloning net-priv's
+ regex-validated pattern rather than widening net-priv's scope.
+
+** Panel anatomy
+
+Two tabs. Devices is the panel; Diagnostics is the escape hatch.
+
+Devices tab, Paired sub-view (the default — daily use is reconnecting
+known devices, not discovering new ones):
+
+#+begin_example
+╭──────────────────────────────────────────────╮
+│ [ Devices ] [ Diagnostics ] │ ← top switcher
+│ │
+│ Bluetooth ●──○ hci0 on │ ← adapter row: power switch
+│ ──────────────────────────────────────────── │
+│ [ Paired ] [ Nearby ] │ ← sub-view switcher
+│ ┌──────────────────────────────────────────┐ │
+│ │ 󰍽 MX Master 3 │ │
+│ │ Connected · battery 80% │ │
+│ │ 󰋋 WH-1000XM4 │ │
+│ │ Paired, not connected │ │
+│ │ 󰌌 K380 Keyboard │ │
+│ │ Paired, not connected │ │
+│ │ │ │
+│ └──────────────────────────────────────────┘ │
+│ [ Disconnect ] [ Forget ] │ ← acts on selected row
+╰──────────────────────────────────────────────╯
+#+end_example
+
+The primary button is one control with a state-following label:
+"Connect" when the selection is disconnected (suggested-action styling),
+"Disconnect" when connected. Row-activate (Enter / double-click)
+connects — never disconnects — matching the net panel's asymmetry.
+Captions carry the human state line; the MAC lives in the row tooltip,
+not the visible caption.
+
+Devices tab, Nearby sub-view:
+
+#+begin_example
+╭──────────────────────────────────────────────╮
+│ [ Devices ] [ Diagnostics ] │
+│ │
+│ Bluetooth ●──○ hci0 on │
+│ ──────────────────────────────────────────── │
+│ [ Paired ] [ Nearby ] │
+│ ┌──────────────────────────────────────────┐ │
+│ │ Scanning… (6s) │ │ ← overlay state label
+│ │ 󰋋 JBL Flip 6 −58 dBm │ │
+│ │ 󰄜 Pixel 9 −71 dBm │ │
+│ │ 󰂱 (unnamed) 74:A5:… −83 dBm │ │
+│ └──────────────────────────────────────────┘ │
+│ [ Pair ] [ Rescan ] [ Discoverable ⊙ ] │
+╰──────────────────────────────────────────────╯
+#+end_example
+
+Pair does the whole intended thing — pair, then trust, then connect —
+because pairing a device means "use it now and reconnect on its own
+later" (decision below). Discoverable is a toggle for the inbound case
+(pairing a phone TO the laptop), off by default, auto-off with bluez's
+discoverable-timeout. Rows sort by RSSI, strongest first; named devices
+above unnamed ones.
+
+Diagnostics tab (mirrors the net panel's shape: one big verb + streaming
+evidence rows + tiered repairs behind confirmation):
+
+#+begin_example
+╭──────────────────────────────────────────────╮
+│ [ Devices ] [ Diagnostics ] │
+│ │
+│ [ Get Bluetooth Working ] [ Advanced ▸]│
+│ ┌──────────────────────────────────────────┐ │
+│ │ ✓ Adapter present (hci0) │ │
+│ │ ✓ Not blocked (rfkill clear) │ │
+│ │ ✓ bluetooth.service active │ │
+│ │ ✓ Adapter powered │ │
+│ │ ✗ MX Master 3: paired but unreachable │ │
+│ │ … Re-pair suggested — see below │ │
+│ │ │ │
+│ │ Fix: [ Reconnect ] [ Re-pair device ] │ │
+│ └──────────────────────────────────────────┘ │
+│ power-cycle · restart service · unblock │ ← tiered repairs (confirm)
+╰──────────────────────────────────────────────╯
+#+end_example
+
+The doctor chain, in order, each an evidence row:
+
+1. Adapter present — =bluetoothctl list= / rfkill has an hci entry.
+ Absent → hardware/driver verdict, no repair offered.
+2. rfkill state — soft-blocked names the likely cause when the
+ airplane-mode state file says airplane is on ("Blocked by airplane
+ mode — turn airplane mode off"), otherwise offers Unblock (no root
+ needed, rfkill group).
+3. bluetooth.service — inactive/failed → offer restart (the one bt-priv
+ verb), evidence quotes the last journal line.
+4. Adapter powered — off → offer power on (and note if a boot-time
+ policy keeps turning it off).
+5. Per-device reachability — paired-but-connect-fails distinguishes
+ "device off/out of range" (RSSI absent in a scan burst) from "bond
+ corrupt" (connect error string), and only the latter suggests the
+ re-pair repair (remove + pair + trust + connect, confirmed first —
+ it's the destructive tier).
+6. Audio profile (audio devices only) — device connected but no wpctl
+ sink/source, or the card stuck in HSP/HFP when A2DP is expected:
+ evidence names the active profile and offers the repair inline —
+ "Switch to A2DP" drives =wpctl set-profile <card> <index>= (profile
+ inventory from =pw-dump= — ground truth 2026-07-02: wpctl can't
+ enumerate a card's profiles, and the card's =bluez5.profile= prop
+ reads "off" mid-stream; the card's Profile param and the sink node's
+ =api.bluez5.profile= are authoritative), verifies the sink came back
+ in the expected profile, and reports fixed or no-change. In v1 per
+ Craig (2026-07-02): this failure mode has bitten repeatedly, so it
+ gets the one-click fix, not just a diagnosis. Connected audio-device
+ row captions also show the profile when it's the degraded one
+ ("Connected · mic mode (HSP)") so the state is visible before the
+ doctor runs.
+
+Repairs confirm with the net panel's future-tense scope copy ("This will
+restart the Bluetooth service. Connected devices will drop and
+reconnect."), run on the worker thread, verify after (re-read state,
+report "fixed" or "no change"), and never chain silently.
+
+** Bar module
+
+=custom/bluetooth= replacing the blueman-applet tray icon: the panel's
+glanceable layer, one glyph, state-following like =custom/net=:
+
+#+begin_example
+ 󰂲 off / blocked (dim; red slash variant when rfkill-blocked)
+ 󰂯 on, nothing connected (dim)
+ 󰂱 connected (white; tooltip lists devices + battery)
+#+end_example
+
+Tooltip carries device names, battery percentages, and the keybind hints
+(the module-tooltip convention shipped 2026-07-02). Click opens the
+panel (=bt-panel= toggle wrapper); the existing Super+Shift+B bind moves
+from blueman-manager to =bt panel=. Low-battery on a connected device
+(<15%) adds a red percentage to the glyph text — the mouse dying
+mid-meeting is the one state worth surfacing unprompted.
+
+** UX conformance notes
+
+Named against the heuristics the panel family follows (Nielsen's ten,
+plus the rulesets patterns catalog):
+
+- Visibility of status: live captions, scan countdown, elapsed ticker on
+ long ops, verify-after-repair rows.
+- Match to the real world: device-kind glyphs + plain state lines; MACs
+ demoted to tooltips; "Forget" not "Remove bond".
+- User control: Esc closes, Rescan is idempotent, scan bursts are
+ bounded, repairs confirm, running ops show a Stop where stoppable.
+- Consistency: interaction grammar is the net panel's — same switcher
+ layout, same primary-button contract, same confirm copy shape.
+- Error prevention: Forget and Re-pair confirm; power-off while devices
+ are connected states the consequence in the confirm body.
+- Recognition over recall: every action is a visible button; no context
+ menus, no hidden gestures (transient-state-buttons pattern).
+- Minimalism: two tabs, one primary action per view, detail behind
+ tooltips and the Advanced reveal.
+- Help users recover: the doctor's evidence rows name the broken link
+ and carry the repair inline (default-most-common-friction-proportional:
+ the likely fix is one click, the destructive one is confirmed).
+
+Tension found with the net panel while writing this (filed as todo.org
+tasks per Craig's instruction, 2026-07-02): transient error toasts
+auto-dismiss in 4s, and the V2 spec's keyboard-navigation claims
+(tab-between-sections, arrow rows, type-to-filter) aren't verifiably
+implemented. Both filed against the net panel rather than cloned here;
+this panel adopts whatever resolution those tasks land on.
+
+* Decisions (Craig) [4/4]
+
+** DONE Pair implies trust + connect?
+CLOSED: [2026-07-02 Thu]
+Decided (Craig, 2026-07-02): yes — one Pair verb does pair → trust →
+connect. A device that shouldn't auto-reconnect gets untrusted later; a
+per-device auto-reconnect toggle can ride a later pass.
+
+** DONE Retire blueman entirely?
+CLOSED: [2026-07-02 Thu]
+Decided (Craig, 2026-07-02): drop it outright, no bake-in period — the
+package leaves archsetup and both machines once phase 2 lands,
+bluetoothctl stays as the terminal fallback. Craig's framing: any issue
+after retirement is a signal the doctor needs another check or the panel
+has a real bug, and it gets fixed there rather than papered over by
+keeping blueman around.
+
+** DONE Battery in the row caption or tooltip only?
+CLOSED: [2026-07-02 Thu]
+Approved (Craig, 2026-07-02): caption when the device reports it
+("Connected · battery 80%"), tooltip otherwise.
+
+** DONE Scan burst length and auto-rescan?
+CLOSED: [2026-07-02 Thu]
+Approved (Craig, 2026-07-02): 8s bursts, no auto-repeat — Rescan stays
+explicit, matching the net panel's Available view.
+
+* Review findings [2/2]
+
+** DONE Empty-state and no-adapter presentation copy undefined :nonblocking:
+CLOSED: [2026-07-02 Thu]
+The mockups show populated lists; the spec didn't say what an empty Paired
+list, an empty post-scan Nearby list, or a machine with no adapter shows
+in the panel and on the bar glyph. Dispositioned same pass: clone the
+donor — the net panel's in-box overlay message pattern (=show_loading= /
+placeholder label) carries the copy. Paired empty: "No paired devices —
+switch to Nearby to pair one." Nearby post-scan empty: "Nothing found —
+Rescan, or make the device discoverable." No adapter: adapter row reads
+"No Bluetooth adapter", Devices controls disable, Diagnostics stays
+usable (the doctor's step 1 names the hardware/driver verdict); bar
+glyph shows the off/blocked state. Non-blocking; recorded so the
+implementer doesn't invent copy mid-build.
+
+** DONE Logging/redaction carry-over unstated :nonblocking:
+CLOSED: [2026-07-02 Thu]
+The spec says "the net panel's stack, verbatim" but didn't name whether
+the engine adopts net's =eventlog= (structured op log) and =redact=
+(sensitive-field scrubbing) modules. Dispositioned same pass: yes, both
+carry over — every mutating verb (pair/connect/forget/repair) logs an
+eventlog entry, and MACs are the redaction surface (device names stay,
+MACs redact in copied reports, mirroring net's report redaction).
+Non-blocking; it's the donor default made explicit.
+
+* Implementation phases
+
+1. Engine =bt= package: adapter/device/scan probes over fake-bluetoothctl,
+ status + doctor chain (rfkill, service, powered, reachability, audio
+ profile probe + A2DP switch repair over fake-wpctl) — pure TDD, no
+ GTK. =bt status= and =bt doctor= work from a terminal. Shared dupre
+ css factored to the common asset if the settings panel hasn't already
+ done it.
+2. Panel: PanelModel presenter + Blueprint pages (Devices with
+ Paired/Nearby, Diagnostics), worker-thread wiring, pairing-dialog
+ state machine, bt-panel toggle wrapper, AT-SPI smoke. Super+Shift+B
+ rebind.
+3. Bar module =custom/bluetooth= (glyph states, tooltip, low-battery
+ surface, refresh signal), waybar config + suite coverage; blueman
+ retirement per the decision.
+4. bt-priv one-verb helper + sudoers rule in archsetup; package-list
+ swap (blueman out per decision, bluez-utils stays); VM test
+ assertions.
+5. archsetup keybind/config defaults so a fresh install lands the panel
+ wired (waybar module present, bind set, sudoers placed).
+
+* Review and iteration history
+
+** 2026-07-02 Thu @ 15:19:58 -0400 — Claude Code (archsetup) — phase 5 builder, spec closed
+- *What changed or was recommended:* Phase 5 shipped and the spec flipped
+ to IMPLEMENTED. No new install code was needed — the waybar module, the
+ =Super+Shift+B= bind, and the shared panel css all ride the dotfiles
+ hyprland tier that a fresh install already clones and stows, and sudoers
+ is covered by the blanket grant. The phase's substance is proof:
+ =test_desktop.py= gained hyprland-gated assertions for the four stowed
+ bt bins, the =custom/bluetooth= waybar entry, the =bt-panel= keybind,
+ and the stowed =panel.css=.
+- *Why:* Final phase of the DOING decomposition; with it the todo parent
+ closed and the lifecycle keyword flipped with a history line.
+- *Artifacts:* archsetup =scripts/testing/tests/test_desktop.py=; todo.org
+ parent DONE + dated phase 5 / test-surface entries; this spec's Status
+ heading.
+
+** 2026-07-02 Thu @ 15:16:51 -0400 — Claude Code (archsetup) — phase 4 builder
+- *What changed or was recommended:* Phase 4 shipped. Dotfiles =2a026b1=:
+ the stowed =bt-priv= shim (one verb, verified against the fake-systemctl)
+ and the sxhkd =Super+Shift+B= bind repointed from blueman-manager to
+ =st -e bluetoothctl= (terminal fallback per the retirement decision — the
+ panel is Wayland-only). archsetup: blueman dropped from the
+ =desktop_environment= package loop; VM assertions added (bluez/bluez-utils
+ present, blueman absent). blueman also removed live from velox.
+- *Why:* Build order per the DOING decomposition. The spec's "sudoers rule"
+ item resolved as net-priv's did: archsetup already grants the primary
+ user blanket =NOPASSWD: ALL= (archsetup:1089), so a narrow bt-priv rule
+ would be dead config — no new sudoers needed, and phase 5's "sudoers
+ placed" is satisfied by the existing grant.
+- *Artifacts:* dotfiles =hyprland/.local/bin/bt-priv=,
+ =common/.config/sxhkd/sxhkdrc=; archsetup =archsetup= (bluetooth loop),
+ =scripts/testing/tests/test_packages.py=; dated phase 4 entry under the
+ todo.org parent.
+
+** 2026-07-02 Thu @ 15:06:00 -0400 — Claude Code (archsetup) — phase 3 builder
+- *What changed or was recommended:* Phase 3 shipped (dotfiles =e372de3=):
+ the =custom/bluetooth= bar module (state-following glyph, low-battery red
+ percentage, device+battery tooltip with the keybind hint, signal 10 with
+ the panel poking it after each reload) and the blueman retirement from the
+ Hyprland session (exec-once + windowrules removed, applet killed live).
+ The phase 2 deferred items also closed this pass: both AT-SPI smokes green
+ (the bt smoke's primary-button assertion fixed for the state-following
+ label, =c1a8219=), both panels eyeballed correct in dupre, and the
+ net-panel keyboard claims verified live (archsetup =e80df2b= — false
+ claims struck from the net spec).
+- *Why:* Build order per the DOING decomposition; the Zoom meeting ended,
+ unblocking the visual work. Phases 4-5 (bt-priv/sudoers/packages, install
+ defaults — archsetup side) remain.
+- *Artifacts:* dotfiles =bluetooth/src/bt/indicator.py=, =waybar-bt=,
+ waybar config + three css files; dated phase 3 entry under the todo.org
+ parent.
+
+** 2026-07-02 Thu @ 14:15:27 -0400 — Claude Code (archsetup) — phase 2 builder
+- *What changed or was recommended:* Phase 2 shipped (dotfiles =76b2c05=):
+ the GTK panel — PanelModel/viewmodel presenter pair (69 tests), Blueprint
+ pages, pairing pty state machine with default-deny passkey confirms,
+ manage.py op envelopes shared by CLI and panel (power + discoverable verbs
+ added), =bt-panel= toggle, Super+Shift+B rebind. The shared dupre css
+ factoring landed as planned: net's inline =_CSS= became
+ =themes/dupre/panel.css= with =dupre-*= classes, both panels consume it.
+ 43 suites green. The AT-SPI smoke (=make test-panel-bt=) is written but
+ not yet run live — a Zoom meeting occupied the compositor; it runs when
+ the meeting ends, along with a visual check of both panels.
+- *Why:* Build order per the DOING decomposition; phases 3-5 (bar module,
+ bt-priv/sudoers, install defaults) remain.
+- *Artifacts:* dotfiles =bluetooth/src/bt/{panel,viewmodel,pairing,manage,
+ gui,pages}.py=, =ui/*.blp=, =tests/bt/test_btpanel.py=, the panel smoke;
+ dated phase 2 entry under the todo.org parent.
+
+** 2026-07-02 Thu @ 13:31:00 -0400 — Claude Code (archsetup) — phase 1 builder
+- *What changed or was recommended:* Phase 1 shipped (dotfiles =eb2230f=):
+ the =bt= engine package, 101 tests over fakes, live-verified read-only
+ on velox. Two spec corrections from ground truth: profile inventory
+ comes from =pw-dump= (wpctl can't enumerate profiles), and the active
+ profile reads from the card's Profile param / sink's
+ =api.bluez5.profile= (the card's =bluez5.profile= prop is unreliable).
+ The shared-css factoring moved into phase 2 — net's css is an inline
+ string in its =gui.py=, so extracting it belongs with the first second
+ consumer rather than as a standalone poke at the working net panel.
+- *Why:* Build order per the DOING decomposition; corrections keep the
+ spec honest for the phase 2 implementer.
+- *Artifacts:* dotfiles =bluetooth/src/bt/=, =tests/bt/=, the stowed
+ =bt= shim; dated phase 1 entry under the todo.org parent.
+
+** 2026-07-02 Thu @ 13:10:00 -0400 — Claude Code (archsetup) — reviewer + responder
+- *What changed or was recommended:* Ran the spec-review gate: passed.
+ All four decisions were already DONE (cookie added to the heading);
+ the five phases are each a clean single-session stop; CLI verbs are
+ verified against installed bluez 5.86. Two non-blocking findings
+ recorded and dispositioned in the same fused pass (empty-state /
+ no-adapter copy, eventlog + redaction carry-over) — both resolve to
+ "clone the net-panel donor," now stated explicitly. Flipped DRAFT →
+ READY → DOING and decomposed the phases into build sub-tasks under the
+ todo.org parent with :SPEC_ID: bound.
+- *Why:* Craig queued the build ("4 first, then 1", 2026-07-02) after
+ resolving all decisions the same morning; the gate held nothing back,
+ so review and response fused to keep the speedrun moving.
+- *Artifacts:* Findings in =* Review findings [2/2]= above; build parent
+ in todo.org ("Bluetooth panel + bar module"); net-panel toast fix the
+ UX-conformance note references landed as dotfiles =0f017d4=.
diff --git a/docs/specs/2026-07-02-desktop-settings-panel-spec.org b/docs/specs/2026-07-02-desktop-settings-panel-spec.org
new file mode 100644
index 0000000..50853f3
--- /dev/null
+++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org
@@ -0,0 +1,161 @@
+#+TITLE: Desktop-Settings Dropdown Panel
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-02
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Desktop-Settings Dropdown Panel
+:PROPERTIES:
+:ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed)
+
+* DRAFT Status
+:PROPERTIES:
+:ID: fb7eec22-a214-4568-82c4-903612f4832f
+:END:
+- [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
+ shipped (lamps, engraved sections, console keys, machined plate), not just
+ the raw palette; add the audio panel as a sibling in the panel family;
+ cross-reference the shared faceplate CSS. Toggle-consolidation scope (the
+ "few other things") still open — see the Decisions section.
+- [2026-07-02 Thu] DRAFT — initial spec from the todo.org task "Desktop-settings
+ dropdown panel" (2026-06-24 review), updated for the Blueprint/GTK4 pipeline
+ the net panel stood up 2026-07-01.
+
+* Metadata
+
+| Field | Value |
+|--------+----------------------------------------------|
+| Status | draft |
+|--------+----------------------------------------------|
+| Owner | Craig Jennings |
+|--------+----------------------------------------------|
+| Repo | dotfiles |
+|--------+----------------------------------------------|
+| Kin | net panel (architecture donor), theme studio |
+| | audio panel (sibling), bt panel (aesthetic) |
+|--------+----------------------------------------------|
+
+* Problem
+
+Desktop toggles are scattered: dim, caffeine/idle, touchpad/mouse, airplane
+mode each own a bar module and a keybind; brightness and keyboard-backlight
+have keybinds but no visible control or level readout. The bar is running out
+of glanceable width (hence the collapse arrows), and sliders can't live in
+waybar at all. One settings dropdown — a gear glyph opening a small panel —
+gathers them.
+
+* Goals
+
+1. One panel with every desktop toggle + slider: auto-dim, idle/caffeine,
+ touchpad, mouse, airplane (laptop-only), screen brightness, keyboard
+ backlight.
+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.
+3. Every control reflects live state and verifies its action took (the net
+ panel's verify-everything contract).
+4. Bar stays the quick layer: which standalone indicators survive is a
+ decision below.
+
+* Design sketch
+
+** Architecture — clone the net panel's proven stack
+
+- GTK4 + gtk4-layer-shell, Blueprint .blp sources compiled to committed .ui
+ (=make ui=; dev-only build dependency, fresh clones run without the
+ compiler).
+- Humble-object split: a GTK-free PanelModel presenter (unit-tested to 100%
+ like the net PanelModel) + thin composite-widget pages. Backing actions in
+ a GTK-free settings.py that shells out to brightnessctl / hyprctl / the
+ existing toggle scripts, TDD'd with fake binaries like every dotfiles
+ suite.
+- One gated AT-SPI smoke (the run-panel-smoke.sh pattern), no bespoke
+ headless widget suite.
+- Instrument-console faceplate aesthetic, consistent with net + bt + audio:
+ the machined gradient plate, glowing status lamps, engraved section labels,
+ physical console keys for the toggles, and (where a level applies) needle
+ gauges. Load the shared instrument-console palette/faceplate CSS asset all
+ the family panels use — factor it once, don't duplicate (feeds the
+ theme-studio task later).
+
+** 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 |
+|--------------------+----------------------------------------------|
+
+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.
+
+** Open/close behavior
+
+Gear glyph module on the bar right cluster; click toggles the panel
+(layer-shell anchored under the bar, right-aligned). Focus-out auto-hide +
+Close button, matching the net panel. Keybind: Super+Shift+G (decision B).
+
+* Decisions (Craig)
+
+** DONE Which standalone bar indicators collapse into the panel?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): touchpad and caffeine stay on the bar (glanceable state); auto-dim and airplane move into the panel (panel-only, freeing bar width). The airplane Super+Shift+A toggle keybind stays as the quick lane — only its bar indicator collapses in.
+
+** DONE Keybind for the panel?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): Super+Shift+G (gear), for parity with the other panels' fast path.
+
+** DONE Where does the code live?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): dotfiles =settings/= sibling to =net/= (same src-layout, tests in tests/settings/), sharing the palette css. The net panel is the architecture donor; the old in-tree pocketbook-style note is out.
+
+** DONE Slider granularity and floor
+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.
+
+*** 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.
+
+* Implementation phases
+
+1. settings.py backings (brightness get/set, kbd backlight, toggle
+ state readers) — pure engine, TDD with fake brightnessctl/hyprctl.
+2. PanelModel presenter (rows, conditional visibility, verify-after-apply
+ semantics) — unit-tested, no GTK.
+3. Blueprint UI + gear bar module + open/close wiring; palette css factored
+ to a shared asset; AT-SPI smoke.
+4. Bar-module consolidation per decision A: drop the dim and airplane bar
+ modules (now panel-only), keep touchpad and caffeine on the bar, wire the
+ refresh signals so bar and panel agree, and bind Super+Shift+G.
diff --git a/docs/specs/2026-07-02-file-manager-swallow-spec.org b/docs/specs/2026-07-02-file-manager-swallow-spec.org
new file mode 100644
index 0000000..b898f11
--- /dev/null
+++ b/docs/specs/2026-07-02-file-manager-swallow-spec.org
@@ -0,0 +1,147 @@
+#+TITLE: File-Manager Swallow Pattern
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-02
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* CANCELLED File-Manager Swallow Pattern
+:PROPERTIES:
+:ID: 179a1cd2-7a02-4c44-a09d-685c5a154895
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to CANCELLED (reason: Native swallow ruled out by test; reassigned to .emacs.d dirvish handling.)
+
+* CANCELLED Status
+:PROPERTIES:
+:ID: d92e0074-f594-4e83-81a0-faf282e15ed0
+:END:
+- [2026-07-02 Thu] CANCELLED — targeted the wrong file manager. Craig's ask
+ is about the dirvish popup (Super+F, an Emacs frame), not nautilus (the
+ Super+Shift+F bind that misled the grounding). For dirvish the right
+ design is elisp-side and strictly better: Emacs is the launcher, so it
+ can spawn the handler directly (=start-process=), hide the popup frame,
+ and restore it from a process sentinel — exact exit tracking plus a
+ failure notify, no window-event heuristics. Reassigned to .emacs.d via
+ its inbox (2026-07-02-2231-from-archsetup-dirvish-popup-swallow-handoff).
+ The gio double-fork finding below stands for any gio-launching file
+ manager; the daemon design is kept for reference only.
+- [2026-07-02 Thu] DRAFT — initial spec from Craig's roam capture ("when the
+ file manager launches another app, it should hide and return when that
+ process ends"). Feasibility ground truth sampled live on velox same
+ evening: Hyprland's native swallow cannot work here (see Problem), so the
+ design is an event-listener daemon.
+
+* Metadata
+
+| Field | Value |
+|--------+----------------------------------------------------|
+| Status | cancelled |
+|--------+----------------------------------------------------|
+| Owner | Craig Jennings |
+|--------+----------------------------------------------------|
+| Repo | dotfiles (daemon + config); archsetup (none) |
+|--------+----------------------------------------------------|
+| Kin | touchpad-auto (socket-listener donor), |
+| | hypr-refocus-scratchpad (event-daemon sibling) |
+|--------+----------------------------------------------------|
+
+* Problem
+
+Opening a file from nautilus (Super+Shift+F, tiled, class
+=org.gnome.Nautilus=) spawns a viewer window while nautilus stays in the
+layout. The wanted behavior is the swallow pattern: the file manager hides
+while the app it launched runs, and returns when that app exits. Today
+there's no signal connecting the two windows — the viewer lands wherever
+the layout puts it, nautilus lingers, and quitting is manual.
+
+*Hyprland's native swallow is ruled out — measured, not assumed.*
+=misc:enable_swallow= + =swallow_regex= would be exactly this feature in two
+config lines, but it matches by walking the new window's PID ancestry to
+the swallow candidate's PID. Nautilus launches handlers through GLib
+(=g_app_info_launch_default_for_uri=), and that path orphans the child:
+reproduced live on velox 2026-07-02 with a python-gi launcher — feh came up
+with PPID 1 (reparented to init) while the launcher was still alive. The
+ancestry walk hits init before it hits nautilus, every time, for every
+handler. Any design that depends on PID parentage is dead on arrival; the
+signal has to come from window events instead.
+
+Ground truth on handlers (velox, 2026-07-02): pdf → zathura, image → feh,
+video → mpv, text/code → emacsclient (window belongs to the emacs daemon).
+Side-note, out of scope here: feh is X11 — an XWayland viewer on a
+no-XWayland-by-preference setup; a default-handler review is its own task.
+
+* Goals
+
+- Double-click a file in nautilus → the viewer takes its place; nautilus is
+ gone (special workspace, not killed — state and tabs survive).
+- Quit the viewer → nautilus returns and has focus.
+- Nothing else changes: terminals, scratchpads, and every other window keep
+ their current behavior.
+- Config-driven, testable logic, one small daemon — the touchpad-auto shape.
+
+* Design sketch
+
+A =hypr-swallow= daemon (dotfiles, =hyprland/.local/bin/=) listening on the
+Hyprland IPC event socket (socket2), same as =touchpad-auto=:
+
+- Track the active window (=activewindow>>= events carry class + title;
+ =activewindowv2>>= carries the address).
+- On =openwindow>>= (address, workspace, class, title) while the active
+ window's class is a configured *parent* (nautilus): dispatch
+ =movetoworkspacesilent special:swallow,address:0x<parent>=, record
+ child-address → {parent-address, origin workspace}.
+- On =closewindow>>= of a recorded child: bring the parent back
+ (=movetoworkspace=) and focus it; drop the record.
+- On =closewindow>>= of a hidden parent (nautilus quit while hidden): drop
+ the record, nothing to restore.
+- Exception classes (fuzzel, dunst, scratchpad classes, the panels) never
+ trigger a swallow even when they open over nautilus.
+- Pure event-machine core (parse lines → state transitions → dispatch list),
+ unit-tested against recorded event streams; a thin socket loop around it.
+
+Known edge, handled: Super+Shift+F while nautilus is hidden re-runs
+=nautilus=, which activates the existing (hidden) instance instead of
+opening a window. The daemon (or the bind) must restore-and-untrack in that
+case so the bind never appears dead.
+
+Known limitation, accepted: the emacsclient case never swallows — the
+window belongs to the long-running emacs daemon and =closewindow= for it
+means a frame closed, not "the file is done." The parent-class trigger plus
+exception list naturally leaves it alone only if we exclude it explicitly —
+see decision 2.
+
+* Decisions (Craig)
+
+** TODO Trigger breadth: any new window while nautilus is active, or an allowlist of viewer classes?
+"Any window" is simple and catches every handler, but a false positive
+exists: an app you launched seconds earlier from elsewhere finishes starting
+while you're focused on nautilus → nautilus gets swallowed by an unrelated
+window. An allowlist (zathura, mpv, imv, feh, …) can't be surprised but
+needs maintaining. Recommendation: any-window + exception list — the false
+positive is rare and self-healing (close the window or refocus).
+
+** TODO The emacs frame case: swallow or exempt?
+Opening a text file from nautilus raises/creates an emacs frame. Swallowing
+nautilus under it "works" going in, but the restore fires when *any* frame
+closes, which may be much later or never. Recommendation: exempt =emacs= —
+text files just open, nautilus stays.
+
+** TODO Restore destination: the workspace nautilus came from, or the one you're on when the viewer closes?
+If you move the viewer to another workspace and quit it there, "origin"
+teleports you back; "current" brings nautilus to you. Recommendation:
+current workspace — the restore should land where your attention is.
+
+** TODO Multiple children: refcount or single-slot?
+You can only launch a second file after restoring nautilus manually, so
+overlap is rare — but a fast double-launch can record two children.
+Recommendation: refcount — restore when the last tracked child closes.
+
+* Implementation phases
+
+1. =hypr-swallow= core: pure event-machine (TDD over recorded event
+ streams; fake hyprctl for dispatch assertions), config block at the top
+ (parent classes, exception classes), unittest suite in =tests/=.
+2. Socket loop + wiring: exec-once in hyprland.conf, the Super+Shift+F
+ restore-if-hidden interplay, daemon single-instance guard.
+3. Live verification on velox (zathura + mpv round-trips, the emacs case,
+ the false-positive probe) + manual-testing entries; ratio rides the
+ dotfiles pull.
diff --git a/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org b/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org
new file mode 100644
index 0000000..0d63feb
--- /dev/null
+++ b/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org
@@ -0,0 +1,195 @@
+#+TITLE: Net Panel — Tailscale, VPN, and WireGuard Interfaces
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-02
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Net Panel — Tailscale, VPN, and WireGuard Interfaces
+:PROPERTIES:
+:ID: 09f4cd40-f391-4eba-a4ff-c22bad00ad7f
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Tunnels track shipped detection, diagnose, and panel bring-up; build task DONE.)
+
+* IMPLEMENTED Status
+:PROPERTIES:
+:ID: 79a1075a-4b56-4f25-a861-b69f120a636a
+:END:
+- [2026-07-02 Thu] IMPLEMENTED — all six phases shipped (dotfiles 2d9d060,
+ 21db05a, 31ba056, b4010bf, b5c8442; archsetup 0389790 + the wireguard
+ import script): probes, panel Tunnels view, diagnose/doctor route
+ awareness, bar badge, installer swap + operator, velox config migration.
+ Residual human steps filed under todo.org "Manual testing and
+ validation": proton CLI sign-in (per machine) and the first live
+ badge/tunnel round-trip. Ratio picks up the import + package swap on its
+ trip.
+- [2026-07-02 Thu] DOING — decomposed into six build phases under the
+ todo.org parent (:SPEC_ID: bound); build started same evening per Craig
+ ("tunnels build now + audio-panel spec alongside").
+- [2026-07-02 Thu] READY — fused review passed the gate: 4/4 decisions
+ resolved, phases decomposable, claims re-verified live (proton-vpn-cli
+ 1.0.1 in extra, binary =/usr/bin/protonvpn=, no package conflict with the
+ GTK app; =tailscale status --json= shape confirmed on velox — Self/Peer/
+ CurrentTailnet.Name/MagicDNSSuffix; zero NM wireguard connections yet,
+ seven configs in assets awaiting the phase 6 import).
+- [2026-07-02 Thu] DRAFT — initial spec from the roam capture "other network
+ interfaces (tailscale, VPNs, wireguard)" filed in todo.org 2026-07-02.
+
+* Metadata
+
+| Field | Value |
+|--------+---------------------------------------------------|
+| Status | implemented |
+|--------+---------------------------------------------------|
+| Owner | Craig Jennings |
+|--------+---------------------------------------------------|
+| Repo | dotfiles (net module); archsetup (packages) |
+|--------+---------------------------------------------------|
+| Parent | Waybar network module spec (2026-06-29), V2 panel |
+|--------+---------------------------------------------------|
+
+* Problem
+
+The net panel's Connections tab shows what NetworkManager knows: WiFi networks
+and wired links. The machines also run overlay and tunnel interfaces the panel
+is blind to:
+
+- Tailscale (tailscaled, both daily drivers; the tailnet is how the machines
+ reach each other; not an NM device)
+- WireGuard configs (assets/wireguard-config/ carries Proton VPN configs;
+ importable as NM connections of type wireguard or run via wg-quick)
+- Commercial VPN clients (Proton VPN GTK app is installed on velox; owns its
+ own tunnel device)
+
+When one of these is up it changes routing, DNS, and reachability — exactly
+the things the Diagnostics tab reasons about — yet the panel neither shows nor
+controls them, and the doctor can misattribute a VPN-caused failure to the
+underlying link.
+
+* Goals
+
+1. Visibility: the Connections tab shows overlay/tunnel interfaces with live
+ state (up/down, address, and for tailscale the tailnet peers summary).
+2. Control: bring each up or down from the panel row, same interaction shape
+ as Join/Disconnect on WiFi rows (no terminals — V2 contract).
+3. Diagnostics awareness: diagnose/doctor know when a tunnel owns the default
+ route or DNS, name it in evidence rows, and stop misattributing its
+ failures to the physical link.
+
+Non-goals (this iteration): installing or configuring VPN providers, tailnet
+ACL management, exit-node selection UI (a "use exit node" affordance can ride
+a later pass), kill-switch management (tracked separately in the spec's
+failure catalog).
+
+* Design sketch
+
+** Data sources — one probe per backend, engine-side
+
+New GTK-free module net/src/net/overlays.py with one probe per backend,
+each returning the same small dict shape ({kind, name, state, addr, detail,
+can_toggle}):
+
+- tailscale: =tailscale status --json= (rich: self, peers, exit node, health
+ messages). Daemon down → state "stopped". Binary absent → backend absent.
+- wireguard-nm: =nmcli -t connection show= filtered to type wireguard —
+ up/down via the existing nmcli wrapper (activate/deactivate connection).
+ The seven Proton configs in assets/wireguard-config/ import cleanly
+ (=nmcli connection import type wireguard file <conf>=, then
+ =connection.autoconnect no= immediately — imports default to autoconnect
+ yes). They use only PrivateKey/Address/DNS + PublicKey/AllowedIPs/Endpoint,
+ no PostUp/PostDown anywhere, so no wg-quick path is needed at all
+ (Craig, 2026-07-02). All are full-tunnel (AllowedIPs 0.0.0.0/0) — the
+ panel should treat them as mutually exclusive.
+- proton: drive the official proton-vpn-cli (Arch extra repo, v1.0.x,
+ stable since 2026-04) — connect/disconnect/status verbs. It drives NM
+ underneath (python-proton-vpn-network-manager), so the panel still sees
+ connection events through NM. Runtime-exclusive with the GTK app, which
+ gets dropped from the install. The imported NM wireguard configs remain
+ a raw fallback when the CLI/API path is down; the CLI stays primary
+ because the raw configs lack kill switch, port forwarding, and server
+ rotation.
+
+** Panel
+
+A fourth Connections group "Tunnels" (after Saved / Available now / Wired)
+using the existing group-header + row machinery. Row: glyph per kind, name,
+state caption; primary action Up/Down where can_toggle, else Open app.
+Tailscale row detail (subtitle or tooltip): tailnet name, peer count online,
+exit node if any.
+
+** Privileged path
+
+- tailscale up/down: needs root or operator — =tailscale set --operator= at
+ install time (archsetup) makes the user an operator, so no sudo needed at
+ runtime. Fallback: the V2 net-priv helper gains tailscale-up/down verbs.
+- NM wireguard connections: no privilege needed (NM polkit default for the
+ active user).
+
+** Diagnostics awareness
+
+- diag gains an "overlay owns default route/DNS" detection step: when the
+ default route or resolv.conf points at a tunnel interface, evidence names
+ it ("default route via tailscale0") and failure classification runs the
+ physical-link checks against the underlying device instead.
+- doctor: a tunnel-caused egress failure (VPN up but its endpoint dead)
+ classifies fixable with next_action "bring the tunnel down / reconnect",
+ not a WiFi reset.
+
+** Bar indicator
+
+Part of v1 (Craig, 2026-07-02 — "shouldn't be optional"): a small overlay
+badge on the net glyph when a tunnel owns the default route. Rides the same
+route/DNS-ownership detection the diagnostics step adds.
+
+* Decisions (Craig)
+
+** DONE Which backends ship in the first pass?
+CLOSED: [2026-07-02 Thu]
+Approved (Craig, 2026-07-02): tailscale + NM-managed wireguard. Craig asked
+whether the wireguard configs can be ported to NM so wg-quick drops out
+entirely — yes: all seven configs in assets/wireguard-config/ use only the
+six directives NM imports cleanly (verified 2026-07-02; import command and
+autoconnect caveat now in the design sketch). wg-quick is out of the spec,
+not deferred. Proton control is CLI-driven per the Proton decision below,
+superseding the detection-only recommendation here.
+
+** DONE Tailscale control path: operator flag at install vs net-priv verbs?
+CLOSED: [2026-07-02 Thu]
+Approved (Craig, 2026-07-02): =tailscale set --operator=$USER= in archsetup's
+tailscale step (declarative, no sudo at runtime); net-priv verbs only if
+operator mode proves insufficient (e.g. up with flags).
+** DONE Does "Tunnels" belong in Connections or its own tab?
+CLOSED: [2026-07-02 Thu]
+Approved (Craig, 2026-07-02): a Connections group. A fourth top tab dilutes
+the V2 nav for three rows.
+
+** DONE Proton VPN: detect-only or drive its CLI?
+CLOSED: [2026-07-02 Thu]
+Decided (Craig, 2026-07-02): drive it through a CLI. Research (2026-07-02):
+Proton shipped an official Linux CLI — first release 2025-11, stable v1.0.0
+2026-04, packaged in Arch extra as proton-vpn-cli (1.0.1 at check time),
+with kill switch, port forwarding, NetShield, server selection, and a
+status command. It drives NM underneath, so the panel sees its connections
+through the existing NM event path. Spec changes: the proton backend calls
+protonvpn connect/disconnect/status instead of device-detection
+(can_toggle true); archsetup installs proton-vpn-cli and drops
+proton-vpn-gtk-app (the two can't run concurrently per the project README —
+untested locally); the imported NM wireguard configs stay as a raw fallback.
+Sources: [[https://protonvpn.com/support/linux-cli][Proton Linux CLI guide]],
+[[https://protonvpn.com/support/release-notes-linux-cli][CLI release notes]],
+[[https://github.com/ProtonVPN/proton-vpn-cli][proton-vpn-cli repo]].
+* Implementation phases
+
+1. overlays.py probes (tailscale JSON, nmcli wireguard filter, proton-vpn-cli
+ status) — pure engine, TDD with fake binaries; =net status= grows an
+ overlays section.
+2. Panel Tunnels group + Up/Down wiring through the worker thread; AT-SPI
+ smoke extension.
+3. Diagnose/doctor overlay awareness (route/DNS ownership step, classifier
+ rows, evidence text) — TDD against the diag harness.
+4. waybar-net tunnel badge on the net glyph (v1 per the bar-indicator
+ decision), riding phase 3's route-ownership detection; suite coverage.
+5. archsetup: tailscale operator flag in the tailscale install step;
+ proton-vpn-cli replaces proton-vpn-gtk-app in the package list; VM test
+ assertions.
+6. One-time per-machine migration: import the seven assets/wireguard-config
+ configs into NM with autoconnect off (scriptable; both daily drivers).
diff --git a/docs/specs/2026-07-02-timer-panel-spec.org b/docs/specs/2026-07-02-timer-panel-spec.org
new file mode 100644
index 0000000..e5f0f4e
--- /dev/null
+++ b/docs/specs/2026-07-02-timer-panel-spec.org
@@ -0,0 +1,221 @@
+#+TITLE: Timer GTK Panel
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-02
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Timer GTK Panel
+:PROPERTIES:
+:ID: 25ed5321-f035-42b3-b115-69364d775f41
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed)
+
+* IMPLEMENTED Status
+:PROPERTIES:
+:ID: 1770af2e-b093-4024-a512-ae4324a2869f
+:END:
+- [2026-07-05 Sun] IMPLEMENTED — redesign built and shipped to dotfiles in a no-approvals speedrun (5 commits =c7ac193=..=5a863b5=): the wtimer engine (timer repeat, recurring alarms with snooze/ringing/dismiss, =@half=/=@hour=/=+dur= alarm parse, the rebuilt configurable pomodoro cycle, bar-tooltip parity), the PanelModel view-data rebuild (=row_view=, ringing-first sort, per-type create options as flags, locked presets + half-past + named pomodoro cycles), the GTK hero-on-top panel (Cairo progress ring + stopwatch sweep dial, per-type create strips, one transport row, close ✕/Esc), and the bar tooltip parity. wtimer + timer suites 231 green, full =make test= green. Live GTK render is the manual checklist (todo.org). Stopwatch run-save deferred to vNext.
+- [2026-07-05 Sun] DOING — UI/UX redesign decided through a prototype process (research → brainstorm → several directions → iterate to final; see Prototype iterations below). The shipped v1 panel stands, but this rewrite supersedes its layout and adds functionality: a hero-on-top + rack layout, a live waybar-module preview at parity with =wtimer render=, per-type create-strip features (timer auto-repeat; alarm recurring weekdays + snooze + a ringing state; a configurable pomodoro cycle with work/rest short+long and a long-break interval; a stopwatch analog sweep dial + last-lap badge), presets (renamed from "chips") whose shipped defaults are locked and whose load flashes the fields, and a header close button. Stopwatch run-save is cut to a vNext. Rebuild pending — folds this decided design into the shipped =timer/= package.
+- [2026-07-05 Sun] IMPLEMENTED — v1 built and shipped to dotfiles in a no-approvals speedrun (4 commits 1f4f270..78d3cbb): wtimer watch/lap/save; a new timer/ package with a GTK-free PanelModel (62 tests) + the GTK instrument-console panel; bar integration (custom/timer opens the panel, the fuzzel creation flow retired, Hyprland float rule added). This is the base the 2026-07-05 redesign iterates on.
+- [2026-07-05 Sun] DOING — Craig directed the build (no-approvals speedrun). Folded in the cj input from the sibling waybar-timer-module spec (GTK app styled like the panels; a queue/output-wall auto-sorted by fire time; stopwatch lap/stop + saveable runs; notify integration; 5/25-min configurable+deletable defaults; up to 10 timers; widget-gallery elements) — see Build scope below. Bypassed the READY spec-review step at Craig's direction; the four decisions were already resolved.
+- [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (standalone; retire fuzzel once the panel lands; timer chips gain 10m/30m/2h; wtimer watch mode over polling). Decision-complete; ready for a spec-review to flip it READY before build.
+- [2026-07-02 Thu] DRAFT — initial spec from Craig's roam capture "give the
+ timer a gtk UI/UX like the network panel. spec this out."
+
+** Prototype iterations
+The redesign ran through the UI/UX prototype process (see the =ui-prototyping= rule proposed to rulesets, 2026-07-05). Full working HTML prototypes over one shared engine, in the dupre instrument-console aesthetic; each iteration links here, newest last, so the design history is walkable.
+- [[file:../prototypes/2026-07-02-timer-panel-prototype-1.html][prototype-1]] — three initial directions over one shared engine: rack unit (faithful vertical list), transport deck (hero + track list), channel-strip board (vertical faders). Predates the formalized five-direction count.
+- [[file:../prototypes/2026-07-02-timer-panel-prototype-2.html][prototype-2]] — chose the rack direction; flipped to hero-on-top → create strip → list; made pomodoro a configurable cycle; locked the shipped presets (default cycle undeletable); dropped the stopwatch/pomodoro value entry.
+- [[file:../prototypes/2026-07-02-timer-panel-prototype-3.html][prototype-3]] — FINAL. Live waybar preview; hero donut moved right with one full-width button row; stopwatch sweep dial + ghost lap badge; alarm recurring days + snooze + ringing state; timer repeat; half-past alarm preset; presets flash on load; header close (Esc / bar-click reopen); verbatim tooltip labels; stopwatch save deferred.
+
+* Metadata
+
+| Field | Value |
+|--------+-----------------------------------------------------------------------------------------|
+| Status | implemented |
+|--------+-----------------------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|--------+-----------------------------------------------------------------------------------------|
+| Repo | dotfiles |
+|--------+-----------------------------------------------------------------------------------------|
+| Kin | net panel (architecture donor), wtimer (backing), desktop-settings panel spec (sibling) |
+|--------+-----------------------------------------------------------------------------------------|
+
+* Problem
+
+The timer's whole UI is a chain of three fuzzel prompts (type, value, label)
+plus a fourth for cancel. That flow can't show what's already running while
+you create, can't offer one-tap presets, gives no feedback on a typo until
+the add silently fails, and pomodoro state (phase, cycle) is only visible in
+a tooltip. The 2026-07-02 styling pass made the dialogs presentable, but the
+shape is still four blind modals for what is really one small control
+surface.
+
+* Goals
+
+1. One panel, opened from the bar's timer module, that shows everything
+ running (live countdowns, pomodoro phase/cycle, paused state) and creates
+ new items without leaving it.
+2. One-tap presets for the common cases (tea, pomodoro, quick alarm) next to
+ freeform entry, with inline validation before the add.
+3. Per-item controls: pause/resume, cancel, promote to primary (the bar
+ glyph slot).
+4. wtimer stays the single owner of timer state and the notification path;
+ the panel is a view over it, never a second engine.
+
+* Design sketch
+
+** Architecture — clone the net panel's proven stack
+
+- GTK4 + gtk4-layer-shell dropdown anchored under the timer module, Blueprint
+ .blp compiled to committed .ui (=make ui=; compiler is dev-only).
+- Humble-object split: GTK-free PanelModel presenter, unit-tested to 100%,
+ with thin widget bindings; one gated AT-SPI smoke via the
+ run-panel-smoke.sh pattern.
+- Backing: shell out to the existing wtimer CLI (=add=, =toggle=, =cancel=,
+ =cycle=, =render=). =render= already emits a JSON payload. Live state comes
+ from a new wtimer watch/subscribe mode (decision D), which the panel
+ subscribes to for push updates instead of polling =render= on a timer.
+ wtimer's 89-case suite keeps owning the logic; panel tests fake the CLI
+ like every dotfiles suite fakes binaries.
+- Dupre WIP palette CSS shared with the net panel (same factoring the
+ desktop-settings spec calls for — one palette asset, three panels).
+
+** Layout sketch (decided in prototype-3)
+
+Top-to-bottom, one column:
+
+- Header: brand + live item count + Clear All + a flat circular close ✕
+ (tooltip "Close (Esc)"), matching the net/bt/audio panels. Esc closes;
+ clicking the bar's timer module reopens it (mirrors =on-click: timer-panel=).
+- Hero (the primary / bar-slot item): the info block (type badge, any feature
+ badges, pomodoro cycle dots, label, big countdown, subline) on the left with
+ the progress donut on the right, and all its controls in one full-width,
+ left-justified button row beneath. Countdown types show a filling progress
+ ring; a stopwatch shows an analog sweep dial (a gold second-hand, one
+ revolution per minute) with its last lap as a bordered ghost badge beside the
+ count — no fake progress ring for a count-up. The ‹ › keys cycle the primary
+ through the whole queue, wrapping at either end.
+- Create strip: the four type buttons, then a per-type body — presets (renamed
+ from "chips") + a freeform entry validated by wtimer's parsers + an optional
+ label, plus per-type extras (see Build scope). Loading a preset flashes the
+ target fields rather than toasting. Shipped presets are locked (no delete);
+ only presets you add carry a ×.
+- Queue list: the rest of the items (everything but the hero), soonest-fire
+ first, one rack row each — lamp, glyph, label, subline, countdown, and inline
+ pause / promote / cancel (two-stage arm). Stopwatches are promotable to the
+ hero like any other item. With a single item the list reads "Only one item is
+ queued. Add more above." Empty state: hero shows "No timers running", create
+ strip below.
+
+** Waybar module parity
+
+A live preview above the panel renders exactly what =wtimer render= emits for
+the bar: =<large glyph> <countdown>= plus =+N= for the other items, recoloured
+by state (urgent < 60 s terracotta, paused dim, pomodoro-work gold,
+pomodoro-break sage, idle silver), with the full per-item hover tooltip. Tooltip
+lines show each item's label verbatim — no phase word appended. The panel and
+the bar stay one source of truth via the wtimer watch subscription.
+
+** What happens to the fuzzel flow
+
+Decision B (below) resolved this: the fuzzel chain retires once the panel
+lands. The panel becomes the single creation surface, replacing both the
+click-driven bar path and the keybind/fuzzel path. Until the panel ships the
+fuzzel flow stays (it's styled and tested); phase 4 removes it after the
+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/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.
+- The hero shows the primary big; the list shows the rest. Clear All cancels everything.
+
+Types + create strip:
+- *Timer*: preset durations 5m / 25m / 10m / 15m / 30m / 60m / 2h (5m and 25m first), freeform entry (wtimer parser), optional label, and a *repeat* toggle — a repeating timer re-arms itself on fire instead of clearing.
+- *Alarm*: presets +30m / top-of-hour / *half-past* (next X:30) / 07:00, freeform HH:MM, optional label, a *recurring weekday* selector (S M T W T F S, with weekdays / daily shortcuts) and a *snooze* duration. An alarm fires into a *ringing* state rather than silently vanishing: the hero/row shows SNOOZE (re-arm by the snooze minutes) and DISMISS (a recurring alarm re-arms to its next matching day; a one-shot clears).
+- *Stopwatch*: no value entry — counts up from zero. Lap (unlimited) and Stop. The hero shows an analog sweep dial and the last lap as a ghost badge beside the count. *Run-save is deferred to a vNext* (cut from v1's org-save plan — see the status history).
+- *Pomodoro*: a configurable cycle — Work and Rest each with a short and a long duration, a "long break every N cycles" interval, and an auto-advance toggle. Every Nth ("deep") cycle uses the long work + long rest together. Cycle progress shows as dots in the hero and row. With auto-advance off, each phase end waits for a Start press. Preset cycles (Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10) load the fields.
+- *Presets*: shipped defaults are locked (undeletable — the pomodoro default cycle can't be removed); presets you add carry a × and are deletable. Loading any preset flashes the target fields (no toast).
+
+Live updates + notifications:
+- A =wtimer watch= subcommand emits state on every change (state-file watch → JSON lines on stdout); the panel subscribes for push updates instead of polling (decision D). Notifications for alarms and timers go through the =notify= script — wtimer stays the single notification owner.
+
+Bar + window:
+- =custom/timer= left-click opens the panel; =wtimer render= stays the bar indicator (glyph + countdown + =+N=, state-coloured, verbatim tooltip labels). A header close ✕ and Esc close the panel; clicking the bar module reopens it. The =wtimer new= fuzzel creation flow is retired (decision B).
+
+* Decisions (Craig)
+
+** DONE Panel scope: standalone timer panel, or a page in the desktop-settings panel?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): standalone, sharing the palette/css asset. Matches the net panel's one-domain-one-panel shape and keeps the timer dropdown small.
+
+** DONE Fuzzel flow: keep as keyboard fast lane, or retire once the panel lands?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): retire the fuzzel flow once the panel lands. The panel becomes the single creation surface; the keybind chain goes away rather than staying as a parallel path. (Implementation phase 4's "decide the fuzzel flow's future" is now decided — retire, don't keep.)
+
+** DONE Presets: which chips per type?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): timer chips are 5m / 10m / 15m / 25m / 30m / 60m / 2h (the strawman plus 10m, 30m, 2h). Alarm +30m / top-of-hour / 07:00, pomodoro default cycle only, stopwatch none — as the strawman.
+
+** DONE Live updates: poll render (1s, like the bar) or a wtimer "watch" mode?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): a wtimer watch/subscribe mode, not 1s polling. This grows wtimer with a new watch capability that the panel (and potentially the bar) subscribes to for live state, rather than reusing the poll cadence — cleaner at the cost of a wtimer addition. Fold the watch mode into the phase 1 CLI-backing seam.
+
+The decisions below were resolved live through the prototype iteration (2026-07-05), each seen working in a prototype before being written down.
+
+** DONE Layout: hero on top, then create strip, then the queue list
+CLOSED: [2026-07-05 Sun]
+Resolved: the primary item rides a hero at the top (info left, donut right, all controls in one full-width button row), the create strip sits under it, the rest of the queue below. Chosen over the transport-deck and channel-strip directions in prototype-1.
+
+** DONE Stopwatch hero visual: analog sweep dial, not a progress ring
+CLOSED: [2026-07-05 Sun]
+Resolved: a count-up stopwatch shows a gold second-hand sweeping once per minute, with its last lap as a bordered ghost badge beside the count — not a fake progress ring (a stopwatch has no target to be a fraction of).
+
+** DONE Alarm: recurring weekdays + snooze + a ringing state; add a half-past preset
+CLOSED: [2026-07-05 Sun]
+Resolved: alarms carry a weekday-repeat selector and a snooze duration, and fire into a ringing state with SNOOZE / DISMISS rather than vanishing. A half-past preset joins top-of-hour (fires at the next X:30). Drawn from Alarm Clock Xtreme / Alarmy.
+
+** DONE Timer: auto-repeat toggle
+CLOSED: [2026-07-05 Sun]
+Resolved: a timer can repeat — it re-arms itself on fire instead of clearing. Drawn from MultiTimer / Multi Timer.
+
+** DONE Pomodoro: a fully configurable cycle
+CLOSED: [2026-07-05 Sun]
+Resolved: Work and Rest each get a short and a long duration, plus a long-break-every-N interval and an auto-advance toggle; every Nth deep cycle uses the long work + long rest; progress shows as cycle dots. The default cycle preset is locked (undeletable). Drawn from Pomofocus / the classic technique.
+
+** DONE Presets (formerly "chips"): lock defaults, flash on load
+CLOSED: [2026-07-05 Sun]
+Resolved: rename "chips" to "presets"; shipped defaults are locked (no delete), presets you add are deletable; loading a preset flashes the target fields instead of firing a toast.
+
+** DONE Stopwatch run-save: deferred to a vNext
+CLOSED: [2026-07-05 Sun]
+Resolved: v1's "save the run's splits to an org file on stop" is cut from this build. Stop just stops. Revisit in a vNext if the need is real.
+
+* Implementation phases (redesign rebuild)
+
+Folding prototype-3 into the shipped =timer/= package. TDD throughout — GTK-free
+logic first, GUI last — reviewing between phases. Each phase is a dotfiles commit
+under the archsetup-owns-dotfiles rule.
+
+1. wtimer engine: alarm recurring-days + snooze + a ringing state, timer repeat,
+ the configurable pomodoro cycle (work/rest short+long, long-break interval,
+ auto-advance, deep cycle), half-past parsing, and the watch/subscribe mode
+ (decision D). Extend wtimer's own suite per addition.
+2. PanelModel: the four-bucket soonest-fire sort, promote/cycle wrap (stopwatches
+ included), per-type create validation + presets (locked defaults, custom
+ delete, flash-on-load), and the row/hero view data (sweep fraction, cycle
+ dots, last lap, feature badges, ringing controls). GTK-free, unit-tested like
+ the net PanelModel.
+3. GTK GUI: the hero (progress ring / sweep dial, one full-width button row, lap
+ badge), the per-type create strip (timer repeat toggle; alarm weekday selector
+ + snooze; pomodoro config grid; presets that flash), the header close ✕,
+ Esc-to-close, and bar-click reopen.
+4. Bar parity: =wtimer render= tooltip labels verbatim, state classes confirmed;
+ panel and bar track one state via the watch subscription.
+5. AT-SPI smoke + a manual-testing checklist (todo.org). Retire the =wtimer new=
+ fuzzel flow (decision B) after the panel proves out.
+
+Deferred to a vNext: stopwatch run-save (an org log of splits).
diff --git a/docs/specs/2026-07-03-audio-panel-spec.org b/docs/specs/2026-07-03-audio-panel-spec.org
new file mode 100644
index 0000000..5b678a8
--- /dev/null
+++ b/docs/specs/2026-07-03-audio-panel-spec.org
@@ -0,0 +1,166 @@
+#+TITLE: Audio Panel — the pulsemixer console
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-03
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Audio Panel — the pulsemixer console
+:PROPERTIES:
+:ID: 9175e017-46ad-4887-ae45-887e9551c005
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Shipped; build task DONE, manual tests filed.)
+
+* IMPLEMENTED Status
+:PROPERTIES:
+:ID: 71f556c6-ee02-47cc-a3be-68c8289380f3
+:END:
+- [2026-07-03 Fri] IMPLEMENTED — built in a no-approvals speedrun in the
+ dotfiles repo (branch panel-bugfixing): engine (pactl), presenter, GTK
+ panel, PTT arming, bar indicator, and the bar/keybind wiring, across four
+ commits 65e5bb0..9601420. 102 unit tests + a passing AT-SPI smoke on velox.
+ All five Decisions below resolved. Live-eyeball validation (visual polish,
+ PTT-in-a-meeting, fader feel, the master-mute hardware key) is the one open
+ follow-up, tracked as a manual-testing task in todo.org.
+- [2026-07-03 Fri] DRAFT — stub from the todo.org task "Audio panel spec"
+ (roam ask 2026-07-02) plus the 2026-07-03 waybar/sound design discussion.
+ Written to iterate alongside the prototype
+ (=docs/prototypes/2026-07-03-sound-panel-prototype.html=). Spine is present; the
+ Decisions and Design detail get filled in as we go.
+
+* Metadata
+
+| Field | Value |
+|--------+---------------------------------------------------|
+| Status | implemented |
+|--------+---------------------------------------------------|
+| Owner | Craig Jennings |
+|--------+---------------------------------------------------|
+| Repo | dotfiles |
+|--------+---------------------------------------------------|
+| Kin | net panel + bt panel (architecture + aesthetic |
+| | donors), desktop-settings panel (sibling) |
+|--------+---------------------------------------------------|
+
+* Problem
+
+Audio control today is the pyprland audio scratchpad (Super+A) — a floating
+pulsemixer TUI — plus scattered bar affordances: =pulseaudio= (volume, click
+to mute sink), =pulseaudio#mic= (mic glyph + mic-toggle), Super+M audio-cycle
+ring, Super+Shift+A mic-toggle. There's no single glanceable surface that
+shows every sink and source, lets you set the default output/input, and
+carries the meeting-grade mic controls Craig wants (a clean muted mode and a
+hold-to-talk mode). The net + bluetooth panels set the pattern for exactly
+this shape; audio is the third instrument in the family.
+
+* Goals
+
+1. One panel, opened from the bar's sound glyph, exposing the full pulsemixer
+ surface: every sink and source, per-device volume, per-device mute, and
+ switching the default output and input.
+2. Replace the pyprland audio scratchpad (Super+A) as the primary audio UI.
+3. Mic modes for meetings: *live*, *muted*, and *push-to-talk* (mic stays
+ muted except while Space is held, releasing re-mutes).
+4. A *master quick-mute* — one action mutes all output — reachable from the
+ faceplate and a keybind.
+5. Instrument-console aesthetic and architecture consistent with net + bt:
+ same faceplate, lamps, engraved sections, console keys, needle gauges,
+ verify-everything contract.
+6. The bar glyph reflects live state: speaker + three arcs normally, a
+ speaker-with-✕ when muted (Craig's called glyphs).
+
+* Design sketch
+
+Prototype: =docs/prototypes/2026-07-03-sound-panel-prototype.html= (the reference for
+layout + idioms below).
+
+** Surface (from the prototype)
+
+- *Faceplate* — status lamp, sound glyph, state word (PLAYBACK / MUTED), a
+ MUTED badge, the SND·01 unit label, and the *master quick-mute switch*
+ (same switch idiom as net wifi / bt power), plus the close ✕.
+- *OUTPUTS section* — one row per sink. Row body click = set default (gold
+ DEF tag). A machined fader sets that sink's volume; the trailing glyph
+ mutes just that device. Active/default row is emphasized (cream name, gold
+ lamp/glyph).
+- *INPUTS section* — one row per source, same idioms.
+- *Mic mode* — three console keys: LIVE / MUTED / PUSH·TALK. Push-to-talk
+ keeps the mic muted (red IN needle) and un-mutes only while Space is held.
+- *Twin VU needles* — output level + input level, the sound analog of net
+ throughput and bt battery gauges. Needle goes red when its side is muted.
+
+** Architecture — clone the net/bt panel stack
+
+- GTK4 + gtk4-layer-shell, Blueprint =.blp= → committed =.ui= (=make ui=,
+ dev-only build dep).
+- Humble-object split: a GTK-free PanelModel presenter (unit-tested like the
+ net/bt PanelModels) + thin composite-widget pages. Backing actions in a
+ GTK-free =audio.py= that shells to the audio control layer (pactl /
+ wpctl / pulsemixer — pick below), TDD'd with fake binaries.
+- One gated AT-SPI smoke (=run-panel-smoke.sh= pattern).
+- Shared instrument-console palette CSS asset (the one net/bt/settings all
+ load) — do not duplicate the palette block.
+- Code lives in dotfiles =audio/= sibling to =net/= (src-layout, tests in
+ =tests/audio/=).
+
+* Decisions (Craig)
+
+** DONE Audio control backend — pactl vs wpctl vs pulsemixer
+CLOSED: [2026-07-03 Fri]
+Resolved: =pactl= (the engine module is =pactl.py=). Both ratio and velox run
+PipeWire with the pipewire-pulse compat layer and no PulseAudio daemon, so
+pactl and wpctl hit the same graph — but =pactl -f json= gives structured,
+name-addressable output where wpctl offers only a volatile-id tree. Reads go
+through =pactl -f json list sinks|sources= + =get-default-*=; writes target
+devices by stable name behind an argv-charset guard.
+
+** DONE Push-to-talk mechanism under Wayland (feasibility — phase 1)
+CLOSED: [2026-07-03 Fri]
+Resolved: route (a), Hyprland dynamic binds. The phase-1 spike confirmed all
+three primitives on velox (Hyprland 0.55.4): =hyprctl keyword bind/unbind=
+adds and removes a bind live, =bindr= fires on release, and =pactl
+set-source-mute @DEFAULT_SOURCE@ 0|1= toggles the mic cleanly. =ptt.py= arms a
+press bind (un-mute) + a bindr (re-mute) on entering PTT mode and unbinds on
+leaving, so the talk key isn't grabbed globally otherwise. No evdev needed.
+Documented behavior: while PTT is armed, the talk key is the talk key.
+
+** DONE Quick-mute keybind + scope
+CLOSED: [2026-07-03 Fri]
+Resolved: the XF86AudioMute hardware key (Super+Shift+M turned out to be taken
+by the monocle-layout bind, so the spec's assumption was wrong). The mute key
+now runs =audio quick-mute=, which mutes every output (master), not just the
+default sink — identical on a single-sink machine, correct on a multi-sink
+one. Also reachable from the faceplate master switch and the panel. Scope:
+master mute of all sinks, with verify-after-apply per sink.
+
+** DONE Bar glyph click map
+CLOSED: [2026-07-03 Fri]
+Resolved with the low-regret wiring: kept the existing =pulseaudio= waybar
+module (left-click mute, scroll volume — no regression) and repointed its
+right-click from the retired pulsemixer scratchpad to =audio-panel=. So: left
+= mute, right = open panel, scroll = volume. A fuller =custom/audio= indicator
+(state-following speaker glyph + its own click map) is built and tested
+(=indicator.py= + =waybar-audio=) but stays unwired until the new bar glyph
+gets a live eyeball — the swap is a one-line waybar edit when Craig's ready.
+
+** DONE Fate of the existing audio affordances
+CLOSED: [2026-07-03 Fri]
+Resolved: Super+A repurposed from =pypr toggle audio= (the pulsemixer
+scratchpad) to =audio-panel= — the panel is the primary audio UI now, so the
+scratchpad is retired. Its definition still sits in the machine-local
+=pyprland.toml= (not stowed) and can be deleted by hand. Kept: =pulseaudio= +
+=pulseaudio#mic= waybar modules (glance + scroll + the mic-mute glance),
+Super+M cycle, Super+Shift+A + XF86AudioMicMute mic-toggle. Changed:
+XF86AudioMute → master quick-mute (see the quick-mute decision above).
+
+* Implementation phases
+
+1. Push-to-talk feasibility spike (decision above) — the one unknown; settle
+ the mechanism before committing the mic-mode design.
+2. =audio.py= backings (list/get/set/mute/default for sinks + sources) —
+ pure engine, TDD with a fake audio backend.
+3. PanelModel presenter (rows, default tracking, mic modes, master mute,
+ verify-after-apply) — unit-tested, no GTK.
+4. Blueprint UI + sound bar glyph (normal / muted / ptt states) + open/close
+ wiring; shared palette css; AT-SPI smoke.
+5. Bar-affordance consolidation per the decision above; retire the Super+A
+ scratchpad; keybinds.
diff --git a/docs/specs/2026-07-03-instrument-console-panels-spec.org b/docs/specs/2026-07-03-instrument-console-panels-spec.org
new file mode 100644
index 0000000..2c80aa9
--- /dev/null
+++ b/docs/specs/2026-07-03-instrument-console-panels-spec.org
@@ -0,0 +1,174 @@
+#+TITLE: Instrument-console rebuild — net + bluetooth panels
+#+DATE: 2026-07-03
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Instrument-console rebuild — net + bluetooth panels
+:PROPERTIES:
+:ID: ac23e996-a51a-466b-ad80-2faff46447bf
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Panel rebuild shipped (dotfiles e993c3f); build task DONE.)
+
+* IMPLEMENTED Status
+:PROPERTIES:
+:ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5
+:END:
+- 2026-07-03 Fri @ 17:35 -0400 :: Post-impl increment (stays IMPLEMENTED): added
+ a manual rescan/scan ⟳ affordance to both panels — net NETWORKS header
+ (drives manage.rescan) and bt NEARBY header (drives on_scan / pair-mode
+ discovery), with the approved "all" busy style (Gtk.Spinner throbber + a
+ GLib breathe on "scanning…" + a one-shot list fade; no CSS keyframes exist in
+ this GTK setup). Prototype: archsetup docs/prototypes/2026-07-03-net-panel-rescan-prototype.html. Code shipped
+ UNCOMMITTED into the dotfiles repo from an archsetup session (cross-project);
+ handoff at ~/.dotfiles/inbox/2026-07-03-1733-from-archsetup-rescan-handoff.org.
+ Verified: net 584 + bt 223 unit OK, both AT-SPI smokes green (⟳ present);
+ live busy-animation feel pending Craig's eyeball.
+- 2026-07-03 Fri @ 06:49 -0400 :: DOING → IMPLEMENTED: all six phases shipped
+ (net GTK-free layer 81ec9c3, net view 800ef60; bt GTK-free layer 5318b34, bt
+ view 66f03d9; phase-6 dead-code removal f4e688e). Both panels are single-screen
+ instrument consoles, verified live on velox — 46 suites + full make test green,
+ both AT-SPI smokes green end to end, render matching the approved prototype. The
+ three folded tasks (network panel redesign, bt switch placement + title, bt
+ rename devices) closed with the build.
+- 2026-07-03 Fri @ 02:07 -0400 :: DRAFT → READY → DOING in one stroke: Craig
+ approved the design through five interactive prototype iterations and
+ authorized the no-approvals speedrun ("let's build them now... go"). The
+ review gate was the live prototype session itself.
+- 2026-07-03 Fri @ 02:07 -0400 :: Created (DRAFT) from the prototype session.
+
+* Metadata
+
+| Field | Value |
+|---------------------+------------------------------------------------------------|
+| Status | implemented |
+|---------------------+------------------------------------------------------------|
+| Owner | Craig Jennings |
+|---------------------+------------------------------------------------------------|
+| Repos | dotfiles (net/, bluetooth/, themes), archsetup |
+|---------------------+------------------------------------------------------------|
+| Normative reference | [[file:../prototypes/2026-07-03-instrument-console-panels-prototype.html][docs/prototypes/2026-07-03-instrument-console-panels-prototype.html]] |
+|---------------------+------------------------------------------------------------|
+
+* Summary
+
+Rebuild both GTK layer-shell panels (net, bluetooth) from the tabbed layout
+to the instrument-console design: one screen, no tabs, a faceplate with a
+state word + badges + radio switch + close, engraved section labels, lamp
+rows that act on click, dial meters under the console keys, and a doctor
+that does it all. The interactive prototype =panel-console-v3.html= is the
+normative design reference — when this spec and the prototype disagree on a
+visual or interaction, the prototype wins.
+
+* Decisions (all resolved — Craig, prototype session 2026-07-02/03)
+
+- Replace the tabbed panels outright. No fallback flag; git history is the
+ rollback. Net panel first, bluetooth second.
+- Advanced repair tiers leave the panel entirely. DOCTOR runs the full
+ diagnose → classify → lightest-repair → re-verify escalation (the engine
+ already does this). The surgical tiers stay CLI-only (=net repair ...=).
+- Faceplate (both panels): state lamp + state word, badges, unit label
+ (NET·01 / BT·01), radio switch (wifi radio / bt adapter power), close ✕.
+ Badges: TUNNEL (gold, net), AIRPLANE (gold, both), LOW BATT (red, bt).
+- Sections in order — net: CHANNEL, NETWORKS (+ hidden action), TUNNELS,
+ CONSOLE (DOCTOR / SPEED TEST keys), meters, output. bt: ADAPTER (with
+ clickable =discoverable= chip), PAIRED, NEARBY (+ scanning note), CONSOLE
+ (DOCTOR / SCAN), battery gauges, output.
+- Section row budgets, half-row peek, internal scroll (thin slate
+ scrollbar): NETWORKS 5.5 rows, TUNNELS 4.5, PAIRED 5.5, NEARBY 4.5.
+ In-range networks sort active-first then strongest-signal-first. Counts on
+ the engraved headers ("networks · 12 in range", "tunnels · 1 up of 9",
+ "paired · 3", "nearby · 12"). The panel silhouette never grows with list
+ length; only the output well is variable and it caps at ~170px.
+- Lamp-row grammar: green = live/connected, gold = available/actionable,
+ off = down/stored, red = failed; busy = pulsing gold during transitions.
+ Rows act on click (tunnels toggle, networks join, paired devices
+ connect/disconnect toggle, nearby devices pair).
+- Arm-first for anything disruptive or destructive, 3s auto-disarm:
+ - forget (network or bt device): hover reveals ✕; first click arms the
+ row terracotta ("forget? click ✕ again"), second fires. No dialog.
+ - disconnect (active network): click the active row; first click arms in
+ GOLD ("disconnect? click again") — disruptive, not destructive — second
+ fires.
+- Meters (net): two dials, RX·DOWN / TX·UP, gold needles, mode tag top-left
+ (LIVE green / TEST gold), HOLD tag top-right. Idle: live link throughput.
+ Speed test: cards flash gold, needles sweep the measured rate, then PIN
+ the final value with HOLD; clicking a held meter releases it to LIVE.
+ Scale 0–100 Mbps, auto-relabel to 0–1000 when a reading exceeds 100.
+ Dial top inset ~13px so the corner tags never touch the arc.
+- Speed test output well gets ONLY: location line ("location: <city> by
+ <sponsor>"), ping (+jitter), final line, conditioned tip(s). The rates
+ live in the meters, not the text.
+- Battery gauges (bt): same dial chrome; one per connected device (two
+ slots; empty slot dim "NO DEVICE"/"ADAPTER OFF"); needle+value red under
+ 15% and the LOW BATT faceplate badge lights.
+- Output well: doctor streams the checks with their narration lines
+ (viewmodel.STEP_NARRATION) and repair steps in gold; verdict line closes
+ (olive for pass/fixed). A dismiss ✕ appears in the well's corner whenever
+ it has content. Both panels.
+- WiFi radio switch: =nmcli radio wifi on|off=. Off empties NETWORKS to one
+ dim "wifi radio off" row, drops the connection, kills tailscale rows'
+ reachability; on rejoins the last network (NM autoconnect does this for
+ real). Airplane mode is system-level (Super+Shift+A owns it): both panels
+ reflect it (state word AIRPLANE, gold badge, switches down); a switch
+ flipped under airplane refuses with a toast naming the exit. A routed
+ ethernet link keeps the net panel ONLINE through airplane mode.
+- Ethernet: presence-based row pinned atop NETWORKS when a cable is up
+ ("enp… · active · wired · 1.0 Gbps" / "wired · standby"); CHANNEL swaps
+ the signal ladder for "wired · <speed> full-duplex" when routed; clicking
+ the row toggles route ownership via device disconnect/connect.
+- Pairing (bt): nearby row click → busy → passkey-confirm dialog (large
+ gold digits) → device moves to PAIRED and connects. SCAN key refreshes
+ with a "scanning…" note on the header.
+- Rename (bt): hover ✎ on a paired row → dialog prefilled → bluez
+ =set-alias= (closes the filed rename task).
+- Tooltips: any ellipsized row label carries its full text as the tooltip.
+- Dialogs (join / hidden SSID / passkey / rename) keep the in-panel dupre
+ dialog style (gold border, dark well inputs, gold caret).
+- Close: ✕ on the faceplate + Esc (already shipped; keep).
+- Folded tasks: "Network panel redesign", "Bluetooth panel: switch placement
+ + panel title", "Bluetooth panel: rename devices" — all close with this
+ build's phases.
+
+* Engine gaps (small, close during phases)
+
+- radio verb: =nmcli radio wifi on|off= helper (manage or sysio) + tests.
+- hidden-SSID join: =manage.add= grows a hidden flag
+ (=802-11-wireless.hidden yes=).
+- ethernet: device rows from =nmcli dev= (type ethernet) + disconnect/
+ connect verbs (device-level; =net down --iface= already disconnects).
+- bt rename: btctl =set-alias= one-shot verb + verify-after read.
+- bt battery: already exposed (indicator uses it).
+- speedtest meters: =run_speedtest_stream= on_update already ticks (pty).
+- link speed for wired channel line: =ethtool=-free read from
+ =/sys/class/net/<dev>/speed=.
+
+* Implementation phases
+
+1. [X] Spec + task wiring (this file; todo.org parent task with :SPEC_ID:).
+2. [X] Net GTK-free layer (TDD): viewmodel row composers for the console
+ sections (network rows sorted+counted, tunnel rows, channel facts,
+ faceplate state word derivation, meter scale logic, arm state machines
+ for forget/disconnect), PanelModel restructure (sections, no tabs).
+ Engine gaps: radio verb, hidden join, ethernet rows, wired link speed.
+3. [X] Net view rebuild: gui.py single-page console built in Python
+ (faceplate, engraved scrolled sections, console keys, cairo dial meters
+ with mode/hold tags, output well + dismiss), panel.css additions
+ (engrave, lamps, dial, badges, arm tints). AT-SPI smoke + driver
+ rewritten for the console layout. Shipped with phase 4 (dotfiles
+ 800ef60): a view-only intermediate is a broken panel (rows and switches
+ that do nothing), so view + interactions landed together.
+4. [X] Net interactions: join/hidden/forget (arm terracotta)/disconnect
+ (arm gold)/radio switch/ethernet toggle/doctor stream/speed-test-drives-
+ meters, toasts. Verified live on velox (DOCTOR streams, SPEED TEST sweeps
+ both dials then HOLD). Shipped in dotfiles 800ef60 with phase 3.
+5. [X] Bluetooth panel: same treatment end to end (faceplate + power
+ switch, adapter chip, paired/nearby lamp rows, pair passkey flow,
+ rename via set-alias, forget arm, battery gauges + LOW BATT, DOCTOR /
+ SCAN keys, output). bt smoke rewritten. Shipped in two commits mirroring
+ net: dotfiles 5318b34 (GTK-free layer + engine gaps) and 66f03d9 (view +
+ interactions + smoke). rename lands on the bluez Alias via busctl
+ (set-alias has no MAC-addressed one-shot); verified live on velox (smoke
+ green end to end, screenshot matches the prototype).
+6. [X] Live verification both panels on velox + all suites + smokes green;
+ summary of findings written to file; folded tasks closed; dead code
+ removed; session context finalized.
diff --git a/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org b/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org
new file mode 100644
index 0000000..f23e97d
--- /dev/null
+++ b/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org
@@ -0,0 +1,232 @@
+#+TITLE: Audio Panel — signal metering (which inputs/outputs actually have sound)
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-06
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Audio panel signal metering
+:PROPERTIES:
+:ID: baef9e7b-d255-4e80-9d10-68a7a3dd04fd
+:END:
+- 2026-07-06 Mon @ 10:40:00 -0500 — IMPLEMENTED — all 4 phases built TDD in dotfiles (6054d3d activity-state engine, 174ce14 three-state lamp, 55ab9f9 peak engine, 21437b4 live needles + lifecycle). Audio suite 124→161; full dotfiles make test green (49 suites). One residual: live eyeball checks (needle deflects under real audio, PTT INPUT registration, three-state lamp, no-CPU-when-closed) filed as a manual-testing task in todo.org.
+- 2026-07-06 Mon @ 10:02:00 -0500 — DOING — decomposing the four phases into the build (no-approvals speedrun).
+- 2026-07-06 Mon @ 10:00:00 -0500 — READY — spec-review round 2 passed (all 4 round-1 findings resolved DONE; both cookies complete; no blockers).
+- 2026-07-06 Mon @ 09:37:52 -0500 — DRAFT — drafted. Scopes "option 2" from the 2026-07-06 design discussion: an activity/peak metering layer on the audio panel, with a per-device-keyed engine so per-device rendering (option 3) is a later GUI-only change.
+
+* Metadata
+
+| Field | Value |
+|----------+--------------------------------------------------------------|
+| Status | implemented |
+|----------+--------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+--------------------------------------------------------------|
+| Reviewer | Claude Code (archsetup) |
+|----------+--------------------------------------------------------------|
+| Repo | dotfiles (=audio/= package); archsetup owns the lifecycle |
+|----------+--------------------------------------------------------------|
+| Related | [[file:2026-07-03-audio-panel-spec.org][audio-panel-spec (parent, IMPLEMENTED)]]; net/bt panels (kin) |
+|----------+--------------------------------------------------------------|
+
+* Summary
+
+The audio panel shows volume and mute per device, but nothing tells you which output or input actually has sound moving through it right now. The "twin VU needles" today are fed by =dev.get("volume")= (=gui.py:522=) — they track the fader, not the signal, so a muted-at-source-but-fader-up device reads full and an actively-playing device at low volume reads low. This spec adds a real signal-presence layer: a cheap activity state (is audio flowing?) driving a three-state lamp, plus a windowed peak meter (is there actual level?) feeding the needles, both keyed per device in the engine so the aggregate v1 view and a future per-device view (option 3) share one metering core.
+
+* Problem / Context
+
+Craig's ask, verbatim: "I would like an indication on the audio panel which outputs or inputs actually have sound coming out of them." The panel (parent spec =2026-07-03-audio-panel-spec.org=, IMPLEMENTED) presents each sink/source with volume + mute and an aggregate INPUT/OUTPUT console-key pair whose lamp is green-when-unmuted / red-when-muted (=panel.py:133= =control_lamp=). Neither surface answers "is this device carrying sound." Two things are conflated today:
+
+- *Mute state* — is the device armed? (Have now.)
+- *Signal presence* — is audio actually flowing / audible through it? (Missing.)
+
+"Which have sound coming out" is really two questions with two mechanisms:
+
+1. *Audio is flowing* (routing level) — the device is =RUNNING= with an uncorked stream. Cheap, already 90% available: =pactl -f json list sinks|sources= returns a per-device =state= field the engine currently drops in =pactl._device()= (=pactl.py:94=). Answers "is this device in use." Won't distinguish a stream playing silence from one making real sound.
+2. *Audible signal* (peak level) — real PCM peak above a floor, read from the sink's =.monitor= source (outputs) or the source directly (inputs). The only thing that truly answers "sound coming out," and what pavucontrol's moving bars show. Costs a live capture stream.
+
+The parent panel already carries the widgets to express both (the INPUT/OUTPUT lamps and the twin VU needles); they just have no signal behind them.
+
+* Goals and Non-Goals
+
+** Goals
+1. A per-device *activity* flag in the engine (device is carrying audio now), derived from the =pactl= =state= field, exposed through the status envelope.
+2. A *three-state* INPUT/OUTPUT lamp: muted, live-idle (unmuted, no signal), live-active (unmuted, audio flowing).
+3. A *windowed peak meter* feeding the twin VU needles from real level on the default sink's monitor and the default source — replacing the current volume-fed needle.
+4. Build the metering *engine keyed by device name* so a later per-device view (option 3) runs the same core over N devices with no engine rework.
+5. Zero cost when the panel is closed — the live meter runs only while the panel is visible.
+
+** Non-Goals
+- *Per-device rows with their own meters* (option 3). The engine is built to support it; the v1 GUI renders aggregate INPUT/OUTPUT only. Deferred to vNext.
+- *Live peak on the bar indicator.* The bar glyph stays state-based (=indicator.py=); a bar that meters continuously would run a capture stream forever. Out of scope.
+- *Per-application / per-stream metering* (which app is making the sound). Device-level only.
+- *Changing volume/mute/default/PTT behavior.* This is an additive read-only display layer.
+- *Configurable meter ballistics beyond a rate + a floor.* No user-facing VU calibration.
+
+** Scope tiers
+- v1: activity flag in engine; three-state aggregate INPUT/OUTPUT lamps; peak-fed twin VU needles for default sink + default source; panel-open-gated metering; per-device-keyed engine.
+- Out of scope: bar-indicator live peak; per-app metering; VU calibration UI.
+- vNext (log to todo.org): per-device *live level meter* — one =parec= reader per active device on each row (the activity *lamp* part of option 3 shipped in Phase 5); uncorked-stream refinement of the activity flag (see Decisions).
+
+* Design
+
+** For the user
+
+Open the panel. Each of the INPUT and OUTPUT console keys now carries a lamp with three readings instead of two: dark-red when that side is muted, a dim/idle green when it's live but nothing is playing, and a bright green when audio is actually flowing. The twin VU needles below move with the *real* signal — the OUTPUT needle rises when sound is genuinely coming out of the speakers (not merely because the fader is up), and the INPUT needle rises when the mic is picking up sound. Hold PTT and watch your own voice register on the INPUT needle — a live confirmation the mic works. Close the panel and nothing keeps running.
+
+The distinction is honest: the lamp says "this device is carrying audio"; the needle says "and here's how loud." A device that's RUNNING but playing silence shows a live-active lamp with a flat needle — which is the truth.
+
+** For the implementer
+
+Two independent signals, both per-device, layered onto the existing humble-object stack (=pactl.py= engine → =status.py= envelope → =panel.py= PanelModel → =viewmodel.py= pure helpers → =gui.py= thin view).
+
+*Activity (cheap, no new process).* Extend =pactl._device()= to carry =state= (=entry.get("state")=, normalized lower-case: =running= / =idle= / =suspended= / =None=). It rides through =build_status()= on each device dict already flagged =default=. In =panel.py=, add =control_active(control)= (the control's default device has =state == "running"=) and a three-state =control_lamp3(control)= → =muted= / =live-idle= / =live-active=. =gui.py= maps those to CSS classes on the existing lamp. Fully unit-testable — feed a status dict, assert the lamp.
+
+*Peak (richer, one managed process per metered device).* A new GTK-free =peak.py=: a =PeakReader(device_name, kind)= that spawns a native-rate mono float capture in its own process group (=parec -d <dev> --format=float32le --channels=1 --rate=44100 --latency-msec=40 --raw=, =start_new_session=True=; for a sink =<dev>= is its monitor resolved from =pactl.list_sources(include_monitors=True)= with =@DEFAULT_MONITOR@= as the default-sink fallback, for a source it's the source name), reads each ~40 ms flush on a worker thread, and reduces it to a window-peak in =[0.0, 1.0]=. A =PeakMeter= manager holds readers keyed by device name and kills each reader's process group on stop — v1 starts two (default sink's monitor, default source); option 3 starts N. Pure ballistics (decimate window-peaks to the display rate =AUDIO_METER_RATE=, normalize, attack/decay smoothing, noise floor) live in =viewmodel.peak_fraction()=, replacing the =volume=-fed =vu_fraction()= call at =gui.py:523=. =gui.py= starts the meter when the panel is shown and tears it down from a =close-request= handler on the window and =do_shutdown= on the application — the real close/quit path, since the app has no =hide= signal and quits when its last window closes — pushing peaks to the needle via =GLib.idle_add= (the existing =bg()= worker pattern). The lamp and the needle are independent: the lamp works with no peak stream at all, so a metering failure degrades to activity-only, never to a dead panel.
+
+Integration points (named): =pactl._device()= / =pactl.py:94=; =status.build_status()=; =panel.PanelModel.control_lamp= / =:133=; =viewmodel.vu_fraction= / =:72= and its caller =gui._set_vu= / =:520-523=; the panel =close-request= / =do_shutdown= lifecycle in =gui.py=; =tests/audio/= (fake-binary-on-PATH harness, mirroring the =pactl= fakes).
+
+* Alternatives Considered
+
+** Peak tool: parec vs pw-cat/pw-record vs a PA peak-detect stream
+- Good, because =parec= ships with =pipewire-pulse= (already the whole stack — =pactl= is the same layer), needs no new dependency, and float32 raw output reduces to a peak with trivial code. The classic VU-from-a-pipe approach.
+- Bad, because it's a managed subprocess per device — start/stop discipline matters (orphan =parec= = silent CPU leak).
+- Neutral, because =pw-cat --record= would work equally and =pw-dump= can't stream peaks; the choice is ergonomic, not capability-bound.
+
+** Activity source: state field alone vs state + uncorked sink-input check
+- Good (state alone), because it's one field already in the JSON, zero extra calls, and =RUNNING= is a strong "audio flowing" signal.
+- Bad, because PipeWire lingers a sink in =RUNNING= briefly after playback stops and can show =IDLE= with a corked stream attached — so state alone is slightly coarse.
+- Neutral, because the uncorked =sink-input=/=source-output= cross-check is a strictly-additive refinement (vNext) that doesn't change the v1 shape.
+
+** Metering lifecycle: always-on vs panel-open-only
+- Good (open-only), because it's zero cost when closed and the panel is the only place the meter renders.
+- Bad, because the bar indicator can't show live peak (acceptable — it stays state-based).
+- Neutral, because always-on would only matter if the bar wanted a live meter, which is a non-goal.
+
+** Aggregate v1 vs jump straight to per-device (option 3)
+- Good (aggregate first), because it de-risks the live-metering runtime (does peak read cleanly, is CPU acceptable, does the needle feel right) against one lamp before investing in per-device row layout + hotplug; and the parent panel's "aggregate only" decision stands.
+- Bad, because a second GUI pass is needed later for option 3.
+- Neutral, because the engine is keyed per-device now, so option 3 is GUI-only — no throwaway.
+
+* Decisions [/]
+
+** DONE Meter only while the panel is open — reap on app-quit, not just a hide signal
+CLOSED: [2026-07-06 Mon]
+- Context: a live peak stream is a running =parec= subprocess; leaving it on continuously (e.g. to feed the bar) burns CPU forever. *Round-1 review (code fact):* =gui.py= connects no =show=/=map=/=hide= signals, the window has no =set_hide_on_close(True)=, and the app takes no =hold()= — so closing the window (=do_activate= toggle =gui.py:218=, close button =:280=, Escape/q =:456=) destroys the last window and the =Gtk.Application= quits, exiting the process. Children spawned via =subprocess= are *not* reaped on interpreter exit; they reparent to init and keep running — exactly the orphan-CPU leak this feature names as its top risk.
+- Decision: We will (a) spawn each =parec= in its own process group (=start_new_session=True=) and kill the group on teardown, and (b) tear down from the real lifecycle — a =close-request= handler on the window *and* =do_shutdown= on the application — not a =hide= signal this app doesn't emit. Start metering when the panel is realized/shown. Resolve the sink's monitor from the graph (=pactl.list_sources(include_monitors=True)=, since =list_sources= drops monitors via =_is_monitor= at =pactl.py:83=), falling back to =@DEFAULT_MONITOR@= for the default sink. The bar indicator stays state-based; only the open panel meters.
+- Consequences: easier — near-zero cost when closed, bounded process lifetime, no orphans even on a straight quit; harder — teardown must hook the actual close/quit path, verified by a test that drives a real window-close / app-quit (not a synthetic hide) and asserts no surviving =parec= children.
+
+** DONE Peak via parec — capture at native rate, window in Python to the display rate
+CLOSED: [2026-07-06 Mon]
+- Context: need a per-device level stream; the stack is pipewire-pulse (no PulseAudio daemon), and =pactl= gives no peak. *Round-1 review verified live on ratio (2026-07-06):* =parec --rate= is the *sample* rate, not an update rate — there is no window flag — and at =--rate=25= with default latency parec flushed ~0 bytes in 3s; only an explicit =--latency-msec= produced timely output. parec has no peak-detect resampler (unlike pavucontrol's PA_RESAMPLER_PEAK), so a low =--rate= just decimates/aliases the transients a meter should show.
+- Decision: We will *capture at a real rate with a low flush latency and window in Python*: =parec -d <dev> --format=float32le --channels=1 --rate=44100 --latency-msec=40 --raw=, reading each ~40 ms flush and reducing it to =max(abs(sample))=, then decimating those window-peaks to the *display* rate =AUDIO_METER_RATE= (default 25 Hz). Capture rate and display rate are separate concepts: capture stays native/high for real transients; the needle updates at the display rate. =<dev>= is the sink's monitor for outputs (resolved from the graph — see the metering-lifecycle decision — with =@DEFAULT_MONITOR@= as the default-sink fallback) and the source name for inputs.
+- Consequences: easier — no new dependency, a genuine windowed level, testable with a fake =parec= emitting scripted float windows; harder — the reader owns the capture-vs-display-rate windowing (pure math in =viewmodel=), subprocess management, and a charset/validity guard on the device name before argv (reuse =pactl.valid_name=). This is a *windowed peak/level meter*, not a hardware true-peak meter — the wording elsewhere is tempered to match.
+
+** DONE Activity from the pactl state field (RUNNING = active)
+CLOSED: [2026-07-06 Mon]
+- Context: the three-state lamp needs an "is audio flowing" bit that's cheap and event-cheap.
+- Decision: We will capture the =state= field in =pactl._device()= and treat =running= as active; =idle=/=suspended=/=None= are not active. The uncorked-stream cross-check is deferred to vNext.
+- Consequences: easier — one field, no extra call, the lamp updates on the panel's existing re-reads; harder — a brief RUNNING linger after playback and a corked-stream-on-IDLE case make the lamp slightly coarse (documented; refinement is vNext).
+
+** DONE Engine keyed per device; v1 GUI renders aggregate
+CLOSED: [2026-07-06 Mon]
+- Context: Craig will want per-device metering (option 3) later; the peak primitive is inherently per-device.
+- Decision: We will build the activity flag on every device dict and the =PeakMeter= manager keyed by device name, while the v1 GUI meters only the default sink + default source. Option 3 becomes a GUI-only change.
+- Consequences: easier — no engine rework for option 3, per-device state is unit-testable now; harder — the manager carries multi-reader machinery the v1 view doesn't exercise (kept minimal, exercised by tests).
+
+** DONE Lamp and needle are independent; metering failure degrades gracefully
+CLOSED: [2026-07-06 Mon]
+- Context: the peak path can fail (no =parec=, monitor unavailable, permission) where the cheap activity path still works.
+- Decision: We will drive the three-state lamp from the (cheap, always-available) activity flag and the needle from peak, as independent signals. A peak failure leaves the lamp working and the needle at rest; it never breaks the panel.
+- Consequences: easier — honest partial degradation, matches the panel's verify-everything contract; harder — two code paths to keep decoupled (no shared failure).
+
+* Review findings [4/4]
+
+** DONE parec --rate=25 produces no usable meter stream :blocking:
+Round-1 review verified live on ratio: =parec --rate= is the sample rate, not an update rate, and at 25 with default latency it flushed ~0 bytes; there's no peak resampler. Resolved: the "Peak via parec" decision now captures at native rate with =--latency-msec=40= and windows to the display rate in Python; "true peak meter" tempered to "windowed peak/level meter"; acceptance now demands a live-audio deflection check.
+
+** DONE Orphan parec on app-quit (teardown hooks absent)
+Round-1 review (code fact): =gui.py= emits no show/hide, and the app quits when its last window closes, leaking =subprocess= children. Resolved: the metering-lifecycle decision now spawns each reader in its own process group (=start_new_session=True=), kills the group, and tears down from =close-request= + =do_shutdown= (not a hide); acceptance verifies reap through the real close/quit path.
+
+** DONE Three-state lamp needs the _set_lamp removal set extended
+Round-1 review (code fact): =_set_lamp= (=gui.py:100-104=) removes a hardcoded class list and treats no-class as green, so new lamp classes stack. Resolved: Phase 2 now specifies adding the three classes to the removal set (or a dedicated setter) via a =control_lamp3= render path.
+
+** DONE state is uppercase; monitors filtered from list_sources
+Round-1 review (live fact): =pactl -f json= emits =state= uppercase and =list_sources= drops monitors via =_is_monitor=. Resolved: Phase 1 now =.lower()=s state with uppercase fixtures; monitor resolution uses =list_sources(include_monitors=True)= with =@DEFAULT_MONITOR@= fallback.
+
+* Implementation phases
+
+** Phase 1 — Engine: capture device activity state
+Extend =pactl._device()= to carry a normalized =state= — =pactl -f json= emits it *uppercase* (=RUNNING=/=IDLE=/=SUSPENDED=), so =.lower()= it to =running=/=idle=/=suspended=/=None=; thread it through =status.build_status()= so each sink/source dict exposes it. Pure parser change, TDD against real =pactl -f json= sample shapes (fixtures carry the uppercase inputs so the =.lower()= is exercised). Tree stays working — added field, nothing consumes it yet.
+
+** Phase 2 — PanelModel + GUI: three-state activity lamp
+Add =panel.control_active(control)= and =panel.control_lamp3(control)= (=muted= / =live-idle= / =live-active=); unit-test the state machine. Wire =gui.py= via a three-state render path — the existing =_set_lamp= (=gui.py:100-104=) removes a *hardcoded* class list (=gold/red/off/busy=) and treats no-class as green, so the three new classes must be added to that removal set (or a dedicated lamp-setter) or stale classes stack. Ships the activity indicator (aggregate) with no peak stream involved.
+
+** Phase 3 — Peak engine: per-device peak reader
+New GTK-free =peak.py=: =PeakReader(device_name, kind)= (spawns =parec= at native rate + low =--latency-msec= in its own process group, reads/reduces flush windows, validity-guards the name) and a =PeakMeter= manager keyed by device name (start/stop/read, kills each reader's process group on stop). Pure ballistics in =viewmodel.peak_fraction()= (decimate window-peaks to the display rate, normalize, attack/decay, noise floor). TDD with a fake =parec= on PATH emitting scripted float windows + pure math unit tests, plus a reader-lifecycle test asserting the process group is reaped. Nothing renders it yet — tree works.
+
+** Phase 5 — per-device activity lamp (option 3, GUI-only)
+Promoted from vNext at Craig's request right after Phase 4. Each OUTPUTS/INPUTS row leads with its own three-state activity lamp (=viewmodel.device_lamp3(dev)= → =lamp3_class=): muted (red) / live-idle (dim) / live-active (bright green) for THAT device, so the panel shows which specific output/input is carrying audio, not just the default. Pure GUI + a pure viewmodel helper over the per-device =state= field already shipped in Phase 1 — no engine change, no new processes, rebuilt with each row on the status refresh. The per-row *live level meter* (N =parec= readers, one per active device) is the heavier remaining increment, still deferred.
+Repoint =gui._set_vu= from =viewmodel.vu_fraction(volume)= to the peak source via =viewmodel.peak_fraction=. Start the =PeakMeter= (default sink's monitor + default source) when the panel is shown; tear it down from a =close-request= handler *and* =do_shutdown= (the real quit path — the app quits when its last window closes), pushing peaks to the needles via =GLib.idle_add=. Update the AT-SPI smoke to assert the meter starts on show and that a window-close/app-quit leaves no =parec= child. Ships windowed signal metering (aggregate).
+
+* Acceptance criteria
+- [ ] Each device dict in =build_status()= carries a =state= of =running=/=idle=/=suspended=/=None=.
+- [ ] INPUT/OUTPUT lamp reads muted when the default device is muted, live-idle when unmuted and not =running=, live-active when unmuted and =running=.
+- [ ] With *real audio playing* to the default sink, the OUTPUT needle deflects and tracks level (manual live check — a resting needle under silence does not confirm this); with the sink silent (fader up, no stream), the needle rests near zero.
+- [ ] With the mic capturing sound, the INPUT needle deflects; muted or silent, it rests.
+- [ ] A window-close *and* an app-quit each leave no =parec= process running (verified through the real close/quit path, not a synthetic hide — the process-group kill reaps children).
+- [ ] A missing/failing =parec= leaves the three-state lamp working and the needle at rest — the panel still opens and functions.
+- [ ] Full dotfiles =make test= green; audio suite grows with new unit tests; AT-SPI smoke passes.
+
+* Readiness dimensions
+- *Data model & ownership:* all signal data is generated/live (never user-authored) — =state= per read, =peak= per window. Owned by the engine; the view renders, never persists. Device identity keyed by stable =name=.
+- *Errors, empty states & failure:* peak-path failure (no =parec=, monitor unavailable, permission) degrades to activity-lamp-only + resting needle, never a broken panel (Decision 5). Empty/absent default device → lamp reads muted/absent (existing =control_lamp= behavior), needle rests. No new user-facing error strings; failures are silent-graceful by design since this is a passive display layer.
+- *Security & privacy:* the peak stream reads amplitude only, never audio content, and nothing is recorded or logged — only a float level reaches the needle. Device names pass the existing =pactl.valid_name= charset guard before hitting =parec= argv. No secrets.
+- *Observability:* the meter *is* the observability surface. A =parec= spawn failure is not surfaced as an error toast (passive layer) but leaves the needle at rest; a debug hook (log the reader lifecycle under the audio package's existing debug gate, if any) is a nice-to-have, not required for v1.
+- *Performance & scale:* one =parec= per metered device, mono, 25 Hz — negligible CPU, and only while the panel is open. v1 meters exactly 2 devices. Option 3 scales to N sinks+sources; the manager caps readers to the visible set. Window reduction is O(samples/window); no per-frame allocation beyond the small buffer.
+- *Reuse & lost opportunities:* reuses the existing VU-needle Cairo widget (=gui.py:160=, shared with net/bt), =pactl.valid_name=, the =bg()= worker pattern, the fake-binary test harness, and the humble-object split. Repoints the already-present but volume-fed needle rather than adding a new widget.
+- *Architecture fit & weak points:* fits the =pactl→status→panel→viewmodel→gui= layering exactly; peak is a new peer engine module (=peak.py=) beside =pactl.py=. Weak point: subprocess lifecycle (orphan =parec=) — mitigated by process-group spawn + =close-request=/=do_shutdown= teardown + a child-reaping test. Second weak point: two independent signal paths — mitigated by keeping them decoupled (Decision 5).
+- *Config surface:* two optional keys with safe defaults — =AUDIO_METER_RATE= (the *display*/needle update rate, default 25 Hz; distinct from the fixed native =parec= capture rate, which is not a user knob) and =AUDIO_METER= on/off (default on; off falls back to activity-lamp-only). Named in the audio package =config.py=. No calibration surface (non-goal).
+- *Documentation plan:* update the audio package README/help to describe the three-state lamp and the signal needles (vs the old volume needle) and the two config keys. No migration doc — additive.
+- *Dev tooling:* existing =make test= / audio suite / AT-SPI smoke cover it; a fake =parec= binary joins the existing fakes. No new make targets needed.
+- *Rollout, compatibility & rollback:* additive display layer, no persisted data or API change. Rollback = set =AUDIO_METER=off= (needle falls back to resting / the prior volume feed can be kept as the off-state fallback) — spelled out at build. Ships to both machines via the dotfiles =common=/hyprland stow path like the rest of the panel.
+- *External APIs & deps:* =parec= (pipewire-pulse) and the =pactl -f json= =state= field are the two external assumptions. =parec= presence verified on ratio at build (it ships with the already-installed pipewire-pulse); the =state= field presence verified against live =pactl -f json list sinks= output in Phase 1 fixtures. No new package install expected — confirm =parec= on ratio before Phase 3, add to archsetup deps only if absent.
+
+* Risks, Rabbit Holes, and Drawbacks
+- *Orphan =parec= processes* — the top risk. Mitigation: process-group spawn (=start_new_session=True=), a single =close-request=/=do_shutdown= teardown owner in =gui.py= calling =PeakMeter.stop_all()= (kills each group), and a test asserting no surviving children through the real close/quit path.
+- *Monitor naming edge cases* — a sink whose =.monitor= name doesn't follow =<sink>.monitor= (rare, but bluez/virtual sinks vary). Mitigation: resolve the monitor source from the sinks/sources graph rather than string-appending =.monitor= where possible; fall back to the appended name; degrade to resting needle if the monitor can't be opened.
+- *RUNNING linger / corked-on-IDLE coarseness* — the activity lamp may show live-active a beat after sound stops. Accepted for v1; the uncorked cross-check is the vNext refinement.
+- *Meter ballistics feel* — attack/decay tuning is a taste call; the pure =peak_fraction= math makes it adjustable without touching GTK, and the live feel is Craig's eyeball check (manual-testing task).
+
+* Testing / Verification / Rollout
+Unit: =pactl._device()= state parsing (each state + missing); =panel.control_active/control_lamp3= truth table (muted / live-idle / live-active / absent); =viewmodel.peak_fraction= ballistics (floor, clamp, decay, None); =peak.PeakReader= window→peak reduction and name-guard rejection via a fake =parec=; =PeakMeter= start/stop/reap (no orphan). Integration/smoke: AT-SPI smoke asserts meter starts on show and stops on close. Manual (Craig, filed to todo.org): play audio and confirm the OUTPUT needle tracks real level not fader; speak and confirm the INPUT needle + PTT registration; confirm the lamp's three states live; confirm no CPU when closed. Rollout: per-phase commit+push to dotfiles main, =make test= green gate each phase, note dotfiles at the end (archsetup-owns-dotfiles).
+
+* Review and iteration history
+** 2026-07-06 Mon @ 09:37:52 -0500 — Claude Code (archsetup) — author
+- What: initial draft scoping option 2 (activity + peak metering, per-device-keyed engine, aggregate GUI).
+- Why: Craig asked for an indication of which inputs/outputs actually have sound; the design was settled in the 2026-07-06 session (two signal levels, three-state lamp, engine keyed per device so option 3 is GUI-only).
+- Artifacts: parent spec 2026-07-03-audio-panel-spec.org; engine facts grounded in pactl.py:94, panel.py:133, viewmodel.py:72, gui.py:520-523; audio suite green baseline 124 tests.
+
+** 2026-07-06 Mon @ 09:52:00 -0500 — Claude Code (archsetup) — reviewer
+- What: round-1 spec-review with live verification on ratio. One blocking finding (parec --rate is sample-rate not update-rate; no peak resampler; flushes nothing at rate 25 without --latency-msec) and three non-blocking (orphan parec on app-quit since gui has no hide signal; _set_lamp hardcoded class-removal list; uppercase state + monitors filtered from list_sources). Rubric: Not-ready pending the parec-command fix. Every other code reference confirmed accurate.
+- Why: the peak half of the feature rested on an incorrect external assumption verified false live; the teardown risk the spec named as top-risk had no concrete hook in the real lifecycle.
+- Artifacts: findings recorded in * Review findings.
+
+** 2026-07-06 Mon @ 09:54:00 -0500 — Claude Code (archsetup) — responder
+- What: accepted all four findings and folded them in — rewrote the parec decision (native capture + --latency-msec + Python windowing, display/capture rates separated), the metering-lifecycle decision (process-group spawn + close-request/do_shutdown teardown + graph-resolved monitor with @DEFAULT_MONITOR@ fallback), Phase 1 (.lower() uppercase state), Phase 2 (_set_lamp removal set), Phase 3/4 (command + real-quit teardown), acceptance (live deflection + real-quit reap), and config (display vs capture rate).
+- Why: close the blocking gap and the three code-accuracy gaps before a re-review.
+- Artifacts: * Review findings all DONE; Decisions updated.
+
+** 2026-07-06 Mon @ 10:15:00 -0500 — Claude Code (archsetup) — reviewer
+- What: round-2 re-review of the folded spec. Confirmed the parec fix (native 44100 + --latency-msec=40 → ~25 workable window-peaks/sec), the process-group + close-request/do_shutdown teardown, the _set_lamp removal-set fix, and the uppercase-state/.lower() + @DEFAULT_MONITOR@ fixes. Rubric: Ready, no remaining blockers; one cosmetic wording note (stale "show/hide"/"true peak" in Goals/Risks) cleaned in the same pass. Flipped DRAFT→READY→DOING.
+- Why: verify the fixes resolve the round-1 findings before build.
+- Artifacts: review verdict; spec cosmetic cleanup.
+
+** 2026-07-06 Mon @ 10:40:00 -0500 — Claude Code (archsetup) — implementer
+- What: built all four phases TDD in the dotfiles audio/ package and flipped the spec IMPLEMENTED. Phase 1 pactl state capture (6054d3d); Phase 2 three-state activity lamp + viewmodel.lamp3_class + CSS (174ce14); Phase 3 peak.py windowed-peak engine + viewmodel.peak_fraction + fake-parec harness (55ab9f9); Phase 4 live VU needles + PeakMeter lifecycle + config keys + smoke orphan-check (21437b4). Audio suite 124→161; full make test green (49 suites); no-orphan reaping unit-tested.
+- Why: complete the no-approvals speedrun through to shipped code.
+- Artifacts: dotfiles commits 6054d3d..21437b4; manual-testing task filed in archsetup todo.org.
+
+** 2026-07-06 Mon @ 11:10:00 -0500 — Claude Code (archsetup) — implementer
+- What: promoted the per-device activity lamp (option 3) from vNext to shipped (Phase 5). viewmodel.device_lamp3 + a leading three-state lamp on every OUTPUTS/INPUTS row; dotfiles 1faae65. Audio suite 161→166. Verified live via geometry-cropped grim: with a tone to the default sink, pactl reads it RUNNING (others SUSPENDED) and the row lamps render red=muted / green=live per device. The per-row live level meter (N parec) stays the deferred vNext.
+- Why: Craig wanted the per-device view; the engine was already keyed per device so this was a GUI-only follow-on.
+- Artifacts: dotfiles 1faae65; smoke green; manual check added to todo.org.
diff --git a/docs/specs/2026-07-07-maintenance-console-spec.org b/docs/specs/2026-07-07-maintenance-console-spec.org
new file mode 100644
index 0000000..9810fe8
--- /dev/null
+++ b/docs/specs/2026-07-07-maintenance-console-spec.org
@@ -0,0 +1,350 @@
+#+TITLE: Maintenance Console — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-07
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Maintenance console
+:PROPERTIES:
+:ID: 9d9df833-c592-4aec-a7df-50d588e943ce
+:END:
+- 2026-07-08 Wed @ 06:18:14 -0500 — IMPLEMENTED — all 14 build phases shipped (1-11 dotfiles 43a39ac..10033be, 11b 3ee22a8, 12 archsetup d6993d3 + dotfiles 0636554, 13 archsetup bef7053 + 18c081f + dotfiles 9a3f0c7). Closing state: maint CLI + GTK panel + waybar glyph live on both hosts; installer wires TOML, timers, and deps; system-health-check workflow adopted and TOML-rewired; VM scenario harness 9/9, nspawn 4/4, AT-SPI smoke green; per-phase review subagents all landed Approve. Residuals tracked in todo.org: manual-testing checklist, zfs base-image DKMS bug [#C], vNext [#D].
+- 2026-07-07 Tue @ 19:54:04 -0500 — DOING — decomposed into the build: 13 phase tasks + manual-testing + flip task under the todo.org parent "Maintenance console build" (:SPEC_ID: stamped); vNext logged [#D].
+- 2026-07-07 Tue @ 19:05:00 -0500 — READY — round-2 spec-review passed (all 10 round-1 findings resolved, no new blockers); Craig approved the config-paths proposal and the sysmon right-click re-homing, closing the last open decision (cookies 13/13, 10/10).
+- 2026-07-07 Tue @ 18:30:04 -0500 — DRAFT — drafted from the completed design arc (docs/design/maintenance-console-design-ideas.org, all decisions dated 2026-07-06/07) and the converged E5 interactive prototype.
+
+* Metadata
+
+| Field | Value |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| Status | implemented |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| Reviewer | Claude Code (archsetup) |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| Repo | dotfiles (=maint/= package); archsetup owns lifecycle, thresholds TOML, VM test harness |
+|----------+-----------------------------------------------------------------------------------------------------------|
+| Related | [[file:../design/maintenance-console-design-ideas.org][design doc (source of decisions)]] ; [[file:../prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5 prototype (converged UI)]] ; [[file:2026-07-03-instrument-console-panels-spec.org][instrument-console-panels-spec]] ; |
+| | todo.org "Maintenance console" |
+|----------+-----------------------------------------------------------------------------------------------------------|
+
+* Summary
+
+A single-host maintenance console for the two daily drivers (ratio, velox): a =maint= CLI + GTK panel in dotfiles (fourth panel sibling beside net/bt/audio) that surfaces every Arch health metric and runs the safe remediations behind Confirm/arm guards, streaming results to a live output wall. archsetup owns the severity thresholds (one TOML both the console and the system-health-check workflow read) and the VM harness that tests the destructive remedies.
+
+* Problem / Context
+
+Routine maintenance on these machines today is either the ~1000-line agent-driven =system-health-check.org= workflow (forensic, slow, requires a session) or ad-hoc habit — and things fall through: ratio ran at ~75% unclean shutdowns for weeks unnoticed (2026-06-08), a snapper TIMELINE misconfig nearly filled =/home=, a live =-Syu= swapped mesa+hyprland under the running session and crashed the desktop (2026-06-07), and =rust= sat looking orphaned until a manual review found it was intentional. There is no glanceable surface that says "your machine is healthy" or "these three things need a press."
+
+The console owns the routine-maintenance and at-a-glance slice; the workflow remains the escalation path for forensic investigation. The design converged over 2026-07-06/07 through five divergent prototypes to E5 (selector + dense subpanels + results wall); every open question in the design doc is resolved and dated there. This spec makes that design buildable.
+
+* Goals and Non-Goals
+
+** Goals
+1. Every metric in the design doc's tables collected read-only on both hosts, capability-dispatched (btrfs/AMD on ratio, ZFS/Intel/battery on velox), exposed as =maint status --json=.
+2. Every determinate remedy as =maint fix <thing>= — allowlisted exact argv, global =--dry-run=, privileged verbs through a validated priv table.
+3. Two doctor actions — CLEAN UP (all Auto metrics, unattended) and REVIEW & FIX (Confirm metrics, per-item approval) — streaming one lamp per action to the results wall.
+4. The E5 GUI: category selector, dense faceplate subpanels, rotary band selectors on tall subpanels, evidence digests, curation lifecycles (MARK KNOWN / KEEP / MARK EXPECTED with UNMARK/CLEAR), arm-to-fire guards, updates strip with the live-update guard.
+5. Severity thresholds in one archsetup-owned TOML; user curation in a separate layer a template sync can never eat.
+6. Waybar glyph (replacing =custom/sysmon=) tracking the worst *Diagnostic* state, fed by a background timer so the bar stays honest with the panel closed; on battery hosts the glyph doubles as the bar's battery level display.
+7. Four-layer test coverage: unit fakes, read-only live, VM remedies via archsetup's harness with qcow2 snapshot/restore, AT-SPI smoke on fixtures derived from the E5 GOOD/BAD snapshots.
+
+** Non-Goals
+- *No AI/workflow assistance from the panel.* Workflow buttons were removed in prototyping (2026-07-07); metrics that need judgment render read-only. vLater.
+- *No auto-updates, ever.* Updates run only via the guarded Confirm levers; the guard arms, the user fires.
+- *No file-deletion keys in the disk top-consumers digest* — evidence only.
+- *No SIGKILL escalation* on the KILL lever (SIGTERM only; escalation is vLater).
+- *No btrfs device-error counter reset button* — resetting without diagnosis masks a dying drive; stays manual.
+- *No per-ecosystem update metrics* (cargo/npm/pipx/…): one topgrade-freshness metric + the fwupd count instead.
+- *No net/bt doctor retrofit here* — separate todo task; only the results-wall idiom is shared.
+
+** Scope tiers
+- v1: everything under Goals — all metrics from the design-doc tables including the full-sweep findings ("all committed to v1", 2026-07-07), both hosts, CLI + GUI + glyph + timers + four test layers + archsetup TOML install.
+- Out of scope: the Non-Goals above.
+- vNext (logged to todo.org at hand-off): AI-assist actions on read-only metrics; SIGKILL escalation; per-row live meters where evidence rows could stream.
+
+* Design
+
+** For the user
+
+Click the bar glyph (it replaced the old sysmon readout; its color is the worst diagnostic state — green/amber/red; on the laptop its text is the live battery level). The console opens: a faceplate row of category keys (STORAGE · SNAPSHOTS · PACKAGES · SYSTEMD · LOGS · MEM·PWR · NETWORK · SERVICES), each carrying its own status lamp and a "N fixable · M watch" split so you know your agency before reading a cell. Selecting a category renders its dense subpanel: every metric a row with lamp, value, and — only when off-nominal — a contextual lever. Tall subpanels (Packages, Logs, Services) carry a rotary band selector (click to cycle: e.g. ORPHANS · PACNEW · ADVISORIES), one band rendered at a time at full width, each band showing its own lamp + count on the dial.
+
+Counts never hide detail: orphans list name + size with per-package REMOVE (armed) and KEEP; journal errors group by identifier with first/last seen and the exact =journalctl= command; listeners show process · port · bind address. Where "expected" is knowledge, it's curated: MARK KNOWN / MARK EXPECTED / KEEP move items to a dim section (never vanish) with UNMARK per row and CLEAR for the layer; every mark logs to the wall.
+
+Anything consequential arms before it fires: first press turns the key red and shows exactly what will run (the dry-run argv); second press runs it. The two doctor keys — CLEAN UP and REVIEW & FIX — and every remedy stream into the results wall at the bottom: one lamp per action, amber while running, green with the result inline (reclaimed MBs, restarted unit), red on failure; date+time stamped, HIDE/COPY, capped at 3.5 visible entries with the half-entry as scroll cue. The updates strip shows pending/AUR/firmware/CVE counts with a state-tiered border; UPDATE and TOPGRADE sit behind the live-update guard — when mesa/hyprland/wayland runtime is in the pending set the key arms with "press again to run anyway — or apply from a TTY". After an update lands, a REBOOT key (armed) appears.
+
+Temps, memory, and throttle re-read every ~3 s while visible; local probes every ~30 s and immediately after any action that touches them; network-tier counts (updates, CVEs, AUR, firmware) come from an hourly timer cache with their age shown. Close the panel and nothing keeps running except the light half-hourly background scan that feeds the glyph.
+
+** For the implementer
+
+*Package shape.* =maint/= in dotfiles mirrors the sibling humble-object stack (=net/=, =audio/=): =src/maint/= with =probes/= (one module per category, read-only collectors that parse command output), =schema.py= (the status envelope), =status.py= (assembles =maint status --json=), =thresholds.py= (TOML load + user-layer merge), =curation.py= (marks/keeps/expected stores, read+write), =remedies.py= (every lever as an allowlisted exact argv), =priv.py= (the closed privileged-verb table, validated args, =sudo= via =MAINT_SUDO= env — the net =priv.py= pattern verbatim), =guard.py= (pure live-update guard over a package list), =doctor.py= (CLEAN UP / REVIEW & FIX sequencing, streaming), =cli.py=, =viewmodel.py= (pure render helpers), =gui.py= (thin GTK4 view), =indicator.py= (glyph state). Tests in dotfiles =tests/maint/= with fake binaries on PATH (the fake-pactl/fake-parec pattern) — fake-smartctl, fake-btrfs, fake-zpool, fake-pacman, fake-journalctl, fake-ss, fake-docker, fake-systemctl, etc.
+
+*The contract.* =maint status --json= emits one envelope: host capabilities (fs type, pstate driver, battery present, docker/libvirt present — "battery present" means a =/sys/class/power_supply/= entry with =type == Battery=, never a non-empty dir: ratio carries Mains + USB-C PD entries with no battery), then per-category metric dicts each carrying =id=, =value=, =severity= (=ok=/=warn=/=crit=), =label=, optional =evidence= (list of digest rows), optional =levers= (list of remedy ids applicable right now), =probed_at=, and =cache_age= for cached metrics (network-tier and slow-local alike). Severity is always computed engine-side from the thresholds TOML — the GUI never holds a threshold. The E5 prototype's GOOD/BAD data snapshots are reshaped to conform to this schema and become the GUI fixtures (=MAINT_PANEL_FIXTURE=bad= renders the degraded board on a healthy machine).
+
+*Remedies.* Each remedy is =(id, argv, priv_verb_or_none, arm_required, re_probe_ids)=. =maint fix <id> [--dry-run]= prints the exact argv under =--dry-run= — the same string the GUI's arm-press displays. Collectors never elevate; only =priv.py= verbs touch =sudo=, and archsetup's install already grants NOPASSWD (=archsetup:1178=). After a remedy fires, the engine re-probes the metrics named in =re_probe_ids= (fire CLEAN → cache re-measured, not assumed). Long-running remedies (btrfs/zfs scrub, SMART self-test, rsyncshot RUN NOW) report a running-% state to the wall, not an instant reset. KILL revalidates PID + process name at fire time and refuses session-critical names (systemd, the compositor, the panel itself → disabled key).
+
+*Curation stores.* Shipped defaults (known-noise patterns, expected listeners sshd/mpd/tailscaled, expected containers winvm, pacnew safe-delete allowlist) live in the archsetup-shipped TOML; user marks live in the user layer, merged over it. Journal noise patterns bind to identifier + message snippet, never a whole unit. Unmarking a shipped default writes a disable flag in the user layer; CLEAR MARKS empties the user layer only.
+
+*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).
+
+*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).
+
+* Alternatives Considered
+
+** Console vs workflow-only maintenance
+- Good (console), because glanceable health + one-press remedies catch the rot the workflow only finds when explicitly run; the workflow stays for forensics.
+- Bad, because it's a large build (this spec's phase count) duplicating some workflow probe logic.
+- Neutral, because the shared thresholds TOML keeps the two from drifting — the duplication is probes, not policy.
+
+** GUI-first vs CLI-first
+- Good (CLI-first), because =maint status --json= + =maint fix= are testable without a display, the VM harness needs exactly that, and the GUI reduces to a face over a verified engine (the sibling panels' proven shape).
+- Bad, because the GUI lands late in the phase order — no visible console until ~P7.
+- Neutral, because the E5 prototype already de-risked the visual design; what's left to prove is the engine.
+
+** Layout: A–E divergents, then 3-column compression vs rotary bands
+- Good (E5 selector + subpanel), because Craig picked E from five divergent prototypes and iterated it live through E2–E5; the selector gives every category full width.
+- Bad, because one-category-at-a-time hides cross-category state — mitigated by per-key lamps + fixable/watch splits on the selector row.
+- Neutral, because the rotary band selector replaced the failed 3-column compression (third-width truncation lost row detail — Craig's verdict after use); MEM·PWR keeps its 3-column strip because its rows are short.
+
+** Update visibility: per-ecosystem metrics vs topgrade freshness
+- Good (freshness), because topgrade's step set has no cheap offline "updates available?" probe — mirroring it costs a network round-trip per registry at panel-open.
+- Bad, because the panel can't say "3 cargo crates stale" — only "topgrade last ran N days ago".
+- Neutral, because fwupd refreshes its own metadata cache, so firmware gets a real count for free.
+
+** Elevation: polkit/pkexec per action vs priv-verb table + sudo
+- Good (priv table), because the net panel already proved it: a closed validated verb set, NOPASSWD covered by archsetup's install, no auth dialog stacking on top of the arm-to-fire press.
+- Bad, because the sudoers grant is broad (archsetup currently writes NOPASSWD: ALL) — the verb table is the real narrow waist, so it must stay small and auditable.
+- Neutral, because pkexec would add a second confirmation UI the arm press already provides.
+
+* Decisions [13/13]
+
+** DONE CLI-first architecture; GUI is a face
+CLOSED: [2026-07-07 Tue]
+- Context: four test layers need a display-free engine; the sibling panels' humble-object stack is proven; the VM harness can only drive a CLI.
+- Decision: We will ship =maint= as engine + =cli.py= with =maint status --json= as the contract and =maint fix <id>= as every lever; =gui.py= renders the same code and never does anything the CLI can't. Global =--dry-run= prints the exact argv.
+- Consequences: easier — testability, VM scenarios, the arm-press preview is the dry-run string for free; harder — the GUI arrives late in the phase order, and the JSON schema becomes a real contract that fixtures and tests pin.
+
+** DONE Determinate remedies only in v1
+CLOSED: [2026-07-07 Tue]
+- Context: workflow buttons were prototyped and removed (E5, 2026-07-07); indeterminate fixes (failed units with unknown cause, journal errors, OOM) need judgment.
+- Decision: We will ship only fixed, scriptable actions with predictable outcomes — the design doc's determinate-remedies list. Judgment metrics render read-only with expectation tags ("hardware — watch only", "evidence below"). AI/workflow assistance is vLater.
+- Consequences: easier — every remedy is an exact argv, auditable and VM-testable; harder — red diagnostic cells offer no in-panel next step beyond evidence + the named command.
+
+** DONE Updates behind an arming live-update guard
+CLOSED: [2026-07-07 Tue]
+- Context: the 2026-06-07 crash was a live =-Syu= swapping mesa+hyprland under the session; the standing rule is never update the graphics/wayland runtime live. Hard-refusing infantilizes the user.
+- Decision: We will put UPDATE (repo+AUR) and TOPGRADE on the updates strip as Confirm levers; when the pending set contains mesa/hyprland/wayland-runtime packages the key arms red with "press again to run anyway — or apply from a TTY". The topgrade wrapper always passes =--disable git= (its git step rebase-autostashes =~/code/*/=). After an update lands, an armed REBOOT key appears and the reboot-required metric flips. The strip border is state-tiered (green/amber/red by pending/CVE/staleness thresholds in the TOML).
+- Consequences: easier — updates finally have a sanctioned in-panel path with the footgun named at press time; harder — the guard's package-set matcher must be right (pure function, heavily unit-tested), and the wrapper owns topgrade's flag discipline.
+
+** DONE Doctor actions stream to a results wall
+CLOSED: [2026-07-06 Mon]
+- Context: fire-and-forget buttons hide failures; Q1 resolved 2026-07-06.
+- Decision: We will stream every doctor and every individual remedy to the wall — one lamp per action, amber running / green with result inline / red failed, always shown, date+time stamped, HIDE/COPY, 3.5-entry scroll cap. Long ops report running-%.
+- Consequences: easier — failures are visible the moment they happen, and mark/unmark curation events get a durable in-session log; harder — remedies need incremental output plumbing (engine yields events; GUI appends), not just exit codes.
+
+** DONE Both hosts first-class; capability dispatch from day one
+CLOSED: [2026-07-06 Mon]
+- Context: Q2 resolved 2026-07-06 — velox travels and suspends; its GAP metrics (battery health, unclean-shutdown rate) motivated the console.
+- Decision: We will probe host capabilities at startup (fs types, pstate driver, battery, docker/libvirt) and run only applicable collectors; ZFS paths (scrub, autotrim, capacity, frag) are built and tested, not stubbed; velox-only metrics ship in v1.
+- Consequences: easier — one codebase, no ratio-first debt; harder — every collector's tests need both-host fixtures. ZFS remedy scenarios are possible in the VM (the harness supports =FS_PROFILE=zfs= base images — =vm-utils.sh:23,53-55=; prerequisite: confirm the zfs base image builds); the floor if that image proves troublesome is argv-construction tests + read-only live runs on velox.
+
+** DONE Thresholds and curation as layered TOML, archsetup-owned
+CLOSED: [2026-07-06 Mon]
+- Context: Q3 resolved 2026-07-06 — severity values must not drift between the console and the system-health-check workflow; template syncs must never eat user curation.
+- Decision: We will put every severity value (cache trigger, disk %, scrub-age bands, snapshot limits, temp bands, staleness windows, "a lot" of updates) plus shipped curation defaults in =maintenance-thresholds.toml=, owned and installed by archsetup, seeded from the workflow's hard-won values. User marks/keeps/expected live in a separate user-layer file merged over it. Both the console and the workflow read the installed TOML.
+- Consequences: easier — one source of truth, workflow and console can't disagree, thresholds tunable without touching code; harder — archsetup grows an install/update step for the TOML, and the merge semantics (including disable-flags for shipped defaults) need explicit tests.
+
+** DONE Evidence digests + curation lifecycles on every count metric
+CLOSED: [2026-07-07 Tue]
+- Context: the full-sweep audit (2026-07-07) committed all findings to v1; a bare count trains you to ignore it, and "expected" is configuration knowledge that today lives in Craig's head (the rust orphan lesson, winvm, sshd).
+- Decision: We will give every count an evidence digest (orphans name+size, journal groups by identifier with first/last seen + next command, listeners process·port·bind, coredumps by binary, docker df by type, snapshots split timeline/single/pre-post, top-5 RAM, recent boots clean/unclean) and every "expected"-shaped metric the full curation lifecycle: MARK KNOWN / KEEP / MARK EXPECTED with per-row UNMARK, CLEAR for the user layer, marked items dimmed but never hidden, every transition logged to the wall. Guarded per-item remedies ride the digests (orphan REMOVE armed + REMOVE ALL skipping keeps, pacnew MERGE via terminal diff, per-unit RESTART+RESET, per-container START, per-socket STOP/KILL armed, DELETE STALE keeping newest 2). KILL carries four guards: arm shows exact victim, SIGTERM only, PID+name revalidated at fire, session-critical names disabled.
+- Consequences: easier — the panel is honest and the curation encodes system knowledge durably; harder — this is most of the GUI surface area and the reason the subpanel phases are the fattest.
+
+** DONE Rotary band selector on tall subpanels
+CLOSED: [2026-07-07 Tue]
+- Context: 3-column compression on Packages/Logs/Services lost row detail to third-width truncation (Craig's verdict after use).
+- Decision: We will render tall subpanels one band at a time behind a rotary selector (click to cycle; engraved band labels each with own lamp + count; gold underline + needle on the selected band): Packages ORPHANS·PACNEW·ADVISORIES, Logs SIGNAL·KNOWN NOISE·COREDUMPS·KERNEL/HW, Services CONTAINERS·DOCKER DISK·CRON & BACKUPS. MEM·PWR keeps its 3-column strip (short rows fit).
+- Consequences: easier — full-width row detail restored, and each selection idiom in the panel keeps a distinct visual voice; harder — off-band state must surface on the dial lamps or a red band goes unseen.
+
+** DONE Four-tier refresh cadence + glyph timer; glyph replaces custom/sysmon, shows battery on laptops
+CLOSED: [2026-07-07 Tue]
+- Context: probe cost varies from a sysfs read to a network round-trip; the bar must stay honest with the panel closed. Craig (2026-07-07, spec session): the maint glyph replaces =custom/sysmon= rather than adding a module beside it, and on a laptop it should function as the bar's battery level display.
+- Decision: We will refresh in tiers matched to probe cost (live ~3 s visible; fast local ~30 s open + post-action; hydration on open; network hourly cache with age shown, on-demand refresh; a slow-local tier for expensive local probes — full =-Qkk= at 47 s measured, disk top-consumers — riding the same hourly cache), run a ~30-min =maint-scan.timer= writing the glyph state file, and wire =custom/maint= in waybar in place of =custom/sysmon=. The glyph's *color* tracks the worst *Diagnostic* state only — actionable clutter (a fat cache) never colors the bar. On battery hosts (capability-gated, same host probe) the module's *text* is the battery level with a charging indicator, read directly by =waybar-maint= on waybar's own interval (a 30-min scan is too coarse for charge %); a critically-low charge (threshold in the TOML) feeds the diagnostic state so the color stays honest when the battery is the emergency. Any existing waybar battery module on the laptop retires in favor of this one.
+- Consequences: easier — panel-open cost is bounded, the bar signal stays high-trust, and the laptop bar loses a module instead of gaining one; harder — the glyph script has two data sources (state file for diagnostics, sysfs for charge), and the old sysmon affordances resolve per Craig's 2026-07-07 ruling: the btop scratchpad (=pypr toggle monitor=) re-homes to the maint glyph's right-click; =sysmon-cycle= and the =waybar-sysmon= readout retire.
+
+** DONE Privileged remedies via a maint-priv verb table
+CLOSED: [2026-07-07 Tue]
+- Context: most remedies need root (systemctl, paccache, journalctl --vacuum, scrub, charge threshold); collectors must never elevate; the net panel's =priv.py= already proved the pattern.
+- Decision: We will route every privileged remedy through =maint/priv.py=: a closed verb table, one exact command per verb, args validated by type-specific regexes before reaching sudo, =MAINT_SUDO= env override so tests run fakes directly. Read/write split is hard: probes never import priv.
+- Consequences: easier — one small auditable module is the entire privilege surface, VM tests exercise the same verbs; harder — every new remedy is a deliberate verb-table addition, never an inline =sudo=.
+
+** DONE Four-layer test strategy; E5 snapshots become fixtures
+CLOSED: [2026-07-07 Tue]
+- Context: remedies mutate systems; the archsetup VM harness (=scripts/testing/run-test.sh=) exists; the sibling panels' fake-binary and AT-SPI patterns are proven.
+- Decision: We will test in four layers — (1) unit with fake binaries on PATH (~90% of surface: probes as parsers, remedies as argv construction, guard as pure function), (2) read-only =maint status --json= on the live hosts, (3) remedies in the VM with scenario scripts that break things over ssh then assert post-state, systemd-nspawn for pure pacman-level tests, (4) AT-SPI GUI smoke on the host driven by fixtures conforming to the JSON schema, derived from the E5 GOOD/BAD snapshots. No GUI in the VM. VM isolation policy: the snapshot primitives already exist (=lib/vm-utils.sh:303-357= — create/restore/delete via =qemu-img=, VM must be stopped for any snapshot op; =run-test.sh= already reverts to a =clean-install= snapshot), so the new work is scenario *orchestration* only. Scenarios are grouped into non-conflicting batches that run within one VM boot (a stopped-cronie scenario doesn't contaminate a fat-cache scenario); a stop → restore → reboot cycle runs only *between destructive groups* whose post-state would contaminate the next batch. The scenario runner declares each scenario's group so the batching is explicit, not inferred.
+- Consequences: easier — destructive coverage without risking real machines, and the harness work is smaller than feared (orchestration over existing primitives); harder — VM runs are slow (~40–60 min), so grouping discipline matters, and a scenario mis-grouped as non-conflicting can produce order-dependent failures (the runner can randomize in-group order to surface these).
+
+** DONE system-health-check workflow moves home → archsetup
+CLOSED: [2026-07-06 Mon]
+- Context: Craig's call (2026-07-06) — home is scoped to finances/health/personal; system maintenance is archsetup's domain, and the move collapses the TOML ownership coupling.
+- Decision: We will move =system-health-check.org= + =homelab-inventory/*.org= into archsetup in the final phase, rewire the workflow to read the installed TOML instead of prose severity rules, and hand the home-side removal (originals, startup references, index) to home via an inbox note — never edited blind from here.
+- Consequences: easier — TOML producer and both consumers land in one project; harder — sequencing: the move waits for the TOML to exist and ship, so it anchors the last phase.
+
+** DONE Config and state file locations
+CLOSED: [2026-07-07 Tue]
+- Context: four files need stable homes both consumers reach: the shipped thresholds+defaults TOML (archsetup-installed, design doc suggests =~/.config/archsetup/maintenance-thresholds.toml=), the user curation layer, the glyph state file, and the hourly network-tier + slow-local cache (round-1 review: this one must survive reboots, or every boot shows "no data" for up to an hour — =$XDG_RUNTIME_DIR= is wrong for it). Sibling precedent: panels read =~/.config/<pkg>/config= (audio =config.py:29=). Craig approved the proposal 2026-07-07.
+- Decision: We will install the shipped TOML at =~/.config/archsetup/maintenance-thresholds.toml=; the user layer at =~/.config/maint/curation.toml= (alongside an optional =~/.config/maint/config= for GUI prefs per sibling convention); the glyph state and the hourly cache both under =~/.local/state/maint/= (XDG state — persists across reboots; the glyph file is cheap to rebuild but gains nothing from being ephemeral). Env overrides for all four so tests and fixtures never touch real files.
+- Consequences: easier — XDG-conventional, stow-safe, sync-safe (curation outside any synced tree), honest counts right after boot; harder — two config roots (=archsetup/= for shipped, =maint/= for user) must be documented or they'll confuse future edits.
+
+* Review findings [10/10]
+
+** DONE Automation-tier normativity contradiction :blocking:
+Round-1: the References section declared the design-doc tables normative for Automation tier, but the tables mark failed-unit restart / pending updates / AUR staleness / =-Qkk= reinstall as Workflow while the 2026-07-07 decisions give them Confirm levers — an implementer couldn't derive the REVIEW & FIX set. Resolved: References now scopes the tables to metric *existence* only; the dated decisions win on tier.
+
+** DONE VM scenario isolation policy undefined :blocking:
+Round-1 (code fact): =vm-utils.sh:301= requires the VM stopped for snapshot ops, contradicting "restore between scenarios" + "batch per boot" as written; snapshot primitives also already exist (=vm-utils.sh:303-357=), so "new harness work" overstated. Resolved: Decision 11 now states the grouped-batch policy (non-conflicting scenarios per boot; stop/restore/reboot only between destructive groups; runner declares groups) and reframes the work as orchestration over existing primitives.
+
+** DONE Expensive local probes had no refresh tier :blocking:
+Round-1 (measured live): full =pacman -Qkk= runs 47 s on ratio; disk top-consumers is the same cost class; neither fit fast-local (~30 s) nor hydration. Resolved: a slow-local tier added — both ride the hourly timer cache with age shown, on-demand refresh only.
+
+** DONE ZFS-in-VM claim verified false as a constraint
+Round-1 (code fact): the harness supports =FS_PROFILE=zfs= base images (=vm-utils.sh:23,53-55=). Resolved: Decision 5 corrected — ZFS scenarios are possible (prerequisite: zfs base image builds); argv+live-read-only becomes the fallback, not the plan.
+
+** DONE Battery capability detection underspecified
+Round-1 (verified live): ratio's =/sys/class/power_supply/= is non-empty (Mains + 2 USB entries) — a naive non-empty-dir probe false-positives. Resolved: contract now defines battery present ⇔ an entry with =type == Battery=.
+
+** DONE sysmon retirement collateral unnamed
+Round-1: retiring =waybar-sysmon=/=sysmon-cycle= without touching =tests/waybar-sysmon/= + =tests/sysmon-cycle/= reds =make test= in the same commit; =#custom-sysmon= CSS lives in three files. Resolved: Phase 11 now names the scripts, suites, and CSS blocks as same-commit retirement collateral.
+
+** DONE Test-gate discovery answered
+Round-1 (code fact): =make test= discovers =tests/maint/test_*.py= via glob (=Makefile:266-271=) — no enumeration needed; AT-SPI smokes ARE enumerated (=Makefile:279-286=). Resolved: Dev-tooling records both; =test-panel-maint= target added to Phase 12 deliverables.
+
+** DONE arch-audit absent on ratio
+Round-1 (verified live): every other assumed tool present; =arch-audit= is not. Resolved: External deps flips it from verify-item to known — add to archsetup deps + install both hosts before Phase 3's CVE collector is live-testable.
+
+** DONE Fourth unhomed persistent file
+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.
+
+* Implementation phases
+
+Each phase leaves the tree working and gates on dotfiles =make test= green (archsetup phases gate on their own checks). Commit + push per phase; dotfiles note at milestones (archsetup-owns-dotfiles).
+
+** Phase 1 — Package skeleton, thresholds, contract
+=maint/= scaffold (src layout, stow wiring, =__main__=), =thresholds.py= (TOML load + user-layer merge + disable flags, env overrides), =schema.py= severity model, capability probe, =maint status --json= envelope with three pilot collectors (disk usage, package cache size, failed-unit count), fake-binary harness in =tests/maint/=. Seed =maintenance-thresholds.toml= content in archsetup (workflow's values: snapshot limits, 10 GB cache trigger, scrub-age bands).
+
+** Phase 2 — Collectors: storage & snapshots
+btrfs (unallocated, scrub age, per-device error counters cross-checked vs SMART), ZFS (health, capacity, frag, autotrim, scrub age), SMART (health, wear, temp, last self-test), fstrim.timer, disk top-consumers digest, snapper/ZFS snapshot counts split timeline·single·pre-post with oldest-single named. Both-host fixtures.
+
+** Phase 3 — Collectors: packages, security, systemd
+Cache size, orphans (name+size), pacnew classified safe-delete vs needs-merge, keyring freshness, reboot-required, pacman -Qkk; network-tier cache readers (checkupdates, arch-audit CVEs named, AUR staleness, fwupd count, topgrade freshness stamp) with age; is-system-running + cause, failed-unit roster (name·since·exit·journalctl hint), maintenance-timers meta-metric, taint letters decoded.
+
+** Phase 4 — Collectors: logs, memory, power
+Journal error digest (grouped by identifier, first/last seen, next command, two noise layers applied), coredumps by binary, kernel/hw events; memory free + top-5 consumers, OOM kills, swap/zram, temps, throttle state, EPP mode read, battery health + charge threshold read (velox), unclean-shutdown rate from recent boots.
+
+** Phase 5 — Collectors: network posture, services
+DNS/NM reachability, firewall state (+ public-bind exposure naming when down), listeners digest with expected-layer merge and unexpected/public severity, tailscale, fail2ban + recent bans, NTP offset; rsyncshot freshness, docker system df by type + stopped containers with expected-layer, libvirt state, cron expected-entries drift. Curation read side complete.
+
+** Phase 6 — Remedies, priv, guard, doctor CLI
+=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.
+
+** 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.
+
+** Phase 9 — GUI subpanels: systemd, logs, mem·pwr, network, services
+Failed-unit roster keys, journal digest + MARK KNOWN lifecycle UI + OPEN JOURNAL, Logs/Services rotary bands, MEM·PWR 3-column strip + CPU-mode segmented control + velox SET 80% + guarded KILL, listeners curation + STOP/KILL, firewall ENABLE, container START, RECLAIM SPACE composite macro. The fattest phase — split by subpanel pair (9a systemd+logs, 9b mem·pwr+network+services) if a session runs long; each pair gates independently.
+
+** Phase 10 — Results wall + doctor keys
+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.
+
+** 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.
+
+** Phase 12 — VM remedy scenarios + AT-SPI smoke (archsetup + dotfiles)
+Scenario orchestration over the existing snapshot primitives (=vm-utils.sh:303-357=): grouped batches per Decision 11, maint scenario scripts (stop cronie, mask fstrim, orphan packages, fill cache, break timers → =maint fix= → assert post-state); systemd-nspawn variant for pacman-level cases; AT-SPI smoke on GOOD/BAD fixtures asserting board render, arm behavior, and no leaked processes, plus the enumerated =test-panel-maint= Makefile target. May split VM work (archsetup) from AT-SPI smoke (dotfiles) into two sessions — each half gates independently.
+
+** Phase 13 — Install wiring, workflow move, docs
+archsetup installs the TOML (idempotent, update-safe); deps added; =maint= README + keybind/user docs; =system-health-check.org= + homelab inventories moved into archsetup and rewired to the TOML; handoff note to home's inbox for the home-side removal; spec flipped IMPLEMENTED with history line.
+
+* Acceptance criteria
+- [ ] =maint status --json= runs read-only on both hosts; ratio emits btrfs/AMD metrics and no ZFS/battery; velox emits ZFS/Intel/battery — same binary, capability-dispatched.
+- [ ] Every metric's severity comes from the installed TOML; editing a threshold changes the verdict with no code change.
+- [ ] =maint fix <id> --dry-run= prints the exact argv for every remedy; the GUI arm-press shows the same string.
+- [ ] Probe modules import no elevation path (verifiable: nothing outside =priv.py= references sudo).
+- [ ] With mesa/hyprland in the pending set, UPDATE arms with the override wording instead of running; without them it runs on confirm. Topgrade always receives =--disable git=.
+- [ ] Doctor runs stream per-action lamps to the wall with results inline; a long op shows running-%; a failure shows red without aborting the remaining stream.
+- [ ] Marks/keeps/expected survive a dotfiles template sync and a package update; marked items render dimmed, UNMARK restores, CLEAR empties only the user layer.
+- [ ] KILL never fires on a recycled PID (name revalidated) and renders disabled for session-critical names.
+- [ ] The bar glyph reflects worst diagnostic within one scan interval with the panel closed, and never colors for actionable-only findings.
+- [ ] On velox the glyph shows live battery level + charging state; a charge below the TOML low threshold turns the diagnostic state (and the glyph) red; on ratio no battery text renders.
+- [ ] =MAINT_PANEL_FIXTURE=bad= renders the fully degraded board on a healthy machine; AT-SPI smoke passes against it.
+- [ ] VM scenarios: each broken-state → =maint fix= → asserted post-state passes from a pristine qcow2 snapshot.
+- [ ] Full dotfiles =make test= green; closing the panel leaves no maint processes beyond the timers.
+
+* Readiness dimensions
+- *Data model & ownership:* live probe data is generated, never persisted (results wall is session-only, in-memory); curation files are user-authored (user layer) vs shipped (archsetup TOML); glyph state file and hourly cache are generated state rebuilt by the timers (their home is the open config-paths decision). The JSON envelope is the one contract fixtures pin.
+- *Errors, empty states & failure:* a failing probe degrades its own metric to an "unprobed" state with the failure named — never a dead board; remedy failures stream red to the wall with stderr excerpt and the metric re-probed (truth, not assumption); network-tier cache absent → counts show "no data · refresh" with the timer named; every guard names the operation and the next step at arm time.
+- *Security & privacy:* privilege surface is the priv verb table alone; args regex-validated pre-sudo; listeners digest shows local process/port data only in the local GUI — never logged off-machine; no secrets read; charge/EPP writes are fixed-value verbs.
+- *Observability:* the results wall is the action log; =maint status --json= + =--dry-run= make any GUI claim reproducible in a terminal; cache ages displayed; scan timers visible via systemctl --user.
+- *Performance & scale:* hydration budget sub-second perceived (fast reads first); live tier only while visible; N of everything is small (tens) except journal lines — the digest groups engine-side with a top-10 cap. Long ops never block the GUI thread (worker + event feed, the sibling =bg()= pattern).
+- *Reuse & lost opportunities:* reuses the sibling panels' humble-object stack, fake-binary harness, AT-SPI smoke pattern, priv.py design, console-key/lamp CSS kit from the widget gallery, the existing archsetup VM harness, and the E5 prototype as both fixture source and pixel reference. Deliberately not reused: waybar-sysmon (replaced), the workflow's prose probes (superseded by TOML-driven collectors).
+- *Architecture fit & weak points:* fourth panel sibling, same layering. Weak points: subprocess sprawl (every probe shells out — bounded by tiering + hydration order); the guard matcher (a miss means a live mesa swap — mitigated by pure-function pairwise tests over package-set cases); scenario orchestration is new harness surface, though the snapshot primitives already exist in =vm-utils.sh= (mitigated: archsetup-side, tested by the scenario suite itself); ZFS scenarios depend on the =FS_PROFILE=zfs= base image building (fallback: argv tests + read-only live on velox + Confirm-tier gating).
+- *Config surface:* the thresholds TOML (all severity values, shipped curation defaults, staleness windows, "a lot" threshold), user layer (marks/keeps/expected/disables), =~/.config/maint/config= optional GUI prefs (refresh rates, fixture env), =MAINT_SUDO= / =MAINT_PANEL_FIXTURE= / path-override envs for tests. All named with defaults in Phase 1/6 docs.
+- *Documentation plan:* maint package README (user: what each key does, guard semantics; developer: schema, adding a remedy = verb-table discipline); archsetup docs for the TOML install + VM scenario harness; the moved workflow's own doc updates. Keybind doc deferred to the panel-chord-family task.
+- *Dev tooling:* dotfiles =make test= discovers =tests/maint/test_*.py= automatically via its glob (=Makefile:266-271= — verified, no enumeration needed); the AT-SPI smoke *is* enumerated per panel (=Makefile:279-286=), so a new =test-panel-maint= target is a Phase 12 deliverable; archsetup gains a =make= target or documented invocation for the maint VM scenario; fixture regeneration script from E5 snapshots.
+- *Rollout, compatibility & rollback:* additive package; stow to install, unstow to remove; waybar module swap is one config change reverted by restoring =custom/sysmon=; TOML install is idempotent and never overwrites user layer; remedies themselves are the dangerous surface — each is Confirm/armed, dry-run-previewable, and the destructive ones (snapshot delete, docker prune tiers 2–3, updates) arm individually.
+- *External APIs & deps:* all local binaries, no web APIs. Verified on ratio (2026-07-07 review): =checkupdates= (1.13.1), =topgrade= (=--disable=/=--dry-run= flags confirmed), =paccache=, =snapper=, =smartctl= 7.5 with working =--json=, =coredumpctl=, =ss=, =docker=, =fwupdmgr=, =ufw=, =fail2ban-client=, =chronyc=, =tailscale= all present. Known: =arch-audit= is ABSENT on ratio — add to archsetup deps and install on both hosts before Phase 3's CVE collector is live-testable. Still prerequisites: same presence sweep on velox; =zpool status -j= support (OpenZFS ≥ 2.3) — fall back to text parsing where absent; =charge_control_end_threshold= sysfs path on velox's hardware; =coredumpctl=/=journalctl= field names pinned by fixtures from live output. =pacman -Qkk= exits 0 even with altered files (summary on stdout, warnings on stderr) — the collector parses output, never trusts the exit code.
+
+* Risks, Rabbit Holes, and Drawbacks
+- *Scope mass* — the biggest risk is the build's sheer surface. Dodge: CLI-first phasing means every phase lands standalone value (collectors are useful from P2 via the CLI), and the GUI phases consume a verified engine. The phase order is also an abort-friendly ladder — stopping after P6 still yields a complete maintenance CLI.
+- *Parsing drift* — probes parse a dozen tools' output. Dodge: prefer machine formats where they exist, pin real both-host output as fixtures, and treat a parse failure as an "unprobed" metric, never a crash.
+- *Guard false-negative* — the one place a bug bricks a session. Dodge: pure function, pairwise-tested over package-name sets, and the TTY escape wording keeps the human in the loop.
+- *VM harness time* — 40–60 min per run makes remedy-test iteration slow. Dodge: nspawn for pacman-level cases; scenarios grouped into non-conflicting batches per VM boot, with a stop/restore/reboot cycle only between destructive groups (Decision 11 — snapshot ops need the VM stopped, =vm-utils.sh:301=).
+- *Curation-merge subtleties* — disable-flags over shipped defaults is the fiddly corner. Dodge: it's a pure merge function; exhaustive unit cases in Phase 1 before anything depends on it.
+- *Results-wall event plumbing* — streaming per-action state through a worker into GTK is the panel's newest moving part. Dodge: the event feed is engine-side and CLI-proven in Phase 6 before the GUI touches it (the doctor's terminal wall is the same feed).
+
+* Testing / Verification / Rollout
+The four-layer strategy is Decision 11; per-phase gates are in the phase list. Verification beyond tests: read-only =maint status --json= eyeballed on both hosts after P5; the doctor CLI exercised live on ratio (Auto tier only) after P6; GUI walked on fixtures + live after P10; glyph observed through a full timer cycle after P11; the E5 prototype is the pixel reference for GUI review. Manual eyeball checks (needle-level look-and-feel, arm wording, wall readability) get filed as a structured manual-testing task in todo.org at the end, per house convention. Rollout: per-phase dotfiles commits to main; archsetup commits for TOML/harness/workflow phases; both machines pick the package up via the normal stow path — velox needs a one-time stow after P11 lands (flagged in the dotfiles note).
+
+* References / Appendix
+- Design doc (all dated decisions + the full metric tables with per-metric Automation/levers/notes): [[file:../design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]]. The metric inventory is deliberately not duplicated here — the tables there are normative for *which metrics exist*. Where a table's Automation column disagrees with the doc's later dated decisions (failed-unit restart, pending updates, AUR staleness, and =-Qkk= reinstall all read Workflow in the tables but gained Confirm levers in the 2026-07-07 determinate-remedies and updates decisions), the dated decision prose — mirrored in Decisions 2, 3, and 7 here — wins. The Confirm set that defines REVIEW & FIX membership derives from the decisions, never from the table column.
+- Converged prototype (interactive; SIM FAIL / SIM BAD DAY / SIM ZFS controls): [[file:../prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5]]; exploration path A–E4 beside it.
+- Sibling precedent: net =priv.py= (privilege pattern), audio =peak.py=/meters (visibility-gated live refresh), instrument-console-panels-spec (visual kit), PTT build (waybar runtime-config gotcha).
+- Workflow to move in from its previous owner: =system-health-check.org= + the host capability inventories.
+
+* Review and iteration history
+** 2026-07-07 Tue @ 18:30:04 -0500 — Claude Code (archsetup) — author
+- What: initial draft from the completed 2026-07-06/07 design arc; consolidated the design doc's dated decisions into twelve DONE decisions + one open (config paths), decomposed the build into thirteen phases, grounded implementer facts against the live tree (net priv.py, audio config.py:29, archsetup:1178 NOPASSWD, waybar custom/sysmon, tests/ fake-binary layout).
+- Why: the [#B] todo task queued spec-create as the next step after the design converged; Craig gave the go this session and added two calls mid-authoring: the glyph replaces custom/sysmon, and on laptops it doubles as the battery level display.
+- Artifacts: design doc; E5 prototype; todo.org task (cross-link updated this session).
+
+** 2026-07-07 Tue @ 18:50:00 -0500 — Claude Code (archsetup) — reviewer
+- What: round-1 review with live read-only verification on ratio. Rubric: Not ready — three blocking findings (Automation-tier normativity contradiction, VM isolation policy undefined vs vm-utils.sh:301, no refresh tier for 47 s-class local probes) + seven non-blocking, all small spec-text fixes. Every code citation in the draft verified accurate; tool sweep on ratio held except arch-audit (absent); found the qcow2 snapshot primitives already implemented (vm-utils.sh:303-357) and make test's glob auto-discovery (Makefile:266-271).
+- Why: the spec's buildability rested on harness and cadence claims that only a code read + live measurement could confirm.
+- Artifacts: findings recorded in * Review findings; verified facts cited inline per finding.
+
+** 2026-07-07 Tue @ 18:53:51 -0500 — Claude Code (archsetup) — responder
+- What: accepted all ten findings and folded them in — References normativity scoping, Decision 11 grouped-batch VM isolation policy, the slow-local refresh tier (Design + Decision 8), Decision 5 ZFS-in-VM correction, battery-detection definition in the contract, Phase 11 retirement collateral, Dev-tooling gate answers + Phase 12 test-panel-maint, External-deps verified-on-ratio state + arch-audit known-absent, the fourth file added to the open config-paths decision, and the panel-CSS convention clarification. Added session-split notes to Phases 6/9/12 per the decomposition check.
+- Why: close the three blocking gaps and bank the verified facts before a re-review.
+- Artifacts: * Review findings all DONE; Decisions 5/8/11 updated; one decision (config paths) remains open for Craig.
+
+** 2026-07-07 Tue @ 18:55:50 -0500 — Claude Code (archsetup) — reviewer + responder
+- What: round-2 re-review confirmed all ten findings resolved with no substantive new gaps. Rubric: Ready pending the one open decision (config/state paths — Craig's call). Three wording nits found and fixed in the same pass: cache_age scoped to network-tier and slow-local alike, the stale "ephemeral" glyph-state wording reconciled to the open decision, the Decisions cookie recomputed to 12/13, plus a CI-that-doesn't-exist phrase softened in Decision 11.
+- Why: verify the round-1 folds before handing the last decision to Craig; the spec stays DRAFT until he rules and the cookie reads complete.
+- Artifacts: round-2 verdict (per-finding resolved list); Craig paged with the config-paths proposal and the sysmon-affordance re-homing question.
+
+** 2026-07-07 Tue @ 19:54:04 -0500 — Claude Code (archsetup) — responder
+- What: folded Craig's two rulings (config/state paths as proposed — shipped TOML =~/.config/archsetup/=, user curation =~/.config/maint/=, glyph state + hourly cache =~/.local/state/maint/=; btop scratchpad re-homed to the maint glyph's right-click, sysmon-cycle + waybar-sysmon retired), flipped DRAFT → READY → DOING, and decomposed the build into todo.org: 13 phase tasks (all =:solo:=), a Manual-testing task seeded with the eyeball checks, and the flip-to-IMPLEMENTED closer, under a parent stamped =:SPEC_ID:=. vNext (AI assistance, SIGKILL escalation, evidence-row meters) logged =[#D]=.
+- Why: Craig approved both recommendations ("go with your recommendations", 2026-07-07); Phase 6 of spec-response owns the READY → DOING flip when the decomposition lands.
+- Artifacts: todo.org "Maintenance console build" parent + children; status heading history lines.
diff --git a/docs/specs/2026-07-09-audio-doctor-spec.org b/docs/specs/2026-07-09-audio-doctor-spec.org
new file mode 100644
index 0000000..5851332
--- /dev/null
+++ b/docs/specs/2026-07-09-audio-doctor-spec.org
@@ -0,0 +1,291 @@
+#+TITLE: Audio Doctor — diagnose and repair a broken sound stack
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-09
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Audio Doctor — diagnose and repair a broken sound stack
+:PROPERTIES:
+:ID: 6ce3f2ef-052e-40cb-9067-42f8e665f813
+:END:
+- [2026-07-10 Fri] Superseded in part, later the same day: the DOCTOR section-header key and its DIAGNOSE key are replaced by a doctor key per direction, and the classifier grows an input side. See [[file:2026-07-10-audio-doctor-input-side-spec.org][2026-07-10-audio-doctor-input-side-spec.org]] (DRAFT). This spec's other decisions stand. The gap: nothing here ever examined the microphone, and the Non-Goals never said it would not.
+- [2026-07-10 Fri] IMPLEMENTED — Phases 0 through 4 shipped (dotfiles =4d42eb3=, =01a1d80=, =76857e3=, =7223c51=, =1550ac8=, =4320255=, =bd33440=). Xruns stayed out of v1 as decided. Per-application stream routing remains a Non-Goal. The wall carries the net panel's copy + close controls, added past the spec at Craig's request; standardizing them across the other panels is tracked as its own task.
+- [2026-07-09 Thu] READY — all eight Decisions closed. Written from the roam-inbox ask "look into a doctor button for the audio panel: what happens if pulseaudio or its equivalent is borked? would we know? how could we test for it? what remedies would we have?" Probe set, classifier, and remedy tiers are grounded in a live survey of ratio's stack. Phase 0 shipped ahead of the rest.
+
+* IMPLEMENTED Status
+:PROPERTIES:
+:ID: 1cc2b355-9450-4ace-bdb3-b935f7051918
+:END:
+- [2026-07-10 Fri] IMPLEMENTED — the build closed three things the Decisions did not anticipate. A tenth verdict =tooling-missing= (=remedy: None=), because an absent =pactl= is an unobserved stack rather than a broken one. A =re-probed= marker between a fix run's two row blocks, because unlabelled they read as the doctor contradicting itself. And the read-only key is named DIAGNOSE beside the engraved DOCTOR header rather than being named DOCTOR itself (Craig's call, 2026-07-10, after asking why the panel carries two keys where net's carries one; the two presses stand on the audibility decision, and net's single key auto-fixes because its remedies are cheap).
+
+ Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5, and immediately found two bugs no fake could surface: =pactl get-default-sink= prints the literal =@DEFAULT_SINK@= placeholder when no default is set, and remedy 1 re-probed before WirePlumber had re-created the devices, reporting a repaired stack as broken. Both fixed.
+- [2026-07-09 Thu] READY — Craig closed the six open Decisions, all to the recommended option: DOCTOR is a section-header key; CLI-first with the GUI as a face; hung and dead are two verdicts sharing one remedy; tiers stay Auto/Confirm/Arm as drafted; a stream-active guard refuses the audible remedies and is overridable by pressing again; xruns are out of v1.
+
+ Two consequences worth naming, because they were not obvious when the questions were asked. Taking the guard is *why* remedy 4 can stay at Confirm — the danger lives in the state of the machine, not the identity of the remedy. And the guard must read its stream state from =pw-dump= rather than =pactl=, because the remedies it protects are exactly the ones you reach for when the Pulse layer is dead; a =pactl=-fed guard would go blind precisely when it is needed.
+- [2026-07-09 Thu] DRAFT — Phase 0 shipped (dotfiles =4d42eb3=) and its Decision closed. The first draft claimed =build_status()= had no timeout and froze the panel; driving it disproved that — =pactl.run()= already times out. The real bug was narrower: two of the four reads sat outside the degrade guard, killing waybar's audio module. The claim is corrected in place rather than quietly dropped.
+- [2026-07-09 Thu] DRAFT — spine complete, Decisions open. Not ready to build: the remedy tiers touch a running meeting's audio, and the classifier's precedence needs Craig's call before code.
+
+* Metadata
+
+| Field | Value |
+|--------+-------------------------------------------------------------------------------------------------------------|
+| Status | implemented |
+|--------+-------------------------------------------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|--------+-------------------------------------------------------------------------------------------------------------|
+| Repo | dotfiles (=audio/= package); archsetup owns this spec |
+|--------+-------------------------------------------------------------------------------------------------------------|
+| Kin | net panel doctor (=net/diag.py=, =classify.py=, =repair.py=, =doctor.py=) — the architecture to mirror. |
+| | Maintenance console (results wall, arm-to-fire) — the interaction idiom. |
+|--------+-------------------------------------------------------------------------------------------------------------|
+
+* Summary
+
+The audio panel shows the sound graph and lets you drive it. It has no answer for the graph being *broken*. When PipeWire, WirePlumber, or the Pulse compatibility layer fails, the panel shows an empty or stale board and the user is left to remember service names.
+
+This adds a DOCTOR key to the audio panel — read-only diagnose, then a lightest-repair-first fix — mirroring the net panel's doctor, which already solves this shape for networking.
+
+* Problem / Context
+
+Craig's questions, answered from a live survey of ratio (2026-07-09):
+
+** "What happens if pulseaudio or its equivalent is borked?"
+
+There is no PulseAudio. The stack is three user-scope services:
+
+| Unit | Role | Failure looks like |
+|--------------------------+---------------------------------------------+---------------------------------------------|
+| =pipewire.service= | the media graph itself | no devices at all; everything silent |
+|--------------------------+---------------------------------------------+---------------------------------------------|
+| =wireplumber.service= | session manager: routing, policy, device | devices exist but nothing routes; defaults |
+| | profiles, default picking | unset; profiles never applied |
+|--------------------------+---------------------------------------------+---------------------------------------------|
+| =pipewire-pulse.service= | Pulse protocol compat, via | =pactl= fails; the panel goes blind while |
+| | =pipewire-pulse.socket= | audio may still work for others |
+|--------------------------+---------------------------------------------+---------------------------------------------|
+
+The third row is the trap. The panel speaks =pactl=, so a dead =pipewire-pulse= blinds the panel even though PipeWire-native clients still play. The panel's own emptiness is therefore *not* evidence that audio is broken, and the doctor must not treat it as such.
+
+** "Would we know?"
+
+Today, no — and worse than no. Two findings from the live survey:
+
+1. *=pactl= hangs on a hung server.* Against a refused socket it fails in ~0ms ("Connection refused"). Against a socket that accepts and never answers, it blocks indefinitely — measured: still blocked at the 5s timeout. A hung server is precisely the case the doctor exists for, so *every probe carries a timeout*. The engine's own =pactl.run()= already does this and raises =PactlTimeout=; the doctor must do the same, and must never call a bare =subprocess.run=.
+
+2. *The panel cannot diagnose itself.* Its only data source is the layer most likely to be down. The doctor's probes therefore read =systemctl --user= (never blocks on the audio graph) and =pw-dump= / =wpctl= (PipeWire-native, alive when the Pulse layer is not) *before* they touch =pactl=.
+
+** "How could we test for it?"
+
+Four probe tiers, cheapest and most reliable first. Each is independently useful; the classifier below reads all four.
+
+*** Tier 1 — unit state (no audio IO, cannot hang)
+=systemctl --user show -p ActiveState -p Result <unit>= for =pipewire=, =wireplumber=, =pipewire-pulse=, and the two sockets. Distinguishes =active= / =failed= / =inactive= / =not-found=.
+
+*** Tier 2 — graph liveness (PipeWire-native, survives a dead Pulse layer)
+=pw-dump= with a timeout. Answers: does the graph exist, how many nodes, are there sinks and sources at all. =wpctl status= gives the same in human form. A graph with zero sinks is a different fault from a graph that will not answer.
+
+*** Tier 3 — Pulse compat reachability (the panel's own dependency)
+=pactl info= under a hard timeout. Three outcomes, and they are *different faults*: answered / refused / hung. The hung case is the one that currently freezes tooling, and it is invisible to a naive "does pactl work" check because that check hangs too.
+
+*** Tier 4 — semantic health (only meaningful once 1–3 pass)
+Default sink and source resolve to devices that exist; at least one sink is not =SUSPENDED= when a stream is playing. Ratio's sink states today are =RUNNING= and =SUSPENDED=; =SUSPENDED= alone is normal idle, not a fault.
+
+Xruns are deliberately *not* probed in v1 — see the Decision "Do xruns belong in v1?" below. They are a quality signal with no remedy in this feature's vocabulary, and a finding nobody can act on trains the user to ignore the wall.
+
+** "What remedies would we have?"
+
+All three units are =--user= scope. *No privilege escalation is needed anywhere in this feature*, so the maintenance console's =priv.py= verb table and its =sudo -n= machinery are dead weight here and must not be copied in. This is the single largest architectural difference from the maint console, and the reason the audio doctor mirrors the *net* panel instead.
+
+Remedies, lightest first:
+
+| # | Remedy | Blast radius | Tier |
+|---+-----------------------------------------------+----------------------------------------------+---------|
+| 1 | =systemctl --user restart wireplumber= | routing/profiles re-applied; streams survive | Auto |
+|---+-----------------------------------------------+----------------------------------------------+---------|
+| 2 | Set default sink/source to a present device | one setting | Auto |
+|---+-----------------------------------------------+----------------------------------------------+---------|
+| 3 | Unmute + restore volume on the default device | audible, but reversible | Confirm |
+|---+-----------------------------------------------+----------------------------------------------+---------|
+| 4 | =systemctl --user restart pipewire-pulse= | Pulse clients drop and reconnect | Confirm |
+|---+-----------------------------------------------+----------------------------------------------+---------|
+| 5 | =systemctl --user restart pipewire= | *every* audio client's stream drops | Arm |
+|---+-----------------------------------------------+----------------------------------------------+---------|
+
+Remedy 5 is the one that cuts audio out from under a live meeting. It gets the arm-to-fire treatment, and its arm line says so in words.
+
+* Goals and Non-Goals
+
+** Goals
+
+- Answer "is my sound stack broken, and if so where" without the user knowing a service name.
+- Work *when the panel's own data source is down* — a doctor that needs =pactl= to answer is useless in the case it exists for.
+- Never hang. Every probe is bounded; a probe that times out is a *finding*, not a stall.
+- Repair from the lightest remedy that could plausibly fix the classified fault, exactly as the net doctor does.
+- Read-only by default. Diagnose without touching anything; fixing is a separate, explicit press.
+
+** Non-Goals
+
+- Fixing a Bluetooth audio device that will not take an A2DP profile. That is the bluetooth panel's problem and it already has a doctor-shaped surface.
+- Per-application stream routing or a full patchbay. Helvum and qpwgraph exist.
+- Diagnosing hardware failure (a dead DAC, an unplugged cable). The doctor can report "sink present but never leaves SUSPENDED"; it cannot see a cable.
+- Sample-rate, quantum, or latency tuning. A separate concern with a separate audience.
+- Any use of =sudo=. If a remedy seems to need it, the remedy is wrong.
+
+* Design
+
+** For the user
+
+A DOCTOR key on the audio panel, beside the existing console keys. Pressing it streams findings into a results wall — the same wall idiom the maintenance console uses, so the interaction is already learned:
+
+#+begin_example
+DOCTOR diagnose · read-only
+ ok pipewire.service active
+ ok wireplumber.service active
+ FAIL pipewire-pulse.service failed (Result=exit-code)
+ ok graph 4 sinks · 7 sources (via pw-dump)
+ FAIL pulse compat not answering — pactl refused
+ -- defaults not checked (pulse compat down)
+
+ verdict: the Pulse compatibility layer is down.
+ PipeWire itself is healthy — native clients still play.
+ FIX restarts pipewire-pulse.
+#+end_example
+
+The verdict names one fault, in the user's terms, and names the remedy it would run. Then a FIX key applies the lightest remedy for that verdict, streaming each step and re-probing after, so the wall shows repaired truth rather than an optimistic claim.
+
+When everything passes, the wall says so and offers nothing to press.
+
+** For the implementer
+
+Mirror the net panel's module split, which already separates these concerns cleanly:
+
+| Module | Responsibility |
+|---------------------+------------------------------------------------------------------------------------------------|
+| =audio/diag.py= | The four probe tiers. Pure IO, each bounded by a timeout. Returns a context dict; never raises |
+| | on a broken stack, never blocks. |
+|---------------------+------------------------------------------------------------------------------------------------|
+| =audio/classify.py= | Pure. Context dict → one verdict, by precedence. Unit-tested against fixture contexts, no |
+| | services involved. |
+|---------------------+------------------------------------------------------------------------------------------------|
+| =audio/repair.py= | One function per remedy. Each returns a result envelope; each re-probes the thing it claimed |
+| | to fix. |
+|---------------------+------------------------------------------------------------------------------------------------|
+| =audio/doctor.py= | Orchestration: gather → classify → (optionally) attempt the lightest matching remedy → |
+| | re-probe → emit the event stream. |
+|---------------------+------------------------------------------------------------------------------------------------|
+
+Three constraints that fall out of the survey and are easy to get wrong:
+
+1. *Timeouts everywhere, and guard every read.* Bound every probe. Beyond that: a function that promises to degrade must guard *all* of its reads, not the first two. That was the Phase 0 bug. Test with a fault that answers early calls and hangs on later ones — a server that hangs on the first call masks everything behind it, and a refused socket does not reproduce the hang at all.
+
+2. *Probe order is load-bearing.* =systemctl= first (cannot hang), then =pw-dump= (alive when Pulse is dead), then =pactl= (may hang). Reversing this makes the doctor unable to report on the case it was built for.
+
+3. *The GTK thread never probes.* The panel already has a =bg()= worker; the doctor runs there and lands events on the main loop, as the maint console's =_stream= does.
+
+*** Classifier precedence (draft — see Decision 3)
+
+Terminal faults first, so a lower-layer failure is never reported as an upper-layer symptom:
+
+1. =pipewire.service= not active → *graph down*. Nothing else is meaningful.
+2. =wireplumber.service= not active → *no session manager*. Devices exist, routing does not.
+3. =pactl= hangs → *pulse compat hung*. Distinct from dead: a restart is the remedy, but the diagnosis differs and the user should see which.
+4. =pipewire-pulse= not active, or =pactl= refused → *pulse compat down*.
+5. Graph healthy, zero sinks → *no output devices*.
+6. Default sink/source names a device that is absent → *stale default*.
+7. Everything up, default device muted or at zero volume → *silenced, not broken* (a finding, not a fault; offer to unmute).
+8. Otherwise → *healthy*.
+
+*** Testing
+
+Follows the four-layer pattern the maint console established:
+
+- *Unit, fakes.* =classify.py= against fixture contexts covering every verdict, including the hung-vs-dead distinction. =diag.py= against a fake =systemctl= / =pactl= / =pw-dump= on a temp PATH, one case per outcome. Pairwise over (unit state × graph state × pulse reachability) — three parameters with three, three, and four values each is 36 exhaustive, and pairwise covers the interactions in far fewer.
+- *Timeout regression.* A test that stands up a Unix socket which accepts and never answers, points =PULSE_SERVER= at it, and asserts the probe returns a =hung= finding within its timeout rather than blocking. This is the test that would have caught the real behavior; write it first.
+- *Live read-only.* =audio doctor= (no fix) on ratio and velox, asserting a healthy verdict.
+- *Remedy, in a VM.* Restarting =pipewire= is not something to exercise on a live desktop. The archsetup VM harness runs remedies 1, 4, and 5 against a real stack.
+- *GUI smoke.* AT-SPI over the DOCTOR key and the wall, on fixtures.
+
+* Alternatives Considered
+
+** Reuse the maintenance console's doctor rather than building one in the audio panel
+
+Rejected. The maint doctor's remedies are system-scope and route through =priv.py= and =sudo -n=. Every audio remedy is =--user= scope. Bolting audio onto that table means either giving audio remedies a privilege path they do not need, or special-casing them out of the mechanism that defines the table. The net panel already demonstrates the right shape for a user-scope doctor.
+
+** A single RESTART AUDIO button
+
+Rejected. It is remedy 5 with no diagnosis: it drops every client's stream to fix a problem that is usually remedy 1 or 2. It also teaches nothing — the user learns to press it for every symptom, including symptoms it cannot fix, such as a stale default device.
+
+** Probe with =pactl= only, since the panel already speaks it
+
+Rejected on evidence. =pactl= hangs against a hung server, and it is dead when the Pulse compat layer is dead — the two most interesting faults. It is the *last* probe, not the first.
+
+** Auto-repair on detection, without asking
+
+Rejected. Remedies 3 through 5 are audible and, in a meeting, disruptive. Diagnosis is free and should be; repair is a press.
+
+* Decisions [8/8]
+
+** DONE Where does the DOCTOR key live, and does it replace anything?
+CLOSED: [2026-07-09 Thu]
+Resolved: a section-header key, the maint console's CLEAN UP / REVIEW & FIX shape. Diagnose and fix are two presses with different consequences, and the header is where the console already teaches that distinction. The console row (INPUT / OUTPUT / PUSH TALK) stays device controls; a doctor is not one. Nothing is replaced.
+
+** DONE Is there an =audio doctor= CLI, and is it the same code path?
+CLOSED: [2026-07-09 Thu]
+Resolved: CLI-first, GUI as a face, one code path — the maint console's architecture. =audio doctor= (read-only) and =audio doctor --fix= print the same findings the wall streams.
+
+The deciding reason: when the desktop's sound is dead, a TTY is where you will be standing. A GUI-only doctor cannot serve the case that motivates the whole feature. It also makes the classifier testable without a display.
+
+** DONE Confirm the classifier precedence above, particularly rows 3 and 4.
+CLOSED: [2026-07-09 Thu]
+Resolved: precedence as drafted, and rows 3 and 4 stay *two verdicts sharing one remedy*. They share a fix but not a diagnosis, and the hung case is the one that freezes other tooling — collapsing them would hide the more interesting fault behind the more common one.
+
+** DONE Does the panel's own status build get the same timeout treatment?
+CLOSED: [2026-07-09 Thu]
+Investigated and answered: it already had one. =pactl.run()= defaults to =timeout=8= and raises =PactlTimeout=, so the panel degrades rather than freezing. The draft of this spec claimed otherwise; the claim was wrong.
+
+What the investigation *did* find is a narrower, real bug, now fixed (dotfiles =4d42eb3=): =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard, so a server that answered the lists and then stopped answering raised =PactlTimeout= out of a function whose contract is to degrade. =waybar-audio= calls it with nothing catching it, so the bar's audio module died rather than dimming. A server that hangs on the *first* call never exposed this — =list_sinks= raised inside the guard and masked everything behind it, which is why the obvious test would have passed against broken code.
+
+** DONE Which remedies are Auto, which Confirm, which Arm?
+CLOSED: [2026-07-09 Thu]
+Resolved: the table as drafted. Remedies 1–2 (restart wireplumber, set a present default) are Auto; 3–4 (unmute + restore volume, restart pipewire-pulse) are Confirm; 5 (restart pipewire) is Arm.
+
+Remedy 4 stays at Confirm rather than Arm *because* the stream guard below was taken. The guard, not the tier, is what catches the mid-meeting case — which is the honest place for it, since the danger is the state of the machine, not the identity of the remedy. Had the guard been declined, remedy 4 would have moved to Arm.
+
+** DONE Should the doctor refuse to run a disruptive remedy while audio is playing?
+CLOSED: [2026-07-09 Thu]
+Resolved: yes. A stream-active guard refuses a disruptive remedy and is overridable by pressing again, mirroring the maintenance console's live-update guard. Same shape of problem: an operation that is fine when idle and destructive mid-use, where wording alone does not stop a press.
+
+Implementation note for the builder: the guard's input is "is any node running" — and it must come from *Tier 2* (=pw-dump=), not from =pactl=. The remedies the guard protects are precisely the ones you reach for when the Pulse layer is dead or hung, so a guard that asked =pactl= would go blind exactly when it is needed, and a PipeWire-native client can be mid-playback while =pactl= answers nothing at all.
+
+A cold or unreadable graph means the guard cannot see a stream. Treat that as *no stream* and let the remedy proceed: refusing on an unreadable graph would wedge the doctor shut in the worst case, which is the fault it exists to repair. Say so in the wall, so the user knows the guard abstained rather than passed.
+
+The guard applies to remedies 3, 4, and 5 — the audible ones. Its refusal names what is playing, not just the remedy.
+
+** DONE Do xruns belong in v1?
+CLOSED: [2026-07-09 Thu]
+Resolved: left out. They are a quality signal, not a broken/working one, and they have no remedy in this feature's vocabulary. A finding you cannot act on trains you to ignore the wall — the opposite of what a doctor is for. Revisit when there is something to do about them.
+
+* Implementation phases
+
+Sequenced so each phase is independently green and independently useful. Nothing here starts until the Decisions are closed.
+
+** DONE Phase 0 — the degrade-on-hang bug fix (prerequisite)
+CLOSED: [2026-07-09 Thu]
+Shipped ahead of the rest (dotfiles =4d42eb3=). =build_status()= now guards all four pactl reads, not just the two list reads, so a half-wedged server dims the bar's audio module instead of killing it. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults" — the blanket sleep knob cannot, because the first call would hang and mask the bug.
+
+** DONE Phase 1 — =diag.py=, the four probe tiers
+Bounded probes, a context dict, fakes for every outcome including hung. No classifier, no GUI. =audio diag --json= prints the context.
+
+** DONE Phase 2 — =classify.py=, the verdict
+Pure function, fixture-driven, pairwise over the parameter space. =audio doctor= (read-only) prints findings and a verdict in the CLI. Already useful from a TTY at this point.
+
+** DONE Phase 3 — =repair.py= + =doctor.py=, the fix path
+One function per remedy, each re-probing what it claims to fix. =audio doctor --fix= applies the lightest matching remedy. VM-tested for remedies 1, 4, and 5.
+
+** DONE Phase 4 — the DOCTOR key and the results wall
+CLOSED: [2026-07-10 Fri]
+Shipped (dotfiles =4320255=). GUI face over the CLI. Streams events to a wall, off the GTK thread. AT-SPI smoke over the key, the wall, and the fix path on fixtures.
+
+** DONE Phase 5 — flip this spec to IMPLEMENTED
+CLOSED: [2026-07-10 Fri]
+With a history line naming what shipped and what was left out.
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
new file mode 100644
index 0000000..0e7e529
--- /dev/null
+++ b/docs/specs/2026-07-10-audio-doctor-input-side-spec.org
@@ -0,0 +1,494 @@
+#+TITLE: Audio Doctor, Input Side — a doctor per direction
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Audio Doctor, Input Side
+:PROPERTIES:
+:ID: 45efdd66-6d22-45c0-a633-ec41079e38ec
+:END:
+- [2026-07-10 Fri] DRAFT — drafted. Extends the audio doctor ([[file:2026-07-09-audio-doctor-spec.org][2026-07-09-audio-doctor-spec.org]], IMPLEMENTED) to the microphone, and replaces its single DOCTOR header key with one doctor per direction. Grounded in a live survey of ratio, not memory.
+
+* Metadata
+
+| Field | Value |
+|----------+----------------------------------------------------------------------------|
+| Status | draft |
+|----------+----------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+----------------------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+----------------------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org]] : "The audio doctor never checks the microphone" |
+|----------+----------------------------------------------------------------------------|
+| Parent | [[file:2026-07-09-audio-doctor-spec.org][2026-07-09-audio-doctor-spec.org]] — this supersedes its DOCTOR-key decision |
+|----------+----------------------------------------------------------------------------|
+
+* Summary
+
+The audio doctor is output-only. It never examines the microphone, so a muted mic, a default source naming an unplugged device, and a mic the sound server cannot see all classify as =healthy=.
+
+This adds the input side, and in doing so answers a question no probe can: whether a machine with no microphone is broken or merely mic-less. The answer comes from the user's finger. A doctor key on the INPUTS section header means "there should be a mic here", so an empty source list under that press is a failure rather than a shrug.
+
+* Problem / Context
+
+Three separate failures, all currently invisible.
+
+** The classifier does not look at the source
+
+=diag.probe_semantic= already collects =default_source= and =default_source_present=. =classify.py= reads neither. The word "source" appears once in that module, in the graph row that counts how many exist. Every semantic rule the classifier applies to the sink (is a default set, is it present, is it muted, is it at zero volume) has no source counterpart.
+
+So the =healthy= verdict prints "Units are up, the graph answers, the Pulse layer answers, and the default output is present and audible." Output. Only. A user reading "the sound stack is healthy" concludes the mic is fine, and the doctor never checked.
+
+** Nobody can tell an absent microphone from a broken one
+
+A desktop with no microphone is a normal machine. So an empty source list cannot be a fault the way =no-output-devices= is, and the classifier has no way to know which machine it is on. This is not a probe problem. No amount of reading the graph settles it, because the missing fact lives in the user's head.
+
+** The doctor cannot see below PipeWire
+
+When Chrome stopped recognizing the microphone on 2026-07-10, the stack was genuinely healthy: the Shure MV7+ was the default source, unmuted, at 82%, with push-to-talk off. The doctor would have said =healthy= and been right about the stack and useless to the user.
+
+The layer that would have helped is the one the doctor never reads. The kernel's own capture list distinguishes "the sound server lost a microphone the kernel can see" from "nothing is plugged in", and those two faults have nothing in common.
+
+** Live survey (ratio, 2026-07-10)
+
+Facts this spec is built on, verified rather than remembered:
+
+- =/proc/asound/card*/pcm*c= lists capture-capable ALSA devices. On ratio: =Generic_1= (ALC623 analog), =MV7= (Shure), =BRIO= (Logitech). A =pcm*c= node means the card has a capture PCM; =pcm*p= would be playback.
+- That path is a kernel filesystem read. It needs no package, spawns no process, and *cannot hang* — unlike every existing probe tier. =arecord -l= reports the same set but is owned by =alsa-utils=, which is a dependency the engine does not otherwise carry.
+- =diag= already tags each stream =input= or =output= (=_STREAM_CLASSES=, mapping =Stream/Input/Audio= and =Stream/Output/Audio=). =active_streams()= returns both without distinction.
+- Push-to-talk mutes the default source and leaves it muted between holds (=ptt.toggle_plan=). =ptt.read_state()= persists =armed=, so the deliberate mute is already knowable.
+- A monitor source is a legitimate default source. =probe_semantic= passes =include_monitors=True= on purpose.
+
+* Goals and Non-Goals
+
+** Goals
+
+- Classify the input side with the same rigor as the output side: default set, default present, muted, at zero volume.
+- Never report an armed push-to-talk mic as a fault.
+- Distinguish "the sound server does not see a microphone the kernel does" from "no capture hardware is attached", and say which.
+- Let the user assert that a microphone should exist, without a dialog.
+- One doctor at a time.
+
+** Non-Goals
+
+- Rebuilding push-to-talk. See the decision below; the mute is the safety property, not the bug.
+- Per-application capture routing. If Chrome cannot see a source the graph plainly has, that is Chrome's fault, and Helvum and qpwgraph exist. This spec makes the doctor able to *say* the stack is fine, which is the sentence that was missing. It does not chase the client.
+- Diagnosing hardware below the kernel. If =/proc/asound= shows no capture device, this spec reports that fact. It does not test cables, USB ports, or drivers.
+- Sample-rate, latency, or xrun concerns. Still out, as in the parent spec.
+- Silent privileged action. The doctor may use =sudo= for the input and output expansion (see the decision "The doctor may use sudo, resolved by context at run time"), but never silently: every privileged or reboot-tail remedy defaults to Confirm or Arm tier. Auto stays reserved for user-scope, reversible remedies.
+
+** Scope tiers
+
+- *v1:* the kernel capture tier; PTT-aware source classification; input verdicts and their remedies; the two section-header doctor keys with mutual exclusion; explicit =audio doctor --output= / =--input= flags, output aliased as the default.
+- *Out of scope:* PTT rebuild; per-application routing; hardware diagnosis.
+- *vNext:* per-device correlation between the kernel capture set and graph sources (v1 uses the coarse set-emptiness rule instead); and a third sighting of "the graph is fine and one client cannot use it", which turns into its own design question about whether the doctor should name the application layer. Both logged to =todo.org=, not built here.
+
+* Design
+
+** For the user
+
+The DOCTOR section header we shipped on 2026-07-10 goes away. In its place, the OUTPUTS and INPUTS engraved section headers each grow a doctor key, beside the device counts they already carry. The keys diagnose the direction they sit above, and both write to the same results wall below.
+
+The keys do not repeat the direction words. The CONTROLS section already carries INPUT and OUTPUT keys — the mute toggles that read LIVE / MUTE — so a doctor key labelled OUTPUT a few rows up would read as a fourth mute control. The doctor keys are named for what they examine instead: SPEAKERS on the OUTPUTS header, MICROPHONE on the INPUTS. Each carries a distinct diagnostic treatment so a user reads it as a different kind of key, not a device control. See the decision below; that treatment is the same on every panel's doctor.
+
+Pressing MICROPHONE says something a probe cannot: that this machine is supposed to have a microphone. That single bit resolves the ambiguity the classifier cannot. An empty source list is normal on a desktop; an empty source list *under a press of the MICROPHONE doctor* is a failure, and the doctor now has license to say so.
+
+The wall names which doctor ran, and only one runs at a time. Pressing SPEAKERS while a MICROPHONE run is in flight does nothing, because the wall holds one verdict and a second run would overwrite the first mid-read.
+
+Diagnosis remains free and read-only. The escalation the user asked for — "we tried reloading all the devices and there is still no microphone" — is a *repair*, not a diagnosis, because reloading devices means restarting WirePlumber. So the diagnose verdict names what is wrong, and FIX is what tries the reload and re-probes. The wall then carries a claim the doctor earned rather than one it assumed:
+
+#+begin_example
+DOCTOR · MICROPHONE diagnose · read-only
+ ok pipewire.service active
+ ok wireplumber.service active
+ ok pipewire-pulse.service active
+ ok graph 4 sinks, 3 sources (via pw-dump)
+ ok pulse compat answering
+ FAIL capture hardware kernel sees MV7, BRIO, Generic_1 — the graph has none
+ -- default input not checked (no input devices)
+
+ verdict: The sound server does not see your microphone.
+ The kernel has 3 capture devices and PipeWire has none, so the
+ hardware is attached and the software lost it. FIX reloads the
+ device profiles.
+#+end_example
+
+And when the kernel sees nothing either, the doctor stops rather than offering a remedy for a fault that is not there:
+
+#+begin_example
+ FAIL capture hardware no capture device is attached
+
+ verdict: No microphone is attached.
+ The kernel sees no capture hardware at all, so this is a cable, a
+ port, or a device that is off. Nothing in the sound stack can fix it.
+#+end_example
+
+** For the implementer
+
+*** A tier below the graph
+
+The probe ladder gains a tier at the bottom, and its position is the point. Today's order is =systemctl= (cannot hang), =pw-dump= (alive when Pulse is dead), =pactl= (may hang). The kernel tier reads =/proc/asound=, which is a filesystem read that cannot hang, cannot fail for want of a package, and answers when every other layer is dead. It is the only probe in the doctor that carries no timeout, because it cannot block.
+
+It answers one question: which cards expose a capture PCM. A card directory containing a =pcm*c= entry has one. That set is what separates a lost device from an absent one.
+
+The comparison is deliberately coarse in v1, because the two layers speak different namespaces. The kernel tier names cards by ALSA id (=MV7=, =BRIO=); the graph names sources by PipeWire =node.name=. Mapping one to the other device-by-device is a rabbit hole, and the doctor does not need it. The v1 rule is a set-emptiness test, not a per-device match: =mic-unrecognized= fires when the kernel capture set is non-empty *and* the graph has zero non-monitor hardware sources. A monitor source does not count as a microphone (it is desktop-audio capture), so a graph carrying only monitors still reads as "no real input the kernel's hardware could be feeding." That coarse rule catches the whole-mic-lost case this feature exists for without ever having to prove that card X is the same device as source Y. Per-device correlation is a vNext refinement, logged, not built.
+
+*** The classifier takes a direction
+
+=classify(ctx)= becomes =classify(ctx, side)= where side is =output= or =input=. The terminal-fault ladder is shared and unchanged: PipeWire down, WirePlumber down, Pulse hung, Pulse dead, tooling missing. All of them break both directions, and the first one that fires wins regardless of side.
+
+Below that the ladder forks. The output side keeps its rules exactly as they are. The input side gets, in order: no capture hardware; hardware present but no source in the graph; a default source that names an absent device; a mic muted or at zero. Every input rule is gated on the user having pressed the input doctor, which is what makes an empty source list a fault instead of a fact.
+
+Push-to-talk sits inside that last rule. =diag.gather()= reads =ptt.read_state()= into the context, and a muted default source with PTT armed is not a finding. It is the mic doing exactly what the user asked. The wall reports it as an informational row rather than saying nothing, because a user who has forgotten PTT is armed deserves to be told why the mic is silent.
+
+*** The guard learns direction
+
+=active_streams()= returns running streams of both directions, and =repair.guard()= refuses the audible remedies whenever any of them is running. That is already slightly wrong: a playback remedy is refused today when the only thing running is a recording. Filtering by direction fixes that and is what the mic remedy needs.
+
+Unmuting a microphone is not audible. It is a privacy event. If something is capturing when the remedy lands, the user's voice goes out. So the mic-unmute remedy is guarded against *input* streams with the same press-again override the audible remedies use, and the playback remedies are guarded against *output* streams.
+
+* Failure modes and remedies
+
+The whole point of a doctor is that every fault it can name has a stated fix, and the reader can see the two together. This is that map: what goes wrong at each probe tier, the verdict the classifier reaches, and the remedy FIX runs. The tiers are the probe ladder, top to bottom, so a fault lower in the table is one the doctor reaches only after the tiers above it answered clean.
+
+The first four tiers are shared: a broken unit, a dead graph, or a dead Pulse layer takes out both directions at once, and the same verdict serves whichever doctor the user pressed. The kernel tier and the source-side semantic rules are what this spec adds.
+
+| Tier | What goes wrong | Verdict | Remedy |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 1 units (=systemctl=) | =pipewire= not active, or | =graph-down= | 5: restart =pipewire= (Arm) |
+| | the graph will not answer | | |
+| | though the unit claims up | | |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 1 units | =wireplumber= not active | =no-session-manager= | 1: restart =wireplumber= |
+| | | | (Auto) |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 1 units | =pipewire-pulse= not active | =pulse-down= | 4: restart =pipewire-pulse= |
+| | | | (Confirm) |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 2 graph (=pw-dump=) | the graph is up but has no | =no-output-devices= | 1: restart =wireplumber=, |
+| | sink | | which re-creates devices |
+| | | | (Auto) |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 3 pulse (=pactl=) | the socket accepts and never | =pulse-hung= | 4: restart =pipewire-pulse= |
+| | answers | | (Confirm) |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 3 pulse | the socket refuses | =pulse-down= | 4: restart =pipewire-pulse= |
+| | | | (Confirm) |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 3 pulse | a probe's own tool is | =tooling-missing= | none: the stack is |
+| | missing | | unobserved, not broken |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 4 semantic, output | the default sink names a | =stale-default= | 2: set a present default |
+| | device that is absent | | sink (Auto) |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 4 semantic, output | the default sink is muted or | =silenced= | 3: unmute and restore volume |
+| | at zero volume | | (Confirm) |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 4 semantic, input | the default source names a | =mic-stale-default= | 7: set a present default |
+| | device that is absent | | source (Auto) |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 4 semantic, input | the default source is muted | =mic-silenced= | 8: unmute and restore volume |
+| | or at zero, PTT off | | (Confirm) |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 4 semantic, input | the default source is muted, | healthy, informational row | none |
+| | PTT armed | | |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 5 kernel (=/proc/asound=) | kernel capture set non- | =mic-unrecognized= | 6: reload profiles, restart |
+| | empty, graph has no non- | | =wireplumber= (Confirm) |
+| | monitor hardware source | | |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| 5 kernel | no capture device is | =no-mic-hardware= | none: a cable, a port, or a |
+| | attached at all | | device that is off |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+| all clean | nothing is wrong | =healthy= | none |
+|---------------------------+------------------------------+----------------------------+------------------------------|
+
+Three entries carry no remedy on purpose, and they are not the same kind of no. =tooling-missing= means the doctor could not observe the stack, =no-mic-hardware= means there is nothing in the stack to fix, and =healthy= means there is nothing wrong. A doctor that offered a button for any of the three would teach the user to distrust the wall.
+
+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
+
+The idea: keep the source unmuted, and instead unlink its nodes from capturing applications (=pw-link=), or stand up a virtual source that applications connect to and link the real mic into it only while the key is held.
+
+- Good, because the doctor would see an unmuted mic and need no special case, and because an application's own level meter would keep working.
+- Bad, because the microphone stays live at the server and silence becomes a property of graph topology. A crash mid-hold, or a link that fails to tear down, is a hot mic while every indicator reads muted. Source-mute is the one state where the server itself guarantees nothing is captured.
+- Bad, because the virtual-source variant requires every application to select the virtual device, which breaks per-application device choice — the thing the panel exists to give.
+- Neutral, because the doctor does not need it: =ptt.read_state()= already persists the fact that the mute is deliberate.
+
+Rejected. The mute is the safety property.
+
+** One doctor that classifies both directions at once
+
+- Good, because one press and one verdict.
+- Bad, because =classify()= returns exactly one verdict, so a machine with a stale sink default and a muted mic forces the classifier to rank two unrelated faults. Any ranking is a guess about what the user came to fix.
+- Bad, because it cannot resolve the absent-microphone ambiguity at all. No press means no assertion, and the doctor is back to shrugging.
+
+Rejected in favor of one doctor per direction, which supplies the missing intent as a side effect of where the user pressed.
+
+** Use =arecord -l= for the kernel tier
+
+- Good, because it is the obvious tool and its output is human-readable.
+- Bad, because it is owned by =alsa-utils=, a dependency the audio engine does not otherwise carry, so its absence would have to be modelled as a fourth =tooling-missing= case.
+- Bad, because it spawns a process, which means a timeout, which means another way for the bottom tier to fail.
+- Neutral, because it reads the same kernel data =/proc/asound= exposes directly.
+
+Rejected. The bottom tier should be the one thing in the doctor that cannot fail.
+
+* Review findings [1/10]
+
+Second review pass, 2026-07-10 (four critical lenses: buildability, technical correctness, adversarial failure-modes, internal consistency). The technical foundations checked out — every load-bearing code and =/proc/asound= claim verified against the real tree. These findings are what the passes surfaced beyond that. The mechanical inconsistencies they also found (a "two new remedies" miscount, two stray OUTPUT/INPUT doctor-key labels) are already fixed in place.
+
+** TODO Mic-unmute must fail closed on an unreadable graph :blocking:
+The stream-active guard abstains and proceeds when =pw-dump= cannot read the graph. That is right for the *output* remedies, whose whole job is repairing a dark graph. It is wrong for the mic-unmute remedy (8), because unmuting is a privacy event, not an availability one: with the graph unreadable the guard cannot see a running capture stream, so FIX would unmute a live mic mid-recording with no second press and no warning. The mic-unmute remedy must fail *closed* on an unreadable graph — refuse, or fall back to =pactl list source-outputs= — never abstain-and-proceed. Every other finding here produces a wrong verdict; this one produces a wrong action. Folds into the open capture-guard decision. (blocking)
+
+** TODO A non-ALSA microphone classifies as no-mic-hardware :blocking:
+A Bluetooth or USB-non-ALSA mic never appears under =/proc/asound/card*=, because bluez runs through PipeWire, not ALSA. The =no-mic-hardware= verdict is gated only on "kernel capture set empty", with no graph clause, so a working BT-headset mic (a real non-monitor source in the graph) reads as "No microphone is attached." The input ladder must consult the graph first: a working non-monitor source in the graph is =healthy= (or routes to the semantic rules) and skips the kernel tier entirely. =no-mic-hardware= fires only when the kernel set is empty *and* the graph has no non-monitor source; =mic-unrecognized= only when the kernel set is non-empty *and* the graph has none. This also resolves the ladder-order contradiction below. Folds into the open input-verdicts decision. (blocking)
+
+** TODO The input ladder's evaluation order is stated two ways :important:
+The "For the implementer" prose lists the kernel-tier rules first (no-mic-hardware, mic-unrecognized), then the semantic rules. The failure table orders the semantic-input rows (tier 4) above the kernel rows (tier 5) and says lower rows are reached only after upper tiers answer clean. Those are opposite orders for the same four rules. The finding above settles it: graph-source presence is the top gate, so define one order and make both the prose and the table match it.
+
+** TODO The probe does not yet surface source mute, volume, or a monitor flag :important:
+=probe_semantic= produces =default_sink_muted= / =default_sink_volume= but no source equivalents, and neither the graph nor the semantic source dicts carry a monitor flag (=pactl._device= strips =device.class=). The =mic-silenced= rule reads source mute/volume the context never provides, and the "non-monitor hardware source" test cannot be computed. Phase 0 must extend =probe_semantic= to surface the source fields and tag sources monitor-vs-hardware, and the =mic-unrecognized= rule must read the =pw-dump= source population (=Audio/Source=, monitor-excluding), not the =pactl include_monitors=True= one — reading the monitor-inclusive list would mean a monitor is always present and the rule never fires. Neither change is scoped by an open decision; both would stall Phase 0/1.
+
+** TODO The direction must thread through findings() and doctor(), not only classify() :important:
+The spec says =classify(ctx)= becomes =classify(ctx, side)= but never that =findings(ctx)= gains =side= too, though the wall shows side-specific rows. =doctor.doctor()= is the sole caller and invokes both =classify= and =findings= twice each (initial and re-probe), plus =final_findings= / =final_verdict=; it must take =side= and thread it to all of them, and =cli.cmd_doctor= must pass the resolved side. Name this in the phase text so it is not left for the implementer to rediscover.
+
+** TODO Remedy 6 restarts WirePlumber unguarded, mid-call :important:
+=mic-unrecognized='s remedy (6, restart WirePlumber) is in neither guard set, so a user on a call can press FIX and reload the device profiles with no guard and no second-press consent. A profile reload can re-select the default sink and drop the call's routing. Decide whether remedy 6 is guarded — against streams of *both* directions, since a WirePlumber restart touches the whole graph — as part of the open capture-guard decision.
+
+** TODO A stale armed=true PTT state can mask a genuinely muted mic :important:
+The safe-degrade argument covers only an unreadable or missing PTT state file (=read_state= returns disarmed). It does not cover =armed=true= left stale — Hyprland restarted and dropped the binds, or the mic was muted by the CONTROLS toggle for an unrelated reason while PTT happened to be armed. =read_state= cannot detect staleness, so the doctor reports "healthy, PTT armed" on a genuinely dead mic, the exact failure this feature exists to fix. At minimum, name the limitation; better, have the informational row say the mic is muted *and* that PTT is armed, so the user is not told the mic is simply fine.
+
+** TODO Minor build-notes to fold in before Phase 3 :minor:
+Three smaller items the passes raised. The =healthy= verdict text is output-worded ("the default output is present and audible") and needs side-aware wording for a clean =MICROPHONE= run. The two keys must clear =doctor_running= on the worker's error path so a raised worker cannot wedge both keys insensitive for the session — the shipped =bg= / =doctor_abort= pattern already does this, so it is a spec-explicitness note, not a code gap. And a USB mic pressed during its ~1s enumeration window (kernel sees the card before WirePlumber creates the source) fires a transient =mic-unrecognized=; self-correcting, worth a line acknowledging the race.
+
+** TODO Open Decisions still gate implementation :blocking:
+The workflow treats unresolved decisions as implementation blockers, because a builder would be accepting product and safety tradeoffs mid-build. Partly addressed as of 2026-07-10: Craig closed the push-to-talk and kernel-tier decisions, and the CLI-flag decision closed with his direction, so the cookie is now =[6/9]=. Three decisions remain open — the direction-aware capture guard, the input verdict and remedy table, and the doctor-key naming and cross-panel style. Close or explicitly risk-accept each before implementation starts. (blocking)
+
+** DONE Kernel capture devices are not mapped to graph sources :blocking:
+CLOSED: [2026-07-10 Fri]
+The finding: the kernel names cards by ALSA id and the graph names sources by PipeWire =node.name=, so "compare the kernel capture set against the graph's source list" was undefined, and monitor sources muddied it further. Resolved 2026-07-10 by adopting the reviewer's coarse rule as the v1 definition: =mic-unrecognized= fires when the kernel capture set is non-empty and the graph has zero non-monitor hardware sources. No per-device correlation, no namespace mapping — that whole-mic-lost case is the one this feature exists for, and per-device matching is logged as vNext. The rule now appears in the "For the implementer" kernel-tier note, the failure table, and the input-verdicts decision.
+
+* Decisions [7/10]
+
+** DONE A doctor key per direction, on the section headers
+CLOSED: [2026-07-10 Fri]
+Context: an empty source list is normal on a mic-less desktop and a failure on a machine that should have one. No probe can tell them apart.
+
+Decision: we will put a doctor key on each of the OUTPUTS and INPUTS engraved section headers, and retire the standalone DOCTOR header and its DIAGNOSE key. Pressing the input doctor asserts that a microphone should exist.
+
+Consequences: the ambiguity is resolved by the user without a dialog, and the check sits beside the devices it judges. Harder: this supersedes a decision in the parent spec that shipped hours earlier, so the parent's history must say so. The panel also grows a third and fourth console key, and the 400px width has to absorb them.
+
+** DONE One doctor at a time
+CLOSED: [2026-07-10 Fri]
+Context: the wall holds one verdict, and a run clears the wall when it begins.
+
+Decision: we will allow only one doctor run in flight. SPEAKERS must finish before MICROPHONE can be pressed, and both keys go insensitive for the duration.
+
+Consequences: the wall is never a mix of two runs, and "which verdict is this" never needs asking. Harder: nothing, since the model already clears on begin and locks on =doctor_running=. The keys simply join the lock.
+
+** DONE An absent microphone is not a fault unless the user says so
+CLOSED: [2026-07-10 Fri]
+Context: =no-output-devices= is a fault because a machine with no way to play sound is broken. The mirror is false: a machine with no way to record is ordinary.
+
+Decision: we will not add a source-side =no-input-devices= fault. The input rules fire only under a press of the input doctor, which supplies the assertion that a mic should exist.
+
+Consequences: the doctor never cries wolf on a mic-less desktop. Harder: the classifier now takes a side parameter, so it is no longer a pure function of the context alone. It stays pure; the side is an input.
+
+** DONE Does push-to-talk get rebuilt?
+CLOSED: [2026-07-10 Fri]
+Context: PTT mutes the default source and leaves it muted between holds, so a naive mirror of the sink rules would classify an armed mic as "silenced, FIX unmutes it". Pressing FIX would break PTT and leave a hot mic the user believes is muted. Craig asked whether PTT could avoid muting.
+
+Decision: we will not rebuild PTT. =diag.gather()= will read =ptt.read_state()= into the context, and an armed-PTT muted mic will be reported as an informational row rather than a finding.
+
+Consequences: a few lines rather than a rewrite, and the safety property is preserved — source-mute is the only state where the server guarantees no capture. Harder: the doctor now depends on a runtime state file, so a stale or unreadable file must degrade to "not armed" (which =read_state()= already does) and the mic then reads as a genuine finding. That is the safe direction: it over-reports a muted mic rather than under-reporting a hot one.
+
+Craig ratified 2026-07-10: not rebuilding is fine.
+
+*** Discussion
+The alternatives are recorded above under "Rebuild push-to-talk so it does not mute the mic". The load-bearing argument is that link-based silence fails open.
+
+** DONE The kernel tier reads =/proc/asound=, not =arecord=
+CLOSED: [2026-07-10 Fri]
+Context: the doctor needs a layer below PipeWire to tell a lost microphone from an absent one.
+
+Decision: we will read =/proc/asound/card*/pcm*c= directly. A card exposing a capture PCM has capture hardware.
+
+Consequences: the bottom tier cannot hang, cannot be absent, and needs no timeout — the only probe in the doctor with that property. Harder: it is Linux-specific and reads a kernel interface rather than a supported API, so a kernel that reorganizes =/proc/asound= would break it silently. The fallback is that an unreadable =/proc/asound= yields "unknown", never "no hardware", so the doctor abstains rather than lying.
+
+Craig ratified 2026-07-10: all okay.
+
+** TODO The capture guard, and what it refuses
+Context: unmuting a microphone is not audible, but it is a privacy event. =active_streams()= currently ignores stream direction, so a playback remedy is refused today when only a recording is running.
+
+Decision (proposed): we will make the guard direction-aware. Playback remedies (3, 4, 5) refuse on running *output* streams; the mic-unmute remedy refuses on running *input* streams. Both take the same press-again override, and both abstain on an unreadable graph, as today.
+
+Consequences: the guard stops over-refusing, and unmuting a mic mid-recording takes deliberate consent. Harder: it changes the behavior of shipped remedies, so the existing guard tests move with it rather than being deleted.
+
+Owner: Craig. By: before Phase 2 lands.
+
+** TODO The input verdicts and their remedies
+Context: the input side needs its own verdict vocabulary and its own remedy table entries.
+
+Decision (proposed): four new verdicts, and three new remedies.
+
+| Verdict | Means | Remedy | Tier |
+|---------------------+------------------------------------------+-------------------------------------------+---------|
+| =no-mic-hardware= | the kernel sees no capture device | none | — |
+|---------------------+------------------------------------------+-------------------------------------------+---------|
+| =mic-unrecognized= | kernel capture set non-empty, graph has | 6: reload device profiles (restart | Confirm |
+| | no non-monitor hardware source | WirePlumber) | |
+|---------------------+------------------------------------------+-------------------------------------------+---------|
+| =mic-stale-default= | the default source names a device that | 7: set a present default source | Auto |
+| | is absent | | |
+|---------------------+------------------------------------------+-------------------------------------------+---------|
+| =mic-silenced= | the default source is muted or at zero, | 8: unmute and restore the mic volume | Confirm |
+| | and PTT is not armed | | |
+|---------------------+------------------------------------------+-------------------------------------------+---------|
+
+=no-mic-hardware= carries no remedy for the same reason =tooling-missing= carries none: offering a fix for a fault that is not in the stack teaches the user to distrust the wall.
+
+=mic-unrecognized= uses the coarse set-emptiness rule from the "For the implementer" kernel-tier note, not a per-device match. The kernel names cards by ALSA id and the graph names sources by PipeWire =node.name=; those are different namespaces, and correlating them device-by-device is a vNext refinement. v1 fires =mic-unrecognized= when the kernel capture set is non-empty and the graph has zero non-monitor hardware sources. This resolves the second spec-review finding, which flagged the matching rule as undefined.
+
+Consequences: the verdict table grows from ten to fourteen, and =classify.findings()= grows a capture-hardware row and a default-input row. Harder: remedy 6 restarts WirePlumber, which remedy 1 already does for a different verdict. Two remedies invoking one action is fine; collapsing them would couple two diagnoses that should stay apart.
+
+Owner: Craig. By: before Phase 1 lands.
+
+** DONE The CLI grows explicit =--output= and =--input= flags, output aliased as the default
+CLOSED: [2026-07-10 Fri]
+Context: =audio doctor= today accepts only =--fix= and =--force=. There is no =--output=, so adding a lone =--input= would read as a special case bolted onto a bare command rather than one of a symmetric pair.
+
+Decision: we will add both =--output= and =--input= as explicit direction flags, and alias bare =audio doctor= to =audio doctor --output=. Both accept =--fix= and =--force=. The two flags are mutually exclusive; passing both is an error.
+
+Consequences: the two directions are named symmetrically, and =--input= has a sibling to pair against rather than standing alone. Bare =audio doctor= keeps its current meaning, so nothing that scripts it breaks, and "output is the default" is now a stated alias a reader can see rather than a convention to infer. Harder: =audio doctor --input --fix --force= is a long line, which is the cost of explicitness over =audio doctor input=; the subcommand form was rejected because it would break every existing call.
+
+Craig's call, 2026-07-10: "alias the command and have =audio doctor --output= be the one that is run by default. That way it's symmetrical, and =audio doctor --input= has a place."
+
+** TODO Name and style the doctor keys, distinct from the device controls
+Context: the CONTROLS section already carries INPUT and OUTPUT keys — the mute toggles that read LIVE / MUTE. A doctor key labelled OUTPUT or INPUT on the section header a few rows up would read as a fourth mute control. Craig flagged the collision and asked for a name and a look that mark the doctor keys as a different function, held consistent across every panel.
+
+Decision (proposed): the doctor keys are named for what they diagnose — SPEAKERS on the OUTPUTS header, MICROPHONE on the INPUTS. Craig delegated the naming ("I'll let you choose"); these are the choice. Each key carries a distinct diagnostic treatment: a small health glyph (a stethoscope, =nf-md-stethoscope=) ahead of the word, plus a subtly distinct outline, so the key stays inside the dupré aesthetic while reading as diagnostic rather than a device control. The same treatment applies to the doctor keys already shipped in the other panels — net's DOCTOR, maint's CLEAN UP / REVIEW & FIX — so a doctor key looks like a doctor key everywhere.
+
+Consequences: no word collides with the CONTROLS keys; the two doctor keys carry distinct AT-SPI names the smoke can target; the diagnostic affordance is consistent across the panel family. Harder: it touches every panel's =gui.py= and the shared panel CSS, so the cross-module restyle is its own task rather than part of this build, and it sits beside the already-filed "copy + close on every wall" convergence. The exact glyph and outline are an aesthetic call that wants Craig's eye on a screenshot before it lands — which is why this stays open while the naming is settled.
+
+Owner: Craig (the visual). By: before Phase 3, where the keys are built.
+
+** DONE The doctor may use sudo, resolved by context at run time
+CLOSED: [2026-07-10 Fri]
+Context: the parent spec decided "no sudo anywhere", correct when the feature was purely user-scope PipeWire. The input and output expansion reaches firmware, ALSA saved state, modprobe overrides, and package management, which genuinely need root. Craig also wants the CLI usable as a generic doctor on machines that are not archsetup installs.
+
+Decision: this supersedes the parent's no-sudo decision. The doctor resolves its privilege at run time from three signals — passwordless sudo available (=sudo -n true=, which succeeds or fails instantly and never hangs), a tty to prompt at, and whether it is the GUI panel. Four remedy classes follow: *Auto* (user-scope, reversible, runs anywhere), *Privileged* (needs sudo — runs silently where passwordless sudo exists, prompts in a CLI with a tty, and degrades to Guide only in a GUI with neither), *Reboot-tail* (the applicable part runs, then the doctor instructs the reboot it cannot complete or verify past), and *Guide* (nothing to run: a physical switch, a BIOS setting, a wait-for-upstream fix). Every archsetup install has passwordless sudo, so on Craig's machines Privileged remedies run; the generic-CLI case prompts.
+
+Consequences: many failure modes that were guide-only (=alsactl store=, a modprobe override, removing a conflicting package) become applicable remedies on archsetup and on any CLI. Harder, and load-bearing: passwordless sudo is not consequence-free, so every Privileged and Reboot-tail remedy defaults to *Confirm or Arm* tier, never silent Auto — a firmware install or a module reload always takes a deliberate second press even when sudo will not prompt. Auto stays reserved for the user-scope, reversible remedies. This model is not audio-specific: the net, bluetooth, and maint doctors adopt the same run-time privilege resolution and the same Confirm/Arm-default for privileged actions, tracked as its own cross-panel task.
+
+Craig agreed 2026-07-10 to both the model and the Confirm/Arm-default safety stance, and to making it a cross-panel standard.
+
+* Implementation phases
+
+Each phase leaves the tree green and independently useful, as the parent spec's phases did.
+
+** TODO Phase 0 — the kernel tier, PTT in the context, direction-aware streams
+Pure engine, no classifier changes, no UI. =diag.probe_kernel()= reads =/proc/asound=; =diag.gather()= adds =kernel= and =ptt= to the context; =active_streams(graph, direction=None)= filters. =audio diag --json= shows the new context. Fakes: a temp =/proc/asound=-shaped tree via an injectable root, since the real one cannot be faked on PATH.
+
+** TODO Phase 1 — =classify(ctx, side)= and the input verdicts
+Pure, fixture-driven. The shared terminal ladder, then the fork. Pairwise over (kernel capture set × graph sources × default source state × PTT armed). This phase adds the direction flags: =--output= and =--input=, mutually exclusive, with bare =audio doctor= aliased to =--output=. =audio doctor --input= read-only prints findings and a verdict, and =audio doctor= keeps printing exactly what it does today.
+
+** TODO Phase 2 — the input remedies and the direction-aware guard
+Remedies 6, 7, 8, each re-probing its own claim. The guard splits by direction. =audio doctor --input --fix=. The existing guard tests move rather than being deleted.
+
+** TODO Phase 3 — two section-header doctor keys
+Retire the DOCTOR header row and its DIAGNOSE key. OUTPUTS and INPUTS each gain one. Mutual exclusion on =doctor_running=. The wall header names the run. AT-SPI smoke over both keys and the fix path, on fixtures.
+
+** 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.
+- A machine whose kernel lists a capture device the graph has no source for reports =mic-unrecognized=, and FIX reloads the device profiles and re-probes.
+- An armed-PTT muted mic classifies as =healthy=, with an informational row saying push-to-talk is armed.
+- A muted mic with PTT disarmed classifies as =mic-silenced= and FIX unmutes it.
+- FIX on the mic while something is recording is refused, and a second press proceeds.
+- Restarting a playback remedy while only a microphone is recording is no longer refused.
+- Pressing SPEAKERS while MICROPHONE is running does nothing.
+- =audio doctor= with no flag behaves exactly as it does today.
+
+* Readiness dimensions
+
+- *Data model & ownership* — the context dict gains =kernel= (generated, per-probe) and =ptt= (read from the runtime state file that =ptt.py= owns; the doctor never writes it).
+- *Errors, empty states & failure* — an unreadable =/proc/asound= yields =unknown=, never "no hardware". An unreadable PTT state reads as disarmed, which over-reports a muted mic rather than under-reporting a hot one. Both are the safe direction.
+- *Security & privacy* — the mic-unmute remedy is the only privacy-relevant action in the feature, and it is guarded against running capture streams with an explicit second-press override. No credentials, no logs.
+- *Observability* — the wall is the observability surface, and it names which doctor ran. The =--json= output carries the full context including the kernel tier.
+- *Performance & scale* — the kernel tier is a directory listing. Everything else is unchanged.
+- *Reuse & lost opportunities* — =classify.findings()= remains the single row-builder for the CLI and the GUI. =ptt.read_state()= is reused rather than reimplemented. =diag.unit_active= stays the single source of truth for unit state.
+- *Architecture fit* — the fork lives in =classify()=, which is pure and already fixture-tested. The GUI change is confined to the section-header rows and the model's key-sensitivity logic.
+- *Config surface* — none. N/A.
+- *Documentation plan* — the module docstrings carry the design, as in the parent. No user docs; the wall is the documentation.
+- *Dev tooling* — =make test= and =make test-panel-audio= cover it. The kernel tier needs an injectable root rather than a PATH fake, which is a new fixture shape for this package.
+- *Rollout, compatibility & rollback* — =audio doctor= with no flag is unchanged, so nothing that scripts it breaks. The GUI change is not reversible by config; it is a redesign of a key that shipped the same day.
+- *External APIs & deps* — =/proc/asound= layout verified live on ratio, 2026-07-10. No new packages.
+
+* Risks, rabbit holes, and drawbacks
+
+The kernel tier reads an interface rather than an API. If a future kernel reorganizes =/proc/asound=, the probe reports =unknown= and the doctor abstains, which is the failure mode we want, but nothing will announce the breakage. A test against a recorded tree pins the shape we expect.
+
+Remedy 6 restarts WirePlumber to reload device profiles, which is what remedy 1 already does. The temptation is to collapse them. Do not: the two verdicts describe different faults, and a shared remedy is not a shared diagnosis. The parent spec made exactly this call for =pulse-hung= and =pulse-down=.
+
+The panel is 400px wide and gains two console keys while losing one row. The layout needs eyes on it before Phase 3 closes, and no automated check will catch an ugly one.
+
+This spec supersedes a decision from a spec that reached IMPLEMENTED the same day. That is a smell worth naming: the DOCTOR-key placement was decided without the input side in view, and one conversation with the user overturned it. The lesson is not that the first decision was careless. It is that the input side should have been in scope from the start, and the parent's Non-Goals never said it was not.
+
+* Review and iteration history
+
+** 2026-07-10 Fri @ 18:52:07 -0500 — Craig Jennings — Reviewer
+What changed or was recommended: a second review pass across four critical lenses — buildability, technical correctness, adversarial failure-modes, internal consistency. Verified every load-bearing code and =/proc/asound= claim against the real tree; all held. Recorded eight new findings (two blocking: mic-unmute failing open on an unreadable graph, and a non-ALSA mic mis-classifying as no-mic-hardware) and fixed three mechanical inconsistencies in place (a remedy miscount and two stray OUTPUT/INPUT doctor-key labels).
+Why: the design was sound at the tier level, but it had two safety and correctness holes that only surface on real hardware (a webcam or Bluetooth mic, an unreadable graph), plus three build gaps that sit outside the open decisions and would stall Phase 0/1.
+Artifacts: [[*Review findings][Review findings]]; code read across =~/.dotfiles/audio/src/audio/= (=diag.py=, =classify.py=, =repair.py=, =doctor.py=, =ptt.py=, =pactl.py=); live =/proc/asound= survey on ratio.
+
+** 2026-07-10 Fri @ 10:55:19 -0500 — Codex — Reviewer
+What changed or was recommended: applied the spec-review workflow and recorded two blocking findings: open Decisions still gate implementation, and kernel-capture-to-graph-source matching is undefined.
+Why: the current implementation confirms the microphone gap and output-only classifier, but these unresolved choices would force the builder to decide safety and classification behavior during implementation.
+Artifacts: [[*Review findings][Review findings]]; current code read in =~/.dotfiles/audio/src/audio/diag.py=, =classify.py=, =repair.py=, =cli.py=; related tracking in [[file:../../todo.org][todo.org]] under "The audio doctor never checks the microphone".
+
+** 2026-07-10 Fri @ 10:38:12 -0500 — Craig Jennings — Author
+What: drafted the spec. Four probe tiers become five, the classifier takes a direction, the panel gets a doctor per direction, and push-to-talk stays as it is.
+Why: the doctor calls a stack healthy while the microphone is dead, and the ambiguity of an absent mic cannot be resolved by any probe.
+Artifacts: live survey of ratio (=/proc/asound= capture nodes, =_STREAM_CLASSES=, =ptt.read_state()=), and the Chrome incident of the same morning.
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..8998585
--- /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 =GW_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 =GW.*= 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. =GW.vuMeter(el, {value: 0.6})= — and get back a handle with setters. Widget-internal CSS ships inside =widgets.js= (the =GW_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 =GW=), 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 =GW.*= 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, =GW= 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 =GW.*= 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 =GW_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/workflows/strix-soak-watch.org b/docs/workflows/strix-soak-watch.org
new file mode 100644
index 0000000..b86921d
--- /dev/null
+++ b/docs/workflows/strix-soak-watch.org
@@ -0,0 +1,144 @@
+#+TITLE: Strix Halo SMU Deadlock — Kernel Update Watch
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-10
+
+* Purpose
+
+Companion to =system-health-check.org=. Triggered when linux / linux-lts / linux-firmware land in =checkupdates= on ratio, this addendum checks whether the upstream side of the Strix Halo SMU deadlock has moved — so we know when to retire the locally-patched =linux-lts-strix= kernel and switch back to stock.
+
+This is a one-time hardware/kernel issue. Delete this file when the strix kernel is retired.
+
+* Current State (as of 2026-05-10)
+
+- *Boot kernel:* =linux-lts-strix 6.18.25-1= (custom AUR-style local build), GRUB default pinned
+- *SMC firmware:* =100.6.0= (from =/sys/kernel/debug/dri/*/amdgpu_firmware_info= SMC line)
+- *Cmdline workaround:* =amdgpu.no_vpe_idle_pg=1= active
+- *Soak verdict:* HOLDING since 2026-04-30 — zero SMU/dcn35/VPE freeze signature hits (see the 2026-05-04 soak-check verdict, archived outside this repo)
+
+* Background (one-paragraph summary so future sessions don't have to reconstruct it)
+
+Strix Halo (gfx_v11_5_0, smu_v14_0_2 APU) has an SMU firmware deadlock triggered during VPE (Video Processing Engine) idle power-gating: hard freeze, no journal output, requires power-cycle. Crupi posted a workaround patch on the Framework community forum that disables VPE idle power-gating via =amdgpu.no_vpe_idle_pg=. Real fix has to come from AMD as a PMFW/SMU firmware update (delivered via =linux-firmware=). Until then, ratio runs a custom kernel build that carries the patch + two supporting amdgpu allocation-in-critical-path patches. Forum thread: https://community.frame.work/t/smu-deadlock-system-freeze-on-fedora-43/81795
+
+* Local Patch Set
+
+Located at =~/build/linux-lts-patched/linux-lts/=:
+
+| File | Strix-relevant? |
+|---------------------------------------------------------------------------------+-----------------|
+| =0001-ZEN-Add-sysctl-and-CONFIG-to-disallow-unprivileged-CLONE_NEWUSER.patch= | No (security) |
+| =0002-drm-amdgpu-avoid-memory-allocation-in-the-critical-code-path-v3.patch= | Yes |
+| =0003-drm-amdgpu-use-GFP_ATOMIC-instead-of-NOWAIT-in-the-critical-path.patch= | Yes |
+| =0004-drm-amdgpu-add-no_vpe_idle_pg-strix-halo-smu-deadlock-workaround.patch= | Yes (Crupi's) |
+
+* When This Watch Fires
+
+Phase 3 of =system-health-check.org= invokes this addendum when *any* of the following appear in =checkupdates=:
+
+- =linux= (any version bump)
+- =linux-lts= (any version bump)
+- =linux-firmware= (any version bump — this is the most likely vector for the real fix)
+- =mesa= (major version only, e.g. 26.x.y → 27.x.y — display-stack timing can shift the deadlock window)
+
+* What To Check On Each Fire
+
+** 1. Stable kernel changelogs (per bumped version)
+
+Fetch the per-version changelog and search for the keyword set below. The 2026-05-10 baseline pass found *no matches* in 6.18.27, 6.18.28, 7.0.4, 7.0.5 — so anything that does match is news.
+
+#+begin_src
+https://cdn.kernel.org/pub/linux/kernel/v6.x/ChangeLog-<NEW-LTS-VERSION>
+https://cdn.kernel.org/pub/linux/kernel/v7.x/ChangeLog-<NEW-MAINLINE-VERSION>
+#+end_src
+
+Keyword set (case-insensitive):
+- =no_vpe_idle_pg=, =vpe_idle=, =VPE idle=, =VPE power gating=
+- =Strix Halo=, =STRIX_HALO=, =strix_halo=
+- =gfx_v11_5= (the GFX block id for Strix Halo)
+- =smu_v14= (the SMU IP block driver, =drivers/gpu/drm/amd/pm/swsmu/smu14=)
+- =dcn351= (Strix Halo display controller; =dcn35= is Phoenix-class — adjacent but not the same)
+- =drm/amdgpu/vpe=, =drm/amdgpu/swsmu/smu14=, =amdgpu/pm/swsmu=
+- =SMU deadlock=, =critical path= (combined with amdgpu/drm)
+
+The two adjacent allocation-fix patches (0002, 0003) may land separately from Crupi's patch. They have distinctive subject lines: =avoid memory allocation in the critical code path= and =use GFP_ATOMIC instead of NOWAIT in the critical path=. Search for those too.
+
+** 2. linux-firmware delta (if linux-firmware is the bumped package)
+
+The canonical fix arrives as a PMFW/SMU firmware update, NOT a kernel patch.
+
+#+begin_src bash
+# Pre-bump SMC version (from running system, BEFORE topgrade)
+sudo cat /sys/kernel/debug/dri/*/amdgpu_firmware_info | grep "SMC feature"
+
+# After topgrade installs the new linux-firmware, examine the delta.
+# Watch for these files in the upgraded package:
+# amdgpu/smu_14_0_2.bin — Strix Halo SMU firmware
+# amdgpu/psp_14_0_*.bin — PSP/secure-os blobs
+# amdgpu/strix* — anything Strix-tagged
+pacman -Ql linux-firmware | grep -iE "smu_14_0_2|psp_14_0|strix"
+
+# Reboot is required to load the new firmware. Re-check SMC version post-reboot.
+#+end_src
+
+A bump from =100.6.0= to anything higher is the canonical Strix-fix signal — that means AMD shipped new SMU firmware. Once that lands, the workaround kernel is no longer needed (in theory; see "Outcomes" below for the validation path).
+
+** 3. Crupi's forum thread
+
+#+begin_src
+https://community.frame.work/t/smu-deadlock-system-freeze-on-fedora-43/81795
+#+end_src
+
+Skim recent posts for:
+- "Merged upstream" / "Landed in 6.x.y" announcements
+- AMD acknowledgements or workaround retirement notes
+- Other affected users reporting fix verification on stock kernels
+
+** 4. amd-pm subsystem mailing list (optional, only if 1-3 are inconclusive)
+
+#+begin_src
+https://lore.kernel.org/amd-gfx/?q=strix+halo+smu+deadlock
+https://lore.kernel.org/amd-gfx/?q=no_vpe_idle_pg
+#+end_src
+
+* Outcomes & Actions
+
+** Nothing Strix-adjacent in the bump (default — also the 2026-05-10 outcome)
+
+- Continue running =linux-lts-strix 6.18.25-1=
+- Do *not* rebuild the strix kernel against the newer base purely for the version bump. Same four patches against a newer base = same workaround behavior + new risk of unrelated base-version regressions. Only rebuild when there's a security-pressing reason.
+- Topgrade is safe; the new stock =linux-lts= installs to disk but isn't booted.
+
+** linux-firmware bumps SMC past =100.6.0=
+
+This is the most likely real-fix path.
+
+1. Note the new SMC version (capture pre and post).
+2. Schedule a controlled test boot:
+ - Backup current =/etc/default/grub= GRUB_DEFAULT and =linux-lts-strix= package
+ - Drop =amdgpu.no_vpe_idle_pg=1= from the cmdline on a test boot of stock =linux-lts=
+ - Re-run the soak-check script for 1 week (same harness used for the original verdict)
+3. If zero signature hits over the soak window → retire =linux-lts-strix=, switch GRUB default back to stock =linux-lts=, remove the custom build dir.
+4. If any freeze recurs → restore strix-lts as default and re-engage the workaround. Note the recurrence in this file and on the forum thread.
+
+** Crupi's patch (or equivalent) lands in 6.18.x stable
+
+1. Check the merged form: is the =no_vpe_idle_pg= behavior the default-on, or still gated behind a cmdline?
+2. If default-on: rebuild =linux-lts-strix= against the new base with patch 0004 dropped (keep 0002, 0003, 0001 as long as they remain non-redundant).
+3. If still cmdline-gated: keep the strix kernel as-is until linux-firmware fix arrives — the in-tree path is equivalent to the local one.
+
+** Patches 0002/0003 land upstream but Crupi's doesn't
+
+Drop the matching patches from the PKGBUILD on next strix rebuild; keep 0004 until the SMU firmware fix or kernel merge.
+
+* Reference Artifacts
+
+- Soak verdict (archived): the 2026-05-04 soak-check verdict, archived outside this repo
+- Working records for the VPE-patch deployment (2026-04-29), the soak fix (2026-05-03), and the verdict-holds check (2026-05-05) are archived outside this repo.
+- Forum thread: https://community.frame.work/t/smu-deadlock-system-freeze-on-fedora-43/81795
+- Build dir: =~/build/linux-lts-patched/linux-lts/=
+- Inventory: the =ratio - Desktop Workstation= node in =~/org/roam/hardware/= (resolve by =#+HOSTNAME: ratio=)
+
+* Out of Scope
+
+- Generalizing this to other AMD APUs. The deadlock signature is specific to Strix Halo (gfx_v11_5_0, smu_v14_0_2). Other Phoenix / Hawk Point / RDNA-N APUs are unaffected and shouldn't be checked here.
+- Tracking AMD's internal bug tracker. Stick to public signals (kernel.org changelogs, lore.kernel.org, the Framework forum thread).
+- This is not a soak-check runner. The soak-check harness (=at=-job + scripts) lives separately under =scripts/= or =inbox/=. This file watches for the *retirement signal* for that harness.
diff --git a/docs/workflows/system-health-check.org b/docs/workflows/system-health-check.org
new file mode 100644
index 0000000..b4f34a5
--- /dev/null
+++ b/docs/workflows/system-health-check.org
@@ -0,0 +1,1038 @@
+#+TITLE: System Health Check Workflow
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-02-27
+
+* Overview
+
+This workflow performs a comprehensive diagnostic scan of whatever host it runs on (ratio, velox, mybitch, or truenas via SSH). Unlike status-check.org (which monitors a single long-running job in real-time), this scans the entire system, produces a severity-ranked report, then investigates and proposes fixes one by one.
+
+Run this when Craig asks "how is <host> doing?", "check my system health", or as a periodic maintenance check.
+
+Owned by archsetup (moved from the home project 2026-07-08 — system maintenance is archsetup's domain). Canonical location: =docs/workflows/system-health-check.org= in the archsetup repo; the per-host inventories it cross-references live in the org-roam knowledge base at =~/org/roam/hardware/= (moved there 2026-07-09).
+
+Relationship to the =maint= console: on the Arch daily drivers (ratio, velox), =maint status --json= collects most of Phases 0–1 in about a second from the same severity thresholds, and =maint doctor= runs the safe remedies — prefer it for routine checks there. This workflow remains the tool for the non-Arch hosts (mybitch, truenas), for forensic deep dives (Phase 2 investigation, the case files), and for the update/reboot choreography in Phase 3.
+
+* Severity thresholds — the TOML is authoritative
+
+The graded severity thresholds in this workflow are defined in the installed thresholds file, shared with the =maint= console so the two can never drift:
+
+- Installed (read this): =~/.config/archsetup/maintenance-thresholds.toml=
+- Canonical seed: =configs/maintenance-thresholds.toml= in the archsetup repo
+
+Read the TOML at the start of Phase 1 and grade against its values. Where a check below names a threshold, it cites the TOML key (e.g. =storage.df_warn_pct=); any inline number is the seeded default kept for readability, and the TOML wins on disagreement. User overrides live in =~/.config/maint/curation.toml= and merge over the shipped layer.
+
+* Hosts & Capabilities
+
+The workflow is capability-dispatched: it probes the live system in Phase 0 and runs only the checks that apply (Btrfs vs ZFS vs ext4 on storage; pacman vs apt vs none on package maintenance; etc.). The per-host inventory nodes in the org-roam knowledge base declare the expected capabilities in a property drawer and act as a cross-check for drift.
+
+The inventories live at =~/org/roam/hardware/= (2026-07-09), alongside the rest of the hardware inventory. They moved out of this repo so any project's session can read a host's capabilities — and write back what a diagnosis discovers — without reaching across a project boundary. The index node "Homelab Hardware Inventory" links them all.
+
+Host inventory nodes (match by =#+HOSTNAME:= keyword inside each):
+
+| Host | Inventory node | Notes |
+|---------+-----------------------------------------------+----------------------------------|
+| ratio | =hardware/…-ratio_desktop_workstation.org= | Arch, Btrfs RAID1, pacman |
+|---------+-----------------------------------------------+----------------------------------|
+| velox | =hardware/…-velox_laptop.org= | Arch, ZFS (re-installed 2026-04) |
+|---------+-----------------------------------------------+----------------------------------|
+| mybitch | =hardware/…-mybitch_christines_laptop.org= | Mint, ext4, apt |
+|---------+-----------------------------------------------+----------------------------------|
+| truenas | =hardware/…-truenas_nas_storage_server.org= | TrueNAS SCALE, ZFS, no PM |
+|---------+-----------------------------------------------+----------------------------------|
+
+Never hardcode a node's filename — its timestamp prefix is not stable across a rename. Resolve by the =#+HOSTNAME:= keyword, as Phase 0 does.
+
+Each inventory file contains an =* Automated Capabilities= section with a property drawer (=:FS:=, =:PM:=, =:ORCH:=, =:SNAPSHOT:=, =:MESH:=, =:BACKUP:=, =:VIRT:=, =:INIT:=, =:LAST_AUDIT:=). Phase 0 parses this drawer, compares it to the live probe, and announces + writes back any drift.
+
+* The Workflow
+
+** Phase 0: Capability Detection
+
+Before running any health checks, probe the live system to determine which checks apply, then cross-reference against the inventory file for this host.
+
+*** Probe the live system
+
+#+begin_src bash
+# Hostname — prefer hostnamectl (always present on systemd hosts), fall back to uname
+host=$(hostnamectl --static 2>/dev/null || uname -n)
+
+# Filesystem of /
+root_fs=$(findmnt -n -o FSTYPE /)
+
+# Any btrfs present?
+has_btrfs=$(findmnt -t btrfs -n 2>/dev/null | wc -l)
+
+# Any zfs pool imported?
+has_zfs=$(command -v zpool >/dev/null 2>&1 && zpool list -H -o name 2>/dev/null | wc -l || echo 0)
+
+# Package manager
+pm=none
+command -v pacman >/dev/null && pm=pacman
+command -v apt >/dev/null && pm=apt
+command -v dnf >/dev/null && pm=dnf
+
+# Orchestrator
+orch=none
+command -v topgrade >/dev/null && orch=topgrade
+
+# Snapshot tool
+snapshot=none
+command -v snapper >/dev/null && snapshot=snapper
+# ZFS-native snapshots present?
+[ "$has_zfs" -gt 0 ] && zfs list -t snapshot -H 2>/dev/null | head -1 | grep -q . && snapshot=zfs-native
+command -v sanoid >/dev/null && snapshot=sanoid
+
+# Mesh
+mesh=none
+command -v tailscale >/dev/null && mesh=tailscale
+
+# Backup role
+backup=none
+[ -f /var/log/rsyncshot.log ] && backup=source
+# Target role is declared by inventory, not probed (target hosts are discovered by their role, not a local artifact).
+
+# Virt / containers
+virt=""
+command -v virsh >/dev/null && virt="${virt}libvirt,"
+command -v docker >/dev/null && virt="${virt}docker,"
+command -v podman >/dev/null && virt="${virt}podman,"
+virt="${virt%,}"
+[ -z "$virt" ] && virt=none
+
+# Init
+init=$(ps -p 1 -o comm=)
+
+echo "host=$host fs=$root_fs btrfs=$has_btrfs zfs=$has_zfs pm=$pm orch=$orch snapshot=$snapshot mesh=$mesh backup=$backup virt=$virt init=$init"
+#+end_src
+
+Present the probe result as a one-line capability summary at the top of the report.
+
+*** Cross-reference against inventory
+
+Locate the inventory file for this host:
+
+#+begin_src bash
+inv=$(grep -l "^#+HOSTNAME: $host\b" "${ROAM_DIR:-$HOME/org/roam}"/hardware/*.org 2>/dev/null | head -1)
+#+end_src
+
+If no inventory file matches → print =NO INVENTORY FILE for $host= as a WARNING and skip the drift check. A host without the roam clone (mybitch, truenas) takes that path, exactly as it did when the inventories lived in this repo.
+
+If found: parse the =:PROPERTIES:= drawer under the =* Automated Capabilities= heading and compare each key to the live probe.
+
+*Canonical parser* (the naive awk range =/^\* Automated Capabilities/,/^\* /= does NOT work — the end pattern matches the start line, truncating the range to one line):
+
+#+begin_src bash
+get_inv() {
+ local key="$1" inv="$2"
+ awk -v key="$key" '
+ /^\* Automated Capabilities/ { in_section=1; next }
+ /^\* / && in_section { in_section=0 }
+ in_section && $0 ~ "^:"key":" {
+ sub("^:"key":[[:space:]]*", "")
+ sub("[[:space:]]+$", "")
+ print; exit
+ }
+ ' "$inv"
+}
+#+end_src
+
+Key-to-probed-variable mapping:
+
+| Key | Probed variable |
+|--------------+-----------------|
+| =:FS:= | =root_fs= |
+|--------------+-----------------|
+| =:PM:= | =pm= |
+|--------------+-----------------|
+| =:ORCH:= | =orch= |
+|--------------+-----------------|
+| =:SNAPSHOT:= | =snapshot= |
+|--------------+-----------------|
+| =:MESH:= | =mesh= |
+|--------------+-----------------|
+| =:BACKUP:= | =backup= |
+|--------------+-----------------|
+| =:VIRT:= | =virt= |
+|--------------+-----------------|
+| =:INIT:= | =init= |
+|--------------+-----------------|
+
+*** Announce-then-update on drift
+
+For each mismatch (excluding =TBD= on the inventory side, which means "not yet probed"):
+
+1. Print in the Phase 0 summary: =DRIFT: <host> <key>: inventory=X probed=Y — updating=
+2. Edit the inventory file: replace the drawer value with the probed value.
+3. Update =:LAST_AUDIT:= to today's date.
+
+Skip the write-back if the workflow is running on a host where the repo isn't checked out (e.g., truenas accessed via SSH). In that case, print the drift but flag it as =DRIFT (read-only host): update <file> manually=.
+
+*** Capability summary dictates which checks run
+
+Each check in the Reference section declares =Applies when: <capability>=. Phase 1 runs the check only if the capability is present; otherwise it prints =N/A — <capability> not present= for that check and moves on.
+
+** Phase 1: Scan & Report
+
+Run all checks from the Health Checks Reference below. Collect every finding into a ranked table sorted by severity:
+
+| Severity | Meaning |
+|----------+-------------------------------------------------------------|
+| CRITICAL | Service down, disk failing, backups not running |
+|----------+-------------------------------------------------------------|
+| WARNING | Disk space low, stale logs, failed units, overdue maint |
+|----------+-------------------------------------------------------------|
+| INFO | Informational (uptime, versions, counts) — no action needed |
+|----------+-------------------------------------------------------------|
+
+*Before presenting findings:* cross-reference each finding against the Known Issues Log at the bottom of this file. If a finding matches a known issue with a decided resolution (won't-fix, accepted-noise, fixed-in-config), annotate it as =KNOWN — <short note>= rather than presenting it as a fresh WARNING. This prevents re-investigating previously-decided items and keeps the report focused on actionable new state.
+
+Present the report as described in Report Format, then proceed to Phase 2.
+
+** Phase 2: Investigate & Fix
+
+After presenting the ranked report:
+
+1. Start with the highest-severity issue
+2. Investigate root cause with detailed commands
+3. Propose a fix, explain what it does
+4. Wait for Craig's approval before applying any changes
+5. Verify the fix worked
+6. Move to next issue
+7. Repeat until all CRITICAL and WARNING items are addressed
+8. INFO items are presented for awareness only (no action unless Craig wants to dig in)
+
+Handle issues one at a time — don't batch fixes together. Craig prefers to approve each individually.
+
+*** Defer Transient Warnings Until After Phase 3 Reboot
+
+If the Phase 1 pending-updates list includes any of these packages, =DEFER= Phase 2 deep-dive on network/DNS/VPN/uptime-accumulation warnings — investigate only if they survive the reboot:
+
+| Package (any of) | Defers these warning categories |
+|---------------------------+---------------------------------------------------------------|
+| kernel (linux, linux-lts) | bgscan/wifi driver spam, firmware-related dmesg noise |
+|---------------------------+---------------------------------------------------------------|
+| iproute2 | VPN daemon failures, ip-rule/routing-related service failures |
+|---------------------------+---------------------------------------------------------------|
+| systemd | resolved/networkd/logind service anomalies |
+|---------------------------+---------------------------------------------------------------|
+| NetworkManager | DNS reachability health checks, Tailscale DNS warnings |
+|---------------------------+---------------------------------------------------------------|
+| wpa_supplicant | WiFi connection/scan errors |
+|---------------------------+---------------------------------------------------------------|
+
+*Rationale:* Warnings on a long-uptime system (>7 days) frequently reflect accumulated mid-transition state from earlier package updates that weren't rebooted into. A fresh reboot resolves these automatically. Investigation pre-reboot is wasted effort.
+
+*Procedure:* Mark the deferred warnings in the Phase 1 report as =DEFER-TO-POST-REBOOT=. Proceed directly from Phase 2 (investigating any non-deferrable warnings) to Phase 3. After reboot (see two-stage pattern in Phase 3), re-evaluate the deferred warnings — most will have vanished. Any that survive get the full Phase 2 treatment.
+
+** Phase 3: System Update
+
+Updates are separate from issue investigation. After all issues are addressed (or deferred per Phase 2's transient-warning rule):
+
+1. Review the pending update list (already gathered in Phase 1)
+2. Identify notable packages (major version bumps, GPU drivers, kernel, firmware)
+3. Check the [[https://archlinux.org/news/][Arch Linux News]] page for any manual intervention notices
+4. Check =/var/log/pacman.log= for recent failed transactions
+5. *Host-specific kernel watches.* On ratio: if =linux=, =linux-lts=, =linux-firmware=, or a major =mesa= bump is pending, run the addendum at [[file:strix-soak-watch.org][docs/workflows/strix-soak-watch.org]] before topgrade. Retire the addendum (delete the file + this bullet) when the strix-lts custom kernel is retired.
+6. Run =topgrade= for the actual update (config at =~/.config/topgrade.toml=). On maint hosts (ratio, velox) plain =topgrade= resolves to the dotfiles PATH wrapper, which stamps the console's topgrade-freshness metric on success — no extra step. If the run happened outside the wrapper somehow, =maint stamp topgrade= records it by hand.
+7. If linux-firmware, kernel, or Mesa were updated, recommend a reboot
+
+*** Two-Stage Reboot Pattern (MANDATORY if Phase 3 installed kernel / iproute2 / systemd / NetworkManager)
+
+When a core networking or kernel package is updated, the workflow formally splits into two sessions:
+
+*Stage 1 — Pause before reboot:*
+1. Update =docs/session-context.org= with:
+ - What's been completed (Phase 1 findings, Phase 3 summary, packages bumped)
+ - What's deferred to post-reboot (the =DEFER-TO-POST-REBOOT= warnings from Phase 2)
+ - Remaining work: Phase 4, wrap-up
+2. Tell Craig to reboot
+3. End session (do NOT delete session-context.org — its presence signals an in-progress health check)
+
+*Stage 2 — Resume in new session post-reboot:*
+1. Read =docs/session-context.org= first
+2. Run post-reboot verification:
+ - =uname -r= → confirm new kernel is running
+ - =systemctl --failed= → confirm zero failed services
+ - =journalctl -p err -b= → count and compare to pre-reboot (sharp drop = transient warnings were real, accumulated noise)
+3. Re-evaluate the deferred warnings — most are gone without action
+4. Full Phase 2 treatment for any survivors
+5. Complete Phase 4, then wrap up (writes session entry to notes.org covering both halves as one unified session, spanning the reboot)
+6. Delete =session-context.org= only after wrap-up is committed
+
+*Rationale:* Trying to cram the whole flow into one session risks either (a) wasting time investigating transient warnings that would have self-healed, or (b) losing context if a crash happens during the reboot gap. The two-stage pattern is explicit about the pause and survives interruptions.
+
+** Phase 4: Resolve .pacnew Files
+
+After updates, check for and resolve .pacnew files:
+
+#+begin_src bash
+find /etc -name "*.pacnew" 2>/dev/null
+#+end_src
+
+For each .pacnew file:
+1. Diff the current config against the .pacnew version
+2. Determine if the current config is actively managed (e.g., by Reflector, or manually customized)
+3. If the current config is correct and the .pacnew adds nothing useful → delete the .pacnew
+4. If the .pacnew has meaningful changes → merge them into the current config, then delete the .pacnew
+5. Always explain the diff to Craig before deleting
+
+Common .pacnew files on ratio:
+- =/etc/pacman.d/mirrorlist.pacnew= — safe to delete; mirrorlist is managed by Reflector
+- =/etc/locale.gen.pacnew= — safe to delete if =en_US.UTF-8= is already uncommented
+
+* Health Checks Reference
+
+Run these checks in order. Each check produces one or more findings for the report.
+
+** 1. System Basics
+
+#+begin_src bash
+echo "Date: $(date)"
+echo "Hostname: $(hostnamectl hostname)"
+echo "Kernel: $(uname -r)"
+echo "Uptime: $(uptime -p)"
+#+end_src
+
+Severity: INFO (always).
+
+** 2. Failed systemd Units
+
+#+begin_src bash
+systemctl --failed
+#+end_src
+
+- Any failed units → WARNING (CRITICAL if essential service like NetworkManager, fail2ban, cronie)
+- No failed units → INFO "All units healthy"
+
+** 3. Journal Errors (Current Boot)
+
+#+begin_src bash
+# Get total error count first
+journalctl -p err -b --no-pager | wc -l
+
+# Then separate known-noisy services from real errors
+journalctl -p err -b --no-pager | grep -v "Hands-Free Voice gateway" | tail -50
+#+end_src
+
+Known noisy patterns to filter when counting:
+- bluetoothd "Hands-Free Voice gateway" — paired device out of range, polls every 60s
+- pixman "_pixman_log_error" — benign Hyprland rendering debug message
+- xkbcomp "Errors from xkbcomp are not fatal" — X compatibility noise
+
+When bluetooth spam is found, identify the device with =bluetoothctl devices Paired= and offer to remove stale pairings.
+
+- Recurring or service-impacting errors → WARNING
+- Hardware errors (MCE, disk I/O) → CRITICAL
+- Noise-level errors (known benign) → skip, but note the noise source
+
+** 4. Kernel & Hardware Events (journalctl -k)
+
+Check #3 catches error-priority journal messages, but many hardware issues show up as
+patterns of lower-severity kernel messages (e.g., repeated USB disconnects, I/O retries).
+This check scans the kernel log for those patterns.
+
+*Why =journalctl -k -b= and not =dmesg=:* on systems that suspend (laptops especially), =dmesg -T= computes wall-clock timestamps using =current_realtime − CLOCK_MONOTONIC=, but kernel printk timestamps include suspend time. After a suspend-heavy uptime, dmesg displays timestamps drifted forward by the cumulative suspend duration (e.g., May 17 displayed for messages logged on May 10). =journalctl -k= stamps CLOCK_REALTIME at message-write time, so dates are always correct. Performance difference is ~100 ms vs ~10 ms — negligible at this cadence. (See the 2026-05-10 entry in the Known Issues Log.)
+
+#+begin_src bash
+# USB disconnect/reconnect cycles (repeated = hardware or power management issue)
+journalctl -k -b --no-pager | grep -c "USB disconnect"
+
+# If disconnects found, show the devices and timestamps
+journalctl -k -b --no-pager | grep "USB disconnect" | tail -20
+
+# USB protocol errors (error -71 = EPROTO, error -110 = timeout)
+journalctl -k -b --no-pager | grep -iE "usbhid.*error|usb.*error -[0-9]+" | tail -10
+
+# I/O errors on block devices
+journalctl -k -b --no-pager | grep -iE "I/O error|blk_update_request|Buffer I/O error" | tail -10
+
+# PCIe errors
+journalctl -k -b --no-pager | grep -iE "pcie.*error|AER|corrected error|uncorrected error" | tail -10
+
+# Machine Check Exceptions (CPU hardware errors)
+journalctl -k -b --no-pager | grep -iE "mce:|hardware error" | tail -10
+
+# GPU faults or resets
+journalctl -k -b --no-pager | grep -iE "amdgpu.*error|amdgpu.*fault|gpu reset|gpu hang" | tail -10
+
+# Thermal throttling
+journalctl -k -b --no-pager | grep -iE "thermal.*throttl|cpu.*throttl|prochot" | tail -10
+
+# ACPI errors (power management issues)
+journalctl -k -b --no-pager | grep -iE "acpi.*error|acpi.*warning" | tail -10
+
+# USB autosuspend state on internal hubs (Framework 16 specific)
+for d in /sys/bus/usb/devices/1-[234]; do
+ if [ -d "$d" ]; then
+ echo "$d: $(cat $d/product 2>/dev/null) | control=$(cat $d/power/control) | delay=$(cat $d/power/autosuspend_delay_ms)"
+ fi
+done
+#+end_src
+
+What to look for:
+- *USB disconnects*: A few at boot or after resume is normal. >5 during a session, or repeated disconnect/reconnect of the same device = problem. On Framework 16, the keyboard and numpad modules are internal USB — disconnects here mean input loss.
+- *Error -71 (EPROTO)*: USB protocol error, often caused by aggressive hub autosuspend. Check hub power settings.
+- *I/O errors*: Any I/O error on NVMe → immediate CRITICAL.
+- *MCE/hardware errors*: Always CRITICAL.
+- *GPU faults*: Occasional corrected errors are INFO. Resets or hangs are WARNING.
+- *Thermal throttling*: WARNING if active. Check if cooling is obstructed.
+- *USB hub autosuspend*: Internal hubs with =control=auto= and =delay=0= → WARNING (causes input disconnects on Framework 16).
+
+- Repeated USB disconnects of input devices → WARNING
+- USB protocol errors (EPROTO) → WARNING
+- I/O errors, MCE, or hardware errors → CRITICAL
+- GPU resets or hangs → WARNING
+- Thermal throttling active → WARNING
+- Aggressive hub autosuspend on internal devices → WARNING
+- Clean dmesg → INFO
+
+** 5. Disk Health (SMART)
+
+#+begin_src bash
+sudo smartctl -H /dev/nvme0n1
+sudo smartctl -H /dev/nvme1n1
+sudo smartctl -A /dev/nvme0n1 | grep -i "critical\|temperature\|wear\|error"
+sudo smartctl -A /dev/nvme1n1 | grep -i "critical\|temperature\|wear\|error"
+#+end_src
+
+- SMART overall health != PASSED → CRITICAL
+- NVMe wear > =storage.smart_wear_warn_pct= or temp > =storage.smart_temp_warn_c= (TOML; seeded 80% / 70°C) → WARNING
+- All healthy → INFO
+
+** 6. Disk Usage
+
+Dispatched by filesystem. Run every sub-check for which the capability is present — a host with both Btrfs and ZFS (hypothetical: ratio with a ZFS backup pool) runs 6a AND 6b.
+
+*** 6a. Btrfs Usage
+
+*Applies when:* =has_btrfs > 0=
+
+#+begin_src bash
+sudo btrfs filesystem usage /
+/usr/bin/df -h / /boot 2>/dev/null
+#+end_src
+
+Btrfs reports two numbers that can look confusing:
+- *Data allocated %* (e.g., 99.43%) — how full the allocated data chunks are. Btrfs allocates in chunks and can allocate more from the unallocated pool.
+- *df %* (e.g., 69%) — actual used space vs total device size. This is what matters.
+
+Check the "Device unallocated" line — as long as there's unallocated space, Btrfs can grow its data chunks.
+
+Severity rules:
+- df > =storage.df_crit_pct= (TOML; seeded 90%) → CRITICAL
+- df > =storage.df_warn_pct= (seeded 80%) → WARNING
+- df under the warn line → INFO (report both df% and unallocated space)
+
+*** 6b. ZFS Pool Health
+
+*Applies when:* =has_zfs > 0=
+
+#+begin_src bash
+# Quick health summary across all pools
+sudo zpool status -x # expect "all pools are healthy"
+
+# Full detail — state, errors, scrub, resilver
+sudo zpool status
+
+# Capacity, fragmentation, health per pool
+sudo zpool list -o name,size,alloc,free,capacity,health,frag
+
+# Per-dataset usage (top-level only; -d1 depth)
+sudo zfs list -o name,used,avail,refer -s used
+#+end_src
+
+Severity rules:
+- Pool state != ONLINE → CRITICAL
+- Any read/write/cksum error > 0 → CRITICAL
+- Capacity > =storage.zfs_capacity_crit_pct= (TOML; seeded 90%) → CRITICAL
+- Capacity > =storage.zfs_capacity_warn_pct= (seeded 80%) → WARNING (ZFS performance degrades)
+- Scrub age > =storage.zfs_scrub_crit_days= (seeded 60) → CRITICAL
+- Scrub age > =storage.zfs_scrub_warn_days= (seeded 35) → WARNING
+- Resilver in progress → INFO (but prominent in the report — mention drive being replaced)
+- Fragmentation > =storage.zfs_frag_info_pct= (seeded 50%) → INFO
+
+Parse scrub age from =zpool status= output: look for the =scan:= line — either =scrub repaired … in … with … errors on <date>= (completed) or =scrub in progress …= (running).
+
+*** 6c. Generic filesystem fallback
+
+*Applies when:* =root_fs= is ext4, xfs, or any other non-Btrfs non-ZFS filesystem
+
+#+begin_src bash
+/usr/bin/df -h / /home /boot 2>/dev/null
+#+end_src
+
+Severity rules:
+- > =storage.df_crit_pct= on any mounted partition → CRITICAL
+- > =storage.df_warn_pct= → WARNING
+- Normal → INFO
+
+** 7. Snapshots
+
+Dispatched by snapshot tool.
+
+*** 7a. Snapper (Btrfs)
+
+*Applies when:* =snapshot = snapper=
+
+#+begin_src bash
+snapper -c home list --columns number,date,description | head -5
+snapper -c home list --columns number,date,description | tail -5
+snapper -c home list | wc -l
+snapper -c home get-config | grep -iE "cleanup|TIMELINE_LIMIT|NUMBER_LIMIT"
+# oldest snapshot age (pile-up signal)
+oldest=$(sudo ls /home/.snapshots/ 2>/dev/null | grep -E '^[0-9]+$' | sort -n | head -1)
+[ -n "$oldest" ] && echo "oldest: $(sudo grep -oP '(?<=<date>).*(?=</date>)' "/home/.snapshots/$oldest/info.xml" 2>/dev/null | head -1)"
+#+end_src
+
+Severity rules:
+- Zero snapshots → WARNING
+- Cleanup disabled → WARNING
+- Snapshot count very high, OR oldest snapshot far older than intended retention (e.g. months back) → WARNING (pile-up)
+- =TIMELINE_LIMIT_MONTHLY= / =_YEARLY= set high (e.g. 10) on a large, churny subvolume → WARNING: that keeps ~10 months of monthly snapshots, which silently hoard space (this bit /home on 2026-05-26 — monthly snapshots back to Feb were the top space holders). Sane values are the TOML's =[snapshots]= =timeline_*= keys (seeded HOURLY 6, DAILY 7, WEEKLY 2, MONTHLY 2, QUARTERLY 0, YEARLY 0). Manual (=single=) snapshots are NOT touched by timeline cleanup — delete stale ones explicitly.
+- Normal range → INFO with count and date range
+
+*Disk-space deep-dive (on demand, only when /home is actually filling).* Per-snapshot reclaimable space is invisible without btrfs quotas. TEMPORARILY enable quota, rank by exclusive space, then DISABLE it again — quotas slow every snapshot deletion, so never leave them on:
+#+begin_src bash
+sudo btrfs quota enable /home && sudo btrfs quota rescan -w /home
+sudo btrfs qgroup show -re --sort=-excl /home | head -20 # top exclusive (reclaimable) holders
+sudo btrfs quota disable /home
+#+end_src
+Reality check: data deleted from the live fs but still in many old snapshots shows ~0 *exclusive* per snapshot (it's shared across them), and only frees once the whole chain holding it is pruned. So reclaiming a large deletion means pruning the old snapshots, not just the newest one.
+
+*** 7b. ZFS Snapshots
+
+*Applies when:* =snapshot = zfs-native= or =sanoid=
+
+#+begin_src bash
+# All snapshots, newest first
+sudo zfs list -t snapshot -o name,creation,used -s creation | tail -20
+
+# Count per pool
+sudo zfs list -t snapshot -H -o name | awk -F'@' '{print $1}' | sort | uniq -c
+
+# Oldest snapshot age per dataset (detect runaway retention)
+sudo zfs list -t snapshot -H -o name,creation -s creation | head -20
+
+# Auto-snapshot service status (zfs-auto-snapshot / sanoid / zrepl)
+systemctl list-timers --all 2>/dev/null | grep -Ei "zfs|sanoid|zrepl" || echo "no auto-snapshot timer"
+#+end_src
+
+Severity rules:
+- Zero snapshots on a dataset that should have them → WARNING
+- Snapshot count on any dataset > =snapshots.zfs_count_warn= (seeded 1000) → WARNING (runaway retention)
+- Snapshot space used > =snapshots.zfs_space_warn_pct= (seeded 20%) of pool capacity → WARNING (heavy divergence)
+- Auto-snapshot timer not running → WARNING
+- Normal → INFO (counts per pool, oldest snapshot age)
+
+*** 7c. No snapshot system
+
+*Applies when:* =snapshot = none=
+
+Report =INFO: no snapshot system configured on this host= and skip.
+
+** 8. Memory and Swap
+
+#+begin_src bash
+free -h
+journalctl -b --no-pager | grep -i "out of memory\|oom-kill\|killed process" | tail -10
+#+end_src
+
+Ratio has 128GB RAM and no swap configured. No swap is expected — don't flag it.
+
+- OOM kills found → WARNING
+- Normal → INFO
+
+** 9. CPU Temperatures
+
+#+begin_src bash
+sensors 2>/dev/null || echo "lm_sensors not installed or not configured"
+#+end_src
+
+Key sensors on ratio:
+- =k10temp= → CPU (Tctl)
+- =amdgpu= → GPU edge temp
+- =nvme= → NVMe drives (2 drives)
+- =cros_ec= → mainboard (power, memory, ambient, CPU)
+
+- CPU temp > =power.cpu_temp_warn_c= or GPU temp > =power.gpu_temp_warn_c= (TOML; seeded 90/95°C) → WARNING, CRITICAL if sustained
+- Normal → INFO (report CPU and GPU temps)
+
+** 10. NTP Sync
+
+#+begin_src bash
+chronyc tracking
+#+end_src
+
+Ratio uses chrony for NTP.
+
+- Clock not synchronized → WARNING
+- NTP service inactive → WARNING
+- Last offset > =network.ntp_offset_warn_ms= (TOML; seeded 100 ms) → WARNING
+- Synchronized → INFO
+
+** 11. rsyncshot Backups
+
+*Applies when:* =backup = source=
+
+#+begin_src bash
+tail -30 /var/log/rsyncshot.log 2>/dev/null
+#+end_src
+
+rsyncshot runs via root crontab:
+- Hourly: =30 0-1,3-23 * * *= (every hour except 2:30)
+- Daily: =30 2 * * *= (2:30 AM)
+
+Backups go to TrueNAS via rsync. Look for "rsyncshot completed successfully" and check the timestamp.
+
+- No daily backup in =backups.rsyncshot_daily_crit_hours= (seeded 48) → CRITICAL
+- No hourly backup in =backups.rsyncshot_hourly_warn_hours= (seeded 3) → WARNING (only on hosts whose log shows hourly runs)
+- Errors in log → WARNING
+- Recent and healthy → INFO
+
+** 12. Tailscale
+
+*Applies when:* =mesh = tailscale=
+
+#+begin_src bash
+tailscale status
+#+end_src
+
+Expected peers: ratio, cjennings, truenas, velox, worker. pixel6 is a phone and is often offline — don't flag it.
+
+- Tailscale not running → WARNING
+- Server peers (truenas, velox, worker) offline → WARNING
+- All expected peers online → INFO
+
+** 13. fail2ban
+
+#+begin_src bash
+sudo fail2ban-client status
+sudo fail2ban-client status sshd 2>/dev/null
+#+end_src
+
+- fail2ban not running → WARNING
+- Running → INFO (note any recent bans for awareness)
+
+** 14. Package Maintenance
+
+Dispatched by package manager.
+
+*** 14a. pacman (Arch)
+
+*Applies when:* =pm = pacman=
+
+#+begin_src bash
+# Orphaned packages
+pacman -Qtdq 2>/dev/null
+
+# Pending updates
+checkupdates 2>/dev/null
+
+# Package cache timer + current size
+systemctl list-timers paccache.timer --no-pager
+du -sh /var/cache/pacman/pkg 2>/dev/null
+
+# .pacnew files
+find /etc -name "*.pacnew" 2>/dev/null
+#+end_src
+
+Notes:
+- =paccache.timer= runs weekly keeping 3 versions — fine for routine pruning, but it only trims *old versions*. A cache can still balloon from *breadth* (many packages, ≤3 versions each), where =paccache -r= finds nothing to prune. On 2026-05-26 the cache was 17 GB and =paccache -r= pruned 0; the space came from deeper levers:
+ - =sudo paccache -ruk0= — remove cache for *uninstalled* packages (safe, re-downloadable).
+ - =sudo paccache -rk1= — keep only 1 version of installed packages (frees the most; less downgrade headroom).
+ Together those took 17 GB → 5.8 GB.
+- When removing orphans, pass package names as arguments (don't pipe through stdin — breaks snap-pac's snapper hook).
+- Review orphans before removing — some (like =rust=) warrant discussion (Craig switched to =rustup=).
+
+Severity rules:
+- Orphaned packages beyond the curated =curation.kept_orphans= set, in bulk (> ~20) → WARNING
+- Pending updates > =updates.pending_warn= (TOML; seeded 50) → WARNING
+- Unreviewed .pacnew files → WARNING
+- Package cache > =packages.cache_warn_gb= (TOML; seeded 10 GB) → INFO: suggest =paccache -ruk0= (uninstalled) and/or =paccache -rk1= (keep 1) for a deeper reclaim beyond the weekly keep-3.
+- Normal counts → INFO
+
+*** 14b. apt (Debian/Ubuntu/Mint)
+
+*Applies when:* =pm = apt=
+
+#+begin_src bash
+# Pending updates
+apt list --upgradable 2>/dev/null | tail -n +2
+
+# Auto-removable packages
+apt -s autoremove 2>&1 | grep -E "^Remv|packages will be REMOVED"
+
+# .dpkg-dist / .dpkg-new files (the apt equivalent of .pacnew)
+find /etc -name "*.dpkg-dist" -o -name "*.dpkg-new" 2>/dev/null
+
+# unattended-upgrades timer
+systemctl list-timers apt-daily.timer apt-daily-upgrade.timer --no-pager 2>/dev/null
+#+end_src
+
+Severity rules:
+- Pending security updates > 0 → WARNING
+- Auto-removable packages > 30 → WARNING
+- Unreviewed .dpkg-dist files → WARNING
+- Normal → INFO
+
+*** 14c. none (TrueNAS SCALE, other appliances)
+
+*Applies when:* =pm = none=
+
+Report =N/A — package maintenance handled by middleware= and skip. On TrueNAS SCALE, OS updates go through the TrueNAS UI (System → Update); user-level =apt= exists but is unsupported and shouldn't be used.
+
+*** 14d. Orchestrator (topgrade)
+
+*Applies when:* =orch = topgrade=
+
+If topgrade is present, Phase 3 uses it rather than the raw package manager. Phase 1 still runs 14a/14b/14c above — topgrade is for the update run, not the status scan.
+
+** 15. App Logs (~/.local/var/log)
+
+#+begin_src bash
+for log in ~/.local/var/log/*.log; do
+ if [ -f "$log" ]; then
+ errors=$(grep -ci "error\|critical\|fatal" "$log" 2>/dev/null)
+ if [ "$errors" -gt 0 ]; then
+ echo "$(basename $log): $errors error lines"
+ grep -i "error\|critical\|fatal" "$log" | tail -3
+ echo ""
+ fi
+ fi
+done
+#+end_src
+
+Known noise patterns:
+- *waybar*: LIBDBUSMENU-GLIB-WARNING "Unable to replace properties on 0" — caused by insync and zoom tray icons with broken dbusmenu implementations. Harmless. Filtered in waybar's log output via =grep -v= in hyprland.conf.
+- *hyprland*: "_pixman_log_error" and "xkbcomp" — benign
+- *gammastep*: "Wayland connection experienced a fatal error" — happens on session restart, self-recovers
+- *dunst*: gdk_pixbuf assertion failures — usually a malformed notification icon, one-off
+
+Look for patterns that are NOT in the known noise list.
+
+- Coredumps within =logs.coredump_window_days= (TOML; seeded 14 — =coredumpctl list= and age-filter) → WARNING
+- Recurring non-noise errors → WARNING
+- Only known noise → INFO
+
+** 16. System Logs (/var/log)
+
+#+begin_src bash
+# Recent pacman errors
+grep -i "error\|failed" /var/log/pacman.log 2>/dev/null | tail -10
+
+# fail2ban anomalies
+tail -50 /var/log/fail2ban.log 2>/dev/null | grep -i "error\|warning"
+#+end_src
+
+- Failed pacman transactions → WARNING
+- fail2ban errors → WARNING
+- Clean → INFO
+
+** 17. Docker/Podman
+
+*Applies when:* =virt= contains =docker= or =podman=
+
+#+begin_src bash
+docker ps -a --filter "status=exited" --format "{{.Names}}: exited {{.Status}}" 2>/dev/null
+docker system df 2>/dev/null
+podman ps -a --filter "status=exited" --format "{{.Names}}: exited {{.Status}}" 2>/dev/null
+podman system df 2>/dev/null
+#+end_src
+
+The WinVM podman container is run on-demand and will often show as exited — this is expected.
+
+*Severity thresholds:*
+- Stopped containers that should be running → WARNING
+- Docker/Podman reclaimable > =services.docker_reclaim_warn_gb= *or* > =services.docker_reclaim_warn_pct= of total (TOML; seeded 5 GB / 50%) → WARNING (propose prune tiers in Phase 2)
+- Docker/Podman reclaimable 1–5 GB → INFO (note for awareness)
+- Expected stopped containers → INFO
+
+*Prune tiers to propose when reclaimable crosses the WARNING threshold:*
+1. =docker container prune= — removes only stopped containers (~tens of MB, unlocks referenced images)
+2. =docker image prune -a= — removes all images not used by any container (biggest bite; requires tier 1 first)
+3. =docker system prune -a --volumes= — full nuke, includes unused volumes and networks. Present as the option when Craig wants to start fresh.
+
+Confirm with Craig before running any tier — especially if work-related containers (e.g. =deepsat-*=) are present.
+
+** 18. libvirt / VMs
+
+*Applies when:* =virt= contains =libvirt=
+
+#+begin_src bash
+sudo virsh list --all 2>/dev/null || echo "libvirt not available"
+#+end_src
+
+VMs (ultmos-15, etc.) are usually shut off unless Craig is actively using them. "shut off" is the normal state.
+
+- VMs in unexpected state → INFO
+- libvirt not running → INFO
+
+** 19. Cron Jobs
+
+#+begin_src bash
+systemctl is-active cronie
+crontab -l 2>/dev/null | grep -v "^#" | grep -v "^$"
+sudo crontab -l 2>/dev/null | grep -v "^#" | grep -v "^$"
+#+end_src
+
+Expected user crontab entries:
+- =0 12 * * *= log-cleanup
+
+Expected root crontab entries:
+- =30 0-1,3-23 * * *= rsyncshot hourly
+- =30 2 * * *= rsyncshot daily
+
+- cronie not running → WARNING
+- Expected crontab entries missing → WARNING
+- Running with expected entries → INFO
+
+** 20. Log Cleanup Cron
+
+#+begin_src bash
+find ~/.local/var/log -type f -name "*.log" -mtime +7 2>/dev/null | head -10
+find ~/.local/var/log -type f 2>/dev/null | wc -l
+#+end_src
+
+The user crontab runs =~/.local/bin/cron/log-cleanup= daily at noon, which should keep logs to ~7 days.
+
+- Log files older than =logs.app_log_warn_days= (TOML; seeded 7) present → WARNING (cleanup cron may not be running)
+- Within the retention window → INFO
+
+** 21. Network
+
+#+begin_src bash
+# DNS resolution (dig may not be available — use ping as fallback)
+ping -c1 -W2 archlinux.org 2>/dev/null && echo "DNS OK" || echo "DNS FAILED"
+
+# NetworkManager status
+nmcli general status 2>/dev/null
+#+end_src
+
+- DNS resolution fails → CRITICAL
+- NetworkManager not running or disconnected → CRITICAL
+- All healthy → INFO
+
+* Report Format
+
+Present findings as a ranked org-mode table, sorted by severity (CRITICAL first, then WARNING, then INFO):
+
+#+begin_example
+| # | Severity | Category | Finding |
+|---+----------+-------------+--------------------------------|
+| 1 | CRITICAL | rsyncshot | Daily backup failed 2 days ago |
+|---+----------+-------------+--------------------------------|
+| 2 | WARNING | disk usage | / at 85% capacity |
+|---+----------+-------------+--------------------------------|
+| 3 | WARNING | packages | 47 orphaned packages |
+|---+----------+-------------+--------------------------------|
+| 4 | INFO | uptime | 12 days |
+|---+----------+-------------+--------------------------------|
+| 5 | INFO | disk health | Both NVMe drives PASSED |
+|---+----------+-------------+--------------------------------|
+| 6 | INFO | tailscale | 3/3 peers online |
+|---+----------+-------------+--------------------------------|
+#+end_example
+
+After the table, announce the investigation plan:
+
+- If CRITICAL or WARNING issues exist: "Starting with #1 — investigating [category]: [finding]..."
+- If only INFO items: "No issues found. System looks healthy. Here are the details..."
+
+* Known Issues Log
+
+Each entry is scoped to one host (or =any=). When Phase 1 cross-references findings against this log, it matches on both the issue signature AND the host — an entry tagged =:host: mybitch= won't suppress a finding on ratio. For pre-existing entries without an explicit host tag, the hostname is inferred from the entry title where possible (e.g., "mybitch —"). New entries should include =:host: <name>= for clarity.
+
+** 2026-03-24: mybitch — USB autosuspend causing keyboard/numpad disconnects
+:host: mybitch
+- Framework Laptop 16 internal Genesys Logic USB hubs (05e3:0610) had =control=auto= and =delay=0=
+- Caused repeated USB disconnects of keyboard module (1-4.2) and numpad module (1-3.2)
+- Error -71 (EPROTO) on reconnection attempts
+- Fix: udev rule at =/etc/udev/rules.d/99-framework-usb-hub-no-autosuspend.rules=
+- Sets =power/control=on= for matching hubs, disabling autosuspend
+- Added check #4 (Kernel & Hardware Events) to this workflow to catch similar issues in future
+
+** 2026-02-27: Bluetooth journal spam (1,006 errors/boot)
+:host: ratio
+- bluetoothd polling "Craig's Pixel Buds" (B8:7B:D4:19:A6:01) every 60s while out of range
+- Fix: removed stale pairing with =bluetoothctl remove B8:7B:D4:19:A6:01=
+- Note: Craig has recurring Pixel Buds pairing issues with this machine
+
+** 2026-02-27: hyprlock deprecated config options (v0.9.2)
+:host: ratio
+- =general:grace= and =general:no_fade_in= moved to CLI flags (=--grace=, =--no-fade-in=)
+- =input-field:fail_transition= replaced by =animations= block (=inputFieldColors= animation)
+- Fix: removed deprecated options, added =animations= block to hyprlock.conf
+- Config at: =~/code/archsetup/dotfiles/hyprland/.config/hypr/hyprlock.conf=
+
+** 2026-02-27: dunst coredump
+:host: ratio
+- Segfault in libglycin/gdk_pixbuf — malformed notification icon
+- One-off, no action taken. Monitor for recurrence.
+
+** 2026-02-27: Missing udev script for Logitech Brio
+:host: ratio
+- Udev rule references =~/.local/bin/logitech-brio-settings.sh= which is a symlink to archsetup dotfiles
+- Fails at boot because udev runs before home is fully available
+- Script works fine for hotplug after login
+- Decision: leave as-is
+
+** 2026-02-27: waybar LIBDBUSMENU-GLIB warnings (~8,000/session)
+:host: ratio
+- Caused by insync and zoom tray icons with broken dbusmenu implementations
+- Both return empty arrays for menu root item (ID 0)
+- No fix available (insync has no option to disable tray icon)
+- Mitigation: added =grep -v "LIBDBUSMENU-GLIB-WARNING"= filter to waybar log line in hyprland.conf
+- Takes effect on next Hyprland start
+
+** 2026-02-27: Package cleanup
+:host: ratio
+- Removed 51 orphaned packages (build deps, Python dev tools)
+- Swapped =rust= pacman package for =rustup= (toolchain manager)
+- Added note to =~/code/archsetup/inbox/rustup.txt=
+
+** 2026-02-27: System update via topgrade
+:host: ratio
+- 66 packages updated including Mesa 25.3→26.0, Firefox/Thunderbird 148, linux-firmware, Signal 8.0
+- No manual intervention required (checked Arch news)
+- Reboot recommended for firmware and Mesa changes
+
+** 2026-02-27: Resolved .pacnew files
+:host: ratio
+- =/etc/pacman.d/mirrorlist.pacnew= — stock mirrorlist, safe to delete (Reflector manages the active one)
+- =/etc/locale.gen.pacnew= — only added commented =en_SE.UTF-8=, current config correct, deleted
+
+** 2026-04-19: proton.VPN.service transient failure after topgrade
+:host: ratio
+- Appeared as a =failed= unit immediately after =topgrade= bumped iproute2 from 6.19.0 → 7.0.0 mid-session (9-day uptime)
+- Self-healed on reboot; no config change required
+- Pattern: VPN daemon holds routing/ip-rule state referencing old iproute2 ABI, can't reconcile after live package swap
+- Classification: KNOWN-TRANSIENT on iproute2 upgrade → DEFER-TO-POST-REBOOT per Phase 2 rules
+
+** 2026-04-19: Tailscale DNS reachability warning on long uptime
+:host: ratio
+- =tailscale status= footer: "Tailscale can't reach the configured DNS servers. Internet connectivity may be affected."
+- Appeared on 9-day uptime; all core functions (name resolution, netcheck, tailnet routing) tested working
+- Cleared on reboot — footer warning gone, no config change
+- Pattern: tailscaled's internal DNS health check accumulates stale state on long uptime, particularly after a mid-session iproute2 transition
+- Classification: KNOWN-TRANSIENT on long uptime / iproute2 upgrade → DEFER-TO-POST-REBOOT per Phase 2 rules
+
+** 2026-04-19: wpa_supplicant bgscan error spam volume scales with uptime
+:host: ratio
+- Error: =bgscan simple: Failed to enable signal strength monitoring=
+- Volume pre-reboot (9-day uptime): 1,200+ instances in journal
+- Volume post-reboot (fresh boot): 1 instance total
+- Root cause: RSSI monitoring ioctl not implemented in mt7925e driver (Framework Desktop WiFi 7 card) — each retry or roam event re-triggers the failure
+- 1–3 instances per boot is baseline noise; only triage if volume > ~50 AND not explainable by long uptime
+- Classification: KNOWN (mt7925e driver limitation, no upstream fix tracked); annotate low-volume occurrences as =KNOWN — mt7925e baseline=
+
+** 2026-04-19: Orphan package cleanup
+:host: ratio
+- Removed 11 packages (8 orphans + 3 cascading python build-tool deps): cli11, electron34, lua-lpeg, minizip-ng, python-build, python-hatchling, python-installer, yarn + python-editables, python-pyproject-hooks, python-trove-classifiers
+- Freed 280 MiB
+- Future note: electron34 and yarn were previously flagged "ask first" due to possible AUR build-dep relevance — post-removal, no AUR rebuild has failed. Can remove in future without asking unless an active AUR build references them.
+
+** 2026-04-21: velox ZFS — systemd-tmpfiles "Protocol driver not attached"
+:host: velox
+- Symptom: =systemd-tmpfiles-setup.service= (boot) and/or =systemd-tmpfiles-clean.service= (periodic) produce 10-30 =statx(...) failed: Protocol driver not attached= journal errors per run
+- Root cause: on ZFS, statx against another service's =/var/tmp/systemd-private-*/tmp= mount returns errno 132 (ENOTNAM); ext4/btrfs don't surface this as an error. Bare systemd-tmpfiles unit has no =PrivateTmp= set, so it traverses sibling namespaces
+- Fix: drop-in with =PrivateTmp=yes= at =/etc/systemd/system/systemd-tmpfiles-clean.service.d/zfs-private-tmp.conf= AND =.../systemd-tmpfiles-setup.service.d/zfs-private-tmp.conf=
+- Applies to any ZFS-on-root Arch host. Not needed on btrfs hosts.
+
+** 2026-04-19: Docker image bloat cleanup
+:host: ratio
+- Freed 15.3 GB via =docker system prune -a --volumes=
+- Removed 10 images (including nvidia/cuda 12.4 devel, postgis, nginx, python-slim, node-alpine, nerdfonts/patcher) and 4 exited deepsat-* work containers from 9 days prior
+- 130 MB orphan volume survived prune (anonymous, not caught by =--volumes=); ignored
+- Future: Check #17 now promotes reclaimable > 5 GB OR > 50% to WARNING — this will catch bloat earlier without Craig having to notice it
+
+** 2026-05-10: WiFi powersave default caused variable WiFi latency on mybitch + ratio
+:host: mybitch, ratio (velox naturally clean — defensive config added anyway)
+- Symptom: variable LAN/WiFi latency manifesting as "slow-feeling internet". On mybitch (Christine's laptop, the original report): gateway ping avg 67.9 ms, max 173.9 ms, mdev 55.7 ms at 1-second cadence vs avg 14.5 ms / mdev 16.4 ms at 0.2-second cadence (faster cadence kept the card awake, masking the issue). On ratio (Arch desktop): same pattern, mdev 46.7 ms, max 183.5 ms.
+- Root cause: NetworkManager defaults to =wifi.powersave = 3= (enable) when no override is configured. On Mint/Ubuntu, this is shipped explicitly as =/etc/NetworkManager/conf.d/default-wifi-powersave-on.conf=. On Arch, it's the upstream NetworkManager default (no shipped file, but same effective behavior). On bursty traffic (typical browsing), the WiFi card sleeps between bursts and the first packet of each new burst takes 50-150 ms to wake it — pages feel like they "stall" before loading.
+- Fix: drop =/etc/NetworkManager/conf.d/wifi-powersave-off.conf= with =wifi.powersave = 2= (disable). NetworkManager merges conf.d alphabetically; =wifi-...= sorts after =default-...= and =dns.conf= so the override wins. Restart NetworkManager applies it.
+- Verification: post-fix gateway ping (both mybitch and ratio) avg 12 ms, max 21-24 ms, mdev 3 ms — 15-18× improvement in jitter, 8-9× improvement in max latency. Throughput unchanged at the ISP/router ceiling (~85 Mbit/s down, 25 Mbit/s up — same on both clients, so that's the connection limit, not a per-host issue).
+- Velox edge: velox's WiFi card defaults to powersave-off at the driver/kernel level even without an explicit NM config (different chipset behavior). Latency was already healthy. Defensive =wifi-powersave-off.conf= added anyway so the configuration is uniform across the homelab and protected against future NM-default or driver-behavior changes.
+- Tradeoff: ~0.5-1 W more power draw — irrelevant on AC, slight battery hit on battery. All three hosts are mostly on AC.
+- Coverage status as of this entry:
+ - mybitch (Mint): explicit override applied, was active issue → resolved
+ - ratio (Arch): explicit override applied, was active issue → resolved
+ - velox (Arch laptop): explicit override applied defensively, no observable issue
+ - truenas: not on WiFi, N/A
+- Pattern note: any new NM-using host added to the homelab should get this override at provisioning time. The 2026-04-30 mybitch upgrade soak picked up the issue indirectly; future host adds should set this proactively.
+
+** 2026-05-10: mybitch — keyboard soak verified closed (2026-03-24 USB hub fix held over 9-day uptime)
+:host: mybitch
+- Reminder from 2026-04-30 (overdue day 10) was to verify the 2026-03-24 USB-autosuspend fix holds in routine use.
+- Verification (2026-05-10, mybitch uptime 1w 2d 2h): all three Framework 16 internal hubs (1-2, 1-3, 1-4) show =control=on, delay=0= — udev rule active. Total USB error count this boot: *0*. Soak passed.
+- Closes the 2026-04-30 reminder. The 2026-03-27 mybitch BIOS-side fixes (which were gated on this soak result) are now unblocked.
+
+** 2026-05-10: dmesg -T displays future-dated timestamps after suspend cycles
+:host: any (laptops affected; desktops rarely suspend)
+- Symptom: =dmesg -T= shows wall-clock timestamps drifted forward by the cumulative suspend duration since boot. Example: mybitch on 2026-05-10 with 9d 2h monotonic uptime displayed messages dated May 15-17 (~7 days of suspend during the boot session pushed the displayed dates 7 days into the future).
+- Root cause: kernel printk timestamps include suspend time (boottime-style clock). =dmesg -T= computes the boot epoch using =current_realtime − CLOCK_MONOTONIC=, but =CLOCK_MONOTONIC= excludes suspend. The two clocks diverge by however long the system was suspended during the current boot, and =dmesg -T= adds the (boottime-correct) message offset to a (monotonic-derived) boot epoch — producing future-dated displays.
+- Verification: =journalctl -k -b= shows the same kernel messages with correct timestamps because journald stamps =CLOCK_REALTIME= at message-write time. =date=, =timedatectl=, and =/proc/stat btime= are all correct.
+- Workflow fix (applied 2026-05-10): Phase 1 check #4 now uses =journalctl -k -b= instead of =dmesg=. Tradeoff is ~100 ms vs ~10 ms per query — negligible at this cadence. Side benefits: cross-boot queries (=-b -1=, =-b -2=) and longer history retention than the kernel ring buffer.
+- Live cleanup (optional): a reboot resets both clocks and =dmesg -T= comes back accurate until the next long suspend session. Not urgent — the workflow change routes around the bug regardless.
+- Out of scope: the dmesg tool itself isn't going to change behavior here; this is a long-standing util-linux design decision around how to translate ring-buffer timestamps. We don't fight it; we use the right tool.
+
+** 2026-05-10: aardvark-dns "empty response" spam during WinVM podman runs
+:host: ratio
+- Pattern: =aardvark-dns[N]: <port> dns request got empty response= logged at error priority while the podman-rootless aardvark-dns daemon forwards DNS for the WinVM container
+- Volume: ~1,200 lines per WinVM session (Windows guest aggressively retries DNS lookups, every retry that gets back NOERROR/empty triggers one line)
+- Trigger: only logs while a podman container with the rootless aardvark-dns network is running. Daemon stops with the container, so journal noise is bounded by WinVM uptime
+- Active when looked at: aardvark-dns daemon is not running between WinVM sessions; these errors are retrospective journal entries, not a live issue
+- Classification: KNOWN — annotate as =KNOWN — WinVM podman aardvark-dns DNS retry noise= and check the surrounding podman/WinVM lifecycle to confirm correlation. Volume scales with WinVM session length.
+- No fix in scope. Suppression options if it becomes annoying: (a) journald filter rule for unit pattern, (b) switch WinVM to a different DNS path. Neither pursued today.
+
+** 2026-05-10: cameractrls cameraptzmidi.py SEGV during Python 3.14 exit
+:host: ratio
+- =python3 /usr/lib/python3.14/site-packages/CameraCtrls/cameraptzmidi.py -l= prints its output (e.g. "JDS Labs Element IV MIDI 1:32:0") and then SEGVs during interpreter shutdown
+- Stack trace lives entirely inside =libpython3.14.so= on the =Py_Exit= path — no cameractrls frames, no asound frames at the crash point
+- Pattern: ctypes-loaded =libasound= atexit handler vs Python 3.14 finalization order; the script's job completes before the crash so functional behavior is fine
+- Reproduces every invocation. Three coredumps on 2026-05-08 11:49–11:50 were Craig running it three times in a row, not a regression burst
+- Classification: KNOWN (upstream Python 3.14 / cameractrls issue, no local fix). Annotate future cameraptzmidi coredumps as =KNOWN — Py_Exit / libasound finalization=
+- Real fix is upstream: Python 3.14 ctypes-finalization change or cameractrls explicit asound cleanup before exit. Not worth tracking locally.
+
+** 2026-05-11: mybitch — hard freeze caused by amdgpu iGPU MES hang; mitigated with =amdgpu.cwsr_enable=0=
+:host: mybitch
+- Symptom: full system freeze during active GUI use (Christine typing in a browser). Desktop frozen, keyboard + trackpad dead, off the network. Not the s2idle USB-keyboard bug — the machine was awake, not resuming from suspend, and the WiFi (M.2 PCIe, not USB) was dead too, so a USB-hub-only failure is excluded. Required hard power-cycle. =last= records the prior session as ending in "crash".
+- Evidence in =journalctl -b -1=: the journal *stops dead* with no panic / oops / hung-task warning / OOM / GPU-reset trace — classic hard hang (kernel wedged, logging stopped). pstore empty. Preceding the freeze: =amdgpu 0000:c5:00.0: amdgpu: MES failed to respond to msg=MISC (WAIT_REG_MEM)= + =failed to reg_write_reg_wait= — *38 occurrences* over the 10-day boot, *zero* in every prior boot including the pre-upgrade 6.8.0-110 stretch. Last one ~4.7 min before the hang. =c5:00.0= is the *integrated* GPU (Radeon 780M / Phoenix1, GFX 11.0.x); =03:00.0= is the dGPU (RX 7700S) — the dGPU's =PSP/SMU is resuming= log lines are routine runtime-PM, not errors.
+- Root cause class: amdgpu MES (Micro Engine Scheduler) firmware going unresponsive on AMD GFX11 APUs (Phoenix 780M, Strix Point, Kraken Point) — a known issue across a wide range of recent kernels (6.11 → 6.18+), no clean upstream fix. The MES errors appeared exactly when mybitch moved to kernel 6.17.0-23 (Mint 22.3 HWE-edge, adopted 2026-04-30 to fix the s2idle USB-keyboard bug). Tracked at: Framework Community "AMD GPU MES Timeouts Causing System Hangs", ROCm issues #3207 / #5590 / #5844, drm/amd GitLab #2986.
+- Mitigation applied 2026-05-11: =amdgpu.cwsr_enable=0= added to =GRUB_CMDLINE_LINUX_DEFAULT= in =/etc/default/grub= on mybitch (backup: =/etc/default/grub.bak-2026-05-11-amdgpu-cwsr=), =update-grub= run, param verified in all 6 grub.cfg entries. Disables Compute Wave Store and Resume — the feature several reporters found triggers the MES firmware hang. Narrow, low-risk. Takes effect on next reboot.
+- Watch: after the next reboot, check =journalctl -k -b | grep -c 'MES failed'= — should stay 0. If a freeze recurs even with cwsr disabled, escalate to =amdgpu.mes=0= (disables hardware MES scheduling entirely, falls back to KIQ; heavier hammer). Last resort = pin back to 6.8.0-111 (still installed), but that reintroduces the s2idle USB-keyboard bug, so not clean.
+- *Update 2026-05-12:* =cwsr_enable=0= did NOT reduce the MES errors — 14 "MES failed to respond" hits in the first 14.5h boot after the fix (≈0.97/h) vs. 38 over the prior 10-day boot (≈0.16/h), i.e. *higher* rate; no freeze yet. Escalated proactively (Christine about to travel with the laptop for a week): added =amdgpu.mes=0= to =/etc/default/grub= alongside =cwsr_enable=0= (backup =/etc/default/grub.bak-2026-05-12-amdgpu-mes=, =update-grub= run, in all 6 grub.cfg entries). Takes effect on the next reboot (after the in-progress first rsyncshot backup). =mes=0= disables the GFX11 hardware MES scheduler → kernel falls back to the mature legacy KIQ path → sidesteps the hanging MES firmware entirely. Tradeoff: KIQ-on-GFX11 is a less-traveled config (small chance of cosmetic display/modeset quirks; irrelevant for a light desktop workload). Post-reboot check: =journalctl -k -b | grep -c 'MES failed'= should be 0; watch for any new amdgpu display oddities. (Also tracked in the =mybitch - Christine's Laptop= roam node → Operational Changes Log.)
+- *Update 2026-05-12b (logged retroactively 2026-05-25):* =mes=0= was swapped for =uni_mes=0= the same day (backup =/etc/default/grub.bak-2026-05-12b-uni-mes=). This refinement was applied but never written into this entry until the 2026-05-25 health check found the live cmdline disagreeing with the documented =mes=0= mitigation. Current cmdline: =amdgpu.cwsr_enable=0 amdgpu.uni_mes=0=.
+- *Update 2026-05-25 (health check):* Kernel bumped 6.17.0-23 → 6.17.0-29-generic (Mint HWE). MES errors persist: 49 =MES failed to respond= / =reg_write_reg_wait= on =c5:00.0= over a 3d5h boot (≈0.63/h), *no hard freeze this uptime*. Key finding from =modinfo amdgpu= on 6.17.0-29: =mes= now defaults to 0 (disabled) and =uni_mes= defaults to 1 (enabled) — between -23 and -29 AMD moved GFX11 onto the *unified* MES path, so =uni_mes=0= is the current-kernel equivalent of the old =mes=0= mitigation. Setting =amdgpu.mes=0= explicitly on this kernel just restates the default and won't change behavior; the errors are on the unified-MES path, which is already disabled. Decision (Craig, 2026-05-25): leave the cmdline as-is — there is no stronger *documented* knob left to pull beyond =uni_mes=0=, and there's no freeze to chase. The newer kernel appears to recover from each MES timeout rather than wedging. Watch freeze behavior; escalation lever if a hard freeze recurs would be the experimental =amdgpu.mes=0 amdgpu.mes_kiq=0 amdgpu.uni_mes=0= full-legacy-KIQ path (not applied — risky on a traveling laptop) or pinning back to 6.8.0-111 (reintroduces the s2idle USB-keyboard bug).
+- Classification: when a future health check on mybitch sees =MES failed to respond= / =reg_write_reg_wait= on =c5:00.0=, annotate as =KNOWN — amdgpu GFX11 iGPU MES hang= and check whether the count is climbing despite the mitigations; a single hard freeze with the dead-journal signature is the same issue recurring. On kernel 6.17.0-29+ the box runs =uni_mes=0= (the unified-MES disable), which is the heaviest documented knob in use — MES errors persisting on it without a freeze is the expected steady state, not an escalation signal. A *hard freeze* with the dead-journal signature is the real escalation trigger; remaining levers at that point are the experimental full-legacy-KIQ cmdline or pinning back to 6.8.0-111.
+
+** 2026-05-11: mybitch — power-profiles-daemon was stuck on power-saver (now balanced); benign dGPU power-limit error
+:host: mybitch
+- Symptom: =amdgpu 0000:03:00.0: amdgpu: New power limit (30) is out of range [100,120]= + =amdgpu: Failed to set power limit value= at error priority, once per boot (and on profile changes).
+- Root cause: =power-profiles-daemon= in the =power-saver= profile tried to cap the discrete RX 7700S (=03:00.0=) at 30W, but the GPU's settable power-cap range is [100W, 120W] (current cap 100W). amdgpu rejected the out-of-range request → cosmetic error; the dGPU stayed at its 100W floor regardless. Underlying issue: PPD persists the last-set profile in =/var/lib/power-profiles-daemon/state.ini= and restores it on every boot — someone had set =power-saver= long ago (state.ini mtime Oct 2025) and it had been pinned there ever since. Christine runs mybitch on AC a lot, so this wasn't what she wanted.
+- Resolution (2026-05-11): set the profile to =balanced=. =powerprofilesctl set= over SSH is polkit-denied (=switch-profile= needs an active local session), so done by editing =state.ini= directly under sudo (=Profile=power-saver= → =Profile=balanced=) with =power-profiles-daemon= stopped, then restarting it. Verified =powerprofilesctl get= → =balanced=; persists across reboots; no new power-limit errors. Christine can still flip the profile anytime via the Cinnamon power applet.
+- Classification: if the =New power limit (30) out of range= error reappears, it means the profile drifted back to =power-saver= — annotate as =KNOWN — PPD power-saver dGPU cap rejection= and re-set to =balanced= the same way. Harmless either way; the only reason to fix it is that power-saver throttles the box on AC.
+
+** 2026-05-18: velox — NVMe Error Information Log entries from kernel optional-feature probing
+:host: velox
+- Symptom: =smartctl -A /dev/nvme0n1= shows =Error Information Log Entries: 9,530= and climbing slowly over time, despite =Media and Data Integrity Errors: 0= and =SMART overall-health PASSED=.
+- Detail (=smartctl -l error /dev/nvme0n1=): all 9,530 entries are a single error class — =Status 0x4004 "Invalid Field in Command"= on the Admin Submission Queue (SQId 0). One row in the error info log, accumulated over 20,079 power-on hours (~2.3 years of drive uptime).
+- Root cause: kernel or userspace polls for an optional NVMe feature/log page the drive doesn't implement; the controller rejects with =Status 0x4004=, the host sees ENOTSUP and moves on, but the drive faithfully logs every rejection. Common on NVMe drives where newer kernels probe for features added after the drive's firmware was written.
+- Classification: KNOWN — annotate future findings as =KNOWN — NVMe Invalid-Field-in-Command from kernel optional-feature probing=. The counter going up is not a failure signal; only =Media and Data Integrity Errors=, =Critical Warning=, or =Available Spare= changes indicate actual drive health regression.
+- Out of scope: no kernel-side fix worth chasing. The probe is harmless and would have to be tracked down to a specific kernel subsystem; not worth the time when the only effect is a benign counter.
+
+** 2026-05-25: mybitch — timeshift SIGSEGV on =--check --scripted= cron runs (GObject teardown race)
+:host: mybitch
+- Symptom: =coredumpctl= shows intermittent SIGSEGV of =/usr/bin/timeshift= during the hourly =timeshift --check --scripted= cron job (e.g. 2026-05-23 02:00, 2026-05-24 23:00 — 2 in a 3-day window). Crash is at address =0x30= inside =libgobject-2.0.so.0= =g_object_unref=, with a second thread in =g_file_get_contents= — a multi-threaded GObject teardown race.
+- Timing: the process runs ~29 s (started :01, segfault :30) — it completes its work and dies during cleanup/exit, the same "job done, crash on teardown" shape as the cameractrls Py_Exit case.
+- Backups unaffected: hourly =backup.log= files in =/var/log/timeshift/= are present every hour, =--list= works, 50 snapshots on disk. timeshift rsyncs to a temp dir then promotes, so a crashed run leaves at worst an incomplete dir the next run ignores — not a corrupt snapshot.
+- Long-standing, not a regression: an older coredump from 2026-01-06 has the identical two-thread =g_object_unref= / =g_file_get_contents= signature. Version: =timeshift 25.12.4+zena= (Mint's maintained fork); apt shows 0 pending so no newer build to move to.
+- Classification: KNOWN — annotate future findings as =KNOWN — timeshift zena teardown SIGSEGV on --check=. A real escalation signal would be missing hourly =backup.log= entries or a gap in the snapshot timeline; the coredumps alone are cosmetic. No fix in scope (upstream zena-fork bug); suppression isn't worth it.
+
+** 2026-06-13: ratio — dockerized telega-server SIGSEGVs in musl build
+:host: ratio
+- Symptom: =coredumpctl= shows repeated SIGSEGVs from =telega-server -O 31 -l ~/.telega/telega-server.log -v 3= running inside the Telega Docker container. Example stack is entirely in the container's musl loader plus =/usr/bin/telega-server=, not host kernel, GPU, storage, or memory paths.
+- Evidence: =~/.telega/telega-server.log= ends with =Unexpected char 'm' in plist value= followed by =Assertion failed: false (telega-dat.c: tdat_plist_value: 500)=. Surrounding TDLib traffic includes sticker/custom-emoji metadata such as =documentAttributeCustomEmoji= and =PhotoSizeSourceThumbnail[Thumbnail, type = m]=.
+- Prior local triage: =~/.emacs.d/todo.org= recorded the same issue on 2026-06-11 as spontaneous memory-corruption crashes in =zevlg/telega-server:latest='s musl build, with several coredumps occurring without action-verb traffic. Telega package installed at the 2026-06-13 health check was =20260513.509=; MELPA had =20260604.2321= available.
+- Functional status at 2026-06-13 check: no coredumps yet that day; Telegram scans still worked from cached chat state. Treat as an app/server-container crash, not a machine-health fault.
+- Classification: KNOWN — annotate future =telega-server= coredumps on ratio as =KNOWN — dockerized telega-server musl SIGSEGV= if the signature matches =tdat_plist_value= / unexpected plist value or otherwise stays inside the Telega container. Escalate only if crashes become continuous, break Telegram workflows, or appear after moving off the Docker musl build.
+- Deferred remediation options, in order of least disruption: update the Emacs =telega= package, rebuild/pull a newer =telega-server= image, pin a known-good pre-2026-06 image digest, build =telega-server= natively, or report upstream with =coredumpctl= and log evidence.
diff --git a/init b/init
index a20bf7c..c300fea 100755
--- a/init
+++ b/init
@@ -1,4 +1,5 @@
#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
# update and install tmux and git
pacman -Sy --noconfirm >> /dev/null
diff --git a/pocketbook/.gitignore b/pocketbook/.gitignore
deleted file mode 100644
index b9c0bb9..0000000
--- a/pocketbook/.gitignore
+++ /dev/null
@@ -1,6 +0,0 @@
-__pycache__/
-*.egg-info/
-dist/
-build/
-.pytest_cache/
-*.pyc
diff --git a/pocketbook/Makefile b/pocketbook/Makefile
deleted file mode 100644
index f09dbbb..0000000
--- a/pocketbook/Makefile
+++ /dev/null
@@ -1,16 +0,0 @@
-.PHONY: test lint install uninstall clean
-
-test:
- python -m pytest tests/ -v
-
-lint:
- python -m ruff check src/ tests/
-
-install:
- pip install --user -e .
-
-uninstall:
- pip uninstall -y quicknotes
-
-clean:
- rm -rf build/ dist/ src/*.egg-info
diff --git a/pocketbook/pyproject.toml b/pocketbook/pyproject.toml
deleted file mode 100644
index afc5c71..0000000
--- a/pocketbook/pyproject.toml
+++ /dev/null
@@ -1,21 +0,0 @@
-[build-system]
-requires = ["setuptools>=68.0"]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "pocketbook"
-version = "0.1.0"
-description = "GTK4 layer-shell notes panel for Hyprland"
-requires-python = ">=3.12"
-dependencies = [
- "PyGObject",
-]
-
-[project.scripts]
-pocketbook = "pocketbook.__main__:main"
-
-[tool.setuptools.packages.find]
-where = ["src"]
-
-[tool.pytest.ini_options]
-testpaths = ["tests"]
diff --git a/pocketbook/src/pocketbook/__init__.py b/pocketbook/src/pocketbook/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/pocketbook/src/pocketbook/__init__.py
+++ /dev/null
diff --git a/pocketbook/src/pocketbook/__main__.py b/pocketbook/src/pocketbook/__main__.py
deleted file mode 100644
index daac841..0000000
--- a/pocketbook/src/pocketbook/__main__.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import argparse
-import ctypes
-import os
-
-# gtk4-layer-shell must be loaded before libwayland-client.
-# See: https://github.com/wmww/gtk4-layer-shell/blob/main/linking.md
-_LIB = "/usr/lib/libgtk4-layer-shell.so"
-if os.path.exists(_LIB):
- ctypes.cdll.LoadLibrary(_LIB)
-
-
-def main():
- parser = argparse.ArgumentParser(description="Pocketbook panel")
- parser.add_argument(
- "--hidden",
- action="store_true",
- help="Start with panel hidden (daemon mode)",
- )
- args = parser.parse_args()
-
- from pocketbook.app import run_app
- raise SystemExit(run_app(start_hidden=args.hidden))
-
-
-if __name__ == "__main__":
- main()
diff --git a/pocketbook/src/pocketbook/app.py b/pocketbook/src/pocketbook/app.py
deleted file mode 100644
index be13fec..0000000
--- a/pocketbook/src/pocketbook/app.py
+++ /dev/null
@@ -1,243 +0,0 @@
-import sys
-from pathlib import Path
-
-APP_ID = "net.cjennings.pocketbook"
-DATA_DIR = Path.home() / ".local" / "share" / "pocketbook"
-CSS_PATH = Path(__file__).parent / "style.css"
-
-
-class ToggleStateMachine:
- """Testable toggle logic, separated from GTK."""
-
- def __init__(self, start_hidden: bool = False):
- self.visible = not start_hidden
-
- def toggle(self):
- self.visible = not self.visible
-
-
-def navigate(current_index: int | None, total: int, direction: int) -> int | None:
- """Return the target index for note navigation.
-
- Args:
- current_index: Index of focused note, or None if no note focused.
- total: Number of notes.
- direction: +1 for next, -1 for previous.
-
- Returns:
- Target index, or None if no notes exist.
- """
- if total == 0:
- return None
- if current_index is None:
- return 0 if direction == 1 else total - 1
- target = current_index + direction
- return max(0, min(target, total - 1))
-
-
-class EscapeStateMachine:
- """Testable escape logic: editing → browse → hidden."""
-
- def escape(self, is_editing: bool, toggle: ToggleStateMachine):
- """Returns action: 'exit_edit', 'hide', or None."""
- if is_editing:
- return "exit_edit"
- else:
- toggle.toggle()
- return "hide"
-
-
-def _build_gtk_app_class():
- """Lazy-load GTK classes to keep ToggleStateMachine importable without GTK."""
- import gi
- gi.require_version("Gtk", "4.0")
- from gi.repository import Gtk, Gdk
-
- from pocketbook.store import NoteStore
- from pocketbook.panel import PanelController, PanelWidget
- from pocketbook.layer_shell import configure_layer_shell
-
- class PocketbookApp(Gtk.Application):
- def __init__(self, start_hidden: bool = False):
- super().__init__(application_id=APP_ID)
- self._start_hidden = start_hidden
- self._window = None
- self._panel = None
- self._toggle = ToggleStateMachine(start_hidden=start_hidden)
- self._esc_sm = EscapeStateMachine()
- self._first_activate = True
-
- def do_activate(self):
- if self._first_activate:
- self._first_activate = False
- self._build_ui()
- if not self._start_hidden:
- self._window.set_visible(True)
- self._focus_first_note()
- else:
- self._toggle.toggle()
- self._window.set_visible(self._toggle.visible)
- if self._toggle.visible:
- self._focus_first_note()
-
- def _focus_first_note(self):
- rows = self._get_note_rows()
- if rows:
- rows[0].grab_focus()
-
- def _build_ui(self):
- self._window = Gtk.Window(application=self)
- self._window.set_default_size(420, -1)
-
- configure_layer_shell(self._window)
- self._load_css()
-
- store = NoteStore(DATA_DIR)
- controller = PanelController(store)
- self._panel = PanelWidget(controller)
- self._window.set_child(self._panel)
-
- self._setup_shortcuts()
-
- def _load_css(self):
- if CSS_PATH.exists():
- provider = Gtk.CssProvider()
- provider.load_from_path(str(CSS_PATH))
- Gtk.StyleContext.add_provider_for_display(
- Gdk.Display.get_default(),
- provider,
- Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
- )
-
- def _setup_shortcuts(self):
- from pocketbook.note_widget import NoteRow
-
- sc = Gtk.ShortcutController()
- sc.set_scope(Gtk.ShortcutScope.GLOBAL)
-
- for key, handler in [
- ("Escape", self._on_escape),
- ("<Control>n", self._on_ctrl_n),
- ("<Control>j", self._on_next_note),
- ("<Control>k", self._on_prev_note),
- ("Delete", self._on_delete_note),
- ("Return", self._on_enter),
- ]:
- sc.add_shortcut(Gtk.Shortcut(
- trigger=Gtk.ShortcutTrigger.parse_string(key),
- action=Gtk.CallbackAction.new(handler),
- ))
-
- self._window.add_controller(sc)
- self._note_row_class = NoteRow
-
- # Arrow keys need EventControllerKey to beat GTK's focus navigation
- key_ctrl = Gtk.EventControllerKey()
- key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
- key_ctrl.connect("key-pressed", self._on_key_pressed)
- self._window.add_controller(key_ctrl)
-
- def _get_focused_note_row(self):
- """Find the NoteRow that currently has focus (or contains the focused widget)."""
- focus = self._window.get_focus()
- widget = focus
- while widget:
- if isinstance(widget, self._note_row_class):
- return widget
- widget = widget.get_parent()
- return None
-
- def _get_note_rows(self):
- """Get all NoteRow children from the panel."""
- rows = []
- child = self._panel._list_box.get_first_child()
- while child:
- if isinstance(child, self._note_row_class):
- rows.append(child)
- child = child.get_next_sibling()
- return rows
-
- def _on_escape(self, _widget, _args):
- focus = self._window.get_focus()
- is_editing = bool(focus and isinstance(focus, (Gtk.Entry, Gtk.TextView, Gtk.Text)))
- action = self._esc_sm.escape(is_editing, self._toggle)
- if action == "exit_edit":
- row = self._get_focused_note_row()
- if row:
- row.exit_edit_mode()
- else:
- self._window.set_focus(None)
- elif action == "hide":
- self._window.set_visible(False)
- return True
-
- def _on_ctrl_n(self, _widget, _args):
- self._panel.add_note()
- return True
-
- def _on_next_note(self, _widget, _args):
- self._navigate_notes(1)
- return True
-
- def _on_prev_note(self, _widget, _args):
- self._navigate_notes(-1)
- return True
-
- def _navigate_notes(self, direction):
- rows = self._get_note_rows()
- current = self._get_focused_note_row()
- current_idx = rows.index(current) if current in rows else None
- target_idx = navigate(current_idx, len(rows), direction)
- if target_idx is not None:
- rows[target_idx].grab_focus()
-
- def _on_key_pressed(self, _controller, keyval, _keycode, _state):
- from gi.repository import Gdk as _Gdk
- focus = self._window.get_focus()
- is_editing = isinstance(focus, (Gtk.Entry, Gtk.TextView, Gtk.Text))
- if keyval == _Gdk.KEY_Down and not is_editing:
- self._on_next_note(None, None)
- return True # stop propagation
- if keyval == _Gdk.KEY_Up and not is_editing:
- self._on_prev_note(None, None)
- return True
- if keyval == _Gdk.KEY_s and is_editing and (_state & _Gdk.ModifierType.CONTROL_MASK):
- row = self._get_focused_note_row()
- if row:
- row.exit_edit_mode()
- return True
- if keyval == _Gdk.KEY_Return and not is_editing:
- row = self._get_focused_note_row()
- if row:
- row.focus_title()
- return True
- return False # let GTK handle it
-
- def _on_delete_note(self, _widget, _args):
- focus = self._window.get_focus()
- # Only delete in browse mode (NoteRow focused, not a text field)
- if isinstance(focus, (Gtk.Entry, Gtk.TextView, Gtk.Text)):
- return False # pass through to text widget
- row = self._get_focused_note_row()
- if row:
- self._panel._on_note_deleted(row._filename)
- self._focus_first_note()
- return True
-
- def _on_enter(self, _widget, _args):
- # Only enter edit mode if a NoteRow itself is focused (browse mode),
- # not when already editing a text field
- focus = self._window.get_focus()
- if isinstance(focus, self._note_row_class):
- focus.focus_title()
- return True
- # Let Enter pass through normally in text fields
- return False
-
- return PocketbookApp
-
-
-def run_app(start_hidden: bool = False) -> int:
- PocketbookApp = _build_gtk_app_class()
- app = PocketbookApp(start_hidden=start_hidden)
- return app.run(sys.argv[:1])
diff --git a/pocketbook/src/pocketbook/layer_shell.py b/pocketbook/src/pocketbook/layer_shell.py
deleted file mode 100644
index bb36373..0000000
--- a/pocketbook/src/pocketbook/layer_shell.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import gi
-gi.require_version("Gtk", "4.0")
-gi.require_version("Gtk4LayerShell", "1.0")
-from gi.repository import Gtk, Gtk4LayerShell
-
-
-def configure_layer_shell(window: Gtk.Window):
- """Configure a GTK4 window as a layer-shell surface."""
- Gtk4LayerShell.init_for_window(window)
- Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.OVERLAY)
- Gtk4LayerShell.set_namespace(window, "pocketbook")
-
- # Anchor top-right
- Gtk4LayerShell.set_anchor(window, Gtk4LayerShell.Edge.TOP, True)
- Gtk4LayerShell.set_anchor(window, Gtk4LayerShell.Edge.RIGHT, True)
-
- # Margins: below waybar, inset from edge
- Gtk4LayerShell.set_margin(window, Gtk4LayerShell.Edge.TOP, 50)
- Gtk4LayerShell.set_margin(window, Gtk4LayerShell.Edge.RIGHT, 10)
-
- # On-demand keyboard: receives keys when focused, doesn't steal focus on appear
- Gtk4LayerShell.set_keyboard_mode(
- window, Gtk4LayerShell.KeyboardMode.ON_DEMAND
- )
diff --git a/pocketbook/src/pocketbook/note.py b/pocketbook/src/pocketbook/note.py
deleted file mode 100644
index e812eea..0000000
--- a/pocketbook/src/pocketbook/note.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from dataclasses import dataclass
-from datetime import datetime
-import secrets
-
-
-@dataclass
-class Note:
- title: str
- body: str
-
- def to_file_content(self) -> str:
- return f"{self.title}\n\n{self.body}"
-
- @classmethod
- def from_file_content(cls, content: str) -> "Note":
- parts = content.split("\n\n", 1)
- title = parts[0]
- body = parts[1] if len(parts) > 1 else ""
- return cls(title=title, body=body)
-
- def generate_filename(self, order: int) -> str:
- ts = datetime.now().strftime("%Y%m%d-%H%M%S")
- short_id = secrets.token_hex(3)[:5]
- return f"{order:04d}-{ts}-{short_id}.txt"
-
- @staticmethod
- def parse_order(filename: str) -> int:
- return int(filename.split("-", 1)[0])
diff --git a/pocketbook/src/pocketbook/note_widget.py b/pocketbook/src/pocketbook/note_widget.py
deleted file mode 100644
index 3467ad0..0000000
--- a/pocketbook/src/pocketbook/note_widget.py
+++ /dev/null
@@ -1,93 +0,0 @@
-from typing import Callable
-
-import gi
-gi.require_version("Gtk", "4.0")
-from gi.repository import Gtk
-
-from pocketbook.note import Note
-
-
-class NoteRow(Gtk.Box):
- """A single note row with browse/edit modes."""
-
- def __init__(
- self,
- filename: str,
- note: Note,
- on_changed: Callable[[str, str, str], None],
- on_deleted: Callable[[str], None],
- ):
- super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4)
- self.add_css_class("note-row")
- self._filename = filename
- self._on_changed = on_changed
- self._on_deleted = on_deleted
-
- # Title row with delete button
- title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
-
- self._title_entry = Gtk.Entry()
- self._title_entry.set_text(note.title)
- self._title_entry.set_hexpand(True)
- self._title_entry.add_css_class("note-title")
- self._title_entry.set_placeholder_text("Title")
- self._title_entry.set_can_focus(False)
- self._title_entry.set_focusable(False)
- self._title_entry.connect("changed", self._on_text_changed)
- self._title_entry.connect("notify::has-focus", self._on_title_focus_changed)
- title_row.append(self._title_entry)
-
- delete_btn = Gtk.Button(label="󰆴")
- delete_btn.add_css_class("delete-button")
- delete_btn.set_focusable(False)
- delete_btn.connect("clicked", self._on_delete_clicked)
- title_row.append(delete_btn)
-
- self.append(title_row)
-
- # Body text view
- self._body_buffer = Gtk.TextBuffer()
- self._body_buffer.set_text(note.body)
- self._body_buffer.connect("changed", self._on_text_changed)
-
- self._body_view = Gtk.TextView(buffer=self._body_buffer)
- self._body_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
- self._body_view.add_css_class("note-body")
- self._body_view.set_vexpand(False)
- self._body_view.set_can_focus(False)
- self._body_view.set_focusable(False)
- self.append(self._body_view)
-
- # NoteRow itself is focusable for browse mode
- self.set_focusable(True)
-
- def focus_title(self):
- """Enter edit mode — focus and select title text."""
- self._title_entry.set_can_focus(True)
- self._title_entry.set_focusable(True)
- self._body_view.set_can_focus(True)
- self._body_view.set_focusable(True)
- self._title_entry.grab_focus()
- self._title_entry.select_region(0, -1)
-
- def exit_edit_mode(self):
- """Return to browse mode — defocus text fields."""
- self._title_entry.set_can_focus(False)
- self._title_entry.set_focusable(False)
- self._body_view.set_can_focus(False)
- self._body_view.set_focusable(False)
- self.grab_focus()
-
- def _on_title_focus_changed(self, entry, _pspec):
- if not entry.has_focus():
- entry.select_region(0, 0)
-
- def _on_text_changed(self, _widget):
- title = self._title_entry.get_text()
- start = self._body_buffer.get_start_iter()
- end = self._body_buffer.get_end_iter()
- body = self._body_buffer.get_text(start, end, False)
- self._on_changed(self._filename, title, body)
-
- def _on_delete_clicked(self, _button):
- self._on_deleted(self._filename)
diff --git a/pocketbook/src/pocketbook/panel.py b/pocketbook/src/pocketbook/panel.py
deleted file mode 100644
index e0a708c..0000000
--- a/pocketbook/src/pocketbook/panel.py
+++ /dev/null
@@ -1,97 +0,0 @@
-from pocketbook.store import NoteStore
-from pocketbook.note import Note
-
-import gi
-gi.require_version("Gtk", "4.0")
-from gi.repository import Gtk
-
-
-class PanelController:
- """Testable controller logic for the notes panel."""
-
- def __init__(self, store: NoteStore):
- self.store = store
-
- def add_note(self) -> str:
- return self.store.create("New Note", "")
-
- def delete_note(self, filename: str):
- self.store.delete(filename)
-
- def update_note(self, filename: str, title: str, body: str):
- self.store.update(filename, title, body)
-
- def get_notes(self) -> list[tuple[str, Note]]:
- return self.store.list_notes()
-
-
-class PanelWidget(Gtk.Box):
- """Main panel widget — scrollable list of notes with add button."""
-
- def __init__(self, controller: PanelController):
- super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
- self.controller = controller
- self.add_css_class("panel")
-
- # Header with title and add button
- header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
- header.add_css_class("panel-header")
-
- title_label = Gtk.Label(label="Pocketbook")
- title_label.add_css_class("panel-title")
- title_label.set_hexpand(True)
- title_label.set_halign(Gtk.Align.START)
- header.append(title_label)
-
- add_btn = Gtk.Button(label="+")
- add_btn.add_css_class("add-button")
- add_btn.set_focusable(False)
- add_btn.connect("clicked", self._on_add_clicked)
- header.append(add_btn)
-
- self.append(header)
-
- # Scrollable note list
- self._scroll = Gtk.ScrolledWindow()
- self._scroll.set_vexpand(True)
- self._scroll.set_propagate_natural_height(True)
- self._scroll.set_max_content_height(550)
- self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
-
- self._list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
- self._list_box.add_css_class("note-list")
- self._scroll.set_child(self._list_box)
- self.append(self._scroll)
-
- self._refresh()
-
- def _refresh(self):
- # Clear existing children
- child = self._list_box.get_first_child()
- while child:
- next_child = child.get_next_sibling()
- self._list_box.remove(child)
- child = next_child
-
- from pocketbook.note_widget import NoteRow
- for filename, note in self.controller.get_notes():
- row = NoteRow(filename, note, self._on_note_changed, self._on_note_deleted)
- self._list_box.append(row)
-
- def add_note(self):
- self.controller.add_note()
- self._refresh()
- # Focus the title entry of the newly added note (last child)
- last = self._list_box.get_last_child()
- if last:
- last.focus_title()
-
- def _on_add_clicked(self, _button):
- self.add_note()
-
- def _on_note_changed(self, filename: str, title: str, body: str):
- self.controller.update_note(filename, title, body)
-
- def _on_note_deleted(self, filename: str):
- self.controller.delete_note(filename)
- self._refresh()
diff --git a/pocketbook/src/pocketbook/store.py b/pocketbook/src/pocketbook/store.py
deleted file mode 100644
index 83d3b74..0000000
--- a/pocketbook/src/pocketbook/store.py
+++ /dev/null
@@ -1,73 +0,0 @@
-from pathlib import Path
-import re
-from pocketbook.note import Note
-
-FILENAME_RE = re.compile(r"^\d{4}-.+\.txt$")
-
-
-class NoteStore:
- def __init__(self, directory: Path):
- self.directory = directory
-
- def _ensure_dir(self):
- self.directory.mkdir(parents=True, exist_ok=True)
-
- def _valid_note_files(self) -> list[tuple[str, Path]]:
- """Return sorted list of (filename, path) for valid note files."""
- if not self.directory.exists():
- return []
- files = []
- for p in self.directory.iterdir():
- if p.is_file() and FILENAME_RE.match(p.name):
- try:
- Note.parse_order(p.name)
- files.append((p.name, p))
- except (ValueError, IndexError):
- continue
- files.sort(key=lambda x: Note.parse_order(x[0]))
- return files
-
- def _next_order(self) -> int:
- files = self._valid_note_files()
- if not files:
- return 1
- return Note.parse_order(files[-1][0]) + 1
-
- def create(self, title: str, body: str) -> str:
- self._ensure_dir()
- note = Note(title=title, body=body)
- order = self._next_order()
- filename = note.generate_filename(order)
- (self.directory / filename).write_text(note.to_file_content())
- return filename
-
- def list_notes(self) -> list[tuple[str, Note]]:
- """Return list of (filename, Note) sorted by order."""
- result = []
- for fname, path in self._valid_note_files():
- content = path.read_text()
- note = Note.from_file_content(content)
- result.append((fname, note))
- return result
-
- def update(self, filename: str, title: str, body: str):
- path = self.directory / filename
- if not path.exists():
- raise FileNotFoundError(f"Note not found: {filename}")
- note = Note(title=title, body=body)
- path.write_text(note.to_file_content())
-
- def delete(self, filename: str):
- path = self.directory / filename
- if not path.exists():
- raise FileNotFoundError(f"Note not found: {filename}")
- path.unlink()
-
- def reorder(self):
- """Renumber all note files sequentially starting from 1."""
- files = self._valid_note_files()
- for i, (fname, path) in enumerate(files, start=1):
- current_order = Note.parse_order(fname)
- if current_order != i:
- new_name = f"{i:04d}{fname[4:]}"
- path.rename(self.directory / new_name)
diff --git a/pocketbook/src/pocketbook/style.css b/pocketbook/src/pocketbook/style.css
deleted file mode 100644
index 92df265..0000000
--- a/pocketbook/src/pocketbook/style.css
+++ /dev/null
@@ -1,102 +0,0 @@
-/* Pocketbook — Dupre theme */
-/* Colors from dupre-palette.org */
-
-window {
- background-color: rgba(21, 19, 17, 0.95);
- border: 2px solid #d7af5f;
- border-radius: 16px;
- font-family: "BerkeleyMono Nerd Font", "Berkeley Mono", monospace;
- font-size: 14px;
- color: #969385;
-}
-
-.panel {
- padding: 12px;
-}
-
-.panel-header {
- padding: 4px 4px 8px 4px;
- margin-bottom: 4px;
- border-bottom: 1px solid #474544;
-}
-
-.panel-title {
- font-size: 16px;
- font-weight: bold;
- color: #d7af5f;
-}
-
-.add-button {
- background-color: transparent;
- color: #d7af5f;
- border: 1px solid #d7af5f;
- border-radius: 8px;
- padding: 2px 10px;
- font-size: 18px;
- font-weight: bold;
- min-width: 32px;
- min-height: 32px;
-}
-
-.add-button:hover {
- background-color: #d7af5f;
- color: #151311;
-}
-
-.note-list {
- padding: 4px 0;
-}
-
-.note-row {
- background-color: rgba(71, 69, 68, 0.3);
- border-radius: 8px;
- padding: 8px;
- margin: 4px 0;
- border: 1px solid transparent;
-}
-
-.note-row:focus {
- border: 1px solid #d7af5f;
-}
-
-.note-title {
- background-color: transparent;
- border: none;
- color: #b2c3cc;
- font-weight: bold;
- font-size: 14px;
- padding: 4px;
-}
-
-.note-title:focus {
- outline: none;
- border-bottom: 1px solid #d7af5f;
-}
-
-.note-body {
- background-color: transparent;
- color: #d0cbc0;
- font-size: 13px;
- padding: 4px;
- border-radius: 4px;
-}
-
-.note-body:focus {
- background-color: rgba(71, 69, 68, 0.4);
-}
-
-.delete-button {
- background-color: transparent;
- color: #969385;
- border: none;
- border-radius: 8px;
- padding: 2px 8px;
- font-size: 16px;
- min-width: 28px;
- min-height: 28px;
-}
-
-.delete-button:hover {
- color: #d47c59;
- background-color: rgba(212, 124, 89, 0.2);
-}
diff --git a/pocketbook/tests/__init__.py b/pocketbook/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/pocketbook/tests/__init__.py
+++ /dev/null
diff --git a/pocketbook/tests/conftest.py b/pocketbook/tests/conftest.py
deleted file mode 100644
index db04f88..0000000
--- a/pocketbook/tests/conftest.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import pytest
-
-
-@pytest.fixture
-def notes_dir(tmp_path):
- """Temporary directory for note storage."""
- d = tmp_path / "quicknotes"
- d.mkdir()
- return d
-
-
-@pytest.fixture
-def sample_note_file(notes_dir):
- """A sample note file on disk."""
- path = notes_dir / "0001-20260101-120000-abc12.txt"
- path.write_text("Shopping List\n\nMilk\nEggs\nBread\n")
- return path
diff --git a/pocketbook/tests/test_app_toggle.py b/pocketbook/tests/test_app_toggle.py
deleted file mode 100644
index cb5ab89..0000000
--- a/pocketbook/tests/test_app_toggle.py
+++ /dev/null
@@ -1,96 +0,0 @@
-from pocketbook.app import ToggleStateMachine, EscapeStateMachine, navigate
-
-
-class TestToggleStateMachine:
- def test_initial_state_visible(self):
- sm = ToggleStateMachine(start_hidden=False)
- assert sm.visible is True
-
- def test_initial_state_hidden(self):
- sm = ToggleStateMachine(start_hidden=True)
- assert sm.visible is False
-
- def test_toggle_alternates(self):
- sm = ToggleStateMachine(start_hidden=False)
- assert sm.visible is True
- sm.toggle()
- assert sm.visible is False
- sm.toggle()
- assert sm.visible is True
-
- def test_toggle_from_hidden(self):
- sm = ToggleStateMachine(start_hidden=True)
- assert sm.visible is False
- sm.toggle()
- assert sm.visible is True
- sm.toggle()
- assert sm.visible is False
-
-
-class TestEscapeStateMachine:
- def test_escape_while_editing_returns_exit_edit(self):
- esc = EscapeStateMachine()
- toggle = ToggleStateMachine(start_hidden=False)
- action = esc.escape(is_editing=True, toggle=toggle)
- assert action == "exit_edit"
- # Toggle state should not change
- assert toggle.visible is True
-
- def test_escape_while_browsing_returns_hide(self):
- esc = EscapeStateMachine()
- toggle = ToggleStateMachine(start_hidden=False)
- action = esc.escape(is_editing=False, toggle=toggle)
- assert action == "hide"
- assert toggle.visible is False
-
- def test_escape_edit_then_browse_hides(self):
- """Simulates: editing → Escape (exit edit) → Escape (hide)."""
- esc = EscapeStateMachine()
- toggle = ToggleStateMachine(start_hidden=False)
-
- # First escape: exit edit mode
- action1 = esc.escape(is_editing=True, toggle=toggle)
- assert action1 == "exit_edit"
- assert toggle.visible is True
-
- # Second escape: now in browse mode, hide panel
- action2 = esc.escape(is_editing=False, toggle=toggle)
- assert action2 == "hide"
- assert toggle.visible is False
-
- def test_escape_hide_does_not_change_when_already_hidden(self):
- esc = EscapeStateMachine()
- toggle = ToggleStateMachine(start_hidden=True)
- assert toggle.visible is False
- action = esc.escape(is_editing=False, toggle=toggle)
- assert action == "hide"
- # Toggled again — now visible (edge case if called when hidden)
- assert toggle.visible is True
-
-
-class TestNavigate:
- def test_no_notes(self):
- assert navigate(None, 0, 1) is None
- assert navigate(None, 0, -1) is None
-
- def test_no_focus_next_goes_to_first(self):
- assert navigate(None, 3, 1) == 0
-
- def test_no_focus_prev_goes_to_last(self):
- assert navigate(None, 3, -1) == 2
-
- def test_next_from_middle(self):
- assert navigate(1, 3, 1) == 2
-
- def test_prev_from_middle(self):
- assert navigate(1, 3, -1) == 0
-
- def test_next_clamps_at_end(self):
- assert navigate(2, 3, 1) == 2
-
- def test_prev_clamps_at_start(self):
- assert navigate(0, 3, -1) == 0
-
- def test_single_note(self):
- assert navigate(0, 1, 1) == 0
- assert navigate(0, 1, -1) == 0
diff --git a/pocketbook/tests/test_note.py b/pocketbook/tests/test_note.py
deleted file mode 100644
index 539451a..0000000
--- a/pocketbook/tests/test_note.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from pocketbook.note import Note
-
-
-class TestNoteSerialisation:
- def test_round_trip(self):
- note = Note(title="Shopping", body="Milk\nEggs\nBread")
- content = note.to_file_content()
- restored = Note.from_file_content(content)
- assert restored.title == note.title
- assert restored.body == note.body
-
- def test_empty_body(self):
- note = Note(title="Empty", body="")
- content = note.to_file_content()
- restored = Note.from_file_content(content)
- assert restored.title == "Empty"
- assert restored.body == ""
-
- def test_empty_title(self):
- note = Note(title="", body="some body")
- content = note.to_file_content()
- restored = Note.from_file_content(content)
- assert restored.title == ""
- assert restored.body == "some body"
-
- def test_unicode(self):
- note = Note(title="日本語タイトル", body="Ünïcödé bödý 🎉")
- content = note.to_file_content()
- restored = Note.from_file_content(content)
- assert restored.title == "日本語タイトル"
- assert restored.body == "Ünïcödé bödý 🎉"
-
- def test_multiline_body(self):
- body = "Line 1\nLine 2\n\nLine 4\n"
- note = Note(title="Multi", body=body)
- content = note.to_file_content()
- restored = Note.from_file_content(content)
- assert restored.body == body
-
- def test_file_content_format(self):
- """Title on line 1, blank line, then body."""
- note = Note(title="Title", body="Body text")
- content = note.to_file_content()
- assert content == "Title\n\nBody text"
-
- def test_from_file_content_no_blank_line(self):
- """Gracefully handle files without a blank separator."""
- restored = Note.from_file_content("JustTitle")
- assert restored.title == "JustTitle"
- assert restored.body == ""
-
-
-class TestNoteFilename:
- def test_generate_filename(self):
- note = Note(title="Test", body="")
- filename = note.generate_filename(order=1)
- assert filename.startswith("0001-")
- assert filename.endswith(".txt")
- # Format: 0001-YYYYMMDD-HHMMSS-shortid.txt
- parts = filename.split("-")
- assert len(parts) == 4
- assert len(parts[0]) == 4 # order
- assert len(parts[1]) == 8 # date
- # parts[2] = HHMMSS + shortid.txt combined via split on -
- # Actually: 0001-20260225-143012-abc12.txt has 4 parts
-
- def test_parse_order_from_filename(self):
- assert Note.parse_order("0005-20260101-120000-abc12.txt") == 5
- assert Note.parse_order("0001-20260101-120000-xyz99.txt") == 1
diff --git a/pocketbook/tests/test_panel.py b/pocketbook/tests/test_panel.py
deleted file mode 100644
index 92f8648..0000000
--- a/pocketbook/tests/test_panel.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from unittest.mock import MagicMock
-from pocketbook.note import Note
-
-
-class TestPanelController:
- """Test panel controller logic with a mocked store."""
-
- def _make_controller(self):
- from pocketbook.panel import PanelController
- store = MagicMock()
- controller = PanelController(store)
- return controller, store
-
- def test_add_note_calls_store_create(self):
- controller, store = self._make_controller()
- store.create.return_value = "0001-20260101-120000-abc12.txt"
- controller.add_note()
- store.create.assert_called_once_with("New Note", "")
-
- def test_delete_note_calls_store_delete(self):
- controller, store = self._make_controller()
- controller.delete_note("0001-20260101-120000-abc12.txt")
- store.delete.assert_called_once_with("0001-20260101-120000-abc12.txt")
-
- def test_update_note_calls_store_update(self):
- controller, store = self._make_controller()
- controller.update_note("0001-20260101-120000-abc12.txt", "New Title", "New Body")
- store.update.assert_called_once_with(
- "0001-20260101-120000-abc12.txt", "New Title", "New Body"
- )
-
- def test_get_notes_calls_store_list(self):
- controller, store = self._make_controller()
- store.list_notes.return_value = [
- ("0001-20260101-120000-abc12.txt", Note(title="A", body="a")),
- ]
- notes = controller.get_notes()
- store.list_notes.assert_called_once()
- assert len(notes) == 1
- assert notes[0][1].title == "A"
diff --git a/pocketbook/tests/test_store.py b/pocketbook/tests/test_store.py
deleted file mode 100644
index fab5bd6..0000000
--- a/pocketbook/tests/test_store.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import pytest
-from pocketbook.store import NoteStore
-from pocketbook.note import Note
-
-
-class TestNoteStoreCreate:
- def test_create_note(self, notes_dir):
- store = NoteStore(notes_dir)
- filename = store.create("My Title", "My Body")
- assert (notes_dir / filename).exists()
- content = (notes_dir / filename).read_text()
- assert content == "My Title\n\nMy Body"
-
- def test_create_assigns_incrementing_order(self, notes_dir):
- store = NoteStore(notes_dir)
- f1 = store.create("First", "")
- f2 = store.create("Second", "")
- assert Note.parse_order(f1) == 1
- assert Note.parse_order(f2) == 2
-
- def test_create_auto_creates_directory(self, tmp_path):
- d = tmp_path / "nonexistent" / "pocketbook"
- store = NoteStore(d)
- filename = store.create("Test", "body")
- assert d.exists()
- assert (d / filename).exists()
-
-
-class TestNoteStoreList:
- def test_list_empty(self, notes_dir):
- # Remove the sample file if any fixture created one
- for f in notes_dir.iterdir():
- f.unlink()
- store = NoteStore(notes_dir)
- assert store.list_notes() == []
-
- def test_list_returns_sorted_by_order(self, notes_dir):
- (notes_dir / "0002-20260101-120000-abc12.txt").write_text("B\n\nbody b")
- (notes_dir / "0001-20260101-120000-def34.txt").write_text("A\n\nbody a")
- (notes_dir / "0003-20260101-120000-ghi56.txt").write_text("C\n\nbody c")
- store = NoteStore(notes_dir)
- notes = store.list_notes()
- assert len(notes) == 3
- assert notes[0][1].title == "A"
- assert notes[1][1].title == "B"
- assert notes[2][1].title == "C"
-
- def test_list_skips_non_txt_files(self, notes_dir):
- (notes_dir / "0001-20260101-120000-abc12.txt").write_text("Note\n\nbody")
- (notes_dir / "README.md").write_text("not a note")
- store = NoteStore(notes_dir)
- assert len(store.list_notes()) == 1
-
- def test_list_skips_corrupted_filenames(self, notes_dir):
- (notes_dir / "0001-20260101-120000-abc12.txt").write_text("Good\n\nbody")
- (notes_dir / "bad-name.txt").write_text("Bad\n\nbody")
- store = NoteStore(notes_dir)
- notes = store.list_notes()
- assert len(notes) == 1
- assert notes[0][1].title == "Good"
-
-
-class TestNoteStoreUpdate:
- def test_update_note(self, notes_dir):
- fname = "0001-20260101-120000-abc12.txt"
- (notes_dir / fname).write_text("Old Title\n\nOld body")
- store = NoteStore(notes_dir)
- store.update(fname, "New Title", "New body")
- content = (notes_dir / fname).read_text()
- assert content == "New Title\n\nNew body"
-
- def test_update_nonexistent_raises(self, notes_dir):
- store = NoteStore(notes_dir)
- with pytest.raises(FileNotFoundError):
- store.update("0099-20260101-120000-nope0.txt", "T", "B")
-
-
-class TestNoteStoreDelete:
- def test_delete_note(self, notes_dir):
- fname = "0001-20260101-120000-abc12.txt"
- (notes_dir / fname).write_text("Delete me\n\nbody")
- store = NoteStore(notes_dir)
- store.delete(fname)
- assert not (notes_dir / fname).exists()
-
- def test_delete_nonexistent_raises(self, notes_dir):
- store = NoteStore(notes_dir)
- with pytest.raises(FileNotFoundError):
- store.delete("0099-20260101-120000-nope0.txt")
-
-
-class TestNoteStoreReorder:
- def test_reorder_renumbers_files(self, notes_dir):
- # Create files with gaps in ordering
- (notes_dir / "0001-20260101-120000-aaa11.txt").write_text("A\n\na")
- (notes_dir / "0005-20260101-120000-bbb22.txt").write_text("B\n\nb")
- (notes_dir / "0010-20260101-120000-ccc33.txt").write_text("C\n\nc")
- store = NoteStore(notes_dir)
- store.reorder()
- notes = store.list_notes()
- assert Note.parse_order(notes[0][0]) == 1
- assert Note.parse_order(notes[1][0]) == 2
- assert Note.parse_order(notes[2][0]) == 3
diff --git a/scripts/arch-distrobox b/scripts/arch-distrobox
index 4afe3d1..99c295d 100755
--- a/scripts/arch-distrobox
+++ b/scripts/arch-distrobox
@@ -1,4 +1,5 @@
#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
# ArchDistrobox - Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
diff --git a/scripts/audit-packages.sh b/scripts/audit-packages.sh
index f7af19f..e41b79c 100755
--- a/scripts/audit-packages.sh
+++ b/scripts/audit-packages.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# audit-packages.sh — verify every package archsetup installs still exists
# at its declared source, and flag packages that moved between the official
# repos and the AUR.
diff --git a/scripts/cmail-setup-finish.sh b/scripts/cmail-setup-finish.sh
index 704b707..7f9d3fc 100755
--- a/scripts/cmail-setup-finish.sh
+++ b/scripts/cmail-setup-finish.sh
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# cmail-setup-finish.sh — finish Proton Mail Bridge + cmail-action setup after
# Bridge first-run. Idempotent; safe to re-run after a Bridge cert rotation or
# a claude-templates re-clone.
diff --git a/scripts/games.sh b/scripts/games.sh
index de6a476..2ccdcb4 100755
--- a/scripts/games.sh
+++ b/scripts/games.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# games installations via flatpak
set -uo pipefail
diff --git a/scripts/hypr-live-update-guard b/scripts/hypr-live-update-guard
new file mode 100755
index 0000000..e78200d
--- /dev/null
+++ b/scripts/hypr-live-update-guard
@@ -0,0 +1,139 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
+# hypr-live-update-guard - abort a live GPU/compositor library upgrade.
+#
+# Installed as a pacman PreTransaction hook. When an upgrade transaction
+# would CHANGE the on-disk version of GPU/compositor runtime libraries
+# (mesa, hyprland, wayland, GPU drivers, ...) AND a Hyprland session is
+# running, this aborts the transaction BEFORE any package is swapped.
+# Replacing those libraries out from under a live compositor makes the next
+# GPU-lib call hit a now "(deleted)" file and SIGABRT, taking the Wayland
+# clients down with it (hit on ratio 2026-06-07: mesa + hyprland upgraded
+# live, Hyprland crashed and took awww/insync/emacs with it). Aborting at
+# PreTransaction is the safe point: nothing has been replaced yet, so the
+# running session is untouched and the user can re-run from a TTY.
+#
+# Version-aware (2026-07-08): a same-version reinstall writes identical
+# bytes over identical bytes and is harmless to the live session — the
+# maintenance console's integrity REINSTALL is exactly that after an
+# update, and the name-only guard was blocking it. Each target's installed
+# version (pacman -Q) is compared against the sync-db candidate
+# (expac -S %v); targets that match are pure reinstalls and pass. Both are
+# read-only queries and run fine inside a hook — the ALPM lock only gates
+# other transactions (pinned live with db.lck present). A target whose
+# versions can't be resolved (AUR package, a -U transaction installing a
+# local file, expac absent) reads as unknown and blocks conservatively —
+# a -U of a guard-listed package at repo-matching version would slip
+# through, which is accepted as pathological.
+#
+# Pacman feeds the matched package names on stdin (NeedsTargets).
+#
+# Test seams / overrides (env):
+# HYPR_GUARD_RUNNING 1/0 forces the running check (default: pgrep Hyprland)
+# HYPR_ALLOW_LIVE_UPDATE 1 proceeds anyway (skip the guard)
+# HYPR_GUARD_SENTINEL path whose existence also proceeds anyway
+# (default /run/archsetup-allow-live-gpu-update,
+# cleared on reboot since /run is tmpfs)
+# HYPR_GUARD_VERSIONS "pkg installed candidate" lines replacing the
+# pacman/expac lookups; when set, a package absent
+# from the map reads as unknown (blocks)
+
+set -u
+
+sentinel="${HYPR_GUARD_SENTINEL:-/run/archsetup-allow-live-gpu-update}"
+
+# Explicit override: the user knows what they're doing. The sentinel is
+# consumed as it's honored — one touch buys exactly one transaction, so a
+# leftover from a crashed caller can't keep the guard disarmed until
+# reboot.
+if [ "${HYPR_ALLOW_LIVE_UPDATE:-0}" = "1" ]; then
+ exit 0
+fi
+if [ -e "$sentinel" ]; then
+ rm -f "$sentinel" 2>/dev/null || true
+ exit 0
+fi
+
+hyprland_running() {
+ if [ -n "${HYPR_GUARD_RUNNING:-}" ]; then
+ [ "$HYPR_GUARD_RUNNING" = "1" ]
+ return
+ fi
+ pgrep -x Hyprland >/dev/null 2>&1
+}
+
+# No live session means no live swap to worry about. Let the upgrade run --
+# this is exactly the from-a-TTY-after-logout path the warning points to.
+hyprland_running || exit 0
+
+# Collect the triggering packages (stdin from NeedsTargets).
+targets=$(cat 2>/dev/null | sort -u)
+
+# "installed candidate" for a package, or nothing when unknown.
+versions_for() {
+ if [ -n "${HYPR_GUARD_VERSIONS+x}" ]; then
+ printf '%s\n' "$HYPR_GUARD_VERSIONS" \
+ | awk -v p="$1" '$1 == p { print $2, $3; exit }'
+ else
+ inst=$(pacman -Q -- "$1" 2>/dev/null | awk '{ print $2 }')
+ # expac prints one line per repo carrying the package; the first is
+ # the repo pacman resolves from
+ cand=$(expac -S '%v' -- "$1" 2>/dev/null | head -n 1)
+ printf '%s %s\n' "$inst" "$cand"
+ fi
+}
+
+# Split the targets into version-changing (dangerous live) and same-version
+# reinstalls (harmless). Unknown versions count as dangerous.
+changed=""
+count=0
+for pkg in $targets; do
+ count=$((count + 1))
+ # shellcheck disable=SC2046 # splitting "inst cand" into $1 $2 is the point
+ set -- $(versions_for "$pkg")
+ inst=${1:-}
+ cand=${2:-}
+ if [ -n "$inst" ] && [ -n "$cand" ] && [ "$inst" = "$cand" ]; then
+ continue # pure reinstall: identical bytes, no swap hazard
+ fi
+ changed="$changed $pkg"
+done
+
+# Every guarded target is a pure reinstall -- nothing changes on disk that
+# the live session has mapped. Let it through silently.
+if [ "$count" -gt 0 ] && [ -z "$changed" ]; then
+ exit 0
+fi
+
+# An empty target list shouldn't normally happen (the hook only fires when
+# dangerous targets exist); if Hyprland is up, stay safe and abort.
+pkg_lines=""
+for pkg in $changed; do
+ pkg_lines="${pkg_lines} - ${pkg}
+"
+done
+[ -n "$pkg_lines" ] || pkg_lines=" (GPU/compositor runtime libraries)
+"
+
+cat >&2 <<EOF
+
+==========================================================================
+ BLOCKED: live GPU/compositor library upgrade while Hyprland is running
+==========================================================================
+ Packages in this upgrade change version and can crash the running
+ compositor if swapped now:
+${pkg_lines}
+ Replacing these out from under a live Hyprland session makes the next
+ GPU-lib call hit a deleted library and SIGABRT, taking your Wayland apps
+ down with it (and risking an unclean shutdown).
+
+ Do it safely instead -- from a TTY with Hyprland stopped:
+ 1. Log out of Hyprland, or switch to a console (Ctrl+Alt+F2) and log in.
+ 2. Re-run the upgrade there: sudo pacman -Syu
+
+ To override and proceed anyway (not recommended while Hyprland runs):
+ sudo touch $sentinel && sudo pacman -Syu
+==========================================================================
+
+EOF
+exit 1
diff --git a/scripts/import-wireguard-configs.sh b/scripts/import-wireguard-configs.sh
new file mode 100755
index 0000000..9e42033
--- /dev/null
+++ b/scripts/import-wireguard-configs.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+# Import the assets/wireguard-config Proton configs into NetworkManager as
+# wireguard connections with autoconnect off. Two NM quirks handled here:
+#
+# - The import filename must be a valid interface name (<= 15 chars), and
+# several config names are longer — so every file imports through a temp
+# copy named wgpvpn.conf and the connection is renamed to the real config
+# name right after (by the UUID parsed from the import output, so a stray
+# same-named connection can't be hit). All profiles share the wgpvpn
+# interface, which is fine (they're mutually exclusive full-tunnel
+# configs), and the wg prefix keeps the net doctor's tunnel-down repair
+# on the NM path.
+# - `nmcli connection import` both sets autoconnect yes AND activates the
+# profile immediately. These are full-tunnel (AllowedIPs 0.0.0.0/0), so a
+# bare import silently brings the tunnel up and routes everything through it
+# — a VPN nobody asked for by importing a file. Each import is therefore
+# followed by a modify (autoconnect no) and a deactivate (connection down),
+# so importing only ever adds inactive profiles.
+#
+# A connection still literally named wgpvpn means an earlier run died
+# between import and rename — and it still has autoconnect on. The script
+# refuses to run until that's cleaned up rather than guessing.
+#
+# Idempotent: already-imported names skip.
+#
+# Usage: import-wireguard-configs.sh [config-dir]
+set -euo pipefail
+
+dir="${1:-$(cd "$(dirname "$0")/.." && pwd)/assets/wireguard-config}"
+[ -d "$dir" ] || { echo "no such config dir: $dir" >&2; exit 1; }
+
+if nmcli -t -f NAME connection show | grep -Fxq "wgpvpn"; then
+ echo "stale 'wgpvpn' connection found (an earlier run died mid-import; it has autoconnect ON)" >&2
+ echo "inspect and remove it first: nmcli connection delete wgpvpn" >&2
+ exit 1
+fi
+
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+shopt -s nullglob
+found=0
+for conf in "$dir"/*.conf; do
+ found=1
+ name="$(basename "$conf" .conf)"
+ if nmcli -t -f NAME connection show | grep -Fxq "$name"; then
+ echo "skip: $name (already imported)"
+ continue
+ fi
+ cp "$conf" "$tmp/wgpvpn.conf"
+ out="$(nmcli connection import type wireguard file "$tmp/wgpvpn.conf")"
+ uuid="$(grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' <<<"$out" | head -1 || true)"
+ if [ -z "$uuid" ]; then
+ echo "could not parse a UUID from the import output for $name:" >&2
+ echo " $out" >&2
+ exit 1
+ fi
+ nmcli connection modify "$uuid" connection.id "$name" \
+ connection.autoconnect no
+ # nmcli import auto-activates; bring it back down so importing never leaves
+ # a tunnel up. A profile that didn't activate makes this a harmless no-op.
+ nmcli connection down "$uuid" >/dev/null 2>&1 || true
+ echo "imported: $name (inactive, autoconnect off, iface wgpvpn)"
+done
+[ "$found" = 1 ] || { echo "no .conf files in $dir" >&2; exit 1; }
diff --git a/scripts/normalize-notify-sounds.sh b/scripts/normalize-notify-sounds.sh
index 52c1d36..72c4c33 100755
--- a/scripts/normalize-notify-sounds.sh
+++ b/scripts/normalize-notify-sounds.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Normalize notify sound files to a uniform RMS loudness so every notification
# plays at the same perceived level. Re-encodes each file in place (ogg -> ogg).
# Run once after adding or changing a sound in the notify set.
diff --git a/scripts/package-inventory b/scripts/package-inventory
index 2dda44b..0a4acf7 100755
--- a/scripts/package-inventory
+++ b/scripts/package-inventory
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# package-inventory - Compare archsetup packages vs live system
# Shows: packages in archsetup but missing from system,
# packages on system but not in archsetup
diff --git a/scripts/post-install.sh b/scripts/post-install.sh
index 9045398..f7dd206 100755
--- a/scripts/post-install.sh
+++ b/scripts/post-install.sh
@@ -1,4 +1,5 @@
#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
logfile="$HOME/post-install.log"
touch "$logfile"
diff --git a/scripts/setup-chess.sh b/scripts/setup-chess.sh
index 6ac8749..648eea9 100755
--- a/scripts/setup-chess.sh
+++ b/scripts/setup-chess.sh
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-3.0-or-later
set -euo pipefail
# En Croissant + lc0 + Maia + Stockfish setup script for Arch Linux.
diff --git a/scripts/testing/archsetup-test-zfs.conf b/scripts/testing/archsetup-test-zfs.conf
new file mode 100644
index 0000000..a5459cf
--- /dev/null
+++ b/scripts/testing/archsetup-test-zfs.conf
@@ -0,0 +1,21 @@
+# archsetup-test-zfs.conf - Archangel config for archsetup ZFS test VMs
+# Used by create-base-vm.sh (FS_PROFILE=zfs) for fully automated base VM creation
+#
+# Usage: archangel --config-file /root/archsetup-test.conf
+#
+# Note: User creation is handled by archsetup, not archangel.
+# See archsetup-vm.conf for archsetup configuration (shared across profiles -
+# archsetup detects ZFS from the live root, so it needs no filesystem setting).
+#
+# Unencrypted ZFS root: encryption isn't what the harness validates, and
+# NO_ENCRYPT=yes skips the passphrase prompt for a fully unattended install.
+
+FILESYSTEM=zfs
+HOSTNAME=archsetup-test
+TIMEZONE=America/Chicago
+LOCALE=en_US.UTF-8
+KEYMAP=us
+DISKS=/dev/vda
+NO_ENCRYPT=yes
+ROOT_PASSWORD=archsetup
+ENABLE_SSH=yes
diff --git a/scripts/testing/cleanup-tests.sh b/scripts/testing/cleanup-tests.sh
index 5c0153b..390d7e5 100755
--- a/scripts/testing/cleanup-tests.sh
+++ b/scripts/testing/cleanup-tests.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Clean up old test VMs and artifacts
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
diff --git a/scripts/testing/create-base-vm.sh b/scripts/testing/create-base-vm.sh
index 4ecf4d6..e626813 100755
--- a/scripts/testing/create-base-vm.sh
+++ b/scripts/testing/create-base-vm.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Create base VM for archsetup testing - Automated via Archangel ISO
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
@@ -19,10 +20,19 @@ source "$SCRIPT_DIR/lib/vm-utils.sh"
# Configuration
VM_IMAGES_DIR="$PROJECT_ROOT/vm-images"
-CONFIG_FILE="$SCRIPT_DIR/archsetup-test.conf"
LIVE_ISO_PASSWORD="archangel"
SNAPSHOT_NAME="clean-install"
+# FS_PROFILE (btrfs default / zfs) picks the archangel base-install config.
+# btrfs -> archsetup-test.conf, zfs -> archsetup-test-zfs.conf. The matching
+# base image name is derived from FS_PROFILE by init_vm_paths.
+FS_PROFILE="${FS_PROFILE:-btrfs}"
+if [ "$FS_PROFILE" = "btrfs" ]; then
+ CONFIG_FILE="$SCRIPT_DIR/archsetup-test.conf"
+else
+ CONFIG_FILE="$SCRIPT_DIR/archsetup-test-${FS_PROFILE}.conf"
+fi
+
# Initialize logging
mkdir -p "$PROJECT_ROOT/test-results"
LOGFILE="$PROJECT_ROOT/test-results/create-base-vm-$(date +'%Y%m%d-%H%M%S').log"
diff --git a/scripts/testing/debug-vm.sh b/scripts/testing/debug-vm.sh
index 32f377c..b0fa2b9 100755
--- a/scripts/testing/debug-vm.sh
+++ b/scripts/testing/debug-vm.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Launch VM for interactive debugging
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
diff --git a/scripts/testing/lib/logging.sh b/scripts/testing/lib/logging.sh
index ed20707..809d396 100755
--- a/scripts/testing/lib/logging.sh
+++ b/scripts/testing/lib/logging.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Logging utilities for archsetup testing
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
diff --git a/scripts/testing/lib/network-diagnostics.sh b/scripts/testing/lib/network-diagnostics.sh
index 674aeba..dc54334 100644
--- a/scripts/testing/lib/network-diagnostics.sh
+++ b/scripts/testing/lib/network-diagnostics.sh
@@ -1,62 +1,115 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Network diagnostics for VM testing
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
# Note: logging.sh and vm-utils.sh should already be sourced by the calling script
# Uses globals: ROOT_PASSWORD, SSH_PORT, SSH_OPTS, VM_IP (from vm-utils.sh or calling script)
+# Optional global: TEST_RESULTS_DIR (raw command outputs are saved there when set)
-# Run quick network diagnostics
+# Gather one read-only fact from the VM, print it, and save the raw output.
+# Facts are collected regardless of pass/fail so a failing install still leaves
+# the IP/route/resolver evidence in the log and the results dir.
+# $1 label human-readable label for the fact
+# $2 slug filename slug for the saved raw output
+# $3 cmd remote command to run over the shared ssh_base
+# Uses the caller's locals ssh_base and results_dir (dynamic scope).
+_netdiag_fact() {
+ local label="$1" slug="$2" cmd="$3" out
+ out="$($ssh_base "$cmd" 2>&1)"
+ info "${label}:"
+ printf '%s\n' "$out" | while IFS= read -r line; do
+ info " $line"
+ done
+ if [ -n "$results_dir" ]; then
+ printf '%s\n' "$out" > "$results_dir/netdiag-${slug}.txt" 2>/dev/null || true
+ fi
+}
+
+# Run quick network diagnostics.
+#
+# Evidence first: collect read-only facts (interfaces, route, resolver)
+# unconditionally, then run every reachability check and report all failures at
+# the end. A DNS failure is named as a DNS failure, not masked as a generic "no
+# internet" or misattributed to the Arch mirror. Returns 0 when all checks pass,
+# non-zero when any check fails, so callers keep their success/failure contract.
run_network_diagnostics() {
local password="${ROOT_PASSWORD:-archsetup}"
local port="${SSH_PORT:-22}"
local host="${VM_IP:-localhost}"
local ssh_base="sshpass -p $password ssh $SSH_OPTS -p $port root@$host"
+ local results_dir="${TEST_RESULTS_DIR:-}"
+ local failures=()
section "Pre-flight Network Diagnostics"
- # Test 1: Basic connectivity (use curl instead of ping - SLIRP may not handle ICMP)
- step "Testing internet connectivity"
- if $ssh_base "curl -s --connect-timeout 5 -o /dev/null http://archlinux.org" 2>/dev/null; then
- success "Internet connectivity OK"
- else
- error "No internet connectivity"
- return 1
- fi
+ # --- Phase 1: collect read-only facts, unconditionally ---
+ # These never gate the outcome; they exist so a failed install still has
+ # the interface/route/resolver evidence to diagnose from.
+ step "Collecting interface addresses"
+ _netdiag_fact "Interface addresses (ip -brief addr)" "ip-addr" "ip -brief addr"
+
+ step "Collecting default route"
+ _netdiag_fact "Default route (ip route show default)" "ip-route" "ip route show default"
- # Test 2: DNS resolution (use getent which is always available, unlike nslookup/dig)
+ step "Reading resolver configuration"
+ _netdiag_fact "Resolver (/etc/resolv.conf)" "resolv-conf" "cat /etc/resolv.conf"
+
+ # --- Phase 2: generic connectivity checks (run all, don't short-circuit) ---
+ # DNS, egress, and TLS are independent failure modes. Keeping them separate
+ # means a resolver problem reads as DNS, not as a downstream mirror failure.
step "Testing DNS resolution"
if $ssh_base "getent hosts archlinux.org >/dev/null 2>&1" 2>/dev/null; then
success "DNS resolution OK"
else
error "DNS resolution failed"
- return 1
+ failures+=("DNS resolution (getent hosts archlinux.org)")
fi
- # Test 3: Arch mirror accessibility
+ step "Testing HTTP egress"
+ if $ssh_base "curl -s --connect-timeout 5 -o /dev/null http://archlinux.org" 2>/dev/null; then
+ success "HTTP egress OK"
+ else
+ error "HTTP egress failed"
+ failures+=("HTTP egress (http://archlinux.org)")
+ fi
+
+ step "Testing TLS/HTTPS egress"
+ if $ssh_base "curl -s --connect-timeout 5 -o /dev/null https://archlinux.org" 2>/dev/null; then
+ success "TLS/HTTPS egress OK"
+ else
+ error "TLS/HTTPS egress failed"
+ failures+=("TLS/HTTPS egress (https://archlinux.org)")
+ fi
+
+ # --- Phase 3: Arch-specific checks (run all, don't short-circuit) ---
step "Testing Arch mirror access"
if $ssh_base "curl -s -I https://geo.mirror.pkgbuild.com/ | head -1 | grep -qE '(200|301|302)'" 2>/dev/null; then
success "Arch mirrors accessible"
else
error "Cannot reach Arch mirrors"
- return 1
+ failures+=("Arch mirror (https://geo.mirror.pkgbuild.com/)")
fi
- # Test 4: AUR accessibility
step "Testing AUR access"
if $ssh_base "curl -s -I https://aur.archlinux.org/ | head -1 | grep -qE '(200|405)'" 2>/dev/null; then
success "AUR accessible"
else
error "Cannot reach AUR"
- return 1
+ failures+=("AUR (https://aur.archlinux.org/)")
fi
- # Show network info
- info "Network configuration:"
- $ssh_base "ip addr show | grep 'inet ' | grep -v '127.0.0.1'" 2>/dev/null | while IFS= read -r line; do
- info " $line"
- done
+ # --- Summary: report every failure, not just the first ---
+ if [ ${#failures[@]} -eq 0 ]; then
+ success "Network diagnostics complete - all checks passed"
+ return 0
+ fi
- success "Network diagnostics complete"
- return 0
+ error "Network diagnostics found ${#failures[@]} failure(s):"
+ local f
+ for f in "${failures[@]}"; do
+ error " - $f"
+ done
+ return 1
}
diff --git a/scripts/testing/lib/testinfra.sh b/scripts/testing/lib/testinfra.sh
new file mode 100644
index 0000000..0822a9f
--- /dev/null
+++ b/scripts/testing/lib/testinfra.sh
@@ -0,0 +1,120 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Testinfra post-install validation sweep (runs on the host, over SSH).
+#
+# This is the primary post-install validator (it replaced the shell
+# run_all_validations sweep). It connects to the freshly-installed VM over SSH
+# and runs the pytest suite under scripts/testing/tests/. Its result drives the
+# run's pass/fail, and per-test failures are bucketed (archsetup / base_install
+# / unknown) into the same issue-attribution report the shell sweep produced.
+#
+# Auth: reuse the root key the harness already authorized (inject_root_key),
+# which survives the sshd prohibit-password hardening; mint our own only if the
+# harness didn't (standalone use). pytest connects key-only via a generated
+# ssh-config. Key + config live in the results dir and are discarded with it.
+#
+# Uses globals from run-test.sh / vm-utils.sh: SCRIPT_DIR, VM_IP, SSH_PORT,
+# ROOT_PASSWORD, ROOT_SSH_KEY, ARCHSETUP_VM_CONF, plus the validation.sh
+# helpers attribute_issue / VALIDATION_*. Toggle with RUN_TESTINFRA=false.
+
+# Record each pytest failure from the attribution file into the issue arrays
+# (validation.sh's attribute_issue), so generate_issue_report covers them.
+_testinfra_record_attribution() {
+ local file="$1" bucket=""
+ [ -f "$file" ] || return 0
+ while IFS= read -r line; do
+ case "$line" in
+ "[archsetup]") bucket=archsetup ;;
+ "[base_install]") bucket=base ;;
+ "[unknown]") bucket=unknown ;;
+ " "*) attribute_issue "testinfra: ${line# }" "$bucket" ;;
+ esac
+ done < "$file"
+}
+
+# run_testinfra_validation <results_dir>
+# Returns 0 only when the pytest sweep ran and passed. Returns non-zero when it
+# failed OR could not run (missing tooling / SSH setup) — a sweep that can't run
+# is not a pass. RUN_TESTINFRA=false is the one explicit opt-out (returns 0).
+run_testinfra_validation() {
+ local results_dir="$1"
+ local tests_dir="$SCRIPT_DIR/tests"
+ local key="$results_dir/testinfra_key"
+ local sshcfg="$results_dir/testinfra_ssh_config"
+
+ if [ "${RUN_TESTINFRA:-true}" != "true" ]; then
+ warn "RUN_TESTINFRA=false - skipping the Testinfra validation sweep"
+ return 0
+ fi
+ if ! command -v pytest >/dev/null 2>&1 || ! python3 -c 'import testinfra' >/dev/null 2>&1; then
+ error "Testinfra/pytest not installed on host - cannot validate (run: make deps)"
+ return 1
+ fi
+
+ section "Running Validation Checks (Testinfra)"
+
+ # Prefer the harness's already-authorized root key; mint one if absent.
+ if [ -n "${ROOT_SSH_KEY:-}" ] && [ -f "${ROOT_SSH_KEY}" ]; then
+ key="$ROOT_SSH_KEY"
+ else
+ rm -f "$key" "$key.pub"
+ if ! ssh-keygen -t ed25519 -N "" -q -f "$key"; then
+ error "testinfra: ssh-keygen failed"
+ return 1
+ fi
+ if ! copy_to_vm "$key.pub" "/tmp/testinfra_key.pub" "$ROOT_PASSWORD"; then
+ error "testinfra: pubkey copy failed"
+ return 1
+ fi
+ if ! vm_exec "$ROOT_PASSWORD" \
+ "mkdir -p /root/.ssh && chmod 700 /root/.ssh && cat /tmp/testinfra_key.pub >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys"; then
+ error "testinfra: authorizing key in VM failed"
+ return 1
+ fi
+ fi
+
+ # ssh-config so testinfra connects key-only, no host-key prompt.
+ cat > "$sshcfg" <<EOF
+Host testinfra-target
+ HostName ${VM_IP:-localhost}
+ Port ${SSH_PORT:-2222}
+ User root
+ IdentityFile $key
+ IdentitiesOnly yes
+ StrictHostKeyChecking no
+ UserKnownHostsFile /dev/null
+EOF
+
+ # The account archsetup created, for the tests that need it.
+ local test_user
+ test_user=$(sed -n 's/^USERNAME=//p' "$ARCHSETUP_VM_CONF" 2>/dev/null | head -n1)
+ : "${test_user:=cjennings}"
+
+ local logf="$results_dir/testinfra.log"
+ ARCHSETUP_TEST_USER="$test_user" pytest "$tests_dir" \
+ --hosts="ssh://testinfra-target" \
+ --ssh-config="$sshcfg" \
+ --attribution-file="$results_dir/testinfra-attribution.txt" \
+ -v >> "$logf" 2>&1
+ local rc=$?
+
+ # Surface pytest's counts through the shared validation counters so the
+ # issue report summary is meaningful (the shell sweep no longer runs).
+ local summary
+ summary=$(grep -oE '[0-9]+ (passed|failed|error|errors|skipped)' "$logf" | tail -10)
+ VALIDATION_PASSED=$(echo "$summary" | awk '/passed/{print $1}' | tail -1); VALIDATION_PASSED=${VALIDATION_PASSED:-0}
+ VALIDATION_WARNINGS=$(echo "$summary" | awk '/skipped/{print $1}' | tail -1); VALIDATION_WARNINGS=${VALIDATION_WARNINGS:-0}
+ local nfail nerr
+ nfail=$(echo "$summary" | awk '/failed/{print $1}' | tail -1); nfail=${nfail:-0}
+ nerr=$(echo "$summary" | awk '/error/{print $1}' | tail -1); nerr=${nerr:-0}
+ VALIDATION_FAILED=$((nfail + nerr))
+
+ if [ "$rc" -eq 0 ]; then
+ success "Testinfra validation passed ($VALIDATION_PASSED passed, $VALIDATION_WARNINGS skipped)"
+ else
+ error "Testinfra validation failed ($VALIDATION_FAILED failed/error; see testinfra.log)"
+ _testinfra_record_attribution "$results_dir/testinfra-attribution.txt"
+ fi
+ return "$rc"
+}
diff --git a/scripts/testing/lib/validation.sh b/scripts/testing/lib/validation.sh
index 91270ef..fa7ddcc 100644
--- a/scripts/testing/lib/validation.sh
+++ b/scripts/testing/lib/validation.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Validation utilities for archsetup testing
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
@@ -20,38 +21,7 @@ declare -a UNKNOWN_ISSUES
# SSH helper (uses globals: VM_IP, ROOT_PASSWORD)
ssh_cmd() {
sshpass -p "$ROOT_PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -o ConnectTimeout=10 -p "${SSH_PORT:-22}" "root@$VM_IP" "$@" 2>/dev/null
-}
-
-# Validation result helpers
-validation_pass() {
- local test_name="$1"
- success "$test_name"
- ((VALIDATION_PASSED++)) || true
-}
-
-validation_fail() {
- local test_name="$1"
- local details="${2:-}"
- error "$test_name"
- [ -n "$details" ] && info " Details: $details"
- ((VALIDATION_FAILED++)) || true
-}
-
-validation_warn() {
- local test_name="$1"
- local details="${2:-}"
- warn "$test_name"
- [ -n "$details" ] && info " Details: $details"
- ((VALIDATION_WARNINGS++)) || true
-}
-
-# A check whose precondition can't hold in this environment (headless VM,
-# slirp networking, pre-reboot state). Logged for the record, counted nowhere
-# — a warning that fires on every run trains readers to ignore warnings.
-validation_skip() {
- local test_name="$1"
- info "SKIP: $test_name"
+ -o ConnectTimeout=10 ${SSH_KEY_OPT:-} -p "${SSH_PORT:-22}" "root@$VM_IP" "$@" 2>/dev/null
}
# Attribute an issue to archsetup or base install
@@ -264,802 +234,6 @@ categorize_errors() {
}
#=============================================================================
-# VALIDATION CHECKS
-#=============================================================================
-
-run_all_validations() {
- section "Running Validation Checks"
-
- # User & Authentication
- validate_user_created
- validate_user_shell
- validate_user_groups
-
- # Dotfiles
- validate_dotfiles
-
- # Package Managers
- validate_yay_installed
- validate_pacman_working
-
- # Window Manager (detects DWM or Hyprland automatically)
- validate_window_manager
-
- # Essential Services
- validate_firewall
- validate_dns_config
- validate_avahi
- validate_fail2ban
- validate_networkmanager
-
- # Developer Tools
- validate_emacs
- validate_git_config
- validate_dev_tools
-
- # System Configuration
- validate_zfs_config
- validate_boot_config
- validate_autologin_config
- validate_gnome_keyring_setup
-
- # Boot & Initramfs (critical for ZFS systems)
- validate_terminus_font
- validate_mkinitcpio_hooks
- validate_initramfs_consolefont
- validate_nvme_module
-
- # Archsetup Specific
- validate_archsetup_log
- validate_state_markers
-}
-
-#-----------------------------------------------------------------------------
-# User & Authentication Validations
-#-----------------------------------------------------------------------------
-
-validate_user_created() {
- step "Checking if user 'cjennings' exists"
- if ssh_cmd "id cjennings" &>> "$LOGFILE"; then
- validation_pass "User cjennings exists"
- else
- validation_fail "User cjennings not found"
- attribute_issue "User cjennings not created" "archsetup"
- fi
-}
-
-validate_user_shell() {
- step "Checking if ZSH is default shell"
- local shell=$(ssh_cmd "getent passwd cjennings | cut -d: -f7")
- if [ "$shell" = "/bin/zsh" ] || [ "$shell" = "/usr/bin/zsh" ]; then
- validation_pass "ZSH is default shell"
- else
- validation_fail "ZSH not default shell (got: $shell)"
- attribute_issue "ZSH not set as default shell" "archsetup"
- fi
-}
-
-validate_user_groups() {
- step "Checking user group memberships"
- # Groups added by archsetup:
- # - wheel (useradd -G wheel)
- # - sys,adm,network,scanner,power,uucp,audio,lp,rfkill,video,storage,optical,users (usermod -aG)
- # - docker (gpasswd -a, added later in developer_workstation)
- local expected_groups="wheel sys adm network scanner power uucp audio lp rfkill video storage optical users docker"
- local missing_groups=""
-
- for group in $expected_groups; do
- if ! ssh_cmd "groups cjennings" | grep -q "\b$group\b"; then
- missing_groups="$missing_groups $group"
- fi
- done
-
- if [ -z "$missing_groups" ]; then
- validation_pass "User in all expected groups (15 groups)"
- else
- validation_fail "User missing groups:$missing_groups"
- attribute_issue "User missing groups:$missing_groups" "archsetup"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Dotfiles Validations
-#-----------------------------------------------------------------------------
-
-validate_dotfiles() {
- step "Checking dotfiles setup"
-
- # 1. Check if .zshrc is a symlink
- if ! ssh_cmd "test -L /home/cjennings/.zshrc"; then
- validation_fail "Dotfiles not stowed (.zshrc is not a symlink)"
- attribute_issue "Dotfiles stow failed" "archsetup"
- return 1
- fi
-
- # 2. Check symlink points to correct location. archsetup now clones the
- # dotfiles repo to ~/.dotfiles and stows from there (DOTFILES_DIR default).
- # Which tree owns .zshrc depends on DESKTOP_ENV: none stows the standalone
- # minimal/ tree; dwm and hyprland stow common/.
- local target=$(ssh_cmd "readlink /home/cjennings/.zshrc")
- local desktop_env=$(sed -n 's/^DESKTOP_ENV=//p' "$ARCHSETUP_VM_CONF" 2>/dev/null | head -n1)
- local expected_pattern=".dotfiles/common/.zshrc"
- [ "$desktop_env" = "none" ] && expected_pattern=".dotfiles/minimal/.zshrc"
-
- if ! echo "$target" | grep -q "$expected_pattern"; then
- validation_fail "Dotfiles symlink points to wrong location: $target"
- attribute_issue "Dotfiles symlink incorrect: $target" "archsetup"
- return 1
- fi
-
- # 3. Check the target file actually exists (not a broken symlink)
- if ! ssh_cmd "test -f /home/cjennings/.zshrc"; then
- validation_fail "Dotfiles symlink is broken (target doesn't exist)"
- ssh_cmd "ls -la /home/cjennings/.zshrc" >> "$LOGFILE" 2>&1
- attribute_issue "Dotfiles symlink broken" "archsetup"
- return 1
- fi
-
- # 4. Check user can actually read the file (not just root)
- local result=$(ssh_cmd "sudo -u cjennings cat /home/cjennings/.zshrc > /dev/null 2>&1 && echo OK || echo FAIL")
- if [ "$result" != "OK" ]; then
- validation_fail "Dotfiles not readable by user (permission issue)"
- ssh_cmd "ls -la /home/cjennings/.zshrc" >> "$LOGFILE" 2>&1
- attribute_issue "Dotfiles not readable by user" "archsetup"
- return 1
- fi
-
- validation_pass "Dotfiles configured correctly (symlink to $target, readable by user)"
-}
-
-#-----------------------------------------------------------------------------
-# Package Manager Validations
-#-----------------------------------------------------------------------------
-
-validate_yay_installed() {
- step "Checking if yay (AUR helper) is installed and functional"
-
- # Check binary exists
- if ! ssh_cmd "which yay" &>> "$LOGFILE"; then
- validation_fail "yay not found"
- attribute_issue "yay not installed" "archsetup"
- return 1
- fi
-
- # Check yay can query packages (functional test)
- if ssh_cmd "sudo -u cjennings yay -Qi yay" &>> "$LOGFILE"; then
- validation_pass "yay is installed and functional"
- else
- validation_fail "yay binary exists but query failed"
- attribute_issue "yay not functional" "archsetup"
- fi
-}
-
-validate_pacman_working() {
- step "Checking if pacman is functional"
- if ssh_cmd "pacman -Qi base" &>> "$LOGFILE"; then
- validation_pass "pacman is functional"
- else
- validation_fail "pacman query failed"
- attribute_issue "pacman not functional" "unknown"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Window Manager Validations
-#-----------------------------------------------------------------------------
-
-validate_suckless_tools() {
- step "Checking suckless tools (dwm, st, dmenu, slock)"
- local missing=""
-
- for tool in dwm st dmenu slock; do
- if ! ssh_cmd "test -f /usr/local/bin/$tool"; then
- missing="$missing $tool"
- fi
- done
-
- if [ -z "$missing" ]; then
- validation_pass "All suckless tools installed (dwm, st, dmenu, slock)"
- else
- validation_fail "Missing suckless tools:$missing"
- attribute_issue "Missing suckless tools:$missing" "archsetup"
- fi
-}
-
-validate_hyprland_tools() {
- step "Checking Hyprland tools"
- local missing=""
-
- # Check core Hyprland packages
- for pkg in hyprland hypridle hyprlock waybar fuzzel swww grim slurp gammastep foot; do
- if ! ssh_cmd "pacman -Q $pkg &>/dev/null"; then
- missing="$missing $pkg"
- fi
- done
-
- if [ -z "$missing" ]; then
- validation_pass "All Hyprland tools installed"
- else
- validation_fail "Missing Hyprland tools:$missing"
- attribute_issue "Missing Hyprland tools:$missing" "archsetup"
- fi
-}
-
-validate_hyprland_config() {
- step "Checking Hyprland configuration files"
- local missing=""
-
- for config in ".config/hypr/hyprland.conf" ".config/hypr/hypridle.conf" \
- ".config/hypr/hyprlock.conf" ".config/waybar/config" \
- ".config/fuzzel/fuzzel.ini" ".config/gammastep/config.ini"; do
- if ! ssh_cmd "test -f /home/cjennings/$config"; then
- missing="$missing $config"
- fi
- done
-
- if [ -z "$missing" ]; then
- validation_pass "All Hyprland config files present"
- else
- validation_fail "Missing Hyprland configs:$missing"
- attribute_issue "Missing Hyprland configs:$missing" "archsetup"
- fi
-}
-
-validate_hyprland_socket() {
- step "Checking Hyprland IPC socket"
- # The socket only exists while the compositor runs. In the headless test
- # VM nobody logs in graphically, so a missing socket with no Hyprland
- # process is the expected state, not a finding.
- if ssh_cmd "test -S /tmp/hypr/*/.socket.sock 2>/dev/null"; then
- validation_pass "Hyprland socket exists"
- elif ! ssh_cmd "pgrep -x Hyprland >/dev/null 2>&1"; then
- validation_skip "Hyprland not running (headless) — socket check not applicable"
- else
- validation_warn "Hyprland running but IPC socket not found"
- fi
-}
-
-validate_portal_dark_mode() {
- step "Checking Settings portal returns dark mode"
-
- # Check portals.conf exists and uses gtk for Settings
- local portals_conf="/home/cjennings/.config/xdg-desktop-portal/portals.conf"
- if ! ssh_cmd "test -f $portals_conf"; then
- validation_fail "portals.conf not found"
- attribute_issue "xdg-desktop-portal portals.conf missing" "archsetup"
- return 1
- fi
-
- local settings_backend=$(ssh_cmd "grep 'org.freedesktop.impl.portal.Settings' $portals_conf 2>/dev/null | cut -d= -f2")
- if [ "$settings_backend" = "none" ]; then
- validation_fail "Settings portal disabled (set to 'none')"
- attribute_issue "Settings portal disabled in portals.conf" "archsetup"
- return 1
- fi
-
- # Query the portal for color-scheme (requires portal services running)
- # Returns "v v u 1" for dark mode (1 = prefer-dark)
- local color_scheme=$(ssh_cmd "sudo -u cjennings busctl --user call org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop org.freedesktop.portal.Settings Read 'ss' 'org.freedesktop.appearance' 'color-scheme' 2>/dev/null | grep -o 'u [0-9]' | cut -d' ' -f2")
-
- if [ "$color_scheme" = "1" ]; then
- validation_pass "Settings portal returns dark mode (color-scheme=1)"
- elif [ -z "$color_scheme" ] && ! ssh_cmd "pgrep -x Hyprland >/dev/null 2>&1"; then
- # No compositor → no graphical session bus to query. A socket-activated
- # xdg-desktop-portal process can exist even headless, so the compositor
- # is the real precondition (same condition as the socket check). The
- # conf-file checks above already validated what install controls.
- validation_skip "No compositor running (headless) — portal query not applicable"
- elif [ -z "$color_scheme" ]; then
- validation_warn "Could not query Settings portal (portal may not be running)"
- else
- validation_fail "Settings portal not returning dark mode (color-scheme=$color_scheme, expected 1)"
- attribute_issue "Settings portal not configured for dark mode" "archsetup"
- fi
-}
-
-validate_window_manager() {
- # Detect which desktop environment is installed and validate accordingly
- if ssh_cmd "pacman -Q hyprland &>/dev/null"; then
- section "Hyprland Desktop Environment"
- validate_hyprland_tools
- validate_hyprland_config
- validate_hyprland_socket
- validate_portal_dark_mode
- elif ssh_cmd "test -f /usr/local/bin/dwm"; then
- section "DWM Desktop Environment"
- validate_suckless_tools
- else
- validation_warn "No window manager detected (DESKTOP_ENV=none?)"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Essential Services Validations
-#-----------------------------------------------------------------------------
-
-validate_firewall() {
- step "Checking if firewall (ufw) is enabled"
- local status=$(ssh_cmd "systemctl is-enabled ufw.service 2>/dev/null || echo disabled")
- if [ "$status" = "enabled" ]; then
- validation_pass "UFW firewall is enabled"
- else
- validation_fail "UFW firewall not enabled"
- attribute_issue "UFW not enabled" "archsetup"
- fi
-}
-
-validate_dns_config() {
- step "Checking DNS-over-TLS configuration"
- if ssh_cmd "grep -q 'DNS=.*#' /etc/systemd/resolved.conf 2>/dev/null"; then
- validation_pass "DNS-over-TLS configured"
- else
- validation_warn "DNS-over-TLS may not be configured"
- fi
-}
-
-validate_avahi() {
- step "Checking avahi-daemon status"
- local status=$(ssh_cmd "systemctl is-enabled avahi-daemon.service 2>/dev/null || echo disabled")
- if [ "$status" = "enabled" ]; then
- validation_pass "avahi-daemon is enabled"
-
- # Full-stack mDNS test: ping hostname.local. QEMU user-mode (slirp,
- # 10.0.2.x) doesn't pass multicast, so mDNS genuinely can't resolve
- # there — only run the ping on real networking.
- if ssh_cmd "ip -4 addr show" 2>/dev/null | grep -q "10\.0\.2\."; then
- validation_skip "mDNS ping not possible on slirp networking (no multicast)"
- else
- local hostname=$(ssh_cmd "hostname")
- if ssh_cmd "ping -c 1 -W 2 ${hostname}.local" &>> "$LOGFILE"; then
- validation_pass "mDNS working (${hostname}.local responds to ping)"
- else
- validation_warn "mDNS ping failed (avahi may need time to propagate)"
- fi
- fi
- else
- # This might be OK if avahi was pre-installed
- validation_warn "avahi-daemon not enabled (may have been pre-configured)"
- fi
-}
-
-validate_fail2ban() {
- step "Checking fail2ban status"
- local status=$(ssh_cmd "systemctl is-enabled fail2ban.service 2>/dev/null || echo disabled")
- if [ "$status" = "enabled" ]; then
- validation_pass "fail2ban is enabled"
- else
- validation_fail "fail2ban not enabled"
- attribute_issue "fail2ban not enabled" "archsetup"
- fi
-}
-
-validate_networkmanager() {
- step "Checking NetworkManager status"
- local status=$(ssh_cmd "systemctl is-enabled NetworkManager.service 2>/dev/null || echo disabled")
- if [ "$status" = "enabled" ]; then
- validation_pass "NetworkManager is enabled"
- # Functional test
- if ssh_cmd "nmcli general status" &>> "$LOGFILE"; then
- validation_pass "NetworkManager is functional"
- else
- validation_warn "NetworkManager enabled but not responding"
- fi
- else
- validation_fail "NetworkManager not enabled"
- attribute_issue "NetworkManager not enabled" "archsetup"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Service-Specific Validations
-#-----------------------------------------------------------------------------
-
-validate_all_services() {
- section "Service Validations"
-
- # Core services (always expected)
- validate_service "sshd" "enabled" "active"
- validate_service "systemd-resolved" "enabled" "active"
- validate_service "ufw" "enabled" "" # VM lacks iptables modules, can't be active
- validate_service "fail2ban" "enabled" "active"
- validate_service "NetworkManager" "enabled" "active"
- validate_service "rngd" "enabled" "active"
- validate_service "cronie" "enabled" ""
- validate_service "atd" "enabled" ""
-
- # Cron job: log cleanup
- step "Checking log-cleanup cron job"
- local crontab_entry=$(ssh_cmd "sudo -u cjennings crontab -l 2>/dev/null | grep log-cleanup")
- if [ -n "$crontab_entry" ]; then
- validation_pass "log-cleanup cron job installed"
- else
- validation_fail "log-cleanup cron job not in crontab"
- attribute_issue "log-cleanup cron job missing from user crontab" "archsetup"
- fi
-
- # Timer services
- validate_service "reflector.timer" "enabled" ""
- validate_service "paccache.timer" "enabled" ""
-
- # Optional services (warn if missing, don't fail)
- validate_service_optional "avahi-daemon" "enabled"
- validate_service_optional "bluetooth" "enabled"
- validate_service_optional "cups" "enabled"
- validate_service_optional "docker" "enabled"
- validate_service_optional "tailscaled" "enabled"
- # Syncthing uses user service (not system), check lingering is enabled.
- # test -e, not ls: ls prints the path on success, so the old capture held
- # "path\nyes" and could never equal "yes" — the check warned on every
- # run, even with lingering correctly enabled.
- step "Checking user lingering for syncthing"
- local linger_enabled=$(ssh_cmd "test -e /var/lib/systemd/linger/cjennings && echo yes || echo no")
- if [ "$linger_enabled" = "yes" ]; then
- validation_pass "User lingering enabled for syncthing user service"
- else
- validation_warn "User lingering not enabled (syncthing may not autostart)"
- fi
-
- # Filesystem-specific
- validate_zfs_services
- validate_btrfs_services
-
- # Functional tests
- validate_service_functions
-}
-
-validate_service() {
- local service="$1"
- local expected_enabled="$2" # "enabled" or ""
- local expected_active="$3" # "active" or ""
-
- step "Checking $service"
-
- if [ -n "$expected_enabled" ]; then
- local enabled=$(ssh_cmd "systemctl is-enabled $service 2>/dev/null || echo disabled")
- if [ "$enabled" = "enabled" ]; then
- validation_pass "$service is enabled"
- else
- validation_fail "$service not enabled (got: $enabled)"
- attribute_issue "$service not enabled" "archsetup"
- return 1
- fi
- fi
-
- if [ -n "$expected_active" ]; then
- local active=$(ssh_cmd "systemctl is-active $service 2>/dev/null || echo inactive")
- if [ "$active" = "active" ]; then
- validation_pass "$service is active"
- else
- validation_fail "$service not active (got: $active)"
- attribute_issue "$service not active" "archsetup"
- return 1
- fi
- fi
-
- return 0
-}
-
-validate_service_optional() {
- local service="$1"
- local expected_enabled="$2"
-
- step "Checking optional service: $service"
-
- local enabled=$(ssh_cmd "systemctl is-enabled $service 2>/dev/null || echo disabled")
- if [ "$enabled" = "enabled" ]; then
- validation_pass "$service is enabled"
- else
- validation_warn "$service not enabled (optional)"
- fi
-}
-
-validate_zfs_services() {
- # Only check if ZFS is installed
- if ! ssh_cmd "which zfs" &>> "$LOGFILE"; then
- return 0
- fi
-
- step "Checking ZFS-specific services"
-
- validate_service_optional "sanoid.timer" "enabled"
-
- # Check for zfs-scrub timer (pool name varies)
- local scrub_enabled
- scrub_enabled=$(ssh_cmd "systemctl list-unit-files 'zfs-scrub*' 2>/dev/null | grep -c enabled" | tr -d '[:space:]')
- scrub_enabled=${scrub_enabled:-0}
- if [ "$scrub_enabled" -gt 0 ]; then
- validation_pass "ZFS scrub timer enabled"
- else
- validation_warn "ZFS scrub timer not found"
- fi
-}
-
-validate_btrfs_services() {
- # Only check if btrfs root
- if ! ssh_cmd "mount | grep 'on / ' | grep -q btrfs"; then
- return 0
- fi
-
- step "Checking btrfs-specific services"
- validate_service_optional "grub-btrfsd" "enabled"
-}
-
-validate_service_functions() {
- section "Service Functional Tests"
-
- # UFW functional test
- # NOTE: VM environment lacks iptables kernel modules, so UFW cannot activate.
- # We only verify it's enabled; active status requires real hardware.
- step "Testing UFW functionality"
- local ufw_enabled
- ufw_enabled=$(ssh_cmd "systemctl is-enabled ufw.service 2>/dev/null || echo disabled")
- if [ "$ufw_enabled" = "enabled" ]; then
- validation_pass "UFW is enabled (activation requires iptables kernel modules)"
- else
- validation_fail "UFW not enabled"
- attribute_issue "UFW not enabled" "archsetup"
- fi
-
- # fail2ban functional test
- step "Testing fail2ban functionality"
- if ssh_cmd "fail2ban-client status" &>> "$LOGFILE"; then
- validation_pass "fail2ban is responding"
- else
- validation_fail "fail2ban not responding"
- attribute_issue "fail2ban not functioning" "archsetup"
- fi
-
- # DNS resolution test
- step "Testing DNS resolution"
- if ssh_cmd "resolvectl query archlinux.org" &>> "$LOGFILE"; then
- validation_pass "DNS resolution working"
- else
- validation_warn "DNS resolution test failed (may be network issue)"
- fi
-
- # Docker functional test (if enabled)
- if ssh_cmd "systemctl is-enabled docker" &>> "$LOGFILE"; then
- step "Testing Docker functionality"
- if ssh_cmd "docker info" &>> "$LOGFILE"; then
- validation_pass "Docker is responding"
- elif ! ssh_cmd "systemctl is-active --quiet docker"; then
- # archsetup enables docker for next boot (enable, not enable --now,
- # by design — the daemon is heavy). Validation runs pre-reboot, so
- # enabled-but-not-started is the correct installed state.
- validation_skip "Docker enabled but not started (starts on boot by design)"
- else
- validation_warn "Docker active but not responding"
- fi
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Developer Tools Validations
-#-----------------------------------------------------------------------------
-
-validate_emacs() {
- step "Checking if Emacs is installed"
- if ssh_cmd "which emacs" &>> "$LOGFILE"; then
- validation_pass "Emacs is installed"
-
- # Check if config exists
- if ssh_cmd "test -d /home/cjennings/.emacs.d"; then
- validation_pass "Emacs config directory exists"
-
- # Check user can access the directory
- local result
- result=$(ssh_cmd "sudo -u cjennings ls /home/cjennings/.emacs.d > /dev/null 2>&1 && echo OK || echo FAIL")
- if [ "$result" = "OK" ]; then
- validation_pass "Emacs config readable by user"
- else
- validation_fail "Emacs config not readable by user (permission issue)"
- attribute_issue "Emacs .emacs.d not readable by user" "archsetup"
- fi
- else
- validation_warn "Emacs config directory not found"
- fi
- else
- validation_fail "Emacs not found"
- attribute_issue "Emacs not installed" "archsetup"
- fi
-}
-
-validate_git_config() {
- step "Checking git installation"
- if ssh_cmd "which git" &>> "$LOGFILE"; then
- validation_pass "git is installed"
- else
- validation_fail "git not found"
- attribute_issue "git not installed" "archsetup"
- fi
-}
-
-validate_dev_tools() {
- step "Checking developer tools"
- local tools="python node npm go rustc"
- local missing=""
-
- for tool in $tools; do
- if ! ssh_cmd "which $tool" &>> "$LOGFILE"; then
- missing="$missing $tool"
- fi
- done
-
- if [ -z "$missing" ]; then
- validation_pass "Core dev tools installed"
- else
- validation_warn "Some dev tools missing:$missing"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# System Configuration Validations
-#-----------------------------------------------------------------------------
-
-validate_zfs_config() {
- step "Checking ZFS configuration (if applicable)"
- if ssh_cmd "which zfs" &>> "$LOGFILE"; then
- # ZFS is installed, check for sanoid
- if ssh_cmd "which sanoid" &>> "$LOGFILE"; then
- validation_pass "ZFS with sanoid detected"
- else
- validation_warn "ZFS detected but sanoid not installed"
- fi
- else
- info "ZFS not installed (non-ZFS system)"
- fi
-}
-
-validate_boot_config() {
- step "Checking GRUB configuration"
- if ssh_cmd "test -f /boot/grub/grub.cfg" &>> "$LOGFILE"; then
- validation_pass "GRUB config exists"
- else
- validation_warn "GRUB config not found (may use different bootloader)"
- fi
-}
-
-validate_terminus_font() {
- step "Checking terminus-font installation"
- if ssh_cmd "pacman -Q terminus-font" &>> "$LOGFILE"; then
- validation_pass "terminus-font package installed"
- else
- validation_fail "terminus-font package not installed"
- attribute_issue "terminus-font not installed via pacman" "archsetup"
- fi
-}
-
-validate_mkinitcpio_hooks() {
- step "Checking mkinitcpio HOOKS configuration"
- local hooks=$(ssh_cmd "grep '^HOOKS=' /etc/mkinitcpio.conf")
- local is_zfs=$(ssh_cmd "findmnt -n -o FSTYPE / 2>/dev/null")
-
- if [ "$is_zfs" = "zfs" ]; then
- # ZFS system: must use udev, not systemd
- if echo "$hooks" | grep -q '\budev\b'; then
- validation_pass "ZFS system uses udev hook (correct)"
- elif echo "$hooks" | grep -q '\bsystemd\b'; then
- validation_fail "ZFS system uses systemd hook (will break boot)"
- attribute_issue "mkinitcpio uses systemd hook on ZFS system" "archsetup"
- else
- validation_warn "Could not determine init hook type"
- fi
- else
- # Non-ZFS: systemd hook is fine
- if echo "$hooks" | grep -q '\bsystemd\b'; then
- validation_pass "Non-ZFS system uses systemd hook"
- elif echo "$hooks" | grep -q '\budev\b'; then
- validation_pass "Non-ZFS system uses udev hook"
- fi
- fi
-}
-
-validate_initramfs_consolefont() {
- step "Checking console font in initramfs"
- local font_in_initramfs=$(ssh_cmd "lsinitcpio /boot/initramfs-linux*.img 2>/dev/null | grep -c 'consolefont.psf\\|ter-'")
-
- if [ "${font_in_initramfs:-0}" -gt 0 ]; then
- validation_pass "Console font included in initramfs"
- else
- validation_warn "Console font may not be in initramfs"
- fi
-}
-
-validate_nvme_module() {
- step "Checking NVMe module configuration"
- local has_nvme=$(ssh_cmd "ls /dev/nvme* 2>/dev/null | head -1")
-
- if [ -n "$has_nvme" ]; then
- # System has NVMe drives
- local modules=$(ssh_cmd "grep '^MODULES=' /etc/mkinitcpio.conf")
- if echo "$modules" | grep -q 'nvme'; then
- validation_pass "NVMe module in mkinitcpio MODULES"
- else
- validation_warn "NVMe system but nvme not in MODULES (may cause slow boot)"
- fi
- else
- info "No NVMe drives detected"
- fi
-}
-
-validate_autologin_config() {
- step "Checking autologin configuration"
- if ssh_cmd "test -f /etc/systemd/system/getty@tty1.service.d/autologin.conf" &>> "$LOGFILE"; then
- validation_pass "Autologin configured"
- else
- info "Autologin not configured (may be intentional)"
- fi
-}
-
-validate_gnome_keyring_setup() {
- step "Checking gnome-keyring pre-configuration"
- local keyring_dir="/home/cjennings/.local/share/keyrings"
-
- # Check directory exists
- if ! ssh_cmd "test -d $keyring_dir"; then
- validation_fail "Keyring directory not created"
- attribute_issue "gnome-keyring directory not pre-created" "archsetup"
- return 1
- fi
-
- # Check directory permissions (should be 700)
- local perms=$(ssh_cmd "stat -c '%a' $keyring_dir")
- if [ "$perms" != "700" ]; then
- validation_fail "Keyring directory has wrong permissions: $perms (expected 700)"
- attribute_issue "gnome-keyring directory wrong permissions" "archsetup"
- return 1
- fi
-
- # Check ownership
- local owner=$(ssh_cmd "stat -c '%U' $keyring_dir")
- if [ "$owner" != "cjennings" ]; then
- validation_fail "Keyring directory owned by $owner (expected cjennings)"
- attribute_issue "gnome-keyring directory wrong ownership" "archsetup"
- return 1
- fi
-
- # Check default file exists and contains "login"
- local default_keyring=$(ssh_cmd "cat $keyring_dir/default 2>/dev/null")
- if [ "$default_keyring" != "login" ]; then
- validation_fail "Default keyring not set to 'login' (got: '$default_keyring')"
- attribute_issue "gnome-keyring default not set to login" "archsetup"
- return 1
- fi
-
- validation_pass "gnome-keyring pre-configured (default=login, dir=700)"
-}
-
-#-----------------------------------------------------------------------------
-# Archsetup-Specific Validations
-#-----------------------------------------------------------------------------
-
-validate_archsetup_log() {
- step "Checking archsetup log for errors"
- local error_count
- # Use grep -h to suppress filenames, then wc -l to count total matches
- error_count=$(ssh_cmd "grep -h '^Error:' /var/log/archsetup-*.log 2>/dev/null | wc -l" | tr -d '[:space:]')
- error_count=${error_count:-0}
-
- if [ "$error_count" = "0" ]; then
- validation_pass "No errors in archsetup log"
- else
- validation_fail "Found $error_count errors in archsetup log"
- attribute_issue "Errors in archsetup log: $error_count" "archsetup"
- fi
-}
-
-validate_state_markers() {
- step "Checking archsetup state markers"
- local state_count=$(ssh_cmd "ls /var/lib/archsetup/state/ 2>/dev/null | wc -l")
-
- if [ "$state_count" -ge 12 ]; then
- validation_pass "All 12 installation steps completed"
- else
- validation_warn "Only $state_count/12 steps completed"
- fi
-}
-
-#=============================================================================
# ISSUE REPORTING
#=============================================================================
@@ -1138,18 +312,3 @@ EOF
info "Issue report saved: $report_file"
}
-#=============================================================================
-# MAIN VALIDATION ENTRY POINT
-#=============================================================================
-
-run_full_validation() {
- local output_dir="$1"
- local archzfs_inbox="${2:-}"
-
- run_all_validations
- analyze_log_diff "$output_dir"
- generate_issue_report "$output_dir" "$archzfs_inbox"
-
- # Return success if no failures
- [ $VALIDATION_FAILED -eq 0 ]
-}
diff --git a/scripts/testing/lib/vm-utils.sh b/scripts/testing/lib/vm-utils.sh
index a8736a3..b85e773 100755
--- a/scripts/testing/lib/vm-utils.sh
+++ b/scripts/testing/lib/vm-utils.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# VM management utilities for archsetup testing (direct QEMU)
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
@@ -10,13 +11,26 @@
# VM configuration defaults
VM_CPUS="${VM_CPUS:-4}"
-VM_RAM="${VM_RAM:-4096}" # MB
+# 8 GiB headroom for AUR builds: makepkg runs -j$VM_CPUS, and parallel cc1plus
+# (~700 MB each on heavy C++ packages) OOM-killed under the old 4 GiB default.
+VM_RAM="${VM_RAM:-8192}" # MB
VM_DISK_SIZE="${VM_DISK_SIZE:-50}" # GB
+# Filesystem profile: selects which base image + archangel config the harness
+# targets. "btrfs" is the historical default (its image name stays unsuffixed
+# so existing base images keep working); "zfs" gets its own image, since the
+# two on-disk layouts can't share a disk. Honoured by init_vm_paths below.
+FS_PROFILE="${FS_PROFILE:-btrfs}"
+
# SSH configuration
SSH_PORT="${SSH_PORT:-2222}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"
ROOT_PASSWORD="${ROOT_PASSWORD:-archsetup}"
+# Set by inject_root_key once a root key is authorized in the VM. When set, the
+# ssh/scp helpers add "-i <key>" so they keep working after archsetup hardens
+# sshd to PermitRootLogin prohibit-password (which kills root *password* login
+# but still allows key auth). Left unquoted at use sites, like SSH_OPTS.
+SSH_KEY_OPT="${SSH_KEY_OPT:-}"
# OVMF firmware paths
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
@@ -36,9 +50,22 @@ init_vm_paths() {
local images_dir="${1:-$VM_IMAGES_DIR}"
[ -z "$images_dir" ] && fatal "VM_IMAGES_DIR not set"
+ case "$FS_PROFILE" in
+ btrfs|zfs) ;;
+ *) fatal "Invalid FS_PROFILE: $FS_PROFILE (must be 'btrfs' or 'zfs')" ;;
+ esac
+
VM_IMAGES_DIR="$images_dir"
- DISK_PATH="$VM_IMAGES_DIR/archsetup-base.qcow2"
- OVMF_VARS="$VM_IMAGES_DIR/OVMF_VARS.fd"
+ # btrfs keeps the legacy unsuffixed name; other profiles get a suffix so
+ # their images sit side by side without clobbering each other.
+ local img_suffix=""
+ [ "$FS_PROFILE" != "btrfs" ] && img_suffix="-$FS_PROFILE"
+ DISK_PATH="$VM_IMAGES_DIR/archsetup-base${img_suffix}.qcow2"
+ # Per-profile NVRAM: UEFI boot entries live here, outside the qcow2, so a
+ # disk-snapshot revert can't restore them. Sharing one file across profiles
+ # let a zfs run's ZFSBootMenu entries clobber the btrfs GRUB entry, leaving
+ # the btrfs base unbootable (no removable ESP fallback to recover from).
+ OVMF_VARS="$VM_IMAGES_DIR/OVMF_VARS${img_suffix}.fd"
PID_FILE="$VM_IMAGES_DIR/qemu.pid"
MONITOR_SOCK="$VM_IMAGES_DIR/qemu-monitor.sock"
SERIAL_LOG="$VM_IMAGES_DIR/qemu-serial.log"
@@ -350,7 +377,7 @@ wait_for_ssh() {
progress "Waiting for SSH on localhost:$SSH_PORT..."
while [ "$elapsed" -lt "$timeout" ]; do
- if sshpass -p "$password" ssh $SSH_OPTS -p "$SSH_PORT" root@localhost true 2>/dev/null; then
+ if sshpass -p "$password" ssh $SSH_OPTS $SSH_KEY_OPT -p "$SSH_PORT" root@localhost true 2>/dev/null; then
success "SSH is available"
return 0
fi
@@ -366,7 +393,7 @@ wait_for_ssh() {
vm_exec() {
local password="${1:-$ROOT_PASSWORD}"
shift
- sshpass -p "$password" ssh $SSH_OPTS \
+ sshpass -p "$password" ssh $SSH_OPTS $SSH_KEY_OPT \
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
-p "$SSH_PORT" root@localhost "$@" 2>> "$LOGFILE"
}
@@ -378,7 +405,7 @@ copy_to_vm() {
local password="${3:-$ROOT_PASSWORD}"
step "Copying $(basename "$local_file") to VM:$remote_path"
- if sshpass -p "$password" scp $SSH_OPTS -P "$SSH_PORT" \
+ if sshpass -p "$password" scp $SSH_OPTS $SSH_KEY_OPT -P "$SSH_PORT" \
"$local_file" "root@localhost:$remote_path" >> "$LOGFILE" 2>&1; then
success "File copied to VM"
return 0
@@ -395,7 +422,7 @@ copy_from_vm() {
local password="${3:-$ROOT_PASSWORD}"
step "Copying $remote_file from VM"
- if sshpass -p "$password" scp $SSH_OPTS -P "$SSH_PORT" \
+ if sshpass -p "$password" scp $SSH_OPTS $SSH_KEY_OPT -P "$SSH_PORT" \
"root@localhost:$remote_file" "$local_path" >> "$LOGFILE" 2>&1; then
success "File copied from VM"
return 0
@@ -404,3 +431,31 @@ copy_from_vm() {
return 1
fi
}
+
+# inject_root_key <key_path>
+# Authorize a throwaway root key over the initial password session and switch
+# all the helpers above to key auth (sets SSH_KEY_OPT + ROOT_SSH_KEY). Call once,
+# right after wait_for_ssh and before running archsetup: archsetup sets
+# PermitRootLogin prohibit-password and reloads sshd partway through, which kills
+# root *password* login. Without a key in place first, every SSH after that step
+# fails and the run aborts before any validation. Key auth survives the hardening.
+# Targets root@$VM_IP on $SSH_PORT so it works for both the local VM runner
+# (VM_IP=localhost, port 2222) and the bare-metal runner (VM_IP=host, port 22).
+inject_root_key() {
+ local key="$1"
+ rm -f "$key" "$key.pub"
+ if ! ssh-keygen -t ed25519 -N "" -q -f "$key"; then
+ warn "Root key generation failed - run may break at sshd hardening"
+ return 1
+ fi
+ if sshpass -p "$ROOT_PASSWORD" ssh $SSH_OPTS -p "$SSH_PORT" "root@${VM_IP:-localhost}" \
+ "mkdir -p /root/.ssh && chmod 700 /root/.ssh && cat >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys" \
+ < "$key.pub" >> "$LOGFILE" 2>&1; then
+ SSH_KEY_OPT="-i $key"
+ export ROOT_SSH_KEY="$key"
+ success "Root SSH key authorized (survives sshd prohibit-password hardening)"
+ return 0
+ fi
+ warn "Root key authorization failed - run may break at sshd hardening"
+ return 1
+}
diff --git a/scripts/testing/maint-scenarios/10-journal-vacuum.sh b/scripts/testing/maint-scenarios/10-journal-vacuum.sh
new file mode 100644
index 0000000..b61de49
--- /dev/null
+++ b/scripts/testing/maint-scenarios/10-journal-vacuum.sh
@@ -0,0 +1,30 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: the journal vacuum executes against live journald at the TOML floor.
+#
+# What this proves: the TOML edit reaches the remedy's argv (vacuum target
+# and warn threshold are the same key), journalctl --vacuum-size runs for
+# real against live journald, and the metric grades ok against the edited
+# floor afterwards.
+#
+# What it deliberately does NOT prove: size reduction. The key is int-GB and
+# bottoms out at 1, vacuum only touches archived files, and a VM-sized
+# journal never nears 1 GB — a genuine reduction test would need journald
+# rate-limit surgery plus ~1 GB of spam per run. A pre/post usage comparison
+# is also unsound here: journald keeps landing in-flight writes (the spam is
+# async, and every ssh hop logs), so usage can grow across a correct vacuum.
+SCENARIO_DESC="journal vacuum runs at the TOML size floor"
+SCENARIO_GROUP="logs"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "sed -i 's/^journal_disk_warn_gb *=.*/journal_disk_warn_gb = 1/' /root/.config/archsetup/maintenance-thresholds.toml" \
+ && mexec "for i in \$(seq 1 20); do head -c 900000 /dev/urandom | base64 | systemd-cat -t maint-scenario-spam; done; journalctl --rotate; journalctl --flush >/dev/null 2>&1 || true"
+}
+
+scenario_fix() {
+ mfix journal_vacuum
+}
+
+scenario_assert() {
+ massert_metric journal_disk ok
+}
diff --git a/scripts/testing/maint-scenarios/11-coredump-age-out.sh b/scripts/testing/maint-scenarios/11-coredump-age-out.sh
new file mode 100644
index 0000000..06d6fa0
--- /dev/null
+++ b/scripts/testing/maint-scenarios/11-coredump-age-out.sh
@@ -0,0 +1,21 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: coredump files beyond the forensic window are aged out.
+#
+# coredumpctl has no clean verb, so the remedy is a find -mtime +N -delete
+# over /var/lib/systemd/coredump. Break plants one file older than the
+# window and one fresh file; assert checks exactly the old one is gone.
+SCENARIO_DESC="aged coredump files removed, fresh ones kept"
+SCENARIO_GROUP="logs"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "mkdir -p /var/lib/systemd/coredump && touch -d '30 days ago' '/var/lib/systemd/coredump/core.maintscenario-old.0.zst' && touch '/var/lib/systemd/coredump/core.maintscenario-new.0.zst'"
+}
+
+scenario_fix() {
+ mfix coredump_clean
+}
+
+scenario_assert() {
+ mexec "test ! -e '/var/lib/systemd/coredump/core.maintscenario-old.0.zst' && test -e '/var/lib/systemd/coredump/core.maintscenario-new.0.zst'"
+}
diff --git a/scripts/testing/maint-scenarios/20-cache-keep3.sh b/scripts/testing/maint-scenarios/20-cache-keep3.sh
new file mode 100644
index 0000000..a9a6ee5
--- /dev/null
+++ b/scripts/testing/maint-scenarios/20-cache-keep3.sh
@@ -0,0 +1,24 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: paccache -rk3 trims an installed package's cache to 3 versions.
+#
+# paccache decides from filenames, so five fake versions of a package that
+# IS installed (bash) stand in for a fat cache. The bootstrap may leave a
+# real bash package in the cache too, so the assert uses the ordering
+# invariant rather than a count: the two newest fakes survive any keep-3
+# window that contains them, and the two oldest fall outside it whether or
+# not a real (newer) version is cached alongside.
+SCENARIO_DESC="pacman cache trimmed to three versions per package"
+SCENARIO_GROUP="packages"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "for v in 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5; do touch /var/cache/pacman/pkg/bash-\$v-1-x86_64.pkg.tar.zst; done; ls /var/cache/pacman/pkg/ | grep -c '^bash-'"
+}
+
+scenario_fix() {
+ mfix cache_clean
+}
+
+scenario_assert() {
+ mexec "test -e /var/cache/pacman/pkg/bash-1.0.5-1-x86_64.pkg.tar.zst && test -e /var/cache/pacman/pkg/bash-1.0.4-1-x86_64.pkg.tar.zst && test ! -e /var/cache/pacman/pkg/bash-1.0.2-1-x86_64.pkg.tar.zst && test ! -e /var/cache/pacman/pkg/bash-1.0.1-1-x86_64.pkg.tar.zst"
+}
diff --git a/scripts/testing/maint-scenarios/21-cache-uninstalled.sh b/scripts/testing/maint-scenarios/21-cache-uninstalled.sh
new file mode 100644
index 0000000..ea788e8
--- /dev/null
+++ b/scripts/testing/maint-scenarios/21-cache-uninstalled.sh
@@ -0,0 +1,20 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: paccache -ruk0 drops every cached version of uninstalled packages.
+#
+# Fake versions of a package that is NOT installed disappear entirely, while
+# an installed package's cached version survives the uninstalled-only pass.
+SCENARIO_DESC="cached versions of uninstalled packages dropped"
+SCENARIO_GROUP="packages"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "touch /var/cache/pacman/pkg/maintghostpkg-2.1-1-x86_64.pkg.tar.zst /var/cache/pacman/pkg/maintghostpkg-2.2-1-x86_64.pkg.tar.zst /var/cache/pacman/pkg/bash-9.9-1-x86_64.pkg.tar.zst"
+}
+
+scenario_fix() {
+ mfix cache_clean_uninstalled
+}
+
+scenario_assert() {
+ mexec "test ! -e /var/cache/pacman/pkg/maintghostpkg-2.1-1-x86_64.pkg.tar.zst && test ! -e /var/cache/pacman/pkg/maintghostpkg-2.2-1-x86_64.pkg.tar.zst && test -e /var/cache/pacman/pkg/bash-9.9-1-x86_64.pkg.tar.zst"
+}
diff --git a/scripts/testing/maint-scenarios/22-orphan-remove.sh b/scripts/testing/maint-scenarios/22-orphan-remove.sh
new file mode 100644
index 0000000..8412128
--- /dev/null
+++ b/scripts/testing/maint-scenarios/22-orphan-remove.sh
@@ -0,0 +1,22 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: a real orphan is created, detected, and removed by name.
+#
+# Installing `tree` --asdeps with no dependents makes it a true orphan
+# (pacman -Qtdq lists it). The remedy is item-based: maint fix orphan_remove
+# tree. Assert covers both the package state and the metric verdict.
+SCENARIO_DESC="orphan package removed via the item-based remedy"
+SCENARIO_GROUP="packages"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "pacman -S --noconfirm --asdeps tree && pacman -Qtdq | grep -qx tree"
+}
+
+scenario_fix() {
+ mfix orphan_remove tree
+}
+
+scenario_assert() {
+ mexec "! pacman -Qi tree >/dev/null 2>&1 && ! pacman -Qtdq | grep -qx tree" \
+ && massert_metric pkg_orphans ok
+}
diff --git a/scripts/testing/maint-scenarios/23-pacnew-delete.sh b/scripts/testing/maint-scenarios/23-pacnew-delete.sh
new file mode 100644
index 0000000..23cd8d1
--- /dev/null
+++ b/scripts/testing/maint-scenarios/23-pacnew-delete.sh
@@ -0,0 +1,22 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: a .pacnew file is deleted through the validated priv verb.
+#
+# pacdiff --output finds the planted file; the remedy takes the path as its
+# item and rm's it. A neighbouring non-pacnew file proves the verb's path
+# validation keeps the blast radius to the named file.
+SCENARIO_DESC="planted .pacnew removed by path"
+SCENARIO_GROUP="packages"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "cp /etc/pacman.conf /etc/pacman.conf.pacnew && pacdiff --output | grep -q pacman.conf.pacnew"
+}
+
+scenario_fix() {
+ mfix pacnew_delete /etc/pacman.conf.pacnew
+}
+
+scenario_assert() {
+ mexec "test ! -e /etc/pacman.conf.pacnew && test -e /etc/pacman.conf" \
+ && massert_metric pkg_pacnew ok
+}
diff --git a/scripts/testing/maint-scenarios/30-unit-reset.sh b/scripts/testing/maint-scenarios/30-unit-reset.sh
new file mode 100644
index 0000000..07eed53
--- /dev/null
+++ b/scripts/testing/maint-scenarios/30-unit-reset.sh
@@ -0,0 +1,28 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: a failed unit's state is reset.
+#
+# A transient oneshot that exits 1 lands in systemctl --failed; the remedy
+# resets it and the failed list returns to its pre-break size. The unit name
+# rides the remedy's item argument, exercising the priv verb's unit-name
+# validation. The base image carries its own failed unit (grub-btrfsd — no
+# snapshot dirs in the VM), so the assert scopes to this scenario's unit and
+# the count delta, never the absolute failed list.
+SCENARIO_DESC="failed unit reset clears the failed list"
+SCENARIO_GROUP="systemd"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ PRE_FAILED_COUNT=$(mexec "systemctl --failed --no-legend | wc -l") \
+ && mexec "systemd-run --unit=maint-scenario-fail --service-type=oneshot --no-block /bin/false; sleep 2; systemctl is-failed maint-scenario-fail.service"
+}
+
+scenario_fix() {
+ mfix unit_reset maint-scenario-fail.service
+}
+
+scenario_assert() {
+ local pre="$PRE_FAILED_COUNT"
+ unset PRE_FAILED_COUNT # don't leak scenario state past this scenario
+ mexec "! systemctl is-failed maint-scenario-fail.service >/dev/null 2>&1" \
+ && [ "$(mexec "systemctl --failed --no-legend | wc -l")" = "$pre" ]
+}
diff --git a/scripts/testing/maint-scenarios/31-fstrim-enable.sh b/scripts/testing/maint-scenarios/31-fstrim-enable.sh
new file mode 100644
index 0000000..2d548a1
--- /dev/null
+++ b/scripts/testing/maint-scenarios/31-fstrim-enable.sh
@@ -0,0 +1,22 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: fstrim.timer enabled and started from the disabled default.
+#
+# The base image ships util-linux with fstrim.timer disabled — the exact
+# state the metric flags. The remedy is systemctl enable --now.
+SCENARIO_DESC="fstrim.timer enabled and started"
+SCENARIO_GROUP="systemd"
+SCENARIO_PROFILES="btrfs"
+
+scenario_break() {
+ # Default state, but pin it so the scenario stays honest if a future
+ # base image enables the timer.
+ mexec "systemctl disable --now fstrim.timer >/dev/null 2>&1 || true; ! systemctl is-enabled fstrim.timer >/dev/null 2>&1"
+}
+
+scenario_fix() {
+ mfix fstrim_enable
+}
+
+scenario_assert() {
+ mexec "systemctl is-enabled fstrim.timer && systemctl is-active fstrim.timer"
+}
diff --git a/scripts/testing/maint-scenarios/32-cron-enable.sh b/scripts/testing/maint-scenarios/32-cron-enable.sh
new file mode 100644
index 0000000..39d4de6
--- /dev/null
+++ b/scripts/testing/maint-scenarios/32-cron-enable.sh
@@ -0,0 +1,20 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: cronie enabled and started (the stopped-cron posture).
+#
+# Bootstrap installs cronie but never enables it — the broken state is the
+# package's default. The remedy enables + starts the service.
+SCENARIO_DESC="cronie enabled and started from the stopped state"
+SCENARIO_GROUP="systemd"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "systemctl disable --now cronie.service >/dev/null 2>&1 || true; ! systemctl is-active cronie.service >/dev/null 2>&1"
+}
+
+scenario_fix() {
+ mfix cron_enable
+}
+
+scenario_assert() {
+ mexec "systemctl is-enabled cronie.service && systemctl is-active cronie.service"
+}
diff --git a/scripts/testing/net-scenarios/10-nm-masked.sh b/scripts/testing/net-scenarios/10-nm-masked.sh
new file mode 100644
index 0000000..d3b382b
--- /dev/null
+++ b/scripts/testing/net-scenarios/10-nm-masked.sh
@@ -0,0 +1,29 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-net-scenarios.sh
+# Scenario: a masked NetworkManager is unmasked and started.
+#
+# Masking NetworkManager makes the generic "isn't running" verdict useless —
+# systemctl can't start a masked unit — so the doctor must name the mask and
+# unmask first. Break by masking; the fix's chain should unmask then start.
+SCENARIO_DESC="masked NetworkManager is unmasked and started"
+SCENARIO_GROUP="control-plane"
+
+scenario_break() {
+ nexec "systemctl mask NetworkManager"
+}
+
+# The verdict the read-only diagnose should produce before any fix runs.
+scenario_diagnose_expect() {
+ ndoctor_json ".outcome" | grep -qx fixable \
+ && ndoctor_json '.report.control_plane.nm_state' | grep -qx masked
+}
+
+scenario_fix() {
+ nfix # net doctor --fix
+}
+
+scenario_assert() {
+ # Unmasked (the state flip, testable anywhere) AND active (needs a real
+ # NetworkManager, so a container without netlink can only show the unmask).
+ ! nexec "systemctl is-enabled NetworkManager | grep -qx masked" \
+ && nexec "systemctl is-active NetworkManager >/dev/null"
+}
diff --git a/scripts/testing/net-scenarios/20-rival-manager.sh b/scripts/testing/net-scenarios/20-rival-manager.sh
new file mode 100644
index 0000000..0d7836b
--- /dev/null
+++ b/scripts/testing/net-scenarios/20-rival-manager.sh
@@ -0,0 +1,27 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-net-scenarios.sh
+# Scenario: a rival network manager active alongside NetworkManager is disabled.
+#
+# dhcpcd (or systemd-networkd / iwd) running next to NM fights it for the link.
+# Break by enabling dhcpcd; the fix disables it, and the chain resets to
+# reconnect. Requires dhcpcd installed in the target.
+SCENARIO_DESC="an active rival manager (dhcpcd) is disabled"
+SCENARIO_GROUP="control-plane"
+
+scenario_break() {
+ nexec "systemctl enable --now dhcpcd"
+}
+
+scenario_diagnose_expect() {
+ # The verdict fires only when the link is also failing; in a fresh target
+ # with no configured uplink that holds, so the rival should be named.
+ ndoctor_json ".report.control_plane.rivals | index(\"dhcpcd\")" \
+ | grep -vqx null
+}
+
+scenario_fix() {
+ nfix
+}
+
+scenario_assert() {
+ ! nexec "systemctl is-active dhcpcd >/dev/null 2>&1"
+}
diff --git a/scripts/testing/net-scenarios/30-keyfile-perms.sh b/scripts/testing/net-scenarios/30-keyfile-perms.sh
new file mode 100644
index 0000000..8149f87
--- /dev/null
+++ b/scripts/testing/net-scenarios/30-keyfile-perms.sh
@@ -0,0 +1,30 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-net-scenarios.sh
+# Scenario: a profile keyfile with unsafe permissions is set back to 0600 root.
+#
+# NetworkManager silently ignores a world-readable keyfile, so the profile
+# never activates. Break by chmod 0644 on an existing profile's keyfile; the
+# fix chmods it back to 0600 root. The doctor must run as root to read the
+# keyfile dir, so this scenario runs the doctor with sudo. Requires at least
+# one saved profile in the target (NET_SCENARIO_PROFILE names it).
+SCENARIO_DESC="an unsafe-perms profile keyfile is restored to 0600 root"
+SCENARIO_GROUP="control-plane"
+
+_keyfile() {
+ echo "/etc/NetworkManager/system-connections/${NET_SCENARIO_PROFILE:?set NET_SCENARIO_PROFILE to a saved profile name}.nmconnection"
+}
+
+scenario_break() {
+ nexec "chmod 0644 '$(_keyfile)'"
+}
+
+scenario_diagnose_expect() {
+ ndoctor_json ".report.control_plane.keyfile.perms_ok" | grep -qx false
+}
+
+scenario_fix() {
+ nfix
+}
+
+scenario_assert() {
+ nexec "[ \"\$(stat -c '%a %U' '$(_keyfile)')\" = '600 root' ]"
+}
diff --git a/scripts/testing/run-maint-nspawn.sh b/scripts/testing/run-maint-nspawn.sh
new file mode 100644
index 0000000..04d4a32
--- /dev/null
+++ b/scripts/testing/run-maint-nspawn.sh
@@ -0,0 +1,268 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
+# Run pacman-level maint remedy scenarios in a systemd-nspawn container
+# Author: Craig Jennings <craigmartinjennings@gmail.com>
+# License: GNU GPLv3
+#
+# The fast lane for the maint scenario suite: pure pacman-level cases (the
+# "packages" scenario group — cache trims, orphan removal, pacnew deletion)
+# don't need a booted machine, so they run in seconds against a pacstrap'd
+# rootfs instead of minutes against the VM. Everything else (systemd, logs)
+# needs pid 1 and journald — that stays in run-maint-scenarios.sh.
+#
+# The scenario files are shared with the VM runner and see the same helpers
+# (mexec/mmaint/mfix/massert_metric); only the transport differs — the
+# helper trio is deliberately duplicated between the two runners rather
+# than abstracted over ssh-vs-nspawn.
+#
+# The rootfs is cached at $NSPAWN_ROOT and rebuilt with --fresh. Building
+# and running need root (pacstrap, systemd-nspawn) via sudo.
+#
+# Usage: run-maint-nspawn.sh [--list] [--fresh]
+# --list print the validated scenario plan and exit (no root needed)
+# --fresh delete the cached rootfs and pacstrap a new one
+# Env: MAINT_SCENARIO_DIR scenario dir (default: maint-scenarios/)
+# MAINT_SRC maint package source tree
+# (default: ~/.dotfiles/maint/src/maint)
+# NSPAWN_ROOT rootfs dir (default: /var/tmp/archsetup-maint-nspawn)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+source "$SCRIPT_DIR/lib/logging.sh"
+
+SCENARIO_DIR="${MAINT_SCENARIO_DIR:-$SCRIPT_DIR/maint-scenarios}"
+MAINT_SRC="${MAINT_SRC:-$HOME/.dotfiles/maint/src/maint}"
+THRESHOLDS_TOML="$PROJECT_ROOT/configs/maintenance-thresholds.toml"
+NSPAWN_ROOT="${NSPAWN_ROOT:-/var/tmp/archsetup-maint-nspawn}"
+NSPAWN_GROUP="packages"
+
+LIST_ONLY=false
+FRESH=false
+
+usage() {
+ echo "Usage: $0 [--list] [--fresh]"
+ echo " --list print the validated scenario plan and exit"
+ echo " --fresh rebuild the cached rootfs from scratch"
+ echo "Env: MAINT_SCENARIO_DIR, MAINT_SRC, NSPAWN_ROOT"
+}
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --list) LIST_ONLY=true; shift ;;
+ --fresh) FRESH=true; shift ;;
+ *) usage; exit 1 ;;
+ esac
+done
+
+# ─── Plan: the pacman-level subset ───────────────────────────────────
+# Same contract validation as the VM runner; the selection rule is the
+# group name — "packages" is pacman-level by construction.
+
+S_FILES=() S_NAMES=() S_DESCS=()
+
+_scenario_var() { # <file> <varname>
+ bash -c 'set -eu; source "$1" || exit 9; eval "printf %s \"\${$2-}\""' \
+ _probe "$1" "$2" 2>/dev/null
+}
+
+_validate_scenario() { # <file> -> fatal on contract violation
+ local f="$1" base probe_err
+ base=$(basename "$f")
+ probe_err=$(bash -c 'set -eu; source "$1"
+ : "${SCENARIO_DESC:?missing SCENARIO_DESC}"
+ : "${SCENARIO_GROUP:?missing SCENARIO_GROUP}"
+ : "${SCENARIO_PROFILES:?missing SCENARIO_PROFILES}"
+ for p in $SCENARIO_PROFILES; do
+ case "$p" in btrfs|zfs|any) ;; *)
+ echo "bad profile token: $p" >&2; exit 1 ;;
+ esac
+ done
+ declare -f scenario_break scenario_fix scenario_assert >/dev/null \
+ || { echo "missing scenario_break/fix/assert" >&2; exit 1; }' \
+ _probe "$f" 2>&1 >/dev/null) \
+ || fatal "scenario contract violation in $base: $probe_err"
+}
+
+build_plan() {
+ [ -d "$SCENARIO_DIR" ] || fatal "scenario dir not found: $SCENARIO_DIR"
+ local f base
+ for f in "$SCENARIO_DIR"/*.sh; do
+ [ -e "$f" ] || break
+ _validate_scenario "$f"
+ [ "$(_scenario_var "$f" SCENARIO_GROUP)" = "$NSPAWN_GROUP" ] || continue
+ base=$(basename "$f" .sh)
+ S_FILES+=("$f")
+ S_NAMES+=("${base#[0-9][0-9]-}")
+ S_DESCS+=("$(_scenario_var "$f" SCENARIO_DESC)")
+ done
+ [ ${#S_FILES[@]} -gt 0 ] \
+ || fatal "no '$NSPAWN_GROUP'-group scenarios in $SCENARIO_DIR"
+}
+
+print_plan() {
+ local i
+ echo "maint nspawn scenario plan (group: $NSPAWN_GROUP)"
+ for i in "${!S_FILES[@]}"; do
+ echo " ${S_NAMES[$i]} — ${S_DESCS[$i]}"
+ done
+}
+
+if [ "$LIST_ONLY" = "true" ]; then
+ LOGFILE=/dev/null
+ build_plan
+ print_plan
+ exit 0
+fi
+
+# ─── Run ─────────────────────────────────────────────────────────────
+
+TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
+RESULTS_DIR="$PROJECT_ROOT/test-results/maint-nspawn-$TIMESTAMP"
+mkdir -p "$RESULTS_DIR"
+LOGFILE="$RESULTS_DIR/scenarios.log"
+init_logging "$LOGFILE"
+
+build_plan
+
+section "Maint Remedy Scenarios (nspawn): $TIMESTAMP"
+info "Rootfs: $NSPAWN_ROOT"
+info "Maint source: $MAINT_SRC"
+print_plan | tee -a "$LOGFILE"
+
+command -v pacstrap >/dev/null \
+ || fatal "pacstrap not found — pacman -S arch-install-scripts"
+command -v systemd-nspawn >/dev/null || fatal "systemd-nspawn not found"
+[ -d "$MAINT_SRC" ] || fatal "maint source tree not found: $MAINT_SRC"
+[ -f "$THRESHOLDS_TOML" ] || fatal "thresholds TOML not found: $THRESHOLDS_TOML"
+sudo -n true 2>/dev/null || fatal "needs passwordless sudo (pacstrap/nspawn)"
+
+# ─── Target helpers (available to scenario scripts) ──────────────────
+
+mexec() { # run a command in the container as root
+ sudo systemd-nspawn -q -D "$NSPAWN_ROOT" --pipe \
+ /bin/bash -c "$*" 2>> "$LOGFILE"
+}
+
+mmaint() {
+ mexec "cd /root/maint-pkg && MAINT_SUDO= PYTHONPATH=/root/maint-pkg python3 -m maint $*"
+}
+
+mfix() {
+ local id="$1"; shift || true
+ step "maint fix $id ${*:+$* }--dry-run"
+ mmaint fix "$id" "$@" --dry-run >> "$LOGFILE" 2>&1 \
+ || { error "dry-run failed: maint fix $id $*"; return 1; }
+ step "maint fix $id $*"
+ mmaint fix "$id" "$@" 2>&1 | tee -a "$LOGFILE"
+ return "${PIPESTATUS[0]}"
+}
+
+massert_metric() { # <metric-id> <expected-severity>
+ local mid="$1" want="$2" got
+ got=$(mmaint status --json | python3 -c "
+import json, sys
+env = json.load(sys.stdin)
+ms = [m for m in env['metrics'] if m['id'] == '$mid']
+print(ms[0]['severity'] if ms else 'MISSING')
+")
+ if [ "$got" = "$want" ]; then
+ success "metric $mid is $want"
+ return 0
+ fi
+ error "metric $mid: expected $want, got $got"
+ return 1
+}
+
+# ─── Rootfs ──────────────────────────────────────────────────────────
+
+if [ "$FRESH" = "true" ] && [ -d "$NSPAWN_ROOT" ]; then
+ step "Removing cached rootfs (--fresh)"
+ sudo rm -rf "$NSPAWN_ROOT"
+fi
+
+if [ ! -x "$NSPAWN_ROOT/usr/bin/python3" ]; then
+ section "Building rootfs (pacstrap)"
+ sudo mkdir -p "$NSPAWN_ROOT"
+ # shellcheck disable=SC2024 # the log is user-owned by design
+ sudo pacstrap -c -K "$NSPAWN_ROOT" base python pacman-contrib expac \
+ >> "$LOGFILE" 2>&1 || fatal "pacstrap failed (see $LOGFILE)"
+ success "Rootfs built"
+else
+ info "Reusing cached rootfs (--fresh to rebuild)"
+fi
+
+section "Installing maint tree + thresholds into the container"
+sudo rm -rf "$NSPAWN_ROOT/root/maint-pkg"
+sudo mkdir -p "$NSPAWN_ROOT/root/maint-pkg" "$NSPAWN_ROOT/root/.config/archsetup"
+sudo cp -r "$MAINT_SRC" "$NSPAWN_ROOT/root/maint-pkg/"
+sudo find "$NSPAWN_ROOT/root/maint-pkg" -name '__pycache__' -type d \
+ -exec rm -rf {} + 2>/dev/null || true
+sudo cp "$THRESHOLDS_TOML" \
+ "$NSPAWN_ROOT/root/.config/archsetup/maintenance-thresholds.toml"
+
+step "Smoke: maint status --json runs in the container"
+mmaint status --json > "$RESULTS_DIR/bootstrap-status.json" \
+ || fatal "maint status failed in the container"
+success "Container ready"
+
+# ─── Execution ───────────────────────────────────────────────────────
+
+PASS=() FAIL=()
+
+run_scenario() { # <index>
+ local i="$1" name="${S_NAMES[$1]}" f="${S_FILES[$1]}"
+ section "Scenario: $name — ${S_DESCS[$1]}"
+ # shellcheck disable=SC1090
+ source "$f"
+ local ok=true
+ step "break"
+ if ! scenario_break; then
+ error "$name: break step failed"
+ ok=false
+ fi
+ if [ "$ok" = "true" ]; then
+ step "fix"
+ if ! scenario_fix; then
+ error "$name: fix step failed"
+ ok=false
+ fi
+ fi
+ if [ "$ok" = "true" ]; then
+ step "assert"
+ if ! scenario_assert; then
+ error "$name: post-state assertion failed"
+ ok=false
+ fi
+ fi
+ unset -f scenario_break scenario_fix scenario_assert
+ unset SCENARIO_DESC SCENARIO_GROUP SCENARIO_PROFILES
+ if [ "$ok" = "true" ]; then
+ success "PASS: $name"
+ PASS+=("$name")
+ else
+ error "FAIL: $name"
+ FAIL+=("$name")
+ fi
+}
+
+for i in "${!S_FILES[@]}"; do
+ run_scenario "$i"
+done
+
+# ─── Report ──────────────────────────────────────────────────────────
+
+section "Results"
+for n in "${PASS[@]}"; do success "PASS $n"; done
+for n in "${FAIL[@]}"; do error "FAIL $n"; done
+info "Log: $LOGFILE"
+info "Rootfs kept for reuse: $NSPAWN_ROOT (--fresh to rebuild)"
+
+if [ ${#FAIL[@]} -gt 0 ]; then
+ error "${#FAIL[@]} scenario(s) failed, ${#PASS[@]} passed"
+ exit 1
+fi
+success "All ${#PASS[@]} scenarios passed"
+exit 0
diff --git a/scripts/testing/run-maint-scenarios.sh b/scripts/testing/run-maint-scenarios.sh
new file mode 100644
index 0000000..448f6d9
--- /dev/null
+++ b/scripts/testing/run-maint-scenarios.sh
@@ -0,0 +1,374 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
+# Run maintenance-console remedy scenarios in the test VM
+# Author: Craig Jennings <craigmartinjennings@gmail.com>
+# License: GNU GPLv3
+#
+# Layer 3 of the maint test strategy: each scenario breaks the VM in a known
+# way over ssh, runs the real `maint fix <id>` inside the VM, and asserts the
+# post-state. Scenarios live in maint-scenarios/*.sh and declare a GROUP;
+# scenarios in one group are non-conflicting and share a VM boot, and a
+# stop -> restore -> boot cycle runs only between groups (snapshot ops need
+# the VM stopped). A scenario that fails may leave residue behind for its
+# in-group successors -- the group restore boundary contains it, so read a
+# multi-failure group front to back.
+#
+# Boot plan:
+# 1. restore clean-install, boot, bootstrap (packages + maint tree + TOML)
+# 2. stop, snapshot maint-ready
+# 3. per group: boot, run scenarios, stop, restore maint-ready
+# 4. restore clean-install, delete maint-ready (base stays pristine)
+#
+# Usage: run-maint-scenarios.sh [--list] [--group NAME] [--keep]
+# --list print the validated batch plan and exit (no VM, no KVM needed)
+# --group run (or list) only the named group
+# --keep keep the VM running in its post-run state; skip the restores
+# Env: FS_PROFILE=btrfs|zfs base image + scenario profile filter
+# MAINT_SCENARIO_DIR scenario dir (default: maint-scenarios/)
+# MAINT_SRC maint package source tree
+# (default: ~/.dotfiles/maint/src/maint)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+source "$SCRIPT_DIR/lib/logging.sh"
+source "$SCRIPT_DIR/lib/vm-utils.sh"
+
+SCENARIO_DIR="${MAINT_SCENARIO_DIR:-$SCRIPT_DIR/maint-scenarios}"
+MAINT_SRC="${MAINT_SRC:-$HOME/.dotfiles/maint/src/maint}"
+THRESHOLDS_TOML="$PROJECT_ROOT/configs/maintenance-thresholds.toml"
+ROOT_PASSWORD="archsetup"
+READY_SNAPSHOT="maint-ready"
+
+LIST_ONLY=false
+ONLY_GROUP=""
+KEEP_VM=false
+
+usage() {
+ echo "Usage: $0 [--list] [--group NAME] [--keep]"
+ echo " --list print the validated scenario plan and exit (no VM)"
+ echo " --group run only the named scenario group"
+ echo " --keep keep the VM running after the run (skip restores)"
+ echo "Env: FS_PROFILE=btrfs|zfs, MAINT_SCENARIO_DIR, MAINT_SRC"
+}
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --list) LIST_ONLY=true; shift ;;
+ --group) ONLY_GROUP="${2:?--group requires a value}"; shift 2 ;;
+ --keep) KEEP_VM=true; shift ;;
+ *) usage; exit 1 ;;
+ esac
+done
+
+# ─── Plan: enumerate, validate, group ────────────────────────────────
+# The plan layer is pure -- no VM, no KVM -- so --list works anywhere and
+# the unit suite (tests/maint-scenarios/) exercises it directly.
+
+# Parallel arrays over scenarios, in file order.
+S_FILES=() S_NAMES=() S_DESCS=() S_GROUPS=()
+# Groups in first-appearance order.
+GROUP_ORDER=()
+
+# Extract one declared var from a scenario file by sourcing it in a clean
+# subshell. Scenario files must only define vars + functions; any top-level
+# command fails here (no helpers exist), which is the contract.
+_scenario_var() { # <file> <varname>
+ bash -c 'set -eu; source "$1" || exit 9; eval "printf %s \"\${$2-}\""' \
+ _probe "$1" "$2" 2>/dev/null
+}
+
+_validate_scenario() { # <file> -> fatal on contract violation
+ local f="$1" base probe_err
+ base=$(basename "$f")
+ probe_err=$(bash -c 'set -eu; source "$1"
+ : "${SCENARIO_DESC:?missing SCENARIO_DESC}"
+ : "${SCENARIO_GROUP:?missing SCENARIO_GROUP}"
+ : "${SCENARIO_PROFILES:?missing SCENARIO_PROFILES}"
+ for p in $SCENARIO_PROFILES; do
+ case "$p" in btrfs|zfs|any) ;; *)
+ echo "bad profile token: $p" >&2; exit 1 ;;
+ esac
+ done
+ declare -f scenario_break scenario_fix scenario_assert >/dev/null \
+ || { echo "missing scenario_break/fix/assert" >&2; exit 1; }' \
+ _probe "$f" 2>&1 >/dev/null) \
+ || fatal "scenario contract violation in $base: $probe_err"
+}
+
+_profile_matches() { # <profiles> -> 0 if scenario runs under $FS_PROFILE
+ local p
+ for p in $1; do
+ [ "$p" = "any" ] || [ "$p" = "$FS_PROFILE" ] && return 0
+ done
+ return 1
+}
+
+build_plan() {
+ [ -d "$SCENARIO_DIR" ] || fatal "scenario dir not found: $SCENARIO_DIR"
+ local f base name desc group profiles seen_groups=" "
+ local any=false
+ for f in "$SCENARIO_DIR"/*.sh; do
+ [ -e "$f" ] || break
+ any=true
+ _validate_scenario "$f"
+ base=$(basename "$f" .sh)
+ name="${base#[0-9][0-9]-}"
+ desc=$(_scenario_var "$f" SCENARIO_DESC)
+ group=$(_scenario_var "$f" SCENARIO_GROUP)
+ profiles=$(_scenario_var "$f" SCENARIO_PROFILES)
+ _profile_matches "$profiles" || continue
+ [ -n "$ONLY_GROUP" ] && [ "$group" != "$ONLY_GROUP" ] && continue
+ S_FILES+=("$f"); S_NAMES+=("$name")
+ S_DESCS+=("$desc"); S_GROUPS+=("$group")
+ case "$seen_groups" in
+ *" $group "*) ;;
+ *) GROUP_ORDER+=("$group"); seen_groups="$seen_groups$group " ;;
+ esac
+ done
+ [ "$any" = "true" ] || fatal "no scenario files in $SCENARIO_DIR"
+ if [ -n "$ONLY_GROUP" ] && [ ${#S_FILES[@]} -eq 0 ]; then
+ fatal "no scenarios match group '$ONLY_GROUP' (profile $FS_PROFILE)"
+ fi
+ [ ${#S_FILES[@]} -gt 0 ] \
+ || fatal "no scenarios match profile $FS_PROFILE"
+}
+
+print_plan() {
+ local g i
+ echo "maint scenario plan (profile: $FS_PROFILE)"
+ for g in "${GROUP_ORDER[@]}"; do
+ echo "group $g:"
+ for i in "${!S_FILES[@]}"; do
+ [ "${S_GROUPS[$i]}" = "$g" ] \
+ && echo " ${S_NAMES[$i]} — ${S_DESCS[$i]}"
+ done
+ done
+}
+
+# --list needs no logging file, VM, or KVM. Give fatal() a LOGFILE target
+# only when we actually run.
+if [ "$LIST_ONLY" = "true" ]; then
+ LOGFILE=/dev/null
+ build_plan
+ print_plan
+ exit 0
+fi
+
+# ─── Run ─────────────────────────────────────────────────────────────
+
+TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
+RESULTS_DIR="$PROJECT_ROOT/test-results/maint-$TIMESTAMP"
+mkdir -p "$RESULTS_DIR"
+LOGFILE="$RESULTS_DIR/scenarios.log"
+init_logging "$LOGFILE"
+init_vm_paths "$PROJECT_ROOT/vm-images"
+
+build_plan
+
+section "Maint Remedy Scenarios: $TIMESTAMP"
+info "Profile: $FS_PROFILE (image: $(basename "$DISK_PATH"))"
+info "Maint source: $MAINT_SRC"
+print_plan | tee -a "$LOGFILE"
+
+[ -f "$DISK_PATH" ] || fatal "base disk not found: $DISK_PATH — build it: FS_PROFILE=$FS_PROFILE $SCRIPT_DIR/create-base-vm.sh"
+snapshot_exists "$DISK_PATH" "clean-install" \
+ || fatal "snapshot 'clean-install' not found on $DISK_PATH"
+[ -d "$MAINT_SRC" ] || fatal "maint source tree not found: $MAINT_SRC"
+[ -f "$THRESHOLDS_TOML" ] || fatal "thresholds TOML not found: $THRESHOLDS_TOML"
+check_prerequisites || fatal "missing prerequisites"
+
+CLEANUP_DONE=0
+cleanup_scenarios() {
+ [ "$CLEANUP_DONE" = "1" ] && return 0
+ CLEANUP_DONE=1
+ [ "$KEEP_VM" = "true" ] && return 0
+ # Never silent: a failed restore/delete here leaves the base image dirty
+ # for the next `make test`, so name it even though the trap can't abort.
+ stop_qemu 2>/dev/null \
+ || echo "[!] cleanup: stop_qemu failed — VM may still hold the image" >&2
+ restore_snapshot "$DISK_PATH" "clean-install" 2>/dev/null \
+ || echo "[!] cleanup: clean-install restore FAILED — base image is dirty" >&2
+ if snapshot_exists "$DISK_PATH" "$READY_SNAPSHOT"; then
+ delete_snapshot "$DISK_PATH" "$READY_SNAPSHOT" 2>/dev/null \
+ || echo "[!] cleanup: stray '$READY_SNAPSHOT' snapshot left on the base image" >&2
+ fi
+}
+trap cleanup_scenarios EXIT
+
+# ─── Target helpers (available to scenario scripts) ──────────────────
+
+mexec() { # run a command in the VM as root
+ vm_exec "$ROOT_PASSWORD" "$@"
+}
+
+# maint invocation inside the VM. Root runs the verbs directly, so
+# MAINT_SUDO is empty (same switch the unit suites use).
+mmaint() {
+ mexec "cd /root/maint-pkg && MAINT_SUDO= PYTHONPATH=/root/maint-pkg python3 -m maint $*"
+}
+
+# Run one remedy: dry-run first (argv parity gate — the printed argv is the
+# arm-press string), then for real. Fails if either exits non-zero.
+mfix() {
+ local id="$1"; shift || true
+ step "maint fix $id ${*:+$* }--dry-run"
+ mmaint fix "$id" "$@" --dry-run >> "$LOGFILE" 2>&1 \
+ || { error "dry-run failed: maint fix $id $*"; return 1; }
+ step "maint fix $id $*"
+ mmaint fix "$id" "$@" 2>&1 | tee -a "$LOGFILE"
+ return "${PIPESTATUS[0]}"
+}
+
+# Assert a metric's severity in `maint status --json`.
+massert_metric() { # <metric-id> <expected-severity>
+ local mid="$1" want="$2" got
+ got=$(mmaint status --json | python3 -c "
+import json, sys
+env = json.load(sys.stdin)
+ms = [m for m in env['metrics'] if m['id'] == '$mid']
+print(ms[0]['severity'] if ms else 'MISSING')
+")
+ if [ "$got" = "$want" ]; then
+ success "metric $mid is $want"
+ return 0
+ fi
+ error "metric $mid: expected $want, got $got"
+ return 1
+}
+
+# ─── Bootstrap ───────────────────────────────────────────────────────
+
+boot_vm() {
+ start_qemu "$DISK_PATH" "disk" "" "none" || fatal "failed to start VM"
+ wait_for_ssh "$ROOT_PASSWORD" 180 || fatal "VM SSH not available"
+}
+
+bootstrap_vm() {
+ section "Bootstrap: packages + maint tree + thresholds"
+ step "Refreshing keyring + installing runtime deps"
+ # The base image can be months old: refresh the keyring first so the
+ # dep install doesn't die on expired signatures.
+ mexec "pacman -Sy --noconfirm archlinux-keyring" >> "$LOGFILE" 2>&1 \
+ || fatal "keyring refresh failed (network up?)"
+ mexec "pacman -S --noconfirm python pacman-contrib expac cronie" \
+ >> "$LOGFILE" 2>&1 || fatal "dep install failed"
+
+ step "Copying maint package tree"
+ local tarball
+ tarball=$(mktemp)
+ tar -C "$(dirname "$MAINT_SRC")" --exclude='__pycache__' -czf "$tarball" \
+ "$(basename "$MAINT_SRC")"
+ copy_to_vm "$tarball" "/tmp/maint-src.tgz" "$ROOT_PASSWORD"
+ rm -f "$tarball"
+ mexec "rm -rf /root/maint-pkg && mkdir -p /root/maint-pkg && tar -C /root/maint-pkg -xzf /tmp/maint-src.tgz && rm /tmp/maint-src.tgz" \
+ >> "$LOGFILE" 2>&1 || fatal "maint tree extract failed"
+
+ step "Installing thresholds TOML"
+ copy_to_vm "$THRESHOLDS_TOML" "/tmp/maintenance-thresholds.toml" \
+ "$ROOT_PASSWORD"
+ mexec "mkdir -p /root/.config/archsetup && mv /tmp/maintenance-thresholds.toml /root/.config/archsetup/maintenance-thresholds.toml" \
+ >> "$LOGFILE" 2>&1 || fatal "TOML install failed"
+
+ step "Smoke: maint status --json runs"
+ mmaint status --json > "$RESULTS_DIR/bootstrap-status.json" \
+ || fatal "maint status failed in the VM"
+ success "Bootstrap complete ($(python3 -c "
+import json; print(len(json.load(open('$RESULTS_DIR/bootstrap-status.json'))['metrics']))") metrics probed)"
+}
+
+# ─── Execution ───────────────────────────────────────────────────────
+
+PASS=() FAIL=()
+
+run_scenario() { # <index>
+ local i="$1" name="${S_NAMES[$1]}" f="${S_FILES[$1]}"
+ section "Scenario: $name — ${S_DESCS[$1]}"
+ # Sourced in the runner's shell so break/fix/assert see the helpers;
+ # unset afterwards so the next scenario can't inherit stale functions.
+ # shellcheck disable=SC1090
+ source "$f"
+ local ok=true
+ step "break"
+ if ! scenario_break; then
+ error "$name: break step failed"
+ ok=false
+ fi
+ if [ "$ok" = "true" ]; then
+ step "fix"
+ if ! scenario_fix; then
+ error "$name: fix step failed"
+ ok=false
+ fi
+ fi
+ if [ "$ok" = "true" ]; then
+ step "assert"
+ if ! scenario_assert; then
+ error "$name: post-state assertion failed"
+ ok=false
+ fi
+ fi
+ unset -f scenario_break scenario_fix scenario_assert
+ unset SCENARIO_DESC SCENARIO_GROUP SCENARIO_PROFILES
+ if [ "$ok" = "true" ]; then
+ success "PASS: $name"
+ PASS+=("$name")
+ else
+ error "FAIL: $name"
+ FAIL+=("$name")
+ fi
+}
+
+section "Preparing VM"
+stop_qemu 2>/dev/null || true
+step "Restoring clean-install snapshot"
+restore_snapshot "$DISK_PATH" "clean-install" || fatal "restore failed"
+boot_vm
+bootstrap_vm
+
+step "Freezing bootstrap as snapshot: $READY_SNAPSHOT"
+stop_qemu
+snapshot_exists "$DISK_PATH" "$READY_SNAPSHOT" \
+ && delete_snapshot "$DISK_PATH" "$READY_SNAPSHOT"
+create_snapshot "$DISK_PATH" "$READY_SNAPSHOT" || fatal "snapshot failed"
+
+first_group=true
+for g in "${GROUP_ORDER[@]}"; do
+ section "Group: $g"
+ if [ "$first_group" = "true" ]; then
+ first_group=false
+ else
+ step "Isolation boundary: restore $READY_SNAPSHOT"
+ stop_qemu
+ restore_snapshot "$DISK_PATH" "$READY_SNAPSHOT" || fatal "restore failed"
+ fi
+ boot_vm
+ for i in "${!S_FILES[@]}"; do
+ [ "${S_GROUPS[$i]}" = "$g" ] && run_scenario "$i"
+ done
+ stop_qemu
+done
+
+# ─── Report ──────────────────────────────────────────────────────────
+
+section "Results"
+for n in "${PASS[@]}"; do success "PASS $n"; done
+for n in "${FAIL[@]}"; do error "FAIL $n"; done
+info "Log: $LOGFILE"
+
+if [ "$KEEP_VM" = "true" ]; then
+ warn "--keep: VM left in post-run state; base image NOT restored"
+ warn "restore by hand: qemu-img snapshot -a clean-install $DISK_PATH"
+ CLEANUP_DONE=1
+else
+ cleanup_scenarios
+fi
+
+if [ ${#FAIL[@]} -gt 0 ]; then
+ error "${#FAIL[@]} scenario(s) failed, ${#PASS[@]} passed"
+ exit 1
+fi
+success "All ${#PASS[@]} scenarios passed"
+exit 0
diff --git a/scripts/testing/run-net-scenarios.sh b/scripts/testing/run-net-scenarios.sh
new file mode 100755
index 0000000..7197e37
--- /dev/null
+++ b/scripts/testing/run-net-scenarios.sh
@@ -0,0 +1,115 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
+# Run net-doctor control-plane repair scenarios against a booted VM.
+# Author: Craig Jennings <craigmartinjennings@gmail.com>
+# License: GNU GPLv3
+#
+# Live verification for net Phase 1's three privileged fixes (unmask-nm,
+# disable-rival, chmod-keyfile). Each scenario breaks the target in a known
+# way, runs the real `net doctor --fix` inside it, and asserts the post-state.
+# These states are dangerous on a daily driver (masking NM kills the network),
+# so they run against a DISPOSABLE VM you can revert, never ratio/velox.
+#
+# The target must be a booted VM (a real kernel + network stack — NetworkManager
+# does not run reliably in an nspawn container) reachable over ssh as root, with
+# NetworkManager and dhcpcd installed. The runner rsyncs the net + panelkit
+# source trees in and runs the doctor from them via a small wrapper (no install
+# needed), matching the stowed shim. jq is used host-side to read the JSON.
+#
+# STATUS: first-draft harness. The scenario break/fix/assert logic is the
+# reviewed part; the ssh transport plumbing has not been exercised against a
+# live target yet and may need a shakeout on the first real run.
+#
+# Usage: run-net-scenarios.sh --target root@HOST [--list] [--profile NAME]
+# --target ssh destination of the booted VM (required unless --list)
+# --profile a saved NM profile name for the keyfile scenario
+# (else the keyfile scenario is skipped)
+# --list print the scenario plan and exit
+#
+# Reverting the VM to a clean snapshot between runs is the caller's job; each
+# scenario also restores what it broke on the way out.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SCENARIO_DIR="${NET_SCENARIO_DIR:-$SCRIPT_DIR/net-scenarios}"
+DOTFILES="${DOTFILES:-$HOME/.dotfiles}"
+T_NET="/root/net-scenario-tree" # where the source trees land in the target
+
+TARGET=""
+LIST_ONLY=false
+export NET_SCENARIO_PROFILE="${NET_SCENARIO_PROFILE:-}"
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --target) TARGET="$2"; shift 2 ;;
+ --profile) NET_SCENARIO_PROFILE="$2"; shift 2 ;;
+ --list) LIST_ONLY=true; shift ;;
+ *) echo "usage: $0 --target root@HOST [--list] [--profile NAME]" >&2; exit 1 ;;
+ esac
+done
+
+mapfile -t S_FILES < <(find "$SCENARIO_DIR" -maxdepth 1 -name '*.sh' | sort)
+
+if [ "$LIST_ONLY" = true ]; then
+ echo "net doctor scenario plan:"
+ for f in "${S_FILES[@]}"; do
+ ( . "$f"; printf ' %-24s %s\n' "$(basename "$f" .sh)" "$SCENARIO_DESC" )
+ done
+ exit 0
+fi
+
+[ -n "$TARGET" ] || { echo "--target root@HOST is required" >&2; exit 1; }
+command -v jq >/dev/null || { echo "jq is required host-side" >&2; exit 1; }
+
+SSH() { ssh -o BatchMode=yes "$TARGET" "$@"; }
+
+# ── target helpers (available to the scenario scripts) ───────────────────
+# nexec runs a payload in the target as root; the doctor runs through the
+# /root/net-run wrapper synced below, so no fragile inline quoting is needed.
+nexec() { SSH "bash -lc $(printf '%q' "$*")"; }
+nfix() { nexec "/root/net-run doctor --fix --json"; }
+ndoctor_json() { nexec "/root/net-run doctor --json" | jq -er "$1"; }
+
+info() { printf ' %s\n' "$*"; }
+pass() { printf ' PASS %s\n' "$*"; }
+fail() { printf ' FAIL %s\n' "$*"; }
+
+# ── sync the source trees + the doctor wrapper into the target ───────────
+echo "==> syncing net + panelkit into $TARGET:$T_NET"
+SSH "mkdir -p $T_NET/net $T_NET/panelkit"
+rsync -a -e "ssh -o BatchMode=yes" "$DOTFILES/net/src" "$TARGET:$T_NET/net/"
+rsync -a -e "ssh -o BatchMode=yes" "$DOTFILES/panelkit/src" "$TARGET:$T_NET/panelkit/"
+# The wrapper: run as root, so NET_SUDO empty (commands run directly) and
+# PANELKIT_SUDO=sudo (root's `sudo -n true` succeeds -> the model resolves RUN).
+SSH "cat > /root/net-run" <<EOF
+#!/bin/bash
+exec env PYTHONPATH=$T_NET/net/src:$T_NET/panelkit/src NET_SUDO= PANELKIT_SUDO=sudo \\
+ python3 -c 'import sys; from net.cli import main; sys.exit(main(sys.argv[1:]))' "\$@"
+EOF
+SSH "chmod +x /root/net-run"
+
+# ── run each scenario ────────────────────────────────────────────────────
+fails=0
+for f in "${S_FILES[@]}"; do
+ name="$(basename "$f" .sh)"
+ # shellcheck disable=SC1090
+ ( . "$f"
+ case "$name" in
+ *keyfile*) [ -n "$NET_SCENARIO_PROFILE" ] || { info "skip $name (no --profile)"; exit 0; } ;;
+ esac
+ echo "== $name — $SCENARIO_DESC"
+ scenario_break || { fail "$name: break failed"; exit 1; }
+ if declare -F scenario_diagnose_expect >/dev/null; then
+ if scenario_diagnose_expect; then pass "$name: diagnose named it"
+ else fail "$name: diagnose did NOT name it (inspect net doctor --json)"; fi
+ fi
+ scenario_fix || info "$name: net doctor --fix returned non-zero"
+ if scenario_assert; then pass "$name: repaired"
+ else fail "$name: NOT repaired"; exit 1; fi
+ ) || fails=$((fails + 1))
+done
+
+echo
+[ "$fails" -eq 0 ] && echo "all scenarios passed" || echo "$fails scenario(s) failed"
+exit "$fails"
diff --git a/scripts/testing/run-test-baremetal.sh b/scripts/testing/run-test-baremetal.sh
index b6d1ab1..d22c424 100755
--- a/scripts/testing/run-test-baremetal.sh
+++ b/scripts/testing/run-test-baremetal.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Run archsetup test on bare metal ZFS system
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
@@ -19,13 +20,16 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Source utilities
source "$SCRIPT_DIR/lib/logging.sh"
-source "$SCRIPT_DIR/lib/validation.sh"
+source "$SCRIPT_DIR/lib/validation.sh" # live helpers: ssh_cmd, capture_*_state, analyze_log_diff, generate_issue_report
+source "$SCRIPT_DIR/lib/vm-utils.sh" # inject_root_key + SSH_OPTS/SSH_KEY_OPT for key auth
+source "$SCRIPT_DIR/lib/testinfra.sh" # run_testinfra_validation (authoritative validator)
# Parse arguments
ROLLBACK_FIRST=false
ROLLBACK_AFTER=false
TARGET_HOST=""
ROOT_PASSWORD=""
+PORT="22"
usage() {
echo "Usage: $0 --host <hostname> --password <root_password> [options]"
@@ -35,6 +39,7 @@ usage() {
echo " --password <password> Root password for SSH"
echo ""
echo "Options:"
+ echo " --port <port> SSH port (default 22; use 2222 to target a test VM)"
echo " --rollback-first Roll back to genesis snapshots before running"
echo " --rollback-after Roll back to genesis snapshots after test (cleanup)"
echo " --validate-only Skip archsetup, only run validation checks"
@@ -54,6 +59,10 @@ while [[ $# -gt 0 ]]; do
ROOT_PASSWORD="${2:?--password requires a value}"
shift 2
;;
+ --port)
+ PORT="${2:?--port requires a value}"
+ shift 2
+ ;;
--rollback-first)
ROLLBACK_FIRST=true
shift
@@ -93,9 +102,16 @@ cleanup_baremetal() {
}
trap cleanup_baremetal EXIT
-# Override VM_IP for validation.sh ssh_cmd function
-# shellcheck disable=SC2034 # consumed by the sourced validation.sh
+# Connection globals consumed by ssh_cmd (validation.sh), inject_root_key
+# (vm-utils.sh), and run_testinfra_validation (testinfra.sh).
+# shellcheck disable=SC2034 # consumed by the sourced libraries
VM_IP="$TARGET_HOST"
+# shellcheck disable=SC2034
+SSH_PORT="$PORT"
+# Test-user source for testinfra (reads USERNAME); the bare-metal user is the
+# archsetup default, cjennings, same as the VM conf.
+# shellcheck disable=SC2034
+ARCHSETUP_VM_CONF="$SCRIPT_DIR/archsetup-vm.conf"
# Initialize logging
mkdir -p "$TEST_RESULTS_DIR"
@@ -108,8 +124,8 @@ info "Target: $TARGET_HOST"
# Test SSH connectivity
step "Testing SSH connectivity to $TARGET_HOST"
if ! sshpass -p "$ROOT_PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -o ConnectTimeout=10 "root@$TARGET_HOST" "echo connected" &>/dev/null; then
- fatal "Cannot connect to $TARGET_HOST via SSH"
+ -o ConnectTimeout=10 -p "$PORT" "root@$TARGET_HOST" "echo connected" &>/dev/null; then
+ fatal "Cannot connect to $TARGET_HOST:$PORT via SSH"
fi
success "SSH connection OK"
@@ -146,6 +162,15 @@ if $ROLLBACK_FIRST; then
success "Reconnected"
fi
+# Authorize a throwaway root key before archsetup hardens sshd. archsetup sets
+# PermitRootLogin prohibit-password and reloads sshd partway through, which kills
+# root *password* SSH; key auth survives it, so every later ssh_cmd and the
+# Testinfra sweep keep working. Placed after any genesis rollback so the key
+# isn't rolled away. Best-effort: a failure only risks the post-hardening steps.
+step "Authorizing throwaway root key (survives sshd hardening)"
+inject_root_key "$TEST_RESULTS_DIR/root_key" || \
+ warn "Continuing without an injected root key — SSH may fail after archsetup hardens sshd"
+
if ! $VALIDATE_ONLY; then
# Capture pre-install state
capture_pre_install_state "$TEST_RESULTS_DIR"
@@ -160,7 +185,7 @@ if ! $VALIDATE_ONLY; then
step "Transferring to $TARGET_HOST"
ssh_cmd "rm -rf /tmp/archsetup-test && mkdir -p /tmp/archsetup-test"
sshpass -p "$ROOT_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- "$BUNDLE_FILE" "root@$TARGET_HOST:/tmp/archsetup.bundle" >> "$LOGFILE" 2>&1
+ ${SSH_KEY_OPT:-} -P "$PORT" "$BUNDLE_FILE" "root@$TARGET_HOST:/tmp/archsetup.bundle" >> "$LOGFILE" 2>&1
step "Extracting on target"
ssh_cmd "cd /tmp && git clone /tmp/archsetup.bundle archsetup-test && rm /tmp/archsetup.bundle" >> "$LOGFILE" 2>&1
@@ -222,12 +247,12 @@ if ! $VALIDATE_ONLY; then
step "Copying archsetup log"
sshpass -p "$ROOT_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- "root@$TARGET_HOST:/var/log/archsetup-*.log" "$TEST_RESULTS_DIR/" 2>> "$LOGFILE" || \
+ ${SSH_KEY_OPT:-} -P "$PORT" "root@$TARGET_HOST:/var/log/archsetup-*.log" "$TEST_RESULTS_DIR/" 2>> "$LOGFILE" || \
warn "Could not copy archsetup log"
step "Copying archsetup output"
sshpass -p "$ROOT_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- "root@$TARGET_HOST:$REMOTE_LOG" "$TEST_RESULTS_DIR/archsetup-output.log" 2>> "$LOGFILE" || \
+ ${SSH_KEY_OPT:-} -P "$PORT" "root@$TARGET_HOST:$REMOTE_LOG" "$TEST_RESULTS_DIR/archsetup-output.log" 2>> "$LOGFILE" || \
warn "Could not copy output log"
# Capture post-install state
@@ -238,13 +263,14 @@ else
mkdir -p "$TEST_RESULTS_DIR/pre-install" "$TEST_RESULTS_DIR/post-install"
fi
-# Run validations
-run_all_validations
-validate_all_services
-
-# Additional ZFS-specific validations
-section "ZFS-Specific Validations"
-validate_zfs_services
+# Run validations. Testinfra is the authoritative validator (same as the VM
+# runner); its ZFS-conditional pytest checks cover what validate_zfs_services
+# used to, and it connects over the key authorized above. set +e because it
+# returns pytest's rc (non-zero on failures) and the report + summary below must
+# still run; the verdict is carried by testinfra_rc and the exit code at the end.
+set +e
+run_testinfra_validation "$TEST_RESULTS_DIR"
+testinfra_rc=$?
# Analyze logs if we ran archsetup
if ! $VALIDATE_ONLY; then
@@ -254,8 +280,8 @@ fi
# Generate reports
generate_issue_report "$TEST_RESULTS_DIR" "$ARCHZFS_INBOX"
-# Set validation result (TEST_PASSED is the boolean; VALIDATION_PASSED stays the counter)
-if [ "$VALIDATION_FAILED" -eq 0 ]; then
+# The run passes only if the Testinfra sweep passed.
+if [ "$testinfra_rc" -eq 0 ]; then
TEST_PASSED=true
else
TEST_PASSED=false
diff --git a/scripts/testing/run-test.sh b/scripts/testing/run-test.sh
index 5830ed9..f962df3 100755
--- a/scripts/testing/run-test.sh
+++ b/scripts/testing/run-test.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Run archsetup test in a VM using snapshots
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
@@ -23,6 +24,7 @@ source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/vm-utils.sh"
source "$SCRIPT_DIR/lib/network-diagnostics.sh"
source "$SCRIPT_DIR/lib/validation.sh"
+source "$SCRIPT_DIR/lib/testinfra.sh"
# Parse arguments
KEEP_VM=false
@@ -48,6 +50,9 @@ while [[ $# -gt 0 ]]; do
echo " --keep Keep VM in post-test state (for debugging)"
echo " --script Specify custom archsetup script to test"
echo " --snapshot Snapshot name to revert to (default: clean-install)"
+ echo ""
+ echo "Env: FS_PROFILE=btrfs|zfs (default btrfs) selects the base image"
+ echo " built by create-base-vm.sh. e.g. FS_PROFILE=zfs $0"
exit 1
;;
esac
@@ -98,6 +103,7 @@ init_logging "$LOGFILE"
init_vm_paths "$VM_IMAGES_DIR"
section "ArchSetup Test Run: $TIMESTAMP"
+info "Filesystem profile: $FS_PROFILE (image: $(basename "$DISK_PATH"))"
# Verify archsetup script exists
if [ ! -f "$ARCHSETUP_SCRIPT" ]; then
@@ -106,7 +112,11 @@ fi
# Check disk exists
if [ ! -f "$DISK_PATH" ]; then
- info "Create it first: ./scripts/testing/create-base-vm.sh"
+ if [ "$FS_PROFILE" = "btrfs" ]; then
+ info "Create it first: ./scripts/testing/create-base-vm.sh"
+ else
+ info "Create it first: FS_PROFILE=$FS_PROFILE ./scripts/testing/create-base-vm.sh"
+ fi
fatal "Base disk not found: $DISK_PATH"
fi
@@ -140,6 +150,13 @@ start_qemu "$DISK_PATH" "disk" "" "none" || fatal "Failed to start VM"
wait_for_ssh "$ROOT_PASSWORD" 120 || fatal "VM SSH not available"
stop_timer "boot"
+# Authorize a root key now, before archsetup runs. archsetup hardens sshd to
+# PermitRootLogin prohibit-password partway through, which breaks the harness's
+# root password SSH; key auth survives it. Without this, the run aborts mid-way
+# (before any validation) once the hardening step lands.
+inject_root_key "$TEST_RESULTS_DIR/root_key" || \
+ warn "Continuing without root key - run may break at the sshd hardening step"
+
# Run network diagnostics
if ! run_network_diagnostics; then
fatal "Network diagnostics failed - aborting test"
@@ -240,7 +257,8 @@ fi
# Poll for completion
step "Monitoring archsetup progress (polling every 30 seconds)..."
POLL_COUNT=0
-MAX_POLLS=180 # 90 minutes max (180 * 30 seconds)
+MAX_POLLS=300 # 150 minutes max (300 * 30 seconds); a full install with heavy
+ # AUR builds (e.g. vagrant) can exceed 90 min on a slow mirror
while [ $POLL_COUNT -lt $MAX_POLLS ]; do
# Check if archsetup process is still running
@@ -261,7 +279,7 @@ while [ $POLL_COUNT -lt $MAX_POLLS ]; do
done
if [ $POLL_COUNT -ge $MAX_POLLS ]; then
- error "ArchSetup timed out after 90 minutes"
+ error "ArchSetup timed out after 150 minutes"
ARCHSETUP_EXIT_CODE=124
else
# Get exit code from the remote log
@@ -307,18 +325,17 @@ copy_from_vm "/var/log/archsetup-installed-packages.txt" "$TEST_RESULTS_DIR/" "$
# Capture post-install state
capture_post_install_state "$TEST_RESULTS_DIR"
-# Run comprehensive validation
-# This uses the validation.sh library for all checks.
+# Run comprehensive validation (Testinfra/pytest is the primary validator; the
+# old shell run_all_validations sweep was retired once pytest reached parity).
#
# From here to the end of the script, errexit is disabled on purpose: the
-# validation functions are designed to fail-and-count (see VALIDATION_FAILED)
-# rather than abort, and the analysis/report-generation steps below can also
-# legitimately return non-zero. With `set -e` active, a single failed check
-# would kill the run before the test report is written or the VM is cleaned
-# up. Pass/fail is signalled explicitly by the exit code at the bottom.
+# analysis/report-generation steps below can legitimately return non-zero, and
+# with `set -e` active a single failed check would kill the run before the test
+# report is written or the VM is cleaned up. Pass/fail is signalled explicitly
+# by the exit code at the bottom.
set +e
-run_all_validations
-validate_all_services
+run_testinfra_validation "$TEST_RESULTS_DIR"
+testinfra_rc=$?
# Analyze log differences (pre vs post install)
analyze_log_diff "$TEST_RESULTS_DIR"
@@ -327,8 +344,8 @@ analyze_log_diff "$TEST_RESULTS_DIR"
# If base install issues found and archzfs inbox exists, create issue file
generate_issue_report "$TEST_RESULTS_DIR" "$ARCHZFS_INBOX"
-# Set validation result based on failure count
-if [ "$VALIDATION_FAILED" -eq 0 ]; then
+# The run passes only if the Testinfra sweep passed.
+if [ "$testinfra_rc" -eq 0 ]; then
TEST_PASSED=true
else
TEST_PASSED=false
diff --git a/scripts/testing/setup-testing-env.sh b/scripts/testing/setup-testing-env.sh
index fb0628b..b5b584f 100755
--- a/scripts/testing/setup-testing-env.sh
+++ b/scripts/testing/setup-testing-env.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Setup testing environment for archsetup
# Author: Craig Jennings <craigmartinjennings@gmail.com>
# License: GNU GPLv3
diff --git a/scripts/testing/tests/conftest.py b/scripts/testing/tests/conftest.py
new file mode 100644
index 0000000..680c967
--- /dev/null
+++ b/scripts/testing/tests/conftest.py
@@ -0,0 +1,111 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Pytest + Testinfra config for archsetup post-install validation.
+
+These tests run on the *host* and connect to the freshly-installed VM over SSH
+(Testinfra provides the `host` fixture, parametrized from --hosts). This file
+adds two things the bespoke shell harness had that Testinfra does not:
+
+ - Failure attribution. Each check is marked with the layer that owns a
+ failure (archsetup | base_install | unknown), mirroring validation.sh's
+ attribute_issue. Failures are bucketed and written to --attribution-file
+ so run-test.sh can route base-install issues to the archzfs inbox as before.
+ - Tiering markers (smoke | integration) so `pytest -m smoke` is a fast gate.
+
+The `target_user` fixture supplies the account archsetup created; it reads
+ARCHSETUP_TEST_USER (set by run-test.sh from the VM conf) and defaults to the
+historical "cjennings".
+"""
+
+import os
+
+import pytest
+
+
+_ATTRIBUTION_BUCKETS = ("archsetup", "base_install", "unknown")
+_failures = {bucket: [] for bucket in _ATTRIBUTION_BUCKETS}
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--attribution-file",
+ action="store",
+ default=None,
+ help="write the failure attribution report (archsetup/base_install/unknown) here",
+ )
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "attribution(bucket): layer that owns a failure — archsetup, base_install, or unknown",
+ )
+ config.addinivalue_line("markers", "smoke: fast subset (user, key packages, dotfiles present)")
+ config.addinivalue_line("markers", "integration: full post-install checks")
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_makereport(item, call):
+ report = yield
+ if report.when == "call" and report.failed:
+ marker = item.get_closest_marker("attribution")
+ bucket = marker.args[0] if (marker and marker.args) else "archsetup"
+ if bucket not in _failures:
+ bucket = "unknown"
+ _failures[bucket].append(item.nodeid)
+ return report
+
+
+def pytest_sessionfinish(session, exitstatus):
+ path = session.config.getoption("--attribution-file")
+ if not path:
+ return
+ with open(path, "w") as fh:
+ for bucket in _ATTRIBUTION_BUCKETS:
+ fh.write("[%s]\n" % bucket)
+ for nodeid in _failures[bucket]:
+ fh.write(" %s\n" % nodeid)
+
+
+@pytest.fixture(scope="session")
+def target_user():
+ """The account archsetup created in the VM under test."""
+ return os.environ.get("ARCHSETUP_TEST_USER", "cjennings")
+
+
+@pytest.fixture(scope="session")
+def home(target_user):
+ return "/home/%s" % target_user
+
+
+@pytest.fixture(scope="module")
+def zfs_root(host):
+ """True when the VM's root filesystem is ZFS (gates ZFS-specific checks)."""
+ return host.run("findmnt -n -o FSTYPE /").stdout.strip() == "zfs"
+
+
+@pytest.fixture(scope="module")
+def has_nvme(host):
+ """True when the VM exposes an NVMe device."""
+ return host.run("ls /dev/nvme0n1 2>/dev/null").rc == 0
+
+
+@pytest.fixture(scope="module")
+def hyprland_installed(host):
+ return host.package("hyprland").is_installed
+
+
+@pytest.fixture(scope="module")
+def dwm_installed(host):
+ return host.file("/usr/local/bin/dwm").exists
+
+
+@pytest.fixture(scope="module")
+def compositor_running(host):
+ """A graphical session is live (gates socket/portal checks that need one)."""
+ return host.run("pgrep -x Hyprland").rc == 0
+
+
+@pytest.fixture(scope="module")
+def on_slirp(host):
+ """QEMU user-mode networking (10.0.2.x) — no multicast, so mDNS can't work."""
+ return "10.0.2." in host.run("ip -4 addr show").stdout
diff --git a/scripts/testing/tests/test_archsetup.py b/scripts/testing/tests/test_archsetup.py
new file mode 100644
index 0000000..52fe3f7
--- /dev/null
+++ b/scripts/testing/tests/test_archsetup.py
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: archsetup's own log and state markers.
+
+Parity port of validate_archsetup_log and validate_state_markers.
+"""
+
+import pytest
+
+
+EXPECTED_STATE_STEPS = 12
+
+
+@pytest.mark.attribution("archsetup")
+def test_no_errors_in_archsetup_log(host):
+ out = host.run("grep -h '^Error:' /var/log/archsetup-*.log 2>/dev/null | wc -l")
+ count = int((out.stdout.strip() or "0"))
+ assert count == 0, "archsetup log reported %d Error: lines" % count
+
+
+@pytest.mark.attribution("archsetup")
+def test_all_install_steps_completed(host):
+ out = host.run("ls /var/lib/archsetup/state/ 2>/dev/null | wc -l")
+ count = int((out.stdout.strip() or "0"))
+ assert count >= EXPECTED_STATE_STEPS, (
+ "only %d/%d install steps completed" % (count, EXPECTED_STATE_STEPS)
+ )
diff --git a/scripts/testing/tests/test_backups.py b/scripts/testing/tests/test_backups.py
new file mode 100644
index 0000000..07da5ec
--- /dev/null
+++ b/scripts/testing/tests/test_backups.py
@@ -0,0 +1,44 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: backup_system_file ran during a real install.
+
+Expansion coverage (P4). The unit suite (tests/backup-system-file/) covers the
+helper's logic; this confirms it actually fires end-to-end — archsetup leaves a
+<file>.archsetup.bak next to each pre-existing file it edits in place.
+
+These targets are edited unconditionally on every run (pacman.conf/makepkg.conf
+always sed'd, sudoers always appended), so their backups must exist.
+mkinitcpio.conf is edited only conditionally (the systemd-hook switch on
+non-ZFS, or the nvme module on NVMe systems), so it gets its own fixture-gated
+check below. Conditionally-edited files (locale.gen, geoclue, fstab) aren't
+asserted here since their edits depend on the base image.
+"""
+
+import pytest
+
+
+ALWAYS_BACKED_UP = [
+ "/etc/pacman.conf",
+ "/etc/makepkg.conf",
+ "/etc/sudoers",
+]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("path", ALWAYS_BACKED_UP)
+def test_backup_created_for_edited_file(host, path):
+ bak = host.file(path + ".archsetup.bak")
+ assert bak.exists, "%s.archsetup.bak missing — backup_system_file did not fire" % path
+ assert bak.is_file
+
+
+@pytest.mark.attribution("archsetup")
+def test_backup_created_for_mkinitcpio(host, zfs_root, has_nvme):
+ # archsetup edits /etc/mkinitcpio.conf only when it has something to change:
+ # the systemd-hook switch (non-ZFS only) or adding the nvme module (NVMe
+ # systems). A ZFS root with no NVMe touches neither, so there's no backup.
+ if zfs_root and not has_nvme:
+ pytest.skip("ZFS root + no NVMe: archsetup doesn't edit mkinitcpio.conf")
+ bak = host.file("/etc/mkinitcpio.conf.archsetup.bak")
+ assert bak.exists, \
+ "/etc/mkinitcpio.conf.archsetup.bak missing — backup_system_file did not fire"
+ assert bak.is_file
diff --git a/scripts/testing/tests/test_boot.py b/scripts/testing/tests/test_boot.py
new file mode 100644
index 0000000..e442682
--- /dev/null
+++ b/scripts/testing/tests/test_boot.py
@@ -0,0 +1,83 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: boot, initramfs, and filesystem config.
+
+Parity port of validate_zfs_config, validate_boot_config,
+validate_mkinitcpio_hooks, validate_initramfs_consolefont, validate_nvme_module.
+Filesystem/hardware-specific checks are gated on fixtures.
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_bootloader_installed(host, zfs_root):
+ # A ZFS root boots via ZFSBootMenu (archangel installs the EFI binary under
+ # /efi/EFI/ZBM), so there is no GRUB; a non-ZFS root uses GRUB.
+ if zfs_root:
+ assert host.file("/efi/EFI/ZBM/zfsbootmenu.efi").exists, \
+ "ZFS root must have the ZFSBootMenu EFI binary"
+ else:
+ assert host.file("/boot/grub/grub.cfg").exists, \
+ "non-ZFS root must have a GRUB config"
+
+
+@pytest.mark.attribution("archsetup")
+def test_mkinitcpio_hooks(host, zfs_root):
+ hooks = host.run("grep '^HOOKS=' /etc/mkinitcpio.conf").stdout
+ if zfs_root:
+ # ZFS must use the udev hook; the systemd hook breaks a ZFS boot.
+ assert " udev" in hooks or "(udev" in hooks, "ZFS root must use the udev hook"
+ assert "systemd" not in hooks, "ZFS root must not use the systemd hook"
+ else:
+ # Non-ZFS: either hook is acceptable.
+ assert ("systemd" in hooks) or ("udev" in hooks)
+
+
+@pytest.mark.attribution("archsetup")
+def test_console_font_configured(host, zfs_root):
+ # archsetup sets FONT=ter-132n in /etc/vconsole.conf on every run.
+ assert host.file("/etc/vconsole.conf").contains("^FONT=ter-132n"), \
+ "archsetup should set FONT=ter-132n in /etc/vconsole.conf"
+ # On non-ZFS it also rebuilds the initramfs (mkinitcpio -P) so the font is
+ # baked in for early boot. On ZFS that rebuild is skipped (the busybox ZFS
+ # hook is incompatible with the systemd-hook switch), so the font applies at
+ # the vconsole layer once userspace starts, not inside the initramfs.
+ if zfs_root:
+ return
+ # Pick the main initramfs (this fleet runs linux-lts, so the name is
+ # initramfs-linux-lts.img, not initramfs-linux.img); skip the fallback image.
+ img = host.run(
+ "ls /boot/initramfs-*.img 2>/dev/null | grep -v fallback | head -1"
+ ).stdout.strip()
+ assert img, "no initramfs image found under /boot"
+ out = host.run("lsinitcpio %s 2>/dev/null | grep -cE 'consolefont.psf|ter-'" % img)
+ assert int((out.stdout.strip() or "0")) > 0, "console font not found in %s" % img
+
+
+def test_nvme_module_when_nvme_present(host, has_nvme):
+ if not has_nvme:
+ pytest.skip("no NVMe device present")
+ modules = host.run("grep '^MODULES=' /etc/mkinitcpio.conf").stdout
+ assert "nvme" in modules, "NVMe system should list nvme in mkinitcpio MODULES"
+
+
+def test_zfs_has_sanoid(host):
+ if not host.exists("zfs"):
+ pytest.skip("ZFS not installed (non-ZFS system)")
+ assert host.exists("sanoid"), "ZFS system should have sanoid installed"
+
+
+def test_zfs_pre_pacman_snapshot_hook(host):
+ # archsetup installs a PreTransaction pacman hook + a self-pruning script so
+ # every pacman transaction is preceded by a rollback snapshot (configure_
+ # pre_pacman_snapshots, run late in boot_ux). ZFS-root only.
+ if not host.exists("zfs"):
+ pytest.skip("ZFS not installed (non-ZFS system)")
+ script = host.file("/usr/local/bin/zfs-pre-snapshot")
+ assert script.exists and script.is_file, "pre-pacman snapshot script missing"
+ assert script.mode & 0o111, "pre-pacman snapshot script is not executable"
+ hook = host.file("/etc/pacman.d/hooks/zfs-snapshot.hook")
+ assert hook.exists and hook.is_file, "zfs-snapshot.hook missing"
+ assert "PreTransaction" in hook.content_string, "hook not PreTransaction"
+ assert "/usr/local/bin/zfs-pre-snapshot" in hook.content_string, \
+ "hook does not exec the snapshot script"
diff --git a/scripts/testing/tests/test_config_applied.py b/scripts/testing/tests/test_config_applied.py
new file mode 100644
index 0000000..00c410e
--- /dev/null
+++ b/scripts/testing/tests/test_config_applied.py
@@ -0,0 +1,55 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: archsetup's in-place config edits actually took effect.
+
+Expansion coverage (P4). These assert the *content* archsetup writes, not just
+that a service is enabled — catching cases where a sed silently no-ops (e.g.
+ParallelDownloads, which current Arch ships uncommented so a "^#"-only match
+left it at 5).
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_pacman_parallel_downloads(host):
+ line = host.run("grep -E '^ParallelDownloads' /etc/pacman.conf").stdout
+ assert "ParallelDownloads = 10" in line, "ParallelDownloads not set to 10 (got: %r)" % line
+
+
+@pytest.mark.attribution("archsetup")
+def test_pacman_color_enabled(host):
+ assert host.run("grep -qx Color /etc/pacman.conf").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_pacman_multilib_enabled(host):
+ # -F: [multilib] is a literal section header, not a regex character class.
+ assert host.run("grep -Fxq '[multilib]' /etc/pacman.conf").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_makepkg_parallel_make(host):
+ line = host.run("grep -E '^MAKEFLAGS' /etc/makepkg.conf").stdout
+ assert "-j" in line, "MAKEFLAGS not configured for parallel make (got: %r)" % line
+
+
+@pytest.mark.attribution("archsetup")
+def test_makepkg_options_trimmed(host):
+ opts = host.run("grep -E '^OPTIONS' /etc/makepkg.conf").stdout
+ assert "!debug" in opts and "purge" in opts, "makepkg OPTIONS not customized"
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("rel", ["dns.conf", "wifi-privacy.conf"])
+def test_networkmanager_dropin(host, rel):
+ assert host.file("/etc/NetworkManager/conf.d/%s" % rel).exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_fail2ban_jail_local(host):
+ assert host.file("/etc/fail2ban/jail.local").exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_reflector_config(host):
+ assert host.file("/etc/xdg/reflector/reflector.conf").exists
diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py
new file mode 100644
index 0000000..6f79bfd
--- /dev/null
+++ b/scripts/testing/tests/test_desktop.py
@@ -0,0 +1,140 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: window manager + desktop integration.
+
+Parity port of validate_window_manager and its Hyprland/DWM branches, plus
+validate_autologin_config. Hyprland and DWM checks are gated on which DE the
+run installed; socket/portal-query checks are gated on a live compositor (the
+headless test VM has none).
+
+Note: validate_hyprland_tools historically checked `swww`, but archsetup now
+installs `awww` (swww successor) and `pacman -Q swww` no longer matches — so
+this checks awww. That divergence from the shell sweep is a correctness fix.
+"""
+
+import pytest
+
+
+HYPRLAND_TOOLS = [
+ "hyprland", "hypridle", "hyprlock", "waybar", "fuzzel",
+ "awww", "grim", "slurp", "gammastep", "foot",
+]
+
+HYPRLAND_CONFIGS = [
+ ".config/hypr/hyprland.conf",
+ ".config/hypr/hypridle.conf",
+ ".config/hypr/hyprlock.conf",
+ ".config/waybar/config",
+ ".config/fuzzel/fuzzel.ini",
+ ".config/gammastep/config.ini",
+]
+
+SUCKLESS_TOOLS = ["dwm", "st", "dmenu", "slock"]
+
+PORTALS_CONF = ".config/xdg-desktop-portal/portals.conf"
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("pkg", HYPRLAND_TOOLS)
+def test_hyprland_tool_installed(host, hyprland_installed, pkg):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ assert host.package(pkg).is_installed, "%s missing" % pkg
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("rel", HYPRLAND_CONFIGS)
+def test_hyprland_config_present(host, hyprland_installed, home, rel):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ assert host.file("%s/%s" % (home, rel)).exists, "%s missing" % rel
+
+
+@pytest.mark.attribution("archsetup")
+def test_live_update_guard_installed(host, hyprland_installed):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ guard = host.file("/usr/local/bin/hypr-live-update-guard")
+ assert guard.exists, "live-update guard script missing"
+ assert guard.mode & 0o111, "live-update guard not executable"
+ hook = host.file("/etc/pacman.d/hooks/hypr-live-update-guard.hook")
+ assert hook.exists, "live-update guard pacman hook missing"
+ assert "hypr-live-update-guard" in hook.content_string, \
+ "hook does not invoke the guard script"
+
+
+@pytest.mark.attribution("archsetup")
+def test_portal_settings_backend_not_disabled(host, hyprland_installed, home):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed")
+ conf = host.file("%s/%s" % (home, PORTALS_CONF))
+ assert conf.exists, "portals.conf missing"
+ line = host.run(
+ "grep org.freedesktop.impl.portal.Settings %s" % conf.path
+ ).stdout
+ assert "=none" not in line.replace(" ", ""), "Settings portal disabled (=none)"
+
+
+def test_portal_returns_dark_mode(host, hyprland_installed, compositor_running, target_user):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed")
+ if not compositor_running:
+ pytest.skip("no compositor running (headless) — portal query not applicable")
+ cmd = (
+ "sudo -u %s busctl --user call org.freedesktop.portal.Desktop "
+ "/org/freedesktop/portal/desktop org.freedesktop.portal.Settings Read "
+ "'ss' 'org.freedesktop.appearance' 'color-scheme'" % target_user
+ )
+ out = host.run(cmd).stdout
+ assert "u 1" in out, "Settings portal should report color-scheme=1 (dark)"
+
+
+def test_hyprland_socket(host, hyprland_installed, compositor_running):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed")
+ if not compositor_running:
+ pytest.skip("Hyprland not running (headless) — socket check not applicable")
+ assert host.run("test -S /tmp/hypr/*/.socket.sock").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("tool", SUCKLESS_TOOLS)
+def test_suckless_tool_installed(host, dwm_installed, tool):
+ if not dwm_installed:
+ pytest.skip("DWM not installed (DESKTOP_ENV != dwm)")
+ assert host.file("/usr/local/bin/%s" % tool).exists, "%s missing" % tool
+
+
+def test_autologin_configured(host):
+ conf = host.file("/etc/systemd/system/getty@tty1.service.d/autologin.conf")
+ if not conf.exists:
+ pytest.skip("autologin not configured (AUTOLOGIN=no, may be intentional)")
+ assert conf.exists
+
+
+BT_PANEL_BINS = ["bt", "bt-panel", "bt-priv", "waybar-bt"]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("name", BT_PANEL_BINS)
+def test_bt_panel_bin_stowed(host, hyprland_installed, home, name):
+ # Executable via either stow shape (per-file symlink or folded dir).
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ path = "%s/.local/bin/%s" % (home, name)
+ assert host.file(path).exists, "%s missing from ~/.local/bin" % name
+ assert host.run("test -x %s" % path).rc == 0, "%s not executable" % name
+
+
+@pytest.mark.attribution("archsetup")
+def test_bt_panel_wired(host, hyprland_installed, home):
+ # A fresh install lands the panel reachable: bar module, keybind, css.
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ waybar = host.file("%s/.config/waybar/config" % home)
+ assert "custom/bluetooth" in waybar.content_string, \
+ "waybar config lacks the custom/bluetooth module"
+ hyprconf = host.file("%s/.config/hypr/hyprland.conf" % home)
+ assert "bt-panel" in hyprconf.content_string, \
+ "hyprland.conf lacks the bt-panel keybind"
+ assert host.file("%s/.config/themes/dupre/panel.css" % home).exists, \
+ "shared panel css missing from the stowed theme"
diff --git a/scripts/testing/tests/test_dotfiles.py b/scripts/testing/tests/test_dotfiles.py
new file mode 100644
index 0000000..cd6e474
--- /dev/null
+++ b/scripts/testing/tests/test_dotfiles.py
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: dotfiles stowed for the user.
+
+Parity port of validate_dotfiles from validation.sh: .zshrc must be a symlink
+into the ~/.dotfiles stow tree, not broken, and readable by the user (not just
+root).
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_zshrc_stowed_and_readable(host, target_user):
+ zshrc = host.file("/home/%s/.zshrc" % target_user)
+ assert zshrc.is_symlink, ".zshrc should be a stow symlink"
+ assert ".dotfiles/" in zshrc.linked_to, "symlink should point into ~/.dotfiles"
+ assert zshrc.exists, "symlink target must exist (not broken)"
+ # Readable by the user, not only root.
+ assert host.run("sudo -u %s test -r %s" % (target_user, zshrc.path)).rc == 0
diff --git a/scripts/testing/tests/test_hardening.py b/scripts/testing/tests/test_hardening.py
new file mode 100644
index 0000000..f12b0e6
--- /dev/null
+++ b/scripts/testing/tests/test_hardening.py
@@ -0,0 +1,50 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: security/system hardening archsetup applies.
+
+Expansion coverage (P4) — these were not in the original shell sweep. They
+assert the system-level changes archsetup makes in place: sshd root hardening,
+quiet kernel console, an emptied /etc/issue, the console font, and the EFI
+mount permission tightening.
+"""
+
+import pytest
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_sshd_root_prohibit_password(host):
+ conf = host.file("/etc/ssh/sshd_config.d/10-hardening.conf")
+ assert conf.exists, "sshd hardening drop-in missing"
+ assert "PermitRootLogin prohibit-password" in conf.content_string
+
+
+@pytest.mark.attribution("archsetup")
+def test_quiet_printk_sysctl(host):
+ conf = host.file("/etc/sysctl.d/20-quiet-printk.conf")
+ assert conf.exists
+ assert "kernel.printk" in conf.content_string
+
+
+@pytest.mark.attribution("archsetup")
+def test_issue_emptied(host):
+ # archsetup truncates /etc/issue to drop the distro/date banner.
+ assert host.file("/etc/issue").size == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_console_font_configured(host):
+ assert "ter-132n" in host.file("/etc/vconsole.conf").content_string
+
+
+@pytest.mark.attribution("archsetup")
+def test_efi_mount_permissions_tightened(host):
+ # archsetup adds fmask/dmask to the /efi vfat line so it isn't world-readable.
+ fstab = host.file("/etc/fstab").content_string
+ efi_lines = [
+ ln for ln in fstab.splitlines()
+ if ln.strip() and not ln.lstrip().startswith("#")
+ and " /efi " in ln and " vfat " in ln
+ ]
+ if not efi_lines:
+ pytest.skip("no /efi vfat line in fstab")
+ assert all("fmask=" in ln for ln in efi_lines), "/efi mount not permission-tightened"
diff --git a/scripts/testing/tests/test_keyring.py b/scripts/testing/tests/test_keyring.py
new file mode 100644
index 0000000..99d322d
--- /dev/null
+++ b/scripts/testing/tests/test_keyring.py
@@ -0,0 +1,35 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: gnome-keyring pre-configuration.
+
+Parity port of validate_gnome_keyring_setup: the keyrings dir must exist, be
+mode 700, owned by the user, with the default keyring set to "login".
+"""
+
+import pytest
+
+
+@pytest.fixture(scope="session")
+def keyring_dir(home):
+ return "%s/.local/share/keyrings" % home
+
+
+@pytest.mark.attribution("archsetup")
+def test_keyring_dir_exists(host, keyring_dir):
+ assert host.file(keyring_dir).is_directory
+
+
+@pytest.mark.attribution("archsetup")
+def test_keyring_dir_mode_700(host, keyring_dir):
+ assert host.file(keyring_dir).mode == 0o700
+
+
+@pytest.mark.attribution("archsetup")
+def test_keyring_dir_owned_by_user(host, keyring_dir, target_user):
+ assert host.file(keyring_dir).user == target_user
+
+
+@pytest.mark.attribution("archsetup")
+def test_default_keyring_is_login(host, keyring_dir):
+ default = host.file("%s/default" % keyring_dir)
+ assert default.exists, "default keyring file missing"
+ assert default.content_string.strip() == "login"
diff --git a/scripts/testing/tests/test_packages.py b/scripts/testing/tests/test_packages.py
new file mode 100644
index 0000000..e0387d6
--- /dev/null
+++ b/scripts/testing/tests/test_packages.py
@@ -0,0 +1,109 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: package managers and key packages.
+
+Parity port of validate_yay_installed, validate_pacman_working,
+validate_terminus_font, validate_emacs, validate_git_config, validate_dev_tools.
+"""
+
+import pytest
+
+
+DEV_TOOLS = ["python", "node", "npm", "go", "rustc"]
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_yay_installed(host):
+ assert host.exists("yay"), "yay binary not on PATH"
+
+
+@pytest.mark.attribution("archsetup")
+def test_yay_functional(host, target_user):
+ # yay must actually query the package DB as the user, not just exist.
+ assert host.run("sudo -u %s yay -Qi yay" % target_user).rc == 0
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("unknown")
+def test_pacman_functional(host):
+ assert host.package("base").is_installed
+
+
+@pytest.mark.attribution("archsetup")
+def test_terminus_font_installed(host):
+ assert host.package("terminus-font").is_installed
+
+
+@pytest.mark.attribution("archsetup")
+def test_emacs_installed(host):
+ assert host.exists("emacs")
+
+
+@pytest.mark.attribution("archsetup")
+def test_emacs_config_readable_by_user(host, target_user, home):
+ emacsd = host.file("%s/.emacs.d" % home)
+ if not emacsd.exists:
+ pytest.skip(".emacs.d not present (config dir optional on some profiles)")
+ assert emacsd.is_directory
+ assert host.run("sudo -u %s ls %s" % (target_user, emacsd.path)).rc == 0
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_git_installed(host):
+ assert host.exists("git")
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("tool", DEV_TOOLS)
+def test_dev_tool_present(host, tool):
+ assert host.exists(tool), "dev tool %s missing from PATH" % tool
+
+
+BLUETOOTH_STACK = ["bluez", "bluez-utils"]
+VPN_STACK = ["wireguard-tools", "proton-vpn-cli", "tailscale"]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("pkg", BLUETOOTH_STACK)
+def test_bluetooth_stack_installed(host, pkg):
+ assert host.package(pkg).is_installed
+
+
+# bt panel replaced blueman; zoom-web replaced zoom; the net panel's Tunnels
+# view + proton-vpn-cli replaced the GTK app (they can't run concurrently).
+RETIRED_PACKAGES = ["blueman", "zoom", "proton-vpn-gtk-app"]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("pkg", RETIRED_PACKAGES)
+def test_retired_package_not_installed(host, pkg):
+ # A reappearance means an install step regressed.
+ assert not host.package(pkg).is_installed
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("pkg", VPN_STACK)
+def test_vpn_stack_installed(host, pkg):
+ assert host.package(pkg).is_installed
+
+
+@pytest.mark.attribution("archsetup")
+def test_tailscale_operator_granted(host, target_user):
+ # The installer grants operator so the net panel can toggle tailscale
+ # without sudo. Prefs only answer when the daemon is up.
+ if not host.service("tailscaled").is_running:
+ pytest.skip("tailscaled not running")
+ out = host.run("tailscale debug prefs")
+ assert out.rc == 0, "tailscale debug prefs failed"
+ assert '"OperatorUser": "%s"' % target_user in out.stdout
+
+
+@pytest.mark.attribution("archsetup")
+def test_eask_installed_user_local(host, home):
+ # Installed via npm -g --prefix ~/.local as the user; chime and
+ # linear-emacs shell out to it.
+ f = host.file("%s/.local/bin/eask" % home)
+ assert f.exists, "eask missing from ~/.local/bin"
+ npmrc = host.file("%s/.npmrc" % home)
+ assert npmrc.exists, ".npmrc (user npm prefix) not stowed"
diff --git a/scripts/testing/tests/test_services.py b/scripts/testing/tests/test_services.py
new file mode 100644
index 0000000..0ca3970
--- /dev/null
+++ b/scripts/testing/tests/test_services.py
@@ -0,0 +1,103 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: services, timers, and their functional health.
+
+Parity port of validate_firewall, validate_dns_config, validate_avahi,
+validate_fail2ban, validate_networkmanager, and validate_all_services /
+validate_service_functions.
+
+Mapping of the shell sweep's three outcomes:
+ - validation_fail (hard) -> assert
+ - validation_warn (soft) -> pytest.skip with the reason (visible, never red)
+ - validation_skip (precond)-> pytest.skip gated on a fixture
+"""
+
+import pytest
+
+
+# Required services: (name, must_be_active). ufw can't activate in the VM (no
+# iptables kernel modules), so it's enabled-only; cronie/atd are enabled-only too.
+REQUIRED_ENABLED_ACTIVE = ["sshd", "systemd-resolved", "fail2ban", "NetworkManager", "rngd"]
+REQUIRED_ENABLED_ONLY = ["ufw", "cronie", "atd"]
+REQUIRED_TIMERS = ["reflector.timer", "paccache.timer"]
+OPTIONAL_SERVICES = ["avahi-daemon", "bluetooth", "cups", "docker", "tailscaled"]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("svc", REQUIRED_ENABLED_ACTIVE)
+def test_required_service_enabled_and_active(host, svc):
+ s = host.service(svc)
+ assert s.is_enabled, "%s should be enabled" % svc
+ assert s.is_running, "%s should be active" % svc
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("svc", REQUIRED_ENABLED_ONLY)
+def test_required_service_enabled(host, svc):
+ assert host.service(svc).is_enabled, "%s should be enabled" % svc
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("timer", REQUIRED_TIMERS)
+def test_required_timer_enabled(host, timer):
+ assert host.service(timer).is_enabled, "%s should be enabled" % timer
+
+
+@pytest.mark.parametrize("svc", OPTIONAL_SERVICES)
+def test_optional_service(host, svc):
+ # Optional: warn-if-missing in the shell sweep -> skip here so it never reds.
+ if not host.service(svc).is_enabled:
+ pytest.skip("%s not enabled (optional)" % svc)
+
+
+@pytest.mark.attribution("archsetup")
+def test_dns_over_tls_dropin_present(host):
+ # archsetup ships /etc/systemd/resolved.conf.d/dns-over-tls.conf.
+ assert host.file("/etc/systemd/resolved.conf.d/dns-over-tls.conf").exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_fail2ban_responds(host):
+ assert host.run("fail2ban-client status").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_networkmanager_responds(host):
+ assert host.run("nmcli general status").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_log_cleanup_cron_installed(host, target_user):
+ out = host.run("sudo -u %s crontab -l" % target_user).stdout
+ assert "log-cleanup" in out, "log-cleanup entry missing from user crontab"
+
+
+@pytest.mark.attribution("archsetup")
+def test_syncthing_user_lingering_enabled(host, target_user):
+ # syncthing runs as a user service; lingering must be on for autostart.
+ assert host.file("/var/lib/systemd/linger/%s" % target_user).exists
+
+
+def test_dns_resolution(host):
+ # Network-dependent; advisory in the shell sweep. Skip on failure.
+ if host.run("resolvectl query archlinux.org").rc != 0:
+ pytest.skip("DNS resolution query failed (network-dependent)")
+
+
+def test_mdns_resolves(host, on_slirp):
+ # mDNS needs multicast, which QEMU slirp doesn't pass.
+ if on_slirp:
+ pytest.skip("mDNS not possible on slirp networking (no multicast)")
+ if not host.service("avahi-daemon").is_enabled:
+ pytest.skip("avahi-daemon not enabled")
+ hostname = host.run("hostname").stdout.strip()
+ assert host.run("ping -c 1 -W 2 %s.local" % hostname).rc == 0
+
+
+def test_docker_functional(host):
+ if not host.service("docker").is_enabled:
+ pytest.skip("docker not enabled")
+ if not host.service("docker").is_running:
+ # archsetup enables docker for next boot, not --now; pre-reboot this is correct.
+ pytest.skip("docker enabled but not started (starts on boot by design)")
+ assert host.run("docker info").rc == 0
diff --git a/scripts/testing/tests/test_users.py b/scripts/testing/tests/test_users.py
new file mode 100644
index 0000000..c0097ed
--- /dev/null
+++ b/scripts/testing/tests/test_users.py
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: the user account archsetup creates.
+
+Parity port of validate_user_created / validate_user_shell / validate_user_groups.
+"""
+
+import pytest
+
+
+# Groups archsetup adds: wheel (useradd -G), the usermod -aG set, and docker
+# (added later in the developer-workstation step).
+EXPECTED_GROUPS = [
+ "wheel", "sys", "adm", "network", "scanner", "power", "uucp",
+ "audio", "lp", "rfkill", "video", "storage", "optical", "users", "docker",
+]
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_user_exists(host, target_user):
+ assert host.user(target_user).exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_user_shell_is_zsh(host, target_user):
+ # archsetup may set either path depending on how zsh resolves.
+ assert host.user(target_user).shell in ("/bin/zsh", "/usr/bin/zsh")
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("group", EXPECTED_GROUPS)
+def test_user_in_group(host, target_user, group):
+ # Parametrized so a failure names the exact missing group.
+ assert group in host.user(target_user).groups
diff --git a/scripts/wipedisk b/scripts/wipedisk
index 0c08c72..b833407 100644
--- a/scripts/wipedisk
+++ b/scripts/wipedisk
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Craig Jennings <c@cjennings.net>
# identify disk and erase
diff --git a/scripts/zfs-pre-snapshot b/scripts/zfs-pre-snapshot
new file mode 100755
index 0000000..ed914d0
--- /dev/null
+++ b/scripts/zfs-pre-snapshot
@@ -0,0 +1,43 @@
+#!/bin/bash
+# Snapshot the root dataset before a pacman transaction, then prune to the most
+# recent $KEEP pre-pacman snapshots. Run from the zfs-snapshot.hook pacman hook
+# (PreTransaction). Sanoid doesn't manage these (they aren't autosnap_ names),
+# so retention is enforced here at creation time.
+#
+# Defaults match the live zroot layout; the ZFS_PRE_* env vars override them so
+# the pruning logic is unit-testable against a fake zfs on PATH.
+
+POOL="${ZFS_PRE_POOL:-zroot}"
+DATASET="${ZFS_PRE_DATASET:-$POOL/ROOT/default}"
+LOCKFILE="${ZFS_PRE_LOCKFILE:-/tmp/.zfs-pre-snapshot.lock}"
+MIN_INTERVAL="${ZFS_PRE_MIN_INTERVAL:-60}"
+KEEP="${ZFS_PRE_KEEP:-10}" # pre-pacman snapshots to retain (recent-transaction rollback)
+
+# Skip if a snapshot was created within the last $MIN_INTERVAL seconds. A single
+# pacman invocation can fire several transactions; this stops a burst of them
+# from each cutting a near-identical snapshot.
+if [ -f "$LOCKFILE" ]; then
+ last=$(stat -c %Y "$LOCKFILE" 2>/dev/null || echo 0)
+ now=$(date +%s)
+ if (( now - last < MIN_INTERVAL )); then
+ exit 0
+ fi
+fi
+
+TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
+SNAPSHOT_NAME="pre-pacman_$TIMESTAMP"
+
+if zfs snapshot "$DATASET@$SNAPSHOT_NAME"; then
+ echo "Created snapshot: $DATASET@$SNAPSHOT_NAME"
+ touch "$LOCKFILE"
+
+ # Keep only the most recent $KEEP pre-pacman snapshots; destroy older ones.
+ zfs list -H -o name -t snapshot -s creation "$DATASET" 2>/dev/null \
+ | grep '@pre-pacman_' \
+ | head -n -"$KEEP" \
+ | while read -r old; do
+ zfs destroy "$old" && echo "Pruned old snapshot: $old"
+ done
+else
+ echo "Warning: Failed to create snapshot" >&2
+fi
diff --git a/scripts/zfs-replicate b/scripts/zfs-replicate
index cf946f1..02ffcf5 100755
--- a/scripts/zfs-replicate
+++ b/scripts/zfs-replicate
@@ -1,4 +1,5 @@
#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# zfs-replicate - Replicate ZFS datasets to TrueNAS
#
# Usage:
diff --git a/tests/backup-system-file/test_backup_system_file.py b/tests/backup-system-file/test_backup_system_file.py
new file mode 100644
index 0000000..5d48d03
--- /dev/null
+++ b/tests/backup-system-file/test_backup_system_file.py
@@ -0,0 +1,161 @@
+"""Tests for the backup_system_file helper in the archsetup installer.
+
+backup_system_file snapshots a pre-existing system file to
+`<path>.archsetup.bak` before archsetup edits it in place, so a botched
+in-place edit (fstab, mkinitcpio.conf, sudoers, ...) is recoverable. It is
+idempotent: it never overwrites an existing backup, so the pristine original
+survives repeated edits within a run and across re-runs of the installer. It
+no-ops (success) when the target does not exist.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), so the production code path is what runs.
+Edits run against real temp files the test creates and tears down.
+
+Run from repo root:
+ python3 -m unittest tests.backup-system-file.test_backup_system_file
+"""
+
+import os
+import shutil
+import stat
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+class BackupHarness(unittest.TestCase):
+ """Source backup_system_file out of the real archsetup script and invoke it."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="backup-system-file-test-")
+ # A bash wrapper that extracts just the backup_system_file function from
+ # the real installer and invokes it with the test's arg. Sourcing the
+ # sed-extracted function means we test the production code path, not a
+ # reimplementation. The helper is self-contained (prints its own
+ # warnings), so no logger stub is needed.
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n '/^backup_system_file() {/,/^}/p' \"$ARCHSETUP\")\n"
+ 'backup_system_file "$@"\n'
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ # Restore writability in case a test made a dir read-only.
+ for root, dirs, _ in os.walk(self.tmp):
+ for d in dirs:
+ os.chmod(os.path.join(root, d), 0o755)
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def run_backup(self, target):
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP, target],
+ capture_output=True, text=True, timeout=10,
+ )
+
+ def write(self, name, content, mode=None):
+ path = os.path.join(self.tmp, name)
+ with open(path, "w") as f:
+ f.write(content)
+ if mode is not None:
+ os.chmod(path, mode)
+ return path
+
+
+# -----------------------------------------------------------------------------
+# Normal cases
+# -----------------------------------------------------------------------------
+
+class TestBackupNormal(BackupHarness):
+
+ def test_existing_file_is_backed_up_with_same_content(self):
+ target = self.write("fstab", "UUID=abc / ext4 defaults 0 1\n")
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ backup = target + ".archsetup.bak"
+ self.assertTrue(os.path.isfile(backup), "backup should be created")
+ with open(backup) as f:
+ self.assertEqual(f.read(), "UUID=abc / ext4 defaults 0 1\n")
+
+ def test_backup_preserves_mode(self):
+ # sudoers ships 0440; a restored backup must keep restrictive perms.
+ target = self.write("sudoers", "root ALL=(ALL) ALL\n", mode=0o440)
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ backup = target + ".archsetup.bak"
+ self.assertEqual(stat.S_IMODE(os.stat(backup).st_mode), 0o440)
+
+
+# -----------------------------------------------------------------------------
+# Boundary cases
+# -----------------------------------------------------------------------------
+
+class TestBackupBoundary(BackupHarness):
+
+ def test_existing_backup_is_not_overwritten(self):
+ # The pristine original must survive a later edit + second backup call.
+ target = self.write("pacman.conf", "PRISTINE\n")
+ self.assertEqual(self.run_backup(target).returncode, 0)
+ # Simulate archsetup editing the file in place, then backing up again.
+ with open(target, "w") as f:
+ f.write("EDITED\n")
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ with open(target + ".archsetup.bak") as f:
+ self.assertEqual(f.read(), "PRISTINE\n", "backup must stay pristine")
+
+ def test_missing_target_is_a_quiet_noop(self):
+ target = os.path.join(self.tmp, "never-existed.conf")
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ self.assertFalse(os.path.exists(target + ".archsetup.bak"))
+
+ def test_second_call_same_run_is_a_noop(self):
+ # A file edited twice in one run (e.g. mkinitcpio MODULES then HOOKS)
+ # gets backed up once; the second call must not error or re-copy.
+ target = self.write("mkinitcpio.conf", "HOOKS=(base udev)\n")
+ self.assertEqual(self.run_backup(target).returncode, 0)
+ backup = target + ".archsetup.bak"
+ first_mtime = os.stat(backup).st_mtime_ns
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ self.assertEqual(os.stat(backup).st_mtime_ns, first_mtime,
+ "backup must not be rewritten on the second call")
+
+
+# -----------------------------------------------------------------------------
+# Error cases
+# -----------------------------------------------------------------------------
+
+class TestBackupErrors(BackupHarness):
+
+ def test_empty_target_is_refused(self):
+ result = self.run_backup("")
+ self.assertNotEqual(result.returncode, 0)
+
+ def test_copy_failure_returns_nonzero(self):
+ # Target exists but its directory is read-only, so the .bak can't be
+ # written. The helper must report failure rather than silently skip.
+ subdir = os.path.join(self.tmp, "ro")
+ os.makedirs(subdir)
+ target = os.path.join(subdir, "fstab")
+ with open(target, "w") as f:
+ f.write("data\n")
+ os.chmod(subdir, 0o500) # r-x: owner cannot create the .bak here
+ try:
+ result = self.run_backup(target)
+ finally:
+ os.chmod(subdir, 0o755)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertFalse(os.path.exists(target + ".archsetup.bak"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/gallery-probes/README.md b/tests/gallery-probes/README.md
new file mode 100644
index 0000000..9b5ce7d
--- /dev/null
+++ b/tests/gallery-probes/README.md
@@ -0,0 +1,26 @@
+# Gallery CDP probes
+
+Behavioral verification for docs/prototypes/panel-widget-gallery.html — headless Chrome
+driven over the DevTools protocol (Node global WebSocket/fetch, no playwright).
+
+- probe.mjs — full regression: card count, size toggle, drag + click behavior under zoom,
+ zero-exception gate. Run: `node probe.mjs [--shot out.png]`. Update the card-count
+ assertion when cards are added.
+- probe-fams.mjs — screen-color families: default fallbacks pixel-identical, chip clicks
+ recolor ink/face/trace, dynamic fills stay var-based.
+
+Both exit nonzero on failure. The gallery's componentization work (widgets.js extraction)
+uses a green run of these as its no-regression gate per batch.
+
+## Traps when writing new checks
+
+- Dispatch clicks on the element that owns the listener, not an ancestor; drive drags
+ with in-page synthetic PointerEvents (setPointerCapture stubbed).
+- A `find()` by textContent that matches nothing returns undefined, and the dispatch
+ no-ops silently — the check then fails looking like a widget bug. Match glyphs
+ exactly against the builder source (e.g. the transport stop button is '⏹' U+23F9,
+ not '■' U+25A0), and prefer asserting the find() hit before dispatching.
+- Reduced-motion emulation must be set before Page.navigate (launch on about:blank,
+ Emulation.setEmulatedMedia, then navigate) or the page's matchMedia snapshot misses it.
+- Kill stale headless Chromes after a crashed run; bracket the pkill pattern
+ (`pkill -f 'remote-debugging-port=934[5]'`) so it can't match its own command line.
diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs
new file mode 100644
index 0000000..99b4e92
--- /dev/null
+++ b/tests/gallery-probes/probe-fams.mjs
@@ -0,0 +1,64 @@
+// Verify screen-family chips: presence, default fallbacks intact, live recolor on click.
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+const PORT = 9335;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const chrome = spawn('google-chrome-stable', ['--headless=new', `--remote-debugging-port=${PORT}`, '--no-first-run', '--window-size=1600,1200', URL], { stdio: 'ignore' });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let ws, id = 0; const pending = new Map(); const events = [];
+for (let i = 0; i < 40; i++) { try { const l = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); const p = l.find(t => t.type === 'page' && t.url.startsWith('file')); if (p) { ws = new WebSocket(p.webSocketDebuggerUrl); break; } } catch {} await sleep(250); }
+await new Promise(r => ws.onopen = r);
+ws.onmessage = m => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method) events.push(d); };
+const send = (method, params = {}) => new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+const evl = async e => { const r = await send('Runtime.evaluate', { expression: e, returnByValue: true }); if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails)); return r.result.result.value; };
+const fails = [];
+const ok = (n, c, d = '') => { console.log(`${c ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`); if (!c) fails.push(n); };
+await send('Runtime.enable'); await sleep(1500);
+
+ok('no exceptions on load', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+// count screen-family rows only — card option groups (e.g. card 01) reuse .famchips
+ok('5 screen chip rows', await evl(`[...document.querySelectorAll('.famchips .lab')].filter(l=>l.textContent==='screen').length`) === 5);
+
+// default fallback: dmx ink computes to gold-hi rgb(255,190,84)
+const dmxFill0 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`);
+ok('R10 default ink = gold-hi', dmxFill0 === 'rgb(255, 190, 84)', dmxFill0);
+
+// click green chip on R10 -> ink becomes phos rgb(127,224,160)
+await evl(`document.querySelector('#card-R10').querySelector('.fc[title="green"]').click()`);
+const dmxFill1 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`);
+ok('R10 green chip recolors ink', dmxFill1 === 'rgb(127, 224, 160)', dmxFill1);
+
+// radar: default sweep line = gold-hi; click green -> phos
+const swSel = `document.querySelector('#card-R31')`;
+const line0 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`);
+await evl(`${swSel}.querySelector('.fc[title="green"]').click()`);
+const line1 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`);
+ok('R31 green chip recolors furniture', line0 !== line1 && line1 === 'rgb(88, 184, 126)', `${line0} -> ${line1}`);
+
+// scope (CSS widget): trace stroke changes on amber chip
+const tr0 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
+await evl(`document.querySelector('.scope').closest('.card').querySelector('.fc[title="amber"]').click()`);
+const tr1 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
+ok('N11 amber chip recolors trace', tr0 !== tr1 && tr1 === 'rgb(255, 190, 84)', `${tr0} -> ${tr1}`);
+
+// R19: drag still works after family switch (click vfd, then check the handle's set() writes var-based fills)
+await evl(`document.querySelector('#card-R19 .fc[title="vfd"]').click()`);
+await evl(`document.getElementById('card-R19').gw.set(30,70)`);
+const barFill = await evl(`(()=>{const b=document.querySelectorAll('#card-R19 svg rect')[10];return b.getAttribute('fill');})()`);
+ok('R19 dynamic fills use vars', barFill.startsWith('var(--scr-'), barFill);
+
+// R17: face gradient stop resolves to amber face after chip
+await evl(`document.querySelector('#card-R17 .fc[title="amber"]').click()`);
+const face = await evl(`getComputedStyle(document.querySelector('#card-R17 svg radialGradient stop')).stopColor`);
+ok('R17 amber chip retints face', face === 'rgb(216, 203, 166)', face);
+
+ok('no exceptions after chip clicks', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+
+// screenshot the recolored screens region (scroll R31 into view)
+await evl(`document.querySelector('#card-R31 svg').scrollIntoView({block:'center'})`);
+await sleep(400);
+const shot = await send('Page.captureScreenshot', { format: 'png' });
+writeFileSync('fams.png', Buffer.from(shot.result.data, 'base64'));
+console.log('shot: fams.png');
+chrome.kill();
+process.exit(fails.length ? 1 : 0);
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
new file mode 100644
index 0000000..17b3d73
--- /dev/null
+++ b/tests/gallery-probes/probe.mjs
@@ -0,0 +1,120 @@
+// CDP probe for the widget gallery — no playwright, Node global WebSocket/fetch.
+// Usage: node probe.mjs [--shot out.png] [--size N]
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+
+const PORT = 9333;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const shotIdx = process.argv.indexOf('--shot');
+const shotPath = shotIdx > -1 ? process.argv[shotIdx + 1] : null;
+
+const chrome = spawn('google-chrome-stable', [
+ '--headless=new', `--remote-debugging-port=${PORT}`,
+ '--no-first-run', '--no-default-browser-check',
+ '--window-size=1600,1200', URL,
+], { stdio: 'ignore' });
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let ws, id = 0;
+const pending = new Map();
+const events = [];
+
+async function connect() {
+ for (let i = 0; i < 40; i++) {
+ try {
+ const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
+ const page = list.find(t => t.type === 'page' && t.url.startsWith('file'));
+ if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; }
+ } catch { /* retry */ }
+ await sleep(250);
+ }
+ if (!ws) throw new Error('no page target');
+ await new Promise(r => ws.onopen = r);
+ ws.onmessage = m => {
+ const d = JSON.parse(m.data);
+ if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); }
+ else if (d.method) events.push(d);
+ };
+}
+function send(method, params = {}) {
+ return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+}
+async function evl(expr) {
+ const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true });
+ if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text));
+ return r.result.result.value;
+}
+async function drag(x1, y1, x2, y2) {
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, button: 'left', clickCount: 1 });
+ const steps = 8;
+ for (let i = 1; i <= steps; i++)
+ await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1 + (x2 - x1) * i / steps, y: y1 + (y2 - y1) * i / steps, button: 'left' });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, button: 'left', clickCount: 1 });
+}
+
+const fails = [];
+const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); };
+
+try {
+ await connect();
+ await send('Runtime.enable');
+ await send('Page.enable');
+ await sleep(1500);
+
+ // 0. console errors / exceptions
+ const errs = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200));
+
+ // 1. defaults: size=2 (M), card count
+ ok('default size 2', await evl(`document.body.dataset.size`) === '2');
+ const cards = await evl(`document.querySelectorAll('.card').length`);
+ ok('109 cards', cards === 109, `got ${cards}`);
+
+ // 2. zoom actually scales: card visual width at 3x vs 1x
+ await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
+ const w3 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ await evl(`document.querySelector('.szbar .key[data-sz="1"]').click()`);
+ const w1 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ ok('3x wider than 1x', w3 > w1 * 1.8, `w3=${Math.round(w3)} w1=${Math.round(w1)}`);
+ ok('size chip flips state', await evl(`document.body.dataset.size`) === '1');
+
+ // 3. behavioral at 3x: fader drag on card 03 (horizontal fader) changes readout
+ await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
+ await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`);
+ const before = await evl(`document.getElementById('rd-03').textContent`);
+ await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2);
+ await sleep(150);
+ const after = await evl(`document.getElementById('rd-03').textContent`);
+ ok('fader drag tracks at 3x', before !== after && after !== '—', `'${before}' -> '${after}'`);
+
+ // 4. behavioral at 3x: toggle click on card 01
+ await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`);
+ const t0 = await evl(`document.getElementById('rd-01').textContent`);
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await sleep(150);
+ const t1 = await evl(`document.getElementById('rd-01').textContent`);
+ ok('toggle click responds at 3x', t0 !== t1, `'${t0}' -> '${t1}'`);
+
+ // late exceptions from interactions
+ const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions after interaction', errs2.length === 0);
+
+ if (shotPath) {
+ await evl(`window.scrollTo(0,0)`);
+ await sleep(300);
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
+ writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64'));
+ console.log('shot: ' + shotPath);
+ }
+} catch (e) {
+ console.error('PROBE ERROR: ' + e.message);
+ fails.push('probe-error');
+} finally {
+ chrome.kill();
+}
+process.exit(fails.length ? 1 : 0);
diff --git a/tests/gallery-tokens/test_gen_tokens.py b/tests/gallery-tokens/test_gen_tokens.py
new file mode 100644
index 0000000..b9fbb50
--- /dev/null
+++ b/tests/gallery-tokens/test_gen_tokens.py
@@ -0,0 +1,278 @@
+"""Tests for docs/prototypes/gen_tokens.py.
+
+gen_tokens.py is the single-source token generator for the panel widget
+gallery. It reads docs/prototypes/tokens.json (the neutral source of truth
+for the design tokens) and emits three target representations from it:
+
+ - web CSS custom properties (:root { --gold:#e2a038; ... })
+ - waybar GTK CSS (@define-color gold #e2a038; ...)
+ - Emacs elisp (an alist for the future svg.el renderer)
+
+The three differ on purpose: CSS uses hyphenated --vars and stores glow
+colors as bare "r,g,b" triples (so rgba(var(--glow-hi),.5) works); GTK CSS
+has no custom properties, so it uses @define-color with underscore names
+and resolves glows to their source hex (GTK uses alpha(@color,a)); elisp
+uses a hyphenated-symbol alist of hex strings. One source, three emitters
+is the whole reason the generator earns its keep across the three targets.
+
+These tests import the REAL gen_tokens module from docs/prototypes/ (not a
+copy) and exercise its functions directly against fixture token dicts, plus
+one integration test that runs main() against temp copies.
+
+Run from repo root:
+ make test-unit
+ (or python3 -m unittest tests.gallery-tokens.test_gen_tokens)
+"""
+
+import importlib.util
+import json
+import os
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+GEN = os.path.join(REPO_ROOT, "docs", "prototypes", "gen_tokens.py")
+TOKENS_JSON = os.path.join(REPO_ROOT, "docs", "prototypes", "tokens.json")
+
+
+def _load_module():
+ """Import the real gen_tokens.py by path (no importable package name)."""
+ spec = importlib.util.spec_from_file_location("gen_tokens", GEN)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+gt = _load_module()
+
+
+# A small deterministic fixture — not the real tokens.json, so these tests
+# don't drift when the palette is retuned.
+FIXTURE = {
+ "palette": {"ground": "#151311", "wash": "#2c2f32", "slate-hi": "#54677d"},
+ "amber": {"gold": "#e2a038", "gold-hi": "#ffbe54", "amber-grad-top": "#f2c76a"},
+ "glow": {"glow-hi": "gold-hi", "glow-lo": "gold"},
+ "font": {"mono": '"Berkeley Mono",monospace'},
+ "timing": {"pulse-rate": "1s"},
+}
+
+
+class HexToTriple(unittest.TestCase):
+ def test_normal_values(self):
+ self.assertEqual(gt.hex_to_triple("#ffbe54"), "255,190,84")
+ self.assertEqual(gt.hex_to_triple("#e2a038"), "226,160,56")
+
+ def test_boundary_black_and_white(self):
+ self.assertEqual(gt.hex_to_triple("#000000"), "0,0,0")
+ self.assertEqual(gt.hex_to_triple("#ffffff"), "255,255,255")
+
+ def test_boundary_three_digit_shorthand(self):
+ self.assertEqual(gt.hex_to_triple("#fff"), "255,255,255")
+ self.assertEqual(gt.hex_to_triple("#0f0"), "0,255,0")
+
+ def test_boundary_no_leading_hash(self):
+ self.assertEqual(gt.hex_to_triple("e2a038"), "226,160,56")
+
+ def test_error_bad_chars(self):
+ with self.assertRaises(ValueError):
+ gt.hex_to_triple("#gggggg")
+
+ def test_error_bad_length(self):
+ with self.assertRaises(ValueError):
+ gt.hex_to_triple("#12")
+
+
+class ResolveColor(unittest.TestCase):
+ def test_finds_in_amber(self):
+ self.assertEqual(gt.resolve_color(FIXTURE, "gold"), "#e2a038")
+
+ def test_finds_in_palette(self):
+ self.assertEqual(gt.resolve_color(FIXTURE, "wash"), "#2c2f32")
+
+ def test_missing_raises(self):
+ with self.assertRaises(KeyError):
+ gt.resolve_color(FIXTURE, "nonexistent")
+
+
+class EmitWebCss(unittest.TestCase):
+ def setUp(self):
+ self.css = gt.emit_web_css(FIXTURE)
+
+ def test_solid_colors_are_hyphenated_vars(self):
+ self.assertIn("--gold:#e2a038;", self.css)
+ self.assertIn("--slate-hi:#54677d;", self.css)
+ self.assertIn("--amber-grad-top:#f2c76a;", self.css)
+
+ def test_glow_stored_as_rgb_triple(self):
+ # so rgba(var(--glow-hi),.5) resolves; derived from the source hex
+ self.assertIn("--glow-hi:255,190,84;", self.css)
+ self.assertIn("--glow-lo:226,160,56;", self.css)
+
+ def test_font_and_timing(self):
+ self.assertIn("--pulse-rate:1s;", self.css)
+ self.assertIn('--mono:"Berkeley Mono",monospace;', self.css)
+
+
+class EmitWaybarGtk(unittest.TestCase):
+ def setUp(self):
+ self.gtk = gt.emit_waybar_gtk(FIXTURE)
+
+ def test_uses_define_color_not_custom_props(self):
+ self.assertIn("@define-color gold #e2a038;", self.gtk)
+ self.assertNotIn("--gold", self.gtk)
+ self.assertNotIn(":root", self.gtk)
+
+ def test_glow_resolved_to_source_hex(self):
+ # GTK has no bare triples; it uses alpha(@color,a), so glow -> hex
+ self.assertIn("@define-color glow_hi #ffbe54;", self.gtk)
+ self.assertIn("@define-color glow_lo #e2a038;", self.gtk)
+
+ def test_names_use_underscores_not_hyphens(self):
+ self.assertIn("@define-color slate_hi #54677d;", self.gtk)
+ self.assertNotIn("slate-hi", self.gtk)
+ self.assertNotIn("amber-grad-top", self.gtk)
+
+
+class EmitElisp(unittest.TestCase):
+ def setUp(self):
+ self.el = gt.emit_elisp(FIXTURE)
+
+ def test_alist_of_hex_with_hyphenated_symbols(self):
+ self.assertIn('(gold . "#e2a038")', self.el)
+ self.assertIn('(slate-hi . "#54677d")', self.el)
+
+ def test_glow_is_hex_for_svg(self):
+ # svg.el / librsvg wants a real color, not a bare triple
+ self.assertIn('(glow-hi . "#ffbe54")', self.el)
+
+ def test_timing_included(self):
+ self.assertIn('(pulse-rate . "1s")', self.el)
+
+
+class ReplaceBetweenMarkers(unittest.TestCase):
+ START = "/* @tokens:start */"
+ END = "/* @tokens:end */"
+
+ def _wrap(self, inner):
+ return f"a\n{self.START}\n{inner}\n{self.END}\nb\n"
+
+ def test_replaces_inner_block(self):
+ src = self._wrap("OLD")
+ out = gt.replace_between_markers(src, self.START, self.END, "NEW")
+ self.assertIn("NEW", out)
+ self.assertNotIn("OLD", out)
+ # markers and surrounding text survive
+ self.assertIn(self.START, out)
+ self.assertIn(self.END, out)
+ self.assertTrue(out.startswith("a\n"))
+ self.assertTrue(out.rstrip().endswith("b"))
+
+ def test_idempotent(self):
+ src = self._wrap("OLD")
+ once = gt.replace_between_markers(src, self.START, self.END, "NEW")
+ twice = gt.replace_between_markers(once, self.START, self.END, "NEW")
+ self.assertEqual(once, twice)
+
+ def test_missing_marker_raises(self):
+ with self.assertRaises(ValueError):
+ gt.replace_between_markers("no markers here", self.START, self.END, "NEW")
+
+ def test_start_marker_on_final_line_without_newline(self):
+ # Defensive branch: no newline after the start marker. The guard must
+ # not let text[:si-of-newline+1] collapse to "" and wipe the prefix.
+ src = f"keep\n{self.START} {self.END}"
+ out = gt.replace_between_markers(src, self.START, self.END, "X")
+ self.assertTrue(out.startswith("keep\n"))
+ self.assertIn(self.END, out)
+ self.assertIn("X", out)
+
+
+class RealTokensJson(unittest.TestCase):
+ """The committed tokens.json must stay well-formed and complete."""
+
+ def setUp(self):
+ with open(TOKENS_JSON) as f:
+ self.tokens = json.load(f)
+
+ def test_has_required_sections(self):
+ for section in ("palette", "amber", "glow", "font", "timing"):
+ self.assertIn(section, self.tokens)
+
+ def test_amber_defines_gold_and_glow_sources_resolve(self):
+ self.assertIn("gold", self.tokens["amber"])
+ self.assertIn("gold-hi", self.tokens["amber"])
+ for _name, source in self.tokens["glow"].items():
+ # every glow source must resolve to a real color
+ gt.resolve_color(self.tokens, source)
+
+ def test_pulse_rate_present(self):
+ self.assertIn("pulse-rate", self.tokens["timing"])
+
+
+class DefaultElispFilename(unittest.TestCase):
+ """The default elisp target must be gallery-tokens.el so that the file's
+ (provide 'gallery-tokens) matches its name and `require` can resolve it
+ from a load-path. A tokens.el/gallery-tokens feature mismatch is a trap."""
+
+ def test_default_elisp_path_matches_provided_feature(self):
+ self.assertEqual(
+ os.path.basename(gt.DEFAULT_ELISP_NAME), "gallery-tokens.el")
+
+
+class MainIntegration(unittest.TestCase):
+ """main() rewrites the html :root and writes the waybar + elisp files."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="gen-tokens-test-")
+ self.html = os.path.join(self.tmp, "gallery.html")
+ self.tokens_path = os.path.join(self.tmp, "tokens.json")
+ self.waybar = os.path.join(self.tmp, "tokens-waybar.css")
+ self.elisp = os.path.join(self.tmp, "gallery-tokens.el")
+ with open(self.tokens_path, "w") as f:
+ json.dump(FIXTURE, f)
+ with open(self.html, "w") as f:
+ f.write(
+ "<style>\n:root{\n"
+ "/* @tokens:start */\n"
+ "STALE\n"
+ "/* @tokens:end */\n"
+ "}\n</style>\n"
+ )
+
+ def tearDown(self):
+ import shutil
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def test_main_writes_all_three_targets(self):
+ gt.main(
+ tokens_path=self.tokens_path,
+ html_path=self.html,
+ waybar_path=self.waybar,
+ elisp_path=self.elisp,
+ )
+ with open(self.html) as f:
+ html = f.read()
+ self.assertIn("--gold:#e2a038;", html)
+ self.assertNotIn("STALE", html)
+ self.assertTrue(os.path.exists(self.waybar))
+ self.assertTrue(os.path.exists(self.elisp))
+ with open(self.waybar) as f:
+ self.assertIn("@define-color gold #e2a038;", f.read())
+ with open(self.elisp) as f:
+ self.assertIn('(gold . "#e2a038")', f.read())
+
+ def test_main_is_idempotent(self):
+ gt.main(tokens_path=self.tokens_path, html_path=self.html,
+ waybar_path=self.waybar, elisp_path=self.elisp)
+ with open(self.html) as f:
+ first = f.read()
+ gt.main(tokens_path=self.tokens_path, html_path=self.html,
+ waybar_path=self.waybar, elisp_path=self.elisp)
+ with open(self.html) as f:
+ second = f.read()
+ self.assertEqual(first, second)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/gallery-widgets/test-gallery-widget.el b/tests/gallery-widgets/test-gallery-widget.el
new file mode 100644
index 0000000..166cd59
--- /dev/null
+++ b/tests/gallery-widgets/test-gallery-widget.el
@@ -0,0 +1,114 @@
+;;; test-gallery-widget.el --- ERT tests for gallery-widget.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the svg.el proof-of-concept renderer in docs/prototypes/.
+;; gallery-widget.el reads the generated gallery-tokens.el (same source of truth as
+;; the web gallery and the waybar CSS) and renders gallery widgets as SVG.
+;; These tests exercise the REAL module (loaded by path, not a copy):
+;; token resolution, needle-angle math (normal/boundary/error), and the
+;; rendered SVG document's structure.
+;;
+;; Run from repo root:
+;; make test-elisp
+;; (or emacs --batch -l ert -l tests/gallery-widgets/test-gallery-widget.el \
+;; -f ert-run-tests-batch-and-exit)
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar test-gallery-widget--root
+ (expand-file-name "../.." (file-name-directory (or load-file-name buffer-file-name)))
+ "Repo root, derived from this test file's location.")
+
+(load (expand-file-name "docs/prototypes/gallery-widget.el" test-gallery-widget--root))
+
+;;; --- token access ---
+
+(ert-deftest gallery-widget-token-resolves-hex ()
+ "Known tokens resolve to hex color strings from the generated alist."
+ (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'gold)))
+ (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'glow-hi)))
+ (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'wash))))
+
+(ert-deftest gallery-widget-token-missing-errors ()
+ "An unknown token name signals an error rather than returning nil."
+ (should-error (gallery-widget-token 'no-such-token)))
+
+;;; --- needle angle math ---
+
+(ert-deftest gallery-widget-needle-angle-normal ()
+ "0..100 maps linearly onto -60..+60 degrees."
+ (should (= (gallery-widget--needle-angle 0) -60.0))
+ (should (= (gallery-widget--needle-angle 50) 0.0))
+ (should (= (gallery-widget--needle-angle 100) 60.0)))
+
+(ert-deftest gallery-widget-needle-angle-boundary-clamps ()
+ "Out-of-range values clamp to the dial's ends instead of overswinging."
+ (should (= (gallery-widget--needle-angle -5) -60.0))
+ (should (= (gallery-widget--needle-angle 150) 60.0)))
+
+(ert-deftest gallery-widget-needle-angle-error-non-number ()
+ "A non-numeric value signals an error."
+ (should-error (gallery-widget--needle-angle "fifty"))
+ (should-error (gallery-widget--needle-angle nil)))
+
+;;; --- rendered SVG structure ---
+
+(defun test-gallery-widget--svg-string (value)
+ "Render the needle gauge at VALUE and return its XML string."
+ (gallery-widget-svg-string (gallery-widget-needle-gauge value)))
+
+(ert-deftest gallery-widget-gauge-renders-svg-document ()
+ "The gauge renders to a parseable SVG document."
+ (let ((xml (test-gallery-widget--svg-string 42)))
+ (should (string-match-p "\\`<svg" xml))
+ (should (with-temp-buffer
+ (insert xml)
+ (libxml-parse-xml-region (point-min) (point-max))))))
+
+(ert-deftest gallery-widget-gauge-has-expected-parts ()
+ "Arc, three ticks, needle, hub, and value text are all present."
+ (let ((xml (test-gallery-widget--svg-string 42)))
+ ;; arc path stroked in the wash token
+ (should (string-match-p (format "path[^>]*stroke=\"%s\"" (gallery-widget-token 'wash)) xml))
+ ;; exactly three ticks
+ (should (= 3 (cl-count-if (lambda (_) t)
+ (split-string xml "class=\"tick\"" t)
+ :start 1)))
+ ;; needle + hub in the amber tokens
+ (should (string-match-p (format "class=\"needle\"[^>]*stroke=\"%s\""
+ (gallery-widget-token 'gold-hi))
+ xml))
+ ;; hub is a half-dome sitting on the dial's bottom edge, like the web card
+ (should (string-match-p (format "class=\"hub\"[^>]*fill=\"%s\""
+ (gallery-widget-token 'gold))
+ xml))
+ (should (string-match-p "class=\"hub\"[^>]*d=\"M 44 48 A 4 4 0 0 1 52 48 Z\"" xml))
+ ;; value readout
+ (should (string-match-p ">42%<" xml))))
+
+(ert-deftest gallery-widget-gauge-has-glow-filter ()
+ "The needle glow is a real SVG blur filter, not a dropped effect."
+ (let ((xml (test-gallery-widget--svg-string 42)))
+ (should (string-match-p "feGaussianBlur" xml))
+ (should (string-match-p "filter=\"url(#" xml))))
+
+(ert-deftest gallery-widget-gauge-needle-tracks-value ()
+ "The needle endpoint lands where the angle math says: left at 0, up at 50, right at 100."
+ ;; value 50 -> vertical: x2 = pivot x (48), y2 = 48 - 40 = 8
+ (let ((xml (test-gallery-widget--svg-string 50)))
+ (should (string-match-p "class=\"needle\"[^>]*x2=\"48.0+\"" xml))
+ (should (string-match-p "class=\"needle\"[^>]*y2=\"8.0+\"" xml)))
+ ;; value 100 -> +60 deg: x2 = 48 + 40*sin60 ~ 82.64
+ (should (string-match-p "x2=\"82.6" (test-gallery-widget--svg-string 100)))
+ ;; value 0 -> -60 deg: x2 = 48 - 40*sin60 ~ 13.36
+ (should (string-match-p "x2=\"13.3" (test-gallery-widget--svg-string 0))))
+
+(ert-deftest gallery-widget-gauge-integer-percent-in-readout ()
+ "The readout shows a rounded integer percent, matching the web card."
+ (should (string-match-p ">67%<" (test-gallery-widget--svg-string 66.6))))
+
+(provide 'test-gallery-widget)
+;;; test-gallery-widget.el ends here
diff --git a/tests/hypr-live-update-guard/test_hypr_live_update_guard.py b/tests/hypr-live-update-guard/test_hypr_live_update_guard.py
new file mode 100644
index 0000000..a6c6f68
--- /dev/null
+++ b/tests/hypr-live-update-guard/test_hypr_live_update_guard.py
@@ -0,0 +1,165 @@
+"""Tests for the hypr-live-update-guard pacman PreTransaction hook script.
+
+The guard aborts a live pacman upgrade of GPU/compositor runtime libraries
+(mesa, hyprland, wayland, GPU drivers) while a Hyprland session is running,
+so the compositor doesn't SIGABRT when a now-"(deleted)" library is next
+called. It reads the triggering package names on stdin (pacman NeedsTargets)
+and exits non-zero to abort the transaction (AbortOnFail) before any package
+is swapped. When Hyprland isn't running, or an override is set, it exits 0
+and the upgrade proceeds.
+
+Test seams (env vars the production script honors):
+ HYPR_GUARD_RUNNING 1/0 forces the Hyprland-running check (default: pgrep)
+ HYPR_ALLOW_LIVE_UPDATE 1 overrides the guard (proceed anyway)
+ HYPR_GUARD_SENTINEL path whose existence also overrides the guard
+ HYPR_GUARD_VERSIONS "pkg installed candidate" lines replacing the
+ pacman -Q / expac -S version lookups; when set,
+ a package absent from the map reads as unknown
+ (conservative block)
+
+Run from repo root:
+ python3 -m unittest tests.hypr-live-update-guard.test_hypr_live_update_guard
+"""
+
+import os
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+GUARD = os.path.join(REPO_ROOT, "scripts", "hypr-live-update-guard")
+
+# Every package the legacy tests feed, mapped to a version CHANGE — those
+# tests describe real upgrades, and the map keeps them hermetic (no
+# pacman/expac calls against the test host).
+CHANGING_VERSIONS = "\n".join((
+ "mesa 25.1.0-1 26.0.0-1",
+ "hyprland 0.55.3-1 0.55.4-1",
+ "vulkan-radeon 25.1.0-1 26.0.0-1",
+))
+
+
+def run_guard(stdin="mesa\n", running="1", allow=None, sentinel=None,
+ versions=CHANGING_VERSIONS):
+ env = dict(os.environ)
+ env["HYPR_GUARD_RUNNING"] = running
+ if allow is not None:
+ env["HYPR_ALLOW_LIVE_UPDATE"] = allow
+ # Point the sentinel at a path that does not exist unless a test sets one,
+ # so the host's real /run state can't leak into the result.
+ env["HYPR_GUARD_SENTINEL"] = sentinel if sentinel else "/nonexistent/guard-sentinel"
+ env["HYPR_GUARD_VERSIONS"] = versions
+ return subprocess.run(
+ ["sh", GUARD],
+ input=stdin, capture_output=True, text=True, timeout=10, env=env,
+ )
+
+
+class HyprLiveUpdateGuard(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_running_with_dangerous_pkg_aborts(self):
+ r = run_guard(stdin="mesa\n", running="1")
+ self.assertEqual(r.returncode, 1, r.stderr)
+
+ def test_abort_message_names_the_package_and_tty_remedy(self):
+ r = run_guard(stdin="mesa\n", running="1")
+ self.assertIn("mesa", r.stderr)
+ self.assertIn("TTY", r.stderr)
+
+ def test_not_running_allows(self):
+ r = run_guard(stdin="mesa\n", running="0")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_not_running_is_silent(self):
+ r = run_guard(stdin="mesa\nhyprland\n", running="0")
+ self.assertEqual(r.stderr.strip(), "")
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_multiple_packages_all_listed(self):
+ r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1")
+ self.assertEqual(r.returncode, 1)
+ for pkg in ("mesa", "hyprland", "vulkan-radeon"):
+ self.assertIn(pkg, r.stderr)
+
+ def test_running_with_empty_stdin_still_guards(self):
+ # The hook only fires when dangerous targets exist, so an empty target
+ # list shouldn't normally happen; if Hyprland is up, stay safe (abort).
+ r = run_guard(stdin="", running="1")
+ self.assertEqual(r.returncode, 1)
+
+ # --- Version awareness ------------------------------------------------
+
+ def test_same_version_reinstall_allows(self):
+ # a pure reinstall replaces identical bytes with identical bytes —
+ # no live-swap hazard, the guard must let it through
+ r = run_guard(stdin="hyprland\n", running="1",
+ versions="hyprland 0.55.4-1 0.55.4-1")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertEqual(r.stderr.strip(), "")
+
+ def test_all_same_version_multi_pkg_allows(self):
+ versions = "\n".join(("mesa 26.1.4-1 26.1.4-1",
+ "hyprland 0.55.4-1 0.55.4-1",
+ "vulkan-radeon 26.1.4-1 26.1.4-1"))
+ r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1",
+ versions=versions)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_mixed_blocks_naming_only_the_version_changing(self):
+ versions = "\n".join(("mesa 25.1.0-1 26.0.0-1",
+ "hyprland 0.55.4-1 0.55.4-1"))
+ r = run_guard(stdin="mesa\nhyprland\n", running="1",
+ versions=versions)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("- mesa", r.stderr)
+ # the prose mentions the Hyprland session, so assert on the
+ # package-list line format, not the bare word
+ self.assertNotIn("- hyprland", r.stderr)
+
+ def test_unknown_versions_block_conservatively(self):
+ # seam set but package absent from the map = the lookup failed
+ # (AUR target, -U transaction, expac missing) — stay safe
+ r = run_guard(stdin="mesa\n", running="1", versions="")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("- mesa", r.stderr)
+
+ # --- Override / error cases -----------------------------------------
+
+ def test_env_override_proceeds_even_when_running(self):
+ r = run_guard(stdin="mesa\n", running="1", allow="1")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_sentinel_file_override_proceeds(self):
+ with tempfile.NamedTemporaryFile(prefix="guard-allow-") as f:
+ r = run_guard(stdin="mesa\n", running="1", sentinel=f.name)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_sentinel_is_consumed_on_use(self):
+ # one touch = one transaction: if the override's cleanup never runs
+ # (a crashed caller), a leftover sentinel must not keep the guard
+ # disarmed until reboot — the hook deletes it as it honors it
+ fd, path = tempfile.mkstemp(prefix="guard-allow-")
+ os.close(fd)
+ try:
+ r = run_guard(stdin="mesa\n", running="1", sentinel=path)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertFalse(os.path.exists(path))
+ finally:
+ if os.path.exists(path):
+ os.unlink(path)
+
+ def test_env_override_consumes_nothing(self):
+ # the env override isn't a file; nothing to consume, still proceeds
+ r = run_guard(stdin="mesa\n", running="1", allow="1")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_override_env_zero_does_not_bypass(self):
+ r = run_guard(stdin="mesa\n", running="1", allow="0")
+ self.assertEqual(r.returncode, 1, r.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/import-wireguard-configs/fake-nmcli b/tests/import-wireguard-configs/fake-nmcli
new file mode 100644
index 0000000..45b88cd
--- /dev/null
+++ b/tests/import-wireguard-configs/fake-nmcli
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Fake nmcli for the import-wireguard-configs tests.
+#
+# Behavior is driven by env vars set by the test harness:
+# FAKE_NMCLI_LOG file every invocation's args are appended to (one line
+# per call; for imports the staged file's basename and
+# content hash context are visible in the args)
+# FAKE_NMCLI_NAMES newline-separated connection names returned by
+# `nmcli -t -f NAME connection show`
+# FAKE_NMCLI_IMPORT_OUT override for the import command's stdout
+# (default: the real NM success line with a per-call
+# deterministic UUID)
+# FAKE_NMCLI_MODIFY_RC exit code for `nmcli connection modify` (default 0)
+#
+# Import calls also copy the staged file into $FAKE_NMCLI_LOG.d/ so tests can
+# assert the temp copy was named wgpvpn.conf and carried the right content.
+set -euo pipefail
+
+echo "$*" >>"$FAKE_NMCLI_LOG"
+
+case "$1 $2" in
+"-t -f")
+ # nmcli -t -f NAME connection show
+ printf '%s\n' "${FAKE_NMCLI_NAMES:-}"
+ ;;
+"connection import")
+ # nmcli connection import type wireguard file <path>
+ file="${6:?}"
+ mkdir -p "$FAKE_NMCLI_LOG.d"
+ n=$(find "$FAKE_NMCLI_LOG.d" -type f | wc -l)
+ cp "$file" "$FAKE_NMCLI_LOG.d/import-$n-$(basename "$file")"
+ if [ -n "${FAKE_NMCLI_IMPORT_OUT:-}" ]; then
+ echo "$FAKE_NMCLI_IMPORT_OUT"
+ else
+ printf "Connection 'wgpvpn' (%08d-aaaa-bbbb-cccc-dddddddddddd) successfully added.\n" "$n"
+ fi
+ ;;
+"connection modify")
+ exit "${FAKE_NMCLI_MODIFY_RC:-0}"
+ ;;
+*)
+ echo "fake-nmcli: unexpected args: $*" >&2
+ exit 99
+ ;;
+esac
diff --git a/tests/import-wireguard-configs/test_import_wireguard_configs.py b/tests/import-wireguard-configs/test_import_wireguard_configs.py
new file mode 100644
index 0000000..0307041
--- /dev/null
+++ b/tests/import-wireguard-configs/test_import_wireguard_configs.py
@@ -0,0 +1,167 @@
+"""Tests for the import-wireguard-configs.sh one-time migration script.
+
+The script imports every assets/wireguard-config/*.conf into NetworkManager
+as a wireguard connection with autoconnect forced off. NM quirks under test:
+the import filename must be a valid interface name (<= 15 chars), so every
+config stages through a temp copy named wgpvpn.conf and is renamed to the
+real config name immediately after import — by the UUID parsed from the
+import output, never by the transient wgpvpn name. A leftover connection
+literally named wgpvpn (an earlier run died between import and rename, so
+it still has autoconnect on) makes the script refuse to run.
+
+nmcli is faked via a stub on PATH (fake-nmcli in this directory) that logs
+every invocation and snapshots the staged import file.
+
+Run from repo root:
+ python3 -m unittest tests.import-wireguard-configs.test_import_wireguard_configs
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+SCRIPT = os.path.join(REPO_ROOT, "scripts", "import-wireguard-configs.sh")
+FAKE_NMCLI = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fake-nmcli")
+
+
+class ImportWireguardConfigs(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="import-wg-test-")
+ self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
+ self.confdir = os.path.join(self.tmp, "configs")
+ os.mkdir(self.confdir)
+ self.bindir = os.path.join(self.tmp, "bin")
+ os.mkdir(self.bindir)
+ shutil.copy(FAKE_NMCLI, os.path.join(self.bindir, "nmcli"))
+ os.chmod(os.path.join(self.bindir, "nmcli"), 0o755)
+ self.log = os.path.join(self.tmp, "nmcli.log")
+
+ def write_conf(self, name, body="[Interface]\nPrivateKey = k\n"):
+ path = os.path.join(self.confdir, name + ".conf")
+ with open(path, "w") as f:
+ f.write(body)
+ return path
+
+ def run_script(self, confdir=None, names="", env_extra=None):
+ env = dict(os.environ)
+ env["PATH"] = self.bindir + os.pathsep + env["PATH"]
+ env["FAKE_NMCLI_LOG"] = self.log
+ env["FAKE_NMCLI_NAMES"] = names
+ if env_extra:
+ env.update(env_extra)
+ return subprocess.run(
+ ["bash", SCRIPT, confdir or self.confdir],
+ capture_output=True, text=True, timeout=10, env=env,
+ )
+
+ def log_lines(self):
+ if not os.path.exists(self.log):
+ return []
+ with open(self.log) as f:
+ return [ln.strip() for ln in f if ln.strip()]
+
+ # --- Normal cases ----------------------------------------------------
+
+ def test_imports_every_conf_with_autoconnect_off(self):
+ self.write_conf("USNY")
+ self.write_conf("USDC")
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")]
+ self.assertEqual(len(modifies), 2)
+ for ln in modifies:
+ self.assertIn("connection.autoconnect no", ln)
+ self.assertIn("imported: USDC", r.stdout)
+ self.assertIn("imported: USNY", r.stdout)
+
+ def test_renames_by_uuid_from_import_output_not_by_name(self):
+ self.write_conf("USNY")
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ modify = [ln for ln in self.log_lines() if ln.startswith("connection modify")][0]
+ # The modify targets the UUID the import printed, and never the
+ # transient wgpvpn name.
+ self.assertIn("00000000-aaaa-bbbb-cccc-dddddddddddd", modify)
+ self.assertIn("connection.id USNY", modify)
+ self.assertNotIn("modify wgpvpn", modify)
+
+ def test_long_name_stages_through_wgpvpn_temp_copy(self):
+ # switzerlan-zurich1 is 18 chars — over NM's 15-char interface-name
+ # limit, the reason the staging copy exists at all.
+ body = "[Interface]\nPrivateKey = long-name-key\n"
+ self.write_conf("switzerlan-zurich1", body)
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ staged = os.listdir(self.log + ".d")
+ self.assertEqual(len(staged), 1)
+ self.assertTrue(staged[0].endswith("wgpvpn.conf"), staged)
+ with open(os.path.join(self.log + ".d", staged[0])) as f:
+ self.assertEqual(f.read(), body)
+ self.assertIn("imported: switzerlan-zurich1", r.stdout)
+
+ # --- Idempotence -----------------------------------------------------
+
+ def test_already_imported_names_skip(self):
+ self.write_conf("USNY")
+ self.write_conf("USDC")
+ r = self.run_script(names="USNY\nsome-wifi")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn("skip: USNY", r.stdout)
+ self.assertIn("imported: USDC", r.stdout)
+ modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")]
+ self.assertEqual(len(modifies), 1)
+
+ def test_all_imported_is_a_clean_noop(self):
+ self.write_conf("USNY")
+ r = self.run_script(names="USNY")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ imports = [ln for ln in self.log_lines() if ln.startswith("connection import")]
+ self.assertEqual(imports, [])
+
+ # --- Boundary cases --------------------------------------------------
+
+ def test_empty_config_dir_fails_loudly(self):
+ r = self.run_script()
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("no .conf files", r.stderr)
+
+ def test_missing_config_dir_fails_loudly(self):
+ r = self.run_script(confdir=os.path.join(self.tmp, "nope"))
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("no such config dir", r.stderr)
+
+ # --- Error cases -----------------------------------------------------
+
+ def test_stale_wgpvpn_connection_refuses_to_run(self):
+ self.write_conf("USNY")
+ r = self.run_script(names="wgpvpn\nUSDC")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("stale", r.stderr)
+ self.assertIn("nmcli connection delete wgpvpn", r.stderr)
+ imports = [ln for ln in self.log_lines() if ln.startswith("connection import")]
+ self.assertEqual(imports, [])
+
+ def test_unparseable_import_output_aborts(self):
+ self.write_conf("USNY")
+ r = self.run_script(env_extra={"FAKE_NMCLI_IMPORT_OUT": "something unexpected"})
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("could not parse a UUID", r.stderr)
+ modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")]
+ self.assertEqual(modifies, [])
+
+ def test_modify_failure_aborts_the_run(self):
+ self.write_conf("USNY")
+ self.write_conf("USDC")
+ r = self.run_script(env_extra={"FAKE_NMCLI_MODIFY_RC": "4"})
+ self.assertNotEqual(r.returncode, 0)
+ # set -e stops at the first failed modify — only one import attempted.
+ imports = [ln for ln in self.log_lines() if ln.startswith("connection import")]
+ self.assertEqual(len(imports), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py
new file mode 100644
index 0000000..2a771ba
--- /dev/null
+++ b/tests/installer-steps/test_orchestrators.py
@@ -0,0 +1,149 @@
+"""Characterization tests for the decomposed installer step orchestrators.
+
+The 2026 decomposition turned the giant step functions into thin
+orchestrators that call one named sub-function per concern. These tests pin
+the call SEQUENCE of each orchestrator: a dropped, added, or reordered
+sub-step call fails the test. They guard the wiring, not the sub-functions'
+own behavior (those mutate the system and are exercised by the VM harness).
+
+Method: sed-extract the orchestrator from the real `archsetup` (its body is
+now just `display` + sub-function calls), source it with `display` silenced
+and every sub-function replaced by a recorder that echoes its own name, run
+it, and assert stdout is the expected ordered list.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_orchestrators
+"""
+
+import os
+import subprocess
+import textwrap
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+# orchestrator -> exact ordered sub-step calls
+ORCHESTRATORS = {
+ "essential_services": [
+ "configure_randomness", "configure_networking", "configure_power",
+ "configure_ssh_server", "configure_fail2ban", "configure_firewall",
+ "configure_service_discovery", "configure_job_scheduling",
+ "configure_package_cache", "configure_snapshots",
+ "configure_user_lingering",
+ ],
+ "prerequisites": [
+ "bootstrap_pacman_keyring", "install_required_software",
+ "configure_build_environment", "configure_package_mirrors",
+ ],
+ "developer_workstation": [
+ "install_programming_languages", "install_editors",
+ "install_android_utilities", "install_vpn_tools",
+ "install_devops_utilities",
+ ],
+ "boot_ux": [
+ "tighten_efi_permissions", "add_nvme_early_module",
+ "configure_initramfs_hook", "configure_encrypted_autologin",
+ "configure_tlp_power", "trim_firmware", "configure_grub",
+ "configure_pre_pacman_snapshots",
+ ],
+ "user_customizations": [
+ "clone_user_repos", "stow_dotfiles", "prune_waybar_battery",
+ "refresh_desktop_caches", "configure_dconf_defaults",
+ "finalize_dotfiles", "install_maintenance_config",
+ "create_user_directories",
+ ],
+}
+
+
+def run_orchestrator(func, stubs, extra_defs=""):
+ """Source `func` from archsetup with `stubs` recording their names."""
+ stub_defs = "\n".join(f"{s}() {{ echo {s}; }}" for s in stubs)
+ script = textwrap.dedent(f"""\
+ display() {{ :; }}
+ {stub_defs}
+ {extra_defs}
+ source <(sed -n '/^{func}() {{/,/^}}/p' "{ARCHSETUP}")
+ {func}
+ """)
+ result = subprocess.run(
+ ["bash", "-c", script],
+ capture_output=True, text=True, timeout=10,
+ )
+ return result
+
+
+class OrchestratorSequence(unittest.TestCase):
+ def test_each_orchestrator_calls_substeps_in_order(self):
+ for func, expected in ORCHESTRATORS.items():
+ with self.subTest(orchestrator=func):
+ result = run_orchestrator(func, expected)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ got = result.stdout.split()
+ self.assertEqual(got, expected,
+ f"{func} call sequence drifted")
+
+
+class SnapshotDispatch(unittest.TestCase):
+ """configure_snapshots branches on filesystem; pin each branch."""
+
+ SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots"]
+
+ def test_zfs_root_runs_zfs_snapshots(self):
+ result = run_orchestrator(
+ "configure_snapshots", self.SUBS,
+ extra_defs="is_zfs_root() { return 0; }\nis_btrfs_root() { return 1; }",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.split(), ["configure_zfs_snapshots"])
+
+ def test_btrfs_root_runs_btrfs_snapshots(self):
+ result = run_orchestrator(
+ "configure_snapshots", self.SUBS,
+ extra_defs="is_zfs_root() { return 1; }\nis_btrfs_root() { return 0; }",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.split(), ["configure_btrfs_snapshots"])
+
+ def test_other_filesystem_runs_neither(self):
+ result = run_orchestrator(
+ "configure_snapshots", self.SUBS,
+ extra_defs="is_zfs_root() { return 1; }\nis_btrfs_root() { return 1; }",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.split(), [])
+
+
+class MaintenanceConfigDispatch(unittest.TestCase):
+ """install_maintenance_config branches on desktop_env; pin each branch.
+
+ The thresholds TOML installs for every environment (the CLI works
+ headless); the scan timers are user units in the hyprland stow tier, so
+ their enablement is hyprland-only.
+ """
+
+ SUBS = ["install_maintenance_thresholds", "enable_maint_timers"]
+
+ def test_hyprland_installs_thresholds_and_enables_timers(self):
+ result = run_orchestrator(
+ "install_maintenance_config", self.SUBS,
+ extra_defs='desktop_env=hyprland',
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.split(), self.SUBS)
+
+ def test_non_hyprland_installs_thresholds_only(self):
+ for env in ("dwm", "none"):
+ with self.subTest(desktop_env=env):
+ result = run_orchestrator(
+ "install_maintenance_config", self.SUBS,
+ extra_defs=f'desktop_env={env}',
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.split(),
+ ["install_maintenance_thresholds"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_pacman_install.py b/tests/installer-steps/test_pacman_install.py
new file mode 100644
index 0000000..28c9a7f
--- /dev/null
+++ b/tests/installer-steps/test_pacman_install.py
@@ -0,0 +1,95 @@
+"""Characterization tests for pacman_install's install-reason handling.
+
+pacman --needed skips a package that is already present as a dependency and
+leaves its install reason alone. A declared package can then sit as asdeps
+on an existing system, show up as an orphan once its accidental dependent
+leaves, and get swept away by an orphan cleanup (expac and lm_sensors nearly
+went this way on 2026-07-08). pacman_install therefore marks every declared
+package explicit after a successful install.
+
+Method mirrors test_orchestrators: sed-extract the real functions from
+`archsetup`, source them with `display`/`error_warn` silenced and `pacman`
+replaced by a recorder, run, and assert the recorded calls.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_pacman_install
+"""
+
+import os
+import subprocess
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run_pacman_install(pkg, pacman_s_rc=0, pacman_d_rc=0):
+ """Extract retry_install + pacman_install, run against a fake pacman.
+
+ Returns (exit_code, recorded pacman calls as a list of strings).
+ """
+ script = textwrap.dedent("""
+ set -u
+ MAX_INSTALL_RETRIES=3
+ logfile=/dev/null
+ display() { :; }
+ error_warn() { return 1; }
+ pacman() {
+ echo "pacman $*" >> "$CALLS"
+ case "$1" in
+ --noconfirm) return "$PACMAN_S_RC" ;;
+ -D) return "$PACMAN_D_RC" ;;
+ esac
+ }
+ %(functions)s
+ pacman_install "%(pkg)s"
+ """)
+ extract = subprocess.run(
+ ["sed", "-n",
+ "/^retry_install()/,/^}/p;/^pacman_install()/,/^}/p", ARCHSETUP],
+ capture_output=True, text=True, check=True)
+ calls_file = os.path.join(os.environ.get("TMPDIR", "/tmp"),
+ f"pacman-install-calls-{os.getpid()}")
+ if os.path.exists(calls_file):
+ os.unlink(calls_file)
+ open(calls_file, "w").close()
+ env = dict(os.environ, CALLS=calls_file,
+ PACMAN_S_RC=str(pacman_s_rc), PACMAN_D_RC=str(pacman_d_rc))
+ proc = subprocess.run(
+ ["bash", "-c", script % {"functions": extract.stdout, "pkg": pkg}],
+ capture_output=True, text=True, env=env)
+ with open(calls_file) as f:
+ calls = [line.strip() for line in f if line.strip()]
+ os.unlink(calls_file)
+ return proc.returncode, calls
+
+
+class PacmanInstallTests(unittest.TestCase):
+ def test_success_marks_package_explicit(self):
+ rc, calls = run_pacman_install("expac")
+ self.assertEqual(rc, 0)
+ self.assertIn("pacman --noconfirm --needed -S expac", calls)
+ self.assertIn("pacman -D --asexplicit expac", calls)
+ # the mark comes after the install, never before
+ self.assertGreater(calls.index("pacman -D --asexplicit expac"),
+ calls.index("pacman --noconfirm --needed -S expac"))
+
+ def test_failed_install_never_marks(self):
+ rc, calls = run_pacman_install("expac", pacman_s_rc=1)
+ self.assertNotEqual(rc, 0)
+ self.assertNotIn("pacman -D --asexplicit expac", calls)
+ # all three retry attempts happened
+ self.assertEqual(
+ calls.count("pacman --noconfirm --needed -S expac"), 3)
+
+ def test_mark_failure_does_not_fail_the_install(self):
+ # -D can fail in odd corners (readonly db mid-transaction); the
+ # install itself succeeded and must report success
+ rc, calls = run_pacman_install("expac", pacman_d_rc=1)
+ self.assertEqual(rc, 0)
+ self.assertIn("pacman -D --asexplicit expac", calls)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/maint-scenarios/test_scenario_plan.py b/tests/maint-scenarios/test_scenario_plan.py
new file mode 100644
index 0000000..9a72db2
--- /dev/null
+++ b/tests/maint-scenarios/test_scenario_plan.py
@@ -0,0 +1,273 @@
+"""Tests for the maint VM scenario runner's plan layer (no VM needed).
+
+run-maint-scenarios.sh orchestrates break -> `maint fix` -> assert scenario
+scripts over the existing qemu-img snapshot primitives (lib/vm-utils.sh).
+Scenarios are grouped into non-conflicting batches that share one VM boot;
+a stop -> restore -> boot cycle runs only between groups (the spec's
+grouped-batch isolation policy). The runner therefore has a pure planning
+layer -- enumerate scenario files, validate their contract, filter by
+filesystem profile and --group, and print the batch plan -- that runs
+without KVM, a base image, or root.
+
+These tests exercise that layer through the REAL script via `--list`:
+ - against the shipped scenarios directory (contract holds for every file
+ we actually ship);
+ - against fake scenario directories (MAINT_SCENARIO_DIR override) for the
+ validation failures a shipped tree must never contain.
+
+The scenario-file contract validated here:
+ - vars SCENARIO_DESC (non-empty), SCENARIO_GROUP (token),
+ SCENARIO_PROFILES (btrfs/zfs/any, space-separated);
+ - functions scenario_break, scenario_fix, scenario_assert;
+ - defining only -- sourcing a scenario file must not execute commands
+ (the probe sources files in a bare shell with no helpers defined).
+
+Run from repo root:
+ python3 -m unittest tests.maint-scenarios.test_scenario_plan
+"""
+
+import os
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+RUNNER = os.path.join(REPO_ROOT, "scripts", "testing", "run-maint-scenarios.sh")
+SCENARIO_DIR = os.path.join(REPO_ROOT, "scripts", "testing", "maint-scenarios")
+
+GOOD_SCENARIO = """\
+SCENARIO_DESC="{desc}"
+SCENARIO_GROUP="{group}"
+SCENARIO_PROFILES="{profiles}"
+scenario_break() {{ mexec "true"; }}
+scenario_fix() {{ mfix some_remedy; }}
+scenario_assert() {{ mexec "true"; }}
+"""
+
+
+def run_list(extra_args=(), scenario_dir=None, fs_profile=None):
+ # Hermetic against the caller's FS_PROFILE: the Makefile exports it, so
+ # `make test-unit FS_PROFILE=zfs` would otherwise change what --list
+ # shows. Tests that care pass fs_profile explicitly; everything else
+ # runs the runner's own default (btrfs).
+ env = {k: v for k, v in os.environ.items() if k != "FS_PROFILE"}
+ if scenario_dir is not None:
+ env["MAINT_SCENARIO_DIR"] = scenario_dir
+ if fs_profile is not None:
+ env["FS_PROFILE"] = fs_profile
+ return subprocess.run(
+ ["bash", RUNNER, "--list", *extra_args],
+ capture_output=True, text=True, env=env, cwd=REPO_ROOT,
+ )
+
+
+def write_scenario(dirpath, name, desc="a scenario", group="g1",
+ profiles="any", body=None):
+ path = os.path.join(dirpath, name)
+ with open(path, "w") as f:
+ f.write(body if body is not None
+ else GOOD_SCENARIO.format(desc=desc, group=group,
+ profiles=profiles))
+ return path
+
+
+class ShippedScenariosTests(unittest.TestCase):
+ """The scenarios we actually ship satisfy the contract."""
+
+ def test_shipped_dir_exists_and_is_nonempty(self):
+ files = [f for f in os.listdir(SCENARIO_DIR) if f.endswith(".sh")]
+ self.assertTrue(files, "no scenario files shipped")
+
+ def test_list_exits_zero_on_shipped_scenarios(self):
+ proc = run_list()
+ self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
+
+ def test_list_names_every_shipped_scenario(self):
+ proc = run_list()
+ for f in os.listdir(SCENARIO_DIR):
+ if not f.endswith(".sh"):
+ continue
+ name = f.split("-", 1)[1][:-3] if "-" in f else f[:-3]
+ self.assertIn(name, proc.stdout,
+ f"scenario {f} missing from --list output")
+
+ def test_list_groups_are_headed(self):
+ proc = run_list()
+ self.assertRegex(proc.stdout, r"(?m)^group \S+:")
+
+ def test_shipped_scenarios_define_contract_without_executing(self):
+ """Sourcing a scenario file in a bare bash defines the contract vars
+ and functions and runs nothing (no helpers exist at source time, so
+ any top-level command would fail loudly)."""
+ probe = (
+ 'set -eu; source "$1"; '
+ ': "${SCENARIO_DESC:?}" "${SCENARIO_GROUP:?}" '
+ '"${SCENARIO_PROFILES:?}"; '
+ 'case " $SCENARIO_PROFILES " in *" btrfs "*|*" zfs "*|*" any "*) '
+ ';; *) echo "bad profiles: $SCENARIO_PROFILES" >&2; exit 1;; esac; '
+ 'declare -f scenario_break scenario_fix scenario_assert >/dev/null'
+ )
+ for f in sorted(os.listdir(SCENARIO_DIR)):
+ if not f.endswith(".sh"):
+ continue
+ path = os.path.join(SCENARIO_DIR, f)
+ proc = subprocess.run(["bash", "-c", probe, "probe", path],
+ capture_output=True, text=True)
+ self.assertEqual(proc.returncode, 0,
+ f"{f}: contract violation\n{proc.stderr}")
+
+
+class PlanFilteringTests(unittest.TestCase):
+ """Profile and --group filtering over a fake scenario dir."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dir = self.tmp.name
+ write_scenario(self.dir, "10-first-btrfs.sh",
+ group="alpha", profiles="btrfs")
+ write_scenario(self.dir, "20-second-any.sh",
+ group="beta", profiles="any")
+ write_scenario(self.dir, "30-third-zfs.sh",
+ group="gamma", profiles="zfs")
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def test_btrfs_profile_excludes_zfs_scenarios(self):
+ proc = run_list(scenario_dir=self.dir, fs_profile="btrfs")
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertIn("first-btrfs", proc.stdout)
+ self.assertIn("second-any", proc.stdout)
+ self.assertNotIn("third-zfs", proc.stdout)
+
+ def test_zfs_profile_excludes_btrfs_scenarios(self):
+ proc = run_list(scenario_dir=self.dir, fs_profile="zfs")
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertNotIn("first-btrfs", proc.stdout)
+ self.assertIn("second-any", proc.stdout)
+ self.assertIn("third-zfs", proc.stdout)
+
+ def test_group_filter_selects_one_group(self):
+ proc = run_list(["--group", "alpha"],
+ scenario_dir=self.dir, fs_profile="btrfs")
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertIn("first-btrfs", proc.stdout)
+ self.assertNotIn("second-any", proc.stdout)
+
+ def test_unknown_group_is_an_error(self):
+ proc = run_list(["--group", "nonesuch"],
+ scenario_dir=self.dir, fs_profile="btrfs")
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn("nonesuch", proc.stdout + proc.stderr)
+
+ def test_groups_appear_in_file_order(self):
+ proc = run_list(scenario_dir=self.dir, fs_profile="zfs")
+ out = proc.stdout
+ self.assertLess(out.index("group beta:"), out.index("group gamma:"))
+
+
+class ContractValidationTests(unittest.TestCase):
+ """Malformed scenario files fail the plan, naming the file."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dir = self.tmp.name
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def assert_plan_fails_naming(self, filename):
+ proc = run_list(scenario_dir=self.dir)
+ self.assertNotEqual(proc.returncode, 0,
+ f"plan accepted malformed {filename}")
+ self.assertIn(filename, proc.stdout + proc.stderr)
+
+ def test_missing_desc_rejected(self):
+ write_scenario(self.dir, "10-no-desc.sh", body=(
+ 'SCENARIO_GROUP="g"\nSCENARIO_PROFILES="any"\n'
+ "scenario_break() { :; }\nscenario_fix() { :; }\n"
+ "scenario_assert() { :; }\n"))
+ self.assert_plan_fails_naming("10-no-desc.sh")
+
+ def test_missing_function_rejected(self):
+ write_scenario(self.dir, "10-no-assert.sh", body=(
+ 'SCENARIO_DESC="d"\nSCENARIO_GROUP="g"\nSCENARIO_PROFILES="any"\n'
+ "scenario_break() { :; }\nscenario_fix() { :; }\n"))
+ self.assert_plan_fails_naming("10-no-assert.sh")
+
+ def test_bad_profile_token_rejected(self):
+ write_scenario(self.dir, "10-bad-profile.sh", profiles="ext4")
+ self.assert_plan_fails_naming("10-bad-profile.sh")
+
+ def test_empty_scenario_dir_is_an_error(self):
+ proc = run_list(scenario_dir=self.dir)
+ self.assertNotEqual(proc.returncode, 0)
+
+
+class UsageTests(unittest.TestCase):
+ def test_unknown_flag_is_an_error_with_usage(self):
+ proc = run_list(["--bogus"], scenario_dir=SCENARIO_DIR)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn("Usage", proc.stdout + proc.stderr)
+
+
+NSPAWN_RUNNER = os.path.join(REPO_ROOT, "scripts", "testing",
+ "run-maint-nspawn.sh")
+
+
+class NspawnPlanTests(unittest.TestCase):
+ """The nspawn fast lane selects exactly the pacman-level (packages)
+ group from the shared scenario dir."""
+
+ def run_nspawn_list(self, scenario_dir=None):
+ env = dict(os.environ)
+ if scenario_dir is not None:
+ env["MAINT_SCENARIO_DIR"] = scenario_dir
+ return subprocess.run(
+ ["bash", NSPAWN_RUNNER, "--list"],
+ capture_output=True, text=True, env=env, cwd=REPO_ROOT,
+ )
+
+ def test_list_exits_zero(self):
+ proc = self.run_nspawn_list()
+ self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
+
+ def test_list_selects_only_the_packages_group(self):
+ proc = self.run_nspawn_list()
+ listed = set()
+ for f in os.listdir(SCENARIO_DIR):
+ if not f.endswith(".sh"):
+ continue
+ name = f.split("-", 1)[1][:-3] if "-" in f else f[:-3]
+ group = subprocess.run(
+ ["bash", "-c", f'source "{os.path.join(SCENARIO_DIR, f)}"; '
+ 'printf %s "$SCENARIO_GROUP"'],
+ capture_output=True, text=True).stdout
+ if group == "packages":
+ self.assertIn(name, proc.stdout,
+ f"packages scenario {f} missing")
+ listed.add(name)
+ else:
+ self.assertNotIn(name, proc.stdout,
+ f"non-packages scenario {f} listed")
+ self.assertTrue(listed, "no packages-group scenarios found")
+
+ def test_unknown_flag_is_an_error_with_usage(self):
+ proc = subprocess.run(["bash", NSPAWN_RUNNER, "--bogus"],
+ capture_output=True, text=True, cwd=REPO_ROOT)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn("Usage", proc.stdout + proc.stderr)
+
+ def test_bad_profile_token_rejected_like_the_vm_lane(self):
+ """Both runners enforce the same scenario contract — a profile typo
+ must not pass the nspawn plan and only surface in the VM lane."""
+ with tempfile.TemporaryDirectory() as d:
+ write_scenario(d, "10-bad-profile.sh", group="packages",
+ profiles="ext4")
+ proc = self.run_nspawn_list(scenario_dir=d)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn("10-bad-profile.sh", proc.stdout + proc.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/network-diagnostics/test_network_diagnostics.py b/tests/network-diagnostics/test_network_diagnostics.py
new file mode 100644
index 0000000..1a8073f
--- /dev/null
+++ b/tests/network-diagnostics/test_network_diagnostics.py
@@ -0,0 +1,215 @@
+"""Tests for run_network_diagnostics in the VM testing harness.
+
+run_network_diagnostics is the VM install pre-flight network check. It
+collects read-only facts (interfaces, default route, resolver) first and
+unconditionally, then runs every reachability check -- DNS, HTTP egress,
+TLS egress, Arch mirror, AUR -- accumulating failures and reporting them all
+at the end. Facts are printed regardless of pass/fail, so a failed install
+still leaves the evidence. Generic checks (DNS/egress/TLS) are kept separate
+from Arch-specific checks (mirror/AUR) so a DNS failure is named as DNS, not
+misattributed to the mirror. Returns 0 when all checks pass, non-zero
+otherwise, preserving the caller's success/failure contract.
+
+These tests exercise the REAL function body (sourced out of
+network-diagnostics.sh, not a copy) with:
+ - stub logging functions (section/step/info/success/error/warn) that just
+ echo, so output is assertable;
+ - a fake `sshpass` on PATH that dispatches on the remote command string and
+ returns canned exit codes driven by FAKE_*_FAIL env vars. This is the
+ system boundary -- the real function shells out through
+ `sshpass ... ssh ... "<remote cmd>"`, and the fake stands in for the VM.
+
+Run from repo root:
+ python3 -m unittest tests.network-diagnostics.test_network_diagnostics
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+NETDIAG = os.path.join(
+ REPO_ROOT, "scripts", "testing", "lib", "network-diagnostics.sh"
+)
+
+# A fake sshpass. The real invocation is:
+# sshpass -p <pw> ssh <opts> -p <port> root@<host> "<remote cmd>"
+# so the remote command is always the last argument. This stub inspects it and
+# returns a canned exit code per check, driven by FAKE_*_FAIL env vars. Fact
+# commands (ip/route/resolv) always succeed and print sample output so the
+# evidence-collection path is exercised.
+FAKE_SSHPASS = r"""#!/bin/bash
+cmd="${@: -1}"
+case "$cmd" in
+ *"ip -brief addr"*)
+ echo "lo UNKNOWN 127.0.0.1/8"
+ echo "eth0 UP 10.0.2.15/24"
+ exit 0 ;;
+ *"ip route show default"*)
+ echo "default via 10.0.2.2 dev eth0"
+ exit 0 ;;
+ *"resolv.conf"*)
+ echo "nameserver 10.0.2.3"
+ exit 0 ;;
+ *"getent hosts"*)
+ [ "${FAKE_DNS_FAIL:-0}" = "1" ] && exit 2
+ exit 0 ;;
+ *"https://archlinux.org"*)
+ [ "${FAKE_TLS_FAIL:-0}" = "1" ] && exit 7
+ exit 0 ;;
+ *"http://archlinux.org"*)
+ [ "${FAKE_HTTP_FAIL:-0}" = "1" ] && exit 7
+ exit 0 ;;
+ *"geo.mirror.pkgbuild.com"*)
+ [ "${FAKE_MIRROR_FAIL:-0}" = "1" ] && exit 1
+ exit 0 ;;
+ *"aur.archlinux.org"*)
+ [ "${FAKE_AUR_FAIL:-0}" = "1" ] && exit 1
+ exit 0 ;;
+ *)
+ exit 0 ;;
+esac
+"""
+
+# Stub logging functions plus the sourced real file, then call the function.
+WRAPPER = r"""#!/bin/bash
+section() { echo "=== $1 ==="; }
+step() { echo " -> $1"; }
+info() { echo "[i] $1"; }
+success() { echo "[OK] $1"; }
+warn() { echo "[!] $1" >&2; }
+error() { echo "[X] $1" >&2; }
+source "$1"
+run_network_diagnostics
+"""
+
+
+class NetworkDiagnosticsHarness(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="netdiag-test-")
+ self.fakebin = os.path.join(self.tmp, "bin")
+ os.makedirs(self.fakebin)
+ sshpass = os.path.join(self.fakebin, "sshpass")
+ with open(sshpass, "w") as f:
+ f.write(FAKE_SSHPASS)
+ os.chmod(sshpass, 0o755)
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(WRAPPER)
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def run_diag(self, results_dir=None, **fail_flags):
+ env = dict(os.environ)
+ env["PATH"] = self.fakebin + os.pathsep + env["PATH"]
+ # Keep the harness deterministic regardless of the host's SSH config.
+ env["SSH_OPTS"] = "-o StrictHostKeyChecking=no"
+ env["ROOT_PASSWORD"] = "archsetup"
+ env["SSH_PORT"] = "22"
+ env["VM_IP"] = "localhost"
+ if results_dir is not None:
+ env["TEST_RESULTS_DIR"] = results_dir
+ for k, v in fail_flags.items():
+ env[k] = v
+ return subprocess.run(
+ ["bash", self.wrapper, NETDIAG],
+ capture_output=True, text=True, timeout=20, env=env,
+ )
+
+ # --- Normal case: everything reachable -----------------------------
+
+ def test_all_checks_pass_returns_zero(self):
+ r = self.run_diag()
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+ self.assertIn("all checks passed", r.stdout)
+
+ def test_facts_collected_on_success(self):
+ r = self.run_diag()
+ out = r.stdout + r.stderr
+ self.assertIn("10.0.2.15/24", out) # interface fact
+ self.assertIn("default via 10.0.2.2", out) # route fact
+ self.assertIn("nameserver 10.0.2.3", out) # resolver fact
+
+ # --- DNS-failure case ----------------------------------------------
+
+ def test_dns_failure_returns_nonzero(self):
+ r = self.run_diag(FAKE_DNS_FAIL="1")
+ self.assertNotEqual(r.returncode, 0)
+
+ def test_dns_failure_names_dns_not_mirror(self):
+ r = self.run_diag(FAKE_DNS_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("DNS resolution failed", out)
+ # A DNS failure must not be misreported as a mirror failure. With only
+ # DNS failing, the mirror check still runs and passes.
+ self.assertNotIn("Cannot reach Arch mirrors", out)
+
+ def test_dns_failure_still_collects_evidence(self):
+ # The whole point of the change: evidence is gathered before any check
+ # can bail, so a DNS failure still leaves the facts in the output.
+ r = self.run_diag(FAKE_DNS_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("10.0.2.15/24", out)
+ self.assertIn("default via 10.0.2.2", out)
+ self.assertIn("nameserver 10.0.2.3", out)
+
+ def test_dns_failure_summary_lists_the_failure(self):
+ r = self.run_diag(FAKE_DNS_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("found 1 failure", out)
+ self.assertIn("getent hosts archlinux.org", out)
+
+ # --- Mirror-only-failure case --------------------------------------
+
+ def test_mirror_only_failure_returns_nonzero(self):
+ r = self.run_diag(FAKE_MIRROR_FAIL="1")
+ self.assertNotEqual(r.returncode, 0)
+
+ def test_mirror_only_failure_generic_checks_pass(self):
+ r = self.run_diag(FAKE_MIRROR_FAIL="1")
+ out = r.stdout + r.stderr
+ # Generic checks are healthy; only the Arch-specific mirror check fails.
+ self.assertIn("DNS resolution OK", out)
+ self.assertIn("HTTP egress OK", out)
+ self.assertIn("TLS/HTTPS egress OK", out)
+ self.assertIn("Cannot reach Arch mirrors", out)
+ self.assertNotIn("DNS resolution failed", out)
+
+ def test_mirror_only_failure_summary_names_mirror(self):
+ r = self.run_diag(FAKE_MIRROR_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("geo.mirror.pkgbuild.com", out)
+
+ # --- All checks run: multiple failures are all reported ------------
+
+ def test_multiple_failures_all_reported(self):
+ r = self.run_diag(FAKE_DNS_FAIL="1", FAKE_AUR_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("found 2 failure", out)
+ self.assertIn("getent hosts archlinux.org", out)
+ self.assertIn("aur.archlinux.org", out)
+
+ # --- Raw outputs saved to the results dir --------------------------
+
+ def test_raw_facts_saved_to_results_dir(self):
+ results = os.path.join(self.tmp, "results")
+ os.makedirs(results)
+ self.run_diag(results_dir=results)
+ for slug, needle in (
+ ("ip-addr", "10.0.2.15/24"),
+ ("ip-route", "default via 10.0.2.2"),
+ ("resolv-conf", "nameserver 10.0.2.3"),
+ ):
+ path = os.path.join(results, "netdiag-%s.txt" % slug)
+ self.assertTrue(os.path.exists(path), "missing " + path)
+ with open(path) as f:
+ self.assertIn(needle, f.read())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/nvidia-preflight/test_nvidia_preflight.py b/tests/nvidia-preflight/test_nvidia_preflight.py
new file mode 100644
index 0000000..bdacfd5
--- /dev/null
+++ b/tests/nvidia-preflight/test_nvidia_preflight.py
@@ -0,0 +1,162 @@
+"""Tests for the nvidia_preflight_report helper in the archsetup installer.
+
+nvidia_preflight_report is the pure core of the NVIDIA/Wayland preflight
+check: it scans DRM (then PCI display-class) modalias files for the NVIDIA
+vendor id, and when one matches it prints the Wayland warning + required
+environment variables and checks the repo's candidate nvidia-utils major
+version. Return codes: 0 = no NVIDIA GPU, 10 = NVIDIA and the driver
+requirement (535+) is met, 11 = NVIDIA and the requirement is not met
+(driver too old or unknown). The interactive continue/abort prompt lives in
+preflight_checks, not here, so this core is unit testable.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), against temp modalias trees and a fake
+pacman on PATH.
+
+Run from repo root:
+ python3 -m unittest tests.nvidia-preflight.test_nvidia_preflight
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+NVIDIA_MODALIAS = "pci:v000010DEd00002684sv00001043sd000088E2bc03sc00i00"
+NVIDIA_MODALIAS_LOWER = "pci:v000010ded00002684sv00001043sd000088e2bc03sc00i00"
+AMD_MODALIAS = "pci:v00001002d0000164Esv00001462sd00007D78bc03sc80i00"
+NON_DISPLAY_NVIDIA = "pci:v000010DEd00002684sv00001043sd000088E2bc0Csc03i30"
+
+
+class NvidiaPreflightHarness(unittest.TestCase):
+ """Source nvidia_preflight_report out of the real archsetup script."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="nvidia-preflight-test-")
+ self.drm = os.path.join(self.tmp, "drm")
+ self.pci = os.path.join(self.tmp, "pci")
+ os.makedirs(self.drm)
+ os.makedirs(self.pci)
+ self.fakebin = os.path.join(self.tmp, "bin")
+ os.makedirs(self.fakebin)
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n "
+ "'/^nvidia_preflight_report() {/,/^}/p' \"$ARCHSETUP\")\n"
+ "nvidia_preflight_report\n"
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def fake_pacman(self, version=None, fail=False):
+ """A pacman stub answering `pacman -Si nvidia-utils`."""
+ path = os.path.join(self.fakebin, "pacman")
+ with open(path, "w") as f:
+ if fail:
+ f.write("#!/bin/sh\nexit 1\n")
+ else:
+ f.write(
+ "#!/bin/sh\n"
+ "printf 'Repository : extra\\n'\n"
+ "printf 'Name : nvidia-utils\\n'\n"
+ "printf 'Version : %s\\n'\n" % version
+ )
+ os.chmod(path, 0o755)
+
+ def add_modalias(self, root, subdir, content):
+ d = os.path.join(root, subdir)
+ os.makedirs(d, exist_ok=True)
+ with open(os.path.join(d, "modalias"), "w") as f:
+ f.write(content + "\n")
+
+ def run_check(self):
+ env = dict(os.environ)
+ env["PATH"] = self.fakebin + os.pathsep + env["PATH"]
+ env["NVIDIA_DRM_GLOB"] = os.path.join(self.drm, "card*", "modalias")
+ env["NVIDIA_PCI_GLOB"] = os.path.join(self.pci, "*", "modalias")
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP],
+ capture_output=True, text=True, env=env,
+ )
+
+ # ---------------------------------------------------------- normal ----
+ def test_no_gpu_files_returns_zero_and_silent(self):
+ self.fake_pacman(version="575.51.02-1")
+ r = self.run_check()
+ self.assertEqual(r.returncode, 0)
+ self.assertNotIn("NVIDIA", r.stdout)
+
+ def test_amd_only_returns_zero(self):
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.drm, "card0", AMD_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 0)
+ self.assertNotIn("NVIDIA", r.stdout)
+
+ def test_nvidia_with_modern_driver_returns_ten_with_guidance(self):
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 10)
+ self.assertIn("NVIDIA GPU detected", r.stdout)
+ self.assertIn("LIBVA_DRIVER_NAME=nvidia", r.stdout)
+ self.assertIn("GBM_BACKEND=nvidia-drm", r.stdout)
+ self.assertIn("__GLX_VENDOR_LIBRARY_NAME=nvidia", r.stdout)
+ self.assertIn("575.51.02-1", r.stdout)
+
+ # -------------------------------------------------------- boundary ----
+ def test_lowercase_vendor_id_detected(self):
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS_LOWER)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 10)
+
+ def test_exactly_535_meets_requirement(self):
+ self.fake_pacman(version="535.216.01-1")
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 10)
+
+ def test_pci_fallback_display_class_only(self):
+ # No DRM entries; PCI holds a display-class NVIDIA device -> detected.
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.pci, "0000:01:00.0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 10)
+
+ def test_pci_non_display_nvidia_ignored(self):
+ # An NVIDIA audio/usb function (bc0C) must not trigger the check.
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.pci, "0000:01:00.1", NON_DISPLAY_NVIDIA)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 0)
+
+ # ----------------------------------------------------------- error ----
+ def test_old_driver_returns_eleven_with_error(self):
+ self.fake_pacman(version="470.256.02-1")
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 11)
+ self.assertIn("535", r.stdout)
+ self.assertIn("470.256.02-1", r.stdout)
+
+ def test_pacman_failure_returns_eleven_unknown(self):
+ self.fake_pacman(fail=True)
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 11)
+ self.assertIn("unknown", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/run-task/test_run_task.py b/tests/run-task/test_run_task.py
new file mode 100644
index 0000000..35036dd
--- /dev/null
+++ b/tests/run-task/test_run_task.py
@@ -0,0 +1,172 @@
+"""Tests for the run_task / enable_service helpers in the archsetup installer.
+
+run_task is the installer's describe-run-warn primitive. It replaces the
+hand-written idiom that recurs ~100 times across the script:
+
+ action="enabling rngd service" && display "task" "$action"
+ systemctl enable rngd >> "$logfile" 2>&1 || error_warn "$action" "$?"
+
+as a single call:
+
+ run_task "enabling rngd service" systemctl enable rngd
+
+It announces the task via display, runs the command with stdout+stderr
+appended to $logfile, and on failure calls error_warn with the command's
+real exit code (non-fatal). enable_service is a thin wrapper that enables
+one or more systemd units with the conventional "enabling <unit> service"
+wording.
+
+These tests exercise the REAL function bodies, extracted from the
+`archsetup` script at run time (not a copy), with recording stubs standing
+in for display, error_warn, and systemctl. The command run by run_task is
+genuinely executed.
+
+Run from repo root:
+ python3 -m unittest tests.run-task.test_run_task
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+# A bash harness that sources the real run_task + enable_service out of the
+# installer, with recording stubs for their dependencies. Each stub appends a
+# tab-separated record to a file named by an env var, so the Python side can
+# assert what was called. The real command passed to run_task still runs.
+WRAPPER = r"""#!/bin/bash
+ARCHSETUP="$1"; shift
+logfile="$LOGFILE"
+
+display() { printf '%s\t%s\n' "$1" "$2" >> "$DISPLAY_LOG"; }
+error_warn() { printf '%s\t%s\n' "$1" "$2" >> "$ERRWARN_LOG"; return 1; }
+systemctl() { printf 'systemctl %s\n' "$*"; }
+
+source <(sed -n '/^run_task() {/,/^}/p' "$ARCHSETUP")
+source <(sed -n '/^enable_service() {/,/^}/p' "$ARCHSETUP")
+
+"$@"
+"""
+
+
+class RunTaskHarness(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="run-task-test-")
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(WRAPPER)
+ os.chmod(self.wrapper, 0o755)
+ self.logfile = os.path.join(self.tmp, "install.log")
+ self.display_log = os.path.join(self.tmp, "display.log")
+ self.errwarn_log = os.path.join(self.tmp, "errwarn.log")
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def call(self, *args):
+ env = dict(os.environ)
+ env["LOGFILE"] = self.logfile
+ env["DISPLAY_LOG"] = self.display_log
+ env["ERRWARN_LOG"] = self.errwarn_log
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP, *args],
+ capture_output=True, text=True, timeout=10, env=env,
+ )
+
+ def read(self, path):
+ if not os.path.exists(path):
+ return ""
+ with open(path) as f:
+ return f.read()
+
+ # --- Normal cases -----------------------------------------------------
+
+ def test_run_task_success_announces_and_runs(self):
+ result = self.call("run_task", "doing a thing", "true")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ # Announced as a "task" with the exact description.
+ self.assertEqual(self.read(self.display_log), "task\tdoing a thing\n")
+ # No warning on success.
+ self.assertEqual(self.read(self.errwarn_log), "")
+
+ def test_run_task_captures_command_output_to_logfile(self):
+ result = self.call("run_task", "echo something", "echo", "hello-from-cmd")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("hello-from-cmd", self.read(self.logfile))
+ # Command output is logged, not printed to the terminal.
+ self.assertNotIn("hello-from-cmd", result.stdout)
+
+ def test_run_task_captures_stderr_to_logfile(self):
+ # `ls` of a missing path writes to stderr; it must land in the logfile.
+ missing = os.path.join(self.tmp, "no-such-path")
+ self.call("run_task", "listing", "ls", missing)
+ self.assertIn("no-such-path", self.read(self.logfile))
+
+ def test_run_task_preserves_multiple_arguments(self):
+ self.call("run_task", "multi-arg", "printf", "%s|%s|%s", "a", "b", "c")
+ self.assertIn("a|b|c", self.read(self.logfile))
+
+ def test_run_task_preserves_arguments_with_spaces(self):
+ self.call("run_task", "spacey", "printf", "[%s]", "two words")
+ self.assertIn("[two words]", self.read(self.logfile))
+
+ # --- enable_service ---------------------------------------------------
+
+ def test_enable_service_single_unit(self):
+ self.call("enable_service", "rngd")
+ self.assertEqual(self.read(self.display_log), "task\tenabling rngd service\n")
+ self.assertIn("systemctl enable rngd", self.read(self.logfile))
+
+ def test_enable_service_multiple_units(self):
+ self.call("enable_service", "foo", "bar", "baz")
+ disp = self.read(self.display_log)
+ self.assertIn("task\tenabling foo service\n", disp)
+ self.assertIn("task\tenabling bar service\n", disp)
+ self.assertIn("task\tenabling baz service\n", disp)
+ log = self.read(self.logfile)
+ self.assertIn("systemctl enable foo", log)
+ self.assertIn("systemctl enable bar", log)
+ self.assertIn("systemctl enable baz", log)
+
+ # --- Error cases ------------------------------------------------------
+
+ def test_run_task_failure_warns_with_description(self):
+ result = self.call("run_task", "failing thing", "false")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(self.read(self.errwarn_log), "failing thing\t1\n")
+
+ def test_run_task_failure_propagates_real_exit_code(self):
+ # `bash -c 'exit 42'` must surface 42 to error_warn, not a clobbered 0.
+ self.call("run_task", "exit-42", "bash", "-c", "exit 42")
+ self.assertEqual(self.read(self.errwarn_log), "exit-42\t42\n")
+
+ def test_enable_service_failure_warns_per_unit(self):
+ # Override systemctl to fail; each unit should produce a warning.
+ env = dict(os.environ)
+ env["LOGFILE"] = self.logfile
+ env["DISPLAY_LOG"] = self.display_log
+ env["ERRWARN_LOG"] = self.errwarn_log
+ # Re-create wrapper with a failing systemctl stub for this case.
+ failing = os.path.join(self.tmp, "run-fail.sh")
+ with open(failing, "w") as f:
+ f.write(WRAPPER.replace(
+ "systemctl() { printf 'systemctl %s\\n' \"$*\"; }",
+ "systemctl() { printf 'systemctl %s\\n' \"$*\"; return 1; }",
+ ))
+ os.chmod(failing, 0o755)
+ subprocess.run(
+ ["bash", failing, ARCHSETUP, "enable_service", "alpha", "beta"],
+ capture_output=True, text=True, timeout=10, env=env,
+ )
+ warns = self.read(self.errwarn_log)
+ self.assertIn("enabling alpha service\t1\n", warns)
+ self.assertIn("enabling beta service\t1\n", warns)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/zfs-pre-snapshot/fake-zfs b/tests/zfs-pre-snapshot/fake-zfs
new file mode 100755
index 0000000..508c0f3
--- /dev/null
+++ b/tests/zfs-pre-snapshot/fake-zfs
@@ -0,0 +1,14 @@
+#!/bin/sh
+# Fake zfs for the zfs-pre-snapshot unit test. `snapshot` and `destroy` are
+# logged (FAKE_ZFS_LOG); `list` prints a fixture snapshot set (FAKE_ZFS_SNAPSHOTS).
+# Set FAKE_ZFS_SNAPSHOT_FAIL to make snapshot creation fail.
+case "$1" in
+ snapshot)
+ [ -n "$FAKE_ZFS_SNAPSHOT_FAIL" ] && exit 1
+ echo "snapshot $2" >> "$FAKE_ZFS_LOG"; exit 0 ;;
+ destroy)
+ echo "destroy $2" >> "$FAKE_ZFS_LOG"; exit 0 ;;
+ list)
+ cat "$FAKE_ZFS_SNAPSHOTS" 2>/dev/null; exit 0 ;;
+esac
+exit 0
diff --git a/tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py b/tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py
new file mode 100644
index 0000000..ed7731b
--- /dev/null
+++ b/tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py
@@ -0,0 +1,116 @@
+"""Unit tests for scripts/zfs-pre-snapshot.
+
+The script snapshots the root dataset before a pacman transaction and prunes to
+the most recent KEEP pre-pacman snapshots. These tests drive the real script
+with a fake zfs on PATH (snapshot/destroy logged, list returns a fixture set)
+and env-rooted state, so nothing touches a real pool.
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import time
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+SCRIPT = os.path.join(REPO_ROOT, "scripts/zfs-pre-snapshot")
+FAKE_ZFS = os.path.join(os.path.dirname(__file__), "fake-zfs")
+
+DATASET = "tank/test"
+# Five pre-pacman snapshots oldest->newest (zfs list -s creation is ascending),
+# plus one autosnap that the grep filter must ignore.
+SNAPSHOTS = "\n".join([
+ f"{DATASET}@autosnap_2026-01-01",
+ f"{DATASET}@pre-pacman_2026-06-01",
+ f"{DATASET}@pre-pacman_2026-06-02",
+ f"{DATASET}@pre-pacman_2026-06-03",
+ f"{DATASET}@pre-pacman_2026-06-04",
+ f"{DATASET}@pre-pacman_2026-06-05",
+]) + "\n"
+
+
+class Harness(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="zfs-pre-snap-")
+ self.bin = os.path.join(self.tmp, "bin")
+ os.makedirs(self.bin)
+ shutil.copy(FAKE_ZFS, os.path.join(self.bin, "zfs"))
+ self.log = os.path.join(self.tmp, "zfs.log")
+ self.snaps = os.path.join(self.tmp, "snaps")
+ with open(self.snaps, "w") as f:
+ f.write(SNAPSHOTS)
+ self.lock = os.path.join(self.tmp, "lock")
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def run_script(self, keep="3", fail=False, snaps=None):
+ env = os.environ.copy()
+ env["PATH"] = self.bin + os.pathsep + env["PATH"]
+ env["ZFS_PRE_DATASET"] = DATASET
+ env["ZFS_PRE_LOCKFILE"] = self.lock
+ env["ZFS_PRE_KEEP"] = keep
+ env["FAKE_ZFS_LOG"] = self.log
+ env["FAKE_ZFS_SNAPSHOTS"] = snaps if snaps is not None else self.snaps
+ if fail:
+ env["FAKE_ZFS_SNAPSHOT_FAIL"] = "1"
+ return subprocess.run([SCRIPT], env=env, capture_output=True, text=True,
+ timeout=15)
+
+ def log_lines(self):
+ try:
+ with open(self.log) as f:
+ return [ln for ln in f.read().splitlines() if ln.strip()]
+ except FileNotFoundError:
+ return []
+
+
+class TestSnapshot(Harness):
+ def test_creates_a_pre_pacman_snapshot(self):
+ self.run_script()
+ snaps = [ln for ln in self.log_lines() if ln.startswith("snapshot ")]
+ self.assertEqual(len(snaps), 1)
+ self.assertIn(f"snapshot {DATASET}@pre-pacman_", snaps[0])
+
+ def test_skips_when_lockfile_is_fresh(self):
+ # A lockfile newer than MIN_INTERVAL → no snapshot this run.
+ open(self.lock, "w").close()
+ os.utime(self.lock, (time.time(), time.time()))
+ self.run_script()
+ self.assertEqual([ln for ln in self.log_lines()
+ if ln.startswith("snapshot ")], [])
+
+
+class TestPrune(Harness):
+ def test_prunes_oldest_beyond_keep(self):
+ # 5 pre-pacman snapshots, KEEP=3 → the two oldest are destroyed.
+ self.run_script(keep="3")
+ destroyed = [ln.split(" ", 1)[1] for ln in self.log_lines()
+ if ln.startswith("destroy ")]
+ self.assertEqual(destroyed,
+ [f"{DATASET}@pre-pacman_2026-06-01",
+ f"{DATASET}@pre-pacman_2026-06-02"])
+
+ def test_never_destroys_non_pre_pacman_snapshots(self):
+ self.run_script(keep="1")
+ destroyed = [ln for ln in self.log_lines() if ln.startswith("destroy ")]
+ self.assertFalse(any("autosnap" in ln for ln in destroyed))
+
+ def test_no_prune_when_at_or_under_keep(self):
+ # KEEP=5 with exactly 5 pre-pacman snapshots → nothing destroyed.
+ self.run_script(keep="5")
+ self.assertEqual([ln for ln in self.log_lines()
+ if ln.startswith("destroy ")], [])
+
+
+class TestError(Harness):
+ def test_snapshot_failure_skips_prune_and_warns(self):
+ r = self.run_script(fail=True)
+ self.assertIn("Failed to create snapshot", r.stderr)
+ self.assertEqual([ln for ln in self.log_lines()
+ if ln.startswith("destroy ")], [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/todo.org b/todo.org
index a6058ee..de9ab5b 100644
--- a/todo.org
+++ b/todo.org
@@ -21,161 +21,352 @@ The vocabulary is open — topic tags are coined as needed — so these are conv
- *Effort / autonomy*: =:quick:= a spare-moment fix (minutes, not a sitting); =:solo:= Claude can carry it end to end — there's a build path, a test path, and no upfront decision needed (a leftover manual spot-check doesn't disqualify it).
- *Topic / area* (open): the subsystem a task touches — e.g. =:hyprland:= =:waybar:= =:mpd:= =:music:= =:network:= =:tooling:= =:llm:= =:eask:= =:pocketbook:= =:cmail:=. Coin a new one when it aids filtering.
* Archsetup Open Work
-** TODO [#B] Scrolling layout: frame fit + wrap-around :hyprland:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-13
+** DOING [#B] Widget gallery upgrades :feature:design:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-13
+:END:
+Usability + documentation pass over the [[file:docs/prototypes/panel-widget-gallery.html][panel widget gallery]], orthogonal to the component-generation spec work, so it runs on the =gallery-upgrades= branch (squash merge to main after Craig's UI confirmation + tweaks). Items 1-4 run as a no-approvals speedrun (Craig authorized 2026-07-12); item 5 is a joint brainstorm after the merge.
+*** 2026-07-12 Sun @ 12:59:48 -0500 Added the card size toggle (1x/2x/3x, default 3x)
+Masthead size chips apply CSS zoom per grid; 2x/3x drop the 1320px wrap cap so wide monitors get the room. CDP-verified: 84 cards, no exceptions, fader drag and toggle click both track at 3x (drag helpers are rect-ratio based, so zoom is transparent to them).
+*** 2026-07-12 Sun @ 13:42:29 -0500 Retuned the scale split per Craig
+Craig: cards a fifth smaller, fonts and palette back to normal but a little larger. The toggle (now S/M/L, "widget size") zooms only the stage — L is 2.4x, M 1.7x — while card text sits at its own raised base size (wname .95rem, note .85, spec-sheet .82) and the palette left the zoom rules entirely (tiles bumped 148→176px, fonts up a notch). Grid columns widen per size step instead of zooming. Probes re-run green.
+*** 2026-07-12 Sun @ 13:52:11 -0500 Raised all reading text to 12-13pt per Craig
+One move: html font-size 130%, scaling every rem-based size (notes/spec sheets/palette/masthead/readouts). Verified computed: notes 13.3pt, spec sheets 12.8pt, palette 12.5pt — was ~10pt. Widget-internal px sizes untouched (they are drawing, scaled by the stage zoom).
+*** 2026-07-12 Sun @ 14:07:00 -0500 Added R32 mechanical timer dial from Craig's reference
+Wind-up interval timer (Controls, after R29; gallery at 85 cards): knurled coin-edge dial rotating under a fixed red index, OFF/20/40/60 at 4.5°/min, hub screw, winding arrow, PUSH TO STOP / TURN TO START engraving, red fluted stop knob. Drag winds (turn to start), the dial runs itself back down at a demo minute-per-second (reduced-motion gated), red knob click zeroes it (push to stop). Spec sheet included. Reference filed (2026-07-12-mechanical-timer-dial.png). CDP-verified: wind → T-35 MIN, tick 35→34, stop → OFF, zero exceptions.
+*** 2026-07-12 Sun @ 14:21:27 -0500 Added R33 four-way rocker from Craig's reference
+Navigation pad (Controls, after R32; gallery at 86 cards): recessed plate, rubber pad, four outward silver arrows, center pivot nub. Quadrant-sized click zones step a tracked cursor (readout shows direction + x/y), pressed arrow flashes gold. Click-only — noted in the spec sheet as the most Emacs-portable control in the kit (it is literally arrow keys). Reference filed (2026-07-12-four-way-rocker.png). CDP-verified: all four quadrants step and accumulate correctly, zero exceptions.
+*** 2026-07-12 Sun @ 14:26:20 -0500 Timing-marker dial judged covered (Craig concurred: probable dupe)
+Scope timing-marker reference (VARIABLE TIMING / MARKER SEC): a knob rotates a printed scale disc under a fixed top index — that mechanism is R02 (vernier disc under hairline), and the value-in-a-window read is R20 (drum roller). BANKED enrichment, new to the kit: the backlit active-value window — the full scale stays visible but only the selected value glows through an amber window (R02 shows all + highlights none; R20 highlights one + hides the rest). Also noted: the 1-2-5 stepped decade scale (us/ms/s) as detent content for any stepped dial. Reference filed (2026-07-12-timing-marker-dial.png); no new card.
+*** 2026-07-12 Sun @ 14:29:22 -0500 Added R34 four-way toggle selector from Craig's reference
+NOT a dupe of R33: the rocker is momentary (press = step), this is stateful (the lever lives in A/B/C/D). Test-panel styling: printed X/Y axes + circle + diagonal legend square, A-D label rings, corner lamps wired by printed lines, chrome ball lever that throws to the clicked quadrant while its lamp takes the light (exactly one lit, jewel-a). Click-only. Gallery at 87 cards. Reference filed (2026-07-12-four-way-toggle.png). CDP-verified: default POS C per the photo, all quadrants select, single-lamp invariant holds, lever transform tracks, zero exceptions.
+*** 2026-07-12 Sun @ 14:37:46 -0500 Added R35 day-date disc calendar from Craig's reference
+Watch day-date complication (Indicators, after R31; gallery at 88 cards). Judged NOT covered: N04 is two nested input knobs, R02 one disc under a hairline — a compound READOUT from independent coaxial discs is a new indicator form. Cream disc, 31 radial date numerals (upside down at bottom like the movement), weekday names twice around inside, fixed yellow hand as the read index, movement-plate screws. Live: initializes to today; click rolls midnight one day (both discs step). Spec sheet notes the honest limitation — the 31-slot disc ignores short months, exactly like the hardware. Reference filed (2026-07-12-daydate-discs.png). CDP-verified: today correct (SUN 12), click → MON 13, both disc transforms track, zero exceptions.
+*** 2026-07-12 Sun @ 14:40:47 -0500 Added R36 LED dot matrix from Craig's reference (answered: what display is this)
+Answered Craig's question: an 8x8 red LED dot-matrix module (64 discrete LEDs behind a tinted window, multiplexed; Kingbright/Lite-On family part) on Kilpatrick Audio's K4816 Pattern Generator, drawing its K logo. Judged new form: seven-seg/starburst are fixed segments, VFD marquee is character cells, R10 is a text LCD — a free bitmap display is none of those. Built as R36 (Indicators; gallery at 89 cards): 8x8 paintable matrix starting on the reference K (17 dots), click toggles any dot, readout counts lit. Reference filed (2026-07-12-led-dot-matrix.png). CDP-verified: K = 17/64, paint on → 18, paint off → 17, zero exceptions.
+*** 2026-07-12 Sun @ 18:11:02 -0500 Built the takuzu-survey Tier 1+2: R37-R47 + N23 alarm lifecycle (gallery at 100 cards)
+Craig picked option 2 from my assessment of takuzu's historical-panel-components research (60+ candidates; ~a fifth of its "new" verdicts were stale against the current kit, and the magic-eye entry re-litigated a removal takuzu itself requested 2026-07-11). Built + CDP-verified in 4 commits: 9b625d6 R37 pin routing matrix (VCS3 many-to-many, pins seat/pull, route count) + R38 dead-man button (first held-state control: pointer-capture, dwell clock, release-to-safe) + R39 rotary telephone dial (wheel winds to the stop and returns, digit pacing authentic); 109b3dd R40 breaker panel (first system-thrown control: TRIP pops a breaker to the amber mid-state, two-step reset) + R41 DSKY (verb/noun grammar, V35 lamp test, OPR ERR on bad grammar — one bug caught by probe: stale verb register re-executed on bare ENTR, fixed) + R42 cam-timer drum (procedure-as-position, self-advances after a click starts it); eb926ea R43 attitude indicator (first two-axis instrument, 2D drag) + R44 heading bug + servo needle (commanded-vs-actual, shortest-path chase); cbcad5b R45 flip-disc array (bistable pixel, scaleX flip) + R46 dekatron (pulse-stepped glow, carry blink on wrap) + R47 landing gear (spatial three-greens + transit pulse) + N23 upgraded with the alarm lifecycle (flash → ACK steadies → new alarm re-arms → RESET / TEST). All spec sheets written. BANKED as idioms, not cards: mimic diagram, oscilloscope cluster, throttle quadrant, voice-loop strip, blinkenlights, lockout-tag disabled-with-reason, knife-switch skin, decade-switch bank, normalled-jack overlay, neon-vs-jewel, VHF detent, valve handwheel, circular chart, two-hand anti-tie-down. Judged covered/stale in the doc: warning flag (R11), split-flap (N20), strip-chart (N16), odometer (N25), interval timer (R32), telegraph (R30), foot switch (R24), light gun/trackball (the mouse), guarded-toggle two-step (R29 already does it). Full regression green at cbcad5b.
+*** 2026-07-12 Sun @ 18:29:52 -0500 Control-grammars reference note + the six promoted banked cards (gallery at 106)
+Craig picked option 3 (note first, then build) and asked for the reading list in home's inbox. Shipped: docs/design/2026-07-12-control-grammars-reference.org (b0cebcf — the seven named grammars each with a kit exemplar, the literature from Moran CLG through MIL-STD-1472/NASA HIDH, and seven proposed taxonomy axes; the gap-finding move is crossing task x cardinality for empty cells). Reading list delivered to home/inbox (2026-07-12-1822). Then promoted six banked items to cards, CDP-verified in two commits: 5ef616f R48 knife switch (side-view blade on a real hinge; the air gap is the proof) + R49 decade box (four skirted decade knobs compose 3,500 Ω style values) + R50 two-hand safety (arm window 0.5s, same-hand and timeout both fault); 8b4978b R51 voice-loop keyset (independent monitors, exclusive talk, per-loop activity flicker) + R52 blinkenlights (live ADDR/DATA lamps folding in a clickable switch register, octal readout) + R53 circular chart recorder (day-per-revolution pen trace, fresh paper on click). Full regression green at 8b4978b, 106 cards. Still banked (genuinely idioms): mimic diagram, scope cluster, throttle quadrant, lockout tag, knife-skin, normalled jacks, neon-vs-jewel, VHF detent, valve handwheel.
+*** 2026-07-12 Sun @ 18:52:41 -0500 Added R54 vertical tape + R55 twin-needle gauge from the Ki-57 panel (gallery at 108)
+Craig fed a Mitsubishi Ki-57 right-panel photo (instruments identified card by card, labels read from the Japanese: 速度計 airspeed, 遠方回転計 remote tach, 昇降計 VSI, 人工水平儀 artificial horizon, 高度計 altimeter, 気筒温度計 CHT, etc.). Two genuine gaps built: R54 vertical tape instrument (scrolling scale behind a fixed amber index — the Ki-57 remote tach's form, universal in glass cockpits; drag spins the tape) and R55 twin-needle gauge (mirrored FUEL/OIL half-scales, two needles one hub, per-half drag surfaces — the banked "twin mirrored gauge" from the French jet panel, distinct from N13 whose needles are read at their crossing). Cleanup pass earlier (c4fcee6): rotary-dial digits were painted under the finger wheel — now on top; legible demo defaults for pin matrix / voice loop / DSKY PROG; caption color unified; cam labels bumped. Reference filed (2026-07-12-ki57-right-panel.jpg). CDP-verified: tape drags (2400→1329), needles independent (fuel up leaves oil, oil down leaves fuel), zero exceptions.
+*** 2026-07-12 Sun @ 18:59:08 -0500 Added R56 comfort-zone crossed needles (gallery at 109)
+Craig fed a brass weather-station comfort meter and correctly picked it as different from both dual meters: N13 derives a NUMBER from the crossing (iso-curves), R55 reads two needles separately; this one lands the crossing in a printed categorical VERDICT (TOO WARM / TOO DRY / JUST RIGHT — active zone goes red, a digital advantage the paper face lacks). Brass knurled bezel, cream face, temp needle from bottom-left, humidity from bottom-right, per-half drag surfaces, needles clipped to the face (probe caught tails poking past the bezel at low values — fixed). Reference filed (2026-07-12-comfort-meter.png). CDP-verified: default 72F/45%/JUST RIGHT, warm and dry verdicts flip with the active label, zero exceptions.
+*** 2026-07-12 Sun @ 19:13:41 -0500 Squash-merged to main (bc93388) + cleanup round 2
+Squash merge of the 25-commit branch landed on main; branch deleted local+remote. Then a programmatic defect sweep (per-card overflow/empty/dead-readout/sparse bounding-box audit) caught R51's monitor bars streaking across the card — Chrome's CSS zoom miscomputes absolute left/right insets, fr tracks, AND stretched widths inside zoomed stages, so the keyset now uses fixed 52px grid tracks and fixed 40px flow bars (fixed px scale correctly under zoom; this is the third zoom-layout trap after the two in the same widget — noted for future stage-internal CSS: prefer fixed px inside .stagew). Seven intrinsically tiny widgets (toggle, chip, arm-to-fire, mini signal, ladder, thermometer, status lamp) got a .boost stage zoom (1.5x, compounds with the size toggle) so they stop floating in empty stages. Sweep false positives understood: clipped content (R43 horizon, R54 tape, N22 marquee, N25 counter) reports as overflow because getBoundingClientRect ignores clip; N13/N17/N28/R20/10 verified clean visually. Full regression green at 109 cards.
+*** 2026-07-12 Sun @ 19:25:04 -0500 Page iteration: palette rebuilt as named instrument colors, moved to bottom; default size M
+Per Craig: the palette left the top (now below Indicators) and stopped being a token dump — it is now a curated card of 36 NAMED colors grouped by role (Materials / Faces & inks / Lamps, LEDs & jewels / Screens & phosphors / Needles & controls), each with a name (Brass, Chart paper, Neon orange, Graticule green...) and where it appears on the instruments. No hex codes visible anywhere on the page (verified by innerText scan); page chrome (background, body text) deliberately excluded. Default widget size dropped 3x → M (1.7x). Data lives in a PALETTE array in gallery JS — the curation IS the data, since most instrument colors are local literals, not tokens.
+*** 2026-07-12 Sun @ 13:01:27 -0500 Added the palette section
+New "Palette — design tokens" section above Controls: 33 tiles read live from the generated =:root= rule via document.styleSheets (zero drift possible — what renders is what the widgets use). Hex tokens and glow rgb triples get swatches; mono/pulse-rate render as text tiles. Scales with the size toggle.
+*** 2026-07-12 Sun @ 13:08:42 -0500 Added screen-color families + chips (first round)
+Six period families defined (green = P1 phosphor built on =phos=, amber = P3 on the gold family, red = the nixie neon pulled out, blue = P4 white-blue, vfd = the marquee's =--vfd=, white = mono LCD), applied via scoped CSS vars with the shipped color as fallback — defaults are pixel-identical until a chip is clicked. Chips on the five clearly-screen widgets: R10 data matrix (amber), R17 CRT scope (green face), R19 waveform LCD (white), R31 radar (amber — the "in amber" one; green chip gives the classic PPI), N11 oscilloscope (green). Answers to the green question: the kit deliberately has four greens — phos #7fe0a0 (CRT trace), vfd #63e6c8 (marquee, blue-leaning), sevgrn #57d357 (LED), jewel-g #6fce33 (jewel lens); the phosphor-screen green is phos, not the others. Nixie left chipless: neon is only ever orange. Families live in gallery JS, not tokens.json — tokenize the winners once picked, the way amber earned its tokens. CDP-verified: recolors land on all five, interactions intact, zero exceptions.
+*** 2026-07-12 Sun @ 13:15:19 -0500 Added spec sheets to all 84 cards
+Every card now carries a collapsible "spec sheet" (a details element under the note) with up to 8 fields: input (always present, names the model — click ports everywhere including Emacs click-regions, drag needs a click/key idiom there, display widgets take no real input), solves, use (common vs specialty + where it shines), limits, origin, difficulty, prefer-when, and period (present on 41 of 84 — only where the component is clearly not timeless, e.g. nixie 1955-75 sits with keypads/telegraphs and clashes with VFD and LED seven-seg). Content lives in one INFO object keyed by card number; card() renders it. CDP-verified: 84/84 sheets present (missing-key check), fields render, zero exceptions.
+*** TODO Widget validation pass
+Craig validates every control via the card lamps (his worklist, not agent-checkable): click cycles off → amber → green, state persists per card in localStorage (key =gv-<no>=). Gate change with the option-1 approval (2026-07-12): the lamps no longer gate the widgets.js extraction (lossless, proceeds now) — they gate per-widget Emacs ports and the final catalogue blessing. When the pass completes, bake the final statuses into the gallery source.
+*** TODO Component classification taxonomy
+Brainstorm WITH Craig (after the squash merge, not solo): break the components down by use — freeform output, binary selection, multi-selection, fine-tuning, etc. The taxonomy identifies use-cases with no appropriate component, which fills out the gallery with some confidence of completeness.
+** DOING [#B] Retro widget catalogue :feature:design:
+:PROPERTIES:
+:SPEC_ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba
+:LAST_REVIEWED: 2026-07-13
+:END:
+The panel widget gallery ([[file:docs/prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]]) grows into a retro-instrument component catalogue: reference photos of period hardware → gallery cards (the visual + behavioral spec) → reusable components for three targets (emacs svg.el, web/React, waybar). Tokens single-sourced in [[file:docs/prototypes/tokens.json][tokens.json]] (gen_tokens.py emits web/waybar/elisp); svg.el proof widget shipped (gallery-widget.el, needle gauge). Reference photos live in [[file:working/retro-stereo-widgets/][working/retro-stereo-widgets/]]. Collection converged at R56 (109 cards, all behaviorally verified; probes in [[file:tests/gallery-probes/][tests/gallery-probes/]]).
+
+Build runs per the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DOING; reviewed + decomposed 2026-07-12): web extraction first (ungated, lossless), then demand-gated Emacs/waybar ports. Banked variant/composition ledger lives in the 2026-07-11/12 session archive.
+*** 2026-07-12 Sun @ 10:14:10 -0500 Wrote the component-generation spec (DRAFT)
+[[file:docs/specs/2026-07-12-component-generation-spec.org][2026-07-12-component-generation-spec.org]] via spec-create: full spine (summary, problem, goals, two-altitude design, alternatives, 8 decisions with 1 open, 5 phases, acceptance criteria, readiness dimensions, risks). Gallery cited as the prototype evidence per the ui-prototyping rule (filed references + R01-R31 iteration history + final at 52a43ec). vNext items logged as the "Widget catalogue vNext" task.
+*** 2026-07-12 Sun @ 20:24:37 -0500 Web library packaging approved — classic-script widgets.js + GW namespace
+Craig approved with the componentization go-ahead (option 1): =widgets.js= as a classic script exposing one =GW= namespace, relative =<script src>= so =file://= keeps working, shared helpers inside, framework wrappers vNext. Decision flipped DONE in the spec (cookie 8/8). Gate per the same approval: extraction proceeds ungated (lossless transform); the validation lamps gate only per-widget Emacs ports and the final blessing.
+*** 2026-07-12 Sun @ 20:57:50 -0500 Spec reviewed and decomposed (DRAFT → READY → DOING)
+spec-review passed (Ready): 3 findings, all accepted and folded same pass — option-1 supersession of the demand-gated extraction order (Phases 1-2 swapped: extraction first, ungated), the card-record refactor named in Phase 1, stale counts refreshed (109 cards / R56). Probe baseline repaired first (753380e — two stale assertions from the evening sprint). Phase tasks below; =:SPEC_ID:= stamped on this parent.
+*** 2026-07-12 Sun @ 22:56:40 -0500 Phase 1 complete — all 109 widgets extracted into widgets.js
+All 109 card builders lifted into [[file:docs/prototypes/widgets.js][docs/prototypes/widgets.js]] (classic script, =GW= namespace, shared engine: svgEl/polar/vuDb/drag helpers/seg7/SCREEN_FAMS/gradient defs); every card is now a declarative record rendered by one =card()= path, all wiring blocks deleted. Widget CSS (incl. pulse/flipdrop/reelspin keyframes) moved from the gallery =<style>= block into =GW_CSS=, injected by widgets.js — verified pixel-identical under forced reduced motion (masked live-date cards N26/R35). Tick contract settled: the page owns the clock + demo signal; live meters expose value-driven handles; widget-owned animation lives in builders behind reduced-motion gates. Finale: slide toggle (card 01) is the first fully-realized component — its four option groups are =GW.slideToggle= constructor opts backed by =STYLES=, the gallery chips a demo rig on =handle.setStyle=. README consumers section documents the GW API + tick contract. Per-batch gate stayed green throughout (probe.mjs, probe-fams.mjs, a 239-check behavioral suite, reduced-motion smoke). Commits acee657 → 7b3bc47 (18 batches), all pushed.
+*** TODO Phase 2 — demand inventory (Emacs/waybar) :design:
+Widget-to-target matrix for the scripted-port targets: walk the live waybar panels (net/bt/audio/maint) and the Emacs surfaces Craig names; record which cards each actually wants. Lands in the spec's appendix; Craig approves. Tree untouched. Not =:solo:= — the matrix is his call.
+*** TODO Phase 3 — Emacs ports of demanded widgets :feature:
+Extend the =gallery-widget.el= pattern per demanded widget: ERT (tokens, geometry normal/boundary/error, SVG structure, state-tracks-value) + rsvg-convert side-by-side against the card; keymap/click-region interaction per widget; wired into =make test-elisp=. Gated on the Phase 2 matrix and Craig's green lamp per widget.
+*** TODO Phase 4 — waybar pilot: audio panel :feature:dotfiles:
+Restyle the audio panel's GTK CSS onto =tokens-waybar.css= + the banked composition idioms. Lives in =~/.dotfiles=; archsetup drives edit/test/commit/push end to end + inbox note. Visual result gets a manual-testing checklist entry (daily-driver panel).
+*** TODO Phase 5 — Level-2 generator go/no-go :design:
+After ~5 hand ports, weigh widget-level codegen with evidence (mechanical duplication vs judgment per port). Recorded as a dated decision in the spec; go spawns its own spec.
+*** TODO Flip the spec to IMPLEMENTED
+When the phases above close: status heading keyword → =IMPLEMENTED=, dated history line with the reason, Metadata =Status= mirror. Three lines, one file.
+** DONE [#C] Org-capture float popup grows too large :bug:hyprland:quick:solo:
+CLOSED: [2026-07-14 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-13
+:END:
+Craig answered the pre-flight (2026-07-14): cap at 120 wide, height proportional. Applied as 120 Emacs columns (11 px/col measured from the live daemon) = 1320 px wide, height 653 px from the old rule's aspect. Ratio's size rule shrank from the 1892x936 scratchpad match and both hosts gained a max_size growth cap (the field is max_size — bare "maxsize" is invalid and hyprctl reload won't say so; check hyprctl configerrors). Verified live: config clean, a probe window floats at exactly 1320x653. Dotfiles 9c4dc2f.
+** TODO [#C] Maint live-refresh hairline replacement :feature:maint:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-14
+:END:
+From the roam inbox (routed 2026-07-13): the memory-killer section seemed to update too often, and "it's a bit unclear what the line is doing; consider something else." Diagnosis (2026-07-14): the data cadence is already the requested 3s (gui live tier, _LIVE_SECONDS); the perceived churn is the live-refresh hairline — the 2px bar under the live sections that drains full-to-empty over each 3s window, redrawn at 150ms (gui._hair_tick, viewmodel.refresh_fraction). It exists to tell a stale board from a frozen one (2026-07-09), but it reads as constant unexplained motion. Design call for Craig: replace the draining line with something whose meaning is legible — candidates: a dot that blinks once per refresh, a "3s" age caption that only appears when refresh is overdue, slowing the drain redraw, or dropping the indicator on live tiers and keeping it only when data goes stale. Keep the stale-vs-frozen distinguishability that motivated the hairline.
+
+** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network:
+:PROPERTIES:
+:SPEC_ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3
+:LAST_REVIEWED: 2026-07-13
+:END:
+Build the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion]] (IMPLEMENTED). Adds the control-plane cluster (rival-manager / nm-masked / keyfile-perms) and a sharper auth verdict to the shipped net doctor (=~/.dotfiles/net/=). Archsetup owns the dotfiles work end to end — edit, test, commit, and push in =~/.dotfiles=, then drop an inbox note. All build phases shipped and fake-verified; the one open piece is the VM live verification below.
+*** 2026-07-11 Sat @ 02:47:47 -0500 Built the read-only control-plane probe
+New module =net/src/net/control_plane.py= plus =diag.py= wiring, on dotfiles main (=21ca3ff=, pushed). =net diagnose= now carries a =control_plane= key in its envelope with three read-only signals: NetworkManager state (masked/failed/stopped/active, finer than the plain =is-active= the ladder used), any rival manager active alongside NM (dhcpcd, systemd-networkd, iwd via =systemctl is-active=), and the active profile's keyfile permissions (=stat= under an env-overridable =NET_NM_CONNECTIONS= root, default =/etc/NetworkManager/system-connections=). Detection only: no classifier change, no new step, no bearing on =overall=. Surfaced via =net diag --json=. The masked/failed read also runs in the nmcli-down early-return path, or a masked NM would short-circuit before the probe. Every read bounded through =cmd.run='s timeout; degrades to unknown / no-rival / None on an unreadable tool or unstattable file, so a probe that can't see never invents a fault. As a normal user the real 0700 root:root dir is unstattable, so the keyfile signal reads unknown (readable only under root, e.g. the doctor's privileged path). 21 new tests, full =make test= green; =/review-code= approved (two Minor items, both Phase 1 concerns already tracked in the spec: link-scoping the rivals and guarding the keyfile path in the chmod fix). Inbox note sent to dotfiles.
+*** DOING [#B] Phase 1 — control-plane verdicts + privilege model
+Unblocked now that panelkit ships. =classify= gains =rival-manager=/=nm-masked=/=keyfile-perms= (all =fixable=), ordered ahead of the generic not-running rule, with the masked check reachable in the NM-down early-return path. Fixes register as =VERBS= + =ACTIONS= (=disable-rival=/=unmask-nm=/=chmod-keyfile=), each narrowly scoped. Live verification needs a real rival/masked/bad-keyfile state (a VM or a deliberately-broken host), so not solo.
+**** 2026-07-11 Sat @ 04:32:06 -0500 nm-masked verdict shipped + first panelkit integration
+On dotfiles main (=d73eba6=, pushed). The clean, unambiguous verdict of the three, done first to prove the panelkit-in-net rails: =classify= reads =control_plane.nm_state=; on =masked= it returns a fixable =unmask-nm= verdict (=remedy_class="privileged"=) ahead of the generic nm-service rule (=nm-restart= can't start a masked unit). New priv verb =unmask-nm= (=systemctl unmask NetworkManager=), =repair_unmask_nm= (unmask → start → verify), narration entry. The doctor resolves a privileged verdict through =panelkit.resolve()= before running: can't-elevate (GUI, no passwordless, no tty) degrades to the manual guide instead of attempting; existing tiers (no remedy_class) run unchanged. The =net= shim + test add =panelkit/src= to sys.path. Design call shipped (flagged for Craig): CLI =net doctor --fix= is the deliberate act satisfying the Confirm floor, so no CLI prompt added; panelkit contributes the run/prompt/guide degradation on the CLI; the GUI arm-press stays its confirm (panel wiring pending). 765 net tests + 65 suites green, review-code clean. Inbox note sent.
+**** 2026-07-11 Sat @ 05:16:24 -0500 rival-manager + keyfile-perms verdicts shipped
+On dotfiles main (=c9f8604=, pushed). Craig approved proceeding with the default precedence. =classify= adds =disable-rival= (rival dhcpcd/networkd/iwd active + a failing link) and =chmod-keyfile= (bad-perms keyfile + a failing link), both =remedy_class="privileged"= through the same panelkit gate. PRECEDENCE settled: both gate on a local-link symptom (never nag next to a working link), sit behind rfkill/service (more fundamental) and ahead of the generic reset (naming the cause beats a blind reconnect); tested (rfkill beats rival, rival beats reset). Priv verbs =disable-rival= (three-unit allowlist), =chmod-keyfile=/=chown-keyfile= (paths validated under system-connections, no traversal). Repairs re-derive their target (=active_rivals= / the active connection). =fake-systemctl= gained a state dir so a disable flips the re-check. 782 net tests + 65 suites green, review-code clean. Two coverage edges deferred to the VM: the keyfile fix's root-owned pass (only reproduces under root), and whether disable-rival needs a follow-on reset to restore the link. All three Phase 1 verdicts now fake-complete; VM live-verification is the remaining piece (see the sub-task).
+**** TODO Net Phase 1 live verification — run the scenario harness against a VM
+The harness is BUILT (archsetup =a364c1e=): =scripts/testing/net-scenarios/{10-nm-masked,20-rival-manager,30-keyfile-perms}.sh= (break/fix/assert + the expected diagnose verdict) and =run-net-scenarios.sh= (rsyncs net+panelkit into a target over ssh, drives the scenarios). Scenario logic reviewed; the ssh transport plumbing is UNEXERCISED (marked first-draft) and wants a shakeout on the first real run. What remains needs Craig: a booted VM (a real kernel + network stack — NM does NOT run reliably in nspawn, so a container only shows the mask-symlink/keyfile-mode half, not "NM active") with NetworkManager + dhcpcd installed, reachable over ssh as root. Then =run-net-scenarios.sh --target root@HOST --profile <saved-profile>=. The two live questions: disable-rival was already chained to a follow-on reset (=be15a81=), so the run confirms whether the reset fires or NM auto-recovers; and the keyfile root-owned pass (only reproduces under root). The by-hand equivalent is the "Manual testing and validation" → "Net doctor privileged fixes" checklist.
+*** 2026-07-11 Sat @ 06:31:29 -0500 Built the sharpened auth verdict
+On dotfiles main (=12e3e76=, pushed). =gather_context= derives an auth cause on an auth failure from the saved profile's key-mgmt + the scanned SECURITY flags for the SSID (not GENERAL.REASON, which only marks *that* auth failed): sae / hidden / enterprise / generic. =classify= turns sae and hidden into =fixable= Privileged profile-modifies (=key-mgmt sae= + PMF, or the hidden flag) routed through =panelkit.resolve()= like the Phase 1 control-plane verdicts; enterprise and generic stay =needs-user-action= with a cause-named message. Two new priv verbs (=conn-sae=, =conn-hidden=, uuid-validated single =nmcli connection modify= commands) + =repair_auth_sae=/=repair_auth_hidden=, each chained into a reset (FIX_CHAINS) so NM reconnects with the corrected profile. Hidden fires only on a non-empty scan missing the SSID, so a failed scan can't fabricate it. Tightened the =fake-nmcli= key-mgmt hook to require the =-g= prefix so the conn-sae modify isn't read back as a get_value. Pairwise (reason × profile-state) covered in =TestAuthCauseDerivation=. 813 net tests + 65 suites green, review-code Approve, voice. Inbox note sent. Live half (real WPA3/hidden profile-modify) is the VM/manual checklist.
+*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the net spec to IMPLEMENTED and logged the vNext items
+Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (flaky/drops cluster, DoT/DNSSEC verdict, profile hygiene) logged as the "Net doctor vNext" task. The privileged-fix live halves remain with the VM live-verification sub-task and the manual-testing checklist — findings there come back as bugs.
+
+** TODO [#C] Panel smoke: faceplate state-word assertion fails on the live compositor :bug:dotfiles:test:
+Found 2026-07-14 while running the net and bt AT-SPI smokes: both fail the shared "state word present" faceplate assertion (empty list) on a live Hyprland session, and the net failure reproduces on a clean HEAD, so it pre-dates the doctor-streaming change. Every other assertion passes. A standing red smoke masks real regressions in the panels' one gated integration check. Diagnose whether the state label genuinely renders empty under the live session or the AT-SPI read races the first render. Major severity for the dev loop × some-users-sometimes = P3 = [#C].
+
+** TODO [#C] Net panel speedtest history :feature:dotfiles:network:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-14
:END:
-Disabled 2026-06-12 (bind and cycle entry points removed; Super+Shift+S reassigned to whole-desktop screenshot). The layout needs real work before it earns its chord back:
-- What fits in each frame: column/frame sizing so windows land at usable widths instead of arbitrary slices.
-- Wrap-around: navigating past the last frame should wrap to the first (and vice versa).
-- Whatever else surfaces in daily use once the above land.
-
-The support machinery was deliberately kept for this task: =layout-navigate= and =layout-resize= retain their scrolling branches, =waybar-layout= still renders the scrolling state, and the unbound legacy =cycle-layout= script still lists it. Re-enabling is two lines: add =scrolling= back to =LAYOUTS= in =layout-cycle= and restore a direct-jump bind (the old chord is taken now — pick a new one). The =tests/layout-cycle= suite pins the disabled state and will go red on re-enable, which is the reminder to update it.
+From the roam inbox (routed 2026-07-13): the networking panel should track speedtests over time with appropriate info. Shape: persist each SPEED TEST result (timestamp, down/up, latency, server) to a small local store and surface history in the net panel. Design questions for work time: retention window, which fields matter, and presentation within the panel's ~400px width (recent-results list vs trend readout). Point-in-time results exist today; the gap is comparison across days and venues.
-** TODO [#C] Waybar indicators unevenly spaced :quick:solo:waybar:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-09
-:END:
-The right-side module icons don't sit at even intervals — spacing reads as inconsistent across the group. Tune the per-module margin/padding in =dotfiles/hyprland/.config/waybar/style.css= so the icons are evenly distributed. Noticed 2026-05-21 after adding the airplane indicator.
-** TODO [#C] Wlogout exit-menu buttons are rectangular, not square
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-The wlogout exit menu renders its buttons taller than they are wide on velox, so the cells read as vertical rectangles instead of squares. They render square (centered) correctly on ratio, so this is a per-host / resolution difference, not a flat bug. Fix the button sizing in the wlogout style (=~/.dotfiles/hyprland/.config/wlogout/style.css=) so each cell is square on both hosts. Noticed 2026-05-21. Related: the [#D] VERIFY about wlogout sizing across displays.
+** TODO [#D] Net doctor vNext :feature:dotfiles:network:
+Deferred from the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion spec]] (IMPLEMENTED, v1 shipped): event-log correlation for the flaky/drops cluster (powersave, roaming stalls, USB autosuspend, no-reconnect-after-resume, firmware crashloop drop signatures — needs history a one-shot probe can't see); a DoT/DNSSEC-specific verdict distinguishing "the venue resolver mangles DNSSEC" from the generic DNS-not-resolving; per-profile autoconnect/duplicate-profile hygiene.
-The wlogout config uses fixed pixel margins, which is the likely reason sizing differs across the two displays — adjusting them for the laptop screen is part of the fix (folded in from the former "Test wlogout menu on laptop" VERIFY, 2026-06-24).
+** TODO [#D] Bt doctor vNext :feature:dotfiles:bluetooth:
+Deferred from the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion spec]] (IMPLEMENTED, v1 shipped): the stale-bond re-pair-offer signature (v1 keeps re-pair strictly user-initiated; the signature must be designed and validated before the doctor ever offers it); a connection-parameter/coexistence hint tail for a "keeps dropping" verdict; the bt-audio-profile expansion beyond the current a2dp repair (codec fallback, default-sink, absolute-volume) — wants coordination with the audio doctor so the two panels don't claim the same A2DP/HFP diagnosis with divergent verdicts.
-Add a regression test so the square-cell fix doesn't silently break on a resolution change: assert the rendered (or computed) wlogout button cells are square across ratio's and velox's resolutions. Dropped :quick: — the cross-host test pushes this past a spare-moment fix.
-** TODO [#C] Zoom launches in a tiny window :bug:hyprland:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-From the roam inbox: Zoom opens at a tiny size. Needs diagnosis (HiDPI scaling vs a window rule vs XWayland) and live verification with Zoom actually running — held for a Craig-driven debug pass, not a blind fix.
+** TODO [#D] Widget catalogue vNext :feature:design:
+Deferred from the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DRAFT): framework wrappers for the web library (React or otherwise — demand-gated, none until a real framework consumer exists); Level-2/3 widget codegen if the spec's Phase 5 decision point says go (a go spawns its own spec); ports beyond the demand matrix as new consumers appear.
-** TODO [#C] Window focus lost when unhiding stashed windows :bug:hyprland:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-From the roam inbox: hiding a window (e.g. the org-capture popup) then unhiding it should leave the unhidden window focused, but another window typically takes focus. Also =ctrl+j/k= (layout-navigate) can't reach the unhidden window afterward — it should always reach any visible window except the waybar. Involves stash-restore + layout-navigate; needs interactive reproduction with Craig.
+** TODO [#D] Maintenance console vNext :feature:
+Deferred from the [[file:docs/specs/2026-07-07-maintenance-console-spec.org][spec]] (v1 ships determinate remedies only): AI/workflow assistance on read-only metrics (the vLater tier — failed-unit diagnosis, journal-error fix suggestions, OOM investigation); SIGKILL escalation for TERM-survivors on the KILL lever; live streaming meters on evidence rows where a count could be a level.
-** TODO [#B] Guard against live mesa/hyprland/wayland-runtime updates :hyprland:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-09
-:END:
-A live =pacman -Syu= that swaps mesa/hyprland/wayland runtime libs out from under a running Hyprland session can crash the compositor: the next GPU-lib call hits a now-"(deleted)" library and SIGABRTs, taking the Wayland clients down with it. Hit ratio 2026-06-07 (mesa 26.0.6 -> 26.1.2 + hyprland upgraded live; Hyprland SIGABRT took down awww/insync/emacs). Likely the driver behind ratio's high lifetime unsafe-shutdown ratio — a crashed compositor forces a hard reset.
+** TODO [#D] Retention-repair remedy unreachable from the panel GUI :bug:dotfiles:
+snapshot_retention_repair (WRITE SANE LIMITS — =snapper -c <config> set-config TIMELINE_CREATE=yes TIMELINE_LIMIT_*=<TOML values>=) sits in the maint remedy table but is wired into no GUI layer (no reference in panel.py, viewmodel.py, or gui.py) — it's reachable only via =maint doctor review= / =maint fix=. Worse, the REVIEW & FIX roster's wording for item-bearing remedies says "per-item — on its subpanel", which for this remedy points at a key that doesn't exist. Found 2026-07-08 while walking the SNAPSHOTS subpanel with Craig.
-Ship a guard: an update wrapper, or a documented practice, that when a pending =-Syu= set includes mesa/hyprland/wayland runtime libs advises running it from a TTY (or after logging out of Hyprland) rather than live. Returned to archsetup from archangel 2026-06-09 — hyprland/mesa are installed and managed by archsetup, not the ISO installer.
+Two fix shapes, decide at work time: (1) wire a digest key — e.g. on the timeline row when the config's installed TIMELINE_LIMIT_* drift from the TOML values (needs the probe to read the installed limits for comparison); or (2) keep it CLI-only deliberately and special-case the roster wording for remedies with no panel key ("CLI only — maint fix"). Option 2 is a few lines; option 1 makes limit drift visible on the board, which is the 2026-05-26 pile-up's root cause. Severity minor × rare edge = P4 per the bug matrix.
-** TODO [#C] Pocketbook development backlog :pocketbook:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-26
-:END:
-Pocketbook (GTK4 layer-shell notes panel, toggled via waybar) was pulled from publication 2026-05-26 — github repo + cjennings.net repo deleted, mirror hook removed — and folded into this repo at =pocketbook/= until it's ready to spin back out. Src-layout Python package with pytest tests and a Makefile. Develop it in-tree; the backing modules are =store/note/panel/layer_shell/app/note_widget= + =style.css=.
-
-Backlog (unordered; promote items to their own dated tasks as they're picked up):
-
-- Configurable options, possibly a dedicated configuration panel.
-- Lose-focus hides pocketbook — configurable on/off.
-- Configurable display order: chronological by creation date (asc/desc), manual, alphabetical (asc/desc).
-- Search / filter notes.
-- Global toggle keybind (Hyprland =bind=) alongside the waybar click; document the waybar integration.
-- Note CRUD polish (create/edit/delete) + optional markdown rendering.
-- Pin / favorite notes.
-- Tags or notebooks / categories.
-- Persistence: confirm store format + =~/.local/share/pocketbook/= location, add versioning/migration, decide a backup/sync story.
-- Theming: track the dupre/hudson theme system so =style.css= follows =set-theme=.
-- Layer-shell geometry config (anchor edge, width, margins) + HiDPI / multi-monitor behavior — ties into [[file:docs/PLAN-per-host-overrides.org][per-host overrides]] scaling work.
-- Config file format (toml) + reload-without-restart.
-- Expand test coverage (TDD per testing standards; =tests/= already exists).
-- Release prep for the eventual spin-back-out: pyproject metadata, version, license.
-- Re-wire the archsetup install (gtk4-layer-shell dep + install step + post-install clone) when pocketbook ships. Removed 2026-05-26 — see git history of =archsetup= / =scripts/post-install.sh=.
-
-** TODO [#C] Fn+F9 toggles pocketbook — source unlocated :hyprland:pocketbook:
+** TODO [#C] zfs base VM image build failure: ZFS DKMS module missing :bug:zfs:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-23
+:LAST_REVIEWED: 2026-07-09
:END:
-On velox, pressing Fn+F9 (physical function key) toggles the pocketbook panel. It shouldn't. Raised from a home-project session 2026-06-23.
-
-Investigated 2026-06-23 and could not locate the trigger in any config. Ruled out, three ways:
-- No F9 bind (bare / $mod / keycode) in the live =hyprland.conf= (now a stow symlink), the velox host tier =conf.d/local.conf=, or the waybar config.
-- =hyprctl binds= runtime (all 90 active binds, authoritative) execs pocketbook on ONLY =SUPER+P=. No F9/XF86 path reaches it. The old touchpad toggle that used to sit on =$mod+F9= was moved to =$mod+M=, so F9 is unbound in Hyprland.
-- No input remapper (keyd/xremap/input-remapper) and no hotkey daemon (sxhkd/swhkd) running or configured; pocketbook's own source has no F9 / GlobalShortcuts / portal / dbus listener (its GTK ShortcutController binds only Esc/Ctrl-n/Ctrl-j/Ctrl-k/Del/Return). pocketbook is a single-instance Gtk.Application, so any path that re-runs =pocketbook= toggles it.
+=FS_PROFILE=zfs make test-vm-base= fails inside the VM at initramfs time: archangel reports "ZFS module not found! DKMS build may have failed" against the installed kernel (linux-lts 6.18.38 at the 2026-07-08 attempt). Consequences: the maint scenario harness's zfs lane (Phase 12) is filtered but unexercised, and a real zfs bare-metal install via archangel would plausibly hit the same wall. Priority per the bug matrix: Major severity (zfs install path broken) × some-users-sometimes = P3. When fixed, run =FS_PROFILE=zfs bash scripts/testing/run-maint-scenarios.sh --list= and add zfs scenario files (zpool scrub / autotrim / snapshot destroy) to the harness.
+*** 2026-07-14 Tue @ 01:40:48 -0500 Diagnosed: OpenZFS/kernel version skew, blocked on archzfs
+Reproduced in ~1 minute of install: =dkms install zfs/2.3.3 -k 6.18.38-2-lts= exits 1 during pacstrap. Root cause confirmed: OpenZFS 2.3.3's META declares Linux-Maximum 6.15, and the VM installs linux-lts 6.18.38. The first release supporting 6.18 is 2.4.0 (2.4.1 covers 6.19), and archzfs currently serves only zfs-dkms 2.3.3-1 — nothing on our side to fix. Unblock condition: archzfs publishes zfs-dkms ≥2.4.0; recheck with =curl -s https://archzfs.com/archzfs/x86_64/ | grep -o 'zfs-dkms-[0-9.]*'=, then rerun =FS_PROFILE=zfs make test-vm-base=.
-Parked at Craig's call (not worth deeper investigation now). If it resurfaces, the one unfinished step is to capture what keysym Fn+F9 actually emits (=wev -f wl_keyboard:key=, press Fn+F9, read the =sym:= / =code:=) and grep for that. Most likely folds into removing pocketbook from the waybar setup — if pocketbook leaves the bar, retire this with it.
-
-** TODO [#B] Pocketbook finish-or-cancel decision :pocketbook:
-SCHEDULED: <2026-08-23 Sun>
+** TODO [#B] Consistent keybinding family for the panel console :feature:hyprland:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
+:LAST_REVIEWED: 2026-07-09
:END:
-Decide whether to finish the pocketbook app or close and cancel the project. Removed from the waybar setup 2026-06-23 (the org-capture popup covers quick reminders and text for now), so it's out of daily use — this is the checkpoint to commit to it or retire it. Backlog above: [[*Pocketbook development backlog][Pocketbook development backlog]].
+Consider putting every panel (net, bluetooth, audio, timer, and the coming maintenance console) on one consistent chord family — a shared modifier set (Super+Shift, Control+Alt, or similar) plus a mnemonic letter per panel (N/B/A/T/M). Today the panels open via waybar clicks only; a uniform chord family makes them keyboard-reachable and predictable. Watch for collisions with existing binds: Super+Shift+A is already PTT toggle, and the hold-to-talk grave bind is load-bearing. Decide the family, audit current hyprland binds for conflicts, wire via the dotfiles hyprland config, and document in the keybind reference. Both machines (velox can't QMK-remap, so chords must work on a plain laptop keyboard).
+*** 2026-07-14 Tue @ 00:31:36 -0500 Folded Craig's ask for a maintenance-panel keybinding; bumped [#C] → [#B]
+Craig asked (in session, 2026-07-14) for a maintenance keybinding specifically — the panel he's reaching for without one. Maintenance (M) is the priority chord when this task gets worked. The capture graduated the task from parking lot to active backlog.
-** TODO [#B] Provision Eask in archsetup :tooling:eask:
+** DONE [#C] Realtime lamp output for the net + bt doctors :feature:solo:
+CLOSED: [2026-07-14 Tue]
:PROPERTIES:
-:LAST_REVIEWED: 2026-05-26
+:LAST_REVIEWED: 2026-07-09
:END:
-Add =@emacs-eask/cli= to archsetup's provisioning so fresh machines get it. Eask is installed by hand today and declared nowhere in archsetup or the dotfiles repo, yet both chime and linear-emacs depend on it (their =make setup/test/coverage= shell out to =eask=). Source: handoff from linear-emacs 2026-05-23.
+Shipped in dotfiles 0318a91. Both doctors stream: diagnose() emits each step as it completes (bt streams the first diagnosis only — the fix loop's re-diagnoses would replay the chain), and a repair's row goes up amber at attempt start and settles green/red with narration + evidence at completion, so the lamp blinks for the repair's real duration. The 3.5-entry height cap turned out to already be in both wells (it landed with the doctor expansions), so only the streaming half needed building. 5 new tests across net + bt; both suites green; AT-SPI smokes at parity with HEAD (one pre-existing state-word failure, filed separately).
+Retrofit the net doctor (=~/.dotfiles/net/src/net/doctor.py=) and bluetooth doctor (=~/.dotfiles/bluetooth/src/bt/doctor.py=) to stream results as a live output wall — one lamp per escalation step, amber while running, green on success, red on failure — instead of a final summary. Matches the maintenance-console doctor design (see [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]], "Doctor = live output wall"). Goal: every doctor in the system reads the same way. Both doctors already step through an escalation chain re-probing after each, so the steps are natural lamp boundaries.
-- Add a global npm install after the node block (=archsetup= ~2030, after =aur_install nvm=), modeled on the claude-code native-install block: run as =$username=, wrapped in =display=/=error_warn=, output to =$logfile=. Roughly =sudo -u "$username" bash -c 'npm install -g --prefix "$HOME/.local" @emacs-eask/cli'=.
-- Pin the prefix to =~/.local= so eask lands at =~/.local/bin/eask= (already on PATH) and the install runs as the user, not root. On the current machine =npm config get prefix= returns =/usr=, so eask was installed with an explicit =--prefix=.
-- Decision: also set a persistent user npm prefix (=~/.npmrc= with =prefix=${HOME}/.local=)? If yes, that =~/.npmrc= is a legitimate dotfile to stow; if no, rely on the explicit =--prefix= flag alone. =~/.eask/= is a regenerable cache — leave un-stowed.
-- Acceptance: fresh run leaves =eask= on PATH at =~/.local/bin/eask= (no root); =cd ~/code/chime && make setup && make test= works.
+Scope note (Craig, 2026-07-07): realtime lamp *behavior* only. The maintenance console's wider results-wall layout (date+time stamp column, COPY, persistent history) does NOT backport — the net/bt panels are ~400px wide and lack the horizontal real estate. Their existing output wells keep their compact layout; this task just makes them stream live.
-** TODO [#B] Waybar timer module :waybar:
+Addendum (Craig, 2026-07-07): DO backport the 3.5-entry height convention — every panel's output well caps at 3.5 visible entries, the half-visible entry being the scroll cue, with the dark slate-on-black scrollbar. Layout stays compact per above; only the height cap + scroll affordance carries over.
+
+** DOING [#B] Run-time privilege model, standard across every panel doctor :feature:dotfiles:
:PROPERTIES:
-:LAST_REVIEWED: 2026-05-26
+:LAST_REVIEWED: 2026-07-13
:END:
-A custom waybar module providing three time-keeping functions, surfaced in the bar with click/scroll controls and dunst notifications on completion.
+The audio input/output doctor is gaining a run-time privilege model (see [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]], decision "The doctor may use sudo, resolved by context at run time"). Craig's call, 2026-07-10: make it a standard, "revise the other panels to be consistent with these changes."
-- *Alarm* — fire a notification at a wall-clock time (e.g. 2:00pm). Builds on the existing =notify= + =at= pattern from protocols.org.
-- *Timer* — count down a duration (e.g. 25m) and notify when it elapses.
-- *Pomodoro* — alternating work/break cycles (default 25/5, long break after 4) with the bar showing phase + remaining time.
+The model: a doctor resolves its privilege at run time from three signals — passwordless sudo available (=sudo -n true=, which never hangs), a tty to prompt at, and whether it is the GUI panel. Four remedy classes: Auto (user-scope, reversible), Privileged (needs sudo — runs where passwordless, prompts on a CLI tty, degrades to Guide in a GUI with neither), Reboot-tail (run the applicable part, then instruct the reboot), and Guide (physical/BIOS/wait-for-upstream, nothing to run). Safety floor: every Privileged and Reboot-tail remedy defaults to Confirm or Arm tier, never silent Auto, because passwordless sudo is not consequence-free.
-Implementation notes (to flesh out when picked up): waybar =custom= module(s) with =exec= polling or a persistent =exec= script emitting JSON; click actions to start/pause/reset; a small state file under =~/.local/state= or =~/.local/var=. Lives in the hyprland tier (=dotfiles/hyprland/.config/waybar/= + a backing script in =hyprland/.local/bin/=). TDD the backing script per testing.md.
+The shared helper is built (see the dated entry below). What remains is per-panel adoption: wire each doctor's remedies through =panelkit.privmodel.resolve()= and audit them against the Confirm/Arm floor, and reconcile maint's =priv.py= build/fire table with the model rather than leaving its implicit always-passwordless assumption. That wiring lives in the per-panel fix phases (net Phase 1, bt Phase 2, audio input/output), each needing a real privileged host to verify =--fix= end to end, so none is agent-solo.
-*** 2026-06-24 Wed @ 17:32:37 -0400 Scope expansion from roam capture (folded duplicate)
-A roam-inbox capture asked for the same widget and expands the scope, so folding it in here rather than duplicating:
-- *One panel, mode-selectable* — a single component where you choose timer / stopwatch / alarm; the icon changes to reflect the selected mode.
-- *Stopwatch* — a count-up (the third function alongside the alarm/timer/pomodoro above), hover shows start time ("Stopwatch started: 12:22pm").
-- Hover text per mode: timer "Timer: 5 min", alarm "Alarm: 12:15pm", stopwatch "Stopwatch started: 12:22pm".
-- *Multiple simultaneous* — several timers/alarms/stopwatches set and displayed at once, in one panel.
-- Deliverable includes proposing a few panel designs and recommending one before building.
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-09
-:END:
-Let either side of the waybar collapse horizontally to a minimal base set, toggled by a click. Each collapsible side carries a small triangle / arrowhead pointing toward the screen edge it collapses into (away from center). Clicking it collapses that side to its base set and flips the arrow to point back toward center; clicking again restores the full side. Same shape-changes-with-state idea as the auto-dim indicator.
+Craig's decision, 2026-07-12: maint's harmless-reclaim privileged remedies (the silent CLEAN UP set — paccache keep3, journal vacuum, coredump clean) STAY silent-auto. The reconciliation gives that class a sanctioned, documented exception to the confirm floor rather than forcing Confirm/Arm; the value of the floor holds for everything else. Where sudo is not passwordless, maint should degrade per the model (prompt on a tty, guide in a GUI) instead of hard-failing.
-Spec ready (2026-06-19): [[file:working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org]]. Spike settled the mechanism: [[file:working/collapsible-waybar-sides/spike-findings.org]].
+*** 2026-07-11 Sat @ 03:48:57 -0500 Built the shared privilege model (panelkit)
+On dotfiles main (=b987820=, pushed). Craig chose to share a library, not per-panel copies. New =panelkit= package (=panelkit/src/panelkit/=): pure library code in the dotfiles tree, no CLI, no stow package — a consumer reaches it by adding =panelkit/src= to =sys.path= alongside its own =<panel>/src= and =from panelkit import privmodel=. Self-contained (its own =cmd.py=) so a panel plus panelkit stays a coherent unit (keeps the pluggable-panel option open). =privmodel=: four classes (=auto=/=privileged=/=reboot-tail=/=guide=), =resolve(remedy_class, ctx)= → a =Resolution= (=RUN=/=PROMPT=/=GUIDE_ONLY=, =confirm=, =reboot_after=, =reason=) over a =PrivContext= (passwordless via =sudo -n true=, tty, is-GUI). Safety floor enforced and property-tested across all 8 context combos: every privileged/reboot-tail remedy that runs requires a deliberate confirmation, never silent auto. =PANELKIT_SUDO= test seam (="true"=/="false"=), =detect_context()= + =plan()= convenience. 19 tests, full =make test= green (65 suites), review-code clean. Inbox note sent to dotfiles. NOT wired to a consumer — that's the per-panel adoption above.
-Decisions locked: right base set = date + worldclock + tray; left base set = menu + workspaces; per-side independent; host-agnostic (base set constant, full set is each host's existing config). Mechanism = config-swap + SIGUSR2 reload via an active-config copy in =$XDG_RUNTIME_DIR= (the CSS/state-file approach was disproven — GTK3 can't reflow-hide native modules). Lives in =~/.dotfiles/hyprland/=. Next: implement per the spec (TDD the toggle + arrow scripts).
+*** 2026-07-12 Sun @ 09:01:25 -0500 Reconciled maint onto the shared privilege model (dotfiles 7937a9e)
+maint's doctor now resolves every privileged step through =panelkit.privmodel= instead of the old always-=sudo -n= hard-fail: RUN where passwordless (unchanged), PROMPT on a CLI tty (plain sudo, preceded by a wall note), GUIDE events in a GUI with neither — the typable command on the wall, nothing executed. User-scope steps never resolve; a macro's user steps still run around guided privileged ones. Craig's silent-auto decision (above) is implemented as the documented exception: recorded in =maint/remedies.py= (tier semantics) and =panelkit/privmodel.py= (the floor's own docstring), waiving only the confirm gesture, only for the auto-tier reclaim set. TDD (15 new tests red→green incl. both wall renderers), full suite 65/65, live-verified RUN + GUIDE end-to-end on this host via the real launcher shim. Bonus root-cause fix caught by the live run: =probes/logs.py= crashed on journalctl MESSAGE=null entries (key present, so the =.get= default never applied), which broke every real fix's re-probe — normalized at =_journal_lines= with a regression test. Remaining under this task: net Phase 1 / bt Phase 2 / audio adoption (queue items 2-3), and the PROMPT-path + GUI-wall manual checks (see Manual testing and validation).
-** TODO [#B] Network-manager dropdown, nmcli-backed with GPG-stored secrets :waybar:network:
+** TODO [#B] Scrolling/Carousel layout: frame fit + wrap-around :hyprland:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-09
+:LAST_REVIEWED: 2026-07-06
:END:
-Replace the current wifi/network waybar component with a self-contained network manager driving nmcli directly (no =nmtui= dependency). Same look as the existing indicator; clicking it drops down the management interface (design open, keep it minimalistic).
-
-Core functionality:
-- Add / edit / remove connections.
-- List saved connections by SSID, ordered by recency (most-recently-used first); select one to switch to it.
-- Recognize a wired/ethernet connection even when plugged in after the session started, and allow selecting it at any time. Switch freely: ethernet↔wifi, wifi↔wifi.
-- Match all current functionality of the existing wifi/network component (status icon, signal strength, tooltip).
-
-Credential storage:
-- Store connection definitions + passwords in a GPG-encrypted file under =~/.config= (appropriate XDG location), encrypted to Craig's private key.
-- Passphrase cadence configurable: decrypt once per session, once per hour (via gpg-agent cache TTL), or never (plaintext / stays decrypted). *Default is unencrypted* — encryption is opt-in.
+Disabled 2026-06-12 (bind and cycle entry points removed; Super+Shift+S reassigned to whole-desktop screenshot). The layout needs real work before it earns its chord back:
+- What fits in each frame: column/frame sizing so windows land at usable widths instead of arbitrary slices.
+- Wrap-around: navigating past the last frame should wrap to the first (and vice versa).
+- Whatever else surfaces in daily use once the above land.
-Design / open questions (propose before building):
-- Dropdown UI tech: a GTK layer-shell panel (like pocketbook), a fuzzel/rofi-style menu, or a waybar-native expanding group.
-- Relationship to NetworkManager's own store (=/etc/NetworkManager/system-connections=, root-only): does the GPG store supplement or replace it, and how do they stay in sync.
-- Whether to keep the existing =custom/netspeed= throughput readout alongside the new SSID/status indicator.
+The support machinery was deliberately kept for this task: =layout-navigate= and =layout-resize= retain their scrolling branches, =waybar-layout= still renders the scrolling state, and the unbound legacy =cycle-layout= script still lists it. Re-enabling is two lines: add =scrolling= back to =LAYOUTS= in =layout-cycle= and restore a direct-jump bind (the old chord is taken now — pick a new one). The =tests/layout-cycle= suite pins the disabled state and will go red on re-enable, which is the reminder to update it.
-Implementation notes: backing scripts in the dotfiles repo (hyprland tier); nmcli for every NM op (device status, con up/down, add/modify/delete, wifi rescan/list). TDD the nmcli-wrapper logic with a fake nmcli on PATH. Sizable — worth a =docs/design/= doc before implementation.
+** TODO [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-14
+:END:
+*** 2026-07-14 Tue @ 00:51:51 -0500 Ratio calibre check passed; waypaper canonical decided (dark-lion)
+Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow folded the whole dir), so the first-launch gap never existed there — check closed. Craig decided dark-lion.jpg is the canonical waypaper wallpaper; the repo config.ini updated from the that-one-up-there.jpg placeholder (the file is skip-worktree volatile, unskipped for the commit and re-flagged). Remaining: when velox is back online, run make conflicts / make reset there so its old conflict copy clears against the updated repo.
+*** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2)
+The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused).
+From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output).
+
+Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a decision and a paused-machine check):
+- Waypaper canonical decision (Craig): live velox has =dark-lion.jpg=, repo has =that-one-up-there.jpg= (placeholder?). Decide the canonical, then =make reset= clears it.
+- Ratio check: whether =~/.config/calibre/*.json= on ratio are symlinks or real files — same first-launch gap likely if calibre ever launched there before stow. On the ratio trip list.
+
+** TODO [#B] Audit dotfiles/common directory :chore:dotfiles:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-14
+:END:
+Refiled from the archsetup task audit (2026-06-28), landed via ~/.dotfiles/inbox; the dotfiles content split into its own repo 2026-06-16 but the task tracking stays here per Craig (2026-07-02). Three parts:
+- Review all 50+ scripts in =~/.local/bin= and remove unused ones.
+- Check dotfiles for uninstalled packages and remove orphaned configs.
+- Verify all stowed files are actually used.
+*** 2026-07-14 Tue @ 01:40:48 -0500 Built the audit evidence report
+Shipped as =docs/2026-07-14-bin-audit-evidence.org= in the dotfiles repo (260752a). 150 scripts bucketed: 69 keep (referenced or cron-driven), 74 kill candidates (zero references in the tree), 7 flagged (all dwm-tier, expected on a hyprland host). Config sweep: audacious and wofi configs are orphan candidates, ranger needs an install-vs-delete call (declared in archsetup but not installed on ratio). Shell history was too shallow (~700 lines) to prove by-hand disuse either way — the kill pass stays Craig's call in the parent task.
+
+** TODO [#B] Waybar network module — custom/net :feature:waybar:network:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-09
+:END:
+Unifies the old wifi-no-internet indicator (was =[#C]=) and the network-manager
+dropdown (was =[#B]=) into one =custom/net= module: a tested Python =net= engine
+(nmcli + diagnostics), a thin bar indicator, and a GTK4 layer-shell panel. Code
+lives in the dotfiles repo (hyprland tier + a =net/= package like pocketbook);
+archsetup only installs deps. Secrets stay in NetworkManager's own store (no
+separate credential store). The =captive= script becomes the diagnostics engine.
+Full design, acceptance criteria, and the failure-mode coverage table:
+[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]].
+
+Phases below, dependency order. Engine/unit work is agent-verifiable (=unittest=
++ fakes on PATH, coverage via venv); the live-network and visual states need real
+conditions, filed under "Manual testing and validation".
+
+*** 2026-06-29 Mon @ 20:19:11 -0400 Phase 1 shipped — indicator + console recovery
+Shipped to the dotfiles repo (10 commits, =5254bd8=..=c095a22=, pushed to main).
+The =net= engine is a src-layout Python package in-tree, imported by a bin shim
+that resolves the stow symlink back to the repo — so it runs from a bare TTY with
+no install, which the recovery path depends on.
+
+Landed: =net status= (fast path, one nmcli call + sysfs, degraded fallback in
+budget) + =net probe= (native captive probe, single-flight flock, atomic cache,
+fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation); =waybar-net=
+replacing =custom/netspeed=, throughput → tooltip, CSS states in both themes +
+live; =net diagnose= (read-only steps) + =net repair= (rfkill/reset/bounce/
+dns-test, cleanup-verified) + =net doctor [--fix]= with the four terminal
+classifications; =net portal= + the =captive --probe-json= refactor; redacted
+JSONL event log; Makefile recovery targets (=make online= etc.); =~/.config/net/
+config=. Verified live: =make net-status= reads the real wlp170s0 / @Hyatt_WiFi.
+
+Airplane (Craig's call, option 1): =custom/net= absorbs only the *display* — net
+reads the airplane-mode state file and shows an airplane state/glyph. The
+airplane-mode toggle stays (it's a low-power mode — radios + CPU + brightness +
+services — not a radio switch), now on =custom/net='s right-click + signal 15.
+Deleted: =waybar-airplane=, =waybar-netspeed=, =custom/airplane=, their tests +
+css. =airplane-mode= kept.
+
+Tests: 160 in =tests/net/= (fake nmcli/curl/rfkill/resolvectl/ping/getent/
+systemctl on a temp PATH; doctor-classification fixtures; degraded-under-slow-
+nmcli benchmark) + the =captive= probe-mode tests; full dotfiles suite green (32
+suites). Coverage-gap pass via throwaway venv: pure modules ≥90% branch
+(classify 100%), IO-error branches excused in the test docstring.
+Deferred to Phase 2/3: archsetup deps (gtk4-layer-shell/python-gobject Phase 2,
+speedtest-go-bin Phase 3 — not added before the code that needs them).
+Verify (manual, live): see Manual testing and validation.
+
+*** 2026-06-29 Mon @ 22:19:25 -0400 Phase 2 shipped — panel shell + connection management
+Shipped to dotfiles (commits =4e7740f=..=24bcac5=, pushed). Engine: =net list= (saved
+MRU + in-range wifi scan, infrastructure types filtered), =net up/down= (UUID-keyed,
+mutation safety — keep prior link until target activates, classify wrong-password vs
+generic, report auto-reactivation), =net add/edit/remove/rescan= (open + WPA-PSK;
+enterprise activate-only; secret to NM's store, never our JSON/log — tested).
+
+Panel: a GTK-free PanelModel (selection, four state machines, the UX-flow enable
+rules, terminal states) + a GTK4 gtk4-layer-shell window (=net panel=) anchored
+top-right under the bar — Connections section with MRU list, active marked, signal
+glyph, row-click select, Connect/Add/Forget/Rescan, confirm-on-forget, worker-thread
+engine calls via GLib.idle_add. GTK imported lazily so the CLI/tests stay GTK-free.
+
+Bar interactions (settled with Craig over live iteration): left = =net-panel= toggle,
+middle = =net portal=, right = =net-fix= (notify the doctor result when one-way; open
+a terminal only when the outcome is fixable — the sudo/interactive case). Airplane on
+Super+Shift+A. archsetup adds =gtk4-layer-shell= + =python-gobject= (this commit);
+already on velox.
+
+Tests: 204 in tests/net (merge ordering/dedup, up/down mutation safety, no-secret-leak
+on add/edit, panel model + state machines, gui row-format helpers). Full dotfiles suite
+green (32 suites). Live-verified on velox: panel opens/toggles, list shows real 24
+profiles, right-click notification delivers (Craig confirmed). Phase 3 (diagnose/repair/
+speedtest IN the panel) is next; the engine for it already exists from Phase 1.
+
+*** 2026-06-29 Mon @ 22:43:40 -0400 Phase 3 shipped — diagnostics + speed test in the panel
+Shipped to dotfiles (=91277cf=..=691abcb=) + archsetup (=48052d6=, speedtest-go-bin),
+pushed. Engine: =net speedtest= (parses speedtest-go --json → ping from latency ns,
+down/up from per-server byte rates; missing-backend / offline / malformed → error
+envelope per the failure table). Panel grew a section switcher with four pages:
+- Connections (Phase 2).
+- Diagnose: =net diagnose= on a worker thread, each step a row (✓/✗/… glyph + title +
+ redacted evidence), read-only; Open-portal button when captive.
+- Repair: "Get me online" (=net doctor --fix=) + tiers (rfkill/reset/bounce/dns-test)
+ + force portal. Confirmations in-panel with the spec's exact wording; the privileged
+ tiers run via =net-popup= terminal (where the sudo prompt + step output, incl.
+ cleanup-verified, show) — a panel has no tty, and pkexec would mean a prompt per op.
+- Speed test: in-process =net speedtest= (no privilege → inline result: ↓/↑ Mbps + ping
+ + server), Run/Cancel (Cancel pkills the child), error envelope shown.
+
+213 net tests; pure helpers (step_indicator, format_speedtest) unit-tested. Full
+dotfiles suite green (32 suites). One unverified assumption: speedtest-go's dl/ul unit
+(taken as bytes/s; =BYTES_PER_SEC= flips it) — needs one real run vs a reference. The
+in-panel repair streaming (vs terminal) is a named future polish once the GUI-privilege
+story settles.
+
+The waybar network module ([#B] parent) is now COMPLETE through Phase 3. Phase 4
+(in-app help + user guide) and Phase 5 (VPN/WireGuard) remain as future work; the core
+feature (indicator + recovery + panel + diagnostics + speed test) is done.
+Verify (manual, live): see Manual testing and validation.
+
+*** 2026-07-09 Thu @ 16:32:54 -0500 Audit reconcile: Phase 4 is filed on the dotfiles side, waiting on them
+The dotfiles project accepted the Phase 4 handoff and filed it as a =[#C]= task in their own =todo.org= (their note, 2026-07-08 16:56): the help-text audit + panel help affordance, the user-guide/README, and the ratio rollout doc. Not started there. They ping when it lands, and this task's Phase 4 child closes then. Nothing to do here meanwhile.
+
+*** TODO Phase 4 — docs + rollout :network:blocked:
+Deliverable: in-app help (=net --help= + per-command, panel help affordance);
+README/user-guide (commands, indicator states, panel, config keys, make targets,
+troubleshooting from the failure table, rollback); archsetup Hyprland dep install
+(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=); ratio manual dep +
+stow step.
+Verify: =net --help= and each subcommand complete; user-guide covers every command
++ the recovery targets.
+Build handed off to the dotfiles project 2026-07-04 (=~/.dotfiles/inbox/2026-07-04-1305-from-archsetup-phase4-handoff.md=): archsetup deps confirmed installed, the remaining help/user-guide/rollout-doc work is in the net package. dotfiles pings back when it lands.
+
+*** TODO Phase 5 — VPN / WireGuard CLI fold (vNext) :network:
+Rescoped 2026-07-04 (audit): the tunnels track already shipped most of the original Phase 5. Panel tunnel bring-up/down and detection landed (dotfiles 2d9d060 probes tailscale/NM-wireguard/Proton; 21db05a brings overlays up/down from the panel's Tunnels sub-view; 31ba056 diagnose/doctor understand tunnel routes; archsetup 2e40781 wireguard config import; the net-panel-other-interfaces spec is IMPLEMENTED). What remains for Phase 5 is only the =net vpn ...= CLI subcommand — cli.py still has no vpn/tunnel parser. Fold the panel's existing tunnel operations into a CLI surface; spec separately when picked up.
+
+** TODO [#C] Waybar collapse control: replace the triangle glyph :feature:waybar:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-14
+:END:
+From the 2026-07-04 roam capture. The waybar collapse mechanism (click the triangle, the bar sections redisplay shortened) works, but the triangle glyph doesn't match the instrument-console aesthetic the panels now use. Replace it with something in keeping with the console look. Aesthetic decision — bring Craig two or three concrete glyph/style options (a machined chevron, a console-key style expander, an engraved caret) before wiring. Dotfiles waybar config (handled per the archsetup-owns-dotfiles rule). Raised alongside the net-panel/audio speedrun; deferred from it because the glyph choice is a taste call.
+
+** TODO [#C] Net panel: driver-health diagnostic tier :feature:network:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-14
+:END:
+Follow-up from the 2026-07-04 net-panel hardening speedrun (Craig's cj question on the no-WiFi item). The shipped no-wifi-hardware verdict covers "no adapter at all." This tier covers "adapter present but the driver is wedged": read-only health signals — =ip link= (device present but no-carrier / down), =dmesg= / =journalctl -k= for firmware-load failures, =rfkill= for a hard block, =modinfo= / =lsmod= for the driver module — classified before a generic reset. Remedy actions: a privileged =modprobe -r <mod> && modprobe <mod>= reload of the wifi driver, and a firmware-package pointer when the failure is a missing/failed firmware load. Dotfiles net-package work (handled per the archsetup-owns-dotfiles rule). Design pass first to decide whether it's worth a repair tier vs a needs-user-action pointer.
+
+** TODO [#B] Desktop-settings dropdown panel :feature:waybar:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-09
+:END:
+Initial spec written 2026-07-02: [[file:docs/specs/2026-07-02-desktop-settings-panel-spec.org]] (DRAFT — four decisions await Craig's review before build; architecture updated to the net panel's Blueprint/GTK4 stack).
-** TODO [#B] Desktop-settings dropdown panel :waybar:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
One waybar dropdown gathering the desktop toggles and sliders into a single settings panel, opened from a gear/settings glyph on the bar. Incorporate:
- *Auto-dim* toggle (the =custom/dim= feature just shipped — fold in here, or keep the standalone indicator and mirror it).
- *Brightness* slider (backlight, via brightnessctl).
@@ -193,17 +384,9 @@ Design / open questions (propose before building):
Implementation notes: a small GTK layer-shell app (mirror pocketbook's structure: src-layout Python package, pytest, Makefile) talking to brightnessctl / hyprctl / the touchpad + airplane helpers. Lives in the dotfiles repo or in-tree like pocketbook. TDD the backing toggle/slider logic. Sizable — worth a design doc first.
-** TODO [#B] Separate mpd playlist_directory from music_directory :mpd:music:quick:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-09
-:END:
-Spec written and approved (option 1), pinned before execution on 2026-06-03. Root issue: mpd.conf has =playlist_directory= == =music_directory= == ~/music, so the whole audio library is the playlist store and radio streams mix with curated playlists. Option 1: radio stream playlists (portable, 73 in the dotfiles repo) move to a dedicated =playlist_directory= (=~/.local/share/mpd/playlists=) via stow; the 22 curated local playlists (machine-specific track refs) live in the music tree. Also removes the broken ~/music/radio/ orphan (73 dead symlinks).
-
-Full step-by-step spec (mpd.conf edit, repo restructure of =common/music/= → =common/.local/share/mpd/playlists/=, curated relocation, restow, verification incl. the 7 relative-path curated playlists, ratio propagation) is in the 2026-06-03 session record under .ai/sessions/. Two open decisions before executing: (1) drop the empty =60s Sounds.m3u= or refill with the SomaFM 60s URL; (2) curated playlists into =~/music/playlists/= subdir vs leave flat in ~/music/. Side cleanup surfaced: a stray audio file =Black Flamingos - Space Bar.m4a= is wrongly committed in the dotfiles repo's =common/music/= — git rm it and move to the synced library.
-
** TODO [#B] Local offline LLM runtime + per-host model cache :tooling:llm:
:PROPERTIES:
-:LAST_REVIEWED: 2026-05-29
+:LAST_REVIEWED: 2026-07-06
:END:
Add a local-LLM provisioning track so machines can run an offline coding agent when there's no network. Install =llama.cpp= (CPU + Vulkan where practical) and prefetch per-host model files while network is available; expose OpenAI-compat local endpoints (=127.0.0.1:8081= coding, =:8082= general; =:11434= reserved for =ollama= if used). Per the rulesets generic-agent-runtime design pass — rulesets becomes runtime-neutral and owns the runtime manifests + project instructions; archsetup owns machine provisioning + the per-machine model inventory. Source: handoff from rulesets 2026-05-28 ([[file:assets/outbox/2026-05-28-from-rulesets-local-llm-install.org][outbox copy]]).
@@ -239,117 +422,17 @@ Boot the configured endpoint and send a short prompt; surface success/failure +
Acceptance: fresh VM install of the ratio profile reaches an endpoint on =:8081= that answers a smoke prompt; velox profile gets Q4_K_M + 8B and answers a prompt within reasonable laptop latency; network-down install completes successfully with the pending-models warning surfaced.
-** DOING [#B] Prepare for GitHub open-source release
+** TODO [#C] Voice dictation / speech-to-text input :feature:tooling:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-09
+:LAST_REVIEWED: 2026-07-06
:END:
-Remove personal info, credentials, and code quality issues before publishing.
-*** 2026-06-16 Tue @ 00:55:39 -0500 Six dotfiles-scoped sub-tasks moved to the ~/.dotfiles project
-Per the 2026-06-16 task audit, the six sub-tasks targeting files now owned by the standalone =~/.dotfiles= repo were handed off to that project (newly bootstrapped as its own AI project) and removed from this epic: "Remove credentials and secrets from dotfiles", "Remove/template personal info from dotfiles", "Remove binary font files from repo", "Move battery out of waybar sysmonitor group", "Resolution-adaptive scratchpad sizing", and "Dynamic waybar/foot config based on screen resolution". Handoff: =~/.dotfiles/inbox/2026-06-16-0053-from-archsetup-dotfiles-release-prep-handoff.org=. This epic now covers archsetup-proper release work only (scripts personal-info, device-specific config, history scrub, shellcheck, SPDX headers, README/LICENSE). The 2026-06-09 reconciliation note below is the prior state.
-*** 2026-06-09 Tue @ 19:21:36 -0500 Reconciliation: six sub-tasks now target the ~/.dotfiles repo, not archsetup
-Phase 3.2 removed the in-repo =dotfiles/= tree, so six sub-tasks below no longer describe archsetup content — they target files now owned by the =~/.dotfiles= repo (=git.cjennings.net/dotfiles.git=): "Remove credentials and secrets from dotfiles", "Remove/template personal info from dotfiles", "Remove binary font files from repo", "Move battery out of waybar sysmonitor group", "Resolution-adaptive scratchpad sizing", and "Dynamic waybar/foot config based on screen resolution". Their paths are relative to that repo now. Kept here for tracking per Craig (2026-06-09); he'll re-scope the archsetup-vs-dotfiles split shortly. archsetup-proper release work (scripts personal-info, device-specific config, shellcheck, and scrubbing the pre-=b10cba5= dotfiles secrets from archsetup's own history) stays this task.
-*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Open-source-prep source audit
-Checked each subtask below against the source / git state. Bottom line: almost nothing is fully done. =LICENSE= and =README.md= were added this session (see those subtasks); the rest still stands.
-- *Remove credentials and secrets from dotfiles* — NOT DONE. All five named files still tracked: =dotfiles/common/.config/.tidal-dl.token.json=, =.config/calibre/smtp.py.json=, =.config/transmission/settings.json=, =.msmtprc=, =.mbsyncrc=. =.gitignore= lists none of them; no =.example= templates exist.
-- *Remove/template personal info from scripts* — PARTIALLY DONE. Repo URLs ARE config-driven (=archsetup:141-146= use =${dwm_repo:-https://git.cjennings.net/...}=, documented in =archsetup.conf.example=). Still personal: =archsetup:2-3= (email/website header), =init:8,21= (=root:welcome=), =scripts/post-install.sh:17-56= (personal repos).
-- *Remove/template personal info from dotfiles* — NOT DONE. =.gitconfig= has =c@cjennings.net=, =name = Craig Jennings=, =github user = cjennings=, =safe.directory= and employer creds; =.config/mpd/musicpd.conf= + =mpd.conf= still use =~cjennings/= / =/home/cjennings/= paths; =.ssh/config= has personal/employer hosts; =.config/yt-dlp/config:2= has =c@cjennings.net=; =hyprland.conf:3= has personal attribution.
-- *Scrub git history of secrets* — NOT DONE. 275 commits; history not fresh, no filter-repo evidence.
-- *Remove device-specific configuration* — NOT DONE. =archsetup:1486-1493= still creates the Logitech BRIO udev rule unconditionally; no config flag.
-- *Add README.md for GitHub* — DONE (this session — initial draft, pending review). See subtask below.
-- *Add LICENSE file* — DONE (this session — GPL-3). See subtask below.
-- *Remove binary font files from repo* — NOT DONE. =dotfiles/common/.local/share/fonts/= still tracks 8 PragmataPro =.ttf= files, =AppleColorEmoji.ttf=, and other commercial fonts (Cartograph, MonoLisa, ComicCode, etc.).
-- *Make claude-code installation optional* — NOT DONE. =archsetup:1817-1818= runs =curl -fsSL https://claude.ai/install.sh | sh= unconditionally; no flag.
-- *Add input validation for username and paths* — PARTIALLY DONE. =archsetup:326-328= validates =$username= against =^[a-z][a-z0-9_]*$= (plus reserved-names check, marked DONE separately). No validation of =$source_dir= or other path vars.
-- *Move battery out of waybar sysmonitor group* — NOT DONE. =dotfiles/hyprland/.config/waybar/config:27-37= still has =battery= inside =group/sysmonitor=.
-- *Resolution-adaptive scratchpad sizing* — NOT DONE. No size/move windowrules for scratchpads in =hypr/conf.d=.
-- *Dynamic waybar/foot config based on screen resolution* — NOT DONE. No resolution-detection/generation script.
-- *Bulk shellcheck cleanup* — PARTIALLY DONE. =shellcheck archsetup= still shows 68 findings: 30×SC2329, 16×SC2174, 15×SC2024, 4×SC2086, 1 each SC2155/SC2129/SC2005. The 4 SC2086 (unquoted) are the ones a reviewer would flag — those are the priority.
-- *Document testing process in README* — NOT DONE. =scripts/testing/README.org= exists but isn't the project README. (Now unblocked — root README exists.)
-- *Add guard for rm -rf on constructed paths* — DONE 2026-05-20. All three constructed-path deletes routed through a =safe_rm_rf= guard (absolute / no-=..= / inside-allowed-prefix / real-dir checks); unit-tested in =tests/safe-rm-rf/=.
-- *Standardize boolean comparison style* — NOT DONE. Mixed: =[ "$var" = "true" ]= at =archsetup:542,544,569= vs bare =if $var;= form ~7 places elsewhere.
-- *Replace eval with safer alternatives* — NOT DONE. =archsetup:442= still =if eval "$cmd" >> "$logfile" 2>&1;= in =retry_install=.
-
-*** TODO [#A] Rotate exposed calendar feed URLs
-Needs the ratio GUI (browser-based regeneration), so deferred until I'm in front of ratio. Three private ical URLs sat in git history (commit =500b1f5=, 2026-05-13) until the 2026-05-20 scrub. The scrub removed them from local + remote history, but anyone who pulled the repo between those dates still has the tokens, so regenerate all three:
-- Google personal (=craigmartinjennings@gmail.com= private ical URL)
-- Proton (calendar.proton.me URL with PassphraseKey)
-- Google DeepSat (=craig.jennings@deepsat.com= private ical URL)
-After regenerating, update the live =~/.emacs.d/calendar-sync.local.el= (now owned by the emacs/dotemacs project — see its inbox handoff from 2026-05-20).
-
-*** 2026-05-20 Wed @ 12:09:32 -0500 Scrubbed the calendar secret from git history
-=dotfiles/common/.emacs.d/calendar-sync.local.el= (private Google/Proton/DeepSat ical URLs, added in =500b1f5= for stow distribution) was discovered while folding tmux-util into stow. Sent the file back to the emacs project's inbox, =git rm='d it, then =git filter-repo --invert-paths --path= purged it from all 29 affected commits. Force-pushed (=0921e4d...618e6cc=, with lease) and ran =reflog expire= + =gc --prune=now= on the bare repo at =/var/git/archsetup.git=. Verified: the file is in zero commits, the secret tokens return zero matches across all history, and =500b1f5= / =0921e4d= are unreachable on both local and remote. Rotation of the URLs tracked as the sibling TODO above. This also proves =filter-repo= works cleanly here — relevant precedent for the broader [[*Scrub git history of secrets (or start fresh)][history-scrub task]] below (the 5 credential files are still in history).
-
-*** TODO [#B] Remove/template personal information from scripts
-- =archsetup= lines 2-3: personal email and website in header
-- =archsetup= lines 141-146: hardcoded =git.cjennings.net= repository URLs — make configurable via conf
-- =scripts/post-install.sh=: personal git repos and server URLs (the old =scripts/gitrepos.sh= was consolidated into this script in =dae7659=, so its personal =git.cjennings.net= clone targets now live here)
-- =init= line 8: hardcoded password =welcome=
-
-*** TODO [#B] Scrub git history of secrets (or start fresh)
-Even after removing files, secrets remain in git history.
-Options: =git filter-repo= to rewrite history, or start a fresh repo for the GitHub remote.
-Recommend: fresh repo for GitHub (keep cjennings.net remote with full history).
-
-*** TODO [#B] Remove device-specific configuration :quick:
-=archsetup= lines 1458-1463: Logitech BRIO webcam udev rule — move to optional/configurable section.
-
-*** DOING [#B] Add README.md for GitHub
-Project description, features, requirements, installation instructions,
-configuration guide (archsetup.conf), security considerations,
-contributing guidelines (or separate CONTRIBUTING.md), and license.
-
-**** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Initial README draft
-Drafted =README.md= at repo root, modeled on =~/code/chime/README.org=. First pass — review and run a voice/style pass before committing. Personal info (emails, =cjennings.net= URLs, personal repo names) intentionally replaced with placeholders for the eventual public release.
-
-*** 2026-05-19 Tue @ 01:54:29 -0500 Added GPL-3 LICENSE file at repo root
-GPL-3 chosen. Canonical GPLv3 text landed at =LICENSE= on 2026-05-11 (commit =f80e664=). README already links to it. SPDX/license headers across source files (or a NOTICE file) split out as a new sub-task below for the eventual public release.
-
-*** TODO [#B] Add SPDX/license headers to source files :quick:
-For a real GPL-3 release on GitHub, every source file should carry an SPDX-License-Identifier header (or the repo should ship a NOTICE file naming the license + contributors). Not blocking — the =LICENSE= file at root is what GitHub needs. Worth doing once the credentials-cleanup work for open-sourcing actually ships. Light lift: pick a header template, sweep the install script + scripts/ + dotfile scripts.
-
-*** 2026-06-09 Tue @ 19:21:36 -0500 Made claude-code install optional
-Shipped in =f2dad22= (feat: make the claude-code install optional). The =curl | sh= from claude.ai now sits behind a config flag instead of running unconditionally.
-
-*** 2026-06-09 Tue @ 19:21:36 -0500 Input validation added (validate_config + validate_username)
-validate_config + validate_username shipped (detail in the 2026-05-11 note below). The =$source_dir= path check was judged unnecessary — it derives from the now-always-validated =$username= (=/home/$username/.local/src=). Closed as done.
-
-**** 2026-05-11 Mon @ 18:20:49 -0500 AI Response: validate_config + validate_username added
-Added two pre-flight validators to =archsetup= (right after =load_config=, before any install step):
-- =validate_username()= — the lowercase / starts-with-letter / =[a-z0-9_]= / not-reserved check, extracted from the inline block in =preflight_checks()=. Fixes an existing gap: the inline check only ran on the *prompted* path, so a config with =USERNAME=root= (or =USERNAME=foo bar=) slipped through unvalidated. Now both =preflight_checks= and =validate_config= call it.
-- =validate_config()= — runs whenever =--config-file= is used: rejects unknown =DESKTOP_ENV= (must be dwm/hyprland/none) early instead of dying in step 7-9; rejects =AUTOLOGIN=/=NO_GPU_DRIVERS= values that aren't =yes=/=no= (currently silently ignored); basic shape check on =LOCALE=; and a scheme + no-whitespace/no-leading-dash check on the six =*_REPO= URLs that get passed to =git clone= (rejects e.g. =--upload-pack=…= injection). Plain =echo …>&2; exit 1= (the logging helpers aren't defined that early). =$source_dir= needs no separate check — it's =/home/$username/.local/src=, derived from the now-always-validated =$username=.
-Not a security boundary (=load_config= sources the config as bash; a hostile config can already run anything) — it's typo-catching. Verified with =bash -n= and a smoke-test matrix of good/bad inputs through both functions. The next =make test= run confirms valid configs still install. Leaving as DOING for review.
-
-*** 2026-05-20 Wed @ 06:50:25 -0500 Swept shellcheck across the shell scripts
-Census across the 16 shell scripts (=archsetup=, =init=, =scripts/*.sh=, =scripts/testing/=): 124 findings, zero errors. Triaged against "what matters for public review" and confirmed the 2026-01-24 read — most are intentional or documented-acceptable:
-- SC2024 (14, sudo redirects), SC2174 (16, =mkdir -p -m=), SC1091 (13, unfollowable sources), SC2329 (32, functions invoked indirectly via the =STEPS= dispatch array), SC2153 (1, =DISK_PATH= sourced from =vm-utils.sh=) — all false positives or accepted.
-- SC2086 on =$SSH_OPTS= in =vm-utils.sh= (×4) and =$TEMP_DISKS= in =cleanup-tests.sh= — intentional word-splitting; quoting would break them. The SSH_OPTS-as-array refactor is the proper fix, deliberately deferred (codebase-wide, one atomic change).
-- SC2086 integer tests in =[ ]= (=archsetup=, =cleanup-tests=) — safe, note-level style; left to avoid churn in the just-fixed =retry_install=.
-- SC2015 (×2, =vm_exec && success || warn=) — =success=/=warn= return 0, so C won't spuriously fire. Idiomatic.
-
-Fixed the four that are genuine: =init= (a =#!/bin/sh= script) used =$(</etc/hostname)= (SC3034 bashism → =$(cat ...)=) and an unquoted =$interface_up= (SC2086 → quoted); =shellcheck init= now clean, =sh -n= passes. Suppressed the two =VM_IP= SC2034 warnings with documented =# shellcheck disable= directives (consumed by the sourced =validation.sh=, which shellcheck can't follow). 124 → 120; the remaining 120 are the triaged-acceptable set above.
-
-*** 2026-05-20 Wed @ 06:32:17 -0500 Documented the testing process in the README
-The README only covered the VM integration harness; the unit-test layer under =tests/= (Python =unittest=, fake-binary-on-PATH, one dir per script — =layout-navigate=, =tmux-util=) was undocumented. Added a =make test-unit= target that runs every =tests/*/test_*.py= suite explicitly (=unittest discover= can't find them — hyphenated dir names aren't valid package paths), then rewrote the README Testing section into "Unit tests" and "Integration tests (VM harness)" subsections, including how to add a suite for a new script. Updated Contributing to point at =make test-unit= for script changes. 61 unit tests pass via the new target.
-*** 2026-05-20 Wed @ 18:22:42 -0400 Added safe_rm_rf guard on constructed-path deletes
-Added a self-contained =safe_rm_rf <path> <allowed_prefix>= helper to =archsetup= and routed all three constructed-path deletes through it. The guard refuses to run unless the target is absolute, free of =..=, deeper than a bare top-level dir, strictly inside the allowed prefix (not the prefix itself), and a real directory (not a symlink); otherwise it prints the reason and returns non-zero without deleting. On the happy path it delegates to =rm -rf=.
-
-Sites converted (the line numbers in the original task body were stale — actual sites located by grep):
-- =--fresh= state-dir wipe — prefix =/var/lib/archsetup=.
-- =git_install= clone-retry cleanup (=build_dir= under =$source_dir=).
-- =aur_installer= yay clone-retry cleanup (same prefix).
-
-The helper is defined before the top-level =--fresh= handler (which runs at load time, before the logging helpers exist), so it carries no =error_warn= dependency and reports refusals to stderr itself. The two in-function sites keep their existing =|| error_warn= / =|| error_fatal= handling.
-
-Tests: =tests/safe-rm-rf/test_safe_rm_rf.py= sources the real function out of the script and exercises Normal/Boundary/Error cases (13 tests) against real temp dirs. =make test-unit= green (61 tests), =bash -n= clean, no new shellcheck warnings.
-*** TODO [#B] Standardize boolean comparison style :quick:
-Mixed =[ "$var" = "true" ]= vs =$var= evaluation — pick one pattern.
+Push-to-talk dictation that types transcribed speech into the focused Wayland window — usable at any text field, including the Claude Code terminal prompt and Emacs buffers. Claude Code has no built-in voice input; dictation has to happen at the OS level and inject text. Raised 2026-07-03.
-*** 2026-05-26 Tue @ 15:27:09 -0500 eval task moot — the line-434 eval is gone, the survivor is deliberate
-Verified: the only =eval= left in =archsetup= is line 578 in =retry_install=, and it's intentional and documented — it captures =$?= directly from =eval "$cmd"= to dodge the if-compound-swallows-exit-code trap. Replacing it with an array would reintroduce that bug. The line-434 eval this task pointed at no longer exists. Nothing to change.
+Tool choice is the open decision (needs Craig): =nerd-dictation= (Vosk, lighter, lower accuracy) vs a =whisper.cpp=-based daemon (heavier, higher accuracy, optional GPU). Wayland typing backend is =wtype= or =ydotool=. Scope once chosen: install + model download, a push-to-talk keybind (Hyprland), and an autostart entry; fold into archsetup so it lands on both daily drivers. Consider an Emacs-native path (=whisper.el=) as a complement for in-buffer dictation.
** TODO [#B] Review post-archsetup laptop setup steps (velox 2026-04-10)
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-09
+:LAST_REVIEWED: 2026-07-04
:END:
Items discovered during velox setup that needed manual intervention after archsetup.
Decide which should be automated in archsetup vs documented as post-install steps.
@@ -359,10 +442,8 @@ Both bluetooth and wifi were soft-blocked by rfkill. Fix was ~rfkill unblock blu
~systemd-rfkill~ persists state, so unblocking once should stick, but new installs may default to blocked.
Consider: add ~rfkill unblock all~ to archsetup post-install or a firstboot script.
-*** TODO Review: /efi mount permissions world-accessible (security)
-Default vfat mount had ~fmask=0022,dmask=0022~. Fixed to ~fmask=0077,dmask=0077~ in fstab.
-~bootctl~ warned about world-accessible random seed file.
-Consider: set restrictive fmask/dmask in archsetup's fstab generation.
+*** 2026-07-04 Sat @ 11:48:24 -0500 Automated /efi restrictive mount permissions in fstab generation
+archsetup:2827-2836 now rewrites the /efi fstab line to =fmask=0177,dmask=0077= (idempotent), so fresh installs no longer land the world-accessible =fmask=0022,dmask=0022= default. Confirmed via the 2026-07-04 task audit. (Original velox note: default vfat mount had =fmask=0022,dmask=0022=, hand-fixed to restrictive; bootctl warned about a world-accessible random-seed file.)
*** TODO Review: tmpfs layered over ZFS /tmp causing systemd-tmpfiles failures
~systemd-tmpfiles-clean.service~ failed repeatedly with "Protocol driver not attached".
@@ -373,9 +454,8 @@ Fix: ~systemctl mask tmp.mount~. Consider: mask tmp.mount in archsetup when ZFS
CPU running old microcode. Installed ~intel-ucode~ and rebuilt initramfs.
Consider: add intel-ucode (or amd-ucode) to archsetup package list based on CPU vendor.
-*** TODO Review: syncthing installed but not enabled
-Package was installed but service was not enabled. Fixed with ~systemctl enable --now syncthing@cjennings~.
-Consider: enable syncthing service in archsetup post-install.
+*** 2026-07-04 Sat @ 11:48:24 -0500 Automated syncthing user-service enable in archsetup
+archsetup:2263-2271 now installs syncthing and enables the user service (via symlink), so fresh installs no longer leave it installed-but-disabled. Confirmed via the 2026-07-04 task audit. (Original velox note: package installed but service not enabled; hand-fixed with =systemctl enable --now syncthing@cjennings=.)
*** TODO Review: awww-daemon crashes at boot (coredump)
Wallpaper daemon crashed with abort() shortly after boot. Hyprland also coredumped at same time.
@@ -407,26 +487,98 @@ docs/ dirs (gitignored) for ~/code and ~/projects repos needed scp/rsync from ra
Same for ~/.emacs.d/docs/. Not in git, so not available after clone.
Consider: document as post-install step or create a sync script.
-** TODO [#C] Ensure sleep/suspend works on laptops
+** TODO [#B] Test + CI infrastructure :test:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-09
+:LAST_REVIEWED: 2026-07-13
:END:
-Critical functionality for laptop use - current battery drain unacceptable
-*NOTE:* This applies to Framework Laptop (velox), not Framework Desktop (ratio)
-Add kernel parameter: ~rtc_cmos.use_acpi_alarm=1~ (will become systemd default)
-Consider: ~acpi_mask_gpe=0x1A~ for battery drain, suspend-then-hibernate config
-See Framework community notes on logind.conf and sleep.conf settings
+Umbrella for the test-harness and CI-automation buildout. Consolidated from the 2026-06-28 task audit: these were scattered top-level tasks circling one effort, re-homed as children so the work reads as a unit. Each child ships independently and keeps the priority it carried before. No CI runner exists yet, so the CI/CD-pipeline child gates several of the others.
-** TODO [#C] Build CI/CD pipeline that runs archsetup on every commit
+*** TODO [#B] Build CI/CD pipeline that runs archsetup on every commit
:PROPERTIES:
:LAST_REVIEWED: 2026-06-13
:END:
Core automation infrastructure - enables continuous validation
+*** TODO [#B] Generate recovery scripts from test failures
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-13
+:END:
+Auto-create post-install fix scripts for failed packages - makes failures actionable
+*** TODO [#B] Establish monthly review workflow
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-13
+:END:
+The diff engine now exists (=scripts/package-inventory= / =make package-diff=), so what remains here is the cadence, not the tooling: a scheduled prompt to run the diff and act on it. Subtasks 1-2 are the recurring human judgment the engine feeds; subtask 3 is the automation to schedule it.
+**** TODO [#B] For packages in archsetup but not on system: determine if still needed
+**** TODO [#B] For packages on system but not in archsetup: decide add or remove
+**** TODO [#B] Schedule monthly package diff review
+*** TODO [#B] Set up automated test schedule
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-28
+:END:
+Weekly full run to catch deprecated packages even without commits
+*** TODO [#B] Implement manual test trigger capability
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-28
+:END:
+Allow on-demand test runs when automation is toggled off
+*** TODO [#B] Create test results dashboard/reporting
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-28
+:END:
+Make test outcomes visible and actionable
+*** TODO [#B] Block merges to main if tests fail
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Enforce quality gate - broken changes don't enter main branch
+*** TODO [#B] Add network failure testing to test suite
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Simulate network disconnect mid-install to verify resilience
+*** TODO [#B] Keep VM base images up to date
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-28
+:END:
+Regular updates to the Arch base VM image (qemu, built by =create-base-vm.sh=) with a review process and schedule. The harness is VM/qemu-based, not containers.
+*** TODO [#B] Persist test logs for historical analysis
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Archive logs with review process and schedule to identify failure patterns and trends
+*** TODO [#B] Implement automated deprecation detection
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Parse package warnings and repo metadata to catch upcoming deprecations proactively
+*** TODO [#B] Monitor and optimize test execution time
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Keep test runs performant as installs and post-install tests grow (target < 2 hours)
+*** TODO [#B] Set up alerts for deprecated packages
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Proactive monitoring integrated with testing
+*** TODO [#B] Fix VM cloning machine-ID conflicts for parallel testing
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-21
+:END:
+Currently using snapshot-based testing which works but limits to sequential test runs
+Cloned VMs fail to get DHCP/network even with machine-ID manipulation (truncate/remove)
+Root cause: Truncating /etc/machine-id breaks systemd/NetworkManager startup
+Need to investigate proper machine-ID regeneration that doesn't break networking
+Would enable parallel test execution in CI/CD
+Priority C because snapshot-based testing meets current needs
-** TODO [#B] Fix install errors surfaced by the 2026-05-11 VM test run
+** TODO [#C] Fix install errors surfaced by the 2026-05-11 VM test run
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-15
+:LAST_REVIEWED: 2026-07-06
:END:
+*** 2026-06-28 Sun @ 13:29:29 -0400 Audit reconcile: 2026-06-28 btrfs+zfs runs reproduce the same residual set
+Newer full runs landed since the 2026-06-11 reconcile below: the 2026-06-25 zfs run (Testinfra 96/0) and the 2026-06-28 btrfs+zfs runs (97/0, "zero attributed issues"). The residual four were NOT fixed and reproduce unchanged: =enabling firewall= (archsetup:1496-1498, carries a VM-kernel note), =enabling gamemode for user= (archsetup:2221, non-critical), and =tidaler (AUR)=. Zero archsetup-attributed Testinfra issues across both profiles confirms these are environment / non-critical, not archsetup bugs. Bare-metal confirmation of the firewall pair is still the open thread.
+
*** 2026-06-15 Mon @ 23:53:21 -0500 Audit reconcile: latest VM run (2026-06-11) confirms the surviving error set
The most recent VM run (=test-results/20260611-113904/=) carries four error-summary entries: =enabling firewall= + =verifying firewall is active= (the iptables/nf_tables "Could not fetch rule set generation id" pair, still unconfirmed on bare metal), =enabling gamemode for user= (non-critical), and =tidaler (AUR)=. The earlier fontconfig/dconf fixes held — none reappear. So the count is down from the 7→6 anchor below to four, all of them the known-residual items already itemized.
Errors logged during the VM install. Status as of the 2026-05-11 18:36 run (=test-results/20260511-183643/archsetup-output.log=) after the =48c9439= fontconfig/dconf fix: 7 → 6.
@@ -448,18 +600,14 @@ Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; t
*** 2026-05-19 Tue @ 01:25:26 -0500 Verified the b9907c7 emacs-stow fix end-to-end
=make test= 21:44 → 22:29 (42 min), =test-results/20260518-214516/=. 52/0/5, =ArchSetup Exit Code: 0=. The third-branch path fired correctly — install log =archsetup-2026-05-18-21-45-46.log:14358-14365= shows =From https://git.cjennings.net/dotemacs= → =[new branch] main -> origin/main= → =Reset branch 'main'= → =branch 'main' set up to track 'origin/main'=. No exit-128, no =fatal: not a git repository=. Error Summary down to 7 (was 13 on 2026-05-16); the emacs entry is gone. AUR exit-0 logging triggered for 2 packages this run (mkinitcpio-firmware, tidaler) vs 6 on 2026-05-16 — same bug class, fewer triggers, still tracked under =[#B] AUR exit-0 logged as error=. Issue Attribution: 1 ARCHSETUP entry (Proton VPN Daemon failed — known VM-no-VPN-config artifact). Cleanup ran clean via the normal path.
-** TODO [#B] Generate recovery scripts from test failures
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-13
-:END:
-Auto-create post-install fix scripts for failed packages - makes failures actionable
-
-** TODO [#B] Review undeclared ratio packages for installer inclusion
+** TODO [#B] Review undeclared ratio packages for installer inclusion :chore:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
+:LAST_REVIEWED: 2026-07-09
:END:
Triggered by the 2026-06-14 =make package-diff= run on ratio: 62 packages are installed but not declared in archsetup. Stripped of the structural buckets — pacstrap base/boot/kernel (base, linux*, grub, efibootmgr, sudo, btrfs-progs, fwupd, logrotate, ex-vi-compat, linux-lts-strix, zram-generator), the =make deps= VM set (qemu-full, virt-manager, virt-viewer, libguestfs, bridge-utils, dnsmasq, archiso), and the yay bootstrap — these 40 remain. Check the ones to add to the installer, then rerun =make package-diff= to confirm they clear.
+Evidence report (2026-07-14, count now 64): [[file:docs/design/2026-07-14-undeclared-packages-evidence.org][docs/design/2026-07-14-undeclared-packages-evidence.org]] (archsetup 78081e4) — description, requirer, and install date per package, bucketed candidates (30) / dependency-pulled (6) / orphan libraries (7) / structural (21). The include/ignore pass reads the candidates bucket; the checklist below stays until Craig walks it.
+
Some entries are libraries likely pulled in as dependencies (blas-openblas, openblas, eigen, tk, lib32-openal, pkcs11-helper, gtk4-layer-shell, webkit2gtk, sane, freerdp, rust-bindgen) — check those only if you want them declared explicitly rather than left to dependency resolution.
- [ ] aws-cli-v2
@@ -503,165 +651,70 @@ Some entries are libraries likely pulled in as dependencies (blas-openblas, open
- [ ] webkit2gtk
- [ ] whisper.cpp
-** TODO [#B] Establish monthly review workflow
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-13
-:END:
-The diff engine now exists (=scripts/package-inventory= / =make package-diff=), so what remains here is the cadence, not the tooling: a scheduled prompt to run the diff and act on it. Subtasks 1-2 are the recurring human judgment the engine feeds; subtask 3 is the automation to schedule it.
-*** TODO [#B] For packages in archsetup but not on system: determine if still needed
-*** TODO [#B] For packages on system but not in archsetup: decide add or remove
-*** TODO [#B] Schedule monthly package diff review
-
-** TODO [#B] Complete security education within 3 months
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Read recommended resources to make informed security decisions (see metrics for Claude suggestions)
-
-** TODO [#B] All error messages should be actionable with recovery steps
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Currently just reports errors without guidance on how to fix them
-
-** TODO [#B] Improve logging consistency
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Some operations log to ~$logfile~, others don't - standardize logging
-All package installs should log, all system modifications should log, all errors should log with context
-Makes debugging failed installations easier
-
-** TODO [#B] Add backup before system file modifications
+** TODO [#B] Security hardening + audit :security:
:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
+:LAST_REVIEWED: 2026-07-14
:END:
-Safety net for /etc/X11/xorg.conf.d and other system file edits
-Files like ~/etc/sudoers~, ~/etc/pacman.conf~, ~/etc/default/grub~ modified without backup
-If modifications fail or are incorrect, difficult to recover - should backup files to ~.backup~ before modifying
+Umbrella for the security-hardening and audit effort. Consolidated from the 2026-06-28 task audit, re-homing the scattered security tasks as children so the work reads as a unit. Each child ships independently and keeps its prior priority.
-** TODO [#B] Implement Testinfra test suite for archsetup
+*** TODO [#B] Test security + functionality together
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
-Create comprehensive integration tests using Testinfra (Python + pytest) to validate archsetup installations
-
-Tests should cover:
-- Smoke tests: user created, key packages installed, dotfiles present
-- Integration tests: services running, configs valid, X11 starts, apps launch
-- End-to-end tests: login as user, startx, open terminal, run emacs, verify workflows
-
-Framework: Testinfra with pytest (SSH-native, built-in modules for files/packages/services/commands)
-Location: scripts/testing/tests/ directory
-Integration: Run via pytest against test VMs after archsetup completes
-Benefits: Expressive Python tests, excellent reporting, can test interactive scenarios
-
-A design doc (not yet written) should cover:
-- Complete example test suite (test_integration.py)
-- Tiered testing strategy (smoke/integration/end-to-end)
-- How to run tests and integrate with run-test.sh
-- Comparison with alternatives (Goss)
-
-** TODO [#B] Set up automated test schedule
+**** TODO [#B] Verify no unexpected open ports or services
+*** TODO [#B] Security audit tooling
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
-Weekly full run to catch deprecated packages even without commits
-
-** TODO [#B] Implement manual test trigger capability
+**** TODO [#B] Implement port scanning check
+**** TODO [#B] Create security posture verification script
+**** TODO [#B] Set up intrusion detection monitoring
+*** TODO [#B] Document threat model and mitigations within 6 months
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
-Allow on-demand test runs when automation is toggled off
-
-** TODO [#B] Create test results dashboard/reporting
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Make test outcomes visible and actionable
-
-** TODO [#B] Block merges to main if tests fail
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Enforce quality gate - broken changes don't enter main branch
-
-** TODO [#B] Add network failure testing to test suite
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Simulate network disconnect mid-install to verify resilience
-
-** TODO [#B] Keep container base images up to date
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Regular updates to Arch base image with review process and schedule
-
-** TODO [#B] Persist test logs for historical analysis
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Archive logs with review process and schedule to identify failure patterns and trends
-
-** TODO [#B] Implement automated deprecation detection
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Parse package warnings and repo metadata to catch upcoming deprecations proactively
-
-** TODO [#B] Audit dotfiles/common directory
+Identify attack vectors, what's mitigated, what remains
+*** TODO [#B] Complete security education within 3 months
:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
+:LAST_REVIEWED: 2026-06-24
:END:
-*** TODO [#B] Review all 50+ scripts in ~/.local/bin - remove unused scripts
-*** TODO [#B] Check dotfiles for uninstalled packages - remove orphaned configs
-*** TODO [#B] Verify all stowed files are actually used
-
-** TODO [#B] Test security + functionality together
+Read recommended resources to make informed security decisions (see metrics for Claude suggestions)
+*** TODO [#B] Create security checklist for cafe/public wifi scenarios
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
-*** TODO [#B] Verify no unexpected open ports or services
+Practical guidelines for working in public spaces
-** TODO [#B] Security audit tooling
+** TODO [#B] Ensure sleep/suspend works on laptops
:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
+:LAST_REVIEWED: 2026-07-06
:END:
-*** TODO [#B] Implement port scanning check
-*** TODO [#B] Create security posture verification script
-*** TODO [#B] Set up intrusion detection monitoring
+Critical functionality for laptop use - current battery drain unacceptable
+*NOTE:* This applies to Framework Laptop (velox), not Framework Desktop (ratio)
+Add kernel parameter: ~rtc_cmos.use_acpi_alarm=1~ (will become systemd default)
+Consider: ~acpi_mask_gpe=0x1A~ for battery drain, suspend-then-hibernate config
+See Framework community notes on logind.conf and sleep.conf settings
-** TODO [#B] Document threat model and mitigations within 6 months
+** TODO [#C] Re-check python-lyricsgenius --skipinteg workaround :chore:solo:
:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
+:LAST_REVIEWED: 2026-07-09
:END:
-Identify attack vectors, what's mitigated, what remains
+archsetup installs =python-lyricsgenius= with =--mflags --skipinteg=, skipping makepkg integrity + PGP checks — a workaround originally for an expired-signature issue upstream (surfaced by the 2026-06-23 --noconfirm audit). Periodically test whether the cause has cleared: if a plain =aur_install python-lyricsgenius= builds without complaint, drop the =--skipinteg= workaround. Removal needs a real AUR build to confirm, so it isn't a blind change.
-** TODO [#C] Re-check python-lyricsgenius --skipinteg workaround :solo:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-=archsetup:2403= installs =python-lyricsgenius= with =--mflags --skipinteg=, skipping makepkg integrity + PGP checks — a workaround for an expired-signature issue upstream (surfaced by the 2026-06-23 --noconfirm audit). Periodically test whether the upstream issue has cleared: if a plain =aur_install python-lyricsgenius= builds without complaint, drop the =--skipinteg= workaround. Removal needs a real AUR build to confirm, so it isn't a blind change.
+*** 2026-07-14 Tue @ 01:40:48 -0500 Rechecked: still needed, same cause
+Fresh AUR clone, =makepkg --verifysource= on 3.7.0-1 (PKGBUILD unchanged): tarball passes, =LICENSE.txt= still FAILS its b2sum — the github-master pin, structural as diagnosed 2026-06-24. =--skipinteg= stays.
-** TODO [#B] Test each modernization thoroughly before replacing
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Ensure new tools integrate with DWM environment and don't break workflow
+*** 2026-07-02 Thu @ 05:08:37 -0400 Rechecked: still needed, same cause
+=makepkg --verifysource= on 3.7.0-1: tarball passes, =LICENSE.txt= still FAILS
+its b2sum — the PKGBUILD still pins a hash for a file fetched from github
+master. Structural, as diagnosed 2026-06-24; =--skipinteg= stays.
-** TODO [#B] Add NVIDIA preflight check for Hyprland
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Detect NVIDIA GPU and warn user about potential Wayland issues:
-- Require driver version 535+ or abort
-- Document required env vars (LIBVA_DRIVER_NAME, GBM_BACKEND, etc.)
-- Prompt to continue or abort if NVIDIA detected
+*** 2026-06-24 Wed @ 17:55:34 -0400 Rechecked: still needed, but the cause changed
+Ran =makepkg --verifysource= on the current AUR PKGBUILD (3.7.0-1). The package tarball =lyricsgenius-3.7.0.tar.gz= now passes its b2sum — the original expired-PGP-signature problem is gone (the PKGBUILD no longer carries any =validpgpkeys=). But integrity still FAILS, on a different file: =LICENSE.txt=, which the PKGBUILD fetches from the project's github master and pins a b2sum for. github master is a moving target, so that b2sum drifts and =--skipinteg= is still required. This is structural (not a transient upstream fix away), so it likely won't clear until the maintainer pins the LICENSE to a tagged release. Updated the archsetup comment to the real cause. Keep rechecking, but lower expectations of it clearing.
** TODO [#C] Review theme config architecture for dunst/fuzzel
:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
+:LAST_REVIEWED: 2026-07-06
:END:
The active dunst config is stowed from dotfiles/common/ but theme templates
live in dotfiles/hyprland/.config/themes/. set-theme copies the templates to
@@ -673,72 +726,9 @@ error-prone — changes must be made in both places. Consider:
- Same situation applies to fuzzel.ini
The goal is a single place to edit each config, not two.
-** TODO [#C] Monitor and optimize test execution time
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Keep test runs performant as installs and post-install tests grow (target < 2 hours)
-
-** TODO [#C] Set up alerts for deprecated packages
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Proactive monitoring integrated with testing
-
-** TODO [#C] Fix VM cloning machine-ID conflicts for parallel testing
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Currently using snapshot-based testing which works but limits to sequential test runs
-Cloned VMs fail to get DHCP/network even with machine-ID manipulation (truncate/remove)
-Root cause: Truncating /etc/machine-id breaks systemd/NetworkManager startup
-Need to investigate proper machine-ID regeneration that doesn't break networking
-Would enable parallel test execution in CI/CD
-Priority C because snapshot-based testing meets current needs
-
-** TODO [#C] Create security checklist for cafe/public wifi scenarios
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Practical guidelines for working in public spaces
-
-** TODO [#C] Install adopted modern CLI tools :tooling:solo:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-Decision (Craig, 2026-06-24): adopt all five recommended tools — =bat=, =dust=, =hyperfine=, =tealdeer=, =doggo= (all in extra). Add them to archsetup's package list and install on both machines. Optional candidates (=xh=/=jless=/=sd=/=ouch=) declined for now. Full evaluation: [[file:docs/2026-06-10-modern-cli-tools-evaluation.org][docs/2026-06-10-modern-cli-tools-evaluation.org]].
-
-- Add the five to the appropriate pacman package section in =archsetup=.
-- =pacman -S bat dust hyperfine tealdeer doggo= on velox + ratio.
-- =bat=: set =BAT_THEME= to match the dupre palette once installed.
-- =tealdeer=: run =tldr --update= to seed the cache after install.
-
-** TODO [#B] Migrate terminal emulator from foot to ghostty :tooling:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-Decision (Craig, 2026-06-24): switch from foot to ghostty. Drivers: ligatures (foot won't add them) and kitty-graphics + sixel image support (foot is sixel-only, no kitty-graphics plans). ghostty is pure-Wayland on Hyprland, declarative config that fits the theme system, runtime config reload (keybind / SIGUSR2 since 1.2). Trade-off accepted: slightly higher input latency than foot. Already in use as Emacs's terminal renderer, so the config + rendering are familiar and the 06-18 tmux theme was tuned against that surface. Full evaluation: [[file:docs/2026-06-10-terminal-emulator-evaluation.org][docs/2026-06-10-terminal-emulator-evaluation.org]].
-
-Migration scope:
-- archsetup: add =ghostty= to the package list; decide whether to keep =foot= installed as a fallback or drop it.
-- dotfiles: port =foot.ini= → ghostty config (flat key=value). The shared foot.ini sets no font (per-host via =host.ini= include) — replicate that per-host font split for ghostty.
-- Themes: the dupre/hudson =themes/<name>/= dirs hold foot configs; add ghostty theme files and teach =set-theme= to write + reload the ghostty config. Watch the reload-clobbers-OSC-10/11 bug (ghostty #2795) when wiring runtime theme switch.
-- hyprland.conf: default-terminal keybind, pyprland scratchpad terminals, and any other =foot= references → ghostty.
-- Verify on velox + ratio: ligatures render, latency acceptable in tmux+vterm use, dupre theme correct, sixel/kitty-graphics previews work.
-
-** DONE [#C] Review file manager options for Wayland
-CLOSED: [2026-06-24 Wed]
-Decision (Craig, 2026-06-24): keep nautilus only; skip yazi. File management lives in Emacs dired plus the Super+F dirvish popup, so a TUI file manager has no daily user here. ranger was already ruled out (frozen upstream). Full evaluation: [[file:docs/2026-06-10-file-manager-evaluation.org][docs/2026-06-10-file-manager-evaluation.org]]. Follow-on surfaced: nautilus needs dark theming (filed as its own task).
-
-** TODO [#B] Theme nautilus to a dark theme :bug:solo:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-nautilus renders blindingly white (Craig, 2026-06-24). It's a GTK4/libadwaita app, so it ignores the GTK theme name (Adwaita-dark) and instead follows the system color-scheme preference =org.gnome.desktop.interface color-scheme=. Likely fix: set =color-scheme = 'prefer-dark'= via the dconf block archsetup already writes (the GNOME-interface dconf settings added for the portal-gtk timeout), then apply it live (=gsettings set org.gnome.desktop.interface color-scheme prefer-dark=). Investigate whether the existing dconf db already sets this and why nautilus isn't picking it up. Apply on velox + ratio, add to archsetup so fresh installs get dark by default. Verify nautilus opens dark.
-
** TODO [#C] Review current tool pain points annually
:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
+:LAST_REVIEWED: 2026-07-06
:END:
Once-yearly systematic inventory of known deficiencies and friction points in current toolset
@@ -765,17 +755,440 @@ animation = fade, 1, 2, default
animation = layers, 1, 2, default, fade
#+end_src
-** CANCELLED [#D] Test wlogout menu on laptop
-CLOSED: [2026-06-24 Wed]
-Merged into the "Wlogout exit-menu buttons are rectangular, not square" task ([#C]) — same effort (per-host wlogout button sizing across velox/ratio). The fixed-pixel-margins hint was folded into that task's body.
-
** TODO [#D] Parse and improve AUR error reporting
Parse yay errors and provide specific, actionable fixes instead of generic error messages
** TODO [#D] Improve progress indicators throughout install
Enhance existing indicators to show what's happening in real-time
-** TODO Manual testing and validation
+** TODO [#B] Manual testing and validation :test:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-09
+:END:
+Craig's standing checklist of everything that isn't agent-verifiable. Each child is one test in the =verification.md= shape (title, what we're verifying, steps, Expected). A child that fails gets its actual behavior written under it and is promoted to a top-level TODO. 44 checks pending as of the 2026-07-09 audit.
+
+Priority and type tag added by that audit: the task carried neither, which kept the project's largest live container out of the agenda entirely.
+
+*** Net panel: sticky error toast dismisses on the next click
+What we're verifying: the stuck enterprise-error banner (dotfiles a157bed) now clears when you click anywhere else in the panel — the fix is a pointer-level gesture that AT-SPI can't drive.
+- Open the net panel from the bar.
+- Click an enterprise (802.1X) network in the NETWORKS list (Cox Mobile in the usual scan) so the red "Enterprise (802.1X) — join or edit it in nmtui/nmcli." banner appears.
+- Click anywhere else in the panel: another network row, a meter card, or empty plate.
+Expected: the red banner disappears on that click. A routine (non-red) toast still fades on its own 4s timer as before.
+
+*** maint privilege degrade: PROMPT path on a real tty
+What we're verifying: with sudo not passwordless, a maint fix on a terminal prompts for the password (after the wall note) instead of hard-failing. Safe on ratio/velox — run from a real terminal; PANELKIT_SUDO=false makes the model treat the box as non-passwordless without touching sudoers, while the fix still fires through real (prompting) sudo.
+#+begin_src sh :results output
+PANELKIT_SUDO=false maint fix cache_clean
+#+end_src
+- Type your password if sudo asks.
+Expected: a dim note line "no passwordless sudo — sudo will ask for your password on this terminal", then the RUN line shows plain "sudo paccache -r" (no -n) and the step lands OK with re-probe notes after. On ratio/velox the NOPASSWD grant means sudo won't actually prompt (only the seam is faked); the real password prompt is only observable on a box without the grant — a VM or a fresh install pre-sudoers.
+
+*** maint privilege degrade: GUIDE events on the GUI wall
+What we're verifying: the maint panel's CLEAN UP degrades to guide instructions on the results wall (nothing executes) when the box can't elevate silently.
+#+begin_src sh :results output
+PANELKIT_SUDO=false setsid -f maint panel >/dev/null 2>&1
+#+end_src
+- In the panel, press CLEAN UP (needs an off-nominal reclaim metric; if the board is quiet, expect the "nothing to clean" note instead).
+Expected: each privileged reclaim lands on the wall as a dim instruction line — "cannot elevate here (...) — run yourself: $ sudo ..." — with no RUN/OK pair for it, and nothing actually reclaimed. Close the panel afterward (it inherited the fake PANELKIT_SUDO).
+
+*** Net doctor privileged fixes (net Phase 1) — run in a disposable VM/container
+What we're verifying: the three control-plane fixes actually repair a real broken state under real sudo. These states are dangerous on a daily driver (masking NM kills the network), so run them in a throwaway VM or booted nspawn with NetworkManager installed and passwordless sudo, NOT on ratio/velox. Each: break the state, run =net doctor --fix=, confirm the repair.
+**** nm-masked → unmask-nm
+What we're verifying: a masked NetworkManager is unmasked and started, not met with a doomed nm-restart.
+#+begin_src sh :results output
+sudo systemctl mask NetworkManager
+net doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], d.get("next_action"))'
+#+end_src
+- Expected (diagnose): the verdict names the masked NM (outcome fixable, action unmask-nm), not the generic "NetworkManager isn't running".
+#+begin_src sh :results output
+net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print("fixed=",d["fixed"],"attempts=",[a["id"] for a in d["attempts"]])'
+systemctl is-enabled NetworkManager; systemctl is-active NetworkManager
+#+end_src
+Expected: the fix attempts unmask-nm, NetworkManager is no longer masked (is-enabled != masked) and is active.
+**** rival-manager → disable-rival
+What we're verifying: a rival manager active alongside NM is disabled, and the doctor names it rather than blindly resetting.
+#+begin_src sh :results output
+sudo systemctl enable --now dhcpcd # a rival that fights NM for the link
+net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], [a["id"] for a in d["attempts"]])'
+systemctl is-active dhcpcd
+#+end_src
+Expected: the verdict/fix is disable-rival, and dhcpcd reads inactive afterward. Note whether the link comes back on its own or needs a follow-up =net doctor --fix= (the un-chained-reset question — if it needs the reset, chain "disable-rival": ["disable-rival","reset"] in FIX_CHAINS).
+**** keyfile-perms → chmod-keyfile
+What we're verifying: a profile keyfile with unsafe perms is set back to 0600 root so NM stops ignoring it. Needs root (the system-connections dir is 0700 root:root), so run the doctor as root or via sudo.
+- Pick an existing profile name (its keyfile is /etc/NetworkManager/system-connections/<name>.nmconnection).
+#+begin_src sh :results output
+sudo chmod 0644 /etc/NetworkManager/system-connections/<PROFILE>.nmconnection
+sudo net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], [a["id"] for a in d["attempts"]])'
+sudo stat -c '%a %U' /etc/NetworkManager/system-connections/<PROFILE>.nmconnection
+#+end_src
+Expected: the verdict/fix is chmod-keyfile and the keyfile reads =600 root= afterward.
+
+*** Net doctor sharpened auth verdict (net Phase 2) — needs a real WPA3 / hidden network
+What we're verifying: the auth-cause classifier names the specific auth cluster (SAE / hidden / enterprise / generic) instead of a bare "password rejected", and the SAE / hidden one-line profile fixes actually run and take. These need a real network the machine can (fail to) associate with, so they can't be faked; run against an AP you control or a VM bridged to one. The classify logic is already unit-tested — this is the live half.
+**** SAE cause named + auth-sae fix
+What we're verifying: a WPA3-only network whose saved profile is still WPA2-PSK reports the SAE cause, and =--fix= sets key-mgmt sae so the association can complete.
+- Set an AP (or its VM equivalent) to WPA3-Personal only (pure SAE, not WPA2/WPA3 transition).
+- Save a profile for it as WPA2-PSK (=nmcli connection add type wifi ... wifi-sec.key-mgmt wpa-psk=), then try to connect so the association fails on auth.
+#+begin_src sh :results output
+net doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], d.get("message"), "|", d.get("next_action"))'
+#+end_src
+- Expected (diagnose): outcome fixable, action auth-sae, the message names WPA3 (not just "password rejected").
+#+begin_src sh :results output
+net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print("fixed=",d["fixed"],"attempts=",[a["id"] for a in d["attempts"]])'
+nmcli -g 802-11-wireless-security.key-mgmt connection show <PROFILE>
+nmcli -g 802-11-wireless-security.pmf connection show <PROFILE>
+#+end_src
+Expected: attempts start with auth-sae (then reset), the profile now reads key-mgmt =sae=, and pmf is accepted (the =pmf optional= keyword took — this is the one value only a live nmcli can confirm). Note whether the link comes back after the chained reset.
+**** hidden SSID cause named + auth-hidden fix
+What we're verifying: a hidden (non-broadcast) network whose profile lacks the hidden flag reports the hidden cause, and =--fix= sets 802-11-wireless.hidden yes so NM probes for it.
+- Set the AP SSID to non-broadcast (hidden). Save a profile for it without =802-11-wireless.hidden yes=, then try to connect.
+#+begin_src sh :results output
+net doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], d.get("message"))'
+net doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print([a["id"] for a in d["attempts"]])'
+nmcli -g 802-11-wireless.hidden connection show <PROFILE>
+#+end_src
+Expected: diagnose names the hidden cause (outcome fixable, action auth-hidden); the fix attempts auth-hidden; the profile now reads hidden =yes=.
+**** enterprise / generic stay terminal + named
+What we're verifying: an enterprise (802.1X) auth failure stays needs-user-action and names the missing cert rather than offering a bogus fix; a plain wrong-password stays the generic "rejected" message.
+#+begin_src sh :results output
+# On an enterprise network with a broken/absent CA cert, or a WPA2 network with a wrong saved password:
+net doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["outcome"], "|", d.get("next_action"))'
+#+end_src
+Expected: enterprise → outcome needs-user-action, next_action names the CA certificate / identity (no auth-sae/auth-hidden action); wrong password → needs-user-action with the "re-enter the password" message.
+
+*** Bt doctor persistent-power fix (bt Phase 2) — proves out only across a real reboot
+What we're verifying: a deliberately boot-disabled adapter reports =powered-off-persistent= (not the transient =power-on=), =bt doctor --fix= clears the cause and powers it on, and the adapter comes up on its own after a reboot. The reboot-survival is the whole point, so this can't be faked; run on a machine (or VM) you can reboot. Back up =/etc/bluetooth/main.conf= first.
+**** AutoEnable=false → persistent verdict → fix → survives reboot
+What we're verifying: the full loop — break persistence, confirm the verdict, fix, reboot, confirm it stuck.
+#+begin_src sh :results output
+sudo cp /etc/bluetooth/main.conf /etc/bluetooth/main.conf.bak
+# Force the deliberate disable and power the adapter off:
+printf '[Policy]\nAutoEnable=false\n' | sudo tee -a /etc/bluetooth/main.conf
+bluetoothctl power off
+bt doctor --json | python3 -c 'import sys,json; d=json.load(sys.stdin); p=[s for s in d["steps"] if s["id"]=="powered"][0]; print(p["code"], "|", p["evidence"])'
+#+end_src
+- Expected (diagnose): the powered step reads code =powered-off-persistent= and the evidence names AutoEnable=false (not the plain "adapter is powered off").
+#+begin_src sh :results output
+bt doctor --fix --json | python3 -c 'import sys,json; d=json.load(sys.stdin); print("attempts=",[a["id"] for a in d["attempts"]])'
+grep -i autoenable /etc/bluetooth/main.conf
+bluetoothctl show | grep -i powered
+#+end_src
+- Expected (fix): attempts include persist-power; main.conf now reads =AutoEnable=true=; the adapter is powered on now.
+- Reboot the machine.
+#+begin_src sh :results output
+bluetoothctl show | grep -i powered # after the reboot, before touching anything
+#+end_src
+Expected: the adapter is powered on straight out of the reboot (AutoEnable=true held). Restore the backup if desired: =sudo mv /etc/bluetooth/main.conf.bak /etc/bluetooth/main.conf=.
+**** service-disabled and TLP causes (optional variants)
+What we're verifying: the other two persistent causes are named and fixed the same way.
+- Service variant: =sudo systemctl disable bluetooth= (leave it running this boot), power the adapter off, then =bt doctor --json= should read =powered-off-persistent=; =--fix= should re-enable it (check =systemctl is-enabled bluetooth=).
+- TLP variant (only if TLP is installed): add =bluetooth= to =DEVICES_TO_DISABLE_ON_STARTUP= in =/etc/tlp.conf=, power off, diagnose (persistent), =--fix=, confirm bluetooth is dropped from the list.
+Expected: each variant names the persistent verdict and the fix clears exactly that cause, leaving the others untouched.
+
+*** Maintenance console — in-person checklist
+Re-homed here by the 2026-07-09 audit: this was a second top-level "Manual testing and validation" parent. One parent, per verification.md. Priority and the :test: tag now live on the parent.
+Promoted from the build parent when it closed 2026-07-08 — Craig's checklist, runs once in person. Collects everything not agent-verifiable; populate per verification.md as phases land. Known so far: panel look-and-feel vs the E5 prototype (Craig's eyeball); arm-press wording reads right at the moment of use; results-wall readability during a real doctor run; a real UPDATE through the armed guard (and the TTY path once); REBOOT offer after an update; velox in-person glyph check (battery %, charging glyph, low-charge red); SET 80% charge limit sticks across a charge cycle; KILL on a real memory hog.
+Phase 8 additions: subpanel scroll position survives an arm press on a long digest (MAINT_PANEL_FIXTURE=bad, PACKAGES band, scroll into the 51-orphan list, press REMOVE — the list should stay put and the key read REMOVE?); a KEEP on a real orphan dims it into the kept section and UNKEEP restores it; MERGE on a needs-merge pacnew opens pacdiff in a foot terminal.
+Phase 9a additions: MARK KNOWN on a real journal group (ratio's wpa_supplicant bgscan spam is the standing candidate) — arm shows the identifier + sample wording, fire writes curation.toml, the next re-scan drops the group into KNOWN NOISE with "marked <date>", and UNMARK restores it to SIGNAL; OPEN JOURNAL opens journalctl -p 3 -b in a foot terminal; a failed unit's RESTART on a real unit (systemctl restart via the armed key) lands the re-probe on the act line.
+Phase 9b additions: MARK EXPECTED on ratio's real unexpected listeners (postgres :5432, docker :8080 — the standing curation material) dims them into the expected list and UNMARK restores them; a CPU-mode press (e.g. BAL·PWR) writes the EPP hint and the row re-reads it; KILL renders disabled (insensitive, tooltip) on a session-critical name in top memory; on velox the battery row shows NO SET 80% key (cros_ec knob absent — correct withholding, not a bug).
+Phase 11 additions: glyph left-click opens the console and a second click closes it; right-click still toggles the btop scratchpad; the glyph color matches the board's worst lever-less diagnostic (open the console and check the watch items against the glyph tint); after fixing/curating a diagnostic, the glyph follows within one 30-min scan (or immediately after a manual =maint scan=); on velox in person — battery % + charging icon in the bar, collapsed bar keeps the battery glyph, low-charge red (drain below 15% or temporarily raise battery_low_pct in the TOML); after velox's reboot confirm systemctl --user list-timers shows both maint timers scheduled.
+Phase 10 additions: results-wall readability during a real doctor run (press CLEAN UP on the live board — every step should appear as a running line the moment it starts, resolve in place with the result, and the re-probed truth should follow; the wall should feel like watching the run, not reading a report afterward); COPY puts the same lines the CLI wall prints on the clipboard, paste somewhere to check; HIDE collapses the wall to its header and SHOW restores it with the scroll position at the bottom; REVIEW & FIX reads right at the moment of use (each row: what's wrong, the maint fix usage, and either a FIX key or "per-item — on its subpanel"); RECLAIM on a degraded disk row arms with the first step + "(+N more steps)" and the fire streams every step to the wall individually; a guard-tripped UPDATE fired from the REVIEW roster re-arms with the override wording and the second press actually runs (the token fix — verify the loop terminates).
+Phase 11b additions: the fidelity eyeball — open the live console beside the settled E5 prototype (headless-Chrome render or the browser) and judge whether the board finally reads as the same instrument: big-number typography, bar fills with the gold threshold tick on the cache card, the scrub/keyring/topgrade rings, status chips, the lit CPU-mode segment, selector count chips + severity left borders, right-aligned attention/ok/fixable/watch header; press an EPP segment on the card (e.g. BAL·PWR) and confirm it arms then fires exactly like the old digest control; on the bad fixture (MAINT_PANEL_FIXTURE=bad maint panel) check a long readout (the BACKUPS card) ellipsizes with a tooltip carrying the full value.
+**** 2026-07-08 Wed @ 06:31:00 -0500 Fixed TOPGRADE remedy failure found in testing (dotfiles 9b5384f)
+Craig's first testing find: TOPGRADE exited 2 on every launch — topgrade 17.6.1 names the git step git_repos and the remedy passed --disable git, rejected by clap before any step ran. Fixed red-first (test pins git_repos), full make test green, dry-run on the installed binary confirms the step name lands and the repo-rebasing step stays excluded. Velox pulled. RE-TEST: press TOPGRADE again (it's guard-armed on ratio's pending mesa set — TTY is still the safe path for the mesa run).
+
+*** CPU mode survives a reboot
+What we're verifying: =maint-epp-restore.service= replays the CPU mode the pill last set. The kernel resets EPP to the driver default on every boot, so this is the only check that can prove the fix; nothing an agent runs before a reboot distinguishes "it works" from "nothing was reset yet."
+- Open the maintenance console, MEM·PWR band, and press a CPU-mode segment other than the current one (say BAL·PWR).
+- Confirm the act line says the mode was remembered.
+#+begin_src sh :results output
+cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference
+grep -A1 '^\[power\]' ~/.config/maint/*.toml 2>/dev/null
+#+end_src
+- Reboot.
+- After logging back in, run the block below.
+#+begin_src sh :results output
+cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference
+systemctl --user status maint-epp-restore.service --no-pager | head -5
+#+end_src
+Expected: the EPP value after the reboot is the mode you selected, not the driver default, and the unit shows =Active: inactive (dead)= with =Result=success= (a oneshot that ran and exited).
+
+*** Audio signal metering: apply the package (precondition)
+What we're verifying: the signal-metering build is live. Only a =git pull= is needed — the =audio/= package .py files are stowed and no new launcher files were added, so no restow or reboot. The shared panel CSS gained a =.lamp.dim= class (live-idle); it's in the same stowed =panel.css=.
+#+begin_src sh :results output
+cd ~/.dotfiles && git pull
+#+end_src
+Expected: the pull succeeds (commits 6054d3d..21437b4).
+*** Three-state activity lamp reads live
+What we're verifying: the INPUT/OUTPUT lamps show muted / live-idle / live-active from real device state, not just mute.
+- Open the audio panel (bar sound glyph / Super+A). With nothing playing and the output unmuted, look at the OUTPUT lamp.
+Expected: a dim (not bright) green lamp — live-idle: unmuted but no audio flowing.
+- Start playing audio (music, a video).
+Expected: the OUTPUT lamp brightens to a bright glowing green — live-active.
+- Mute the output (click OUTPUT).
+Expected: the lamp goes red — muted.
+*** OUTPUT needle tracks real signal, not the fader
+What we're verifying: the OUTPUT VU needle moves with actual output level, replacing the old volume-fed needle.
+- With audio playing, watch the OUT · PLAY needle; then pause the audio (leave the volume fader where it is).
+Expected: the needle deflects and rides the music while playing, and falls to rest when paused even though the fader hasn't moved. (The old behavior held the needle up at the fader position.)
+*** INPUT needle registers the mic, including during PTT
+What we're verifying: the IN · MIC needle shows real mic level and confirms PTT is capturing.
+- With the mic live (INPUT unmuted), speak.
+Expected: the IN · MIC needle deflects with your voice.
+- Set the mic to muted, then hold the PUSH·TALK key and speak.
+Expected: while held, the INPUT lamp reads live and the IN · MIC needle registers your voice; on release it rests again.
+*** No parec / CPU cost when the panel is closed
+What we're verifying: the meter runs only while the panel is open and leaves no orphan =parec=.
+- Open the audio panel, then close it (✕, Esc, or the bar click).
+#+begin_src sh :results output
+pgrep -af 'parec .*@DEFAULT_(MONITOR|SOURCE)@' || echo "no parec running — clean"
+#+end_src
+Expected: no matching =parec= process after the panel is closed (the process groups were reaped on teardown).
+*** Per-device activity lamp on each device row (option 3)
+What we're verifying: each OUTPUTS/INPUTS row leads with a lamp showing whether THAT device is live — red (muted) / dim green (unmuted, idle) / bright green (carrying audio) — so you can see which specific device has sound, not just the default.
+- Open the panel. Scan the leading lamp on each output/input row.
+Expected: muted devices show a red lamp; unmuted devices show green.
+- Play audio to a non-default output (or switch the default to a device, then play).
+Expected: the row of the device actually playing shows the brightest (live-active) green; an unmuted-but-silent device shows the dimmer (live-idle) green. Judge whether the dim-vs-bright contrast is clear enough — if not, say so and the CSS is a one-line tweak.
+- Note: the row lamps update on the panel's status refresh (~3s), so allow a moment after audio starts.
+*** Timer redesign: apply the package + wtimer, clear stale timers (precondition)
+What we're verifying: the redesigned =timer/= package + =wtimer= are live. Only a =git pull= is needed — the package .py files and =wtimer= are already stowed, and this build added no new launcher files, so no restow or reboot. Clear any pre-existing timers first so a stale-shape state file (an old pomodoro item lacking the new =cfg=) can't crash =wtimer render=.
+#+begin_src sh :results output
+cd ~/.dotfiles && git pull
+wtimer cancel-all
+#+end_src
+Expected: the pull succeeds and the bar timer glyph goes idle (no active items).
+*** Panel opens hero-on-top; close via the ✕, Esc, and the bar
+What we're verifying: the redesigned layout renders (header, hero, CONFIGURE, queue) and every close path works.
+- Click the timer module on the bar.
+Expected: the panel opens floating top-right — a header faceplate, a HERO block for the primary item, the CONFIGURE strip, then a QUEUE well below.
+- Click the ✕ in the header; reopen; press Esc; reopen; click the bar module again.
+Expected: each of ✕, Esc, and the bar click closes the panel.
+*** Timer with repeat
+What we're verifying: the REPEAT toggle re-arms a timer on fire instead of dropping it.
+- Pick TIMER, type =10s= (fast to observe), toggle REPEAT on, click +.
+Expected: the timer appears carrying a "repeat" badge and a "timer · repeats" sub-line.
+- Let it fire.
+Expected: it notifies and immediately re-arms to a fresh full countdown rather than vanishing.
+*** Alarm: recurring day, snooze, ringing, dismiss
+What we're verifying: a recurring alarm rings (not fire-and-vanish), snooze re-arms, and dismiss re-arms a recurring one.
+- Pick ALARM, type a clock time about a minute ahead (HH:MM), select today's weekday in the day picker, set SNOOZE to 1, click +.
+Expected: the row shows the fire clock time and a weekday badge; the hero donut is a countdown ring to the fire time.
+- Let it reach the fire time.
+Expected: the hero shows RING and the bar glyph goes urgent (RING); SNOOZE and DISMISS buttons appear on the hero.
+- Click SNOOZE.
+Expected: ringing stops and the alarm re-arms one minute out.
+- Let it ring again, click DISMISS.
+Expected: ringing stops; because it is recurring it re-arms to its next matching day (a one-shot alarm would be removed instead).
+*** Pomodoro: configurable cycle, auto vs await, cycle dots
+What we're verifying: the config grid drives the cycle and the AUTO toggle switches between auto-advance and awaiting a start.
+- Pick POMODORO, tap a named cycle (e.g. Deep) to fill the grid, set WORK S to 1 (min) for a fast run, toggle AUTO off, click + ADD CYCLE.
+Expected: a pomodoro starts in work; the hero shows cycle dots and "work · cycle 1/N".
+- Let the work phase fire with AUTO off.
+Expected: it enters the rest phase awaiting a start — the hero shows "ready · start break" and a START BREAK button, and it does not count down until pressed.
+- Press START BREAK.
+Expected: the rest phase counts down; the glyph/color shifts to the break (sage) state.
+*** Stopwatch: sweep dial, lap badge, promote, stop
+What we're verifying: the analog sweep dial animates, the last-lap ghost badge shows, promote works, and STOP just stops (run-save deferred).
+- Pick STOPWATCH, click +. If it isn't the hero, promote it (the ▲ on its queue row).
+Expected: the hero donut is an analog second-hand dial sweeping once per minute (not a percentage ring); the elapsed value counts up.
+- Hit LAP a couple of times (name one via the popover).
+Expected: a "LAP m:ss" ghost badge appears beside the elapsed value and the sub-line shows the lap count.
+- Hit STOP.
+Expected: the stopwatch is removed. No =~/org/stopwatch-runs.org= entry is written — run-save is deferred to a later version.
+*** Queue sort, promote/cycle, 10-item cap
+- Add a 25m timer, a 5m timer, and an alarm a few minutes out.
+Expected: the queue orders soonest-first; a ringing alarm jumps above everything.
+- Use ‹ / › on the hero (or ▲ on a queue row) to change the primary.
+Expected: the hero (bar-slot) item changes accordingly and the bar glyph follows.
+- Queue items until 10 exist.
+Expected: + disables with a "queue is full (10/10)" reason; running countdowns advance live (the =wtimer watch= subscription).
+*** Presets: locked defaults, half-past, custom add/delete
+- On TIMER, note the default chips carry no × (locked); add a custom preset via + preset, then delete it with its ×.
+Expected: locked defaults can't be deleted; a custom preset adds and deletes cleanly.
+- On ALARM, tap the half-past chip, click +.
+Expected: an alarm is created at the next :30.
+*** Bar tooltip parity
+What we're verifying: the bar tooltip mirrors the redesign verbatim.
+- With a pomodoro, a paused timer, and a ringing alarm active, hover the bar module.
+Expected: each item lists on its own line — the pomodoro as "label cycle/iv countdown" (no phase word), the paused one with a "(paused)" suffix, the ringing one as "RING (ringing)".
+*** Audio panel: apply the new shims + configs (precondition for the tests below)
+What we're verifying: the new =audio=/=audio-panel=/=waybar-audio= bin shims and the hyprland.conf + waybar config edits are live. New files need a restow (a plain =git pull= doesn't symlink them). Quit Hyprland or run from a TTY — restowing live Hyprland writes a stub hyprland.conf.
+#+begin_src sh :results output
+cd ~/.dotfiles && git pull
+cd ~/.dotfiles && make restow hyprland
+#+end_src
+- Reload waybar: mod+B (or =killall -SIGUSR2 waybar=).
+- Reload the Hyprland config: mod+Shift+R (or =hyprctl reload=).
+Expected: =which audio audio-panel waybar-audio= resolves all three to ~/.local/bin symlinks; no stow conflicts reported.
+
+*** Audio panel: opens and reads the live graph
+What we're verifying: Super+A opens the instrument-console panel (not the old pulsemixer scratchpad) and it shows the real devices.
+- Press Super+A.
+Expected: the audio panel opens top-right. OUTPUTS lists every sink, INPUTS every mic (no .monitor entries), the default device in each is emphasized (cream name, gold glyph), each row shows its volume percent, and the twin OUT·PLAY / IN·MIC needles deflect. Esc closes it.
+
+*** Audio panel: set default, volume fader, per-device mute
+What we're verifying: the three row interactions drive the real graph and the panel re-reads to confirm.
+- With two or more outputs present, click a non-default output row's body.
+Expected: that device becomes default (gold DEF emphasis moves to it) and playback follows.
+- Drag a device's fader.
+Expected: the volume percent tracks the fader and the actual device volume changes (no lag, no jump to the border).
+- Click a device's trailing mute glyph.
+Expected: that one device mutes/unmutes; the glyph and caption reflect it; other devices unaffected.
+
+*** Audio panel: master quick-mute (faceplate switch + mute key)
+What we're verifying: the master switch and the XF86AudioMute key both mute every output at once (not just the default sink).
+- Flip the faceplate master switch.
+Expected: every output mutes, the state word reads MUTED, the badge shows, the faceplate glyph goes to the slashed speaker. Flip back to restore.
+- With two outputs audible, press the hardware mute key (XF86AudioMute).
+Expected: both outputs mute (master), not just the default. Press again to unmute all.
+
+*** Audio panel: mic modes + push-to-talk (the meeting case)
+What we're verifying: LIVE/MUTED work, and PUSH·TALK holds the mic muted except while the talk key is held — the one genuinely new capability, and the one only a live test can confirm.
+- Click LIVE, then MUTED, watching the default mic row.
+Expected: LIVE unmutes the mic (needle lifts), MUTED mutes it (needle red); the active mode key shows a gold lamp.
+- Click PUSH·TALK. In a call app (or =audio status= in a loop), watch the mic while you hold Space, speak, then release.
+Expected: mic is muted at rest, un-mutes for exactly as long as Space is held, re-mutes on release. Switching back to LIVE or MUTED disarms the hold (Space types normally again).
+
+*** Audio panel: visual polish eyeball + bar entry point
+What we're verifying: the panel looks right in the dupre instrument-console language, and the bar opens it.
+- Look over the whole panel: faceplate spacing, engraved section labels, fader styling, VU needle centering, the muted-vs-audible glyph/color states.
+Expected: it reads as a sibling of the net and bt panels; nothing overflows the gold border; the faders and gauges look machined, not stock GTK.
+- Right-click the waybar sound module.
+Expected: the panel opens (left-click still mutes, scroll still changes volume).
+
+*** SUPERSEDED — Timer fuzzel dialogs (Escape-cancel, 12h alarm entry)
+These two tests exercised the old fuzzel creation dialogs, retired when the bar's =custom/timer= on-click became =timer-panel=. The panel redesign checklist above covers the same ground: alarm entry (12h shapes, bad-input reject) is now the panel's ALARM freeform + inline validation, and the whole-flow abort is the panel's Esc/✕/bar close. Nothing to run here; kept as a pointer so the intent isn't lost.
+
+*** Speed test streams in the panel
+What we're verifying: the panel's speed test fills in as phases complete instead of dumping everything at the end (the CLI path is live-verified; this is the GTK rendering).
+NOTE (2026-07-04 audit): these steps describe the OLD four-tab Blueprint panel ("Diagnostics → Network Performance"). The net panel was rebuilt as a single-screen instrument console (dotfiles 800ef60, f4e688e dropped the Blueprint pages), so the navigation below no longer matches the shipped UI — reword to the console layout before running. Don't delete; the streaming-render behavior is still worth verifying.
+- Open the net panel, Diagnostics → Network Performance → Run Speedtest.
+Expected: a Ping/Download/Upload checklist appears under the running row; ping lands within seconds, download mid-run, upload near the end; then the final rows (with jitter on Ping, a Packet loss row) replace it. A Tip row appears only if a rule fired, and it names the numbers that triggered it.
+
+*** Proton VPN CLI sign-in (velox now, ratio on its trip)
+What we're verifying: the proton CLI has its own account store (separate from the retired GTK app's), so the panel's proton rows can't toggle until you sign in once per machine.
+- Run in a terminal: protonvpn signin <your-proton-username> (it prompts for the password). The CLI's action is =signin=, not =login=.
+#+begin_src sh :results output
+protonvpn info
+#+end_src
+Expected: Account shows your Proton username instead of 'None'. After that, protonvpn status still says Disconnected — correct, nothing auto-connects.
+
+*** Tunnels round-trip: panel rows + bar badge (first real tunnel-owned route)
+What we're verifying: the panel's Tunnels tab drives a real wireguard tunnel up and down, and the bar indicator grows the vpn badge while the tunnel owns the default route (the badge has never rendered live — every prior check ran with the wlan owning the route).
+- Open the net panel (left-click the bar's net module), switch Connections to the Tunnels page.
+- Confirm the rows: tailscale (up), and the three Proton configs (wg-US-TX-714, wg-US-CA-144, wg-NL-781), all down.
+- Select wg-US-TX-714, press Bring Up, wait for the row to land.
+Expected: the bar's net glyph gains the small vpn badge; its tooltip names the owner ("Tunnel: default route via wgpvpn (wireguard)").
+- Press Bring Down on the same row.
+Expected: badge gone, tooltip back to normal, internet still works (the wlan owns the route again).
+
+*** Screenshot View Image option
+What we're verifying: the new post-capture menu entry opens the shot and puts its path on the clipboard (dispatch is unit-tested; this is the live end-to-end).
+- Take a screenshot the usual way (region or fullscreen).
+- Pick "View Image" from the fuzzel menu.
+- Paste anywhere (the clipboard should hold the file path).
+Expected: the shot opens in feh (the default image/png viewer) and the pasted text is the saved file's path under ~/pictures/screenshots/.
+
+*** Wlogout square cells on velox (and later ratio)
+What we're verifying: the exit menu's six buttons read as squares with visible muted borders, and only pointer hover shows gold (measured 361x361 px, but your eyeball is the arbiter).
+- Press Super+Shift+Q.
+- Look at the six cells' shape and borders; hover a couple of buttons; note the lock button is not gold before you hover it.
+- Press Esc to close (careful: the letter keybinds are live — e is logout).
+Expected: square cells with subtle borders, gold only under the pointer, muted ring on the focused button. Repeat on ratio after its sync.
+
+*** wtimer: color states + click/scroll interactions on the live bar
+What we're verifying: the timer module's interactions and CSS state colors render right on the live bar. The glyph, position (right of battery), countdown, and "+N" badge are already verified live; the per-state colors and the real mouse/scroll bindings are what's left. The logic is unit-tested (86 cases); this is the human-in-the-loop visual + input check.
+- Left-click the timer module — a fuzzel menu offers timer / alarm / stopwatch / pomodoro; pick timer, enter =5s=.
+- Watch it count down; at under a minute it should turn the urgent color (dupre orange #d47c59).
+Expected: the timer reaches 0:00, a persistent notification fires, and the item disappears from the bar.
+- Create two timers (e.g. =3m= and =10m=); a =+1= badge shows; scroll over the module.
+Expected: scrolling cycles which item is primary (the displayed time/glyph changes); the badge count stays correct.
+- Middle-click the module while a timer runs.
+Expected: it pauses (dimmed paused color #5f5c52) and the countdown freezes; middle-click again resumes from where it left off.
+- Right-click the module with items present.
+Expected: a fuzzel menu lists the items; choosing one cancels it.
+- Start a pomodoro (left-click → pomodoro); let a work phase elapse (or set short test phases by editing state).
+Expected: the glyph + color switch between work (gold #d7af5f) and break (#8a9a5b), a notification fires at each phase change, and the cycle count advances.
+*** Sysmon right-click cycles the visible metric (live waybar)
+What we're verifying: right-clicking the collapsed sysmon module rotates the visible metric and the bar refreshes at once, left-click still opens btop, and the cpu/temp/mem icons render as real glyphs (not tofu boxes). The cycle logic is unit-tested; this is the live-waybar + visual confirmation.
+- Reload waybar so it picks up the new =signal= / =on-click-right= config (Super+B relaunches it, or =pkill waybar; waybar &= from a terminal)
+- Right-click the sysmon module several times, watching the visible metric
+- Left-click the sysmon module once
+Expected: each right-click advances the visible metric battery → cpu → temp → mem → disk → back to battery (velox is a laptop, so battery is in the ring) and the bar updates immediately. Every metric shows a sensible icon plus its value, no tofu. Left-click still opens the btop popup. The tooltip still lists all metrics.
+*** Give the README a final read before public release
+What we're verifying: =README.md= reads cleanly and accurately for a first-time reader, with no stale personal info and consistent public-fork placeholders.
+- Open =~/code/archsetup/README.md=
+- Read it end to end as if you've never seen the project
+Expected: every section is accurate, the personal-project disclaimer reads right, the placeholders (=<your-domain>=, =github.com/yourusername=) are consistent, and nothing personal leaked into the public-facing draft.
+*** Bt console: connect / disconnect a paired device
+What we're verifying: a paired row's primary action toggles the real connection and the row's lamp + battery dial follow (the smoke drives the widgets against fakes; this is a real device).
+- Open the bt panel (left-click the bar's bluetooth module, or Super+Shift+B).
+- On a paired-but-disconnected audio device, click its row's connect action.
+Expected: the device connects, the row lamp goes live, and if it reports battery a dial fills in with its level.
+- Click the same row again to disconnect.
+Expected: the device disconnects, the lamp dims, and its battery dial returns to NO DEVICE.
+*** Bt console: rename a paired device
+What we're verifying: the ✎ affordance renames a real device through the bluez Alias and the new name persists (mechanism live-probed on the M650 during the build; this is the in-panel path).
+- In the bt panel, click the ✎ on a paired device's row.
+- Enter a new name in the dialog and confirm.
+Expected: the row's name updates to the new alias immediately, and it survives closing and reopening the panel (verify-after read confirms it stuck).
+- Rename it back to its original name the same way.
+*** Bt console: pair a nearby device (passkey flow)
+What we're verifying: pairing a new device from the NEARBY list runs the pair flow into the passkey-confirm dialog and default-deny holds (this can't be auto-driven — it needs a real discoverable device and the passkey compare).
+- Put a bluetooth device into pairing mode.
+- In the bt panel, press SCAN and wait for the device to appear under NEARBY.
+- Click its row to start pairing.
+Expected: a passkey-confirm dialog appears; confirming completes the pairing and the device moves to PAIRED; dismissing it leaves the device unpaired.
+*** Bt console: arm-forget a paired device
+What we're verifying: the ✕ two-click arm-to-fire removes a real pairing (the arm latch is unit-tested; this confirms the real forget lands).
+- In the bt panel, click the ✕ on a device you can re-pair later.
+Expected: the row arms (tinted, a confirm affordance) rather than forgetting on the first click.
+- Click again to confirm.
+Expected: the device is unpaired and drops off the PAIRED list.
+*** Bt console: discoverable toggle and adapter power switch
+What we're verifying: the discoverable chip and the faceplate power switch drive the real adapter state.
+- In the bt panel, click the discoverable chip.
+Expected: the chip reads "discoverable on" and another device can see this adapter while it's on; clicking again turns it off.
+- Flip the faceplate adapter-power switch off, then on.
+Expected: off powers the adapter down (faceplate word → OFF, rows empty), on brings it back (POWERED, paired rows return). Airplane mode overrides — if it's on, the switch refuses with the way out.
+*** Bt console: LOW BATT badge with a real sub-15% device
+What we're verifying: a connected device reporting under 15% battery drives the faceplate LOW BATT badge and the red dial (the threshold is unit-tested; this needs a real device actually low).
+- Connect an audio device whose battery is genuinely under 15% (drain one, or catch it low).
+Expected: its battery dial renders red, and the faceplate shows the LOW BATT badge. Charge it above 15% and the badge clears on the next refresh.
+*** 2026-06-28 Sun @ 12:54:47 -0400 Live-update guard verified on velox (live Hyprland)
+Verified the =hypr-live-update-guard= PreTransaction hook end-to-end on velox
+with Hyprland running (pid 1997). velox predated the feature, so the guard was
+absent — placed =/usr/local/bin/hypr-live-update-guard= (755) and
+=/etc/pacman.d/hooks/hypr-live-update-guard.hook= (644), byte-matching the
+archsetup hyprland-step install. The guard now ships on velox permanently.
+
+Results:
+- Quick contract (=printf 'mesa\nhyprland\n' | guard=) → exit=1, BLOCKED banner,
+ sorted pkgs, correct TTY remedy + sentinel path.
+- Not-running branch (=HYPR_GUARD_RUNNING=0=) → exit=0, silent.
+- Env override (=HYPR_ALLOW_LIVE_UPDATE=1=) → exit=0.
+- Sentinel (=touch /run/archsetup-allow-live-gpu-update=) → exit=0; removed →
+ re-armed exit=1.
+- Real firing through pacman: =sudo pacman -S mesa= (same-version reinstall =
+ Upgrade op on a guarded target). pacman ran the hook, fed =mesa= via
+ =NeedsTargets=, the guard aborted, =AbortOnFail= stopped the transaction
+ ("no packages were upgraded"); mesa unchanged at 1:26.1.3-2. This is the
+ authoritative proof pacman parses + wires the hook.
+- Full-logout end-to-end (guard quiet, upgrade completes after logout): covered
+ by construction — the not-running branch exits 0, and a 0-exit PreTransaction
+ hook lets pacman proceed normally (proven by the mesa abort showing the hook
+ path runs). Not re-run under a real logout; no separate residual.
+*** Wallpaper survives relogin (waypaper --restore)
+What we're verifying: the hyprland =exec-once= now runs =waypaper --restore= instead of a hardcoded =awww img=, so a wallpaper chosen via =set-wallpaper= / waypaper / dirvish persists across a relogin. The exec-once only fires at Hyprland startup, so this can't be confirmed without a real relogin. (Mechanism already verified: =waypaper --restore= applied the persisted wallpaper via the awww backend, exit 0.)
+- Set a wallpaper different from the current one (or pick one in waypaper, Super+Shift+P):
+#+begin_src sh :results output
+set-wallpaper ~/pictures/wallpaper/trondheim-norway.jpg
+#+end_src
+- Log out of Hyprland and back in (or reboot)
+Expected: the wallpaper you just set is what comes back after login — not whatever was showing before, and never the old hardcoded default unless that's what you set.
+
*** velox per-host env applies after Hyprland restart
What we're verifying: the velox tier's env lines (GDK_SCALE/QT_SCALE_FACTOR 1.5, XCURSOR_SIZE 36) only apply at Hyprland startup, and the foot font moved to host.ini — neither can be confirmed over ssh.
- On velox, log out of Hyprland and back in (or reboot)
@@ -815,557 +1228,364 @@ What we're verifying: the physical keychord opens a floating Dirvish popup; open
- Expected: still exactly one popup — the second press focuses the existing one, no second frame, no stray buffer (for several independent file managers, use C-x d)
- Press Super+Shift+F
- Expected: GUI nautilus opens (the binding nautilus moved to)
+*** Network module Phase 1 — indicator states on the live bar
+What we're verifying: =custom/net= shows the right state for each real network condition. The engine logic is unit-tested; this is the live-bar + visual check (states can't be faked on the running bar). Phase 2-3 tests get added under this task as those phases land.
+- Reload waybar to pick up =custom/net=. Super+B does NOT reload a running bar — it only toggles visibility (SIGUSR1), and the bar reads a generated runtime config, so a stale copy keeps the old module. The correct reload regenerates the runtime config then restarts:
+#+begin_src sh :results output
+waybar-active-config && killall waybar && waybar-toggle
+#+end_src
+- On a normal connected network, read the module.
+- Expected: wifi glyph + signal + SSID; tooltip shows IPv4, gateway, throughput, and a recent "online" probe result.
+- Join the hotel/captive network (or any portal network).
+- Expected: the module shows the captive state (distinct glyph + warning color), tooltip names the portal host.
+- Unplug to a network with no internet (or block egress).
+- Expected: the no-internet state (distinct from captive and from disconnected).
+*** Network module Phase 1 — net doctor recovers rfkill from a TTY
+What we're verifying: the console-recovery path works with no GUI, and recovers the framework's post-power-loss soft-block.
+#+begin_src sh :results output
+rfkill block wifi # simulate the soft-block
+rfkill list wifi # confirm Soft blocked: yes
+#+end_src
+- Switch to a TTY (Ctrl+Alt+F3) and log in (no Hyprland).
+#+begin_src sh :results output
+make -C ~/.dotfiles online # or: net doctor --fix
+#+end_src
+- Expected: doctor reports the rfkill block, runs =rfkill unblock wifi= + =nmcli radio wifi on=, reconnects, and ends "online" — all from the TTY.
+*** Network module — bar clicks + airplane keybind (FINAL scheme)
+What we're verifying: the custom/net clicks and the airplane keybind. Clicks (settled with Craig over live use 2026-06-29): left = =net-panel= toggle (the GTK panel), middle = =net portal= (floating terminal), right = =net-fix= (notify the doctor result when one-way; open a terminal only when fixable). Airplane = Super+Shift+A.
+- Left-click =custom/net=.
+- Expected: the GTK connection panel toggles open (left-click again, or Esc, closes it).
+- Right-click =custom/net= while online.
+- Expected: a desktop notification "Network / Online" (success), no terminal. When a repair is needed it instead opens a terminal running =net doctor --fix=. (Craig confirmed the notification delivers, 2026-06-29.)
+- Middle-click =custom/net= on a captive network.
+- Expected: =net portal= runs in the floating terminal — reset + opens the portal page.
+- Press Super+Shift+A.
+- Expected: airplane engages (wifi off, dim, low-power); =custom/net= shows the airplane glyph in gold. Super+Shift+A again restores everything.
+- Check =airplane-mode= is still present (=ls ~/.local/bin/airplane-mode=), and =waybar-airplane= / =waybar-netspeed= / =custom/airplane= are gone.
+*** Network module Phase 3 — panel Diagnose / Repair / Speed test tabs
+What we're verifying: the four-tab panel works end to end. Left-click =custom/net= to open it.
+NOTE (2026-07-04 audit): the "four-tab panel" framing predates the instrument-console rebuild (dotfiles 800ef60, f4e688e dropped the Blueprint pages). Diagnose/Repair/Speed-test now live in the single-screen console, not tabs — reword the steps to the console layout before running. Don't delete; the underlying behaviors still need verification.
+- Diagnose tab → "Run diagnose".
+- Expected: a list of steps (link, DHCP, gateway, DNS config, DNS resolution, internet) each with a ✓/✗/… glyph + evidence; on a captive network an "Open portal" button appears.
+- Repair tab → click Reset (or Bounce, or DNS override test).
+- Expected: a confirmation dialog with the exact wording (Reset names the network + new-MAC warning; Bounce "links drop briefly"; DNS test "reverts automatically"). Proceed opens a floating terminal that runs the repair (sudo prompt there) and shows the step output incl. cleanup-verified for the DNS test.
+- Speed test tab → "Run speed test" (uses ~30s + data — do it on real wifi, not the metered hotspot).
+- Expected: ↓/↑ Mbps + ping + server shown inline.
+- Byte-rate→Mbps unit: VERIFIED 2026-06-30 (velox). Raw =speedtest-go --json= dl_speed read ~3.66M, unambiguously bytes/s (29 down / 80 up Mbps); =net speedtest= reported 33.62 / 77.99 through the wired path. =BYTES_PER_SEC = True= + =* 8 / 1e6= are correct, no flip needed. Remaining here is only that the panel renders the inline result.
-** DONE [#B] Enlarge org-capture popup to scratchpad size :hyprland:
-CLOSED: [2026-06-24 Wed]
-From a .emacs.d inbox handoff (2026-06-15, captured via roam): the quick-capture / org-protocol popup is too small to be effective — it should be about the size of a terminal scratchpad.
+** DOING [#B] Prepare for GitHub open-source release
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-09
+:END:
+Remove personal info, credentials, and code quality issues before publishing.
+*** 2026-07-09 Thu @ 16:32:54 -0500 Audit reconcile: docs/ scrubbed; scripts and history still open
+The =docs/= half of "remove personal information" landed (=a8f7e9b=). Four absolute =/home/cjennings= paths became the repo root, a home-relative path, or a named variable — every snippet stays runnable. Three =~/projects/home= references and three =.ai/sessions/= links, all dead in any clone but Craig's, were replaced by what they mean. No =file:= links broke.
-*** 2026-06-24 Wed @ 17:21:11 -0400 Sized the popup to the scratchpad, per-host in pixels
-The 06-15 read was wrong: the real size lever is the Hyprland window rule, not the quick-capture char-cell count. The =size 900 500= rule on the org-capture window pinned it to 900x500 regardless of the frame's requested geometry (demoing 120x24 vs 180x32 looked identical because both clamped to 900x500). Tried a percentage rule (=size 75% 70%=) to auto-adapt per host like the pyprland scratchpad — native window rules do NOT honor percentages (only pyprland does), so the frame fell back to char-cell geometry and overflowed the screen. Fix: absolute pixels matching each host's terminal scratchpad, placed in the host tier (=<host>/conf.d/local.conf=) since pixels don't adapt across monitors. velox = 1078x671 (75%x70% of its 1437x958 logical desktop) — verified on-screen. ratio = 1892x936 (55%x65% of 3440x1440) — set but not yet eyeballed on ratio (tracked as an Active Reminder in notes.org). The shared hyprland.conf keeps float/center/stay_focused and a comment pointing at the per-host size. dotfiles change — needs commit in =~/.dotfiles=.
+The scrub also turned up something the epic didn't name: nine tracked files carried =#+AUTHOR: Craig Jennings & Claude=, a co-author who is not a person and which survives conversion into docx, a wiki page, or a PDF. The five under =docs/= are fixed. *Four remain, and they are Craig's call* (dated records under =assets/=, which the rule says stay as history, but which are tracked and would publish): =assets/2026-06-19-collapsible-waybar-sides-spec.org=, =assets/2026-07-03-instrument-console-panels-build-summary.org=, =assets/outbox/2025-11-08-keyring-fix-next-steps.org=, =assets/outbox/2025-11-08-test-failure-analysis.org=.
-*** 2026-06-15 Mon @ 19:19:55 -0500 AI Response: popup size is the frame's char-cell count, not the Hyprland rule
-Triaged under auto inbox-zero. The popup is the emacsclient frame named "org-capture", created by =~/.dotfiles/hyprland/.local/bin/quick-capture= with =(width . 90) (height . 22)= — 90 columns by 22 lines. Emacs sizes by character cells and overrides the Hyprland rule =windowrule = match:title ^(org-capture)$, size 900 500= (hyprland.conf:182). The live frame measured ~889x860 px; the width tracks the 90-column count, not the window rule. Setting the Hyprland rule to =size 55% 65%= (the scratchpad's pyprland spec) did not change the frame width, so I reverted it — dotfiles left clean.
+The two open children below are untouched: the =scripts/= personal-information pass, and the git-history secret scrub.
+*** 2026-06-16 Tue @ 00:55:39 -0500 Six dotfiles-scoped sub-tasks moved to the ~/.dotfiles project
+Per the 2026-06-16 task audit, the six sub-tasks targeting files now owned by the standalone =~/.dotfiles= repo were handed off to that project (newly bootstrapped as its own AI project) and removed from this epic: "Remove credentials and secrets from dotfiles", "Remove/template personal info from dotfiles", "Remove binary font files from repo", "Move battery out of waybar sysmonitor group", "Resolution-adaptive scratchpad sizing", and "Dynamic waybar/foot config based on screen resolution". Handoff: =~/.dotfiles/inbox/2026-06-16-0053-from-archsetup-dotfiles-release-prep-handoff.org=. This epic now covers archsetup-proper release work only (scripts personal-info, device-specific config, history scrub, shellcheck, SPDX headers, README/LICENSE). The 2026-06-09 reconciliation note below is the prior state.
+*** 2026-06-09 Tue @ 19:21:36 -0500 Reconciliation: six sub-tasks now target the ~/.dotfiles repo, not archsetup
+Phase 3.2 removed the in-repo =dotfiles/= tree, so six sub-tasks below no longer describe archsetup content — they target files now owned by the =~/.dotfiles= repo (=git.cjennings.net/dotfiles.git=): "Remove credentials and secrets from dotfiles", "Remove/template personal info from dotfiles", "Remove binary font files from repo", "Move battery out of waybar sysmonitor group", "Resolution-adaptive scratchpad sizing", and "Dynamic waybar/foot config based on screen resolution". Their paths are relative to that repo now. Kept here for tracking per Craig (2026-06-09); he'll re-scope the archsetup-vs-dotfiles split shortly. archsetup-proper release work (scripts personal-info, device-specific config, shellcheck, and scrubbing the pre-=b10cba5= dotfiles secrets from archsetup's own history) stays this task.
+*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Open-source-prep source audit
+Checked each subtask below against the source / git state. Bottom line: almost nothing is fully done. =LICENSE= and =README.md= were added this session (see those subtasks); the rest still stands.
+- *Remove credentials and secrets from dotfiles* — NOT DONE. All five named files still tracked: =dotfiles/common/.config/.tidal-dl.token.json=, =.config/calibre/smtp.py.json=, =.config/transmission/settings.json=, =.msmtprc=, =.mbsyncrc=. =.gitignore= lists none of them; no =.example= templates exist.
+- *Remove/template personal info from scripts* — PARTIALLY DONE. Repo URLs ARE config-driven (=archsetup:141-146= use =${dwm_repo:-https://git.cjennings.net/...}=, documented in =archsetup.conf.example=). Still personal: =archsetup:2-3= (email/website header), =init:8,21= (=root:welcome=), =scripts/post-install.sh:17-56= (personal repos).
+- *Remove/template personal info from dotfiles* — NOT DONE. =.gitconfig= has =c@cjennings.net=, =name = Craig Jennings=, =github user = cjennings=, =safe.directory= and employer creds; =.config/mpd/musicpd.conf= + =mpd.conf= still use =~cjennings/= / =/home/cjennings/= paths; =.ssh/config= has personal/employer hosts; =.config/yt-dlp/config:2= has =c@cjennings.net=; =hyprland.conf:3= has personal attribution.
+- *Scrub git history of secrets* — NOT DONE. 275 commits; history not fresh, no filter-repo evidence.
+- *Remove device-specific configuration* — NOT DONE. =archsetup:1486-1493= still creates the Logitech BRIO udev rule unconditionally; no config flag.
+- *Add README.md for GitHub* — DONE (this session — initial draft, pending review). See subtask below.
+- *Add LICENSE file* — DONE (this session — GPL-3). See subtask below.
+- *Remove binary font files from repo* — NOT DONE. =dotfiles/common/.local/share/fonts/= still tracks 8 PragmataPro =.ttf= files, =AppleColorEmoji.ttf=, and other commercial fonts (Cartograph, MonoLisa, ComicCode, etc.).
+- *Make claude-code installation optional* — NOT DONE. =archsetup:1817-1818= runs =curl -fsSL https://claude.ai/install.sh | sh= unconditionally; no flag.
+- *Add input validation for username and paths* — PARTIALLY DONE. =archsetup:326-328= validates =$username= against =^[a-z][a-z0-9_]*$= (plus reserved-names check, marked DONE separately). No validation of =$source_dir= or other path vars.
+- *Move battery out of waybar sysmonitor group* — NOT DONE. =dotfiles/hyprland/.config/waybar/config:27-37= still has =battery= inside =group/sysmonitor=.
+- *Resolution-adaptive scratchpad sizing* — NOT DONE. No size/move windowrules for scratchpads in =hypr/conf.d=.
+- *Dynamic waybar/foot config based on screen resolution* — NOT DONE. No resolution-detection/generation script.
+- *Bulk shellcheck cleanup* — PARTIALLY DONE. =shellcheck archsetup= still shows 68 findings: 30×SC2329, 16×SC2174, 15×SC2024, 4×SC2086, 1 each SC2155/SC2129/SC2005. The 4 SC2086 (unquoted) are the ones a reviewer would flag — those are the priority.
+- *Document testing process in README* — NOT DONE. =scripts/testing/README.org= exists but isn't the project README. (Now unblocked — root README exists.)
+- *Add guard for rm -rf on constructed paths* — DONE 2026-05-20. All three constructed-path deletes routed through a =safe_rm_rf= guard (absolute / no-=..= / inside-allowed-prefix / real-dir checks); unit-tested in =tests/safe-rm-rf/=.
+- *Standardize boolean comparison style* — NOT DONE. Mixed: =[ "$var" = "true" ]= at =archsetup:542,544,569= vs bare =if $var;= form ~7 places elsewhere.
+- *Replace eval with safer alternatives* — NOT DONE. =archsetup:442= still =if eval "$cmd" >> "$logfile" 2>&1;= in =retry_install=.
-Real lever: the column/line count in the quick-capture script. Scratchpad reference on ratio (DP-4, 3440x1440) is 55% 65% ~= 1892x936 px ~= 190 cols by 24 lines. Why this isn't a solo auto-fix — it needs a tradeoff decision:
-- The script lives in the shared =hyprland/= stow tier, so a fixed ~190 columns overflows velox's 1920-wide laptop, and 24+ lines overflows velox's 1080 height (22 lines ~= 860 px is already near the safe max there).
-- Emacs char-cell sizing doesn't adapt to the monitor the way pyprland's percentage does, so "scratchpad-size on both machines" needs one of: a fixed compromise count, a per-host override via the ratio/velox tiers, or a script that computes columns from the active monitor.
-Options to weigh: (a) a safe-on-both compromise like width 120-130 / height 24; (b) per-host width through the ratio/velox tiers; (c) dynamic sizing in quick-capture from =hyprctl monitors=. Pick the tradeoff and I'll implement.
+*** 2026-06-28 Sun @ 13:34:03 -0400 Cancelled: calendar-feed URL rotation
+Craig's call — not rotating. The three private iCal URLs (Google personal, Proton with PassphraseKey, Google DeepSat) sat in git history from =500b1f5= (2026-05-13) until the 2026-05-20 filter-repo scrub, which removed them from local + remote history. The residual exposure is only to anyone who cloned the repo in that 2026-05-13..05-20 window; Craig accepts that window rather than regenerating all three tokens on ratio. The history scrub already happened; the live =calendar-sync.local.el= is owned by the emacs project. Closing without rotation.
-** TODO [#C] archsetup Waybar Wi-Fi module should show no-internet state :feature:waybar:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-13
-:END:
-From the roam inbox: the Waybar Wi-Fi module should distinguish "connected to an access point" from "connected and has internet." Add a no-internet state or indicator to the archsetup Waybar configuration. Not marked quick/solo because it needs the archsetup environment and live network-state verification.
+*** 2026-05-20 Wed @ 12:09:32 -0500 Scrubbed the calendar secret from git history
+=dotfiles/common/.emacs.d/calendar-sync.local.el= (private Google/Proton/DeepSat ical URLs, added in =500b1f5= for stow distribution) was discovered while folding tmux-util into stow. Sent the file back to the emacs project's inbox, =git rm='d it, then =git filter-repo --invert-paths --path= purged it from all 29 affected commits. Force-pushed (=0921e4d...618e6cc=, with lease) and ran =reflog expire= + =gc --prune=now= on the bare repo at =/var/git/archsetup.git=. Verified: the file is in zero commits, the secret tokens return zero matches across all history, and =500b1f5= / =0921e4d= are unreachable on both local and remote. Rotation of the URLs tracked as the sibling TODO above. This also proves =filter-repo= works cleanly here — relevant precedent for the broader [[*Scrub git history of secrets (or start fresh)][history-scrub task]] below (the 5 credential files are still in history).
-A 2026-06-22 roam capture expands the scope past a passive indicator: the wifi module should also bounce the network, run basic diagnostics, and optionally run a speed test with results — surfaced through modifier clicks (ctrl/super/meta) on the module. The no-internet state is the indicator; this adds active remediation off the same component.
+*** TODO [#B] Remove/template personal information from scripts
+- =archsetup= lines 3-4: personal email and website in header
+- =scripts/post-install.sh=: personal git repos and server URLs (the old =scripts/gitrepos.sh= was consolidated into this script in =dae7659=, so its personal =git.cjennings.net= clone targets now live here)
+- =init= line 9: hardcoded password =welcome=
+**** 2026-06-28 Sun @ 13:29:29 -0400 Reconciled: dotfiles repo URLs already config-driven
+Dropped the "lines 141-146 hardcoded =git.cjennings.net= URLs" bullet. archsetup:138-140 reads =DOTFILES_REPO= / =DOTFILES_BRANCH= / =DOTFILES_DIR= overrides (defaults only, documented in =archsetup.conf.example=), so that item is already done. Refreshed the stale line numbers on the remaining bullets (header email/site now lines 3-4, init password now line 9, after the SPDX headers shifted the files).
-** TODO [#C] Waybar emacs-service status + control :feature:waybar:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-From the roam inbox (2026-06-22): with Emacs integrated into the system as file manager and instant note-taker, make bouncing it trivial. A waybar component showing the emacs service status, with detail on hover, that turns the server on / off / bounce via right-click. Pairs with running the Emacs daemon as a managed systemd user service.
+*** TODO [#B] Scrub git history of secrets (or start fresh)
+Even after removing files, secrets remain in git history.
+Options: =git filter-repo= to rewrite history, or start a fresh repo for the GitHub remote.
+Recommend: fresh repo for GitHub (keep cjennings.net remote with full history).
+**** 2026-06-28 Sun @ 13:29:29 -0400 Reconciled: 589 commits, 5 credential files still in history
+History is now 589 commits (the 2026-05-11 note's "275" is stale). Only the calendar-feed file has been filter-repo'd so far (2026-05-20). The five credential files remain in history at their pre-=b10cba5= paths: =.tidal-dl.token.json= (5 commits), =calibre/smtp.py.json= (6), =transmission/settings.json= (5), =.msmtprc= (8), =.mbsyncrc= (9). None are tracked in the current tree. The scrub-or-fresh-repo decision still stands.
+***** 2026-07-04 Sat @ 11:48:24 -0500 Count refresh — history now 565 commits; re-verify the 5-file claim before scrubbing
+The 2026-07-04 audit found the history is now 565 commits, down from the 589 recorded above. Because the count dropped, re-verify that the five credential files are still present in history (re-run the per-file =git log --all -- <path>= check) before relying on the scrub scope — the earlier count is stale and the file set may have moved.
-** TODO [#C] Collapse waybar sysmonitor to a single icon + hover :feature:waybar:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-From the roam inbox (2026-06-22): replace the spread-out sysmonitor readouts (temp, cpu, mem, storage) with one visible icon showing a single chosen metric, the rest in the hover tooltip. Open question: fold it into the battery component instead of a standalone module. Implementation lives in the waybar config under ~/.dotfiles.
+*** 2026-06-24 Wed @ 19:41:56 -0400 Gated device-specific udev rules behind a flag
+The Logitech BRIO udev rule is now wrapped in =if [ "$install_device_udev_rules" = "true" ]=, fed by a new =INSTALL_DEVICE_UDEV_RULES= key (default yes, opt-out — still mainly a personal project). Added the var default, the config read, a =validate_config= check, and an =archsetup.conf.example= entry. Verified: default/yes writes the rule, no skips it, bogus is rejected; =bash -n= clean.
-** TODO [#C] Proton Mail Bridge font size :chore:
-From the roam inbox (2026-06-22): adjust the Proton Mail Bridge UI font to a comfortable size. The bridge is a Qt app, so it likely keys off Qt scaling or the qt5ct/qt6ct config like the other Qt apps (QT_SCALE_FACTOR or a font setting).
+*** 2026-06-28 Sun @ 13:37:33 -0400 Added README.md — full draft complete, final read filed
+=README.md= is substantively done at repo root (10.9 KB), covering project description, features, requirements, installation, the =archsetup.conf= configuration guide, security considerations, contributing, and license, with generic placeholders for the eventual public fork. The 2026-05-11 "first pass" note below is superseded. Craig's final read before public release is filed under "Manual testing and validation"; closing as code-complete pending that human check, per the audit rule.
-** TODO [#C] Highlight current month and year in the calendar hover :feature:waybar:quick:solo:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-From the roam inbox (2026-06-24): the waybar clock's calendar tooltip highlights today's date in goldenrod; the current month and year header should be goldenrod too. Mirror the existing date-highlight pango/CSS span onto the calendar header (=format-calendar= / the clock module's tooltip format). Small mechanical tweak following the established highlight pattern; residual is a visual spot-check of the hover.
+**** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Initial README draft
+Drafted =README.md= at repo root, modeled on =~/code/chime/README.org=. First pass — review and run a voice/style pass before committing. Personal info (emails, =cjennings.net= URLs, personal repo names) intentionally replaced with placeholders for the eventual public release.
-** TODO [#C] Rename idle inhibitor to something more intuitive :chore:waybar:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-24
-:END:
-From the roam inbox (2026-06-24): the "idle inhibitor" name doesn't work as a mnemonic — something like "sleep" (i.e. "keep awake" / "no-sleep") would land better. Decide the new name, then rename across the touchpoints: the =custom/idle= waybar module, the keybind mnemonic, and the backing script names (=hypridle-toggle= / =waybar-idle= from the 2026-06-24 idle-inhibitor work). Needs Craig's call on the name first, so not solo.
+*** 2026-05-19 Tue @ 01:54:29 -0500 Added GPL-3 LICENSE file at repo root
+GPL-3 chosen. Canonical GPLv3 text landed at =LICENSE= on 2026-05-11 (commit =f80e664=). README already links to it. SPDX/license headers across source files (or a NOTICE file) split out as a new sub-task below for the eventual public release.
-* Archsetup Resolved
+*** 2026-06-24 Wed @ 19:41:56 -0400 Added SPDX headers to all shell scripts
+Swept =# SPDX-License-Identifier: GPL-3.0-or-later= in right after the shebang of all 24 shell scripts in the repo (=archsetup=, =init=, =scripts/**/*.sh= incl. =scripts/testing/=). The dotfiles are a separate repo now, so they aren't swept here. Verified the header sits at line 2 (after the shebang) and syntax still passes.
-** DONE [#B] Full install logs should contain timestamps
-CLOSED: [2026-02-23 Sun]
-Log filename includes timestamp via =date +'%Y-%m-%d-%H-%M-%S'=.
-Functions =error_warn()=, =error_fatal()=, and =display()= all output timestamps via =date +'%T'=.
-
-** DONE [#B] Validate DESKTOP_ENV default behavior
-CLOSED: [2026-02-23 Sun]
-Defaults to =hyprland= silently via =desktop_env="${desktop_env:-hyprland}"=.
-Overridable via config file or =DESKTOP_ENV= environment variable.
-
-** DONE [#B] Test archsetup username/password prompts
-CLOSED: [2026-02-23 Sun]
-Username prompt with regex validation (lines 320-332) and password prompt
-with confirmation (lines 339-353) implemented and functional.
-
-** DONE [#B] Verify SSH to remote server works
-CLOSED: [2026-02-02 Mon]
-Tested 2026-02-02: ssh cjennings.net returns "connected" successfully.
-SSH key authentication working, no password required.
-
-** DONE [#B] Verify Proton Mail Bridge retrieves email
-CLOSED: [2026-02-02 Mon]
-Verified 2026-02-02: Proton Mail Bridge running, ports 1143 (IMAP) and 1025 (SMTP)
-listening on 127.0.0.1. mu4e email retrieval functional.
-
-** DONE [#B] Fix unsafe sed patterns with user input
-CLOSED: [2026-02-23 Sun]
-Quoted =$username= in sed replacement, switched locale and wireless-regdom sed
-patterns to pipe delimiter to avoid conflicts with path/encoding characters.
-
-** DONE [#B] Fix unsafe heredoc variable expansion
-CLOSED: [2026-02-23 Sun]
-Quoted =UDEVEOF= heredoc and used placeholder + sed replacement pattern (same as hyprpm hook).
-
-** DONE [#C] Add mountpoint check before ramdisk mount
-CLOSED: [2026-02-23 Sun]
-Added =mountpoint -q= guard before mount; skips with info message if already mounted.
-
-** DONE [#C] Improve error handling in chained commands :chore:
-CLOSED: [2026-05-07 Thu]
-Line 820: three operations chained with =&&= reported as single failure.
-Broken into separate error-handled steps.
-
-** DONE [#C] Add comments on complex logic
-CLOSED: [2026-02-23 Sun]
-Added comments explaining wireless region locale-to-ISO3166 mapping and
-archsetup clone strategy (why symlinks need user-owned repo).
-
-** DONE [#D] Validate reserved usernames
-CLOSED: [2026-02-23 Sun]
-Added check against list of reserved system usernames (root, bin, daemon, sys, etc.).
-
-** DONE Review: Hyprland conf.d source ordering :chore:
-CLOSED: [2026-05-07 Thu]
-~source = $HOME/.config/hypr/conf.d/*.conf~ was at top of hyprland.conf (line 9).
-Machine-local overrides (gaps, monitor scale) were overwritten by defaults later in the file.
-Fixed by moving source line to end of file. Update stowed hyprland.conf.
-
-** DONE Review: natural_scroll not set for mouse (only touchpad) :chore:
-CLOSED: [2026-05-07 Thu]
-~input:natural_scroll~ was missing; only ~touchpad:natural_scroll~ was set.
-Added ~natural_scroll = true~ to input block.
-
-** DONE [#B] Extend layout-navigate to escape special workspaces
-CLOSED: [2026-04-19 Sun]
-With the =special:stash= overlay visible and focus on a window inside it,
-=$mod+J= was trapped because =layoutmsg cyclenext= only operates within the
-current workspace. The 2026-04-09 fix handled floating→tiled but not
-special-workspace→regular.
-
-Fix in =dotfiles/hyprland/.local/bin/layout-navigate=: when the active
-window's =workspace.name= begins with =special:= and the user is navigating
-focus (not moving), dispatch =togglespecialworkspace <name>= first, re-read
-activewindow state, then fall through to the existing floating/layout
-branches. Move variant (=$mod SHIFT J=) is intentionally left untouched so
-moving a window out of a scratchpad remains a deliberate separate action.
-
-Unit tests live in =tests/layout-navigate/= (stdlib =unittest=, fakes
-=hyprctl= via PATH). Run with:
-=python3 -m unittest tests.layout-navigate.test_layout_navigate=
-
-** DONE Check linux-lts version until 6.18+
-CLOSED: [2026-03-07 Sat]
-Run =topgrade= and check =pacman -Q linux-lts=. Once 6.18+, remove =/etc/modprobe.d/amdgpu.conf= and mark this DONE.
-Background: AMD Strix Halo VPE power gating bug causes system freeze. Workaround disables power gating. Fix is in kernel 6.15+.
-Running linux-lts 6.18.16-1. amdgpu.conf workaround already removed.
-
-** DONE [#D] Find or create a monocle layout for Hyprland
-CLOSED: [2026-03-07 Sat]
-Both existing monocle plugins (zakk4223/hyprlandMonocle, pianocomposer321/hyprland-monocle) are
-abandoned and broken against current Hyprland. Options: fork and fix hyprlandMonocle (more features),
-script a pseudo-monocle using fullscreen 1, or wait for a maintained plugin. Lower priority since
-stash-window ($mod+O / $mod+Shift+O) covers the main use case. More important for laptop installs.
-Resolved: Hyprland 0.54 added native monocle layout. Bound to $mod SHIFT M.
-
-** DONE [#B] Investigate rlwrap not installed after archsetup run
-CLOSED: [2026-05-11 Mon]
-rlwrap was declared in archsetup (Emacs Dependencies) but missing after a run on ratio (2026-02-06).
-The 2026-05-11 VM test run shows it installs cleanly in a fresh install (=...installing rlwrap via pacman @ 15:36:55=; =rlwrap 0.48-1= in the captured package list), so it doesn't reproduce — likely a one-off / machine-specific glitch on ratio, not a systemic skip. Closing; reopen if it recurs.
-
-** DONE [#C] Remove stale hyprpm/plugins validations; make run-test.sh tolerant of validation failures
-CLOSED: [2026-05-11 Mon]
-The 2026-05-11 VM test aborted because =validate_hyprland_plugins= in =scripts/testing/lib/validation.sh= checked for =~/.local/bin/hyprland-plugins-setup=, which was deliberately removed in dd543e3 (=feat(hyprland): remove plugins, add layout cycling=; Hyprland 0.54 brings the layouts into core). The function's =return 1= under run-test.sh's =set -e= killed the run before the test report was written or the VM cleaned up.
-Fix: deleted =validate_hyprland_plugins= and =validate_hyprpm_hook= (the hyprpm pacman hook was removed in the same commit) plus their calls in =validate_window_manager=; disabled errexit in =run-test.sh= from the validation phase onward so a failed check is counted (=VALIDATION_FAILED=) instead of fatal — the script signals pass/fail via its exit code at the end. Verified with =bash -n=; the next =make test= run confirms the count-not-abort behavior.
-** DONE [#B] toggle key for touchpad on/off
-CLOSED: [2026-05-20 Wed]
-*** 2026-05-20 Wed @ 18:18:30 -0400 Spec: touchpad toggle + waybar indicator
-
-**** Current state
-A toggle mechanism already exists in the live home dir but is only partly committed.
-- =~/.local/bin/toggle-touchpad= (live, NOT in repo): reads/writes a state file at =${XDG_RUNTIME_DIR:-/tmp}/touchpad-state= (values "enabled"/"disabled"), flips =hyprctl keyword "device[$TOUCHPAD]:enabled" true|false=, and fires a =notify info "Touchpad" ...= toast. Hardcodes =TOUCHPAD="pixa3854:00-093a:0274-touchpad"=.
-- =~/.local/bin/touchpad-auto= (live, NOT in repo): daemon watching Hyprland's =.socket2.sock= for mouseadded/mouseremoved/configreloaded, auto-disables the touchpad when an external mouse is present, writes the same state file. Same hardcoded device name.
-- Keybinding already committed: =bind = $mod, F9, exec, toggle-touchpad= (=hyprland.conf:315=).
-- State file confirmed live at =/run/user/1000/touchpad-state= (reads "enabled").
-
-**** Gap
-1. No waybar indicator — nothing in modules-right shows touchpad state; no =custom/touchpad= module exists.
-2. Neither =toggle-touchpad= nor =touchpad-auto= is committed into the repo. They live only in =~/.local/bin=, so a fresh stow won't install them. They belong in =dotfiles/hyprland/.local/bin/= (the =dotfiles/dwm/.local/bin/toggle-touchpad= is the old X11/xinput version, unrelated).
-3. =touchpad-auto= is never started — no =exec-once= launches it.
-4. The toggle doesn't refresh waybar, so an indicator would lag until its poll interval.
-
-**** Proposed implementation
-1. New status script =dotfiles/hyprland/.local/bin/waybar-touchpad= mirroring =waybar-layout= / =waybar-netspeed= (emit one JSON line: text + tooltip + class). Reads the state file the toggle already writes — single source of truth, no extra hyprctl call. Emits a "disabled" class + off-icon when the state file reads "disabled", else "enabled" + on-icon.
-2. Waybar module in =dotfiles/hyprland/.config/waybar/config=, using "signal" so the toggle pushes an instant refresh (no polling — state only changes on toggle or mouse hotplug):
- =, "custom/touchpad": { "exec": "waybar-touchpad", "return-type": "json", "signal": 9, "on-click": "toggle-touchpad" }=
- Add =custom/touchpad= to modules-right, near =idle_inhibitor=.
-3. Refresh-on-toggle: have =toggle-touchpad= (and =touchpad-auto='s set function) run =pkill -RTMIN+9 waybar= after each write to the state file (RTMIN+N ⇄ waybar "signal": N). Alternative: drop "signal", use "interval": 2 (simpler, ~2s lag, constant poll). Signal is the cleaner fit.
-4. =style.css= (=dotfiles/hyprland/.config/waybar/style.css=): add =#custom-touchpad= to the shared padding/hover selector lists; add =#custom-touchpad.disabled { color: #d47c59; }= (the dupre orange already used for warnings). Enabled state inherits the default color.
-5. Keybinding: keep =$mod+F9= (=hyprland.conf:315=). The waybar on-click gives a mouse path to the same action.
-6. Commit the live scripts so stow installs them: =toggle-touchpad= and =touchpad-auto= into =dotfiles/hyprland/.local/bin/= (plus the =pkill= line), and =waybar-touchpad= (new). If the auto-disable-on-external-mouse behavior is wanted at boot, add =exec-once = touchpad-auto= near the other daemon exec-once lines.
-
-**** Decisions (Craig, 2026-05-20)
-1. Icons: 󰍽 enabled / 󰍾 disabled (the mouse / mouse-off pair).
-2. Waybar on-click toggles the touchpad.
-3. Commit =touchpad-auto= and add its =exec-once= so it runs at login.
-4. Signal-driven refresh (=pkill -RTMIN+9 waybar=).
-Note: the hardcoded device name =pixa3854:00-093a:0274-touchpad= is Framework-laptop-specific — a portability concern for other machines, not a blocker for this task.
-
-*** 2026-05-20 Wed @ 18:29:06 -0400 Implemented the toggle + waybar indicator (in repo)
-Built per spec + decisions above. Committed the two formerly-live-only scripts into the repo and added the indicator:
-- =dotfiles/hyprland/.local/bin/waybar-touchpad= (new) — reads =$XDG_RUNTIME_DIR/touchpad-state=, emits JSON (text/tooltip/class), fail-safe to "enabled". Unit-tested in =tests/waybar-touchpad/= (6 Normal/Boundary cases).
-- =dotfiles/hyprland/.local/bin/toggle-touchpad= — copied from =~/.local/bin=, added =pkill -RTMIN+9 waybar= so the indicator refreshes on toggle.
-- =dotfiles/hyprland/.local/bin/touchpad-auto= — copied in, =pkill -RTMIN+9 waybar= inside =set_touchpad= so auto on/off events refresh too. Added =exec-once = touchpad-auto= to =hyprland.conf=.
-- =waybar/config= — =custom/touchpad= module (signal:9, on-click toggle-touchpad), placed in modules-right before idle_inhibitor.
-- =waybar/style.css= — =#custom-touchpad= in padding + hover lists; =.disabled { color: #d47c59 }= (dupre orange).
-- =$mod+F9= bind already present (=hyprland.conf=), left as-is.
-
-*** 2026-05-20 Wed @ 18:36:26 -0400 Deployed + verified on velox
-Discovered =.local/bin= is stow-symlinked (waybar-layout/netspeed point into the repo); the two touchpad scripts were real files only because they weren't committed. Replaced both real files with repo symlinks and symlinked the new =waybar-touchpad= (matching the existing relative-symlink form). velox needed no hyprland.conf change — =exec-once = touchpad-auto= and the =$mod+F9= bind were already present. waybar =config= / =style.css= are real local files on velox (config diverges: standalone battery, no sysmonitor group), so applied targeted edits there rather than a copy.
-
-Verified end-to-end after a waybar restart: config loads with no parse errors; toggle round-trips state enabled → disabled (󰍾, class disabled) → enabled (󰍽), and the =pkill -RTMIN+9 waybar= refresh fires into the running bar. Touchpad left enabled. Visual confirmation (icon in bar, orange when off) is Craig's to eyeball. Other machines (ratio) pick this up on =git pull && make restow hyprland= — their =.local/bin= and waybar configs are symlinks, so no real-file conflict there.
-** DONE [#B] Airplane-mode toggle + waybar indicator
-CLOSED: [2026-05-21 Thu]
-Laptop-only low-power toggle, modeled on the touchpad indicator. Wifi off (bluetooth left alone for earbuds), CPU EPP → power, brightness → 35%, and stops network-only services. Disengage restores only what it recorded, so anything already off stays off.
-*** 2026-05-21 Thu @ 17:43:07 -0400 Built the toggle, indicator, and tests
-- =dotfiles/hyprland/.local/bin/airplane-mode= (new) — toggle. Engage records prior state (wifi enabled/disabled, EPP value, brightness, which services were active) to =$XDG_RUNTIME_DIR/airplane-state=, then applies low-power: =nmcli radio wifi off=, EPP → power on all CPUs (sudo sysfs write), =brightnessctl set 35%=, and stops Tier 1+2 services (tailscaled, proton.VPN, avahi-daemon, cups, wsdd, geoclue, sshd, fail2ban + user syncthing). Disengage replays the recorded state — only re-enables wifi if it was on, only restarts services it stopped. Refreshes the bar via =pkill -RTMIN+10 waybar=.
-- =dotfiles/hyprland/.local/bin/waybar-airplane= (new) — indicator. Reads =mode= from the state file; fail-safe to inactive. Laptop-gated: exits silently (module hidden) when no battery is present (=/sys/class/power_supply/BAT*=). One clear plane glyph (FA U+F072) for both states; color carries state (gold active / gray inactive).
-- =waybar/config= — =custom/airplane= module (signal 10, on-click airplane-mode), placed after custom/touchpad. =waybar/style.css= — =#custom-airplane= in padding + hover lists; =.active { color: #d7af5f }= (dupre gold).
-- Tests: =tests/airplane-mode/= (20 — engage/disengage/preserve-existing-state/dispatch, via command stubs + fake EPP sysfs) and =tests/waybar-airplane/= (10 — states/boundary/laptop-gating). All green; shellcheck clean.
-- Deployed + live-verified on velox (engage → disengage round-trip works). Other machines pick it up via git pull && make restow hyprland.
-** DONE [#C] super+e emacs launch doesn't grab focus from tiled browser :quick:
-CLOSED: [2026-05-22 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-22
-:END:
-Launching emacs with super+e while a browser window is open in tiled mode leaves focus on the browser instead of moving it to the newly opened emacs window in the main (left) portion of the screen. Expected: the new emacs window takes focus. Noticed 2026-05-22.
+*** 2026-06-09 Tue @ 19:21:36 -0500 Made claude-code install optional
+Shipped in =f2dad22= (feat: make the claude-code install optional). The =curl | sh= from claude.ai now sits behind a config flag instead of running unconditionally.
-Resolved 2026-05-22: not a focus *failure* but a focus *fight*. Live socket2 capture showed the new (XWayland, non-pgtk Emacs 30.2) frame does get focus on open, then Firefox reclaims it via an activation request because =misc:focus_on_activate=true=. Set it =false= in the dotfiles repo (=3bfba5a=) — new-window focus is a separate path so emacs still focuses on open, but the browser can no longer steal it back. Verified by Craig.
-** DONE [#C] Dim inactive windows in Hyprland :hyprland:
-CLOSED: [2026-05-27 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-26
-:END:
-Shipped in the =~/.dotfiles= repo (=66124e8=): =dim_inactive = true=, =dim_strength = 0.4= (tuned by eye), =dim_special = 0.2= for pyprland scratchpads, and a =no_dim true= window rule for Zoom. The opt-out rule is =no_dim= (underscore), not =nodim= — the latter throws a config-error banner. Config uses Hyprland 0.55's =windowrule = match:class ...= grammar.
-** CANCELLED [#A] Prevent X termination and VT switching (security risk)
-CLOSED: [2026-05-21 Thu]
-If someone grabs laptop at cafe and hits ctrl+alt+backspace, they kill screensaver/X and get console access
-Need to disable: ctrl+alt+backspace (zap X) and ctrl+alt+F# (VT switching)
-Previous attempts to configure in xorg.conf.d failed - need to investigate what's overriding the settings
-Tried: /etc/X11/xorg.conf.d/00-no-vt-or-zap.conf with DontVTSwitch and DontZap options
-Removed conflicting setxkbmap statements, gdm, and keyd configs - still didn't work
-** DONE [#B] Add Rust installation via rustup instead of pacman package :quick:
-CLOSED: [2026-05-26 Tue]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Already implemented — =archsetup= lines 1976-1979 (Programming Languages and Utilities) =pacman_install rustup= then =rustup default stable= as the user. Closing on verification; the task predated that work.
+*** 2026-06-09 Tue @ 19:21:36 -0500 Input validation added (validate_config + validate_username)
+validate_config + validate_username shipped (detail in the 2026-05-11 note below). The =$source_dir= path check was judged unnecessary — it derives from the now-always-validated =$username= (=/home/$username/.local/src=). Closed as done.
-The =rust= package has been removed from archsetup. Need to add Rust installation using =rustup= (the official Rust toolchain manager) instead of the Arch package.
+**** 2026-05-11 Mon @ 18:20:49 -0500 AI Response: validate_config + validate_username added
+Added two pre-flight validators to =archsetup= (right after =load_config=, before any install step):
+- =validate_username()= — the lowercase / starts-with-letter / =[a-z0-9_]= / not-reserved check, extracted from the inline block in =preflight_checks()=. Fixes an existing gap: the inline check only ran on the *prompted* path, so a config with =USERNAME=root= (or =USERNAME=foo bar=) slipped through unvalidated. Now both =preflight_checks= and =validate_config= call it.
+- =validate_config()= — runs whenever =--config-file= is used: rejects unknown =DESKTOP_ENV= (must be dwm/hyprland/none) early instead of dying in step 7-9; rejects =AUTOLOGIN=/=NO_GPU_DRIVERS= values that aren't =yes=/=no= (currently silently ignored); basic shape check on =LOCALE=; and a scheme + no-whitespace/no-leading-dash check on the six =*_REPO= URLs that get passed to =git clone= (rejects e.g. =--upload-pack=…= injection). Plain =echo …>&2; exit 1= (the logging helpers aren't defined that early). =$source_dir= needs no separate check — it's =/home/$username/.local/src=, derived from the now-always-validated =$username=.
+Not a security boundary (=load_config= sources the config as bash; a hostile config can already run anything) — it's typo-catching. Verified with =bash -n= and a smoke-test matrix of good/bad inputs through both functions. The next =make test= run confirms valid configs still install. Leaving as DOING for review.
-Steps:
-- Install rustup: =pacman -S rustup=
-- Initialize default toolchain: =rustup default stable=
-- Consider adding to archsetup or post-install script
+*** 2026-05-20 Wed @ 06:50:25 -0500 Swept shellcheck across the shell scripts
+Census across the 16 shell scripts (=archsetup=, =init=, =scripts/*.sh=, =scripts/testing/=): 124 findings, zero errors. Triaged against "what matters for public review" and confirmed the 2026-01-24 read — most are intentional or documented-acceptable:
+- SC2024 (14, sudo redirects), SC2174 (16, =mkdir -p -m=), SC1091 (13, unfollowable sources), SC2329 (32, functions invoked indirectly via the =STEPS= dispatch array), SC2153 (1, =DISK_PATH= sourced from =vm-utils.sh=) — all false positives or accepted.
+- SC2086 on =$SSH_OPTS= in =vm-utils.sh= (×4) and =$TEMP_DISKS= in =cleanup-tests.sh= — intentional word-splitting; quoting would break them. The SSH_OPTS-as-array refactor is the proper fix, deliberately deferred (codebase-wide, one atomic change).
+- SC2086 integer tests in =[ ]= (=archsetup=, =cleanup-tests=) — safe, note-level style; left to avoid churn in the just-fixed =retry_install=.
+- SC2015 (×2, =vm_exec && success || warn=) — =success=/=warn= return 0, so C won't spuriously fire. Idiomatic.
-Reference: Removed from archsetup on 2025-11-15
-** CANCELLED [#D] Add cpupower installation and enabling to archsetup :quick:
-CLOSED: [2026-05-26 Tue]
-Implemented, VM-verified, then removed — wrong tool for this fleet. Both machines run active-mode pstate drivers (ratio amd-pstate-epp, velox intel_pstate) where only performance/powersave exist and the driver self-manages frequency via EPP; both correctly sit on powersave. cpupower's governor-forcing only helps older acpi-cpufreq systems, which we don't run. Forcing performance would pin max clocks (worse on the laptop, pointless on the desktop). Dropped from archsetup rather than ship a backwards default.
+Fixed the four that are genuine: =init= (a =#!/bin/sh= script) used =$(</etc/hostname)= (SC3034 bashism → =$(cat ...)=) and an unquoted =$interface_up= (SC2086 → quoted); =shellcheck init= now clean, =sh -n= passes. Suppressed the two =VM_IP= SC2034 warnings with documented =# shellcheck disable= directives (consumed by the sourced =validation.sh=, which shellcheck can't follow). 124 → 120; the remaining 120 are the triaged-acceptable set above.
-cpupower service configures the default CPU scheduler (powersave or performance)
-Install cpupower, configure /etc/default/cpupower, enable service: ~systemctl enable --now cpupower.service~
-** DONE [#C] Airplane-mode toggle robustness follow-ups :quick:solo:
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Shipped 2026-06-10 as dotfiles commit =16fbe4e=, TDD'd (23 tests green). Both gaps closed: the toggle now no-ops without a BAT* (same check as waybar-airplane, AIRPLANE_POWER_SUPPLY_DIR override for tests), and an empty recorded brightness at disengage falls back to 100% (AIRPLANE_BRIGHTNESS_DEFAULT) instead of stranding the screen at 35%.
-** DONE [#B] protonmail-bridge package service conflicts with Hyprland autostart :cmail:
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Craig confirmed resolved 2026-06-10 — the per-machine fix (disable the packaged user service, Hyprland exec-once as sole launcher) has held since 2026-05-22 with no recurrence.
+*** 2026-05-20 Wed @ 06:32:17 -0500 Documented the testing process in the README
+The README only covered the VM integration harness; the unit-test layer under =tests/= (Python =unittest=, fake-binary-on-PATH, one dir per script — =layout-navigate=, =tmux-util=) was undocumented. Added a =make test-unit= target that runs every =tests/*/test_*.py= suite explicitly (=unittest discover= can't find them — hyphenated dir names aren't valid package paths), then rewrote the README Testing section into "Unit tests" and "Integration tests (VM harness)" subsections, including how to add a suite for a new script. Updated Contributing to point at =make test-unit= for script changes. 61 unit tests pass via the new target.
+*** 2026-05-20 Wed @ 18:22:42 -0400 Added safe_rm_rf guard on constructed-path deletes
+Added a self-contained =safe_rm_rf <path> <allowed_prefix>= helper to =archsetup= and routed all three constructed-path deletes through it. The guard refuses to run unless the target is absolute, free of =..=, deeper than a bare top-level dir, strictly inside the allowed prefix (not the prefix itself), and a real directory (not a symlink); otherwise it prints the reason and returns non-zero without deleting. On the happy path it delegates to =rm -rf=.
-The =protonmail-bridge= package ships an enabled systemd user service (=/usr/lib/systemd/user/protonmail-bridge.service=, =--noninteractive=, =Restart=always=) that double-launches with the Hyprland =exec-once = protonmail-bridge --no-window= GUI autostart. Two symptoms: (1) no tray icon — the headless service grabs ports 127.0.0.1:1143/:1025 before the GUI =--no-window= instance can bind; (2) TLS cert mismatch — the headless service can't reach gnome-keyring (starts outside the graphical session), falls back to its own self-signed cert, so =mbsync=/mu4e and cmail-action.py fail STARTTLS against =~/.config/protonbridge.pem= with SSL CERTIFICATE_VERIFY_FAILED.
+Sites converted (the line numbers in the original task body were stale — actual sites located by grep):
+- =--fresh= state-dir wipe — prefix =/var/lib/archsetup=.
+- =git_install= clone-retry cleanup (=build_dir= under =$source_dir=).
+- =aur_installer= yay clone-retry cleanup (same prefix).
-Fix applied per-machine 2026-05-22: =systemctl --user disable --now protonmail-bridge.service=, leaving the Hyprland exec-once GUI as the sole bridge (tray icon returns, served cert matches, =mbsync -a= clean). A fresh install re-enables the package service, so make it durable: mask/disable =protonmail-bridge.service= during install (likely in =scripts/cmail-setup-finish.sh=) and document that the Hyprland exec-once is the intended launcher — never run both. Source: handoff from .emacs.d 2026-05-22.
-** DONE [#B] Add signal-cli to the standard install :tooling:signal:solo:
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Shipped 2026-06-10 as archsetup commit =1229fb2= — =aur_install signal-cli= beside signal-desktop, with the JRE/update-cadence/manual-linking caveats as comments.
+The helper is defined before the top-level =--fresh= handler (which runs at load time, before the logging helpers exist), so it carries no =error_warn= dependency and reports refusals to stderr itself. The two in-function sites keep their existing =|| error_warn= / =|| error_fatal= handling.
-Add =signal-cli= (AUR) to the regular package set so every provisioned machine has it. It's the headless JSON-RPC engine for an in-Emacs Signal client (a =signel= fork) that's the same across all machines. Source: handoff from .emacs.d 2026-05-26.
+Tests: =tests/safe-rm-rf/test_safe_rm_rf.py= sources the real function out of the script and exercises Normal/Boundary/Error cases (13 tests) against real temp dirs. =make test-unit= green (61 tests), =bash -n= clean, no new shellcheck warnings.
+*** 2026-06-24 Wed @ 19:41:56 -0400 Standardized boolean comparisons on the explicit form
+Rewrote the bare =if $var= boolean conditionals (=show_status_only=, =fresh_install=, =skip_gpu_drivers=, =detected_intel/amd/nvidia=, plus two =! $var= negation chains) to the explicit =[ "$var" = "true" ]= / =!= "true"= form, and quoted the one unquoted =install_claude_code = true=. Left =if $step_func= alone — that's the STEPS function-dispatch, not a boolean. Verified: only =step_func= remains bare, all comparisons are quoted, =bash -n= clean.
+
+*** 2026-05-26 Tue @ 15:27:09 -0500 eval task moot — the line-434 eval is gone, the survivor is deliberate
+Verified: the only =eval= left in =archsetup= is line 578 in =retry_install=, and it's intentional and documented — it captures =$?= directly from =eval "$cmd"= to dodge the if-compound-swallows-exit-code trap. Replacing it with an array would reintroduce that bug. The line-434 eval this task pointed at no longer exists. Nothing to change.
-- =aur_install signal-cli= in the appropriate section (comms/messaging or AUR utilities).
-- Runtime needs a JRE (OpenJDK 17+) — already satisfied by =jdk-openjdk=; note it as a dependency if the install set is ever trimmed.
-- Keep-current caveat: signal-cli must update roughly every 3 months or Signal-Server rejects it (client-version floor moves). It belongs in the regularly-updated AUR set, not pinned.
-- Linking is per-machine and interactive (QR scan from phone's Linked Devices), so that stays manual. archsetup only guarantees the binary is present.
-** DONE [#B] Mic-mute keybind + waybar indicator :waybar:hyprland:solo:
-CLOSED: [2026-06-10 Wed]
+** TODO [#B] The audio doctor never checks the microphone :bug:audio:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
+:LAST_REVIEWED: 2026-07-13
:END:
-Shipped 2026-06-10 as dotfiles commit =07d056c= (script + 5 unit tests + bind + waybar module + CSS in all three theme files; old CTRL+ALT+SPACE bind removed). Verified live on ratio: state flips in wpctl, indicator renders both states with correct glyphs and colors, notifications fire. velox picks it up via pull + restow.
+The classifier is output-only. =diag.probe_semantic= already collects =default_source= and =default_source_present=, and =classify.py= reads neither: the word "source" appears once in the whole module, in the graph row that counts them. So a muted mic, a default source naming an unplugged device, or a mic at zero volume all classify as =healthy=, and the verdict prints "the default output is present and audible" while the input side goes unexamined. Found 2026-07-10 while asking whether the doctor would have caught Chrome losing the mic. It would not have.
-A single mute state in PipeWire, reachable from a keybind and a waybar indicator, each reflecting the other. Agreed design (2026-06-10):
+Not a scope decision. The spec's Non-Goals never say the doctor is output-only, and its Summary calls it a doctor for "a broken sound stack".
-- *Keybind*: Super+Shift+A (=bindl= so it works on the lock screen), running a =mic-toggle= script in =hyprland/.local/bin/=: =wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle=, then read the new state and fire =notify= (alert "Mic muted" / success "Mic live"). wpctl targets PipeWire's default source, so the bind keeps working if the default mic changes (ratio has three capture devices).
-- *Waybar indicator*: a second pulseaudio module instance (=pulseaudio#mic=) using =format-source= / =format-source-muted= — waybar subscribes to PipeWire events natively, so the keybind and the click both update the icon with no signal plumbing (unlike =custom/dim=). =on-click= runs the same wpctl toggle.
-- *Icons*: Nerd Font MD glyphs — mic (U+F036C) live, mic-off (U+F036D) muted — matching the MD volume glyphs already in the pulseaudio block. Verify by rendering, not by name (BerkeleyMono remaps codepoints; see the 2026-06-10 glyph lesson).
-- *Coloring* (dupre): default =#969385= when live; =#d47c59= when muted — same semantic as =#custom-touchpad.disabled= (an input device turned off). The gold =#d7af5f= stays reserved for active/attention states (airplane, dim). Mirror the rule in the hudson theme's waybar css with its palette equivalent.
-- *Remove the old mechanism entirely*: the =CTRL ALT, SPACE= amixer Capture-toggle bind in =hyprland.conf= (~line 325) — ALSA-level, fragile with multiple capture devices, brittle notify grep chain.
+Work: mirror the sink rules onto the source. =probe_semantic= gains =default_source_muted= / =default_source_volume=; the classifier gains the source cases; =classify.findings()= gains an input row beside its =defaults= row, so the CLI wall and the GUI wall both grow it for free.
-Lives in the dotfiles repo (=hyprland/.config/hypr/hyprland.conf=, =hyprland/.config/waybar/=, =hyprland/.local/bin/=). TDD the =mic-toggle= script per the dotfiles suite. velox picks it up via pull + restow.
-** DONE [#B] Waybar theme-CSS drift — live style.css ahead of theme copies :waybar:hyprland:solo:
-CLOSED: [2026-06-11 Thu]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-11
-:END:
-Shipped 2026-06-10/11 across two dotfiles commits: =1589734= reconciled dupre to a byte-copy of the live style.css, rebuilt hudson with the full live selector set in its palette, and added the guard suite (dupre must equal live; hudson must cover every live selector). The same guards were extended to the foot.ini family in =c5e699b= when the per-host work touched it (set-theme overwrites foot.ini the same way). The symlink-instead-of-cp alternative wasn't needed — the test guard catches drift at =make test= time.
-** DONE [#B] Add =uv= to the install playbook :tooling:python:solo:
-CLOSED: [2026-06-10 Wed]
+Two things not to get wrong. An absent microphone is legitimate on a desktop, so "no input devices" must never be a fault the way =no-output-devices= is. And a monitor source is a legitimate default source (recording desktop audio), which is why =probe_semantic= passes =include_monitors=True= — inheriting the panel's display filter here would call a working setup broken.
+
+Specced 2026-07-10 after discussion with Craig, and the design grew past the original gap: [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]] (DRAFT, four decisions open). A doctor key per direction, a kernel-level capture probe below PipeWire, PTT-aware muting, and a direction-aware guard. The precedence question the build would have faced is gone: a doctor per direction means the user's press says which side they came to fix.
+
+Parent spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] (IMPLEMENTED). This is a v1 gap found after the fact, not a phase of it.
+
+** TODO [#D] Telega coredump recurrence tell :bug:maint:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
+:LAST_REVIEWED: 2026-07-09
:END:
-Shipped 2026-06-10 as archsetup commit =3e22b06= — =pacman_install uv= in the Python tooling block (uv 0.11.19 in extra). Exercised by the same-day hyprland VM run.
-Add =uv= (Astral's Python package + script runner) to archsetup so fresh machines pick it up automatically. Currently installed by hand on ratio + velox (=/usr/bin/uv= 0.11.15), not in the standard set — a fresh install would skip it, and project scripts using PEP 723 inline-script metadata (=#!/usr/bin/env -S uv run --script= shebangs) would fail with =env: uv: No such file or directory=. Source: handoff from health 2026-05-29 ([[file:assets/outbox/2026-05-29-1127-from-health-todo-a-add-uv-to-the-install-playbook.org][outbox copy]]).
+*** 2026-07-09 Thu @ 16:32:54 -0500 Audit reconcile: the fix is holding; re-graded C → D
+Verified rather than assumed: =~/.telega/telega-server.log= carries zero =tdat_plist_value= assertions, and the newest telega-server coredump is 2026-07-08 16:57 — before .emacs.d upgraded the elisp. Nothing has recurred.
-Health requested [#A] (load-bearing for the PEP 723 pattern they're promoting + the rulesets template-script proposal). Demoted to [#B] for archsetup: no current install is broken (uv is pre-installed everywhere it's needed), and the shape matches the existing [#B] tooling-codification tasks (eask, signal-cli) — load-bearing for other projects, manually installed today, codify so fresh installs pick it up.
+Re-graded =[#C]= → =[#D]= per the bug matrix. There is no defect to fix here; it is a watch item with a named tell, and the severity × frequency read is cosmetic (host coredump noise on a metric we own) × rare edge case → P4 → =[#D]=. It stays on the list only so the tell isn't lost.
+The maintenance console's coredump metric flagged telega-server on ratio (8 coredumps) and velox (18). Root cause was a version skew: the Dockerized =zevlg/telega-server:latest= is frozen at the 2026-06-05 build while the installed elisp lagged at 20260513, so the newer server's plist parser choked on the older elisp's output. .emacs.d fixed it by upgrading telega to 20260706 on both machines (docker kept, =docker pull= is a no-op against the frozen image). Host-coredump pollution should stop. If zevlg later pushes a =:latest= that outruns the installed elisp, the skew and the coredumps recur — the tell is a fresh =tdat_plist_value:500= assertion in =~/.telega/telega-server.log=. The durable escape is a host-native pinned TDLib build, at the cost of an AUR source build.
-- *Install via pacman* — =uv= is in extra (=pacman -S uv=). Cleanest path; auto-updates with the rest of the system. AUR =uv-bin= and Astral's official installer are alternatives but add a non-pacman path to maintain.
-- *Placement* — alongside the existing language-tooling block in =archsetup= (near =rustup=, =nvm=, or the Python set). Decide the exact section at implementation time.
-- *Verification* — post-install =which uv && uv --version=; PEP 723 end-to-end check per the health handoff (=/tmp/uv-test.py= shebang script with inline =requests= dep).
+* Archsetup Resolved
-Related: the new [#B] LLM task above may grow scripts that benefit from PEP 723 (e.g. =scripts/llm-smoke-test.sh= if Python-based). =uv= landing here removes that friction.
-** DONE [#A] Separate dotfiles from archsetup
-CLOSED: [2026-06-09 Tue]
+** DONE [#B] Maintenance console build :feature:
+CLOSED: [2026-07-08 Wed]
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-09
+:SPEC_ID: 9d9df833-c592-4aec-a7df-50d588e943ce
:END:
-*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Dotfile separation plan
-Approach: keep =dotfiles/= committed in this repo as the working default (Craig's machines and CI keep functioning untouched), but make the *source location* a config variable. The install script learns one new conf key — =DOTFILES_REPO= / =DOTFILES_BRANCH= — and when set, clones that repo into =~/.dotfiles= and stows from there instead of from =dotfiles/= inside archsetup. The Makefile gets a =DOTFILES= override env var so the same stow targets work whether dotfiles live in-repo or elsewhere. No submodule (adds fragility for a curl|bash installer); a separate published =archsetup-dotfiles= repo is optional follow-up, not a blocker.
+Build the maintenance console per [[file:docs/specs/2026-07-07-maintenance-console-spec.org][maintenance-console-spec.org]] (DOING; review incorporated 2026-07-07 — two rounds, all 13 decisions DONE, all 10 findings DONE, verdict Ready; Craig approved config paths + sysmon right-click re-homing). Design source of truth: [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]]; pixel reference: [[file:docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5 prototype]]. CLI-first =maint= package in dotfiles (archsetup-owns-dotfiles applies: edit, test, commit, push, note dotfiles inbox per phase); thresholds TOML + VM harness in archsetup. Each phase gates on green tests before its commit.
-1. Add conf keys to =archsetup.conf.example= under the "Git Repositories" block (after line 57): =DOTFILES_REPO= (commented, with note "leave unset to use the dotfiles bundled with archsetup"), =DOTFILES_BRANCH= (default =main=), and =DOTFILES_DIR= (target clone path, default =~/.dotfiles=). Document that a user's repo must have =common/= plus optionally =dwm/= and =hyprland/= subdirs that stow cleanly to =~=.
-2. In =archsetup= lines 114-122, map =DOTFILES_REPO=/=DOTFILES_BRANCH=/=DOTFILES_DIR= to lowercase vars. At lines 136-146, leave =dotfiles_dir="$archsetup_dir/dotfiles"= as the fallback default and add =dotfiles_repo="${dotfiles_repo:-}"=.
-3. In =user_customizations()= (lines 828-854): after the archsetup clone (line 838-841), branch — if =dotfiles_repo= is non-empty, =git clone --depth 1 --branch "$dotfiles_branch" "$dotfiles_repo" "$dotfiles_clone_dir"= (chown to user) and set =dotfiles_dir="$dotfiles_clone_dir"=; else keep =dotfiles_dir="$user_archsetup_dir/dotfiles"= (line 844). The stow calls at lines 847-854 stay as-is since they just =cd "$dotfiles_dir"=. Guard the hyprland stow (851) so it no-ops if the user repo has no =hyprland/= dir.
-4. The waybar-battery sed block (lines 856-865) and the =git restore= step (lines 896-902) both assume Craig's exact files — wrap each in an existence check (=[[ -f "$waybar_config" ]]=, and only =git -C "$dotfiles_dir" restore .= when =dotfiles_dir= is a git repo). Right now they'd error on a foreign dotfiles tree.
-5. =Makefile= line 5: change =DOTFILES := $(shell pwd)/dotfiles= to =DOTFILES ?= $(shell pwd)/dotfiles= so a user with external dotfiles runs =make stow hyprland DOTFILES=~/.dotfiles=. =reset= (line 123, =git checkout -- dotfiles/=) and =import= (writes to =$(DOTFILES)/$(DEST)=) already key off =$(DOTFILES)= except that one hardcoded path — fix line 123 to =git -C $(DOTFILES) checkout -- .=. Update the =help= text (lines 16-45) to mention the =DOTFILES== override.
-6. Migration: this is purely additive. Default behavior = today's behavior, so Craig's existing machines and =make test= VMs are unaffected. Craig can later extract =dotfiles/= to =git.cjennings.net/archsetup-dotfiles= and set =DOTFILES_REPO= in his own =archsetup.conf= — but that's his choice and a separate commit; the in-repo copy stays as the canonical default. Update =CLAUDE.md= "Project Structure" + "Makefile Targets" sections to document the override.
-7. Simple-UX summary to put in =archsetup.conf.example= and README: "Accept the defaults → you get the bundled dotfiles (DWM or Hyprland). Bring your own → set =DOTFILES_REPO= to your git URL; it gets cloned to =~/.dotfiles= and stowed automatically." Optional stretch: ship a tiny =dotfiles/minimal/= (bash/zsh/git/tmux only) and a conf option =DESKTOP_ENV=none= path that stows just that.
+*** 2026-07-07 Tue @ 20:11:23 -0500 Built Phase 1 — package skeleton, thresholds, contract (dotfiles 43a39ac)
+maint/ package + shim + tests/maint/ (48 tests, fake-tool harness); thresholds two-layer merge with disable flags + env overrides; capability probe (battery ⇔ type==Battery, verified against ratio's Mains+USB tree); =maint status --json= with disk-usage/pacman-cache/failed-units pilots. Seeded configs/maintenance-thresholds.toml in archsetup from the workflow's values and installed a copy to ~/.config/archsetup/ on ratio (installer wiring stays Phase 13). Full make test green (50 suites). Live on ratio: worst=warn from the real 10.75 GB cache; du grades on its printed total, not its exit code (root-owned partial-download dirs make it exit 1) — pinned in a test.
-Open questions for Craig:
-- Extract =dotfiles/= to a standalone repo now, or defer? (Plan above defers — keeps it as the in-repo default.)
-- Clone target: =~/.dotfiles= vs =~/code/dotfiles= vs let the user pick via =DOTFILES_DIR=?
-- Should =DOTFILES_REPO= unset mean "bundled dotfiles" (proposed) or "no dotfiles at all"?
-- Do you want the minimal/headless starter set (=dotfiles/minimal/=), or out of scope?
-- Keep =git restore=/=git checkout= conflict-recovery behavior only when dotfiles are a git checkout, and silently skip otherwise — acceptable?
+*** 2026-07-07 Tue @ 20:49:05 -0500 Built Phase 2 — storage & snapshot collectors (dotfiles 94e8371)
+All Phase 2 collectors TDD'd against the fake-tool harness (argv-matched cases added): btrfs unallocated/scrub-age/device-errors (SMART cross-check), per-disk SMART health + last self-test, fstrim posture (discard mount option = continuous trim, ratio's real setup), disk top-consumers on a new slow-local cache (=maint scan --slow=, ~/.local/state/maint), snapper counts split timeline·single·pre-post (split keys on cleanup, not type), full ZFS family. =priv.py= pulled forward from Phase 6 (read-only verbs =smart_json= + =btrfs_scrub_status=, validated args, MAINT_SUDO) so probes stay elevation-free. maint suites 48→118; make test green (51). Live read-only verified on BOTH hosts: ratio worst=warn (10.75 GB cache; scrub 0d, SMART ok), velox over tailscale shows the ZFS family live (ONLINE/autotrim-on/scrub 1d/231 snaps) with a genuine fstrim warn (timer off — Phase 6 Confirm remedy). TOML gained storage keys (btrfs_unalloc_warn_gb, smart_*, fstrim_stale_days, hog_*); installed copies refreshed on ratio + velox.
-*** 2026-05-13 Wed @ 13:07:54 -0500 AI Response: Open questions answered; full spec written
-All 5 questions resolved: extract now (Q1), clone to =~/.dotfiles= (Q2), no opt-out — =DOTFILES_REPO= always has a default (Q3), include =minimal/= as Tier B with TUI apps (Q4), error out if dotfiles dir isn't a git checkout (Q5). Full spec at [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]] — covers the =minimal/= tree, SSH/GPG availability, three-phase implementation plan (Phase 1: extract + populate new repo at =cjennings.net/archsetup-dotfiles.git=; Phase 2: wire archsetup + VM test; Phase 3: migrate machines + remove =dotfiles/=), commit map, and open observations. Implementation gated on spec review.
+*** 2026-07-07 Tue @ 21:30:59 -0500 Built Phase 3 — packages, security, systemd collectors (dotfiles 3eaab6e)
+All Phase 3 collectors TDD'd: orphans name+size with [curation.kept_orphans], pacnew safe-delete vs needs-merge, keyring freshness, reboot-required (uname -r vs modules dir), -Qkk on =maint scan --slow= with a noise split (unverifiable-as-user reads, mtime-only, [curation.qkk_known] paths tallied but never counted — an unfiltered live run sat permanently red at 68 "altered" files; classified, ratio shows 10 real findings); =maint scan --net= writes checkupdates/yay/arch-audit/fwupd caches (yay rc-1-silent = AUR-clean zero; failures never clobber good cache; malformed payloads degrade to unprobed), readers grade with age + stale bump; is-system-running with state-aware cause, failed-unit roster since/exit/journalctl hint, maintenance-timers meta-metric (capability-derived expected set, @-prefix instance match), taint letters decoded. Prereq done: arch-audit added to archsetup deps + installed both hosts. Review subagent (live-verified) found 2 Important (yay AUR-clean rc quirk, cache-shape envelope kill) + 2 minor — all fixed red-first; archsetup now enables btrfs-scrub@-.timer on btrfs installs (was ratio-only out-of-band, the meta-metric expects it). maint suites 118→169; full make test green. Live on ratio: 23-metric envelope, worst=warn from real findings (10.75GB cache, 8 orphans, mirrorlist.pacnew, 713 repo updates, 32 CVEs, 1 firmware).
-*** 2026-05-14 Thu Review docs/PLAN-dotfiles-separation.org
-CLOSED: [2026-05-14 Thu]
-Review the spec for accuracy, edge cases, and scope. Flag changes before implementation starts. See [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]].
+*** 2026-07-07 Tue @ 21:59:27 -0500 Built Phase 4 — logs + mem·pwr collectors (dotfiles 40c448b)
+All Phase 4 collectors TDD'd: journal error digest (identifier groups w/ first/last seen + journalctl hint, known-noise curation tallied but never counted, byte-array MESSAGE decoded), coredumps by binary over a TOML window, kernel/hw event classes (MCE/EDAC/IO/thermal/GPU — generic driver noise excluded), journald disk usage, app-log staleness mirroring the log-cleanup cron's filename-date rules; memory pressure + top-5 RSS, OOM kills (kill-verdict lines only — one event spans several matching lines), swap/zram, temps via sensors -j (k10temp Tctl / coretemp package / amdgpu edge), Intel thermal-throttle count, EPP read, battery capacity/health/charge-cap, unclean-shutdown rate from wtmp (last -x pairing). Throttle/EPP/battery self-gate on sysfs and drop out of the envelope. Review subagent (live-verified) found 1 Blocking — windowed-empty coredumpctl exits 1 (my earlier --since-now pin was wrong), which left the metric permanently unprobed on healthy hosts — plus OOM ~3× overcount and a malformed-curation-entry envelope crash; all fixed red-first, re-review Approve. maint suites 169→227; full make test green. Live read-only both hosts: ratio 34 metrics (telega-server crashing 3× in window — real find), velox 38 (genuine 75% battery-health warn; charge_control_end_threshold ABSENT on velox's cros_ec hardware → Phase 9's SET 80% lever must gate on the file). TOML +journal_disk_warn_gb/coredump_window_days, new [memory], [power] temp/health/unclean keys; installed both hosts.
-*** 2026-05-14 Thu @ 21:43:41 -0500 AI Response: Review resolved; spec locked for Phase 1
-Walked the spec's 5 open questions plus my 5 review concerns. Locked: URL =https://git.cjennings.net/dotfiles.git= (anonymous HTTPS read confirmed against existing repos at the same host), bare repo path =/var/git/dotfiles.git=, scope = Phase 1 only (~30 min). Added =environment.d/envvars.conf= (with rofi path stripped) and =systemd/user/emacs.service= to the =minimal/= tree; skipped =ncmpcpp= and =systemd/user/geoclue-agent.service=. Phase 2/3 constraints folded into the spec body for the executor: =DESKTOP_ENV=none= VM test required (was optional), clone uses =sudo -u "$username"= to avoid chown-after races, Phase 3 unstow/restow runs without an intermediate Hyprland reload, dotfiles repo can't go on GitHub until secrets cleanup ships, and Step 3.3 documents the post-install update flow. Latest spec at =docs/PLAN-dotfiles-separation.org= (=817d939=). End-of-day Phase 1 session reads from there and executes.
+*** 2026-07-07 Tue @ 22:35:28 -0500 Built Phase 5 — network posture + services collectors (dotfiles 2e92a9f)
+All Phase 5 collectors TDD'd after live-pinning every tool format on both hosts (velox presence sweep over tailscale: full tool set present): nm_state (passive — NM's own connectivity check covers DNS+HTTP, so status never generates traffic), chronyc offset vs [network].ntp_offset_warn_ms, tailscale Health + peer tally, fail2ban per-jail bans, listeners digest via priv ss (expected-entry curation tallied-not-counted, unexpected+wildcard=crit, v4/v6 dedup, multi-holder sockets match any name), firewall (priv ufw status; fallback requires unit-active AND ufw.conf ENABLED=yes; names "ufw down — N public binds exposed" from the digest), rsyncshot freshness (1MB log-tail parse; hourly graded only where the log shows hourly runs — velox is daily-only; daily ≥48h/missing = crit), docker df JSONL + stopped containers w/ [curation.expected_containers], libvirt roster (queried only when libvirtd already active — a read must not socket-activate), cron drift (root rsyncshot expectations gate on backup-source). priv.py +4 read-only verbs; thresholds.curation_entries() tolerant helper. Review subagent (live-verified): Request Changes — 1 Important (malformed fail2ban counts raise ValueError into the envelope) + ufw-disable unit-state false positive, docker unknown-unit sizes reading 0, unfloatable chrony offset; all fixed red-first, re-review Approve. maint suites 227→297; full make test green (54 suites, unpiped exit 0 — an earlier tail-piped run masked make's exit code, rerun properly). Live both hosts: ratio 45 metrics, velox 49. REAL FINDS: listeners crit on both (ratio postgres/8080 wildcard binds, velox syncthing *:8384/*:22000) — curation material for Craig; backups stale on both since 2026-07-06 (truenas down for repairs, Craig confirmed — clears when it returns). TOML +[network]/hourly-backup/cron keys, installed both hosts.
-*** 2026-05-22 Fri @ 13:41:08 -0500 Phase 1 executed — dotfiles repo live on cjennings.net
-Created the bare repo at =/var/git/dotfiles.git=, extracted =dotfiles/= from archsetup with =git filter-repo --subdirectory-filter= (229 commits, per-file history preserved), built the =minimal/= stow target per the spec, and pushed to =git@cjennings.net:dotfiles.git= (HEAD =68daeab=). Anonymous read at =https://git.cjennings.net/dotfiles.git= confirmed. Two spec corrections committed in archsetup (=7c26495=): push URL switched to SSH (HTTPS is read-only), and =minimal/.profile.d/= now ships 5 files including =claude.sh= (added on Craig's call, post-dated the spec lock). Phase 2 (wire archsetup config + VM test, ~2-3 hrs) and Phase 3 (migrate machines, remove =dotfiles/= from archsetup) remain.
+*** 2026-07-07 Tue @ 23:14:56 -0500 Built Phase 6 — remedy layer, priv verbs, guard, doctor CLI (dotfiles e171c08)
+The full remedy layer TDD'd in one pass (no 6a/6b split needed): remedies.py allowlisted table (~30 remedies — exact argv, tiers per the design tables as amended by the dated decisions, re_probe_ids, long flags, RECLAIM SPACE macro resolving steps against the envelope incl. per-config snapper expansion); priv.py +28 write verbs (closed builders, type-validated args, per-verb timeouts; =build()= returns the exact sudo-prefixed argv so dry-run and the GUI arm-press share one string); guard.py pure live-update matcher over new TOML =[updates] guard_patterns= (fnmatch, case-sensitive, pairwise-tested; cold pending-cache is surfaced as a note, never silent); curation.py user-layer writes (mark/unmark/clear + KEEP/MARK KNOWN/EXPECTED wrappers, disable-flags for shipped defaults, self-verifying TOML emitter); doctor.py clean|review + iter_fix streaming the event feed (begin/ok/fail/guard/note; failures red without aborting; re-probed truth after each action); cli =maint fix <id> [--dry-run] [--force]= + =maint doctor clean|review= wall renderer; status envelope now stamps =levers= (always-on for free controls: epp_set, charge_limit, update, topgrade). KILL four guards: pid:name victim, TERM-only verb, session-critical denylist ([curation.session_critical], new TOML table) at preview, comm revalidation + denylist re-check at fire. Live-pinned before coding: coredumpctl HAS NO clean verb (find -mtime +N -delete via priv), log-cleanup at ~/.local/bin/cron/, rsyncshot takes <mode> <keep>, ufw enable needs --force, EPP value enum. Review subagent (live-verified): Request Changes — 1 Blocking (json.dumps astral-char surrogate pairs = invalid TOML: one emoji in a MARK KNOWN example would brick thresholds.load() for the whole console → ensure_ascii=False + write self-verifies by re-parsing before replace) + 1 Important (KeyError from sparse installed TOML escapes the event stream → caught, degrades to a red wall entry) + 2 Minor (cold-cache guard note, comm-vs-denylist re-check); all fixed red-first (6 new tests), re-review Approve. maint suites 297→393 (96 in the new suite); full make test green ×2 (unpiped, exit 0). VERIFIED LIVE on ratio: doctor clean executed the Auto tier for real — paccache -ruk0 freed 223.91 MiB and the re-probe measured the cache drop exactly; the live-update guard trips on ratio's genuinely-pending mesa/vulkan-radeon set. TOML +guard_patterns/+session_critical/+timeline_quarterly installed both hosts.
-*** 2026-05-22 Fri @ 17:05 -0500 Phase 2 shipped — archsetup clones the dotfiles repo
-Wired archsetup to the external dotfiles repo: clones =DOTFILES_REPO= to =~/.dotfiles= and stows per =DESKTOP_ENV= (dwm/hyprland → common + that DE; none → minimal). Added =DOTFILES_REPO=/=BRANCH=/=DIR= config keys + validation; test harness serves the repo to the VM as =/tmp/dotfiles-test=. Commits =bab6901= (feat) + =68172c8= (test infra), pushed to origin/main. Spec-directed =sudo -u= clone hit a real bug — =useradd -m= skips the home-dir chown when =/home/$username= pre-exists (root-owned), so the user-clone failed with Permission denied; fixed by cloning as root + =chown -R= (mirrors the archsetup clone). git restore now runs for all DE paths (minimal ships skel-colliding .bashrc etc.).
+*** 2026-07-07 Tue @ 23:52:40 -0500 Built Phase 7 — GUI shell (dotfiles d28435e)
+The GTK4 window lands as the fourth panel sibling (net.cjennings.maintpanel, humble-object layering: GTK-free PanelModel presenter + pure viewmodel formatters + thin gui.py view). Read-only shell: faceplate (worst lamp, CVE/ATTN badges, MNT·01, close), inert doctor/update keys, updates strip (pending/CVE/AUR/firmware from the strip ids), eight-band selector (lamp + fixable/watch split from the envelope's levers; updates/security categories fold into PACKAGES), metric-row subpanel per band with per-id value formatting (bytes/percent/days/°C/ms/bool). Hydration off-thread; 3s live tier (build_live: cheap mem·pwr reads) + 30s full rebuild, both visibility-gated; MAINT_PANEL_FIXTURE=good|bad renders shipped envelope fixtures (reshaped from the E5 prototype by tests/maint/gen_fixtures.py, deterministic, real remedy ids) and freezes both tiers so the degraded board stays put on a healthy machine. Review subagent: Request Changes — 1 Blocking (build_live returned bare metrics, so the 3s merge stripped the levers the full envelope established and the band splits oscillated between tiers → attach_levers in build_live) + 2 Minor (synthetic error envelope missing worst read as healthy green → panel.error_envelope with worst=unprobed; smoke leaked the panel process on mid-run assertion errors → try/finally reap); all fixed red-first, re-review Approve (+1 hardening: merge into an error envelope is a no-op). maint suites 393→447 (54 in test_panel.py); full make test green (56 suites, unpiped exit 0). AT-SPI smoke PASS on live Hyprland (faceplate, badges, strip, all 8 tiles, band switch, clean close; window parked on a special workspace, never the active one). Screenshots off-workspace via headless output: bad fixture + live board vs the E5 reference. Live board on ratio honest: 32 CVE / 13 ATTN, NETWORK red from the real listeners crit, SERVICES fixable from the truenas-down backup staleness. GOTCHAS: windowrules need hyprctl reload before first launch (panel opens tiled otherwise); pkill -f "maint panel" self-matches the invoking shell — anchor with "maint panel$".
-*** 2026-05-22 Fri @ 18:10 -0500 Phase 3.1 + 3.3 done — this machine on ~/.dotfiles
-Migrated this workstation: cloned the dotfiles repo to =~/.dotfiles=, committed the gpg-agent SSH routing (=.zshenv= + =envvars.conf=) that was uncommitted in the live tree as =888a599= in the dotfiles repo, then =make unstow hyprland= + =make stow hyprland DOTFILES=~/.dotfiles=. Snag: unstowing while Hyprland ran made it write a stub hyprland.conf that blocked the restow — quit Hyprland, removed the stub, restowed clean. All symlinks now resolve into =~/.dotfiles=. CLAUDE.md updated with the external-repo docs + migration steps + the quit-Hyprland gotcha (=e1810ce=). Remaining: 3.2 (=git rm dotfiles/=) blocked until ratio + velox migrate the same way.
+*** 2026-07-08 Wed @ 00:39:23 -0500 Built Phase 8 — GUI levers: digests, armed keys, rotary band, live strip (dotfiles 388521e)
+The storage/snapshots/packages subpanels grew their evidence digests (disk hogs, per-drive SMART, device errors, snapper by-type, orphans KEEP/REMOVE, pacnew DELETE/MERGE, CVE advisories), packages got the E5 rotary band selector (ORPHANS/PACNEW/ADVISORIES), and the updates strip went live: state-tiered border, UPDATE/TOPGRADE behind the arm-to-override live-update guard, REBOOT offered when required or after an update lands. Every lever is arm-then-fire — first press shows the exact argv on the new act line (identical to =maint fix --dry-run=, the parity criterion), second press fires through =doctor.iter_fix= with the re-probe landing back on the act line; one key armed at a time, band/deck switch disarms; fixture boards always dry-run and never write curation, which is what lets the AT-SPI smoke exercise the full press-press path safely. Engine support: snapper probe emits =stale_singles= (DELETE STALE's candidates, newest kept), both scrub probes parse running-%, =doctor.pending_updates()= public for the arm-press guard read, fixtures regenerated with deterministic evidence. Review subagent (live-verified): Request Changes — 2 Important (refresh tiers raced a firing remedy: a 30s tick could probe the package DB mid-transaction and land stale data after the fire's own re-probe → tiers pause on =firing= + post-fire rebuild retries past an in-flight tick; digest builders KeyError'd on identity-less evidence rows replayed from cache files → skip-and-degrade, 8 new red-first tests) + 4 Minor (all taken: broadened strip-key except, scrub key offers first idle target instead of vanishing while any runs, busy-refused press no longer consumes the arm, fire-time guard refusal names the tripped set in GUI wording). Re-review: Approve. maint suites 447→516; full =make test= green (exit 0, unpiped); AT-SPI smoke PASS end-to-end on the bad fixture; headless-output screenshots vs E5; live board honest on ratio (real orphans w/ KEEP/REMOVE, 10.5 GB cache CLEAN, arm-press names the genuinely pending mesa set). Velox pulled to 388521e (no restow/TOML/windowrule changes). Scroll-position-on-arm check added to Manual testing.
-*** 2026-05-22 Fri @ 21:20 -0500 velox migrated to ~/.dotfiles (laptop overrides preserved)
-ratio is THIS machine (was "fractal" pre-reinstall) — migrated in 3.1. velox migrated over SSH (Craig quit its Hyprland): cloned ~/.dotfiles, stowed common+hyprland from it. velox carries deliberate laptop-local real-file overrides (foot.ini font 12, pypr config.toml laptop scratchpad sizing, waybar config battery module) that shadow stow — preserved them as local real files (backed up, restowed the rest, restored the overrides). All machines now on ~/.dotfiles.
+*** 2026-07-08 Wed @ 01:51:26 -0500 Built Phase 9 — GUI: systemd, logs, mem·pwr, network, services (dotfiles 9a2985f + a3da942)
+Shipped in two gated halves. 9a (9a2985f): failed-unit + timer rosters (armed RESTART/RESET per unit; ENABLE only on present-but-inactive timers), logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, row levers VACUUM/CLEAR/RUN. 9b (a3da942): CPU-mode segmented control (current + "default" excluded — priv's closed verb refuses "default"), top-memory guarded KILL (session-critical disabled pre-press, arm refusal behind it, fire-time comm revalidation behind both), battery SET 80% gated on the sysfs knob (velox: unsupported → withheld, verified live), listeners curation (probe emits pid + owning unit from the cgroup v2 line; STOP for system.slice services, KILL otherwise, "?" watch-only), firewall ENABLE / tailscale UP / chrony STEP row levers, services deck (containers MARK EXPECTED + START, docker df, cron roster + backups w/ worst-of chip lamp). RECLAIM SPACE deferred to Phase 10's wall by design — the act line can't honestly render a 7-step stream. Reviews: 9a Request Changes (curation done-callbacks bypassed the busy-retry rebuild → routed through _refresh_after_fire) → Approve; 9b Request Changes (DEFAULT EPP dead control + cgroup hybrid-hierarchy, "?"-curation, cron chip lamp, unprobed ✓ minors — all fixed red-first) → Approve. maint 516→612 tests; make test 58 suites exit 0; AT-SPI smoke PASS across all 8 bands; headless screenshots vs E5; live envelopes verified both hosts.
+**** 2026-07-08 Wed @ 01:13:48 -0500 Built 9a — systemd + logs subpanels (dotfiles 9a2985f)
+Failed-unit roster (armed RESTART/RESET per unit, since/exit context, journalctl hint) + expected-timer roster (ENABLE only on present-but-inactive timers); logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, and row levers VACUUM / CLEAR / RUN. Review subagent (live-verified): Request Changes — 1 Important (curation done-callbacks called refresh_full, which silently no-ops while a tick refresh holds busy → a mid-tick write left a pre-write envelope on the board up to 30s while the act line claimed success; routed all four incl. Phase 8's keep through _refresh_after_fire) + minors taken (single _mark_token definition, three test gaps closed, docstring truthfulness, stale arm wording cleared on band/deck switch — my own find during screenshot eyeballing). Re-review: Approve. maint 516→562 tests; make test 58 suites exit 0 unpiped; AT-SPI smoke PASS incl. the new rosters/deck/mark path; headless screenshots vs E5. velox pulled (no restow). Accepted gap: KNOWN NOISE subsec lists the live curation table on a fixture board — fixture-sourced entries list is later polish.
-*** 2026-06-02 Tue @ 12:16:54 -0500 Phase 3.2 done — removed in-repo dotfiles/ from archsetup
-git rm'd the in-repo =dotfiles/= tree (831 files) now that ratio + velox both stow from =~/.dotfiles=; the installer already clones DOTFILES_REPO so nothing read it at install time. Stripped the stow targets from archsetup's Makefile (kept VM-integration + the safe-rm-rf installer-helper suite). Updated CLAUDE.md (Project Structure, Makefile Targets, Dotfiles Repository, Script Counts, Theme/Key-Config path refs) and README.md (dotfile-management, theme, DE, unit-test sections) to point at =~/.dotfiles=; the README had been describing the pre-Phase-2 in-repo model. Commit b10cba5 on archsetup origin/main. velox + ratio local clones drop dotfiles/ on their next archsetup pull (ratio: see the "Pull Phase 3.2 changes onto ratio" task). 4 untracked calibre cache/annotation files that were never committed got moved aside to /tmp/archsetup-dotfiles-orphan-untracked-20260602 (disposable reading-position markers).
+*** 2026-07-08 Wed @ 02:36:54 -0500 Built Phase 10 — results wall + doctor keys (dotfiles cc2fb5d)
+Results wall shipped as the session action log: doctor events stream per-event (begin opens a running entry with the exact argv, ok/fail resolves in place, re-probe notes follow), 3.5-visible-row scroll cap with autoscroll, HIDE/COPY (COPY emits CLI-wall text), curation writes logged (fixture-suppressed as dim notes). CLEAN UP streams the auto tier with no arm press; REVIEW & FIX swaps the subpanel to the confirm-tier roster (item-less remedies get armed FIX keys, per-item ones point at their subpanel; review events carry item + worst severity). RECLAIM SPACE macro landed on the disk-usage row (deferred from 9b); macro arm line shows first step + remaining count; zero-step macros refuse by name. Review (live-verified): Request Changes — 1 Important: the roster's FIX for update/topgrade could never override the live-update guard (fire-time re-arm used the bare rid while the FIX key arms on "rid:", and _on_lever never passed force — an infinite arm→guard loop); fixed by threading the pressed token through _fire and sharing the strip's guard arming (_guard_arm_line + panel.guarded). Minors all taken: wall_abort resolves stranded running entries red when a stream dies, empty-macro IndexError → named RemedyError, test predicate tightened. Re-review: Approve, no residuals. maint 612→645; make test 59 suites exit 0 ×2; AT-SPI smoke extended (wall, HIDE/SHOW, CLEAN UP stream, review roster, 14-line RECLAIM stream) PASS; screenshots vs E5; LIVE verify on ratio: real CLEAN UP on the live board streamed "3 done" with real re-probes (coredumps warn 5, disk_usage ok 60). Velox pulled cc2fb5d.
-*** 2026-06-02 Tue @ 12:16:54 -0500 Migrated script unit-test suites + a Makefile into ~/.dotfiles
-Gave =~/.dotfiles= its own Makefile rather than repointing archsetup's =DOTFILES= default — the dotfiles repo now owns its stow tooling and tests, so it manages and validates standalone (relevant to the open-source release too). Authored =~/.dotfiles/Makefile= with the stow family (=stow/restow/reset/unstow/import= + check-de/check-dest + DE/DEST machinery) plus a =make test= target (mirrors archsetup's hyphenated-dir test-unit loop). Moved-Makefile fixups: =DOTFILES := $(shell pwd)= (trees at repo root), =reset='s revert scoped to =git checkout -- common $(DE)= (not the whole repo — caught in review), import header/path "dotfiles/$(DEST)" → "$(DEST)", =minimal= added to the import DEST filter only.
+*** 2026-07-08 Wed @ 03:24:44 -0500 Built Phase 11 — waybar glyph + timers (dotfiles 10033be)
+custom/maint replaced custom/sysmon: waybar-maint renders from the state file maint-scan.timer writes every 30 min; color tracks the worst diagnostic (lever-less) metric only, actionable findings never color the bar; missing/stale scan data dims to an honest unprobed state naming the timer. Battery hosts show live battery % + charging icon from sysfs on waybar's interval; low charge forces red. Left-click toggles the console (new maint-panel wrapper); right-click keeps the btop scratchpad. Bare =maint scan= became the glyph scan (envelope → state → RTMIN+12); maint-net-scan.timer drives =--net --slow= hourly. Retired same commit: waybar-sysmon, sysmon-cycle, both suites, #custom-sysmon CSS ×3 — plus unnamed collateral the grep caught: waybar-collapse's collapsed base set (maint glyph stays on battery hosts — it IS the battery display now) and a stale dotfiles todo. Review (live-verified): Approve with 4 Minor, all taken red-first — scope!=Device gate so a wireless mouse's battery can't masquerade as the machine's (shared capability.device_scope), unprobed diagnostics named in the tooltip instead of "all clear" under a dimmed glyph, comment truth, dead state field dropped; re-confirm clean (677 maint tests). Full make test exit 0; smoke PASS (first run's pipe masked a transient AT-SPI race — rerun unpiped clean). LIVE both hosts: ratio timer fired for real (glyph amber from genuine diagnostics: firmware update + journal errors; 11 actionable NOT coloring), velox pulled + one-time stow + battery 100% text w/ warning class. velox's user systemd manager found wedged (pre-existing) — timers enabled via manual symlinks + state seeded; Craig rebooting to clear.
-Moved 6 suites (=airplane-mode=, =layout-navigate=, =notify=, =tmux-util=, =waybar-airplane=, =waybar-touchpad=) into =~/.dotfiles/tests/=, dropping the =dotfiles/= =SCRIPT=-path prefix (=REPO_ROOT= is now the dotfiles root), and copied their fixtures (=layout-navigate/fake-hyprctl=, =tmux-util/fake-{fzf,kill,sleep,tmux}=). =waybar-netspeed='s suite was already there. =safe-rm-rf= stayed in archsetup (it tests the installer, not a dotfile). =make test= green: 7 suites, 124 tests. Committed 59b10c4 + pushed to the dotfiles repo. =minimal= is a standalone tree (stowed alone, not =common + minimal=), so a =make stow minimal= target needs its own branch — deferred as a small follow-up; the move kept stow/restow/reset/unstow behavior-identical to archsetup (dwm/hyprland).
+*** 2026-07-08 Wed @ 04:15:45 -0500 Built Phase 11b — prototype fidelity pass (dotfiles 3ee22a8)
+Rebuilt the GUI presentation to the E5 instrument-card idiom. Each band renders a 4-column card grid: big-number readouts (viewmodel.card_spec registry — one builder per bespoke metric id, count-card fallback for the rest), cairo progress bars w/ threshold ticks (cache scaled to 1.5× the warn line), radial gauges (scrub cadence, keyring/topgrade age, temperatures — sweep scaled to the metric's own threshold), status chips (PASSED/FAILING/INACTIVE/YES; firewall chips the state word, exposure detail moves to the caption), and the CPU-mode segmented control promoted from digest section to card (current mode lit, alternatives as joined armed keys — same DIGEST_ONLY epp_set arm-then-fire). Evidence digests stay full-width below the grid. Selector became a two-row 4-col grid of wide tiles w/ count chips (3! · 7✓, crit reddens) + severity left borders; subpanel header carries the attention/ok/fixable/watch summary right-aligned beside a gold band title. The scoped NVMe wear dial was skipped honestly — no standalone wear metric exists (wear rides SMART evidence rows). Presentation only: engine/remedies/doctor/curation and the lever contracts unchanged. New 42-test suite (card kinds, threshold scales, running-scrub override, unprobed degrade incl. tick drop, seg exclusions, chips/subhead/band_counts, both-fixture sweep). Review (live-verified): Approve w/ 1 Important (pre-existing: chrony "not running" string crashed format_value's ms branch — fixed w/ card guard) + 4 Minor all taken red-first (disk None guard, _pct negative clamp, unprobed tick, dead row_detail/split_label/.maint-val removed); re-review Approve. make test 61 suites exit 0 ×2 unpiped; AT-SPI smoke 92 checks PASS (new gauge/chip/subhead assertions — DrawingAreas carry accessible_role=IMG + names so the smoke can see them); all 8 bands + review roster screenshotted on both fixtures + the live board against the settled headless-Chrome prototype render. Velox pulled (no restow). Gotcha pinned: a card body's vexpand propagates up a Gtk.Grid — a one-card band ballooned until grid.set_vexpand(False).
-*** 2026-06-09 Tue @ 19:21:36 -0500 Pulled Phase 3.2 onto ratio + cleaned dangling links
-ratio's archsetup clone was already current with origin/main (Phase 3.2 pulled), but the migration had left stale symlinks pointing into the now-deleted =~/code/archsetup/dotfiles=: =~/.config/calibre= plus a manual =~/music/radio/= playlist farm (73 broken =.m3u= links) and one dead reference under =~/projects/home/reconciliation=. Re-pointed calibre into =~/.dotfiles/common/.config/calibre=. Deleted the 73 radio links — dead and redundant, since the same playlists already stow correctly to =~/music/*.m3u=, which is what mpd reads (=music_directory=/=playlist_directory= both =~/music=) — and removed the reconciliation link. ratio now has zero archsetup-dangling symlinks. (The ~3400 other dangling links in =~= are unrelated system/flatpak noise: ca-certificates, =/run/host=, =/bin=.)
-** DONE [#B] Cleaner per-machine override mechanism for the dotfiles repo
-CLOSED: [2026-06-11 Thu]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-11
-:END:
-Shipped 2026-06-11 as dotfiles =c5e699b= after spec review (all five questions decided — see the spec's Status table). Host tiers =ratio/= + =velox/= auto-included by every stow target; first tenants: hypr local.conf (velox HiDPI scale + XWayland toolkit env, replacing the Zoom per-app hack), pypr whole-file split, foot font via per-host host.ini include. waybar stays shared (velox's was stale, not divergent). velox restows cleanly for the first time and caught up on all pending dotfiles work. Drift guards extended to foot.ini; Makefile host logic unit-tested (15 suites green). Phase 5 (installer) filed as a follow-on below. velox needs a Hyprland restart for the env vars — see Manual testing.
+*** 2026-07-08 Wed @ 05:05:20 -0500 Ran the granted /refactor sweep over the maint package (dotfiles a178470..73e9d94)
+Four parallel read-only scans (complexity, duplication, dead-code, simplification) converged on the same hotspots; ten behavior-preserving commits applied and pushed. Headlines: six curation handlers folded into one _curation helper (~150 lines of repeated fixture/done plumbing, act/wall strings byte-identical), _on_lever/_on_strip_key merged into _press_lever, _on_fired split (_rearm_after_guard + _fire_summary), the "tool: not found / exit N" wording single-sited in cmd.why() across 20 probe sites, exact clones deduped (packages curation_entries, _parse_asctime, double VERSION), ten uniform digest sections folded into _evidence_digest, _card_body split per kind, listener key rule named, plus a small-simplifications batch. Deliberately skipped: format_value's flat chain (readable as-is), _journal_lines generalization (rc-semantics risk), test-panel-maint Makefile target (Phase 12 scope). Verification: suite parity exact pre/post (59 suites / 2490 tests, exit 0 unpiped), AT-SPI smoke PASS end-to-end, live ratio envelope 45 metrics zero unprobed, review subagent walked the full diff against 3ee22a8 and approved (no behavior change; accessible names untouched; its one latent-robustness minor pinned as an invariant comment). gui.py 1680→1605; net −103 lines. Velox pulled.
-velox keeps laptop-specific configs (foot font, pypr scratchpad sizing for 2256x1504, waybar battery) as local REAL files shadowing the stow symlinks. That's fragile: any =make restow= on velox re-conflicts (hit exactly this during the 2026-05-22 migration — stow aborts on the real files). The =~/.dotfiles= model needs a real per-machine override story (a =minimal/=-style per-host package, a documented local-override convention with =.stow-local-ignore=, or host-conditional includes) so overrides survive restows without manual backup/restore.
+*** 2026-07-08 Wed @ 05:46:02 -0500 Built Phase 12 — VM remedy scenarios + AT-SPI target (archsetup d6993d3, dotfiles 0636554)
+Scenario orchestration landed as =run-maint-scenarios.sh= over the existing vm-utils.sh snapshot primitives: nine break → =maint fix= → assert scenarios in three groups (logs, packages, systemd), batched per Decision 11 (one boot per group, maint-ready snapshot restored between groups, clean-install restored at the end). =run-maint-nspawn.sh= is the fast lane — the packages group against a cached pacstrap rootfs in seconds. Plan layer is pure (=--list=, no KVM) with a 19-test unit suite; =make test-maint= wires it in. Dotfiles side: panel smoke now runs GOOD + BAD fixture passes (healthy render, badge tallies from the fixture, no-REBOOT, leak check) and =make test-panel-maint= is enumerated. Verified: full VM run 9/9 green from pristine snapshot ×2 (test-results/maint-20260708-054029), nspawn 4/4, smoke 100 checks exit 0, unit suite green under both FS_PROFILE values. Review subagent: Request Changes (FS_PROFILE env leak in the unit suite, journal-vacuum assert vacuous both directions) — all six findings fixed red-first, re-review Approve. FS_PROFILE=zfs scenarios: filtered but unexercised — the zfs base image fails to build (see the zfs DKMS task).
-Spec: [[file:docs/PLAN-per-host-overrides.org][docs/PLAN-per-host-overrides.org]] — gated on review before implementation.
+*** 2026-07-08 Wed @ 06:18:58 -0500 Built Phase 13 — install wiring, workflow move, docs (archsetup bef7053 + 18c081f, dotfiles 9a3f0c7)
+Installer: install_maintenance_config in user_customizations installs the shipped TOML to ~/.config/archsetup/ (re-run refreshes it, ~/.config/maint/ user layer never touched) and enables maint-scan/maint-net-scan timers via wants-symlinks (hyprland-only — the units live in that stow tier; no session bus during install, syncthing idiom); orchestrator + dispatch-branch tests pin the wiring. Deps swept from the probes' argv: expac, lm_sensors, fwupd added (arch-audit was already in from Phase 3; the rest ride base/archinstall or existing sections). maint/README.md in dotfiles covers user (CLI, panel, glyph, config layers, guard, privilege) + developer (module map, env seams, four test layers, add-a-metric recipe). system-health-check.org moved home → archsetup docs/workflows/ and rewired: TOML-authoritative severity section, threshold lines cite TOML keys (TOML wins on disagreement), inventory paths → docs/homelab-inventory/, maint-CLI fast-path note; only the four #+HOSTNAME: host inventories moved (+ the truenas specs asset they link) — the personal gear records stay in home per the domain split; discoverability via .ai/project-workflows symlink + notes.org entry; handoff note in home's inbox (2026-07-08-0607) lists home-side removals and flags strix-soak-watch as a candidate to move later. Verified: fresh-shell =maint status= green from installed paths on ratio; make test-unit exit 0; dotfiles make test exit 0; shellcheck count unchanged; review subagent Approve (2 Minor doc nits, both taken).
-*** 2026-06-11 Wed @ 04:40:00 -0500 AI Response: Second restow-conflict instance on velox
-The roam-units rollout hit this again: =make restow hyprland= on velox aborted on three real files (foot.ini font size 12, the velox-tuned pypr config.toml, an older waybar config). The roam systemd units were linked manually to unblock; everything else velox should have picked up from recent dotfiles work (mic-toggle, quick-capture, airplane-mode hardening, ranger plugin) is still NOT stowed there — velox's tree stays partially stale until this mechanism ships. That raises this task's practical urgency: velox can no longer cleanly receive dotfiles changes at all.
+*** 2026-07-08 Wed @ 06:18:58 -0500 Flipped the spec to IMPLEMENTED
+Spec keyword DOING → IMPLEMENTED with a dated history line naming the closing commits (phases 1-13, dotfiles 43a39ac..9a3f0c7, archsetup d6993d3/bef7053/18c081f) and the Status mirror updated. Residuals tracked as their own tasks: manual-testing checklist, zfs base-image DKMS bug [#C], vNext [#D].
+** DONE [#B] Signal and known-noise counts disagree with the journal :bug:maint:solo:
+CLOSED: [2026-07-09 Thu]
+Not a counting bug — a semantics bug (dotfiles eded0c0). journal_errors grouped by identifier and summed lines, but displayed one sample message, and logs.py overwrote that sample on every iteration so it named the *last* error rather than the counted one. Live on ratio: systemd had 10 error lines this boot — 6 "Failed to start Emacs text editor." and 4 org-roam auto-sync — which is exactly the 10-vs-6. Now grouped by identifier + normalized message (pids, hex addresses, uuids, long integer runs blanked), so a row's count is that message's count, and each row's hint carries a =--grep= reproducing it (verified: 6 and 4). MARK KNOWN binds the volatile-free literal instead of the pid-bearing sample, so marking suppresses exactly what the row counted. The KNOWN NOISE headline counts matched lines while the deck lists patterns; the caption now names both units.
+Logs > SIGNAL reports 10 journal errors and the signal item claims 10, but the group holds one item and the open journal shows it 6 times. KNOWN NOISE reports 19 against 12 visible entries. The counts are the point of the band, so a wrong number undermines the whole diagnostic. Find where the tally diverges from the rendered set — likely counting matched journal lines rather than distinct grouped entries, and counting curated entries against a different window than the one displayed.
+** DONE [#B] Persist the CPU pill setting across reboot :feature:maint:
+CLOSED: [2026-07-09 Thu]
+The kernel resets energy_performance_preference to the driver default every boot. A successful epp_set now records the preference in the maint user config; maint-epp-restore.service replays it at login. No-op when nothing is remembered, the host lacks EPP, or the mode already matches. A hand-edited bogus value is refused by priv's closed verb. Installer enables the unit via the existing wants-symlink idiom. Verified live on ratio: unit enabled, ran clean, EPP untouched. (dotfiles a7f34bd, archsetup 430ef1a)
-*** 2026-05-26 Tue @ 10:21:08 -0500 AI Response: Spec written, gated on review
-Surfaced by a HiDPI scaling failure: a per-app =QT_SCALE_FACTOR=1.5= in the shared =Zoom.desktop= (meant for velox) made Zoom open enormous on ratio. Reverted that patch to plain =/usr/bin/zoom %U=; the durable fix is this mechanism. Proposed approach: a per-host stow tier (=ratio/=, =velox/=) stowed as =common + hyprland + $(uname -n)=, with the existing =conf.d/*.conf= glob as the first clean tenant — move =local.conf= out of the shared =hyprland/= tier into per-host tiers so each machine gets its own (HiDPI monitor scale + =env = QT_SCALE_FACTOR/GDK_SCALE= on velox, minimal on ratio). XWayland apps don't scale via the compositor (=force_zero_scaling=true=), so toolkit env vars set in =conf.d= are the right layer — kills per-app =.desktop= hacks. Open question in the spec: whole-file configs with no include directive (waybar JSON, pypr toml) need a separate strategy. Full design + 5 open questions for Craig in the spec.
-** DONE [#B] Verify Phase 2 in the VM (hyprland + none) — pending clean run :solo:
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Both runs clean on 2026-06-10. Hyprland (=make test=, results =20260610-151228=): 52 passed / 0 failed, and the same-day uv + signal-cli install additions were exercised in-run. None (results =20260610-165438=-ish, second attempt): 50 passed / 0 failed — the minimal/ tree stowed correctly. The first none attempt failed on a test-harness bug, not the installer: validation.sh hardcoded the common/ symlink target, fixed in =1754a94= (expected path now follows DESKTOP_ENV). The only attributed issue in both runs is the Proton-VPN-daemon-fails-in-VM known noise. The Phase 2 none/minimal path is now verified end-to-end.
-** DONE [#C] Investigate the 2026-05-11 VM-test warnings
-CLOSED: [2026-06-11 Thu]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-11
-:END:
-All five resolved. Four were environment-impossible checks converted to uncounted skips (=ced91c4= + the portal refinement =19015c7=) — socket, portal, mDNS-on-slirp, docker-pre-reboot — and all four skips verified firing in the 2026-06-11 12:56 run (52/0, 1 warning). The fifth (lingering) turned out to be a harness quoting bug, not a logind issue — fixed in =5b51900=, dated entry below. The next clean run should report zero warnings. The 18:36 =make test= run that filed this passed 52/0/5; the sub-entries below carry each investigation.
+The power/perf/balance pill sets EPP live, but the value reverts to its original setting after a reboot. Add a way to make a selection permanent. Needs a call on where persistence lives — a systemd unit, a udev rule, or a config file the maint package owns.
+** DONE [#B] Preview the pacman + AUR update queue before upgrade :feature:maint:solo:
+CLOSED: [2026-07-09 Thu]
+A QUEUE key on the updates strip streams the pending pacman + AUR set onto the results wall, each package with its version move; `maint queue` prints the same in a terminal. The cap reports how many it dropped rather than truncating silently, and a cold cache says it has no pending set instead of rendering "nothing pending". Verified live against ratio's 105 pending packages. (dotfiles 8837a47)
-*** 2026-06-10 Wed @ 19:07:54 -0500 Hyprland-socket warning converted to a skip
-Shipped in =ced91c4=: the check now passes when the socket exists, skips (uncounted) when no Hyprland process is running — the headless-VM state — and warns only in the genuinely odd case of a running compositor with no socket. Verified live: the skip fired in the 2026-06-10 19:06 run.
+Show which pacman and AUR packages are queued before UPDATE or topgrade runs, so the armed live-update guard isn't the first place the package set becomes visible.
+** DONE [#B] Audio PTT state desync across panel, waybar icon, and actual state :bug:audio:solo:
+CLOSED: [2026-07-09 Thu]
+Two state stores and two copies of the logic. The panel flipped an in-memory ptt_armed flag with its own enter/exit rules; the CLI toggle and the bar tag read a runtime state file. The file is now the only state and ptt.toggle_plan the only decision. The panel adopts the persisted state on every refresh, routes its toggle through the shared plan, and persists before signalling waybar (writing after would race the bar into re-rendering the pre-toggle class). Tests drive both entry points against one state across armed/disarmed x muted/live. (dotfiles 9524a44)
-*** 2026-06-10 Wed @ 19:07:54 -0500 Portal-query warning converted to a skip
-Shipped in =ced91c4= + a follow-up refinement: the first condition (portal process absent) didn't fire because a socket-activated =xdg-desktop-portal= exists even headless; the precondition is really a running compositor, so the skip now keys on =pgrep -x Hyprland= like the socket check. The conf-file checks (the part install controls) still pass/fail normally. The dconf-write angle stays tracked under =[#B] Fix install errors=.
+The audio panel's push-to-talk, the waybar PTT icon, and the real PTT state disagree. All three must agree at all times. Cover every state transition with tests, not just the happy path — this is the second PTT bug after the pre-talk mute restore landed 2026-07-05 (dotfiles 1443b9e).
+** DONE [#B] Publish the homelab inventory into the agent knowledge base :chore:docs:
+CLOSED: [2026-07-09 Thu]
+The four host inventories, the TrueNAS specs asset, and the ratio USB/xHCI record now live as roam nodes in =~/org/roam/hardware/=, linked from the "Homelab Hardware Inventory" index node. =docs/homelab-inventory/= is gone; roam is canonical.
-*** 2026-06-10 Wed @ 19:07:54 -0500 mDNS-ping warning converted to a slirp-aware skip
-Shipped in =ced91c4=: when the VM is on QEMU slirp (a =10.0.2.x= address), the =.local= ping is skipped — multicast genuinely can't pass there — and the =is-enabled= check stands alone. On real networking the full ping test still runs and still warns on failure. Verified live: the skip fired in the 2026-06-10 19:06 run.
+Carried rather than pointed, per home's argument, which beat mine. Pointing keeps one canonical copy but breaks the write path: a third project that discovers a durable hardware fact would have to write into archsetup's repo to record it, which the cross-project rules forbid, so the fact would sit in an inbox instead of on the device's page. Moving answers the canonical-copy worry without that cost.
-*** 2026-06-11 Thu @ 12:58:19 -0500 Lingering warning was a harness quoting bug — fixed, hypothesis disproven
-make test-keep forensics on the kept VM: the linger file existed (created mid-install), =loginctl show-user cjennings -p Linger= said yes, logind active with zero errors — lingering was correctly enabled all along, so the logind-degraded hypothesis was wrong and archsetup's =enable-linger= calls were always fine. The actual bug was in the check itself (=validation.sh=): it captured =ls path && echo yes=, so a present file produced "path\nyes", which never string-equals "yes" — the check warned on every run regardless of state. Fixed in =5b51900= with =test -e=; the corrected expression verified returning "yes" against the live VM. With this, all five 2026-05-11 warnings are resolved and a clean run should report zero.
+The =* Automated Capabilities= drawer moved with the pages. =system-health-check.org= now resolves a host's node by its =#+HOSTNAME:= keyword under =${ROAM_DIR:-$HOME/org/roam}/hardware/= — never by filename, since the timestamp prefix isn't stable. Verified: all four hosts resolve, an unknown host degrades to "NO INVENTORY FILE", and ratio's capability drawer still parses. A host without the roam clone (mybitch, truenas) takes the same no-inventory path it always did.
-*** 2026-06-10 Wed @ 19:07:54 -0500 Docker warning converted to a pre-reboot skip
-Shipped in =ced91c4=: =docker info= success still passes; enabled-but-inactive (the deliberate enable-not-now install state, validated pre-reboot) now skips; active-but-unresponsive still warns — that's the real failure case. Verified live: the skip fired in the 2026-06-10 19:06 run. The enable vs enable-now question for archsetup itself was left as-is (the daemon's weight makes enable-on-boot defensible).
+The strix kernel watch stayed at =docs/workflows/strix-soak-watch.org= — it's a workflow, not an inventory page.
+** DONE [#C] Optional ticking sound for the pomodoro :feature:timer:
+CLOSED: [2026-07-09 Thu]
+WTIMER_TICK=1 plays a quiet tick each second through a pomodoro's work phase; off by default, silent through rest (a tick there would undo the break), while paused, and while awaiting a manual advance. Rides the bar's existing once-a-second `wtimer render` call. Playback is fire-and-forget so it can never stall or break the bar. Sound is an 18ms 1.4kHz sine at a tenth of full scale (notify/tick.ogg); WTIMER_TICK_SOUND overrides. Craig still has to judge whether the tick sounds right. (dotfiles 886dafd)
-Note: the run also logged two log-diff meta-warnings — "Found 4 new error lines after archsetup" and "New failed services detected (before: 1, after: 2)". Those correspond to the post-install systemd noise (pam_systemd / logind / Proton VPN) already captured under =[#B] Fix install errors= above; not duplicated here.
-** DONE [#B] Enable TLP power management for laptops :quick:
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Done live on velox 2026-06-10: tlp 1.10.1 installed, =/etc/tlp.d/01-custom.conf= written (EPP balance_performance/power + platform-profile per power source; 80% charge cap present but commented off), service enabled and active, systemd-rfkill masked per TLP docs. Verified: tlp-stat runs, EPP reads balance_performance on AC. Codified in archsetup commit =adb39f2= as a battery-gated block.
-** DONE [#B] Remove unnecessary linux-firmware packages (velox only) :quick:
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Done live on velox 2026-06-10. Hardware re-verified first (i915 graphics, ath9k wifi), then removed the meta + 12 subpackages (the task's 9 plus liquidio/mellanox/nfp/qlogic from the finer 2026 split), keeping intel + atheros + whence. The meta needed =-Rdd= — mkinitcpio-firmware declares a dep on it; the dangling dep is cosmetic. Initramfs rebuilt clean (warnings only for absent hardware), wifi stayed connected. Codified in archsetup commit =adb39f2= as a DMI-gated Framework-Intel block. Full confidence needs the next reboot — see Manual testing below.
-** DONE [#B] Identify and replace packages no longer in repos
-CLOSED: [2026-06-11 Thu]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-11
-:END:
-Shipped 2026-06-11 as =1f89523=: =scripts/audit-packages.sh= (unit-tested) makes the check repeatable, and its first run over 420 packages found four casualties, all fixed in the same commit — libva-mesa-driver (folded into mesa), nvidia-dkms → nvidia-open-dkms, swww → awww (set-theme's stale swww call fixed in dotfiles =4ea35a1=), libappindicator-gtk3 → libayatana-appindicator. Re-run anytime: =scripts/audit-packages.sh=.
-** DONE [#B] Verify package origin for all packages
-CLOSED: [2026-06-11 Thu]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-11
-:END:
-Covered by the same auditor (=1f89523=): it flags movers in both directions. Current state: zero official packages wrongly routed through aur_install-only territory; 15 aur_install entries have graduated to official repos (duf, flameshot, gist, inxi, nsxiv, nvm, papirus-icon-theme, ptyxis, qt5ct, qt6ct, ttf-lato, ueberzug, warpinator, xcolor, xdg-desktop-portal-hyprland). Left as-is deliberately — yay resolves repo packages fine — but switching them to pacman_install is a clean :quick: cleanup whenever wanted; the auditor lists them on every run.
-** DONE [#B] Automate script usage tracking :solo:
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Shipped 2026-06-10 as dotfiles commit =e5044b8=: =script-usage= in =common/.local/bin/= (10 unit tests). Reads zsh extended + bash history, reports last-used date per ~/.local/bin script, =--unused= lists the never-seen set. First run on ratio: 109 scripts, 98 unseen by the current (short) history window.
-** DONE [#B] Automate dotfile validation :solo:
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Shipped 2026-06-10 as dotfiles commit =2054da4=: =dotfiles-validate= in =common/.local/bin/= (11 unit tests). Extracts commands from hypr exec/bind-exec lines, waybar exec/on-click/on-scroll values, and systemd user-unit Exec* lines, then verifies each resolves. First run found 4 real orphans — see the follow-up task below.
+Timers module. Off by default; needs a sound choice and a volume decision.
+** DONE [#C] Refresh indicator for the top-like items :feature:maint:
+CLOSED: [2026-07-09 Thu]
+A hairline under the top-memory section drains toward the next live refresh, so a slow board reads differently from a frozen one. Withheld on a fixture board, which never live-ticks — a countdown that never counts is worse than none. The fraction is a pure viewmodel function; only the cairo stroke lives in the GUI. (dotfiles 5d384d9)
-*** 2026-06-11 Thu @ 00:44:41 -0500 All 4 orphaned references fixed; validator fully clean
-Both emacs.service units repointed to /usr/bin/emacs (dotfiles =cd15d9b=), and per Craig's call the tor-browser and virtualbox keybinds were dropped rather than backed by installs (dotfiles =e4cb4c2= — Ctrl+Alt+W and Super+V now free). dotfiles-validate: 102 references checked, all resolve.
-** DONE [#B] Document evaluation criteria and trade-offs
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Written 2026-06-10: [[file:docs/2026-06-10-tool-evaluation-criteria.org][docs/2026-06-10-tool-evaluation-criteria.org]] — four gating criteria (Wayland-native, actively maintained with live verification, automation-compatible, stowable config), five weighting criteria, the process, and the trade-offs accepted in the 2026-06-10 evaluation round.
-** DONE [#B] Add org-capture popup frame on keyboard shortcut
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Shipped 2026-06-10, all five spec steps: =quick-capture= script (dotfiles =08ae188=, 3 unit tests, notify-on-failure when the daemon's down), Hyprland window rules in current 0.53+ syntax (float, 900x500, center, stay_focused on title org-capture) + Super+Shift+N bind (same commit), and the auto-close hook in =org-capture-config.el= (.emacs.d =1a25fada=, .elc recompiled, loaded live). Verified end-to-end on ratio: popup opens floating/centered with the template menu (screenshot), frame auto-deletes on org-capture-kill — finalize uses the same hook. Existing capture templates untouched.
-** DONE [#C] Create Chrome theme with dupre colors :quick:solo:
-CLOSED: [2026-06-10 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
-:END:
-Shipped 2026-06-10 as archsetup commit =4736058=: unpacked-extension theme at =assets/color-themes/dupre/chrome-theme/= (manifest.json + README with the color mapping and load-unpacked install steps). Visual check is yours — see Manual testing below.
-** DONE [#C] Install Zoxide integration into Ranger :quick:
-CLOSED: [2026-06-10 Wed]
+Show when the process/memory rows are about to refresh, so a stale-looking board is distinguishable from a frozen one. Shape is a design call — a countdown, a pulse, or a progress hairline.
+** DONE [#C] CPU pill should not reposition on selection :bug:maint:quick:solo:
+CLOSED: [2026-07-09 Thu]
+epp_rows dropped the current preference from the key list, so the remaining segments slid sideways on every selection. Every preference now renders in a fixed order with the current one lit and unpressable in place, at full contrast rather than GTK's disabled dimming. The separate lit label is gone — the active segment is the lit one. Three separate tests encoded the old contract (unit, AT-SPI smoke, phase11b card); all moved with the behavior. The smoke caught a real gap the unit tests missed: the EPP row renders through the card path, which ignored the disabled flag. (dotfiles 5d384d9)
+
+The perf/balance/power pill moves position depending on which option is selected. The segmented control should hold a fixed layout and mark the active segment in place.
+** DONE [#C] Session identifier needs more width :bug:waybar:quick:
+CLOSED: [2026-07-09 Thu]
+Not waybar — the tmux status line. status-left-length was never set, so tmux clipped the drawn status-left to its default of 10 columns: "[aiv-archsetup] " came out "[aiv-archs", losing the closing bracket and the separating space, which is why the window list ran into it. Set to 40. Applied to the live tmux server too. (dotfiles dc3c27f)
+
+The session section truncates; give it more characters, and keep the window sections that follow from running into it. Screenshot: [[file:/home/cjennings/pictures/screenshots/2026-07-07_145236.png][2026-07-07_145236.png]]
+** DONE [#C] minimal tier zsh login PATH gap :bug:dotfiles:quick:solo:
+CLOSED: [2026-07-09 Thu]
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
+:LAST_REVIEWED: 2026-07-09
:END:
-Shipped 2026-06-10 as dotfiles commit =220dde6=: jchook/ranger-zoxide vendored (with MIT license) into both =common/= and =minimal/= ranger plugin dirs — :z and :zi commands wherever ranger runs. Python syntax verified; live verification is yours (see Manual testing) and needs a machine with ranger installed — note neither Wayland box has it, and the same-day file-manager evaluation recommends yazi over porting ranger forward.
-** DONE [#D] Add retry logic to git_install function :quick:
-CLOSED: [2026-06-10 Wed]
-Already shipped before this review — commit =798b86f= gave git_install the same MAX_INSTALL_RETRIES loop as pacman/aur, with a clean-slate build dir per attempt. The task predates the fix; closing as done.
-** DONE [#B] Org-capture popup frame split (quick-task Super+Shift+N)
-CLOSED: [2026-06-13 Sat] SCHEDULED: <2026-06-12 Fri>
+Fixed (dotfiles =c6a7878=). =minimal/.zprofile= added, mirroring =common/='s PATH-only prepend and its rationale comment.
+
+Tested the invariant rather than the file, because the bug *was* a per-file fix missing a tier: any stow tier shipping a =.zshrc= must ship a =.zprofile= that prepends =~/.local/bin=. Red first, and it named =minimal=. Verified behaviorally as the task asked — stowed the tier into a throwaway HOME and read a real =zsh -lc 'echo $PATH'=, which now leads with =~/.local/bin=. Full =make test= green.
+
+The 2026-07-08 fix gave common/ a .zprofile (zsh logins never read .profile, so ~/.local/bin was missing from TTY/ssh PATH — dotfiles 39cef40). The minimal/ tier has the same gap: it carries .zshrc and .profile but no .zprofile, so headless installs (DESKTOP_ENV=none stows minimal/ instead of common/) still get zsh logins without ~/.local/bin. Add a matching minimal/.zprofile (PATH-only prepend, same rationale comment). Verify: fresh minimal stow, zsh -lc 'echo $PATH' shows ~/.local/bin first. The installer itself is clean — it writes no shell profiles; the stow layer owns this.
+** DONE [#B] Doctor button for the audio panel :feature:audio:
+CLOSED: [2026-07-10 Fri]
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-12
+:LAST_REVIEWED: 2026-07-09
:END:
-Resolved: .emacs.d fixed it config-side (single-window display + cj/quick-capture command); archsetup pointed the popup script at cj/quick-capture (8cc1be7). Verified end-to-end on ratio.
-The quick-capture popup opens split in two windows — a top sliver of the daemon's last-visited buffer plus the =*Org Select*= menu below — so the two stacked modelines read like tmux status bars. Root cause: =org-mks= displays the template menu via =org-switch-to-buffer-other-window=, splitting the fresh popup frame instead of taking it over.
+What happens when pipewire or pulseaudio is broken — would we know? Spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] — *READY*, all eight decisions closed 2026-07-09, Phase 0 shipped. Buildable: Phase 1 diag, Phase 2 classify, Phase 3 repair + doctor, Phase 4 the DOCTOR key and wall. Grounded in a live survey of ratio's stack: there is no PulseAudio (pipewire + wireplumber + pipewire-pulse, all user-scope, so no sudo anywhere), =pactl= hangs against a server that accepts and never answers, and the panel can't diagnose itself because =pactl= is the layer most likely to be down. Mirrors the net panel's diag/classify/repair/doctor split, not the maint console's priv-verb table.
-Coordinating with the .emacs.d project: handoff sent 2026-06-12 18:59 requesting a config-side fix scoped to frames named =org-capture= (handoff note + screenshot evidence delivered to .emacs.d's inbox, since processed and removed). Waiting on its reply in this project's inbox; then verify the popup end-to-end on ratio (Super+Shift+N → single-window menu → single-window capture buffer). Fallback if .emacs.d declines: carry the fix in the dotfiles =quick-capture= script's =-e= elisp.
+Re-graded =[#C]= → =[#B]= by the 2026-07-09 audit: the spec is written, Phase 0 shipped, and six decisions are all that stand between this and a build. Active backlog, not parking lot.
-Related finding, no change needed: whole-desktop screenshot already exists at CTRL+Super+S (=screenshot fullscreen=, grim fires before the fuzzel menu so popups survive). Possible follow-up decision: rebind Super+Shift+S (currently layout-switch to scrolling) if Craig wants fullscreen capture there.
+*** 2026-07-09 Thu @ 14:12:00 -0500 Fixed: waybar's audio module died against a half-wedged server
+My first read of this was wrong. =pactl.run()= already carries =timeout=8= and raises =PactlTimeout=, so the panel degrades and does not freeze. Correcting the record rather than leaving the claim standing.
-*** 2026-06-12 Fri @ 20:21:00 -0500 Incorporated .emacs.d's fix and verified end-to-end
-.emacs.d replied same evening with two notes (now in [[file:assets/outbox/2026-06-12-1947-from-.emacs.d-org-capture-popup-singlewindow-reply.org][outbox]] and [[file:assets/outbox/2026-06-12-2006-from-.emacs.d-quick-capture-script-change.org][outbox]]): the single-window fix landed config-side (frame-scoped =display-buffer-alist=, 7 ERT tests, live in the daemon), plus a new =cj/quick-capture= command (Task/Bug/Event only, global-inbox targets, frame closes on every exit path, 12 ERT tests). Our side: test-first one-line change in the dotfiles =quick-capture= script — =(org-capture)= → =(cj/quick-capture)= — suite 15/15 green, live immediately via stow. Verified on ratio with sendshortcut-driven popups + grim: menu single-window with the 3-template subset, capture buffer single-window targeting =CAPTURE-inbox.org=, no orphan frames, nothing leaked into the inbox file. Verification reply + screenshot evidence sent back to .emacs.d. Remaining: commit the dotfiles change (Craig's gate) and the Super+Shift+S rebind decision.
-** DONE [#C] Silent notifications for the mic-mute toggle :quick:solo:
-CLOSED: [2026-06-11 Thu]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-11
-:END:
-Shipped 2026-06-11 as dotfiles =a4ae4a4=, minutes after filing: =--silent= on all four of mic-toggle's notify calls (Muted/Live/unknown/fail), tests assert the flag on every path (5/5, full suite 15 suites green), and a live round-trip on ratio confirmed the toggle works with the toast and without the chime. velox picks it up on next pull.
-** DONE [#B] Create package inventory system
-CLOSED: [2026-06-14 Sun]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-13
-:END:
-Satisfied by =scripts/package-inventory= (the same script that closes "Automate the inventory comparison" above). It lists archsetup's declared packages, lists the live system's packages, and prints the diff in both directions. Design note: it compares explicit-vs-explicit (=pacman -Qqe= against declared =pacman_install=/=aur_install=), which is the meaningful comparison — the original "including dependencies" framing was superseded, since transitive deps are pulled automatically and listing full closures would only add noise.
-*** 2026-06-14 Sun @ 22:13:48 -0500 Listed archsetup's declared packages — package-inventory extraction (pacman_install/aur_install + for-loop lists)
-*** 2026-06-14 Sun @ 22:13:48 -0500 Listed live-system packages — package-inventory via pacman -Qqe / -Qq / -Qqen / -Qqem
-*** 2026-06-14 Sun @ 22:13:48 -0500 Generated archsetup-vs-system diff — package-inventory, both directions, AUR/official split
-** DONE [#B] Automate the inventory comparison :test:solo:
-CLOSED: [2026-06-14 Sun]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-13
-:END:
-Make package diff a runnable script instead of manual process
-
-Resolved 2026-06-14: the runnable script already existed — =scripts/package-inventory= (built 2026-02-06) extracts archsetup's declared packages and diffs them against the live system (=--summary= / =--archsetup-only= / =--system-only= / full report). This pass added the missing coverage: 7 characterization tests in =tests/package-inventory/= pinning the extraction and both diff directions behind injectable =PKGINV_ARCHSETUP= / =PKGINV_PACMAN= seams, plus a =make package-diff= target for discoverability. Full unit suite green (26 tests, 3 suites).
-** DONE [#B] Idle-inhibitor keybind + synced waybar indicator :hyprland:waybar:
-CLOSED: [2026-06-23 Tue]
-Shipped 2026-06-23 as dotfiles commit =a004201=. Super+I toggles the hypridle daemon (kill = inhibit, relaunch = restore). The built-in waybar =idle_inhibitor= module was replaced with a =custom/idle= module backed by a =waybar-idle= script, so the keybind, the bar click, and the icon share one source of truth (whether hypridle is running) and stay in sync. Icons 󰒳 inhibited / 󰒲 active, with a 5s poll safety net. Freed =Super+I= by pruning the unused ai-term pyprland scratchpad from both host configs. TDD'd (=waybar-idle= + =hypridle-toggle= suites); dupre/hudson theme CSS updated. From a home-project handoff 2026-06-23; Craig confirmed it works live.
-** DONE [#B] Verify package signature verification not bypassed by --noconfirm
-CLOSED: [2026-06-23 Tue]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Audited 2026-06-23. =--noconfirm= does not bypass signature verification — it only auto-answers interactive prompts. Signature checking is governed by =SigLevel= in =/etc/pacman.conf=, which archsetup leaves at the Arch default (=Required DatabaseOptional=): its only pacman.conf edits are ParallelDownloads, Color, and enabling multilib (=archsetup:913,917=), none of which touch =SigLevel=. So every repo package stays signature-verified regardless of =--noconfirm=.
-
-One real integrity bypass exists, and it is not =--noconfirm=: =archsetup:2403= runs =yay -S --noconfirm --mflags --skipinteg python-lyricsgenius=, where =--skipinteg= skips makepkg's checksum and PGP-signature checks for that one AUR package (a documented workaround for an expired-signature issue upstream). It's scoped to a single package, not global. Tracked for periodic re-check below.
-** DONE [#C] Harden sshd in the installer (explicit prohibit-password) :solo:
-CLOSED: [2026-06-24 Wed]
-Done 2026-06-24: the openssh block (=archsetup:1271-1277=) now writes =/etc/ssh/sshd_config.d/10-hardening.conf= with =PermitRootLogin prohibit-password= and reloads sshd, right after starting the service. =PasswordAuthentication= left untouched so ssh-copy-id to the user still works. Makes the posture intentional rather than dependent on the upstream default. Velox and ratio (which carried an explicit =PermitRootLogin yes= at =sshd_config:33= from earlier provisioning) were already fixed by hand 2026-06-23. Verified =bash -n= + =shellcheck -S error= clean; full drop-in-on-fresh-install confirmation is VM-deferred (the unit harness covers helpers, not inline install steps).
-** DONE [#C] Build security dashboard command :solo:
-CLOSED: [2026-06-23 Tue]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-05-21
-:END:
-Shipped 2026-06-23 as dotfiles commit =1b9b205=: =security-status= (=common/.local/bin=, on PATH). Read-only dashboard showing disk encryption (LUKS *and* ZFS native — the fleet runs ZFS, so a LUKS-only check would have falsely reported "no encryption"), ufw state, externally-reachable ports (counts all listening, lists only the non-loopback exposures), and running/failed service counts. Command lookups are env-overridable; parsing covered by unit tests against canned output. New file, so ratio needs =git pull && make stow hyprland= to link it.
-** DONE [#C] paru vs yay — evaluated, staying with yay
-CLOSED: [2026-06-10 Wed]
-Research done 2026-06-10: [[file:docs/2026-06-10-paru-vs-yay-evaluation.org][docs/2026-06-10-paru-vs-yay-evaluation.org]]. The maintenance picture inverted since the task was filed: yay released v12.6.0 on 2026-06-07 with active triage, while paru has had no release in 11 months, no commit in 5, and a stable that fails to build against current libalpm (issue #1468 open 6 months). For an installer that bootstraps the AUR helper unattended, paru is the riskier choice on every axis that matters. No decision needed — the evidence closes this one; revisit only if paru's maintenance resumes.
-** DONE [#C] Teach archsetup to stow the host tier :solo:
-CLOSED: [2026-06-23 Tue]
+The real bug, found by driving it: =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard. A server that answered the lists and then stopped answering raised =PactlTimeout= straight out of a function whose whole contract is to degrade to =ok: False=. =waybar-audio= calls it with nothing catching it, so the bar's audio module died instead of dimming; the GTK panel survived only because its background worker catches everything.
+
+A server that hangs on the *first* call never exposes this — =list_sinks= raises inside the guard and masks the unguarded reads behind it — so the obvious regression test passes against broken code. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults". Verified end to end: =waybar-audio= now emits =class: degraded= / "Audio graph unavailable" and exits 0. dotfiles =4d42eb3=; full =make test= green.
+
+*** 2026-07-10 Fri @ 06:41:00 -0500 Shipped: the audio doctor, phases 1 through 4
+=audio diag=, =audio doctor=, =audio doctor --fix=, and the panel's DOCTOR section header with DIAGNOSE / FIX keys streaming into a results wall (copy + close). Spec is IMPLEMENTED. dotfiles =01a1d80=, =76857e3=, =7223c51=, =1550ac8=, =4320255=, =bd33440=.
+
+Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5, and found two bugs no fake could surface: =pactl get-default-sink= prints the literal =@DEFAULT_SINK@= placeholder when no default is set, and remedy 1 re-probed before WirePlumber had re-created the devices, calling a repaired stack broken. Both fixed.
+
+Left open, not a v1 gap: mpv played silently while the doctor read healthy. The stack was genuinely fine, and per-application stream routing is an explicit spec Non-Goal. Worth a task only if it recurs.
+
+Second sighting, 2026-07-10: Chrome stopped recognizing the microphone while the stack was healthy (Shure MV7+ default, unmuted, 82%, PTT off). Restarting Chrome fixed it; nothing on the machine was touched. Both sightings share a shape the doctor cannot see: the graph is fine and one client cannot use it. A third sighting turns this into a design question, namely whether the doctor should say "the stack is fine, the fault is in the application" rather than a bare healthy.
+** DONE [#C] Net panel: Enterprise error never dismisses :bug:dotfiles:network:
+CLOSED: [2026-07-12 Sun]
+Fixed in dotfiles =a157bed=. Root cause: error toasts are sticky by design (so background refreshes can't wipe an unread error), but the enterprise join hint's flow posts no follow-up status and row clicks post none either, so nothing ever replaced it. Fix: a window-wide capture-phase click gesture dismisses a sticky toast on the user's next interaction; policy in =viewmodel.toast_action_plan= (unit-tested), timed toasts and background clears unchanged. Panel smoke run confirms launch/doctor/close with the gesture installed. Pointer-level dismiss is a manual-testing child (AT-SPI can't drive pointer gestures). Repro screenshot: =~/pictures/screenshots/2026-07-10_195911.png=.
+** DONE [#C] Net diagnostics leak connection names + SSIDs into copyable report and --json :bug:dotfiles:network:solo:
+CLOSED: [2026-07-12 Sun]
+Resolved in dotfiles =df1543a=: the =redact_ssid= toggle now scrubs saved profile names, active SSIDs, envelope-carried names, and =.nmconnection= keyfile basenames from the copyable report and the diag/doctor =--json= envelopes (one systemic pass in =redact.py=; MAC/IP scrub applies to those envelopes too). On-screen output and functional envelopes (status/list) unchanged. 15 new tests; live-verified on ratio (toggle on removes the active connection name from =diagnose --json=, default unchanged).
+The net doctor's copyable report (=report.py=, =scrub_text=) scrubs only MAC/IP, and =net diag/doctor --json= (=cli.py=) dumps the raw dict with no redaction. SSID redaction lives only in the event log (=redact_event=, gated on =redact_ssid=, default off). So a connection name (usually the SSID) appears in the clear in the link-step evidence and in every =--json= consumer — the copyable report is exactly the text a user pastes into a bug report. Secrets (PSK/password/token/portal URL) are already stripped, so this is names, not credentials: Minor severity, graded on severity alone per the privacy carve-out.
+
+Split out of the 2026-07-11 net-doctor-expansion spec review: that spec's new rival-manager/keyfile-perms verdicts keep parity with this pre-existing behavior rather than half-solve it. Fix shape: extend redaction to cover the connection name + keyfile basename across the copyable report and =--json= (one systemic pass, not per-verdict), with a redaction test. Engine-wide, so it wants one coherent change rather than being bolted onto the expansion work.
+** DONE [#B] Bt doctor expansion v1 — build the READY spec :feature:dotfiles:bluetooth:
+CLOSED: [2026-07-12 Sun]
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-11
+:SPEC_ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7
:END:
-Already implemented in =user_customizations()= (=archsetup:1049-1058=): after stowing =common= + the DE package, it derives =host_tier="$(cat /etc/hostname 2>/dev/null || uname -n)"= and stows that package when =$dotfiles_dir/$host_tier= exists, else prints "no host tier for '<host>' — skipping". The =/etc/hostname=-first detection is the right call for install time (=uname -n= still reports the ISO's name until reboot), and it's the same skip-if-absent semantics as the dotfiles Makefile. Verified by reading the installer 2026-06-23; no code change needed.
+Build the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion]] (IMPLEMENTED). Adds a dmesg firmware-hint probe (names the missing blob on a no-adapter fault) and a boot-enablement probe (catches an adapter disabled at boot) to the shipped bt doctor (=~/.dotfiles/bluetooth/=). Archsetup owns the dotfiles work end to end. All phases shipped and fake-verified (d19fdca, f05a9b4, d7d859f); the live reboot-persistence half is on the manual-testing checklist.
+*** 2026-07-11 Sat @ 03:06:32 -0500 Built the two read-only probes
+New module =bluetooth/src/bt/probes.py= plus =doctor.py= wiring, on dotfiles main (=d19fdca=, pushed). Two reads the diagnose chain never did: =firmware_hint()= scans the current boot's kernel log for per-vendor firmware-load failures (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom .hcd, Qualcomm QCA), returning the named blob via a bounded =cmd.run(journalctl -k)= that reuses the =doctor.py:84= precedent; =boot_enablement()= reads three boot-persistence signals (bluez AutoEnable from main.conf [Policy], =systemctl is-enabled bluetooth=, whether TLP lists bluetooth in =DEVICES_TO_DISABLE_ON_STARTUP=). =diagnose()= gates the firmware read to the no-adapter branch and the boot read to the soft-blocked/powered-off branch, so a healthy run reads neither; the raw signals ride a new =probes= key that =doctor()= carries into =--json=. Detection only: no verdict, formatter, or repair change (that's Phase 1). Every read degrades to None on an unreadable tool/file, so a probe that can't see never invents a fault. AutoEnable absent/unset reads None, not false, matching bluez's compiled default of true, so only an explicit =AutoEnable=false= is the fault. New env roots for tests (=BT_MAIN_CONF=, =BT_TLP_CONF=, defaulting to absent temp paths in the Sandbox base so no test reads real /etc); =fake-journalctl= branches on =-k=, =fake-systemctl= answers =is-enabled bluetooth=. 125 bt tests, full =make test= green; =/review-code= approved (no Critical/Important; one Minor noting the firmware read also covers the btctl-unavailable branch, harmless). Inbox note sent to dotfiles.
+*** 2026-07-11 Sat @ 03:16:59 -0500 Built the firmware-hint Guide verdict
+On dotfiles main (=f05a9b4=, pushed). The no-adapter step now names the blob: a new =_no_adapter_step= consults =probes.firmware_hint()= on a genuine no-adapter fault and, on a per-vendor signature match, sets =evidence= to "no Bluetooth adapter found — <Vendor> firmware <blob> failed to load" and =next_action= to "update linux-firmware and reboot", tagged with a new =code="no-adapter-firmware"= so a =--json= consumer can branch without string-matching. A clean log keeps the generic hardware/driver verdict. A Guide, not a repair: the step carries no =repair= action, so =--fix= never touches it, and it needs no privilege model (so Phase 1 lands independently of the shared cross-panel model). A missing =bluetoothctl= (=BtctlError=) short-circuits before the firmware read, so the verdict fires only on a real no-adapter fault, not a broken install — this also tightened Phase 0 (which read the log on both None branches) to the genuine no-adapter case. =_mk= gained a uniform =code= key (default None) added to every diagnose step, mirroring the existing =repair= key; no test asserts an exact step key-set, verified. =format_doctor_human= already renders =evidence=/=next_action=, so no formatter change. 133 bt tests (+8), full =make test= green; =/review-code= clean. Inbox note sent to dotfiles.
+*** 2026-07-11 Sat @ 06:48:21 -0500 Built the persistent-power verdict + fix
+On dotfiles main (=d7d859f=, pushed). =_powered_step= consumes the bt Phase 0 boot-enablement probe: AutoEnable explicitly false, service disabled at boot, or TLP listing bluetooth → =powered-off-persistent= (code + evidence naming the cause) carrying the =persist-power= repair; absent/unset config reads as auto-enable-on (bluez default) → the plain =power-on=, so healthy machines can't false-positive. The =persist-power= repair fixes only the causes set (AutoEnable=true, =systemctl enable bluetooth=, drop bluetooth from the TLP list), powers the adapter on now, and verifies each cause cleared. Config edits are pure idempotent text transforms in a new =bootconf= module (comments preserved), staged and installed via a fixed-destination =cp= verb so the write needs root but the mutation is unit-testable. =priv.py= gained three narrow verbs (=enable-bluetooth=, =write-main-conf=, =write-tlp-conf=). The repair is Privileged and resolves through =panelkit= before running: can't-elevate degrades to the guide. The bt shim gained =panelkit= on its path. 287 bt tests + 65 make-test suites green, review-code Approve, voice. Inbox note sent. Live half (real reboot persistence) is the VM/manual checklist.
+*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the bt spec to IMPLEMENTED and logged the vNext items
+Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) logged as the "Bt doctor vNext" task. The live half (real reboot persistence) remains on the manual-testing checklist — findings there come back as bugs.
+** DONE [#B] One copy + close control pair on every output wall :feature:dotfiles:solo:
+CLOSED: [2026-07-12 Sun]
+Resolved in dotfiles =dccd744=: every wall carries the o-copy/o-clear overlay pair. bluetooth gained the copy key (transcript via the new =viewmodel.step_copy_line=, CLI-shaped); maint traded its header COPY key for the overlay pair, kept HIDE, and its ✕ clears the session log via =PanelModel.wall_clear=; the four hand-rolled wl-copy calls collapsed into =panelkit.clipboard.copy_text= (PANELKIT_WLCOPY test seam), moving maint off the GTK clipboard so its copies survive the panel closing too. 15 new tests (8 clipboard, 4 step_copy_line, 3 wall_clear); full suite 66 green; all four panel smokes run live off-workspace — maint + audio fully pass, net + bt fail only the pre-existing state-word startup race.
+
+Converge all four instrument-console output walls on the net panel's well controls: a copy glyph and a ✕ close, as an overlay at the top right, hidden until content lands. Craig's call, 2026-07-10, while reviewing the audio doctor's wall: "network panels as standard across all others, make it consistent."
+
+Where they stand today, no two alike. net has copy + ✕ (=net/src/net/gui.py=, the =o-copy= / =o-clear= overlay). bluetooth has ✕ but no copy. maint has COPY + HIDE as keys in a header row, and no ✕. audio just gained copy + ✕ (dotfiles =bd33440=).
+
+Work: give bluetooth a copy key, give maint the overlay pair, and lift the four hand-rolled =_copy_output= implementations into one shared helper rather than a fifth copy. maint keeps HIDE alongside close, because its wall is a persistent session action log you collapse and keep, where net's, bt's, and audio's are per-run results you dismiss.
+
+Copy text is per panel but one rule: it pastes as that panel's CLI prints, so the paste lines up with the terminal a user is already looking at. audio's =viewmodel.wall_copy_text()= is the worked example.
+
+Consistent with the 2026-07-07 scope note on the sibling task above: net's compact glyph overlay is what standardizes, not maint's wide COPY-key header row.
diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.20.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.20.png
new file mode 100644
index 0000000..3133f55
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.20.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.48.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.48.png
new file mode 100644
index 0000000..7b3af65
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.16.48.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.02.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.02.png
new file mode 100644
index 0000000..c7b8eda
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.02.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.21.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.21.png
new file mode 100644
index 0000000..4c84856
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.17.21.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.18.11.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.18.11.png
new file mode 100644
index 0000000..cdf701e
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-18.18.11.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.09.00.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.09.00.png
new file mode 100644
index 0000000..9d55384
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.09.00.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.12.27.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.12.27.png
new file mode 100644
index 0000000..cd17101
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.12.27.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.21.23.png b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.21.23.png
new file mode 100644
index 0000000..d188fa9
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-CleanShot-2021-10-01-at-19.21.23.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2021-cdm-cleanshot-1024x600.png b/working/retro-stereo-widgets/references/2021-cdm-cleanshot-1024x600.png
new file mode 100644
index 0000000..6a967c9
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-cleanshot-1024x600.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2021-cdm-cleanshot-2.png b/working/retro-stereo-widgets/references/2021-cdm-cleanshot-2.png
new file mode 100644
index 0000000..48e38fb
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2021-cdm-cleanshot-2.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-battery-selector.png b/working/retro-stereo-widgets/references/2026-07-12-battery-selector.png
new file mode 100644
index 0000000..400c82a
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-battery-selector.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-chrome-slot-fader.png b/working/retro-stereo-widgets/references/2026-07-12-chrome-slot-fader.png
new file mode 100644
index 0000000..2251bf7
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-chrome-slot-fader.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-comfort-meter.png b/working/retro-stereo-widgets/references/2026-07-12-comfort-meter.png
new file mode 100644
index 0000000..3e2de30
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-comfort-meter.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-daydate-discs.png b/working/retro-stereo-widgets/references/2026-07-12-daydate-discs.png
new file mode 100644
index 0000000..f5402fd
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-daydate-discs.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-electric-telegraph-indicator.png b/working/retro-stereo-widgets/references/2026-07-12-electric-telegraph-indicator.png
new file mode 100644
index 0000000..498f3c5
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-electric-telegraph-indicator.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-eq-drum-rollers.png b/working/retro-stereo-widgets/references/2026-07-12-eq-drum-rollers.png
new file mode 100644
index 0000000..38034b5
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-eq-drum-rollers.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-four-way-rocker.png b/working/retro-stereo-widgets/references/2026-07-12-four-way-rocker.png
new file mode 100644
index 0000000..fc71218
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-four-way-rocker.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-four-way-toggle.png b/working/retro-stereo-widgets/references/2026-07-12-four-way-toggle.png
new file mode 100644
index 0000000..9e86013
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-four-way-toggle.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-guarded-toggles.png b/working/retro-stereo-widgets/references/2026-07-12-guarded-toggles.png
new file mode 100644
index 0000000..fe76c9b
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-guarded-toggles.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-industrial-keypad.png b/working/retro-stereo-widgets/references/2026-07-12-industrial-keypad.png
new file mode 100644
index 0000000..9a291fe
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-industrial-keypad.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-ki57-right-panel.jpg b/working/retro-stereo-widgets/references/2026-07-12-ki57-right-panel.jpg
new file mode 100644
index 0000000..493231b
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-ki57-right-panel.jpg
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-led-dot-matrix.png b/working/retro-stereo-widgets/references/2026-07-12-led-dot-matrix.png
new file mode 100644
index 0000000..e76adc0
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-led-dot-matrix.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-lexicon-224.png b/working/retro-stereo-widgets/references/2026-07-12-lexicon-224.png
new file mode 100644
index 0000000..6d0b117
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-lexicon-224.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-mechanical-timer-dial.png b/working/retro-stereo-widgets/references/2026-07-12-mechanical-timer-dial.png
new file mode 100644
index 0000000..aed748d
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-mechanical-timer-dial.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png b/working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png
new file mode 100644
index 0000000..cb0174f
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpg b/working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpg
new file mode 100644
index 0000000..c1c0b14
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-pill-toggle-render.jpg
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-radar-ppi.png b/working/retro-stereo-widgets/references/2026-07-12-radar-ppi.png
new file mode 100644
index 0000000..7d22420
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-radar-ppi.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-round-crt-scope.png b/working/retro-stereo-widgets/references/2026-07-12-round-crt-scope.png
new file mode 100644
index 0000000..4ea6ea1
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-round-crt-scope.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-thumb-slide-attenuators.png b/working/retro-stereo-widgets/references/2026-07-12-thumb-slide-attenuators.png
new file mode 100644
index 0000000..8cf2ea3
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-thumb-slide-attenuators.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-timing-marker-dial.png b/working/retro-stereo-widgets/references/2026-07-12-timing-marker-dial.png
new file mode 100644
index 0000000..a2a35f0
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-timing-marker-dial.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-twin-trem-pedal.png b/working/retro-stereo-widgets/references/2026-07-12-twin-trem-pedal.png
new file mode 100644
index 0000000..88c4829
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-twin-trem-pedal.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-variation-slide.png b/working/retro-stereo-widgets/references/2026-07-12-variation-slide.png
new file mode 100644
index 0000000..e3cc5cd
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-variation-slide.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-12-waves-alpha.png b/working/retro-stereo-widgets/references/2026-07-12-waves-alpha.png
new file mode 100644
index 0000000..3adccf8
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-12-waves-alpha.png
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg b/working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg
new file mode 100644
index 0000000..c97a9a9
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg
Binary files differ
diff --git a/working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp b/working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp
new file mode 100644
index 0000000..0a9705a
--- /dev/null
+++ b/working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp
Binary files differ