aboutsummaryrefslogtreecommitdiff
path: root/docs/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/specs')
-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
9 files changed, 2122 insertions, 0 deletions
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..275bb2c
--- /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/2026-07-03-panel-widget-gallery-prototype.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..bff187b
--- /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).
+- Home workflow to move: =~/projects/home/.ai/project-workflows/system-health-check.org= + =homelab-inventory/*.org=.
+
+* 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.