diff options
55 files changed, 7983 insertions, 2083 deletions
@@ -26,3 +26,7 @@ __pycache__/ # Dated local backups of secret files (kept on disk, never tracked) *.bak-[0-9]* + +# Claude Code per-project tooling (swept 2026-07-01) +/CLAUDE.md +/AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b6b3f04..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,139 +0,0 @@ -# Archsetup Project Context - -## Overview -Arch Linux installation and configuration scripts with dotfiles managed via GNU Stow. Supports both X11 (DWM) and Wayland (Hyprland) setups. - -## Project Structure -``` -archsetup Main installation script -archsetup.conf.example Configuration template -init Bootstrap script for live ISO -Makefile VM integration testing + project dependencies -todo.org Active task list - -scripts/ Utility scripts (wireguard, post-install, wipedisk, etc.) -assets/ Reference documents and archived files - color-themes/ Theme palettes, generation scripts - outbox/ Processed inbox items - wireguard-config/ Proton VPN configs -.ai/ Claude tooling: session docs, workflows, protocols (gitignored) -inbox/ Unprocessed documents for session review -reference-repos/ External dotfile references -scripts/testing/ VM-based integration test framework -test-results/ Test run output -vm-images/ VM disk images for testing -``` - -## Makefile Targets -``` -make deps # Install VM-testing dependencies -make test-unit # Run fast unit tests for installer helpers (no VM) -make test # Run full VM integration test (creates base VM if needed) -make test-keep # Run test and keep the VM up for manual poking -make test-vm-base # Create the base VM only -``` -Dotfile stow operations live in the dotfiles repo's own Makefile, not here. -Run them from `~/.dotfiles`: -``` -cd ~/.dotfiles && make stow hyprland # common + hyprland + host tier -cd ~/.dotfiles && make restow hyprland # refresh links after git pull -cd ~/.dotfiles && make reset hyprland # fix conflicts, keep repo version -cd ~/.dotfiles && make unstow hyprland # remove symlinks -cd ~/.dotfiles && make import common # fzf select → import to common/ -cd ~/.dotfiles && make test # run the dotfile-script unit suites -``` -Per-host overrides live in a stow tier named after the machine (`ratio/`, -`velox/`), auto-included by every stow target when the directory exists -(`HOST=<name>` overrides detection; hostname binary is absent — `uname -n`). -The tier holds only files no shared package owns: hypr `conf.d/local.conf` -(velox: HiDPI scale + XWayland toolkit env), `pypr/config.toml`, and foot's -`host.ini` (the shared foot.ini sets no font — it's per-host via include). - -## Dotfiles Repository - -Dotfiles live in their own repo at `git.cjennings.net/dotfiles.git` -(read: `https://git.cjennings.net/dotfiles.git`, push: `git@cjennings.net:dotfiles.git`). -A fresh `archsetup` install clones it to `~/.dotfiles` and stows per `DESKTOP_ENV`: - -``` -dwm → common/ + dwm/ -hyprland → common/ + hyprland/ -none → minimal/ (standalone headless tree, not common/) -``` - -Config keys (in `archsetup.conf`, all optional — defaults shown): -``` -DOTFILES_REPO=https://git.cjennings.net/dotfiles.git -DOTFILES_BRANCH=main -DOTFILES_DIR= # defaults to the target user's ~/.dotfiles -``` - -Pull dotfile changes after install: -``` -cd ~/.dotfiles && git pull -cd ~/.dotfiles && make restow hyprland -``` - -Setting up a new machine by hand (outside the installer): -``` -git clone https://git.cjennings.net/dotfiles.git ~/.dotfiles -cd ~/.dotfiles && make stow hyprland -``` -**Quit Hyprland before an unstow/restow.** Unstowing while Hyprland runs makes -it write a stub `hyprland.conf` into the gap, which then blocks the restow. Do -it from a TTY, or with the session stopped. - -## Dotfile Script Counts -The scripts live in the dotfiles repo (`~/.dotfiles`), not here: -``` -~/.dotfiles/common/.local/bin/ → 33 universal scripts -~/.dotfiles/dwm/.local/bin/ → 8 X11/DWM scripts -~/.dotfiles/hyprland/.local/bin/ → 7 Hyprland scripts -``` - -## Shell Configuration -``` -~/.profile → Environment variables only (POSIX compatible) -~/.bash_profile → Sources .profile + .bashrc for login shells -~/.bashrc → Bash-specific settings, sources .bashrc.d/ -~/.zshrc → Zsh-specific settings, sources .zshrc.d/ -~/.profile.d/ → Modular env vars (display, framework, auto-tmux, hyprland) -~/.bashrc.d/ → Modular bash configs (aliases, fzf, git, media, utilities, emacs) -~/.zshrc.d/ → Modular zsh configs (same + arch-downgrade) -``` - -## Theme System -Two themes available: dupre (default) and hudson. Switched via `set-theme` script. - -Full palette reference: `assets/color-themes/dupre/dupre-palette.org` - -- **GTK**: Adwaita-dark -- **Qt**: Adwaita-Dark (via adwaita-qt5/qt6, QT_STYLE_OVERRIDE) -- **Icons**: Papirus-Dark -- **Cursors**: Bibata-Modern-Ice (size 24) -- **Font**: BerkeleyMono Nerd Font (13pt terminal, 14px waybar) -- **Theme configs**: `~/.dotfiles/hyprland/.config/themes/dupre/` - -## Key Configuration Files -- `~/.dotfiles/hyprland/.config/hypr/hyprland.conf` - Main Hyprland config -- `~/.dotfiles/hyprland/.config/themes/dupre/` - Dupre theme files (foot, fuzzel, waybar, dunst, hyprlock, Xresources) -- `~/.dotfiles/common/.profile.d/` - Shell environment scripts -- `~/.dotfiles/common/.config/qt5ct/qt5ct.conf` - Qt5 theming -- `~/.dotfiles/common/.config/qt6ct/qt6ct.conf` - Qt6 theming - -## Notes -- Desktop file overrides go in `~/.dotfiles/hyprland/.local/share/applications/` -- MPD is configured but mpv handles audio file associations -- Firewall is ufw (configured in `archsetup`, default-deny incoming, explicit allow list). Tailscale traffic **does** traverse ufw on ratio — a probe from a tailnet IP is still blocked unless a rule covers the port. Don't assume tailnet-only services bypass the firewall; they need an explicit ufw rule like any other. -- Never assume which machine this is — always run `uname -n` to find the hostname (the `hostname` binary is absent, so `uname -n` is the source of truth; `uname -r` is the kernel release, not the host). The fleet is **ratio** (workstation) and **velox** (laptop), both Hyprland (Wayland). archsetup still supports dwm/X11, but no current machine uses it. -- Remote repository on cjennings.net -- .ai/ is gitignored; living project context is in .ai/notes.org - -## Codified Insights - -- **VM tests run committed code, not your working tree.** `scripts/testing/run-test.sh` provisions the VM from `git bundle create <file> HEAD` (it simulates `git clone`), so an uncommitted edit to `archsetup` or the pytest suite silently runs the old code. Commit (even a throwaway WIP commit) before `make test FS_PROFILE=...`, or the change isn't exercised. (`gotcha` — 2026-06-25) -- **Iterate the pytest sweep against a kept VM, not a reinstall.** `make test-keep FS_PROFILE=...` leaves the VM up after the install and writes `testinfra_ssh_config` + `root_key` into `test-results/<timestamp>/`. Point pytest at that ssh-config to re-run only the Testinfra checks in ~30s instead of a ~70-minute full reinstall. Use it when iterating test assertions, not installer logic. (`pattern` — 2026-06-25) -- **VM UEFI NVRAM lives outside the qcow2 and must be per-profile.** OVMF boot entries live in the `OVMF_VARS` file, not the disk image, so reverting the `clean-install` snapshot does NOT restore them. The base ESPs have no removable `\EFI\BOOT\BOOTX64.EFI` fallback, so a base boots only via its NVRAM entry — lose or overwrite it and the VM dies in UEFI ("No bootable option") and SSH-times-out before archsetup runs. `init_vm_paths` now suffixes `OVMF_VARS` per `FS_PROFILE` (matching the disk image); never share one NVRAM file across btrfs/zfs. (`gotcha` — 2026-06-28) -- **sed/awk function extraction breaks on column-0 `}` inside heredocs.** The `tests/` harness and any `/^name() {/,/^}/` extraction stop at the first line beginning with `}` — but a JSON heredoc body (e.g. the docker `daemon.json` in `developer_workstation`) has a column-0 `}` that is NOT the function's close. Find the real closing brace before slicing, or the bounds are silently wrong. (`gotcha` — 2026-06-28) -- **AUR builds need ≥8 GiB VM RAM.** `makepkg` runs `-j$VM_CPUS`, and parallel `cc1plus` (~700 MB each on heavy C++ AUR packages) OOM-killed under the old 4 GiB `VM_RAM` default; the install still passed (yay retries) but the kills showed as attributed issues. Default is now 8192 MB. If you raise `VM_CPUS`, raise `VM_RAM` with it. (`threshold` — 2026-06-28) -- **Guard live upgrades with a PreTransaction hook, not a wrapper.** `hypr-live-update-guard` is a pacman `PreTransaction` hook (`AbortOnFail` + `NeedsTargets`) so it fires no matter how the upgrade launches (pacman, yay, topgrade) and aborts before any package is swapped — the safe point, since nothing is replaced yet. A shell wrapper around `pacman` would be bypassed by the other front-ends. (`pattern` — 2026-06-28) diff --git a/archive/task-archive.org b/archive/task-archive.org new file mode 100644 index 0000000..9339f02 --- /dev/null +++ b/archive/task-archive.org @@ -0,0 +1,654 @@ +#+TITLE: Task Archive +#+FILETAGS: :archive: + +* Resolved (archived) +** DONE [#B] Full install logs should contain timestamps +CLOSED: [2026-02-23 Sun] +Log filename includes timestamp via =date +'%Y-%m-%d-%H-%M-%S'=. +Functions =error_warn()=, =error_fatal()=, and =display()= all output timestamps via =date +'%T'=. +** DONE [#B] Validate DESKTOP_ENV default behavior +CLOSED: [2026-02-23 Sun] +Defaults to =hyprland= silently via =desktop_env="${desktop_env:-hyprland}"=. +Overridable via config file or =DESKTOP_ENV= environment variable. +** DONE [#B] Test archsetup username/password prompts +CLOSED: [2026-02-23 Sun] +Username prompt with regex validation (lines 320-332) and password prompt +with confirmation (lines 339-353) implemented and functional. +** DONE [#B] Verify SSH to remote server works +CLOSED: [2026-02-02 Mon] +Tested 2026-02-02: ssh cjennings.net returns "connected" successfully. +SSH key authentication working, no password required. +** DONE [#B] Verify Proton Mail Bridge retrieves email +CLOSED: [2026-02-02 Mon] +Verified 2026-02-02: Proton Mail Bridge running, ports 1143 (IMAP) and 1025 (SMTP) +listening on 127.0.0.1. mu4e email retrieval functional. +** DONE [#B] Fix unsafe sed patterns with user input +CLOSED: [2026-02-23 Sun] +Quoted =$username= in sed replacement, switched locale and wireless-regdom sed +patterns to pipe delimiter to avoid conflicts with path/encoding characters. +** DONE [#B] Fix unsafe heredoc variable expansion +CLOSED: [2026-02-23 Sun] +Quoted =UDEVEOF= heredoc and used placeholder + sed replacement pattern (same as hyprpm hook). +** DONE [#C] Add mountpoint check before ramdisk mount +CLOSED: [2026-02-23 Sun] +Added =mountpoint -q= guard before mount; skips with info message if already mounted. +** DONE [#C] Improve error handling in chained commands :chore: +CLOSED: [2026-05-07 Thu] +Line 820: three operations chained with =&&= reported as single failure. +Broken into separate error-handled steps. +** DONE [#C] Add comments on complex logic +CLOSED: [2026-02-23 Sun] +Added comments explaining wireless region locale-to-ISO3166 mapping and +archsetup clone strategy (why symlinks need user-owned repo). +** DONE [#D] Validate reserved usernames +CLOSED: [2026-02-23 Sun] +Added check against list of reserved system usernames (root, bin, daemon, sys, etc.). +** DONE Review: Hyprland conf.d source ordering :chore: +CLOSED: [2026-05-07 Thu] +~source = $HOME/.config/hypr/conf.d/*.conf~ was at top of hyprland.conf (line 9). +Machine-local overrides (gaps, monitor scale) were overwritten by defaults later in the file. +Fixed by moving source line to end of file. Update stowed hyprland.conf. +** DONE Review: natural_scroll not set for mouse (only touchpad) :chore: +CLOSED: [2026-05-07 Thu] +~input:natural_scroll~ was missing; only ~touchpad:natural_scroll~ was set. +Added ~natural_scroll = true~ to input block. +** DONE [#B] Extend layout-navigate to escape special workspaces +CLOSED: [2026-04-19 Sun] +With the =special:stash= overlay visible and focus on a window inside it, +=$mod+J= was trapped because =layoutmsg cyclenext= only operates within the +current workspace. The 2026-04-09 fix handled floating→tiled but not +special-workspace→regular. + +Fix in =dotfiles/hyprland/.local/bin/layout-navigate=: when the active +window's =workspace.name= begins with =special:= and the user is navigating +focus (not moving), dispatch =togglespecialworkspace <name>= first, re-read +activewindow state, then fall through to the existing floating/layout +branches. Move variant (=$mod SHIFT J=) is intentionally left untouched so +moving a window out of a scratchpad remains a deliberate separate action. + +Unit tests live in =tests/layout-navigate/= (stdlib =unittest=, fakes +=hyprctl= via PATH). Run with: +=python3 -m unittest tests.layout-navigate.test_layout_navigate= +** DONE Check linux-lts version until 6.18+ +CLOSED: [2026-03-07 Sat] +Run =topgrade= and check =pacman -Q linux-lts=. Once 6.18+, remove =/etc/modprobe.d/amdgpu.conf= and mark this DONE. +Background: AMD Strix Halo VPE power gating bug causes system freeze. Workaround disables power gating. Fix is in kernel 6.15+. +Running linux-lts 6.18.16-1. amdgpu.conf workaround already removed. +** DONE [#D] Find or create a monocle layout for Hyprland +CLOSED: [2026-03-07 Sat] +Both existing monocle plugins (zakk4223/hyprlandMonocle, pianocomposer321/hyprland-monocle) are +abandoned and broken against current Hyprland. Options: fork and fix hyprlandMonocle (more features), +script a pseudo-monocle using fullscreen 1, or wait for a maintained plugin. Lower priority since +stash-window ($mod+O / $mod+Shift+O) covers the main use case. More important for laptop installs. +Resolved: Hyprland 0.54 added native monocle layout. Bound to $mod SHIFT M. +** DONE [#B] Investigate rlwrap not installed after archsetup run +CLOSED: [2026-05-11 Mon] +rlwrap was declared in archsetup (Emacs Dependencies) but missing after a run on ratio (2026-02-06). +The 2026-05-11 VM test run shows it installs cleanly in a fresh install (=...installing rlwrap via pacman @ 15:36:55=; =rlwrap 0.48-1= in the captured package list), so it doesn't reproduce — likely a one-off / machine-specific glitch on ratio, not a systemic skip. Closing; reopen if it recurs. +** DONE [#C] Remove stale hyprpm/plugins validations; make run-test.sh tolerant of validation failures +CLOSED: [2026-05-11 Mon] +The 2026-05-11 VM test aborted because =validate_hyprland_plugins= in =scripts/testing/lib/validation.sh= checked for =~/.local/bin/hyprland-plugins-setup=, which was deliberately removed in dd543e3 (=feat(hyprland): remove plugins, add layout cycling=; Hyprland 0.54 brings the layouts into core). The function's =return 1= under run-test.sh's =set -e= killed the run before the test report was written or the VM cleaned up. +Fix: deleted =validate_hyprland_plugins= and =validate_hyprpm_hook= (the hyprpm pacman hook was removed in the same commit) plus their calls in =validate_window_manager=; disabled errexit in =run-test.sh= from the validation phase onward so a failed check is counted (=VALIDATION_FAILED=) instead of fatal — the script signals pass/fail via its exit code at the end. Verified with =bash -n=; the next =make test= run confirms the count-not-abort behavior. +** DONE [#B] toggle key for touchpad on/off +CLOSED: [2026-05-20 Wed] +*** 2026-05-20 Wed @ 18:18:30 -0400 Spec: touchpad toggle + waybar indicator + +**** Current state +A toggle mechanism already exists in the live home dir but is only partly committed. +- =~/.local/bin/toggle-touchpad= (live, NOT in repo): reads/writes a state file at =${XDG_RUNTIME_DIR:-/tmp}/touchpad-state= (values "enabled"/"disabled"), flips =hyprctl keyword "device[$TOUCHPAD]:enabled" true|false=, and fires a =notify info "Touchpad" ...= toast. Hardcodes =TOUCHPAD="pixa3854:00-093a:0274-touchpad"=. +- =~/.local/bin/touchpad-auto= (live, NOT in repo): daemon watching Hyprland's =.socket2.sock= for mouseadded/mouseremoved/configreloaded, auto-disables the touchpad when an external mouse is present, writes the same state file. Same hardcoded device name. +- Keybinding already committed: =bind = $mod, F9, exec, toggle-touchpad= (=hyprland.conf:315=). +- State file confirmed live at =/run/user/1000/touchpad-state= (reads "enabled"). + +**** Gap +1. No waybar indicator — nothing in modules-right shows touchpad state; no =custom/touchpad= module exists. +2. Neither =toggle-touchpad= nor =touchpad-auto= is committed into the repo. They live only in =~/.local/bin=, so a fresh stow won't install them. They belong in =dotfiles/hyprland/.local/bin/= (the =dotfiles/dwm/.local/bin/toggle-touchpad= is the old X11/xinput version, unrelated). +3. =touchpad-auto= is never started — no =exec-once= launches it. +4. The toggle doesn't refresh waybar, so an indicator would lag until its poll interval. + +**** Proposed implementation +1. New status script =dotfiles/hyprland/.local/bin/waybar-touchpad= mirroring =waybar-layout= / =waybar-netspeed= (emit one JSON line: text + tooltip + class). Reads the state file the toggle already writes — single source of truth, no extra hyprctl call. Emits a "disabled" class + off-icon when the state file reads "disabled", else "enabled" + on-icon. +2. Waybar module in =dotfiles/hyprland/.config/waybar/config=, using "signal" so the toggle pushes an instant refresh (no polling — state only changes on toggle or mouse hotplug): + =, "custom/touchpad": { "exec": "waybar-touchpad", "return-type": "json", "signal": 9, "on-click": "toggle-touchpad" }= + Add =custom/touchpad= to modules-right, near =idle_inhibitor=. +3. Refresh-on-toggle: have =toggle-touchpad= (and =touchpad-auto='s set function) run =pkill -RTMIN+9 waybar= after each write to the state file (RTMIN+N ⇄ waybar "signal": N). Alternative: drop "signal", use "interval": 2 (simpler, ~2s lag, constant poll). Signal is the cleaner fit. +4. =style.css= (=dotfiles/hyprland/.config/waybar/style.css=): add =#custom-touchpad= to the shared padding/hover selector lists; add =#custom-touchpad.disabled { color: #d47c59; }= (the dupre orange already used for warnings). Enabled state inherits the default color. +5. Keybinding: keep =$mod+F9= (=hyprland.conf:315=). The waybar on-click gives a mouse path to the same action. +6. Commit the live scripts so stow installs them: =toggle-touchpad= and =touchpad-auto= into =dotfiles/hyprland/.local/bin/= (plus the =pkill= line), and =waybar-touchpad= (new). If the auto-disable-on-external-mouse behavior is wanted at boot, add =exec-once = touchpad-auto= near the other daemon exec-once lines. + +**** Decisions (Craig, 2026-05-20) +1. Icons: enabled / disabled (the mouse / mouse-off pair). +2. Waybar on-click toggles the touchpad. +3. Commit =touchpad-auto= and add its =exec-once= so it runs at login. +4. Signal-driven refresh (=pkill -RTMIN+9 waybar=). +Note: the hardcoded device name =pixa3854:00-093a:0274-touchpad= is Framework-laptop-specific — a portability concern for other machines, not a blocker for this task. + +*** 2026-05-20 Wed @ 18:29:06 -0400 Implemented the toggle + waybar indicator (in repo) +Built per spec + decisions above. Committed the two formerly-live-only scripts into the repo and added the indicator: +- =dotfiles/hyprland/.local/bin/waybar-touchpad= (new) — reads =$XDG_RUNTIME_DIR/touchpad-state=, emits JSON (text/tooltip/class), fail-safe to "enabled". Unit-tested in =tests/waybar-touchpad/= (6 Normal/Boundary cases). +- =dotfiles/hyprland/.local/bin/toggle-touchpad= — copied from =~/.local/bin=, added =pkill -RTMIN+9 waybar= so the indicator refreshes on toggle. +- =dotfiles/hyprland/.local/bin/touchpad-auto= — copied in, =pkill -RTMIN+9 waybar= inside =set_touchpad= so auto on/off events refresh too. Added =exec-once = touchpad-auto= to =hyprland.conf=. +- =waybar/config= — =custom/touchpad= module (signal:9, on-click toggle-touchpad), placed in modules-right before idle_inhibitor. +- =waybar/style.css= — =#custom-touchpad= in padding + hover lists; =.disabled { color: #d47c59 }= (dupre orange). +- =$mod+F9= bind already present (=hyprland.conf=), left as-is. + +*** 2026-05-20 Wed @ 18:36:26 -0400 Deployed + verified on velox +Discovered =.local/bin= is stow-symlinked (waybar-layout/netspeed point into the repo); the two touchpad scripts were real files only because they weren't committed. Replaced both real files with repo symlinks and symlinked the new =waybar-touchpad= (matching the existing relative-symlink form). velox needed no hyprland.conf change — =exec-once = touchpad-auto= and the =$mod+F9= bind were already present. waybar =config= / =style.css= are real local files on velox (config diverges: standalone battery, no sysmonitor group), so applied targeted edits there rather than a copy. + +Verified end-to-end after a waybar restart: config loads with no parse errors; toggle round-trips state enabled → disabled (, class disabled) → enabled (), and the =pkill -RTMIN+9 waybar= refresh fires into the running bar. Touchpad left enabled. Visual confirmation (icon in bar, orange when off) is Craig's to eyeball. Other machines (ratio) pick this up on =git pull && make restow hyprland= — their =.local/bin= and waybar configs are symlinks, so no real-file conflict there. +** DONE [#B] Airplane-mode toggle + waybar indicator +CLOSED: [2026-05-21 Thu] +Laptop-only low-power toggle, modeled on the touchpad indicator. Wifi off (bluetooth left alone for earbuds), CPU EPP → power, brightness → 35%, and stops network-only services. Disengage restores only what it recorded, so anything already off stays off. +*** 2026-05-21 Thu @ 17:43:07 -0400 Built the toggle, indicator, and tests +- =dotfiles/hyprland/.local/bin/airplane-mode= (new) — toggle. Engage records prior state (wifi enabled/disabled, EPP value, brightness, which services were active) to =$XDG_RUNTIME_DIR/airplane-state=, then applies low-power: =nmcli radio wifi off=, EPP → power on all CPUs (sudo sysfs write), =brightnessctl set 35%=, and stops Tier 1+2 services (tailscaled, proton.VPN, avahi-daemon, cups, wsdd, geoclue, sshd, fail2ban + user syncthing). Disengage replays the recorded state — only re-enables wifi if it was on, only restarts services it stopped. Refreshes the bar via =pkill -RTMIN+10 waybar=. +- =dotfiles/hyprland/.local/bin/waybar-airplane= (new) — indicator. Reads =mode= from the state file; fail-safe to inactive. Laptop-gated: exits silently (module hidden) when no battery is present (=/sys/class/power_supply/BAT*=). One clear plane glyph (FA U+F072) for both states; color carries state (gold active / gray inactive). +- =waybar/config= — =custom/airplane= module (signal 10, on-click airplane-mode), placed after custom/touchpad. =waybar/style.css= — =#custom-airplane= in padding + hover lists; =.active { color: #d7af5f }= (dupre gold). +- Tests: =tests/airplane-mode/= (20 — engage/disengage/preserve-existing-state/dispatch, via command stubs + fake EPP sysfs) and =tests/waybar-airplane/= (10 — states/boundary/laptop-gating). All green; shellcheck clean. +- Deployed + live-verified on velox (engage → disengage round-trip works). Other machines pick it up via git pull && make restow hyprland. +** DONE [#C] super+e emacs launch doesn't grab focus from tiled browser :quick: +CLOSED: [2026-05-22 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-05-22 +:END: +Launching emacs with super+e while a browser window is open in tiled mode leaves focus on the browser instead of moving it to the newly opened emacs window in the main (left) portion of the screen. Expected: the new emacs window takes focus. Noticed 2026-05-22. + +Resolved 2026-05-22: not a focus *failure* but a focus *fight*. Live socket2 capture showed the new (XWayland, non-pgtk Emacs 30.2) frame does get focus on open, then Firefox reclaims it via an activation request because =misc:focus_on_activate=true=. Set it =false= in the dotfiles repo (=3bfba5a=) — new-window focus is a separate path so emacs still focuses on open, but the browser can no longer steal it back. Verified by Craig. +** DONE [#C] Dim inactive windows in Hyprland :hyprland: +CLOSED: [2026-05-27 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-05-26 +:END: +Shipped in the =~/.dotfiles= repo (=66124e8=): =dim_inactive = true=, =dim_strength = 0.4= (tuned by eye), =dim_special = 0.2= for pyprland scratchpads, and a =no_dim true= window rule for Zoom. The opt-out rule is =no_dim= (underscore), not =nodim= — the latter throws a config-error banner. Config uses Hyprland 0.55's =windowrule = match:class ...= grammar. +** CANCELLED [#A] Prevent X termination and VT switching (security risk) +CLOSED: [2026-05-21 Thu] +If someone grabs laptop at cafe and hits ctrl+alt+backspace, they kill screensaver/X and get console access +Need to disable: ctrl+alt+backspace (zap X) and ctrl+alt+F# (VT switching) +Previous attempts to configure in xorg.conf.d failed - need to investigate what's overriding the settings +Tried: /etc/X11/xorg.conf.d/00-no-vt-or-zap.conf with DontVTSwitch and DontZap options +Removed conflicting setxkbmap statements, gdm, and keyd configs - still didn't work +** DONE [#B] Add Rust installation via rustup instead of pacman package :quick: +CLOSED: [2026-05-26 Tue] +:PROPERTIES: +:LAST_REVIEWED: 2026-05-21 +:END: +Already implemented — =archsetup= lines 1976-1979 (Programming Languages and Utilities) =pacman_install rustup= then =rustup default stable= as the user. Closing on verification; the task predated that work. + +The =rust= package has been removed from archsetup. Need to add Rust installation using =rustup= (the official Rust toolchain manager) instead of the Arch package. + +Steps: +- Install rustup: =pacman -S rustup= +- Initialize default toolchain: =rustup default stable= +- Consider adding to archsetup or post-install script + +Reference: Removed from archsetup on 2025-11-15 +** CANCELLED [#D] Add cpupower installation and enabling to archsetup :quick: +CLOSED: [2026-05-26 Tue] +Implemented, VM-verified, then removed — wrong tool for this fleet. Both machines run active-mode pstate drivers (ratio amd-pstate-epp, velox intel_pstate) where only performance/powersave exist and the driver self-manages frequency via EPP; both correctly sit on powersave. cpupower's governor-forcing only helps older acpi-cpufreq systems, which we don't run. Forcing performance would pin max clocks (worse on the laptop, pointless on the desktop). Dropped from archsetup rather than ship a backwards default. + +cpupower service configures the default CPU scheduler (powersave or performance) +Install cpupower, configure /etc/default/cpupower, enable service: ~systemctl enable --now cpupower.service~ +** DONE [#C] Airplane-mode toggle robustness follow-ups :quick:solo: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Shipped 2026-06-10 as dotfiles commit =16fbe4e=, TDD'd (23 tests green). Both gaps closed: the toggle now no-ops without a BAT* (same check as waybar-airplane, AIRPLANE_POWER_SUPPLY_DIR override for tests), and an empty recorded brightness at disengage falls back to 100% (AIRPLANE_BRIGHTNESS_DEFAULT) instead of stranding the screen at 35%. +** DONE [#B] protonmail-bridge package service conflicts with Hyprland autostart :cmail: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Craig confirmed resolved 2026-06-10 — the per-machine fix (disable the packaged user service, Hyprland exec-once as sole launcher) has held since 2026-05-22 with no recurrence. + +The =protonmail-bridge= package ships an enabled systemd user service (=/usr/lib/systemd/user/protonmail-bridge.service=, =--noninteractive=, =Restart=always=) that double-launches with the Hyprland =exec-once = protonmail-bridge --no-window= GUI autostart. Two symptoms: (1) no tray icon — the headless service grabs ports 127.0.0.1:1143/:1025 before the GUI =--no-window= instance can bind; (2) TLS cert mismatch — the headless service can't reach gnome-keyring (starts outside the graphical session), falls back to its own self-signed cert, so =mbsync=/mu4e and cmail-action.py fail STARTTLS against =~/.config/protonbridge.pem= with SSL CERTIFICATE_VERIFY_FAILED. + +Fix applied per-machine 2026-05-22: =systemctl --user disable --now protonmail-bridge.service=, leaving the Hyprland exec-once GUI as the sole bridge (tray icon returns, served cert matches, =mbsync -a= clean). A fresh install re-enables the package service, so make it durable: mask/disable =protonmail-bridge.service= during install (likely in =scripts/cmail-setup-finish.sh=) and document that the Hyprland exec-once is the intended launcher — never run both. Source: handoff from .emacs.d 2026-05-22. +** DONE [#B] Add signal-cli to the standard install :tooling:signal:solo: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Shipped 2026-06-10 as archsetup commit =1229fb2= — =aur_install signal-cli= beside signal-desktop, with the JRE/update-cadence/manual-linking caveats as comments. + +Add =signal-cli= (AUR) to the regular package set so every provisioned machine has it. It's the headless JSON-RPC engine for an in-Emacs Signal client (a =signel= fork) that's the same across all machines. Source: handoff from .emacs.d 2026-05-26. + +- =aur_install signal-cli= in the appropriate section (comms/messaging or AUR utilities). +- Runtime needs a JRE (OpenJDK 17+) — already satisfied by =jdk-openjdk=; note it as a dependency if the install set is ever trimmed. +- Keep-current caveat: signal-cli must update roughly every 3 months or Signal-Server rejects it (client-version floor moves). It belongs in the regularly-updated AUR set, not pinned. +- Linking is per-machine and interactive (QR scan from phone's Linked Devices), so that stays manual. archsetup only guarantees the binary is present. +** DONE [#B] Mic-mute keybind + waybar indicator :waybar:hyprland:solo: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Shipped 2026-06-10 as dotfiles commit =07d056c= (script + 5 unit tests + bind + waybar module + CSS in all three theme files; old CTRL+ALT+SPACE bind removed). Verified live on ratio: state flips in wpctl, indicator renders both states with correct glyphs and colors, notifications fire. velox picks it up via pull + restow. + +A single mute state in PipeWire, reachable from a keybind and a waybar indicator, each reflecting the other. Agreed design (2026-06-10): + +- *Keybind*: Super+Shift+A (=bindl= so it works on the lock screen), running a =mic-toggle= script in =hyprland/.local/bin/=: =wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle=, then read the new state and fire =notify= (alert "Mic muted" / success "Mic live"). wpctl targets PipeWire's default source, so the bind keeps working if the default mic changes (ratio has three capture devices). +- *Waybar indicator*: a second pulseaudio module instance (=pulseaudio#mic=) using =format-source= / =format-source-muted= — waybar subscribes to PipeWire events natively, so the keybind and the click both update the icon with no signal plumbing (unlike =custom/dim=). =on-click= runs the same wpctl toggle. +- *Icons*: Nerd Font MD glyphs — mic (U+F036C) live, mic-off (U+F036D) muted — matching the MD volume glyphs already in the pulseaudio block. Verify by rendering, not by name (BerkeleyMono remaps codepoints; see the 2026-06-10 glyph lesson). +- *Coloring* (dupre): default =#969385= when live; =#d47c59= when muted — same semantic as =#custom-touchpad.disabled= (an input device turned off). The gold =#d7af5f= stays reserved for active/attention states (airplane, dim). Mirror the rule in the hudson theme's waybar css with its palette equivalent. +- *Remove the old mechanism entirely*: the =CTRL ALT, SPACE= amixer Capture-toggle bind in =hyprland.conf= (~line 325) — ALSA-level, fragile with multiple capture devices, brittle notify grep chain. + +Lives in the dotfiles repo (=hyprland/.config/hypr/hyprland.conf=, =hyprland/.config/waybar/=, =hyprland/.local/bin/=). TDD the =mic-toggle= script per the dotfiles suite. velox picks it up via pull + restow. +** DONE [#B] Waybar theme-CSS drift — live style.css ahead of theme copies :waybar:hyprland:solo: +CLOSED: [2026-06-11 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-11 +:END: +Shipped 2026-06-10/11 across two dotfiles commits: =1589734= reconciled dupre to a byte-copy of the live style.css, rebuilt hudson with the full live selector set in its palette, and added the guard suite (dupre must equal live; hudson must cover every live selector). The same guards were extended to the foot.ini family in =c5e699b= when the per-host work touched it (set-theme overwrites foot.ini the same way). The symlink-instead-of-cp alternative wasn't needed — the test guard catches drift at =make test= time. +** DONE [#B] Add =uv= to the install playbook :tooling:python:solo: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Shipped 2026-06-10 as archsetup commit =3e22b06= — =pacman_install uv= in the Python tooling block (uv 0.11.19 in extra). Exercised by the same-day hyprland VM run. + +Add =uv= (Astral's Python package + script runner) to archsetup so fresh machines pick it up automatically. Currently installed by hand on ratio + velox (=/usr/bin/uv= 0.11.15), not in the standard set — a fresh install would skip it, and project scripts using PEP 723 inline-script metadata (=#!/usr/bin/env -S uv run --script= shebangs) would fail with =env: uv: No such file or directory=. Source: handoff from health 2026-05-29 ([[file:assets/outbox/2026-05-29-1127-from-health-todo-a-add-uv-to-the-install-playbook.org][outbox copy]]). + +Health requested [#A] (load-bearing for the PEP 723 pattern they're promoting + the rulesets template-script proposal). Demoted to [#B] for archsetup: no current install is broken (uv is pre-installed everywhere it's needed), and the shape matches the existing [#B] tooling-codification tasks (eask, signal-cli) — load-bearing for other projects, manually installed today, codify so fresh installs pick it up. + +- *Install via pacman* — =uv= is in extra (=pacman -S uv=). Cleanest path; auto-updates with the rest of the system. AUR =uv-bin= and Astral's official installer are alternatives but add a non-pacman path to maintain. +- *Placement* — alongside the existing language-tooling block in =archsetup= (near =rustup=, =nvm=, or the Python set). Decide the exact section at implementation time. +- *Verification* — post-install =which uv && uv --version=; PEP 723 end-to-end check per the health handoff (=/tmp/uv-test.py= shebang script with inline =requests= dep). + +Related: the new [#B] LLM task above may grow scripts that benefit from PEP 723 (e.g. =scripts/llm-smoke-test.sh= if Python-based). =uv= landing here removes that friction. +** DONE [#A] Separate dotfiles from archsetup +CLOSED: [2026-06-09 Tue] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-09 +:END: +*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Dotfile separation plan +Approach: keep =dotfiles/= committed in this repo as the working default (Craig's machines and CI keep functioning untouched), but make the *source location* a config variable. The install script learns one new conf key — =DOTFILES_REPO= / =DOTFILES_BRANCH= — and when set, clones that repo into =~/.dotfiles= and stows from there instead of from =dotfiles/= inside archsetup. The Makefile gets a =DOTFILES= override env var so the same stow targets work whether dotfiles live in-repo or elsewhere. No submodule (adds fragility for a curl|bash installer); a separate published =archsetup-dotfiles= repo is optional follow-up, not a blocker. + +1. Add conf keys to =archsetup.conf.example= under the "Git Repositories" block (after line 57): =DOTFILES_REPO= (commented, with note "leave unset to use the dotfiles bundled with archsetup"), =DOTFILES_BRANCH= (default =main=), and =DOTFILES_DIR= (target clone path, default =~/.dotfiles=). Document that a user's repo must have =common/= plus optionally =dwm/= and =hyprland/= subdirs that stow cleanly to =~=. +2. In =archsetup= lines 114-122, map =DOTFILES_REPO=/=DOTFILES_BRANCH=/=DOTFILES_DIR= to lowercase vars. At lines 136-146, leave =dotfiles_dir="$archsetup_dir/dotfiles"= as the fallback default and add =dotfiles_repo="${dotfiles_repo:-}"=. +3. In =user_customizations()= (lines 828-854): after the archsetup clone (line 838-841), branch — if =dotfiles_repo= is non-empty, =git clone --depth 1 --branch "$dotfiles_branch" "$dotfiles_repo" "$dotfiles_clone_dir"= (chown to user) and set =dotfiles_dir="$dotfiles_clone_dir"=; else keep =dotfiles_dir="$user_archsetup_dir/dotfiles"= (line 844). The stow calls at lines 847-854 stay as-is since they just =cd "$dotfiles_dir"=. Guard the hyprland stow (851) so it no-ops if the user repo has no =hyprland/= dir. +4. The waybar-battery sed block (lines 856-865) and the =git restore= step (lines 896-902) both assume Craig's exact files — wrap each in an existence check (=[[ -f "$waybar_config" ]]=, and only =git -C "$dotfiles_dir" restore .= when =dotfiles_dir= is a git repo). Right now they'd error on a foreign dotfiles tree. +5. =Makefile= line 5: change =DOTFILES := $(shell pwd)/dotfiles= to =DOTFILES ?= $(shell pwd)/dotfiles= so a user with external dotfiles runs =make stow hyprland DOTFILES=~/.dotfiles=. =reset= (line 123, =git checkout -- dotfiles/=) and =import= (writes to =$(DOTFILES)/$(DEST)=) already key off =$(DOTFILES)= except that one hardcoded path — fix line 123 to =git -C $(DOTFILES) checkout -- .=. Update the =help= text (lines 16-45) to mention the =DOTFILES== override. +6. Migration: this is purely additive. Default behavior = today's behavior, so Craig's existing machines and =make test= VMs are unaffected. Craig can later extract =dotfiles/= to =git.cjennings.net/archsetup-dotfiles= and set =DOTFILES_REPO= in his own =archsetup.conf= — but that's his choice and a separate commit; the in-repo copy stays as the canonical default. Update =CLAUDE.md= "Project Structure" + "Makefile Targets" sections to document the override. +7. Simple-UX summary to put in =archsetup.conf.example= and README: "Accept the defaults → you get the bundled dotfiles (DWM or Hyprland). Bring your own → set =DOTFILES_REPO= to your git URL; it gets cloned to =~/.dotfiles= and stowed automatically." Optional stretch: ship a tiny =dotfiles/minimal/= (bash/zsh/git/tmux only) and a conf option =DESKTOP_ENV=none= path that stows just that. + +Open questions for Craig: +- Extract =dotfiles/= to a standalone repo now, or defer? (Plan above defers — keeps it as the in-repo default.) +- Clone target: =~/.dotfiles= vs =~/code/dotfiles= vs let the user pick via =DOTFILES_DIR=? +- Should =DOTFILES_REPO= unset mean "bundled dotfiles" (proposed) or "no dotfiles at all"? +- Do you want the minimal/headless starter set (=dotfiles/minimal/=), or out of scope? +- Keep =git restore=/=git checkout= conflict-recovery behavior only when dotfiles are a git checkout, and silently skip otherwise — acceptable? + +*** 2026-05-13 Wed @ 13:07:54 -0500 AI Response: Open questions answered; full spec written +All 5 questions resolved: extract now (Q1), clone to =~/.dotfiles= (Q2), no opt-out — =DOTFILES_REPO= always has a default (Q3), include =minimal/= as Tier B with TUI apps (Q4), error out if dotfiles dir isn't a git checkout (Q5). Full spec at [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]] — covers the =minimal/= tree, SSH/GPG availability, three-phase implementation plan (Phase 1: extract + populate new repo at =cjennings.net/archsetup-dotfiles.git=; Phase 2: wire archsetup + VM test; Phase 3: migrate machines + remove =dotfiles/=), commit map, and open observations. Implementation gated on spec review. + +*** 2026-05-14 Thu Review docs/PLAN-dotfiles-separation.org +CLOSED: [2026-05-14 Thu] +Review the spec for accuracy, edge cases, and scope. Flag changes before implementation starts. See [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]]. + +*** 2026-05-14 Thu @ 21:43:41 -0500 AI Response: Review resolved; spec locked for Phase 1 +Walked the spec's 5 open questions plus my 5 review concerns. Locked: URL =https://git.cjennings.net/dotfiles.git= (anonymous HTTPS read confirmed against existing repos at the same host), bare repo path =/var/git/dotfiles.git=, scope = Phase 1 only (~30 min). Added =environment.d/envvars.conf= (with rofi path stripped) and =systemd/user/emacs.service= to the =minimal/= tree; skipped =ncmpcpp= and =systemd/user/geoclue-agent.service=. Phase 2/3 constraints folded into the spec body for the executor: =DESKTOP_ENV=none= VM test required (was optional), clone uses =sudo -u "$username"= to avoid chown-after races, Phase 3 unstow/restow runs without an intermediate Hyprland reload, dotfiles repo can't go on GitHub until secrets cleanup ships, and Step 3.3 documents the post-install update flow. Latest spec at =docs/PLAN-dotfiles-separation.org= (=817d939=). End-of-day Phase 1 session reads from there and executes. + +*** 2026-05-22 Fri @ 13:41:08 -0500 Phase 1 executed — dotfiles repo live on cjennings.net +Created the bare repo at =/var/git/dotfiles.git=, extracted =dotfiles/= from archsetup with =git filter-repo --subdirectory-filter= (229 commits, per-file history preserved), built the =minimal/= stow target per the spec, and pushed to =git@cjennings.net:dotfiles.git= (HEAD =68daeab=). Anonymous read at =https://git.cjennings.net/dotfiles.git= confirmed. Two spec corrections committed in archsetup (=7c26495=): push URL switched to SSH (HTTPS is read-only), and =minimal/.profile.d/= now ships 5 files including =claude.sh= (added on Craig's call, post-dated the spec lock). Phase 2 (wire archsetup config + VM test, ~2-3 hrs) and Phase 3 (migrate machines, remove =dotfiles/= from archsetup) remain. + +*** 2026-05-22 Fri @ 17:05 -0500 Phase 2 shipped — archsetup clones the dotfiles repo +Wired archsetup to the external dotfiles repo: clones =DOTFILES_REPO= to =~/.dotfiles= and stows per =DESKTOP_ENV= (dwm/hyprland → common + that DE; none → minimal). Added =DOTFILES_REPO=/=BRANCH=/=DIR= config keys + validation; test harness serves the repo to the VM as =/tmp/dotfiles-test=. Commits =bab6901= (feat) + =68172c8= (test infra), pushed to origin/main. Spec-directed =sudo -u= clone hit a real bug — =useradd -m= skips the home-dir chown when =/home/$username= pre-exists (root-owned), so the user-clone failed with Permission denied; fixed by cloning as root + =chown -R= (mirrors the archsetup clone). git restore now runs for all DE paths (minimal ships skel-colliding .bashrc etc.). + +*** 2026-05-22 Fri @ 18:10 -0500 Phase 3.1 + 3.3 done — this machine on ~/.dotfiles +Migrated this workstation: cloned the dotfiles repo to =~/.dotfiles=, committed the gpg-agent SSH routing (=.zshenv= + =envvars.conf=) that was uncommitted in the live tree as =888a599= in the dotfiles repo, then =make unstow hyprland= + =make stow hyprland DOTFILES=~/.dotfiles=. Snag: unstowing while Hyprland ran made it write a stub hyprland.conf that blocked the restow — quit Hyprland, removed the stub, restowed clean. All symlinks now resolve into =~/.dotfiles=. CLAUDE.md updated with the external-repo docs + migration steps + the quit-Hyprland gotcha (=e1810ce=). Remaining: 3.2 (=git rm dotfiles/=) blocked until ratio + velox migrate the same way. + +*** 2026-05-22 Fri @ 21:20 -0500 velox migrated to ~/.dotfiles (laptop overrides preserved) +ratio is THIS machine (was "fractal" pre-reinstall) — migrated in 3.1. velox migrated over SSH (Craig quit its Hyprland): cloned ~/.dotfiles, stowed common+hyprland from it. velox carries deliberate laptop-local real-file overrides (foot.ini font 12, pypr config.toml laptop scratchpad sizing, waybar config battery module) that shadow stow — preserved them as local real files (backed up, restowed the rest, restored the overrides). All machines now on ~/.dotfiles. + +*** 2026-06-02 Tue @ 12:16:54 -0500 Phase 3.2 done — removed in-repo dotfiles/ from archsetup +git rm'd the in-repo =dotfiles/= tree (831 files) now that ratio + velox both stow from =~/.dotfiles=; the installer already clones DOTFILES_REPO so nothing read it at install time. Stripped the stow targets from archsetup's Makefile (kept VM-integration + the safe-rm-rf installer-helper suite). Updated CLAUDE.md (Project Structure, Makefile Targets, Dotfiles Repository, Script Counts, Theme/Key-Config path refs) and README.md (dotfile-management, theme, DE, unit-test sections) to point at =~/.dotfiles=; the README had been describing the pre-Phase-2 in-repo model. Commit b10cba5 on archsetup origin/main. velox + ratio local clones drop dotfiles/ on their next archsetup pull (ratio: see the "Pull Phase 3.2 changes onto ratio" task). 4 untracked calibre cache/annotation files that were never committed got moved aside to /tmp/archsetup-dotfiles-orphan-untracked-20260602 (disposable reading-position markers). + +*** 2026-06-02 Tue @ 12:16:54 -0500 Migrated script unit-test suites + a Makefile into ~/.dotfiles +Gave =~/.dotfiles= its own Makefile rather than repointing archsetup's =DOTFILES= default — the dotfiles repo now owns its stow tooling and tests, so it manages and validates standalone (relevant to the open-source release too). Authored =~/.dotfiles/Makefile= with the stow family (=stow/restow/reset/unstow/import= + check-de/check-dest + DE/DEST machinery) plus a =make test= target (mirrors archsetup's hyphenated-dir test-unit loop). Moved-Makefile fixups: =DOTFILES := $(shell pwd)= (trees at repo root), =reset='s revert scoped to =git checkout -- common $(DE)= (not the whole repo — caught in review), import header/path "dotfiles/$(DEST)" → "$(DEST)", =minimal= added to the import DEST filter only. + +Moved 6 suites (=airplane-mode=, =layout-navigate=, =notify=, =tmux-util=, =waybar-airplane=, =waybar-touchpad=) into =~/.dotfiles/tests/=, dropping the =dotfiles/= =SCRIPT=-path prefix (=REPO_ROOT= is now the dotfiles root), and copied their fixtures (=layout-navigate/fake-hyprctl=, =tmux-util/fake-{fzf,kill,sleep,tmux}=). =waybar-netspeed='s suite was already there. =safe-rm-rf= stayed in archsetup (it tests the installer, not a dotfile). =make test= green: 7 suites, 124 tests. Committed 59b10c4 + pushed to the dotfiles repo. =minimal= is a standalone tree (stowed alone, not =common + minimal=), so a =make stow minimal= target needs its own branch — deferred as a small follow-up; the move kept stow/restow/reset/unstow behavior-identical to archsetup (dwm/hyprland). + +*** 2026-06-09 Tue @ 19:21:36 -0500 Pulled Phase 3.2 onto ratio + cleaned dangling links +ratio's archsetup clone was already current with origin/main (Phase 3.2 pulled), but the migration had left stale symlinks pointing into the now-deleted =~/code/archsetup/dotfiles=: =~/.config/calibre= plus a manual =~/music/radio/= playlist farm (73 broken =.m3u= links) and one dead reference under =~/projects/home/reconciliation=. Re-pointed calibre into =~/.dotfiles/common/.config/calibre=. Deleted the 73 radio links — dead and redundant, since the same playlists already stow correctly to =~/music/*.m3u=, which is what mpd reads (=music_directory=/=playlist_directory= both =~/music=) — and removed the reconciliation link. ratio now has zero archsetup-dangling symlinks. (The ~3400 other dangling links in =~= are unrelated system/flatpak noise: ca-certificates, =/run/host=, =/bin=.) +** DONE [#B] Cleaner per-machine override mechanism for the dotfiles repo +CLOSED: [2026-06-11 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-11 +:END: +Shipped 2026-06-11 as dotfiles =c5e699b= after spec review (all five questions decided — see the spec's Status table). Host tiers =ratio/= + =velox/= auto-included by every stow target; first tenants: hypr local.conf (velox HiDPI scale + XWayland toolkit env, replacing the Zoom per-app hack), pypr whole-file split, foot font via per-host host.ini include. waybar stays shared (velox's was stale, not divergent). velox restows cleanly for the first time and caught up on all pending dotfiles work. Drift guards extended to foot.ini; Makefile host logic unit-tested (15 suites green). Phase 5 (installer) filed as a follow-on below. velox needs a Hyprland restart for the env vars — see Manual testing. + +velox keeps laptop-specific configs (foot font, pypr scratchpad sizing for 2256x1504, waybar battery) as local REAL files shadowing the stow symlinks. That's fragile: any =make restow= on velox re-conflicts (hit exactly this during the 2026-05-22 migration — stow aborts on the real files). The =~/.dotfiles= model needs a real per-machine override story (a =minimal/=-style per-host package, a documented local-override convention with =.stow-local-ignore=, or host-conditional includes) so overrides survive restows without manual backup/restore. + +Spec: [[file:docs/PLAN-per-host-overrides.org][docs/PLAN-per-host-overrides.org]] — gated on review before implementation. + +*** 2026-06-11 Wed @ 04:40:00 -0500 AI Response: Second restow-conflict instance on velox +The roam-units rollout hit this again: =make restow hyprland= on velox aborted on three real files (foot.ini font size 12, the velox-tuned pypr config.toml, an older waybar config). The roam systemd units were linked manually to unblock; everything else velox should have picked up from recent dotfiles work (mic-toggle, quick-capture, airplane-mode hardening, ranger plugin) is still NOT stowed there — velox's tree stays partially stale until this mechanism ships. That raises this task's practical urgency: velox can no longer cleanly receive dotfiles changes at all. + +*** 2026-05-26 Tue @ 10:21:08 -0500 AI Response: Spec written, gated on review +Surfaced by a HiDPI scaling failure: a per-app =QT_SCALE_FACTOR=1.5= in the shared =Zoom.desktop= (meant for velox) made Zoom open enormous on ratio. Reverted that patch to plain =/usr/bin/zoom %U=; the durable fix is this mechanism. Proposed approach: a per-host stow tier (=ratio/=, =velox/=) stowed as =common + hyprland + $(uname -n)=, with the existing =conf.d/*.conf= glob as the first clean tenant — move =local.conf= out of the shared =hyprland/= tier into per-host tiers so each machine gets its own (HiDPI monitor scale + =env = QT_SCALE_FACTOR/GDK_SCALE= on velox, minimal on ratio). XWayland apps don't scale via the compositor (=force_zero_scaling=true=), so toolkit env vars set in =conf.d= are the right layer — kills per-app =.desktop= hacks. Open question in the spec: whole-file configs with no include directive (waybar JSON, pypr toml) need a separate strategy. Full design + 5 open questions for Craig in the spec. +** DONE [#B] Verify Phase 2 in the VM (hyprland + none) — pending clean run :solo: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Both runs clean on 2026-06-10. Hyprland (=make test=, results =20260610-151228=): 52 passed / 0 failed, and the same-day uv + signal-cli install additions were exercised in-run. None (results =20260610-165438=-ish, second attempt): 50 passed / 0 failed — the minimal/ tree stowed correctly. The first none attempt failed on a test-harness bug, not the installer: validation.sh hardcoded the common/ symlink target, fixed in =1754a94= (expected path now follows DESKTOP_ENV). The only attributed issue in both runs is the Proton-VPN-daemon-fails-in-VM known noise. The Phase 2 none/minimal path is now verified end-to-end. +** DONE [#C] Investigate the 2026-05-11 VM-test warnings +CLOSED: [2026-06-11 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-11 +:END: +All five resolved. Four were environment-impossible checks converted to uncounted skips (=ced91c4= + the portal refinement =19015c7=) — socket, portal, mDNS-on-slirp, docker-pre-reboot — and all four skips verified firing in the 2026-06-11 12:56 run (52/0, 1 warning). The fifth (lingering) turned out to be a harness quoting bug, not a logind issue — fixed in =5b51900=, dated entry below. The next clean run should report zero warnings. The 18:36 =make test= run that filed this passed 52/0/5; the sub-entries below carry each investigation. + +*** 2026-06-10 Wed @ 19:07:54 -0500 Hyprland-socket warning converted to a skip +Shipped in =ced91c4=: the check now passes when the socket exists, skips (uncounted) when no Hyprland process is running — the headless-VM state — and warns only in the genuinely odd case of a running compositor with no socket. Verified live: the skip fired in the 2026-06-10 19:06 run. + +*** 2026-06-10 Wed @ 19:07:54 -0500 Portal-query warning converted to a skip +Shipped in =ced91c4= + a follow-up refinement: the first condition (portal process absent) didn't fire because a socket-activated =xdg-desktop-portal= exists even headless; the precondition is really a running compositor, so the skip now keys on =pgrep -x Hyprland= like the socket check. The conf-file checks (the part install controls) still pass/fail normally. The dconf-write angle stays tracked under =[#B] Fix install errors=. + +*** 2026-06-10 Wed @ 19:07:54 -0500 mDNS-ping warning converted to a slirp-aware skip +Shipped in =ced91c4=: when the VM is on QEMU slirp (a =10.0.2.x= address), the =.local= ping is skipped — multicast genuinely can't pass there — and the =is-enabled= check stands alone. On real networking the full ping test still runs and still warns on failure. Verified live: the skip fired in the 2026-06-10 19:06 run. + +*** 2026-06-11 Thu @ 12:58:19 -0500 Lingering warning was a harness quoting bug — fixed, hypothesis disproven +make test-keep forensics on the kept VM: the linger file existed (created mid-install), =loginctl show-user cjennings -p Linger= said yes, logind active with zero errors — lingering was correctly enabled all along, so the logind-degraded hypothesis was wrong and archsetup's =enable-linger= calls were always fine. The actual bug was in the check itself (=validation.sh=): it captured =ls path && echo yes=, so a present file produced "path\nyes", which never string-equals "yes" — the check warned on every run regardless of state. Fixed in =5b51900= with =test -e=; the corrected expression verified returning "yes" against the live VM. With this, all five 2026-05-11 warnings are resolved and a clean run should report zero. + +*** 2026-06-10 Wed @ 19:07:54 -0500 Docker warning converted to a pre-reboot skip +Shipped in =ced91c4=: =docker info= success still passes; enabled-but-inactive (the deliberate enable-not-now install state, validated pre-reboot) now skips; active-but-unresponsive still warns — that's the real failure case. Verified live: the skip fired in the 2026-06-10 19:06 run. The enable vs enable-now question for archsetup itself was left as-is (the daemon's weight makes enable-on-boot defensible). + +Note: the run also logged two log-diff meta-warnings — "Found 4 new error lines after archsetup" and "New failed services detected (before: 1, after: 2)". Those correspond to the post-install systemd noise (pam_systemd / logind / Proton VPN) already captured under =[#B] Fix install errors= above; not duplicated here. +** DONE [#B] Enable TLP power management for laptops :quick: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Done live on velox 2026-06-10: tlp 1.10.1 installed, =/etc/tlp.d/01-custom.conf= written (EPP balance_performance/power + platform-profile per power source; 80% charge cap present but commented off), service enabled and active, systemd-rfkill masked per TLP docs. Verified: tlp-stat runs, EPP reads balance_performance on AC. Codified in archsetup commit =adb39f2= as a battery-gated block. +** DONE [#B] Remove unnecessary linux-firmware packages (velox only) :quick: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Done live on velox 2026-06-10. Hardware re-verified first (i915 graphics, ath9k wifi), then removed the meta + 12 subpackages (the task's 9 plus liquidio/mellanox/nfp/qlogic from the finer 2026 split), keeping intel + atheros + whence. The meta needed =-Rdd= — mkinitcpio-firmware declares a dep on it; the dangling dep is cosmetic. Initramfs rebuilt clean (warnings only for absent hardware), wifi stayed connected. Codified in archsetup commit =adb39f2= as a DMI-gated Framework-Intel block. Full confidence needs the next reboot — see Manual testing below. +** DONE [#B] Identify and replace packages no longer in repos +CLOSED: [2026-06-11 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-11 +:END: +Shipped 2026-06-11 as =1f89523=: =scripts/audit-packages.sh= (unit-tested) makes the check repeatable, and its first run over 420 packages found four casualties, all fixed in the same commit — libva-mesa-driver (folded into mesa), nvidia-dkms → nvidia-open-dkms, swww → awww (set-theme's stale swww call fixed in dotfiles =4ea35a1=), libappindicator-gtk3 → libayatana-appindicator. Re-run anytime: =scripts/audit-packages.sh=. +** DONE [#B] Verify package origin for all packages +CLOSED: [2026-06-11 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-11 +:END: +Covered by the same auditor (=1f89523=): it flags movers in both directions. Current state: zero official packages wrongly routed through aur_install-only territory; 15 aur_install entries have graduated to official repos (duf, flameshot, gist, inxi, nsxiv, nvm, papirus-icon-theme, ptyxis, qt5ct, qt6ct, ttf-lato, ueberzug, warpinator, xcolor, xdg-desktop-portal-hyprland). Left as-is deliberately — yay resolves repo packages fine — but switching them to pacman_install is a clean :quick: cleanup whenever wanted; the auditor lists them on every run. +** DONE [#B] Automate script usage tracking :solo: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Shipped 2026-06-10 as dotfiles commit =e5044b8=: =script-usage= in =common/.local/bin/= (10 unit tests). Reads zsh extended + bash history, reports last-used date per ~/.local/bin script, =--unused= lists the never-seen set. First run on ratio: 109 scripts, 98 unseen by the current (short) history window. +** DONE [#B] Automate dotfile validation :solo: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Shipped 2026-06-10 as dotfiles commit =2054da4=: =dotfiles-validate= in =common/.local/bin/= (11 unit tests). Extracts commands from hypr exec/bind-exec lines, waybar exec/on-click/on-scroll values, and systemd user-unit Exec* lines, then verifies each resolves. First run found 4 real orphans — see the follow-up task below. + +*** 2026-06-11 Thu @ 00:44:41 -0500 All 4 orphaned references fixed; validator fully clean +Both emacs.service units repointed to /usr/bin/emacs (dotfiles =cd15d9b=), and per Craig's call the tor-browser and virtualbox keybinds were dropped rather than backed by installs (dotfiles =e4cb4c2= — Ctrl+Alt+W and Super+V now free). dotfiles-validate: 102 references checked, all resolve. +** DONE [#B] Document evaluation criteria and trade-offs +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Written 2026-06-10: [[file:docs/2026-06-10-tool-evaluation-criteria.org][docs/2026-06-10-tool-evaluation-criteria.org]] — four gating criteria (Wayland-native, actively maintained with live verification, automation-compatible, stowable config), five weighting criteria, the process, and the trade-offs accepted in the 2026-06-10 evaluation round. +** DONE [#B] Add org-capture popup frame on keyboard shortcut +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Shipped 2026-06-10, all five spec steps: =quick-capture= script (dotfiles =08ae188=, 3 unit tests, notify-on-failure when the daemon's down), Hyprland window rules in current 0.53+ syntax (float, 900x500, center, stay_focused on title org-capture) + Super+Shift+N bind (same commit), and the auto-close hook in =org-capture-config.el= (.emacs.d =1a25fada=, .elc recompiled, loaded live). Verified end-to-end on ratio: popup opens floating/centered with the template menu (screenshot), frame auto-deletes on org-capture-kill — finalize uses the same hook. Existing capture templates untouched. +** DONE [#C] Create Chrome theme with dupre colors :quick:solo: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Shipped 2026-06-10 as archsetup commit =4736058=: unpacked-extension theme at =assets/color-themes/dupre/chrome-theme/= (manifest.json + README with the color mapping and load-unpacked install steps). Visual check is yours — see Manual testing below. +** DONE [#C] Install Zoxide integration into Ranger :quick: +CLOSED: [2026-06-10 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-10 +:END: +Shipped 2026-06-10 as dotfiles commit =220dde6=: jchook/ranger-zoxide vendored (with MIT license) into both =common/= and =minimal/= ranger plugin dirs — :z and :zi commands wherever ranger runs. Python syntax verified; live verification is yours (see Manual testing) and needs a machine with ranger installed — note neither Wayland box has it, and the same-day file-manager evaluation recommends yazi over porting ranger forward. +** DONE [#D] Add retry logic to git_install function :quick: +CLOSED: [2026-06-10 Wed] +Already shipped before this review — commit =798b86f= gave git_install the same MAX_INSTALL_RETRIES loop as pacman/aur, with a clean-slate build dir per attempt. The task predates the fix; closing as done. +** DONE [#B] Org-capture popup frame split (quick-task Super+Shift+N) +CLOSED: [2026-06-13 Sat] SCHEDULED: <2026-06-12 Fri> +:PROPERTIES: +:LAST_REVIEWED: 2026-06-12 +:END: +Resolved: .emacs.d fixed it config-side (single-window display + cj/quick-capture command); archsetup pointed the popup script at cj/quick-capture (8cc1be7). Verified end-to-end on ratio. +The quick-capture popup opens split in two windows — a top sliver of the daemon's last-visited buffer plus the =*Org Select*= menu below — so the two stacked modelines read like tmux status bars. Root cause: =org-mks= displays the template menu via =org-switch-to-buffer-other-window=, splitting the fresh popup frame instead of taking it over. + +Coordinating with the .emacs.d project: handoff sent 2026-06-12 18:59 requesting a config-side fix scoped to frames named =org-capture= (handoff note + screenshot evidence delivered to .emacs.d's inbox, since processed and removed). Waiting on its reply in this project's inbox; then verify the popup end-to-end on ratio (Super+Shift+N → single-window menu → single-window capture buffer). Fallback if .emacs.d declines: carry the fix in the dotfiles =quick-capture= script's =-e= elisp. + +Related finding, no change needed: whole-desktop screenshot already exists at CTRL+Super+S (=screenshot fullscreen=, grim fires before the fuzzel menu so popups survive). Possible follow-up decision: rebind Super+Shift+S (currently layout-switch to scrolling) if Craig wants fullscreen capture there. + +*** 2026-06-12 Fri @ 20:21:00 -0500 Incorporated .emacs.d's fix and verified end-to-end +.emacs.d replied same evening with two notes (now in [[file:assets/outbox/2026-06-12-1947-from-.emacs.d-org-capture-popup-singlewindow-reply.org][outbox]] and [[file:assets/outbox/2026-06-12-2006-from-.emacs.d-quick-capture-script-change.org][outbox]]): the single-window fix landed config-side (frame-scoped =display-buffer-alist=, 7 ERT tests, live in the daemon), plus a new =cj/quick-capture= command (Task/Bug/Event only, global-inbox targets, frame closes on every exit path, 12 ERT tests). Our side: test-first one-line change in the dotfiles =quick-capture= script — =(org-capture)= → =(cj/quick-capture)= — suite 15/15 green, live immediately via stow. Verified on ratio with sendshortcut-driven popups + grim: menu single-window with the 3-template subset, capture buffer single-window targeting =CAPTURE-inbox.org=, no orphan frames, nothing leaked into the inbox file. Verification reply + screenshot evidence sent back to .emacs.d. Remaining: commit the dotfiles change (Craig's gate) and the Super+Shift+S rebind decision. +** DONE [#C] Silent notifications for the mic-mute toggle :quick:solo: +CLOSED: [2026-06-11 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-11 +:END: +Shipped 2026-06-11 as dotfiles =a4ae4a4=, minutes after filing: =--silent= on all four of mic-toggle's notify calls (Muted/Live/unknown/fail), tests assert the flag on every path (5/5, full suite 15 suites green), and a live round-trip on ratio confirmed the toggle works with the toast and without the chime. velox picks it up on next pull. +** DONE [#B] Create package inventory system +CLOSED: [2026-06-14 Sun] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-13 +:END: +Satisfied by =scripts/package-inventory= (the same script that closes "Automate the inventory comparison" above). It lists archsetup's declared packages, lists the live system's packages, and prints the diff in both directions. Design note: it compares explicit-vs-explicit (=pacman -Qqe= against declared =pacman_install=/=aur_install=), which is the meaningful comparison — the original "including dependencies" framing was superseded, since transitive deps are pulled automatically and listing full closures would only add noise. +*** 2026-06-14 Sun @ 22:13:48 -0500 Listed archsetup's declared packages — package-inventory extraction (pacman_install/aur_install + for-loop lists) +*** 2026-06-14 Sun @ 22:13:48 -0500 Listed live-system packages — package-inventory via pacman -Qqe / -Qq / -Qqen / -Qqem +*** 2026-06-14 Sun @ 22:13:48 -0500 Generated archsetup-vs-system diff — package-inventory, both directions, AUR/official split +** DONE [#B] Automate the inventory comparison :test:solo: +CLOSED: [2026-06-14 Sun] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-13 +:END: +Make package diff a runnable script instead of manual process + +Resolved 2026-06-14: the runnable script already existed — =scripts/package-inventory= (built 2026-02-06) extracts archsetup's declared packages and diffs them against the live system (=--summary= / =--archsetup-only= / =--system-only= / full report). This pass added the missing coverage: 7 characterization tests in =tests/package-inventory/= pinning the extraction and both diff directions behind injectable =PKGINV_ARCHSETUP= / =PKGINV_PACMAN= seams, plus a =make package-diff= target for discoverability. Full unit suite green (26 tests, 3 suites). +** DONE [#C] paru vs yay — evaluated, staying with yay +CLOSED: [2026-06-10 Wed] +Research done 2026-06-10: [[file:docs/2026-06-10-paru-vs-yay-evaluation.org][docs/2026-06-10-paru-vs-yay-evaluation.org]]. The maintenance picture inverted since the task was filed: yay released v12.6.0 on 2026-06-07 with active triage, while paru has had no release in 11 months, no commit in 5, and a stable that fails to build against current libalpm (issue #1468 open 6 months). For an installer that bootstraps the AUR helper unattended, paru is the riskier choice on every axis that matters. No decision needed — the evidence closes this one; revisit only if paru's maintenance resumes. +** DONE [#B] Idle-inhibitor keybind + synced waybar indicator :hyprland:waybar: +CLOSED: [2026-06-23 Tue] +Shipped 2026-06-23 as dotfiles commit =a004201=. Super+I toggles the hypridle daemon (kill = inhibit, relaunch = restore). The built-in waybar =idle_inhibitor= module was replaced with a =custom/idle= module backed by a =waybar-idle= script, so the keybind, the bar click, and the icon share one source of truth (whether hypridle is running) and stay in sync. Icons inhibited / active, with a 5s poll safety net. Freed =Super+I= by pruning the unused ai-term pyprland scratchpad from both host configs. TDD'd (=waybar-idle= + =hypridle-toggle= suites); dupre/hudson theme CSS updated. From a home-project handoff 2026-06-23; Craig confirmed it works live. +** DONE [#B] Verify package signature verification not bypassed by --noconfirm +CLOSED: [2026-06-23 Tue] +:PROPERTIES: +:LAST_REVIEWED: 2026-05-21 +:END: +Audited 2026-06-23. =--noconfirm= does not bypass signature verification — it only auto-answers interactive prompts. Signature checking is governed by =SigLevel= in =/etc/pacman.conf=, which archsetup leaves at the Arch default (=Required DatabaseOptional=): its only pacman.conf edits are ParallelDownloads, Color, and enabling multilib (=archsetup:913,917=), none of which touch =SigLevel=. So every repo package stays signature-verified regardless of =--noconfirm=. + +One real integrity bypass exists, and it is not =--noconfirm=: =archsetup:2403= runs =yay -S --noconfirm --mflags --skipinteg python-lyricsgenius=, where =--skipinteg= skips makepkg's checksum and PGP-signature checks for that one AUR package (a documented workaround for an expired-signature issue upstream). It's scoped to a single package, not global. Tracked for periodic re-check below. +** DONE [#C] Harden sshd in the installer (explicit prohibit-password) :solo: +CLOSED: [2026-06-24 Wed] +Done 2026-06-24: the openssh block (=archsetup:1271-1277=) now writes =/etc/ssh/sshd_config.d/10-hardening.conf= with =PermitRootLogin prohibit-password= and reloads sshd, right after starting the service. =PasswordAuthentication= left untouched so ssh-copy-id to the user still works. Makes the posture intentional rather than dependent on the upstream default. Velox and ratio (which carried an explicit =PermitRootLogin yes= at =sshd_config:33= from earlier provisioning) were already fixed by hand 2026-06-23. Verified =bash -n= + =shellcheck -S error= clean; full drop-in-on-fresh-install confirmation is VM-deferred (the unit harness covers helpers, not inline install steps). +** DONE [#C] Build security dashboard command :solo: +CLOSED: [2026-06-23 Tue] +:PROPERTIES: +:LAST_REVIEWED: 2026-05-21 +:END: +Shipped 2026-06-23 as dotfiles commit =1b9b205=: =security-status= (=common/.local/bin=, on PATH). Read-only dashboard showing disk encryption (LUKS *and* ZFS native — the fleet runs ZFS, so a LUKS-only check would have falsely reported "no encryption"), ufw state, externally-reachable ports (counts all listening, lists only the non-loopback exposures), and running/failed service counts. Command lookups are env-overridable; parsing covered by unit tests against canned output. New file, so ratio needs =git pull && make stow hyprland= to link it. +** DONE [#C] Teach archsetup to stow the host tier :solo: +CLOSED: [2026-06-23 Tue] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-11 +:END: +Already implemented in =user_customizations()= (=archsetup:1049-1058=): after stowing =common= + the DE package, it derives =host_tier="$(cat /etc/hostname 2>/dev/null || uname -n)"= and stows that package when =$dotfiles_dir/$host_tier= exists, else prints "no host tier for '<host>' — skipping". The =/etc/hostname=-first detection is the right call for install time (=uname -n= still reports the ISO's name until reboot), and it's the same skip-if-absent semantics as the dotfiles Makefile. Verified by reading the installer 2026-06-23; no code change needed. +** DONE [#C] Waybar indicators unevenly spaced :quick:solo:waybar: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +The right-side module icons don't sit at even intervals — spacing reads as inconsistent across the group. Noticed 2026-05-21 after adding the airplane indicator. + +Done 2026-06-24: a screenshot showed the standalone module icons were already even — the unevenness was the tray, whose icons clustered tight (tray =spacing: 4= vs the ~0.3rem margins on every other module). Bumped tray =spacing= 4 → 10 in the waybar =config=; restarting waybar and re-screenshotting confirmed the row reads even. The lever was the tray spacing, not the per-module CSS the original body guessed at. +** DONE [#B] Separate mpd playlist_directory from music_directory :mpd:music:quick: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +Done 2026-06-24 (dotfiles a9bfdf3): set =playlist_directory= to =~/.local/share/mpd/playlists= (separate from =music_directory= ~/music). git-moved the 73 radio-stream playlists from =common/music/= into =common/.local/share/mpd/playlists/= (history preserved); dropped the empty =60s Sounds.m3u= (Craig's call); git rm'd the stray =Black Flamingos - Space Bar.m4a= and moved the real track into the music library. Curated playlists left flat in ~/music (Craig's call — avoids rewriting the 7 relative-path ones). The ~/music/radio orphan was already gone. Relinked surgically (a pre-existing =whereami= stow conflict blocked a full =stow common=). mpd restarted clean: 73 radio playlists load from playlist_directory (verified SomaFM stream URLs), 24 curated browsable from the music tree. ratio needs the same restow + mpd restart on its next pull (reminder filed). Decisions answered: 60s dropped, curated flat. +Spec written and approved (option 1), pinned before execution on 2026-06-03. Root issue: mpd.conf has =playlist_directory= == =music_directory= == ~/music, so the whole audio library is the playlist store and radio streams mix with curated playlists. Option 1: radio stream playlists (portable, 73 in the dotfiles repo) move to a dedicated =playlist_directory= (=~/.local/share/mpd/playlists=) via stow; the 22 curated local playlists (machine-specific track refs) live in the music tree. Also removes the broken ~/music/radio/ orphan (73 dead symlinks). + +Full step-by-step spec (mpd.conf edit, repo restructure of =common/music/= → =common/.local/share/mpd/playlists/=, curated relocation, restow, verification incl. the 7 relative-path curated playlists, ratio propagation) is in the 2026-06-03 session record under .ai/sessions/. Two open decisions before executing: (1) drop the empty =60s Sounds.m3u= or refill with the SomaFM 60s URL; (2) curated playlists into =~/music/playlists/= subdir vs leave flat in ~/music/. Side cleanup surfaced: a stray audio file =Black Flamingos - Space Bar.m4a= is wrongly committed in the dotfiles repo's =common/music/= — git rm it and move to the synced library. +** DONE [#C] Install adopted modern CLI tools :tooling:solo: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +Done 2026-06-24: added bat/dust/hyperfine/doggo to archsetup General Utilities (tealdeer was already declared), installed all five on velox, set =BAT_THEME=ansi= in =common/.profile.d/tools.sh= (tracks the dupre terminal palette), seeded the tldr cache. ratio still needs the =pacman -S= (additive; lands on its next archsetup run). +Decision (Craig, 2026-06-24): adopt all five recommended tools — =bat=, =dust=, =hyperfine=, =tealdeer=, =doggo= (all in extra). Add them to archsetup's package list and install on both machines. Optional candidates (=xh=/=jless=/=sd=/=ouch=) declined for now. Full evaluation: [[file:docs/2026-06-10-modern-cli-tools-evaluation.org][docs/2026-06-10-modern-cli-tools-evaluation.org]]. + +- Add the five to the appropriate pacman package section in =archsetup=. +- =pacman -S bat dust hyperfine tealdeer doggo= on velox + ratio. +- =bat=: set =BAT_THEME= to match the dupre palette once installed. +- =tealdeer=: run =tldr --update= to seed the cache after install. +** DONE [#C] Review file manager options for Wayland +CLOSED: [2026-06-24 Wed] +Decision (Craig, 2026-06-24): keep nautilus only; skip yazi. File management lives in Emacs dired plus the Super+F dirvish popup, so a TUI file manager has no daily user here. ranger was already ruled out (frozen upstream). Full evaluation: [[file:docs/2026-06-10-file-manager-evaluation.org][docs/2026-06-10-file-manager-evaluation.org]]. Follow-on surfaced: nautilus needs dark theming (filed as its own task). +** DONE [#B] Theme nautilus to a dark theme :bug:solo: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +nautilus rendered blindingly white (Craig, 2026-06-24). As a GTK4/libadwaita app it follows the appearance portal's =org.freedesktop.appearance color-scheme=, which mirrors =org.gnome.desktop.interface color-scheme=. Two stacked causes: + +1. velox had no system-wide dconf db at all — no =/etc/dconf/profile/user=, no =/etc/dconf/db/site.d/00-archsetup-defaults=, no compiled =site= db — so archsetup's declared default (=color-scheme='prefer-dark'=, =archsetup:1109-1119=) never reached the machine (velox predates that block). Created the profile + site defaults as archsetup writes them and ran =dconf update=. =gsettings get= then returned =prefer-dark=. + +2. That alone did NOT fix the running session: a system-db default emits no GSettings change signal, so the appearance portal kept reporting =0= (no-preference → light), and libadwaita reads the portal, not =GTK_THEME=. (An early screenshot looked dark only because the shell env carries =GTK_THEME=Adwaita:dark=, which Hyprland-launched apps don't inherit — masking the real state.) Fix: a user-level =gsettings set org.gnome.desktop.interface color-scheme prefer-dark=, which signals the portal live. It now reports =1=, and a portal-driven nautilus (GTK_THEME unset) renders dark — screenshot-verified. + +Durable: the user value persists in =~/.config/dconf/user=; archsetup's system-db handles fresh installs (the portal reads the default fresh at login, so no signal is needed there). No archsetup change. ratio may need the same one-two — see the Active Reminder. +** CANCELLED [#D] Test wlogout menu on laptop +CLOSED: [2026-06-24 Wed] +Merged into the "Wlogout exit-menu buttons are rectangular, not square" task ([#C]) — same effort (per-host wlogout button sizing across velox/ratio). The fixed-pixel-margins hint was folded into that task's body. +** DONE [#B] Enlarge org-capture popup to scratchpad size :hyprland: +CLOSED: [2026-06-24 Wed] +From a .emacs.d inbox handoff (2026-06-15, captured via roam): the quick-capture / org-protocol popup is too small to be effective — it should be about the size of a terminal scratchpad. + +*** 2026-06-24 Wed @ 17:21:11 -0400 Sized the popup to the scratchpad, per-host in pixels +The 06-15 read was wrong: the real size lever is the Hyprland window rule, not the quick-capture char-cell count. The =size 900 500= rule on the org-capture window pinned it to 900x500 regardless of the frame's requested geometry (demoing 120x24 vs 180x32 looked identical because both clamped to 900x500). Tried a percentage rule (=size 75% 70%=) to auto-adapt per host like the pyprland scratchpad — native window rules do NOT honor percentages (only pyprland does), so the frame fell back to char-cell geometry and overflowed the screen. Fix: absolute pixels matching each host's terminal scratchpad, placed in the host tier (=<host>/conf.d/local.conf=) since pixels don't adapt across monitors. velox = 1078x671 (75%x70% of its 1437x958 logical desktop) — verified on-screen. ratio = 1892x936 (55%x65% of 3440x1440) — set but not yet eyeballed on ratio (tracked as an Active Reminder in notes.org). The shared hyprland.conf keeps float/center/stay_focused and a comment pointing at the per-host size. dotfiles change — needs commit in =~/.dotfiles=. + +*** 2026-06-15 Mon @ 19:19:55 -0500 AI Response: popup size is the frame's char-cell count, not the Hyprland rule +Triaged under auto inbox-zero. The popup is the emacsclient frame named "org-capture", created by =~/.dotfiles/hyprland/.local/bin/quick-capture= with =(width . 90) (height . 22)= — 90 columns by 22 lines. Emacs sizes by character cells and overrides the Hyprland rule =windowrule = match:title ^(org-capture)$, size 900 500= (hyprland.conf:182). The live frame measured ~889x860 px; the width tracks the 90-column count, not the window rule. Setting the Hyprland rule to =size 55% 65%= (the scratchpad's pyprland spec) did not change the frame width, so I reverted it — dotfiles left clean. + +Real lever: the column/line count in the quick-capture script. Scratchpad reference on ratio (DP-4, 3440x1440) is 55% 65% ~= 1892x936 px ~= 190 cols by 24 lines. Why this isn't a solo auto-fix — it needs a tradeoff decision: +- The script lives in the shared =hyprland/= stow tier, so a fixed ~190 columns overflows velox's 1920-wide laptop, and 24+ lines overflows velox's 1080 height (22 lines ~= 860 px is already near the safe max there). +- Emacs char-cell sizing doesn't adapt to the monitor the way pyprland's percentage does, so "scratchpad-size on both machines" needs one of: a fixed compromise count, a per-host override via the ratio/velox tiers, or a script that computes columns from the active monitor. +Options to weigh: (a) a safe-on-both compromise like width 120-130 / height 24; (b) per-host width through the ratio/velox tiers; (c) dynamic sizing in quick-capture from =hyprctl monitors=. Pick the tradeoff and I'll implement. +** DONE [#C] Highlight current month and year in the calendar hover :feature:waybar:quick:solo: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +From the roam inbox (2026-06-24): the waybar clock's calendar tooltip highlights today's date in goldenrod; the current month and year header should be goldenrod too. + +Done 2026-06-24: the date module is the custom =waybar-date= script (not the built-in clock), so the highlight lives in its tooltip markup. Added a sed wrapping line 1 of the current-month =cal= output (the centered "Month Year") in the same =#daa520= goldenrod the day highlight uses. Verified the tooltip JSON carries =<span color='#daa520'><b>June 2026</b></span>= with today's highlight intact and waybar live; the on-hover look is Craig's spot-check. +** DONE [#C] Wallpaper-set from dirvish doesn't work on Wayland :hyprland: +CLOSED: [2026-06-24 Wed] +From the roam inbox (2026-06-24, claimed for archsetup by Craig): typing =bg= in the dirvish popup doesn't change the wallpaper — Craig's read is it may still be wired to feh/X11 instead of a Wayland utility. + +Findings (2026-06-24): the Wayland wallpaper utility on this setup is =awww= (waypaper's configured =backend = awww=; =set-theme= sets the default via =awww img <file>=). There was no shared wallpaper script (=bg= on PATH is just the shell builtin), and the dirvish =bg= command lives in the Emacs config, so it was calling the wrong (or no Wayland) setter. + +Done 2026-06-24 (dotfiles 8be2484): added =set-wallpaper <image>= to the hyprland tier — sets live via =awww img= and persists the choice into =waypaper/config.ini=, the single Wayland-correct entry point. Resolves relative paths, validates the file, exits non-zero without persisting if awww fails. 8 Normal/Boundary/Error tests green; live-verified (awww set it, config rewrote). Notified =.emacs.d= to point the dirvish =bg= command at =set-wallpaper <file>= — that wiring is its piece (dependency cleared, =:blocker:= dropped). + +Follow-up (separate, small): the login restore =exec-once= in =hyprland.conf= is hardcoded to =trondheim-norway.jpg=, so a wallpaper set via =set-wallpaper= shows live but won't survive a relogin until the exec-once becomes =waypaper --restore= (which reads the now-persisted config). Filed below. +** DONE [#C] Proton Mail Bridge font size :chore:quick: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +From the roam inbox (2026-06-22): adjust the Proton Mail Bridge UI font to a comfortable size. The bridge is a Qt app, so it likely keys off Qt scaling or the qt5ct/qt6ct config like the other Qt apps (QT_SCALE_FACTOR or a font setting). + +Done 2026-06-24 (dotfiles =hyprland.conf:47=): the bridge is a Qt6 *QML* app, so it ignores the qt6ct General font — bumped the UI font via =QT_FONT_DPI= on the autostart instead. Changed the exec-once to =env QT_FONT_DPI=108 protonmail-bridge --no-window= (default DPI is 96; 108 = 1.125x). Iterated live with Craig: 120 too big, 108 comfortable. hyprland.conf is a stow symlink so the change is already live; applies at every login. The =~/.config/autostart/Proton Mail Bridge.desktop= entry is dormant under Hyprland (no XDG-autostart), so it was left as-is. +** DONE [#C] Wallpaper login-restore is hardcoded, not waypaper --restore :hyprland:quick:solo: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +The Hyprland =exec-once= (=hyprland.conf:26=) restores the wallpaper with a hardcoded =awww img ~/pictures/wallpaper/trondheim-norway.jpg=, so any wallpaper set later (via =set-wallpaper=, waypaper, or the dirvish =bg=) reverts on relogin. =set-wallpaper= now persists the choice to =waypaper/config.ini=, so switch the exec-once to =waypaper --restore= (after =awww-daemon= is up) to make set wallpapers survive a relogin. Small, dotfiles-only; verify by setting a different wallpaper, relogging, and confirming it sticks. + +Done 2026-06-24 (dotfiles): swapped the line-26 exec-once from the hardcoded =awww img …/trondheim-norway.jpg= to =awww-daemon & sleep 1 && waypaper --restore=. waypaper has a real =awww= backend (in its =--backend= list), the stowed =waypaper/config.ini= carries =backend = awww= plus a default =wallpaper == line, so =--restore= works on a fresh install too. Mechanism verified live: =waypaper --restore= reapplied the persisted wallpaper via awww, exit 0. Relogin confirmation filed under "Manual testing and validation". Follow-up filed: =set-wallpaper='s =mv= detached the live =waypaper/config.ini= from its stow symlink, so set-wallpaper changes no longer flow back to dotfiles. +** DONE [#B] Add backup before system file modifications :solo: +CLOSED: [2026-06-25 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +Safety net for /etc/X11/xorg.conf.d and other system file edits +Files like ~/etc/sudoers~, ~/etc/pacman.conf~, ~/etc/default/grub~ modified without backup +If modifications fail or are incorrect, difficult to recover - should backup files to ~.backup~ before modifying + +Done 2026-06-25: added a =backup_system_file <path>= helper next to =safe_rm_rf= — it snapshots a pre-existing file to =<path>.archsetup.bak= before an in-place edit, idempotent (never clobbers an existing backup, so the pristine original survives repeated edits and re-runs), =cp -p= to preserve mode/ownership, no-op when the file is absent. Took the narrow scope (Craig's call): route only the in-place =sed -i= / append edits to *pre-existing* files through it — locale.gen, makepkg.conf, pacman.conf, sudoers, conf.d/wireless-regdom, geoclue.conf, conf.d/pacman-contrib, fstab, mkinitcpio.conf, vconsole.conf — and skip the brand-new drop-in files archsetup fully owns (nothing to back up; recovery is just deleting them). Tests: =tests/backup-system-file/= (7 Normal/Boundary/Error, incl. mode-preserved, existing-backup-not-overwritten, missing-target no-op, cp-failure). =make test-unit= green across all 5 suites; =bash -n= clean; only shellcheck note is the known SC2329 false positive (indirect STEPS dispatch). Integration verification is the next VM run. +** DONE [#B] Migrate bare-metal test runner to Testinfra, then delete the shell sweep :test: +CLOSED: [2026-06-25 Thu] +Plan + ZFS-coverage expansion: [[file:docs/design/2026-06-25-zfs-vm-test-coverage.org]] (build a ZFS base VM via archangel + a =FS_PROFILE= selector so =make test= covers the ZFS path, then migrate this runner to key auth + Testinfra against it, then delete the dead =validation.sh= functions = phase E here). +=run-test.sh= (VM) now uses the Testinfra/pytest sweep as its authoritative validator, but =run-test-baremetal.sh= (lines ~243-244) still calls the old =run_all_validations= / =validate_all_services= from =scripts/testing/lib/validation.sh=. Migrate the bare-metal runner to =run_testinfra_validation= too (same key + ssh-config approach, adapted for a real host), then delete the now-dead shell-sweep functions from =validation.sh=. Keep the live helpers: =ssh_cmd=, =attribute_issue=, =capture_pre/post_install_state=, =analyze_log_diff=, =categorize_errors=, =generate_issue_report=, and the =VALIDATION_*= counters/arrays. Deferred from the Testinfra cutover because it needs a bare-metal test loop to validate, out of scope for the VM-only autonomous run. +*** 2026-06-25 Thu @ 12:37:02 -0400 P-A/P-B shipped (FS_PROFILE selector); P-C blocked on archangel ZFS-install bug +P-A + P-B landed in =353b179=: =archsetup-test-zfs.conf= (archangel ZFS config) + an =FS_PROFILE= (btrfs default / zfs) selector across =vm-utils.sh= (=init_vm_paths= derives a per-profile image + validates the profile), =create-base-vm.sh= (selects the archangel config), =run-test.sh= (--help + profile display), and the Makefile (=make test FS_PROFILE=zfs=). Design simplification recorded: no =archsetup-vm-zfs.conf= needed — archsetup auto-detects ZFS from the live root via =is_zfs_root()=, so the archsetup run config is shared; only the archangel base config + base image differ. Open Q1 resolved: archangel supports ZFS root natively (it's the default FS). + +P-C (build the ZFS base image) is BLOCKED on archangel. =create-base-vm.sh FS_PROFILE=zfs= built the disk + booted the archangel ISO fine, but the archangel install died: =dkms install zfs/2.3.3 -k 6.18.36-1-lts= exited 1, ZFS module not built. Root cause is in archangel, not archsetup: it appends the [archzfs] experimental repo then runs =pacstrap -K= with no =pacman -Sy= refresh, so it uses the archzfs sync db baked into the Feb-2026 ISO (zfs-dkms 2.3.3) while linux-lts is pulled fresh (6.18.36). 2.3.3 doesn't build against 6.18. velox runs zfs-dkms 2.4.2 on the same kernel from the same channel, so the fix exists upstream — archangel just needs to refresh the db before pacstrap (+ a fresh ISO). Bug + dependency handoff sent to archangel inbox (=2026-06-25-1236-from-archsetup-bug-zfs-install-fails-stale-baked.org=). Retry P-C once a fixed archangel ISO is available. P-D (bare-metal migration code) is still workable in the meantime against the btrfs VM / velox. + +*** 2026-06-25 Thu @ 16:05:07 -0400 archangel unblocked; ZFS base built; 3 archsetup bugs fixed (local); re-run paused +archangel shipped the fix (archangel =89691a0=: =pacman -Syy= before pacstrap) + rebuilt the ISO. With it, =create-base-vm.sh FS_PROFILE=zfs= built a verified ZFS-root base (=archsetup-base-zfs.qcow2=, clean-install snapshot, kernel 6.18.36). =make test FS_PROFILE=zfs= then surfaced three real archsetup bugs against the current archangel base, each fixed in a LOCAL (unpushed) commit: +- =8ed42b9= informant: the base ships informant; its pacman PreTransaction hook (AbortOnFail) blocked archsetup's first transaction. Fix: =informant read --all= up front (guarded). PROVEN. +- =66caeb5= pacman.conf perms: the base ships =/etc/pacman.conf= 0600 (archangel =strip_repo_stanza= mktemp+mv clobbers perms), breaking user =makepkg=/=yay=. Fix: =chmod 644= after archsetup's edits. PROVEN (run reached 75 min deep). +- =05ec096= reflector: archsetup configured reflector's timer but never ran it, so installs used the base's 425-mirror worldwide list and pacman stalled ~15 min on a slow/unresponsive mirror. Fix: run reflector once before the heavy installs (=timeout=-bounded, non-fatal). NOT yet integration-proven — the next re-run validates it. +Second archangel handoff sent for the pacman.conf-0600 root cause (=2026-06-25-1440-...=); archsetup's chmod is defensive, archangel should ship 0644. Paused before the re-run at Craig's request (he starts =sudo make test FS_PROFILE=zfs= from the laptop). Possible harness-side factor on the stall: slirp IPv6 blackholing (one stalled conn was IPv6) — watch if it recurs despite reflector. + +*** 2026-06-25 Thu @ 21:56:12 -0400 P-C GREEN — ZFS VM test path passes end to end +=make test FS_PROFILE=zfs= PASSED: archsetup exit 0 (full ~68-min ZFS install, reflector held — no stall), pytest =95 passed, 0 failed, 11 skipped=. The ZFS-conditional checks now run the ZFS branch instead of skipping: =test_bootloader_installed= (ZFSBootMenu EFI binary at /efi/EFI/ZBM), =test_mkinitcpio_hooks= (zfs udev hook), =test_console_font_configured= (vconsole.conf), =test_zfs_has_sanoid= all PASS; =test_backup_created_for_mkinitcpio= correctly SKIPs (ZFS+virtio edits nothing). The 3 archsetup issues (gamemode, mu, signal-cli AUR) are the known non-critical residuals, same as on btrfs. Four commits pushed to main: =8ed42b9= informant news-hook, =66caeb5= pacman.conf 0644, =05ec096= reflector-during-install, =eb379c3= ZFS-aware boot/backup tests. P-C (ZFS coverage, design phases A-C) is DONE. Remaining on this task: P-D (migrate run-test-baremetal.sh to inject_root_key + run_testinfra_validation) and P-E (delete the dead validation.sh shell sweep). +*** 2026-06-25 Thu @ 23:26:02 -0400 P-D + P-E done — whole epic closed +P-D (=771b92e=): migrated =run-test-baremetal.sh= to key auth + Testinfra. =inject_root_key= generalized to =root@$VM_IP= (vm-utils) so it serves both runners; the bare-metal runner now injects the key after the genesis rollback, threads =SSH_KEY_OPT= + a new =--port= through every ssh/scp, and validates via =run_testinfra_validation= instead of the shell sweep. Follow-up fix =fb495d4=: =set +e= around the validator (it returns pytest's rc, which under =set -e= aborted before the report) — caught by the smoke test. Validated against the ZFS VM (=--validate-only=, localhost:2222): connectivity, ZFS check, key auth, Testinfra connect+run, report all work; a green bare-metal install still needs real ZFS hardware. + +P-E (=a4a339b=): deleted the dead shell sweep from =validation.sh= now both runners use Testinfra — run_all_validations, validate_all_services, run_full_validation, the ~35 validate_* checks, validation_pass/fail/warn/skip. Kept the live helpers (ssh_cmd, attribute_issue, capture_pre/post_install_state, analyze_log_diff, categorize_errors, generate_issue_report, VALIDATION_* counters + arrays). 1156 → 314 lines. Verified: no dangling refs, both runners parse + smoke-run clean, unit suite green. + +Known follow-ups (not blockers): (1) archangel still owes the pacman.conf-0600 root-cause fix (handoff in its inbox; archsetup's chmod is the defensive layer). (2) The bare-metal runner runs =bash archsetup= with no --config-file — pre-existing, would prompt on real hardware; out of this epic's scope. (3) A true green bare-metal run needs real ZFS hardware (ratio). +** DONE [#B] Implement Testinfra test suite for archsetup +CLOSED: [2026-06-25 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +*** 2026-06-25 Thu @ Final fresh make test GREEN — Testinfra is the validator +=make test= (fresh build, 150-min cap) PASSED: =TEST PASSED=, =Validation: PASSED=, pytest =96 passed, 10 skipped, 0 failed, 0 errors=, pytest as the authoritative gate. ParallelDownloads now =10= on the fixed build. End-state: the VM test runner validates post-install via the Testinfra/pytest sweep (=scripts/testing/tests/=, 88 tests + conftest fixtures) — full parity with the old shell sweep plus expansion coverage (sshd hardening, =backup_system_file= .bak files, applied pacman/makepkg/NM/fail2ban/reflector config). Three real bugs surfaced + fixed by this work: (1) the 2026-06-24 sshd hardening had silently broken =make test= (root password SSH died mid-run → key auth, f50fc1d); (2) =ParallelDownloads= stuck at Arch's default 5 (sed only matched the commented form → fixed, 2d63802); (3) install monitor cap too tight at 90 min (→ 150, fe84b71). Follow-up filed: migrate =run-test-baremetal.sh= off the shell sweep, then delete the dead =validation.sh= functions (P5). +*** 2026-06-25 Thu @ Decision: port to Testinfra + expand coverage, design doc first +Reviewed against the existing harness: =scripts/testing/lib/validation.sh= already runs ~14 post-install checks (=run_all_validations=), so this isn't net-new capability — it's porting that shell validation to Testinfra/pytest for better expressiveness + reporting, then growing coverage. Craig's call (prioritizes test investment over feature speed): do the port and expand. Starting with a design doc in =docs/design/= per the task's own "design doc not yet written" note. Stale slice to drop/rescope: the X11/startx end-to-end tests (fleet is Wayland/Hyprland now). +*** 2026-06-25 Thu @ 00:54:22 -0400 P1 scaffold landed (advisory, alongside shell sweep) +Built the Testinfra harness skeleton: =scripts/testing/tests/= (conftest.py with the attribution marker + report hook + =target_user= fixture; 3 parity checks — user exists/shell, ufw enabled, dotfiles stowed+readable), =scripts/testing/lib/testinfra.sh= (=run_testinfra_validation=: ephemeral-key injection, ssh-config, pytest-over-SSH; advisory + non-fatal, =RUN_TESTINFRA= toggle), wired into run-test.sh after the shell sweep, and added =python-pytest python-pytest-testinfra= to =make deps=. Verified on host: py_compile clean, =pytest --collect-only= green in a throwaway venv (4 tests, fixtures resolve), =bash -n= + shellcheck clean, unit suite still green. Integration (the pytest sweep actually running against a VM) is unverified here — needs a =make test= run. Decisions locked: inject test key; run both through parity; full expansion (P4) in this task after the P3 cutover. +*** 2026-06-25 Thu @ 01:12:09 -0400 P2 full parity port (88 tests) +Ported the whole shell sweep to pytest: test_users (exists/shell/15 groups parametrized), test_packages (yay+functional, pacman, terminus-font, emacs+config readable, git, 5 dev tools), test_services (required enabled/active, enabled-only, timers, optional skip-if-absent, DoT drop-in, fail2ban/nmcli responds, log-cleanup cron, syncthing lingering, DNS/mDNS/docker skips), test_desktop (Hyprland tools+configs+portal+socket gated on install/compositor, DWM suckless, autologin), test_boot (grub, mkinitcpio hooks branched on zfs_root, console-font-in-initramfs, nvme gated, zfs/sanoid), test_keyring (dir 700/owner/default=login), test_archsetup (log no Error:, ≥12 state markers). conftest fixtures: target_user/home/zfs_root/has_nvme/hyprland_installed/dwm_installed/compositor_running/on_slirp. 88 tests collected, py_compile clean. Correctness fix vs the shell sweep: check =awww= not the stale =swww=. Installed python-pytest-testinfra on velox so the harness gate passes. Next: VM run to diff pytest vs shell sweep for parity. +*** 2026-06-25 Thu @ 01:24:11 -0400 Fixed: sshd hardening had silently broken =make test= +VM run #1 aborted ~6 min in (Error 5), before any validation ran. Root cause (pre-existing, not the Testinfra work): the 2026-06-24 sshd hardening sets =PermitRootLogin prohibit-password= + reloads sshd mid-install, and the harness SSHes as root by *password* throughout — so every op after that step got "Permission denied" and run-test.sh fataled before validations. Fix: =inject_root_key= authorizes a throwaway root key right after first SSH (before archsetup runs) and all helpers (=wait_for_ssh=/=vm_exec=/=copy_to_vm=/=copy_from_vm=/=ssh_cmd=) gained =$SSH_KEY_OPT= so they use key auth, which =prohibit-password= still allows. testinfra.sh reuses that key. Additive (password stays as fallback). bash -n + shellcheck clean. Re-running the VM suite to confirm it now reaches the validation + pytest phases. +*** 2026-06-25 Thu @ 03:33:33 -0400 Parity proven + P4 expansion validated on a live VM +VM run #3 (=make test-keep=, kept VM up): pytest parity = 78 passed / 10 skipped / 0 fail / 0 err — matches & exceeds the shell sweep (53/0/0). Then built P4 expansion against the live VM (iterating in ~30s, no rebuild): test_hardening (sshd prohibit-password, sysctl printk, /etc/issue emptied, vconsole font, /efi fmask), test_config_applied (pacman ParallelDownloads/Color/multilib, makepkg MAKEFLAGS/OPTIONS, NM dns+wifi-privacy drop-ins, fail2ban jail, reflector), test_backups (=.archsetup.bak= present for pacman.conf/makepkg.conf/sudoers/mkinitcpio.conf — end-to-end proof of the backup feature). Full suite vs live VM: 95 passed / 10 skipped / 1 fail. The 1 fail = a REAL archsetup bug the tests caught: =ParallelDownloads= stayed at the Arch default 5 because the sed only matched a commented =#ParallelDownloads=, but current Arch ships it uncommented — fixed the sed to match both (=^#\?ParallelDownloads=). Also fixed a test bug (=grep -qx '[multilib]'= → =grep -Fxq=, the brackets were a regex char class). Remaining: P3 cutover (pytest authoritative) + P5 retire shell sweep, then a final fresh =make test=. +*** 2026-06-25 Thu @ 03:38:28 -0400 P3 cutover: Testinfra is now the authoritative validator +run-test.sh dropped the =run_all_validations= + =validate_all_services= shell-sweep calls; =run_testinfra_validation= now drives =TEST_PASSED= (returns pytest's rc; "couldn't run" = fail, not a silent pass). It surfaces pytest's pass/skip/fail counts through the shared =VALIDATION_*= counters and parses =testinfra-attribution.txt= into the issue arrays so =generate_issue_report= still buckets failures archsetup/base/unknown. Validated the failure path against the still-up VM: pytest rc=1, failure correctly bucketed to [archsetup]. P5 (physically delete the dead shell-sweep functions) is NOT done here — =run-test-baremetal.sh= still calls =run_all_validations=/=validate_all_services=, so deletion must wait until the bare-metal runner is migrated too (filed below). Final step: fresh =make test= to confirm the pass path (ParallelDownloads now 10) with pytest as the gate. +*** 2026-06-25 Thu @ 08:35:26 -0400 Final run hit the harness 90-min install cap (not a regression) +The fresh =make test= timed out at 9/12 steps while building =vagrant= from AUR (=ARCHSETUP timed out after 90 minutes=, exit 124), so validation ran against a half-installed system → 10 pytest failures, all late-step (issue/sysctl/vconsole/mkinitcpio/docker/state-markers). The suite worked correctly — it caught an incomplete install. Verified my ParallelDownloads sed is clean (no pacman corruption) and archsetup logged 0 errors. Root cause: =MAX_POLLS=180= (90 min) is too tight for a full install with heavy AUR builds; bumped to 300 (150 min). Re-running. +Create comprehensive integration tests using Testinfra (Python + pytest) to validate archsetup installations + +Tests should cover: +- Smoke tests: user created, key packages installed, dotfiles present +- Integration tests: services running, configs valid, X11 starts, apps launch +- End-to-end tests: login as user, startx, open terminal, run emacs, verify workflows + +Framework: Testinfra with pytest (SSH-native, built-in modules for files/packages/services/commands) +Location: scripts/testing/tests/ directory +Integration: Run via pytest against test VMs after archsetup completes +Benefits: Expressive Python tests, excellent reporting, can test interactive scenarios + +A design doc (not yet written) should cover: +- Complete example test suite (test_integration.py) +- Tiered testing strategy (smoke/integration/end-to-end) +- How to run tests and integrate with run-test.sh +- Comparison with alternatives (Goss) @@ -419,6 +419,67 @@ if [ "$fresh_install" = "true" ]; then fi ### Pre-flight Checks +# NVIDIA/Wayland preflight core — pure report + status; the caller prompts. +# Scans DRM (then PCI display-class) modalias files for the NVIDIA vendor id +# (10DE); on a match prints the Wayland guidance + required env vars and +# checks the repo's candidate nvidia-utils major version. Globs overridable +# for tests: NVIDIA_DRM_GLOB, NVIDIA_PCI_GLOB. +# Returns: 0 = no NVIDIA GPU; 10 = NVIDIA, driver requirement met (>= 535); +# 11 = NVIDIA, requirement not met (repo driver too old or unknown). +nvidia_preflight_report() { + local drm_glob="${NVIDIA_DRM_GLOB:-/sys/class/drm/card*/device/modalias}" + local pci_glob="${NVIDIA_PCI_GLOB:-/sys/bus/pci/devices/*/modalias}" + local found=false modalias modalias_file + + # shellcheck disable=SC2086 # the unquoted vars ARE the globs + for modalias_file in $drm_glob; do + [[ -r "$modalias_file" ]] || continue + modalias=$(cat "$modalias_file" 2>/dev/null) + case "$modalias" in + *v000010DE*|*v000010de*) found=true ;; + esac + done + if [ "$found" != "true" ]; then + # shellcheck disable=SC2086 + for modalias_file in $pci_glob; do + [[ -r "$modalias_file" ]] || continue + modalias=$(cat "$modalias_file" 2>/dev/null) + # Only display-class devices (bc03), so an NVIDIA audio/usb + # function on the same card doesn't trigger the check. + if [[ "$modalias" == *bc03* ]]; then + case "$modalias" in + *v000010DE*|*v000010de*) found=true ;; + esac + fi + done + fi + [ "$found" = "true" ] || return 0 + + echo " [!!] NVIDIA GPU detected." + echo " Wayland/Hyprland on NVIDIA needs driver 535+ and explicit" + echo " environment variables; expect rougher edges than AMD/Intel:" + echo " LIBVA_DRIVER_NAME=nvidia" + echo " GBM_BACKEND=nvidia-drm" + echo " __GLX_VENDOR_LIBRARY_NAME=nvidia" + echo " ELECTRON_OZONE_PLATFORM_HINT=auto" + echo " archsetup installs nvidia-open-dkms (Turing+); pre-Turing" + echo " cards need an AUR legacy driver instead." + + local ver major + ver=$(pacman -Si nvidia-utils 2>/dev/null | awk '/^Version/ {print $3; exit}') + major="${ver%%.*}" + if [[ "$major" =~ ^[0-9]+$ ]] && [ "$major" -ge 535 ]; then + echo " [OK] NVIDIA driver candidate: ${ver} (>= 535)" + return 10 + fi + echo "ERROR: NVIDIA driver requirement not met" + echo " Required: driver 535 or newer for usable Wayland" + echo " Repo offers: ${ver:-unknown (pacman -Si nvidia-utils failed)}" + echo " Fix: refresh the package database (pacman -Syy) and retry, or" + echo " install with DESKTOP_ENV=dwm (X11) instead." + return 11 +} + preflight_checks() { echo "Running pre-flight checks..." @@ -460,6 +521,20 @@ preflight_checks() { fi echo " [OK] System: Arch Linux detected" + # NVIDIA + Wayland preflight: abort on a too-old driver, confirm on a + # detected card (the report prints the guidance + env vars). + local nvidia_rc=0 + nvidia_preflight_report || nvidia_rc=$? + if [ "$nvidia_rc" = "11" ]; then + exit 1 + elif [ "$nvidia_rc" = "10" ]; then + read -r -p "Continue installing for Wayland on NVIDIA? [Y/n]: " nvidia_go + case "$nvidia_go" in + [nN]*) echo "Aborted at NVIDIA preflight."; exit 1 ;; + *) echo " [OK] Continuing on NVIDIA." ;; + esac + fi + # Check locale configuration if grep -q "^LANG=" /etc/locale.conf 2>/dev/null; then current_locale=$(grep "^LANG=" /etc/locale.conf | cut -d= -f2) @@ -548,7 +623,7 @@ intro() { # count the arch packages before install pacman -Q > "$packages_before" || \ - error_fatal "generating pre-install package list" "$?" + error_fatal "generating pre-install package list" "$?" "confirm pacman works on this system: pacman -Q | head" } ### Error Handling @@ -564,10 +639,20 @@ error_warn() { } # Fatal error - log and exit -# Usage: error_fatal "what failed" "$?" +# Usage: error_fatal "what failed" "$?" ["recovery hint"] +# Every fatal points at the log (with its last lines inline), prints the +# per-site recovery hint when one is given, and names the resume path — +# archsetup's step markers mean a re-run continues where it stopped. error_fatal() { printf "CRASH: %s (error: %s) @ %s. Halting.\n" \ "$1" "$2" "$(date +'%T')" | tee -a "$logfile" + if [ -f "$logfile" ]; then + printf " Last log lines:\n" + tail -n 5 "$logfile" | sed 's/^/ | /' + printf " Full log: %s\n" "$logfile" + fi + [ -n "${3:-}" ] && printf " Fix: %s\n" "$3" | tee -a "$logfile" + printf " Then re-run archsetup - completed steps are tracked, so it resumes here.\n" | tee -a "$logfile" exit 1 } @@ -861,8 +946,8 @@ install_gpu_drivers() { if [ "$detected_nvidia" = "true" ]; then display "task" "NVIDIA GPU detected (via modalias) - installing drivers" # nvidia-dkms left the repos; nvidia-open-dkms is the packaged driver - # (Turing and newer — pre-Turing cards need an AUR legacy variant, - # see the NVIDIA preflight task). + # (Turing and newer — pre-Turing cards need an AUR legacy variant; + # nvidia_preflight_report warns about this at preflight). pacman_install nvidia-open-dkms pacman_install nvidia-utils pacman_install nvidia-settings @@ -918,13 +1003,14 @@ bootstrap_pacman_keyring() { fi action="ensuring current Arch Linux keyring" && display "task" "$action" - (pacman -Syy) >> "$logfile" 2>&1 || error_fatal "$action" "$?" + (pacman -Syy) >> "$logfile" 2>&1 || error_fatal "$action" "$?" \ + "check network and mirrors: ping archlinux.org; cat /etc/pacman.d/mirrorlist" (pacman -S --noconfirm archlinux-keyring) >> "$logfile" 2>&1 || \ - error_fatal "$action" "$?" + error_fatal "$action" "$?" "reinitialize the keyring: pacman-key --init && pacman-key --populate archlinux" display "task" "verifying Arch Linux keys" (pacman-key --populate archlinux >> "$logfile" 2>&1) || \ - error_fatal "verifying Arch Linux keys" "$?" + error_fatal "verifying Arch Linux keys" "$?" "reinitialize the keyring: pacman-key --init && pacman-key --populate archlinux" # The bulk refresh gets the same retry budget as per-package installs — # a single slow mirror ("Operation too slow") halted a full install at @@ -940,7 +1026,7 @@ bootstrap_pacman_keyring() { [ "$attempt" -lt "$MAX_INSTALL_RETRIES" ] && \ display "task" "retrying package cache refresh (attempt $((attempt + 1))/$MAX_INSTALL_RETRIES)" done - $refresh_ok || error_fatal "$action" "$?" + $refresh_ok || error_fatal "$action" "$?" "run pacman -Syu manually to see the failure, or switch mirrors in /etc/pacman.d/mirrorlist" } @@ -965,7 +1051,8 @@ configure_build_environment() { # Uncomment the selected locale in locale.gen (format: "en_US.UTF-8 UTF-8") locale_entry="${locale} ${locale##*.}" # e.g., "en_US.UTF-8 UTF-8" backup_system_file /etc/locale.gen - sed -i "s|^#${locale_entry}|${locale_entry}|" /etc/locale.gen + sed -i "s|^#${locale_entry}|${locale_entry}|" /etc/locale.gen 2>> "$logfile" || \ + error_warn "uncommenting $locale_entry in locale.gen" "$?" (locale-gen >> "$logfile" 2>&1) || error_warn "$action" "$?" echo "LANG=$locale" > /etc/locale.conf export LANG="$locale" @@ -996,11 +1083,13 @@ configure_build_environment() { backup_system_file /etc/pacman.conf # Match a commented OR already-uncommented ParallelDownloads: current Arch # ships it uncommented at 5, so a "^#"-only match silently leaves it at 5. - sed -i "s/^#\?ParallelDownloads.*$/ParallelDownloads = 10/;s/^#Color$/Color/" /etc/pacman.conf + sed -i "s/^#\?ParallelDownloads.*$/ParallelDownloads = 10/;s/^#Color$/Color/" /etc/pacman.conf 2>> "$logfile" || \ + error_warn "configuring pacman.conf (ParallelDownloads/Color)" "$?" # enable multilib repository (required for 32-bit libraries, Steam, etc.) action="enabling multilib repository" && display "task" "$action" - sed -i '/^#\[multilib\]/{s/^#//;n;s/^#//}' /etc/pacman.conf + sed -i '/^#\[multilib\]/{s/^#//;n;s/^#//}' /etc/pacman.conf 2>> "$logfile" || \ + error_warn "$action" "$?" # Keep pacman.conf world-readable. User-level makepkg/yay reads it to # resolve dependencies, so a root-only file makes every AUR build fail with @@ -1052,7 +1141,7 @@ EOF [ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers >> "$logfile" 2>&1 action="creating a directory to build/install software from git/AUR." - (mkdir -p "$source_dir") || error_fatal "creating the directory $source_dir" "$?" + (mkdir -p "$source_dir") || error_fatal "creating the directory $source_dir" "$?" "check permissions and free space: df -h" } @@ -1063,12 +1152,12 @@ create_user() { display "task" "checking if user exists" # halt if $username exists ( id -u "$username" >/dev/null 2>&1; ) && \ - error_fatal "user '$username' already exists" "user exists" + error_fatal "user '$username' already exists" "user exists" "pick a different USERNAME in archsetup.conf, or remove the user first: userdel -r $username" # create $username with home, group, shell, password action="creating user and home directory" && display "task" "$action" (useradd -m -G wheel -s /bin/zsh "$username" >> "$logfile" 2>&1) || \ - error_fatal "adding user '$username'" "$?" + error_fatal "adding user '$username'" "$?" "run the useradd manually to see why it failed" display "task" "assigning the password" echo "$username:$password" | chpasswd # any text is allowable! be careful! @@ -1077,7 +1166,7 @@ create_user() { display "task" "adding to appropriate groups" (usermod -aG \ sys,adm,network,scanner,power,uucp,audio,lp,rfkill,video,storage,optical,users \ - "$username" >> "$logfile" 2>&1) || error_fatal "adding $username to groups" "$?" + "$username" >> "$logfile" 2>&1) || error_fatal "adding $username to groups" "$?" "confirm the groups exist: getent group network rfkill video" display "task" "configuring shell" # zsh cache required: $username will install via yay; zsh will run those commands @@ -1086,19 +1175,19 @@ create_user() { # give $username sudo nopasswd rights (required for aur installs) action="granting permissions" && display "task" "$action" backup_system_file /etc/sudoers - (echo "%$username ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers) \ + (echo "%$username ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers) 2>> "$logfile" \ || error_warn "$action" "$?" # mount as ramdisk to speed aur/git build/installs if ! mountpoint -q "$source_dir" 2>/dev/null; then (mount -t tmpfs -o size=4G archsetup "$source_dir" >> "$logfile" 2>&1) || \ - error_fatal "mounting the RAM disk for archsetup" "$?" + error_fatal "mounting the RAM disk for archsetup" "$?" "check available memory (free -h) or mount a smaller tmpfs at $source_dir by hand" else display "task" "ramdisk already mounted at $source_dir" fi (chown -R "$username":wheel "$source_dir" >> "$logfile" 2>&1) || \ - error_fatal "changing ownership of $source_dir" "$?" + error_fatal "changing ownership of $source_dir" "$?" "run the chown manually to see the failure" # Bootstrap DNS for git clones and AUR installs (full config in essential_services) if [ ! -L /etc/resolv.conf ] || [ "$(readlink /etc/resolv.conf)" != "/run/systemd/resolve/stub-resolv.conf" ]; then @@ -1152,7 +1241,7 @@ clone_user_repos() { # Q5: the --adopt/restore conflict handling below needs a real git checkout. # Refuse to continue if the clone didn't produce one (bad URL, network, a # tarball drop) rather than silently skipping the restore step. - [[ -d "$dotfiles_dir/.git" ]] || error_fatal "dotfiles dir is not a git checkout: $dotfiles_dir" 1 + [[ -d "$dotfiles_dir/.git" ]] || error_fatal "dotfiles dir is not a git checkout: $dotfiles_dir" 1 "move that directory aside so archsetup can clone fresh, or point DOTFILES_DIR at a real clone" # root runs stow/restore against the user-owned clone; mark it safe. git config --global --add safe.directory "$dotfiles_dir" >> "$logfile" 2>&1 || true @@ -1199,10 +1288,10 @@ prune_waybar_battery() { action="removing waybar battery module (no battery detected)" && display "task" "$action" waybar_config="/home/$username/.config/waybar/config" # Remove "battery" from sysmonitor modules array and fix trailing comma - sed -i '/"battery"$/d' "$waybar_config" - sed -i 's/"custom\/disk",/"custom\/disk"/' "$waybar_config" + sed -i '/"battery"$/d' "$waybar_config" 2>> "$logfile" || error_warn "$action" "$?" + sed -i 's/"custom\/disk",/"custom\/disk"/' "$waybar_config" 2>> "$logfile" || error_warn "$action" "$?" # Remove the battery config block - sed -i '/"battery": {/,/^ },$/d' "$waybar_config" + sed -i '/"battery": {/,/^ },$/d' "$waybar_config" 2>> "$logfile" || error_warn "$action" "$?" fi } @@ -1306,14 +1395,14 @@ aur_installer() { if ! (sudo -u "$username" git clone --depth 1 "$yay_repo" "$build_dir" >> "$logfile" 2>&1); then error_warn "cloning source code for yay - directory may exist, removing and retrying" "$?" (safe_rm_rf "$build_dir" "$source_dir" >> "$logfile" 2>&1) || \ - error_fatal "removing existing directory for yay" "$?" + error_fatal "removing existing directory for yay" "$?" "remove the build dir manually: rm -rf $source_dir/yay" (sudo -u "$username" git clone --depth 1 "$yay_repo" "$build_dir" >> "$logfile" 2>&1) || \ - error_fatal "re-cloning source code for yay after cleanup" "$?" + error_fatal "re-cloning source code for yay after cleanup" "$?" "check that aur.archlinux.org is reachable" fi action="packaging and installing yay"; display "task" "$action" (cd "$build_dir" && sudo -u "$username" makepkg --noconfirm -si >> "$logfile" 2>&1) || \ - error_fatal "$action" "$?" + error_fatal "$action" "$?" "confirm base-devel is installed (pacman -S base-devel), then re-run" } ### Essential Services @@ -1372,7 +1461,8 @@ EOF wireless_region="${current_lang:3:2}" # extract country code (positions 3-4) action="configuring wireless regulatory domain ($wireless_region)" && display "task" "$action" backup_system_file /etc/conf.d/wireless-regdom - sed -i "s|^#WIRELESS_REGDOM=\"${wireless_region}\"|WIRELESS_REGDOM=\"${wireless_region}\"|" /etc/conf.d/wireless-regdom + sed -i "s|^#WIRELESS_REGDOM=\"${wireless_region}\"|WIRELESS_REGDOM=\"${wireless_region}\"|" /etc/conf.d/wireless-regdom 2>> "$logfile" || \ + error_warn "$action" "$?" # Encrypted DNS (DNS over TLS) @@ -1502,7 +1592,7 @@ configure_firewall() { # netfilter modules. This is a test environment limitation, not a bug. # On real hardware with proper kernel support, UFW activates correctly. action="verifying firewall is active" && display "task" "$action" - if ! ufw status | grep -q "Status: active"; then + if ! ufw status 2>> "$logfile" | grep -q "Status: active"; then error_messages=("FIREWALL NOT ACTIVE - run: sudo ufw enable" "${error_messages[@]}") error_warn "$action" "1" fi @@ -1533,7 +1623,8 @@ configure_service_discovery() { action="configuring geoclue to use BeaconDB location service" && display "task" "$action" if grep -q '^#url=https://api.beacondb.net/v1/geolocate' /etc/geoclue/geoclue.conf 2>/dev/null; then backup_system_file /etc/geoclue/geoclue.conf - sed -i 's|^#url=https://api.beacondb.net/v1/geolocate|url=https://api.beacondb.net/v1/geolocate|' /etc/geoclue/geoclue.conf + sed -i 's|^#url=https://api.beacondb.net/v1/geolocate|url=https://api.beacondb.net/v1/geolocate|' /etc/geoclue/geoclue.conf 2>> "$logfile" || \ + error_warn "$action" "$?" fi # Whitelist gammastep in geoclue config (geoclue demo agent is started via hyprland.conf exec-once) @@ -1585,7 +1676,8 @@ configure_package_cache() { action="configuring paccache to keep 3 versions" && display "task" "$action" backup_system_file /etc/conf.d/pacman-contrib - sed -i 's/^PACCACHE_ARGS=.*/PACCACHE_ARGS=-k3/' /etc/conf.d/pacman-contrib + sed -i 's/^PACCACHE_ARGS=.*/PACCACHE_ARGS=-k3/' /etc/conf.d/pacman-contrib 2>> "$logfile" || \ + error_warn "$action" "$?" } @@ -1778,6 +1870,39 @@ configure_btrfs_snapshots() { } +configure_pre_pacman_snapshots() { + # ZFS only: a pacman PreTransaction hook that snapshots the root dataset + # before every transaction, with a self-pruning script that keeps the most + # recent 10 (sanoid ignores these — they aren't autosnap_ names). This is + # the transaction-triggered complement to the scheduled sanoid snapshots in + # configure_zfs_snapshots. + # + # Called from boot_ux (the last step), NOT from configure_zfs_snapshots + # (which runs early), so the hook doesn't fire during the install's own + # package operations — the first pre-pacman snapshot is the fresh system. + is_zfs_root || return 0 + + action="installing pre-pacman snapshot script" && display "task" "$action" + cp "$user_archsetup_dir/scripts/zfs-pre-snapshot" /usr/local/bin/zfs-pre-snapshot + chmod +x /usr/local/bin/zfs-pre-snapshot + + action="installing pre-pacman snapshot hook" && display "task" "$action" + mkdir -p /etc/pacman.d/hooks + cat << 'EOF' > /etc/pacman.d/hooks/zfs-snapshot.hook +[Trigger] +Operation = Upgrade +Operation = Install +Operation = Remove +Type = Package +Target = * + +[Action] +Description = Creating ZFS snapshot before pacman transaction... +When = PreTransaction +Exec = /usr/local/bin/zfs-pre-snapshot +EOF +} + configure_user_lingering() { # User Services Lingering @@ -1866,6 +1991,10 @@ hyprland() { action="Hyprland Utilities" && display "subtitle" "$action" aur_install pyprland # scratchpads, magnify, expose (fixes special workspace issues) pacman_install waybar # status bar + pacman_install gtk4-layer-shell # custom/net connection panel (GTK4 layer-shell) + pacman_install python-gobject # PyGObject for the net panel + pacman_install blueprint-compiler # regenerate the net panel's .ui from .blp (dev; committed .ui runs without it) + aur_install speedtest-go-bin # net panel speed test backend pacman_install fuzzel # app launcher (native Wayland, pinentry support) pacman_install awww # wallpaper daemon (swww successor; provides swww) aur_install waypaper # wallpaper GUI (awww backend) @@ -1900,7 +2029,8 @@ hyprland() { # Apply camera settings when Logitech BRIO is connected ACTION=="add", SUBSYSTEM=="video4linux", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="085e", ATTR{index}=="0", RUN+="/home/ARCHSETUP_USERNAME/.local/bin/logitech-brio-settings.sh /dev/%k" UDEVEOF - sed -i "s/ARCHSETUP_USERNAME/${username}/" /etc/udev/rules.d/99-logitech-brio.rules + sed -i "s/ARCHSETUP_USERNAME/${username}/" /etc/udev/rules.d/99-logitech-brio.rules 2>> "$logfile" || \ + error_warn "personalizing the Logitech BRIO udev rule" "$?" chmod 644 /etc/udev/rules.d/99-logitech-brio.rules fi @@ -1961,7 +2091,7 @@ display_server() { action="Skipping display server (DESKTOP_ENV=none)" && display "task" "$action" ;; *) - error_fatal "Unknown DESKTOP_ENV: $desktop_env. Valid options: dwm, hyprland, none" "1" + error_fatal "Unknown DESKTOP_ENV: $desktop_env. Valid options: dwm, hyprland, none" "1" "set DESKTOP_ENV in archsetup.conf (or --desktop-env) to one of the valid options" ;; esac } @@ -1980,7 +2110,7 @@ window_manager() { action="Skipping window manager (DESKTOP_ENV=none)" && display "task" "$action" ;; *) - error_fatal "Unknown DESKTOP_ENV: $desktop_env. Valid options: dwm, hyprland, none" "1" + error_fatal "Unknown DESKTOP_ENV: $desktop_env. Valid options: dwm, hyprland, none" "1" "set DESKTOP_ENV in archsetup.conf (or --desktop-env) to one of the valid options" ;; esac } @@ -2098,7 +2228,8 @@ desktop_environment() { # Bluetooth Devices action="Bluetooth System" && display "subtitle" "$action" - for software in bluez bluez-utils blueman; do + # blueman retired 2026-07-02: the dotfiles bt panel + bar module replace it + for software in bluez bluez-utils; do pacman_install "$software" done pacman_install solaar # Logitech device manager @@ -2372,6 +2503,14 @@ install_programming_languages() { pacman_install npm # Node-js package manager aur_install nvm # Node-js version manager + # Eask (Emacs package build/test CLI) — chime and linear-emacs shell out to + # it. Global npm install as the user with a ~/.local prefix so eask lands + # on PATH at ~/.local/bin/eask without root (the stowed ~/.npmrc pins the + # same prefix for future npm -g installs). + action="installing eask via npm" && display "task" "$action" + (sudo -u "$username" bash -c 'npm install -g --prefix "$HOME/.local" @emacs-eask/cli' >> "$logfile" 2>&1) || \ + error_warn "$action" "$?" + # AI coding assistant (native install to ~/.local/bin), opt-out via # INSTALL_CLAUDE_CODE=no / --no-claude-code. Gated because it's curl|sh from # a third party and not every user wants AI tooling. @@ -2473,10 +2612,28 @@ install_vpn_tools() { action="VPN Tools" && display "subtitle" "$action" pacman_install wireguard-tools # VPN - add configs to /etc/wireguard/ pacman_install systemd-resolvconf # resolvconf for wg-quick DNS integration - pacman_install proton-vpn-gtk-app # Proton VPN GUI client with system tray + # proton-vpn-gtk-app retired 2026-07-02: the net panel's Tunnels view + # drives the official CLI, and the two can't run concurrently + pacman_install proton-vpn-cli # Proton VPN official CLI pacman_install tailscale # mesh VPN - run 'tailscale up' to authenticate - run_task "enabling tailscale service" systemctl enable tailscaled + # --now: the operator grant below needs the daemon answering (an unstarted + # tailscaled refuses `tailscale set`). Starting pre-auth is harmless. + run_task "enabling tailscale service" systemctl enable --now tailscaled + + # Operator mode lets $username run tailscale up/down without sudo — the + # net panel's Tunnels view relies on it. Brief retry: the daemon can take + # a moment to accept its socket right after the start. + action="granting tailscale operator to $username" && display "task" "$action" + operator_ok=false + for _attempt in 1 2 3 4 5; do + if tailscale set --operator="$username" >> "$logfile" 2>&1; then + operator_ok=true + break + fi + sleep 2 + done + $operator_ok || error_warn "$action" "1" } @@ -2576,7 +2733,7 @@ supplemental_software() { else aur_install slack-desktop # team messaging fi - aur_install zoom # video conferencing + # zoom retired 2026-07-02: zoom-web (dotfiles) opens meetings in the browser pacman_install iperf3 # network bandwidth testing pacman_install bind # DNS utilities (dig, host, nslookup) pacman_install net-tools # network tools (netstat for security auditing) @@ -2663,6 +2820,7 @@ boot_ux() { configure_tlp_power trim_firmware configure_grub + configure_pre_pacman_snapshots # ZFS only; late so it skips the install itself } tighten_efi_permissions() { @@ -2674,7 +2832,7 @@ tighten_efi_permissions() { && ! grep -E "^[^#].*[[:space:]]/efi[[:space:]]+vfat[[:space:]]" /etc/fstab | grep -q "fmask="; then action="tightening /efi mount permissions in fstab" && display "task" "$action" backup_system_file /etc/fstab - sed -i -E '/^[^#].*[[:space:]]\/efi[[:space:]]+vfat[[:space:]]/ s/([[:space:]]+vfat[[:space:]]+)([^[:space:]]+)/\1\2,fmask=0177,dmask=0077/' /etc/fstab \ + sed -i -E '/^[^#].*[[:space:]]\/efi[[:space:]]+vfat[[:space:]]/ s/([[:space:]]+vfat[[:space:]]+)([^[:space:]]+)/\1\2,fmask=0177,dmask=0077/' /etc/fstab 2>> "$logfile" \ || error_warn "$action" "$?" fi @@ -2716,7 +2874,7 @@ configure_initramfs_hook() { if ! is_zfs_root; then action="delegating fsck messages from udev to systemd" && display "task" "$action" backup_system_file /etc/mkinitcpio.conf - sed -i '/^HOOKS=/ s/\budev\b/systemd/' /etc/mkinitcpio.conf || error_warn "$action" "$?" + sed -i '/^HOOKS=/ s/\budev\b/systemd/' /etc/mkinitcpio.conf 2>> "$logfile" || error_warn "$action" "$?" mkinitcpio -P >> "$logfile" 2>&1 || error_warn "running mkinitcpio -P to silence fsck messages" "$?" fi @@ -2865,6 +3023,8 @@ outro() { for msg in "${error_messages[@]}"; do printf " - %s\n" "$msg" | tee -a "$logfile" done + printf " Each error's command output is in the log: grep -n 'ERROR:' %s\n" "$logfile" | tee -a "$logfile" + printf " Fix the cause and re-run archsetup for anything that must succeed.\n" | tee -a "$logfile" fi printf "\n" diff --git a/assets/2026-07-03-instrument-console-panels-build-summary.org b/assets/2026-07-03-instrument-console-panels-build-summary.org new file mode 100644 index 0000000..a7a3768 --- /dev/null +++ b/assets/2026-07-03-instrument-console-panels-build-summary.org @@ -0,0 +1,107 @@ +#+TITLE: Instrument-Console Panel Rebuild — Build Summary +#+DATE: 2026-07-03 +#+AUTHOR: Craig Jennings & Claude + +Findings summary for the no-approvals speedrun that rebuilt the net and +bluetooth waybar panels as single-screen instrument consoles. Spec: +[[file:../docs/design/2026-07-03-instrument-console-panels-spec.org][2026-07-03-instrument-console-panels-spec.org]] (ID e73877f5, IMPLEMENTED). +Normative design: [[file:2026-07-03-instrument-console-panels-prototype.html][2026-07-03-instrument-console-panels-prototype.html]] +(Craig approved through five prototype iterations). + +* What shipped + +Both panels went from a tabbed Blueprint UI (Connections / Diagnostics / +Performance style tabs) to one always-visible instrument console: a faceplate +with a state lamp and word, engraved section labels, scrolled lamp rows for +the live entities, a row of console keys, cairo dial meters, and an output +well that streams diagnostics in place. No terminals, no tabs. + +Six phases, each committed and pushed on landing: + +| Phase | Commit (dotfiles) | What landed | +|-------+----------------------+-------------------------------------------------| +| 1 | (spec + task wiring) | Spec authored, parent task wired with :SPEC_ID: | +|-------+----------------------+-------------------------------------------------| +| 2 | 81ec9c3 | Net GTK-free presenter layer + engine verbs | +| | | (52 new tests) | +|-------+----------------------+-------------------------------------------------| +| 3+4 | 800ef60 | Net view rebuilt as the console + all | +| | | interactions wired | +|-------+----------------------+-------------------------------------------------| +| 5a | 5318b34 | Bt GTK-free layer + engine gaps (47 new tests) | +|-------+----------------------+-------------------------------------------------| +| 5b | 66f03d9 | Bt view rebuilt as the console + all | +| | | interactions wired | +|-------+----------------------+-------------------------------------------------| +| 6 | f4e688e | Dead-code removal, build close-out | +|-------+----------------------+-------------------------------------------------| + +* Engine gaps closed + +The console needed capabilities the tabbed panels never had: + +- Net: =manage.wifi_radio= (nmcli radio wifi on/off), =manage.device_up= + (ethernet takes the route), =sysio.link_speed_mbps= (/sys wired speed), + =connections.ethernet_devices=, and a hidden-SSID flag on =manage.add=. +- Bt: =btctl.set_alias= renames a device through the bluez D-Bus Alias via + busctl (there is no MAC-addressed one-shot for set-alias, so =device_path= + discovers the controller node from the object tree), =manage.rename= wraps + it with a verify-after read, =parse_info= reads the Alias as the display + name, and =doctor= grew =on_report= / =on_begin= callbacks so its checks and + repairs stream into the output well. + +* Tests added + +- Phase 2: 52 new net presenter tests (581 net total at the time). +- Phase 5a: 47 new bt console tests. +- Both panels' AT-SPI smokes rewritten to drive the single-screen console and + anchor on stable engraved labels + the panel-unique console key (net DOCTOR, + bt SCAN) rather than the flaky count labels. +- Full suite through phase 6: 46 suites, zero failures. + +* Live-verify results (velox, 2026-07-03) + +- =make test=: 46 suites green, zero FAILED/ERROR. +- =make test-panel= (net): green end to end — faceplate NET·01 / ONLINE, one + Close, DOCTOR + SPEED TEST keys, engraved CHANNEL/CONSOLE/NETWORKS/TUNNELS, + live route line, tunnel rows (tailscale + 7 WireGuard NM), DOCTOR streamed + real diagnose steps, output-well dismiss, panel closed on Close. +- =make test-panel-bt= (bt): green end to end — faceplate BT·01 / POWERED, one + Close, adapter-power switch, DOCTOR + SCAN keys, engraved + ADAPTER/CONSOLE/NEARBY/PAIRED, discoverable chip, battery gauge slots, DOCTOR + streamed real checks, output-well dismiss, panel closed on Close. +- Both =gui.py= files are byte-identical to their screenshot-verified commits + (net 800ef60, bt 66f03d9), so the phase-3/4/5 screenshots (render matching + the prototype) still stand — phase 6 touched no view code. + +* Phase-6 dead code removed (dotfiles f4e688e) + +The console builds its widget tree in Python, so the old Gtk.Template page +classes and Blueprint sources had no caller: + +- =net/src/net/pages.py=, =bluetooth/src/bt/pages.py= +- both panels' =ui/= dirs (=window_content=, =diagnostics_page=, + =connections_page= / =devices_page= — the =.blp= sources and compiled =.ui=) +- the =make ui= Blueprint-compile target and its =.PHONY= entry (no =.blp= + files remain to compile) +- a stale =gui.py / pages.py= mention in the bt =viewmodel.py= docstring + +Confirmed nothing imported the removed modules before deleting; 46 suites and +both smokes stayed green after. + +* Folded tasks closed with this build + +- Network panel redesign — no terminals, verify-everything, full failure + coverage (its failure-mode catalog stays as the standing diagnose/repair + completeness reference). +- Bluetooth panel: switch placement + panel title. +- Bluetooth panel: rename devices. + +* Deferrals + +Interactions that mutate Craig's real bluetooth state can't be auto-driven — +they need a human at the keyboard with real devices. Filed as a manual-test +checklist under "Manual testing and validation" in todo.org: pair-passkey +flow, rename a real device, connect/disconnect, forget, discoverable toggle, +power toggle, and the LOW BATT badge with a real sub-15% device. Net's +in-panel speedtest and timer-dialog manual tests were already pending there. diff --git a/assets/2026-07-03-instrument-console-panels-prototype.html b/assets/2026-07-03-instrument-console-panels-prototype.html new file mode 100644 index 0000000..0258f20 --- /dev/null +++ b/assets/2026-07-03-instrument-console-panels-prototype.html @@ -0,0 +1,1359 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Instrument consoles — network + bluetooth</title> +<style> +:root { + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.masthead{max-width:1280px;margin:0 auto 1.8rem} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:80ch} +.masthead b{color:var(--silver);font-weight:700} + +.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start} +.slot{width:400px} +.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem} +.aside{flex:1 1 300px;min-width:280px} +.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.24em;text-transform:uppercase;margin:1.1rem 0 .5rem} +.aside h3:first-child{margin-top:.2rem} +.aside ul{list-style:none} +.aside li{font-size:.82rem;padding:.22rem 0 .22rem 1.1rem;position:relative} +.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.aside li em{color:var(--dim);font-style:normal} +.aside li b{color:var(--cream);font-weight:700} +.demo-box{border:1px dashed var(--wash);border-radius:10px;padding:.8rem 1rem;margin-top:1rem} +.demo-box label{display:flex;gap:.6rem;align-items:center;font-size:.82rem;cursor:pointer;color:var(--silver);margin-top:.45rem} +.demo-box label:first-child{margin-top:0} +.demo-box input{accent-color:#dab53d} +.demo-box .hint{color:var(--dim);font-size:.73rem;margin:.15rem 0 0 1.5rem} +.reset{font:inherit;font-size:.78rem;color:var(--silver);background:transparent;border:1px solid var(--wash); + border-radius:8px;padding:.4rem .9rem;cursor:pointer;margin-top:.8rem} +.reset:hover{background:var(--wash)} + +.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative; + transition:opacity .25s,transform .25s} +.panel.closed{opacity:0;transform:translateY(-8px);pointer-events:none} +.reopen{display:none;font:inherit;font-size:.75rem;color:var(--gold);background:transparent; + border:1px dashed var(--gold);border-radius:8px;padding:.5rem 1rem;cursor:pointer;margin-top:.6rem} + +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto; + box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.55)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px} +.b-id{display:flex;align-items:center;gap:9px} +.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em} +.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto} +.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold); + border-radius:4px;padding:1px 6px;display:none} +.badge.show{display:inline-block} +.badge.red{background:var(--fail);color:var(--cream)} +.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem; + cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto} +.x-btn:hover{background:var(--wash);color:var(--silver)} +.switch{width:38px;height:20px;border-radius:10px;background:var(--wash); + border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px; + border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:19px;background:var(--gold)} + +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;margin:12px 0 6px} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:12px} +.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer} +.engrave .act:hover{color:var(--gold)} + +.chan .line1{display:flex;align-items:baseline;gap:9px} +.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px} +.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px} +.chan .chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash)} +.chan .chip:hover{color:var(--gold)} +.chan .chip.on{color:var(--gold)} +.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px} +.ladder i{width:4px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px} +.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px} +.ladder.l1 i:nth-child(-n+1){background:var(--gold)} +.ladder.l2 i:nth-child(-n+2){background:var(--gold)} +.ladder.l3 i:nth-child(-n+3){background:var(--gold)} +.ladder.l4 i{background:var(--gold)} + +/* section row budgets: lists never grow the panel — they scroll inside it, + cut at a half row so the peek says "there's more" */ +.sec-scroll{overflow-y:auto;overscroll-behavior:contain} +#networks.sec-scroll{max-height:160px} +#tunnels.sec-scroll{max-height:131px} +#b-paired.sec-scroll{max-height:160px} +#b-nearby.sec-scroll{max-height:131px} +.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px} +.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer;position:relative} +.lamp-row:hover{background:var(--wash)} +.lamp-row .who{color:var(--silver);white-space:nowrap} +.lamp-row .who b{color:var(--cream)} +.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.lamp-row.busy{pointer-events:none} +.lamp-row .zap,.lamp-row .pen{display:none;color:var(--dim);border:0;background:transparent;font:inherit;font-size:.85rem; + cursor:pointer;border-radius:5px;padding:0 5px;flex:0 0 auto} +.lamp-row:hover .zap,.lamp-row:hover .pen{display:inline-block} +.lamp-row .zap:hover{color:var(--fail)} +.lamp-row .pen:hover{color:var(--gold)} +.lamp-row.armed-soft{background:rgba(218,181,61,.10)} +.lamp-row.armed-soft .what{color:var(--gold)} +.lamp-row.armed{background:rgba(203,107,77,.12)} +.lamp-row.armed .what{color:var(--fail)} +.lamp-row.armed .zap{display:inline-block;color:var(--fail)} + +.console-btns{display:flex;gap:8px;margin-top:2px} +.c-btn{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11.5px; + background:linear-gradient(180deg,#23211e,#191715);color:var(--silver); + border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.c-btn:hover{color:var(--gold);border-color:var(--gold)} +.c-btn:active{transform:translateY(1px)} +.c-btn:disabled{opacity:.4;pointer-events:none} + +.meters{display:flex;gap:12px;margin-top:10px} +.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;cursor:default;position:relative} +.meter.testing{animation:flash 1s ease-in-out infinite;border-color:var(--gold)} +@keyframes flash{50%{box-shadow:0 0 10px 1px rgba(218,181,61,.35)}} +.meter.held{border-color:var(--gold);cursor:pointer} +.meter .hold-tag{display:none;position:absolute;top:6px;right:8px;font-size:.56rem;letter-spacing:.2em; + color:var(--panel);background:var(--gold);border-radius:3px;padding:0 4px} +.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)} +.meter .mode-tag.test{color:var(--gold)} +.meter .mode-tag.off{color:var(--dim)} +.meter.held .hold-tag{display:block} +.meter .dial{position:relative;height:52px;overflow:hidden;margin-top:13px} +.meter .arc{position:absolute;inset:0 0 -52px 0;border:2px solid var(--wash);border-radius:50%} +.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:10px;background:var(--steel);transform-origin:50% 52px} +.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:44px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(-60deg);border-radius:2px; + box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .45s cubic-bezier(.3,1.3,.5,1)} +.meter .needle.dead{background:var(--wash);box-shadow:none} +.meter .needle.low{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)} +.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)} +.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums} +.meter .m-value small{color:var(--dim);font-weight:400} +.meter .m-value.low{color:var(--fail)} +.meter .m-label{color:var(--steel);font-size:.62rem;letter-spacing:.26em;text-align:center;margin-top:2px; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.meter-note{color:var(--dim);font-size:10px;text-align:center;margin-top:5px;min-height:1.2em} + +.well{border:1px solid var(--wash);border-radius:10px;background:var(--well)} +.outwrap{position:relative;margin-top:10px} +.outwrap .output{margin-top:0} +.o-clear{display:none;position:absolute;top:3px;right:5px;z-index:2;color:var(--dim); + border:0;background:var(--well);font:inherit;font-size:.8rem;cursor:pointer; + border-radius:50%;width:20px;height:20px;line-height:1} +.o-clear:hover{color:var(--silver);background:var(--wash)} +.outwrap.has .o-clear{display:block} +.output{margin-top:10px;padding:8px 10px;max-height:170px;overflow-y:auto;font-size:11.5px} +.output:empty{padding:4px 10px;min-height:10px} +.o-step{display:flex;gap:8px;align-items:flex-start;padding:2.5px 0} +.o-step .lamp{margin-top:4px;width:7px;height:7px} +.o-step .t b{color:var(--cream);font-weight:700} +.o-step .t .why{color:var(--dim);display:block;font-size:10.5px} +.o-step .t .ev{color:var(--steel);display:block;font-size:11px} +.o-step.repair .t b{color:var(--gold)} +.o-line{padding:2px 0;color:var(--silver)} +.o-line b{color:var(--steel);font-weight:400} +.o-verdict{margin-top:5px;padding-top:5px;border-top:1px solid var(--wash);color:var(--gold);font-weight:700} +.o-verdict.ok{color:var(--pass)} +.o-tip{color:var(--dim);font-size:10.5px;margin-top:4px} + +.toast{margin-top:9px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px; + padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em} +.toast.show{opacity:1} +.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)} + +.overlay{position:absolute;inset:0;background:rgba(10,12,13,.82);border-radius:14px;display:none; + align-items:center;justify-content:center;z-index:5} +.overlay.show{display:flex} +.dlg{background:var(--panel);border:1px solid var(--gold);border-radius:12px;padding:16px 18px;width:300px} +.dlg h4{color:var(--cream);font-size:13px;margin-bottom:4px} +.dlg .sub{color:var(--dim);font-size:11px;margin-bottom:10px} +.dlg .passkey{color:var(--gold-hi);font-size:22px;font-weight:700;letter-spacing:.18em; + text-align:center;margin:6px 0 12px;font-variant-numeric:tabular-nums} +.dlg input{width:100%;font:inherit;font-size:12.5px;color:var(--silver);background:var(--well); + border:1px solid var(--wash);border-radius:7px;padding:7px 9px;margin-bottom:8px;caret-color:var(--gold)} +.dlg input:focus{outline:none;border-color:var(--gold)} +.dlg .dlg-btns{display:flex;gap:8px;justify-content:flex-end;margin-top:4px} +.btn{font:inherit;font-size:12px;cursor:pointer;background:var(--slate);color:var(--cream); + border:1px solid var(--gold);border-radius:8px;padding:5px 12px} +.btn:hover{background:var(--slate-hi)} +.btn.quiet{background:transparent;border-color:var(--wash);color:var(--silver)} +.btn.quiet:hover{background:var(--wash)} + +*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent} +::-webkit-scrollbar{width:6px;height:6px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px} +::-webkit-scrollbar-thumb:hover{background:var(--slate-hi)} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div> + <h1>Network + Bluetooth — the pair</h1> + <p>Same faceplate, same idioms: lamp rows act on click, hover reveals ✎ rename and the + arm-to-fire ✕, gauges under the console keys (throughput needles on NET, battery fuel + gauges on BT), doctor streams and repairs in the output well. <b>Try the power switch + on BT·01</b> — everything follows it.</p> +</header> + +<div class="stage"> + + <!-- ============================ NET·01 ============================ --> + <div class="slot"> + <div class="slot-label">net·01 — as iterated</div> + <div class="panel" id="p"> + <div class="overlay" id="ov"> + <div class="dlg"> + <h4 id="dlg-title">Join network</h4> + <div class="sub" id="dlg-sub">WPA2 — password required</div> + <input id="dlg-ssid" placeholder="SSID" style="display:none"> + <input id="dlg-pass" type="password" placeholder="password"> + <div class="dlg-btns"> + <button class="btn quiet" onclick="dlgClose()">Cancel</button> + <button class="btn" id="dlg-go" onclick="dlgGo()">Join</button> + </div> + </div> + </div> + + <div class="b-face"> + <div class="b-id"> + <span class="lamp" id="lamp"></span> + <span class="state-word" id="state">ONLINE</span> + <span class="badge" id="badge">TUNNEL</span> + <span class="badge" id="air-badge">AIRPLANE</span> + <span class="unit">NET·01</span> + <span class="switch on" id="n-power" onclick="wifiPower()" title="WiFi radio"></span> + <button class="x-btn" onclick="closePanel()" title="Close (Esc)">✕</button> + </div> + </div> + + <div class="engrave">channel</div> + <div class="chan"> + <div class="line1"><span class="ssid" id="ch-ssid">@Hyatt_WiFi</span> + <span class="ladder l3" id="ch-ladder"><i></i><i></i><i></i><i></i></span> + <span class="dim" style="font-size:11px;white-space:nowrap" id="ch-dbm">-59 dBm · 44 ms</span></div> + <div class="line2" id="ch-route">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div> + </div> + + <div class="engrave">networks<span class="cnt" id="net-count"></span><span class="act" onclick="dlgHidden()" title="Join a hidden SSID">+ hidden</span></div> + <div id="networks" class="sec-scroll"></div> + + <div class="engrave">tunnels<span class="cnt" id="tun-count"></span></div> + <div id="tunnels" class="sec-scroll"></div> + + <div class="engrave">console</div> + <div class="console-btns"> + <button class="c-btn" id="b-doctor" onclick="runDoctor()">DOCTOR</button> + <button class="c-btn" id="b-speed" onclick="runSpeed()">SPEED TEST</button> + </div> + + <div class="meters"> + <div class="meter" id="m-rx" onclick="release('rx')"> + <span class="mode-tag" id="mt-rx">LIVE</span> + <span class="hold-tag">HOLD</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-rx"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-rx">0.1</span> <small>Mbps</small></div> + <div class="m-label">RX · DOWN</div> + </div> + <div class="meter" id="m-tx" onclick="release('tx')"> + <span class="mode-tag" id="mt-tx">LIVE</span> + <span class="hold-tag">HOLD</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-tx"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-tx">0.1</span> <small>Mbps</small></div> + <div class="m-label">TX · UP</div> + </div> + </div> + <div class="meter-note" id="m-note"></div> + + <div class="outwrap" id="outwrap"> + <button class="o-clear" onclick="clearOut('out')" title="Dismiss results">✕</button> + <div class="well output" id="out"></div> + </div> + <div class="toast" id="toastEl"></div> + </div> + <button class="reopen" id="reopen" onclick="openPanel()">reopen NET·01 (bar click)</button> + </div> + + <!-- ============================ BT·01 ============================ --> + <div class="slot"> + <div class="slot-label">bt·01 — new, same idioms</div> + <div class="panel" id="bp"> + <div class="overlay" id="bov"> + <div class="dlg"> + <h4 id="bdlg-title">Pair device</h4> + <div class="sub" id="bdlg-sub">confirm the passkey matches the device</div> + <div class="passkey" id="bdlg-key" style="display:none">847 291</div> + <input id="bdlg-name" placeholder="device name" style="display:none"> + <div class="dlg-btns"> + <button class="btn quiet" onclick="bdlgClose()">Cancel</button> + <button class="btn" id="bdlg-go" onclick="bdlgGo()">Confirm</button> + </div> + </div> + </div> + + <div class="b-face"> + <div class="b-id"> + <span class="lamp" id="b-lamp"></span> + <span class="state-word" id="b-state">POWERED</span> + <span class="badge red" id="b-badge">LOW BATT</span> + <span class="badge" id="b-air-badge">AIRPLANE</span> + <span class="unit">BT·01</span> + <span class="switch on" id="b-power" onclick="btPower()" title="Adapter power"></span> + <button class="x-btn" onclick="closeBt()" title="Close (Esc)">✕</button> + </div> + </div> + + <div class="engrave">adapter</div> + <div class="chan"> + <div class="line1"><span class="ssid">intel ax211</span> + <span class="dim" style="font-size:11px;margin-left:auto">hci0</span></div> + <div class="line2" id="b-adapter-line"> + <span class="chip" id="b-disco" onclick="btDisco()">discoverable off</span> + <span> · </span><span id="b-conn-count">1 device connected</span></div> + </div> + + <div class="engrave">paired<span class="cnt" id="b-paired-count"></span></div> + <div id="b-paired" class="sec-scroll"></div> + + <div class="engrave">nearby<span class="cnt" id="b-nearby-count"></span><span class="act" id="b-scan-note"></span></div> + <div id="b-nearby" class="sec-scroll"></div> + + <div class="engrave">console</div> + <div class="console-btns"> + <button class="c-btn" id="bb-doctor" onclick="btDoctor()">DOCTOR</button> + <button class="c-btn" id="bb-scan" onclick="btScan()">SCAN</button> + </div> + + <div class="meters"> + <div class="meter" id="bm-0"> + <span class="mode-tag" id="bmt-0">LIVE</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="bn-0"></div><div class="hub"></div></div> + <div class="m-value" id="bvw-0"><span id="bv-0">72</span> <small>%</small></div> + <div class="m-label" id="bl-0">LOGI M650</div> + </div> + <div class="meter" id="bm-1"> + <span class="mode-tag off" id="bmt-1">—</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle dead" id="bn-1"></div><div class="hub"></div></div> + <div class="m-value" id="bvw-1"><span id="bv-1">—</span></div> + <div class="m-label" id="bl-1">NO DEVICE</div> + </div> + </div> + <div class="meter-note">battery · connected devices</div> + + <div class="outwrap" id="b-outwrap"> + <button class="o-clear" onclick="clearOut('b-out')" title="Dismiss results">✕</button> + <div class="well output" id="b-out"></div> + </div> + <div class="toast" id="b-toastEl"></div> + </div> + <button class="reopen" id="b-reopen" onclick="openBt()">reopen BT·01 (bar click)</button> + </div> + + <!-- ============================ NOTES ============================ --> + <div class="aside"> + <h3>The bt mapping</h3> + <ul> + <li><b>Power switch on the faceplate</b> — flip it: devices drop, gauges die, keys disable, state goes OFF. Flip back: the mouse auto-reconnects. <em>(the switch-placement ask, in console form)</em></li> + <li><b>Battery fuel gauges</b> are BT's meters — one per connected device, needle at charge, red under 15% with a LOW BATT badge on the faceplate.</li> + <li><b>Paired rows toggle on click</b> (connect/disconnect), exactly like tunnels. Hover: ✎ rename <em>(the rename ask)</em>, ✕ arm-to-forget.</li> + <li><b>Nearby rows pair on click</b> — passkey confirm dialog, then the device moves up to PAIRED and connects. SCAN refreshes the neighborhood.</li> + <li><b>discoverable off</b> in the adapter line is a click-toggle (gold when on).</li> + <li><b>Disconnect is arm-first on the active row</b> — first click arms in gold ("disconnect? click again"), second fires. Gold, not terracotta: disruptive, not destructive.</li> + <li><b>NET·01 grew the wifi radio switch</b> (faceplate, same spot as BT's). Airplane mode is system-level: both switches drop, AIRPLANE badges light, and a switch flipped under airplane refuses with the way out. A plugged ethernet cable keeps NET·01 online through it.</li> + <li><b>DOCTOR does it all here too</b>: adapter → radio → service → powered → devices → audio profile. Tick the degraded-audio switch and run it: it finds HSP, flips to A2DP, verifies the sink followed.</li> + </ul> + <div class="demo-box"> + <label><input type="checkbox" id="cafe" onchange="setScenario(this.checked)"> net: walk into a new café</label> + <label><input type="checkbox" id="breakdns"> net: broken hotel DNS (then DOCTOR)</label> + <label><input type="checkbox" id="ethercb" onchange="setEther(this.checked)"> net: plug in an ethernet cable</label> + <label><input type="checkbox" id="aircb" onchange="setAirplane(this.checked)"> both: airplane mode (Super+Shift+A)</label> + <label><input type="checkbox" id="airportcb" onchange="setAirport(this.checked)"> both: airport terminal (crowded airspace)</label> + <label><input type="checkbox" id="lowbatt" onchange="btLowBatt(this.checked)"> bt: mouse battery low</label> + <label><input type="checkbox" id="badaudio"> bt: degraded audio profile (then DOCTOR)</label> + <div class="hint">the audio one needs the headphones connected — click WH-1000XM4 first.</div> + <button class="reset" onclick="location.search=''">reset prototypes</button> + </div> + </div> +</div> + +<script> +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const $ = id => document.getElementById(id); +const T = f => reduced ? Math.max(10, f*0.02) : f; + +/* =========================================================== NET·01 */ +let busy = false; +const HOTEL_NETS = () => [ + {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:true, sig:3, active:true}, + {id:'meeting', ssid:'Hyatt_Meeting', sec:'WPA2', stored:false, range:true, sig:3, active:false}, + {id:'roku', ssid:'DIRECT-roku-882',sec:'WPA2',stored:false, range:true, sig:2, active:false}, + {id:'xfinity', ssid:'xfinitywifi', sec:null, stored:false, range:true, sig:1, active:false}, + {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false}, +]; +const CAFE_NETS = () => [ + {id:'cafe5g', ssid:'CafeAmore_5G', sec:'WPA2', stored:false, range:true, sig:4, active:false, ip:'10.11.4.27/22 · gw 10.11.4.1'}, + {id:'cafeg', ssid:'CafeAmore_Guest',sec:null, stored:false, range:true, sig:3, active:false, ip:'10.11.8.102/22 · gw 10.11.8.1'}, + {id:'iot', ssid:'Neighbor_IoT', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false}, + {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false}, +]; +const AIRPORT_NETS = () => [ + {id:'ord', ssid:'ORD Free Wi-Fi', sec:null, stored:false, range:true, sig:4, active:false, ip:'10.40.2.19/18 · gw 10.40.0.1'}, + {id:'boingo',ssid:'Boingo Hotspot', sec:null, stored:false, range:true, sig:3, active:false}, + {id:'united',ssid:'United_Club', sec:'WPA2', stored:false, range:true, sig:3, active:false}, + {id:'sky', ssid:'SkyClub_5G', sec:'WPA2', stored:false, range:true, sig:3, active:false}, + {id:'aa', ssid:'AmericanAir-Lounge',sec:'WPA2', stored:false, range:true, sig:2, active:false}, + {id:'sbux', ssid:'Starbucks WiFi', sec:null, stored:false, range:true, sig:2, active:false}, + {id:'tom', ssid:"Tom's iPhone", sec:'WPA2', stored:false, range:true, sig:2, active:false}, + {id:'hp', ssid:'HP-Print-88-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'gate', ssid:'Gate B12 Display', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'clear', ssid:'CLEAR-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'tsa', ssid:'TSA-Ops', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'dfw', ssid:'ORD-Employee', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false}, + {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false}, +]; +const AIRPORT_NEARBY = () => [ + {id:'ap1', name:'AirPods Pro', kind:'audio', passkey:'118 402'}, + {id:'ap2', name:'AirPods (3rd gen)', kind:'audio', passkey:'220 981'}, + {id:'gb2', name:'Galaxy Buds2', kind:'audio', passkey:'914 555'}, + {id:'pbp', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'}, + {id:'xm5', name:'Sony WF-1000XM5', kind:'audio', passkey:'774 210'}, + {id:'jab', name:'Jabra Elite 7', kind:'audio', passkey:'333 190'}, + {id:'aw', name:'Apple Watch', kind:'wearable',passkey:'602 118'}, + {id:'gar', name:'Garmin Fenix 8', kind:'wearable',passkey:'488 007'}, + {id:'tile', name:'Tile Tracker', kind:'tracker', passkey:'150 129'}, + {id:'jbl2', name:'JBL Charge 5', kind:'audio', passkey:'847 291'}, + {id:'bose', name:'Bose QC Ultra', kind:'audio', passkey:'962 340'}, + {id:'gate2',name:'[TV] Gate B12', kind:'display', passkey:'302 118'}, +]; +let NETS = HOTEL_NETS(); +const WG = (id, who) => ({id, who, upWhat:'10.2.0.2/32 · route owner', + downWhat:'wireguard (NM) · down', up:false, ownsRoute:true, dev:'wgpvpn'}); +const TUNNELS = [ + {id:'ts', who:'tailscale · velox', upWhat:'100.127.238.103 · 4/6 peers', downWhat:'down', + up:true, ownsRoute:false, dev:'tailscale0'}, + WG('usny','USNY'), WG('usdc','USDC'), WG('uscala','USCALA'), WG('uscasf','USCASF'), + WG('usgaat','USGAAT'), WG('szur1','switzerlan-zurich1'), WG('szur2','switzerlan-zurich2'), + {id:'proton', who:'Proton VPN CLI', upWhat:'', downWhat:'down', up:false, needsLogin:true}, +]; +const tinit = () => ({ts:true, usny:false, usdc:false, uscala:false, uscasf:false, + usgaat:false, szur1:false, szur2:false, proton:false}); +const tstate = tinit(); +let connected = true; +let routeBase = '172.20.2.108/20 · gw 172.20.0.1'; +let ether = { present:false, routed:false, + ip:'172.20.7.44/20 · gw 172.20.0.1', dev:'enp3s0' }; +let armed = null, armTimer = null; +let wifiOn = true, airplane = false; +let armedDisc = null, armDiscTimer = null; +let lastSsid = '@Hyatt_WiFi'; + +function renderNets(){ + const host = $('networks'); host.innerHTML = ''; + if (ether.present){ + const row = document.createElement('div'); + row.className = 'lamp-row'; + row.innerHTML = `<span class="${ether.routed?'lamp':'lamp gold'}" id="eth-lamp"></span>`+ + `<span class="who">${ether.routed?'<b>'+ether.dev+'</b>':ether.dev}</span>`+ + `<span class="what" id="eth-what">${ether.routed?'active · wired · 1.0 Gbps':'wired · standby'}</span>`; + row.onclick = () => toggleEther(); + host.appendChild(row); + } + if (!wifiOn){ + const note = document.createElement('div'); + note.className = 'lamp-row'; + note.style.cursor = 'default'; + note.innerHTML = `<span class="lamp off"></span><span class="who dim">wifi radio off</span>`+ + `<span class="what">${airplane ? 'airplane mode' : 'flip the switch to scan'}</span>`; + host.appendChild(note); + tips('networks'); + return; + } + const inRange = NETS.filter(n => n.range).sort((a,b) => (b.active-a.active) || (b.sig-a.sig)); + const out = NETS.filter(n => !n.range); + $('net-count').textContent = '· ' + inRange.length + ' in range'; + for (const n of [...inRange, ...out]){ + const row = document.createElement('div'); + row.className = 'lamp-row' + (armed===n.id ? ' armed' : '') + + (armedDisc===n.id ? ' armed-soft' : ''); + const lamp = n.active ? 'lamp' : (n.range ? 'lamp gold' : 'lamp off'); + const what = armed===n.id ? 'forget? click ✕ again' + : armedDisc===n.id ? 'disconnect? click again' + : n.active ? (ether.present && ether.routed ? 'connected · standby · ' : 'active · ') + (n.sec || 'open') + : !n.range ? 'stored · out of range' + : (n.stored ? 'stored · ' : '') + (n.sec || 'open') + ' · ' + [null,'22%','44%','61%','78%'][n.sig]; + row.innerHTML = `<span class="${lamp}"></span>`+ + `<span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+ + (n.range && !n.active ? `<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>` : '')+ + `<span class="what" id="nw-${n.id}">${what}</span>`+ + (n.stored ? `<button class="zap" title="Forget ${n.ssid}">✕</button>` : ''); + if (n.stored) row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); armForget(n.id); }; + row.onclick = n.active ? () => armDisconnect(n.id) : () => joinNet(n.id); + host.appendChild(row); + } + tips('networks'); +} +function armDisconnect(id){ + if (busy) return; + const n = NETS.find(x=>x.id===id); + if (armedDisc === id){ // second click: disconnect + clearTimeout(armDiscTimer); armedDisc = null; + busy = true; + const what = $('nw-'+id); + if (what) what.textContent = 'disconnecting…'; + setTimeout(() => { + busy = false; + n.active = false; connected = false; + if (!(ether.present && ether.routed)) tstate.ts = false; + netFace(); + renderTunnels(); renderNets(); + toast('disconnected from ' + n.ssid); + }, T(1100)); + return; + } + armedDisc = id; renderNets(); // first click: arm (gold — disruptive, not destructive) + clearTimeout(armDiscTimer); + armDiscTimer = setTimeout(() => { armedDisc = null; renderNets(); }, 3000); +} +function armForget(id){ + const n = NETS.find(x=>x.id===id); + if (armed === id){ + clearTimeout(armTimer); armed = null; + NETS.splice(NETS.indexOf(n), 1); renderNets(); + toast(`${n.ssid} forgotten`); + return; + } + armed = id; renderNets(); + clearTimeout(armTimer); + armTimer = setTimeout(() => { armed = null; renderNets(); }, 3000); +} +let joining = null; +function joinNet(id){ + if (busy) return; + if (!wifiOn){ toast('wifi radio is off', true); return; } + const n = NETS.find(x=>x.id===id); + if (!n.range){ toast(n.ssid + ' is out of range', true); return; } + if (n.sec && !n.stored){ joining = n; dlgJoin(n); return; } + doJoin(n); +} +function doJoin(n){ + busy = true; + const what = $('nw-'+n.id); + if (what) what.textContent = 'joining…'; + lampState('JOINING','gold'); + setTimeout(() => { + NETS.forEach(x => x.active = false); + n.active = true; n.stored = true; + busy = false; + $('ch-ssid').textContent = n.ssid; + $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][n.sig] + ' · 41 ms'; + $('ch-ladder').className = 'ladder l'+n.sig; + connected = true; + lastSsid = n.ssid; + routeBase = n.ip || '172.20.2.108/20 · gw 172.20.0.1'; + netFace(); + if (!tstate.ts) tstate.ts = true; + renderTunnels(); renderNets(); + toast('joined ' + n.ssid + ' — saved for next time'); + }, T(1600)); +} +function dlgJoin(n){ + $('dlg-title').textContent = 'Join ' + n.ssid; + $('dlg-sub').textContent = n.sec + ' — password required'; + $('dlg-ssid').style.display = 'none'; + $('dlg-pass').value = ''; + $('ov').classList.add('show'); + $('dlg-pass').focus(); +} +function dlgHidden(){ + joining = 'hidden'; + $('dlg-title').textContent = 'Join hidden network'; + $('dlg-sub').textContent = 'SSID is not broadcast — enter it exactly'; + $('dlg-ssid').style.display = 'block'; $('dlg-ssid').value = ''; + $('dlg-pass').value = ''; + $('ov').classList.add('show'); + $('dlg-ssid').focus(); +} +function dlgClose(){ $('ov').classList.remove('show'); joining = null; } +function dlgGo(){ + if (joining === 'hidden'){ + const ssid = $('dlg-ssid').value.trim() || 'hidden-net'; + const n = {id:'h'+Date.now(), ssid, sec:'WPA2', stored:true, range:true, sig:2, active:false}; + NETS.splice(0,0,n); renderNets(); dlgClose(); doJoin(n); + return; + } + const n = joining; dlgClose(); if (n) doJoin(n); +} + +function renderTunnels(){ + const host = $('tunnels'); host.innerHTML = ''; + for (const t of TUNNELS){ + const up = tstate[t.id]; + const row = document.createElement('div'); + row.className = 'lamp-row'; + row.innerHTML = `<span class="${up?'lamp':'lamp off'}" id="tl-${t.id}"></span>`+ + `<span class="who">${t.who}</span><span class="what" id="tw-${t.id}">${up?(t.upWhat||'up'):t.downWhat}</span>`; + row.onclick = () => toggleTunnel(t.id); + host.appendChild(row); + } + $('tun-count').textContent = '· ' + TUNNELS.filter(t=>tstate[t.id]).length + ' up of ' + TUNNELS.length; + tips('tunnels'); + updateRoute(); +} +function updateRoute(){ + if (!connected && !(ether.present && ether.routed)){ + $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off'; + $('ch-ladder').style.display = 'none'; + $('ch-dbm').textContent = ''; + $('ch-route').textContent = !wifiOn ? (airplane ? 'airplane mode' : 'flip the radio switch to scan') + : 'join a network below'; + $('badge').classList.remove('show'); + return; + } + const owner = TUNNELS.find(t => t.ownsRoute && tstate[t.id]); + const wired = ether.present && ether.routed; + const base = wired ? ether.ip : routeBase; + const dev = wired ? ether.dev : 'wlp170s0'; + $('ch-route').textContent = base + ' · route ' + (owner ? owner.dev + ' (tunnel)' : dev); + $('badge').classList.toggle('show', !!owner); + /* channel headline follows the routed link */ + if (wired){ + $('ch-ssid').textContent = ether.dev; + $('ch-ladder').style.display = 'none'; + $('ch-dbm').textContent = 'wired · 1.0 Gbps full-duplex'; + } else if (connected){ + const act = NETS.find(n => n.active); + if (act){ + $('ch-ssid').textContent = act.ssid; + $('ch-ladder').style.display = ''; + $('ch-ladder').className = 'ladder l' + act.sig; + $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][act.sig] + ' · 44 ms'; + } + } else { + $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off'; + $('ch-ladder').style.display = 'none'; + $('ch-dbm').textContent = ''; + } +} +/* the faceplate state word, derived from one place */ +function netFace(){ + const wired = ether.present && ether.routed; + $('air-badge').classList.toggle('show', airplane); + if (wired){ lampState('ONLINE'); return; } + if (connected){ lampState('ONLINE'); return; } + if (airplane){ lampState('AIRPLANE','gold'); return; } + if (!wifiOn){ lampState('OFF','off'); $('lamp').className='lamp off'; return; } + lampState('OFFLINE','red'); +} +function toggleTunnel(id){ + if (busy) return; + const t = TUNNELS.find(x=>x.id===id), up = tstate[id]; + const lamp = $('tl-'+id), what = $('tw-'+id); + busy = true; + lamp.className = 'lamp busy'; + what.textContent = t.needsLogin && !up ? 'connecting…' : up ? 'bringing down…' : 'bringing up…'; + setTimeout(() => { + busy = false; + if (t.needsLogin && !up){ + lamp.className = 'lamp red'; + what.textContent = 'sign in first: protonvpn login'; + toast('Proton: no account signed in — run: protonvpn login', true); + setTimeout(() => { lamp.className = 'lamp off'; what.textContent = t.downWhat; }, 2600); + return; + } + tstate[id] = !up; + renderTunnels(); + toast(tstate[id] ? `${t.who} up` + (t.ownsRoute ? ' — default route moved to '+t.dev : '') + : `${t.who} down` + (t.ownsRoute ? ' — route back on wlp170s0' : '')); + }, T(1500)); +} + +let toastTimer; +function toast(msg, err){ + const el = $('toastEl'); + el.textContent = msg; el.className = 'toast show' + (err ? ' err' : ''); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600); +} +function lampState(word, cls){ + $('state').textContent = word; + $('lamp').className = 'lamp' + (cls && cls !== 'off' ? ' '+cls : cls === 'off' ? ' off' : ''); +} + +const held = {rx:false, tx:false}; +let testing = false, amb = 0; +function meter(side, val){ + const deg = -60 + Math.min(1, val/100) * 120; + $('n-'+side).style.transform = `rotate(${deg}deg)`; + $('v-'+side).textContent = val.toFixed(1); +} +function setMeterMode(side, mode){ + const m = $('m-'+side), t = $('mt-'+side); + m.classList.toggle('testing', mode==='test'); + m.classList.toggle('held', mode==='hold'); + held[side] = mode==='hold'; + t.textContent = mode==='live' ? 'LIVE' : 'TEST'; + t.className = 'mode-tag' + (mode==='live' ? '' : ' test'); +} +function release(side){ + if (!held[side]) return; + setMeterMode(side, 'live'); + if (!held.rx && !held.tx) $('m-note').textContent = ''; +} + +function runSpeed(){ + if (busy) return; + busy = true; testing = true; + $('b-doctor').disabled = $('b-speed').disabled = true; + const out = $('out'); out.innerHTML = ''; + setMeterMode('rx','test'); setMeterMode('tx','test'); + $('m-note').textContent = 'measuring — needles follow the live rate'; + const line = (k, v) => { + const el = document.createElement('div'); + el.className = 'o-line'; el.innerHTML = `<b>${k}</b> ${v}`; + out.appendChild(el); out.scrollTop = out.scrollHeight; + }; + setTimeout(() => line('location', 'Tulsa, OK (US) by Encore Communications'), T(900)); + setTimeout(() => line('ping', '44.5 ms (jitter 2.1 ms)'), T(1800)); + let dv = 0; const dT = 25.3; + setTimeout(() => { + const dTick = setInterval(() => { + dv = Math.min(dT, dv + 1.3 + Math.random()*2.1); + meter('rx', dv); + if (dv >= dT){ + clearInterval(dTick); + setMeterMode('rx','hold'); meter('rx', dT); + let uv = 0; const uT = 90.8; + const uTick = setInterval(() => { + uv = Math.min(uT, uv + 4.5 + Math.random()*7); + meter('tx', uv); + if (uv >= uT){ + clearInterval(uTick); + setMeterMode('tx','hold'); meter('tx', uT); + line('final', '25.3↓ 90.8↑ Mbps · 44.5 ms · loss 0.0%'); + const tip = document.createElement('div'); + tip.className = 'o-tip'; + tip.textContent = 'tip: download well below upload — typical of a congested or shaped venue network. Try 5 GHz, move closer, or retest off-peak.'; + out.appendChild(tip); out.scrollTop = out.scrollHeight; + $('m-note').textContent = 'result held — click a meter to go back to live'; + testing = false; busy = false; + $('b-doctor').disabled = $('b-speed').disabled = false; + } + }, T(200)); + } + }, T(185)); + }, T(2100)); +} + +const CHECKS = [ + {t:'Link', why:'is the adapter connected to a network (every later check rides the link)', + ev:'wlp170s0 connected (@Hyatt_WiFi)'}, + {t:'DHCP / IPv4', why:'did the network lease us an IP address (nothing routes without one)', + ev:'172.20.2.108/20'}, + {t:'Gateway', why:'does the router (first hop) answer a ping', ev:'172.20.0.1 [5 ms]'}, + {t:'DNS config', why:'is a DNS resolver configured on the link', ev:'172.20.0.1'}, + {t:'DNS resolution', why:'does a known hostname resolve (catches dead DNS and portal hijacks)', + ev:'names resolve (captive.apple.com) [48 ms]', + evBroken:'no resolution (portal may be stalling DNS)', canBreak:true}, + {t:'Internet', why:'does an HTTP probe reach the open internet (the online/captive verdict)', + ev:'open internet (HTTP 204) [112 ms]', + evBroken:'link up but no clean internet (DNS or egress issue)', canBreak:true}, +]; +const REPAIR = {t:'repair: dns-test', why:'points DNS at 1.1.1.1, tests resolution, then reverts (tells a broken venue resolver from blocked egress)', + ev:'1.1.1.1 resolved captive.apple.com — the hotel resolver is broken, not the link'}; +const REPAIR2 = {t:'repair: dns-override', why:'sets 1.1.1.1 on the link until reconnect (works around the broken venue resolver)', + ev:'DNS set to 1.1.1.1 on wlp170s0'}; + +function addStep(host, step, repair){ + const el = document.createElement('div'); + el.className = 'o-step' + (repair ? ' repair' : ''); + el.innerHTML = `<span class="lamp busy"></span><span class="t"><b>${step.t}</b>`+ + `<span class="why">${step.why}</span><span class="ev">…</span></span>`; + host.appendChild(el); host.scrollTop = host.scrollHeight; + return el; +} +function landStep(el, ev, cls){ + el.querySelector('.lamp').className = 'lamp' + (cls ? ' '+cls : ''); + el.querySelector('.ev').textContent = ev; + el.closest('.output').scrollTop = 1e6; +} +function verdict(host, text, ok){ + const v = document.createElement('div'); + v.className = 'o-verdict' + (ok ? ' ok' : ''); + v.textContent = text; + host.appendChild(v); host.scrollTop = host.scrollHeight; +} + +function runDoctor(){ + if (busy) return; + busy = true; + $('b-doctor').disabled = $('b-speed').disabled = true; + const brokenDNS = $('breakdns').checked; + const out = $('out'); out.innerHTML = ''; + lampState('CHECKING','gold'); + const gap = T(620); + let i = 0; + const next = () => { + if (i >= CHECKS.length){ return brokenDNS ? repairPhase() : finish('Overall — everything checks out'); } + const c = CHECKS[i]; + const el = addStep(out, c); + setTimeout(() => { + const broken = brokenDNS && c.canBreak; + landStep(el, broken ? c.evBroken : c.ev, broken ? 'red' : ''); + i++; setTimeout(next, gap*0.35); + }, gap); + }; + const repairPhase = () => { + lampState('FIXING','gold'); + verdict(out, 'DNS not resolving — trying the lightest repair'); + const r1 = addStep(out, REPAIR, true); + setTimeout(() => { + landStep(r1, REPAIR.ev); + const r2 = addStep(out, REPAIR2, true); + setTimeout(() => { + landStep(r2, REPAIR2.ev); + const re = addStep(out, {t:'re-check: Internet', why:'probe again through the repaired resolver', ev:''}); + setTimeout(() => { + landStep(re, 'open internet (HTTP 204) [96 ms]'); + $('breakdns').checked = false; + finish('fixed — back online after dns-override'); + }, gap); + }, gap*1.2); + }, gap*1.4); + }; + const finish = (text) => { + verdict(out, text, true); + lampState('ONLINE'); + busy = false; + $('b-doctor').disabled = $('b-speed').disabled = false; + }; + next(); +} + +function setEther(present){ + if (busy) return; + ether.present = present; + if (present){ + ether.routed = true; // cable wins the route by metric + lampState('ONLINE'); + toast('link detected on ' + ether.dev + ' — route moved to wired'); + } else { + ether.routed = false; + toast(connected ? 'cable unplugged — route back on wlp170s0' + : 'cable unplugged', !connected); + if (!connected) lampState('OFFLINE','red'); + } + renderTunnels(); renderNets(); +} +function toggleEther(){ + if (busy) return; + const lamp = $('eth-lamp'), what = $('eth-what'); + busy = true; + lamp.className = 'lamp busy'; + what.textContent = ether.routed ? 'standing by…' : 'taking the route…'; + setTimeout(() => { + busy = false; + ether.routed = !ether.routed; + renderTunnels(); renderNets(); + toast(ether.routed ? 'route moved to ' + ether.dev + ' (wired)' + : 'route back on wlp170s0 — ' + ether.dev + ' standing by'); + }, T(1200)); +} + +function wifiPower(){ + if (busy) return; + if (airplane){ toast('airplane mode is on — Super+Shift+A to leave it', true); return; } + setWifi(!wifiOn); +} +function setWifi(on, quiet){ + wifiOn = on; + $('n-power').classList.toggle('on', on); + if (!on){ + const act = NETS.find(n => n.active); + if (act){ lastSsid = act.ssid; act.active = false; } + connected = false; + if (!(ether.present && ether.routed)) tstate.ts = false; + netFace(); renderTunnels(); renderNets(); + if (!quiet) toast('wifi radio off'); + } else { + netFace(); renderTunnels(); renderNets(); + if (!quiet) toast('wifi radio on — rejoining ' + lastSsid); + const n = NETS.find(x => x.ssid === lastSsid && x.range); + if (n) setTimeout(() => doJoin(n), T(700)); + } +} +function setAirplane(on){ + airplane = on; + if (on){ + if (wifiOn) setWifi(false, true); + if (bpower) btPowerSet(false, true); + netFace(); + $('b-air-badge').classList.add('show'); + toast('airplane mode — all radios off'); + } else { + $('b-air-badge').classList.remove('show'); + setWifi(true, true); + btPowerSet(true, true); + netFace(); + toast('airplane mode off — radios back up'); + } +} + +function setAirport(on){ + if (busy || bbusy) return; + if (on && $('cafe').checked){ $('cafe').checked = false; } + NETS = on ? AIRPORT_NETS() : HOTEL_NETS(); + NEARBY.length = 0; + NEARBY.push(...(on ? AIRPORT_NEARBY() + : [{id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'}, + {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'}])); + if (on){ + tstate.ts = false; connected = false; + lampState('OFFLINE','red'); + toast('ORD concourse B — pick a network'); + } else { + tstate.ts = true; connected = true; + routeBase = '172.20.2.108/20 · gw 172.20.0.1'; + NETS.find(n=>n.id==='hyatt').active = true; + lampState('ONLINE'); + } + renderTunnels(); renderNets(); renderBt(); +} + +function setScenario(cafe){ + if (busy) return; + if (cafe && $('airportcb') && $('airportcb').checked){ $('airportcb').checked = false; } + NETS = cafe ? CAFE_NETS() : HOTEL_NETS(); + if (cafe){ + tstate.ts = false; connected = false; + $('ch-ssid').textContent = '— not connected'; + $('ch-dbm').textContent = ''; + $('ch-ladder').className = 'ladder'; + lampState('OFFLINE','red'); + } else { + tstate.ts = true; connected = true; + routeBase = '172.20.2.108/20 · gw 172.20.0.1'; + $('ch-ssid').textContent = '@Hyatt_WiFi'; + $('ch-dbm').textContent = '-59 dBm · 44 ms'; + $('ch-ladder').className = 'ladder l3'; + lampState('ONLINE'); + } + renderTunnels(); renderNets(); +} + +function closePanel(){ $('p').classList.add('closed'); $('reopen').style.display='inline-block'; } +function openPanel(){ $('p').classList.remove('closed'); $('reopen').style.display='none'; } + +/* =========================================================== BT·01 */ +let bbusy = false, bpower = true, bdisco = false, blow = false; +const DEVS = [ + {id:'m650', name:'Logi M650', kind:'mouse', paired:true, conn:true, batt:72, audio:false}, + {id:'xm4', name:'WH-1000XM4', kind:'audio', paired:true, conn:false, batt:58, audio:true}, + {id:'k3', name:'Keychron K3', kind:'keyboard', paired:true, conn:false, batt:34, audio:false}, +]; +const NEARBY = [ + {id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'}, + {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'}, +]; +let barmed = null, barmTimer = null; + +function btConnected(){ return DEVS.filter(d => d.conn); } + +function renderBt(){ + /* paired rows */ + const host = $('b-paired'); host.innerHTML = ''; + for (const d of DEVS){ + const row = document.createElement('div'); + row.className = 'lamp-row' + (barmed===d.id ? ' armed' : ''); + const lamp = !bpower ? 'lamp off' : d.conn ? 'lamp' : 'lamp off'; + const battTxt = d.batt !== null && d.conn ? ` · battery ${d.batt}%` : ''; + const what = barmed===d.id ? 'forget? click ✕ again' + : !bpower ? 'adapter off' + : d.conn ? d.kind + battTxt : d.kind + ' · not connected'; + row.innerHTML = `<span class="${lamp}" id="bl-${d.id}"></span>`+ + `<span class="who">${d.conn && bpower ? '<b>'+d.name+'</b>' : d.name}</span>`+ + `<span class="what" id="bw-${d.id}">${what}</span>`+ + `<button class="pen" title="Rename ${d.name}">✎</button>`+ + `<button class="zap" title="Forget ${d.name}">✕</button>`; + row.querySelector('.pen').onclick = (e) => { e.stopPropagation(); bdlgRename(d.id); }; + row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); btArmForget(d.id); }; + if (bpower) row.onclick = () => btToggleDev(d.id); + host.appendChild(row); + } + /* nearby rows */ + const nb = $('b-nearby'); nb.innerHTML = ''; + for (const n of NEARBY){ + const row = document.createElement('div'); + row.className = 'lamp-row'; + row.innerHTML = `<span class="${bpower?'lamp gold':'lamp off'}" id="bnl-${n.id}"></span>`+ + `<span class="who">${n.name}</span><span class="what" id="bnw-${n.id}">${bpower ? n.kind+' · pairable' : 'adapter off'}</span>`; + if (bpower) row.onclick = () => btPair(n.id); + nb.appendChild(row); + } + /* adapter line + faceplate */ + const c = btConnected().length; + $('b-conn-count').textContent = bpower ? (c === 1 ? '1 device connected' : c + ' devices connected') : 'adapter off'; + $('b-paired-count').textContent = '· ' + DEVS.length; + $('b-nearby-count').textContent = '· ' + NEARBY.length; + $('b-disco').textContent = 'discoverable ' + (bdisco && bpower ? 'on' : 'off'); + $('b-disco').className = 'chip' + (bdisco && bpower ? ' on' : ''); + renderGauges(); + updateBtBadge(); + tips('b-paired'); tips('b-nearby'); + for (const el of document.querySelectorAll('#bp .m-label')) el.title = el.textContent; +} + +function renderGauges(){ + const conns = btConnected(); + for (let i = 0; i < 2; i++){ + const d = bpower ? conns[i] : null; + const needle = $('bn-'+i), val = $('bv-'+i), label = $('bl-'+i), + tag = $('bmt-'+i), wrap = $('bvw-'+i); + if (!d){ + needle.className = 'needle dead'; + needle.style.transform = 'rotate(-60deg)'; + val.textContent = '—'; wrap.className = 'm-value'; + label.textContent = bpower ? 'NO DEVICE' : 'ADAPTER OFF'; + tag.textContent = '—'; tag.className = 'mode-tag off'; + continue; + } + const low = d.batt < 15; + needle.className = 'needle' + (low ? ' low' : ''); + needle.style.transform = `rotate(${-60 + (d.batt/100)*120}deg)`; + val.innerHTML = d.batt; wrap.className = 'm-value' + (low ? ' low' : ''); + wrap.innerHTML = `<span id="bv-${i}">${d.batt}</span> <small>%</small>`; + label.textContent = d.name.toUpperCase(); + tag.textContent = 'LIVE'; tag.className = 'mode-tag'; + } +} + +function updateBtBadge(){ + const low = bpower && btConnected().some(d => d.batt < 15); + $('b-badge').classList.toggle('show', low); +} + +function btToggleDev(id){ + if (bbusy || !bpower) return; + const d = DEVS.find(x=>x.id===id); + const lamp = $('bl-'+id), what = $('bw-'+id); + bbusy = true; + lamp.className = 'lamp busy'; + what.textContent = d.conn ? 'disconnecting…' : 'connecting…'; + setTimeout(() => { + bbusy = false; + d.conn = !d.conn; + renderBt(); + btToast(d.conn ? `${d.name} connected` : `${d.name} disconnected`); + }, T(1300)); +} + +function btArmForget(id){ + const d = DEVS.find(x=>x.id===id); + if (barmed === id){ + clearTimeout(barmTimer); barmed = null; + DEVS.splice(DEVS.indexOf(d), 1); renderBt(); + btToast(`${d.name} forgotten`); + return; + } + barmed = id; renderBt(); + clearTimeout(barmTimer); + barmTimer = setTimeout(() => { barmed = null; renderBt(); }, 3000); +} + +/* pairing + rename dialogs */ +let bdlgMode = null, bdlgTarget = null; +function btPair(id){ + if (bbusy) return; + const n = NEARBY.find(x=>x.id===id); + const lamp = $('bnl-'+id), what = $('bnw-'+id); + bbusy = true; + lamp.className = 'lamp busy'; + what.textContent = 'pairing…'; + setTimeout(() => { + bdlgMode = 'pair'; bdlgTarget = n; + $('bdlg-title').textContent = 'Pair ' + n.name; + $('bdlg-sub').textContent = 'confirm this passkey shows on the device'; + $('bdlg-key').style.display = 'block'; + $('bdlg-key').textContent = n.passkey; + $('bdlg-name').style.display = 'none'; + $('bdlg-go').textContent = 'Confirm'; + $('bov').classList.add('show'); + }, T(1200)); +} +function bdlgRename(id){ + const d = DEVS.find(x=>x.id===id); + bdlgMode = 'rename'; bdlgTarget = d; + $('bdlg-title').textContent = 'Rename device'; + $('bdlg-sub').textContent = 'the alias lives on this machine (bluez set-alias)'; + $('bdlg-key').style.display = 'none'; + $('bdlg-name').style.display = 'block'; + $('bdlg-name').value = d.name; + $('bdlg-go').textContent = 'Rename'; + $('bov').classList.add('show'); + $('bdlg-name').focus(); +} +function bdlgClose(){ + $('bov').classList.remove('show'); + if (bdlgMode === 'pair'){ bbusy = false; renderBt(); btToast('pairing cancelled'); } + bdlgMode = null; bdlgTarget = null; +} +function bdlgGo(){ + if (bdlgMode === 'pair'){ + const n = bdlgTarget; + $('bov').classList.remove('show'); + const what = $('bnw-'+n.id); + if (what) what.textContent = 'connecting…'; + setTimeout(() => { + bbusy = false; + NEARBY.splice(NEARBY.indexOf(n), 1); + DEVS.push({id:n.id, name:n.name, kind:n.kind, paired:true, conn:true, + batt:n.kind==='audio' ? 91 : null, audio:n.kind==='audio'}); + renderBt(); + btToast(`${n.name} paired and connected`); + }, T(1100)); + } else if (bdlgMode === 'rename'){ + const d = bdlgTarget; + const name = $('bdlg-name').value.trim() || d.name; + d.name = name; + $('bov').classList.remove('show'); + renderBt(); + btToast(`renamed to ${name}`); + } + bdlgMode = null; bdlgTarget = null; +} + +/* scan */ +const MORE_NEARBY = [ + {id:'buds', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'}, +]; +function btScan(){ + if (bbusy || !bpower) return; + $('b-scan-note').textContent = 'scanning…'; + $('bb-scan').disabled = true; + setTimeout(() => { + if (MORE_NEARBY.length){ + NEARBY.push(MORE_NEARBY.shift()); + renderBt(); + } + $('b-scan-note').textContent = ''; + $('bb-scan').disabled = false; + btToast('scan complete — ' + NEARBY.length + ' nearby'); + }, T(2200)); +} + +/* discoverable + power */ +function btDisco(){ + if (!bpower) return; + bdisco = !bdisco; + renderBt(); + btToast(bdisco ? 'discoverable for 2 minutes' : 'discoverable off'); +} +function btPower(){ + if (bbusy) return; + if (airplane){ btToast('airplane mode is on — Super+Shift+A to leave it', true); return; } + btPowerSet(!bpower); +} +function btPowerSet(on, quiet){ + if (bbusy) return; + bpower = on; + $('b-power').classList.toggle('on', bpower); + if (!bpower){ + bdisco = false; + DEVS.forEach(d => d._wasConn = d.conn); + DEVS.forEach(d => d.conn = false); + $('b-state').textContent = airplane ? 'AIRPLANE' : 'OFF'; + $('b-lamp').className = airplane ? 'lamp gold' : 'lamp off'; + $('bb-doctor').disabled = $('bb-scan').disabled = true; + renderBt(); + if (!quiet) btToast('adapter powered off'); + } else { + $('b-state').textContent = 'POWERED'; + $('b-lamp').className = 'lamp'; + $('bb-doctor').disabled = $('bb-scan').disabled = false; + renderBt(); + if (quiet === true) { /* airplane restore: quiet */ } + /* the mouse auto-reconnects, like real life */ + const mouse = DEVS.find(d => d._wasConn); + if (mouse){ + setTimeout(() => { + const lamp = $('bl-'+mouse.id), what = $('bw-'+mouse.id); + if (lamp){ lamp.className = 'lamp busy'; what.textContent = 'reconnecting…'; } + setTimeout(() => { mouse.conn = true; renderBt(); btToast(mouse.name + ' reconnected'); }, T(1200)); + }, T(600)); + } + } +} + +let btoastTimer; +function btToast(msg, err){ + const el = $('b-toastEl'); + el.textContent = msg; el.className = 'toast show' + (err ? ' err' : ''); + clearTimeout(btoastTimer); + btoastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600); +} + +/* bt doctor — the real chain: adapter → radio → service → powered → devices → audio */ +function btChecks(){ + const badAudio = $('badaudio').checked && DEVS.some(d => d.audio && d.conn); + return [ + {t:'Adapter', why:'is a bluetooth adapter visible to the stack', ev:'intel ax211 (hci0)'}, + {t:'Radio', why:'rfkill can block the radio in software or hardware', ev:'unblocked'}, + {t:'Service', why:'is the bluez daemon running', ev:'bluetooth.service active'}, + {t:'Powered', why:'radio on and ready to connect', ev:'powered on'}, + {t:'Devices', why:'are paired devices reachable', + ev: btConnected().length ? btConnected().map(d=>d.name).join(', ') + ' connected' : 'none connected'}, + {t:'Audio profile', why:'is a connected audio device on the high-quality profile (A2DP)', + ev: DEVS.some(d=>d.audio && d.conn) ? 'a2dp-sink' : 'no audio device connected', + evBroken:'stuck on headset-head-unit (HSP) — phone-call-grade audio', canBreak:badAudio}, + ]; +} +const BT_REPAIR = {t:'repair: a2dp-switch', why:'flips the card profile to A2DP and verifies the sink followed', + ev:'card profile set to a2dp-sink — sink followed'}; + +function btDoctor(){ + if (bbusy || !bpower) return; + bbusy = true; + $('bb-doctor').disabled = $('bb-scan').disabled = true; + const checks = btChecks(); + const willRepair = checks.some(c => c.canBreak); + const out = $('b-out'); out.innerHTML = ''; + $('b-state').textContent = 'CHECKING'; $('b-lamp').className = 'lamp gold'; + const gap = T(620); + let i = 0; + const next = () => { + if (i >= checks.length){ return willRepair ? repairPhase() : finish('Overall — everything checks out'); } + const c = checks[i]; + const el = addStep(out, c); + setTimeout(() => { + landStep(el, c.canBreak ? c.evBroken : c.ev, c.canBreak ? 'red' : ''); + i++; setTimeout(next, gap*0.35); + }, gap); + }; + const repairPhase = () => { + $('b-state').textContent = 'FIXING'; + verdict(out, 'audio degraded — trying the lightest repair'); + const r = addStep(out, BT_REPAIR, true); + setTimeout(() => { + landStep(r, BT_REPAIR.ev); + const re = addStep(out, {t:'re-check: Audio profile', why:'read the sink profile again after the switch', ev:''}); + setTimeout(() => { + landStep(re, 'a2dp-sink [verified]'); + $('badaudio').checked = false; + finish('fixed — high-quality audio restored'); + }, gap); + }, gap*1.4); + }; + const finish = (text) => { + verdict(out, text, true); + $('b-state').textContent = 'POWERED'; $('b-lamp').className = 'lamp'; + bbusy = false; + $('bb-doctor').disabled = $('bb-scan').disabled = false; + }; + next(); +} + +function btLowBatt(low){ + blow = low; + const m = DEVS.find(d => d.id === 'm650'); + if (m) m.batt = low ? 9 : 72; + renderBt(); + if (low) btToast('Logi M650 battery low (9%)', true); +} + +function closeBt(){ $('bp').classList.add('closed'); $('b-reopen').style.display='inline-block'; } +function openBt(){ $('bp').classList.remove('closed'); $('b-reopen').style.display='none'; } + +/* shared ambience + keys */ +if (!reduced) setInterval(() => { + amb++; + if (!testing){ + if (!held.rx) meter('rx', 0.1 + Math.abs(Math.sin(amb/3))*0.35); + if (!held.tx) meter('tx', 0.08 + Math.abs(Math.cos(amb/4))*0.25); + } +}, 900); +document.addEventListener('keydown', e => { + if (e.key === 'Escape'){ + if ($('ov').classList.contains('show')) return dlgClose(); + if ($('bov').classList.contains('show')) return bdlgClose(); + closePanel(); closeBt(); + } +}); + +function clearOut(id){ $(id).innerHTML = ''; } +function tips(hostId){ + for (const el of $(hostId).querySelectorAll('.what,.who,.m-label')) + el.title = el.textContent; +} +[['out','outwrap'],['b-out','b-outwrap']].forEach(([oid, wid]) => { + new MutationObserver(() => { + $(wid).classList.toggle('has', $(oid).childElementCount > 0); + }).observe($(oid), {childList: true}); +}); + +renderNets(); renderTunnels(); renderBt(); + +/* headless hooks */ +const auto = new URLSearchParams(location.search).get('auto'); +if (auto === 'btdoctor') btDoctor(); +if (auto === 'btdoctorfix'){ + const xm4 = DEVS.find(d=>d.id==='xm4'); xm4.conn = true; renderBt(); + $('badaudio').checked = true; btDoctor(); +} +if (auto === 'btpair'){ btPair('jbl'); setTimeout(() => bdlgGo(), T(1800)); } +if (auto === 'btpower'){ btPower(); } +if (auto === 'btpoweron'){ btPower(); setTimeout(() => btPower(), T(600)); } +if (auto === 'btlow'){ $('lowbatt').checked = true; btLowBatt(true); } +if (auto === 'speed') runSpeed(); +if (auto === 'ether'){ $('ethercb').checked = true; setEther(true); } +if (auto === 'air'){ $('aircb').checked = true; setAirplane(true); } +if (auto === 'airether'){ $('ethercb').checked = true; setEther(true); $('aircb').checked = true; setAirplane(true); } +if (auto === 'disc'){ armDisconnect('hyatt'); setTimeout(() => armDisconnect('hyatt'), T(600)); } +if (auto === 'wifioff'){ setWifi(false); } +if (auto === 'airport'){ $('airportcb').checked = true; setAirport(true); } +if (auto === 'doctorfix'){ $('breakdns').checked = true; runDoctor(); } +</script> +</body> +</html> diff --git a/assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt b/assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt new file mode 100644 index 0000000..6f62d17 --- /dev/null +++ b/assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt @@ -0,0 +1,5 @@ +Security check from rulesets — action needed: your CLAUDE.md is tracked. + +Same broadcast as the tooling-check note also in your inbox, plus a project-specific finding: archsetup is gitignore-mode (anchored /.ai/ style) but CLAUDE.md is currently TRACKED in git. The fixed sweep just added /CLAUDE.md and /AGENTS.md to your .gitignore, but an ignore line never untracks an already-tracked file. + +Action: git rm --cached CLAUDE.md (keeps the file on disk), then commit. Your only remote is private cjennings.net, so no public exposure is known — but verify with Craig that the bare repo carries no mirror hook, since .emacs.d's exposure rode exactly that. diff --git a/assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt b/assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt new file mode 100644 index 0000000..7b10753 --- /dev/null +++ b/assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt @@ -0,0 +1,12 @@ +Security check request from rulesets: tracked tooling vs public reachability. + +Background: .emacs.d found its entire tooling layer (CLAUDE.md + .claude/ — 22 files, including rules with personal data) tracked in git and mirrored to PUBLIC GitHub by a server-side post-receive hook on its cjennings.net bare repo. The mirror was invisible from the client; the history is now scrubbed. The sweep script that should have caught it silently skipped any project using the anchored /.ai/ gitignore style — that bug is fixed as of rulesets 909b21b. + +Action for this project's next session: +1. Check whether .ai/, .claude/, CLAUDE.md, or AGENTS.md is tracked: git ls-files .ai .claude CLAUDE.md AGENTS.md +2. Check reachability: git remote -v. A non-cjennings.net remote with tracked tooling is an exposure — gitignore the set, git rm --cached the paths (files stay on disk), and consider a history scrub (see .emacs.d's 2026-06-30 filter-repo precedent). +3. Even with only a cjennings.net remote, a server-side mirror hook can republish invisibly. If this project might be mirrored, ask Craig to check the bare repo's hooks/ on the server. + +Convention update (protocols.org): any repo whose remotes include a non-cjennings.net host gitignores the tooling set; a deliberate, explicitly-decided team-shared config is the only exception. Track-mode on the private server (history-is-the-project repos) is unchanged. + +The fixed sweep has already backfilled missing ignore lines across gitignore-mode projects (2026-07-01 run). No reply needed unless you find tracked tooling with public reach. diff --git a/assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org b/assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org new file mode 100644 index 0000000..055e635 --- /dev/null +++ b/assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org @@ -0,0 +1,5 @@ +#+TITLE: Accepted: your spec-review UI-traps checklist is promoted in +#+SOURCE: from rulesets +#+DATE: 2026-07-01 21:44:52 -0400 + +Accepted: your spec-review UI-traps checklist is promoted into the canonical spec-review.org (rulesets 9814b94). It landed as a conditional Phase 4 dimension — 'Operational-panel UI traps', applied when a spec covers a user-facing panel/dialog/control surface, skipped otherwise — with all six checks and a provenance note crediting the 2026-06-30 Waybar network-panel review. Every project picks it up on its next startup sync; you can drop your local copy's divergence next time it syncs. diff --git a/assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org b/assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org new file mode 100644 index 0000000..3ead015 --- /dev/null +++ b/assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org @@ -0,0 +1,5 @@ +#+TITLE: Your roam-routed handoff (2026-07-02 0110) is processed: ite +#+SOURCE: from rulesets +#+DATE: 2026-07-02 01:31:58 -0400 + +Your roam-routed handoff (2026-07-02 0110) is processed: item 1 (template pull with gitignored-only changes) filed as a [#C] feature task in rulesets todo.org; item 2 (ai-term colors) forwarded to .emacs.d — ai-term is its module; item 3 (wrap-it-up summary keep-or-cut) filed as a [#C] task for a think-through with Craig — the teardown-by-default half already shipped 2026-07-01. diff --git a/assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org b/assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org new file mode 100644 index 0000000..675d73b --- /dev/null +++ b/assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org @@ -0,0 +1,5 @@ +#+TITLE: Auto-flush is canonicalized: self-inject.sh now lives in the +#+SOURCE: from rulesets +#+DATE: 2026-07-02 01:36:19 -0400 + +Auto-flush is canonicalized: self-inject.sh now lives in the synced .ai/scripts/ (6-test bats suite, tmux stubbed at the boundary), the flush skill gained an auto mode (checkpoint gate unchanged, then derive-pane-first + tmux run-shell -b arm + end-turn, your two gotchas documented), and work-the-backlog.org (the speedrun's home) auto-flushes between tasks when context grows heavy. Your design note is preserved at rulesets docs/design/2026-07-02-auto-flush-mechanism-note.org. Every project gets the script on its next template sync. diff --git a/assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org b/assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org new file mode 100644 index 0000000..f5aac52 --- /dev/null +++ b/assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org @@ -0,0 +1,5 @@ +#+TITLE: Both your 0543 handoffs are shipped. inbox-send now never ov +#+SOURCE: from rulesets +#+DATE: 2026-07-02 05:55:34 -0400 + +Both your 0543 handoffs are shipped. inbox-send now never overwrites: a colliding filename gets a -2/-3 stem suffix (4 new tests reproduce your lost-message case, 30/30 green — thanks for the wild find). Pages are info-level now: page-me.org and the work-the-backlog end-of-set page use notify info --persist instead of alarm, per Craig's too-alarming verdict. The dupre-blue color item went to .emacs.d with the hex reference — ai-term owns instance colors. diff --git a/assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org b/assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org new file mode 100644 index 0000000..bdca099 --- /dev/null +++ b/assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org @@ -0,0 +1,4 @@ +* lint-org follow-ups — todo.org (2026-07-03) +** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 1317) +** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 1292) +** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 1202) diff --git a/docs/design/2026-06-29-waybar-network-module-spec.org b/docs/design/2026-06-29-waybar-network-module-spec.org new file mode 100644 index 0000000..3a1260c --- /dev/null +++ b/docs/design/2026-06-29-waybar-network-module-spec.org @@ -0,0 +1,2094 @@ +#+TITLE: Waybar Network Module — Design Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-29 + +* Status + +*Phases 1-3 SHIPPED* (2026-06-29 → 2026-06-30, dotfiles). The core module is live: +the =net= engine (=status/probe/list/up/down/add/edit/remove/rescan/diagnose/repair/ +doctor/portal/speedtest=), the =waybar-net= indicator (split-cadence cache, redacted +event log, display-only airplane absorption per decision 12), and the GTK4 +layer-shell panel (Connections / Diagnose / Repair / Speed test) with the settled bar +clicks (left = panel, middle = =net portal=, right = =net-fix=; airplane on +Super+Shift+A). 230+ net tests; full dotfiles suite green. Live-verified on velox. + +Built on top since the original spec: +- *Captive-portal login engine* (2026-06-30, dotfiles =a7d7559=) — =net portal= now + runs a native =portal-login= repair tier (drop DoT → recover the portal URL from + the redirect → open a throwaway browser profile → auto-restore DoT once online), + replacing the old shell-out to =captive= for the force-portal flow. =net portal + --restore= is the manual fallback. +- *Portal UX fixes from live testing* (2026-06-30, dotfiles =eef6b0b=) — removed a + polkit-gated =resolvectl flush-caches= that popped an auth dialog (the DoT-drop + restart already clears the cache); added an already-online short-circuit so a + forced run on a working connection opens nothing; suppressed Chrome's first-run + wizard; moved =net portal= off the terminal into the panel status line; hardened + the portal-URL extractor against Firefox's detection page. +- *Panel auto-hide + Close button* (2026-06-30, dotfiles =450b7f0=) — the panel + closes on focus-out (popup behavior, suppressed while a child dialog holds focus) + and carries a Close button bottom-right. + +*V2 redesign in flight* (designed 2026-06-30, not yet built — see todo.org "Network +panel redesign — no terminals, verify-everything, full failure coverage"). It +reverses two earlier choices and widens coverage: +- *No terminals anywhere.* =net-popup= is removed; every action and result renders + in the panel. This depends on a passwordless privileged path — a root-owned helper + plus a narrow NOPASSWD sudoers rule, archsetup-installed — because an in-panel + worker thread has no tty to prompt for a password. Reverses decision 11's + "privileged tiers run in a terminal". +- *New navigation* — top tabs Connections | Diagnostics | Performance. Diagnostics + merges Diagnose + Repair (sub-row Diagnose | Get Me Online | Advanced; a shared + area below shows diagnose items and streams repair progress; Advanced reveals the + individual repair buttons, renamed with tooltips). Speed test lives under + Performance. +- *Verify every action* (each mutating op confirms its effect before reporting + success) and *detect + respond to every failure mode* — the full ~44-mode catalog, + edge cases included, lives in the redesign task and supersedes the table below. + +Phase 4 (docs / rollout) and Phase 5 (VPN) remain. Review incorporated (Codex, +2026-06-30): four review rounds + Craig's cj comments are all dispositioned +([40/40], no open findings) — the fourth round reshaped the V2 panel UX (single nav +target, saved-vs-available groups, join-from-row, the auth matrix, progressive +loading, a findable diagnostics report, and the Waybar visual contract; see "V2 panel +UX"). Phases 1-3's manual live checks are under todo.org "Manual testing and +validation". + +* Goal + +One waybar network component that does the whole job: shows connection state +(including the missing "associated but no internet / captive portal" state), +manages connections from a dropdown (nmcli-backed; secrets stay in +NetworkManager's own store, no separate credential file), and runs the network +diagnostics and remediation off the same place +(captive-portal detection + forcing, bounce/reset, gateway/DNS checks, speed +test). + +It unifies three todo tasks that are really one feature: +- =[#C]= "archsetup Waybar Wi-Fi module should show no-internet state" — the + indicator state plus the 2026-06-22 roam expansion (bounce, diagnostics, speed + test off the component). +- =[#B]= "Network-manager dropdown, nmcli-backed" — the management dropdown. (The + todo task's original "GPG-stored secrets" framing is superseded: secrets stay in + NM's own store, decision 5.) +- The network diagnostics already shipped in =captive= (the hotel/captive-portal + tool, formerly =login-page=) become this module's diagnostics engine rather + than a standalone CLI. + +* Scope + +** In +- *Indicator* — wifi/ethernet icon + signal + SSID, plus an internet sub-state: + online / captive / no-internet / connecting / disconnected / airplane. +- *Absorbs the airplane module* — the airplane state + toggle move into + =custom/net= (airplane is a network concern). Once this ships, the standalone + =custom/airplane= module, the =waybar-airplane= + =airplane-mode= scripts, their + =tests/=, and the css are deleted (listed under Files touched). The + desktop-settings panel (sibling =[#B]=) no longer needs an airplane row. +- *Interface-correct* — targets the wifi (or chosen) device, not the + default-route interface, so an active USB tether or wired link can't mask + wifi state. (Same lesson =captive= fixed; the current =custom/netspeed= keys + off the default route and has the bug.) +- *Connection management (panel)* — list saved connections most-recently-used + first, live signal for in-range wifi, click to switch; add / edit / remove for + open + WPA-PSK; activate any existing saved profile (including enterprise ones + NM already stores); ethernet↔wifi and wifi↔wifi switching even when a link + appears mid-session. +- *Diagnostics (panel)* — read-only Diagnose (captive probe 204-vs-portal with + the extracted portal URL, gateway ping, DNS config) separated from mutating + Repair. Repair has tiers, lightest first: rfkill-unblock, per-connection reset + (fresh MAC), full-stack bounce (=nmcli networking off/on=, then restart + NetworkManager if that fails), and the temporary 1.1.1.1 override test. Each + Repair action confirms and verifies cleanup. +- *Speed test (panel)* — down/up/ping with a progress indicator and last-result + shown, via the already-installed =speedtest-go --json=. +- *Connection secrets* — none of our own. Settings and passwords live where NM + already keeps them: =/etc/NetworkManager/system-connections/*.nmconnection= + (root-only =0600=, the PSK/EAP secret stored inline). We read/write them through + nmcli, which handles the privilege. No separate file, no GPG, no gpg-agent — one + fewer dependency, and NM's store is already the secure-at-rest source of truth. +- *Persistence* — connectivity probe result cached in the runtime dir so the + bar reads it cheaply between probes. +- *Observability* — a redacted JSONL event log so a post-failure session can + diagnose without re-running destructive actions. + +** Out (v1, note for later) +- No replacement of NetworkManager's connection engine. NM stays the thing that + connects; we drive it via nmcli. +- No add/edit *form* for WPA-Enterprise / 802.1X in v1. The reason is effort vs + payoff: 802.1X has many interdependent fields (CA cert, client cert, identity, + anonymous identity, phase-2 auth) where a wrong entry silently fails auth, so a + trustworthy form is a lot of UI for connections Craig rarely adds (open + + WPA-PSK covers home, hotels, and phone hotspots). v1 still *activates* existing + saved enterprise profiles and points editing at =nmtui=/=nmcli=. Settled + (Craig, 2026-06-29): enterprise add/edit is vNext — 24 saved profiles on velox, + 0 enterprise, so the form would be unused UI; if one ever appears nmtui adds it + once and the module activates it thereafter. +- No per-connection captive-portal *auto-login* in v1. (That would mean storing a + portal's login form answers — room number, surname, a checkbox — and replaying + them automatically when a known portal is detected, so the page never appears. + Out for v1 because every portal's form differs and it means storing per-venue + answers; v1 just opens the portal for you.) +- No graphing/history of speed-test results beyond the last run. +- No static-IP / proxy / metered / MAC-randomization editing in v1 (activate + existing, edit elsewhere). +- No VPN / WireGuard management in v1, but it's a planned later phase (Phase 5), + not a permanent exclusion — it folds the existing archsetup wireguard tooling + into the same panel/CLI. +- The desktop-settings dropdown (sibling =[#B]=) is a separate module, but it + shares the GTK4 layer-shell panel shell built here. + +* Architecture + +Three layers. Keep the bar cheap, the panel rich, the logic in one tested place. + +1. *Engine* — a =net= Python package (src-layout, unittest), exposing a CLI. Wraps + every nmcli op and owns the diagnostics. Emits JSON. This is the testable + core (fake =nmcli= / =curl= / =speedtest-go= on PATH, like the existing + =waybar-netspeed= and =waybar-sysmon= test harnesses). Precedent: pocketbook is + Python in the dotfiles repo; =wtimer= is Python for the same testability + reason. +2. *Indicator* — a thin =waybar-net= script that calls =net status --json= and + renders icon + signal + state + tooltip. Replaces =custom/netspeed= + (throughput folds into the tooltip). +3. *Panel* — a GTK4 + gtk4-layer-shell app (mirrors pocketbook's structure) + that imports the engine. Hosts connection management, diagnostics, and the + speed test. + +How the existing pieces map in: +- =captive= (bash, shipped) — its cheap portal-detection logic is mirrored natively + in the engine for the fast status path so the bar never blocks on a subprocess, + and it still exposes a =--probe-json= mode the engine reuses. *As built (2026-06-30): + the force-portal flow is now native too* — =repair.py='s =portal-login= tier does + the DoT drop, portal-URL recovery, clean-browser launch, and auto-restore in + Python, so =net portal= no longer shells out to =captive= for it. =captive= stays a + usable standalone CLI. +- =waybar-netspeed= (sh, shipped) — retired; its throughput sampling moves into + the engine's status output and renders in the indicator tooltip only. +- =nmcli= — the connection backend for every op. + +Language note: the engine is Python; the indicator is a thin Python or sh +wrapper over =net status --json=. The bar path must stay fast (see Performance +budgets), so the indicator does no network I/O itself — it reads link state and +the cached connectivity result. + +Privileged-path model (v2, planned): repairs that need root (rfkill unblock, nmcli +modify/up, networking off/on, =systemctl restart NetworkManager/systemd-resolved=, +resolvectl dns/revert, the DoT toggle) go through a single root-owned helper +installed by archsetup, with a narrow NOPASSWD sudoers rule scoped to that helper +only (never a blanket =mv=/=systemctl= rule). =repair.py= calls =sudo <helper> +<verb>=. This is what lets every action run in-panel with no terminal: a GTK worker +thread has no tty, so without a passwordless path it can't prompt. It also fixes a +latent bug in the shipped portal flow — the detached DoT-restore watcher runs with +no tty and silently fails to restore encrypted DNS when sudo creds aren't cached. + +* Repository + dependencies + +- *Code lives in the dotfiles repo* (=~/.dotfiles=), not archsetup. The =net= + package sits in-tree like pocketbook (src-layout, unittest, Makefile target); + =waybar-net= and the =net= CLI entry live in the hyprland tier + (=hyprland/.local/bin/=). Tests under =tests/net/= and =tests/waybar-net/=. + archsetup owns only the *dependency install*, not the code. +- *archsetup installs the deps* in its Hyprland step: =gtk4-layer-shell=, + =python-gobject=, plus =nmcli=/=curl=/=resolvectl=/=rfkill= (already present via + NetworkManager/curl/systemd/util-linux). Speed test uses =speedtest-go= (AUR + =speedtest-go-bin=, already installed on velox); archsetup adds it to the AUR + list. librespeed-cli is the documented fallback if a self-hosted LibreSpeed + server is ever wanted. No =gpg= dependency (secrets live in NM's own store). +- *Daily-drivers*: a stowed-script + AUR-dep feature, so ratio needs the same + =git pull= + stow + the archsetup-added deps. Note the manual dep step in the + rollout. + +** Makefile targets (console recovery is a first-class path) +=net doctor= and the diagnostics are reachable from a bare TTY when waybar and +the GUI are down — that's the case where you most need them. The dotfiles +Makefile carries targets that wrap the =net= CLI so "get back online" is one make +command from the console: +- =make online= — =net doctor --fix= (diagnose, then apply the lightest repair: + rfkill-unblock → reset → bounce → open portal). The headline recovery target. +- =make net-doctor= — =net doctor= (read-only diagnose + recommendation). +- =make net-status= / =make net-diagnose= / =make net-portal= / =make net-reset= + / =make net-bounce= — the individual ops. +- =make test= — already runs =tests/*=; the =net= package's unittest suites are + collected the same way. +These intentionally need only nmcli/curl/rfkill (no GUI, no waybar, no Python +GTK), so they work from a TTY on a broken graphical session. + +* Connectivity model — split cadence + +The indicator polls every ~2s, but a real internet/captive probe every 2s wastes +battery and can re-trigger a captive portal. So split it: + +- *Fast path (every poll, cheap, no network)* — interface, type, SSID, signal, + IPv4 presence, throughput sample. From nmcli / sysfs only. No network I/O. +- *Slow path (cached, TTL ~45s)* — the actual internet/captive probe (the 204 + check + meta-refresh portal extraction). Result cached at + =$XDG_RUNTIME_DIR/waybar/net-connectivity.json= with a timestamp. + +The indicator reads the cache each poll. When the cache is older than the TTL, +=net status= kicks =net probe= in the background (spawn + detach, never awaited) +and renders the last cached sub-state meanwhile. A user-triggered +diagnose/reconnect refreshes the cache immediately. This keeps the bar +responsive and the portal un-poked. + +** Concurrency, atomicity, staleness +- *Single-flight* — =net probe= takes a lock file at + =$XDG_RUNTIME_DIR/waybar/net-probe.lock= (flock, non-blocking). A second probe + while one runs is a no-op, so a flapping 2s poll can't pile up overlapping + probes. +- *Atomic writes* — the cache is written to a temp file + =os.replace= (atomic + rename), so a reader never sees a half-written cache. Same pattern as =wtimer=. +- *Max probe runtime* — the probe has a hard timeout (≤ 6s total: curl + =--max-time 5= + slack). On timeout it writes an =unknown= result, never hangs. +- *Stale classes* the indicator distinguishes: fresh (< TTL), stale (TTL..3×TTL, + shown with a subdued/aging hint), expired (> 3×TTL → treat as unknown), + unknown (no cache / probe failed). The bar never shows a confident "online" + past the expired threshold. +- *Invalidation* — the cache records the iface + SSID + active-connection UUID it + was taken under; a change in any of them invalidates it immediately (a + reconnect must not show the old network's verdict). +- *Crash cleanup* — a stale lock older than the max runtime is ignored/reclaimed. + +* Performance budgets (hot path) + +The bar exec path (=waybar-net= → =net status=) must stay responsive: +- *Budget*: =net status= returns in < 100ms typical, < 250ms worst case. +- *No sleeping in the bar path.* Throughput is sampled from two reads of + =/sys/class/net/<iface>/statistics/{rx,tx}_bytes= across the *waybar poll + interval itself* (delta since the last cached sample + timestamp), not via an + in-process =sleep= like the old =waybar-netspeed=. The cache holds the prior + counters. +- *Subprocess cap*: at most one =nmcli= invocation on the hot path (a single + =nmcli -t -f ...= multi-field query), plus sysfs reads. Never a per-field + nmcli call. +- *Every subprocess has a timeout* (=nmcli --wait 2=, =subprocess timeout=). On + timeout or error the indicator emits a degraded JSON state (class + =net-degraded=, a neutral glyph) rather than blocking or crashing waybar. +- *Benchmark test*: a fake slow =nmcli= asserts =net status= still returns within + budget by falling back to the degraded state. + +* Engine — =net= CLI surface + +All subcommands take =--json= where a machine reads them. Pure formatting/state +functions under the CLI; IO (nmcli, curl, file) at the edges. Every subcommand +exits non-zero with a JSON error envelope (see JSON schemas) on failure. + +- =net status [--json] [--iface IF]= — fast link state + cached connectivity + sub-state + throughput. The indicator's source. Never does network I/O. +- =net probe [--iface IF]= — run the connectivity/captive probe now, update the + cache (single-flight, atomic), print online | captive (+ portal URL) | + no-internet | unknown. Mirrors =captive='s cheap detection natively. +- =net list [--json]= — saved connections, MRU order, active flag, plus in-range + wifi with signal. +- =net up <uuid>= / =net down [--iface IF]= — switch / disconnect. Operates on + UUID, not name (see nmcli contract). +- =net add= / =net edit <uuid>= / =net remove <uuid>= — manage connections + (open + WPA-PSK) through nmcli; the secret lands in NM's own + =.nmconnection=. Enterprise profiles are activate-only. +- =net rescan [--iface IF]= — wifi rescan. +- =net diagnose [--json]= — read-only report: gateway ping, DNS config, captive + probe. The structured contract below. Doubles as the post-failure snapshot. +- =net repair <action> [--json]= — mutating remediation, lightest first: + =rfkill= (unblock + radio on), =reset= (fresh MAC), =bounce= (full-stack: + =nmcli networking off/on=, escalating to =systemctl restart NetworkManager=), + =dns-test= (temporary 1.1.1.1 override, auto-reverted). Each confirms via the + caller and verifies cleanup. +- =net doctor [--json] [--fix]= — one-shot "get me online" mode for the console: + runs the full diagnose, then applies the lightest repair that fits (unblock + rfkill, reset, bounce, open portal) — read-only without =--fix=, acting with + it. The TTY recovery path when waybar/the GUI is down (see the Makefile + targets). +- =net portal [--restore]= — the native captive-login flow (=repair.py= =portal-login= + tier): short-circuits if already online, else drops DoT to plain DNS, recovers the + portal URL from the redirect, opens it in a throwaway browser profile, and spawns a + detached watcher that restores DoT once online. =--restore= forces the restore now. +- =net speedtest [--json]= — =speedtest-go --json= run; down/up/ping. + +* nmcli contract + +The command wrapper is the reliability boundary; SSIDs and connection names +contain spaces, colons, duplicates, hidden names, and non-ASCII. Rules: + +- *Terse, field-selected output*: =nmcli -t -f <fields> --escape yes ...= and + =nmcli -g <fields> ...= (get-values) for single-value reads. Parse with the + documented escaping (=\:= and =\\=); never naive =cut -d:=. +- *UUID is the handle.* Every saved-profile op (=up=, =down=, =modify=, =delete=) + uses the connection UUID, never the display name — names duplicate and contain + separators. =net list= surfaces UUIDs; the panel maps row → UUID. +- *Wait budgets*: activation/deactivation use =nmcli --wait <n>= with an explicit + budget (hot-path reads =--wait 2=; activation =--wait 30=). No unbounded waits. +- *Connectivity*: NM's own =nmcli networking connectivity= can return + =none/portal/limited/full/unknown=. Use it as a *cheap hint* on the fast path + when present, but the authoritative captive verdict is still our own probe + (NM's portal detection is coarser and config-dependent). +- *Parser tests* (fake nmcli fixtures): escaped colons and backslashes in SSIDs, + embedded newlines, duplicate connection names, hidden SSID (empty name), + non-ASCII SSID, the wired-appears-mid-session case, and the multi-active case + (wifi + tether both up). + +* JSON schemas + +Versioned (="v": 1=) envelopes so tests lock the contract. Sketches (fields +nullable unless noted): + +- =status=: ={v, iface, type: wifi|ethernet|none, ssid, signal, ipv4, + gateway, throughput: {rx_bps, tx_bps}, connectivity: online|captive|no-internet|unknown, + connectivity_age_s, connectivity_class: fresh|stale|expired|unknown, state: + online|captive|no-internet|connecting|disconnected|airplane|wired|degraded}=. +- =probe=: ={v, result: online|captive|no-internet|unknown, portal_url, http_code, + redirect_host, elapsed_ms, ts}=. +- =list=: ={v, connections: [{uuid, name, type, active, last_used, signal, + in_range, security}]}=. +- =diagnose=: ={v, steps: [<diagnostic step, see contract>], overall: + ok|warn|fail}=. +- =speedtest=: ={v, down_mbps, up_mbps, ping_ms, server, elapsed_ms, ts}=. +- error envelope (any command): ={v, error: {code, message, detail, partial: + bool}}= with a non-zero exit. + +* Diagnostics contract + +=net diagnose --json= returns an ordered list of steps. Each step is the unit the +panel renders and the log records: + +- =id= — stable identifier (e.g. =link=, =dhcp=, =gateway-ping=, =dns-config=, + =dns-resolve=, =http-probe=, =portal=). +- =status= — =pending | running | pass | warn | fail | skipped=. +- =title= — short human label. +- =evidence= — redacted detail (the value seen), per the redaction rules. +- =elapsed_ms=. +- =safety= — =read-only= or =mutating= (diagnose steps are all read-only). +- =next_action= — what the user/agent should do on warn/fail (e.g. "open portal", + "reset connection", "switch network"). + +Repair actions (=net repair=) carry the same shape but =safety: mutating=, plus a +=cleanup_verified: bool= field (e.g. the DNS override was reverted) and a +terminal =cleanup-unverified= status when revert can't be confirmed. + +** Diagnose vs Repair (read-only vs mutating) +The panel separates them visually and behaviorally: +- *Diagnose* — probe, gateway ping, DNS config read, captive check. No state + change, no sudo, runnable freely. +- *Repair* — reset (fresh MAC, deletes+recreates the NM profile), DNS override + test (mutates resolver, auto-reverts), portal force. Each needs an explicit + confirm, shows that it's privacy/state-changing, and verifies cleanup. A + Repair whose cleanup can't be verified ends in a visible =cleanup-unverified= + state, never a silent success. + +* Failure states, messages, recovery + +Each row below gives the *exact, final* user-facing string (not a template) with +=<placeholders>= for redacted evidence, plus the evidence field included and the +next action. The string is canonical: every surface renders the same text, so +there's one source of truth. + +Per-surface rendering of the canonical string: +- *Indicator* — the matching glyph + CSS class; the string is the tooltip + (untruncated). +- *Notification* (=notify=) — title = "Networking"; body = the failure label on + its own line, then the canonical string. +- *CLI* — the string on stderr; =--json= puts it in =error.message= with the + evidence in =error.detail= and a stable =error.code=. +- *Panel* — the string as the section banner, with the diagnostic step's evidence + shown beneath. +Evidence is always redacted per the redaction rules (SSID/host shown; PSK/EAP/ +portal tokens never). + +- *associated, no DHCP* — "Connected to <SSID>, no IP (DHCP failed)" → + evidence: SSID, iface → reset / reconnect. +- *no-internet* — "On <SSID>, no internet (gateway reachable, no route out)" → + diagnose / switch network. +- *captive* — "Captive portal at <host> — login required" → Open portal. +- *DNS hijack* — "DNS is being redirected (portal)" → Open portal. +- *DNS broken* — "DNS not resolving (hotel DNS down); 1.1.1.1 works" → use + override / report. +- *HTTP intercepted* — "Traffic is being intercepted before it leaves" → Open + portal. +- *sudo declined* — "Reset needs admin; it was declined — nothing changed" → + retry with auth. +- *command timed out* — "<op> timed out; the system was left unchanged" → retry. +- *partial mutation* — "<op> partially applied: <what>; rolled back to <state>" + → review. +- *missing speedtest-go* — "speedtest-go not installed" → install hint. +- *no wifi hardware* (desktop) — wifi rows hidden; ethernet-only view. +- *wifi rfkill-blocked* — "WiFi is blocked (rfkill)" → unblock. The indicator + detects a soft-blocked radio (=rfkill list= shows the radio off though hardware + is present) and shows this distinct from disconnected. =net repair rfkill= (and + =net doctor --fix= as its first step) runs =rfkill unblock wifi= + =nmcli radio + wifi on= and reconnects. This is the framework-laptop case: an out-of-power + shutdown sometimes leaves wifi soft-blocked at next boot, and yes — the module + recovers it (the rfkill state is the indicator; the rfkill repair / doctor is + the one-step fix). A *hard* block (physical switch) is reported as + not-recoverable-in-software with that message. +- *wifi rfkill hard-blocked* — "WiFi is blocked by the hardware switch" → + evidence: rfkill hard state → flip the physical switch. +- *wrong password / missing secret* — "Saved password for <SSID> was rejected" → + evidence: SSID, NM auth-failure reason → re-enter the password. +- *enterprise auth/cert failure* — "Enterprise login failed for <SSID> (802.1X)" + → evidence: SSID, EAP failure reason → edit the profile in nmtui/nmcli. +- *upstream / AP / provider* — "On <SSID>, link is fine but the network has no + uplink" → evidence: gateway reachable, no route out, not a portal → switch + network or contact the venue. +- *VPN-routed* — "Connected; internet is routed through a VPN (<dev>)" → + evidence: default route on a tun/wg device or non-NM DNS owner → check the VPN, + not WiFi. +- *HTTP interception, no parseable portal URL* — "A portal is intercepting + traffic but didn't give a login link" → evidence: HTTP code, redirect host → + opens neverssl + the gateway page to log in manually. +- *DNS override cleanup unverified* — "Couldn't confirm DNS was restored after the + test" → evidence: iface, attempted revert → revert DNS manually + (=resolvectl revert <iface>=). +- *VPN kill-switch blocking* — "A VPN kill-switch is blocking all traffic, and the + VPN itself is down" → evidence: a block artifact present with no tunnel up → bring + the VPN back, or clear the kill-switch (the exact root command surfaced, not + auto-run). + +*VPN kill-switch detection + correction.* A kill-switch blocks all non-VPN egress when +the tunnel drops, so the link looks up (wifi, IP, gateway) but nothing reaches the +internet. This extends the =deferred-vpn= branch: when a VPN is active and the probe +fails, run a rootless cascade to tell a working tunnel from a kill-switch that's +blocking because the tunnel is down — +- =ip rule= for wg-quick's =not fwmark 0xca6c= + =suppress_prefixlength 0= (and the + PostUp =REJECT ! -o %i= rule that makes it leak-proof); +- =wg show= for an up tunnel interface; +- =nmcli connection show= for Proton's =pvpn-killswitch= / =pvpn-ipv6leak-protection= + (device =pvpnksintrf0=); +- =nft list ruleset= / =iptables -S OUTPUT= for a drop/reject table (=killswitch=, + =protonvpn=, =oifname != "wg0" ... drop=); +- =nmcli -f connection.zone= for a firewalld =drop= zone. +Classify *kill-switch-blocking* only when a block artifact exists AND no tunnel +interface is up — that's what distinguishes it from a healthy VPN. Correction is tiered +by artifact and every option needs root, so surface the exact command rather than +auto-running it: =wg-quick down <iface>=, =nmcli connection delete pvpn-killswitch +pvpn-ipv6leak-protection=, =nft delete table inet killswitch=, or =nmcli connection +modify <con> connection.zone ''=. (Sits alongside the Phase 5 VPN work; detection can +land earlier since =deferred-vpn= already exists.) + +Each message names whether the system was left unchanged, partially changed (with +what), or fully changed, so the user knows the residue. + +* Doctor: escalation, classification, terminal states + +=net doctor= diagnoses, classifies the failure, then (with =--fix=) applies the +*lightest* repair that fits and re-checks — it never loops destructive repairs +against a failure they can't fix. Each failure resolves to one of four outcomes, +and the doctor stops at any terminal one: + +- =fixable= — a local repair should help. Escalate lightest-first: rfkill-unblock + → reset (fresh MAC) → bounce (full stack) → portal, re-probing after each, and + stop as soon as the probe returns online. +- =needs-user-action= (terminal) — no reset/bounce will help; doctor stops and + names the exact next step. Covers: wrong WPA password / missing NM secret + (enter the password), locked keyring or polkit denial (retry with auth), + enterprise 802.1X cert/identity failure (edit the profile in =nmtui=/=nmcli=), + captive portal login-required (open the portal + accept terms). Doctor must not + delete/recreate the profile against these — that loses the saved password and + makes things worse. +- =upstream-not-local= (terminal) — the local link is up but the problem is past + it: AP has no uplink, gateway down/dropping traffic, DHCP server broken, ISP + outage, portal backend failing. =diagnose= proves it (link up + IP + gateway + reachable, but no route out and not a captive redirect), and =doctor --fix= + stops after local repairs are exhausted with "local repairs tried; likely + upstream/AP/provider" + the evidence. Next action: switch networks or contact + the venue. +- =deferred/vpn= (terminal for v1) — an active VPN / policy route / non-NM + resolver owns the default route or DNS, so "no internet" may be the VPN's fault, + not WiFi's. v1 *detects* this (default route on a =tun/wg= device, or DNS owned + by something other than the NM link) and classifies it separately — "link is + fine; internet is VPN-routed" — rather than misclassifying it as a WiFi failure. + v1 does not repair it (VPN management is Phase 5); it names the VPN as the likely + owner and stops. + +** DNS handling in doctor (explicit per class) +- *Captive DNS hijack* — open the portal (the hijack clears on login). No DNS + mutation. +- *Broken resolver, 1.1.1.1 works* — the shipped =dns-test= repair is *diagnostic*: + it sets 1.1.1.1, confirms the venue resolver is the culprit, then auto-reverts + (=cleanup_verified=). Because it reverts, =doctor --fix= does not currently leave + you online in this case — it falls through to =upstream-not-local=, which + misreports a locally-fixable problem. *V2 fix (planned):* on a dns-test *pass* + (public DNS works), set a PERSISTENT resolver override and verify online, with an + offered revert — and classify it as its own outcome rather than upstream. +- *Port-53 / egress blocked* (even 1.1.1.1 fails) — terminal =upstream-not-local=; + doctor stops, since it's not locally fixable. + +* Failure-mode coverage + +*V2 note (2026-06-30):* the authoritative, exhaustive catalog (~44 modes across 10 +connectivity layers, edge cases included, each tagged fix-and-verify or report-text) +now lives in the redesign task (todo.org "Network panel redesign"). The table below is +the v1 baseline; two rows reflect intent the shipped code doesn't yet match, and the +v2 catalog closes them: =gateway unreachable= claims a bounce that doctor never +actually reaches (a no-route failure goes straight to =upstream-not-local=), and +=broken DNS, 1.1.1.1 works= auto-reverts so the user is left offline and misreported +as upstream (the v2 persistent-override fix closes this). + +For each common field failure: does =net diagnose= detect it, can =net doctor +--fix= repair it, and what terminal user action remains when it can't. (The +=needs-user-action= / =upstream-not-local= / =deferred/vpn= outcomes are defined +above.) + +| Failure mode | diagnose detects | doctor --fix | terminal user action | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| rfkill soft block | yes | yes (unblock) | none | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| rfkill hard block | yes | no | flip the physical switch | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| no wifi hardware | yes | n/a | use ethernet | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| associated, no DHCP | yes | yes (reset/bounce) | none, else switch network | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| gateway unreachable | yes | yes (bounce) | switch network if it | +| | | | persists | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| captive DNS hijack | yes | opens portal | log in at the portal | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| broken DNS, 1.1.1.1 works | yes | yes (temp override, | report the venue's DNS | +| | | auto-reverted) | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| HTTP captive portal | yes | opens portal | log in at the portal | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| HTTP interception, no | yes | opens neverssl + gateway | log in manually | +| parseable URL | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| upstream / AP outage | yes (link up, no route out) | no (stops after local) | switch network / contact | +| | | | venue | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| wrong WPA password / | yes | no | enter the password | +| missing secret | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| enterprise auth / cert | yes | no | edit the profile in | +| failure | | | nmtui/nmcli | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| duplicate SSID / | yes (UUID-keyed) | yes (activate by UUID) | none | +| connection-name | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| hidden SSID | yes | yes (connect by name) | enter SSID + password | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| multiple active links | yes | n/a | pick the interface | +| (wifi+tether) | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| wedged NetworkManager | yes | yes (bounce → restart NM) | none, else reboot | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| slow / hung command | yes (degraded) | retries within budget | retry | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| stale / corrupt cache | yes | self-heals (atomic + | none | +| | | invalidation) | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| DNS cleanup failure | yes | flags cleanup-unverified | revert DNS manually | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| missing speedtest backend | yes | n/a | install speedtest-go | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| VPN / policy-routing | yes (route/DNS ownership) | no (deferred to Phase 5) | check the VPN | +| interference | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| + +* Observability — logging + redaction + +- *Event log*: JSONL at =$XDG_STATE_HOME/net/events.jsonl= (fallback + =~/.local/state/net/events.jsonl=), size-rotated (e.g. 1 MB × 3). Every + mutating op and probe appends an event: =ts, op, argv (redacted), exit_code, + stderr_tail, elapsed_ms, iface, nm_uuid, probe_url_class, http_code, + redirect_host, cache_event=. +- *Redaction (always on)*: PSKs, EAP identities/passwords, NM secrets, and + portal query tokens are never logged. MAC addresses, full IPs, and SSID are + redacted when configured (=redact_mac=, =redact_ip=, =redact_ssid= in config). +- *Post-failure diagnosis*: =net doctor --json= is the snapshot + recommendation + (diagnose plus the suggested repair), =net diagnose --json= the raw report, and + the event log the history. =net doctor= is the console-recoverable entry point + (reachable as =make online= / =make net-doctor=). +- *Secret-leak tests*: assert no PSK/EAP/portal-token ever appears in any JSON + output, log line, or error message. + +** Automatic diagnostic verbose-capture (V2) + +A distinct layer from the event log above: that log records what =net= did; +this captures what the *underlying stack* did at debug verbosity during a run, so a +failed diagnosis leaves real ground-truth instead of relying on memory. Two triggers, +one mechanism: + +- *Automatic — on a failing diagnose.* When =net diagnose= ends =overall: fail=, the + next escalation (or =Get Me Online=) runs inside a verbose-capture session. +- *Manual — a debug on/off toggle in the panel's Advanced section.* "Debug on" + elevates and leaves it elevated (with a visible "debug capturing" indicator) so the + user can reproduce an intermittent problem over time; "Debug off" restores and + writes the bundle. Useful when the failure doesn't reproduce inside one diagnose. + +Mechanism (shared): +1. *Snapshot* the current log levels (=nmcli general logging=, resolved's level, + wpa_supplicant's). +2. *Elevate* the relevant components to debug at runtime, no restarts, scoped to the + domains that matter (NM: =WIFI,DHCP,DNS,CORE=; resolved; wpa_supplicant). +3. *Run* the diagnostics / repair. +4. *Capture the window*: =journalctl= for NetworkManager + systemd-resolved + + wpa_supplicant since the run started, a =dmesg= tail (driver / firmware / rfkill), + and any =curl -v= probe output. +5. *Restore* every level to its snapshot. +6. *Write a redacted support bundle* to =$XDG_STATE_HOME/net/bundles/<ts>/= and + surface it in the panel. + +Hard requirements: +- *Restore is guaranteed and idempotent.* A =try/finally= restores even on error, + and a crash-recovery guard detects "a prior run left NM/resolved/wpa_supplicant + elevated" on the next run and puts it back — the same shape as the DoT-restore + watcher. A crash must never strand the stack at debug verbosity. +- *Redaction before anything leaves.* Raw wpa_supplicant and NM debug logs carry the + PSK and EAP credentials in cleartext. The captured journal is scrubbed before the + bundle is written, shown, or shared; the secret-leak test asserts no passphrase or + EAP secret survives into a bundle. +- *Privilege via the V2 sudo-helper.* The log-level toggles need root, so they become + verbs on the passwordless helper (decision 16) — no extra prompt. + +Bonus — this closes a real detection gap, not just observability: the spec notes live +auth-failure detection is a v1 limit (it leans on a one-shot NM state-120 snapshot). +wpa_supplicant at debug during the run is exactly how a wrong-password or EAP failure +is caught properly, so the capture feeds back into classification. + +* Indicator (task #C — Phase 1, the fast win) + +** States (internet sub-state on top of link state) +- online — associated and the probe returned 204. Normal icon. +- captive — associated, probe hit a portal. Distinct glyph + warning CSS class; + tooltip names the portal host; left-click opens diagnostics with the portal + ready to open (Phase 2+; see interactions for the Phase-1 interim). +- no-internet — associated, probe failed (no portal, no 204). Distinct glyph + + warning class. +- degraded — =net status= couldn't read link state within budget (slow/failed + nmcli). Neutral glyph, =net-degraded= class. Never blocks the bar. +- rfkill-blocked — the radio is soft-blocked (=rfkill=), distinct from + disconnected. Distinct glyph; the fix is =net repair rfkill= / =net doctor=. +- connecting / disconnected / airplane / wired — as today, plus wired shown + correctly even when it appears after session start. (airplane is now this + module's state, absorbed from the retired airplane module.) + +** Glyphs +Nerd-font codepoints, final values verified live before merge (same discipline +as wtimer). Reuse the signal-strength ramp already in =waybar-netspeed=; add a +captive / no-internet / degraded overlay glyph. + +** Tooltip +SSID + signal + IPv4 + gateway + the throughput readout (absorbed from +netspeed) + the last probe result and its age (stale/expired hinted). + +** Interactions (no keyboard-modifier clicks — waybar can't qualify clicks by +modifier, so the rich actions live in the panel, not ctrl/super-click) +Clicks never block the bar: each dispatches a detached background job, single-flight +per action. *As built (settled live with Craig, 2026-06-29):* +- *left* — =net-panel= toggle (pkill-or-launch the GTK panel). +- *middle* — =net portal= (the captive-login flow). +- *right* — =net-fix= (=net doctor= with =--notify=: reports the result when the + outcome is one-way, opens a terminal only when it's fixable; the v2 redesign moves + even that into the panel). +- airplane toggle moved off the bar to Super+Shift+A. + +* Panel (tasks #B + #C diagnostics — Phases 2-3) + +GTK4 + gtk4-layer-shell, pocketbook scaffold (src-layout package, unittest, +Makefile, gtk4-layer-shell anchored dropdown under the bar). One panel shell, +reused by the future desktop-settings panel. + +Sections as built (Phases 1-3, a four-page stack switcher): +1. *Connections* — list, MRU-first, active marked, live signal bars for in-range + wifi; row click switches; buttons for add / edit / remove; a rescan control. +2. *Diagnose* (read-only) — Probe (204/captive, shows portal URL + Open), Gateway + ping, DNS config. Streaming step output (the diagnostics contract). +3. *Repair* (mutating, confirmed) — tiered lightest-first: Unblock rfkill, Reset + (fresh MAC), Bounce (full stack), DNS override test, Force portal. A "Get me + online" button runs =net doctor --fix= (the auto-escalating sequence). +4. *Speed test* — Run button, progress, down/up/ping result + last-run line. +As built, the panel also auto-hides on focus-out (popup behavior, suppressed while a +child dialog holds focus) and carries a Close button bottom-right (2026-06-30). + +*V2 nav (planned):* three top tabs — Connections | Diagnostics | Performance. +Diagnostics merges the Diagnose and Repair pages into one: a sub-row +=Diagnose= | =Get Me Online= | =Advanced= over a shared area that shows diagnose +items and streams repair progress in-panel (no terminal). =Advanced= reveals the +individual repair tiers (renamed, with tooltips) plus a *Debug capture on/off* +toggle (the manual side of the verbose-capture feature; a failing diagnose triggers +it automatically). Speed test moves under Performance. + +** V2 panel UX — the target design + +The shipped four-page stack (Connections / Diagnose / Repair / Speed test) is +*history*, not active design. V2 is the sole current target: one panel opened from +the bar, three top tabs — Connections | Diagnostics | Performance — and the page +model below is the contract for what gets built and what gets deleted, not just for +labels. + +*** Connections — saved vs available, join-from-row +Three labelled groups, never one merged list: +- *Saved* — saved NM profiles, MRU-first, rendered instantly without a scan. +- *Available now* — scan-backed in-range SSIDs with signal + security; may carry a + loading/stale hint; unsaved networks appear here. +- *Wired* — ethernet when a wired device is present. +=net list= already yields this (=connections.py= lists saved MRU-first, merges live +signal/security for in-range saved profiles, then appends unsaved in-range SSIDs with +=uuid: nil=); the panel groups and labels it. *Rescan refreshes only the +availability/signal layer* — it never gates or reloads the Saved list. + +*Progressive loading:* render the Saved group immediately on open, then overlay +availability, signal, and the unsaved Available-now rows when the scan returns. Show a +small scan-in-progress state (elapsed + last-scan age). A slow or bad radio scan must +not make the whole panel feel stuck — this is the direct answer to "why does it take +so long to see my connections?" + +*Join-from-row (no Add page):* selecting an unsaved Available-now row *is* the join +flow — SSID and security come prefilled from the scan, never retyped. Open networks +connect (confirm only if needed); WPA/WPA2/WPA3-Personal ask only for the password. +The standalone Add button + modal are deleted for visible networks. A hidden/manual +SSID join lives behind an Advanced "Join hidden network" affordance. + +*** Supported authentication classes (the join matrix) +From the scanned NM =SECURITY= value, V2 handles: +- *Inline-supported* — open, open-with-captive-portal, WPA/WPA2/WPA3-Personal + (PSK/SAE), and WPA2/WPA3 transition mode. The row shows the security label so the + user knows why a password is or isn't asked. +- *Activate-only* — 802.1X / enterprise: connect if already saved, else "edit in + nmtui/nmcli" (no add form in v1/V2, per decision 9). +- *Hidden / manual* — behind the Advanced "Join hidden network" affordance. +- *Rare / unsupported* — WEP, OWE/enhanced-open, MAC-registration, voucher, or + proxy-required: a clear in-panel explanation ("not supported here yet") plus a + non-terminal next step, never a hand-off to a terminal tool. + +*** Diagnostics owns the diagnostic story +Diagnostics holds the read-only checks, the repair stream, Get Me Online, debug +capture (Advanced), and the doctor report. A *lightweight* latency/throughput probe +runs inline as a Diagnose evidence row when internet is available (skipped offline, on +a metered/hotspot warning, or with no backend), and its result is stored in the doctor +report. The *full* speed test stays under Performance (decision 19) — which is also +the home for future throughput history, so Performance earns its tab rather than being +a lone button. + +*** Forget confirmation — future tense + verified +The destructive copy is future tense and names the scope: "This will remove the saved +NetworkManager profile and its stored password from this machine." After the op, +verify the UUID is gone, refresh the Saved list, and report "Forgot <SSID>" or "Could +not forget <SSID>; nothing changed / partial <evidence>" — the verify-every-action +decision applied to a destructive op. + +*** Findable diagnostics report +Every diagnose, repair, and speed/performance run ends with a "Copy report" / "Open +report" action in Diagnostics. The report carries the step statuses + elapsed, the +final classification, the last speed/latency result when available, scan age, +route/interface owner, the redacted event-log tail, and the bundle path when verbose +capture ran. It states explicitly whether any repair mutated state and whether +cleanup/verification passed. "Logs exist somewhere" isn't enough when the network is +already down — the report is the one artifact the user copies to hand over. + +*** Visual contract — a Waybar-attached popup +The panel reads as part of the bar, not a separate app. Match the live Waybar theme: +the dark rounded capsule (=border-radius: 1rem=), the golden border, compact monospace +text, and the =custom/net= state colors. Avoid square corners next to rounded UI, keep +cards out of cards, and use compact icon+label controls with tooltips for the advanced +repairs. Reuse any existing archsetup-owned GTK/panel conventions. (Non-blocking for +engine work; blocks final V2 UX acceptance.) + +** Panel state, cancellation, permissions +State machines for: connection-list loading, rescan-in-progress, +activation-in-progress, diagnose-running, repair-running, speedtest-running. Plus +the real terminal states on this two-machine fleet: no-wifi-hardware (desktop → +ethernet-only view) and missing speedtest-go. (No GPG-key state — there's no +credential store; secrets live in NM.) ("No NetworkManager" is not a modeled +state — NM is always present +on these machines; if nmcli is somehow absent the panel shows a single hard-error +and exits.) Long operations show elapsed time and are cancellable where the +underlying op allows (rescan, speedtest, probe); clearly non-cancellable ones +(an in-flight activation) show elapsed + a disabled control. Permission-denied +(sudo/polkit declined) is a first-class outcome with the "nothing changed" +message, never a silent failure. + +Interaction-pattern catalog (=~/code/rulesets/patterns/=) principles that apply: +- transient-state-buttons — all the network levers in one place, reachable by + one chord (the bar click), state visible. +- default-most-common-friction-proportional — connections MRU-ordered so the + common pick is first; destructive ops (remove) and privacy-changing ones + (reset, override) get a confirm, switching does not. +- one-prompt-picker-typed-prefix — if the connection picker ever goes + keyboard-driven, kind (wifi/eth/saved/in-range) + name in one typed picker. + +** Panel UX flow (settle before Phase 2) +The concrete interaction defaults, so the GTK build isn't inventing them: +- *Default focus*: the Connections section, current connection's row selected. If + the indicator opened the panel because of a captive/no-internet state, focus + Diagnose instead with the relevant action highlighted. +- *Row content*: glyph (signal bars / wired / active check) + name + a secondary + line (security type, "active"/last-used). The active row is visually pinned at + top of its group. +- *Buttons*: one *primary* per section (Connections: Connect to the selected row; + Diagnose: Run diagnose; Repair: "Get me online"; Speed test: Run). Secondary + actions (add / edit / remove / rescan; individual repair tiers) are smaller and + grouped. +- *Disabled rules*: Connect disabled on the already-active row; Repair tiers + disabled while one runs; Speed test disabled while running; add/edit disabled + for enterprise (with the "edit in nmtui/nmcli" hint). +- *Confirmations* (exact wording): Reset → "Reset <SSID>? This drops the + connection and reconnects with a new MAC."; Bounce → "Restart networking? All + links drop briefly."; DNS override → "Temporarily set DNS to 1.1.1.1 for the + test? It reverts automatically."; Remove → "Forget <SSID>? The saved password is + deleted." +- *"Get me online" reporting*: shows each escalation step live (Unblock rfkill → + Reset → Bounce → Portal) with per-step pass/fail and stops at the first that + restores internet or at a terminal state, naming the next action. +- *After close*: the bar reflects the new state immediately (signal/refresh on + next poll); a running speedtest/diagnose keeps running and notifies on finish + (panel close doesn't cancel it). +- *Keyboard*: Esc closes (wired); arrows move row focus and Enter activates a + row (GTK ListBox defaults — row-activate connects, never disconnects); Tab is + the plain GTK focus chain, widget by widget (inside a list it crawls row by + row — no section jumps); there is NO type-to-filter. Verified live via + targeted-key AT-SPI probe 2026-07-02; the earlier tab-between-sections and + type-to-filter claims were aspirational and are struck. If section-jump Tab + or filtering is ever wanted, it's a new task, not an existing behavior. + +* Connection management (nmcli) + +- Every op via nmcli per the nmcli contract above (terse, escaped, UUID-keyed, + bounded =--wait=). +- MRU ordering from NM's =connection.timestamp= (last activated), descending. +- Ethernet appears in the list whenever a wired device is present, selectable at + any time; switching just brings the chosen connection up. +- *Mutation safety + rollback*: switching keeps the current connection up until + the new one activates successfully (=nmcli --wait 30=); on failure it does not + tear down the working link, surfaces the failure, and leaves the prior + connection active. =net down= notes that NM may auto-reactivate a profile and + reports the post-op active connection so the user isn't surprised. A switch that + needs a password it doesn't have prompts (or fails with "password required"), + never silently strands. The exact NM command sequence (preflight active-state + read → activate target → verify default route → on failure, confirm prior + still up) is pinned in the engine and tested against fake nmcli. +- *Add/edit scope*: open + WPA-PSK only in v1. Existing saved profiles of any + type (including enterprise) can be *activated*; editing an enterprise profile + shows "edit via nmtui/nmcli" rather than a broken partial form. + +* Connection secrets (no separate store) + +Per Craig's call: don't build a parallel credential store. Settings and secrets +live where NetworkManager already keeps them, so there's one source of truth and +no extra dependency (no GPG, no gpg-agent, no =~/.config/net/connections=). + +- *Where secrets live*: =/etc/NetworkManager/system-connections/<name>.nmconnection=, + root-owned =0600=, with the PSK/EAP secret stored inline (the default + =secret-flags=0= "owned by NM"). That's already secure-at-rest (root-only) and + is what =nmcli= reads/writes. +- *How we touch them*: every add/edit/remove goes through =nmcli= (=connection add + / modify / delete=), which writes the =.nmconnection= with the right ownership + and perms. We never read or write =system-connections= files directly (root) and + never copy a secret out of them. +- *No export / import / sync* — there's nothing to sync. A new machine gets its + connections the way it always has (the user joins, or restores NM profiles), + not from a tool-specific vault. +- *config file*: =~/.config/net/config= still exists, but only for non-secret + preferences (speedtest server, redaction flags, probe TTL). It holds no + credentials. +- *No secret leakage*: PSK/EAP never appear in =net=' =--json= output, the event + log, or error text (tested) — even though NM is the store, our surfaces must not + echo a secret =nmcli= happens to return. + +* Speed test + +- Backend: *=speedtest-go=* (=--json=, =--server=, =--no-download/--no-upload=), + already installed on velox (AUR =speedtest-go-bin=). No new dependency for v1. + librespeed-cli is the documented fallback for a self-hosted LibreSpeed server. +- =net speedtest --json= parses speedtest-go's JSON into the =speedtest= schema. +- *Server policy*: auto-select nearest by default; allow a pinned server id in + =~/.config/net/config=. +- *Timeout + cancellation*: a hard run timeout (e.g. 60s); the panel run is + cancellable (kills the child). Offline / rate-limited / no-server errors map to + the failure-message table. +- *Tests*: fixture JSON (success) and fixture stderr (offline, no server, + malformed output) drive =net speedtest= parsing without touching the network. + +* Help + documentation + +In-app help has three layers, each reachable in the situation it's needed: + +- *CLI help (works from a dead-GUI TTY)*: =net --help= lists the subcommands in + one screen; =net <cmd> --help= documents each (flags, what it mutates, the + console-recovery targets). The Makefile targets are self-describing (=make help= + lists =online= / =net-doctor= / etc. with one-line descriptions). This is the + layer that matters most when you're at a console with no network. +- *Panel help (in the GUI)*: a small =?= affordance in the panel header opens an + inline help pane — what each section does, which Repair actions mutate state, + what the indicator glyphs/colors mean. Per-control tooltips on the less-obvious + buttons (rfkill, bounce, DNS override). No external help browser. +- *User guide (the durable doc)*: a README / docs page covering every command, + the indicator states + glyphs, the panel sections, the config file keys, the + recovery make targets, troubleshooting (the failure-message table), and + rollback. Written so a future session — or Craig six months out — can operate + and recover the module from the doc alone. + +The failure-message table above is the single source of truth for the +troubleshooting text; the guide and the panel help both render from it rather +than restating it. + +* Enhancement radar + +Low-cost adjacent affordances, each dispositioned so cheap wins aren't lost and +the v1 panel stays focused. (Several are already in v1 by virtue of other +sections; marked here so the consideration is visible.) + +| Enhancement | Disposition | Reason | +|-------------------------------------+-------------+--------------------------------------------------------| +| Open / copy portal URL | v1 | already in the captive flow; trivial Open + Copy | +|-------------------------------------+-------------+--------------------------------------------------------| +| Forget network | v1 | it's the remove op, already specced | +|-------------------------------------+-------------+--------------------------------------------------------| +| Rescan now | v1 | already a Connections control | +|-------------------------------------+-------------+--------------------------------------------------------| +| Retry with hardware MAC | v1 | captive already has --hardware-mac; expose in Repair | +|-------------------------------------+-------------+--------------------------------------------------------| +| Pin speedtest server | v1 | already a config key | +|-------------------------------------+-------------+--------------------------------------------------------| +| Copy redacted doctor report | v1 | cheap, serves the observability/support goal | +|-------------------------------------+-------------+--------------------------------------------------------| +| Show last good network / result | vNext | needs small history persistence | +|-------------------------------------+-------------+--------------------------------------------------------| +| Watch mode for net doctor | vNext | a --watch loop; handy at a TTY, not v1-critical | +|-------------------------------------+-------------+--------------------------------------------------------| +| Actionable desktop notifications | vNext | dunst supports actions; extra wiring | +|-------------------------------------+-------------+--------------------------------------------------------| +| Keyboard connection picker (fuzzel) | vNext | the typed-prefix pattern; panel covers v1 | +|-------------------------------------+-------------+--------------------------------------------------------| +| QR-code share / import WiFi | rejected | low value for a personal 2-machine setup; phones do QR | +|-------------------------------------+-------------+--------------------------------------------------------| + +* Waybar wiring + +- Replace =custom/netspeed= with =custom/net= in the bar's module list (same + slot). +- Module def: =exec: waybar-net=, =return-type: json=, =interval: 2=, a =signal= + for on-demand refresh (next free signal after wtimer's 14), =on-click=, + =on-click-right=, =on-click-middle= per the phase-aware interactions (each + dispatches a detached job, never blocks). +- Remove the old =on-click: pypr toggle network= scratchpad only once the panel + replaces it (Phase 2); Phase 1 keeps it as the interim manager. + +* Testing plan (TDD) + +- *Engine (normal)* — fake =nmcli= + =curl= + =speedtest-go= on PATH; assert + command sequences and parsed/emitted JSON for status, list, up/down, + add/edit/remove, probe, diagnose, repair, speedtest. Pure state/format + functions tested directly. JSON schemas locked by example. +- *Portal parser* — already covered in =tests/captive= (Normal/Boundary/Error + + the real SONIFI body). The engine's native probe reuses the same cases. +- *nmcli parsing* — escaped colon/backslash/newline in SSID, duplicate names, + hidden SSID, non-ASCII, wired-mid-session, multi-active (wifi+tether). +- *Failure + concurrency (the risky classes)* — slow/hung nmcli/curl/speedtest + (degraded state within budget), concurrent =net status= probe refresh + (single-flight), corrupt cache (recovered), stale cache after SSID change + (invalidated), permission denied / sudo declined, DNS-override cleanup failure + (=cleanup-unverified=), NM partial activation (rollback keeps prior link), + secret redaction, missing speedtest-go, no wifi hardware, rfkill soft/hard + block. +- *Doctor classification* — fixture-driven =net doctor= over fake nmcli/curl + asserting the right terminal classification + that =--fix= stops before + destructive repairs: auth failures (=needs-user-action=), upstream/AP failure + (=upstream-not-local=), VPN-routed failure (=deferred/vpn=), and the DNS classes + (hijack → portal, broken-but-1.1.1.1-works → offered override, egress-blocked → + upstream). Assert the failure-mode coverage table's "detects / repairs / terminal + action" holds for each row. +- *Indicator* — drive =net status --json= through =waybar-net=, assert the JSON + per state (online / captive / no-internet / degraded / wired / disconnected / + rfkill), iface override via env. +- *Panel* — pocketbook-style: backing logic (list ordering, op dispatch, + state-machine transitions), not GTK widgets. +- *NM secrets / no-leak* — add/edit writes the secret into NM via nmcli (asserted + against fake nmcli, never to a tool-owned file); assert no PSK/EAP appears in any + =--json=, log line, or error (there is no credential store to round-trip). +- *Live checklist (gated out of the suite)* — a "Manual testing and validation" + task per phase for the real-network states (captive at a hotel, no-internet, + switch under load, reset, speedtest) that can't be faked. + +** Harness + coverage gate +The concrete contract, matching the repo's existing convention (not pytest — the +dotfiles suites are =unittest=, run by =make test= as =python3 -m unittest= over +=tests/*/test_*.py=; 33 suites today): +- *Framework*: =unittest=. Each suite is =tests/<name>/test_<name>.py= + (=tests/net/=, =tests/waybar-net/=), collected by the existing =make test= loop + — no new runner, no pytest dependency. +- *Fakes on a temp PATH*: =fake-nmcli=, =fake-curl=, =fake-speedtest-go=, + =fake-rfkill=, =fake-resolvectl= live as executable stubs in =tests/<name>/= + (the =tests/layout-navigate/fake-hyprctl= pattern). A fixture file encodes the + command→canned-output map and the stub appends each invocation to a log the test + asserts against. Subprocess timeouts are simulated by a stub that sleeps past the + budget; =net status= must still return the degraded state. +- *Waybar wrappers end-to-end*: =waybar-net= is run as a subprocess with the fake + PATH and the env overrides (iface, cache path), asserting the emitted JSON — same + as =tests/waybar-netspeed=. +- *Coverage*: coverage.py is absent system-wide (and not importable), so coverage + runs in a throwaway venv (=python3 -m venv=, =pip install coverage=, =coverage + run -m unittest=, =coverage report=) — the method the wtimer suite used (95%). + Target: *branch* coverage over =net/= and the wrapper, ≥ 90% on the pure + classifier/parser modules. + +** Coverage as a gap-finder, not a number (per phase) +Line coverage alone misses the branches that matter here, so each phase ends with +a *coverage-gap pass*, not just a percentage: +- After the first green run, read the branch report and map every uncovered branch + to either a new test or a consciously-excluded live-only behavior (with a comment + or a Manual-testing entry naming it). +- *Branch coverage is required* for the pure logic: the doctor classifier (every + outcome — fixable / needs-user-action / upstream-not-local / deferred-vpn), the + cleanup-unverified path, the redaction paths, the degraded hot-path fallback, the + timeout branches, and the portal/nmcli parsers. +- A phase isn't "done" until its coverage-gap pass is recorded — uncovered logic is + either tested or explicitly excused, never silently uncovered. + +* Files touched (planned, all in =~/.dotfiles=) + +- =net/= package (src-layout, like pocketbook) — engine + panel. +- =hyprland/.local/bin/waybar-net= — the indicator (replaces =waybar-netspeed=). +- =hyprland/.local/bin/net= — engine CLI entry (console-script shim). +- =hyprland/.config/waybar/config= — swap =custom/netspeed= → =custom/net=; + remove =custom/airplane=. +- =hyprland/.config/waybar/style.css= — captive / no-internet / degraded / + rfkill classes; remove airplane classes. +- =tests/net/=, =tests/waybar-net/= — suites. +- =captive= — refactor: extract probe + reset into functions callable + non-interactively (a =--json= probe mode) so the engine reuses them. +- =~/.config/net/config= — seed config (probe TTL, speedtest server, redaction + flags). No secrets; not a credential store. +- dotfiles =Makefile= — add the console-recovery targets (=online=, =net-doctor=, + =net-status=, =net-diagnose=, =net-portal=, =net-reset=, =net-bounce=). +- *Deletions once net ships* (the airplane module is absorbed): + =hyprland/.local/bin/waybar-airplane=, =hyprland/.local/bin/airplane-mode=, + =tests/waybar-airplane/=, =tests/airplane-mode/=, and the =custom/airplane= + module + its css. +- archsetup Hyprland step — add =gtk4-layer-shell=, =python-gobject=, + =speedtest-go-bin= to the install lists (the only archsetup change; no =gpg= + added, secrets stay in NM's store). + +* Resolved decisions (Craig's calls + this response) + +1. Panel UI tech → GTK4 + gtk4-layer-shell, shared pocketbook scaffold (one + panel shell, reused by the desktop-settings sibling). +2. Engine language → Python =net= package; shells out to =captive= for the + portal-force flow, native cheap probe for the bar path. +3. Connectivity probe → split cadence (fast link poll every 2s + slow cached + internet/captive probe, TTL ~45s) with single-flight + atomic cache. +4. No keyboard-modifier clicks (waybar can't qualify them) — the panel hosts the + rich actions; bar clicks dispatch detached jobs (phase-aware). +5. No separate credential store (Craig's call, cj). Secrets live in NM's own + =system-connections= (root =0600=, inline), touched via nmcli. No GPG, no + gpg-agent, no =~/.config/net/connections=. Supersedes the earlier GPG-store + design. +6. =custom/netspeed= absorbed into =custom/net=; throughput moves to the tooltip. +7. Speed-test backend → =speedtest-go= (already installed), not a new + librespeed-cli dependency; librespeed-cli is the self-hosted fallback. +8. Code lives in the dotfiles repo; archsetup only installs deps. +9. v1 add/edit scope = open + WPA-PSK; enterprise/802.1X is activate-only, + add/edit is vNext (settled by Craig 2026-06-29 — no enterprise networks in his + history, so the form would be unused UI). +10. =net doctor= is in v1 (Craig's call, cj) — a one-shot diagnose+fix mode, + reachable from a TTY via =make online= / =make net-doctor=. (The earlier + "defer the doctor/bundle command" decision is reversed.) +11. Diagnose (read-only) and Repair (mutating, confirmed) are separated in the + panel and the CLI; Repair is tiered lightest-first (rfkill → reset → bounce). +12. =custom/net= absorbs the airplane module (Craig's call, cj). *As built + (2026-06-29, option 1): display-only.* net shows the airplane state (reads + the airplane-mode state file); the =airplane-mode= low-power toggle is kept + (radios + CPU + brightness + services is not a network concern) and moved to + =custom/net='s right-click + signal 15. Only the redundant display pieces — + =waybar-airplane=, =custom/airplane=, and the retired =waybar-netspeed= — + plus their tests/css were deleted. The earlier "delete airplane-mode" framing + is superseded. +13. Repair includes a full-stack bounce and an rfkill-unblock (Craig's calls, + cj) — the latter recovers the framework-laptop post-power-loss soft-block. +14. VPN / WireGuard is a planned Phase 5 (Craig's call, cj), not a permanent + exclusion. + +V2 redesign decisions (Craig, 2026-06-30): + +15. *No terminals anywhere in the module* — =net-popup= is removed; every action and + result renders in the panel. No terminal is ever used to report information to the + user or to collect input from them: every prompt, confirmation, repair stream, and + result lives in the panel UI (Craig, cj, 2026-06-30). Reverses the part of decision + 11 that ran privileged repairs in a terminal "so sudo/polkit can prompt". (Unrelated + to the doctor's "terminal states" — that word means a final outcome, not a tty. The + one open question is the dead-GUI console-recovery path; see the VERIFY in todo.org.) +16. *Passwordless privileged path* — a root-owned helper + a narrow NOPASSWD sudoers + rule scoped to it, archsetup-installed, run as =sudo <helper> <verb>=. This gates + decision 15 (a worker thread can't prompt). Absorbs the earlier DoT-toggle + follow-up and fixes the detached-restore-watcher bug. +17. *Verify every action* — each mutating op (repair, connect, forget, add, DNS + override) re-checks its effect and surfaces pass/fail in the panel. +18. *Detect + respond to every failure mode, edges included* — the full ~44-mode + catalog (todo.org redesign task) is the contract; auto-fix where safe, else report + the exact in-panel text. Includes IPv6-only awareness and multi-homing, which need + diagnose to stop being IPv4-only and single-iface. +19. *Navigation* — top tabs Connections | Diagnostics | Performance; Diagnostics + merges Diagnose + Repair (Diagnose | Get Me Online | Advanced over a shared + streaming area); Speed test under Performance. +20. *Automatic diagnostic verbose-capture* (Craig, 2026-06-30) — on a failing + diagnose, elevate the underlying stack (NM / resolved / wpa_supplicant) to debug, + capture the journal + dmesg window, restore (guaranteed + crash-guarded), and + write a redacted bundle. Plus a manual Debug on/off toggle in Advanced. Restore + bulletproof, secrets scrubbed before the bundle, log-level toggles via the V2 + helper. See Observability. + +* Implementation phases + +*Phases 1-3 are SHIPPED* (2026-06-29 → 2026-06-30, dotfiles); their acceptance +criteria passed and the work is live on velox. Phase 4 (docs/rollout) and Phase 5 +(VPN) remain. The V2 redesign phases at the end are designed, not yet built. + +- *Phase 1 — Indicator + console recovery (task #C).* =net status= + =net probe= + (native cheap probe, reusing captive's logic) + the =captive= probe refactor + + =waybar-net= + the split-cadence cache (single-flight, atomic, stale classes) + + CSS states (incl. rfkill) + performance budget. Plus the CLI-only recovery path: + =net repair= tiers (rfkill / reset / bounce), =net doctor [--fix]=, and the + Makefile targets (=make online= etc.) — all testable without the GTK panel. + Absorbs the airplane state and removes the standalone airplane module. Interim + left-click keeps the existing scratchpad until the panel lands. + - *Acceptance*: fresh-login waybar smoke test shows correct state on + online/captive/no-internet/wired/rfkill; =net status= stays within budget + under a fake slow nmcli (degraded state); =net doctor --fix= recovers a + soft-blocked radio from a TTY; the live captive checklist passes at a real + portal; the airplane state works and the old airplane module is gone; + reverting = swap =custom/netspeed= + =custom/airplane= back. +- *Phase 2 — Panel shell + connection management (task #B core).* GTK4 + layer-shell scaffold + =net list/up/down/add/edit/remove/rescan= + MRU list + + mutation safety/rollback + panel state machines. + - *Acceptance*: switch wifi↔wifi and ethernet↔wifi without stranding; a failed + switch leaves the prior link up; add/edit open + WPA-PSK writes the secret to + NM; remove confirms; panel states render for loading/rescan/activation. +- *Phase 3 — Diagnostics + speed test in the panel.* Wire =net diagnose= / + =net repair= / =net doctor= / =net portal= / =net speedtest= into the Diagnose + vs Repair sections; the "Get me online" button; portal Open button; speedtest + progress + cancel. + - *Acceptance*: diagnose runs read-only; each repair tier confirms + verifies + cleanup (DNS override reverts, shown); speedtest result parses from + speedtest-go and a fixture-driven failure shows the right message. +- *Phase 4 — Docs + rollout.* In-app help (=net --help= / per-command help, the + panel help affordance), README/user-guide (commands, panel, config, + troubleshooting, the make targets, rollback), and the manual dep step on ratio. + - *Acceptance*: =net --help= and each subcommand's help are complete; the + user-guide covers every command + the recovery targets; ratio rollout + documented. +- *Phase 5 — VPN / WireGuard (future).* Fold the existing archsetup wireguard + tooling into the same panel + CLI (=net vpn ...=). Out of the v1 milestone; + specced separately when picked up. + +V2 redesign phases (designed 2026-06-30, dependency order): +- *V2.1 — Sudo helper + NOPASSWD sudoers (gates everything).* Root-owned helper + dispatching net's fixed privileged verbs, archsetup-installed, narrow sudoers. + Also fixes the detached DoT-restore-watcher bug. + - *Acceptance*: every repair runs passwordless in-panel on a non-NOPASSWD machine; + the sudoers rule is scoped to the helper only. +- *V2.2 — Merged Diagnostics panel + nav restructure.* Connections | Diagnostics | + Performance; the Diagnostics sub-row + shared streaming area; Advanced reveal + + tooltips; delete =net-popup=. + - *Acceptance*: no terminal opens for any action; repair progress streams in the + panel; Speed test lives under Performance. +- *V2.3 — IPv6-aware and multi-homing-aware diagnose.* Stop treating no-IPv4 as a + failure when online over IPv6; identify which interface owns the default route. +- *V2.4 — Close every detect/correct gap in the catalog, with post-action + verification.* Work the redesign-task catalog mode by mode. +- *V2.5 — Automatic diagnostic verbose-capture.* Snapshot/elevate/capture/restore + around a failing diagnose + the Advanced Debug on/off toggle; guaranteed + + crash-guarded restore; redacted support bundle; helper log-level verbs. + - *Acceptance*: a failing diagnose leaves a redacted bundle (NM/resolved/ + wpa_supplicant journal + dmesg) and restores every log level; a crash mid-capture + is detected and restored on the next run; the secret-leak test finds no PSK/EAP in + a bundle; the toggle elevates and restores on demand. + +* Open items / risks + +- gtk4-layer-shell dropdown anchoring under a waybar module needs the same + positioning work pocketbook solved; reuse it. (Phase 2.) +- The =captive= refactor must keep the standalone CLI behavior identical while + exposing a non-interactive =--json= probe; covered by the existing + =tests/captive= suite plus new probe-mode tests. (Phase 1.) +- speedtest-go server selection variance (nearest-server flor) — pin a server in + config if results are noisy. (Phase 3.) +- The background-probe kick from =net status= must be truly non-blocking (spawn + + detach); enforced by the single-flight lock and the performance benchmark test. + +* Rollback + +Each phase is independent. The indicator (Phase 1) is a drop-in replacement for +=custom/netspeed= (and =custom/airplane=); reverting is swapping those modules +back in the config and restoring their scripts. The panel is additive — not +wiring its clicks leaves the bar working as before. No credential store to roll +back (secrets stay in NM throughout). + +* Review findings [40/40] + +** DONE Define the structured diagnostics contract :blocking: +The spec says the engine "emits JSON" and that diagnostics "reuse =captive= +verbatim", but the current =~/.dotfiles/common/.local/bin/captive= flow is a +human-readable bash script that mixes diagnostics, sudo prompts, DNS mutation, +browser launch, and terminal prose. A GTK panel cannot reliably turn that into +clear state, progress, cancellation, or useful error messages. Define the +machine contract before implementation: every diagnostic step should have a +stable id, status (=pending/running/pass/warn/fail/skipped=), redacted evidence, +elapsed time, safety outcome, and next action. Keep =captive= as the interactive +CLI, but either refactor reusable probe/reset functions behind =net diagnose +--json= or make =captive= expose a non-interactive JSON mode. This blocks the +panel and logging work because otherwise the implementer must invent the +boundary. + +Disposition: accept — added the "Diagnostics contract" section (per-step id / +status / evidence / elapsed / safety / next_action) and the =captive= =--json= +probe-mode refactor under Architecture + Files touched. + +** DONE Specify user-facing failure messages and recovery actions :blocking: +The spec names failure states like =no-internet=, =captive=, failed probe, +failed reset, missing DNS, and missing speed-test backend, but it does not define +the messages the user sees or what each message tells them to do next. For this +feature, "error" is not enough: a user needs to know whether WiFi is associated, +whether DHCP succeeded, whether DNS is hijacked/broken, whether HTTP is +intercepted, whether sudo was declined, whether a command timed out, and whether +the system was left unchanged or partially changed. Add a message table for the +indicator, panel, and CLI with: failure class, visible text, evidence included, +redaction rule, and next action. This is blocking because UX quality here is the +product, not an implementation detail. + +Disposition: accept — added the "Failure states, messages, recovery" section +covering each class, the visible message, the "what changed" residue note, and +the next action across indicator/panel/CLI. + +** DONE Define the debug log and redacted support bundle :blocking: +There is no observability section. When this fails in a hotel or cafe, an agent +needs enough evidence to diagnose it without rerunning destructive actions. Add +log location, rotation/retention, JSONL event schema, command argv logging, +exit-code/stderr capture, elapsed time, selected iface, NM active connection +UUID, probe URL class, HTTP code, redirect host, DNS servers, and cache +read/write events. Also define a =net doctor --json= or =net debug-bundle= +command that emits redacted status, recent log events, dependency versions, and +a reproduction command. Redact SSID if configured, MAC addresses, portal query +tokens, PSKs, EAP identities/passwords, IPs when requested, and all GPG/NM +secrets. This blocks implementation readiness because post-failure diagnosis is +currently left to ad hoc terminal spelunking. + +Disposition: modify — accepted the JSONL event log, the schema, and the redaction +rules in full (new "Observability" section). Deferred the dedicated =net +debug-bundle= / =net doctor= command to vNext: for a single-user tool =net +diagnose --json= (the snapshot) plus the event log (the history) cover +post-failure diagnosis; a bundle command is gold-plating for v1. Recorded under +Out + Resolved decision 10. + +** DONE Pin the nmcli parsing and timeout contract :blocking: +The spec lists nmcli operations but not the exact fields, output modes, escaping +rules, ID semantics, or timeouts. This is risky because SSIDs and connection +names can contain spaces, colons, duplicates, hidden names, and non-ASCII; the +current =waybar-netspeed= already had an SSID parsing bug. The nmcli manual +documents =--terse=, =--get-values=, =--escape=, =--wait=, ID/UUID/path +selection, =passwd-file=, and built-in connectivity states +(=none/portal/limited/full/unknown=) at +https://man.archlinux.org/man/nmcli.1.en. The spec should require UUIDs for +saved-profile operations, explicit =--wait= budgets, parser tests for escaped +colons/backslashes/newlines/duplicate names/hidden SSIDs, and a decision on when +to use or ignore =nmcli networking connectivity [check]=. This is blocking +because the command wrapper is the core reliability boundary. + +Disposition: accept — added the "nmcli contract" section: terse + =--escape= + +=--get-values=, UUID-keyed ops, explicit =--wait= budgets, NM connectivity as a +cheap hint (our probe authoritative), and the parser test matrix. + +** DONE Define cache concurrency, atomicity, and stale-state behavior :blocking: +=net status= may spawn =net probe= whenever the cache is stale, but the spec +does not define locking, process coalescing, atomic writes, crash cleanup, or +what happens when the probe hangs. With a 2s Waybar interval, a bad network could +start overlapping probes, corrupt the runtime cache, or keep showing stale +"online" while the link is gone. Add a single-flight lock under +=$XDG_RUNTIME_DIR/waybar=, atomic write+rename for cache updates, max probe +runtime, stale age classes (fresh/stale/expired/unknown), cache invalidation on +iface/SSID/connection UUID change, and tests for concurrent =net status= calls. +This blocks the fast-path design because it is the main performance and +correctness risk. + +Disposition: accept — added "Concurrency, atomicity, staleness" under the +Connectivity model: flock single-flight, temp+rename atomic write, ≤6s probe +timeout, fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation, stale +lock reclaim, plus concurrency tests in the test plan. + +** DONE Bound hot-path performance with measured budgets :blocking: +The spec says the cheap poll should be sub-100ms, but the proposed fast path +still may call multiple =nmcli= commands every two seconds, read sysfs, parse +throughput, and maybe spawn a background probe. The existing =waybar-netspeed= +had a deliberate sleep for throughput sampling; replacing it must define how +throughput is sampled without sleeping in the bar path. Add a per-command budget +for =waybar-net= and =net status=, a maximum number of subprocesses on the hot +path, a timeout for every subprocess, benchmark tests with fake slow =nmcli=, +and a rule that the indicator emits a degraded JSON state rather than blocking. +This is blocking because Waybar custom modules can visibly freeze or lag when +their exec path stalls. + +Disposition: accept — added the "Performance budgets" section: <100ms typical / +<250ms worst, throughput sampled across the poll interval (no in-process sleep), +one nmcli call max on the hot path, timeouts on every subprocess, the degraded +state, and a fake-slow-nmcli benchmark test. + +** DONE Make click actions non-blocking and visible :blocking: +Waybar right-click runs =net reset= and middle-click runs =net portal= directly. +Those operations can require sudo, open browsers, mutate DNS, delete/recreate NM +profiles, or hang on network commands, but Waybar click handlers provide no +panel, terminal, progress, or cancellation surface by default. Define whether +right/middle click instead opens the panel focused on the action, dispatches a +background job with notifications, or is removed from v1. If kept, specify +single-flight behavior, how sudo/polkit prompts surface, how success/failure is +reported, and how the user can inspect logs. This blocks UX readiness because +the fastest remediation path is currently the easiest place to hide failure. + +Disposition: modify — accepted the concern; made the interactions phase-aware and +non-blocking. Every click dispatches a detached, single-flight background job and +reports via =notify=; sudo surfaces through polkit/the normal prompt; failures go +to the notify + the event log. In Phase 1 (no panel) left-click runs probe + +notify and keeps the scratchpad; from Phase 2 left-click opens the panel focused +on the action. Recorded in the Indicator "Interactions" subsection. + +** DONE Specify connection mutation safety and rollback :blocking: +The spec says row click switches connections and remove gets a confirm, but it +does not define what happens when a switch partially succeeds, disconnects the +current working link, needs a password, loses the default route, or triggers +auto-activation. The nmcli manual warns that =connection down= does not prevent +future auto-activation and may internally block a profile until user action. +Define preflight, the exact NM command sequence, whether the old active +connection is kept until the new one proves usable, when rollback is attempted, +how long activation waits, and what the panel says when rollback fails. This is +blocking because the module can strand the user offline. + +Disposition: accept — added "Mutation safety + rollback" under Connection +management: keep the prior link up until the target activates (=--wait 30=), no +teardown on failure, password-required surfaced not stranded, =net down= reports +post-op active state + the auto-reactivation caveat, and the pinned NM command +sequence is tested against fake nmcli. + +** DONE Define the credential-store security model :blocking: +The GPG store is described as optional and default-unencrypted, but the spec does +not define file modes, schema, secret-source rules, import/export prompts, +recipient verification, stale secret handling, or what is logged. It also says +NM remains source of truth while the user-owned store contains PSK/EAP secrets, +which creates two truth sources for sensitive data. Add a precise schema, +=0600= file creation with parent-dir permissions, encrypted-recipient checks, +plaintext warning text, explicit opt-in flow, redaction requirements, behavior +when NM has a secret not in the store, behavior when the store has a secret NM +rejects, and tests for no secret leakage in JSON/logs/errors. This blocks Phase +4 and the full spec because otherwise the implementer must make security +decisions mid-code. + +Disposition: accept — rewrote "Credential storage" with the versioned schema, +=0600= file / =0700= dir, recipient verification on opt-in, the plaintext +warning, secret-source rule (entered/exported, never harvested from root store), +the two-source reconciliation policy (NM wins live, store wins for what NM +lacks, stale-secret flagging), and the no-leak tests. + +** DONE Define EAP, enterprise WiFi, and unsupported connection behavior :blocking: +The store says "PSK/EAP" and connection management says add/edit, but there is +no v1 contract for WPA-Enterprise fields, certificates, identity vs anonymous +identity, hidden networks, static IP, proxy settings, metered flags, MAC +randomization, or 802.1X prompt behavior. Either scope v1 to open/WPA-PSK plus +existing saved-profile activation, or define the minimum EAP form and the +unsupported-state messages. This blocks add/edit/import because enterprise WiFi +is too sensitive to hand-wave. + +Disposition: modify (scope) — scoped v1 to open + WPA-PSK add/edit, with +*activation* of any existing saved profile (including enterprise). Enterprise / +802.1X add/edit, static-IP, proxy, metered, and MAC-randomization editing are +vNext, shown as "edit via nmtui/nmcli". Recorded in Scope/Out, Connection +management, and Resolved decision 9. + +** DONE Split read-only diagnostics from mutating remediation :blocking: +The panel's diagnostics section includes probe, bounce/reset, gateway ping, and +DNS override test in one area, while =captive= currently performs resets and +temporary DNS changes as part of its flow. Users need to know which buttons are +read-only and which mutate NM profiles, MAC mode, DNS, or browser state. Add +separate "Diagnose" and "Repair" actions, confirmations for destructive or +privacy-changing operations, explicit cleanup verification for DNS override, and +a terminal state when cleanup is unverified. This blocks readiness because +network repair must not surprise the user or leave hidden residue. + +Disposition: accept — split the panel into a read-only Diagnose section and a +confirmed, mutating Repair section (and split the CLI into =net diagnose= vs =net +repair=). Added =cleanup_verified= + a terminal =cleanup-unverified= state to the +diagnostics contract. + +** DONE Define panel state, cancellation, and permissions UX :blocking: +The panel sections list buttons and a streaming output area, but not loading +states, disabled states, empty states, keyboard/focus behavior, cancellation, or +permission-denied handling. Add panel state machines for connection list loading, +rescan in progress, activation in progress, diagnostics running, speedtest +running, and no NetworkManager/no WiFi/no permissions/no GPG key/no +librespeed-cli. Each long operation should be cancellable where possible or +clearly non-cancellable with an elapsed-time display. This blocks the GTK work +because without it the implementer must invent the user flow. + +Disposition: modify — accepted the state-machine requirement (added "Panel state, +cancellation, permissions"), but scoped the state set to what can actually occur +on the two-machine fleet: dropped "no NetworkManager" as a modeled state (NM is +always present; a missing nmcli is a single hard-error exit) and kept +no-wifi-hardware, missing speedtest-go, no-GPG-key, plus the in-progress states +with elapsed-time + cancellation where the op allows. + +** DONE Verify speed-test dependency, server choice, and failure contract :blocking: +The spec chooses =librespeed-cli= and notes availability/default-server research +as an open risk, but Phase 3 still depends on parsing its JSON and showing +progress. I checked the upstream project page +(https://github.com/librespeed/speedtest-cli) and the AUR URL named by search is +not sufficient as a verified package/install contract in this spec. Add the +exact package name/source to install, command version expected, JSON shape, +server-selection policy, timeout, cancellation behavior, offline/rate-limited +messages, and tests with fixture JSON and fixture stderr. This blocks Phase 3 +because speed-test failure modes are otherwise undefined. + +Disposition: modify — verified live and changed the backend: =speedtest-go= (AUR +=speedtest-go-bin=, 1.x) is already installed on velox and supports =--json=, +=--server=, =--no-download/--no-upload=, so v1 needs no new dependency. +librespeed-cli (AUR =librespeed-cli= / =-bin=) is the documented self-hosted +fallback. Added the "Speed test" section with server policy, timeout, +cancellation, the failure-message mapping, and fixture-JSON/stderr tests. + +** DONE Define dependency installation and repo boundaries :blocking: +The files touched section alternates between archsetup paths and the external +dotfiles repo, while pocketbook has been folded into this repo and its previous +archsetup provisioning was intentionally removed. The spec should state where +the =net= package actually lives, which repository owns the scripts/tests, +whether =gtk4-layer-shell=, =python-gobject=, =librespeed-cli=, =gpg=, =nmcli=, +=curl=, and =resolvectl= are installed by archsetup or assumed present, and the +Makefile targets for test/lint/install. This blocks implementation because the +current path plan can produce code that is not installed on a fresh machine. + +Disposition: accept — added the "Repository + dependencies" section: all code in +=~/.dotfiles= (=net/= package in-tree like pocketbook, scripts in the hyprland +tier, tests under =tests/=), archsetup owns only the dep install +(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=; nmcli/curl/resolvectl +already present), Makefile =make test= collects the package suite, and a +daily-drivers note for ratio. Rewrote Files touched to match. + +** DONE Expand the test plan for failure, concurrency, and live verification :blocking: +The testing plan covers normal parsing and fake command sequences, but it misses +the riskiest behaviors: slow/hung =nmcli=/=curl=/=librespeed=, concurrent +=net status= cache refresh, corrupt cache, stale cache after SSID change, +permission denied, sudo declined, DNS override cleanup failure, NM partial +activation, duplicate connection names, secret redaction, missing optional +dependencies, no WiFi hardware, wired+tether+WiFi ambiguity, portal redirect +tokens, and Waybar click handlers. Add unit/fixture tests for each class plus a +manual/live checklist gated out of the normal suite. This is blocking because +the current plan would leave the exact "things that can go wrong here" mostly +untested. + +Disposition: accept — rewrote the Testing plan with the "Failure + concurrency" +class (slow/hung commands, single-flight, corrupt/stale cache, perm-denied, +cleanup-failure, partial activation, redaction, missing deps, no-wifi, +multi-active) and a per-phase live checklist gated out of the suite. + +** DONE Define status JSON schemas and compatibility rules +The spec says all subcommands take =--json= but does not define schemas. Add +versioned JSON examples for =status=, =probe=, =list=, =diagnose=, =speedtest=, +and error envelopes, including nullable fields and unknown/degraded states. This +is non-blocking for product direction but should be fixed before code so tests +can lock the CLI contract. + +Disposition: accept — added the "JSON schemas" section with versioned (=v:1=) +envelopes for status / probe / list / diagnose / speedtest and a shared error +envelope, including the degraded/unknown states. + +** DONE Rename or alias the phasing section for workflow compatibility +The spec has a usable =Phasing= section, but the spec-review workflow expects an +=Implementation phases= section that can be lifted into =todo.org=. Rename it or +add an alias heading during response. This is non-blocking because the existing +phase decomposition is understandable, but aligning the heading prevents future +workflow friction. + +Disposition: accept — renamed =Phasing= → =Implementation phases= and added +per-phase acceptance criteria. + +** DONE Add documentation and rollout acceptance checks +Rollback is described, but docs and rollout are thin. Add README/user-guide +updates for commands, panel behavior, config file, GPG opt-in, troubleshooting, +and rollback; add acceptance checks for each phase, including a fresh-login +Waybar smoke test and restoring =custom/netspeed=. This is non-blocking but +important for handing the feature to a future session without re-discovery. + +Disposition: accept — added per-phase acceptance criteria under Implementation +phases (incl. the fresh-login waybar smoke test and the =custom/netspeed= +restore), a Phase 4 "Docs + rollout", and (answering Craig's cj follow-up) a +dedicated "Help + documentation" section with the three help layers (CLI help, +panel help affordance, user guide). + +** DONE Add a failure-mode coverage table :blocking: +The spec now names many individual network failures, but it still does not carry +one compact coverage matrix that says, for each common failure mode, whether +=net diagnose= detects it, whether =net doctor --fix= can repair it, and what +terminal user action remains when it cannot. Add a table covering at least: +rfkill soft block, rfkill hard block, no WiFi hardware, associated/no DHCP, +gateway unreachable, captive DNS hijack, broken DNS where 1.1.1.1 works, HTTP +portal, HTTP interception without a parseable portal URL, upstream/AP outage, +wrong WPA password or missing secret, enterprise auth/cert failure, duplicate +SSID/connection-name ambiguity, hidden SSID, multiple active links, wedged +NetworkManager, slow/hung command, stale/corrupt cache, DNS cleanup failure, +missing speedtest backend, and VPN/routing interference. This blocks because +Craig asked for confidence that the diagnostics and doctor cover the real field +failures, and prose scattered across sections is too easy to misread. + +Disposition: accept — added the "Failure-mode coverage" section: a 22-row table +(every mode the finding named) with detect / doctor-fix / terminal-action +columns, conformed to the org-table standard (rules under every row, ≤120). + +** DONE Pin DNS repair semantics in doctor :blocking: +The spec diagnoses DNS hijack, broken hotel DNS, and the temporary 1.1.1.1 +override test, but =net doctor --fix= does not say whether it merely recommends +the override, applies a temporary override during recovery, or leaves DNS alone +after diagnosis. Define the exact behavior for each DNS class: captive hijack +should open the portal, broken DNS where 1.1.1.1 works should either offer an +explicit temporary repair with cleanup verification or recommend the command, +and port-53/egress blocking should stop as upstream/not locally fixable. This is +blocking because DNS is one of the most common "connected but unusable" failures +and the current doctor contract is ambiguous. + +Disposition: accept — added "DNS handling in doctor (explicit per class)" under +the new Doctor section: hijack → open portal (no DNS mutation); broken-but-1.1.1.1 +→ explicit temporary override with cleanup verification under =--fix=, recommend +otherwise; egress-blocked → terminal =upstream-not-local=. + +** DONE Make auth failures terminal user-action states :blocking: +Wrong WPA password, missing NM secret, locked keyring/polkit denial, enterprise +802.1X certificate/identity failure, and portal login-required are not fixed by +resetting or bouncing NetworkManager. The doctor sequence should classify these +as =needs-user-action= terminal states, stop before looping through destructive +repairs, and tell the user the exact next action (enter password, edit profile in +=nmtui=/=nmcli=, accept portal terms, provide cert/identity, or retry with +admin auth). This blocks because repeated reset/bounce against auth failures is +slow, noisy, and can make the network state worse without helping. + +Disposition: accept — added the =needs-user-action= terminal outcome to the +Doctor section: wrong password / missing secret / keyring-or-polkit denial / +802.1X cert-or-identity failure / portal-login-required all stop the doctor before +any destructive repair and name the exact next step. + +** DONE Define upstream/AP/provider failure terminal states :blocking: +Some failures are not client-repairable: AP has no uplink, hotel gateway is +down, DHCP server is broken, gateway drops traffic, ISP outage, or captive +portal backend is failing. The spec should define how =diagnose= proves "local +link is up but upstream is broken" and how =doctor --fix= stops after local +repairs are exhausted with a clear message like "local repairs tried; likely +upstream/AP/provider" plus the evidence. This blocks because users need to know +when to stop poking the laptop and switch networks or contact the venue. + +Disposition: accept — added the =upstream-not-local= terminal outcome: diagnose +proves link-up + IP + gateway-reachable but no route out and no captive redirect; +=doctor --fix= stops after local repairs with "local repairs tried; likely +upstream/AP/provider" + evidence → switch network / contact venue. + +** DONE Decide how VPN and policy routing affect v1 diagnosis +VPN/WireGuard management is Phase 5, but active VPNs, policy routes, DNS +overrides, and firewall killswitches can break apparent internet access in v1. +The current spec does not say whether v1 detects active VPN/policy routing and +classifies "network is fine, VPN route/DNS is broken" separately from WiFi +failure. Add either a v1 diagnostic check for active VPN/default-route/DNS +ownership with a "deferred repair" outcome, or explicitly state that VPN-routed +failures are out of scope and may be misclassified. This is blocking if Craig +expects the module to diagnose normal daily-driver network failures while VPN +tooling remains separate. + +Disposition: accept (chose the detect-and-classify option) — v1 detects an active +VPN / non-NM default route / non-NM DNS owner and classifies =deferred/vpn= ("link +is fine; internet is VPN-routed"), distinct from a WiFi failure. v1 does not +repair it (VPN management is Phase 5); it names the VPN as the likely owner and +stops. Added to the Doctor section + the coverage table + a doctor-classification +test. + +** DONE Remove stale GPG-store references from the resolved spec +The spec now decides "no separate credential store; secrets live in +NetworkManager", but the Testing plan still mentions =gpg round-trip= and =GPG +store= tests, and the panel-state list still mentions a no-GPG-key state. Remove +those stale references and replace them with NM-secret/no-secret-leak tests. +This is non-blocking for product behavior but blocking for implementation +clarity: otherwise tests will be written for a credential store that no longer +exists. + +Disposition: accept — replaced the Testing-plan =gpg round-trip= / =GPG store= +bullets with an "NM secrets / no-leak" test (add/edit writes the secret via nmcli; +assert no PSK/EAP in any JSON/log/error; no store to round-trip) and dropped the +=no-GPG-key= panel state. Residue from the cj-comment pass that dropped the store. + +** DONE Reconcile status, goal, and task text before implementation :blocking: +The spec status says "Implementation-ready with caveats" and "Phase 1 ready to +build", but the body still has an unresolved enterprise add/edit VERIFY, the +Goal still says "optional GPG-encrypted secret store", and the unified task title +still names "GPG-stored secrets" even though the accepted design removed the +store. Before implementation, make the top-level status, goal, scope, task +mapping, and resolved decisions agree with the current design. This blocks +readiness because a developer starting from the top of the file would still build +or plan around abandoned GPG-store behavior. + +Disposition: accept — fixed the Goal ("secrets stay in NM's own store"), the +=[#B]= task-mapping line (notes the "GPG-stored secrets" framing is superseded by +decision 5), the enterprise VERIFY (now resolved → Status updated), and corrected +the stale =pytest= mentions to =unittest= (the repo's actual harness). Top-of-file +status/goal/scope/decisions now agree with the design. + +** DONE Resolve enterprise add/edit scope or make the caveat explicit :blocking: +The spec still says "One open question for Craig: pull enterprise add/edit into +v1?" and points to a VERIFY in =todo.org=. That is a real product-scope decision: +if enterprise add/edit is in v1, panel forms, nmcli command sequences, tests, +error messages, and docs change materially; if it is out, the UI must consistently +show activate-only with "edit in nmtui/nmcli". Decide it in the spec before +implementation, or downgrade the status to =Ready with caveats= with this exact +accepted caveat. As written, the spec cannot be plain =Ready=. + +Disposition: accept — Craig decided (2026-06-29): enterprise add/edit is vNext, +activate-only in v1. Settled in the Status line, the Scope/Out bullet, decision 9, +and the VERIFY (now DONE in todo.org). The UI shows activate-only with "edit in +nmtui/nmcli" consistently. Evidence: 24 saved profiles, 0 enterprise. + +** DONE Define the concrete test harness and coverage gate :blocking: +The spec says TDD, fake binaries on PATH, and benchmark tests, but it does not +define the actual harness contract: pytest vs unittest for the =net= package, +where fake =nmcli=/=curl=/=speedtest-go=/=rfkill=/=resolvectl= live, how test +fixtures encode command histories, how subprocess timeouts are simulated, how +Waybar scripts are executed end-to-end, and how coverage is run. Add the exact +Makefile targets (=test=, =test-unit= or package-local =pytest=), pytest config, +coverage command (e.g. branch coverage over =net/= and =waybar-net= wrappers), +minimum threshold, and the rule for reading the coverage report to add missing +tests before declaring a phase done. This blocks readiness because "what is the +test harness?" is still answerable only by analogy to older suites. + +Disposition: accept — added the "Harness + coverage gate" section. Corrected the +premise: the repo is =unittest= (=make test= → =python3 -m unittest=, 33 suites), +not pytest. Pinned the fake-binary stub convention (=tests/<name>/fake-*= on a +temp PATH), the fixture command→output map, timeout simulation, the end-to-end +=waybar-net= subprocess run, and coverage via a throwaway venv (coverage.py is +absent system-wide) with a ≥90% branch target on the pure modules. + +** DONE Use coverage to find missing behavior, not just report a percentage :blocking: +The spec does not say how coverage findings affect implementation. For this +feature, line coverage alone can miss the important holes: doctor classification +branches, cleanup-unverified paths, redaction paths, degraded hot-path fallbacks, +timeout branches, and auth/upstream/VPN terminal states. Define coverage review +criteria per phase: branch coverage for pure classifiers and parsers, named +untested branches allowed only with comments or manual-check entries, and a +required "coverage gap pass" after the first green test run that maps uncovered +logic back to tests or consciously excluded live-only behavior. This blocks +readiness because the current test plan is broad but does not force the suite to +expose missing edge tests. + +Disposition: accept — added the "Coverage as a gap-finder, not a number (per +phase)" subsection: branch coverage required for the doctor classifier (every +outcome), cleanup-unverified, redaction, degraded-fallback, timeout, and the +parsers; a mandatory coverage-gap pass after the first green run mapping each +uncovered branch to a test or a named live-only exclusion; a phase isn't done +until that pass is recorded. + +** DONE Convert error classes into exact user-facing strings and evidence fields :blocking: +The failure table and doctor outcomes classify errors well, but many messages +are still templates or descriptions rather than final text. Add exact strings +for indicator tooltip, notification, CLI stderr, JSON =error.message=, and panel +banner/step text for every failure-mode row, including cases doctor cannot fix: +wrong password, missing secret, enterprise cert failure, upstream/AP/provider +failure, VPN-routed failure, hard rfkill block, DNS cleanup failure, speedtest +missing, and HTTP interception without parseable URL. For each string, specify +the redacted evidence included and the next action. This blocks UX readiness +because "useful error" is only testable once the actual text and evidence are +defined. + +Disposition: accept — rewrote the Failure states section: each row now carries the +exact final string (with =<placeholder>= evidence), the evidence field, and the +next action, plus a per-surface rendering rule (indicator tooltip / notify / +CLI+JSON error.message+detail+code / panel banner all render the one canonical +string). Added the missing doctor-unfixable rows: hard rfkill, wrong password / +missing secret, enterprise cert failure, upstream/AP/provider, VPN-routed, HTTP +interception without a parseable URL, and DNS cleanup-unverified. + +** DONE Add an enhancement disposition table +The spec captures several good enhancements (doctor, Makefile recovery, rfkill, +airplane absorption, VPN phase), but it does not show that low-cost adjacent +enhancements were considered and accepted/deferred/rejected. Add a small radar +table for likely affordances: copy redacted doctor report, open/copy portal URL, +retry with hardware MAC, forget network, rescan now, pin speedtest server, show +last good network/result, watch mode for =net doctor=, desktop notification +actions, QR-code/share WiFi import/export, and keyboard picker. Mark each +=v1=, =vNext=, or =rejected= with a one-line reason. This is non-blocking, but it +prevents accidental loss of cheap UX wins and keeps the v1 panel focused. + +Disposition: accept — added the "Enhancement radar" table dispositioning all the +named affordances: open/copy portal URL, forget network, rescan, hardware-MAC +retry, pin speedtest server, copy redacted doctor report = v1; last-good +network/result, doctor watch mode, actionable notifications, keyboard picker = +vNext; QR-share = rejected (low value for a 2-machine personal setup). + +** DONE Tighten the panel UX flow before Phase 2 +The panel has sections and state machines, but not a concrete interaction flow: +default focused section, row content, primary/secondary buttons, disabled-state +rules, confirmation wording for reset/bounce/DNS override, how "Get me online" +reports each escalation, what stays visible after the panel closes, and keyboard +navigation. Add a short UX flow spec or wire-level outline before Phase 2. This +is non-blocking for Phase 1, but it blocks Phase 2 implementation because a GTK +panel can easily become noisy or surprising if these defaults are invented while +coding. + +Disposition: accept — added the "Panel UX flow (settle before Phase 2)" +subsection: default focus (Connections, or Diagnose when opened from a captive +state), row content, one primary button per section, disabled-state rules, exact +confirmation wording for reset/bounce/DNS-override/remove, the live "Get me +online" escalation reporting, what survives panel close, and keyboard nav. + +** DONE Reconcile the panel navigation source of truth :blocking: +Disposition: accept — folded into "V2 panel UX". V2 (Connections | Diagnostics | +Performance) is the sole current target; the shipped four-page stack is marked history, +not active design. +The spec now names at least three navigation shapes: the shipped four-page stack +(Connections / Diagnose / Repair / Speed test), the V2 three-tab plan +(Connections / Diagnostics / Performance), and the redesign task's Diagnostics +sub-row (Diagnose / Get Me Online / Advanced). That leaves an implementer free +to keep extra pages and buttons even though Craig is explicitly asking for the +opposite. Make V2 the sole current target: one panel opened from the bar, top +tabs =Connections | Diagnostics | Performance=, with Diagnostics owning the +read-only checks, repair stream, debug capture, doctor report, and related +diagnostic evidence. Mark the old four-page stack as shipped history only, not +active design. This blocks the redesign because the page model determines what +code is deleted, not just labels. + +** DONE Fold speed tests into the diagnostic story :blocking: +Disposition: modify — Craig pre-decided Speed test lives under Performance (decision +19), and Performance carries future throughput history, which meets this finding's own +"keep the tab only if it carries ongoing throughput" condition. Accepted the rest: +Diagnostics runs a lightweight inline latency/throughput probe as a Diagnose evidence +row (with skip conditions for offline / metered / no-backend), and the full speed +result is stored in the doctor report. Folded into "V2 panel UX → Diagnostics owns the +diagnostic story". +Speed test is currently isolated under =Performance=, while the Goal and user +mental model treat speed, latency, and packet loss as part of "diagnostics." +That split risks another top-level button/page whose only job is a diagnostic +measurement. Keep the top-level =Performance= tab only if it carries ongoing +throughput/history later; for V2, specify that Diagnostics can run a lightweight +performance check from the same Diagnose/Get Me Online flow when internet is +available, and that the full speed test is presented as a diagnostic evidence +row or secondary action rather than a separate repair-adjacent workflow. Define +when it is skipped (offline, metered/hotspot warning, missing backend) and how +the result is stored in the doctor report. This is blocking because otherwise +the implementation preserves avoidable navigation and misses a useful failure +signal. + +** DONE Define saved-list vs available-scan semantics :blocking: +Disposition: accept — folded into "V2 panel UX → Connections". Saved / Available now / +Wired groups; Rescan refreshes only the availability/signal layer, never the Saved +list. +=net list= merges saved profiles with in-range scanned networks, while the panel +copy calls the page "Connections" and the control "Rescan." It is not clear to a +user whether they are looking at saved connections, currently available +networks, or both. The current implementation confirms the ambiguity: +=connections.py= lists saved profiles MRU-first, merges live signal/security for +saved profiles that are in range, then appends unsaved in-range SSIDs with +=uuid: nil=. Rename and specify the groups: e.g. =Saved= (instant, does not +require scan), =Available now= (scan-backed, may still be loading/stale), and +=Wired=. =Scan= should refresh only the availability/signal layer, not gate the +saved profile list. This blocks readiness because it affects loading behavior, +button enablement, and whether unsaved rows can be selected. + +** DONE Replace the Add page with join-from-row behavior :blocking: +Disposition: accept — folded into "V2 panel UX → Connections". Selecting an unsaved +Available-now row is the join flow (SSID/security prefilled); the standalone Add modal +is deleted for visible networks; hidden/manual join lives behind Advanced. +The current Add dialog asks for an SSID as free text even though a scan usually +already found the SSID and security type. That is redundant UI and a common +network-manager mistake: it turns "join this visible network" into "copy a name +from the list and type it again." V2 should remove the standalone Add button and +modal for normal visible networks. Selecting an unsaved available row should +become the join flow: the SSID/security are prefilled from the row, open +networks connect with a confirmation only if needed, WPA/WPA2/WPA3-Personal ask +only for the password, and hidden/manual SSID is tucked behind an Advanced +"Join hidden network" affordance. Keep edit/create for enterprise profiles out +of v1/V2 unless explicitly added later. This blocks the redesign because it +changes the primary connection workflow and deletes a whole page/control. + +** DONE Pin the supported authentication types in the join flow :blocking: +Disposition: accept — folded into "V2 panel UX → Supported authentication classes". +The spec says "open + WPA-PSK" and "enterprise activate-only," but cafe/hotel +networks also commonly appear as open captive portals, WPA/WPA2/WPA3-Personal +(PSK/SAE), and sometimes transition-mode networks; less commonly they use +enterprise/802.1X, WEP, OWE/enhanced-open, MAC registration, voucher portals, or +proxy-required networks. Define the V2 join matrix from the scanned NM +=SECURITY= value: supported inline (open, captive/open, WPA/WPA2/WPA3 Personal), +activate-only if already saved (802.1X/enterprise), hidden-manual behind +Advanced, and unsupported/rare types with a clear in-panel explanation plus a +non-terminal next step. If an auth type is common enough to support, support it +in the panel; if it is too rare for V2, say "not supported here yet" and keep +the user in the same UI rather than sending them to a terminal tool. Also define +what security label appears in the row so the user knows why a password is or is +not requested. This blocks because the Add/Join deletion above cannot be +implemented safely without knowing which auth classes the simplified flow covers. + +** DONE Fix destructive confirmation tense and verification +Disposition: accept — folded into "V2 panel UX → Forget confirmation". +The Forget confirmation says "The saved password is deleted" before the user has +clicked Forget. That reads as if the destructive action already happened. Change +the copy to future tense and name the scope, e.g. "This will remove the saved +NetworkManager profile and its stored password from this machine." After the +operation, verify the UUID is gone, refresh the Saved list, and report either +"Forgot <SSID>" or "Could not forget <SSID>; nothing changed / partial state +<evidence>." This is non-blocking because the existing confirm prevents an +accidental click, but the wording is misleading and the V2 "verify every action" +decision should cover it. + +** DONE Make connection loading progressive and observable :blocking: +Disposition: accept — folded into "V2 panel UX → Connections (progressive loading)". +Opening the panel currently says "Loading connections..." while =net list= +collects both saved profiles and the WiFi scan. Saved profiles do not require a +network scan, so a slow scan should not delay the saved list. Split loading into +two phases: render saved NM profiles immediately, then overlay availability, +signal, and unsaved in-range rows when the scan completes. Show a small +scan-in-progress state with elapsed time and stale-last-scan age, and make +Rescan update only the scan-backed fields. This blocks because it is the direct +answer to "why does it take so long to see the list of connections?" and keeps a +bad radio scan from making the whole panel feel broken. + +** DONE Define the visual contract with Waybar and existing Archsetup UI +Disposition: accept — folded into "V2 panel UX → Visual contract". +The panel is a layer-shell popup anchored under Waybar, but the spec does not +state the visual contract. The live Waybar theme uses a dark rounded capsule +(=border-radius: 1rem=), golden border, compact monospace text, and state colors +for =custom/net=; the GTK panel currently has a generic title, stack switcher, +default GTK controls, and square-ish/default widget corners. Add a short style +section: panel should read as a Waybar-attached popup, not a separate app; match +Waybar's palette, border/radius, spacing density, and state colors; avoid square +corners where surrounding UI is rounded; keep cards out of cards; use compact +icon+label controls with tooltips for advanced repairs. Also cite any existing +Archsetup-owned GTK/panel conventions that should be reused. This is +non-blocking for engine work but should block final V2 UX acceptance. + +** DONE Add a diagnostics report affordance that users can actually find +Disposition: accept — folded into "V2 panel UX → Findable diagnostics report". +The observability design has a JSONL event log, =diagnose --json=, automatic +verbose capture, and redacted bundles, but the panel flow does not yet define +the user affordance that turns those into an inspectable diagnosis. Add a +Diagnostics-side "Copy report" / "Open report" action after every diagnose, +repair, and speed/performance run. The report should include the current step +statuses, elapsed time, final classification, last speed/latency result when +available, scan age, route/interface owner, relevant redacted event-log tail, +and bundle path when verbose capture ran. It must explicitly say whether any +repair mutated state and whether cleanup/verification passed. This is blocking +for observability because "logs exist somewhere" is not enough when the network +is already failing. + +* Review and iteration history + +** 2026-06-29 Mon @ 17:00:39 -0400 — Codex — reviewer + +- *What changed or was recommended:* Rubric: =Not ready=. Applied the + spec-review workflow and added blocking findings for diagnostics structure, + user-facing errors, observability, nmcli contracts, cache concurrency, + hot-path performance, Waybar click actions, mutation rollback, credential + security, unsupported WiFi types, panel states, speed-test dependency + verification, install boundaries, and test coverage. +- *Why:* The spec has the right broad shape, but this feature combines a status + indicator, NetworkManager mutation, sudo-backed remediation, captive-portal + probing, secret storage, and GTK UI. Too many failure modes were still left for + implementers to invent during coding. +- *Artifacts:* Findings recorded in =Review findings=. Local code read included + =~/.dotfiles/hyprland/.local/bin/waybar-netspeed=, + =~/.dotfiles/common/.local/bin/captive=, + =~/.dotfiles/tests/waybar-netspeed/test_waybar_netspeed.py=, + =~/.dotfiles/tests/captive/test_captive.py=, pocketbook's GTK layer-shell + scaffold, current Waybar config, and the linked =todo.org= tasks. External + checks included the Arch nmcli man page and upstream LibreSpeed CLI page. + +** 2026-06-29 Mon @ 17:35:00 -0400 — Claude Code (archsetup) — responder + +- *What changed:* Processed all 18 findings — 13 accepted, 5 modified, 0 + rejected. Accepts added new sections: Repository + dependencies, Performance + budgets, nmcli contract, JSON schemas, Diagnostics contract (+ Diagnose vs + Repair), Failure states/messages/recovery, Observability, cache + concurrency/atomicity/staleness, mutation safety + rollback, the rewritten + credential security model, the expanded test plan, JSON schemas, the renamed + Implementation phases with per-phase acceptance criteria, and a Status line. + Modifies: deferred the =net debug-bundle= command (log + =diagnose --json= + cover it), made click actions phase-aware + non-blocking, scoped enterprise + WiFi to activate-only (add/edit vNext), trimmed the panel state set to states + that occur on the two-machine fleet, and switched the speed-test backend to the + already-installed =speedtest-go= (verified live) instead of a new + librespeed-cli dependency. +- *Why:* Codex's review was strong and mostly right; the modifies are scope calls + for a single-user, two-machine tool (avoid gold-plating, use installed deps, + don't model impossible states) and one phasing reconciliation (the panel is + Phase 2, so Phase-1 clicks can't open it). +- *Artifacts:* All 18 findings completed =DONE= in place with dispositions. Live + check: =pacman -Qq | grep speed= (speedtest-go-bin installed; =speedtest-go + --json= confirmed). Status now "review incorporated (Codex, 2026-06-29)", + implementation-ready with the named Phase-2/3 caveats; Phase 1 ready to build. + +** 2026-06-29 Mon @ 17:37:58 -0400 — Claude Code (archsetup) — responder (cj comments) + +- *What changed:* Folded in Craig's 10 cj comments on the spec. Design changes: + (1) dropped the separate credential store entirely — secrets stay in NM's own + =system-connections= (root =0600=, inline), touched via nmcli; no GPG, no + gpg-agent (rewrote the secrets section, decision 5, dropped Phase 4 + the gpg + dep). (2) Added =net doctor [--fix]= + Makefile console-recovery targets + (=make online= etc.) as a first-class TTY path; reversed the earlier + defer-the-doctor call (decision 10). (3) Added a full-stack =bounce= repair and + an =rfkill= unblock repair + indicator state — the rfkill one recovers the + framework-laptop post-power-loss soft-block Craig hit. (4) =custom/net= absorbs + the airplane module; the standalone airplane scripts/tests/module are deleted on + ship. (5) Moved VPN/WireGuard from "out" to a planned Phase 5. (6) Added a + "Help + documentation" section (CLI help / panel help / user guide). Answered + the enterprise-defer rationale and the captive-auto-login explanation inline. +- *Why:* Craig's comments simplify (NM-only secrets, fewer deps) and harden the + recovery story (doctor + make targets + rfkill/bounce reachable from a dead-GUI + console — his stated need). Enterprise add/edit in v1 is the one open call, + raised as a VERIFY. +- *Artifacts:* All 10 cj blocks removed. Live checks: airplane scripts/tests + present (confirmed deletion targets); =/etc/NetworkManager/system-connections/= + is root =0600= with inline secrets (confirms the NM-only secret model); + =rfkill= present. VERIFY filed in =todo.org= for the enterprise-scope decision. + +** 2026-06-29 Mon @ 17:49:24 -0400 — Codex — reviewer + +- *What changed or was recommended:* Added a second feedback batch from the + common network failure-mode review: require a diagnose/doctor coverage table, + pin DNS repair semantics, make auth failures terminal user-action states, + define upstream/AP/provider terminal states, decide how active VPN/policy + routing affects v1 diagnosis, and remove stale GPG-store references. +- *Why:* The design now has a solid recovery shape, but the field question is + sharper: for each common failure, does =diagnose= catch it, does =doctor --fix= + fix it, or does the user need to switch networks/provide credentials/contact + the venue? That mapping needs to live in the spec, not in chat. +- *Artifacts:* Findings appended under =Review findings= after reviewing the + current spec's Diagnostics, Failure states, Doctor, Testing plan, and + responder history. + +** 2026-06-29 Mon @ 18:06:22 -0400 — Codex — reviewer + +- *What changed or was recommended:* Added a readiness pass focused on + development-time questions, missed enhancements, UX cleanliness, error wording, + and test/coverage mechanics. New findings require reconciling stale status/goal + text, resolving or explicitly caveating enterprise add/edit, defining the + concrete harness and coverage gate, making coverage actionable, turning failure + classes into exact user-facing strings, adding an enhancement disposition + table, and tightening the Phase-2 panel UX flow. +- *Why:* The network failure model is now strong, but implementation would still + force developers to invent test infrastructure, coverage expectations, final + error copy, and several UX details. The spec should decide those up front or + explicitly mark them as caveats. +- *Artifacts:* Findings appended under =Review findings= after reading the + updated spec, current archsetup Makefile test targets, pocketbook's pyproject, + and the linked =todo.org= enterprise VERIFY. + +** 2026-06-29 Mon @ 17:55:08 -0400 — Claude Code (archsetup) — responder (2nd review) + +- *What changed:* Processed all 6 second-batch findings — 6 accepted, 0 modified, + 0 rejected. Added a "Doctor: escalation, classification, terminal states" + section with four outcomes (=fixable= escalating lightest-first; =needs-user- + action=, =upstream-not-local=, =deferred/vpn= terminal) and explicit per-class + DNS handling. Added a "Failure-mode coverage" table (22 rows: detect / doctor-fix + / terminal action, org-table-standard conformant, ≤120). Cleaned the last + GPG-store residue (Testing plan → NM-secret/no-leak test; dropped the no-GPG-key + panel state). Added a doctor-classification test class. +- *Why:* The findings sharpen the doctor from "escalate repairs" to "classify, + then stop at the right terminal state" — the safety property that keeps + =doctor --fix= from looping destructive repairs against a wrong password or an + upstream outage. The VPN finding got the detect-and-classify option (not repair; + that's Phase 5) so v1 doesn't misread a VPN-routed failure as WiFi down. The + coverage table is the single artifact Craig asked for to trust the field + coverage. +- *Artifacts:* All 6 findings =DONE= in place; cookie =[24/24]=. Table conformed + via =wrap-org-table.el= (max line 120). No new findings introduced; no scope + expansion past what the findings asked. Spec remains implementation-ready, the + enterprise add/edit VERIFY still the one open decision. + +** 2026-06-29 Mon @ 18:24:51 -0400 — Claude Code (archsetup) — responder (3rd review) + +- *What changed:* Processed the third review (7 findings). The enterprise-scope + finding closed first on Craig's call (vNext, activate-only). The other 6, all + accepted: (1) reconciled the top-of-file text — fixed the Goal's GPG-store + wording, the =[#B]= task-mapping line, the resolved enterprise VERIFY, and + corrected the stale =pytest= mentions to =unittest= (the repo's real harness). + (2) Added a "Harness + coverage gate" section (unittest, fake-binary stubs on a + temp PATH, venv coverage, ≥90% branch on pure modules). (3) Added a per-phase + "coverage as a gap-finder" pass. (4) Rewrote the Failure states section to exact + final strings + evidence fields + a per-surface rendering rule, and added the + missing doctor-unfixable rows. (5) Added the "Enhancement radar" table + (v1/vNext/rejected). (6) Added the "Panel UX flow" subsection. +- *Why:* The findings close the gap between "design decided" and "a developer can + start": the harness/coverage contract, the exact UX strings, and the panel flow + are the things otherwise invented mid-code. The =pytest=→=unittest= correction + was a real defect — the spec contradicted the repo's actual test convention. +- *Artifacts:* All 31 findings =DONE=; cookie =[31/31]=. Both new tables conformed + via =wrap-org-table.el= (coverage 120, radar 110). Harness verified against the + live repo (33 unittest suites, =make test=, coverage.py absent → venv). Status + raised to "Ready for Phase 1; Ready-with-caveats overall" — no open decisions + remain. + +** 2026-06-30 Tue @ 16:32:07 -0400 — Claude Code (archsetup) — responder (build + V2 redesign) + +- *What changed:* Brought the spec current with what shipped and what got decided + across 2026-06-29/30. Recorded Phases 1-3 as SHIPPED (engine, indicator, GTK4 + panel, bar clicks). Added the native captive-login engine (=portal-login= repair + tier replacing the =captive= shell-out), the live-testing portal UX fixes (removed + the polkit-gated flush, already-online short-circuit, Chrome first-run suppression, + in-panel portal, extractor hardening), and the panel auto-hide + Close button. + Then folded in the V2 redesign Craig directed: no terminals anywhere, a passwordless + root-helper + NOPASSWD sudoers as the enabler, verify-every-action, the full + ~44-mode failure catalog (now the authoritative coverage, in todo.org), and the + Connections | Diagnostics | Performance nav. Added decisions 15-19, V2 phases + 2.1-2.4, and corrected the two failure-table rows the shipped code never matched + (gateway-bounce, venue-DNS auto-revert). +- *Why:* The spec had drifted behind the code (still said "Phase 1 shipped, terminal + for privileged ops") and behind Craig's redesign calls. The doc is the contract for + the V2 build, so it has to state the no-terminal reversal, the sudo-helper + dependency, and where coverage now lives. +- *Artifacts:* Updated Status, Architecture, Engine CLI surface, Doctor DNS handling, + Failure-mode coverage, Interactions, Panel, Resolved decisions, Implementation + phases. Shipped commits referenced: dotfiles =a7d7559=, =eef6b0b=, =450b7f0=. Full + catalog + redesign children in todo.org "Network panel redesign". + +** 2026-06-30 Tue @ 17:29:29 -0400 — Codex — reviewer + +- *What changed or was recommended:* Rubric: =Not ready= for the V2 redesign until + the new blocking findings are dispositioned. Added a fourth review batch focused on + diagnostics/observability and the simple UX questions Craig raised: reconcile the + active panel nav, fold performance/speed testing into the diagnostics story, make + saved-vs-available list semantics explicit, replace the Add page with join-from-row, + pin supported WiFi authentication classes, fix Forget confirmation tense, make + connection loading progressive, define the Waybar/Archsetup visual contract, and add + a findable redacted diagnostics report affordance. +- *Why:* The spec has strong engine and failure-mode coverage, but the current doc and + shipped implementation still replicate common network-manager UX mistakes: ambiguous + mixed lists, scan-gated saved data, redundant SSID entry, misleading destructive + copy, diagnostics split across too many pages, and observability that exists in logs + more than in the user's workflow. +- *Artifacts:* Findings appended under =Review findings= after reading the + spec-review workflow, the target spec, todo.org's redesign task, and current + dotfiles implementation files =net/src/net/connections.py=, + =net/src/net/gui.py=, =net/src/net/panel.py=, and Waybar =style.css=. + +** 2026-06-30 Tue @ 17:36:40 -0400 — Claude Code (archsetup) — responder (4th review) + +- *What changed:* Dispositioned all nine fourth-review findings (8 accept, 1 modify, + 0 reject) and wove them into a new "V2 panel UX — the target design" section. + Accepts: V2's three-tab nav is the sole target (the four-page stack is history); + Connections splits into Saved / Available now / Wired with Rescan touching only the + scan layer; selecting an unsaved row is the join flow (the Add modal is deleted); + a join matrix pins which NM SECURITY classes are inline-supported / activate-only / + hidden / unsupported; loading renders Saved first and overlays the scan; the Forget + copy goes future-tense + verified; every run ends with a Copy/Open redacted report; + and a Waybar visual contract (rounded capsule, golden border, state colors). Modify: + the speed-test finding kept Craig's decision-19 placement (full speed test under + Performance, which carries future history) while accepting a lightweight inline + latency probe as Diagnose evidence stored in the doctor report. Cookie [40/40]. +- *Why:* Codex read the live implementation and caught the UX places where the module + still replicated common network-manager mistakes — mixed lists, scan-gated saved + data, redundant SSID entry, misleading destructive copy, diagnostics scattered + across pages, observability that lived in logs more than the workflow. +- *Artifacts:* Findings 32-40 completed in place with dispositions; the modify reason + on the speed-test finding. New "V2 panel UX" section under Panel. todo.org redesign + task updated to point the V2 build at the dispositioned design. + +** 2026-07-01 Wed @ 10:43:18 -0400 — Claude Code (archsetup) — responder (cj comments) + +- *What changed:* Folded in Craig's three cj comments from his review. (1) Notification + rendering: title = "Networking", body = the failure label on its own line then the + canonical string. (2) VPN kill-switch: added a "kill-switch blocking" failure state + plus a detection-and-correction strategy off the =deferred-vpn= branch (rootless + cascade over =ip rule= fwmark 0xca6c / =wg show= / Proton =pvpn-*= NM connections / + =nft=/=iptables= drop tables / firewalld =drop= zone; classify blocking only when a + block artifact exists AND no tunnel is up; correction surfaces the exact root command + per artifact). (3) Terminals: strengthened decision 15 to "no terminal ever reports + to or collects input from the user", disambiguated from the doctor's "terminal + states" wording. +- *Why:* Craig's review annotations. The kill-switch closes a real gap in the + VPN-routed classification; the terminal directive makes the no-terminal rule + absolute for the module UX. +- *Artifacts:* Three cj blocks removed. VPN research subagent cited wg-quick man page, + Pro Custodibus, System76/Proton killswitch docs, and local =doctor.py:42= / + =classify.py:60= / =USNY.conf:15=. One open tension filed as a VERIFY in todo.org: + the dead-GUI console-recovery path (=make online= from a TTY) vs the no-terminal + directive. diff --git a/docs/design/2026-06-29-waybar-timer-module-spec.org b/docs/design/2026-06-29-waybar-timer-module-spec.org new file mode 100644 index 0000000..4b0ed0e --- /dev/null +++ b/docs/design/2026-06-29-waybar-timer-module-spec.org @@ -0,0 +1,217 @@ +#+TITLE: Waybar Timer Module (wtimer) — Design Spec +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-06-29 + +* Goal + +One always-visible waybar module that keeps time four ways — countdown timer, +wall-clock alarm, count-up stopwatch, and pomodoro — with several items running +at once. The bar shows the most urgent item with a per-type glyph; the tooltip +lists them all. Backed by a single =wtimer= script over a small JSON state file. +notify fires on completion. fuzzel drives creation. No GTK app. + +Source task: archsetup =todo.org= "Waybar timer module" (=:waybar:=), including +the folded roam-capture scope expansion (mode-selectable single panel, +stopwatch, multiple simultaneous, per-mode hover text). + +* Scope + +** In +- *Timer* — count down a duration, notify on elapse, then remove. +- *Alarm* — fire at a wall-clock time, notify, then remove. +- *Stopwatch* — count up from start; pause/resume; manual stop. +- *Pomodoro* — work/break cycles (25/5, long break 15 after 4 works), auto-advance with a notify at each phase change, runs until cancelled. +- *Multiple simultaneous* — N items of any mix held in state. Bar shows one primary item plus a =+N= badge; tooltip lists every item with its remaining/elapsed and label. +- *Pause / resume* per item; *cancel* one or all. +- *Interactions* — click to create (fuzzel), middle-click pause/resume primary, right-click cancel (fuzzel pick), scroll to cycle which item is primary. +- *Per-type glyph + CSS state classes* (running / paused / urgent / break). +- *Persistence across waybar restarts* (state file in the runtime dir). + +** Out (v1, note for later) +- No GTK panel — waybar module + tooltip + fuzzel only. +- No persistence across *reboot* (runtime-dir state clears). Alarms set before a reboot won't survive. Acceptable v1; revisit with =~/.local/state= + a catch-up-on-boot pass if wanted. +- No sound selection per item (uses notify's type sound). +- No history/stats of completed pomodoros beyond the current run's cycle count. + +* Architecture + +- =wtimer= — a single executable Python script in =hyprland/.local/bin/=. Chosen over POSIX sh (the other waybar backings) deliberately: the multi-item state machine, time arithmetic, pomodoro FSM, and JSON I/O are cleaner in Python, and it gives real line/branch *coverage numbers* (Craig asked for them). Precedent: pocketbook is Python in this repo. +- *Pure core + thin IO shell.* All logic is pure functions taking =now= as a parameter (dependency-injected clock — satisfies testing.md: no recursion, no scope-shadowing, production reads =time.time()=, tests pass an explicit instant). The CLI layer does the IO: read state, call pure fns, write state, emit JSON, shell out to notify/fuzzel. +- *State file*: =$XDG_RUNTIME_DIR/waybar/wtimer.json= (env override =WTIMER_STATE= for tests). Same runtime-dir convention as =sysmon-metric=. +- *Heartbeat*: waybar calls =wtimer render= every 1s. =render= runs the tick logic first (detect elapsed items, fire notify, advance pomodoro, drop finished timers/alarms), then prints the waybar JSON. One entry point waybar polls; no separate daemon. +- *Concurrency (BLOCKER from review).* The 1s =render= and the click/scroll handlers (=add=, =toggle=, =cancel=, =cycle=) are separate processes doing read-modify-write on the same state file. Without serialization, last-writer-wins drops a click's =add=, or clobbers render's "item removed/advanced" write so the same item ticks and notifies again next second. So every read-modify-write takes an exclusive =flock= on the state file for the whole cycle, and writes go through a temp file + =os.replace= (atomic), so a concurrent render never reads a half-written file. This is what actually makes "notify fires once" true — the mutation is only authoritative under the lock. +- *State dir*: =render= and the mutating commands =mkdir -p= the state dir first (=$XDG_RUNTIME_DIR/waybar/= may not exist on a fresh boot). +- *Clock injection everywhere*: =now= comes from =WTIMER_NOW= (epoch) if set, else =time.time()=. Pure fns take =now= as a parameter; the CLI seeds it from the env. This lets the CLI integration tests hit boundary instants (exactly-at-target), not just the pure-fn tests. +- *Instant refresh*: after any mutating command, send waybar =SIGRTMIN+14= (the module's signal) so the bar updates immediately instead of lagging up to 1s. Faked in tests (=WTIMER_REFRESH= override, default =pkill -RTMIN+14 waybar=). + +* State model + +#+begin_src json +{ + "items": [ + {"id": "1", "type": "timer", "label": "tea", "target": 1751240400, "duration": 300, "paused_left": null}, + {"id": "2", "type": "alarm", "label": "", "target": 1751251200, "paused_left": null}, + {"id": "3", "type": "stopwatch", "label": "", "start": 1751240000, "paused_elapsed": null}, + {"id": "4", "type": "pomodoro", "label": "", "target": 1751241900, "phase": "work", + "cycle": 1, "work": 1500, "short": 300, "long": 900, "interval": 4, "paused_left": null} + ], + "primary": "1", + "seq": 4 +} +#+end_src + +- =seq= is the monotonic id source (string ids). +- *Paused* timer/pomodoro: =paused_left= holds seconds remaining; =target= ignored while paused; resume sets =target = now + paused_left=, =paused_left = null=. +- *Paused* stopwatch: =paused_elapsed= holds elapsed seconds; resume sets =start = now - paused_elapsed=. +- =primary= is the id the bar shows; =null= or stale → auto-select (below). + +* Display logic + +** Primary selection (bar text) +1. If =primary= names a live item, show it. +2. Else the running countdown (timer/alarm/pomodoro) with the smallest remaining. +3. Else the first running stopwatch. +4. Else idle (no items). + +** Bar text +- =<glyph> <time>= for the primary, plus = +N= when N other items exist. +- Idle: a dim timer glyph alone (or empty — decide at render; lean dim glyph so the module has a stable click target). +- =time= formatting: =M:SS= under 1h, =H:MM:SS= at/over 1h. Stopwatch counts up; timer/alarm/pomodoro count down to target. +- Paused item: prefix a pause glyph or rely on the =paused= class (CSS dims it). + +** Glyphs (nerd font; final codepoints verified live before merge) +- timer , alarm , stopwatch , pomodoro-work , pomodoro-break (coffee), paused , idle (dim). +- One glyph table at the top of the script so a live-render tweak is one edit. + +** Tooltip (all items) +One line per item: =<glyph> <label-or-type> <remaining/elapsed> (<state>)=. Pomodoro line shows phase + cycle (e.g. =work 2/4=). Header line summarizes count. Empty state: "No timers". + +** CSS classes (the =alt=/=class= field) +=timer= / =alarm= / =stopwatch= / =pomodoro-work= / =pomodoro-break=, plus =paused= and =urgent= (remaining < 60s). Drives color in style.css + both themes. + +* Commands (CLI) + +| Command | Effect | +|---------------------------------+---------------------------------------------------------------------| +| =wtimer render= | tick + emit waybar JSON (the heartbeat) | +| =wtimer add timer <dur> [label]=| add a countdown (=dur= like =25m=, =90s=, =1h30m=, =5= → minutes) | +| =wtimer add alarm <HH:MM> [lbl]=| add a wall-clock alarm (next occurrence of that time) | +| =wtimer add stopwatch [label]= | start a count-up | +| =wtimer add pomodoro [label]= | start a pomodoro at work phase | +| =wtimer new= | fuzzel: pick type, prompt value, dispatch to =add= (thin wrapper) | +| =wtimer toggle [id]= | pause/resume the item (default: primary) | +| =wtimer cancel <id>= | remove one item | +| =wtimer pick-cancel= | fuzzel: choose an item to cancel (right-click handler) | +| =wtimer cancel-all= | clear all | +| =wtimer cycle [next|prev]= | move the primary pointer across all items (incl. paused), state-list order, wrapping | + +Duration parse: =Nh=, =Nm=, =Ns= combos, or a bare integer = minutes. Reject +unparseable input (exit non-zero, notify nothing). Alarm parse: =HH:MM= 24h; if +that time today already passed, target tomorrow. + +* Notifications + +- Timer elapse: =notify alarm "Timer" "<label or duration> done" --persist=. +- Alarm fire: =notify alarm "Alarm" "<HH:MM><, label>" --persist=. +- Pomodoro phase change: =notify info "Pomodoro" "Work → short break (3/4)"= (no =--persist=; phase nudges shouldn't pile up), long-break and work-resume worded accordingly. +- notify is faked on PATH in tests; assert type + that it fired once per event. + +* Pomodoro semantics + +- Defaults: work 25m, short 5m, long 15m, interval 4 (long break after every 4th work). +- FSM: work → short → work → short → work → short → work → long → work … +- =cycle= counts completed works in the current set (1..interval); resets after a long break. +- Each phase elapse advances =phase=, recomputes =target=, fires the phase notify. Pomodoro never auto-removes; cancel ends it. + +* Waybar wiring + +** Module def (config) — signal 14 (next free; 8–13 used) +#+begin_src json +"custom/timer": { + "exec": "wtimer render", + "return-type": "json", + "interval": 1, + "signal": 14, + "on-click": "wtimer new", + "on-click-middle": "wtimer toggle", + "on-click-right": "wtimer pick-cancel", + "on-scroll-up": "wtimer cycle next", + "on-scroll-down": "wtimer cycle prev" +} +#+end_src + +** Position — right of the sysmon (battery/resource) module +Insert =custom/timer= into =modules-right= immediately after =custom/sysmon= +(between =custom/sysmon= and =custom/netspeed=). On screen that places it just +right of the battery/resource readout. + +** Not collapsible — survives the right-side collapse +The module *definition* lives in the canonical config object, and =waybar-collapse= +only swaps the =modules-right= *array* in the runtime copy (which it seeds from +canonical, so the def is always present). So making the timer non-collapsible is +purely an array-membership change: add =custom/timer= to the =waybar-collapse= +right *base set* so it stays listed when the right side collapses: +- laptop: =["custom/arrow-right","custom/sysmon","custom/timer","tray","custom/date","custom/worldclock"]= +- desktop: =["custom/arrow-right","custom/timer","tray","custom/date","custom/worldclock"]= +Update the =tests/waybar-collapse= base-set expectations to match (TDD the change). + +* CSS + +Add =#custom-timer= plus the state classes to all three stylesheets. Keep the +*selectors and structure* parallel across the three (what the theme-drift test +checks); the actual color *values* are per-theme (dupre vs hudson) and differ by +design, so this is structural parity, not byte-identity. Confirm against the real +CSS files what the drift test compares before editing. +- =hyprland/.config/waybar/style.css= +- =hyprland/.config/themes/dupre/...= waybar css +- =hyprland/.config/themes/hudson/...= waybar css +Colors: normal = foreground; =urgent= = a warning hue (reuse the sysmon +warn/crit palette); =paused= = dimmed; =pomodoro-break= = a calmer accent. + +* Testing plan (TDD) + +- Suite: =tests/wtimer/test_wtimer.py= (auto-discovered by =make test='s =tests/*/test_*.py= glob — no enumeration gap). +- *Pure-function tests* (fast, the bulk), explicit injected =now=: + - =parse_duration=: =25m=, =90s=, =1h30m=, =5= (→min), =0=, negative, garbage, empty (Normal/Boundary/Error). + - =parse_alarm=: future today, already-passed-today → tomorrow, =00:00=, =23:59=, =24:00=/=12:60= invalid, non-=HH:MM=. + - =format_time=: 0, 59s, 60s, 3599s, 3600s, multi-hour, negative clamps to 0. + - =add_item= for each type; =seq= increments; ids unique. + - =tick=: timer not-yet-elapsed (no change), exactly-at-target, past-target (fires once, removed); alarm same; pomodoro work→short→…→long→work advance + cycle counting + the 4th-work→long boundary; paused items never tick; multiple items in one tick. + - =select_primary=: explicit primary, stale primary falls back, soonest-remaining rule, stopwatch-only, empty. + - =render_payload=: text/tooltip/class for each type + paused + urgent + =+N= badge + idle. + - =toggle= pause then resume round-trips remaining/elapsed exactly; =cycle= wraps; =cancel= / =cancel-all=. +- *CLI integration tests* (subprocess, fakes on PATH, =WTIMER_NOW= to hit boundaries): =add= then =render= round-trip; =render= fires the faked =notify= once on an elapsed item and drops it; state file created if absent; *missing parent dir* created (fresh-boot case); corrupt/empty state file → treated as empty, no crash; mutating command sends the faked refresh signal. +- *Concurrency test*: spawn overlapping =render= + a mutating command against one state file; assert no lost update (the added item survives) and exactly-once notify (no double-fire from a clobbered tick). This is the regression guard for the flock/atomic-write fix. +- *Mocking boundary*: fake =notify=, =fuzzel=, =killall= on PATH (record calls); never mock the wtimer logic. Clock injected as a parameter. +- *Coverage*: measure with =coverage.py= if present (target 90%+ on the logic per testing.md business-logic bar); report the actual number. If =coverage= is absent, report per-command/per-branch case coverage explicitly and flag the tool gap (verification.md). +- =tests/waybar-collapse= base-set expectations updated for the new module. +- =tests/= theme-drift check stays green (CSS parity). + +* Files touched + +dotfiles branch =waybar-timer-module=: +- =hyprland/.local/bin/wtimer= (new, executable) +- =tests/wtimer/test_wtimer.py= (new) +- =hyprland/.config/waybar/config= (module def + modules-right position) +- =hyprland/.local/bin/waybar-collapse= (base-set) + =tests/waybar-collapse/...= (expectations) +- =hyprland/.config/waybar/style.css= + dupre + hudson waybar css (CSS) + +archsetup (main, at the end): +- this spec +- =todo.org= task closure + +* Resolved decisions (no approvals — my calls) + +- Python, not sh — testability + coverage; pocketbook precedent. +- One =render= heartbeat (no daemon) — simplest, waybar already polls. +- notify fires from =render='s tick, mutation guarantees once-only. +- Primary = user-cycled, else soonest-remaining; =+N= badge for the rest. +- Multiple simultaneous via tooltip list + badge (not a GTK panel) — keeps it "cool yet simple". +- Pomodoro is one self-advancing item, not four chained timers. +- Runtime-dir state (waybar-restart durable, not reboot durable) — v1. + +* Rollback + +All code on the dotfiles =waybar-timer-module= branch off =09815f3=. Squash-merge +at the end; =git switch main && git branch -D waybar-timer-module= reverts cleanly +if it goes sideways. diff --git a/docs/design/2026-06-29-zfs-pre-snapshot-installer.org b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org new file mode 100644 index 0000000..e5a339e --- /dev/null +++ b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org @@ -0,0 +1,106 @@ +#+TITLE: ZFS pre-pacman snapshot installer step (durable retention) +#+DATE: 2026-06-29 +#+SOURCE: handoff from the home project, 2026-06-29 + +* Problem + +A pacman =PreTransaction= hook snapshots =zroot/ROOT/default@pre-pacman_<ts>= +before every transaction, but nothing prunes them. Sanoid doesn't manage them +(they aren't =autosnap_= names), so they accumulated to 53 on velox between +April and the 2026-06-29 health check. Unbounded, they fill the pool over time. + +* What's actually on velox vs. archsetup + +The live =/usr/local/bin/zfs-pre-snapshot= is *not* authored by archsetup — +=git grep= for its content (=MIN_INTERVAL=, the pre-pacman =LOCKFILE= logic) +finds nothing tracked. The =PreTransaction= hooks in the archsetup monolith +(~lines 910, 1907, 1942) are the live-update guard, a different hook. The +script appears hand-placed on velox. + +The 2026-01-17 security doc line "ZFS pre-pacman snapshots (already in +install-archzfs)" is therefore *out of date* — archsetup does not install this. +Incorporating the fix is a NET-NEW installer step, not a patch to an existing +one. Correct that stale doc line as part of the work. + +velox was patched live (pruned to 10, script replaced with the self-pruning +version below); live backup at =/usr/local/bin/zfs-pre-snapshot.bak-2026-06-29=. + +* Proposed installer step + +In the archzfs / ZFS-on-root install path, gated to ZFS-root installs (velox is +the only ZFS daily driver; ratio is btrfs), install: + +1. =/etc/pacman.d/hooks/zfs-snapshot.hook= — the =PreTransaction= hook that + runs the script. *Not included in the handoff* — source it from velox + (=/etc/pacman.d/hooks/zfs-snapshot.hook=) or write it. +2. =/usr/local/bin/zfs-pre-snapshot= — the =KEEP=10= self-pruning version + below. + +Tests live in archsetup, so this wants an archsetup session and a ZFS-root VM +test (=make test FS_PROFILE=zfs=), not a cross-project edit from home. + +* The script (KEEP=10 self-pruning version) + +#+begin_src bash +#!/bin/bash +POOL="zroot" +DATASET="$POOL/ROOT/default" +LOCKFILE="/tmp/.zfs-pre-snapshot.lock" +MIN_INTERVAL=60 +KEEP=10 # how many pre-pacman snapshots to retain (rollback safety for recent transactions) + +# Skip if a snapshot was created within the last 60 seconds +if [ -f "$LOCKFILE" ]; then + last=$(stat -c %Y "$LOCKFILE" 2>/dev/null || echo 0) + now=$(date +%s) + if (( now - last < MIN_INTERVAL )); then + exit 0 + fi +fi + +TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) +SNAPSHOT_NAME="pre-pacman_$TIMESTAMP" + +if zfs snapshot "$DATASET@$SNAPSHOT_NAME"; then + echo "Created snapshot: $DATASET@$SNAPSHOT_NAME" + touch "$LOCKFILE" + + # Retention: keep only the most recent $KEEP pre-pacman snapshots, destroy older ones. + # Sanoid does not manage these (they aren't autosnap_), so prune them here at creation time. + zfs list -H -o name -t snapshot -s creation "$DATASET" 2>/dev/null \ + | grep '@pre-pacman_' \ + | head -n -"$KEEP" \ + | while read -r old; do + zfs destroy "$old" && echo "Pruned old snapshot: $old" + done +else + echo "Warning: Failed to create snapshot" >&2 +fi +#+end_src + +* Implementation (2026-06-30) + +- Hook sourced from velox (=/etc/pacman.d/hooks/zfs-snapshot.hook=) and embedded + as a heredoc in =configure_pre_pacman_snapshots()=. +- Insertion point: a new =configure_pre_pacman_snapshots()= gated on + =is_zfs_root=, called from =boot_ux= (the last step) so the hook doesn't fire + during the install's own package operations — the first pre-pacman snapshot is + the fresh system. The script ships as =scripts/zfs-pre-snapshot= (the + =zfs-replicate= pattern), made =ZFS_PRE_*=-env-overridable for testability. +- Tests: =tests/zfs-pre-snapshot/= unit-tests the pruning logic against a fake + =zfs= (creates, prunes oldest-past-KEEP, ignores non-=pre-pacman_= snapshots, + honors the lockfile, warns on snapshot failure); =test_boot.py= asserts the + hook + script land on a ZFS install; the orchestrator test pins the new + =boot_ux= substep. + +* Note on the "stale security doc" + +The 2026-01-17 line "ZFS pre-pacman snapshots (already in install-archzfs)" is +*not* stale: that file is an archive generated by install-archzfs (see its +header and footer), and the claim is accurate for install-archzfs. The real gap +was that archsetup took sanoid from install-archzfs but never ported the +pre-pacman hook. This change ports it. The archive is left untouched. + +* Remaining + +- ZFS-root VM verification (=make test FS_PROFILE=zfs=) before the task closes. diff --git a/docs/design/2026-06-30-captive-portal-login.org b/docs/design/2026-06-30-captive-portal-login.org new file mode 100644 index 0000000..1739689 --- /dev/null +++ b/docs/design/2026-06-30-captive-portal-login.org @@ -0,0 +1,89 @@ +#+TITLE: Captive-portal login — learnings + baking it into the net panel +#+DATE: 2026-06-30 +#+SOURCE: the 2026-06-30 Hyatt wifi saga (velox) + +* Why this exists + +On a locked-down-DNS laptop, captive portals never show their login page, even +though phones get on fine. We spent hours on a Hyatt portal before finding the +mechanism; this captures it so the fix becomes a panel feature instead of a +one-off script. + +* The mechanism (what actually blocks the login) + +A redirect portal works by *DNS hijack*: you query a name, the hotel's resolver +hands back the portal, you get the login page. Two things on velox stop that: + +- *System resolver forces DNS-over-TLS.* =/etc/systemd/resolved.conf.d/dns-over-tls.conf= + hardcodes =DNS=1.1.1.1#... 9.9.9.9#...= with =DNSOverTLS=yes=. The system never + queries the hotel's resolver at all. The hotel blocks 853 (DoT) and external + 53, so system DNS is simply dead on the portal — only 443 (DoH) gets out. +- *Browser DoH.* Chrome "secure DNS" on bypasses the hotel DNS too, so the + browser never gets redirected either. + +A phone works because it uses *plain DNS* from the hotel plus a built-in +captive-portal popper. The laptop has neither. + +Confirmed facts from the saga: +- Front desk: it's a normal redirect-to-login portal. Phone: connects fine. +- No DHCP option 114 (RFC 8910) — the portal doesn't advertise its URL. But the + URL is recoverable from the HTTP 302 once you're on plain DNS. +- The walled garden whitelists OS captive-detection endpoints + (=captive.apple.com= returns "Success") — a *misleading* signal, not real + internet. Don't trust it. +- 443/DoH egress works broadly on the portal; only port-53 DNS is held. So + "system DNS fails" never means "no internet" here. + +* The working fix (=~/.local/bin/hotel-wifi=, to be folded in) + +Temporarily disable DoT → plain hotel DNS → discover the portal URL from the +redirect → open it in a clean browser profile (no DoH, no stale HSTS/cookies) → +click the button → restore DoT. Reversible; tested to restore cleanly. + +#+begin_src sh +#!/bin/sh +# hotel-wifi disable DoT -> find the portal login URL -> open it +# hotel-wifi off restore normal encrypted DNS (run once online) +conf=/etc/systemd/resolved.conf.d/dns-over-tls.conf +if [ "${1:-on}" = "off" ]; then + [ -f "$conf.captive-disabled" ] && sudo mv "$conf.captive-disabled" "$conf" + sudo systemctl restart systemd-resolved + echo "Encrypted DNS (DoT) restored."; exit 0 +fi +[ -f "$conf" ] && sudo mv "$conf" "$conf.captive-disabled" +sudo systemctl restart systemd-resolved; sleep 1 +resolvectl flush-caches 2>/dev/null || true +portal="" +for t in http://captive.apple.com/hotspot-detect.html http://neverssl.com \ + http://detectportal.firefox.com/canonical.html; do + loc=$(curl -sS -m 6 -o /dev/null -w '%{redirect_url}' "$t" 2>/dev/null) + [ -n "$loc" ] && { portal="$loc"; break; } + url=$(curl -sS -m 6 "$t" 2>/dev/null | grep -ioE 'https?://[^"'"'"' >]+' \ + | grep -ivE 'apple\.com|neverssl|firefox|w3\.org|gstatic' | head -1) + [ -n "$url" ] && { portal="$url"; break; } +done +prof=$(mktemp -d) +setsid -f google-chrome-stable --user-data-dir="$prof" "${portal:-http://neverssl.com}" >/dev/null 2>&1 +echo "Click the login button. When online: hotel-wifi off" +#+end_src + +* Baking it into the net panel (the task) + +- The net engine already diagnoses captive / no-internet. When it sees a held + portal, the panel should offer a first-class *"Log in to this network"* + action that runs the plain-DNS + clean-browser flow above, reversibly, and + auto-restores DoT when connectivity returns (or on a timeout). +- Reconcile with the existing =net portal= command and the =captive= helper — + they assumed a DNS-hijack-to-gateway model that did NOT match this portal + (gateway served no web; DNS was held, not hijacked-to-portal). The plain-DNS + approach is the one that worked; make it the engine's portal path. +- The DoT toggle must be safe and reversible (the =off= step). Consider a + per-connection or time-boxed DoT-off that can't strand encrypted DNS. +- Surface the misleading-"Success" lesson: a whitelisted captive-check passing + is not "online" — gate on a real, non-whitelisted fetch. + +* Related fix that unblocked the panel (already shipped) + +The panel could never switch networks because =net up= placed =--wait= after the +nmcli subcommand (it's a global option). Fixed in dotfiles 2432311; fake-nmcli +now rejects the misplaced flag so it can't regress. diff --git a/docs/design/2026-07-02-bluetooth-panel-spec.org b/docs/design/2026-07-02-bluetooth-panel-spec.org new file mode 100644 index 0000000..121197a --- /dev/null +++ b/docs/design/2026-07-02-bluetooth-panel-spec.org @@ -0,0 +1,470 @@ +#+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 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/design/2026-07-02-desktop-settings-panel-spec.org b/docs/design/2026-07-02-desktop-settings-panel-spec.org new file mode 100644 index 0000000..8becf71 --- /dev/null +++ b/docs/design/2026-07-02-desktop-settings-panel-spec.org @@ -0,0 +1,128 @@ +#+TITLE: Desktop-Settings Dropdown Panel +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Status +:PROPERTIES: +:ID: fb7eec22-a214-4568-82c4-903612f4832f +:END: +- [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 | +|--------+----------------------------------------------| + +* 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. +- Dupre WIP palette CSS, shared with the net panel — factor the palette + block into a common css asset both panels load rather than duplicating + (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 decision below. + +* Decisions (Craig) + +** TODO Which standalone bar indicators collapse into the panel? +Options per module (dim, touchpad, caffeine): keep on bar + mirrored in +panel; or panel-only (frees bar width). Recommendation: keep touchpad and +caffeine visible on the bar (state you glance at), move dim into the panel +(you set it rarely), keep airplane where it is. + +** TODO Keybind for the panel? +Super+Shift+G (gear) is free. Or no keybind — mouse-only surface. + +** TODO Where does the code live? +Recommendation: dotfiles =settings/= sibling to =net/= (same src-layout, +tests in tests/settings/), sharing the palette css. In-tree pocketbook-style +was the old note; the net panel is the better donor now. + +** TODO Slider granularity and floor +brightnessctl exposes 0-100%; a 5% floor stops "screen went black in a dark +room" lockouts. Confirm the floor (or allow 0 with a long-press escape +hatch). + +* 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 the decision above (drop/keep indicators, + refresh-signal wiring, keybind). diff --git a/docs/design/2026-07-02-file-manager-swallow-spec.org b/docs/design/2026-07-02-file-manager-swallow-spec.org new file mode 100644 index 0000000..4c61be1 --- /dev/null +++ b/docs/design/2026-07-02-file-manager-swallow-spec.org @@ -0,0 +1,141 @@ +#+TITLE: File-Manager Swallow Pattern +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* 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/design/2026-07-02-net-panel-other-interfaces-spec.org b/docs/design/2026-07-02-net-panel-other-interfaces-spec.org new file mode 100644 index 0000000..6b0a72d --- /dev/null +++ b/docs/design/2026-07-02-net-panel-other-interfaces-spec.org @@ -0,0 +1,189 @@ +#+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 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/design/2026-07-02-timer-panel-spec.org b/docs/design/2026-07-02-timer-panel-spec.org new file mode 100644 index 0000000..2c9f7d4 --- /dev/null +++ b/docs/design/2026-07-02-timer-panel-spec.org @@ -0,0 +1,113 @@ +#+TITLE: Timer GTK Panel +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Status +:PROPERTIES: +:ID: 1770af2e-b093-4024-a512-ae4324a2869f +:END: +- [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." + +* Metadata + +| Field | Value | +|--------+---------------------------------------------------| +| Status | draft | +|--------+---------------------------------------------------| +| 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; the panel polls + it (or subscribes to the same RTMIN+14 refresh signal) for live state. + 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 + +- Header row: running-item count + a Clear All button (maps to cancel-all). +- Item list: one row per item — type glyph, label, live countdown / clock + time / phase+cycle for pomodoro, pause and cancel buttons, click-to-promote. +- Create strip: four type buttons (the wtimer glyphs), preset chips per type + (e.g. 5m / 15m / 25m / 60m for timers), a freeform entry validated with + wtimer's own parsers, an optional label field. +- Empty state: the create strip alone, centered. + +** What happens to the fuzzel flow + +The keybind/fuzzel path stays as the keyboard-fast lane (it's now styled and +tested); the panel replaces the click-driven path on the bar module. Whether +the fuzzel chain eventually retires is a decision below. + +* Decisions (Craig) + +** TODO Panel scope: standalone timer panel, or a page in the desktop-settings panel? +The desktop-settings spec (sibling DRAFT) could host timers as a page. +Standalone matches the net panel's one-domain-one-panel shape and keeps the +timer dropdown small; folding in means one panel binary fewer. Recommend +standalone, sharing the palette/css asset. + +** TODO Fuzzel flow: keep as keyboard fast lane, or retire once the panel lands? +Keeping both costs two creation paths to maintain (though the fuzzel chain is +small and freshly tested). Recommend keep until the panel proves itself, then +revisit. + +** TODO Presets: which chips per type? +Strawman: timer 5m/15m/25m/60m; alarm +30m/top-of-hour/07:00; pomodoro +default cycle only; stopwatch needs none. Adjust to taste. + +** TODO Live updates: poll render (1s, like the bar) or a wtimer "watch" mode? +Polling reuses what exists and matches the bar's cadence; a watch/subscribe +mode is cleaner but grows wtimer. Recommend polling first. + +* Implementation phases + +1. PanelModel presenter + CLI-backing seam (TDD, GTK-free, 100% like the net + PanelModel). +2. Blueprint UI: item list + create strip, wired to the presenter; palette + css factored to the shared asset. +3. Bar integration: timer module left-click opens the panel (replacing the + fuzzel menu binding there), RTMIN+14 refresh keeps bar and panel in step. +4. AT-SPI smoke + manual-testing checklist; decide the fuzzel flow's future + after a week of real use. diff --git a/docs/design/2026-07-02-waybar-expansion-animation-feasibility.org b/docs/design/2026-07-02-waybar-expansion-animation-feasibility.org new file mode 100644 index 0000000..cb195c6 --- /dev/null +++ b/docs/design/2026-07-02-waybar-expansion-animation-feasibility.org @@ -0,0 +1,53 @@ +#+TITLE: Waybar Expansion Animation — Feasibility Assessment +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 + +* Question + +The todo.org task "Smooth waybar expansion animation" [#C]: the collapse/expand +jump is abrupt, and a few systray icons pop in one-by-one afterward. Can the +expansion animate smoothly? + +* How the collapse actually works + +=waybar-collapse= rewrites the module arrays in a runtime copy of the config +(=$XDG_RUNTIME_DIR/waybar/config=) and sends waybar SIGUSR2. Waybar reloads: +it tears down every module widget and rebuilds the bar from the new config. + +* Findings + +1. *No widget survives the reload, so nothing can transition.* GTK3 CSS + transitions animate property changes on live widgets. The collapse + mechanism replaces the whole widget tree; there is no widget on both sides + of the change to interpolate. The jump is structural, not stylistic. +2. *GTK3 doesn't animate widget add/remove anyway.* Smooth insert/remove needs + =GtkRevealer= wrapping, which waybar does not use for modules. Making it do + so is an upstream waybar patch, not a config or CSS matter. +3. *The layer surface resize isn't animatable either.* Hyprland layerrules can + animate map/unmap (slide/fade), but the bar stays mapped through a collapse + — the same surface changes width. No compositor-side hook exists for that. +4. *A CSS-only fake covers custom modules at best.* Custom modules could emit + a "collapsed" class and transition font-size/padding toward zero (GTK3 CSS + can animate those). But the collapsed set includes built-ins — tray, + pulseaudio, workspaces — which take no script-driven classes. The result + would be half the modules gliding and half popping: worse than the clean + jump. +5. *Tray icons popping in one-by-one is separate and unfixable here.* That's + asynchronous StatusNotifier re-registration after the reload; each app + answers on its own schedule. Only keeping the tray alive across the change + (i.e. not reloading) avoids it. + +* Conclusion + +Not feasible with the current collapse mechanism, and no acceptable partial +measure exists. A real animation requires waybar itself to support dynamic +module sets with Revealer-style transitions (an upstream feature), or +replacing the collapse-by-reload design entirely. + +* Recommendation + +Close the task as infeasible-for-now (or park at [#D] with a pointer here). +Revisit only if waybar upstream gains dynamic module visibility (worth a +check at major waybar releases) or if the bar ever migrates to a custom +GTK4 shell — the Blueprint pipeline from the net panel would make Revealer +transitions natural there. diff --git a/docs/design/2026-07-03-audio-panel-spec.org b/docs/design/2026-07-03-audio-panel-spec.org new file mode 100644 index 0000000..16a087d --- /dev/null +++ b/docs/design/2026-07-03-audio-panel-spec.org @@ -0,0 +1,160 @@ +#+TITLE: Audio Panel — the pulsemixer console +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-03 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* 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 + (=working/sound-panel/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: =working/sound-panel/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/design/2026-07-03-instrument-console-panels-spec.org b/docs/design/2026-07-03-instrument-console-panels-spec.org new file mode 100644 index 0000000..315e0b4 --- /dev/null +++ b/docs/design/2026-07-03-instrument-console-panels-spec.org @@ -0,0 +1,158 @@ +#+TITLE: Instrument-console rebuild — net + bluetooth panels +#+DATE: 2026-07-03 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Status +:PROPERTIES: +:ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5 +:END: +- 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:../../assets/2026-07-03-instrument-console-panels-prototype.html][assets/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/pocketbook/.gitignore b/pocketbook/.gitignore deleted file mode 100644 index b9c0bb9..0000000 --- a/pocketbook/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -__pycache__/ -*.egg-info/ -dist/ -build/ -.pytest_cache/ -*.pyc diff --git a/pocketbook/Makefile b/pocketbook/Makefile deleted file mode 100644 index f09dbbb..0000000 --- a/pocketbook/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -.PHONY: test lint install uninstall clean - -test: - python -m pytest tests/ -v - -lint: - python -m ruff check src/ tests/ - -install: - pip install --user -e . - -uninstall: - pip uninstall -y quicknotes - -clean: - rm -rf build/ dist/ src/*.egg-info diff --git a/pocketbook/pyproject.toml b/pocketbook/pyproject.toml deleted file mode 100644 index afc5c71..0000000 --- a/pocketbook/pyproject.toml +++ /dev/null @@ -1,21 +0,0 @@ -[build-system] -requires = ["setuptools>=68.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "pocketbook" -version = "0.1.0" -description = "GTK4 layer-shell notes panel for Hyprland" -requires-python = ">=3.12" -dependencies = [ - "PyGObject", -] - -[project.scripts] -pocketbook = "pocketbook.__main__:main" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.pytest.ini_options] -testpaths = ["tests"] diff --git a/pocketbook/src/pocketbook/__init__.py b/pocketbook/src/pocketbook/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/pocketbook/src/pocketbook/__init__.py +++ /dev/null diff --git a/pocketbook/src/pocketbook/__main__.py b/pocketbook/src/pocketbook/__main__.py deleted file mode 100644 index daac841..0000000 --- a/pocketbook/src/pocketbook/__main__.py +++ /dev/null @@ -1,26 +0,0 @@ -import argparse -import ctypes -import os - -# gtk4-layer-shell must be loaded before libwayland-client. -# See: https://github.com/wmww/gtk4-layer-shell/blob/main/linking.md -_LIB = "/usr/lib/libgtk4-layer-shell.so" -if os.path.exists(_LIB): - ctypes.cdll.LoadLibrary(_LIB) - - -def main(): - parser = argparse.ArgumentParser(description="Pocketbook panel") - parser.add_argument( - "--hidden", - action="store_true", - help="Start with panel hidden (daemon mode)", - ) - args = parser.parse_args() - - from pocketbook.app import run_app - raise SystemExit(run_app(start_hidden=args.hidden)) - - -if __name__ == "__main__": - main() diff --git a/pocketbook/src/pocketbook/app.py b/pocketbook/src/pocketbook/app.py deleted file mode 100644 index be13fec..0000000 --- a/pocketbook/src/pocketbook/app.py +++ /dev/null @@ -1,243 +0,0 @@ -import sys -from pathlib import Path - -APP_ID = "net.cjennings.pocketbook" -DATA_DIR = Path.home() / ".local" / "share" / "pocketbook" -CSS_PATH = Path(__file__).parent / "style.css" - - -class ToggleStateMachine: - """Testable toggle logic, separated from GTK.""" - - def __init__(self, start_hidden: bool = False): - self.visible = not start_hidden - - def toggle(self): - self.visible = not self.visible - - -def navigate(current_index: int | None, total: int, direction: int) -> int | None: - """Return the target index for note navigation. - - Args: - current_index: Index of focused note, or None if no note focused. - total: Number of notes. - direction: +1 for next, -1 for previous. - - Returns: - Target index, or None if no notes exist. - """ - if total == 0: - return None - if current_index is None: - return 0 if direction == 1 else total - 1 - target = current_index + direction - return max(0, min(target, total - 1)) - - -class EscapeStateMachine: - """Testable escape logic: editing → browse → hidden.""" - - def escape(self, is_editing: bool, toggle: ToggleStateMachine): - """Returns action: 'exit_edit', 'hide', or None.""" - if is_editing: - return "exit_edit" - else: - toggle.toggle() - return "hide" - - -def _build_gtk_app_class(): - """Lazy-load GTK classes to keep ToggleStateMachine importable without GTK.""" - import gi - gi.require_version("Gtk", "4.0") - from gi.repository import Gtk, Gdk - - from pocketbook.store import NoteStore - from pocketbook.panel import PanelController, PanelWidget - from pocketbook.layer_shell import configure_layer_shell - - class PocketbookApp(Gtk.Application): - def __init__(self, start_hidden: bool = False): - super().__init__(application_id=APP_ID) - self._start_hidden = start_hidden - self._window = None - self._panel = None - self._toggle = ToggleStateMachine(start_hidden=start_hidden) - self._esc_sm = EscapeStateMachine() - self._first_activate = True - - def do_activate(self): - if self._first_activate: - self._first_activate = False - self._build_ui() - if not self._start_hidden: - self._window.set_visible(True) - self._focus_first_note() - else: - self._toggle.toggle() - self._window.set_visible(self._toggle.visible) - if self._toggle.visible: - self._focus_first_note() - - def _focus_first_note(self): - rows = self._get_note_rows() - if rows: - rows[0].grab_focus() - - def _build_ui(self): - self._window = Gtk.Window(application=self) - self._window.set_default_size(420, -1) - - configure_layer_shell(self._window) - self._load_css() - - store = NoteStore(DATA_DIR) - controller = PanelController(store) - self._panel = PanelWidget(controller) - self._window.set_child(self._panel) - - self._setup_shortcuts() - - def _load_css(self): - if CSS_PATH.exists(): - provider = Gtk.CssProvider() - provider.load_from_path(str(CSS_PATH)) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - - def _setup_shortcuts(self): - from pocketbook.note_widget import NoteRow - - sc = Gtk.ShortcutController() - sc.set_scope(Gtk.ShortcutScope.GLOBAL) - - for key, handler in [ - ("Escape", self._on_escape), - ("<Control>n", self._on_ctrl_n), - ("<Control>j", self._on_next_note), - ("<Control>k", self._on_prev_note), - ("Delete", self._on_delete_note), - ("Return", self._on_enter), - ]: - sc.add_shortcut(Gtk.Shortcut( - trigger=Gtk.ShortcutTrigger.parse_string(key), - action=Gtk.CallbackAction.new(handler), - )) - - self._window.add_controller(sc) - self._note_row_class = NoteRow - - # Arrow keys need EventControllerKey to beat GTK's focus navigation - key_ctrl = Gtk.EventControllerKey() - key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) - key_ctrl.connect("key-pressed", self._on_key_pressed) - self._window.add_controller(key_ctrl) - - def _get_focused_note_row(self): - """Find the NoteRow that currently has focus (or contains the focused widget).""" - focus = self._window.get_focus() - widget = focus - while widget: - if isinstance(widget, self._note_row_class): - return widget - widget = widget.get_parent() - return None - - def _get_note_rows(self): - """Get all NoteRow children from the panel.""" - rows = [] - child = self._panel._list_box.get_first_child() - while child: - if isinstance(child, self._note_row_class): - rows.append(child) - child = child.get_next_sibling() - return rows - - def _on_escape(self, _widget, _args): - focus = self._window.get_focus() - is_editing = bool(focus and isinstance(focus, (Gtk.Entry, Gtk.TextView, Gtk.Text))) - action = self._esc_sm.escape(is_editing, self._toggle) - if action == "exit_edit": - row = self._get_focused_note_row() - if row: - row.exit_edit_mode() - else: - self._window.set_focus(None) - elif action == "hide": - self._window.set_visible(False) - return True - - def _on_ctrl_n(self, _widget, _args): - self._panel.add_note() - return True - - def _on_next_note(self, _widget, _args): - self._navigate_notes(1) - return True - - def _on_prev_note(self, _widget, _args): - self._navigate_notes(-1) - return True - - def _navigate_notes(self, direction): - rows = self._get_note_rows() - current = self._get_focused_note_row() - current_idx = rows.index(current) if current in rows else None - target_idx = navigate(current_idx, len(rows), direction) - if target_idx is not None: - rows[target_idx].grab_focus() - - def _on_key_pressed(self, _controller, keyval, _keycode, _state): - from gi.repository import Gdk as _Gdk - focus = self._window.get_focus() - is_editing = isinstance(focus, (Gtk.Entry, Gtk.TextView, Gtk.Text)) - if keyval == _Gdk.KEY_Down and not is_editing: - self._on_next_note(None, None) - return True # stop propagation - if keyval == _Gdk.KEY_Up and not is_editing: - self._on_prev_note(None, None) - return True - if keyval == _Gdk.KEY_s and is_editing and (_state & _Gdk.ModifierType.CONTROL_MASK): - row = self._get_focused_note_row() - if row: - row.exit_edit_mode() - return True - if keyval == _Gdk.KEY_Return and not is_editing: - row = self._get_focused_note_row() - if row: - row.focus_title() - return True - return False # let GTK handle it - - def _on_delete_note(self, _widget, _args): - focus = self._window.get_focus() - # Only delete in browse mode (NoteRow focused, not a text field) - if isinstance(focus, (Gtk.Entry, Gtk.TextView, Gtk.Text)): - return False # pass through to text widget - row = self._get_focused_note_row() - if row: - self._panel._on_note_deleted(row._filename) - self._focus_first_note() - return True - - def _on_enter(self, _widget, _args): - # Only enter edit mode if a NoteRow itself is focused (browse mode), - # not when already editing a text field - focus = self._window.get_focus() - if isinstance(focus, self._note_row_class): - focus.focus_title() - return True - # Let Enter pass through normally in text fields - return False - - return PocketbookApp - - -def run_app(start_hidden: bool = False) -> int: - PocketbookApp = _build_gtk_app_class() - app = PocketbookApp(start_hidden=start_hidden) - return app.run(sys.argv[:1]) diff --git a/pocketbook/src/pocketbook/layer_shell.py b/pocketbook/src/pocketbook/layer_shell.py deleted file mode 100644 index bb36373..0000000 --- a/pocketbook/src/pocketbook/layer_shell.py +++ /dev/null @@ -1,24 +0,0 @@ -import gi -gi.require_version("Gtk", "4.0") -gi.require_version("Gtk4LayerShell", "1.0") -from gi.repository import Gtk, Gtk4LayerShell - - -def configure_layer_shell(window: Gtk.Window): - """Configure a GTK4 window as a layer-shell surface.""" - Gtk4LayerShell.init_for_window(window) - Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.OVERLAY) - Gtk4LayerShell.set_namespace(window, "pocketbook") - - # Anchor top-right - Gtk4LayerShell.set_anchor(window, Gtk4LayerShell.Edge.TOP, True) - Gtk4LayerShell.set_anchor(window, Gtk4LayerShell.Edge.RIGHT, True) - - # Margins: below waybar, inset from edge - Gtk4LayerShell.set_margin(window, Gtk4LayerShell.Edge.TOP, 50) - Gtk4LayerShell.set_margin(window, Gtk4LayerShell.Edge.RIGHT, 10) - - # On-demand keyboard: receives keys when focused, doesn't steal focus on appear - Gtk4LayerShell.set_keyboard_mode( - window, Gtk4LayerShell.KeyboardMode.ON_DEMAND - ) diff --git a/pocketbook/src/pocketbook/note.py b/pocketbook/src/pocketbook/note.py deleted file mode 100644 index e812eea..0000000 --- a/pocketbook/src/pocketbook/note.py +++ /dev/null @@ -1,28 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -import secrets - - -@dataclass -class Note: - title: str - body: str - - def to_file_content(self) -> str: - return f"{self.title}\n\n{self.body}" - - @classmethod - def from_file_content(cls, content: str) -> "Note": - parts = content.split("\n\n", 1) - title = parts[0] - body = parts[1] if len(parts) > 1 else "" - return cls(title=title, body=body) - - def generate_filename(self, order: int) -> str: - ts = datetime.now().strftime("%Y%m%d-%H%M%S") - short_id = secrets.token_hex(3)[:5] - return f"{order:04d}-{ts}-{short_id}.txt" - - @staticmethod - def parse_order(filename: str) -> int: - return int(filename.split("-", 1)[0]) diff --git a/pocketbook/src/pocketbook/note_widget.py b/pocketbook/src/pocketbook/note_widget.py deleted file mode 100644 index 3467ad0..0000000 --- a/pocketbook/src/pocketbook/note_widget.py +++ /dev/null @@ -1,93 +0,0 @@ -from typing import Callable - -import gi -gi.require_version("Gtk", "4.0") -from gi.repository import Gtk - -from pocketbook.note import Note - - -class NoteRow(Gtk.Box): - """A single note row with browse/edit modes.""" - - def __init__( - self, - filename: str, - note: Note, - on_changed: Callable[[str, str, str], None], - on_deleted: Callable[[str], None], - ): - super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4) - self.add_css_class("note-row") - self._filename = filename - self._on_changed = on_changed - self._on_deleted = on_deleted - - # Title row with delete button - title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - - self._title_entry = Gtk.Entry() - self._title_entry.set_text(note.title) - self._title_entry.set_hexpand(True) - self._title_entry.add_css_class("note-title") - self._title_entry.set_placeholder_text("Title") - self._title_entry.set_can_focus(False) - self._title_entry.set_focusable(False) - self._title_entry.connect("changed", self._on_text_changed) - self._title_entry.connect("notify::has-focus", self._on_title_focus_changed) - title_row.append(self._title_entry) - - delete_btn = Gtk.Button(label="") - delete_btn.add_css_class("delete-button") - delete_btn.set_focusable(False) - delete_btn.connect("clicked", self._on_delete_clicked) - title_row.append(delete_btn) - - self.append(title_row) - - # Body text view - self._body_buffer = Gtk.TextBuffer() - self._body_buffer.set_text(note.body) - self._body_buffer.connect("changed", self._on_text_changed) - - self._body_view = Gtk.TextView(buffer=self._body_buffer) - self._body_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - self._body_view.add_css_class("note-body") - self._body_view.set_vexpand(False) - self._body_view.set_can_focus(False) - self._body_view.set_focusable(False) - self.append(self._body_view) - - # NoteRow itself is focusable for browse mode - self.set_focusable(True) - - def focus_title(self): - """Enter edit mode — focus and select title text.""" - self._title_entry.set_can_focus(True) - self._title_entry.set_focusable(True) - self._body_view.set_can_focus(True) - self._body_view.set_focusable(True) - self._title_entry.grab_focus() - self._title_entry.select_region(0, -1) - - def exit_edit_mode(self): - """Return to browse mode — defocus text fields.""" - self._title_entry.set_can_focus(False) - self._title_entry.set_focusable(False) - self._body_view.set_can_focus(False) - self._body_view.set_focusable(False) - self.grab_focus() - - def _on_title_focus_changed(self, entry, _pspec): - if not entry.has_focus(): - entry.select_region(0, 0) - - def _on_text_changed(self, _widget): - title = self._title_entry.get_text() - start = self._body_buffer.get_start_iter() - end = self._body_buffer.get_end_iter() - body = self._body_buffer.get_text(start, end, False) - self._on_changed(self._filename, title, body) - - def _on_delete_clicked(self, _button): - self._on_deleted(self._filename) diff --git a/pocketbook/src/pocketbook/panel.py b/pocketbook/src/pocketbook/panel.py deleted file mode 100644 index e0a708c..0000000 --- a/pocketbook/src/pocketbook/panel.py +++ /dev/null @@ -1,97 +0,0 @@ -from pocketbook.store import NoteStore -from pocketbook.note import Note - -import gi -gi.require_version("Gtk", "4.0") -from gi.repository import Gtk - - -class PanelController: - """Testable controller logic for the notes panel.""" - - def __init__(self, store: NoteStore): - self.store = store - - def add_note(self) -> str: - return self.store.create("New Note", "") - - def delete_note(self, filename: str): - self.store.delete(filename) - - def update_note(self, filename: str, title: str, body: str): - self.store.update(filename, title, body) - - def get_notes(self) -> list[tuple[str, Note]]: - return self.store.list_notes() - - -class PanelWidget(Gtk.Box): - """Main panel widget — scrollable list of notes with add button.""" - - def __init__(self, controller: PanelController): - super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) - self.controller = controller - self.add_css_class("panel") - - # Header with title and add button - header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - header.add_css_class("panel-header") - - title_label = Gtk.Label(label="Pocketbook") - title_label.add_css_class("panel-title") - title_label.set_hexpand(True) - title_label.set_halign(Gtk.Align.START) - header.append(title_label) - - add_btn = Gtk.Button(label="+") - add_btn.add_css_class("add-button") - add_btn.set_focusable(False) - add_btn.connect("clicked", self._on_add_clicked) - header.append(add_btn) - - self.append(header) - - # Scrollable note list - self._scroll = Gtk.ScrolledWindow() - self._scroll.set_vexpand(True) - self._scroll.set_propagate_natural_height(True) - self._scroll.set_max_content_height(550) - self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - - self._list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - self._list_box.add_css_class("note-list") - self._scroll.set_child(self._list_box) - self.append(self._scroll) - - self._refresh() - - def _refresh(self): - # Clear existing children - child = self._list_box.get_first_child() - while child: - next_child = child.get_next_sibling() - self._list_box.remove(child) - child = next_child - - from pocketbook.note_widget import NoteRow - for filename, note in self.controller.get_notes(): - row = NoteRow(filename, note, self._on_note_changed, self._on_note_deleted) - self._list_box.append(row) - - def add_note(self): - self.controller.add_note() - self._refresh() - # Focus the title entry of the newly added note (last child) - last = self._list_box.get_last_child() - if last: - last.focus_title() - - def _on_add_clicked(self, _button): - self.add_note() - - def _on_note_changed(self, filename: str, title: str, body: str): - self.controller.update_note(filename, title, body) - - def _on_note_deleted(self, filename: str): - self.controller.delete_note(filename) - self._refresh() diff --git a/pocketbook/src/pocketbook/store.py b/pocketbook/src/pocketbook/store.py deleted file mode 100644 index 83d3b74..0000000 --- a/pocketbook/src/pocketbook/store.py +++ /dev/null @@ -1,73 +0,0 @@ -from pathlib import Path -import re -from pocketbook.note import Note - -FILENAME_RE = re.compile(r"^\d{4}-.+\.txt$") - - -class NoteStore: - def __init__(self, directory: Path): - self.directory = directory - - def _ensure_dir(self): - self.directory.mkdir(parents=True, exist_ok=True) - - def _valid_note_files(self) -> list[tuple[str, Path]]: - """Return sorted list of (filename, path) for valid note files.""" - if not self.directory.exists(): - return [] - files = [] - for p in self.directory.iterdir(): - if p.is_file() and FILENAME_RE.match(p.name): - try: - Note.parse_order(p.name) - files.append((p.name, p)) - except (ValueError, IndexError): - continue - files.sort(key=lambda x: Note.parse_order(x[0])) - return files - - def _next_order(self) -> int: - files = self._valid_note_files() - if not files: - return 1 - return Note.parse_order(files[-1][0]) + 1 - - def create(self, title: str, body: str) -> str: - self._ensure_dir() - note = Note(title=title, body=body) - order = self._next_order() - filename = note.generate_filename(order) - (self.directory / filename).write_text(note.to_file_content()) - return filename - - def list_notes(self) -> list[tuple[str, Note]]: - """Return list of (filename, Note) sorted by order.""" - result = [] - for fname, path in self._valid_note_files(): - content = path.read_text() - note = Note.from_file_content(content) - result.append((fname, note)) - return result - - def update(self, filename: str, title: str, body: str): - path = self.directory / filename - if not path.exists(): - raise FileNotFoundError(f"Note not found: {filename}") - note = Note(title=title, body=body) - path.write_text(note.to_file_content()) - - def delete(self, filename: str): - path = self.directory / filename - if not path.exists(): - raise FileNotFoundError(f"Note not found: {filename}") - path.unlink() - - def reorder(self): - """Renumber all note files sequentially starting from 1.""" - files = self._valid_note_files() - for i, (fname, path) in enumerate(files, start=1): - current_order = Note.parse_order(fname) - if current_order != i: - new_name = f"{i:04d}{fname[4:]}" - path.rename(self.directory / new_name) diff --git a/pocketbook/src/pocketbook/style.css b/pocketbook/src/pocketbook/style.css deleted file mode 100644 index 92df265..0000000 --- a/pocketbook/src/pocketbook/style.css +++ /dev/null @@ -1,102 +0,0 @@ -/* Pocketbook — Dupre theme */ -/* Colors from dupre-palette.org */ - -window { - background-color: rgba(21, 19, 17, 0.95); - border: 2px solid #d7af5f; - border-radius: 16px; - font-family: "BerkeleyMono Nerd Font", "Berkeley Mono", monospace; - font-size: 14px; - color: #969385; -} - -.panel { - padding: 12px; -} - -.panel-header { - padding: 4px 4px 8px 4px; - margin-bottom: 4px; - border-bottom: 1px solid #474544; -} - -.panel-title { - font-size: 16px; - font-weight: bold; - color: #d7af5f; -} - -.add-button { - background-color: transparent; - color: #d7af5f; - border: 1px solid #d7af5f; - border-radius: 8px; - padding: 2px 10px; - font-size: 18px; - font-weight: bold; - min-width: 32px; - min-height: 32px; -} - -.add-button:hover { - background-color: #d7af5f; - color: #151311; -} - -.note-list { - padding: 4px 0; -} - -.note-row { - background-color: rgba(71, 69, 68, 0.3); - border-radius: 8px; - padding: 8px; - margin: 4px 0; - border: 1px solid transparent; -} - -.note-row:focus { - border: 1px solid #d7af5f; -} - -.note-title { - background-color: transparent; - border: none; - color: #b2c3cc; - font-weight: bold; - font-size: 14px; - padding: 4px; -} - -.note-title:focus { - outline: none; - border-bottom: 1px solid #d7af5f; -} - -.note-body { - background-color: transparent; - color: #d0cbc0; - font-size: 13px; - padding: 4px; - border-radius: 4px; -} - -.note-body:focus { - background-color: rgba(71, 69, 68, 0.4); -} - -.delete-button { - background-color: transparent; - color: #969385; - border: none; - border-radius: 8px; - padding: 2px 8px; - font-size: 16px; - min-width: 28px; - min-height: 28px; -} - -.delete-button:hover { - color: #d47c59; - background-color: rgba(212, 124, 89, 0.2); -} diff --git a/pocketbook/tests/__init__.py b/pocketbook/tests/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/pocketbook/tests/__init__.py +++ /dev/null diff --git a/pocketbook/tests/conftest.py b/pocketbook/tests/conftest.py deleted file mode 100644 index db04f88..0000000 --- a/pocketbook/tests/conftest.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest - - -@pytest.fixture -def notes_dir(tmp_path): - """Temporary directory for note storage.""" - d = tmp_path / "quicknotes" - d.mkdir() - return d - - -@pytest.fixture -def sample_note_file(notes_dir): - """A sample note file on disk.""" - path = notes_dir / "0001-20260101-120000-abc12.txt" - path.write_text("Shopping List\n\nMilk\nEggs\nBread\n") - return path diff --git a/pocketbook/tests/test_app_toggle.py b/pocketbook/tests/test_app_toggle.py deleted file mode 100644 index cb5ab89..0000000 --- a/pocketbook/tests/test_app_toggle.py +++ /dev/null @@ -1,96 +0,0 @@ -from pocketbook.app import ToggleStateMachine, EscapeStateMachine, navigate - - -class TestToggleStateMachine: - def test_initial_state_visible(self): - sm = ToggleStateMachine(start_hidden=False) - assert sm.visible is True - - def test_initial_state_hidden(self): - sm = ToggleStateMachine(start_hidden=True) - assert sm.visible is False - - def test_toggle_alternates(self): - sm = ToggleStateMachine(start_hidden=False) - assert sm.visible is True - sm.toggle() - assert sm.visible is False - sm.toggle() - assert sm.visible is True - - def test_toggle_from_hidden(self): - sm = ToggleStateMachine(start_hidden=True) - assert sm.visible is False - sm.toggle() - assert sm.visible is True - sm.toggle() - assert sm.visible is False - - -class TestEscapeStateMachine: - def test_escape_while_editing_returns_exit_edit(self): - esc = EscapeStateMachine() - toggle = ToggleStateMachine(start_hidden=False) - action = esc.escape(is_editing=True, toggle=toggle) - assert action == "exit_edit" - # Toggle state should not change - assert toggle.visible is True - - def test_escape_while_browsing_returns_hide(self): - esc = EscapeStateMachine() - toggle = ToggleStateMachine(start_hidden=False) - action = esc.escape(is_editing=False, toggle=toggle) - assert action == "hide" - assert toggle.visible is False - - def test_escape_edit_then_browse_hides(self): - """Simulates: editing → Escape (exit edit) → Escape (hide).""" - esc = EscapeStateMachine() - toggle = ToggleStateMachine(start_hidden=False) - - # First escape: exit edit mode - action1 = esc.escape(is_editing=True, toggle=toggle) - assert action1 == "exit_edit" - assert toggle.visible is True - - # Second escape: now in browse mode, hide panel - action2 = esc.escape(is_editing=False, toggle=toggle) - assert action2 == "hide" - assert toggle.visible is False - - def test_escape_hide_does_not_change_when_already_hidden(self): - esc = EscapeStateMachine() - toggle = ToggleStateMachine(start_hidden=True) - assert toggle.visible is False - action = esc.escape(is_editing=False, toggle=toggle) - assert action == "hide" - # Toggled again — now visible (edge case if called when hidden) - assert toggle.visible is True - - -class TestNavigate: - def test_no_notes(self): - assert navigate(None, 0, 1) is None - assert navigate(None, 0, -1) is None - - def test_no_focus_next_goes_to_first(self): - assert navigate(None, 3, 1) == 0 - - def test_no_focus_prev_goes_to_last(self): - assert navigate(None, 3, -1) == 2 - - def test_next_from_middle(self): - assert navigate(1, 3, 1) == 2 - - def test_prev_from_middle(self): - assert navigate(1, 3, -1) == 0 - - def test_next_clamps_at_end(self): - assert navigate(2, 3, 1) == 2 - - def test_prev_clamps_at_start(self): - assert navigate(0, 3, -1) == 0 - - def test_single_note(self): - assert navigate(0, 1, 1) == 0 - assert navigate(0, 1, -1) == 0 diff --git a/pocketbook/tests/test_note.py b/pocketbook/tests/test_note.py deleted file mode 100644 index 539451a..0000000 --- a/pocketbook/tests/test_note.py +++ /dev/null @@ -1,69 +0,0 @@ -from pocketbook.note import Note - - -class TestNoteSerialisation: - def test_round_trip(self): - note = Note(title="Shopping", body="Milk\nEggs\nBread") - content = note.to_file_content() - restored = Note.from_file_content(content) - assert restored.title == note.title - assert restored.body == note.body - - def test_empty_body(self): - note = Note(title="Empty", body="") - content = note.to_file_content() - restored = Note.from_file_content(content) - assert restored.title == "Empty" - assert restored.body == "" - - def test_empty_title(self): - note = Note(title="", body="some body") - content = note.to_file_content() - restored = Note.from_file_content(content) - assert restored.title == "" - assert restored.body == "some body" - - def test_unicode(self): - note = Note(title="日本語タイトル", body="Ünïcödé bödý 🎉") - content = note.to_file_content() - restored = Note.from_file_content(content) - assert restored.title == "日本語タイトル" - assert restored.body == "Ünïcödé bödý 🎉" - - def test_multiline_body(self): - body = "Line 1\nLine 2\n\nLine 4\n" - note = Note(title="Multi", body=body) - content = note.to_file_content() - restored = Note.from_file_content(content) - assert restored.body == body - - def test_file_content_format(self): - """Title on line 1, blank line, then body.""" - note = Note(title="Title", body="Body text") - content = note.to_file_content() - assert content == "Title\n\nBody text" - - def test_from_file_content_no_blank_line(self): - """Gracefully handle files without a blank separator.""" - restored = Note.from_file_content("JustTitle") - assert restored.title == "JustTitle" - assert restored.body == "" - - -class TestNoteFilename: - def test_generate_filename(self): - note = Note(title="Test", body="") - filename = note.generate_filename(order=1) - assert filename.startswith("0001-") - assert filename.endswith(".txt") - # Format: 0001-YYYYMMDD-HHMMSS-shortid.txt - parts = filename.split("-") - assert len(parts) == 4 - assert len(parts[0]) == 4 # order - assert len(parts[1]) == 8 # date - # parts[2] = HHMMSS + shortid.txt combined via split on - - # Actually: 0001-20260225-143012-abc12.txt has 4 parts - - def test_parse_order_from_filename(self): - assert Note.parse_order("0005-20260101-120000-abc12.txt") == 5 - assert Note.parse_order("0001-20260101-120000-xyz99.txt") == 1 diff --git a/pocketbook/tests/test_panel.py b/pocketbook/tests/test_panel.py deleted file mode 100644 index 92f8648..0000000 --- a/pocketbook/tests/test_panel.py +++ /dev/null @@ -1,40 +0,0 @@ -from unittest.mock import MagicMock -from pocketbook.note import Note - - -class TestPanelController: - """Test panel controller logic with a mocked store.""" - - def _make_controller(self): - from pocketbook.panel import PanelController - store = MagicMock() - controller = PanelController(store) - return controller, store - - def test_add_note_calls_store_create(self): - controller, store = self._make_controller() - store.create.return_value = "0001-20260101-120000-abc12.txt" - controller.add_note() - store.create.assert_called_once_with("New Note", "") - - def test_delete_note_calls_store_delete(self): - controller, store = self._make_controller() - controller.delete_note("0001-20260101-120000-abc12.txt") - store.delete.assert_called_once_with("0001-20260101-120000-abc12.txt") - - def test_update_note_calls_store_update(self): - controller, store = self._make_controller() - controller.update_note("0001-20260101-120000-abc12.txt", "New Title", "New Body") - store.update.assert_called_once_with( - "0001-20260101-120000-abc12.txt", "New Title", "New Body" - ) - - def test_get_notes_calls_store_list(self): - controller, store = self._make_controller() - store.list_notes.return_value = [ - ("0001-20260101-120000-abc12.txt", Note(title="A", body="a")), - ] - notes = controller.get_notes() - store.list_notes.assert_called_once() - assert len(notes) == 1 - assert notes[0][1].title == "A" diff --git a/pocketbook/tests/test_store.py b/pocketbook/tests/test_store.py deleted file mode 100644 index fab5bd6..0000000 --- a/pocketbook/tests/test_store.py +++ /dev/null @@ -1,103 +0,0 @@ -import pytest -from pocketbook.store import NoteStore -from pocketbook.note import Note - - -class TestNoteStoreCreate: - def test_create_note(self, notes_dir): - store = NoteStore(notes_dir) - filename = store.create("My Title", "My Body") - assert (notes_dir / filename).exists() - content = (notes_dir / filename).read_text() - assert content == "My Title\n\nMy Body" - - def test_create_assigns_incrementing_order(self, notes_dir): - store = NoteStore(notes_dir) - f1 = store.create("First", "") - f2 = store.create("Second", "") - assert Note.parse_order(f1) == 1 - assert Note.parse_order(f2) == 2 - - def test_create_auto_creates_directory(self, tmp_path): - d = tmp_path / "nonexistent" / "pocketbook" - store = NoteStore(d) - filename = store.create("Test", "body") - assert d.exists() - assert (d / filename).exists() - - -class TestNoteStoreList: - def test_list_empty(self, notes_dir): - # Remove the sample file if any fixture created one - for f in notes_dir.iterdir(): - f.unlink() - store = NoteStore(notes_dir) - assert store.list_notes() == [] - - def test_list_returns_sorted_by_order(self, notes_dir): - (notes_dir / "0002-20260101-120000-abc12.txt").write_text("B\n\nbody b") - (notes_dir / "0001-20260101-120000-def34.txt").write_text("A\n\nbody a") - (notes_dir / "0003-20260101-120000-ghi56.txt").write_text("C\n\nbody c") - store = NoteStore(notes_dir) - notes = store.list_notes() - assert len(notes) == 3 - assert notes[0][1].title == "A" - assert notes[1][1].title == "B" - assert notes[2][1].title == "C" - - def test_list_skips_non_txt_files(self, notes_dir): - (notes_dir / "0001-20260101-120000-abc12.txt").write_text("Note\n\nbody") - (notes_dir / "README.md").write_text("not a note") - store = NoteStore(notes_dir) - assert len(store.list_notes()) == 1 - - def test_list_skips_corrupted_filenames(self, notes_dir): - (notes_dir / "0001-20260101-120000-abc12.txt").write_text("Good\n\nbody") - (notes_dir / "bad-name.txt").write_text("Bad\n\nbody") - store = NoteStore(notes_dir) - notes = store.list_notes() - assert len(notes) == 1 - assert notes[0][1].title == "Good" - - -class TestNoteStoreUpdate: - def test_update_note(self, notes_dir): - fname = "0001-20260101-120000-abc12.txt" - (notes_dir / fname).write_text("Old Title\n\nOld body") - store = NoteStore(notes_dir) - store.update(fname, "New Title", "New body") - content = (notes_dir / fname).read_text() - assert content == "New Title\n\nNew body" - - def test_update_nonexistent_raises(self, notes_dir): - store = NoteStore(notes_dir) - with pytest.raises(FileNotFoundError): - store.update("0099-20260101-120000-nope0.txt", "T", "B") - - -class TestNoteStoreDelete: - def test_delete_note(self, notes_dir): - fname = "0001-20260101-120000-abc12.txt" - (notes_dir / fname).write_text("Delete me\n\nbody") - store = NoteStore(notes_dir) - store.delete(fname) - assert not (notes_dir / fname).exists() - - def test_delete_nonexistent_raises(self, notes_dir): - store = NoteStore(notes_dir) - with pytest.raises(FileNotFoundError): - store.delete("0099-20260101-120000-nope0.txt") - - -class TestNoteStoreReorder: - def test_reorder_renumbers_files(self, notes_dir): - # Create files with gaps in ordering - (notes_dir / "0001-20260101-120000-aaa11.txt").write_text("A\n\na") - (notes_dir / "0005-20260101-120000-bbb22.txt").write_text("B\n\nb") - (notes_dir / "0010-20260101-120000-ccc33.txt").write_text("C\n\nc") - store = NoteStore(notes_dir) - store.reorder() - notes = store.list_notes() - assert Note.parse_order(notes[0][0]) == 1 - assert Note.parse_order(notes[1][0]) == 2 - assert Note.parse_order(notes[2][0]) == 3 diff --git a/scripts/import-wireguard-configs.sh b/scripts/import-wireguard-configs.sh new file mode 100755 index 0000000..ae6ca7e --- /dev/null +++ b/scripts/import-wireguard-configs.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Import the assets/wireguard-config Proton configs into NetworkManager as +# wireguard connections with autoconnect off. Two NM quirks handled here: +# +# - The import filename must be a valid interface name (<= 15 chars), and +# several config names are longer — so every file imports through a temp +# copy named wgpvpn.conf and the connection is renamed to the real config +# name right after (by the UUID parsed from the import output, so a stray +# same-named connection can't be hit). All profiles share the wgpvpn +# interface, which is fine (they're mutually exclusive full-tunnel +# configs), and the wg prefix keeps the net doctor's tunnel-down repair +# on the NM path. +# - Imports default to autoconnect yes, and these are full-tunnel +# (AllowedIPs 0.0.0.0/0) — a VPN that arms itself on boot is not a default +# anyone chose, so the modify runs immediately after each import. +# +# A connection still literally named wgpvpn means an earlier run died +# between import and rename — and it still has autoconnect on. The script +# refuses to run until that's cleaned up rather than guessing. +# +# Idempotent: already-imported names skip. +# +# Usage: import-wireguard-configs.sh [config-dir] +set -euo pipefail + +dir="${1:-$(cd "$(dirname "$0")/.." && pwd)/assets/wireguard-config}" +[ -d "$dir" ] || { echo "no such config dir: $dir" >&2; exit 1; } + +if nmcli -t -f NAME connection show | grep -Fxq "wgpvpn"; then + echo "stale 'wgpvpn' connection found (an earlier run died mid-import; it has autoconnect ON)" >&2 + echo "inspect and remove it first: nmcli connection delete wgpvpn" >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +shopt -s nullglob +found=0 +for conf in "$dir"/*.conf; do + found=1 + name="$(basename "$conf" .conf)" + if nmcli -t -f NAME connection show | grep -Fxq "$name"; then + echo "skip: $name (already imported)" + continue + fi + cp "$conf" "$tmp/wgpvpn.conf" + out="$(nmcli connection import type wireguard file "$tmp/wgpvpn.conf")" + uuid="$(grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' <<<"$out" | head -1 || true)" + if [ -z "$uuid" ]; then + echo "could not parse a UUID from the import output for $name:" >&2 + echo " $out" >&2 + exit 1 + fi + nmcli connection modify "$uuid" connection.id "$name" \ + connection.autoconnect no + echo "imported: $name (autoconnect off, iface wgpvpn)" +done +[ "$found" = 1 ] || { echo "no .conf files in $dir" >&2; exit 1; } diff --git a/scripts/testing/tests/test_boot.py b/scripts/testing/tests/test_boot.py index 78b4404..e442682 100644 --- a/scripts/testing/tests/test_boot.py +++ b/scripts/testing/tests/test_boot.py @@ -65,3 +65,19 @@ def test_zfs_has_sanoid(host): if not host.exists("zfs"): pytest.skip("ZFS not installed (non-ZFS system)") assert host.exists("sanoid"), "ZFS system should have sanoid installed" + + +def test_zfs_pre_pacman_snapshot_hook(host): + # archsetup installs a PreTransaction pacman hook + a self-pruning script so + # every pacman transaction is preceded by a rollback snapshot (configure_ + # pre_pacman_snapshots, run late in boot_ux). ZFS-root only. + if not host.exists("zfs"): + pytest.skip("ZFS not installed (non-ZFS system)") + script = host.file("/usr/local/bin/zfs-pre-snapshot") + assert script.exists and script.is_file, "pre-pacman snapshot script missing" + assert script.mode & 0o111, "pre-pacman snapshot script is not executable" + hook = host.file("/etc/pacman.d/hooks/zfs-snapshot.hook") + assert hook.exists and hook.is_file, "zfs-snapshot.hook missing" + assert "PreTransaction" in hook.content_string, "hook not PreTransaction" + assert "/usr/local/bin/zfs-pre-snapshot" in hook.content_string, \ + "hook does not exec the snapshot script" diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py index c02d2b6..6f79bfd 100644 --- a/scripts/testing/tests/test_desktop.py +++ b/scripts/testing/tests/test_desktop.py @@ -109,3 +109,32 @@ def test_autologin_configured(host): if not conf.exists: pytest.skip("autologin not configured (AUTOLOGIN=no, may be intentional)") assert conf.exists + + +BT_PANEL_BINS = ["bt", "bt-panel", "bt-priv", "waybar-bt"] + + +@pytest.mark.attribution("archsetup") +@pytest.mark.parametrize("name", BT_PANEL_BINS) +def test_bt_panel_bin_stowed(host, hyprland_installed, home, name): + # Executable via either stow shape (per-file symlink or folded dir). + if not hyprland_installed: + pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)") + path = "%s/.local/bin/%s" % (home, name) + assert host.file(path).exists, "%s missing from ~/.local/bin" % name + assert host.run("test -x %s" % path).rc == 0, "%s not executable" % name + + +@pytest.mark.attribution("archsetup") +def test_bt_panel_wired(host, hyprland_installed, home): + # A fresh install lands the panel reachable: bar module, keybind, css. + if not hyprland_installed: + pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)") + waybar = host.file("%s/.config/waybar/config" % home) + assert "custom/bluetooth" in waybar.content_string, \ + "waybar config lacks the custom/bluetooth module" + hyprconf = host.file("%s/.config/hypr/hyprland.conf" % home) + assert "bt-panel" in hyprconf.content_string, \ + "hyprland.conf lacks the bt-panel keybind" + assert host.file("%s/.config/themes/dupre/panel.css" % home).exists, \ + "shared panel css missing from the stowed theme" diff --git a/scripts/testing/tests/test_packages.py b/scripts/testing/tests/test_packages.py index f237088..e0387d6 100644 --- a/scripts/testing/tests/test_packages.py +++ b/scripts/testing/tests/test_packages.py @@ -58,3 +58,52 @@ def test_git_installed(host): @pytest.mark.parametrize("tool", DEV_TOOLS) def test_dev_tool_present(host, tool): assert host.exists(tool), "dev tool %s missing from PATH" % tool + + +BLUETOOTH_STACK = ["bluez", "bluez-utils"] +VPN_STACK = ["wireguard-tools", "proton-vpn-cli", "tailscale"] + + +@pytest.mark.attribution("archsetup") +@pytest.mark.parametrize("pkg", BLUETOOTH_STACK) +def test_bluetooth_stack_installed(host, pkg): + assert host.package(pkg).is_installed + + +# bt panel replaced blueman; zoom-web replaced zoom; the net panel's Tunnels +# view + proton-vpn-cli replaced the GTK app (they can't run concurrently). +RETIRED_PACKAGES = ["blueman", "zoom", "proton-vpn-gtk-app"] + + +@pytest.mark.attribution("archsetup") +@pytest.mark.parametrize("pkg", RETIRED_PACKAGES) +def test_retired_package_not_installed(host, pkg): + # A reappearance means an install step regressed. + assert not host.package(pkg).is_installed + + +@pytest.mark.attribution("archsetup") +@pytest.mark.parametrize("pkg", VPN_STACK) +def test_vpn_stack_installed(host, pkg): + assert host.package(pkg).is_installed + + +@pytest.mark.attribution("archsetup") +def test_tailscale_operator_granted(host, target_user): + # The installer grants operator so the net panel can toggle tailscale + # without sudo. Prefs only answer when the daemon is up. + if not host.service("tailscaled").is_running: + pytest.skip("tailscaled not running") + out = host.run("tailscale debug prefs") + assert out.rc == 0, "tailscale debug prefs failed" + assert '"OperatorUser": "%s"' % target_user in out.stdout + + +@pytest.mark.attribution("archsetup") +def test_eask_installed_user_local(host, home): + # Installed via npm -g --prefix ~/.local as the user; chime and + # linear-emacs shell out to it. + f = host.file("%s/.local/bin/eask" % home) + assert f.exists, "eask missing from ~/.local/bin" + npmrc = host.file("%s/.npmrc" % home) + assert npmrc.exists, ".npmrc (user npm prefix) not stowed" diff --git a/scripts/zfs-pre-snapshot b/scripts/zfs-pre-snapshot new file mode 100755 index 0000000..ed914d0 --- /dev/null +++ b/scripts/zfs-pre-snapshot @@ -0,0 +1,43 @@ +#!/bin/bash +# Snapshot the root dataset before a pacman transaction, then prune to the most +# recent $KEEP pre-pacman snapshots. Run from the zfs-snapshot.hook pacman hook +# (PreTransaction). Sanoid doesn't manage these (they aren't autosnap_ names), +# so retention is enforced here at creation time. +# +# Defaults match the live zroot layout; the ZFS_PRE_* env vars override them so +# the pruning logic is unit-testable against a fake zfs on PATH. + +POOL="${ZFS_PRE_POOL:-zroot}" +DATASET="${ZFS_PRE_DATASET:-$POOL/ROOT/default}" +LOCKFILE="${ZFS_PRE_LOCKFILE:-/tmp/.zfs-pre-snapshot.lock}" +MIN_INTERVAL="${ZFS_PRE_MIN_INTERVAL:-60}" +KEEP="${ZFS_PRE_KEEP:-10}" # pre-pacman snapshots to retain (recent-transaction rollback) + +# Skip if a snapshot was created within the last $MIN_INTERVAL seconds. A single +# pacman invocation can fire several transactions; this stops a burst of them +# from each cutting a near-identical snapshot. +if [ -f "$LOCKFILE" ]; then + last=$(stat -c %Y "$LOCKFILE" 2>/dev/null || echo 0) + now=$(date +%s) + if (( now - last < MIN_INTERVAL )); then + exit 0 + fi +fi + +TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) +SNAPSHOT_NAME="pre-pacman_$TIMESTAMP" + +if zfs snapshot "$DATASET@$SNAPSHOT_NAME"; then + echo "Created snapshot: $DATASET@$SNAPSHOT_NAME" + touch "$LOCKFILE" + + # Keep only the most recent $KEEP pre-pacman snapshots; destroy older ones. + zfs list -H -o name -t snapshot -s creation "$DATASET" 2>/dev/null \ + | grep '@pre-pacman_' \ + | head -n -"$KEEP" \ + | while read -r old; do + zfs destroy "$old" && echo "Pruned old snapshot: $old" + done +else + echo "Warning: Failed to create snapshot" >&2 +fi diff --git a/tests/import-wireguard-configs/fake-nmcli b/tests/import-wireguard-configs/fake-nmcli new file mode 100644 index 0000000..45b88cd --- /dev/null +++ b/tests/import-wireguard-configs/fake-nmcli @@ -0,0 +1,45 @@ +#!/bin/bash +# Fake nmcli for the import-wireguard-configs tests. +# +# Behavior is driven by env vars set by the test harness: +# FAKE_NMCLI_LOG file every invocation's args are appended to (one line +# per call; for imports the staged file's basename and +# content hash context are visible in the args) +# FAKE_NMCLI_NAMES newline-separated connection names returned by +# `nmcli -t -f NAME connection show` +# FAKE_NMCLI_IMPORT_OUT override for the import command's stdout +# (default: the real NM success line with a per-call +# deterministic UUID) +# FAKE_NMCLI_MODIFY_RC exit code for `nmcli connection modify` (default 0) +# +# Import calls also copy the staged file into $FAKE_NMCLI_LOG.d/ so tests can +# assert the temp copy was named wgpvpn.conf and carried the right content. +set -euo pipefail + +echo "$*" >>"$FAKE_NMCLI_LOG" + +case "$1 $2" in +"-t -f") + # nmcli -t -f NAME connection show + printf '%s\n' "${FAKE_NMCLI_NAMES:-}" + ;; +"connection import") + # nmcli connection import type wireguard file <path> + file="${6:?}" + mkdir -p "$FAKE_NMCLI_LOG.d" + n=$(find "$FAKE_NMCLI_LOG.d" -type f | wc -l) + cp "$file" "$FAKE_NMCLI_LOG.d/import-$n-$(basename "$file")" + if [ -n "${FAKE_NMCLI_IMPORT_OUT:-}" ]; then + echo "$FAKE_NMCLI_IMPORT_OUT" + else + printf "Connection 'wgpvpn' (%08d-aaaa-bbbb-cccc-dddddddddddd) successfully added.\n" "$n" + fi + ;; +"connection modify") + exit "${FAKE_NMCLI_MODIFY_RC:-0}" + ;; +*) + echo "fake-nmcli: unexpected args: $*" >&2 + exit 99 + ;; +esac diff --git a/tests/import-wireguard-configs/test_import_wireguard_configs.py b/tests/import-wireguard-configs/test_import_wireguard_configs.py new file mode 100644 index 0000000..0307041 --- /dev/null +++ b/tests/import-wireguard-configs/test_import_wireguard_configs.py @@ -0,0 +1,167 @@ +"""Tests for the import-wireguard-configs.sh one-time migration script. + +The script imports every assets/wireguard-config/*.conf into NetworkManager +as a wireguard connection with autoconnect forced off. NM quirks under test: +the import filename must be a valid interface name (<= 15 chars), so every +config stages through a temp copy named wgpvpn.conf and is renamed to the +real config name immediately after import — by the UUID parsed from the +import output, never by the transient wgpvpn name. A leftover connection +literally named wgpvpn (an earlier run died between import and rename, so +it still has autoconnect on) makes the script refuse to run. + +nmcli is faked via a stub on PATH (fake-nmcli in this directory) that logs +every invocation and snapshots the staged import file. + +Run from repo root: + python3 -m unittest tests.import-wireguard-configs.test_import_wireguard_configs +""" + +import os +import shutil +import subprocess +import tempfile +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SCRIPT = os.path.join(REPO_ROOT, "scripts", "import-wireguard-configs.sh") +FAKE_NMCLI = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fake-nmcli") + + +class ImportWireguardConfigs(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="import-wg-test-") + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.confdir = os.path.join(self.tmp, "configs") + os.mkdir(self.confdir) + self.bindir = os.path.join(self.tmp, "bin") + os.mkdir(self.bindir) + shutil.copy(FAKE_NMCLI, os.path.join(self.bindir, "nmcli")) + os.chmod(os.path.join(self.bindir, "nmcli"), 0o755) + self.log = os.path.join(self.tmp, "nmcli.log") + + def write_conf(self, name, body="[Interface]\nPrivateKey = k\n"): + path = os.path.join(self.confdir, name + ".conf") + with open(path, "w") as f: + f.write(body) + return path + + def run_script(self, confdir=None, names="", env_extra=None): + env = dict(os.environ) + env["PATH"] = self.bindir + os.pathsep + env["PATH"] + env["FAKE_NMCLI_LOG"] = self.log + env["FAKE_NMCLI_NAMES"] = names + if env_extra: + env.update(env_extra) + return subprocess.run( + ["bash", SCRIPT, confdir or self.confdir], + capture_output=True, text=True, timeout=10, env=env, + ) + + def log_lines(self): + if not os.path.exists(self.log): + return [] + with open(self.log) as f: + return [ln.strip() for ln in f if ln.strip()] + + # --- Normal cases ---------------------------------------------------- + + def test_imports_every_conf_with_autoconnect_off(self): + self.write_conf("USNY") + self.write_conf("USDC") + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stderr) + modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")] + self.assertEqual(len(modifies), 2) + for ln in modifies: + self.assertIn("connection.autoconnect no", ln) + self.assertIn("imported: USDC", r.stdout) + self.assertIn("imported: USNY", r.stdout) + + def test_renames_by_uuid_from_import_output_not_by_name(self): + self.write_conf("USNY") + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stderr) + modify = [ln for ln in self.log_lines() if ln.startswith("connection modify")][0] + # The modify targets the UUID the import printed, and never the + # transient wgpvpn name. + self.assertIn("00000000-aaaa-bbbb-cccc-dddddddddddd", modify) + self.assertIn("connection.id USNY", modify) + self.assertNotIn("modify wgpvpn", modify) + + def test_long_name_stages_through_wgpvpn_temp_copy(self): + # switzerlan-zurich1 is 18 chars — over NM's 15-char interface-name + # limit, the reason the staging copy exists at all. + body = "[Interface]\nPrivateKey = long-name-key\n" + self.write_conf("switzerlan-zurich1", body) + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stderr) + staged = os.listdir(self.log + ".d") + self.assertEqual(len(staged), 1) + self.assertTrue(staged[0].endswith("wgpvpn.conf"), staged) + with open(os.path.join(self.log + ".d", staged[0])) as f: + self.assertEqual(f.read(), body) + self.assertIn("imported: switzerlan-zurich1", r.stdout) + + # --- Idempotence ----------------------------------------------------- + + def test_already_imported_names_skip(self): + self.write_conf("USNY") + self.write_conf("USDC") + r = self.run_script(names="USNY\nsome-wifi") + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("skip: USNY", r.stdout) + self.assertIn("imported: USDC", r.stdout) + modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")] + self.assertEqual(len(modifies), 1) + + def test_all_imported_is_a_clean_noop(self): + self.write_conf("USNY") + r = self.run_script(names="USNY") + self.assertEqual(r.returncode, 0, r.stderr) + imports = [ln for ln in self.log_lines() if ln.startswith("connection import")] + self.assertEqual(imports, []) + + # --- Boundary cases -------------------------------------------------- + + def test_empty_config_dir_fails_loudly(self): + r = self.run_script() + self.assertEqual(r.returncode, 1) + self.assertIn("no .conf files", r.stderr) + + def test_missing_config_dir_fails_loudly(self): + r = self.run_script(confdir=os.path.join(self.tmp, "nope")) + self.assertEqual(r.returncode, 1) + self.assertIn("no such config dir", r.stderr) + + # --- Error cases ----------------------------------------------------- + + def test_stale_wgpvpn_connection_refuses_to_run(self): + self.write_conf("USNY") + r = self.run_script(names="wgpvpn\nUSDC") + self.assertEqual(r.returncode, 1) + self.assertIn("stale", r.stderr) + self.assertIn("nmcli connection delete wgpvpn", r.stderr) + imports = [ln for ln in self.log_lines() if ln.startswith("connection import")] + self.assertEqual(imports, []) + + def test_unparseable_import_output_aborts(self): + self.write_conf("USNY") + r = self.run_script(env_extra={"FAKE_NMCLI_IMPORT_OUT": "something unexpected"}) + self.assertEqual(r.returncode, 1) + self.assertIn("could not parse a UUID", r.stderr) + modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")] + self.assertEqual(modifies, []) + + def test_modify_failure_aborts_the_run(self): + self.write_conf("USNY") + self.write_conf("USDC") + r = self.run_script(env_extra={"FAKE_NMCLI_MODIFY_RC": "4"}) + self.assertNotEqual(r.returncode, 0) + # set -e stops at the first failed modify — only one import attempted. + imports = [ln for ln in self.log_lines() if ln.startswith("connection import")] + self.assertEqual(len(imports), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py index e62c198..48b7508 100644 --- a/tests/installer-steps/test_orchestrators.py +++ b/tests/installer-steps/test_orchestrators.py @@ -46,6 +46,7 @@ ORCHESTRATORS = { "tighten_efi_permissions", "add_nvme_early_module", "configure_initramfs_hook", "configure_encrypted_autologin", "configure_tlp_power", "trim_firmware", "configure_grub", + "configure_pre_pacman_snapshots", ], "user_customizations": [ "clone_user_repos", "stow_dotfiles", "prune_waybar_battery", diff --git a/tests/nvidia-preflight/test_nvidia_preflight.py b/tests/nvidia-preflight/test_nvidia_preflight.py new file mode 100644 index 0000000..bdacfd5 --- /dev/null +++ b/tests/nvidia-preflight/test_nvidia_preflight.py @@ -0,0 +1,162 @@ +"""Tests for the nvidia_preflight_report helper in the archsetup installer. + +nvidia_preflight_report is the pure core of the NVIDIA/Wayland preflight +check: it scans DRM (then PCI display-class) modalias files for the NVIDIA +vendor id, and when one matches it prints the Wayland warning + required +environment variables and checks the repo's candidate nvidia-utils major +version. Return codes: 0 = no NVIDIA GPU, 10 = NVIDIA and the driver +requirement (535+) is met, 11 = NVIDIA and the requirement is not met +(driver too old or unknown). The interactive continue/abort prompt lives in +preflight_checks, not here, so this core is unit testable. + +These tests exercise the REAL function body, extracted from the `archsetup` +script at run time (not a copy), against temp modalias trees and a fake +pacman on PATH. + +Run from repo root: + python3 -m unittest tests.nvidia-preflight.test_nvidia_preflight +""" + +import os +import shutil +import subprocess +import tempfile +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +NVIDIA_MODALIAS = "pci:v000010DEd00002684sv00001043sd000088E2bc03sc00i00" +NVIDIA_MODALIAS_LOWER = "pci:v000010ded00002684sv00001043sd000088e2bc03sc00i00" +AMD_MODALIAS = "pci:v00001002d0000164Esv00001462sd00007D78bc03sc80i00" +NON_DISPLAY_NVIDIA = "pci:v000010DEd00002684sv00001043sd000088E2bc0Csc03i30" + + +class NvidiaPreflightHarness(unittest.TestCase): + """Source nvidia_preflight_report out of the real archsetup script.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="nvidia-preflight-test-") + self.drm = os.path.join(self.tmp, "drm") + self.pci = os.path.join(self.tmp, "pci") + os.makedirs(self.drm) + os.makedirs(self.pci) + self.fakebin = os.path.join(self.tmp, "bin") + os.makedirs(self.fakebin) + self.wrapper = os.path.join(self.tmp, "run.sh") + with open(self.wrapper, "w") as f: + f.write( + "#!/bin/bash\n" + 'ARCHSETUP="$1"; shift\n' + "source <(sed -n " + "'/^nvidia_preflight_report() {/,/^}/p' \"$ARCHSETUP\")\n" + "nvidia_preflight_report\n" + ) + os.chmod(self.wrapper, 0o755) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def fake_pacman(self, version=None, fail=False): + """A pacman stub answering `pacman -Si nvidia-utils`.""" + path = os.path.join(self.fakebin, "pacman") + with open(path, "w") as f: + if fail: + f.write("#!/bin/sh\nexit 1\n") + else: + f.write( + "#!/bin/sh\n" + "printf 'Repository : extra\\n'\n" + "printf 'Name : nvidia-utils\\n'\n" + "printf 'Version : %s\\n'\n" % version + ) + os.chmod(path, 0o755) + + def add_modalias(self, root, subdir, content): + d = os.path.join(root, subdir) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, "modalias"), "w") as f: + f.write(content + "\n") + + def run_check(self): + env = dict(os.environ) + env["PATH"] = self.fakebin + os.pathsep + env["PATH"] + env["NVIDIA_DRM_GLOB"] = os.path.join(self.drm, "card*", "modalias") + env["NVIDIA_PCI_GLOB"] = os.path.join(self.pci, "*", "modalias") + return subprocess.run( + ["bash", self.wrapper, ARCHSETUP], + capture_output=True, text=True, env=env, + ) + + # ---------------------------------------------------------- normal ---- + def test_no_gpu_files_returns_zero_and_silent(self): + self.fake_pacman(version="575.51.02-1") + r = self.run_check() + self.assertEqual(r.returncode, 0) + self.assertNotIn("NVIDIA", r.stdout) + + def test_amd_only_returns_zero(self): + self.fake_pacman(version="575.51.02-1") + self.add_modalias(self.drm, "card0", AMD_MODALIAS) + r = self.run_check() + self.assertEqual(r.returncode, 0) + self.assertNotIn("NVIDIA", r.stdout) + + def test_nvidia_with_modern_driver_returns_ten_with_guidance(self): + self.fake_pacman(version="575.51.02-1") + self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS) + r = self.run_check() + self.assertEqual(r.returncode, 10) + self.assertIn("NVIDIA GPU detected", r.stdout) + self.assertIn("LIBVA_DRIVER_NAME=nvidia", r.stdout) + self.assertIn("GBM_BACKEND=nvidia-drm", r.stdout) + self.assertIn("__GLX_VENDOR_LIBRARY_NAME=nvidia", r.stdout) + self.assertIn("575.51.02-1", r.stdout) + + # -------------------------------------------------------- boundary ---- + def test_lowercase_vendor_id_detected(self): + self.fake_pacman(version="575.51.02-1") + self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS_LOWER) + r = self.run_check() + self.assertEqual(r.returncode, 10) + + def test_exactly_535_meets_requirement(self): + self.fake_pacman(version="535.216.01-1") + self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS) + r = self.run_check() + self.assertEqual(r.returncode, 10) + + def test_pci_fallback_display_class_only(self): + # No DRM entries; PCI holds a display-class NVIDIA device -> detected. + self.fake_pacman(version="575.51.02-1") + self.add_modalias(self.pci, "0000:01:00.0", NVIDIA_MODALIAS) + r = self.run_check() + self.assertEqual(r.returncode, 10) + + def test_pci_non_display_nvidia_ignored(self): + # An NVIDIA audio/usb function (bc0C) must not trigger the check. + self.fake_pacman(version="575.51.02-1") + self.add_modalias(self.pci, "0000:01:00.1", NON_DISPLAY_NVIDIA) + r = self.run_check() + self.assertEqual(r.returncode, 0) + + # ----------------------------------------------------------- error ---- + def test_old_driver_returns_eleven_with_error(self): + self.fake_pacman(version="470.256.02-1") + self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS) + r = self.run_check() + self.assertEqual(r.returncode, 11) + self.assertIn("535", r.stdout) + self.assertIn("470.256.02-1", r.stdout) + + def test_pacman_failure_returns_eleven_unknown(self): + self.fake_pacman(fail=True) + self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS) + r = self.run_check() + self.assertEqual(r.returncode, 11) + self.assertIn("unknown", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/zfs-pre-snapshot/fake-zfs b/tests/zfs-pre-snapshot/fake-zfs new file mode 100755 index 0000000..508c0f3 --- /dev/null +++ b/tests/zfs-pre-snapshot/fake-zfs @@ -0,0 +1,14 @@ +#!/bin/sh +# Fake zfs for the zfs-pre-snapshot unit test. `snapshot` and `destroy` are +# logged (FAKE_ZFS_LOG); `list` prints a fixture snapshot set (FAKE_ZFS_SNAPSHOTS). +# Set FAKE_ZFS_SNAPSHOT_FAIL to make snapshot creation fail. +case "$1" in + snapshot) + [ -n "$FAKE_ZFS_SNAPSHOT_FAIL" ] && exit 1 + echo "snapshot $2" >> "$FAKE_ZFS_LOG"; exit 0 ;; + destroy) + echo "destroy $2" >> "$FAKE_ZFS_LOG"; exit 0 ;; + list) + cat "$FAKE_ZFS_SNAPSHOTS" 2>/dev/null; exit 0 ;; +esac +exit 0 diff --git a/tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py b/tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py new file mode 100644 index 0000000..ed7731b --- /dev/null +++ b/tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py @@ -0,0 +1,116 @@ +"""Unit tests for scripts/zfs-pre-snapshot. + +The script snapshots the root dataset before a pacman transaction and prunes to +the most recent KEEP pre-pacman snapshots. These tests drive the real script +with a fake zfs on PATH (snapshot/destroy logged, list returns a fixture set) +and env-rooted state, so nothing touches a real pool. +""" + +import os +import shutil +import subprocess +import tempfile +import time +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SCRIPT = os.path.join(REPO_ROOT, "scripts/zfs-pre-snapshot") +FAKE_ZFS = os.path.join(os.path.dirname(__file__), "fake-zfs") + +DATASET = "tank/test" +# Five pre-pacman snapshots oldest->newest (zfs list -s creation is ascending), +# plus one autosnap that the grep filter must ignore. +SNAPSHOTS = "\n".join([ + f"{DATASET}@autosnap_2026-01-01", + f"{DATASET}@pre-pacman_2026-06-01", + f"{DATASET}@pre-pacman_2026-06-02", + f"{DATASET}@pre-pacman_2026-06-03", + f"{DATASET}@pre-pacman_2026-06-04", + f"{DATASET}@pre-pacman_2026-06-05", +]) + "\n" + + +class Harness(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="zfs-pre-snap-") + self.bin = os.path.join(self.tmp, "bin") + os.makedirs(self.bin) + shutil.copy(FAKE_ZFS, os.path.join(self.bin, "zfs")) + self.log = os.path.join(self.tmp, "zfs.log") + self.snaps = os.path.join(self.tmp, "snaps") + with open(self.snaps, "w") as f: + f.write(SNAPSHOTS) + self.lock = os.path.join(self.tmp, "lock") + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def run_script(self, keep="3", fail=False, snaps=None): + env = os.environ.copy() + env["PATH"] = self.bin + os.pathsep + env["PATH"] + env["ZFS_PRE_DATASET"] = DATASET + env["ZFS_PRE_LOCKFILE"] = self.lock + env["ZFS_PRE_KEEP"] = keep + env["FAKE_ZFS_LOG"] = self.log + env["FAKE_ZFS_SNAPSHOTS"] = snaps if snaps is not None else self.snaps + if fail: + env["FAKE_ZFS_SNAPSHOT_FAIL"] = "1" + return subprocess.run([SCRIPT], env=env, capture_output=True, text=True, + timeout=15) + + def log_lines(self): + try: + with open(self.log) as f: + return [ln for ln in f.read().splitlines() if ln.strip()] + except FileNotFoundError: + return [] + + +class TestSnapshot(Harness): + def test_creates_a_pre_pacman_snapshot(self): + self.run_script() + snaps = [ln for ln in self.log_lines() if ln.startswith("snapshot ")] + self.assertEqual(len(snaps), 1) + self.assertIn(f"snapshot {DATASET}@pre-pacman_", snaps[0]) + + def test_skips_when_lockfile_is_fresh(self): + # A lockfile newer than MIN_INTERVAL → no snapshot this run. + open(self.lock, "w").close() + os.utime(self.lock, (time.time(), time.time())) + self.run_script() + self.assertEqual([ln for ln in self.log_lines() + if ln.startswith("snapshot ")], []) + + +class TestPrune(Harness): + def test_prunes_oldest_beyond_keep(self): + # 5 pre-pacman snapshots, KEEP=3 → the two oldest are destroyed. + self.run_script(keep="3") + destroyed = [ln.split(" ", 1)[1] for ln in self.log_lines() + if ln.startswith("destroy ")] + self.assertEqual(destroyed, + [f"{DATASET}@pre-pacman_2026-06-01", + f"{DATASET}@pre-pacman_2026-06-02"]) + + def test_never_destroys_non_pre_pacman_snapshots(self): + self.run_script(keep="1") + destroyed = [ln for ln in self.log_lines() if ln.startswith("destroy ")] + self.assertFalse(any("autosnap" in ln for ln in destroyed)) + + def test_no_prune_when_at_or_under_keep(self): + # KEEP=5 with exactly 5 pre-pacman snapshots → nothing destroyed. + self.run_script(keep="5") + self.assertEqual([ln for ln in self.log_lines() + if ln.startswith("destroy ")], []) + + +class TestError(Harness): + def test_snapshot_failure_skips_prune_and_warns(self): + r = self.run_script(fail=True) + self.assertIn("Failed to create snapshot", r.stderr) + self.assertEqual([ln for ln in self.log_lines() + if ln.startswith("destroy ")], []) + + +if __name__ == "__main__": + unittest.main() @@ -21,89 +21,186 @@ The vocabulary is open — topic tags are coined as needed — so these are conv - *Effort / autonomy*: =:quick:= a spare-moment fix (minutes, not a sitting); =:solo:= Claude can carry it end to end — there's a build path, a test path, and no upfront decision needed (a leftover manual spot-check doesn't disqualify it). - *Topic / area* (open): the subsystem a task touches — e.g. =:hyprland:= =:waybar:= =:mpd:= =:music:= =:network:= =:tooling:= =:llm:= =:eask:= =:pocketbook:= =:cmail:=. Coin a new one when it aids filtering. * Archsetup Open Work -** TODO [#B] Scrolling/Carousel layout: frame fit + wrap-around :hyprland: -:PROPERTIES: -:LAST_REVIEWED: 2026-06-13 -:END: -Disabled 2026-06-12 (bind and cycle entry points removed; Super+Shift+S reassigned to whole-desktop screenshot). The layout needs real work before it earns its chord back: -- What fits in each frame: column/frame sizing so windows land at usable widths instead of arbitrary slices. -- Wrap-around: navigating past the last frame should wrap to the first (and vice versa). -- Whatever else surfaces in daily use once the above land. +** TODO [#B] Panels moveable + resizable by drag :feature:waybar:network:bluetooth: +Both the net and bluetooth instrument-console panels should be repositionable and resizable at runtime: click-drag to move the panel anywhere on screen, drag the corners to resize. Raised from roam capture 2026-07-03. -The support machinery was deliberately kept for this task: =layout-navigate= and =layout-resize= retain their scrolling branches, =waybar-layout= still renders the scrolling state, and the unbound legacy =cycle-layout= script still lists it. Re-enabling is two lines: add =scrolling= back to =LAYOUTS= in =layout-cycle= and restore a direct-jump bind (the old chord is taken now — pick a new one). The =tests/layout-cycle= suite pins the disabled state and will go red on re-enable, which is the reminder to update it. +Design note: the panels are gtk4-layer-shell overlays anchored TOP+RIGHT with fixed margins — layer-shell surfaces are compositor-positioned, so free drag-move/resize needs either dynamic margin updates on pointer motion or a switch to a normal floating window (Hyprland moves/resizes those natively). Approach decision required before build. -** TODO [#B] Pocketbook finish-or-cancel decision :pocketbook: -SCHEDULED: <2026-08-23 Sun> -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -Decide whether to finish the pocketbook app or close and cancel the project. Removed from the waybar setup 2026-06-23 (the org-capture popup covers quick reminders and text for now), so it's out of daily use — this is the checkpoint to commit to it or retire it. Backlog above: [[*Pocketbook development backlog][Pocketbook development backlog]]. +** TODO [#B] Net panel wider initial width :waybar:network:quick: +Start the network panel a bit wider — keep the right edge fixed (it's right-anchored), extend the left border leftward. Raised from roam capture 2026-07-03. -** TODO [#B] Provision Eask in archsetup :tooling:eask: -:PROPERTIES: -:LAST_REVIEWED: 2026-05-26 -:END: -Add =@emacs-eask/cli= to archsetup's provisioning so fresh machines get it. Eask is installed by hand today and declared nowhere in archsetup or the dotfiles repo, yet both chime and linear-emacs depend on it (their =make setup/test/coverage= shell out to =eask=). Source: handoff from linear-emacs 2026-05-23. +** TODO [#B] Net panel doctor results can't display :bug:waybar:network: +The doctor diagnostic output is unreadable — the results well is too constrained to show the multi-line result. It should open a results box tall enough for several lines with a copy-results button; closing it via an X in the box's upper-right collapses the space back to what it occupied before. Raised from roam capture 2026-07-03. -- Add a global npm install after the node block (=archsetup= ~2030, after =aur_install nvm=), modeled on the claude-code native-install block: run as =$username=, wrapped in =display=/=error_warn=, output to =$logfile=. Roughly =sudo -u "$username" bash -c 'npm install -g --prefix "$HOME/.local" @emacs-eask/cli'=. -- Pin the prefix to =~/.local= so eask lands at =~/.local/bin/eask= (already on PATH) and the install runs as the user, not root. On the current machine =npm config get prefix= returns =/usr=, so eask was installed with an explicit =--prefix=. -- Decision: also set a persistent user npm prefix (=~/.npmrc= with =prefix=${HOME}/.local=)? If yes, that =~/.npmrc= is a legitimate dotfile to stow; if no, rely on the explicit =--prefix= flag alone. =~/.eask/= is a regenerable cache — leave un-stowed. -- Acceptance: fresh run leaves =eask= on PATH at =~/.local/bin/eask= (no root); =cd ~/code/chime && make setup && make test= works. - -** TODO [#B] Waybar timer module :waybar: +** DONE [#B] Audio panel spec :feature:waybar:audio:solo: +CLOSED: [2026-07-03 Fri] :PROPERTIES: -:LAST_REVIEWED: 2026-05-26 +:LAST_REVIEWED: 2026-07-02 :END: -A custom waybar module providing three time-keeping functions, surfaced in the bar with click/scroll controls and dunst notifications on completion. - -- *Alarm* — fire a notification at a wall-clock time (e.g. 2:00pm). Builds on the existing =notify= + =at= pattern from protocols.org. -- *Timer* — count down a duration (e.g. 25m) and notify when it elapses. -- *Pomodoro* — alternating work/break cycles (default 25/5, long break after 4) with the bar showing phase + remaining time. - -Implementation notes (to flesh out when picked up): waybar =custom= module(s) with =exec= polling or a persistent =exec= script emitting JSON; click actions to start/pause/reset; a small state file under =~/.local/state= or =~/.local/var=. Lives in the hyprland tier (=dotfiles/hyprland/.config/waybar/= + a backing script in =hyprland/.local/bin/=). TDD the backing script per testing.md. +Went past the spec to a full build in a no-approvals speedrun. Spec is now IMPLEMENTED ([[file:docs/design/2026-07-03-audio-panel-spec.org]], all 5 Decisions resolved). The panel shipped in the dotfiles repo (branch panel-bugfixing, commits 65e5bb0..9601420): pactl engine, GTK-free presenter, GTK instrument-console panel (OUTPUTS/INPUTS device rows with faders + per-device mute, LIVE/MUTED/PUSH·TALK mic keys, twin VU gauges, master quick-mute), Hyprland-bind push-to-talk, bar indicator, and the bar/keybind wiring (Super+A → panel, XF86AudioMute → master quick-mute). 102 unit tests + a passing AT-SPI smoke on velox. Live-eyeball validation filed under Manual testing and validation. Apply steps + follow-ups handed to the dotfiles project inbox. -*** 2026-06-24 Wed @ 17:32:37 -0400 Scope expansion from roam capture (folded duplicate) -A roam-inbox capture asked for the same widget and expands the scope, so folding it in here rather than duplicating: -- *One panel, mode-selectable* — a single component where you choose timer / stopwatch / alarm; the icon changes to reflect the selected mode. -- *Stopwatch* — a count-up (the third function alongside the alarm/timer/pomodoro above), hover shows start time ("Stopwatch started: 12:22pm"). -- Hover text per mode: timer "Timer: 5 min", alarm "Alarm: 12:15pm", stopwatch "Stopwatch started: 12:22pm". -- *Multiple simultaneous* — several timers/alarms/stopwatches set and displayed at once, in one panel. -- Deliverable includes proposing a few panel designs and recommending one before building. +Original ask (roam inbox, 2026-07-02): net/bt-panel kin — change default output/input, volume for both, push-to-talk mic mode for meetings, master quick-mute, bar sound-glyph state. Related bindings: Super+M audio-cycle ring, Super+Shift+A mic-toggle. Prototype: =working/sound-panel/sound-panel-prototype.html=. -** DONE [#B] Sysmon module right-click cycles the visible metric :feature:waybar:solo: -CLOSED: [2026-06-28 Sun] -Shipped in the dotfiles repo (=f7b6896=, implemented from this archsetup session per Craig). =waybar-sysmon= reads a selected metric from =$XDG_RUNTIME_DIR/waybar/sysmon-metric= (absent = host default, so the old behavior is preserved); the new =sysmon-cycle= helper advances through a host-appropriate ring (battery only on a laptop), wraps, and refreshes waybar via signal 12 wired to =on-click-right=. Left-click stays the btop popup. Added cpu/temp/mem icons + thresholds. TDD: 13 new =waybar-sysmon= selection cases + a 9-case =sysmon-cycle= suite, full dotfiles suite green (29 suites). =sysmon-cycle= symlinked into =~/.local/bin= on velox. Live visual/relogin check filed under "Manual testing and validation". Handoff sent to the dotfiles inbox. -Builds on the just-shipped =custom/sysmon= collapse (dotfiles be7469b). Right-clicking the module rotates which metric is the visible one, in a fixed order: battery → cpu → temp → mem → disk → back to battery. Each click advances one step and wraps around. The host default (battery on a laptop, disk on a desktop) is the starting/reset metric; the tooltip keeps showing all metrics regardless. Left-click stays =pypr toggle monitor= (the btop popup) — the cycle lives on =on-click-right=. - -Implementation notes: =waybar-sysmon= needs a persisted selection (a state file in =$XDG_RUNTIME_DIR/waybar/=, absent = host default) that it reads to pick the visible metric. A new =sysmon-cycle= helper bumps the index and signals the module to refresh (add a =signal= to =custom/sysmon=, like the other custom modules; wire =sysmon-cycle= to =on-click-right=). TDD both — extend =tests/waybar-sysmon= for selection-driven output, add a =tests/sysmon-cycle= for the index advance/wrap and the signal. - -** TODO [#B] Network-manager dropdown, nmcli-backed with GPG-stored secrets :waybar:network: +** TODO [#B] Scrolling/Carousel layout: frame fit + wrap-around :hyprland: :PROPERTIES: -:LAST_REVIEWED: 2026-06-09 +:LAST_REVIEWED: 2026-06-13 :END: -Replace the current wifi/network waybar component with a self-contained network manager driving nmcli directly (no =nmtui= dependency). Same look as the existing indicator; clicking it drops down the management interface (design open, keep it minimalistic). - -Core functionality: -- Add / edit / remove connections. -- List saved connections by SSID, ordered by recency (most-recently-used first); select one to switch to it. -- Recognize a wired/ethernet connection even when plugged in after the session started, and allow selecting it at any time. Switch freely: ethernet↔wifi, wifi↔wifi. -- Match all current functionality of the existing wifi/network component (status icon, signal strength, tooltip). - -Credential storage: -- Store connection definitions + passwords in a GPG-encrypted file under =~/.config= (appropriate XDG location), encrypted to Craig's private key. -- Passphrase cadence configurable: decrypt once per session, once per hour (via gpg-agent cache TTL), or never (plaintext / stays decrypted). *Default is unencrypted* — encryption is opt-in. +Disabled 2026-06-12 (bind and cycle entry points removed; Super+Shift+S reassigned to whole-desktop screenshot). The layout needs real work before it earns its chord back: +- What fits in each frame: column/frame sizing so windows land at usable widths instead of arbitrary slices. +- Wrap-around: navigating past the last frame should wrap to the first (and vice versa). +- Whatever else surfaces in daily use once the above land. -Design / open questions (propose before building): -- Dropdown UI tech: a GTK layer-shell panel (like pocketbook), a fuzzel/rofi-style menu, or a waybar-native expanding group. -- Relationship to NetworkManager's own store (=/etc/NetworkManager/system-connections=, root-only): does the GPG store supplement or replace it, and how do they stay in sync. -- Whether to keep the existing =custom/netspeed= throughput readout alongside the new SSID/status indicator. +The support machinery was deliberately kept for this task: =layout-navigate= and =layout-resize= retain their scrolling branches, =waybar-layout= still renders the scrolling state, and the unbound legacy =cycle-layout= script still lists it. Re-enabling is two lines: add =scrolling= back to =LAYOUTS= in =layout-cycle= and restore a direct-jump bind (the old chord is taken now — pick a new one). The =tests/layout-cycle= suite pins the disabled state and will go red on re-enable, which is the reminder to update it. -Implementation notes: backing scripts in the dotfiles repo (hyprland tier); nmcli for every NM op (device status, con up/down, add/modify/delete, wifi rescan/list). TDD the nmcli-wrapper logic with a fake nmcli on PATH. Sizable — worth a =docs/design/= doc before implementation. +** TODO [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-02 +:END: +*** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2) +The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused). +From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output). + +Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a decision and a paused-machine check): +- Waypaper canonical decision (Craig): live velox has =dark-lion.jpg=, repo has =that-one-up-there.jpg= (placeholder?). Decide the canonical, then =make reset= clears it. +- Ratio check: whether =~/.config/calibre/*.json= on ratio are symlinks or real files — same first-launch gap likely if calibre ever launched there before stow. On the ratio trip list. + +** TODO [#B] Audit dotfiles/common directory :chore:dotfiles: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-02 +:END: +Refiled from the archsetup task audit (2026-06-28), landed via ~/.dotfiles/inbox; the dotfiles content split into its own repo 2026-06-16 but the task tracking stays here per Craig (2026-07-02). Three parts: +- Review all 50+ scripts in =~/.local/bin= and remove unused ones. +- Check dotfiles for uninstalled packages and remove orphaned configs. +- Verify all stowed files are actually used. + +** TODO [#B] Waybar network module — custom/net :feature:waybar:network: +:PROPERTIES: +:LAST_REVIEWED: 2026-06-29 +:END: +Unifies the old wifi-no-internet indicator (was =[#C]=) and the network-manager +dropdown (was =[#B]=) into one =custom/net= module: a tested Python =net= engine +(nmcli + diagnostics), a thin bar indicator, and a GTK4 layer-shell panel. Code +lives in the dotfiles repo (hyprland tier + a =net/= package like pocketbook); +archsetup only installs deps. Secrets stay in NetworkManager's own store (no +separate credential store). The =captive= script becomes the diagnostics engine. +Full design, acceptance criteria, and the failure-mode coverage table: +[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]]. + +Phases below, dependency order. Engine/unit work is agent-verifiable (=unittest= ++ fakes on PATH, coverage via venv); the live-network and visual states need real +conditions, filed under "Manual testing and validation". + +*** 2026-06-29 Mon @ 20:19:11 -0400 Phase 1 shipped — indicator + console recovery +Shipped to the dotfiles repo (10 commits, =5254bd8=..=c095a22=, pushed to main). +The =net= engine is a src-layout Python package in-tree, imported by a bin shim +that resolves the stow symlink back to the repo — so it runs from a bare TTY with +no install, which the recovery path depends on. + +Landed: =net status= (fast path, one nmcli call + sysfs, degraded fallback in +budget) + =net probe= (native captive probe, single-flight flock, atomic cache, +fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation); =waybar-net= +replacing =custom/netspeed=, throughput → tooltip, CSS states in both themes + +live; =net diagnose= (read-only steps) + =net repair= (rfkill/reset/bounce/ +dns-test, cleanup-verified) + =net doctor [--fix]= with the four terminal +classifications; =net portal= + the =captive --probe-json= refactor; redacted +JSONL event log; Makefile recovery targets (=make online= etc.); =~/.config/net/ +config=. Verified live: =make net-status= reads the real wlp170s0 / @Hyatt_WiFi. + +Airplane (Craig's call, option 1): =custom/net= absorbs only the *display* — net +reads the airplane-mode state file and shows an airplane state/glyph. The +airplane-mode toggle stays (it's a low-power mode — radios + CPU + brightness + +services — not a radio switch), now on =custom/net='s right-click + signal 15. +Deleted: =waybar-airplane=, =waybar-netspeed=, =custom/airplane=, their tests + +css. =airplane-mode= kept. + +Tests: 160 in =tests/net/= (fake nmcli/curl/rfkill/resolvectl/ping/getent/ +systemctl on a temp PATH; doctor-classification fixtures; degraded-under-slow- +nmcli benchmark) + the =captive= probe-mode tests; full dotfiles suite green (32 +suites). Coverage-gap pass via throwaway venv: pure modules ≥90% branch +(classify 100%), IO-error branches excused in the test docstring. +Deferred to Phase 2/3: archsetup deps (gtk4-layer-shell/python-gobject Phase 2, +speedtest-go-bin Phase 3 — not added before the code that needs them). +Verify (manual, live): see Manual testing and validation. + +*** 2026-06-29 Mon @ 22:19:25 -0400 Phase 2 shipped — panel shell + connection management +Shipped to dotfiles (commits =4e7740f=..=24bcac5=, pushed). Engine: =net list= (saved +MRU + in-range wifi scan, infrastructure types filtered), =net up/down= (UUID-keyed, +mutation safety — keep prior link until target activates, classify wrong-password vs +generic, report auto-reactivation), =net add/edit/remove/rescan= (open + WPA-PSK; +enterprise activate-only; secret to NM's store, never our JSON/log — tested). + +Panel: a GTK-free PanelModel (selection, four state machines, the UX-flow enable +rules, terminal states) + a GTK4 gtk4-layer-shell window (=net panel=) anchored +top-right under the bar — Connections section with MRU list, active marked, signal +glyph, row-click select, Connect/Add/Forget/Rescan, confirm-on-forget, worker-thread +engine calls via GLib.idle_add. GTK imported lazily so the CLI/tests stay GTK-free. + +Bar interactions (settled with Craig over live iteration): left = =net-panel= toggle, +middle = =net portal=, right = =net-fix= (notify the doctor result when one-way; open +a terminal only when the outcome is fixable — the sudo/interactive case). Airplane on +Super+Shift+A. archsetup adds =gtk4-layer-shell= + =python-gobject= (this commit); +already on velox. + +Tests: 204 in tests/net (merge ordering/dedup, up/down mutation safety, no-secret-leak +on add/edit, panel model + state machines, gui row-format helpers). Full dotfiles suite +green (32 suites). Live-verified on velox: panel opens/toggles, list shows real 24 +profiles, right-click notification delivers (Craig confirmed). Phase 3 (diagnose/repair/ +speedtest IN the panel) is next; the engine for it already exists from Phase 1. + +*** 2026-06-29 Mon @ 22:43:40 -0400 Phase 3 shipped — diagnostics + speed test in the panel +Shipped to dotfiles (=91277cf=..=691abcb=) + archsetup (=48052d6=, speedtest-go-bin), +pushed. Engine: =net speedtest= (parses speedtest-go --json → ping from latency ns, +down/up from per-server byte rates; missing-backend / offline / malformed → error +envelope per the failure table). Panel grew a section switcher with four pages: +- Connections (Phase 2). +- Diagnose: =net diagnose= on a worker thread, each step a row (✓/✗/… glyph + title + + redacted evidence), read-only; Open-portal button when captive. +- Repair: "Get me online" (=net doctor --fix=) + tiers (rfkill/reset/bounce/dns-test) + + force portal. Confirmations in-panel with the spec's exact wording; the privileged + tiers run via =net-popup= terminal (where the sudo prompt + step output, incl. + cleanup-verified, show) — a panel has no tty, and pkexec would mean a prompt per op. +- Speed test: in-process =net speedtest= (no privilege → inline result: ↓/↑ Mbps + ping + + server), Run/Cancel (Cancel pkills the child), error envelope shown. + +213 net tests; pure helpers (step_indicator, format_speedtest) unit-tested. Full +dotfiles suite green (32 suites). One unverified assumption: speedtest-go's dl/ul unit +(taken as bytes/s; =BYTES_PER_SEC= flips it) — needs one real run vs a reference. The +in-panel repair streaming (vs terminal) is a named future polish once the GUI-privilege +story settles. + +The waybar network module ([#B] parent) is now COMPLETE through Phase 3. Phase 4 +(in-app help + user guide) and Phase 5 (VPN/WireGuard) remain as future work; the core +feature (indicator + recovery + panel + diagnostics + speed test) is done. +Verify (manual, live): see Manual testing and validation. + +*** TODO Phase 4 — docs + rollout :network: +Deliverable: in-app help (=net --help= + per-command, panel help affordance); +README/user-guide (commands, indicator states, panel, config keys, make targets, +troubleshooting from the failure table, rollback); archsetup Hyprland dep install +(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=); ratio manual dep + +stow step. +Verify: =net --help= and each subcommand complete; user-guide covers every command ++ the recovery targets. + +*** TODO Phase 5 — VPN / WireGuard (vNext) :network: +Fold the existing archsetup wireguard tooling into the panel + CLI (=net vpn ...=). +Out of the v1 milestone; spec separately when picked up. (v1 only detects + +classifies a VPN-routed failure, it doesn't repair it.) + +** TODO [#B] Timer GTK panel :feature:waybar: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-02 +:END: +Initial spec written 2026-07-02: [[file:docs/design/2026-07-02-timer-panel-spec.org]] (DRAFT — four decisions await Craig's review before build; net-panel Blueprint/GTK4 stack, wtimer stays the state owner). + +From Craig's roam capture 2026-07-02: give the timer a GTK UI/UX like the network panel. ** TODO [#B] Desktop-settings dropdown panel :waybar: :PROPERTIES: :LAST_REVIEWED: 2026-06-24 :END: +Initial spec written 2026-07-02: [[file:docs/design/2026-07-02-desktop-settings-panel-spec.org]] (DRAFT — four decisions await Craig's review before build; architecture updated to the net panel's Blueprint/GTK4 stack). + One waybar dropdown gathering the desktop toggles and sliders into a single settings panel, opened from a gear/settings glyph on the bar. Incorporate: - *Auto-dim* toggle (the =custom/dim= feature just shipped — fold in here, or keep the standalone indicator and mirror it). - *Brightness* slider (backlight, via brightnessctl). @@ -159,6 +256,11 @@ Boot the configured endpoint and send a short prompt; surface success/failure + Acceptance: fresh VM install of the ratio profile reaches an endpoint on =:8081= that answers a smoke prompt; velox profile gets Q4_K_M + 8B and answers a prompt within reasonable laptop latency; network-down install completes successfully with the pending-models warning surfaced. +** TODO [#C] Voice dictation / speech-to-text input :feature:tooling: +Push-to-talk dictation that types transcribed speech into the focused Wayland window — usable at any text field, including the Claude Code terminal prompt and Emacs buffers. Claude Code has no built-in voice input; dictation has to happen at the OS level and inject text. Raised 2026-07-03. + +Tool choice is the open decision (needs Craig): =nerd-dictation= (Vosk, lighter, lower accuracy) vs a =whisper.cpp=-based daemon (heavier, higher accuracy, optional GPU). Wayland typing backend is =wtype= or =ydotool=. Scope once chosen: install + model download, a push-to-talk keybind (Hyprland), and an autostart entry; fold into archsetup so it lands on both daily drivers. Consider an Emacs-native path (=whisper.el=) as a complement for in-buffer dictation. + ** TODO [#B] Review post-archsetup laptop setup steps (velox 2026-04-10) :PROPERTIES: :LAST_REVIEWED: 2026-06-09 @@ -225,7 +327,7 @@ Consider: document as post-install step or create a sync script. :END: Umbrella for the test-harness and CI-automation buildout. Consolidated from the 2026-06-28 task audit: these were scattered top-level tasks circling one effort, re-homed as children so the work reads as a unit. Each child ships independently and keeps the priority it carried before. No CI runner exists yet, so the CI/CD-pipeline child gates several of the others. -*** TODO [#C] Build CI/CD pipeline that runs archsetup on every commit +*** TODO [#B] Build CI/CD pipeline that runs archsetup on every commit :PROPERTIES: :LAST_REVIEWED: 2026-06-13 :END: @@ -243,17 +345,17 @@ The diff engine now exists (=scripts/package-inventory= / =make package-diff=), **** TODO [#B] For packages in archsetup but not on system: determine if still needed **** TODO [#B] For packages on system but not in archsetup: decide add or remove **** TODO [#B] Schedule monthly package diff review -*** TODO [#C] Set up automated test schedule +*** TODO [#B] Set up automated test schedule :PROPERTIES: :LAST_REVIEWED: 2026-06-28 :END: Weekly full run to catch deprecated packages even without commits -*** TODO [#C] Implement manual test trigger capability +*** TODO [#B] Implement manual test trigger capability :PROPERTIES: :LAST_REVIEWED: 2026-06-28 :END: Allow on-demand test runs when automation is toggled off -*** TODO [#C] Create test results dashboard/reporting +*** TODO [#B] Create test results dashboard/reporting :PROPERTIES: :LAST_REVIEWED: 2026-06-28 :END: @@ -283,17 +385,17 @@ Archive logs with review process and schedule to identify failure patterns and t :LAST_REVIEWED: 2026-05-21 :END: Parse package warnings and repo metadata to catch upcoming deprecations proactively -*** TODO [#C] Monitor and optimize test execution time +*** TODO [#B] Monitor and optimize test execution time :PROPERTIES: :LAST_REVIEWED: 2026-05-21 :END: Keep test runs performant as installs and post-install tests grow (target < 2 hours) -*** TODO [#C] Set up alerts for deprecated packages +*** TODO [#B] Set up alerts for deprecated packages :PROPERTIES: :LAST_REVIEWED: 2026-05-21 :END: Proactive monitoring integrated with testing -*** TODO [#C] Fix VM cloning machine-ID conflicts for parallel testing +*** TODO [#B] Fix VM cloning machine-ID conflicts for parallel testing :PROPERTIES: :LAST_REVIEWED: 2026-05-21 :END: @@ -381,20 +483,6 @@ Some entries are libraries likely pulled in as dependencies (blas-openblas, open - [ ] webkit2gtk - [ ] whisper.cpp -** TODO [#B] All error messages should be actionable with recovery steps -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -Currently just reports errors without guidance on how to fix them - -** TODO [#B] Improve logging consistency -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -Some operations log to ~$logfile~, others don't - standardize logging -All package installs should log, all system modifications should log, all errors should log with context -Makes debugging failed installations easier - ** TODO [#B] Security hardening + audit :security: :PROPERTIES: :LAST_REVIEWED: 2026-06-28 @@ -418,12 +506,12 @@ Umbrella for the security-hardening and audit effort. Consolidated from the 2026 :LAST_REVIEWED: 2026-05-21 :END: Identify attack vectors, what's mitigated, what remains -*** TODO [#C] Complete security education within 3 months +*** TODO [#B] Complete security education within 3 months :PROPERTIES: :LAST_REVIEWED: 2026-06-24 :END: Read recommended resources to make informed security decisions (see metrics for Claude suggestions) -*** TODO [#C] Create security checklist for cafe/public wifi scenarios +*** TODO [#B] Create security checklist for cafe/public wifi scenarios :PROPERTIES: :LAST_REVIEWED: 2026-05-21 :END: @@ -435,97 +523,12 @@ Practical guidelines for working in public spaces :END: Ensure new tools integrate with the Hyprland environment and don't break workflow (the fleet is all Hyprland now; archsetup still supports DWM/X11 but no current machine uses it) -** TODO [#B] Add NVIDIA preflight check for Hyprland -:PROPERTIES: -:LAST_REVIEWED: 2026-05-21 -:END: -Detect NVIDIA GPU and warn user about potential Wayland issues: -- Require driver version 535+ or abort -- Document required env vars (LIBVA_DRIVER_NAME, GBM_BACKEND, etc.) -- Prompt to continue or abort if NVIDIA detected - -** CANCELLED [#B] Migrate terminal emulator from foot to ghostty :tooling: -CLOSED: [2026-06-28 Sun 13:58] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -Decision (Craig, 2026-06-24): switch from foot to ghostty. Drivers: ligatures (foot won't add them) and kitty-graphics + sixel image support (foot is sixel-only, no kitty-graphics plans). ghostty is pure-Wayland on Hyprland, declarative config that fits the theme system, runtime config reload (keybind / SIGUSR2 since 1.2). Trade-off accepted: slightly higher input latency than foot. Already in use as Emacs's terminal renderer, so the config + rendering are familiar and the 06-18 tmux theme was tuned against that surface. Full evaluation: [[file:docs/2026-06-10-terminal-emulator-evaluation.org][docs/2026-06-10-terminal-emulator-evaluation.org]]. - -Migration scope: -- archsetup: add =ghostty= to the package list; decide whether to keep =foot= installed as a fallback or drop it. -- dotfiles: port =foot.ini= → ghostty config (flat key=value). The shared foot.ini sets no font (per-host via =host.ini= include) — replicate that per-host font split for ghostty. -- Themes: the dupre/hudson =themes/<name>/= dirs hold foot configs; add ghostty theme files and teach =set-theme= to write + reload the ghostty config. Watch the reload-clobbers-OSC-10/11 bug (ghostty #2795) when wiring runtime theme switch. -- hyprland.conf: default-terminal keybind, pyprland scratchpad terminals, and any other =foot= references → ghostty. -- Verify on velox + ratio: ligatures render, latency acceptable in tmux+vterm use, dupre theme correct, sixel/kitty-graphics previews work. - -** DONE [#C] Scratchpad launch turns on focus-follows-mouse :bug:hyprland: -CLOSED: [2026-06-28 Sun] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-28 -:END: -Root cause: =float_switch_override_focus = 1= in hyprland.conf. With =follow_mouse = 0=, focus still jumped to the window under the pointer when it crossed a floating-tiled boundary, so launching a floating scratchpad re-enabled focus-follows-mouse onto tiled windows. Fixed by setting it to 0 (dotfiles =5619342=). Not a pyprland side effect. - -Imported from roam inbox 2026-06-25. Repro: with two tiled windows, moving the mouse over the other tile does nothing (focus-follows-mouse off, as expected). Then launch a terminal (scratchpad), move the mouse over a tile, and focus now switches to the window under the pointer. Something about the scratchpad/terminal launch flips focus-follows-mouse on. Find what re-enables it (likely a Hyprland focus/input setting or a pyprland scratchpad side effect) and keep it off. - -** DONE [#B] mod+J/K focus navigation: raise to front, reach floating, monocle fix :feature:bug:hyprland: -CLOSED: [2026-06-29 Mon] -Three improvements to =layout-navigate= (mod+J/K), validated live on velox: -- Raise the focused window to the front on focus navigation, so focusing a window behind an overlapping floating one brings it forward (dotfiles =5619342=, bundled with the =float_switch_override_focus = 0= scratchpad fix tracked above). -- Cycle into floating windows, so you can navigate back to a scratchpad like any window instead of it being a one-way trip (dotfiles =f2107f7=). -- Fixed a monocle regression from that change: the =cyclenext= dispatcher no-ops between monocle-stacked tiles, so focus navigation now computes the workspace window list and focuses the next/prev by address — layout-independent and floating-inclusive (dotfiles =09815f3=). - -** TODO [#C] Wlogout exit-menu buttons are rectangular, not square -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -The wlogout exit menu renders its buttons taller than they are wide on velox, so the cells read as vertical rectangles instead of squares. They render square (centered) correctly on ratio, so this is a per-host / resolution difference, not a flat bug. Fix the button sizing in the wlogout style (=~/.dotfiles/hyprland/.config/wlogout/style.css=) so each cell is square on both hosts. Noticed 2026-05-21. Related: the [#D] VERIFY about wlogout sizing across displays. - -The wlogout config uses fixed pixel margins, which is the likely reason sizing differs across the two displays — adjusting them for the laptop screen is part of the fix (folded in from the former "Test wlogout menu on laptop" VERIFY, 2026-06-24). - -Add a regression test so the square-cell fix doesn't silently break on a resolution change: assert the rendered (or computed) wlogout button cells are square across ratio's and velox's resolutions. Dropped :quick: — the cross-host test pushes this past a spare-moment fix. ** TODO [#C] Window focus lost when unhiding stashed windows :bug:hyprland: :PROPERTIES: :LAST_REVIEWED: 2026-06-24 :END: From the roam inbox: hiding a window (e.g. the org-capture popup) then unhiding it should leave the unhidden window focused, but another window typically takes focus. Also =ctrl+j/k= (layout-navigate) can't reach the unhidden window afterward — it should always reach any visible window except the waybar. Involves stash-restore + layout-navigate; needs interactive reproduction with Craig. -** TODO [#C] Pocketbook development backlog :pocketbook: -:PROPERTIES: -:LAST_REVIEWED: 2026-05-26 -:END: -Pocketbook (GTK4 layer-shell notes panel, toggled via waybar) was pulled from publication 2026-05-26 — github repo + cjennings.net repo deleted, mirror hook removed — and folded into this repo at =pocketbook/= until it's ready to spin back out. Src-layout Python package with pytest tests and a Makefile. Develop it in-tree; the backing modules are =store/note/panel/layer_shell/app/note_widget= + =style.css=. - -Backlog (unordered; promote items to their own dated tasks as they're picked up): - -- Configurable options, possibly a dedicated configuration panel. -- Lose-focus hides pocketbook — configurable on/off. -- Configurable display order: chronological by creation date (asc/desc), manual, alphabetical (asc/desc). -- Search / filter notes. -- Global toggle keybind (Hyprland =bind=) alongside the waybar click; document the waybar integration. -- Note CRUD polish (create/edit/delete) + optional markdown rendering. -- Pin / favorite notes. -- Tags or notebooks / categories. -- Persistence: confirm store format + =~/.local/share/pocketbook/= location, add versioning/migration, decide a backup/sync story. -- Theming: track the dupre/hudson theme system so =style.css= follows =set-theme=. -- Layer-shell geometry config (anchor edge, width, margins) + HiDPI / multi-monitor behavior — ties into [[file:docs/PLAN-per-host-overrides.org][per-host overrides]] scaling work. -- Config file format (toml) + reload-without-restart. -- Expand test coverage (TDD per testing standards; =tests/= already exists). -- Release prep for the eventual spin-back-out: pyproject metadata, version, license. -- Re-wire the archsetup install (gtk4-layer-shell dep + install step + post-install clone) when pocketbook ships. Removed 2026-05-26 — see git history of =archsetup= / =scripts/post-install.sh=. - -** TODO [#C] Fn+F9 toggles pocketbook — source unlocated :hyprland:pocketbook: -:PROPERTIES: -:LAST_REVIEWED: 2026-06-23 -:END: -On velox, pressing Fn+F9 (physical function key) toggles the pocketbook panel. It shouldn't. Raised from a home-project session 2026-06-23. - -Investigated 2026-06-23 and could not locate the trigger in any config. Ruled out, three ways: -- No F9 bind (bare / $mod / keycode) in the live =hyprland.conf= (now a stow symlink), the velox host tier =conf.d/local.conf=, or the waybar config. -- =hyprctl binds= runtime (all 90 active binds, authoritative) execs pocketbook on ONLY =SUPER+P=. No F9/XF86 path reaches it. The old touchpad toggle that used to sit on =$mod+F9= was moved to =$mod+M=, so F9 is unbound in Hyprland. -- No input remapper (keyd/xremap/input-remapper) and no hotkey daemon (sxhkd/swhkd) running or configured; pocketbook's own source has no F9 / GlobalShortcuts / portal / dbus listener (its GTK ShortcutController binds only Esc/Ctrl-n/Ctrl-j/Ctrl-k/Del/Return). pocketbook is a single-instance Gtk.Application, so any path that re-runs =pocketbook= toggles it. - -Parked at Craig's call (not worth deeper investigation now). If it resurfaces, the one unfinished step is to capture what keysym Fn+F9 actually emits (=wev -f wl_keyboard:key=, press Fn+F9, read the =sym:= / =code:=) and grep for that. Most likely folds into removing pocketbook from the waybar setup — if pocketbook leaves the bar, retire this with it. - ** TODO [#C] Ensure sleep/suspend works on laptops :PROPERTIES: :LAST_REVIEWED: 2026-06-09 @@ -542,6 +545,11 @@ See Framework community notes on logind.conf and sleep.conf settings :END: archsetup installs =python-lyricsgenius= with =--mflags --skipinteg=, skipping makepkg integrity + PGP checks — a workaround originally for an expired-signature issue upstream (surfaced by the 2026-06-23 --noconfirm audit). Periodically test whether the cause has cleared: if a plain =aur_install python-lyricsgenius= builds without complaint, drop the =--skipinteg= workaround. Removal needs a real AUR build to confirm, so it isn't a blind change. +*** 2026-07-02 Thu @ 05:08:37 -0400 Rechecked: still needed, same cause +=makepkg --verifysource= on 3.7.0-1: tarball passes, =LICENSE.txt= still FAILS +its b2sum — the PKGBUILD still pins a hash for a file fetched from github +master. Structural, as diagnosed 2026-06-24; =--skipinteg= stays. + *** 2026-06-24 Wed @ 17:55:34 -0400 Rechecked: still needed, but the cause changed Ran =makepkg --verifysource= on the current AUR PKGBUILD (3.7.0-1). The package tarball =lyricsgenius-3.7.0.tar.gz= now passes its b2sum — the original expired-PGP-signature problem is gone (the PKGBUILD no longer carries any =validpgpkeys=). But integrity still FAILS, on a different file: =LICENSE.txt=, which the PKGBUILD fetches from the project's github master and pins a b2sum for. github master is a moving target, so that b2sum drifts and =--skipinteg= is still required. This is structural (not a transient upstream fix away), so it likely won't clear until the maintainer pins the LICENSE to a tagged release. Updated the archsetup comment to the real cause. Keep rechecking, but lower expectations of it clearing. @@ -565,26 +573,6 @@ The goal is a single place to edit each config, not two. :END: Once-yearly systematic inventory of known deficiencies and friction points in current toolset -** TODO [#C] archsetup Waybar Wi-Fi module should show no-internet state :feature:waybar: -:PROPERTIES: -:LAST_REVIEWED: 2026-06-13 -:END: -From the roam inbox: the Waybar Wi-Fi module should distinguish "connected to an access point" from "connected and has internet." Add a no-internet state or indicator to the archsetup Waybar configuration. Not marked quick/solo because it needs the archsetup environment and live network-state verification. - -A 2026-06-22 roam capture expands the scope past a passive indicator: the wifi module should also bounce the network, run basic diagnostics, and optionally run a speed test with results — surfaced through modifier clicks (ctrl/super/meta) on the module. The no-internet state is the indicator; this adds active remediation off the same component. - -** TODO [#C] Waybar emacs-service status + control :feature:waybar: -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -From the roam inbox (2026-06-22): with Emacs integrated into the system as file manager and instant note-taker, make bouncing it trivial. A waybar component showing the emacs service status, with detail on hover, that turns the server on / off / bounce via right-click. Pairs with running the Emacs daemon as a managed systemd user service. - -** TODO [#C] set-wallpaper detaches waypaper config from its stow symlink :bug:hyprland:quick:solo: -:PROPERTIES: -:LAST_REVIEWED: 2026-06-28 -:END: -=set-wallpaper= persists with =mv "$tmp" "$CONFIG"=, which replaces the =~/.config/waypaper/config.ini= stow symlink with a real file. After the first run the live config is detached from =~/.dotfiles/hyprland/.config/waypaper/config.ini=, so a later =git pull= + restow won't update it and set-wallpaper changes never flow back to the repo. Fix: write in place rather than =mv= over the symlink — e.g. =cp "$tmp" "$CONFIG"= (follows the symlink to the real dotfiles file), or resolve the link target and write there. Lives in =~/.dotfiles/hyprland/.local/bin/set-wallpaper=; it has a test suite, so add a Boundary case for "CONFIG is a symlink". - ** TODO [#D] Consider Customizing Hyprland Animations Current: windows pop in, scratchpads slide from bottom. @@ -615,6 +603,115 @@ Parse yay errors and provide specific, actionable fixes instead of generic error Enhance existing indicators to show what's happening in real-time ** TODO Manual testing and validation +*** Audio panel: apply the new shims + configs (precondition for the tests below) +What we're verifying: the new =audio=/=audio-panel=/=waybar-audio= bin shims and the hyprland.conf + waybar config edits are live. New files need a restow (a plain =git pull= doesn't symlink them). Quit Hyprland or run from a TTY — restowing live Hyprland writes a stub hyprland.conf. +#+begin_src sh :results output +cd ~/.dotfiles && git pull +cd ~/.dotfiles && make restow hyprland +#+end_src +- Reload waybar: mod+B (or =killall -SIGUSR2 waybar=). +- Reload the Hyprland config: mod+Shift+R (or =hyprctl reload=). +Expected: =which audio audio-panel waybar-audio= resolves all three to ~/.local/bin symlinks; no stow conflicts reported. + +*** Audio panel: opens and reads the live graph +What we're verifying: Super+A opens the instrument-console panel (not the old pulsemixer scratchpad) and it shows the real devices. +- Press Super+A. +Expected: the audio panel opens top-right. OUTPUTS lists every sink, INPUTS every mic (no .monitor entries), the default device in each is emphasized (cream name, gold glyph), each row shows its volume percent, and the twin OUT·PLAY / IN·MIC needles deflect. Esc closes it. + +*** Audio panel: set default, volume fader, per-device mute +What we're verifying: the three row interactions drive the real graph and the panel re-reads to confirm. +- With two or more outputs present, click a non-default output row's body. +Expected: that device becomes default (gold DEF emphasis moves to it) and playback follows. +- Drag a device's fader. +Expected: the volume percent tracks the fader and the actual device volume changes (no lag, no jump to the border). +- Click a device's trailing mute glyph. +Expected: that one device mutes/unmutes; the glyph and caption reflect it; other devices unaffected. + +*** Audio panel: master quick-mute (faceplate switch + mute key) +What we're verifying: the master switch and the XF86AudioMute key both mute every output at once (not just the default sink). +- Flip the faceplate master switch. +Expected: every output mutes, the state word reads MUTED, the badge shows, the faceplate glyph goes to the slashed speaker. Flip back to restore. +- With two outputs audible, press the hardware mute key (XF86AudioMute). +Expected: both outputs mute (master), not just the default. Press again to unmute all. + +*** Audio panel: mic modes + push-to-talk (the meeting case) +What we're verifying: LIVE/MUTED work, and PUSH·TALK holds the mic muted except while the talk key is held — the one genuinely new capability, and the one only a live test can confirm. +- Click LIVE, then MUTED, watching the default mic row. +Expected: LIVE unmutes the mic (needle lifts), MUTED mutes it (needle red); the active mode key shows a gold lamp. +- Click PUSH·TALK. In a call app (or =audio status= in a loop), watch the mic while you hold Space, speak, then release. +Expected: mic is muted at rest, un-mutes for exactly as long as Space is held, re-mutes on release. Switching back to LIVE or MUTED disarms the hold (Space types normally again). + +*** Audio panel: visual polish eyeball + bar entry point +What we're verifying: the panel looks right in the dupre instrument-console language, and the bar opens it. +- Look over the whole panel: faceplate spacing, engraved section labels, fader styling, VU needle centering, the muted-vs-audible glyph/color states. +Expected: it reads as a sibling of the net and bt panels; nothing overflows the gold border; the faders and gauges look machined, not stock GTK. +- Right-click the waybar sound module. +Expected: the panel opens (left-click still mutes, scroll still changes volume). + +*** Timer dialog: Escape cancels at every step +What we're verifying: Escape in the real fuzzel dialogs aborts the whole flow (the fix rides fuzzel's abort exit code; the unit tests fake it, this is the live confirmation). +- Left-click the timer module on the bar, pick "timer", then hit Escape at the Duration prompt. +Expected: no Label prompt appears and no timer is created (bar count unchanged). +- Left-click again and hit Escape at the very first Timer-type menu, and once more at the Label prompt after entering a duration. +Expected: each Escape ends the flow with nothing created. + +*** Timer alarm: 12-hour time accepted, bad input notifies +What we're verifying: the alarm prompt takes 12h shapes live and errors are audible instead of silent. +- Left-click the timer module, pick "alarm", enter 2:30pm (or any 12h form), Enter through the label. +Expected: an alarm appears on the bar showing 14:30 as its fire time. +- Repeat with garbage like 99:99. +Expected: a fail notification names the bad input; nothing is created. +- Cancel the test alarm (right-click the module, pick it). + +*** Speed test streams in the panel +What we're verifying: the panel's speed test fills in as phases complete instead of dumping everything at the end (the CLI path is live-verified; this is the GTK rendering). +- Open the net panel, Diagnostics → Network Performance → Run Speedtest. +Expected: a Ping/Download/Upload checklist appears under the running row; ping lands within seconds, download mid-run, upload near the end; then the final rows (with jitter on Ping, a Packet loss row) replace it. A Tip row appears only if a rule fired, and it names the numbers that triggered it. + +*** Proton VPN CLI sign-in (velox now, ratio on its trip) +What we're verifying: the proton CLI has its own account store (separate from the retired GTK app's), so the panel's proton rows can't toggle until you sign in once per machine. +- Run in a terminal: protonvpn login <your-proton-username> (it prompts for the password). +#+begin_src sh :results output +protonvpn info +#+end_src +Expected: Account shows your Proton username instead of 'None'. After that, protonvpn status still says Disconnected — correct, nothing auto-connects. + +*** Tunnels round-trip: panel rows + bar badge (first real tunnel-owned route) +What we're verifying: the panel's Tunnels tab drives a real wireguard tunnel up and down, and the bar indicator grows the vpn badge while the tunnel owns the default route (the badge has never rendered live — every prior check ran with the wlan owning the route). +- Open the net panel (left-click the bar's net module), switch Connections to the Tunnels page. +- Confirm the rows: tailscale (up), and the seven Proton configs (USNY, USDC, USCALA, USCASF, USGAAT, switzerlan-zurich1/2), all down. +- Select USNY, press Bring Up, wait for the row to land. +Expected: the bar's net glyph gains the small vpn badge; its tooltip names the owner ("Tunnel: default route via wgpvpn (wireguard)"). +- Press Bring Down on the same row. +Expected: badge gone, tooltip back to normal, internet still works (the wlan owns the route again). + +*** Screenshot View Image option +What we're verifying: the new post-capture menu entry opens the shot and puts its path on the clipboard (dispatch is unit-tested; this is the live end-to-end). +- Take a screenshot the usual way (region or fullscreen). +- Pick "View Image" from the fuzzel menu. +- Paste anywhere (the clipboard should hold the file path). +Expected: the shot opens in feh (the default image/png viewer) and the pasted text is the saved file's path under ~/pictures/screenshots/. + +*** Wlogout square cells on velox (and later ratio) +What we're verifying: the exit menu's six buttons read as squares with visible muted borders, and only pointer hover shows gold (measured 361x361 px, but your eyeball is the arbiter). +- Press Super+Shift+Q. +- Look at the six cells' shape and borders; hover a couple of buttons; note the lock button is not gold before you hover it. +- Press Esc to close (careful: the letter keybinds are live — e is logout). +Expected: square cells with subtle borders, gold only under the pointer, muted ring on the focused button. Repeat on ratio after its sync. + +*** wtimer: color states + click/scroll interactions on the live bar +What we're verifying: the timer module's interactions and CSS state colors render right on the live bar. The glyph, position (right of battery), countdown, and "+N" badge are already verified live; the per-state colors and the real mouse/scroll bindings are what's left. The logic is unit-tested (86 cases); this is the human-in-the-loop visual + input check. +- Left-click the timer module — a fuzzel menu offers timer / alarm / stopwatch / pomodoro; pick timer, enter =5s=. +- Watch it count down; at under a minute it should turn the urgent color (dupre orange #d47c59). +Expected: the timer reaches 0:00, a persistent notification fires, and the item disappears from the bar. +- Create two timers (e.g. =3m= and =10m=); a =+1= badge shows; scroll over the module. +Expected: scrolling cycles which item is primary (the displayed time/glyph changes); the badge count stays correct. +- Middle-click the module while a timer runs. +Expected: it pauses (dimmed paused color #5f5c52) and the countdown freezes; middle-click again resumes from where it left off. +- Right-click the module with items present. +Expected: a fuzzel menu lists the items; choosing one cancels it. +- Start a pomodoro (left-click → pomodoro); let a work phase elapse (or set short test phases by editing state). +Expected: the glyph + color switch between work (gold #d7af5f) and break (#8a9a5b), a notification fires at each phase change, and the cycle count advances. *** Sysmon right-click cycles the visible metric (live waybar) What we're verifying: right-clicking the collapsed sysmon module rotates the visible metric and the bar refreshes at once, left-click still opens btop, and the cpu/temp/mem icons render as real glyphs (not tofu boxes). The cycle logic is unit-tested; this is the live-waybar + visual confirmation. - Reload waybar so it picks up the new =signal= / =on-click-right= config (Super+B relaunches it, or =pkill waybar; waybar &= from a terminal) @@ -626,6 +723,41 @@ What we're verifying: =README.md= reads cleanly and accurately for a first-time - Open =~/code/archsetup/README.md= - Read it end to end as if you've never seen the project Expected: every section is accurate, the personal-project disclaimer reads right, the placeholders (=<your-domain>=, =github.com/yourusername=) are consistent, and nothing personal leaked into the public-facing draft. +*** Bt console: connect / disconnect a paired device +What we're verifying: a paired row's primary action toggles the real connection and the row's lamp + battery dial follow (the smoke drives the widgets against fakes; this is a real device). +- Open the bt panel (left-click the bar's bluetooth module, or Super+Shift+B). +- On a paired-but-disconnected audio device, click its row's connect action. +Expected: the device connects, the row lamp goes live, and if it reports battery a dial fills in with its level. +- Click the same row again to disconnect. +Expected: the device disconnects, the lamp dims, and its battery dial returns to NO DEVICE. +*** Bt console: rename a paired device +What we're verifying: the ✎ affordance renames a real device through the bluez Alias and the new name persists (mechanism live-probed on the M650 during the build; this is the in-panel path). +- In the bt panel, click the ✎ on a paired device's row. +- Enter a new name in the dialog and confirm. +Expected: the row's name updates to the new alias immediately, and it survives closing and reopening the panel (verify-after read confirms it stuck). +- Rename it back to its original name the same way. +*** Bt console: pair a nearby device (passkey flow) +What we're verifying: pairing a new device from the NEARBY list runs the pair flow into the passkey-confirm dialog and default-deny holds (this can't be auto-driven — it needs a real discoverable device and the passkey compare). +- Put a bluetooth device into pairing mode. +- In the bt panel, press SCAN and wait for the device to appear under NEARBY. +- Click its row to start pairing. +Expected: a passkey-confirm dialog appears; confirming completes the pairing and the device moves to PAIRED; dismissing it leaves the device unpaired. +*** Bt console: arm-forget a paired device +What we're verifying: the ✕ two-click arm-to-fire removes a real pairing (the arm latch is unit-tested; this confirms the real forget lands). +- In the bt panel, click the ✕ on a device you can re-pair later. +Expected: the row arms (tinted, a confirm affordance) rather than forgetting on the first click. +- Click again to confirm. +Expected: the device is unpaired and drops off the PAIRED list. +*** Bt console: discoverable toggle and adapter power switch +What we're verifying: the discoverable chip and the faceplate power switch drive the real adapter state. +- In the bt panel, click the discoverable chip. +Expected: the chip reads "discoverable on" and another device can see this adapter while it's on; clicking again turns it off. +- Flip the faceplate adapter-power switch off, then on. +Expected: off powers the adapter down (faceplate word → OFF, rows empty), on brings it back (POWERED, paired rows return). Airplane mode overrides — if it's on, the switch refuses with the way out. +*** Bt console: LOW BATT badge with a real sub-15% device +What we're verifying: a connected device reporting under 15% battery drives the faceplate LOW BATT badge and the red dial (the threshold is unit-tested; this needs a real device actually low). +- Connect an audio device whose battery is genuinely under 15% (drain one, or catch it low). +Expected: its battery dial renders red, and the faceplate shows the LOW BATT badge. Charge it above 15% and the badge clears on the next refresh. *** 2026-06-28 Sun @ 12:54:47 -0400 Live-update guard verified on velox (live Hyprland) Verified the =hypr-live-update-guard= PreTransaction hook end-to-end on velox with Hyprland running (pid 1997). velox predated the feature, so the guard was @@ -697,6 +829,49 @@ What we're verifying: the physical keychord opens a floating Dirvish popup; open - Expected: still exactly one popup — the second press focuses the existing one, no second frame, no stray buffer (for several independent file managers, use C-x d) - Press Super+Shift+F - Expected: GUI nautilus opens (the binding nautilus moved to) +*** Network module Phase 1 — indicator states on the live bar +What we're verifying: =custom/net= shows the right state for each real network condition. The engine logic is unit-tested; this is the live-bar + visual check (states can't be faked on the running bar). Phase 2-3 tests get added under this task as those phases land. +- Reload waybar to pick up =custom/net=. Super+B does NOT reload a running bar — it only toggles visibility (SIGUSR1), and the bar reads a generated runtime config, so a stale copy keeps the old module. The correct reload regenerates the runtime config then restarts: +#+begin_src sh :results output +waybar-active-config && killall waybar && waybar-toggle +#+end_src +- On a normal connected network, read the module. +- Expected: wifi glyph + signal + SSID; tooltip shows IPv4, gateway, throughput, and a recent "online" probe result. +- Join the hotel/captive network (or any portal network). +- Expected: the module shows the captive state (distinct glyph + warning color), tooltip names the portal host. +- Unplug to a network with no internet (or block egress). +- Expected: the no-internet state (distinct from captive and from disconnected). +*** Network module Phase 1 — net doctor recovers rfkill from a TTY +What we're verifying: the console-recovery path works with no GUI, and recovers the framework's post-power-loss soft-block. +#+begin_src sh :results output +rfkill block wifi # simulate the soft-block +rfkill list wifi # confirm Soft blocked: yes +#+end_src +- Switch to a TTY (Ctrl+Alt+F3) and log in (no Hyprland). +#+begin_src sh :results output +make -C ~/.dotfiles online # or: net doctor --fix +#+end_src +- Expected: doctor reports the rfkill block, runs =rfkill unblock wifi= + =nmcli radio wifi on=, reconnects, and ends "online" — all from the TTY. +*** Network module — bar clicks + airplane keybind (FINAL scheme) +What we're verifying: the custom/net clicks and the airplane keybind. Clicks (settled with Craig over live use 2026-06-29): left = =net-panel= toggle (the GTK panel), middle = =net portal= (floating terminal), right = =net-fix= (notify the doctor result when one-way; open a terminal only when fixable). Airplane = Super+Shift+A. +- Left-click =custom/net=. +- Expected: the GTK connection panel toggles open (left-click again, or Esc, closes it). +- Right-click =custom/net= while online. +- Expected: a desktop notification "Network / Online" (success), no terminal. When a repair is needed it instead opens a terminal running =net doctor --fix=. (Craig confirmed the notification delivers, 2026-06-29.) +- Middle-click =custom/net= on a captive network. +- Expected: =net portal= runs in the floating terminal — reset + opens the portal page. +- Press Super+Shift+A. +- Expected: airplane engages (wifi off, dim, low-power); =custom/net= shows the airplane glyph in gold. Super+Shift+A again restores everything. +- Check =airplane-mode= is still present (=ls ~/.local/bin/airplane-mode=), and =waybar-airplane= / =waybar-netspeed= / =custom/airplane= are gone. +*** Network module Phase 3 — panel Diagnose / Repair / Speed test tabs +What we're verifying: the four-tab panel works end to end. Left-click =custom/net= to open it. +- Diagnose tab → "Run diagnose". +- Expected: a list of steps (link, DHCP, gateway, DNS config, DNS resolution, internet) each with a ✓/✗/… glyph + evidence; on a captive network an "Open portal" button appears. +- Repair tab → click Reset (or Bounce, or DNS override test). +- Expected: a confirmation dialog with the exact wording (Reset names the network + new-MAC warning; Bounce "links drop briefly"; DNS test "reverts automatically"). Proceed opens a floating terminal that runs the repair (sudo prompt there) and shows the step output incl. cleanup-verified for the DNS test. +- Speed test tab → "Run speed test" (uses ~30s + data — do it on real wifi, not the metered hotspot). +- Expected: ↓/↑ Mbps + ping + server shown inline. +- Byte-rate→Mbps unit: VERIFIED 2026-06-30 (velox). Raw =speedtest-go --json= dl_speed read ~3.66M, unambiguously bytes/s (29 down / 80 up Mbps); =net speedtest= reported 33.62 / 77.99 through the wired path. =BYTES_PER_SEC = True= + =* 8 / 1e6= are correct, no flip needed. Remaining here is only that the panel renders the inline result. ** DOING [#B] Prepare for GitHub open-source release :PROPERTIES: @@ -803,17 +978,144 @@ Rewrote the bare =if $var= boolean conditionals (=show_status_only=, =fresh_inst *** 2026-05-26 Tue @ 15:27:09 -0500 eval task moot — the line-434 eval is gone, the survivor is deliberate Verified: the only =eval= left in =archsetup= is line 578 in =retry_install=, and it's intentional and documented — it captures =$?= directly from =eval "$cmd"= to dodge the if-compound-swallows-exit-code trap. Replacing it with an array would reintroduce that bug. The line-434 eval this task pointed at no longer exists. Nothing to change. +* Archsetup Resolved + +** DONE [#B] VM test harness shared one NVRAM file across filesystem profiles :bug:test: +CLOSED: [2026-06-27 Sat] +The harness shared one OVMF NVRAM file (=vm-images/OVMF_VARS.fd=) across the btrfs +and zfs profiles (=init_vm_paths= suffixed the disk image per profile but not the +NVRAM). NVRAM lives outside the qcow2, so a disk-snapshot revert can't restore it, +and a zfs run's ZFSBootMenu boot entries clobbered the btrfs GRUB entry. With no +removable =\EFI\BOOT\BOOTX64.EFI= fallback on the base ESP, the next btrfs run +booted into UEFI with no bootable device ("BdsDxe: No bootable option or device +was found", then PXE/HTTP, then SSH timeout before archsetup ran). Found +2026-06-27 trying to VM-validate the installer refactor. + +Fixed: =OVMF_VARS= now carries the same per-profile suffix as the disk image +(=OVMF_VARS${img_suffix}.fd=) in =vm-utils.sh init_vm_paths=, so btrfs and zfs keep +separate NVRAM. Validated by a full green zfs run 2026-06-27 (ArchSetup exit 0, +Testinfra 96 passed / 0 failed). Remaining hardening tracked below. +** DONE [#B] Guard against live mesa/hyprland/wayland-runtime updates :hyprland: +CLOSED: [2026-06-28 Sun] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-09 +:END: +A live =pacman -Syu= that swaps mesa/hyprland/wayland runtime libs out from under a running Hyprland session can crash the compositor: the next GPU-lib call hits a now-"(deleted)" library and SIGABRTs, taking the Wayland clients down with it. Hit ratio 2026-06-07 (mesa 26.0.6 -> 26.1.2 + hyprland upgraded live; Hyprland SIGABRT took down awww/insync/emacs). Likely the driver behind ratio's high lifetime unsafe-shutdown ratio — a crashed compositor forces a hard reset. + +Shipped as a pacman PreTransaction hook rather than a wrapper, so it fires no matter how the upgrade is launched (pacman, yay, topgrade). =scripts/hypr-live-update-guard= aborts the transaction before any package is swapped when the GPU/compositor runtime set is being upgraded AND Hyprland is running, pointing the user to re-run from a TTY with the session stopped; it stays quiet when Hyprland isn't running (the safe from-a-TTY path). Override via =HYPR_ALLOW_LIVE_UPDATE=1= or by touching the sentinel file named in the abort message. archsetup installs the script to =/usr/local/bin= and the hook to =/etc/pacman.d/hooks/= in the hyprland path. Decision logic unit-tested (=tests/hypr-live-update-guard=, 9 cases). Live firing test filed under Manual testing and validation. Commits: archsetup (this session). +** DONE [#B] Collapsible waybar sides :waybar: +CLOSED: [2026-06-27 Sat] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-09 +:END: +Let either side of the waybar collapse horizontally to a minimal base set, toggled by a click. Each collapsible side carries a small triangle / arrowhead pointing toward the screen edge it collapses into (away from center). Clicking it collapses that side to its base set and flips the arrow to point back toward center; clicking again restores the full side. Same shape-changes-with-state idea as the auto-dim indicator. + +Spec (2026-06-19): [[file:assets/2026-06-19-collapsible-waybar-sides-spec.org]]. Spike that settled the mechanism: [[file:assets/2026-06-18-collapsible-waybar-sides-spike-findings.org]]. + +Decisions locked: right base set = date + worldclock + tray; left base set = menu + workspaces; per-side independent; host-agnostic (base set constant, full set is each host's existing config). Mechanism = config-swap + SIGUSR2 reload via an active-config copy in =$XDG_RUNTIME_DIR= (the CSS/state-file approach was disproven — GTK3 can't reflow-hide native modules). Lives in =~/.dotfiles/hyprland/=. + +Shipped per spec (dotfiles 804bef6): 3 TDD'd scripts (=waybar-active-config=, =waybar-collapse=, =waybar-arrow=; 22 cases), arrow modules wired into the config (left arrow innermost-left, right arrow innermost-right), CSS ×3, =$mod+[= / =$mod+]= keybinds, and =waybar-toggle= relaunch updated to load the active config so a crash preserves collapse state. Verified live: click, keybind, and per-side independence all work; expand round-trips exactly to canonical. +** DONE [#C] Collapse waybar sysmonitor to a single icon + hover :feature:waybar: +CLOSED: [2026-06-27 Sat] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +From the roam inbox (2026-06-22): replace the spread-out sysmonitor readouts (temp, cpu, mem, storage) with one visible icon showing a single chosen metric, the rest in the hover tooltip. Open question: fold it into the battery component instead of a standalone module. Implementation lives in the waybar config under ~/.dotfiles. + +Shipped as a standalone =custom/sysmon= module (Craig's call: host-dependent primary — battery on laptop, disk on desktop — rather than fold into battery, which is laptop-only). Backing script =waybar-sysmon= gathers cpu/temp/mem/disk/battery, shows the host-appropriate metric, rest in tooltip; 13-case TDD suite; removed the 5 native modules + their CSS across all 3 themes. Dotfiles be7469b. +** DONE [#C] Rename idle inhibitor to something more intuitive :chore:waybar: +CLOSED: [2026-06-27 Sat] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +From the roam inbox (2026-06-24): the "idle inhibitor" name doesn't work as a mnemonic — something like "sleep" (i.e. "keep awake" / "no-sleep") would land better. Decide the new name, then rename across the touchpoints: the =custom/idle= waybar module, the keybind mnemonic, and the backing script names (=hypridle-toggle= / =waybar-idle= from the 2026-06-24 idle-inhibitor work). Needs Craig's call on the name first, so not solo. + +Renamed to "caffeine" (Craig's call, 2026-06-27): =custom/caffeine= module, =waybar-caffeine= + =caffeine-toggle= scripts, tooltip "Caffeine: ON/OFF", CSS + test suites updated. Keybind stays =$mod+I= (=$mod+C= is hyprpicker). Shipped in dotfiles 8b45b51. +** DONE [#B] ZFS pre-pacman snapshot installer step (ZFS-root) :feature:zfs: +CLOSED: [2026-06-30 Tue] +Add a ZFS-root-gated installer step that installs the pre-pacman snapshot pacman hook plus a self-pruning =/usr/local/bin/zfs-pre-snapshot= (KEEP=10). The script is hand-placed on velox, not authored by archsetup, so a reinstall loses it; snapshots accumulated unbounded (53 since April) because nothing prunes them and Sanoid ignores non-autosnap_ names. Gate to ZFS-root (velox; ratio is btrfs). Also correct the stale 2026-01-17 security-doc line claiming it's "already in install-archzfs". Needs the hook file (source from velox) and a ZFS-root VM test. + +Shipped: =configure_pre_pacman_snapshots()= in boot_ux (late, ZFS-gated) + =scripts/zfs-pre-snapshot=; unit tests for pruning + a Testinfra assertion. VM-verified ZFS install passed 97/0 (test_zfs_pre_pacman_snapshot_hook PASSED). The "stale doc" turned out accurate (it's an install-archzfs archive) — left as-is. Design notes and the KEEP=10 script: [[file:docs/design/2026-06-29-zfs-pre-snapshot-installer.org]]. Origin: home handoff 2026-06-29. +** DONE [#B] Waybar timer module :waybar: +CLOSED: [2026-06-29 Mon] +:PROPERTIES: +:LAST_REVIEWED: 2026-05-26 +:END: +Shipped as =wtimer= in the dotfiles repo (=134d61e=), a single always-visible module right of the battery/resource readout, non-collapsible. Covers all four modes (timer / alarm / stopwatch / pomodoro) with multiple running at once: the bar shows the most urgent item with a per-type glyph + "+N" badge, the tooltip lists them all. Left-click creates (fuzzel), middle-click pauses, right-click cancels, scroll cycles the primary; notify fires on completion and pomodoro phase changes. Pure-functions-over-injected-clock design; CLI serializes state with flock + atomic write so the 1s render and click handlers never lose an update or double-fire. TDD: 86 cases, 95% line coverage. Design spec: [[file:docs/design/2026-06-29-waybar-timer-module-spec.org][docs/design/2026-06-29-waybar-timer-module-spec.org]]. Live-verified on velox (glyph renders, position, countdown); the color states + click interactions filed under Manual testing and validation. + +A custom waybar module providing three time-keeping functions, surfaced in the bar with click/scroll controls and dunst notifications on completion. + +- *Alarm* — fire a notification at a wall-clock time (e.g. 2:00pm). Builds on the existing =notify= + =at= pattern from protocols.org. +- *Timer* — count down a duration (e.g. 25m) and notify when it elapses. +- *Pomodoro* — alternating work/break cycles (default 25/5, long break after 4) with the bar showing phase + remaining time. + +Implementation notes (to flesh out when picked up): waybar =custom= module(s) with =exec= polling or a persistent =exec= script emitting JSON; click actions to start/pause/reset; a small state file under =~/.local/state= or =~/.local/var=. Lives in the hyprland tier (=dotfiles/hyprland/.config/waybar/= + a backing script in =hyprland/.local/bin/=). TDD the backing script per testing.md. + +*** 2026-06-24 Wed @ 17:32:37 -0400 Scope expansion from roam capture (folded duplicate) +A roam-inbox capture asked for the same widget and expands the scope, so folding it in here rather than duplicating: +- *One panel, mode-selectable* — a single component where you choose timer / stopwatch / alarm; the icon changes to reflect the selected mode. +- *Stopwatch* — a count-up (the third function alongside the alarm/timer/pomodoro above), hover shows start time ("Stopwatch started: 12:22pm"). +- Hover text per mode: timer "Timer: 5 min", alarm "Alarm: 12:15pm", stopwatch "Stopwatch started: 12:22pm". +- *Multiple simultaneous* — several timers/alarms/stopwatches set and displayed at once, in one panel. +- Deliverable includes proposing a few panel designs and recommending one before building. +** DONE [#B] Sysmon module right-click cycles the visible metric :feature:waybar:solo: +CLOSED: [2026-06-28 Sun] +Shipped in the dotfiles repo (=f7b6896=, implemented from this archsetup session per Craig). =waybar-sysmon= reads a selected metric from =$XDG_RUNTIME_DIR/waybar/sysmon-metric= (absent = host default, so the old behavior is preserved); the new =sysmon-cycle= helper advances through a host-appropriate ring (battery only on a laptop), wraps, and refreshes waybar via signal 12 wired to =on-click-right=. Left-click stays the btop popup. Added cpu/temp/mem icons + thresholds. TDD: 13 new =waybar-sysmon= selection cases + a 9-case =sysmon-cycle= suite, full dotfiles suite green (29 suites). =sysmon-cycle= symlinked into =~/.local/bin= on velox. Live visual/relogin check filed under "Manual testing and validation". Handoff sent to the dotfiles inbox. +Builds on the just-shipped =custom/sysmon= collapse (dotfiles be7469b). Right-clicking the module rotates which metric is the visible one, in a fixed order: battery → cpu → temp → mem → disk → back to battery. Each click advances one step and wraps around. The host default (battery on a laptop, disk on a desktop) is the starting/reset metric; the tooltip keeps showing all metrics regardless. Left-click stays =pypr toggle monitor= (the btop popup) — the cycle lives on =on-click-right=. + +Implementation notes: =waybar-sysmon= needs a persisted selection (a state file in =$XDG_RUNTIME_DIR/waybar/=, absent = host default) that it reads to pick the visible metric. A new =sysmon-cycle= helper bumps the index and signals the module to refresh (add a =signal= to =custom/sysmon=, like the other custom modules; wire =sysmon-cycle= to =on-click-right=). TDD both — extend =tests/waybar-sysmon= for selection-driven output, add a =tests/sysmon-cycle= for the index advance/wrap and the signal. +** DONE [#B] Network module: enterprise WiFi add/edit deferred to vNext :waybar:network: +CLOSED: [2026-06-29 Mon] +Decided 2026-06-29 (Craig): keep v1 to open + WPA-PSK add/edit; the +WPA-Enterprise / 802.1X add/edit form is vNext, not a v1 phase. v1 still +*activates* any saved enterprise profile and points editing at nmtui/nmcli. +Evidence that settled it: 24 saved profiles on velox, 18 WPA-PSK, 0 enterprise — +no 802.1X network in Craig's history, so the form would be unused UI. If one ever +appears, nmtui adds it once and the module activates it thereafter. Spec: +[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]]. +** CANCELLED [#B] Migrate terminal emulator from foot to ghostty :tooling: +CLOSED: [2026-06-28 Sun 13:58] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-24 +:END: +Decision (Craig, 2026-06-24): switch from foot to ghostty. Drivers: ligatures (foot won't add them) and kitty-graphics + sixel image support (foot is sixel-only, no kitty-graphics plans). ghostty is pure-Wayland on Hyprland, declarative config that fits the theme system, runtime config reload (keybind / SIGUSR2 since 1.2). Trade-off accepted: slightly higher input latency than foot. Already in use as Emacs's terminal renderer, so the config + rendering are familiar and the 06-18 tmux theme was tuned against that surface. Full evaluation: [[file:docs/2026-06-10-terminal-emulator-evaluation.org][docs/2026-06-10-terminal-emulator-evaluation.org]]. + +Migration scope: +- archsetup: add =ghostty= to the package list; decide whether to keep =foot= installed as a fallback or drop it. +- dotfiles: port =foot.ini= → ghostty config (flat key=value). The shared foot.ini sets no font (per-host via =host.ini= include) — replicate that per-host font split for ghostty. +- Themes: the dupre/hudson =themes/<name>/= dirs hold foot configs; add ghostty theme files and teach =set-theme= to write + reload the ghostty config. Watch the reload-clobbers-OSC-10/11 bug (ghostty #2795) when wiring runtime theme switch. +- hyprland.conf: default-terminal keybind, pyprland scratchpad terminals, and any other =foot= references → ghostty. +- Verify on velox + ratio: ligatures render, latency acceptable in tmux+vterm use, dupre theme correct, sixel/kitty-graphics previews work. +** DONE [#C] Scratchpad launch turns on focus-follows-mouse :bug:hyprland: +CLOSED: [2026-06-28 Sun] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-28 +:END: +Root cause: =float_switch_override_focus = 1= in hyprland.conf. With =follow_mouse = 0=, focus still jumped to the window under the pointer when it crossed a floating-tiled boundary, so launching a floating scratchpad re-enabled focus-follows-mouse onto tiled windows. Fixed by setting it to 0 (dotfiles =5619342=). Not a pyprland side effect. + +Imported from roam inbox 2026-06-25. Repro: with two tiled windows, moving the mouse over the other tile does nothing (focus-follows-mouse off, as expected). Then launch a terminal (scratchpad), move the mouse over a tile, and focus now switches to the window under the pointer. Something about the scratchpad/terminal launch flips focus-follows-mouse on. Find what re-enables it (likely a Hyprland focus/input setting or a pyprland scratchpad side effect) and keep it off. +** DONE [#B] mod+J/K focus navigation: raise to front, reach floating, monocle fix :feature:bug:hyprland: +CLOSED: [2026-06-29 Mon] +Three improvements to =layout-navigate= (mod+J/K), validated live on velox: +- Raise the focused window to the front on focus navigation, so focusing a window behind an overlapping floating one brings it forward (dotfiles =5619342=, bundled with the =float_switch_override_focus = 0= scratchpad fix tracked above). +- Cycle into floating windows, so you can navigate back to a scratchpad like any window instead of it being a one-way trip (dotfiles =f2107f7=). +- Fixed a monocle regression from that change: the =cyclenext= dispatcher no-ops between monocle-stacked tiles, so focus navigation now computes the workspace window list and focuses the next/prev by address — layout-independent and floating-inclusive (dotfiles =09815f3=). +** CANCELLED [#C] archsetup Waybar Wi-Fi module should show no-internet state :feature:waybar: +CLOSED: [2026-06-29 Mon] +Consolidated, not dropped: the no-internet/captive indicator + the diagnostics/ +bounce/speed-test scope are now Phase 1 + Phase 3 of the unified +[[*Waybar network module — custom/net][Waybar network module — custom/net]] parent. The work continues there; +this separate entry is retired so it's tracked in one place. Spec: +[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]]. ** CANCELLED [#B] Audit dotfiles/common directory CLOSED: [2026-06-28 Sun] Refiled to the standalone =~/.dotfiles= repo, which owns this content since the 2026-06-16 split. Handoff sent 2026-06-28: =~/.dotfiles/inbox/2026-06-28-1335-from-archsetup-refiled-from-archsetup-task-audit-2026.org=. The three sub-tasks (review ~/.local/bin scripts, remove orphaned configs, verify stowed files are used) travel with it. Cancelled here, not abandoned. - ** CANCELLED [#C] Zoom launches in a tiny window :bug:hyprland: CLOSED: [2026-06-28 Sun 13:56] :PROPERTIES: :LAST_REVIEWED: 2026-06-24 :END: From the roam inbox: Zoom opens at a tiny size. Needs diagnosis (HiDPI scaling vs a window rule vs XWayland) and live verification with Zoom actually running — held for a Craig-driven debug pass, not a blind fix. - ** DONE [#B] btrfs base VM unbuildable — archangel ISO bakes zfs-auto-snapshot :bug:test: CLOSED: [2026-06-28 Sun] Resolved: archangel shipped a fixed ISO (2026-06-27) that conditions the baked AUR list on the filesystem, so a btrfs install no longer drags in =zfs-auto-snapshot=. The btrfs base rebuilt and went green in the 2026-06-28 VM run (97/0, zero attributed issues). The EFI removable-fallback hardening is archangel-side and optional. @@ -832,724 +1134,571 @@ this is fixed. zfs-profile testing works (=make test FS_PROFILE=zfs=). Companion hardening (defense-in-depth, archangel-side): install the bootloader with a removable =\EFI\BOOT\BOOTX64.EFI= fallback so a base boots even from fresh/empty NVRAM, and real installs survive firmware that drops boot entries. - -* Archsetup Resolved - -** DONE [#B] Full install logs should contain timestamps -CLOSED: [2026-02-23 Sun] -Log filename includes timestamp via =date +'%Y-%m-%d-%H-%M-%S'=. -Functions =error_warn()=, =error_fatal()=, and =display()= all output timestamps via =date +'%T'=. - -** DONE [#B] Validate DESKTOP_ENV default behavior -CLOSED: [2026-02-23 Sun] -Defaults to =hyprland= silently via =desktop_env="${desktop_env:-hyprland}"=. -Overridable via config file or =DESKTOP_ENV= environment variable. - -** DONE [#B] Test archsetup username/password prompts -CLOSED: [2026-02-23 Sun] -Username prompt with regex validation (lines 320-332) and password prompt -with confirmation (lines 339-353) implemented and functional. - -** DONE [#B] Verify SSH to remote server works -CLOSED: [2026-02-02 Mon] -Tested 2026-02-02: ssh cjennings.net returns "connected" successfully. -SSH key authentication working, no password required. - -** DONE [#B] Verify Proton Mail Bridge retrieves email -CLOSED: [2026-02-02 Mon] -Verified 2026-02-02: Proton Mail Bridge running, ports 1143 (IMAP) and 1025 (SMTP) -listening on 127.0.0.1. mu4e email retrieval functional. - -** DONE [#B] Fix unsafe sed patterns with user input -CLOSED: [2026-02-23 Sun] -Quoted =$username= in sed replacement, switched locale and wireless-regdom sed -patterns to pipe delimiter to avoid conflicts with path/encoding characters. - -** DONE [#B] Fix unsafe heredoc variable expansion -CLOSED: [2026-02-23 Sun] -Quoted =UDEVEOF= heredoc and used placeholder + sed replacement pattern (same as hyprpm hook). - -** DONE [#C] Add mountpoint check before ramdisk mount -CLOSED: [2026-02-23 Sun] -Added =mountpoint -q= guard before mount; skips with info message if already mounted. - -** DONE [#C] Improve error handling in chained commands :chore: -CLOSED: [2026-05-07 Thu] -Line 820: three operations chained with =&&= reported as single failure. -Broken into separate error-handled steps. - -** DONE [#C] Add comments on complex logic -CLOSED: [2026-02-23 Sun] -Added comments explaining wireless region locale-to-ISO3166 mapping and -archsetup clone strategy (why symlinks need user-owned repo). - -** DONE [#D] Validate reserved usernames -CLOSED: [2026-02-23 Sun] -Added check against list of reserved system usernames (root, bin, daemon, sys, etc.). - -** DONE Review: Hyprland conf.d source ordering :chore: -CLOSED: [2026-05-07 Thu] -~source = $HOME/.config/hypr/conf.d/*.conf~ was at top of hyprland.conf (line 9). -Machine-local overrides (gaps, monitor scale) were overwritten by defaults later in the file. -Fixed by moving source line to end of file. Update stowed hyprland.conf. - -** DONE Review: natural_scroll not set for mouse (only touchpad) :chore: -CLOSED: [2026-05-07 Thu] -~input:natural_scroll~ was missing; only ~touchpad:natural_scroll~ was set. -Added ~natural_scroll = true~ to input block. - -** DONE [#B] Extend layout-navigate to escape special workspaces -CLOSED: [2026-04-19 Sun] -With the =special:stash= overlay visible and focus on a window inside it, -=$mod+J= was trapped because =layoutmsg cyclenext= only operates within the -current workspace. The 2026-04-09 fix handled floating→tiled but not -special-workspace→regular. - -Fix in =dotfiles/hyprland/.local/bin/layout-navigate=: when the active -window's =workspace.name= begins with =special:= and the user is navigating -focus (not moving), dispatch =togglespecialworkspace <name>= first, re-read -activewindow state, then fall through to the existing floating/layout -branches. Move variant (=$mod SHIFT J=) is intentionally left untouched so -moving a window out of a scratchpad remains a deliberate separate action. - -Unit tests live in =tests/layout-navigate/= (stdlib =unittest=, fakes -=hyprctl= via PATH). Run with: -=python3 -m unittest tests.layout-navigate.test_layout_navigate= - -** DONE Check linux-lts version until 6.18+ -CLOSED: [2026-03-07 Sat] -Run =topgrade= and check =pacman -Q linux-lts=. Once 6.18+, remove =/etc/modprobe.d/amdgpu.conf= and mark this DONE. -Background: AMD Strix Halo VPE power gating bug causes system freeze. Workaround disables power gating. Fix is in kernel 6.15+. -Running linux-lts 6.18.16-1. amdgpu.conf workaround already removed. - -** DONE [#D] Find or create a monocle layout for Hyprland -CLOSED: [2026-03-07 Sat] -Both existing monocle plugins (zakk4223/hyprlandMonocle, pianocomposer321/hyprland-monocle) are -abandoned and broken against current Hyprland. Options: fork and fix hyprlandMonocle (more features), -script a pseudo-monocle using fullscreen 1, or wait for a maintained plugin. Lower priority since -stash-window ($mod+O / $mod+Shift+O) covers the main use case. More important for laptop installs. -Resolved: Hyprland 0.54 added native monocle layout. Bound to $mod SHIFT M. - -** DONE [#B] Investigate rlwrap not installed after archsetup run -CLOSED: [2026-05-11 Mon] -rlwrap was declared in archsetup (Emacs Dependencies) but missing after a run on ratio (2026-02-06). -The 2026-05-11 VM test run shows it installs cleanly in a fresh install (=...installing rlwrap via pacman @ 15:36:55=; =rlwrap 0.48-1= in the captured package list), so it doesn't reproduce — likely a one-off / machine-specific glitch on ratio, not a systemic skip. Closing; reopen if it recurs. - -** DONE [#C] Remove stale hyprpm/plugins validations; make run-test.sh tolerant of validation failures -CLOSED: [2026-05-11 Mon] -The 2026-05-11 VM test aborted because =validate_hyprland_plugins= in =scripts/testing/lib/validation.sh= checked for =~/.local/bin/hyprland-plugins-setup=, which was deliberately removed in dd543e3 (=feat(hyprland): remove plugins, add layout cycling=; Hyprland 0.54 brings the layouts into core). The function's =return 1= under run-test.sh's =set -e= killed the run before the test report was written or the VM cleaned up. -Fix: deleted =validate_hyprland_plugins= and =validate_hyprpm_hook= (the hyprpm pacman hook was removed in the same commit) plus their calls in =validate_window_manager=; disabled errexit in =run-test.sh= from the validation phase onward so a failed check is counted (=VALIDATION_FAILED=) instead of fatal — the script signals pass/fail via its exit code at the end. Verified with =bash -n=; the next =make test= run confirms the count-not-abort behavior. -** DONE [#B] toggle key for touchpad on/off -CLOSED: [2026-05-20 Wed] -*** 2026-05-20 Wed @ 18:18:30 -0400 Spec: touchpad toggle + waybar indicator - -**** Current state -A toggle mechanism already exists in the live home dir but is only partly committed. -- =~/.local/bin/toggle-touchpad= (live, NOT in repo): reads/writes a state file at =${XDG_RUNTIME_DIR:-/tmp}/touchpad-state= (values "enabled"/"disabled"), flips =hyprctl keyword "device[$TOUCHPAD]:enabled" true|false=, and fires a =notify info "Touchpad" ...= toast. Hardcodes =TOUCHPAD="pixa3854:00-093a:0274-touchpad"=. -- =~/.local/bin/touchpad-auto= (live, NOT in repo): daemon watching Hyprland's =.socket2.sock= for mouseadded/mouseremoved/configreloaded, auto-disables the touchpad when an external mouse is present, writes the same state file. Same hardcoded device name. -- Keybinding already committed: =bind = $mod, F9, exec, toggle-touchpad= (=hyprland.conf:315=). -- State file confirmed live at =/run/user/1000/touchpad-state= (reads "enabled"). - -**** Gap -1. No waybar indicator — nothing in modules-right shows touchpad state; no =custom/touchpad= module exists. -2. Neither =toggle-touchpad= nor =touchpad-auto= is committed into the repo. They live only in =~/.local/bin=, so a fresh stow won't install them. They belong in =dotfiles/hyprland/.local/bin/= (the =dotfiles/dwm/.local/bin/toggle-touchpad= is the old X11/xinput version, unrelated). -3. =touchpad-auto= is never started — no =exec-once= launches it. -4. The toggle doesn't refresh waybar, so an indicator would lag until its poll interval. - -**** Proposed implementation -1. New status script =dotfiles/hyprland/.local/bin/waybar-touchpad= mirroring =waybar-layout= / =waybar-netspeed= (emit one JSON line: text + tooltip + class). Reads the state file the toggle already writes — single source of truth, no extra hyprctl call. Emits a "disabled" class + off-icon when the state file reads "disabled", else "enabled" + on-icon. -2. Waybar module in =dotfiles/hyprland/.config/waybar/config=, using "signal" so the toggle pushes an instant refresh (no polling — state only changes on toggle or mouse hotplug): - =, "custom/touchpad": { "exec": "waybar-touchpad", "return-type": "json", "signal": 9, "on-click": "toggle-touchpad" }= - Add =custom/touchpad= to modules-right, near =idle_inhibitor=. -3. Refresh-on-toggle: have =toggle-touchpad= (and =touchpad-auto='s set function) run =pkill -RTMIN+9 waybar= after each write to the state file (RTMIN+N ⇄ waybar "signal": N). Alternative: drop "signal", use "interval": 2 (simpler, ~2s lag, constant poll). Signal is the cleaner fit. -4. =style.css= (=dotfiles/hyprland/.config/waybar/style.css=): add =#custom-touchpad= to the shared padding/hover selector lists; add =#custom-touchpad.disabled { color: #d47c59; }= (the dupre orange already used for warnings). Enabled state inherits the default color. -5. Keybinding: keep =$mod+F9= (=hyprland.conf:315=). The waybar on-click gives a mouse path to the same action. -6. Commit the live scripts so stow installs them: =toggle-touchpad= and =touchpad-auto= into =dotfiles/hyprland/.local/bin/= (plus the =pkill= line), and =waybar-touchpad= (new). If the auto-disable-on-external-mouse behavior is wanted at boot, add =exec-once = touchpad-auto= near the other daemon exec-once lines. - -**** Decisions (Craig, 2026-05-20) -1. Icons: enabled / disabled (the mouse / mouse-off pair). -2. Waybar on-click toggles the touchpad. -3. Commit =touchpad-auto= and add its =exec-once= so it runs at login. -4. Signal-driven refresh (=pkill -RTMIN+9 waybar=). -Note: the hardcoded device name =pixa3854:00-093a:0274-touchpad= is Framework-laptop-specific — a portability concern for other machines, not a blocker for this task. - -*** 2026-05-20 Wed @ 18:29:06 -0400 Implemented the toggle + waybar indicator (in repo) -Built per spec + decisions above. Committed the two formerly-live-only scripts into the repo and added the indicator: -- =dotfiles/hyprland/.local/bin/waybar-touchpad= (new) — reads =$XDG_RUNTIME_DIR/touchpad-state=, emits JSON (text/tooltip/class), fail-safe to "enabled". Unit-tested in =tests/waybar-touchpad/= (6 Normal/Boundary cases). -- =dotfiles/hyprland/.local/bin/toggle-touchpad= — copied from =~/.local/bin=, added =pkill -RTMIN+9 waybar= so the indicator refreshes on toggle. -- =dotfiles/hyprland/.local/bin/touchpad-auto= — copied in, =pkill -RTMIN+9 waybar= inside =set_touchpad= so auto on/off events refresh too. Added =exec-once = touchpad-auto= to =hyprland.conf=. -- =waybar/config= — =custom/touchpad= module (signal:9, on-click toggle-touchpad), placed in modules-right before idle_inhibitor. -- =waybar/style.css= — =#custom-touchpad= in padding + hover lists; =.disabled { color: #d47c59 }= (dupre orange). -- =$mod+F9= bind already present (=hyprland.conf=), left as-is. - -*** 2026-05-20 Wed @ 18:36:26 -0400 Deployed + verified on velox -Discovered =.local/bin= is stow-symlinked (waybar-layout/netspeed point into the repo); the two touchpad scripts were real files only because they weren't committed. Replaced both real files with repo symlinks and symlinked the new =waybar-touchpad= (matching the existing relative-symlink form). velox needed no hyprland.conf change — =exec-once = touchpad-auto= and the =$mod+F9= bind were already present. waybar =config= / =style.css= are real local files on velox (config diverges: standalone battery, no sysmonitor group), so applied targeted edits there rather than a copy. - -Verified end-to-end after a waybar restart: config loads with no parse errors; toggle round-trips state enabled → disabled (, class disabled) → enabled (), and the =pkill -RTMIN+9 waybar= refresh fires into the running bar. Touchpad left enabled. Visual confirmation (icon in bar, orange when off) is Craig's to eyeball. Other machines (ratio) pick this up on =git pull && make restow hyprland= — their =.local/bin= and waybar configs are symlinks, so no real-file conflict there. -** DONE [#B] Airplane-mode toggle + waybar indicator -CLOSED: [2026-05-21 Thu] -Laptop-only low-power toggle, modeled on the touchpad indicator. Wifi off (bluetooth left alone for earbuds), CPU EPP → power, brightness → 35%, and stops network-only services. Disengage restores only what it recorded, so anything already off stays off. -*** 2026-05-21 Thu @ 17:43:07 -0400 Built the toggle, indicator, and tests -- =dotfiles/hyprland/.local/bin/airplane-mode= (new) — toggle. Engage records prior state (wifi enabled/disabled, EPP value, brightness, which services were active) to =$XDG_RUNTIME_DIR/airplane-state=, then applies low-power: =nmcli radio wifi off=, EPP → power on all CPUs (sudo sysfs write), =brightnessctl set 35%=, and stops Tier 1+2 services (tailscaled, proton.VPN, avahi-daemon, cups, wsdd, geoclue, sshd, fail2ban + user syncthing). Disengage replays the recorded state — only re-enables wifi if it was on, only restarts services it stopped. Refreshes the bar via =pkill -RTMIN+10 waybar=. -- =dotfiles/hyprland/.local/bin/waybar-airplane= (new) — indicator. Reads =mode= from the state file; fail-safe to inactive. Laptop-gated: exits silently (module hidden) when no battery is present (=/sys/class/power_supply/BAT*=). One clear plane glyph (FA U+F072) for both states; color carries state (gold active / gray inactive). -- =waybar/config= — =custom/airplane= module (signal 10, on-click airplane-mode), placed after custom/touchpad. =waybar/style.css= — =#custom-airplane= in padding + hover lists; =.active { color: #d7af5f }= (dupre gold). -- Tests: =tests/airplane-mode/= (20 — engage/disengage/preserve-existing-state/dispatch, via command stubs + fake EPP sysfs) and =tests/waybar-airplane/= (10 — states/boundary/laptop-gating). All green; shellcheck clean. -- Deployed + live-verified on velox (engage → disengage round-trip works). Other machines pick it up via git pull && make restow hyprland. -** DONE [#C] super+e emacs launch doesn't grab focus from tiled browser :quick: -CLOSED: [2026-05-22 Fri] +** DONE [#B] Network panel UI — review findings :feature:waybar:network: +CLOSED: [2026-07-01 Wed] +Full UI review 2026-07-01 (visual walk of every state + code pass over the view logic), 30 findings: 21 from the agent review + color audit, 9 from Craig. All fixed the same night in a no-approvals speedrun, five commits, each test-gated (33 suites) and the whole panel re-verified via AT-SPI smoke + screenshots. + +*** 2026-07-02 Wed @ 00:00 -0400 Theme pass: contrast, hierarchy, focus, hover, destructive, dialogs (dotfiles 82aad0b, 998829b) +Selected-row captions turned cream (the dim gray measured 2.2:1 on the slate fill, a WCAG fail on the auto-selected active row). Names gained weight and captions dropped a size (type hierarchy beyond color). Focus rings gold, scrollbars slim slate, rows got a hover wash. Disconnect and Forget became terracotta destructive-action (red = off). The Join/Add dialogs carry the dupre contract now (ground, mono, styled entries with a gold focus border, no stock blue anywhere), the Add dialog disables its action button until the SSID is non-empty, its password field gained the peek icon, and long SSIDs ellipsize. + +*** 2026-07-02 Wed @ 00:00 -0400 Connections logic: lies, gaps, races (dotfiles 693f820) +Saved profiles stopped claiming "open" (security shows only when the scan knows it; subtitles are view-aware — Available adds signal %, Saved says just "out of range"). An active wired connection pins above the wifi scan in Available. Add connects immediately (Craig's decision), both add dialogs' action reads Connect. Rescan went through the op state machine (the dead guard let double-clicks double-scan). A failed initial load shows in the boxes with a retry instead of stranding "Loading…". Available's first message is "Scanning networks…". Live-info ages humanize (7m, not 445s). A held portal replaces the internet line (stable row height under the poll). The poll pauses on other tabs. VPN profiles got a VPN glyph. Forgetting the active network warns it will disconnect you. + +*** 2026-07-02 Wed @ 00:00 -0400 Diagnostics restructure: selector, live speed test, streamed verdicts, leaner chrome (dotfiles 787b475) +The six-button wall became a dropdown tool selector (full tool names, a description of the selection, one Run button) revealed under Advanced. Speed Test became Network Performance: a reveal with Run Speedtest + Stop (no button-flips-to-Cancel), a once-a-second elapsed ticker while running, and Download / Upload / Ping (high-latency warn) / Server / Tip rows in the diagnose aesthetic. Get Me Online streams the diagnose rows first, announces each attempt ("Trying Reset Connection…"), then its result, and closes with a bold verdict row — as do diagnose, the single tools, and the portal login; verdicts left the status line. Get Me Online at a held captive portal opens the login flow (doctor runs the safe, reversible portal-login when fix is requested). Connecting to a network that turns out captive sends a desktop heads-up, waits a beat, then opens the login page. Diagnose evidence humanized ("open internet (HTTP 204)", "names resolve (captive.apple.com)"). The title row and Close button are gone (Esc + focus-loss auto-hide cover a transient popup) and the status line became a self-clearing toast. The AT-SPI driver anchors on the Diagnostics tab now. +** DONE [#B] Advanced repair buttons: half width, two per row :feature:waybar:network:quick: +CLOSED: [2026-07-01 Wed] +The wide Advanced buttons shrink the panel and leave the diagnostics output impossible to read. Make each half width, two to a row, and rename where needed to fit. Origin: roam inbox capture. + +Done 2026-07-01 (dotfiles aca6827): the Advanced reveal became a 3-column, 2-row grid (Diagnose, Unblock WiFi, Reset / Restart, Test DNS, Force Portal) per Craig's follow-up; labels shortened, tooltips carry the full descriptions. Verified live + AT-SPI smoke. +** DONE [#B] Panel action-button rows fill the panel width :feature:waybar:network:quick: +CLOSED: [2026-07-01 Wed] +Disconnect / Rescan / Add / Add Hidden, and the Saved row, should be as wide as the panel and the buttons above them. Apply the same homogeneous full-width treatment used on the Available / Saved sub-tabs. Origin: roam inbox capture. + +Done 2026-07-01 (dotfiles aca6827): both Connections action rows are homogeneous full-width, which also fixed the panel resizing when Connect flips to Disconnect. Verified live. +** DONE [#B] Live connection info in the row subtitle :feature:waybar:network: +CLOSED: [2026-07-01 Wed] +The live connection information shown in the row hover should also appear in the small print under the connection name, updated in realtime like the hover. Origin: roam inbox capture. + +Done 2026-07-01 (dotfiles aca6827): a 1.5s poll fills the active row's subtitle with the bar-tooltip fields minus the SSID (signal, interface, internet + age, portal note, throughput), sharing the bar's per-field formatters. Verified live. +** DONE [#B] Right-click date/time: ntp sync + timezone update :feature:waybar: +CLOSED: [2026-07-02 Thu] +Right-click on the date updates the clock from ntpd (or whatever keeps the clock in sync); right-click on the time runs the update-timezone script. Neither opens a terminal — both run transparently in the background. Errors surface as notifications. The module should detect whether we're online and, if not, show a message instead of running the script. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 2f7993d). Date right-click = clock-sync (chronyc makestep behind a connectivity gate; offline/captive get explanatory notifications). Time right-click = timezone-set, rewritten to prefer WiFi geolocation (whereami → timeapi.io) over IP lookup — the hotel IP geolocated two timezones off, and ipapi.co is paywalled now (ipinfo.io is the fallback). Both promptless (sudo -n), all outcomes notify, worldclock gained signal 16 for an instant refresh. 13 new tests across clock-sync + timezone-set; live-verified on velox (WiFi path resolved Rhode Island → America/New_York correctly). +** DONE [#B] Screenshot "view image" option :feature:hyprland: +CLOSED: [2026-07-02 Thu] +The screenshot flow should also offer a "view image" selection: saves the shot, opens it in a viewer, and puts the path on the clipboard. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 10e5961). View Image entry in the post-capture fuzzel menu: opens the shot via xdg-open (default viewer, currently feh) and puts the path on the clipboard. Script gained env seams + a nine-test suite (menu dispatch, both capture modes, cancel, failure). Live check pending: take a shot and pick View Image once. +** DONE [#C] Collapse-triangle buttons: dimmer, inlaid styling :waybar: +CLOSED: [2026-07-02 Thu] +The triangle collapse buttons should look embedded (inlaid) and be slightly dimmed so they don't compete for eye attention with the other components. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 15cb93c): muted color + dark inset well + smaller glyph; hover still brightens. Hudson theme carries the same shape. Screenshot-verified. +** DONE [#C] Contrast button ignores the white=on / red=off paradigm :bug:waybar: +CLOSED: [2026-07-02 Thu] +The contrast button doesn't respect the white=on, red=off color paradigm the other waybar modules follow. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 15cb93c). The "contrast button" is the auto-dim module — its ON icon (nf-fa-adjust) is the classic contrast glyph. It showed gold when on and nothing when off; now on = default silver, off = terracotta, matching every other toggle. Screenshot-verified both states. +** DONE [#C] Off-state red inconsistent across waybar modules :bug:waybar: +CLOSED: [2026-07-02 Thu] +Terracotta red isn't applied uniformly for "off": the sleep icon is a different shade than the mouse/trackpad when off, and the dim indicator doesn't show red when off at all. Cosmetic × every time = P3. Origin: roam inbox capture 2026-07-02. + +Shipped 2026-07-02 (dotfiles 15cb93c + 7f1f334). The touchpad script's Pango-markup red predated the terracotta theme pass (#d47c59 vs the CSS's #cb6b4d) — unified on #cb6b4d. Dim-off now red (see the contrast task). Bonus find: waybar hands every pulseaudio instance the sink's .muted class, so a muted speaker also painted the mic red — scoped with :not(.mic) so each glyph keys on its own device. +** DONE [#B] Waybar volume/mic toggle like the touchpad module :feature:waybar: +CLOSED: [2026-07-02 Thu] +Make the volume/mic waybar component look and behave like the touchpad/mouse toggle. +- Move the mic to the other side of the volume so the percentage isn't in the way. The mic and speaker icons sit the same distance apart as the hand and mouse. +- One keybinding cycles the four states: volume on / mic on, volume on / mic off (red), volume off (red) / mic on, volume off (red) / mic off (red). +- Move the trackpad/mouse toggle to another keybinding (discuss an open mnemonic, e.g. =d= for disable) and assign Super+M to this module (for mute). +Origin: roam inbox capture. + +Shipped 2026-07-02 (dotfiles 7f1f334). New audio-cycle script walks the four-state ring in the exact order above (wpctl-backed, explicit set-mute so the pair can't desync, 6 tests) on Super+M; live-verified the full ring on velox. Mic moved left of the speaker and hugs it via paired margins, percentage on the outside. Touchpad toggle moved to Super+Shift+I ("input devices" — d-for-disable was taken at both levels by removemaster and dim-toggle); its tooltip and tests follow. +** DONE [#C] Timer end sends no notification :bug:waybar: +CLOSED: [2026-07-02 Thu] +The end of a wtimer timer didn't fire a desktop notification. Needs reproduction to confirm frequency; priority follows the severity-by-frequency matrix once known (a reliably-missing timer-end alert would rate higher). Origin: roam inbox capture. + +Root-caused 2026-07-02 (dotfiles ca35642). The pipeline works (a live 3s timer fired and persisted), but notify sent everything --urgency=normal and dunstrc delays normal-urgency popups while a fullscreen window has focus — a timer ending mid-video sat invisible until fullscreen exit. Alarms now go critical urgency, which rides the fullscreen_show_critical rule; verified CRITICAL in dunst history. The alarm sound (paplay, separate from dunst) was never affected. +** DONE [#B] Bake captive-portal login into the net panel :feature:network: +CLOSED: [2026-07-01 Wed] +Make the captive-portal login a first-class net-panel feature instead of the one-off =~/.local/bin/hotel-wifi= script. When the engine sees a held portal, offer "Log in to this network" that runs the plain-DNS + clean-browser flow reversibly (disable DoT -> recover the portal URL from the redirect -> open a clean Chrome profile -> restore DoT when online). Reconcile with the existing =net portal= / =captive= helper, whose DNS-hijack-to-gateway model did NOT match the real Hyatt portal. + +Full mechanism writeup, the working script, and the integration plan: [[file:docs/design/2026-06-30-captive-portal-login.org]]. From the 2026-06-30 Hyatt saga. + +*** 2026-06-30 Tue @ 11:40 -0400 Engine core landed (dotfiles a7d7559) +Replaced =net portal='s old captive-helper hand-off with a =portal-login= repair tier: drop DoT to plain DNS, probe the portal URL (302 / meta-refresh), open a throwaway browser profile, spawn a detached watcher that restores DoT once online (or on timeout). =net portal --restore= is the manual fallback. 7 tests. So =net doctor= / the bar's =net portal= hookups already run the real flow now. Remaining: (1) name the DoT-blocking cause in =net diagnose=; (2) a dedicated "Log in to this network" button in the panel's Diagnose/Repair tab (today it rides the generic =net portal=); (3) live validation against a real captive portal (unit-tested only — didn't run it live to avoid disrupting a meeting). + +*** 2026-07-01 Wed @ 22:41:51 -0400 Live-validated end to end against a local captive simulator (dotfiles c1401db) +The last remainder. tests/net/captive_sim.py is a local redirect portal (302s to a login page until "logged in", then a clean 204). NET_PROBE_URL and NET_PORTAL_TRIGGERS point the whole flow at it (an overridden probe skips the interface binding, which can't reach loopback). Ran live on velox, both restore paths verified: online-detect (login click, watcher saw the 204, DoT drop-in restored within ~2s, clean exit) and the timeout fallback (a watcher that never saw online restored DoT at its 300s deadline). Real sudo mv, real resolved restarts, real redirect URL recovery, real clean-profile Chrome — against a temp drop-in dir, so live DNS was untouched. All three remainders are done; the task is closed. The remaining what-if is a real venue's walled-garden quirks, which only an actual portal exercises. + +*** 2026-07-01 Wed @ 21:44:05 -0400 Diagnose names the DoT block; panel gained Log in to This Network (dotfiles 51e0e2d) +Remainders 1 and 2 landed. The dns-resolve step names the DoT pin when resolution is dead and the drop-in exists (sysio.dot_forced), and routes next_action to the portal login. The panel's hidden Open Portal button became a first-class suggested-action "Log in to This Network", shown whenever the report holds a portal signal (portal step with or without a URL, or the DoT-blocked resolution) via the unit-tested viewmodel.wants_portal_login. TDD, 33 suites green. Remainder 3 (live validation against a real portal) still open. + +*** 2026-06-30 Tue @ 14:59:53 -0400 Live test on velox surfaced two fixed bugs + a deeper follow-up +Force portal (panel Repair tab) = =net-popup net portal= = the same portal-login tier. Tested live on @Hyatt_WiFi (already authorized, so no real intercept). Two bugs fixed in dotfiles (TDD, full suite green): +- Chrome first-run wizard fired on every launch — =_open_portal= made a fresh tempfile profile but passed no first-run flags. Added =--no-first-run --no-default-browser-check= + a unit test. +- Flashing sudo prompt for the DoT drop + pointless resolved restart on velox, where the DoT drop-in the code looks for (=/etc/systemd/resolved.conf.d/dns-over-tls.conf=) doesn't exist. Guarded =_disable_dot=/=_restore_dot= to be true no-ops (no sudo, no restart) when there's no DoT drop-in to move; tests assert no systemctl call fires. +** DONE [#B] Consistent red=off across waybar toggle modules :waybar: +CLOSED: [2026-07-01 Wed] +Extend the red=off convention (just added to the touchpad/mouse indicator) to the other toggles — sound volume, microphone mute, and caffeine — so a disabled / muted / off state reads red across the board. Skip the "cross"/slash; the color alone carries it. Origin: roam inbox capture. + +Already implemented (verified 2026-07-01): =style.css= gives =#pulseaudio.muted=, =#pulseaudio.mic.source-muted=, and =#custom-caffeine.inhibited= the off-state color =#d47c59=, matching =#custom-touchpad.disabled=. Note: caffeine's red fires on =.inhibited= (caffeine ON / staying awake), which is arguably the inverse of "off" — leave as-is unless you want strict off=red semantics there. +** DONE [#B] Microphone-mute keybind :feature:waybar:quick: +CLOSED: [2026-07-01 Wed] +A keyboard shortcut to toggle the mic mute. The pulseaudio#mic module shows the state but there's no hotkey to flip it. Wire a hyprland bind to a mic-mute toggle. Origin: roam inbox capture. + +Already implemented (verified 2026-07-01): hyprland.conf binds both =XF86AudioMicMute= and =Super+Shift+A= to =mic-toggle= (no conflict — airplane is Super+Shift+X). +** DONE [#C] Keybind hints in waybar module tooltips :waybar: +CLOSED: [2026-07-02 Thu] +Every module's hover tooltip should list its keyboard shortcut(s), for discoverability. Audit the modules and add the bindings to each tooltip. Origin: roam inbox capture. + +Shipped 2026-07-02 (dotfiles 4c32aec). Audited every module: arrows (Super+[ / Super+]), sysmon (Super+R), net (Super+Shift+N), layout (Super+Shift+←/→), menu (Super+Space / Super+Shift+Q, tooltip enabled), plus the already-hinted dim/caffeine/touchpad/mic/volume. Date/time tooltips document their new right-click actions. Workspaces/window/tray don't take custom tooltips; timer has no keybind. +** CANCELLED [#C] Smooth waybar expansion animation :waybar: +CLOSED: [2026-07-02 Thu] +The cluster expansion jumps instead of animating, and a few systray icons pop in one-by-one afterward, which reads as glitchy. Animate the expansion smoothly if waybar allows it — width transitions are limited, so feasibility is uncertain (hence [#C]). Origin: roam inbox capture. + +Assessed infeasible 2026-07-02: collapse works by config rewrite + SIGUSR2 reload, which rebuilds every widget — nothing survives to transition, GTK3 can't animate add/remove without Revealer (an upstream waybar change), and the tray pop-in is async StatusNotifier re-registration. Full findings + revisit conditions: [[file:docs/design/2026-07-02-waybar-expansion-animation-feasibility.org]]. +** DONE [#C] Optional label on timer/alarm/stopwatch items :feature:waybar: +CLOSED: [2026-07-02 Thu] +Let each wtimer item carry an optional short text label. The data model already supports it (=add_timer/add_alarm/add_stopwatch/add_pomodoro= all take =label=""=, and =_describe= shows =label or type=); the gap is the fuzzel-driven creation flow, which doesn't prompt for a label. Add the optional label prompt on create. Origin: roam inbox capture. + +Shipped 2026-07-02 (dotfiles ca35642): =wtimer new= gained a "label (optional)" fuzzel prompt after the type/value prompts; empty keeps the unlabeled default. 2 new tests (89 total in the suite). +** DONE [#C] Alarm tooltip shows time remaining, not alarm time :bug:waybar:quick: +CLOSED: [2026-07-01 Wed] +The =wtimer= alarm tooltip displays the countdown (time remaining) instead of the alarm's wall-clock fire time. For an alarm set to 2:00pm, the tooltip should name the target time, not "1h 23m left". Fix the tooltip rendering in =wtimer= (dotfiles repo). Origin: roam inbox capture. + +Fixed 2026-07-01 (dotfiles): =_describe= now renders an alarm's wall-clock target via a new =format_clock= helper instead of =format_time(remaining)=. TDD test added; full wtimer suite (87) green. +** DONE [#C] Waybar right-cluster module order :waybar:quick: +CLOSED: [2026-07-01 Wed] +Move the timer module to the rightmost position, just left of the systray, and move the battery/sysmonitor module to second-to-rightmost. Config edit in the waybar config (dotfiles hyprland tier). Origin: roam inbox capture. + +Done 2026-07-01 (dotfiles waybar config): =custom/timer= now sits just left of =tray= with =custom/sysmon= second-to-rightmost. waybar regenerated + reloaded live on velox; visual confirmation pending Craig. +** DONE [#B] Pocketbook finish-or-cancel decision :pocketbook: +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-05-22 +:LAST_REVIEWED: 2026-06-24 :END: -Launching emacs with super+e while a browser window is open in tiled mode leaves focus on the browser instead of moving it to the newly opened emacs window in the main (left) portion of the screen. Expected: the new emacs window takes focus. Noticed 2026-05-22. - -Resolved 2026-05-22: not a focus *failure* but a focus *fight*. Live socket2 capture showed the new (XWayland, non-pgtk Emacs 30.2) frame does get focus on open, then Firefox reclaims it via an activation request because =misc:focus_on_activate=true=. Set it =false= in the dotfiles repo (=3bfba5a=) — new-window focus is a separate path so emacs still focuses on open, but the browser can no longer steal it back. Verified by Craig. -** DONE [#C] Dim inactive windows in Hyprland :hyprland: -CLOSED: [2026-05-27 Wed] +Decided by Craig 2026-07-02, ahead of the scheduled checkpoint: remove pocketbook altogether. Executed same day — pip package uninstalled (user site clean), running instance killed, launcher gone, =pocketbook/= tree removed from the repo, Super+P rebound to toggle-touchpad (P for Pointers; Super+Shift+I unbound, waybar tooltip hint updated — dotfiles a750cb4). The org-capture popup remains the quick-notes surface. +** DONE [#B] Provision Eask in archsetup :tooling:eask: +CLOSED: [2026-07-02 Thu] :PROPERTIES: :LAST_REVIEWED: 2026-05-26 :END: -Shipped in the =~/.dotfiles= repo (=66124e8=): =dim_inactive = true=, =dim_strength = 0.4= (tuned by eye), =dim_special = 0.2= for pyprland scratchpads, and a =no_dim true= window rule for Zoom. The opt-out rule is =no_dim= (underscore), not =nodim= — the latter throws a config-error banner. Config uses Hyprland 0.55's =windowrule = match:class ...= grammar. -** CANCELLED [#A] Prevent X termination and VT switching (security risk) -CLOSED: [2026-05-21 Thu] -If someone grabs laptop at cafe and hits ctrl+alt+backspace, they kill screensaver/X and get console access -Need to disable: ctrl+alt+backspace (zap X) and ctrl+alt+F# (VT switching) -Previous attempts to configure in xorg.conf.d failed - need to investigate what's overriding the settings -Tried: /etc/X11/xorg.conf.d/00-no-vt-or-zap.conf with DontVTSwitch and DontZap options -Removed conflicting setxkbmap statements, gdm, and keyd configs - still didn't work -** DONE [#B] Add Rust installation via rustup instead of pacman package :quick: -CLOSED: [2026-05-26 Tue] -:PROPERTIES: -:LAST_REVIEWED: 2026-05-21 -:END: -Already implemented — =archsetup= lines 1976-1979 (Programming Languages and Utilities) =pacman_install rustup= then =rustup default stable= as the user. Closing on verification; the task predated that work. - -The =rust= package has been removed from archsetup. Need to add Rust installation using =rustup= (the official Rust toolchain manager) instead of the Arch package. - -Steps: -- Install rustup: =pacman -S rustup= -- Initialize default toolchain: =rustup default stable= -- Consider adding to archsetup or post-install script - -Reference: Removed from archsetup on 2025-11-15 -** CANCELLED [#D] Add cpupower installation and enabling to archsetup :quick: -CLOSED: [2026-05-26 Tue] -Implemented, VM-verified, then removed — wrong tool for this fleet. Both machines run active-mode pstate drivers (ratio amd-pstate-epp, velox intel_pstate) where only performance/powersave exist and the driver self-manages frequency via EPP; both correctly sit on powersave. cpupower's governor-forcing only helps older acpi-cpufreq systems, which we don't run. Forcing performance would pin max clocks (worse on the laptop, pointless on the desktop). Dropped from archsetup rather than ship a backwards default. +Shipped 2026-07-02 (speedrun): npm global install block added after the nvm line — runs as $username with --prefix $HOME/.local, display/error_warn wrapped, output to $logfile, matching the claude-code block's shape. The npmrc decision went yes: dotfiles common/.npmrc pins prefix=${HOME}/.local (stowed; hand-linked live, npm config get prefix confirms ~/.local — dotfiles 01627cc). VM assertion added: ~/.local/bin/eask present + ~/.npmrc stowed. Live smoke: eask 0.12.9 on PATH. Full acceptance (fresh-install chime make setup/test) rides the next VM pass. +Add =@emacs-eask/cli= to archsetup's provisioning so fresh machines get it. Eask is installed by hand today and declared nowhere in archsetup or the dotfiles repo, yet both chime and linear-emacs depend on it (their =make setup/test/coverage= shell out to =eask=). Source: handoff from linear-emacs 2026-05-23. -cpupower service configures the default CPU scheduler (powersave or performance) -Install cpupower, configure /etc/default/cpupower, enable service: ~systemctl enable --now cpupower.service~ -** DONE [#C] Airplane-mode toggle robustness follow-ups :quick:solo: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Shipped 2026-06-10 as dotfiles commit =16fbe4e=, TDD'd (23 tests green). Both gaps closed: the toggle now no-ops without a BAT* (same check as waybar-airplane, AIRPLANE_POWER_SUPPLY_DIR override for tests), and an empty recorded brightness at disengage falls back to 100% (AIRPLANE_BRIGHTNESS_DEFAULT) instead of stranding the screen at 35%. -** DONE [#B] protonmail-bridge package service conflicts with Hyprland autostart :cmail: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Craig confirmed resolved 2026-06-10 — the per-machine fix (disable the packaged user service, Hyprland exec-once as sole launcher) has held since 2026-05-22 with no recurrence. +- Add a global npm install after the node block (=archsetup= ~2030, after =aur_install nvm=), modeled on the claude-code native-install block: run as =$username=, wrapped in =display=/=error_warn=, output to =$logfile=. Roughly =sudo -u "$username" bash -c 'npm install -g --prefix "$HOME/.local" @emacs-eask/cli'=. +- Pin the prefix to =~/.local= so eask lands at =~/.local/bin/eask= (already on PATH) and the install runs as the user, not root. On the current machine =npm config get prefix= returns =/usr=, so eask was installed with an explicit =--prefix=. +- Decision: also set a persistent user npm prefix (=~/.npmrc= with =prefix=${HOME}/.local=)? If yes, that =~/.npmrc= is a legitimate dotfile to stow; if no, rely on the explicit =--prefix= flag alone. =~/.eask/= is a regenerable cache — leave un-stowed. +- Acceptance: fresh run leaves =eask= on PATH at =~/.local/bin/eask= (no root); =cd ~/code/chime && make setup && make test= works. +** DONE [#C] Waybar timer dialog styling :waybar: +CLOSED: [2026-07-02 Thu] +From Craig's roam capture 2026-07-02: style the timer module dialogs like the screenshot dialog — tighter window, icons on the selections, colon+space after the prompt. -The =protonmail-bridge= package ships an enabled systemd user service (=/usr/lib/systemd/user/protonmail-bridge.service=, =--noninteractive=, =Restart=always=) that double-launches with the Hyprland =exec-once = protonmail-bridge --no-window= GUI autostart. Two symptoms: (1) no tray icon — the headless service grabs ports 127.0.0.1:1143/:1025 before the GUI =--no-window= instance can bind; (2) TLS cert mismatch — the headless service can't reach gnome-keyring (starts outside the graphical session), falls back to its own self-signed cert, so =mbsync=/mu4e and cmail-action.py fail STARTTLS against =~/.config/protonbridge.pem= with SSL CERTIFICATE_VERIFY_FAILED. +Shipped 2026-07-02 (dotfiles 9ffcba7): dialogs size to content, type menu carries the kind glyphs, prompts end ": ". Three new tests; screenshot-verified live. +** DONE [#B] Waybar collapse jumps client windows :bug:waybar:hyprland: +CLOSED: [2026-07-02 Thu] +From Craig's roam capture 2026-07-02: collapsing/expanding (and any waybar teardown) snapped every tiled window up and back down; hold the clients still and let only the bar change. -Fix applied per-machine 2026-05-22: =systemctl --user disable --now protonmail-bridge.service=, leaving the Hyprland exec-once GUI as the sole bridge (tray icon returns, served cert matches, =mbsync -a= clean). A fresh install re-enables the package service, so make it durable: mask/disable =protonmail-bridge.service= during install (likely in =scripts/cmail-setup-finish.sh=) and document that the Hyprland exec-once is the intended launcher — never run both. Source: handoff from .emacs.d 2026-05-22. -** DONE [#B] Add signal-cli to the standard install :tooling:signal:solo: -CLOSED: [2026-06-10 Wed] +Shipped 2026-07-02 (dotfiles 4b1a4ec): waybar now runs exclusive:false and the new waybar-reserve script statically reserves the bar strip per monitor (wired as exec so config reloads re-apply it). Verified live: client geometry held constant through bar kill, relaunch, and a collapse round-trip. Eight new tests (script + pairing). +** DONE [#B] Bluetooth panel + bar module :feature:waybar:bluetooth: +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-10 +:SPEC_ID: 1271a845-4463-4831-9902-990eda6b2265 :END: -Shipped 2026-06-10 as archsetup commit =1229fb2= — =aur_install signal-cli= beside signal-desktop, with the JRE/update-cadence/manual-linking caveats as comments. +Spec: [[file:docs/design/2026-07-02-bluetooth-panel-spec.org]] (IMPLEMENTED 2026-07-02 — all five phases shipped same day: engine eb2230f, panel 76b2c05, bar module e372de3, bt-priv + blueman retirement 2a026b1/d8d8c53, install wiring proven by VM assertions). Residual: the phase 4-5 VM assertions run on the next VM pass; ratio picks up the package removal + hand-links on its trip list. -Add =signal-cli= (AUR) to the regular package set so every provisioned machine has it. It's the headless JSON-RPC engine for an in-Emacs Signal client (a =signel= fork) that's the same across all machines. Source: handoff from .emacs.d 2026-05-26. +A bluetooth panel driving a CLI underneath (bluetoothctl one-shot verbs), consistent in look and feel with the net panel (GTK4 + layer-shell + Blueprint, humble-object presenter, verify-everything). Minimalistic interface, full functionality, plus a diagnostics/troubleshooting section mirroring the net panel's Diagnostics tab. Bar module glyph opens it. Craig's ask (2026-07-02): follow UX/UI best practices; where the net panel's patterns conflict with best practices, file a net-panel bug task rather than clone the flaw. -- =aur_install signal-cli= in the appropriate section (comms/messaging or AUR utilities). -- Runtime needs a JRE (OpenJDK 17+) — already satisfied by =jdk-openjdk=; note it as a dependency if the install set is ever trimmed. -- Keep-current caveat: signal-cli must update roughly every 3 months or Signal-Server rejects it (client-version floor moves). It belongs in the regularly-updated AUR set, not pinned. -- Linking is per-machine and interactive (QR scan from phone's Linked Devices), so that stays manual. archsetup only guarantees the binary is present. -** DONE [#B] Mic-mute keybind + waybar indicator :waybar:hyprland:solo: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Shipped 2026-06-10 as dotfiles commit =07d056c= (script + 5 unit tests + bind + waybar module + CSS in all three theme files; old CTRL+ALT+SPACE bind removed). Verified live on ratio: state flips in wpctl, indicator renders both states with correct glyphs and colors, notifications fire. velox picks it up via pull + restow. +*** 2026-07-02 Thu @ 13:30:42 -0400 Shipped phase 1 — the bt engine package (dotfiles eb2230f) +=bluetooth/src/bt/= mirrors the net engine's layout: btctl parsing boundary (show/devices/info, connect-error classifier), sysio rfkill/airplane, audio module over pw-dump/wpctl (HSP probe + A2DP switch repair with verify-after), redacted eventlog, six repair tiers, and the doctor chain (adapter → rfkill → service → powered → devices → audio profile) with safe auto-repairs behind =--fix= (never auto-connects; airplane blocks are named, not fought). 101 tests over fake binaries; 42 suites green (=make test= glob auto-discovered =tests/bt/= — gate check verified). Live read-only on velox: =bt status= + =bt doctor= read the real adapter/devices/audio graph; =~/.local/bin/bt= hand-linked (no restow under running Hyprland). Ground truth vs spec: profile inventory needs =pw-dump= (wpctl can't enumerate), and the card's =bluez5.profile= prop is unreliable — sink node's =api.bluez5.profile= is authoritative. Deferred INTO phase 2: the shared dupre css factoring (net's css is an inline string in =gui.py=, not an asset — factoring it without the bt-panel consumer just risks the working net panel). -A single mute state in PipeWire, reachable from a keybind and a waybar indicator, each reflecting the other. Agreed design (2026-06-10): +*** 2026-07-02 Thu @ 14:15:27 -0400 Shipped phase 2 — the GTK panel (dotfiles 76b2c05) +Cloned the net panel's shape over the phase 1 engine: GTK-free PanelModel + viewmodel (69 new tests, display-free), Blueprint pages (Devices with the adapter power row + Paired/Nearby sub-views; Diagnostics with the doctor cascade, inline fix buttons mapped from step =repair= keys, Advanced tool selector), gui.py controller (layer-shell OVERLAY 380x520 TOP+RIGHT, Esc closes, single-instance toggle, bg worker, passkey + confirm dialogs), pairing pty state machine (=bt/pairing.py=: confirm-passkey yes/no with default-deny, display-passkey, bounded deadline, tested over the fake's interactive mode), manage.py op envelopes shared by CLI and panel (cli refactored onto it; power + discoverable verbs added), =bt panel= subcommand + =bt-panel= toggle wrapper (hand-linked into =~/.local/bin=, no restow under live Hyprland). Shared dupre css factored: net's inline =_CSS= → =hyprland/.config/themes/dupre/panel.css= with =dupre-*= classes, both panels load it (stowed path first, repo-relative fallback; hand-linked =~/.config/themes/dupre/panel.css=). Super+Shift+B rebound blueman-manager → bt-panel (hyprctl reloaded, live). 43 suites green (=make test= exit 0). DEFERRED pending Zoom ending: the AT-SPI smoke (=make test-panel-bt=, written and wired) and any visual check of either panel with the factored css — both need a visible window. Gotcha for posterity: the old =test_panel_stub_exits_two= CLI test ran =bt panel= for real once cmd_panel was wired and launched the panel on the live compositor mid-meeting for ~30s before the test timeout killed it; it now asserts parser wiring only — never run =bt panel= inside =make test=. -- *Keybind*: Super+Shift+A (=bindl= so it works on the lock screen), running a =mic-toggle= script in =hyprland/.local/bin/=: =wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle=, then read the new state and fire =notify= (alert "Mic muted" / success "Mic live"). wpctl targets PipeWire's default source, so the bind keeps working if the default mic changes (ratio has three capture devices). -- *Waybar indicator*: a second pulseaudio module instance (=pulseaudio#mic=) using =format-source= / =format-source-muted= — waybar subscribes to PipeWire events natively, so the keybind and the click both update the icon with no signal plumbing (unlike =custom/dim=). =on-click= runs the same wpctl toggle. -- *Icons*: Nerd Font MD glyphs — mic (U+F036C) live, mic-off (U+F036D) muted — matching the MD volume glyphs already in the pulseaudio block. Verify by rendering, not by name (BerkeleyMono remaps codepoints; see the 2026-06-10 glyph lesson). -- *Coloring* (dupre): default =#969385= when live; =#d47c59= when muted — same semantic as =#custom-touchpad.disabled= (an input device turned off). The gold =#d7af5f= stays reserved for active/attention states (airplane, dim). Mirror the rule in the hudson theme's waybar css with its palette equivalent. -- *Remove the old mechanism entirely*: the =CTRL ALT, SPACE= amixer Capture-toggle bind in =hyprland.conf= (~line 325) — ALSA-level, fragile with multiple capture devices, brittle notify grep chain. +*** 2026-07-02 Thu @ 15:06:00 -0400 Shipped phase 3 — the bar module + blueman retirement (dotfiles e372de3) +=custom/bluetooth= over the engine: waybar-bt shim + =bt/indicator.py= (state-following glyph — slashed off/blocked/absent, plain dim idle, connected mark white; low-battery <15% adds a red pango percentage to the glyph; tooltip = connected devices with battery + Super+Shift+B hint). Signal 10; the panel pokes =pkill -RTMIN+10 -x waybar= after each status reload. Blueman retired from the Hyprland session: exec-once + both windowrules removed, applet killed live; waybar relaunched on the runtime config and the module verified on the bar (connected glyph, blueman tray icon gone). Theme drift guard caught that themes/*/waybar.css edits must mirror into the live =waybar/style.css= — all three updated. 43 suites green. ALSO closed this pass: the deferred phase 2 visual batch (bt AT-SPI smoke green after fixing its Connect/Disconnect state-following assertion — c1a8219; net smoke green on the factored css; both panels eyeballed correct in dupre). Left for phase 4: package removal (blueman out of archsetup), sxhkdrc's dwm blueman-manager binding decision rides that pass. -Lives in the dotfiles repo (=hyprland/.config/hypr/hyprland.conf=, =hyprland/.config/waybar/=, =hyprland/.local/bin/=). TDD the =mic-toggle= script per the dotfiles suite. velox picks it up via pull + restow. -** DONE [#B] Waybar theme-CSS drift — live style.css ahead of theme copies :waybar:hyprland:solo: -CLOSED: [2026-06-11 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-11 -:END: -Shipped 2026-06-10/11 across two dotfiles commits: =1589734= reconciled dupre to a byte-copy of the live style.css, rebuilt hudson with the full live selector set in its palette, and added the guard suite (dupre must equal live; hudson must cover every live selector). The same guards were extended to the foot.ini family in =c5e699b= when the per-host work touched it (set-theme overwrites foot.ini the same way). The symlink-instead-of-cp alternative wasn't needed — the test guard catches drift at =make test= time. -** DONE [#B] Add =uv= to the install playbook :tooling:python:solo: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Shipped 2026-06-10 as archsetup commit =3e22b06= — =pacman_install uv= in the Python tooling block (uv 0.11.19 in extra). Exercised by the same-day hyprland VM run. - -Add =uv= (Astral's Python package + script runner) to archsetup so fresh machines pick it up automatically. Currently installed by hand on ratio + velox (=/usr/bin/uv= 0.11.15), not in the standard set — a fresh install would skip it, and project scripts using PEP 723 inline-script metadata (=#!/usr/bin/env -S uv run --script= shebangs) would fail with =env: uv: No such file or directory=. Source: handoff from health 2026-05-29 ([[file:assets/outbox/2026-05-29-1127-from-health-todo-a-add-uv-to-the-install-playbook.org][outbox copy]]). +*** 2026-07-02 Thu @ 15:16:51 -0400 Shipped phase 4 — bt-priv shim, blueman out, VM assertions +Dotfiles =2a026b1=: the stowed =bt-priv= shim over the phase-2 =bt.priv= module (one verb, =restart-bluetooth=; verified end-to-end against the symlinked fake-systemctl — rc 0 with =BT_SUDO= empty, rc 2 on bad verb/usage; hand-linked into =~/.local/bin=), and the sxhkd =Super+Shift+B= bind repointed from the retired blueman-manager to =st -e bluetoothctl= (the decided terminal fallback — the GTK panel is Wayland-only, and the bt CLI is hyprland-tier so dwm never gets it). 43 dotfiles suites green. archsetup: blueman dropped from the =desktop_environment= bluetooth loop (bluez + bluez-utils stay, solaar untouched); VM assertions added to =test_packages.py= (bluez/bluez-utils installed, blueman NOT installed as the retirement regression guard — collected 15, exercised on the next VM run since VM tests run committed code); =bash -n= + =py_compile= + =make test-unit= green. SUDOERS: no new rule needed, same conclusion as net-priv (2026-07-01 entry) — archsetup:1089 grants the primary user blanket =NOPASSWD: ALL=, which covers =systemctl restart bluetooth=; a narrow bt-priv rule would be dead config under the blanket grant, so phase 5's "sudoers placed" item is satisfied by the existing grant. LIVE: blueman package removed from velox (=pacman -Rns=, decision "drop it outright, both machines"); ratio needs the same + the bt-priv hand-link on its trip list. -Health requested [#A] (load-bearing for the PEP 723 pattern they're promoting + the rulesets template-script proposal). Demoted to [#B] for archsetup: no current install is broken (uv is pre-installed everywhere it's needed), and the shape matches the existing [#B] tooling-codification tasks (eask, signal-cli) — load-bearing for other projects, manually installed today, codify so fresh installs pick it up. +*** 2026-07-02 Thu @ 15:19:58 -0400 Shipped phase 5 — install-default wiring proven by VM assertions +No new install code was needed: the waybar =custom/bluetooth= module, the =Super+Shift+B= → =bt-panel= bind (hyprland.conf), and the shared =themes/dupre/panel.css= all live in the dotfiles hyprland tier, so the existing clone + =make stow hyprland= step lands them on a fresh install; sudoers is covered by the blanket grant (phase 4 conclusion). The phase's substance is the proof: =test_desktop.py= gained hyprland-gated assertions that the four bt bins (=bt=, =bt-panel=, =bt-priv=, =waybar-bt=) are stowed executable in =~/.local/bin= (either stow shape — per-file symlink or folded dir), the waybar config carries =custom/bluetooth=, hyprland.conf carries the =bt-panel= bind, and the stowed theme has =panel.css=. Collected 30 in =test_desktop.py=; exercised on the next VM run (VM tests run committed code). -- *Install via pacman* — =uv= is in extra (=pacman -S uv=). Cleanest path; auto-updates with the rest of the system. AUR =uv-bin= and Astral's official installer are alternatives but add a non-pacman path to maintain. -- *Placement* — alongside the existing language-tooling block in =archsetup= (near =rustup=, =nvm=, or the Python set). Decide the exact section at implementation time. -- *Verification* — post-install =which uv && uv --version=; PEP 723 end-to-end check per the health handoff (=/tmp/uv-test.py= shebang script with inline =requests= dep). - -Related: the new [#B] LLM task above may grow scripts that benefit from PEP 723 (e.g. =scripts/llm-smoke-test.sh= if Python-based). =uv= landing here removes that friction. -** DONE [#A] Separate dotfiles from archsetup -CLOSED: [2026-06-09 Tue] +*** 2026-07-02 Thu @ 15:19:58 -0400 Test surface complete across all phases +Everything the surface named exists and is green: engine suites over fake binaries (phase 1, 101 tests — btctl parse, doctor chain, A2DP repair verify), PanelModel presenter suite (phase 2, 69 tests), pairing state-machine suite (passkey confirm / NoInputNoOutput / timeout, over the fake's interactive mode), bar-module suite (phase 3), gated AT-SPI smoke (=make test-panel-bt=, run green live), and the phase 4-5 VM assertions (=test_packages.py= bluetooth stack + blueman-absent guard; =test_desktop.py= panel wiring). 43 dotfiles suites green; VM assertions await the next VM run. +** DONE [#B] All error messages should be actionable with recovery steps +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-09 +:LAST_REVIEWED: 2026-06-24 :END: -*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Dotfile separation plan -Approach: keep =dotfiles/= committed in this repo as the working default (Craig's machines and CI keep functioning untouched), but make the *source location* a config variable. The install script learns one new conf key — =DOTFILES_REPO= / =DOTFILES_BRANCH= — and when set, clones that repo into =~/.dotfiles= and stows from there instead of from =dotfiles/= inside archsetup. The Makefile gets a =DOTFILES= override env var so the same stow targets work whether dotfiles live in-repo or elsewhere. No submodule (adds fragility for a curl|bash installer); a separate published =archsetup-dotfiles= repo is optional follow-up, not a blocker. - -1. Add conf keys to =archsetup.conf.example= under the "Git Repositories" block (after line 57): =DOTFILES_REPO= (commented, with note "leave unset to use the dotfiles bundled with archsetup"), =DOTFILES_BRANCH= (default =main=), and =DOTFILES_DIR= (target clone path, default =~/.dotfiles=). Document that a user's repo must have =common/= plus optionally =dwm/= and =hyprland/= subdirs that stow cleanly to =~=. -2. In =archsetup= lines 114-122, map =DOTFILES_REPO=/=DOTFILES_BRANCH=/=DOTFILES_DIR= to lowercase vars. At lines 136-146, leave =dotfiles_dir="$archsetup_dir/dotfiles"= as the fallback default and add =dotfiles_repo="${dotfiles_repo:-}"=. -3. In =user_customizations()= (lines 828-854): after the archsetup clone (line 838-841), branch — if =dotfiles_repo= is non-empty, =git clone --depth 1 --branch "$dotfiles_branch" "$dotfiles_repo" "$dotfiles_clone_dir"= (chown to user) and set =dotfiles_dir="$dotfiles_clone_dir"=; else keep =dotfiles_dir="$user_archsetup_dir/dotfiles"= (line 844). The stow calls at lines 847-854 stay as-is since they just =cd "$dotfiles_dir"=. Guard the hyprland stow (851) so it no-ops if the user repo has no =hyprland/= dir. -4. The waybar-battery sed block (lines 856-865) and the =git restore= step (lines 896-902) both assume Craig's exact files — wrap each in an existence check (=[[ -f "$waybar_config" ]]=, and only =git -C "$dotfiles_dir" restore .= when =dotfiles_dir= is a git repo). Right now they'd error on a foreign dotfiles tree. -5. =Makefile= line 5: change =DOTFILES := $(shell pwd)/dotfiles= to =DOTFILES ?= $(shell pwd)/dotfiles= so a user with external dotfiles runs =make stow hyprland DOTFILES=~/.dotfiles=. =reset= (line 123, =git checkout -- dotfiles/=) and =import= (writes to =$(DOTFILES)/$(DEST)=) already key off =$(DOTFILES)= except that one hardcoded path — fix line 123 to =git -C $(DOTFILES) checkout -- .=. Update the =help= text (lines 16-45) to mention the =DOTFILES== override. -6. Migration: this is purely additive. Default behavior = today's behavior, so Craig's existing machines and =make test= VMs are unaffected. Craig can later extract =dotfiles/= to =git.cjennings.net/archsetup-dotfiles= and set =DOTFILES_REPO= in his own =archsetup.conf= — but that's his choice and a separate commit; the in-repo copy stays as the canonical default. Update =CLAUDE.md= "Project Structure" + "Makefile Targets" sections to document the override. -7. Simple-UX summary to put in =archsetup.conf.example= and README: "Accept the defaults → you get the bundled dotfiles (DWM or Hyprland). Bring your own → set =DOTFILES_REPO= to your git URL; it gets cloned to =~/.dotfiles= and stowed automatically." Optional stretch: ship a tiny =dotfiles/minimal/= (bash/zsh/git/tmux only) and a conf option =DESKTOP_ENV=none= path that stows just that. - -Open questions for Craig: -- Extract =dotfiles/= to a standalone repo now, or defer? (Plan above defers — keeps it as the in-repo default.) -- Clone target: =~/.dotfiles= vs =~/code/dotfiles= vs let the user pick via =DOTFILES_DIR=? -- Should =DOTFILES_REPO= unset mean "bundled dotfiles" (proposed) or "no dotfiles at all"? -- Do you want the minimal/headless starter set (=dotfiles/minimal/=), or out of scope? -- Keep =git restore=/=git checkout= conflict-recovery behavior only when dotfiles are a git checkout, and silently skip otherwise — acceptable? - -*** 2026-05-13 Wed @ 13:07:54 -0500 AI Response: Open questions answered; full spec written -All 5 questions resolved: extract now (Q1), clone to =~/.dotfiles= (Q2), no opt-out — =DOTFILES_REPO= always has a default (Q3), include =minimal/= as Tier B with TUI apps (Q4), error out if dotfiles dir isn't a git checkout (Q5). Full spec at [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]] — covers the =minimal/= tree, SSH/GPG availability, three-phase implementation plan (Phase 1: extract + populate new repo at =cjennings.net/archsetup-dotfiles.git=; Phase 2: wire archsetup + VM test; Phase 3: migrate machines + remove =dotfiles/=), commit map, and open observations. Implementation gated on spec review. - -*** 2026-05-14 Thu Review docs/PLAN-dotfiles-separation.org -CLOSED: [2026-05-14 Thu] -Review the spec for accuracy, edge cases, and scope. Flag changes before implementation starts. See [[file:docs/PLAN-dotfiles-separation.org][docs/PLAN-dotfiles-separation.org]]. - -*** 2026-05-14 Thu @ 21:43:41 -0500 AI Response: Review resolved; spec locked for Phase 1 -Walked the spec's 5 open questions plus my 5 review concerns. Locked: URL =https://git.cjennings.net/dotfiles.git= (anonymous HTTPS read confirmed against existing repos at the same host), bare repo path =/var/git/dotfiles.git=, scope = Phase 1 only (~30 min). Added =environment.d/envvars.conf= (with rofi path stripped) and =systemd/user/emacs.service= to the =minimal/= tree; skipped =ncmpcpp= and =systemd/user/geoclue-agent.service=. Phase 2/3 constraints folded into the spec body for the executor: =DESKTOP_ENV=none= VM test required (was optional), clone uses =sudo -u "$username"= to avoid chown-after races, Phase 3 unstow/restow runs without an intermediate Hyprland reload, dotfiles repo can't go on GitHub until secrets cleanup ships, and Step 3.3 documents the post-install update flow. Latest spec at =docs/PLAN-dotfiles-separation.org= (=817d939=). End-of-day Phase 1 session reads from there and executes. - -*** 2026-05-22 Fri @ 13:41:08 -0500 Phase 1 executed — dotfiles repo live on cjennings.net -Created the bare repo at =/var/git/dotfiles.git=, extracted =dotfiles/= from archsetup with =git filter-repo --subdirectory-filter= (229 commits, per-file history preserved), built the =minimal/= stow target per the spec, and pushed to =git@cjennings.net:dotfiles.git= (HEAD =68daeab=). Anonymous read at =https://git.cjennings.net/dotfiles.git= confirmed. Two spec corrections committed in archsetup (=7c26495=): push URL switched to SSH (HTTPS is read-only), and =minimal/.profile.d/= now ships 5 files including =claude.sh= (added on Craig's call, post-dated the spec lock). Phase 2 (wire archsetup config + VM test, ~2-3 hrs) and Phase 3 (migrate machines, remove =dotfiles/= from archsetup) remain. - -*** 2026-05-22 Fri @ 17:05 -0500 Phase 2 shipped — archsetup clones the dotfiles repo -Wired archsetup to the external dotfiles repo: clones =DOTFILES_REPO= to =~/.dotfiles= and stows per =DESKTOP_ENV= (dwm/hyprland → common + that DE; none → minimal). Added =DOTFILES_REPO=/=BRANCH=/=DIR= config keys + validation; test harness serves the repo to the VM as =/tmp/dotfiles-test=. Commits =bab6901= (feat) + =68172c8= (test infra), pushed to origin/main. Spec-directed =sudo -u= clone hit a real bug — =useradd -m= skips the home-dir chown when =/home/$username= pre-exists (root-owned), so the user-clone failed with Permission denied; fixed by cloning as root + =chown -R= (mirrors the archsetup clone). git restore now runs for all DE paths (minimal ships skel-colliding .bashrc etc.). - -*** 2026-05-22 Fri @ 18:10 -0500 Phase 3.1 + 3.3 done — this machine on ~/.dotfiles -Migrated this workstation: cloned the dotfiles repo to =~/.dotfiles=, committed the gpg-agent SSH routing (=.zshenv= + =envvars.conf=) that was uncommitted in the live tree as =888a599= in the dotfiles repo, then =make unstow hyprland= + =make stow hyprland DOTFILES=~/.dotfiles=. Snag: unstowing while Hyprland ran made it write a stub hyprland.conf that blocked the restow — quit Hyprland, removed the stub, restowed clean. All symlinks now resolve into =~/.dotfiles=. CLAUDE.md updated with the external-repo docs + migration steps + the quit-Hyprland gotcha (=e1810ce=). Remaining: 3.2 (=git rm dotfiles/=) blocked until ratio + velox migrate the same way. - -*** 2026-05-22 Fri @ 21:20 -0500 velox migrated to ~/.dotfiles (laptop overrides preserved) -ratio is THIS machine (was "fractal" pre-reinstall) — migrated in 3.1. velox migrated over SSH (Craig quit its Hyprland): cloned ~/.dotfiles, stowed common+hyprland from it. velox carries deliberate laptop-local real-file overrides (foot.ini font 12, pypr config.toml laptop scratchpad sizing, waybar config battery module) that shadow stow — preserved them as local real files (backed up, restowed the rest, restored the overrides). All machines now on ~/.dotfiles. - -*** 2026-06-02 Tue @ 12:16:54 -0500 Phase 3.2 done — removed in-repo dotfiles/ from archsetup -git rm'd the in-repo =dotfiles/= tree (831 files) now that ratio + velox both stow from =~/.dotfiles=; the installer already clones DOTFILES_REPO so nothing read it at install time. Stripped the stow targets from archsetup's Makefile (kept VM-integration + the safe-rm-rf installer-helper suite). Updated CLAUDE.md (Project Structure, Makefile Targets, Dotfiles Repository, Script Counts, Theme/Key-Config path refs) and README.md (dotfile-management, theme, DE, unit-test sections) to point at =~/.dotfiles=; the README had been describing the pre-Phase-2 in-repo model. Commit b10cba5 on archsetup origin/main. velox + ratio local clones drop dotfiles/ on their next archsetup pull (ratio: see the "Pull Phase 3.2 changes onto ratio" task). 4 untracked calibre cache/annotation files that were never committed got moved aside to /tmp/archsetup-dotfiles-orphan-untracked-20260602 (disposable reading-position markers). - -*** 2026-06-02 Tue @ 12:16:54 -0500 Migrated script unit-test suites + a Makefile into ~/.dotfiles -Gave =~/.dotfiles= its own Makefile rather than repointing archsetup's =DOTFILES= default — the dotfiles repo now owns its stow tooling and tests, so it manages and validates standalone (relevant to the open-source release too). Authored =~/.dotfiles/Makefile= with the stow family (=stow/restow/reset/unstow/import= + check-de/check-dest + DE/DEST machinery) plus a =make test= target (mirrors archsetup's hyphenated-dir test-unit loop). Moved-Makefile fixups: =DOTFILES := $(shell pwd)= (trees at repo root), =reset='s revert scoped to =git checkout -- common $(DE)= (not the whole repo — caught in review), import header/path "dotfiles/$(DEST)" → "$(DEST)", =minimal= added to the import DEST filter only. - -Moved 6 suites (=airplane-mode=, =layout-navigate=, =notify=, =tmux-util=, =waybar-airplane=, =waybar-touchpad=) into =~/.dotfiles/tests/=, dropping the =dotfiles/= =SCRIPT=-path prefix (=REPO_ROOT= is now the dotfiles root), and copied their fixtures (=layout-navigate/fake-hyprctl=, =tmux-util/fake-{fzf,kill,sleep,tmux}=). =waybar-netspeed='s suite was already there. =safe-rm-rf= stayed in archsetup (it tests the installer, not a dotfile). =make test= green: 7 suites, 124 tests. Committed 59b10c4 + pushed to the dotfiles repo. =minimal= is a standalone tree (stowed alone, not =common + minimal=), so a =make stow minimal= target needs its own branch — deferred as a small follow-up; the move kept stow/restow/reset/unstow behavior-identical to archsetup (dwm/hyprland). - -*** 2026-06-09 Tue @ 19:21:36 -0500 Pulled Phase 3.2 onto ratio + cleaned dangling links -ratio's archsetup clone was already current with origin/main (Phase 3.2 pulled), but the migration had left stale symlinks pointing into the now-deleted =~/code/archsetup/dotfiles=: =~/.config/calibre= plus a manual =~/music/radio/= playlist farm (73 broken =.m3u= links) and one dead reference under =~/projects/home/reconciliation=. Re-pointed calibre into =~/.dotfiles/common/.config/calibre=. Deleted the 73 radio links — dead and redundant, since the same playlists already stow correctly to =~/music/*.m3u=, which is what mpd reads (=music_directory=/=playlist_directory= both =~/music=) — and removed the reconciliation link. ratio now has zero archsetup-dangling symlinks. (The ~3400 other dangling links in =~= are unrelated system/flatpak noise: ca-certificates, =/run/host=, =/bin=.) -** DONE [#B] Cleaner per-machine override mechanism for the dotfiles repo -CLOSED: [2026-06-11 Thu] +Shipped 2026-07-02 (speedrun). Structural fix at the helper: =error_fatal= now takes an optional third recovery-hint arg and every fatal prints the last five log lines inline, the full log path, the per-site "Fix:" when given, and the resume pointer (step markers mean a re-run continues where it stopped) — so even a hint-less fatal is actionable. All 17 fatal call sites got specific hints (keyring reinit, mirrorlist switch, userdel/USERNAME conflict, base-devel for makepkg, DESKTOP_ENV values, dotfiles-dir cleanup, tmpfs sizing, aur.archlinux.org reachability). The end-of-run Error Summary now closes with the grep-the-log line and the fix-and-re-run pointer. =error_warn= already carried what-failed + exit code into the summary; unchanged. +** DONE [#B] Improve logging consistency +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-11 +:LAST_REVIEWED: 2026-06-24 :END: -Shipped 2026-06-11 as dotfiles =c5e699b= after spec review (all five questions decided — see the spec's Status table). Host tiers =ratio/= + =velox/= auto-included by every stow target; first tenants: hypr local.conf (velox HiDPI scale + XWayland toolkit env, replacing the Zoom per-app hack), pypr whole-file split, foot font via per-host host.ini include. waybar stays shared (velox's was stale, not divergent). velox restows cleanly for the first time and caught up on all pending dotfiles work. Drift guards extended to foot.ini; Makefile host logic unit-tested (15 suites green). Phase 5 (installer) filed as a follow-on below. velox needs a Hyprland restart for the env vars — see Manual testing. - -velox keeps laptop-specific configs (foot font, pypr scratchpad sizing for 2256x1504, waybar battery) as local REAL files shadowing the stow symlinks. That's fragile: any =make restow= on velox re-conflicts (hit exactly this during the 2026-05-22 migration — stow aborts on the real files). The =~/.dotfiles= model needs a real per-machine override story (a =minimal/=-style per-host package, a documented local-override convention with =.stow-local-ignore=, or host-conditional includes) so overrides survive restows without manual backup/restore. - -Spec: [[file:docs/PLAN-per-host-overrides.org][docs/PLAN-per-host-overrides.org]] — gated on review before implementation. - -*** 2026-06-11 Wed @ 04:40:00 -0500 AI Response: Second restow-conflict instance on velox -The roam-units rollout hit this again: =make restow hyprland= on velox aborted on three real files (foot.ini font size 12, the velox-tuned pypr config.toml, an older waybar config). The roam systemd units were linked manually to unblock; everything else velox should have picked up from recent dotfiles work (mic-toggle, quick-capture, airplane-mode hardening, ranger plugin) is still NOT stowed there — velox's tree stays partially stale until this mechanism ships. That raises this task's practical urgency: velox can no longer cleanly receive dotfiles changes at all. - -*** 2026-05-26 Tue @ 10:21:08 -0500 AI Response: Spec written, gated on review -Surfaced by a HiDPI scaling failure: a per-app =QT_SCALE_FACTOR=1.5= in the shared =Zoom.desktop= (meant for velox) made Zoom open enormous on ratio. Reverted that patch to plain =/usr/bin/zoom %U=; the durable fix is this mechanism. Proposed approach: a per-host stow tier (=ratio/=, =velox/=) stowed as =common + hyprland + $(uname -n)=, with the existing =conf.d/*.conf= glob as the first clean tenant — move =local.conf= out of the shared =hyprland/= tier into per-host tiers so each machine gets its own (HiDPI monitor scale + =env = QT_SCALE_FACTOR/GDK_SCALE= on velox, minimal on ratio). XWayland apps don't scale via the compositor (=force_zero_scaling=true=), so toolkit env vars set in =conf.d= are the right layer — kills per-app =.desktop= hacks. Open question in the spec: whole-file configs with no include directive (waybar JSON, pypr toml) need a separate strategy. Full design + 5 open questions for Craig in the spec. -** DONE [#B] Verify Phase 2 in the VM (hyprland + none) — pending clean run :solo: -CLOSED: [2026-06-10 Wed] +Shipped 2026-07-02 (speedrun), paired with the actionable-errors task. Audit result: the install helpers (pacman_install/aur_install/retry_install/run_task/git_install/pipx) and error helpers already tee/append everything to $logfile — the gaps were direct mutations whose stderr went to the console and vanished. Swept every =sed -i= and file-write mutation lacking capture (locale.gen uncomment, pacman.conf ParallelDownloads/Color + multilib, waybar battery removal x3, wireless-regdom, geoclue BeaconDB, paccache, BRIO udev rule, fstab fmask, mkinitcpio HOOKS, sudoers append, ufw status read): each now sends stderr to $logfile, and the previously-silent ones (locale.gen, pacman.conf, multilib, waybar, regdom, geoclue, paccache, udev) gained =error_warn= handlers so failures land in the summary instead of passing silently. Verified: bash -n clean, 10 unit suites green, shellcheck warning-diff vs HEAD empty (no new findings). +** DONE [#B] Add NVIDIA preflight check for Hyprland +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-10 +:LAST_REVIEWED: 2026-05-21 :END: -Both runs clean on 2026-06-10. Hyprland (=make test=, results =20260610-151228=): 52 passed / 0 failed, and the same-day uv + signal-cli install additions were exercised in-run. None (results =20260610-165438=-ish, second attempt): 50 passed / 0 failed — the minimal/ tree stowed correctly. The first none attempt failed on a test-harness bug, not the installer: validation.sh hardcoded the common/ symlink target, fixed in =1754a94= (expected path now follows DESKTOP_ENV). The only attributed issue in both runs is the Proton-VPN-daemon-fails-in-VM known noise. The Phase 2 none/minimal path is now verified end-to-end. -** DONE [#C] Investigate the 2026-05-11 VM-test warnings -CLOSED: [2026-06-11 Thu] +Shipped 2026-07-02 (speedrun), TDD. =nvidia_preflight_report= is a pure sed-extractable core (same harness pattern as zig-pin): modalias scan for vendor 10DE — DRM first, PCI display-class (bc03) fallback so an NVIDIA audio function can't false-trigger — then the repo's =nvidia-utils= candidate major checked against 535. Prints the Wayland guidance + env vars (LIBVA_DRIVER_NAME, GBM_BACKEND, __GLX_VENDOR_LIBRARY_NAME, ELECTRON_OZONE_PLATFORM_HINT) and the pre-Turing/AUR-legacy note. preflight_checks aborts on <535/unknown (rc 11), prompts continue/abort on a healthy NVIDIA box (rc 10), silent on non-NVIDIA (rc 0). 9 Normal/Boundary/Error tests over fake modalias trees + a fake pacman (=tests/nvidia-preflight/=, glob-discovered by test-unit — 10 suites green). +** DONE [#C] Wlogout exit-menu buttons are rectangular, not square +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-11 +:LAST_REVIEWED: 2026-06-24 :END: -All five resolved. Four were environment-impossible checks converted to uncounted skips (=ced91c4= + the portal refinement =19015c7=) — socket, portal, mDNS-on-slirp, docker-pre-reboot — and all four skips verified firing in the 2026-06-11 12:56 run (52/0, 1 warning). The fifth (lingering) turned out to be a harness quoting bug, not a logind issue — fixed in =5b51900=, dated entry below. The next clean run should report zero warnings. The 18:36 =make test= run that filed this passed 52/0/5; the sub-entries below carry each investigation. - -*** 2026-06-10 Wed @ 19:07:54 -0500 Hyprland-socket warning converted to a skip -Shipped in =ced91c4=: the check now passes when the socket exists, skips (uncounted) when no Hyprland process is running — the headless-VM state — and warns only in the genuinely odd case of a running compositor with no socket. Verified live: the skip fired in the 2026-06-10 19:06 run. +The wlogout exit menu renders its buttons taller than they are wide on velox, so the cells read as vertical rectangles instead of squares. They render square (centered) correctly on ratio, so this is a per-host / resolution difference, not a flat bug. Fix the button sizing in the wlogout style (=~/.dotfiles/hyprland/.config/wlogout/style.css=) so each cell is square on both hosts. Noticed 2026-05-21. Related: the [#D] VERIFY about wlogout sizing across displays. -*** 2026-06-10 Wed @ 19:07:54 -0500 Portal-query warning converted to a skip -Shipped in =ced91c4= + a follow-up refinement: the first condition (portal process absent) didn't fire because a socket-activated =xdg-desktop-portal= exists even headless; the precondition is really a running compositor, so the skip now keys on =pgrep -x Hyprland= like the socket check. The conf-file checks (the part install controls) still pass/fail normally. The dconf-write angle stays tracked under =[#B] Fix install errors=. +The wlogout config uses fixed pixel margins, which is the likely reason sizing differs across the two displays — adjusting them for the laptop screen is part of the fix (folded in from the former "Test wlogout menu on laptop" VERIFY, 2026-06-24). -*** 2026-06-10 Wed @ 19:07:54 -0500 mDNS-ping warning converted to a slirp-aware skip -Shipped in =ced91c4=: when the VM is on QEMU slirp (a =10.0.2.x= address), the =.local= ping is skipped — multicast genuinely can't pass there — and the =is-enabled= check stands alone. On real networking the full ping test still runs and still warns on failure. Verified live: the skip fired in the 2026-06-10 19:06 run. +Add a regression test so the square-cell fix doesn't silently break on a resolution change: assert the rendered (or computed) wlogout button cells are square across ratio's and velox's resolutions. Dropped :quick: — the cross-host test pushes this past a spare-moment fix. -*** 2026-06-11 Thu @ 12:58:19 -0500 Lingering warning was a harness quoting bug — fixed, hypothesis disproven -make test-keep forensics on the kept VM: the linger file existed (created mid-install), =loginctl show-user cjennings -p Linger= said yes, logind active with zero errors — lingering was correctly enabled all along, so the logind-degraded hypothesis was wrong and archsetup's =enable-linger= calls were always fine. The actual bug was in the check itself (=validation.sh=): it captured =ls path && echo yes=, so a present file produced "path\nyes", which never string-equals "yes" — the check warned on every run regardless of state. Fixed in =5b51900= with =test -e=; the corrected expression verified returning "yes" against the live VM. With this, all five 2026-05-11 warnings are resolved and a clean run should report zero. +Shipped 2026-07-02 (dotfiles 775771b). Keybind now calls a =wlogout-menu= wrapper computing centered margins from the focused monitor (the old fixed L/R 1200 exceeded velox's 1436 logical width). Also fixed two styling defects the geometry hid: invisible unfocused borders (now muted, so the square edge is visible) and hover/focus sharing one gold rule (lock button glowed at launch; focus is now a muted ring). Tests: unit margin-math suite across both hosts' resolutions + portrait + small + bad-geometry, CSS regression suite, and a compositor-gated =make test-wlogout= smoke that launches a no-op probe, screenshots, and measures squareness (velox: 361x361 px, PASS). Ratio's visual eyeball rides the pending ratio sync. +** DONE [#C] Net panel: error toasts auto-dismiss unread :bug:network:waybar: +CLOSED: [2026-07-02 Thu] +Fixed in dotfiles 0f017d4: viewmodel.toast_plan owns the toast policy — errors show sticky and ignore the post-op refresh's empty clear (worst case: a forget failure's error was wiped within ~2s by its own refresh), and the next real status replaces them. Successes keep the 4s fade. 7 policy tests added; 41 suites green. +** DONE [#C] Net panel: verify claimed keyboard navigation :test:network:waybar: +CLOSED: [2026-07-02 Thu] +Found during the bluetooth-panel UX pass (2026-07-02). The V2 spec claims tab-between-sections, arrow-key row navigation, and type-to-filter, but no custom keyboard code exists in the panel — arrows and type-ahead may ride GTK ListBox defaults, tab-between-sections likely doesn't. Verify each claim against the live panel (AT-SPI smoke can assert focus order); implement or strike the claims from the spec so spec and panel agree. -*** 2026-06-10 Wed @ 19:07:54 -0500 Docker warning converted to a pre-reboot skip -Shipped in =ced91c4=: =docker info= success still passes; enabled-but-inactive (the deliberate enable-not-now install state, validated pre-reboot) now skips; active-but-unresponsive still warns — that's the real failure case. Verified live: the skip fired in the 2026-06-10 19:06 run. The enable vs enable-now question for archsetup itself was left as-is (the daemon's weight makes enable-on-boot defensible). +*** 2026-07-02 Thu @ 13:05:00 -0400 Code-level pass done; live probe deferred (Craig in a Zoom meeting) +Code reality (dotfiles net/src/net): Esc close is wired (gui EventControllerKey); row-activated -> primary is wired for both connection lists (pages.py:122,126), so Enter-on-row rides the GTK ListBox activate binding; arrows ride ListBox defaults; NOTHING implements type-to-filter (no search/filter code exists — that claim is false as written); Tab is the plain GTK focus chain, widget by widget, not section jumps. Live AT-SPI probe plan: launch panel in test mode, drive keys via hyprctl dispatch sendshortcut targeted AT THE PANEL WINDOW (never the focused surface — wtype/ydotool absent anyway), gate every key on the panel holding focus, never send Enter on the available list (real connect risk). BLOCKED at 13:05: active window is zoom (meeting) — no test windows, no synthetic input until clear. Then: verify focus order + arrows + no-filter live, strike/reword the spec's keyboard bullet to match. -Note: the run also logged two log-diff meta-warnings — "Found 4 new error lines after archsetup" and "New failed services detected (before: 1, after: 2)". Those correspond to the post-install systemd noise (pam_systemd / logind / Proton VPN) already captured under =[#B] Fix install errors= above; not duplicated here. -** DONE [#B] Enable TLP power management for laptops :quick: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Done live on velox 2026-06-10: tlp 1.10.1 installed, =/etc/tlp.d/01-custom.conf= written (EPP balance_performance/power + platform-profile per power source; 80% charge cap present but commented off), service enabled and active, systemd-rfkill masked per TLP docs. Verified: tlp-stat runs, EPP reads balance_performance on AC. Codified in archsetup commit =adb39f2= as a battery-gated block. -** DONE [#B] Remove unnecessary linux-firmware packages (velox only) :quick: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Done live on velox 2026-06-10. Hardware re-verified first (i915 graphics, ath9k wifi), then removed the meta + 12 subpackages (the task's 9 plus liquidio/mellanox/nfp/qlogic from the finer 2026 split), keeping intel + atheros + whence. The meta needed =-Rdd= — mkinitcpio-firmware declares a dep on it; the dangling dep is cosmetic. Initramfs rebuilt clean (warnings only for absent hardware), wifi stayed connected. Codified in archsetup commit =adb39f2= as a DMI-gated Framework-Intel block. Full confidence needs the next reboot — see Manual testing below. -** DONE [#B] Identify and replace packages no longer in repos -CLOSED: [2026-06-11 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-11 -:END: -Shipped 2026-06-11 as =1f89523=: =scripts/audit-packages.sh= (unit-tested) makes the check repeatable, and its first run over 420 packages found four casualties, all fixed in the same commit — libva-mesa-driver (folded into mesa), nvidia-dkms → nvidia-open-dkms, swww → awww (set-theme's stale swww call fixed in dotfiles =4ea35a1=), libappindicator-gtk3 → libayatana-appindicator. Re-run anytime: =scripts/audit-packages.sh=. -** DONE [#B] Verify package origin for all packages -CLOSED: [2026-06-11 Thu] +*** 2026-07-02 Thu @ 14:57:18 -0400 Ran the live probe; spec bullet reworded to match reality +Zoom ended ~14:45; probe ran per plan (panel in test mode, hyprctl dispatch sendshortcut targeted at the panel address, every key gated on panel focus, Enter never sent). Verdicts: arrows move row focus and Enter rides the ListBox activate binding (TRUE — kept); Esc closes (TRUE — kept); Tab is the plain GTK widget-by-widget chain and inside a list crawls row by row, no section jumps (claim FALSE — struck); type-to-filter does not exist (claim FALSE — struck; typing into the 24-row Saved list filtered nothing). Spec's Keyboard bullet reworded with the live evidence and a note that section-jump Tab or filtering would be new work. Probe gotchas for reuse: AT-SPI list items have empty accessible names, so row identity needs get_index_in_parent(); a killed test panel can leave a windowless single-instance process that eats the next launch via D-Bus activation — pkill -9 -f 'net panel$' and wait before relaunching. +** CANCELLED [#C] Pocketbook development backlog :pocketbook: +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-11 -:END: -Covered by the same auditor (=1f89523=): it flags movers in both directions. Current state: zero official packages wrongly routed through aur_install-only territory; 15 aur_install entries have graduated to official repos (duf, flameshot, gist, inxi, nsxiv, nvm, papirus-icon-theme, ptyxis, qt5ct, qt6ct, ttf-lato, ueberzug, warpinator, xcolor, xdg-desktop-portal-hyprland). Left as-is deliberately — yay resolves repo packages fine — but switching them to pacman_install is a clean :quick: cleanup whenever wanted; the auditor lists them on every run. -** DONE [#B] Automate script usage tracking :solo: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Shipped 2026-06-10 as dotfiles commit =e5044b8=: =script-usage= in =common/.local/bin/= (10 unit tests). Reads zsh extended + bash history, reports last-used date per ~/.local/bin script, =--unused= lists the never-seen set. First run on ratio: 109 scripts, 98 unseen by the current (short) history window. -** DONE [#B] Automate dotfile validation :solo: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 +:LAST_REVIEWED: 2026-05-26 :END: -Shipped 2026-06-10 as dotfiles commit =2054da4=: =dotfiles-validate= in =common/.local/bin/= (11 unit tests). Extracts commands from hypr exec/bind-exec lines, waybar exec/on-click/on-scroll values, and systemd user-unit Exec* lines, then verifies each resolves. First run found 4 real orphans — see the follow-up task below. +Cancelled with the 2026-07-02 remove-pocketbook decision — the app and its in-tree package are gone. +Pocketbook (GTK4 layer-shell notes panel, toggled via waybar) was pulled from publication 2026-05-26 — github repo + cjennings.net repo deleted, mirror hook removed — and folded into this repo at =pocketbook/= until it's ready to spin back out. Src-layout Python package with pytest tests and a Makefile. Develop it in-tree; the backing modules are =store/note/panel/layer_shell/app/note_widget= + =style.css=. -*** 2026-06-11 Thu @ 00:44:41 -0500 All 4 orphaned references fixed; validator fully clean -Both emacs.service units repointed to /usr/bin/emacs (dotfiles =cd15d9b=), and per Craig's call the tor-browser and virtualbox keybinds were dropped rather than backed by installs (dotfiles =e4cb4c2= — Ctrl+Alt+W and Super+V now free). dotfiles-validate: 102 references checked, all resolve. -** DONE [#B] Document evaluation criteria and trade-offs -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Written 2026-06-10: [[file:docs/2026-06-10-tool-evaluation-criteria.org][docs/2026-06-10-tool-evaluation-criteria.org]] — four gating criteria (Wayland-native, actively maintained with live verification, automation-compatible, stowable config), five weighting criteria, the process, and the trade-offs accepted in the 2026-06-10 evaluation round. -** DONE [#B] Add org-capture popup frame on keyboard shortcut -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Shipped 2026-06-10, all five spec steps: =quick-capture= script (dotfiles =08ae188=, 3 unit tests, notify-on-failure when the daemon's down), Hyprland window rules in current 0.53+ syntax (float, 900x500, center, stay_focused on title org-capture) + Super+Shift+N bind (same commit), and the auto-close hook in =org-capture-config.el= (.emacs.d =1a25fada=, .elc recompiled, loaded live). Verified end-to-end on ratio: popup opens floating/centered with the template menu (screenshot), frame auto-deletes on org-capture-kill — finalize uses the same hook. Existing capture templates untouched. -** DONE [#C] Create Chrome theme with dupre colors :quick:solo: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Shipped 2026-06-10 as archsetup commit =4736058=: unpacked-extension theme at =assets/color-themes/dupre/chrome-theme/= (manifest.json + README with the color mapping and load-unpacked install steps). Visual check is yours — see Manual testing below. -** DONE [#C] Install Zoxide integration into Ranger :quick: -CLOSED: [2026-06-10 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-10 -:END: -Shipped 2026-06-10 as dotfiles commit =220dde6=: jchook/ranger-zoxide vendored (with MIT license) into both =common/= and =minimal/= ranger plugin dirs — :z and :zi commands wherever ranger runs. Python syntax verified; live verification is yours (see Manual testing) and needs a machine with ranger installed — note neither Wayland box has it, and the same-day file-manager evaluation recommends yazi over porting ranger forward. -** DONE [#D] Add retry logic to git_install function :quick: -CLOSED: [2026-06-10 Wed] -Already shipped before this review — commit =798b86f= gave git_install the same MAX_INSTALL_RETRIES loop as pacman/aur, with a clean-slate build dir per attempt. The task predates the fix; closing as done. -** DONE [#B] Org-capture popup frame split (quick-task Super+Shift+N) -CLOSED: [2026-06-13 Sat] SCHEDULED: <2026-06-12 Fri> +Backlog (unordered; promote items to their own dated tasks as they're picked up): + +- Configurable options, possibly a dedicated configuration panel. +- Lose-focus hides pocketbook — configurable on/off. +- Configurable display order: chronological by creation date (asc/desc), manual, alphabetical (asc/desc). +- Search / filter notes. +- Global toggle keybind (Hyprland =bind=) alongside the waybar click; document the waybar integration. +- Note CRUD polish (create/edit/delete) + optional markdown rendering. +- Pin / favorite notes. +- Tags or notebooks / categories. +- Persistence: confirm store format + =~/.local/share/pocketbook/= location, add versioning/migration, decide a backup/sync story. +- Theming: track the dupre/hudson theme system so =style.css= follows =set-theme=. +- Layer-shell geometry config (anchor edge, width, margins) + HiDPI / multi-monitor behavior — ties into [[file:docs/PLAN-per-host-overrides.org][per-host overrides]] scaling work. +- Config file format (toml) + reload-without-restart. +- Expand test coverage (TDD per testing standards; =tests/= already exists). +- Release prep for the eventual spin-back-out: pyproject metadata, version, license. +- Re-wire the archsetup install (gtk4-layer-shell dep + install step + post-install clone) when pocketbook ships. Removed 2026-05-26 — see git history of =archsetup= / =scripts/post-install.sh=. +** CANCELLED [#C] Fn+F9 toggles pocketbook — source unlocated :hyprland:pocketbook: +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-12 +:LAST_REVIEWED: 2026-06-23 :END: -Resolved: .emacs.d fixed it config-side (single-window display + cj/quick-capture command); archsetup pointed the popup script at cj/quick-capture (8cc1be7). Verified end-to-end on ratio. -The quick-capture popup opens split in two windows — a top sliver of the daemon's last-visited buffer plus the =*Org Select*= menu below — so the two stacked modelines read like tmux status bars. Root cause: =org-mks= displays the template menu via =org-switch-to-buffer-other-window=, splitting the fresh popup frame instead of taking it over. - -Coordinating with the .emacs.d project: handoff sent 2026-06-12 18:59 requesting a config-side fix scoped to frames named =org-capture= (handoff note + screenshot evidence delivered to .emacs.d's inbox, since processed and removed). Waiting on its reply in this project's inbox; then verify the popup end-to-end on ratio (Super+Shift+N → single-window menu → single-window capture buffer). Fallback if .emacs.d declines: carry the fix in the dotfiles =quick-capture= script's =-e= elisp. +Retired with pocketbook itself (2026-07-02 removal) per this task's own exit condition — with the app uninstalled and unbound, whatever Fn+F9 emitted has nothing to toggle. +On velox, pressing Fn+F9 (physical function key) toggles the pocketbook panel. It shouldn't. Raised from a home-project session 2026-06-23. -Related finding, no change needed: whole-desktop screenshot already exists at CTRL+Super+S (=screenshot fullscreen=, grim fires before the fuzzel menu so popups survive). Possible follow-up decision: rebind Super+Shift+S (currently layout-switch to scrolling) if Craig wants fullscreen capture there. +Investigated 2026-06-23 and could not locate the trigger in any config. Ruled out, three ways: +- No F9 bind (bare / $mod / keycode) in the live =hyprland.conf= (now a stow symlink), the velox host tier =conf.d/local.conf=, or the waybar config. +- =hyprctl binds= runtime (all 90 active binds, authoritative) execs pocketbook on ONLY =SUPER+P=. No F9/XF86 path reaches it. The old touchpad toggle that used to sit on =$mod+F9= was moved to =$mod+M=, so F9 is unbound in Hyprland. +- No input remapper (keyd/xremap/input-remapper) and no hotkey daemon (sxhkd/swhkd) running or configured; pocketbook's own source has no F9 / GlobalShortcuts / portal / dbus listener (its GTK ShortcutController binds only Esc/Ctrl-n/Ctrl-j/Ctrl-k/Del/Return). pocketbook is a single-instance Gtk.Application, so any path that re-runs =pocketbook= toggles it. -*** 2026-06-12 Fri @ 20:21:00 -0500 Incorporated .emacs.d's fix and verified end-to-end -.emacs.d replied same evening with two notes (now in [[file:assets/outbox/2026-06-12-1947-from-.emacs.d-org-capture-popup-singlewindow-reply.org][outbox]] and [[file:assets/outbox/2026-06-12-2006-from-.emacs.d-quick-capture-script-change.org][outbox]]): the single-window fix landed config-side (frame-scoped =display-buffer-alist=, 7 ERT tests, live in the daemon), plus a new =cj/quick-capture= command (Task/Bug/Event only, global-inbox targets, frame closes on every exit path, 12 ERT tests). Our side: test-first one-line change in the dotfiles =quick-capture= script — =(org-capture)= → =(cj/quick-capture)= — suite 15/15 green, live immediately via stow. Verified on ratio with sendshortcut-driven popups + grim: menu single-window with the 3-template subset, capture buffer single-window targeting =CAPTURE-inbox.org=, no orphan frames, nothing leaked into the inbox file. Verification reply + screenshot evidence sent back to .emacs.d. Remaining: commit the dotfiles change (Craig's gate) and the Super+Shift+S rebind decision. -** DONE [#C] Silent notifications for the mic-mute toggle :quick:solo: -CLOSED: [2026-06-11 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-11 -:END: -Shipped 2026-06-11 as dotfiles =a4ae4a4=, minutes after filing: =--silent= on all four of mic-toggle's notify calls (Muted/Live/unknown/fail), tests assert the flag on every path (5/5, full suite 15 suites green), and a live round-trip on ratio confirmed the toggle works with the toast and without the chime. velox picks it up on next pull. -** DONE [#B] Create package inventory system -CLOSED: [2026-06-14 Sun] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-13 -:END: -Satisfied by =scripts/package-inventory= (the same script that closes "Automate the inventory comparison" above). It lists archsetup's declared packages, lists the live system's packages, and prints the diff in both directions. Design note: it compares explicit-vs-explicit (=pacman -Qqe= against declared =pacman_install=/=aur_install=), which is the meaningful comparison — the original "including dependencies" framing was superseded, since transitive deps are pulled automatically and listing full closures would only add noise. -*** 2026-06-14 Sun @ 22:13:48 -0500 Listed archsetup's declared packages — package-inventory extraction (pacman_install/aur_install + for-loop lists) -*** 2026-06-14 Sun @ 22:13:48 -0500 Listed live-system packages — package-inventory via pacman -Qqe / -Qq / -Qqen / -Qqem -*** 2026-06-14 Sun @ 22:13:48 -0500 Generated archsetup-vs-system diff — package-inventory, both directions, AUR/official split -** DONE [#B] Automate the inventory comparison :test:solo: -CLOSED: [2026-06-14 Sun] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-13 -:END: -Make package diff a runnable script instead of manual process - -Resolved 2026-06-14: the runnable script already existed — =scripts/package-inventory= (built 2026-02-06) extracts archsetup's declared packages and diffs them against the live system (=--summary= / =--archsetup-only= / =--system-only= / full report). This pass added the missing coverage: 7 characterization tests in =tests/package-inventory/= pinning the extraction and both diff directions behind injectable =PKGINV_ARCHSETUP= / =PKGINV_PACMAN= seams, plus a =make package-diff= target for discoverability. Full unit suite green (26 tests, 3 suites). -** DONE [#B] Idle-inhibitor keybind + synced waybar indicator :hyprland:waybar: -CLOSED: [2026-06-23 Tue] -Shipped 2026-06-23 as dotfiles commit =a004201=. Super+I toggles the hypridle daemon (kill = inhibit, relaunch = restore). The built-in waybar =idle_inhibitor= module was replaced with a =custom/idle= module backed by a =waybar-idle= script, so the keybind, the bar click, and the icon share one source of truth (whether hypridle is running) and stay in sync. Icons inhibited / active, with a 5s poll safety net. Freed =Super+I= by pruning the unused ai-term pyprland scratchpad from both host configs. TDD'd (=waybar-idle= + =hypridle-toggle= suites); dupre/hudson theme CSS updated. From a home-project handoff 2026-06-23; Craig confirmed it works live. -** DONE [#B] Verify package signature verification not bypassed by --noconfirm -CLOSED: [2026-06-23 Tue] -:PROPERTIES: -:LAST_REVIEWED: 2026-05-21 -:END: -Audited 2026-06-23. =--noconfirm= does not bypass signature verification — it only auto-answers interactive prompts. Signature checking is governed by =SigLevel= in =/etc/pacman.conf=, which archsetup leaves at the Arch default (=Required DatabaseOptional=): its only pacman.conf edits are ParallelDownloads, Color, and enabling multilib (=archsetup:913,917=), none of which touch =SigLevel=. So every repo package stays signature-verified regardless of =--noconfirm=. - -One real integrity bypass exists, and it is not =--noconfirm=: =archsetup:2403= runs =yay -S --noconfirm --mflags --skipinteg python-lyricsgenius=, where =--skipinteg= skips makepkg's checksum and PGP-signature checks for that one AUR package (a documented workaround for an expired-signature issue upstream). It's scoped to a single package, not global. Tracked for periodic re-check below. -** DONE [#C] Harden sshd in the installer (explicit prohibit-password) :solo: -CLOSED: [2026-06-24 Wed] -Done 2026-06-24: the openssh block (=archsetup:1271-1277=) now writes =/etc/ssh/sshd_config.d/10-hardening.conf= with =PermitRootLogin prohibit-password= and reloads sshd, right after starting the service. =PasswordAuthentication= left untouched so ssh-copy-id to the user still works. Makes the posture intentional rather than dependent on the upstream default. Velox and ratio (which carried an explicit =PermitRootLogin yes= at =sshd_config:33= from earlier provisioning) were already fixed by hand 2026-06-23. Verified =bash -n= + =shellcheck -S error= clean; full drop-in-on-fresh-install confirmation is VM-deferred (the unit harness covers helpers, not inline install steps). -** DONE [#C] Build security dashboard command :solo: -CLOSED: [2026-06-23 Tue] -:PROPERTIES: -:LAST_REVIEWED: 2026-05-21 -:END: -Shipped 2026-06-23 as dotfiles commit =1b9b205=: =security-status= (=common/.local/bin=, on PATH). Read-only dashboard showing disk encryption (LUKS *and* ZFS native — the fleet runs ZFS, so a LUKS-only check would have falsely reported "no encryption"), ufw state, externally-reachable ports (counts all listening, lists only the non-loopback exposures), and running/failed service counts. Command lookups are env-overridable; parsing covered by unit tests against canned output. New file, so ratio needs =git pull && make stow hyprland= to link it. -** DONE [#C] paru vs yay — evaluated, staying with yay -CLOSED: [2026-06-10 Wed] -Research done 2026-06-10: [[file:docs/2026-06-10-paru-vs-yay-evaluation.org][docs/2026-06-10-paru-vs-yay-evaluation.org]]. The maintenance picture inverted since the task was filed: yay released v12.6.0 on 2026-06-07 with active triage, while paru has had no release in 11 months, no commit in 5, and a stable that fails to build against current libalpm (issue #1468 open 6 months). For an installer that bootstraps the AUR helper unattended, paru is the riskier choice on every axis that matters. No decision needed — the evidence closes this one; revisit only if paru's maintenance resumes. -** DONE [#C] Teach archsetup to stow the host tier :solo: -CLOSED: [2026-06-23 Tue] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-11 -:END: -Already implemented in =user_customizations()= (=archsetup:1049-1058=): after stowing =common= + the DE package, it derives =host_tier="$(cat /etc/hostname 2>/dev/null || uname -n)"= and stows that package when =$dotfiles_dir/$host_tier= exists, else prints "no host tier for '<host>' — skipping". The =/etc/hostname=-first detection is the right call for install time (=uname -n= still reports the ISO's name until reboot), and it's the same skip-if-absent semantics as the dotfiles Makefile. Verified by reading the installer 2026-06-23; no code change needed. -** DONE [#C] Waybar indicators unevenly spaced :quick:solo:waybar: -CLOSED: [2026-06-24 Wed] +Parked at Craig's call (not worth deeper investigation now). If it resurfaces, the one unfinished step is to capture what keysym Fn+F9 actually emits (=wev -f wl_keyboard:key=, press Fn+F9, read the =sym:= / =code:=) and grep for that. Most likely folds into removing pocketbook from the waybar setup — if pocketbook leaves the bar, retire this with it. +** CANCELLED [#C] Waybar emacs-service status + control :feature:waybar: +CLOSED: [2026-07-02 Thu] :PROPERTIES: :LAST_REVIEWED: 2026-06-24 :END: -The right-side module icons don't sit at even intervals — spacing reads as inconsistent across the group. Noticed 2026-05-21 after adding the airplane indicator. +From the roam inbox (2026-06-22): with Emacs integrated into the system as file manager and instant note-taker, make bouncing it trivial. A waybar component showing the emacs service status, with detail on hover, that turns the server on / off / bounce via right-click. Pairs with running the Emacs daemon as a managed systemd user service. -Done 2026-06-24: a screenshot showed the standalone module icons were already even — the unevenness was the tray, whose icons clustered tight (tray =spacing: 4= vs the ~0.3rem margins on every other module). Bumped tray =spacing= 4 → 10 in the waybar =config=; restarting waybar and re-screenshotting confirmed the row reads even. The lever was the tray spacing, not the per-module CSS the original body guessed at. -** DONE [#B] Separate mpd playlist_directory from music_directory :mpd:music:quick: -CLOSED: [2026-06-24 Wed] +Cancelled 2026-07-02 per Craig during the task-batch pick: no current need. Re-add or pull back from Resolved if a need surfaces. +** DONE [#C] set-wallpaper detaches waypaper config from its stow symlink :bug:hyprland:quick:solo: +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-24 +:LAST_REVIEWED: 2026-06-28 :END: -Done 2026-06-24 (dotfiles a9bfdf3): set =playlist_directory= to =~/.local/share/mpd/playlists= (separate from =music_directory= ~/music). git-moved the 73 radio-stream playlists from =common/music/= into =common/.local/share/mpd/playlists/= (history preserved); dropped the empty =60s Sounds.m3u= (Craig's call); git rm'd the stray =Black Flamingos - Space Bar.m4a= and moved the real track into the music library. Curated playlists left flat in ~/music (Craig's call — avoids rewriting the 7 relative-path ones). The ~/music/radio orphan was already gone. Relinked surgically (a pre-existing =whereami= stow conflict blocked a full =stow common=). mpd restarted clean: 73 radio playlists load from playlist_directory (verified SomaFM stream URLs), 24 curated browsable from the music tree. ratio needs the same restow + mpd restart on its next pull (reminder filed). Decisions answered: 60s dropped, curated flat. -Spec written and approved (option 1), pinned before execution on 2026-06-03. Root issue: mpd.conf has =playlist_directory= == =music_directory= == ~/music, so the whole audio library is the playlist store and radio streams mix with curated playlists. Option 1: radio stream playlists (portable, 73 in the dotfiles repo) move to a dedicated =playlist_directory= (=~/.local/share/mpd/playlists=) via stow; the 22 curated local playlists (machine-specific track refs) live in the music tree. Also removes the broken ~/music/radio/ orphan (73 dead symlinks). +=set-wallpaper= persists with =mv "$tmp" "$CONFIG"=, which replaces the =~/.config/waypaper/config.ini= stow symlink with a real file. After the first run the live config is detached from =~/.dotfiles/hyprland/.config/waypaper/config.ini=, so a later =git pull= + restow won't update it and set-wallpaper changes never flow back to the repo. Fix: write in place rather than =mv= over the symlink — e.g. =cp "$tmp" "$CONFIG"= (follows the symlink to the real dotfiles file), or resolve the link target and write there. Lives in =~/.dotfiles/hyprland/.local/bin/set-wallpaper=; it has a test suite, so add a Boundary case for "CONFIG is a symlink". -Full step-by-step spec (mpd.conf edit, repo restructure of =common/music/= → =common/.local/share/mpd/playlists/=, curated relocation, restow, verification incl. the 7 relative-path curated playlists, ratio propagation) is in the 2026-06-03 session record under .ai/sessions/. Two open decisions before executing: (1) drop the empty =60s Sounds.m3u= or refill with the SomaFM 60s URL; (2) curated playlists into =~/music/playlists/= subdir vs leave flat in ~/music/. Side cleanup surfaced: a stray audio file =Black Flamingos - Space Bar.m4a= is wrongly committed in the dotfiles repo's =common/music/= — git rm it and move to the synced library. -** DONE [#C] Install adopted modern CLI tools :tooling:solo: -CLOSED: [2026-06-24 Wed] +Shipped 2026-07-02 (dotfiles d826be4): write-back now redirects through the symlink instead of mv-ing over it; two boundary tests pin the invariant (replace + append paths). velox's live config was still a healthy symlink, so no repair needed. +** DONE [#B] Instrument-console rebuild: net + bluetooth panels :feature:waybar:network:bluetooth:solo: +CLOSED: [2026-07-03 Fri] :PROPERTIES: -:LAST_REVIEWED: 2026-06-24 +:SPEC_ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5 :END: -Done 2026-06-24: added bat/dust/hyperfine/doggo to archsetup General Utilities (tealdeer was already declared), installed all five on velox, set =BAT_THEME=ansi= in =common/.profile.d/tools.sh= (tracks the dupre terminal palette), seeded the tldr cache. ratio still needs the =pacman -S= (additive; lands on its next archsetup run). -Decision (Craig, 2026-06-24): adopt all five recommended tools — =bat=, =dust=, =hyperfine=, =tealdeer=, =doggo= (all in extra). Add them to archsetup's package list and install on both machines. Optional candidates (=xh=/=jless=/=sd=/=ouch=) declined for now. Full evaluation: [[file:docs/2026-06-10-modern-cli-tools-evaluation.org][docs/2026-06-10-modern-cli-tools-evaluation.org]]. - -- Add the five to the appropriate pacman package section in =archsetup=. -- =pacman -S bat dust hyperfine tealdeer doggo= on velox + ratio. -- =bat=: set =BAT_THEME= to match the dupre palette once installed. -- =tealdeer=: run =tldr --update= to seed the cache after install. -** DONE [#C] Review file manager options for Wayland -CLOSED: [2026-06-24 Wed] -Decision (Craig, 2026-06-24): keep nautilus only; skip yazi. File management lives in Emacs dired plus the Super+F dirvish popup, so a TUI file manager has no daily user here. ranger was already ruled out (frozen upstream). Full evaluation: [[file:docs/2026-06-10-file-manager-evaluation.org][docs/2026-06-10-file-manager-evaluation.org]]. Follow-on surfaced: nautilus needs dark theming (filed as its own task). -** DONE [#B] Theme nautilus to a dark theme :bug:solo: -CLOSED: [2026-06-24 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -nautilus rendered blindingly white (Craig, 2026-06-24). As a GTK4/libadwaita app it follows the appearance portal's =org.freedesktop.appearance color-scheme=, which mirrors =org.gnome.desktop.interface color-scheme=. Two stacked causes: - -1. velox had no system-wide dconf db at all — no =/etc/dconf/profile/user=, no =/etc/dconf/db/site.d/00-archsetup-defaults=, no compiled =site= db — so archsetup's declared default (=color-scheme='prefer-dark'=, =archsetup:1109-1119=) never reached the machine (velox predates that block). Created the profile + site defaults as archsetup writes them and ran =dconf update=. =gsettings get= then returned =prefer-dark=. - -2. That alone did NOT fix the running session: a system-db default emits no GSettings change signal, so the appearance portal kept reporting =0= (no-preference → light), and libadwaita reads the portal, not =GTK_THEME=. (An early screenshot looked dark only because the shell env carries =GTK_THEME=Adwaita:dark=, which Hyprland-launched apps don't inherit — masking the real state.) Fix: a user-level =gsettings set org.gnome.desktop.interface color-scheme prefer-dark=, which signals the portal live. It now reports =1=, and a portal-driven nautilus (GTK_THEME unset) renders dark — screenshot-verified. +The no-approvals speedrun build of the console design Craig approved through five prototype iterations (2026-07-02/03). Spec: [[file:docs/design/2026-07-03-instrument-console-panels-spec.org]] — the interactive prototype [[file:assets/2026-07-03-instrument-console-panels-prototype.html][assets/2026-07-03-instrument-console-panels-prototype.html]] is the normative design reference. Folds three open tasks: network panel redesign, bt switch placement + title, bt rename devices. Code in ~/.dotfiles (net/, bluetooth/, themes/dupre/panel.css). Final step: flip the spec to IMPLEMENTED, write the findings summary to file, finalize session context. -Durable: the user value persists in =~/.config/dconf/user=; archsetup's system-db handles fresh installs (the portal reads the default fresh at login, so no signal is needed there). No archsetup change. ratio may need the same one-two — see the Active Reminder. -** CANCELLED [#D] Test wlogout menu on laptop -CLOSED: [2026-06-24 Wed] -Merged into the "Wlogout exit-menu buttons are rectangular, not square" task ([#C]) — same effort (per-host wlogout button sizing across velox/ratio). The fixed-pixel-margins hint was folded into that task's body. -** DONE [#B] Enlarge org-capture popup to scratchpad size :hyprland: -CLOSED: [2026-06-24 Wed] -From a .emacs.d inbox handoff (2026-06-15, captured via roam): the quick-capture / org-protocol popup is too small to be effective — it should be about the size of a terminal scratchpad. +*** 2026-07-03 Fri @ 03:20:00 -0400 Phase 2 shipped: net GTK-free console layer + engine verbs +Dotfiles =81ec9c3= (TDD, 52 new tests, 581 net green). Pure presenter logic for the single-screen console, no view code touched: =viewmodel.net_faceplate= (state word + lamp + TUNNEL/AIRPLANE badges, wired-link-wins precedence), =network_console_rows= (ethernet pinned, radio-off note, active-then-signal sort, per-row lamp/caption/ladder/forget), =channel_headline= (wired device+speed / SSID+ladder+dBm / not-connected placeholder), =tunnel_console_rows=, dial-meter geometry (=meter_needle_deg= + =meter_scale= 100→1000 auto-relabel), =signal_bars=/=mbps_label=, and =panel.ArmState= (two-click arm-to-fire for forget/disconnect). Engine verbs: =manage.wifi_radio= (nmcli radio wifi on|off), =manage.device_up= (ethernet take-the-route), =sysio.link_speed_mbps= (/sys wired speed), =connections.ethernet_devices=, hidden flag on =manage.add=. -*** 2026-06-24 Wed @ 17:21:11 -0400 Sized the popup to the scratchpad, per-host in pixels -The 06-15 read was wrong: the real size lever is the Hyprland window rule, not the quick-capture char-cell count. The =size 900 500= rule on the org-capture window pinned it to 900x500 regardless of the frame's requested geometry (demoing 120x24 vs 180x32 looked identical because both clamped to 900x500). Tried a percentage rule (=size 75% 70%=) to auto-adapt per host like the pyprland scratchpad — native window rules do NOT honor percentages (only pyprland does), so the frame fell back to char-cell geometry and overflowed the screen. Fix: absolute pixels matching each host's terminal scratchpad, placed in the host tier (=<host>/conf.d/local.conf=) since pixels don't adapt across monitors. velox = 1078x671 (75%x70% of its 1437x958 logical desktop) — verified on-screen. ratio = 1892x936 (55%x65% of 3440x1440) — set but not yet eyeballed on ratio (tracked as an Active Reminder in notes.org). The shared hyprland.conf keeps float/center/stay_focused and a comment pointing at the per-host size. dotfiles change — needs commit in =~/.dotfiles=. +*** 2026-07-03 Fri @ 06:02:32 -0400 Phases 3+4 shipped: net view rebuilt as the instrument console +Dotfiles =800ef60= (1197+/250-). =gui.py= rewritten as the single-screen console — no tabs, no Blueprint template (the dial meters and arm-to-fire rows are too dynamic, so the tree is built in Python; =pages.py= + the =*.blp/*.ui= are now orphaned, Phase 6 dead-code). Faceplate (lamp/word/TUNNEL+AIRPLANE badges/wifi-radio switch/close), engraved CHANNEL headline, scrolled NETWORKS + TUNNELS lamp rows, CONSOLE keys, two cairo dial meters, output well + dismiss ✕, toast. Interactions all wired: open network joins / secured opens the password dialog, active row arm-disconnects (gold), ✕ arm-forgets (terracotta), tunnel toggles, ethernet row takes/yields the route, radio switch flips the wifi radio (refuses under airplane with the way out), + hidden joins a non-broadcast SSID, DOCTOR streams diagnose+repair into the well, SPEED TEST sweeps both dials with the live rate then pins the final with HOLD (location/ping/final/tips in the well). =panel.css= grew the console classes (lamps+glow, b-face, engrave, chan, lamp-row + arm tints, c-btn, meter/mode/hold, output steps, toast). AT-SPI smoke + driver rewritten (anchor on the DOCTOR key). Phases 3 and 4 landed together because a view-only intermediate is a non-functional panel. Verified live on velox: full render screenshotted, console smoke green (faceplate/keys/sections/tunnels/DOCTOR/dismiss/close), DOCTOR streams real diagnose steps, SPEED TEST drove RX 36.6↓ / TX 90.7↑ then HELD. 581 net tests + full make test green. -*** 2026-06-15 Mon @ 19:19:55 -0500 AI Response: popup size is the frame's char-cell count, not the Hyprland rule -Triaged under auto inbox-zero. The popup is the emacsclient frame named "org-capture", created by =~/.dotfiles/hyprland/.local/bin/quick-capture= with =(width . 90) (height . 22)= — 90 columns by 22 lines. Emacs sizes by character cells and overrides the Hyprland rule =windowrule = match:title ^(org-capture)$, size 900 500= (hyprland.conf:182). The live frame measured ~889x860 px; the width tracks the 90-column count, not the window rule. Setting the Hyprland rule to =size 55% 65%= (the scratchpad's pyprland spec) did not change the frame width, so I reverted it — dotfiles left clean. +*** 2026-07-03 Fri @ 06:55:00 -0400 Phase 5 shipped: bt panel rebuilt as the instrument console +Bluetooth's turn, two commits mirroring net. Phase-5a (dotfiles =5318b34=, 47 new console tests): the GTK-free layer — =viewmodel.bt_faceplate= (POWERED/OFF/AIRPLANE word + lamp + LOW BATT/AIRPLANE badges), =paired_console_rows= / =nearby_console_rows= (lamp rows with connect/forget/rename affordances), =discoverable_chip=, count labels, =battery_gauges= (two dial slots, one per connected device, red under 15%, dim NO DEVICE / ADAPTER OFF empties), =STEP_NARRATION=, and =panel.ArmState= (the forget latch). Engine gaps: =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (set-alias has no MAC-addressed one-shot; =device_path= discovers the controller node from the object tree), =manage.rename= wraps it with a verify-after read, =parse_info= reads the Alias as the display name (a rename lands there, not on Name; the MAC-shaped placeholder stays "unnamed"), and =doctor= grew =on_report=/=on_begin= callbacks. Phase-5b (dotfiles =66f03d9=): =gui.py= rewritten as the single-screen console — faceplate (lamp/word/LOW BATT+AIRPLANE badges/adapter-power switch/close), engraved ADAPTER line with the clickable discoverable chip, scrolled PAIRED + NEARBY lamp rows, CONSOLE keys DOCTOR / SCAN, two cairo battery dials, output well + toast. Interactions: paired rows toggle connect/disconnect, ✎ renames via a dialog, ✕ arm-forgets, nearby rows run the pair flow into a passkey-confirm dialog, the chip toggles discoverability, the switch powers the adapter, SCAN refreshes nearby, DOCTOR streams checks + repairs. =panel.css= gained =.chip= / =.pen= / =.o-passkey= (the rest already shared with net). AT-SPI smoke rewritten (anchor on the bt-only SCAN key). Verified live on velox: smoke green end to end, screenshot matches the prototype (POWERED faceplate, four paired audio devices, two NO DEVICE battery dials). 46 suites + full make test green. Phase 6 next: live both-panel verify, folded tasks closed, dead code removed, spec → IMPLEMENTED. -Real lever: the column/line count in the quick-capture script. Scratchpad reference on ratio (DP-4, 3440x1440) is 55% 65% ~= 1892x936 px ~= 190 cols by 24 lines. Why this isn't a solo auto-fix — it needs a tradeoff decision: -- The script lives in the shared =hyprland/= stow tier, so a fixed ~190 columns overflows velox's 1920-wide laptop, and 24+ lines overflows velox's 1080 height (22 lines ~= 860 px is already near the safe max there). -- Emacs char-cell sizing doesn't adapt to the monitor the way pyprland's percentage does, so "scratchpad-size on both machines" needs one of: a fixed compromise count, a per-host override via the ratio/velox tiers, or a script that computes columns from the active monitor. -Options to weigh: (a) a safe-on-both compromise like width 120-130 / height 24; (b) per-host width through the ratio/velox tiers; (c) dynamic sizing in quick-capture from =hyprctl monitors=. Pick the tradeoff and I'll implement. -** DONE [#C] Highlight current month and year in the calendar hover :feature:waybar:quick:solo: -CLOSED: [2026-06-24 Wed] +*** 2026-07-03 Fri @ 06:49:45 -0400 Phase 6 shipped: build closed out, dead code removed, spec IMPLEMENTED +Live both-panel verify on velox: 46 suites + full make test green, and both AT-SPI smokes green end to end (net: faceplate NET·01/ONLINE, DOCTOR streams real diagnose steps, tunnels rows, close; bt: BT·01/POWERED, SCAN/DOCTOR keys, battery dials, close). The two =gui.py= files are byte-identical to their screenshot-verified commits (net =800ef60=, bt =66f03d9=), so the render carries over from the phase-3/4/5 screenshots — this pass touched no view code. Dead code removed (dotfiles =f4e688e=): both panels' orphaned =pages.py= + =ui/= (=*.blp/*.ui=) gone now that =gui.py= builds the tree in Python, the now-dead =make ui= Blueprint-compile target and its =.PHONY= entry dropped, and the stale =gui.py / pages.py= mention in bt =viewmodel.py= fixed; nothing imported the removed modules. Three folded tasks close with this build (network panel redesign, bt switch placement + title, bt rename devices). Build summary written to [[file:assets/2026-07-03-instrument-console-panels-build-summary.org][assets/2026-07-03-instrument-console-panels-build-summary.org]]. Spec =e73877f5= flipped DOING → IMPLEMENTED. Manual-test checklist for the real-device bt interactions filed under Manual testing and validation. +** DONE [#B] Net diagnostics: narrate every step :feature:network:solo: +CLOSED: [2026-07-02 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-24 +:LAST_REVIEWED: 2026-07-02 :END: -From the roam inbox (2026-06-24): the waybar clock's calendar tooltip highlights today's date in goldenrod; the current month and year header should be goldenrod too. +Follow-on 2 (dotfiles =ebf24fe=, Craig's decision 2026-07-02, option 1 of the discussed policies): mutating tiers pre-check whether we're online before acting. dns-test/dns-override short-circuit with an "already online" step and touch nothing (live-verified on hotel wifi: 100 ms skip, DNS untouched); reset/bounce/nm-restart/resolved-restart proceed (reset has a legit online use — fresh MAC) but carry "(was online before ...)" in their evidence; the panel's repair confirm warns via the cached probe verdict (=probe.cached_online=, file read only); rfkill/dns-revert/tunnel-down already verify their own state, unchanged. 492 net tests / 45 suites / panel smoke green. -Done 2026-06-24: the date module is the custom =waybar-date= script (not the built-in clock), so the highlight lives in its tooltip markup. Added a sed wrapping line 1 of the current-month =cal= output (the centered "Month Year") in the same =#daa520= goldenrod the day highlight uses. Verified the tooltip JSON carries =<span color='#daa520'><b>June 2026</b></span>= with today's highlight intact and waybar live; the on-hover look is Craig's spot-check. -** DONE [#C] Wallpaper-set from dirvish doesn't work on Wayland :hyprland: -CLOSED: [2026-06-24 Wed] -From the roam inbox (2026-06-24, claimed for archsetup by Craig): typing =bg= in the dirvish popup doesn't change the wallpaper — Craig's read is it may still be wired to feh/X11 instead of a Wayland utility. +Follow-on (dotfiles =50a7239=, Craig's ask 2026-07-02): the same requirements now cover every Advanced-dropdown action — all repair ids + portal narrate in the panel's step rows, =net repair= / =net portal= human-by-default (=--json= kept, added to portal), doctor's attempts render like checks, and mutating steps keep their next_action on pass (the fail/warn-only rule was dropping dns-override's revert pointer, portal-login's login click, and cleanup-unverified's manual revert). 479 net tests / 45 suites green; panel smoke shows narration in live rows. -Findings (2026-06-24): the Wayland wallpaper utility on this setup is =awww= (waypaper's configured =backend = awww=; =set-theme= sets the default via =awww img <file>=). There was no shared wallpaper script (=bg= on PATH is just the shell builtin), and the dirvish =bg= command lives in the Emacs config, so it was calling the wrong (or no Wayland) setter. +Shipped (dotfiles =7772427=): every diagnose step id carries a one-line narration (what it tests and why) in a viewmodel table, and both human renderers print every step — status, title, narration, evidence with timing, and the fix pointer on fail/warn — in =net doctor= and =net diagnose= alike. Bare =net diagnose= now prints the narrated report (was raw JSON; =--json= keeps the machine envelope; =net-fix= already used =--json= explicitly, the panel calls the engine in-process). A completeness test walks diag.py's step ids against the narration table so a new step can't land unnarrated. 470 net tests / 45 suites green; verified live on velox hotel wifi — both commands narrate the full probe sequence. Ratio picks it up with its queued dotfiles pull (source-imported, no restow-only step). -Done 2026-06-24 (dotfiles 8be2484): added =set-wallpaper <image>= to the hyprland tier — sets live via =awww img= and persists the choice into =waypaper/config.ini=, the single Wayland-correct entry point. Resolves relative paths, validates the file, exits non-zero without persisting if awww fails. 8 Normal/Boundary/Error tests green; live-verified (awww set it, config rewrote). Notified =.emacs.d= to point the dirvish =bg= command at =set-wallpaper <file>= — that wiring is its piece (dependency cleared, =:blocker:= dropped). +Original ask (roam inbox, 2026-07-02, from a real net doctor run on hotel wifi): the output isn't enough to know what's being tested, why, and whether it passed — a failing run printed only the verdict, the fix pointer, and the failing rows: -Follow-up (separate, small): the login restore =exec-once= in =hyprland.conf= is hardcoded to =trondheim-norway.jpg=, so a wallpaper set via =set-wallpaper= shows live but won't survive a relogin until the exec-once becomes =waypaper --restore= (which reads the now-persisted config). Filed below. -** DONE [#B] Add backup before system file modifications :solo: -CLOSED: [2026-06-25 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -Safety net for /etc/X11/xorg.conf.d and other system file edits -Files like ~/etc/sudoers~, ~/etc/pacman.conf~, ~/etc/default/grub~ modified without backup -If modifications fail or are incorrect, difficult to recover - should backup files to ~.backup~ before modifying - -Done 2026-06-25: added a =backup_system_file <path>= helper next to =safe_rm_rf= — it snapshots a pre-existing file to =<path>.archsetup.bak= before an in-place edit, idempotent (never clobbers an existing backup, so the pristine original survives repeated edits and re-runs), =cp -p= to preserve mode/ownership, no-op when the file is absent. Took the narrow scope (Craig's call): route only the in-place =sed -i= / append edits to *pre-existing* files through it — locale.gen, makepkg.conf, pacman.conf, sudoers, conf.d/wireless-regdom, geoclue.conf, conf.d/pacman-contrib, fstab, mkinitcpio.conf, vconsole.conf — and skip the brand-new drop-in files archsetup fully owns (nothing to back up; recovery is just deleting them). Tests: =tests/backup-system-file/= (7 Normal/Boundary/Error, incl. mode-preserved, existing-backup-not-overwritten, missing-target no-op, cp-failure). =make test-unit= green across all 5 suites; =bash -n= clean; only shellcheck note is the known SC2329 false positive (indirect STEPS dispatch). Integration verification is the next VM run. -** DONE [#B] Migrate bare-metal test runner to Testinfra, then delete the shell sweep :test: -CLOSED: [2026-06-25 Thu] -Plan + ZFS-coverage expansion: [[file:docs/design/2026-06-25-zfs-vm-test-coverage.org]] (build a ZFS base VM via archangel + a =FS_PROFILE= selector so =make test= covers the ZFS path, then migrate this runner to key auth + Testinfra against it, then delete the dead =validation.sh= functions = phase E here). -=run-test.sh= (VM) now uses the Testinfra/pytest sweep as its authoritative validator, but =run-test-baremetal.sh= (lines ~243-244) still calls the old =run_all_validations= / =validate_all_services= from =scripts/testing/lib/validation.sh=. Migrate the bare-metal runner to =run_testinfra_validation= too (same key + ssh-config approach, adapted for a real host), then delete the now-dead shell-sweep functions from =validation.sh=. Keep the live helpers: =ssh_cmd=, =attribute_issue=, =capture_pre/post_install_state=, =analyze_log_diff=, =categorize_errors=, =generate_issue_report=, and the =VALIDATION_*= counters/arrays. Deferred from the Testinfra cutover because it needs a bare-metal test loop to validate, out of scope for the VM-only autonomous run. -*** 2026-06-25 Thu @ 12:37:02 -0400 P-A/P-B shipped (FS_PROFILE selector); P-C blocked on archangel ZFS-install bug -P-A + P-B landed in =353b179=: =archsetup-test-zfs.conf= (archangel ZFS config) + an =FS_PROFILE= (btrfs default / zfs) selector across =vm-utils.sh= (=init_vm_paths= derives a per-profile image + validates the profile), =create-base-vm.sh= (selects the archangel config), =run-test.sh= (--help + profile display), and the Makefile (=make test FS_PROFILE=zfs=). Design simplification recorded: no =archsetup-vm-zfs.conf= needed — archsetup auto-detects ZFS from the live root via =is_zfs_root()=, so the archsetup run config is shared; only the archangel base config + base image differ. Open Q1 resolved: archangel supports ZFS root natively (it's the default FS). - -P-C (build the ZFS base image) is BLOCKED on archangel. =create-base-vm.sh FS_PROFILE=zfs= built the disk + booted the archangel ISO fine, but the archangel install died: =dkms install zfs/2.3.3 -k 6.18.36-1-lts= exited 1, ZFS module not built. Root cause is in archangel, not archsetup: it appends the [archzfs] experimental repo then runs =pacstrap -K= with no =pacman -Sy= refresh, so it uses the archzfs sync db baked into the Feb-2026 ISO (zfs-dkms 2.3.3) while linux-lts is pulled fresh (6.18.36). 2.3.3 doesn't build against 6.18. velox runs zfs-dkms 2.4.2 on the same kernel from the same channel, so the fix exists upstream — archangel just needs to refresh the db before pacstrap (+ a fresh ISO). Bug + dependency handoff sent to archangel inbox (=2026-06-25-1236-from-archsetup-bug-zfs-install-fails-stale-baked.org=). Retry P-C once a fixed archangel ISO is available. P-D (bare-metal migration code) is still workable in the meantime against the btrfs VM / velox. - -*** 2026-06-25 Thu @ 16:05:07 -0400 archangel unblocked; ZFS base built; 3 archsetup bugs fixed (local); re-run paused -archangel shipped the fix (archangel =89691a0=: =pacman -Syy= before pacstrap) + rebuilt the ISO. With it, =create-base-vm.sh FS_PROFILE=zfs= built a verified ZFS-root base (=archsetup-base-zfs.qcow2=, clean-install snapshot, kernel 6.18.36). =make test FS_PROFILE=zfs= then surfaced three real archsetup bugs against the current archangel base, each fixed in a LOCAL (unpushed) commit: -- =8ed42b9= informant: the base ships informant; its pacman PreTransaction hook (AbortOnFail) blocked archsetup's first transaction. Fix: =informant read --all= up front (guarded). PROVEN. -- =66caeb5= pacman.conf perms: the base ships =/etc/pacman.conf= 0600 (archangel =strip_repo_stanza= mktemp+mv clobbers perms), breaking user =makepkg=/=yay=. Fix: =chmod 644= after archsetup's edits. PROVEN (run reached 75 min deep). -- =05ec096= reflector: archsetup configured reflector's timer but never ran it, so installs used the base's 425-mirror worldwide list and pacman stalled ~15 min on a slow/unresponsive mirror. Fix: run reflector once before the heavy installs (=timeout=-bounded, non-fatal). NOT yet integration-proven — the next re-run validates it. -Second archangel handoff sent for the pacman.conf-0600 root cause (=2026-06-25-1440-...=); archsetup's chmod is defensive, archangel should ship 0644. Paused before the re-run at Craig's request (he starts =sudo make test FS_PROFILE=zfs= from the laptop). Possible harness-side factor on the stall: slirp IPv6 blackholing (one stalled conn was IPv6) — watch if it recurs despite reflector. - -*** 2026-06-25 Thu @ 21:56:12 -0400 P-C GREEN — ZFS VM test path passes end to end -=make test FS_PROFILE=zfs= PASSED: archsetup exit 0 (full ~68-min ZFS install, reflector held — no stall), pytest =95 passed, 0 failed, 11 skipped=. The ZFS-conditional checks now run the ZFS branch instead of skipping: =test_bootloader_installed= (ZFSBootMenu EFI binary at /efi/EFI/ZBM), =test_mkinitcpio_hooks= (zfs udev hook), =test_console_font_configured= (vconsole.conf), =test_zfs_has_sanoid= all PASS; =test_backup_created_for_mkinitcpio= correctly SKIPs (ZFS+virtio edits nothing). The 3 archsetup issues (gamemode, mu, signal-cli AUR) are the known non-critical residuals, same as on btrfs. Four commits pushed to main: =8ed42b9= informant news-hook, =66caeb5= pacman.conf 0644, =05ec096= reflector-during-install, =eb379c3= ZFS-aware boot/backup tests. P-C (ZFS coverage, design phases A-C) is DONE. Remaining on this task: P-D (migrate run-test-baremetal.sh to inject_root_key + run_testinfra_validation) and P-E (delete the dead validation.sh shell sweep). -*** 2026-06-25 Thu @ 23:26:02 -0400 P-D + P-E done — whole epic closed -P-D (=771b92e=): migrated =run-test-baremetal.sh= to key auth + Testinfra. =inject_root_key= generalized to =root@$VM_IP= (vm-utils) so it serves both runners; the bare-metal runner now injects the key after the genesis rollback, threads =SSH_KEY_OPT= + a new =--port= through every ssh/scp, and validates via =run_testinfra_validation= instead of the shell sweep. Follow-up fix =fb495d4=: =set +e= around the validator (it returns pytest's rc, which under =set -e= aborted before the report) — caught by the smoke test. Validated against the ZFS VM (=--validate-only=, localhost:2222): connectivity, ZFS check, key auth, Testinfra connect+run, report all work; a green bare-metal install still needs real ZFS hardware. - -P-E (=a4a339b=): deleted the dead shell sweep from =validation.sh= now both runners use Testinfra — run_all_validations, validate_all_services, run_full_validation, the ~35 validate_* checks, validation_pass/fail/warn/skip. Kept the live helpers (ssh_cmd, attribute_issue, capture_pre/post_install_state, analyze_log_diff, categorize_errors, generate_issue_report, VALIDATION_* counters + arrays). 1156 → 314 lines. Verified: no dangling refs, both runners parse + smoke-run clean, unit suite green. - -Known follow-ups (not blockers): (1) archangel still owes the pacman.conf-0600 root-cause fix (handoff in its inbox; archsetup's chmod is the defensive layer). (2) The bare-metal runner runs =bash archsetup= with no --config-file — pre-existing, would prompt on real hardware; out of this epic's scope. (3) A true green bare-metal run needs real ZFS hardware (ratio). -** DONE [#B] Implement Testinfra test suite for archsetup -CLOSED: [2026-06-25 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -*** 2026-06-25 Thu @ Final fresh make test GREEN — Testinfra is the validator -=make test= (fresh build, 150-min cap) PASSED: =TEST PASSED=, =Validation: PASSED=, pytest =96 passed, 10 skipped, 0 failed, 0 errors=, pytest as the authoritative gate. ParallelDownloads now =10= on the fixed build. End-state: the VM test runner validates post-install via the Testinfra/pytest sweep (=scripts/testing/tests/=, 88 tests + conftest fixtures) — full parity with the old shell sweep plus expansion coverage (sshd hardening, =backup_system_file= .bak files, applied pacman/makepkg/NM/fail2ban/reflector config). Three real bugs surfaced + fixed by this work: (1) the 2026-06-24 sshd hardening had silently broken =make test= (root password SSH died mid-run → key auth, f50fc1d); (2) =ParallelDownloads= stuck at Arch's default 5 (sed only matched the commented form → fixed, 2d63802); (3) install monitor cap too tight at 90 min (→ 150, fe84b71). Follow-up filed: migrate =run-test-baremetal.sh= off the shell sweep, then delete the dead =validation.sh= functions (P5). -*** 2026-06-25 Thu @ Decision: port to Testinfra + expand coverage, design doc first -Reviewed against the existing harness: =scripts/testing/lib/validation.sh= already runs ~14 post-install checks (=run_all_validations=), so this isn't net-new capability — it's porting that shell validation to Testinfra/pytest for better expressiveness + reporting, then growing coverage. Craig's call (prioritizes test investment over feature speed): do the port and expand. Starting with a design doc in =docs/design/= per the task's own "design doc not yet written" note. Stale slice to drop/rescope: the X11/startx end-to-end tests (fleet is Wayland/Hyprland now). -*** 2026-06-25 Thu @ 00:54:22 -0400 P1 scaffold landed (advisory, alongside shell sweep) -Built the Testinfra harness skeleton: =scripts/testing/tests/= (conftest.py with the attribution marker + report hook + =target_user= fixture; 3 parity checks — user exists/shell, ufw enabled, dotfiles stowed+readable), =scripts/testing/lib/testinfra.sh= (=run_testinfra_validation=: ephemeral-key injection, ssh-config, pytest-over-SSH; advisory + non-fatal, =RUN_TESTINFRA= toggle), wired into run-test.sh after the shell sweep, and added =python-pytest python-pytest-testinfra= to =make deps=. Verified on host: py_compile clean, =pytest --collect-only= green in a throwaway venv (4 tests, fixtures resolve), =bash -n= + shellcheck clean, unit suite still green. Integration (the pytest sweep actually running against a VM) is unverified here — needs a =make test= run. Decisions locked: inject test key; run both through parity; full expansion (P4) in this task after the P3 cutover. -*** 2026-06-25 Thu @ 01:12:09 -0400 P2 full parity port (88 tests) -Ported the whole shell sweep to pytest: test_users (exists/shell/15 groups parametrized), test_packages (yay+functional, pacman, terminus-font, emacs+config readable, git, 5 dev tools), test_services (required enabled/active, enabled-only, timers, optional skip-if-absent, DoT drop-in, fail2ban/nmcli responds, log-cleanup cron, syncthing lingering, DNS/mDNS/docker skips), test_desktop (Hyprland tools+configs+portal+socket gated on install/compositor, DWM suckless, autologin), test_boot (grub, mkinitcpio hooks branched on zfs_root, console-font-in-initramfs, nvme gated, zfs/sanoid), test_keyring (dir 700/owner/default=login), test_archsetup (log no Error:, ≥12 state markers). conftest fixtures: target_user/home/zfs_root/has_nvme/hyprland_installed/dwm_installed/compositor_running/on_slirp. 88 tests collected, py_compile clean. Correctness fix vs the shell sweep: check =awww= not the stale =swww=. Installed python-pytest-testinfra on velox so the harness gate passes. Next: VM run to diff pytest vs shell sweep for parity. -*** 2026-06-25 Thu @ 01:24:11 -0400 Fixed: sshd hardening had silently broken =make test= -VM run #1 aborted ~6 min in (Error 5), before any validation ran. Root cause (pre-existing, not the Testinfra work): the 2026-06-24 sshd hardening sets =PermitRootLogin prohibit-password= + reloads sshd mid-install, and the harness SSHes as root by *password* throughout — so every op after that step got "Permission denied" and run-test.sh fataled before validations. Fix: =inject_root_key= authorizes a throwaway root key right after first SSH (before archsetup runs) and all helpers (=wait_for_ssh=/=vm_exec=/=copy_to_vm=/=copy_from_vm=/=ssh_cmd=) gained =$SSH_KEY_OPT= so they use key auth, which =prohibit-password= still allows. testinfra.sh reuses that key. Additive (password stays as fallback). bash -n + shellcheck clean. Re-running the VM suite to confirm it now reaches the validation + pytest phases. -*** 2026-06-25 Thu @ 03:33:33 -0400 Parity proven + P4 expansion validated on a live VM -VM run #3 (=make test-keep=, kept VM up): pytest parity = 78 passed / 10 skipped / 0 fail / 0 err — matches & exceeds the shell sweep (53/0/0). Then built P4 expansion against the live VM (iterating in ~30s, no rebuild): test_hardening (sshd prohibit-password, sysctl printk, /etc/issue emptied, vconsole font, /efi fmask), test_config_applied (pacman ParallelDownloads/Color/multilib, makepkg MAKEFLAGS/OPTIONS, NM dns+wifi-privacy drop-ins, fail2ban jail, reflector), test_backups (=.archsetup.bak= present for pacman.conf/makepkg.conf/sudoers/mkinitcpio.conf — end-to-end proof of the backup feature). Full suite vs live VM: 95 passed / 10 skipped / 1 fail. The 1 fail = a REAL archsetup bug the tests caught: =ParallelDownloads= stayed at the Arch default 5 because the sed only matched a commented =#ParallelDownloads=, but current Arch ships it uncommented — fixed the sed to match both (=^#\?ParallelDownloads=). Also fixed a test bug (=grep -qx '[multilib]'= → =grep -Fxq=, the brackets were a regex char class). Remaining: P3 cutover (pytest authoritative) + P5 retire shell sweep, then a final fresh =make test=. -*** 2026-06-25 Thu @ 03:38:28 -0400 P3 cutover: Testinfra is now the authoritative validator -run-test.sh dropped the =run_all_validations= + =validate_all_services= shell-sweep calls; =run_testinfra_validation= now drives =TEST_PASSED= (returns pytest's rc; "couldn't run" = fail, not a silent pass). It surfaces pytest's pass/skip/fail counts through the shared =VALIDATION_*= counters and parses =testinfra-attribution.txt= into the issue arrays so =generate_issue_report= still buckets failures archsetup/base/unknown. Validated the failure path against the still-up VM: pytest rc=1, failure correctly bucketed to [archsetup]. P5 (physically delete the dead shell-sweep functions) is NOT done here — =run-test-baremetal.sh= still calls =run_all_validations=/=validate_all_services=, so deletion must wait until the bare-metal runner is migrated too (filed below). Final step: fresh =make test= to confirm the pass path (ParallelDownloads now 10) with pytest as the gate. -*** 2026-06-25 Thu @ 08:35:26 -0400 Final run hit the harness 90-min install cap (not a regression) -The fresh =make test= timed out at 9/12 steps while building =vagrant= from AUR (=ARCHSETUP timed out after 90 minutes=, exit 124), so validation ran against a half-installed system → 10 pytest failures, all late-step (issue/sysctl/vconsole/mkinitcpio/docker/state-markers). The suite worked correctly — it caught an incomplete install. Verified my ParallelDownloads sed is clean (no pacman corruption) and archsetup logged 0 errors. Root cause: =MAX_POLLS=180= (90 min) is too tight for a full install with heavy AUR builds; bumped to 300 (150 min). Re-running. -Create comprehensive integration tests using Testinfra (Python + pytest) to validate archsetup installations - -Tests should cover: -- Smoke tests: user created, key packages installed, dotfiles present -- Integration tests: services running, configs valid, X11 starts, apps launch -- End-to-end tests: login as user, startx, open terminal, run emacs, verify workflows - -Framework: Testinfra with pytest (SSH-native, built-in modules for files/packages/services/commands) -Location: scripts/testing/tests/ directory -Integration: Run via pytest against test VMs after archsetup completes -Benefits: Expressive Python tests, excellent reporting, can test interactive scenarios - -A design doc (not yet written) should cover: -- Complete example test suite (test_integration.py) -- Tiered testing strategy (smoke/integration/end-to-end) -- How to run tests and integrate with run-test.sh -- Comparison with alternatives (Goss) -** DONE [#C] Proton Mail Bridge font size :chore:quick: -CLOSED: [2026-06-24 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -From the roam inbox (2026-06-22): adjust the Proton Mail Bridge UI font to a comfortable size. The bridge is a Qt app, so it likely keys off Qt scaling or the qt5ct/qt6ct config like the other Qt apps (QT_SCALE_FACTOR or a font setting). +#+begin_example +% net doctor +net doctor: fixable + DNS not resolving + -> net repair dns-test + diagnose: + fail: DNS resolution — no resolution (portal may be stalling DNS) + fail: Internet — link up but no clean internet (DNS or egress issue) +#+end_example +** DONE [#B] Network panel: identify tunnel backends + richer connection info :feature:waybar:network: +CLOSED: [2026-07-02 Thu] +Shipped (dotfiles =405235f=). Identification: every Tunnels row's caption now leads with its backend — "tailscale", "WireGuard (NetworkManager)", "openvpn (NetworkManager)" (from the profile's =vpn.service-type=, resolved on the panel path only), "Proton VPN CLI" — via =viewmodel.tunnel_kind_label=. Found and fixed a real gap: NM vpn-type profiles (openvpn etc.) weren't listed at all, only wireguard type. Active tunnels now carry their device's IP4 address. Info page: the active connection's live subtitle gains IP, gateway, and DNS via =build_status(full=True)= (panel poll only — the bar's one-nmcli hot path is untouched). Live-verified on velox: all 9 tunnel rows correctly labeled (tailscale w/ tailnet + peers, 7 WireGuard NM profiles, Proton CLI), live subtitle shows IP/gw/DNS on hotel wifi. 523 net tests / 45 suites / panel smoke green. -Done 2026-06-24 (dotfiles =hyprland.conf:47=): the bridge is a Qt6 *QML* app, so it ignores the qt6ct General font — bumped the UI font via =QT_FONT_DPI= on the autostart instead. Changed the exec-once to =env QT_FONT_DPI=108 protonmail-bridge --no-window= (default DPI is 96; 108 = 1.125x). Iterated live with Craig: 120 too big, 108 comfortable. hyprland.conf is a stow symlink so the change is already live; applies at every login. The =~/.config/autostart/Proton Mail Bridge.desktop= entry is dormant under Hyprland (no XDG-autostart), so it was left as-is. -** DONE [#C] Wallpaper login-restore is hardcoded, not waypaper --restore :hyprland:quick:solo: -CLOSED: [2026-06-24 Wed] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -The Hyprland =exec-once= (=hyprland.conf:26=) restores the wallpaper with a hardcoded =awww img ~/pictures/wallpaper/trondheim-norway.jpg=, so any wallpaper set later (via =set-wallpaper=, waypaper, or the dirvish =bg=) reverts on relogin. =set-wallpaper= now persists the choice to =waypaper/config.ini=, so switch the exec-once to =waypaper --restore= (after =awww-daemon= is up) to make set wallpapers survive a relogin. Small, dotfiles-only; verify by setting a different wallpaper, relogging, and confirming it sticks. +Craig's ask (roam inbox, 2026-07-02): the Tunnels rows all look alike — no way to tell which is tailscale, which is an NM wireguard/openvpn profile, and which is proton CLI without prior knowledge (e.g. when you want to bounce tailscale specifically). Second half: improve the stats under each connection — the panel is effectively a connection's info page. +** DONE [#B] Timer: alarm am/pm input silently fails :bug:waybar:solo: +CLOSED: [2026-07-02 Thu] +Fixed (dotfiles =8dd36c4=). Two root causes: =parse_alarm= only accepted 24h =HH:MM=, and =cmd_new= suppressed the ValueError, so any 12h input silently created nothing. Now accepts 24h (=14:30=, bare =14=) and all common 12h shapes (=2:30pm=, =2:30 PM=, =7:15p=, =7p=; any case, optional space, bare a/p; 12am = midnight), and input that still doesn't parse fires a fail notification instead of vanishing. 107 wtimer tests green (10 new parse cases + notify-on-error CLI tests). Manual test filed (live dialog run). -Done 2026-06-24 (dotfiles): swapped the line-26 exec-once from the hardcoded =awww img …/trondheim-norway.jpg= to =awww-daemon & sleep 1 && waypaper --restore=. waypaper has a real =awww= backend (in its =--backend= list), the stowed =waypaper/config.ini= carries =backend = awww= plus a default =wallpaper == line, so =--restore= works on a fresh install too. Mechanism verified live: =waypaper --restore= reapplied the persisted wallpaper via awww, exit 0. Relogin confirmation filed under "Manual testing and validation". Follow-up filed: =set-wallpaper='s =mv= detached the live =waypaper/config.ini= from its stow symlink, so set-wallpaper changes no longer flow back to dotfiles. -** DONE [#B] VM test harness shared one NVRAM file across filesystem profiles :bug:test: -CLOSED: [2026-06-27 Sat] -The harness shared one OVMF NVRAM file (=vm-images/OVMF_VARS.fd=) across the btrfs -and zfs profiles (=init_vm_paths= suffixed the disk image per profile but not the -NVRAM). NVRAM lives outside the qcow2, so a disk-snapshot revert can't restore it, -and a zfs run's ZFSBootMenu boot entries clobbered the btrfs GRUB entry. With no -removable =\EFI\BOOT\BOOTX64.EFI= fallback on the base ESP, the next btrfs run -booted into UEFI with no bootable device ("BdsDxe: No bootable option or device -was found", then PXE/HTTP, then SSH timeout before archsetup ran). Found -2026-06-27 trying to VM-validate the installer refactor. +Craig's report (roam inbox, 2026-07-02): when setting an alarm, entering am or pm in any fashion makes the timer silently fail. It should accept 24h and 12h variants — capitalization, spaces, bare "a"/"p" — all common forms. +** DONE [#B] Timer: escape doesn't cancel the dialog flow :bug:waybar:solo: +CLOSED: [2026-07-02 Thu] +Fixed (dotfiles =8dd36c4=). Root cause: =_fuzzel= ignored fuzzel's exit code, so Escape (fuzzel exits 2 on a dmenu abort — confirmed in its changelog) returned "" and the flow fed it onward to the next prompt. =_fuzzel= now returns None on any non-zero exit and =cmd_new= aborts the whole flow on None at any step (type, duration/alarm, label). Escape-at-each-step covered by CLI tests against a fake fuzzel exiting 2. Manual test filed (real keyboard Escape). -Fixed: =OVMF_VARS= now carries the same per-profile suffix as the disk image -(=OVMF_VARS${img_suffix}.fd=) in =vm-utils.sh init_vm_paths=, so btrfs and zfs keep -separate NVRAM. Validated by a full green zfs run 2026-06-27 (ArchSetup exit 0, -Testinfra 96 passed / 0 failed). Remaining hardening tracked below. -** DONE [#B] Guard against live mesa/hyprland/wayland-runtime updates :hyprland: -CLOSED: [2026-06-28 Sun] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-09 -:END: -A live =pacman -Syu= that swaps mesa/hyprland/wayland runtime libs out from under a running Hyprland session can crash the compositor: the next GPU-lib call hits a now-"(deleted)" library and SIGABRTs, taking the Wayland clients down with it. Hit ratio 2026-06-07 (mesa 26.0.6 -> 26.1.2 + hyprland upgraded live; Hyprland SIGABRT took down awww/insync/emacs). Likely the driver behind ratio's high lifetime unsafe-shutdown ratio — a crashed compositor forces a hard reset. +Craig's report (roam inbox, 2026-07-02): hitting cancel via escape at the step after choosing "timer" does nothing but proceed to the next step — likely the same for the other dialog steps. +** DONE [#B] Network panel: stream speedtest results live :feature:waybar:network:solo: +CLOSED: [2026-07-02 Thu] +FIX-UP (dotfiles =60707be=, 2026-07-03, caught by Craig): the first shipped version didn't actually stream — speedtest-go buffers all phase lines to process exit when piped (per-line arrival timestamps proved it: 25s of silence, then everything at once; the original "live" verification never checked arrival timing). The stream now runs the binary under a pty, where terminal mode redraws continuously: in-flight rates tick (download climbing like a speedometer), ANSI/spinner noise is stripped, and on_update fires per changed value. CLI closes with a "final:" settled-numbers line. Re-verified with timestamps (server +1s, ping +2s, download first tick +4s, upload +19s, final +29s) AND an AT-SPI probe of the live panel that sampled the results box mid-run: ping filled at 4s, download ticking at 12s, upload at 24s, final rows + conditioned tips at the end. 529 net tests / 45 suites green. -Shipped as a pacman PreTransaction hook rather than a wrapper, so it fires no matter how the upgrade is launched (pacman, yay, topgrade). =scripts/hypr-live-update-guard= aborts the transaction before any package is swapped when the GPU/compositor runtime set is being upgraded AND Hyprland is running, pointing the user to re-run from a TTY with the session stopped; it stays quiet when Hyprland isn't running (the safe from-a-TTY path). Override via =HYPR_ALLOW_LIVE_UPDATE=1= or by touching the sentinel file named in the abort message. archsetup installs the script to =/usr/local/bin= and the hook to =/etc/pacman.d/hooks/= in the hyprland path. Decision logic unit-tested (=tests/hypr-live-update-guard=, 9 cases). Live firing test filed under Manual testing and validation. Commits: archsetup (this session). -** DONE [#B] Collapsible waybar sides :waybar: -CLOSED: [2026-06-27 Sat] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-09 -:END: -Let either side of the waybar collapse horizontally to a minimal base set, toggled by a click. Each collapsible side carries a small triangle / arrowhead pointing toward the screen edge it collapses into (away from center). Clicking it collapses that side to its base set and flips the arrow to point back toward center; clicking again restores the full side. Same shape-changes-with-state idea as the auto-dim indicator. +Shipped (dotfiles =38171e8=). =run_speedtest_stream= runs speedtest-go's plain mode, whose lines land one per completed phase (parser written against a real captured hotel-wifi run). Panel: a checklist fills in as ping → download → upload arrive, final rows at the end. =net speedtest= streams the same lines at the terminal (=--json= keeps the one-shot envelope). Bonus from the text mode: jitter (rides the Ping row) and packet loss (own row, warns >1%) — the JSON mode never reported either. The static Tip is gone; =speedtest_tips= derives guidance from the numbers (high ping >100ms, download < half of upload, <10 Mbps both ways, loss >1%), each tip naming its trigger values — that's the answer to Craig's "what criteria" question: the old tip had none, the new ones are stated rules. 509 net tests / 45 suites green; live CLI run streamed correctly and fired the asymmetric-download tip on real numbers (33 down / 76 up). Manual test filed for the in-panel run. -Spec (2026-06-19): [[file:assets/2026-06-19-collapsible-waybar-sides-spec.org]]. Spike that settled the mechanism: [[file:assets/2026-06-18-collapsible-waybar-sides-spike-findings.org]]. +Craig's ask (roam inbox, 2026-07-02): the speedtest only shows results at the end; typical speedtest UIs report the numbers as they come in. Stream the CLI's progress into the results box as it arrives, then the final numbers at the end. Screenshot: ~/pictures/screenshots/2026-07-02_225441.png. +** DONE [#C] Bluetooth bar icon: gray instead of the bar's white :bug:waybar:bluetooth:solo: +CLOSED: [2026-07-03 Fri] +Fixed (dotfiles =27d8eda=). Root cause: the =on= state sat in the css dim group with off/absent/degraded (a deliberate "idle dims" choice that read as broken). Removed =.on= from the dim rule in all three css copies (dupre, hudson, live style.css — the theme-drift guard suite pins them together); indicator docstring updated. Live-verified: SIGUSR2 css reload, bar screenshot shows the bt glyph in the bar's resting white alongside battery/text. -Decisions locked: right base set = date + worldclock + tray; left base set = menu + workspaces; per-side independent; host-agnostic (base set constant, full set is each host's existing config). Mechanism = config-swap + SIGUSR2 reload via an active-config copy in =$XDG_RUNTIME_DIR= (the CSS/state-file approach was disproven — GTK3 can't reflow-hide native modules). Lives in =~/.dotfiles/hyprland/=. +Craig's report (roam inbox, 2026-07-03): the bluetooth waybar icon renders gray, not the same white as the other bar module icons. +** DONE [#B] Bluetooth panel: close button like the net panel :feature:waybar:bluetooth:solo: +CLOSED: [2026-07-03 Fri] +Shipped (dotfiles =42c93d6=): a flat circular Close button right of the tab switcher (accessible "Close" label, "Close (Esc)" tooltip), wired to window.close. The bt smoke asserts it exists AND that clicking it exits the panel (run green live). Plot twist answered in-session: the net panel had no close button either — Craig's leaner-chrome pass removed it 2026-07-01 (787b475) on the Esc-suffices theory; he asked where it went, so it was restored with the same tab-row button (=6a0aff7=, net smoke extended the same way). Both panels match again. -Shipped per spec (dotfiles 804bef6): 3 TDD'd scripts (=waybar-active-config=, =waybar-collapse=, =waybar-arrow=; 22 cases), arrow modules wired into the config (left arrow innermost-left, right arrow innermost-right), CSS ×3, =$mod+[= / =$mod+]= keybinds, and =waybar-toggle= relaunch updated to load the active config so a crash preserves collapse state. Verified live: click, keybind, and per-side independence all work; expand round-trips exactly to canonical. -** DONE [#C] Collapse waybar sysmonitor to a single icon + hover :feature:waybar: -CLOSED: [2026-06-27 Sat] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -From the roam inbox (2026-06-22): replace the spread-out sysmonitor readouts (temp, cpu, mem, storage) with one visible icon showing a single chosen metric, the rest in the hover tooltip. Open question: fold it into the battery component instead of a standalone module. Implementation lives in the waybar config under ~/.dotfiles. +Craig's ask (roam inbox, 2026-07-03): the bt panel needs a close button matching the network panel's. +** DONE [#B] Bluetooth panel: switch placement + panel title :feature:waybar:bluetooth:solo: +CLOSED: [2026-07-03 Fri] +Delivered by the instrument-console rebuild (spec e73877f5, phase 5). The adapter-power switch now sits on the faceplate above every console key, and the engraved ADAPTER line is the panel's title row with the clickable discoverable chip right-justified on it. -Shipped as a standalone =custom/sysmon= module (Craig's call: host-dependent primary — battery on laptop, disk on desktop — rather than fold into battery, which is laptop-only). Backing script =waybar-sysmon= gathers cpu/temp/mem/disk/battery, shows the host-appropriate metric, rest in tooltip; 13-case TDD suite; removed the 5 native modules + their CSS across all 3 themes. Dotfiles be7469b. -** DONE [#C] Rename idle inhibitor to something more intuitive :chore:waybar: -CLOSED: [2026-06-27 Sat] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-24 -:END: -From the roam inbox (2026-06-24): the "idle inhibitor" name doesn't work as a mnemonic — something like "sleep" (i.e. "keep awake" / "no-sleep") would land better. Decide the new name, then rename across the touchpoints: the =custom/idle= waybar module, the keybind mnemonic, and the backing script names (=hypridle-toggle= / =waybar-idle= from the 2026-06-24 idle-inhibitor work). Needs Craig's call on the name first, so not solo. +Craig's ask (roam inbox, 2026-07-02): move the bluetooth on/off switch above all the buttons. "Bluetooth" becomes the panel's title, with the on/off switch right-justified on that title row. Panel code in ~/.dotfiles =bluetooth/= (GTK4 + Blueprint, phase-2 PanelModel/presenter — see the shipped panel task in Resolved). Presenter tests + the AT-SPI smoke likely need their layout assertions updated. +** DONE [#B] Bluetooth panel: rename devices :feature:waybar:bluetooth:solo: +CLOSED: [2026-07-03 Fri] +Delivered by the instrument-console rebuild (spec e73877f5, phase 5). =btctl.set_alias= renames through the bluez D-Bus Alias via busctl (no MAC-addressed one-shot exists; =device_path= finds the controller node from the object tree), =manage.rename= wraps it with a verify-after read, and =parse_info= reads the Alias as the display name. Each paired row carries a ✎ affordance opening a rename dialog. Live-probed the mechanism on velox before wiring it (rename + restore verified on the M650). -Renamed to "caffeine" (Craig's call, 2026-06-27): =custom/caffeine= module, =waybar-caffeine= + =caffeine-toggle= scripts, tooltip "Caffeine: ON/OFF", CSS + test suites updated. Keybind stays =$mod+I= (=$mod+C= is hyprpicker). Shipped in dotfiles 8b45b51. +Craig's ask (roam inbox, 2026-07-02): the panel should be able to rename a device. bluez supports per-device aliases (=bluetoothctl= device menu =set-alias=; the one-shot invocation shape needs verifying at the btctl boundary). Wire it through the engine (=bluetooth/src/bt/=) with a verify-after read, and surface a rename affordance on the device row consistent with the panel's existing patterns. +** DONE [#B] Network panel: other network interfaces (tailscale, VPNs, wireguard) :feature:waybar:network: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:SPEC_ID: 79a1075a-4b56-4f25-a861-b69f120a636a +:END: +Spec: [[file:docs/design/2026-07-02-net-panel-other-interfaces-spec.org]] (DOING — reviewed READY and decomposed 2026-07-02 evening; all four decisions were resolved same morning, claims re-verified live at review: protonvpn binary, tailscale JSON shape, seven importable wireguard configs). + +Tunnels visible and controllable in the net panel: tailscale + NM wireguard + proton-vpn-cli probes, a Tunnels group in Connections, diagnose/doctor route-ownership awareness, a bar badge when a tunnel owns the default route, archsetup operator flag + package swap, and the one-time NM import of the seven Proton configs. Origin: roam inbox capture 2026-07-02. + +*** 2026-07-02 Thu @ 18:47:05 -0400 Shipped phase 1 — overlay probes (dotfiles 2d9d060) +=net/src/net/overlays.py=: one probe per backend, shared row shape ={kind, name, state, addr, detail, can_toggle}=. tailscale parses =status --json= (up/down/needs-login/stopped, tailnet + N/M peers online + exit node detail, first TailscaleIP); wireguard rows filter =nmcli connection show= by type with uuids for the existing up/down wrappers; proton drives the official CLI — ground truth sampled live before writing the parser: the GUI-running refusal prints to stdout and EXITS 0 (text-detected, =can_toggle false=), disconnected = "Status: Disconnected", and the CLI's account store is separate from the GTK app's (=protonvpn info= → Account 'None' — sign-in is a phase 6 migration step for Craig). =net status= gained a fast-path overlays section (tailscale + wireguard only; the python CLI's ~300ms startup stays out of the indicator poll, and an active proton tunnel surfaces as its NM wireguard row anyway), guarded so a probe crash yields =[]= not a dead indicator. 19 new tests over fake-tailscale/fake-protonvpn/fake-nmcli (45 suites green); live check on velox: tailscale row up, 5/6 peers, hot path 149ms. proton-vpn-cli 1.0.1 installed on velox (GTK app stays until phase 5). + +*** 2026-07-02 Thu @ 19:02:45 -0400 Shipped phase 2 — panel Tunnels sub-view (dotfiles 21db05a) +Connections gained a third sub-view (Available | Saved | Tunnels — a StackSwitcher page, the natural landing for the spec's "fourth group" in this UI): rows from =overlays.collect(fast=False)= with the vpn glyph, name, and a =tunnel_caption= (state · addr · backend detail); one primary button follows the selected row via =PanelModel.tunnel_primary()= — Bring Up/Bring Down when toggleable, disabled explainers for needs-login ("Sign in first: tailscale up") and the Proton GUI-running case. =manage.tunnel_up/down= dispatch by kind (wireguard rides the existing nmcli up envelope + =connection down=; tailscale/protonvpn shell their tools into a =_tool_result= envelope carrying stderr on failure); ops run on the worker thread, rows + bar reload on land. gui grew =refresh_tunnels()= (bg, full probe set) kicked from the list load. AT-SPI smoke extended (Tunnels tab, action button, rows — POLLING for the bg load; a fixed sleep raced it and false-failed). 22 new tests (45 suites green). LIVE on velox: smoke fully green, rows eyeballed in dupre (tailscale up caption with peers count; proton app-running row), =tailscale set --operator=cjennings= applied and the user-mode =tailscale down/up= round-trip verified (Self.Online back true). Gotcha reconfirmed: stray test panels leave a windowless single-instance process — =pkill -9 -f '[n]et panel'= + wait before relaunch. + +*** 2026-07-02 Thu @ 19:11:47 -0400 Shipped phase 3 — diagnose/doctor tunnel awareness (dotfiles 31ba056) +=overlays.default_route_owner()= classifies the default route's owner (tailscale prefix, wg/pvpn/proton/tun/tap prefixes, else the active NM connection's type — imports can name a wireguard device anything). diag's route step went three-way: overlay owner = informational pass row ("internet flows through the tailscale tunnel tailscale0"), other physical link = the old multi-homing warn. When the HTTP probe fails while a tunnel owns the route, a new "tunnel" edge row LEADS the evidence and the classifier returns fixable/action tunnel-down (the deferred-vpn verdict is retired — it was look-don't-touch, and it never caught tailscale at all since NM lists it unmanaged; an NM VPN that doesn't own the route now falls through to normal classification instead of being blamed). =repair_tunnel_down= dispatches by owner (tailscale CLI / protonvpn CLI for pvpn-named devs / nmcli connection down via active-connection lookup), verifies route ownership actually moved, and registered in ACTIONS so Get Me Online drives it. fake-ip gained FAKE_IP_DEFAULT_DEV_SEQ (head-first line consume, the UP_RC_SEQ idiom) so tests watch the owner change across the verify. 11 new tests, 2 old deferred-vpn pins rewritten to the new contract; 45 suites green; live read-only diagnose on velox clean (wlan owns the route — no tunnel rows, as designed). + +*** 2026-07-02 Thu @ 19:14:58 -0400 Shipped phase 4 — bar tunnel badge (dotfiles b4010bf) +=net status= carries =tunnel_route= ({dev, kind} via =overlays.default_route_owner=, exception-guarded like the overlays list, present on the no-device path too). The indicator appends a small nf-md-vpn badge after the state glyph, emits =["<state>", "tunnel"]= as a waybar class list (string class unchanged when no tunnel), and the tooltip names the owner ("Tunnel: default route via tailscale0 (tailscale)"). No css edit — presence is the signal, themes can hook the class later, and the waybar/style.css drift test stays untouched. 4 new tests; StatusHarness gained fake-ip so the machine's real route can't leak into assertions (462 net tests, 45 suites green). Live payload on velox verified badge-free (wlp170s0 owns the route — correct); a badge render awaits the first real tunnel-owned route (phase 6's wg import or a tailscale exit node). + +*** 2026-07-02 Thu @ 21:56:00 -0400 Shipped phase 5 — installer proton CLI swap + tailscale operator (archsetup 0389790); GTK app retired live on velox +The feat commit landed at 19:16 (the session died before this close-out): installer enables tailscaled with =--now= and grants =tailscale set --operator= to the primary user (brief retry while the daemon's socket comes up), proton-vpn-cli replaces proton-vpn-gtk-app, VM asserts the vpn stack + the retirement + the OperatorUser pref (format verified against a live daemon). Live velox application finished 21:55: the =protonvpn-app --start-minimized= exec-once removed (dotfiles b5c8442 — nothing replaces it, the CLI is on-demand from the panel), the running app killed, =pacman -Rns proton-vpn-gtk-app= (proton-vpn-daemon stays — separate package the CLI uses). CLI verified unblocked: =protonvpn status= → "Status: Disconnected", =protonvpn info= → Account 'None' (sign-in is Craig's step, filed under Manual testing and validation). + +*** 2026-07-02 Thu @ 21:57:00 -0400 Shipped phase 6 — wireguard import script + velox migration (scripts/import-wireguard-configs.sh) +The script stages each config through a =wgpvpn.conf= temp copy (NM's import name must be a valid <=15-char interface name; several config names are longer), renames by the UUID parsed from the import output (never by the transient name, so a stray same-named connection can't be hit), forces =autoconnect no= (full-tunnel AllowedIPs 0.0.0.0/0 must not arm itself at boot), skips already-imported names, and refuses to run past a stale =wgpvpn= connection (an earlier run that died between import and rename — it still has autoconnect on). =tests/import-wireguard-configs/=: 10 cases over a fake nmcli; writing them caught a real bug (under =set -e= the grep-for-UUID pipeline aborted before the error message printed). shellcheck clean; 11 unit suites green. Velox migration verified: the crashed session had already run the import, so tonight's run exercised the skip path live — all 7 connections confirmed wireguard type, autoconnect no, iface wgpvpn, no stale leftovers; =net status= overlays show tailscale + all 7 rows. Ratio runs the script on its trip (rides the archsetup pull). + +*** 2026-07-02 Thu @ 21:58:00 -0400 Test surface complete across the phases +Probe suites over fake tailscale/nmcli/protonvpn (19, phase 1), panel-model Tunnels coverage (22, phase 2), diag overlay-ownership cases (11, phase 3), badge suite (4, phase 4) — all in dotfiles; VM assertions for phase 5 in archsetup 0389790; the import-script suite (10, phase 6) closes the set. +** CANCELLED [#B] File-manager swallow pattern :feature:hyprland: +CLOSED: [2026-07-02 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-02 +:END: +Reassigned to .emacs.d 2026-07-02 (handoff: =~/.emacs.d/inbox/2026-07-02-2231-from-archsetup-dirvish-popup-swallow-handoff.org=). The "file manager" is the dirvish popup (Super+F, an Emacs frame), not nautilus — so the fix is elisp in dirvish's external-open path (=cj/xdg-open=): spawn the handler directly with =start-process=, hide the popup frame, restore it from the process sentinel, notify on non-zero exit. The spec drafted here first ([[file:docs/design/2026-07-02-file-manager-swallow-spec.org]], now CANCELLED) records the feasibility finding that stays useful: gio/xdg-open launches double-fork, so no PID-ancestry approach (Hyprland native swallow included) can ever connect viewer to launcher. + +When the file manager launches another app, it should hide to a special workspace (the "swallow" pattern) and return when that process ends, rather than vanishing. Today it disappears with no signal of whether it's coming back, so the user can't tell success from failure — they should quit explicitly instead. Origin: roam inbox capture. + +*** 2026-07-02 Thu @ 22:20:00 -0400 Feasibility ground truth: Hyprland native swallow ruled out +=misc:enable_swallow= would be the whole feature in two config lines, but it matches by PID ancestry, and nautilus's launch path (GLib =g_app_info_launch_default_for_uri=) orphans the handler — reproduced live on velox with a python-gi launcher: feh came up with PPID 1 while the launcher was still running. The spec's design is therefore an event-listener daemon (socket2 =openwindow=/=closewindow= while nautilus is active), the touchpad-auto shape. Handlers sampled: pdf → zathura, image → feh (X11 — flagged as a side task), video → mpv, text → emacsclient (exempt candidate, decision 2). +** DONE [#C] Open meeting links in the browser instead of the Zoom app :feature: +CLOSED: [2026-07-02 Thu] +Shipped 2026-07-02, mechanism per Craig ("the Linux zoom app is really terrible — one less dependency"): a =zoommtg://= URL handler, and the native app retired outright. =zoom-web= (dotfiles 187414a, 10 tests) registers as the xdg default for x-scheme-handler/zoommtg via =zoom-web.desktop=; Zoom's launch-page bounce rewrites deterministically to =https://<host>/wc/join/<confno>?pwd=…= in the default browser (subdomain hosts preserved, tracking params dropped, start action mapped, malformed URIs notify + exit 2). The registration landed in the stowed mimeapps.list, so it ships with dotfiles. Zoom uninstalled from velox (=pacman -Rns=), its windowrules removed from hyprland.conf, =aur_install zoom= dropped from archsetup, and the VM retired-package assertion now covers blueman + zoom. Known limit, accepted: a host who disabled join-from-browser blocks the web client — that meeting needs the native app installed ad hoc. Ratio trip: =pacman -Rns zoom= + the pull brings the handler; run =xdg-mime default zoom-web.desktop x-scheme-handler/zoommtg= if the stowed mimeapps.list doesn't take effect. +** DONE [#B] Network panel redesign — no terminals, verify-everything, full failure coverage :feature:waybar:network: +CLOSED: [2026-07-03 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-02 +:END: +Delivered by the instrument-console rebuild (spec e73877f5). The three locked decisions all landed: no terminals (the single-screen console renders every action and result in the output well — net-popup is gone), the passwordless privileged path (the net-priv helper + narrow NOPASSWD sudoers, shipped earlier and carried forward), and verify-every-action (arm-to-fire mutations plus doctor's re-probe). The failure-mode catalog below is the diagnose/repair contract, built out across the net-diagnostics tasks and this rebuild's DOCTOR path; the catalog stays here as the standing completeness reference for that path. + +Major evolution of the shipped =custom/net= module ([[file:docs/design/2026-06-29-waybar-network-module-spec.org]]). +Reverses the spec's "privileged tiers run in a net-popup terminal" decision. Origin: +design conversation 2026-06-30. + +*** Locked decisions +- *No terminals anywhere in the module.* Delete =net-popup= entirely. Every action and + every result renders in the panel. +- *Passwordless privileged path (the enabler).* A single root-owned helper runs net's + specific privileged commands (rfkill unblock, nmcli modify/up, networking off/on, + systemctl restart NetworkManager/systemd-resolved, resolvectl dns/revert, DoT toggle), + installed by archsetup with a narrow NOPASSWD sudoers rule scoped to that helper only + (never blanket mv/systemctl). =repair.py= calls =sudo <helper> <verb>=. This supersedes + and absorbs the earlier [#C] "Passwordless DoT toggle" follow-up. Without it an in-panel + worker thread can't prompt for a password, so this gates the whole no-terminal goal. +- *Verify every action.* Every mutating op confirms its effect before reporting success + (doctor already re-probes; generalize so each repair, connect, forget, add, and DNS + override re-checks and surfaces pass/fail in the panel). +- *Detect + respond to every failure mode below* (auto-fix where we can, else report the + helpful text), including the edge cases. + +*** Navigation (confirmed) +- Top tabs: =Connections= | =Diagnostics= | =Performance=. +- Connections: saved + in-range list, connect / add / forget. +- Diagnostics: sub-row =Diagnose= | =Get Me Online= | =Advanced=; shared area below shows + diagnose items AND streams repair progress (replacing the terminal). =Advanced= reveals + the individual repair buttons, renamed with tooltips describing each. +- Performance: Speedtest (+ live throughput later). + +*** Failure-mode catalog — detect / correct-or-report (the completeness backbone) +Organized by the connectivity stack, bottom-up. "Fix" = auto-correct + verify; "Report" = +the in-panel text when there's no safe auto-fix. Audit this list for completeness; it is the +contract for what diagnose must detect and what the panel must say. + +**** Radio / hardware +- rfkill soft block — Detect: rfkill soft. Fix: unblock + =nmcli radio wifi on=, verify radio unblocked. +- rfkill hard block — Detect: rfkill hard. Report: "WiFi is off at the hardware switch — flip the physical switch or Fn key." +- No WiFi adapter present — Detect: no wifi device in nmcli + rfkill absent. Report: "No WiFi adapter detected — use ethernet, or check the driver (dmesg | grep firmware)." +- Driver/firmware not loaded — Detect: device present but errored / no operational state. Report: "WiFi driver or firmware didn't load — check dmesg for the adapter." +- USB WiFi adapter unplugged — Detect: device disappeared since last scan. Report: "WiFi adapter was removed — reconnect it." +- Airplane mode on — Detect: airplane state file set. Fix: offer toggle off (Super+Shift+A), verify radios back. + +**** Association (L2 link) +- Not connected / disconnected — Detect: link down, device disconnected. Fix: reset (reconnect saved), verify link up. +- Stuck "connecting" — Detect: device state connecting > budget. Fix: reset, verify; if it persists Report: "Stuck connecting to <ssid> — the AP may be rejecting us." +- Weak signal / high loss — Detect: associated but signal below threshold (dBm) or heavy packet loss. Report: "Signal is weak (<dBm>) — move closer to the access point." +- Saved network not in range — Detect: profile active target not in scan. Report: "<ssid> isn't in range here." +- AP roaming flap — Detect: BSSID bouncing. Report: "Connection is unstable — switching between access points." + +**** Authentication +- Wrong WPA password / missing secret — Detect: NM state 120 (snapshot; live detection is a known limit). Report + in-panel re-enter: "Saved password for <ssid> was rejected — re-enter it." +- Enterprise / 802.1X cert or identity failure — Detect: 802.1X profile + activation failure. Report: "Enterprise auth failed — check the certificate or identity (edit the profile)." +- Randomized MAC rejected by AP — Detect: reset-with-random-MAC fails where a prior connect worked. Fix: retry reset with the permanent MAC, verify; else Report. +- WPA3/SAE incompatibility — Detect: SAE key-mgmt + association failure. Report: "This network needs WPA3 and the adapter or profile may not support it." + +**** IP / DHCP +- No IPv4 lease (DHCP timeout) — Detect: connected, no IP4.ADDRESS. Fix: reset → bounce, verify lease. +- APIPA / link-local only (169.254.x) — Detect: only a link-local IPv4. Fix: reset/bounce, verify real lease; else Report: "DHCP server didn't answer — switch network." +- IPv6-only network (no IPv4 by design) — Detect: no IPv4 but IPv6 address + online via v6. Report (not a failure): "Online over IPv6 (no IPv4 here)." Requires making diagnose IPv6-aware. +- IP but no gateway — Detect: IP4.ADDRESS present, IP4.GATEWAY empty. Fix: bounce, verify gateway; else Report. +- Duplicate IP / ARP conflict — Detect: kernel ARP-conflict signal. Report: "Another device is using our IP address — reconnect to get a new lease." (edge) + +**** Gateway (L3 local) +- Gateway unreachable — Detect: no route out, gateway no ICMP. Fix: try one bounce (renew route), verify online; else Report: "No route to the gateway — switch network." (closes the spec/code gap where bounce was never tried) + +**** DNS +- No resolver configured — Detect: IP4.DNS empty. Fix: bounce to re-pull DHCP DNS, verify; else Report. +- Venue DNS broken, public DNS works — Detect: name fails to resolve but 1.1.1.1 resolves (dns-test). Fix: set a PERSISTENT resolver override (1.1.1.1 / 9.9.9.9), verify resolution + online, offer revert. (closes gap #1 — today dns-test reverts and misreports as upstream.) +- DNS hijack (resolves to gateway / private IP) — Detect: classify_resolution hijack. Treat as captive → portal-login flow. +- DNSSEC validation failure — Detect: resolution fails with SERVFAIL where public resolver succeeds without DNSSEC. Report: "DNS security checks are failing on this network." (edge) +- Encrypted DNS (DoT/DoH) hiding the portal — Detect: captive suspected + DoT on. Fix: portal-login drops DoT, opens portal, auto-restores. (existing) + +**** Egress / internet +- Upstream / AP outage (no uplink) — Detect: link/IP/DNS fine, http-probe fail, not a redirect. Report: "This network has no internet — switch network or contact the venue." +- Captive portal (redirect) — Detect: probe redirected. Fix: portal-login opens the page; verify online after login. +- Captive blocked pre-auth (no portal URL) — Detect: probe blocked, no URL. Fix: fresh MAC + open trigger; verify. +- Proxy-required network — Detect: probe fails but a PAC/proxy is advertised (WPAD/env). Report: "This network requires a proxy — configure it in settings." (edge) +- MTU / MSS blackhole (PMTUD broken) — Detect: small probe ok, large transfer hangs. Fix: lower the interface MTU, verify; else Report. (edge) +- Clock skew breaking TLS — Detect: HTTPS/portal fails with cert-time errors + system clock far off. Fix: trigger a time sync, verify; else Report: "System clock is wrong — fix the date/time." (edge) + +**** Routing / multi-homing +- VPN owns the route, no internet through it — Detect: VPN device connected + http-probe fail. Report: "Internet is routed through a VPN (<dev>) — check the VPN, not WiFi." +- VPN up but dead — Detect: VPN device up, no traffic/handshake. Report: "The VPN is connected but not passing traffic." (Phase 5 territory) +- WiFi + tether/ethernet both active — Detect: which iface owns the default route + whether the system is online by any path. Report: "You're online through <other iface>; WiFi itself has no internet," or let the user pick. (closes gap #4) + +**** Infrastructure / system +- Wedged NetworkManager — Detect: nmcli fails / API unresponsive. Fix: restart NetworkManager (bounce escalation), verify. +- NetworkManager not running — Detect: service inactive. Fix: start it, verify; else Report. +- systemd-resolved down — Detect: resolved inactive / DNS via it fails. Fix: restart, verify. +- resolv.conf not resolved-managed — Detect: /etc/resolv.conf not the resolved stub. Report: "DNS isn't managed by systemd-resolved — manual resolv.conf in play." (edge) + +**** Tooling / environment +- nmcli / NM API unavailable — Detect: nmcli error or timeout. Report: "Can't reach NetworkManager — is it installed and running?" +- Slow / hung tool — Detect: step exceeds budget. Fix: degrade that step, retry within budget. +- Stale / corrupt cache — Detect: schema/age mismatch. Fix: self-heal (atomic write + invalidation). +- Missing speedtest backend — Detect: speedtest-go absent. Report: "Install speedtest-go to run a speed test." +- Privileged op fails (helper missing / sudo declined) — Detect: helper exits non-zero or absent. Report: "Couldn't get admin rights for this repair — <install/fix the helper>." + +*** 2026-07-01 Wed @ 13:02 -0400 net-priv helper landed (V2.1) +Craig's call: stowed (not root-owned), low security on locked-down single-user machines. +Shipped =net.priv= module + stowed =net-priv= bin (dotfiles =00aac1e=): a fixed 12-verb set +(rfkill/radio/mac-random/conn-up/net-off/net-on/restart-nm/dns-set/dns-revert/restart-resolved/ +dot-disable/dot-enable) with per-arg validation (uuid/iface/ipv4/resolved.conf.d-path, injection +rejected). =repair.py= now routes every privileged op through =priv.run(verb)= in-process instead +of scattered inline sudo — which also fixes the detached DoT-restore watcher (runs privileged ops +with no tty) and closes the gap where rfkill repair ran unprivileged. 244 net + 33 dotfiles suites +green. NO new sudoers needed: archsetup already grants =%<user> ALL=(ALL) NOPASSWD: ALL= +(archsetup:1089), so every build's primary user already runs net-priv's commands passwordless; +"replicate in archsetup" is already satisfied. net-priv rides =make stow hyprland=; hand-linked on +velox. The velox DoT-path reconcile (whether velox should run DoT at all) stays open — folded into +the deeper reconcile, low priority since the guard makes it a no-op. +*** 2026-07-01 Wed @ 14:05:47 -0400 Shipped V2.2 — merged Diagnostics panel + nav restructure, no terminals +Built the V2 panel (dotfiles =75ed825=, pushed): three top tabs Connections | +Diagnostics | Performance; Diagnostics merges the old Diagnose + Repair pages into a +sub-row (Diagnose | Get me online | Advanced) over a shared area that shows diagnose +rows AND streams repair progress in-panel. net-popup deleted entirely; repairs run on +a worker thread through net-priv (no tty). doctor grew an =on_step= callback so Get me +online streams each escalation step live. Connections groups Saved / Available now / +Wired with a golden group header and joins from a row (=join_plan= auth matrix + +=manage.join= one-step connect, secret to NM only); the Add modal became the hidden-network +affordance. Every diagnose/repair/speed run offers a Copy/Open redacted report +(=report.py=, MAC/IP scrubbed). Waybar visual contract applied (dark capsule, golden +border, monospace) via a CssProvider. =net-fix= opens the panel on Diagnostics instead of +a terminal; middle-click runs =net portal= directly. TDD: 34 new GTK-free tests (grouping, +join_plan, join, report, on_step, eventlog.tail); 278 net + 33 dotfiles suites green. +Live-verified: AT-SPI panel_smoke passes end-to-end + screenshots confirm both pages and +the visual contract. DAILY-DRIVER: waybar config + net-fix are stow symlinks (live on +disk); ratio needs =git pull= + waybar restart; velox waybar picks up on next restart. +**** 2026-06-30 Tue @ 17:36 -0400 Dispositioned the 4th-review findings into the spec +Codex's 9 fourth-review findings (8 accept, 1 modify) are folded into the spec's +"V2 panel UX — the target design" section (cookie [40/40]): single nav target, +saved-vs-available groups, join-from-row instead of Add, the auth-class join matrix, +progressive loading, future-tense + verified Forget, a findable redacted diagnostics +report, the Waybar visual contract, and a lightweight inline latency probe (full speed +test stays under Performance per decision 19). The V2 build below implements that +design: [[file:docs/design/2026-06-29-waybar-network-module-spec.org::*V2 panel UX][V2 panel UX]]. +*** 2026-07-01 Wed @ 22:01:38 -0400 Made diagnose IPv6-aware and multi-homing-aware (dotfiles c0d48e2) +IPv6-only networks pass the DHCP step ("IPv6 only: <addr>") with the v6 gateway standing in for the ping; a bare fe80:: doesn't count. A new route step fires only under multi-homing and names the interface that owns the default route (tether/ethernet/VPN). Also landed the adjacent IP-layer detects: APIPA 169.254 fails DHCP with a link-local explanation, address-without-gateway fails the gateway step as a bad DHCP answer, and a weak wifi signal (below fair) warns on the link step with the dBm. fake-nmcli grew IP6.* and a fake ip(8) serves the JSON route reads. TDD, 33 suites green. + +*** TODO Close every detect/correct gap in the catalog, with post-action verification +**** 2026-07-01 Wed @ 22:41:51 -0400 Closed the feasible edge rows (dotfiles d096b30, 241744b, fafefb6) +Three grouped commits, all TDD. Services/radio: dead NetworkManager and dead systemd-resolved get their own diagnose steps and verified restart repairs (resolved only when resolv.conf is resolved-managed; hand-managed DNS gets a heads-up row), airplane mode fails the link by name and classifies needs-user-action ahead of rfkill, and a missing WiFi adapter is named with the dmesg pointer. Association/auth: reset retries once with the permanent MAC when the randomized one is rejected (new mac-permanent net-priv verb), SAE/WPA3 activation failures classify sae-incompat, and stuck-connecting classifies fixable/reset. Egress edges (run only on an existing failure): DNSSEC validation failure named via resolvectl, clock skew off the probe's Date header, MTU/PMTUD blackhole via df-bit pings, and proxy detection (env vars or an advertised WPAD name). Deferred as infeasible without state the engine doesn't keep: AP roaming flap (needs BSSID history), duplicate-IP/ARP conflict (needs the kernel log), and the USB-unplug transition (its end state is the no-adapter row). Still open here: generalized post-action verification for connect/forget/add. + +**** 2026-07-01 Wed @ 22:01:38 -0400 Closed the two named correct gaps (dotfiles 7819f58) +Gateway unreachable now earns one bounce before the upstream verdict (classifier returns fixable/bounce on gateway warn/fail + probe fail; reachable-gateway keeps the honest upstream call, DNS failure still outranks it). Venue-DNS-broken-but-public-works now ends online: the dns-test chain escalates to a persistent dns-override (1.1.1.1 on the link, dies on reconnect, offered dns-revert undo; a useless override reverts itself) instead of auto-reverting into a misreported upstream outage. Override-aware getent/curl fakes model the venue end to end. Remaining: the edge rows (DNSSEC, proxy, MTU blackhole, clock skew, ARP conflict, roaming flap, stuck-connecting budget, USB-adapter unplug, driver/firmware, WPA3/SAE, randomized-MAC retry, NM-not-running, resolved-down, unmanaged resolv.conf) and the generalized post-action verification for connect/forget/add. +*** TODO Automatic diagnostic verbose-capture (failing diagnose + Advanced toggle) +On =overall: fail=, elevate the underlying stack (NM =WIFI,DHCP,DNS,CORE= / systemd-resolved / +wpa_supplicant) to debug at runtime, run the escalation, capture the journal + dmesg window + +=curl -v=, then restore every level. Also a manual "Debug on/off" toggle in Advanced for +reproducing intermittent failures. HARD: restore is guaranteed (try/finally) AND crash-guarded +(next run detects a left-elevated stack and restores it, like the DoT-restore watcher); the +captured journal is REDACTED before the bundle is written/shown (raw wpa_supplicant/NM debug +carries the PSK/EAP secret in cleartext) with a secret-leak test; log-level toggles run via the +V2 sudo-helper. Bonus: wpa_supplicant debug catches wrong-password/EAP failures the current NM +state-120 snapshot misses, so it also closes the auth live-detection gap. Spec: Observability → +"Automatic diagnostic verbose-capture". Origin: Craig 2026-06-30. + +*** VERIFY Dead-GUI console recovery vs "no terminals" — keep =make online= or replace it? :network: +The cj comment (2026-07-01) said scrub every terminal the module uses to report to or get input +from the user, and I folded that into decision 15 (all module UX is in-panel). The one place it +collides: the deliberate console-recovery path — =make online= / =net doctor --fix= run from a +bare TTY when waybar and the GUI are *down* — is the whole point of the CLI being usable with no +GUI. That's a terminal reporting to the user, but only because there's no panel to use. Keep it +as an explicit carve-out (recovery-only, not terminal-as-UI), or replace it with something else +(a TTY text UI still counts as a terminal)? Your call settles whether the Makefile/CLI recovery +targets stay in the spec. |
