diff options
Diffstat (limited to 'docs')
43 files changed, 18322 insertions, 0 deletions
diff --git a/docs/design/2026-06-25-testinfra-validation.org b/docs/design/2026-06-25-testinfra-validation.org new file mode 100644 index 0000000..5c82aa2 --- /dev/null +++ b/docs/design/2026-06-25-testinfra-validation.org @@ -0,0 +1,238 @@ +#+TITLE: Design: Testinfra Post-Install Validation for archsetup +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-25 +#+STATUS: Accepted (2026-06-25) + +* Problem + +The VM integration harness (=scripts/testing/run-test.sh=) runs archsetup in a +QEMU VM, then verifies the result two ways: + +1. Parses archsetup's own install log for its Error Summary and the + =ARCHSETUP_EXECUTION_COMPLETE= marker (did the script finish, did it log + errors). +2. Runs =run_all_validations= from =scripts/testing/lib/validation.sh= — a + hand-rolled, shell-based post-install assertion sweep of ~26 checks over SSH. + +The shell sweep works, but each check is 6-40 lines of =ssh_cmd= + +=validation_pass/fail= + =attribute_issue= boilerplate, the pass/fail counters +are hand-maintained globals, and the reporting is bespoke. Adding or reading a +check is heavier than it should be, and growing the suite (archsetup configures +far more than the 26 checks cover) compounds that weight. + +This doc proposes porting the post-install validation to Testinfra (Python + +pytest) for more expressive checks and better reporting, then growing coverage. + +* Decision + +Port the post-install validation layer to Testinfra + pytest, reaching parity +with the existing =validation.sh= sweep, then expand coverage. Recorded +rationale: the up-front port cost (parity rewrite + a test-only dependency) is +an accepted trade — the priority is a robust, well-reported, growing validation +suite over feature speed. The framework swap alone buys ergonomics and +reporting, not coverage, so it is paired with real new coverage (below). + +This replaces the shell sweep; it does not touch archsetup's own install-log +parsing (that stays as a separate signal). The full coverage expansion (P4) +lands in this task too, sequenced strictly after the parity cutover so the +parity verification stays clean. + +* Current harness (what exists today) + +** Flow (run-test.sh) +1. Revert VM to base snapshot, boot, wait for SSH. +2. =capture_pre_install_state=. +3. Bundle + copy archsetup + dotfiles into the VM, run archsetup in background, + poll to completion. +4. =capture_post_install_state=. +5. =run_all_validations= (the shell sweep). +6. =analyze_log_diff= + =generate_issue_report= (issue attribution). +7. Explicit pass/fail exit code; cleanup. + +** The shell sweep (validation.sh) +~26 checks under =run_all_validations=: user created / shell / groups, dotfiles, +yay, pacman working, window manager, firewall, DNS, avahi, fail2ban, +NetworkManager, emacs, git config, dev tools, zfs, boot config, autologin, +gnome-keyring, terminus font, mkinitcpio hooks, initramfs consolefont, nvme +module, archsetup log, state markers. + +** Issue attribution +=attribute_issue <msg> <bucket>= sorts each failure into one of three arrays — +=ARCHSETUP_ISSUES=, =BASE_INSTALL_ISSUES=, =UNKNOWN_ISSUES= — and +=generate_issue_report= writes them out (base-install issues route to the +archzfs inbox). This is domain logic Testinfra has no equivalent for; the port +must preserve it. + +** Connection +=ssh_cmd= uses =sshpass -p "$ROOT_PASSWORD" ssh ... -p "$SSH_PORT" root@$VM_IP=, +with =VM_IP=localhost=, =SSH_PORT=2222=, =ROOT_PASSWORD=archsetup=. + +* Design + +** Where Testinfra fits +Replace the =run_all_validations= call (step 5) with a pytest invocation against +the running VM. Steps 1-4 and 6-7 are unchanged; =analyze_log_diff= stays. +Testinfra connects over the same SSH the harness already exposes. + +** Connection model +Testinfra's paramiko/ssh backend targets the live VM via its host spec: + +#+begin_src sh +pytest scripts/testing/tests/ \ + --hosts="ssh://root@localhost:2222" \ + --ssh-config=<generated> \ + --json-report --json-report-file="$TEST_RESULTS_DIR/testinfra.json" +#+end_src + +Password auth: generate a throwaway ssh-config (or reuse sshpass via a +=--ssh-identity= once archsetup drops the key, but at validation time we only +have the root password). Simplest: a tiny generated ssh config + sshpass +wrapper, or switch the test VM to a known test key injected pre-run. Open +question below. + +** Test layout +#+begin_example +scripts/testing/tests/ + conftest.py # host fixture, markers, attribution hook, report glue + test_users.py # user created / shell / groups + test_dotfiles.py # stow symlinks, readable by user + test_packages.py # yay, pacman working, dev tools, key packages + test_services.py # firewall, dns, avahi, fail2ban, networkmanager + test_boot.py # zfs, mkinitcpio hooks, nvme, consolefont, terminus + test_desktop.py # window manager, autologin, gnome-keyring + test_archsetup.py # install log, state markers + test_hardening.py # NEW: sshd drop-in, sysctl, /etc fstab perms, backups +#+end_example + +** Example tests (parity) +#+begin_src python +def test_ufw_enabled(host): + assert host.service("ufw").is_enabled + +def test_user_cjennings_exists(host): + u = host.user("cjennings") + assert u.exists + assert u.shell == "/usr/bin/zsh" + +def test_zshrc_stowed_and_readable(host): + f = host.file("/home/cjennings/.zshrc") + assert f.is_symlink + assert ".dotfiles/" in f.linked_to + assert f.exists # not broken + assert host.run("sudo -u cjennings test -r %s" % f.path).rc == 0 + +def test_mkinitcpio_systemd_hook(host): + # non-ZFS systems delegate fsck from udev to systemd + conf = host.file("/etc/mkinitcpio.conf").content_string + assert "systemd" in conf +#+end_src + +Compare =test_ufw_enabled= (1 line) to the current =validate_firewall= (8 lines +of ssh_cmd + branch + counters). + +** Preserving issue attribution +Map the three buckets to pytest markers and collect them in a =conftest.py= +hook: + +#+begin_src python +@pytest.mark.attribution("archsetup") # or "base_install" / "unknown" +def test_ufw_enabled(host): ... +#+end_src + +A =pytest_runtest_makereport= hook records each failure under its marker's +bucket and writes the same three-way report =generate_issue_report= produces +(base-install failures still route to the archzfs inbox). Default bucket = +archsetup when unmarked. + +** Tiered strategy +Markers =@pytest.mark.smoke= (user, key packages, dotfiles present) and +=@pytest.mark.integration= (services, configs, boot). =pytest -m smoke= for a +fast gate, full run otherwise. Drop the task's original X11/startx end-to-end +slice — the fleet is Wayland/Hyprland and headless GUI e2e is flaky and +expensive; a Wayland-session smoke check can be reconsidered later as its own +task. + +** Reporting +=pytest-json-report= (or junit-xml) → =$TEST_RESULTS_DIR/=, surfaced in the +test report alongside the install-log analysis. pytest's own per-test +pass/fail/skip output replaces the hand-maintained counters. + +* Coverage + +** Parity (port all current checks) +All ~26 =validation.sh= checks, grouped per the layout above. + +** Expansion (new — the coverage win) +archsetup configures much that isn't validated today. Candidates: +- sshd hardening drop-in (=/etc/ssh/sshd_config.d/10-hardening.conf=, + PermitRootLogin prohibit-password). +- =backup_system_file= behavior — assert =.archsetup.bak= exists for files + archsetup edited in place (fstab, mkinitcpio.conf, sudoers, …). +- pacman.conf (ParallelDownloads, Color, multilib) and makepkg.conf (MAKEFLAGS, + OPTIONS) settings actually applied. +- systemd-resolved DNS-over-TLS drop-in; NetworkManager wifi-privacy. +- fail2ban jail.local present; reflector config; sysctl printk; /etc/issue + emptied; vconsole font; fstab /efi fmask/dmask perms. +- sanoid / zfs-replicate units (ZFS hosts). + +* Dependencies + +Add =python-pytest=, =python-pytest-testinfra= (pulls paramiko), and a JSON +reporter to =make deps= (test host only — not installed by archsetup itself). +Note: the existing unit suites run under =python3 -m unittest=; the integration +layer runs under pytest. Two runners, both Python; =make test-unit= unchanged, +=make test= gains the pytest step. + +* Goss comparison (the task asked) + +- *Goss* — YAML-declarative health specs, a single Go binary executed *on the + target*. Fast, no Python. But the spec must be pushed into the VM and run + there, the assertions are less programmable, and it adds a Go binary to the + flow. +- *Testinfra* — Python, runs *on the host* over SSH (nothing installed in the + VM), assertions are full Python with rich built-in modules + (File/Package/Service/User/Command), integrates with pytest's tooling. + +Choose Testinfra: it runs from the host (the VM stays clean), it's far more +programmable for the conditional checks archsetup needs (DESKTOP_ENV branches, +ZFS-vs-not), and it aligns with the repo's existing Python test tooling. + +* Migration plan (phased, TDD where the helper logic is ours) + +- *P1 — Scaffold.* conftest.py (host fixture + connection), the attribution + marker + report hook, and 3 parity checks (firewall, user, dotfiles). Wire a + pytest step into run-test.sh behind a flag so the shell sweep still runs. +- *P2 — Full parity.* Port all ~26 checks; diff a real VM run's results against + the shell sweep to confirm no check was lost. +- *P3 — Cut over.* Make pytest the primary sweep in run-test.sh; keep + =analyze_log_diff= and the install-log signal. +- *P4 — Expand.* Add the new coverage (hardening, backups, applied settings). +- *P5 — Retire.* Remove =run_all_validations= from validation.sh (keep the + capture/analyze helpers that pytest doesn't replace). + +* Acceptance criteria + +- =make test= runs archsetup in a VM, then a pytest sweep over SSH, and a real + run reports parity with (or a superset of) the current shell checks. +- Failures still sort into archsetup / base-install / unknown, with base-install + issues routed to the archzfs inbox as today. +- =make deps= installs the test dependencies; the VM has nothing extra installed. +- A documented =pytest -m smoke= fast path exists. + +* Resolved decisions (2026-06-25) + +1. *Auth at validation time — inject a throwaway test key.* Pre-run, generate + an ephemeral keypair, push the pubkey into the VM's + =/root/.ssh/authorized_keys= over the existing sshpass channel, and point + Testinfra at the private key via a generated ssh-config. No password in the + pytest invocation; paramiko key auth just works; the keypair is discarded + after the run. (Chosen over wrapping sshpass around Testinfra, which is + awkward since Testinfra spawns its own ssh connections.) +2. *Cut over — run both through parity, then switch.* Keep the shell sweep + running alongside pytest through P2 so a real VM run can diff pytest's + results against the shell sweep and prove no check was dropped. pytest + becomes primary at P3; =run_all_validations= is deleted at P5 after the + expanded suite proves out. +3. *Expansion scope — full, in this task, after cutover.* All of P4 lands here, + sequenced strictly after the P3 parity cutover so the parity diff is clean + before new checks are added. diff --git a/docs/design/2026-06-25-zfs-vm-test-coverage.org b/docs/design/2026-06-25-zfs-vm-test-coverage.org new file mode 100644 index 0000000..d9625e0 --- /dev/null +++ b/docs/design/2026-06-25-zfs-vm-test-coverage.org @@ -0,0 +1,139 @@ +#+TITLE: Design: ZFS VM Test Coverage + Bare-Metal Runner Migration +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-25 +#+STATUS: Draft — for review + +* Problem + +Two gaps, one root: + +1. *The ZFS install path is untested in automation.* The VM harness + (=make test=) uses a single non-ZFS base image, so every ZFS-conditional + check skips (mkinitcpio udev hook on ZFS, sanoid, zfs-scrub timer, the whole + ZFS branch of archsetup). ZFS is exercised *only* by =run-test-baremetal.sh= + against real hardware. + +2. *=run-test-baremetal.sh= is latently broken by the sshd hardening.* It SSHes + to the target as root *by password* throughout the run, exactly the pattern + archsetup's =PermitRootLogin prohibit-password= (shipped 2026-06-24) kills + mid-install. The VM runner already hit and fixed this (=inject_root_key= + + key auth, commit f50fc1d); the bare-metal runner never got that fix, so it + almost certainly aborts mid-install now, the same way the VM runner did. + +The fix for both is the same shape: a ZFS base VM gives a safe, repeatable, +snapshot-rollback ZFS target (no sacrificial hardware), which both fills the +coverage gap *and* provides a target to migrate + validate the bare-metal +runner against. This also unblocks P5 (deleting the dead shell-sweep functions +from validation.sh), which is gated on the bare-metal runner leaving the shell +sweep. + +* Decision + +Build a ZFS base VM via archangel, add a filesystem-profile selector to the VM +harness so =make test= can target zfs or non-zfs, then migrate +=run-test-baremetal.sh= to key auth + the Testinfra sweep and validate it +against the ZFS VM. Finish by deleting the now-dead shell-sweep functions (P5). + +Explicitly rejected: loosening =PermitRootLogin= (or adding a skip-hardening +test flag). That trades a real security feature for harness convenience and +would mean never validating the hardened config. Key auth is the correct fix, +already proven in the VM runner. + +* Current state (grounded) + +- =create-base-vm.sh= boots an =archangel-*.iso=, copies =archsetup-test.conf= + into the live env, runs =archangel --config-file /root/archsetup-test.conf= + (the base-OS install — partitioning/filesystem live here), powers off, and + snapshots =clean-install= onto =vm-images/archsetup-base.qcow2=. +- =run-test.sh= hardcodes that one image + snapshot, and copies + =scripts/testing/archsetup-vm.conf= (DESKTOP_ENV=hyprland, non-ZFS) into the + VM as the archsetup config. +- =run-test-baremetal.sh= takes =--host= / =--password=, SSHes as root by + password, rolls back ZFS =@genesis= snapshots, transfers + runs archsetup, + then calls =run_all_validations= / =validate_all_services= (overriding + =VM_IP= to the target). It is the only remaining caller of the shell sweep. +- Key auth machinery already exists and is reusable: =inject_root_key= and + =SSH_KEY_OPT= in =vm-utils.sh=, and =run_testinfra_validation= in + =testinfra.sh= (drives connection from a generated ssh-config keyed on + =VM_IP= / =SSH_PORT=). + +* Design + +** A. ZFS archangel base +Add a ZFS archangel config (a =archsetup-test-zfs.conf= or equivalent) that +installs a ZFS root. Confirm archangel supports a ZFS-root config (it's a +separate project — verify its config options first). Unencrypted ZFS for the +test VM (skip the passphrase prompt; encryption isn't what we're validating). + +** B. Per-profile base images + selector +- =create-base-vm.sh= takes a profile (e.g. =FS_PROFILE=zfs|ext4=, default + current/non-ZFS), picks the matching archangel config, and writes a + profile-named image: =vm-images/archsetup-base.qcow2= (default) vs + =vm-images/archsetup-base-zfs.qcow2=. Same =clean-install= snapshot name. +- =run-test.sh= + Makefile take the same =FS_PROFILE= and select the image (via + =init_vm_paths=). The archsetup run config (=archsetup-vm.conf=) is *shared* — + archsetup auto-detects ZFS from the live root, so no per-profile run config is + needed. =make test FS_PROFILE=zfs=. + +** C. Bare-metal runner migration +Mirror the VM runner's fix in =run-test-baremetal.sh=: +- After the first successful SSH to =TARGET_HOST=, call =inject_root_key= (it + authorizes a key over the password session; set =VM_IP=TARGET_HOST=, + =SSH_PORT=22= so the helpers + ssh-config target the real host). +- Replace =run_all_validations= / =validate_all_services= with + =run_testinfra_validation= (now authoritative). +- Everything downstream already routes through =$SSH_KEY_OPT= (the vm-utils + helpers) and the ssh-config, so it survives the hardening. + +** D. Validate +- =make test FS_PROFILE=zfs= → the ZFS-conditional pytest checks now *run* + (not skip): mkinitcpio uses the udev hook, sanoid installed, zfs-scrub timer, + zfs root. Fix any real ZFS-path findings archsetup has. +- Point =run-test-baremetal.sh= at the ZFS VM (or real hardware) → confirm the + key-auth migration carries it through the hardening to a green pytest sweep. + +** E. Delete the shell sweep (P5) +Once both runners use =run_testinfra_validation=, delete the dead functions from +=validation.sh= (run_all_validations, validate_all_services, the ~26 validate_* +checks, validate_service*, run_full_validation, validation_pass/fail/warn/skip). +Keep the live helpers: ssh_cmd, attribute_issue, capture_pre/post_install_state, +analyze_log_diff, categorize_errors, generate_issue_report, VALIDATION_*. + +* Phases +- *P-A* archangel ZFS config (verify archangel ZFS support first). +- *P-B* create-base-vm.sh + run-test.sh + Makefile profile selector; build the + ZFS base image + snapshot. +- *P-C* =make test FS_PROFILE=zfs= green (ZFS-conditional tests run; fix + findings). VM-validatable here. +- *P-D* migrate run-test-baremetal.sh to key auth + Testinfra; validate against + the ZFS VM. +- *P-E* delete the dead shell-sweep functions (the standing P5 follow-up). + +* Open questions +1. *Does archangel support a ZFS-root config out of the box?* RESOLVED (yes). + ZFS is archangel's *default* filesystem (=FILESYSTEM=zfs=, validated by + =installer/lib/config.sh:validate_filesystem=), with =NO_ENCRYPT=yes= for an + unattended unencrypted install and a ready =installer/velox-zfs.conf.example= + to model. No archangel work needed. +2. *Two images vs one image + two snapshots?* RESOLVED — two images. ZFS vs + btrfs are different on-disk layouts; cleaner than juggling snapshots on one + disk. =btrfs= keeps the legacy unsuffixed =archsetup-base.qcow2=; =zfs= gets + =archsetup-base-zfs.qcow2=. +3. *Profile on run-test.sh vs a separate run-test-zfs.sh?* RESOLVED — + =FS_PROFILE= env param on the existing runner + Makefile, no duplicate + harness. +4. *Disk size / RAM for the ZFS VM* — start at the 4G RAM / 50G disk defaults; + bump =VM_RAM= only if the ZFS install OOMs (decide at P-C build time). +5. *Should the bare-metal runner stay at all once a ZFS VM exists*, or does the + ZFS VM profile make it redundant for everything except real-hardware smoke? + Defer until after P-D. + +* Design corrections (found during P-A/P-B grounding) +- The "non-ZFS" base is *btrfs*, not ext4 — =archsetup-test.conf= sets + =FILESYSTEM=btrfs=. The profile axis is zfs vs btrfs throughout. +- *No =archsetup-vm-zfs.conf= is needed.* archsetup reads no filesystem key; it + auto-detects ZFS from the live root via =is_zfs_root()= (=findmnt -n -o FSTYPE + /=, archsetup:688). The ZFS branch (sanoid, zfs-scrub timer, mkinitcpio udev + hook, docker zfs storage driver) fires whenever the running root is ZFS. So + only the *archangel* base config and the base *image* differ per profile; the + archsetup run config (=archsetup-vm.conf=) is shared. diff --git a/docs/design/2026-06-29-waybar-network-module-spec.org b/docs/design/2026-06-29-waybar-network-module-spec.org new file mode 100644 index 0000000..3a1260c --- /dev/null +++ b/docs/design/2026-06-29-waybar-network-module-spec.org @@ -0,0 +1,2094 @@ +#+TITLE: Waybar Network Module — Design Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-29 + +* Status + +*Phases 1-3 SHIPPED* (2026-06-29 → 2026-06-30, dotfiles). The core module is live: +the =net= engine (=status/probe/list/up/down/add/edit/remove/rescan/diagnose/repair/ +doctor/portal/speedtest=), the =waybar-net= indicator (split-cadence cache, redacted +event log, display-only airplane absorption per decision 12), and the GTK4 +layer-shell panel (Connections / Diagnose / Repair / Speed test) with the settled bar +clicks (left = panel, middle = =net portal=, right = =net-fix=; airplane on +Super+Shift+A). 230+ net tests; full dotfiles suite green. Live-verified on velox. + +Built on top since the original spec: +- *Captive-portal login engine* (2026-06-30, dotfiles =a7d7559=) — =net portal= now + runs a native =portal-login= repair tier (drop DoT → recover the portal URL from + the redirect → open a throwaway browser profile → auto-restore DoT once online), + replacing the old shell-out to =captive= for the force-portal flow. =net portal + --restore= is the manual fallback. +- *Portal UX fixes from live testing* (2026-06-30, dotfiles =eef6b0b=) — removed a + polkit-gated =resolvectl flush-caches= that popped an auth dialog (the DoT-drop + restart already clears the cache); added an already-online short-circuit so a + forced run on a working connection opens nothing; suppressed Chrome's first-run + wizard; moved =net portal= off the terminal into the panel status line; hardened + the portal-URL extractor against Firefox's detection page. +- *Panel auto-hide + Close button* (2026-06-30, dotfiles =450b7f0=) — the panel + closes on focus-out (popup behavior, suppressed while a child dialog holds focus) + and carries a Close button bottom-right. + +*V2 redesign in flight* (designed 2026-06-30, not yet built — see todo.org "Network +panel redesign — no terminals, verify-everything, full failure coverage"). It +reverses two earlier choices and widens coverage: +- *No terminals anywhere.* =net-popup= is removed; every action and result renders + in the panel. This depends on a passwordless privileged path — a root-owned helper + plus a narrow NOPASSWD sudoers rule, archsetup-installed — because an in-panel + worker thread has no tty to prompt for a password. Reverses decision 11's + "privileged tiers run in a terminal". +- *New navigation* — top tabs Connections | Diagnostics | Performance. Diagnostics + merges Diagnose + Repair (sub-row Diagnose | Get Me Online | Advanced; a shared + area below shows diagnose items and streams repair progress; Advanced reveals the + individual repair buttons, renamed with tooltips). Speed test lives under + Performance. +- *Verify every action* (each mutating op confirms its effect before reporting + success) and *detect + respond to every failure mode* — the full ~44-mode catalog, + edge cases included, lives in the redesign task and supersedes the table below. + +Phase 4 (docs / rollout) and Phase 5 (VPN) remain. Review incorporated (Codex, +2026-06-30): four review rounds + Craig's cj comments are all dispositioned +([40/40], no open findings) — the fourth round reshaped the V2 panel UX (single nav +target, saved-vs-available groups, join-from-row, the auth matrix, progressive +loading, a findable diagnostics report, and the Waybar visual contract; see "V2 panel +UX"). Phases 1-3's manual live checks are under todo.org "Manual testing and +validation". + +* Goal + +One waybar network component that does the whole job: shows connection state +(including the missing "associated but no internet / captive portal" state), +manages connections from a dropdown (nmcli-backed; secrets stay in +NetworkManager's own store, no separate credential file), and runs the network +diagnostics and remediation off the same place +(captive-portal detection + forcing, bounce/reset, gateway/DNS checks, speed +test). + +It unifies three todo tasks that are really one feature: +- =[#C]= "archsetup Waybar Wi-Fi module should show no-internet state" — the + indicator state plus the 2026-06-22 roam expansion (bounce, diagnostics, speed + test off the component). +- =[#B]= "Network-manager dropdown, nmcli-backed" — the management dropdown. (The + todo task's original "GPG-stored secrets" framing is superseded: secrets stay in + NM's own store, decision 5.) +- The network diagnostics already shipped in =captive= (the hotel/captive-portal + tool, formerly =login-page=) become this module's diagnostics engine rather + than a standalone CLI. + +* Scope + +** In +- *Indicator* — wifi/ethernet icon + signal + SSID, plus an internet sub-state: + online / captive / no-internet / connecting / disconnected / airplane. +- *Absorbs the airplane module* — the airplane state + toggle move into + =custom/net= (airplane is a network concern). Once this ships, the standalone + =custom/airplane= module, the =waybar-airplane= + =airplane-mode= scripts, their + =tests/=, and the css are deleted (listed under Files touched). The + desktop-settings panel (sibling =[#B]=) no longer needs an airplane row. +- *Interface-correct* — targets the wifi (or chosen) device, not the + default-route interface, so an active USB tether or wired link can't mask + wifi state. (Same lesson =captive= fixed; the current =custom/netspeed= keys + off the default route and has the bug.) +- *Connection management (panel)* — list saved connections most-recently-used + first, live signal for in-range wifi, click to switch; add / edit / remove for + open + WPA-PSK; activate any existing saved profile (including enterprise ones + NM already stores); ethernet↔wifi and wifi↔wifi switching even when a link + appears mid-session. +- *Diagnostics (panel)* — read-only Diagnose (captive probe 204-vs-portal with + the extracted portal URL, gateway ping, DNS config) separated from mutating + Repair. Repair has tiers, lightest first: rfkill-unblock, per-connection reset + (fresh MAC), full-stack bounce (=nmcli networking off/on=, then restart + NetworkManager if that fails), and the temporary 1.1.1.1 override test. Each + Repair action confirms and verifies cleanup. +- *Speed test (panel)* — down/up/ping with a progress indicator and last-result + shown, via the already-installed =speedtest-go --json=. +- *Connection secrets* — none of our own. Settings and passwords live where NM + already keeps them: =/etc/NetworkManager/system-connections/*.nmconnection= + (root-only =0600=, the PSK/EAP secret stored inline). We read/write them through + nmcli, which handles the privilege. No separate file, no GPG, no gpg-agent — one + fewer dependency, and NM's store is already the secure-at-rest source of truth. +- *Persistence* — connectivity probe result cached in the runtime dir so the + bar reads it cheaply between probes. +- *Observability* — a redacted JSONL event log so a post-failure session can + diagnose without re-running destructive actions. + +** Out (v1, note for later) +- No replacement of NetworkManager's connection engine. NM stays the thing that + connects; we drive it via nmcli. +- No add/edit *form* for WPA-Enterprise / 802.1X in v1. The reason is effort vs + payoff: 802.1X has many interdependent fields (CA cert, client cert, identity, + anonymous identity, phase-2 auth) where a wrong entry silently fails auth, so a + trustworthy form is a lot of UI for connections Craig rarely adds (open + + WPA-PSK covers home, hotels, and phone hotspots). v1 still *activates* existing + saved enterprise profiles and points editing at =nmtui=/=nmcli=. Settled + (Craig, 2026-06-29): enterprise add/edit is vNext — 24 saved profiles on velox, + 0 enterprise, so the form would be unused UI; if one ever appears nmtui adds it + once and the module activates it thereafter. +- No per-connection captive-portal *auto-login* in v1. (That would mean storing a + portal's login form answers — room number, surname, a checkbox — and replaying + them automatically when a known portal is detected, so the page never appears. + Out for v1 because every portal's form differs and it means storing per-venue + answers; v1 just opens the portal for you.) +- No graphing/history of speed-test results beyond the last run. +- No static-IP / proxy / metered / MAC-randomization editing in v1 (activate + existing, edit elsewhere). +- No VPN / WireGuard management in v1, but it's a planned later phase (Phase 5), + not a permanent exclusion — it folds the existing archsetup wireguard tooling + into the same panel/CLI. +- The desktop-settings dropdown (sibling =[#B]=) is a separate module, but it + shares the GTK4 layer-shell panel shell built here. + +* Architecture + +Three layers. Keep the bar cheap, the panel rich, the logic in one tested place. + +1. *Engine* — a =net= Python package (src-layout, unittest), exposing a CLI. Wraps + every nmcli op and owns the diagnostics. Emits JSON. This is the testable + core (fake =nmcli= / =curl= / =speedtest-go= on PATH, like the existing + =waybar-netspeed= and =waybar-sysmon= test harnesses). Precedent: pocketbook is + Python in the dotfiles repo; =wtimer= is Python for the same testability + reason. +2. *Indicator* — a thin =waybar-net= script that calls =net status --json= and + renders icon + signal + state + tooltip. Replaces =custom/netspeed= + (throughput folds into the tooltip). +3. *Panel* — a GTK4 + gtk4-layer-shell app (mirrors pocketbook's structure) + that imports the engine. Hosts connection management, diagnostics, and the + speed test. + +How the existing pieces map in: +- =captive= (bash, shipped) — its cheap portal-detection logic is mirrored natively + in the engine for the fast status path so the bar never blocks on a subprocess, + and it still exposes a =--probe-json= mode the engine reuses. *As built (2026-06-30): + the force-portal flow is now native too* — =repair.py='s =portal-login= tier does + the DoT drop, portal-URL recovery, clean-browser launch, and auto-restore in + Python, so =net portal= no longer shells out to =captive= for it. =captive= stays a + usable standalone CLI. +- =waybar-netspeed= (sh, shipped) — retired; its throughput sampling moves into + the engine's status output and renders in the indicator tooltip only. +- =nmcli= — the connection backend for every op. + +Language note: the engine is Python; the indicator is a thin Python or sh +wrapper over =net status --json=. The bar path must stay fast (see Performance +budgets), so the indicator does no network I/O itself — it reads link state and +the cached connectivity result. + +Privileged-path model (v2, planned): repairs that need root (rfkill unblock, nmcli +modify/up, networking off/on, =systemctl restart NetworkManager/systemd-resolved=, +resolvectl dns/revert, the DoT toggle) go through a single root-owned helper +installed by archsetup, with a narrow NOPASSWD sudoers rule scoped to that helper +only (never a blanket =mv=/=systemctl= rule). =repair.py= calls =sudo <helper> +<verb>=. This is what lets every action run in-panel with no terminal: a GTK worker +thread has no tty, so without a passwordless path it can't prompt. It also fixes a +latent bug in the shipped portal flow — the detached DoT-restore watcher runs with +no tty and silently fails to restore encrypted DNS when sudo creds aren't cached. + +* Repository + dependencies + +- *Code lives in the dotfiles repo* (=~/.dotfiles=), not archsetup. The =net= + package sits in-tree like pocketbook (src-layout, unittest, Makefile target); + =waybar-net= and the =net= CLI entry live in the hyprland tier + (=hyprland/.local/bin/=). Tests under =tests/net/= and =tests/waybar-net/=. + archsetup owns only the *dependency install*, not the code. +- *archsetup installs the deps* in its Hyprland step: =gtk4-layer-shell=, + =python-gobject=, plus =nmcli=/=curl=/=resolvectl=/=rfkill= (already present via + NetworkManager/curl/systemd/util-linux). Speed test uses =speedtest-go= (AUR + =speedtest-go-bin=, already installed on velox); archsetup adds it to the AUR + list. librespeed-cli is the documented fallback if a self-hosted LibreSpeed + server is ever wanted. No =gpg= dependency (secrets live in NM's own store). +- *Daily-drivers*: a stowed-script + AUR-dep feature, so ratio needs the same + =git pull= + stow + the archsetup-added deps. Note the manual dep step in the + rollout. + +** Makefile targets (console recovery is a first-class path) +=net doctor= and the diagnostics are reachable from a bare TTY when waybar and +the GUI are down — that's the case where you most need them. The dotfiles +Makefile carries targets that wrap the =net= CLI so "get back online" is one make +command from the console: +- =make online= — =net doctor --fix= (diagnose, then apply the lightest repair: + rfkill-unblock → reset → bounce → open portal). The headline recovery target. +- =make net-doctor= — =net doctor= (read-only diagnose + recommendation). +- =make net-status= / =make net-diagnose= / =make net-portal= / =make net-reset= + / =make net-bounce= — the individual ops. +- =make test= — already runs =tests/*=; the =net= package's unittest suites are + collected the same way. +These intentionally need only nmcli/curl/rfkill (no GUI, no waybar, no Python +GTK), so they work from a TTY on a broken graphical session. + +* Connectivity model — split cadence + +The indicator polls every ~2s, but a real internet/captive probe every 2s wastes +battery and can re-trigger a captive portal. So split it: + +- *Fast path (every poll, cheap, no network)* — interface, type, SSID, signal, + IPv4 presence, throughput sample. From nmcli / sysfs only. No network I/O. +- *Slow path (cached, TTL ~45s)* — the actual internet/captive probe (the 204 + check + meta-refresh portal extraction). Result cached at + =$XDG_RUNTIME_DIR/waybar/net-connectivity.json= with a timestamp. + +The indicator reads the cache each poll. When the cache is older than the TTL, +=net status= kicks =net probe= in the background (spawn + detach, never awaited) +and renders the last cached sub-state meanwhile. A user-triggered +diagnose/reconnect refreshes the cache immediately. This keeps the bar +responsive and the portal un-poked. + +** Concurrency, atomicity, staleness +- *Single-flight* — =net probe= takes a lock file at + =$XDG_RUNTIME_DIR/waybar/net-probe.lock= (flock, non-blocking). A second probe + while one runs is a no-op, so a flapping 2s poll can't pile up overlapping + probes. +- *Atomic writes* — the cache is written to a temp file + =os.replace= (atomic + rename), so a reader never sees a half-written cache. Same pattern as =wtimer=. +- *Max probe runtime* — the probe has a hard timeout (≤ 6s total: curl + =--max-time 5= + slack). On timeout it writes an =unknown= result, never hangs. +- *Stale classes* the indicator distinguishes: fresh (< TTL), stale (TTL..3×TTL, + shown with a subdued/aging hint), expired (> 3×TTL → treat as unknown), + unknown (no cache / probe failed). The bar never shows a confident "online" + past the expired threshold. +- *Invalidation* — the cache records the iface + SSID + active-connection UUID it + was taken under; a change in any of them invalidates it immediately (a + reconnect must not show the old network's verdict). +- *Crash cleanup* — a stale lock older than the max runtime is ignored/reclaimed. + +* Performance budgets (hot path) + +The bar exec path (=waybar-net= → =net status=) must stay responsive: +- *Budget*: =net status= returns in < 100ms typical, < 250ms worst case. +- *No sleeping in the bar path.* Throughput is sampled from two reads of + =/sys/class/net/<iface>/statistics/{rx,tx}_bytes= across the *waybar poll + interval itself* (delta since the last cached sample + timestamp), not via an + in-process =sleep= like the old =waybar-netspeed=. The cache holds the prior + counters. +- *Subprocess cap*: at most one =nmcli= invocation on the hot path (a single + =nmcli -t -f ...= multi-field query), plus sysfs reads. Never a per-field + nmcli call. +- *Every subprocess has a timeout* (=nmcli --wait 2=, =subprocess timeout=). On + timeout or error the indicator emits a degraded JSON state (class + =net-degraded=, a neutral glyph) rather than blocking or crashing waybar. +- *Benchmark test*: a fake slow =nmcli= asserts =net status= still returns within + budget by falling back to the degraded state. + +* Engine — =net= CLI surface + +All subcommands take =--json= where a machine reads them. Pure formatting/state +functions under the CLI; IO (nmcli, curl, file) at the edges. Every subcommand +exits non-zero with a JSON error envelope (see JSON schemas) on failure. + +- =net status [--json] [--iface IF]= — fast link state + cached connectivity + sub-state + throughput. The indicator's source. Never does network I/O. +- =net probe [--iface IF]= — run the connectivity/captive probe now, update the + cache (single-flight, atomic), print online | captive (+ portal URL) | + no-internet | unknown. Mirrors =captive='s cheap detection natively. +- =net list [--json]= — saved connections, MRU order, active flag, plus in-range + wifi with signal. +- =net up <uuid>= / =net down [--iface IF]= — switch / disconnect. Operates on + UUID, not name (see nmcli contract). +- =net add= / =net edit <uuid>= / =net remove <uuid>= — manage connections + (open + WPA-PSK) through nmcli; the secret lands in NM's own + =.nmconnection=. Enterprise profiles are activate-only. +- =net rescan [--iface IF]= — wifi rescan. +- =net diagnose [--json]= — read-only report: gateway ping, DNS config, captive + probe. The structured contract below. Doubles as the post-failure snapshot. +- =net repair <action> [--json]= — mutating remediation, lightest first: + =rfkill= (unblock + radio on), =reset= (fresh MAC), =bounce= (full-stack: + =nmcli networking off/on=, escalating to =systemctl restart NetworkManager=), + =dns-test= (temporary 1.1.1.1 override, auto-reverted). Each confirms via the + caller and verifies cleanup. +- =net doctor [--json] [--fix]= — one-shot "get me online" mode for the console: + runs the full diagnose, then applies the lightest repair that fits (unblock + rfkill, reset, bounce, open portal) — read-only without =--fix=, acting with + it. The TTY recovery path when waybar/the GUI is down (see the Makefile + targets). +- =net portal [--restore]= — the native captive-login flow (=repair.py= =portal-login= + tier): short-circuits if already online, else drops DoT to plain DNS, recovers the + portal URL from the redirect, opens it in a throwaway browser profile, and spawns a + detached watcher that restores DoT once online. =--restore= forces the restore now. +- =net speedtest [--json]= — =speedtest-go --json= run; down/up/ping. + +* nmcli contract + +The command wrapper is the reliability boundary; SSIDs and connection names +contain spaces, colons, duplicates, hidden names, and non-ASCII. Rules: + +- *Terse, field-selected output*: =nmcli -t -f <fields> --escape yes ...= and + =nmcli -g <fields> ...= (get-values) for single-value reads. Parse with the + documented escaping (=\:= and =\\=); never naive =cut -d:=. +- *UUID is the handle.* Every saved-profile op (=up=, =down=, =modify=, =delete=) + uses the connection UUID, never the display name — names duplicate and contain + separators. =net list= surfaces UUIDs; the panel maps row → UUID. +- *Wait budgets*: activation/deactivation use =nmcli --wait <n>= with an explicit + budget (hot-path reads =--wait 2=; activation =--wait 30=). No unbounded waits. +- *Connectivity*: NM's own =nmcli networking connectivity= can return + =none/portal/limited/full/unknown=. Use it as a *cheap hint* on the fast path + when present, but the authoritative captive verdict is still our own probe + (NM's portal detection is coarser and config-dependent). +- *Parser tests* (fake nmcli fixtures): escaped colons and backslashes in SSIDs, + embedded newlines, duplicate connection names, hidden SSID (empty name), + non-ASCII SSID, the wired-appears-mid-session case, and the multi-active case + (wifi + tether both up). + +* JSON schemas + +Versioned (="v": 1=) envelopes so tests lock the contract. Sketches (fields +nullable unless noted): + +- =status=: ={v, iface, type: wifi|ethernet|none, ssid, signal, ipv4, + gateway, throughput: {rx_bps, tx_bps}, connectivity: online|captive|no-internet|unknown, + connectivity_age_s, connectivity_class: fresh|stale|expired|unknown, state: + online|captive|no-internet|connecting|disconnected|airplane|wired|degraded}=. +- =probe=: ={v, result: online|captive|no-internet|unknown, portal_url, http_code, + redirect_host, elapsed_ms, ts}=. +- =list=: ={v, connections: [{uuid, name, type, active, last_used, signal, + in_range, security}]}=. +- =diagnose=: ={v, steps: [<diagnostic step, see contract>], overall: + ok|warn|fail}=. +- =speedtest=: ={v, down_mbps, up_mbps, ping_ms, server, elapsed_ms, ts}=. +- error envelope (any command): ={v, error: {code, message, detail, partial: + bool}}= with a non-zero exit. + +* Diagnostics contract + +=net diagnose --json= returns an ordered list of steps. Each step is the unit the +panel renders and the log records: + +- =id= — stable identifier (e.g. =link=, =dhcp=, =gateway-ping=, =dns-config=, + =dns-resolve=, =http-probe=, =portal=). +- =status= — =pending | running | pass | warn | fail | skipped=. +- =title= — short human label. +- =evidence= — redacted detail (the value seen), per the redaction rules. +- =elapsed_ms=. +- =safety= — =read-only= or =mutating= (diagnose steps are all read-only). +- =next_action= — what the user/agent should do on warn/fail (e.g. "open portal", + "reset connection", "switch network"). + +Repair actions (=net repair=) carry the same shape but =safety: mutating=, plus a +=cleanup_verified: bool= field (e.g. the DNS override was reverted) and a +terminal =cleanup-unverified= status when revert can't be confirmed. + +** Diagnose vs Repair (read-only vs mutating) +The panel separates them visually and behaviorally: +- *Diagnose* — probe, gateway ping, DNS config read, captive check. No state + change, no sudo, runnable freely. +- *Repair* — reset (fresh MAC, deletes+recreates the NM profile), DNS override + test (mutates resolver, auto-reverts), portal force. Each needs an explicit + confirm, shows that it's privacy/state-changing, and verifies cleanup. A + Repair whose cleanup can't be verified ends in a visible =cleanup-unverified= + state, never a silent success. + +* Failure states, messages, recovery + +Each row below gives the *exact, final* user-facing string (not a template) with +=<placeholders>= for redacted evidence, plus the evidence field included and the +next action. The string is canonical: every surface renders the same text, so +there's one source of truth. + +Per-surface rendering of the canonical string: +- *Indicator* — the matching glyph + CSS class; the string is the tooltip + (untruncated). +- *Notification* (=notify=) — title = "Networking"; body = the failure label on + its own line, then the canonical string. +- *CLI* — the string on stderr; =--json= puts it in =error.message= with the + evidence in =error.detail= and a stable =error.code=. +- *Panel* — the string as the section banner, with the diagnostic step's evidence + shown beneath. +Evidence is always redacted per the redaction rules (SSID/host shown; PSK/EAP/ +portal tokens never). + +- *associated, no DHCP* — "Connected to <SSID>, no IP (DHCP failed)" → + evidence: SSID, iface → reset / reconnect. +- *no-internet* — "On <SSID>, no internet (gateway reachable, no route out)" → + diagnose / switch network. +- *captive* — "Captive portal at <host> — login required" → Open portal. +- *DNS hijack* — "DNS is being redirected (portal)" → Open portal. +- *DNS broken* — "DNS not resolving (hotel DNS down); 1.1.1.1 works" → use + override / report. +- *HTTP intercepted* — "Traffic is being intercepted before it leaves" → Open + portal. +- *sudo declined* — "Reset needs admin; it was declined — nothing changed" → + retry with auth. +- *command timed out* — "<op> timed out; the system was left unchanged" → retry. +- *partial mutation* — "<op> partially applied: <what>; rolled back to <state>" + → review. +- *missing speedtest-go* — "speedtest-go not installed" → install hint. +- *no wifi hardware* (desktop) — wifi rows hidden; ethernet-only view. +- *wifi rfkill-blocked* — "WiFi is blocked (rfkill)" → unblock. The indicator + detects a soft-blocked radio (=rfkill list= shows the radio off though hardware + is present) and shows this distinct from disconnected. =net repair rfkill= (and + =net doctor --fix= as its first step) runs =rfkill unblock wifi= + =nmcli radio + wifi on= and reconnects. This is the framework-laptop case: an out-of-power + shutdown sometimes leaves wifi soft-blocked at next boot, and yes — the module + recovers it (the rfkill state is the indicator; the rfkill repair / doctor is + the one-step fix). A *hard* block (physical switch) is reported as + not-recoverable-in-software with that message. +- *wifi rfkill hard-blocked* — "WiFi is blocked by the hardware switch" → + evidence: rfkill hard state → flip the physical switch. +- *wrong password / missing secret* — "Saved password for <SSID> was rejected" → + evidence: SSID, NM auth-failure reason → re-enter the password. +- *enterprise auth/cert failure* — "Enterprise login failed for <SSID> (802.1X)" + → evidence: SSID, EAP failure reason → edit the profile in nmtui/nmcli. +- *upstream / AP / provider* — "On <SSID>, link is fine but the network has no + uplink" → evidence: gateway reachable, no route out, not a portal → switch + network or contact the venue. +- *VPN-routed* — "Connected; internet is routed through a VPN (<dev>)" → + evidence: default route on a tun/wg device or non-NM DNS owner → check the VPN, + not WiFi. +- *HTTP interception, no parseable portal URL* — "A portal is intercepting + traffic but didn't give a login link" → evidence: HTTP code, redirect host → + opens neverssl + the gateway page to log in manually. +- *DNS override cleanup unverified* — "Couldn't confirm DNS was restored after the + test" → evidence: iface, attempted revert → revert DNS manually + (=resolvectl revert <iface>=). +- *VPN kill-switch blocking* — "A VPN kill-switch is blocking all traffic, and the + VPN itself is down" → evidence: a block artifact present with no tunnel up → bring + the VPN back, or clear the kill-switch (the exact root command surfaced, not + auto-run). + +*VPN kill-switch detection + correction.* A kill-switch blocks all non-VPN egress when +the tunnel drops, so the link looks up (wifi, IP, gateway) but nothing reaches the +internet. This extends the =deferred-vpn= branch: when a VPN is active and the probe +fails, run a rootless cascade to tell a working tunnel from a kill-switch that's +blocking because the tunnel is down — +- =ip rule= for wg-quick's =not fwmark 0xca6c= + =suppress_prefixlength 0= (and the + PostUp =REJECT ! -o %i= rule that makes it leak-proof); +- =wg show= for an up tunnel interface; +- =nmcli connection show= for Proton's =pvpn-killswitch= / =pvpn-ipv6leak-protection= + (device =pvpnksintrf0=); +- =nft list ruleset= / =iptables -S OUTPUT= for a drop/reject table (=killswitch=, + =protonvpn=, =oifname != "wg0" ... drop=); +- =nmcli -f connection.zone= for a firewalld =drop= zone. +Classify *kill-switch-blocking* only when a block artifact exists AND no tunnel +interface is up — that's what distinguishes it from a healthy VPN. Correction is tiered +by artifact and every option needs root, so surface the exact command rather than +auto-running it: =wg-quick down <iface>=, =nmcli connection delete pvpn-killswitch +pvpn-ipv6leak-protection=, =nft delete table inet killswitch=, or =nmcli connection +modify <con> connection.zone ''=. (Sits alongside the Phase 5 VPN work; detection can +land earlier since =deferred-vpn= already exists.) + +Each message names whether the system was left unchanged, partially changed (with +what), or fully changed, so the user knows the residue. + +* Doctor: escalation, classification, terminal states + +=net doctor= diagnoses, classifies the failure, then (with =--fix=) applies the +*lightest* repair that fits and re-checks — it never loops destructive repairs +against a failure they can't fix. Each failure resolves to one of four outcomes, +and the doctor stops at any terminal one: + +- =fixable= — a local repair should help. Escalate lightest-first: rfkill-unblock + → reset (fresh MAC) → bounce (full stack) → portal, re-probing after each, and + stop as soon as the probe returns online. +- =needs-user-action= (terminal) — no reset/bounce will help; doctor stops and + names the exact next step. Covers: wrong WPA password / missing NM secret + (enter the password), locked keyring or polkit denial (retry with auth), + enterprise 802.1X cert/identity failure (edit the profile in =nmtui=/=nmcli=), + captive portal login-required (open the portal + accept terms). Doctor must not + delete/recreate the profile against these — that loses the saved password and + makes things worse. +- =upstream-not-local= (terminal) — the local link is up but the problem is past + it: AP has no uplink, gateway down/dropping traffic, DHCP server broken, ISP + outage, portal backend failing. =diagnose= proves it (link up + IP + gateway + reachable, but no route out and not a captive redirect), and =doctor --fix= + stops after local repairs are exhausted with "local repairs tried; likely + upstream/AP/provider" + the evidence. Next action: switch networks or contact + the venue. +- =deferred/vpn= (terminal for v1) — an active VPN / policy route / non-NM + resolver owns the default route or DNS, so "no internet" may be the VPN's fault, + not WiFi's. v1 *detects* this (default route on a =tun/wg= device, or DNS owned + by something other than the NM link) and classifies it separately — "link is + fine; internet is VPN-routed" — rather than misclassifying it as a WiFi failure. + v1 does not repair it (VPN management is Phase 5); it names the VPN as the likely + owner and stops. + +** DNS handling in doctor (explicit per class) +- *Captive DNS hijack* — open the portal (the hijack clears on login). No DNS + mutation. +- *Broken resolver, 1.1.1.1 works* — the shipped =dns-test= repair is *diagnostic*: + it sets 1.1.1.1, confirms the venue resolver is the culprit, then auto-reverts + (=cleanup_verified=). Because it reverts, =doctor --fix= does not currently leave + you online in this case — it falls through to =upstream-not-local=, which + misreports a locally-fixable problem. *V2 fix (planned):* on a dns-test *pass* + (public DNS works), set a PERSISTENT resolver override and verify online, with an + offered revert — and classify it as its own outcome rather than upstream. +- *Port-53 / egress blocked* (even 1.1.1.1 fails) — terminal =upstream-not-local=; + doctor stops, since it's not locally fixable. + +* Failure-mode coverage + +*V2 note (2026-06-30):* the authoritative, exhaustive catalog (~44 modes across 10 +connectivity layers, edge cases included, each tagged fix-and-verify or report-text) +now lives in the redesign task (todo.org "Network panel redesign"). The table below is +the v1 baseline; two rows reflect intent the shipped code doesn't yet match, and the +v2 catalog closes them: =gateway unreachable= claims a bounce that doctor never +actually reaches (a no-route failure goes straight to =upstream-not-local=), and +=broken DNS, 1.1.1.1 works= auto-reverts so the user is left offline and misreported +as upstream (the v2 persistent-override fix closes this). + +For each common field failure: does =net diagnose= detect it, can =net doctor +--fix= repair it, and what terminal user action remains when it can't. (The +=needs-user-action= / =upstream-not-local= / =deferred/vpn= outcomes are defined +above.) + +| Failure mode | diagnose detects | doctor --fix | terminal user action | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| rfkill soft block | yes | yes (unblock) | none | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| rfkill hard block | yes | no | flip the physical switch | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| no wifi hardware | yes | n/a | use ethernet | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| associated, no DHCP | yes | yes (reset/bounce) | none, else switch network | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| gateway unreachable | yes | yes (bounce) | switch network if it | +| | | | persists | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| captive DNS hijack | yes | opens portal | log in at the portal | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| broken DNS, 1.1.1.1 works | yes | yes (temp override, | report the venue's DNS | +| | | auto-reverted) | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| HTTP captive portal | yes | opens portal | log in at the portal | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| HTTP interception, no | yes | opens neverssl + gateway | log in manually | +| parseable URL | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| upstream / AP outage | yes (link up, no route out) | no (stops after local) | switch network / contact | +| | | | venue | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| wrong WPA password / | yes | no | enter the password | +| missing secret | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| enterprise auth / cert | yes | no | edit the profile in | +| failure | | | nmtui/nmcli | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| duplicate SSID / | yes (UUID-keyed) | yes (activate by UUID) | none | +| connection-name | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| hidden SSID | yes | yes (connect by name) | enter SSID + password | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| multiple active links | yes | n/a | pick the interface | +| (wifi+tether) | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| wedged NetworkManager | yes | yes (bounce → restart NM) | none, else reboot | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| slow / hung command | yes (degraded) | retries within budget | retry | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| stale / corrupt cache | yes | self-heals (atomic + | none | +| | | invalidation) | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| DNS cleanup failure | yes | flags cleanup-unverified | revert DNS manually | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| missing speedtest backend | yes | n/a | install speedtest-go | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| VPN / policy-routing | yes (route/DNS ownership) | no (deferred to Phase 5) | check the VPN | +| interference | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| + +* Observability — logging + redaction + +- *Event log*: JSONL at =$XDG_STATE_HOME/net/events.jsonl= (fallback + =~/.local/state/net/events.jsonl=), size-rotated (e.g. 1 MB × 3). Every + mutating op and probe appends an event: =ts, op, argv (redacted), exit_code, + stderr_tail, elapsed_ms, iface, nm_uuid, probe_url_class, http_code, + redirect_host, cache_event=. +- *Redaction (always on)*: PSKs, EAP identities/passwords, NM secrets, and + portal query tokens are never logged. MAC addresses, full IPs, and SSID are + redacted when configured (=redact_mac=, =redact_ip=, =redact_ssid= in config). +- *Post-failure diagnosis*: =net doctor --json= is the snapshot + recommendation + (diagnose plus the suggested repair), =net diagnose --json= the raw report, and + the event log the history. =net doctor= is the console-recoverable entry point + (reachable as =make online= / =make net-doctor=). +- *Secret-leak tests*: assert no PSK/EAP/portal-token ever appears in any JSON + output, log line, or error message. + +** Automatic diagnostic verbose-capture (V2) + +A distinct layer from the event log above: that log records what =net= did; +this captures what the *underlying stack* did at debug verbosity during a run, so a +failed diagnosis leaves real ground-truth instead of relying on memory. Two triggers, +one mechanism: + +- *Automatic — on a failing diagnose.* When =net diagnose= ends =overall: fail=, the + next escalation (or =Get Me Online=) runs inside a verbose-capture session. +- *Manual — a debug on/off toggle in the panel's Advanced section.* "Debug on" + elevates and leaves it elevated (with a visible "debug capturing" indicator) so the + user can reproduce an intermittent problem over time; "Debug off" restores and + writes the bundle. Useful when the failure doesn't reproduce inside one diagnose. + +Mechanism (shared): +1. *Snapshot* the current log levels (=nmcli general logging=, resolved's level, + wpa_supplicant's). +2. *Elevate* the relevant components to debug at runtime, no restarts, scoped to the + domains that matter (NM: =WIFI,DHCP,DNS,CORE=; resolved; wpa_supplicant). +3. *Run* the diagnostics / repair. +4. *Capture the window*: =journalctl= for NetworkManager + systemd-resolved + + wpa_supplicant since the run started, a =dmesg= tail (driver / firmware / rfkill), + and any =curl -v= probe output. +5. *Restore* every level to its snapshot. +6. *Write a redacted support bundle* to =$XDG_STATE_HOME/net/bundles/<ts>/= and + surface it in the panel. + +Hard requirements: +- *Restore is guaranteed and idempotent.* A =try/finally= restores even on error, + and a crash-recovery guard detects "a prior run left NM/resolved/wpa_supplicant + elevated" on the next run and puts it back — the same shape as the DoT-restore + watcher. A crash must never strand the stack at debug verbosity. +- *Redaction before anything leaves.* Raw wpa_supplicant and NM debug logs carry the + PSK and EAP credentials in cleartext. The captured journal is scrubbed before the + bundle is written, shown, or shared; the secret-leak test asserts no passphrase or + EAP secret survives into a bundle. +- *Privilege via the V2 sudo-helper.* The log-level toggles need root, so they become + verbs on the passwordless helper (decision 16) — no extra prompt. + +Bonus — this closes a real detection gap, not just observability: the spec notes live +auth-failure detection is a v1 limit (it leans on a one-shot NM state-120 snapshot). +wpa_supplicant at debug during the run is exactly how a wrong-password or EAP failure +is caught properly, so the capture feeds back into classification. + +* Indicator (task #C — Phase 1, the fast win) + +** States (internet sub-state on top of link state) +- online — associated and the probe returned 204. Normal icon. +- captive — associated, probe hit a portal. Distinct glyph + warning CSS class; + tooltip names the portal host; left-click opens diagnostics with the portal + ready to open (Phase 2+; see interactions for the Phase-1 interim). +- no-internet — associated, probe failed (no portal, no 204). Distinct glyph + + warning class. +- degraded — =net status= couldn't read link state within budget (slow/failed + nmcli). Neutral glyph, =net-degraded= class. Never blocks the bar. +- rfkill-blocked — the radio is soft-blocked (=rfkill=), distinct from + disconnected. Distinct glyph; the fix is =net repair rfkill= / =net doctor=. +- connecting / disconnected / airplane / wired — as today, plus wired shown + correctly even when it appears after session start. (airplane is now this + module's state, absorbed from the retired airplane module.) + +** Glyphs +Nerd-font codepoints, final values verified live before merge (same discipline +as wtimer). Reuse the signal-strength ramp already in =waybar-netspeed=; add a +captive / no-internet / degraded overlay glyph. + +** Tooltip +SSID + signal + IPv4 + gateway + the throughput readout (absorbed from +netspeed) + the last probe result and its age (stale/expired hinted). + +** Interactions (no keyboard-modifier clicks — waybar can't qualify clicks by +modifier, so the rich actions live in the panel, not ctrl/super-click) +Clicks never block the bar: each dispatches a detached background job, single-flight +per action. *As built (settled live with Craig, 2026-06-29):* +- *left* — =net-panel= toggle (pkill-or-launch the GTK panel). +- *middle* — =net portal= (the captive-login flow). +- *right* — =net-fix= (=net doctor= with =--notify=: reports the result when the + outcome is one-way, opens a terminal only when it's fixable; the v2 redesign moves + even that into the panel). +- airplane toggle moved off the bar to Super+Shift+A. + +* Panel (tasks #B + #C diagnostics — Phases 2-3) + +GTK4 + gtk4-layer-shell, pocketbook scaffold (src-layout package, unittest, +Makefile, gtk4-layer-shell anchored dropdown under the bar). One panel shell, +reused by the future desktop-settings panel. + +Sections as built (Phases 1-3, a four-page stack switcher): +1. *Connections* — list, MRU-first, active marked, live signal bars for in-range + wifi; row click switches; buttons for add / edit / remove; a rescan control. +2. *Diagnose* (read-only) — Probe (204/captive, shows portal URL + Open), Gateway + ping, DNS config. Streaming step output (the diagnostics contract). +3. *Repair* (mutating, confirmed) — tiered lightest-first: Unblock rfkill, Reset + (fresh MAC), Bounce (full stack), DNS override test, Force portal. A "Get me + online" button runs =net doctor --fix= (the auto-escalating sequence). +4. *Speed test* — Run button, progress, down/up/ping result + last-run line. +As built, the panel also auto-hides on focus-out (popup behavior, suppressed while a +child dialog holds focus) and carries a Close button bottom-right (2026-06-30). + +*V2 nav (planned):* three top tabs — Connections | Diagnostics | Performance. +Diagnostics merges the Diagnose and Repair pages into one: a sub-row +=Diagnose= | =Get Me Online= | =Advanced= over a shared area that shows diagnose +items and streams repair progress in-panel (no terminal). =Advanced= reveals the +individual repair tiers (renamed, with tooltips) plus a *Debug capture on/off* +toggle (the manual side of the verbose-capture feature; a failing diagnose triggers +it automatically). Speed test moves under Performance. + +** V2 panel UX — the target design + +The shipped four-page stack (Connections / Diagnose / Repair / Speed test) is +*history*, not active design. V2 is the sole current target: one panel opened from +the bar, three top tabs — Connections | Diagnostics | Performance — and the page +model below is the contract for what gets built and what gets deleted, not just for +labels. + +*** Connections — saved vs available, join-from-row +Three labelled groups, never one merged list: +- *Saved* — saved NM profiles, MRU-first, rendered instantly without a scan. +- *Available now* — scan-backed in-range SSIDs with signal + security; may carry a + loading/stale hint; unsaved networks appear here. +- *Wired* — ethernet when a wired device is present. +=net list= already yields this (=connections.py= lists saved MRU-first, merges live +signal/security for in-range saved profiles, then appends unsaved in-range SSIDs with +=uuid: nil=); the panel groups and labels it. *Rescan refreshes only the +availability/signal layer* — it never gates or reloads the Saved list. + +*Progressive loading:* render the Saved group immediately on open, then overlay +availability, signal, and the unsaved Available-now rows when the scan returns. Show a +small scan-in-progress state (elapsed + last-scan age). A slow or bad radio scan must +not make the whole panel feel stuck — this is the direct answer to "why does it take +so long to see my connections?" + +*Join-from-row (no Add page):* selecting an unsaved Available-now row *is* the join +flow — SSID and security come prefilled from the scan, never retyped. Open networks +connect (confirm only if needed); WPA/WPA2/WPA3-Personal ask only for the password. +The standalone Add button + modal are deleted for visible networks. A hidden/manual +SSID join lives behind an Advanced "Join hidden network" affordance. + +*** Supported authentication classes (the join matrix) +From the scanned NM =SECURITY= value, V2 handles: +- *Inline-supported* — open, open-with-captive-portal, WPA/WPA2/WPA3-Personal + (PSK/SAE), and WPA2/WPA3 transition mode. The row shows the security label so the + user knows why a password is or isn't asked. +- *Activate-only* — 802.1X / enterprise: connect if already saved, else "edit in + nmtui/nmcli" (no add form in v1/V2, per decision 9). +- *Hidden / manual* — behind the Advanced "Join hidden network" affordance. +- *Rare / unsupported* — WEP, OWE/enhanced-open, MAC-registration, voucher, or + proxy-required: a clear in-panel explanation ("not supported here yet") plus a + non-terminal next step, never a hand-off to a terminal tool. + +*** Diagnostics owns the diagnostic story +Diagnostics holds the read-only checks, the repair stream, Get Me Online, debug +capture (Advanced), and the doctor report. A *lightweight* latency/throughput probe +runs inline as a Diagnose evidence row when internet is available (skipped offline, on +a metered/hotspot warning, or with no backend), and its result is stored in the doctor +report. The *full* speed test stays under Performance (decision 19) — which is also +the home for future throughput history, so Performance earns its tab rather than being +a lone button. + +*** Forget confirmation — future tense + verified +The destructive copy is future tense and names the scope: "This will remove the saved +NetworkManager profile and its stored password from this machine." After the op, +verify the UUID is gone, refresh the Saved list, and report "Forgot <SSID>" or "Could +not forget <SSID>; nothing changed / partial <evidence>" — the verify-every-action +decision applied to a destructive op. + +*** Findable diagnostics report +Every diagnose, repair, and speed/performance run ends with a "Copy report" / "Open +report" action in Diagnostics. The report carries the step statuses + elapsed, the +final classification, the last speed/latency result when available, scan age, +route/interface owner, the redacted event-log tail, and the bundle path when verbose +capture ran. It states explicitly whether any repair mutated state and whether +cleanup/verification passed. "Logs exist somewhere" isn't enough when the network is +already down — the report is the one artifact the user copies to hand over. + +*** Visual contract — a Waybar-attached popup +The panel reads as part of the bar, not a separate app. Match the live Waybar theme: +the dark rounded capsule (=border-radius: 1rem=), the golden border, compact monospace +text, and the =custom/net= state colors. Avoid square corners next to rounded UI, keep +cards out of cards, and use compact icon+label controls with tooltips for the advanced +repairs. Reuse any existing archsetup-owned GTK/panel conventions. (Non-blocking for +engine work; blocks final V2 UX acceptance.) + +** Panel state, cancellation, permissions +State machines for: connection-list loading, rescan-in-progress, +activation-in-progress, diagnose-running, repair-running, speedtest-running. Plus +the real terminal states on this two-machine fleet: no-wifi-hardware (desktop → +ethernet-only view) and missing speedtest-go. (No GPG-key state — there's no +credential store; secrets live in NM.) ("No NetworkManager" is not a modeled +state — NM is always present +on these machines; if nmcli is somehow absent the panel shows a single hard-error +and exits.) Long operations show elapsed time and are cancellable where the +underlying op allows (rescan, speedtest, probe); clearly non-cancellable ones +(an in-flight activation) show elapsed + a disabled control. Permission-denied +(sudo/polkit declined) is a first-class outcome with the "nothing changed" +message, never a silent failure. + +Interaction-pattern catalog (=~/code/rulesets/patterns/=) principles that apply: +- transient-state-buttons — all the network levers in one place, reachable by + one chord (the bar click), state visible. +- default-most-common-friction-proportional — connections MRU-ordered so the + common pick is first; destructive ops (remove) and privacy-changing ones + (reset, override) get a confirm, switching does not. +- one-prompt-picker-typed-prefix — if the connection picker ever goes + keyboard-driven, kind (wifi/eth/saved/in-range) + name in one typed picker. + +** Panel UX flow (settle before Phase 2) +The concrete interaction defaults, so the GTK build isn't inventing them: +- *Default focus*: the Connections section, current connection's row selected. If + the indicator opened the panel because of a captive/no-internet state, focus + Diagnose instead with the relevant action highlighted. +- *Row content*: glyph (signal bars / wired / active check) + name + a secondary + line (security type, "active"/last-used). The active row is visually pinned at + top of its group. +- *Buttons*: one *primary* per section (Connections: Connect to the selected row; + Diagnose: Run diagnose; Repair: "Get me online"; Speed test: Run). Secondary + actions (add / edit / remove / rescan; individual repair tiers) are smaller and + grouped. +- *Disabled rules*: Connect disabled on the already-active row; Repair tiers + disabled while one runs; Speed test disabled while running; add/edit disabled + for enterprise (with the "edit in nmtui/nmcli" hint). +- *Confirmations* (exact wording): Reset → "Reset <SSID>? This drops the + connection and reconnects with a new MAC."; Bounce → "Restart networking? All + links drop briefly."; DNS override → "Temporarily set DNS to 1.1.1.1 for the + test? It reverts automatically."; Remove → "Forget <SSID>? The saved password is + deleted." +- *"Get me online" reporting*: shows each escalation step live (Unblock rfkill → + Reset → Bounce → Portal) with per-step pass/fail and stops at the first that + restores internet or at a terminal state, naming the next action. +- *After close*: the bar reflects the new state immediately (signal/refresh on + next poll); a running speedtest/diagnose keeps running and notifies on finish + (panel close doesn't cancel it). +- *Keyboard*: Esc closes (wired); arrows move row focus and Enter activates a + row (GTK ListBox defaults — row-activate connects, never disconnects); Tab is + the plain GTK focus chain, widget by widget (inside a list it crawls row by + row — no section jumps); there is NO type-to-filter. Verified live via + targeted-key AT-SPI probe 2026-07-02; the earlier tab-between-sections and + type-to-filter claims were aspirational and are struck. If section-jump Tab + or filtering is ever wanted, it's a new task, not an existing behavior. + +* Connection management (nmcli) + +- Every op via nmcli per the nmcli contract above (terse, escaped, UUID-keyed, + bounded =--wait=). +- MRU ordering from NM's =connection.timestamp= (last activated), descending. +- Ethernet appears in the list whenever a wired device is present, selectable at + any time; switching just brings the chosen connection up. +- *Mutation safety + rollback*: switching keeps the current connection up until + the new one activates successfully (=nmcli --wait 30=); on failure it does not + tear down the working link, surfaces the failure, and leaves the prior + connection active. =net down= notes that NM may auto-reactivate a profile and + reports the post-op active connection so the user isn't surprised. A switch that + needs a password it doesn't have prompts (or fails with "password required"), + never silently strands. The exact NM command sequence (preflight active-state + read → activate target → verify default route → on failure, confirm prior + still up) is pinned in the engine and tested against fake nmcli. +- *Add/edit scope*: open + WPA-PSK only in v1. Existing saved profiles of any + type (including enterprise) can be *activated*; editing an enterprise profile + shows "edit via nmtui/nmcli" rather than a broken partial form. + +* Connection secrets (no separate store) + +Per Craig's call: don't build a parallel credential store. Settings and secrets +live where NetworkManager already keeps them, so there's one source of truth and +no extra dependency (no GPG, no gpg-agent, no =~/.config/net/connections=). + +- *Where secrets live*: =/etc/NetworkManager/system-connections/<name>.nmconnection=, + root-owned =0600=, with the PSK/EAP secret stored inline (the default + =secret-flags=0= "owned by NM"). That's already secure-at-rest (root-only) and + is what =nmcli= reads/writes. +- *How we touch them*: every add/edit/remove goes through =nmcli= (=connection add + / modify / delete=), which writes the =.nmconnection= with the right ownership + and perms. We never read or write =system-connections= files directly (root) and + never copy a secret out of them. +- *No export / import / sync* — there's nothing to sync. A new machine gets its + connections the way it always has (the user joins, or restores NM profiles), + not from a tool-specific vault. +- *config file*: =~/.config/net/config= still exists, but only for non-secret + preferences (speedtest server, redaction flags, probe TTL). It holds no + credentials. +- *No secret leakage*: PSK/EAP never appear in =net=' =--json= output, the event + log, or error text (tested) — even though NM is the store, our surfaces must not + echo a secret =nmcli= happens to return. + +* Speed test + +- Backend: *=speedtest-go=* (=--json=, =--server=, =--no-download/--no-upload=), + already installed on velox (AUR =speedtest-go-bin=). No new dependency for v1. + librespeed-cli is the documented fallback for a self-hosted LibreSpeed server. +- =net speedtest --json= parses speedtest-go's JSON into the =speedtest= schema. +- *Server policy*: auto-select nearest by default; allow a pinned server id in + =~/.config/net/config=. +- *Timeout + cancellation*: a hard run timeout (e.g. 60s); the panel run is + cancellable (kills the child). Offline / rate-limited / no-server errors map to + the failure-message table. +- *Tests*: fixture JSON (success) and fixture stderr (offline, no server, + malformed output) drive =net speedtest= parsing without touching the network. + +* Help + documentation + +In-app help has three layers, each reachable in the situation it's needed: + +- *CLI help (works from a dead-GUI TTY)*: =net --help= lists the subcommands in + one screen; =net <cmd> --help= documents each (flags, what it mutates, the + console-recovery targets). The Makefile targets are self-describing (=make help= + lists =online= / =net-doctor= / etc. with one-line descriptions). This is the + layer that matters most when you're at a console with no network. +- *Panel help (in the GUI)*: a small =?= affordance in the panel header opens an + inline help pane — what each section does, which Repair actions mutate state, + what the indicator glyphs/colors mean. Per-control tooltips on the less-obvious + buttons (rfkill, bounce, DNS override). No external help browser. +- *User guide (the durable doc)*: a README / docs page covering every command, + the indicator states + glyphs, the panel sections, the config file keys, the + recovery make targets, troubleshooting (the failure-message table), and + rollback. Written so a future session — or Craig six months out — can operate + and recover the module from the doc alone. + +The failure-message table above is the single source of truth for the +troubleshooting text; the guide and the panel help both render from it rather +than restating it. + +* Enhancement radar + +Low-cost adjacent affordances, each dispositioned so cheap wins aren't lost and +the v1 panel stays focused. (Several are already in v1 by virtue of other +sections; marked here so the consideration is visible.) + +| Enhancement | Disposition | Reason | +|-------------------------------------+-------------+--------------------------------------------------------| +| Open / copy portal URL | v1 | already in the captive flow; trivial Open + Copy | +|-------------------------------------+-------------+--------------------------------------------------------| +| Forget network | v1 | it's the remove op, already specced | +|-------------------------------------+-------------+--------------------------------------------------------| +| Rescan now | v1 | already a Connections control | +|-------------------------------------+-------------+--------------------------------------------------------| +| Retry with hardware MAC | v1 | captive already has --hardware-mac; expose in Repair | +|-------------------------------------+-------------+--------------------------------------------------------| +| Pin speedtest server | v1 | already a config key | +|-------------------------------------+-------------+--------------------------------------------------------| +| Copy redacted doctor report | v1 | cheap, serves the observability/support goal | +|-------------------------------------+-------------+--------------------------------------------------------| +| Show last good network / result | vNext | needs small history persistence | +|-------------------------------------+-------------+--------------------------------------------------------| +| Watch mode for net doctor | vNext | a --watch loop; handy at a TTY, not v1-critical | +|-------------------------------------+-------------+--------------------------------------------------------| +| Actionable desktop notifications | vNext | dunst supports actions; extra wiring | +|-------------------------------------+-------------+--------------------------------------------------------| +| Keyboard connection picker (fuzzel) | vNext | the typed-prefix pattern; panel covers v1 | +|-------------------------------------+-------------+--------------------------------------------------------| +| QR-code share / import WiFi | rejected | low value for a personal 2-machine setup; phones do QR | +|-------------------------------------+-------------+--------------------------------------------------------| + +* Waybar wiring + +- Replace =custom/netspeed= with =custom/net= in the bar's module list (same + slot). +- Module def: =exec: waybar-net=, =return-type: json=, =interval: 2=, a =signal= + for on-demand refresh (next free signal after wtimer's 14), =on-click=, + =on-click-right=, =on-click-middle= per the phase-aware interactions (each + dispatches a detached job, never blocks). +- Remove the old =on-click: pypr toggle network= scratchpad only once the panel + replaces it (Phase 2); Phase 1 keeps it as the interim manager. + +* Testing plan (TDD) + +- *Engine (normal)* — fake =nmcli= + =curl= + =speedtest-go= on PATH; assert + command sequences and parsed/emitted JSON for status, list, up/down, + add/edit/remove, probe, diagnose, repair, speedtest. Pure state/format + functions tested directly. JSON schemas locked by example. +- *Portal parser* — already covered in =tests/captive= (Normal/Boundary/Error + + the real SONIFI body). The engine's native probe reuses the same cases. +- *nmcli parsing* — escaped colon/backslash/newline in SSID, duplicate names, + hidden SSID, non-ASCII, wired-mid-session, multi-active (wifi+tether). +- *Failure + concurrency (the risky classes)* — slow/hung nmcli/curl/speedtest + (degraded state within budget), concurrent =net status= probe refresh + (single-flight), corrupt cache (recovered), stale cache after SSID change + (invalidated), permission denied / sudo declined, DNS-override cleanup failure + (=cleanup-unverified=), NM partial activation (rollback keeps prior link), + secret redaction, missing speedtest-go, no wifi hardware, rfkill soft/hard + block. +- *Doctor classification* — fixture-driven =net doctor= over fake nmcli/curl + asserting the right terminal classification + that =--fix= stops before + destructive repairs: auth failures (=needs-user-action=), upstream/AP failure + (=upstream-not-local=), VPN-routed failure (=deferred/vpn=), and the DNS classes + (hijack → portal, broken-but-1.1.1.1-works → offered override, egress-blocked → + upstream). Assert the failure-mode coverage table's "detects / repairs / terminal + action" holds for each row. +- *Indicator* — drive =net status --json= through =waybar-net=, assert the JSON + per state (online / captive / no-internet / degraded / wired / disconnected / + rfkill), iface override via env. +- *Panel* — pocketbook-style: backing logic (list ordering, op dispatch, + state-machine transitions), not GTK widgets. +- *NM secrets / no-leak* — add/edit writes the secret into NM via nmcli (asserted + against fake nmcli, never to a tool-owned file); assert no PSK/EAP appears in any + =--json=, log line, or error (there is no credential store to round-trip). +- *Live checklist (gated out of the suite)* — a "Manual testing and validation" + task per phase for the real-network states (captive at a hotel, no-internet, + switch under load, reset, speedtest) that can't be faked. + +** Harness + coverage gate +The concrete contract, matching the repo's existing convention (not pytest — the +dotfiles suites are =unittest=, run by =make test= as =python3 -m unittest= over +=tests/*/test_*.py=; 33 suites today): +- *Framework*: =unittest=. Each suite is =tests/<name>/test_<name>.py= + (=tests/net/=, =tests/waybar-net/=), collected by the existing =make test= loop + — no new runner, no pytest dependency. +- *Fakes on a temp PATH*: =fake-nmcli=, =fake-curl=, =fake-speedtest-go=, + =fake-rfkill=, =fake-resolvectl= live as executable stubs in =tests/<name>/= + (the =tests/layout-navigate/fake-hyprctl= pattern). A fixture file encodes the + command→canned-output map and the stub appends each invocation to a log the test + asserts against. Subprocess timeouts are simulated by a stub that sleeps past the + budget; =net status= must still return the degraded state. +- *Waybar wrappers end-to-end*: =waybar-net= is run as a subprocess with the fake + PATH and the env overrides (iface, cache path), asserting the emitted JSON — same + as =tests/waybar-netspeed=. +- *Coverage*: coverage.py is absent system-wide (and not importable), so coverage + runs in a throwaway venv (=python3 -m venv=, =pip install coverage=, =coverage + run -m unittest=, =coverage report=) — the method the wtimer suite used (95%). + Target: *branch* coverage over =net/= and the wrapper, ≥ 90% on the pure + classifier/parser modules. + +** Coverage as a gap-finder, not a number (per phase) +Line coverage alone misses the branches that matter here, so each phase ends with +a *coverage-gap pass*, not just a percentage: +- After the first green run, read the branch report and map every uncovered branch + to either a new test or a consciously-excluded live-only behavior (with a comment + or a Manual-testing entry naming it). +- *Branch coverage is required* for the pure logic: the doctor classifier (every + outcome — fixable / needs-user-action / upstream-not-local / deferred-vpn), the + cleanup-unverified path, the redaction paths, the degraded hot-path fallback, the + timeout branches, and the portal/nmcli parsers. +- A phase isn't "done" until its coverage-gap pass is recorded — uncovered logic is + either tested or explicitly excused, never silently uncovered. + +* Files touched (planned, all in =~/.dotfiles=) + +- =net/= package (src-layout, like pocketbook) — engine + panel. +- =hyprland/.local/bin/waybar-net= — the indicator (replaces =waybar-netspeed=). +- =hyprland/.local/bin/net= — engine CLI entry (console-script shim). +- =hyprland/.config/waybar/config= — swap =custom/netspeed= → =custom/net=; + remove =custom/airplane=. +- =hyprland/.config/waybar/style.css= — captive / no-internet / degraded / + rfkill classes; remove airplane classes. +- =tests/net/=, =tests/waybar-net/= — suites. +- =captive= — refactor: extract probe + reset into functions callable + non-interactively (a =--json= probe mode) so the engine reuses them. +- =~/.config/net/config= — seed config (probe TTL, speedtest server, redaction + flags). No secrets; not a credential store. +- dotfiles =Makefile= — add the console-recovery targets (=online=, =net-doctor=, + =net-status=, =net-diagnose=, =net-portal=, =net-reset=, =net-bounce=). +- *Deletions once net ships* (the airplane module is absorbed): + =hyprland/.local/bin/waybar-airplane=, =hyprland/.local/bin/airplane-mode=, + =tests/waybar-airplane/=, =tests/airplane-mode/=, and the =custom/airplane= + module + its css. +- archsetup Hyprland step — add =gtk4-layer-shell=, =python-gobject=, + =speedtest-go-bin= to the install lists (the only archsetup change; no =gpg= + added, secrets stay in NM's store). + +* Resolved decisions (Craig's calls + this response) + +1. Panel UI tech → GTK4 + gtk4-layer-shell, shared pocketbook scaffold (one + panel shell, reused by the desktop-settings sibling). +2. Engine language → Python =net= package; shells out to =captive= for the + portal-force flow, native cheap probe for the bar path. +3. Connectivity probe → split cadence (fast link poll every 2s + slow cached + internet/captive probe, TTL ~45s) with single-flight + atomic cache. +4. No keyboard-modifier clicks (waybar can't qualify them) — the panel hosts the + rich actions; bar clicks dispatch detached jobs (phase-aware). +5. No separate credential store (Craig's call, cj). Secrets live in NM's own + =system-connections= (root =0600=, inline), touched via nmcli. No GPG, no + gpg-agent, no =~/.config/net/connections=. Supersedes the earlier GPG-store + design. +6. =custom/netspeed= absorbed into =custom/net=; throughput moves to the tooltip. +7. Speed-test backend → =speedtest-go= (already installed), not a new + librespeed-cli dependency; librespeed-cli is the self-hosted fallback. +8. Code lives in the dotfiles repo; archsetup only installs deps. +9. v1 add/edit scope = open + WPA-PSK; enterprise/802.1X is activate-only, + add/edit is vNext (settled by Craig 2026-06-29 — no enterprise networks in his + history, so the form would be unused UI). +10. =net doctor= is in v1 (Craig's call, cj) — a one-shot diagnose+fix mode, + reachable from a TTY via =make online= / =make net-doctor=. (The earlier + "defer the doctor/bundle command" decision is reversed.) +11. Diagnose (read-only) and Repair (mutating, confirmed) are separated in the + panel and the CLI; Repair is tiered lightest-first (rfkill → reset → bounce). +12. =custom/net= absorbs the airplane module (Craig's call, cj). *As built + (2026-06-29, option 1): display-only.* net shows the airplane state (reads + the airplane-mode state file); the =airplane-mode= low-power toggle is kept + (radios + CPU + brightness + services is not a network concern) and moved to + =custom/net='s right-click + signal 15. Only the redundant display pieces — + =waybar-airplane=, =custom/airplane=, and the retired =waybar-netspeed= — + plus their tests/css were deleted. The earlier "delete airplane-mode" framing + is superseded. +13. Repair includes a full-stack bounce and an rfkill-unblock (Craig's calls, + cj) — the latter recovers the framework-laptop post-power-loss soft-block. +14. VPN / WireGuard is a planned Phase 5 (Craig's call, cj), not a permanent + exclusion. + +V2 redesign decisions (Craig, 2026-06-30): + +15. *No terminals anywhere in the module* — =net-popup= is removed; every action and + result renders in the panel. No terminal is ever used to report information to the + user or to collect input from them: every prompt, confirmation, repair stream, and + result lives in the panel UI (Craig, cj, 2026-06-30). Reverses the part of decision + 11 that ran privileged repairs in a terminal "so sudo/polkit can prompt". (Unrelated + to the doctor's "terminal states" — that word means a final outcome, not a tty. The + one open question is the dead-GUI console-recovery path; see the VERIFY in todo.org.) +16. *Passwordless privileged path* — a root-owned helper + a narrow NOPASSWD sudoers + rule scoped to it, archsetup-installed, run as =sudo <helper> <verb>=. This gates + decision 15 (a worker thread can't prompt). Absorbs the earlier DoT-toggle + follow-up and fixes the detached-restore-watcher bug. +17. *Verify every action* — each mutating op (repair, connect, forget, add, DNS + override) re-checks its effect and surfaces pass/fail in the panel. +18. *Detect + respond to every failure mode, edges included* — the full ~44-mode + catalog (todo.org redesign task) is the contract; auto-fix where safe, else report + the exact in-panel text. Includes IPv6-only awareness and multi-homing, which need + diagnose to stop being IPv4-only and single-iface. +19. *Navigation* — top tabs Connections | Diagnostics | Performance; Diagnostics + merges Diagnose + Repair (Diagnose | Get Me Online | Advanced over a shared + streaming area); Speed test under Performance. +20. *Automatic diagnostic verbose-capture* (Craig, 2026-06-30) — on a failing + diagnose, elevate the underlying stack (NM / resolved / wpa_supplicant) to debug, + capture the journal + dmesg window, restore (guaranteed + crash-guarded), and + write a redacted bundle. Plus a manual Debug on/off toggle in Advanced. Restore + bulletproof, secrets scrubbed before the bundle, log-level toggles via the V2 + helper. See Observability. + +* Implementation phases + +*Phases 1-3 are SHIPPED* (2026-06-29 → 2026-06-30, dotfiles); their acceptance +criteria passed and the work is live on velox. Phase 4 (docs/rollout) and Phase 5 +(VPN) remain. The V2 redesign phases at the end are designed, not yet built. + +- *Phase 1 — Indicator + console recovery (task #C).* =net status= + =net probe= + (native cheap probe, reusing captive's logic) + the =captive= probe refactor + + =waybar-net= + the split-cadence cache (single-flight, atomic, stale classes) + + CSS states (incl. rfkill) + performance budget. Plus the CLI-only recovery path: + =net repair= tiers (rfkill / reset / bounce), =net doctor [--fix]=, and the + Makefile targets (=make online= etc.) — all testable without the GTK panel. + Absorbs the airplane state and removes the standalone airplane module. Interim + left-click keeps the existing scratchpad until the panel lands. + - *Acceptance*: fresh-login waybar smoke test shows correct state on + online/captive/no-internet/wired/rfkill; =net status= stays within budget + under a fake slow nmcli (degraded state); =net doctor --fix= recovers a + soft-blocked radio from a TTY; the live captive checklist passes at a real + portal; the airplane state works and the old airplane module is gone; + reverting = swap =custom/netspeed= + =custom/airplane= back. +- *Phase 2 — Panel shell + connection management (task #B core).* GTK4 + layer-shell scaffold + =net list/up/down/add/edit/remove/rescan= + MRU list + + mutation safety/rollback + panel state machines. + - *Acceptance*: switch wifi↔wifi and ethernet↔wifi without stranding; a failed + switch leaves the prior link up; add/edit open + WPA-PSK writes the secret to + NM; remove confirms; panel states render for loading/rescan/activation. +- *Phase 3 — Diagnostics + speed test in the panel.* Wire =net diagnose= / + =net repair= / =net doctor= / =net portal= / =net speedtest= into the Diagnose + vs Repair sections; the "Get me online" button; portal Open button; speedtest + progress + cancel. + - *Acceptance*: diagnose runs read-only; each repair tier confirms + verifies + cleanup (DNS override reverts, shown); speedtest result parses from + speedtest-go and a fixture-driven failure shows the right message. +- *Phase 4 — Docs + rollout.* In-app help (=net --help= / per-command help, the + panel help affordance), README/user-guide (commands, panel, config, + troubleshooting, the make targets, rollback), and the manual dep step on ratio. + - *Acceptance*: =net --help= and each subcommand's help are complete; the + user-guide covers every command + the recovery targets; ratio rollout + documented. +- *Phase 5 — VPN / WireGuard (future).* Fold the existing archsetup wireguard + tooling into the same panel + CLI (=net vpn ...=). Out of the v1 milestone; + specced separately when picked up. + +V2 redesign phases (designed 2026-06-30, dependency order): +- *V2.1 — Sudo helper + NOPASSWD sudoers (gates everything).* Root-owned helper + dispatching net's fixed privileged verbs, archsetup-installed, narrow sudoers. + Also fixes the detached DoT-restore-watcher bug. + - *Acceptance*: every repair runs passwordless in-panel on a non-NOPASSWD machine; + the sudoers rule is scoped to the helper only. +- *V2.2 — Merged Diagnostics panel + nav restructure.* Connections | Diagnostics | + Performance; the Diagnostics sub-row + shared streaming area; Advanced reveal + + tooltips; delete =net-popup=. + - *Acceptance*: no terminal opens for any action; repair progress streams in the + panel; Speed test lives under Performance. +- *V2.3 — IPv6-aware and multi-homing-aware diagnose.* Stop treating no-IPv4 as a + failure when online over IPv6; identify which interface owns the default route. +- *V2.4 — Close every detect/correct gap in the catalog, with post-action + verification.* Work the redesign-task catalog mode by mode. +- *V2.5 — Automatic diagnostic verbose-capture.* Snapshot/elevate/capture/restore + around a failing diagnose + the Advanced Debug on/off toggle; guaranteed + + crash-guarded restore; redacted support bundle; helper log-level verbs. + - *Acceptance*: a failing diagnose leaves a redacted bundle (NM/resolved/ + wpa_supplicant journal + dmesg) and restores every log level; a crash mid-capture + is detected and restored on the next run; the secret-leak test finds no PSK/EAP in + a bundle; the toggle elevates and restores on demand. + +* Open items / risks + +- gtk4-layer-shell dropdown anchoring under a waybar module needs the same + positioning work pocketbook solved; reuse it. (Phase 2.) +- The =captive= refactor must keep the standalone CLI behavior identical while + exposing a non-interactive =--json= probe; covered by the existing + =tests/captive= suite plus new probe-mode tests. (Phase 1.) +- speedtest-go server selection variance (nearest-server flor) — pin a server in + config if results are noisy. (Phase 3.) +- The background-probe kick from =net status= must be truly non-blocking (spawn + + detach); enforced by the single-flight lock and the performance benchmark test. + +* Rollback + +Each phase is independent. The indicator (Phase 1) is a drop-in replacement for +=custom/netspeed= (and =custom/airplane=); reverting is swapping those modules +back in the config and restoring their scripts. The panel is additive — not +wiring its clicks leaves the bar working as before. No credential store to roll +back (secrets stay in NM throughout). + +* Review findings [40/40] + +** DONE Define the structured diagnostics contract :blocking: +The spec says the engine "emits JSON" and that diagnostics "reuse =captive= +verbatim", but the current =~/.dotfiles/common/.local/bin/captive= flow is a +human-readable bash script that mixes diagnostics, sudo prompts, DNS mutation, +browser launch, and terminal prose. A GTK panel cannot reliably turn that into +clear state, progress, cancellation, or useful error messages. Define the +machine contract before implementation: every diagnostic step should have a +stable id, status (=pending/running/pass/warn/fail/skipped=), redacted evidence, +elapsed time, safety outcome, and next action. Keep =captive= as the interactive +CLI, but either refactor reusable probe/reset functions behind =net diagnose +--json= or make =captive= expose a non-interactive JSON mode. This blocks the +panel and logging work because otherwise the implementer must invent the +boundary. + +Disposition: accept — added the "Diagnostics contract" section (per-step id / +status / evidence / elapsed / safety / next_action) and the =captive= =--json= +probe-mode refactor under Architecture + Files touched. + +** DONE Specify user-facing failure messages and recovery actions :blocking: +The spec names failure states like =no-internet=, =captive=, failed probe, +failed reset, missing DNS, and missing speed-test backend, but it does not define +the messages the user sees or what each message tells them to do next. For this +feature, "error" is not enough: a user needs to know whether WiFi is associated, +whether DHCP succeeded, whether DNS is hijacked/broken, whether HTTP is +intercepted, whether sudo was declined, whether a command timed out, and whether +the system was left unchanged or partially changed. Add a message table for the +indicator, panel, and CLI with: failure class, visible text, evidence included, +redaction rule, and next action. This is blocking because UX quality here is the +product, not an implementation detail. + +Disposition: accept — added the "Failure states, messages, recovery" section +covering each class, the visible message, the "what changed" residue note, and +the next action across indicator/panel/CLI. + +** DONE Define the debug log and redacted support bundle :blocking: +There is no observability section. When this fails in a hotel or cafe, an agent +needs enough evidence to diagnose it without rerunning destructive actions. Add +log location, rotation/retention, JSONL event schema, command argv logging, +exit-code/stderr capture, elapsed time, selected iface, NM active connection +UUID, probe URL class, HTTP code, redirect host, DNS servers, and cache +read/write events. Also define a =net doctor --json= or =net debug-bundle= +command that emits redacted status, recent log events, dependency versions, and +a reproduction command. Redact SSID if configured, MAC addresses, portal query +tokens, PSKs, EAP identities/passwords, IPs when requested, and all GPG/NM +secrets. This blocks implementation readiness because post-failure diagnosis is +currently left to ad hoc terminal spelunking. + +Disposition: modify — accepted the JSONL event log, the schema, and the redaction +rules in full (new "Observability" section). Deferred the dedicated =net +debug-bundle= / =net doctor= command to vNext: for a single-user tool =net +diagnose --json= (the snapshot) plus the event log (the history) cover +post-failure diagnosis; a bundle command is gold-plating for v1. Recorded under +Out + Resolved decision 10. + +** DONE Pin the nmcli parsing and timeout contract :blocking: +The spec lists nmcli operations but not the exact fields, output modes, escaping +rules, ID semantics, or timeouts. This is risky because SSIDs and connection +names can contain spaces, colons, duplicates, hidden names, and non-ASCII; the +current =waybar-netspeed= already had an SSID parsing bug. The nmcli manual +documents =--terse=, =--get-values=, =--escape=, =--wait=, ID/UUID/path +selection, =passwd-file=, and built-in connectivity states +(=none/portal/limited/full/unknown=) at +https://man.archlinux.org/man/nmcli.1.en. The spec should require UUIDs for +saved-profile operations, explicit =--wait= budgets, parser tests for escaped +colons/backslashes/newlines/duplicate names/hidden SSIDs, and a decision on when +to use or ignore =nmcli networking connectivity [check]=. This is blocking +because the command wrapper is the core reliability boundary. + +Disposition: accept — added the "nmcli contract" section: terse + =--escape= + +=--get-values=, UUID-keyed ops, explicit =--wait= budgets, NM connectivity as a +cheap hint (our probe authoritative), and the parser test matrix. + +** DONE Define cache concurrency, atomicity, and stale-state behavior :blocking: +=net status= may spawn =net probe= whenever the cache is stale, but the spec +does not define locking, process coalescing, atomic writes, crash cleanup, or +what happens when the probe hangs. With a 2s Waybar interval, a bad network could +start overlapping probes, corrupt the runtime cache, or keep showing stale +"online" while the link is gone. Add a single-flight lock under +=$XDG_RUNTIME_DIR/waybar=, atomic write+rename for cache updates, max probe +runtime, stale age classes (fresh/stale/expired/unknown), cache invalidation on +iface/SSID/connection UUID change, and tests for concurrent =net status= calls. +This blocks the fast-path design because it is the main performance and +correctness risk. + +Disposition: accept — added "Concurrency, atomicity, staleness" under the +Connectivity model: flock single-flight, temp+rename atomic write, ≤6s probe +timeout, fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation, stale +lock reclaim, plus concurrency tests in the test plan. + +** DONE Bound hot-path performance with measured budgets :blocking: +The spec says the cheap poll should be sub-100ms, but the proposed fast path +still may call multiple =nmcli= commands every two seconds, read sysfs, parse +throughput, and maybe spawn a background probe. The existing =waybar-netspeed= +had a deliberate sleep for throughput sampling; replacing it must define how +throughput is sampled without sleeping in the bar path. Add a per-command budget +for =waybar-net= and =net status=, a maximum number of subprocesses on the hot +path, a timeout for every subprocess, benchmark tests with fake slow =nmcli=, +and a rule that the indicator emits a degraded JSON state rather than blocking. +This is blocking because Waybar custom modules can visibly freeze or lag when +their exec path stalls. + +Disposition: accept — added the "Performance budgets" section: <100ms typical / +<250ms worst, throughput sampled across the poll interval (no in-process sleep), +one nmcli call max on the hot path, timeouts on every subprocess, the degraded +state, and a fake-slow-nmcli benchmark test. + +** DONE Make click actions non-blocking and visible :blocking: +Waybar right-click runs =net reset= and middle-click runs =net portal= directly. +Those operations can require sudo, open browsers, mutate DNS, delete/recreate NM +profiles, or hang on network commands, but Waybar click handlers provide no +panel, terminal, progress, or cancellation surface by default. Define whether +right/middle click instead opens the panel focused on the action, dispatches a +background job with notifications, or is removed from v1. If kept, specify +single-flight behavior, how sudo/polkit prompts surface, how success/failure is +reported, and how the user can inspect logs. This blocks UX readiness because +the fastest remediation path is currently the easiest place to hide failure. + +Disposition: modify — accepted the concern; made the interactions phase-aware and +non-blocking. Every click dispatches a detached, single-flight background job and +reports via =notify=; sudo surfaces through polkit/the normal prompt; failures go +to the notify + the event log. In Phase 1 (no panel) left-click runs probe + +notify and keeps the scratchpad; from Phase 2 left-click opens the panel focused +on the action. Recorded in the Indicator "Interactions" subsection. + +** DONE Specify connection mutation safety and rollback :blocking: +The spec says row click switches connections and remove gets a confirm, but it +does not define what happens when a switch partially succeeds, disconnects the +current working link, needs a password, loses the default route, or triggers +auto-activation. The nmcli manual warns that =connection down= does not prevent +future auto-activation and may internally block a profile until user action. +Define preflight, the exact NM command sequence, whether the old active +connection is kept until the new one proves usable, when rollback is attempted, +how long activation waits, and what the panel says when rollback fails. This is +blocking because the module can strand the user offline. + +Disposition: accept — added "Mutation safety + rollback" under Connection +management: keep the prior link up until the target activates (=--wait 30=), no +teardown on failure, password-required surfaced not stranded, =net down= reports +post-op active state + the auto-reactivation caveat, and the pinned NM command +sequence is tested against fake nmcli. + +** DONE Define the credential-store security model :blocking: +The GPG store is described as optional and default-unencrypted, but the spec does +not define file modes, schema, secret-source rules, import/export prompts, +recipient verification, stale secret handling, or what is logged. It also says +NM remains source of truth while the user-owned store contains PSK/EAP secrets, +which creates two truth sources for sensitive data. Add a precise schema, +=0600= file creation with parent-dir permissions, encrypted-recipient checks, +plaintext warning text, explicit opt-in flow, redaction requirements, behavior +when NM has a secret not in the store, behavior when the store has a secret NM +rejects, and tests for no secret leakage in JSON/logs/errors. This blocks Phase +4 and the full spec because otherwise the implementer must make security +decisions mid-code. + +Disposition: accept — rewrote "Credential storage" with the versioned schema, +=0600= file / =0700= dir, recipient verification on opt-in, the plaintext +warning, secret-source rule (entered/exported, never harvested from root store), +the two-source reconciliation policy (NM wins live, store wins for what NM +lacks, stale-secret flagging), and the no-leak tests. + +** DONE Define EAP, enterprise WiFi, and unsupported connection behavior :blocking: +The store says "PSK/EAP" and connection management says add/edit, but there is +no v1 contract for WPA-Enterprise fields, certificates, identity vs anonymous +identity, hidden networks, static IP, proxy settings, metered flags, MAC +randomization, or 802.1X prompt behavior. Either scope v1 to open/WPA-PSK plus +existing saved-profile activation, or define the minimum EAP form and the +unsupported-state messages. This blocks add/edit/import because enterprise WiFi +is too sensitive to hand-wave. + +Disposition: modify (scope) — scoped v1 to open + WPA-PSK add/edit, with +*activation* of any existing saved profile (including enterprise). Enterprise / +802.1X add/edit, static-IP, proxy, metered, and MAC-randomization editing are +vNext, shown as "edit via nmtui/nmcli". Recorded in Scope/Out, Connection +management, and Resolved decision 9. + +** DONE Split read-only diagnostics from mutating remediation :blocking: +The panel's diagnostics section includes probe, bounce/reset, gateway ping, and +DNS override test in one area, while =captive= currently performs resets and +temporary DNS changes as part of its flow. Users need to know which buttons are +read-only and which mutate NM profiles, MAC mode, DNS, or browser state. Add +separate "Diagnose" and "Repair" actions, confirmations for destructive or +privacy-changing operations, explicit cleanup verification for DNS override, and +a terminal state when cleanup is unverified. This blocks readiness because +network repair must not surprise the user or leave hidden residue. + +Disposition: accept — split the panel into a read-only Diagnose section and a +confirmed, mutating Repair section (and split the CLI into =net diagnose= vs =net +repair=). Added =cleanup_verified= + a terminal =cleanup-unverified= state to the +diagnostics contract. + +** DONE Define panel state, cancellation, and permissions UX :blocking: +The panel sections list buttons and a streaming output area, but not loading +states, disabled states, empty states, keyboard/focus behavior, cancellation, or +permission-denied handling. Add panel state machines for connection list loading, +rescan in progress, activation in progress, diagnostics running, speedtest +running, and no NetworkManager/no WiFi/no permissions/no GPG key/no +librespeed-cli. Each long operation should be cancellable where possible or +clearly non-cancellable with an elapsed-time display. This blocks the GTK work +because without it the implementer must invent the user flow. + +Disposition: modify — accepted the state-machine requirement (added "Panel state, +cancellation, permissions"), but scoped the state set to what can actually occur +on the two-machine fleet: dropped "no NetworkManager" as a modeled state (NM is +always present; a missing nmcli is a single hard-error exit) and kept +no-wifi-hardware, missing speedtest-go, no-GPG-key, plus the in-progress states +with elapsed-time + cancellation where the op allows. + +** DONE Verify speed-test dependency, server choice, and failure contract :blocking: +The spec chooses =librespeed-cli= and notes availability/default-server research +as an open risk, but Phase 3 still depends on parsing its JSON and showing +progress. I checked the upstream project page +(https://github.com/librespeed/speedtest-cli) and the AUR URL named by search is +not sufficient as a verified package/install contract in this spec. Add the +exact package name/source to install, command version expected, JSON shape, +server-selection policy, timeout, cancellation behavior, offline/rate-limited +messages, and tests with fixture JSON and fixture stderr. This blocks Phase 3 +because speed-test failure modes are otherwise undefined. + +Disposition: modify — verified live and changed the backend: =speedtest-go= (AUR +=speedtest-go-bin=, 1.x) is already installed on velox and supports =--json=, +=--server=, =--no-download/--no-upload=, so v1 needs no new dependency. +librespeed-cli (AUR =librespeed-cli= / =-bin=) is the documented self-hosted +fallback. Added the "Speed test" section with server policy, timeout, +cancellation, the failure-message mapping, and fixture-JSON/stderr tests. + +** DONE Define dependency installation and repo boundaries :blocking: +The files touched section alternates between archsetup paths and the external +dotfiles repo, while pocketbook has been folded into this repo and its previous +archsetup provisioning was intentionally removed. The spec should state where +the =net= package actually lives, which repository owns the scripts/tests, +whether =gtk4-layer-shell=, =python-gobject=, =librespeed-cli=, =gpg=, =nmcli=, +=curl=, and =resolvectl= are installed by archsetup or assumed present, and the +Makefile targets for test/lint/install. This blocks implementation because the +current path plan can produce code that is not installed on a fresh machine. + +Disposition: accept — added the "Repository + dependencies" section: all code in +=~/.dotfiles= (=net/= package in-tree like pocketbook, scripts in the hyprland +tier, tests under =tests/=), archsetup owns only the dep install +(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=; nmcli/curl/resolvectl +already present), Makefile =make test= collects the package suite, and a +daily-drivers note for ratio. Rewrote Files touched to match. + +** DONE Expand the test plan for failure, concurrency, and live verification :blocking: +The testing plan covers normal parsing and fake command sequences, but it misses +the riskiest behaviors: slow/hung =nmcli=/=curl=/=librespeed=, concurrent +=net status= cache refresh, corrupt cache, stale cache after SSID change, +permission denied, sudo declined, DNS override cleanup failure, NM partial +activation, duplicate connection names, secret redaction, missing optional +dependencies, no WiFi hardware, wired+tether+WiFi ambiguity, portal redirect +tokens, and Waybar click handlers. Add unit/fixture tests for each class plus a +manual/live checklist gated out of the normal suite. This is blocking because +the current plan would leave the exact "things that can go wrong here" mostly +untested. + +Disposition: accept — rewrote the Testing plan with the "Failure + concurrency" +class (slow/hung commands, single-flight, corrupt/stale cache, perm-denied, +cleanup-failure, partial activation, redaction, missing deps, no-wifi, +multi-active) and a per-phase live checklist gated out of the suite. + +** DONE Define status JSON schemas and compatibility rules +The spec says all subcommands take =--json= but does not define schemas. Add +versioned JSON examples for =status=, =probe=, =list=, =diagnose=, =speedtest=, +and error envelopes, including nullable fields and unknown/degraded states. This +is non-blocking for product direction but should be fixed before code so tests +can lock the CLI contract. + +Disposition: accept — added the "JSON schemas" section with versioned (=v:1=) +envelopes for status / probe / list / diagnose / speedtest and a shared error +envelope, including the degraded/unknown states. + +** DONE Rename or alias the phasing section for workflow compatibility +The spec has a usable =Phasing= section, but the spec-review workflow expects an +=Implementation phases= section that can be lifted into =todo.org=. Rename it or +add an alias heading during response. This is non-blocking because the existing +phase decomposition is understandable, but aligning the heading prevents future +workflow friction. + +Disposition: accept — renamed =Phasing= → =Implementation phases= and added +per-phase acceptance criteria. + +** DONE Add documentation and rollout acceptance checks +Rollback is described, but docs and rollout are thin. Add README/user-guide +updates for commands, panel behavior, config file, GPG opt-in, troubleshooting, +and rollback; add acceptance checks for each phase, including a fresh-login +Waybar smoke test and restoring =custom/netspeed=. This is non-blocking but +important for handing the feature to a future session without re-discovery. + +Disposition: accept — added per-phase acceptance criteria under Implementation +phases (incl. the fresh-login waybar smoke test and the =custom/netspeed= +restore), a Phase 4 "Docs + rollout", and (answering Craig's cj follow-up) a +dedicated "Help + documentation" section with the three help layers (CLI help, +panel help affordance, user guide). + +** DONE Add a failure-mode coverage table :blocking: +The spec now names many individual network failures, but it still does not carry +one compact coverage matrix that says, for each common failure mode, whether +=net diagnose= detects it, whether =net doctor --fix= can repair it, and what +terminal user action remains when it cannot. Add a table covering at least: +rfkill soft block, rfkill hard block, no WiFi hardware, associated/no DHCP, +gateway unreachable, captive DNS hijack, broken DNS where 1.1.1.1 works, HTTP +portal, HTTP interception without a parseable portal URL, upstream/AP outage, +wrong WPA password or missing secret, enterprise auth/cert failure, duplicate +SSID/connection-name ambiguity, hidden SSID, multiple active links, wedged +NetworkManager, slow/hung command, stale/corrupt cache, DNS cleanup failure, +missing speedtest backend, and VPN/routing interference. This blocks because +Craig asked for confidence that the diagnostics and doctor cover the real field +failures, and prose scattered across sections is too easy to misread. + +Disposition: accept — added the "Failure-mode coverage" section: a 22-row table +(every mode the finding named) with detect / doctor-fix / terminal-action +columns, conformed to the org-table standard (rules under every row, ≤120). + +** DONE Pin DNS repair semantics in doctor :blocking: +The spec diagnoses DNS hijack, broken hotel DNS, and the temporary 1.1.1.1 +override test, but =net doctor --fix= does not say whether it merely recommends +the override, applies a temporary override during recovery, or leaves DNS alone +after diagnosis. Define the exact behavior for each DNS class: captive hijack +should open the portal, broken DNS where 1.1.1.1 works should either offer an +explicit temporary repair with cleanup verification or recommend the command, +and port-53/egress blocking should stop as upstream/not locally fixable. This is +blocking because DNS is one of the most common "connected but unusable" failures +and the current doctor contract is ambiguous. + +Disposition: accept — added "DNS handling in doctor (explicit per class)" under +the new Doctor section: hijack → open portal (no DNS mutation); broken-but-1.1.1.1 +→ explicit temporary override with cleanup verification under =--fix=, recommend +otherwise; egress-blocked → terminal =upstream-not-local=. + +** DONE Make auth failures terminal user-action states :blocking: +Wrong WPA password, missing NM secret, locked keyring/polkit denial, enterprise +802.1X certificate/identity failure, and portal login-required are not fixed by +resetting or bouncing NetworkManager. The doctor sequence should classify these +as =needs-user-action= terminal states, stop before looping through destructive +repairs, and tell the user the exact next action (enter password, edit profile in +=nmtui=/=nmcli=, accept portal terms, provide cert/identity, or retry with +admin auth). This blocks because repeated reset/bounce against auth failures is +slow, noisy, and can make the network state worse without helping. + +Disposition: accept — added the =needs-user-action= terminal outcome to the +Doctor section: wrong password / missing secret / keyring-or-polkit denial / +802.1X cert-or-identity failure / portal-login-required all stop the doctor before +any destructive repair and name the exact next step. + +** DONE Define upstream/AP/provider failure terminal states :blocking: +Some failures are not client-repairable: AP has no uplink, hotel gateway is +down, DHCP server is broken, gateway drops traffic, ISP outage, or captive +portal backend is failing. The spec should define how =diagnose= proves "local +link is up but upstream is broken" and how =doctor --fix= stops after local +repairs are exhausted with a clear message like "local repairs tried; likely +upstream/AP/provider" plus the evidence. This blocks because users need to know +when to stop poking the laptop and switch networks or contact the venue. + +Disposition: accept — added the =upstream-not-local= terminal outcome: diagnose +proves link-up + IP + gateway-reachable but no route out and no captive redirect; +=doctor --fix= stops after local repairs with "local repairs tried; likely +upstream/AP/provider" + evidence → switch network / contact venue. + +** DONE Decide how VPN and policy routing affect v1 diagnosis +VPN/WireGuard management is Phase 5, but active VPNs, policy routes, DNS +overrides, and firewall killswitches can break apparent internet access in v1. +The current spec does not say whether v1 detects active VPN/policy routing and +classifies "network is fine, VPN route/DNS is broken" separately from WiFi +failure. Add either a v1 diagnostic check for active VPN/default-route/DNS +ownership with a "deferred repair" outcome, or explicitly state that VPN-routed +failures are out of scope and may be misclassified. This is blocking if Craig +expects the module to diagnose normal daily-driver network failures while VPN +tooling remains separate. + +Disposition: accept (chose the detect-and-classify option) — v1 detects an active +VPN / non-NM default route / non-NM DNS owner and classifies =deferred/vpn= ("link +is fine; internet is VPN-routed"), distinct from a WiFi failure. v1 does not +repair it (VPN management is Phase 5); it names the VPN as the likely owner and +stops. Added to the Doctor section + the coverage table + a doctor-classification +test. + +** DONE Remove stale GPG-store references from the resolved spec +The spec now decides "no separate credential store; secrets live in +NetworkManager", but the Testing plan still mentions =gpg round-trip= and =GPG +store= tests, and the panel-state list still mentions a no-GPG-key state. Remove +those stale references and replace them with NM-secret/no-secret-leak tests. +This is non-blocking for product behavior but blocking for implementation +clarity: otherwise tests will be written for a credential store that no longer +exists. + +Disposition: accept — replaced the Testing-plan =gpg round-trip= / =GPG store= +bullets with an "NM secrets / no-leak" test (add/edit writes the secret via nmcli; +assert no PSK/EAP in any JSON/log/error; no store to round-trip) and dropped the +=no-GPG-key= panel state. Residue from the cj-comment pass that dropped the store. + +** DONE Reconcile status, goal, and task text before implementation :blocking: +The spec status says "Implementation-ready with caveats" and "Phase 1 ready to +build", but the body still has an unresolved enterprise add/edit VERIFY, the +Goal still says "optional GPG-encrypted secret store", and the unified task title +still names "GPG-stored secrets" even though the accepted design removed the +store. Before implementation, make the top-level status, goal, scope, task +mapping, and resolved decisions agree with the current design. This blocks +readiness because a developer starting from the top of the file would still build +or plan around abandoned GPG-store behavior. + +Disposition: accept — fixed the Goal ("secrets stay in NM's own store"), the +=[#B]= task-mapping line (notes the "GPG-stored secrets" framing is superseded by +decision 5), the enterprise VERIFY (now resolved → Status updated), and corrected +the stale =pytest= mentions to =unittest= (the repo's actual harness). Top-of-file +status/goal/scope/decisions now agree with the design. + +** DONE Resolve enterprise add/edit scope or make the caveat explicit :blocking: +The spec still says "One open question for Craig: pull enterprise add/edit into +v1?" and points to a VERIFY in =todo.org=. That is a real product-scope decision: +if enterprise add/edit is in v1, panel forms, nmcli command sequences, tests, +error messages, and docs change materially; if it is out, the UI must consistently +show activate-only with "edit in nmtui/nmcli". Decide it in the spec before +implementation, or downgrade the status to =Ready with caveats= with this exact +accepted caveat. As written, the spec cannot be plain =Ready=. + +Disposition: accept — Craig decided (2026-06-29): enterprise add/edit is vNext, +activate-only in v1. Settled in the Status line, the Scope/Out bullet, decision 9, +and the VERIFY (now DONE in todo.org). The UI shows activate-only with "edit in +nmtui/nmcli" consistently. Evidence: 24 saved profiles, 0 enterprise. + +** DONE Define the concrete test harness and coverage gate :blocking: +The spec says TDD, fake binaries on PATH, and benchmark tests, but it does not +define the actual harness contract: pytest vs unittest for the =net= package, +where fake =nmcli=/=curl=/=speedtest-go=/=rfkill=/=resolvectl= live, how test +fixtures encode command histories, how subprocess timeouts are simulated, how +Waybar scripts are executed end-to-end, and how coverage is run. Add the exact +Makefile targets (=test=, =test-unit= or package-local =pytest=), pytest config, +coverage command (e.g. branch coverage over =net/= and =waybar-net= wrappers), +minimum threshold, and the rule for reading the coverage report to add missing +tests before declaring a phase done. This blocks readiness because "what is the +test harness?" is still answerable only by analogy to older suites. + +Disposition: accept — added the "Harness + coverage gate" section. Corrected the +premise: the repo is =unittest= (=make test= → =python3 -m unittest=, 33 suites), +not pytest. Pinned the fake-binary stub convention (=tests/<name>/fake-*= on a +temp PATH), the fixture command→output map, timeout simulation, the end-to-end +=waybar-net= subprocess run, and coverage via a throwaway venv (coverage.py is +absent system-wide) with a ≥90% branch target on the pure modules. + +** DONE Use coverage to find missing behavior, not just report a percentage :blocking: +The spec does not say how coverage findings affect implementation. For this +feature, line coverage alone can miss the important holes: doctor classification +branches, cleanup-unverified paths, redaction paths, degraded hot-path fallbacks, +timeout branches, and auth/upstream/VPN terminal states. Define coverage review +criteria per phase: branch coverage for pure classifiers and parsers, named +untested branches allowed only with comments or manual-check entries, and a +required "coverage gap pass" after the first green test run that maps uncovered +logic back to tests or consciously excluded live-only behavior. This blocks +readiness because the current test plan is broad but does not force the suite to +expose missing edge tests. + +Disposition: accept — added the "Coverage as a gap-finder, not a number (per +phase)" subsection: branch coverage required for the doctor classifier (every +outcome), cleanup-unverified, redaction, degraded-fallback, timeout, and the +parsers; a mandatory coverage-gap pass after the first green run mapping each +uncovered branch to a test or a named live-only exclusion; a phase isn't done +until that pass is recorded. + +** DONE Convert error classes into exact user-facing strings and evidence fields :blocking: +The failure table and doctor outcomes classify errors well, but many messages +are still templates or descriptions rather than final text. Add exact strings +for indicator tooltip, notification, CLI stderr, JSON =error.message=, and panel +banner/step text for every failure-mode row, including cases doctor cannot fix: +wrong password, missing secret, enterprise cert failure, upstream/AP/provider +failure, VPN-routed failure, hard rfkill block, DNS cleanup failure, speedtest +missing, and HTTP interception without parseable URL. For each string, specify +the redacted evidence included and the next action. This blocks UX readiness +because "useful error" is only testable once the actual text and evidence are +defined. + +Disposition: accept — rewrote the Failure states section: each row now carries the +exact final string (with =<placeholder>= evidence), the evidence field, and the +next action, plus a per-surface rendering rule (indicator tooltip / notify / +CLI+JSON error.message+detail+code / panel banner all render the one canonical +string). Added the missing doctor-unfixable rows: hard rfkill, wrong password / +missing secret, enterprise cert failure, upstream/AP/provider, VPN-routed, HTTP +interception without a parseable URL, and DNS cleanup-unverified. + +** DONE Add an enhancement disposition table +The spec captures several good enhancements (doctor, Makefile recovery, rfkill, +airplane absorption, VPN phase), but it does not show that low-cost adjacent +enhancements were considered and accepted/deferred/rejected. Add a small radar +table for likely affordances: copy redacted doctor report, open/copy portal URL, +retry with hardware MAC, forget network, rescan now, pin speedtest server, show +last good network/result, watch mode for =net doctor=, desktop notification +actions, QR-code/share WiFi import/export, and keyboard picker. Mark each +=v1=, =vNext=, or =rejected= with a one-line reason. This is non-blocking, but it +prevents accidental loss of cheap UX wins and keeps the v1 panel focused. + +Disposition: accept — added the "Enhancement radar" table dispositioning all the +named affordances: open/copy portal URL, forget network, rescan, hardware-MAC +retry, pin speedtest server, copy redacted doctor report = v1; last-good +network/result, doctor watch mode, actionable notifications, keyboard picker = +vNext; QR-share = rejected (low value for a 2-machine personal setup). + +** DONE Tighten the panel UX flow before Phase 2 +The panel has sections and state machines, but not a concrete interaction flow: +default focused section, row content, primary/secondary buttons, disabled-state +rules, confirmation wording for reset/bounce/DNS override, how "Get me online" +reports each escalation, what stays visible after the panel closes, and keyboard +navigation. Add a short UX flow spec or wire-level outline before Phase 2. This +is non-blocking for Phase 1, but it blocks Phase 2 implementation because a GTK +panel can easily become noisy or surprising if these defaults are invented while +coding. + +Disposition: accept — added the "Panel UX flow (settle before Phase 2)" +subsection: default focus (Connections, or Diagnose when opened from a captive +state), row content, one primary button per section, disabled-state rules, exact +confirmation wording for reset/bounce/DNS-override/remove, the live "Get me +online" escalation reporting, what survives panel close, and keyboard nav. + +** DONE Reconcile the panel navigation source of truth :blocking: +Disposition: accept — folded into "V2 panel UX". V2 (Connections | Diagnostics | +Performance) is the sole current target; the shipped four-page stack is marked history, +not active design. +The spec now names at least three navigation shapes: the shipped four-page stack +(Connections / Diagnose / Repair / Speed test), the V2 three-tab plan +(Connections / Diagnostics / Performance), and the redesign task's Diagnostics +sub-row (Diagnose / Get Me Online / Advanced). That leaves an implementer free +to keep extra pages and buttons even though Craig is explicitly asking for the +opposite. Make V2 the sole current target: one panel opened from the bar, top +tabs =Connections | Diagnostics | Performance=, with Diagnostics owning the +read-only checks, repair stream, debug capture, doctor report, and related +diagnostic evidence. Mark the old four-page stack as shipped history only, not +active design. This blocks the redesign because the page model determines what +code is deleted, not just labels. + +** DONE Fold speed tests into the diagnostic story :blocking: +Disposition: modify — Craig pre-decided Speed test lives under Performance (decision +19), and Performance carries future throughput history, which meets this finding's own +"keep the tab only if it carries ongoing throughput" condition. Accepted the rest: +Diagnostics runs a lightweight inline latency/throughput probe as a Diagnose evidence +row (with skip conditions for offline / metered / no-backend), and the full speed +result is stored in the doctor report. Folded into "V2 panel UX → Diagnostics owns the +diagnostic story". +Speed test is currently isolated under =Performance=, while the Goal and user +mental model treat speed, latency, and packet loss as part of "diagnostics." +That split risks another top-level button/page whose only job is a diagnostic +measurement. Keep the top-level =Performance= tab only if it carries ongoing +throughput/history later; for V2, specify that Diagnostics can run a lightweight +performance check from the same Diagnose/Get Me Online flow when internet is +available, and that the full speed test is presented as a diagnostic evidence +row or secondary action rather than a separate repair-adjacent workflow. Define +when it is skipped (offline, metered/hotspot warning, missing backend) and how +the result is stored in the doctor report. This is blocking because otherwise +the implementation preserves avoidable navigation and misses a useful failure +signal. + +** DONE Define saved-list vs available-scan semantics :blocking: +Disposition: accept — folded into "V2 panel UX → Connections". Saved / Available now / +Wired groups; Rescan refreshes only the availability/signal layer, never the Saved +list. +=net list= merges saved profiles with in-range scanned networks, while the panel +copy calls the page "Connections" and the control "Rescan." It is not clear to a +user whether they are looking at saved connections, currently available +networks, or both. The current implementation confirms the ambiguity: +=connections.py= lists saved profiles MRU-first, merges live signal/security for +saved profiles that are in range, then appends unsaved in-range SSIDs with +=uuid: nil=. Rename and specify the groups: e.g. =Saved= (instant, does not +require scan), =Available now= (scan-backed, may still be loading/stale), and +=Wired=. =Scan= should refresh only the availability/signal layer, not gate the +saved profile list. This blocks readiness because it affects loading behavior, +button enablement, and whether unsaved rows can be selected. + +** DONE Replace the Add page with join-from-row behavior :blocking: +Disposition: accept — folded into "V2 panel UX → Connections". Selecting an unsaved +Available-now row is the join flow (SSID/security prefilled); the standalone Add modal +is deleted for visible networks; hidden/manual join lives behind Advanced. +The current Add dialog asks for an SSID as free text even though a scan usually +already found the SSID and security type. That is redundant UI and a common +network-manager mistake: it turns "join this visible network" into "copy a name +from the list and type it again." V2 should remove the standalone Add button and +modal for normal visible networks. Selecting an unsaved available row should +become the join flow: the SSID/security are prefilled from the row, open +networks connect with a confirmation only if needed, WPA/WPA2/WPA3-Personal ask +only for the password, and hidden/manual SSID is tucked behind an Advanced +"Join hidden network" affordance. Keep edit/create for enterprise profiles out +of v1/V2 unless explicitly added later. This blocks the redesign because it +changes the primary connection workflow and deletes a whole page/control. + +** DONE Pin the supported authentication types in the join flow :blocking: +Disposition: accept — folded into "V2 panel UX → Supported authentication classes". +The spec says "open + WPA-PSK" and "enterprise activate-only," but cafe/hotel +networks also commonly appear as open captive portals, WPA/WPA2/WPA3-Personal +(PSK/SAE), and sometimes transition-mode networks; less commonly they use +enterprise/802.1X, WEP, OWE/enhanced-open, MAC registration, voucher portals, or +proxy-required networks. Define the V2 join matrix from the scanned NM +=SECURITY= value: supported inline (open, captive/open, WPA/WPA2/WPA3 Personal), +activate-only if already saved (802.1X/enterprise), hidden-manual behind +Advanced, and unsupported/rare types with a clear in-panel explanation plus a +non-terminal next step. If an auth type is common enough to support, support it +in the panel; if it is too rare for V2, say "not supported here yet" and keep +the user in the same UI rather than sending them to a terminal tool. Also define +what security label appears in the row so the user knows why a password is or is +not requested. This blocks because the Add/Join deletion above cannot be +implemented safely without knowing which auth classes the simplified flow covers. + +** DONE Fix destructive confirmation tense and verification +Disposition: accept — folded into "V2 panel UX → Forget confirmation". +The Forget confirmation says "The saved password is deleted" before the user has +clicked Forget. That reads as if the destructive action already happened. Change +the copy to future tense and name the scope, e.g. "This will remove the saved +NetworkManager profile and its stored password from this machine." After the +operation, verify the UUID is gone, refresh the Saved list, and report either +"Forgot <SSID>" or "Could not forget <SSID>; nothing changed / partial state +<evidence>." This is non-blocking because the existing confirm prevents an +accidental click, but the wording is misleading and the V2 "verify every action" +decision should cover it. + +** DONE Make connection loading progressive and observable :blocking: +Disposition: accept — folded into "V2 panel UX → Connections (progressive loading)". +Opening the panel currently says "Loading connections..." while =net list= +collects both saved profiles and the WiFi scan. Saved profiles do not require a +network scan, so a slow scan should not delay the saved list. Split loading into +two phases: render saved NM profiles immediately, then overlay availability, +signal, and unsaved in-range rows when the scan completes. Show a small +scan-in-progress state with elapsed time and stale-last-scan age, and make +Rescan update only the scan-backed fields. This blocks because it is the direct +answer to "why does it take so long to see the list of connections?" and keeps a +bad radio scan from making the whole panel feel broken. + +** DONE Define the visual contract with Waybar and existing Archsetup UI +Disposition: accept — folded into "V2 panel UX → Visual contract". +The panel is a layer-shell popup anchored under Waybar, but the spec does not +state the visual contract. The live Waybar theme uses a dark rounded capsule +(=border-radius: 1rem=), golden border, compact monospace text, and state colors +for =custom/net=; the GTK panel currently has a generic title, stack switcher, +default GTK controls, and square-ish/default widget corners. Add a short style +section: panel should read as a Waybar-attached popup, not a separate app; match +Waybar's palette, border/radius, spacing density, and state colors; avoid square +corners where surrounding UI is rounded; keep cards out of cards; use compact +icon+label controls with tooltips for advanced repairs. Also cite any existing +Archsetup-owned GTK/panel conventions that should be reused. This is +non-blocking for engine work but should block final V2 UX acceptance. + +** DONE Add a diagnostics report affordance that users can actually find +Disposition: accept — folded into "V2 panel UX → Findable diagnostics report". +The observability design has a JSONL event log, =diagnose --json=, automatic +verbose capture, and redacted bundles, but the panel flow does not yet define +the user affordance that turns those into an inspectable diagnosis. Add a +Diagnostics-side "Copy report" / "Open report" action after every diagnose, +repair, and speed/performance run. The report should include the current step +statuses, elapsed time, final classification, last speed/latency result when +available, scan age, route/interface owner, relevant redacted event-log tail, +and bundle path when verbose capture ran. It must explicitly say whether any +repair mutated state and whether cleanup/verification passed. This is blocking +for observability because "logs exist somewhere" is not enough when the network +is already failing. + +* Review and iteration history + +** 2026-06-29 Mon @ 17:00:39 -0400 — Codex — reviewer + +- *What changed or was recommended:* Rubric: =Not ready=. Applied the + spec-review workflow and added blocking findings for diagnostics structure, + user-facing errors, observability, nmcli contracts, cache concurrency, + hot-path performance, Waybar click actions, mutation rollback, credential + security, unsupported WiFi types, panel states, speed-test dependency + verification, install boundaries, and test coverage. +- *Why:* The spec has the right broad shape, but this feature combines a status + indicator, NetworkManager mutation, sudo-backed remediation, captive-portal + probing, secret storage, and GTK UI. Too many failure modes were still left for + implementers to invent during coding. +- *Artifacts:* Findings recorded in =Review findings=. Local code read included + =~/.dotfiles/hyprland/.local/bin/waybar-netspeed=, + =~/.dotfiles/common/.local/bin/captive=, + =~/.dotfiles/tests/waybar-netspeed/test_waybar_netspeed.py=, + =~/.dotfiles/tests/captive/test_captive.py=, pocketbook's GTK layer-shell + scaffold, current Waybar config, and the linked =todo.org= tasks. External + checks included the Arch nmcli man page and upstream LibreSpeed CLI page. + +** 2026-06-29 Mon @ 17:35:00 -0400 — Claude Code (archsetup) — responder + +- *What changed:* Processed all 18 findings — 13 accepted, 5 modified, 0 + rejected. Accepts added new sections: Repository + dependencies, Performance + budgets, nmcli contract, JSON schemas, Diagnostics contract (+ Diagnose vs + Repair), Failure states/messages/recovery, Observability, cache + concurrency/atomicity/staleness, mutation safety + rollback, the rewritten + credential security model, the expanded test plan, JSON schemas, the renamed + Implementation phases with per-phase acceptance criteria, and a Status line. + Modifies: deferred the =net debug-bundle= command (log + =diagnose --json= + cover it), made click actions phase-aware + non-blocking, scoped enterprise + WiFi to activate-only (add/edit vNext), trimmed the panel state set to states + that occur on the two-machine fleet, and switched the speed-test backend to the + already-installed =speedtest-go= (verified live) instead of a new + librespeed-cli dependency. +- *Why:* Codex's review was strong and mostly right; the modifies are scope calls + for a single-user, two-machine tool (avoid gold-plating, use installed deps, + don't model impossible states) and one phasing reconciliation (the panel is + Phase 2, so Phase-1 clicks can't open it). +- *Artifacts:* All 18 findings completed =DONE= in place with dispositions. Live + check: =pacman -Qq | grep speed= (speedtest-go-bin installed; =speedtest-go + --json= confirmed). Status now "review incorporated (Codex, 2026-06-29)", + implementation-ready with the named Phase-2/3 caveats; Phase 1 ready to build. + +** 2026-06-29 Mon @ 17:37:58 -0400 — Claude Code (archsetup) — responder (cj comments) + +- *What changed:* Folded in Craig's 10 cj comments on the spec. Design changes: + (1) dropped the separate credential store entirely — secrets stay in NM's own + =system-connections= (root =0600=, inline), touched via nmcli; no GPG, no + gpg-agent (rewrote the secrets section, decision 5, dropped Phase 4 + the gpg + dep). (2) Added =net doctor [--fix]= + Makefile console-recovery targets + (=make online= etc.) as a first-class TTY path; reversed the earlier + defer-the-doctor call (decision 10). (3) Added a full-stack =bounce= repair and + an =rfkill= unblock repair + indicator state — the rfkill one recovers the + framework-laptop post-power-loss soft-block Craig hit. (4) =custom/net= absorbs + the airplane module; the standalone airplane scripts/tests/module are deleted on + ship. (5) Moved VPN/WireGuard from "out" to a planned Phase 5. (6) Added a + "Help + documentation" section (CLI help / panel help / user guide). Answered + the enterprise-defer rationale and the captive-auto-login explanation inline. +- *Why:* Craig's comments simplify (NM-only secrets, fewer deps) and harden the + recovery story (doctor + make targets + rfkill/bounce reachable from a dead-GUI + console — his stated need). Enterprise add/edit in v1 is the one open call, + raised as a VERIFY. +- *Artifacts:* All 10 cj blocks removed. Live checks: airplane scripts/tests + present (confirmed deletion targets); =/etc/NetworkManager/system-connections/= + is root =0600= with inline secrets (confirms the NM-only secret model); + =rfkill= present. VERIFY filed in =todo.org= for the enterprise-scope decision. + +** 2026-06-29 Mon @ 17:49:24 -0400 — Codex — reviewer + +- *What changed or was recommended:* Added a second feedback batch from the + common network failure-mode review: require a diagnose/doctor coverage table, + pin DNS repair semantics, make auth failures terminal user-action states, + define upstream/AP/provider terminal states, decide how active VPN/policy + routing affects v1 diagnosis, and remove stale GPG-store references. +- *Why:* The design now has a solid recovery shape, but the field question is + sharper: for each common failure, does =diagnose= catch it, does =doctor --fix= + fix it, or does the user need to switch networks/provide credentials/contact + the venue? That mapping needs to live in the spec, not in chat. +- *Artifacts:* Findings appended under =Review findings= after reviewing the + current spec's Diagnostics, Failure states, Doctor, Testing plan, and + responder history. + +** 2026-06-29 Mon @ 18:06:22 -0400 — Codex — reviewer + +- *What changed or was recommended:* Added a readiness pass focused on + development-time questions, missed enhancements, UX cleanliness, error wording, + and test/coverage mechanics. New findings require reconciling stale status/goal + text, resolving or explicitly caveating enterprise add/edit, defining the + concrete harness and coverage gate, making coverage actionable, turning failure + classes into exact user-facing strings, adding an enhancement disposition + table, and tightening the Phase-2 panel UX flow. +- *Why:* The network failure model is now strong, but implementation would still + force developers to invent test infrastructure, coverage expectations, final + error copy, and several UX details. The spec should decide those up front or + explicitly mark them as caveats. +- *Artifacts:* Findings appended under =Review findings= after reading the + updated spec, current archsetup Makefile test targets, pocketbook's pyproject, + and the linked =todo.org= enterprise VERIFY. + +** 2026-06-29 Mon @ 17:55:08 -0400 — Claude Code (archsetup) — responder (2nd review) + +- *What changed:* Processed all 6 second-batch findings — 6 accepted, 0 modified, + 0 rejected. Added a "Doctor: escalation, classification, terminal states" + section with four outcomes (=fixable= escalating lightest-first; =needs-user- + action=, =upstream-not-local=, =deferred/vpn= terminal) and explicit per-class + DNS handling. Added a "Failure-mode coverage" table (22 rows: detect / doctor-fix + / terminal action, org-table-standard conformant, ≤120). Cleaned the last + GPG-store residue (Testing plan → NM-secret/no-leak test; dropped the no-GPG-key + panel state). Added a doctor-classification test class. +- *Why:* The findings sharpen the doctor from "escalate repairs" to "classify, + then stop at the right terminal state" — the safety property that keeps + =doctor --fix= from looping destructive repairs against a wrong password or an + upstream outage. The VPN finding got the detect-and-classify option (not repair; + that's Phase 5) so v1 doesn't misread a VPN-routed failure as WiFi down. The + coverage table is the single artifact Craig asked for to trust the field + coverage. +- *Artifacts:* All 6 findings =DONE= in place; cookie =[24/24]=. Table conformed + via =wrap-org-table.el= (max line 120). No new findings introduced; no scope + expansion past what the findings asked. Spec remains implementation-ready, the + enterprise add/edit VERIFY still the one open decision. + +** 2026-06-29 Mon @ 18:24:51 -0400 — Claude Code (archsetup) — responder (3rd review) + +- *What changed:* Processed the third review (7 findings). The enterprise-scope + finding closed first on Craig's call (vNext, activate-only). The other 6, all + accepted: (1) reconciled the top-of-file text — fixed the Goal's GPG-store + wording, the =[#B]= task-mapping line, the resolved enterprise VERIFY, and + corrected the stale =pytest= mentions to =unittest= (the repo's real harness). + (2) Added a "Harness + coverage gate" section (unittest, fake-binary stubs on a + temp PATH, venv coverage, ≥90% branch on pure modules). (3) Added a per-phase + "coverage as a gap-finder" pass. (4) Rewrote the Failure states section to exact + final strings + evidence fields + a per-surface rendering rule, and added the + missing doctor-unfixable rows. (5) Added the "Enhancement radar" table + (v1/vNext/rejected). (6) Added the "Panel UX flow" subsection. +- *Why:* The findings close the gap between "design decided" and "a developer can + start": the harness/coverage contract, the exact UX strings, and the panel flow + are the things otherwise invented mid-code. The =pytest=→=unittest= correction + was a real defect — the spec contradicted the repo's actual test convention. +- *Artifacts:* All 31 findings =DONE=; cookie =[31/31]=. Both new tables conformed + via =wrap-org-table.el= (coverage 120, radar 110). Harness verified against the + live repo (33 unittest suites, =make test=, coverage.py absent → venv). Status + raised to "Ready for Phase 1; Ready-with-caveats overall" — no open decisions + remain. + +** 2026-06-30 Tue @ 16:32:07 -0400 — Claude Code (archsetup) — responder (build + V2 redesign) + +- *What changed:* Brought the spec current with what shipped and what got decided + across 2026-06-29/30. Recorded Phases 1-3 as SHIPPED (engine, indicator, GTK4 + panel, bar clicks). Added the native captive-login engine (=portal-login= repair + tier replacing the =captive= shell-out), the live-testing portal UX fixes (removed + the polkit-gated flush, already-online short-circuit, Chrome first-run suppression, + in-panel portal, extractor hardening), and the panel auto-hide + Close button. + Then folded in the V2 redesign Craig directed: no terminals anywhere, a passwordless + root-helper + NOPASSWD sudoers as the enabler, verify-every-action, the full + ~44-mode failure catalog (now the authoritative coverage, in todo.org), and the + Connections | Diagnostics | Performance nav. Added decisions 15-19, V2 phases + 2.1-2.4, and corrected the two failure-table rows the shipped code never matched + (gateway-bounce, venue-DNS auto-revert). +- *Why:* The spec had drifted behind the code (still said "Phase 1 shipped, terminal + for privileged ops") and behind Craig's redesign calls. The doc is the contract for + the V2 build, so it has to state the no-terminal reversal, the sudo-helper + dependency, and where coverage now lives. +- *Artifacts:* Updated Status, Architecture, Engine CLI surface, Doctor DNS handling, + Failure-mode coverage, Interactions, Panel, Resolved decisions, Implementation + phases. Shipped commits referenced: dotfiles =a7d7559=, =eef6b0b=, =450b7f0=. Full + catalog + redesign children in todo.org "Network panel redesign". + +** 2026-06-30 Tue @ 17:29:29 -0400 — Codex — reviewer + +- *What changed or was recommended:* Rubric: =Not ready= for the V2 redesign until + the new blocking findings are dispositioned. Added a fourth review batch focused on + diagnostics/observability and the simple UX questions Craig raised: reconcile the + active panel nav, fold performance/speed testing into the diagnostics story, make + saved-vs-available list semantics explicit, replace the Add page with join-from-row, + pin supported WiFi authentication classes, fix Forget confirmation tense, make + connection loading progressive, define the Waybar/Archsetup visual contract, and add + a findable redacted diagnostics report affordance. +- *Why:* The spec has strong engine and failure-mode coverage, but the current doc and + shipped implementation still replicate common network-manager UX mistakes: ambiguous + mixed lists, scan-gated saved data, redundant SSID entry, misleading destructive + copy, diagnostics split across too many pages, and observability that exists in logs + more than in the user's workflow. +- *Artifacts:* Findings appended under =Review findings= after reading the + spec-review workflow, the target spec, todo.org's redesign task, and current + dotfiles implementation files =net/src/net/connections.py=, + =net/src/net/gui.py=, =net/src/net/panel.py=, and Waybar =style.css=. + +** 2026-06-30 Tue @ 17:36:40 -0400 — Claude Code (archsetup) — responder (4th review) + +- *What changed:* Dispositioned all nine fourth-review findings (8 accept, 1 modify, + 0 reject) and wove them into a new "V2 panel UX — the target design" section. + Accepts: V2's three-tab nav is the sole target (the four-page stack is history); + Connections splits into Saved / Available now / Wired with Rescan touching only the + scan layer; selecting an unsaved row is the join flow (the Add modal is deleted); + a join matrix pins which NM SECURITY classes are inline-supported / activate-only / + hidden / unsupported; loading renders Saved first and overlays the scan; the Forget + copy goes future-tense + verified; every run ends with a Copy/Open redacted report; + and a Waybar visual contract (rounded capsule, golden border, state colors). Modify: + the speed-test finding kept Craig's decision-19 placement (full speed test under + Performance, which carries future history) while accepting a lightweight inline + latency probe as Diagnose evidence stored in the doctor report. Cookie [40/40]. +- *Why:* Codex read the live implementation and caught the UX places where the module + still replicated common network-manager mistakes — mixed lists, scan-gated saved + data, redundant SSID entry, misleading destructive copy, diagnostics scattered + across pages, observability that lived in logs more than the workflow. +- *Artifacts:* Findings 32-40 completed in place with dispositions; the modify reason + on the speed-test finding. New "V2 panel UX" section under Panel. todo.org redesign + task updated to point the V2 build at the dispositioned design. + +** 2026-07-01 Wed @ 10:43:18 -0400 — Claude Code (archsetup) — responder (cj comments) + +- *What changed:* Folded in Craig's three cj comments from his review. (1) Notification + rendering: title = "Networking", body = the failure label on its own line then the + canonical string. (2) VPN kill-switch: added a "kill-switch blocking" failure state + plus a detection-and-correction strategy off the =deferred-vpn= branch (rootless + cascade over =ip rule= fwmark 0xca6c / =wg show= / Proton =pvpn-*= NM connections / + =nft=/=iptables= drop tables / firewalld =drop= zone; classify blocking only when a + block artifact exists AND no tunnel is up; correction surfaces the exact root command + per artifact). (3) Terminals: strengthened decision 15 to "no terminal ever reports + to or collects input from the user", disambiguated from the doctor's "terminal + states" wording. +- *Why:* Craig's review annotations. The kill-switch closes a real gap in the + VPN-routed classification; the terminal directive makes the no-terminal rule + absolute for the module UX. +- *Artifacts:* Three cj blocks removed. VPN research subagent cited wg-quick man page, + Pro Custodibus, System76/Proton killswitch docs, and local =doctor.py:42= / + =classify.py:60= / =USNY.conf:15=. One open tension filed as a VERIFY in todo.org: + the dead-GUI console-recovery path (=make online= from a TTY) vs the no-terminal + directive. diff --git a/docs/design/2026-06-29-waybar-timer-module-spec.org b/docs/design/2026-06-29-waybar-timer-module-spec.org new file mode 100644 index 0000000..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-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/maintenance-console-design-ideas.org b/docs/design/maintenance-console-design-ideas.org new file mode 100644 index 0000000..066c25d --- /dev/null +++ b/docs/design/maintenance-console-design-ideas.org @@ -0,0 +1,527 @@ +#+TITLE: Maintenance Console — Design Ideas +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-06 + +* Direction + +A single-host maintenance console (GUI, waybar-launched) that surfaces every +health metric for this Arch workstation and, where the remediation is safe, +runs it. It reshapes the earlier install-health/workstation-contract framing +(=system-monitor-design-ideas.org=) into a maintenance surface with a doctor +action. + +The console is the glanceable, single-host version of the home project's +=system-health-check.org= workflow (~1000 lines, capability-dispatched across +ratio/velox/mybitch/truenas). The console owns the routine-maintenance and +at-a-glance-status slice; the workflow stays as the escalation path for +forensic investigation and as the only sanctioned way to run updates. + +* Scope: hosts (Q2 resolved 2026-07-06) + +v1 targets *both* daily drivers — ratio and velox — as first-class hosts, not +ratio-first-velox-later. Consequences for v1: + +- *Capability dispatch is mandatory from day one*, not deferred. The console + probes the live host and runs only applicable checks (btrfs RAID1 on ratio vs + ZFS-primary on velox; AMD amd-pstate on ratio vs Intel intel_pstate on velox). + This mirrors the home workflow's Phase 0 probe. +- *velox-only metrics ship in v1*: battery health (capacity / cycles) and the + unclean-shutdown / suspend-failure rate — both currently GAPs, both driven by + velox being the machine that travels and suspends. +- The ZFS-primary remediation paths (scrub, snapshot retention, pool health) are + built and tested, not stubbed. + +* Thresholds source of truth (Q3 resolved 2026-07-06) + +One machine-readable file, =maintenance-thresholds.toml=, holds every severity +value (cache-size trigger, disk %, scrub-age warn/crit, snapshot retention +limits, temp bands, backup-staleness windows, etc.). archsetup owns and ships +it; both consumers read the *installed* path, so neither reaches into the +other's repo: + +- the maintenance console (dotfiles code) reads it at runtime, +- the system-health-check workflow reads it instead of hardcoding severity + rules in prose. + +The workflow's hard-won values migrate into the TOML as the seed content (the +snapshot MONTHLY limit that bit /home, the 10 GB cache trigger, the scrub-age +bands). Install path is an implementation sub-question — a stable location both +consumers reach (e.g. =~/.config/archsetup/maintenance-thresholds.toml=). + +* Workflow ownership — move system-health-check into archsetup (proposed 2026-07-06) + +Craig's call: the =system-health-check.org= workflow should live in archsetup, +not the home project. Rationale — the home project is scoped to finances, +health, and personal matters; system design, execution, and maintenance are +archsetup's domain. Home only owns the workflow by inherited accident, not by +fit. Moving it here also collapses the Q3 coupling: the TOML source-of-truth and +its workflow consumer end up in the same project. + +Move scope (home → archsetup), to sequence when Craig gives the go: +- =system-health-check.org= (the workflow itself) +- =homelab-inventory/*.org= (ratio/velox/mybitch/truenas capability inventories + it cross-references) +- any home-project references to the workflow (startup, project-workflows index) + +Cross-project mechanics: the archsetup side (receiving the files, wiring the +TOML) is in-scope here; the home side (removing the originals, updating home's +references) is the home project's scope — handled by a handoff note to home's +inbox or a home session, not edited blind from here. + +* Panel shape + +Three regions, driven by the Automation column of the metrics table below: + +- *Actionable* (left) — metrics with a lever. Automation = Auto or Confirm. + Two doctor actions serve this column: "Clean up" fires every Auto metric + unattended; "Review & fix" opens a preview for the Confirm metrics and acts + only on approval. +- *Diagnostic* (right) — read-only telemetry. Automation = None or Human. A + state color (green/amber/red) and the value; no button. Red here is the + signal to run the workflow. +- *Updates* (quarantined strip) — Automation = Workflow. Shown as a count with + notable packages named; the only affordance is "run the workflow." The panel + never applies updates in place. + +*Bar glyph* tracks the worst *Diagnostic* state only — not the actionable +count. A big package cache is boring; a SMART failure is a fire. Actionable +clutter must not turn the bar red or it trains you to ignore it. + +** Doctor = live output wall (Q1 resolved 2026-07-06) + +"Clean up" and "Review & fix" are not fire-and-forget buttons. Running a doctor +opens an *output wall* — one lamp per action, streaming in realtime as each +runs: + +- *amber* while the action is running, +- *green* on success (with the reclaimed amount / result inline), +- *red* on failure. + +Feedback is always shown (not just when something was reclaimed) — you watch it +happen. This is the same live-results shape that should back the other doctors +(net, bluetooth), so every doctor in the system reads the same way. See the +todo task to retrofit the net + bt doctors to realtime lamp output. + +* Automation legend + +| Value | Meaning | +|----------+-----------------------------------------------------------------------------------------------------------| +| Auto | Doctor "Clean up" button — fire unattended, reversible or harmless | +|----------+-----------------------------------------------------------------------------------------------------------| +| Confirm | Doctor "Review & fix" — preview the change, act only on one click | +|----------+-----------------------------------------------------------------------------------------------------------| +| Human | Panel nudges; a human decides and acts (no button, not the agent) | +|----------+-----------------------------------------------------------------------------------------------------------| +| Workflow | vLater — metric shown read-only; no in-panel button. Agent-workflow assistance deferred (decided | +| | 2026-07-07, see below) | +|----------+-----------------------------------------------------------------------------------------------------------| +| None | Diagnostic only — no software remediation exists (hardware, telemetry) | +|----------+-----------------------------------------------------------------------------------------------------------| + +*Workflow buttons removed (decided 2026-07-07, prototyping E5).* The panel +carries no "run workflow" affordances — not on failed units, unclean boots, +updates, or CVEs. The metrics stay on the board as read-only telemetry; AI +assistance via the system-health-check workflow is a vLater feature. v1 ships +only determinate remedies (fixed, scriptable actions with predictable +outcomes). + +*Determinate remedies adopted (decided 2026-07-07).* Every remedy below is +Confirm-tier; contextual levers appear only when the metric is off-nominal: + +- /Service restarts & enables/: failed units (systemctl restart + reset-failed), + fail2ban, cronie, chronyd (+ makestep), tailscaled, snapper timers, zram + config re-apply. DNS/NetworkManager delegates to the net panel's doctor + (deep-link, not a duplicate repair chain). +- /Deterministic maintenance ops/: btrfs balance -dusage=50 (unallocated low), + reinstall owning packages on pacman -Qkk failures, rsyncshot RUN NOW (result + streams to the output well), smartctl -t short self-test, snapshot-retention + repair (write sane TIMELINE limits + cleanup — the /home lesson as a + one-press fix). btrfs device-error counter reset stays manual: resetting + without diagnosis masks a dying drive. +- /Composite macro/: RECLAIM SPACE on the disk-usage cell — runs every reclaim + lever (cache, journal, coredumps, app logs, docker tier-1, snapper cleanup) + as one output-wall stream. +- /Disruptive but determinate/: REBOOT behind arm-to-fire, offered when + running kernel != installed. +- /Still read-only/: temps, throttling, battery, memory/OOM, taint, journal + error content, kernel/hw events, listeners, unclean-shutdown rate — physical + or investigative; vLater AI territory. + +*Updates join the Confirm layer (decided 2026-07-07).* Two levers on the +updates strip, both behind the live-update guard (mesa/hyprland/wayland +runtime in the pending set): + +- UPDATE — repo + AUR system update. +- TOPGRADE — full ecosystem run. The panel's wrapper always passes + --disable git (topgrade's git step rebase-autostashes ~/code/*/ — never + under a live session). + +*MEM·PWR: evidence + two levers + expectation tags (decided 2026-07-07).* +Mostly physics, so the category leans watch-only — with these additions: + +- /CPU mode selector/: a free segmented control (PERF · BAL · POWER) writing + the EPP hint — set the active mode to anything, not drift-repair against a + declared default. amd-pstate on ratio, intel_pstate on velox. +- /Battery charge limit/ (velox-only, capability-gated): SET 80% writes + charge_control_end_threshold — the standard longevity cap. Battery *health* + (capacity vs design, cycles) stays watch-only hardware telemetry. +- /Evidence drill-downs/ (digest idiom): top-5 RAM consumers under memory, + recent boots listed clean/unclean under the unclean-rate, and throttle/OOM + events with timestamps. Evidence makes the numbers actionable even where no + button exists. +- /KILL on top-memory items/ (revised 2026-07-07 — Craig): arm-to-fire, four + guards: the arm shows the exact victim (name + size); SIGTERM not SIGKILL, + with the outcome reported to the wall; PID + process name revalidated at + fire time so a recycled PID can't be hit; session-critical names (systemd, + the compositor, the panel itself) render a disabled key — protected. A + SIGKILL escalation for TERM-survivors is vLater. +- /Expectation-setting, panel-wide/: every leverless cell's sub-line carries an + explicit tag ("hardware — watch only", "evidence below"), and each subpanel + header shows the split — "N fixable · M watch" — so the user knows their + agency before reading a single cell. + +*Refresh cadence (decided 2026-07-07).* Four tiers, matching probe cost: + +- /Live group, panel open/: temps, memory free + top consumers, throttle + state — re-read every ~3 s while their subpanel is visible, gated on + panel-open exactly like the audio panel's meters. Stop when hidden. +- /Fast local tier, panel open/: re-probed every ~30 s while the panel is up; + additionally, any metric re-probes immediately after an action that touches + it (fire CLEAN → cache re-measured, not assumed). +- /On open/: the hydration tiers re-run (fast reads first, process probes + behind them — sub-second perceived). +- /Network tier/: checkupdates / arch-audit / AUR / firmware stay on the + hourly systemd-timer cache with age shown; refreshed on demand only. +- /Panel closed/: the waybar glyph is fed by a light background scan every + ~30 min (systemd timer writing the state file the glyph reads) — the bar + stays honest without the panel running. + +*Journal errors get a digest, not a fix (decided 2026-07-07).* No generic +remedy exists — an error-priority line is a symptom of an arbitrary subsystem — +so the panel ships four determinate assists instead: + +- /Digest/: the cell expands to errors grouped by syslog identifier — count, + message snippet, first/last seen, and the exact next command (journalctl -u + <unit> -b) when the identifier maps to a unit. Top-10 groups, read-only. +- /MARK KNOWN with a full lifecycle/: arm-to-fire showing the exact pattern + before it stores; marked groups move to a dim KNOWN section (never vanish) + with per-row UNMARK; every mark/unmark logs to the results wall; marks carry + date + example. Patterns bind to identifier + message, never a whole unit — + a muted service's *new* errors still surface. +- /Two noise layers/: shipped defaults (bluetoothd HFP, pixman, xkbcomp) in + the packaged TOML, user marks in a separate user file merged over it (a + template sync never eats curation). CLEAR MARKS (arm-to-fire) empties the + user layer and re-enables shipped defaults; unmarking a shipped default + records a disable flag in the user layer. +- /OPEN JOURNAL/: launches a terminal running journalctl -p err -b — the same + delegation pattern as NET DOCTOR. + +Ruled out: auto-restarting units that log errors (error ≠ failed) and +keyword-driven fix suggestions (vLater AI territory). + +*Full-sweep findings — all committed to v1 (decided 2026-07-07).* Every metric +was audited against the converged checklist (honest label, evidence digest +where a count hides detail, curation lifecycle where "expected" is config +knowledge, guarded per-item remedies, cross-links, watch-only tags). Adopted, +all prototyped: + +- /Storage/: disk top-consumers digest (evidence only — no file deletion + keys); per-device error rows on RAID1 when counters are nonzero, + cross-checked against SMART; SMART sub-line carries the last self-test + result. Spec note: a real scrub runs hours — the ring needs a running-% + state, not an instant reset. +- /Packages/: orphan digest (name + size) with per-package REMOVE (armed) and + KEEP — the curation lifecycle encoding "intentional, not orphaned" (the + rust lesson); batch REMOVE ALL skips kept packages. Per-file pacnew rows + tagged safe-delete (reflector-managed) vs needs-merge, MERGE delegating to + a terminal diff. CVEs named: package · CVE id · severity. AUR and firmware + names spelled out. +- /systemd/: failed units upgraded from a count-lever to a per-unit roster + (name · since · exit code · journalctl hint) with per-row RESTART + RESET; + is-system-running names its cause ("degraded — N failed units below"); + taint letters decoded. +- /Logs/: coredumps grouped by binary (count · last · coredumpctl hint), + cleared with the CLEAR action; kernel/hw events listed when not clean + (hardware — watch only). +- /Services/: docker system df breakdown (images / containers / volumes / + build cache with per-type reclaimable); stopped containers upgraded to the + full signal/expected curation lifecycle (MARK EXPECTED / UNMARK, shipped + default: winvm) with per-container START; cron expected-entries drift + roster. +- /Snapshots/: count split by type — timeline (auto-pruned) · single + (manual — escapes timeline cleanup, the pile-up risk) · pre/post — with + oldest-single named and DELETE STALE (armed, keeps newest 2) when singles + accumulate. +- /Network minors/: fail2ban shows recent-ban count; NTP shows offset. + +Rationale for prototyping everything (Craig): real estate and complexity have +bitten before — surface those limits in the disposable prototype, not after +functionality exists behind the UI. + +*Vertical compression → rotary band selector (2026-07-07).* First attempt — +the MEM·PWR three-column layout on Packages/Logs/Services — lost too much row +detail to third-width truncation (Craig's verdict after use). Replaced by a +*rotary band selector*: the amplifier input-selector idiom. A machined knob +(click to cycle) whose needle swings to engraved band labels, one per evidence +section (ORPHANS · PACNEW · ADVISORIES; SIGNAL · KNOWN NOISE · COREDUMPS · +KERNEL/HW; CONTAINERS · DOCKER DISK · CRON & BACKUPS). Each band carries its +own status lamp (section health at a glance without switching) plus a count; +the selected band gets a gold underline and the needle. One section renders at +a time at full width, restoring complete row detail. Deliberately distinct +from the category tiles, console keys, and the CPU-mode segmented control — +each selection idiom in the panel now has its own visual voice. MEM·PWR keeps +its three-column evidence strip (short rows fit fine at third-width). + +*Listeners get the same treatment (decided 2026-07-07).* The count becomes +"unexpected listeners" — evidence digest (process · port · bind address from +ss -tlnp), expected-list curation with the full MARK EXPECTED / UNMARK / +CLEAR lifecycle (shipped defaults: sshd, mpd, tailscaled; user marks in the +user layer), and guarded per-socket remedies: STOP (systemctl stop, armed) +when a unit owns the socket, KILL (SIGTERM, armed) otherwise. Severity keys +on unexpected AND public-bind (0.0.0.0/::) — a loopback listener warns, an +exposed one fails — and when ufw is down the signal header names the exposure +("ufw down — N public binds exposed"). Stopped containers likewise gained a +contextual START lever (allowlist: winvm), and the firewall its ENABLE. + +*Updates strip border is state-tiered (decided 2026-07-07).* Green when +nothing pending, amber for ordinary pending/AUR/firmware counts, red when +CVEs exist or pending exceeds the "a lot" threshold (or the update cache has +gone stale — staleness window in the TOML). The CVE badge renders only when +the count is nonzero. + +*Guard arms instead of blocking (revised 2026-07-07).* When the guard trips, +the key arms (red, "press again to run anyway — or apply from a TTY") rather +than hard-refusing. The user decides; the footgun is acknowledged and +deliberately handed over. After a system update lands, the panel offers a +reboot: a REBOOT key (arm-to-fire) appears on the updates strip and the +reboot-required metric flips. + +*No per-ecosystem update metrics.* Topgrade's step set (yay, rustup, cargo, +pipx, npm/pnpm, gem, go, flatpak, fwupd, tmux/zsh/nvim plugins, git repos) has +no cheap offline "updates available?" probe — mirroring it means a network +round-trip per registry at panel-open. Instead: one *topgrade freshness* +metric (wrapper stamps last-run time; threshold in the TOML) whose remedy is +the TOPGRADE lever, plus a *firmware updates* count in the updates strip +(fwupd refreshes metadata on its own timer; the panel reads the cache). + +Rationale for the hard lines: system updates are Workflow, never Auto — the +2026-06-07 Hyprland crash was a live -Syu swapping mesa+hyprland under the +running session, and the standing rule is never -Syu live under Hyprland when +the mesa/hyprland/wayland runtime is in the set. Hardware findings (SMART, MCE, +thermal) are None — the fix is replacing a drive or clearing a fan, not +software. + +* Metrics — Storage & filesystem integrity + +| Metric | Fix / lever | Automation | Notes | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Package cache size | paccache -r / -ruk0 | Auto | Reclaim, all re-downloadable | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| paccache -rk1 (keep 1 version) | paccache -rk1 | Confirm | Frees most; kills downgrade | +| | | | headroom | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Disk usage (df) | — | None | Fix is cache/snapshot/prune | +| | | | levers | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Btrfs unallocated space | — | None | Chunk headroom; diagnostic | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Btrfs scrub age | btrfs scrub start | Confirm | GAP; ZFS has this, btrfs doesn't. | +| | | | IO-heavy, on-demand | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Btrfs device stats (per-drive | btrfs device stats --reset | None | GAP; RAID1 early-warning ahead of | +| error counters) | | | SMART; reset after review | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS pool health / errors | — | None | CRITICAL if state != ONLINE or | +| | | | errors > 0 | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS scrub age | zpool scrub | Confirm | Covered | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS capacity | — | None | Perf degrades > 80%; the ZFS | +| | | | headroom metric (no unallocated | +| | | | concept) | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS fragmentation (FRAG) | — | None | GAP; no defrag exists — the | +| | | | remedy is snapshot pruning + | +| | | | staying under 80% | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS autotrim (SSD TRIM) | zpool set autotrim=on / zpool | Confirm | GAP; velox; the fstrim.timer | +| | trim | | counterpart on ZFS | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| fstrim.timer enabled + firing | systemctl enable --now | Confirm | GAP; standard SSD hygiene | +| | fstrim.timer | | | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| pacman file integrity (Qkk) | reinstall package | Workflow | GAP; modified/missing files need | +| | | | judgment | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| SMART health | — | None | Hardware; replace drive | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| SMART wear / temperature | — | None | Diagnostic | +|----------------------------------+----------------------------------+------------+-----------------------------------| + +* Metrics — Snapshots + +| Metric | Fix / lever | Automation | Notes | +|--------------------------------+--------------------------+------------+---------------------------------------------| +| Snapper count / retention | snapper cleanup / delete | Confirm | Manual (single) snapshots need explicit | +| | | | choice | +|--------------------------------+--------------------------+------------+---------------------------------------------| +| ZFS snapshot count / retention | zfs destroy | Confirm | Runaway retention | +|--------------------------------+--------------------------+------------+---------------------------------------------| +| Snapshot auto-timer running | systemctl enable timer | Confirm | Is the auto-snapshot service firing | +|--------------------------------+--------------------------+------------+---------------------------------------------| + +* Metrics — Packages & security + +| Metric | Fix / lever | Automation | Notes | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| Orphaned packages | pacman -Rns (named args) | Confirm | Review first (rust looked orphaned, | +| | | | was intentional) | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| Pending updates count | — | Workflow | Never auto; workflow-only | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| .pacnew files | diff + merge / delete | Confirm | Allowlist auto-deletes mirrorlist / | +| | | | locale.gen | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| arch-audit CVEs | reviewed update | Workflow | GAP; top-priority add. Fix is an | +| | | | update | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| Keyring freshness | pacman -Sy archlinux-keyring | Confirm | GAP; stale keyring breaks update | +| | | | signatures | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| AUR / foreign staleness (Qm) | paru -Sua | Workflow | GAP; AUR updates reviewed, not auto | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| Reboot required (kernel mismatch) | reboot | Human | GAP; uname -r vs /usr/lib/modules. | +| | | | You pick when | +|-----------------------------------+------------------------------+------------+--------------------------------------| + +* Metrics — systemd & boot + +| Metric | Fix / lever | Automation | Notes | +|--------------------------------+------------------------+------------+-----------------------------------------------| +| is-system-running (degraded?) | — | None | GAP; one-token whole-system verdict; | +| | | | candidate for the bar glyph | +|--------------------------------+------------------------+------------+-----------------------------------------------| +| Failed systemd units | restart / investigate | Workflow | Depends why it failed | +|--------------------------------+------------------------+------------+-----------------------------------------------| +| Maintenance timers enabled + | systemctl enable timer | Confirm | GAP; meta-metric — are | +| firing | | | paccache/btrfs-scrub/fstrim/reflector/snapper | +| | | | firing | +|--------------------------------+------------------------+------------+-----------------------------------------------| +| Kernel taint flag | — | None | GAP; tainted != 0 = proprietary module or | +| | | | prior crash | +|--------------------------------+------------------------+------------+-----------------------------------------------| + +* Metrics — Logs & coredumps + +| Metric | Fix / lever | Automation | Notes | +|------------------------------------+---------------------------------+------------+----------------------------------| +| Coredump count | coredumpctl clean (keep recent) | Auto | Keep last few days for forensics | +|------------------------------------+---------------------------------+------------+----------------------------------| +| Journald disk usage | journalctl --vacuum-size/time | Auto | Bounded journal | +|------------------------------------+---------------------------------+------------+----------------------------------| +| App-log cleanup (>7d) | log-cleanup cron trigger | Auto | Already a cron; manual trigger | +|------------------------------------+---------------------------------+------------+----------------------------------| +| Journal error count (real vs | — | Workflow | Forensic; noise-filtered count | +| noise) | | | | +|------------------------------------+---------------------------------+------------+----------------------------------| +| Kernel/hardware events | — | None | Forensic, hardware | +| (MCE/USB/thermal/GPU) | | | | +|------------------------------------+---------------------------------+------------+----------------------------------| + +* Metrics — Memory, thermal, power + +| Metric | Fix / lever | Automation | Notes | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Memory free / OOM kills | — | Workflow | OOM = investigate | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Swap / zram present + healthy | — | None | Diagnostic | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| CPU / GPU temperatures | — | None | Hardware | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Thermal throttling active | — | None | Cooling issue | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Battery health (capacity / cycles) | — | None | GAP; laptop; ties to open suspend todo | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Unclean-shutdown / suspend-failure rate | — | Workflow | GAP; ratio flagged ~75% unclean 2026-06-08 | +|-----------------------------------------+-------------+------------+--------------------------------------------| + +* Metrics — Network & security posture + +| Metric | Fix / lever | Automation | Notes | +|-----------------------------------+---------------------------+------------+-----------------------| +| DNS / NetworkManager reachability | restart NM | Workflow | CRITICAL if down | +|-----------------------------------+---------------------------+------------+-----------------------| +| Firewall active (ufw / nftables) | ufw enable | Confirm | GAP; security posture | +|-----------------------------------+---------------------------+------------+-----------------------| +| Unexpected listeners (ss -tlnp) | — | None | GAP; security review | +|-----------------------------------+---------------------------+------------+-----------------------| +| Tailscale peers | tailscale up | Confirm | Covered | +|-----------------------------------+---------------------------+------------+-----------------------| +| fail2ban running + bans | systemctl start | Confirm | Covered | +|-----------------------------------+---------------------------+------------+-----------------------| +| NTP sync (chrony) | systemctl restart chronyd | Confirm | Covered | +|-----------------------------------+---------------------------+------------+-----------------------| + +* Metrics — Services, backups, virt + +| Metric | Fix / lever | Automation | Notes | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| rsyncshot backup freshness | — | Workflow | CRITICAL if daily > 48h; investigate | +| | | | failure | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| Docker/podman reclaimable | prune tier 1 / tiers 2-3 | Confirm | Tier 1 nearly Auto; 2-3 destructive | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| Docker stopped containers | — | None | Mostly expected (WinVM on-demand) | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| libvirt VM state | — | None | Expected off | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| Cron running + expected entries | systemctl enable cronie | Confirm | rsyncshot + log-cleanup entries | +|---------------------------------+--------------------------+------------+--------------------------------------------| + +* Architecture & testing (decided 2026-07-07) + +*CLI-first, GUI as a face.* The console ships as the fourth panel sibling: a +=maint= Python package in dotfiles (like =net/=, =bt/=, =audio/=) with probe +modules (read-only collectors), a remedies module, =cli.py=, and =gui.py= +driving the same code. =maint status --json= is the contract; =maint fix +<thing>= is every lever. The GUI never does anything the CLI can't. + +Safety mechanics baked into the CLI: +- global =--dry-run= prints the exact command instead of executing — free test + surface, and the GUI's arm-press can display it ("this will run: …"). +- hard read/write split: collectors never elevate; every remedy is an + allowlisted exact argv in one small auditable module. + +*Four test layers (safest → scariest):* + +1. /Unit, fake binaries — no VM, no root (~90% of surface)./ Probes are + parsers over command output: feed canned smartctl/btrfs/journalctl/pacman/ + ss/docker output via fakes on PATH (the net suite's fake-curl and audio's + fake-parec pattern). Remedies tested as command construction (assert the + argv, don't run it). The live-update guard is a pure function over a + package list. +2. /Read-only integration on the live machine./ All collectors are read-only + by design — =maint status --json= runs safely against real hosts. +3. /Remedies in a VM — archsetup's existing harness./ + =scripts/testing/run-test.sh= boots the installer VM; a maint scenario + breaks things deliberately over ssh (stop cronie, mask fstrim, orphan + packages, fill the cache), runs =maint fix …=, asserts post-state. No GUI + in the VM. Add qcow2 snapshot/restore between remedy tests so each starts + pristine and destructive remedies can't contaminate each other. Pure + pacman-level tests may use a throwaway systemd-nspawn container instead + (lighter); the VM stays for systemd/btrfs/reboot territory. +4. /GUI on the host, never in the VM./ AT-SPI smoke like the sibling panels, + driven by fixture data. The prototype's GOOD/BAD snapshots become those + fixtures — =MAINT_PANEL_FIXTURE=bad= renders the degraded board without a + degraded machine, conforming to the =maint status --json= schema. + +* Open questions + +- RESOLVED 2026-07-06 — live output wall (amber running / green done / red fail), + realtime, always shown. See "Doctor = live output wall" above. +- RESOLVED 2026-07-06 — velox is a first-class v1 target alongside ratio. See + "Scope: hosts" below. +- RESOLVED 2026-07-06 — single machine-readable thresholds file + (=maintenance-thresholds.toml=) is the source of truth, *owned by archsetup*. + Both the console and the system-health-check workflow read it, so they can + never drift. See "Thresholds source of truth" and "Workflow ownership" below. diff --git a/docs/design/system-monitor-design-ideas.org b/docs/design/system-monitor-design-ideas.org new file mode 100644 index 0000000..26619a0 --- /dev/null +++ b/docs/design/system-monitor-design-ideas.org @@ -0,0 +1,1008 @@ +#+TITLE: System Monitor Design Ideas +#+DATE: 2026-07-04 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Status +:PROPERTIES: +:ID: system-monitor-design-ideas +:END: +- [2026-07-04 Sat] DRAFT — initial design sketch for a health monitor covering + Archangel ISO/base-install health, Archsetup workstation health, and the local + laptop as the daily canary. + +* Metadata + +| Field | Value | +|--------+--------------------------------------------| +| Status | draft | +|--------+--------------------------------------------| +| Owner | Craig Jennings | +|--------+--------------------------------------------| +| Repos | archsetup, archangel, dotfiles | +|--------+--------------------------------------------| +| Kin | net panel, bluetooth panel, audio panel | +|--------+--------------------------------------------| + +* Problem + +Archangel and Archsetup can fail in ways that are individually obvious only +after the damage is done: an ISO build goes stale against Arch or archzfs, +ZFSBootMenu or GRUB boots once but not after the first upgrade, a snapshot +hook silently disappears, a package database ages out, systemd services fail +after a reboot, or the desktop contract is technically installed but not +usable. + +The health surface should compress those risks into one operational question: +"Can I trust a fresh install, and is this current workstation drifting away +from the known-good install contract?" + +This monitor is not a generic CPU/RAM graph. It is an install-health and +workstation-contract console. CPU, memory, and temperature belong only as +secondary context unless they block install/test operations. + +* Priority Model + +Rank metrics by the cost of blindness: what happens if Craig never sees the +metric, no one mitigates it, and the next install/upgrade/reboot simply happens. + +Severity: + +- =P0= — can cause data loss, unbootable systems, or loss of rollback path. +- =P1= — can break fresh installs, upgrades, remote access, or core + workstation use. +- =P2= — causes degraded workstation behavior, security drift, or accumulating + maintenance debt. +- =P3= — useful context, not a release gate by itself. + +The panel should sort by live severity first, then by this priority. A red =P2= +row appears above a green =P0= row, but in the steady state the layout keeps the +P0/P1 rows in the first viewport. + +* Priority Ranking + +| Rank | Priority | Metric | Why this rank exists | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 1 | P0 | Storage health | Silent pool/filesystem degradation is the nearest thing to data loss. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 2 | P0 | Snapshot safety coverage | Without snapshots, upgrades lose their rollback safety net. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 3 | P0 | Bootloader and EFI redundancy | A machine that cannot boot is operationally dead. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 4 | P1 | First-upgrade bootability | Catches the classic "installed fine, broke after update" failure. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 5 | P1 | End-to-end VM install pass rate | Best release gate for the whole Archangel + Archsetup chain. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 6 | P1 | Package sync and repo freshness | Arch, archzfs, keyring, and mirror drift are leading break signals. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 7 | P1 | Archangel ISO reproducibility | If current inputs cannot build an ISO, recovery/install confidence is stale. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 8 | P1 | Post-install service health | Network, DNS, SSH, and user services decide whether the system is usable. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 9 | P1 | Archsetup state/log cleanliness | Prevents "half-installed but looks fine" machines. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 10 | P2 | Workstation contract checks | Confirms this is Craig's workstation, not just generic Arch. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 11 | P2 | Backup and rollback readiness | Catches loss of off-machine recovery and edited-file backups. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 12 | P2 | Security and hardening drift | Important, but usually less immediately destructive than boot/storage. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| + +This order intentionally puts ZFS/Btrfs health above VM install evidence. A +broken future install is expensive; silent damage to the current root or backup +chain is worse. + +* Consequence Matrix + +This is the design justification for every metric. A row earns panel space only +if blindness has a clear failure mode and Doctor has at least a useful +diagnostic or mitigation. + +| Rank | Metric | If never seen / never mitigated | Typical failure | Worst plausible failure | Doctor posture | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 1 | Storage health | Disk, pool, or metadata degradation accumulates silently. | Correctable ZFS/Btrfs errors, low EFI/root space, stale scrub. | Data loss, degraded root, failed import/mount during boot. | Diagnose + scrub/cleanup with confirmation. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 2 | Snapshot safety coverage | Upgrades proceed without a rollback point. | Missing recent snapshot or missing pre-pacman hook. | Bad upgrade cannot be rolled back cleanly; manual repair required. | Create snapshot; restore hook; never rollback. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 3 | Bootloader and EFI redundancy | Boot path rots until the next reboot or disk failure. | Missing GRUB/ZBM file on one EFI partition. | Unbootable machine after update, firmware reset, or disk loss. | Diagnose; regenerate config; advanced reinstall only. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 4 | First-upgrade bootability | Installs look good until the first real system update. | Kernel/initramfs/bootloader mismatch in VM. | Fresh bare-metal install dies on first reboot after upgrade. | VM-only upgrade test; collect boot evidence. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 5 | End-to-end VM install pass rate | Unit tests give false confidence about the real workflow. | Current branch fails one filesystem path or desktop assertion. | Bare-metal install fails mid-flight after disks are wiped. | Run/schedule VM test; clean stale VM artifacts. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 6 | Package sync/repo freshness | Arch/keyring/archzfs drift surprises the next install. | 404s, stale keyring, bad mirror, stale archzfs DB. | Installer cannot pacstrap or installs mismatched ZFS/kernel bits. | Refresh DB; update keyring; reflector. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 7 | ISO build reproducibility | Recovery/install media confidence becomes historical. | AUR package fails to build; mkarchiso or DKMS breaks. | Need rescue/install media and discover no current ISO can be built. | Parse logs; clean work; explicit rebuild. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 8 | Post-install service health | System is installed but degraded in daily operation. | DNS, NetworkManager, fail2ban, tailscale, or user service down. | No remote access, no network, broken sync, or security tooling off. | Restart/re-enable classified services only. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 9 | Archsetup state/log cleanliness | Half-completed provisioning masquerades as success. | Missing marker, log error, skipped step after resume. | Fresh workstation lacks critical config but looks mostly usable. | Summarize; rerun resumable archsetup. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 10 | Workstation contract checks | The box drifts from "Craig's workstation" to generic Arch. | Dotfile symlink broken, keyring wrong, missing tool/package. | Desktop/session workflow is broken during real work. | Restow, repair perms, reinstall with confirm. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 11 | Backup and rollback readiness | Recovery assumptions go stale. | Missing =.archsetup.bak=, backup timer stale, dry-run fails. | Local rollback works but important personal/system state is gone. | Dry-run, start configured job, no deletes. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 12 | Security and hardening drift | Small protective edits quietly regress. | SSH/firewall/fail2ban/sysctl/EFI mask drift. | Exposed service or weakened local boot/config protections. | Restore owned snippets; no broad rewrite. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| + +The worst cases are intentionally conservative, not dramatic. The monitor is +useful because it catches the boring early signal: stale, missing, not recently +tested, or silently degraded. + +* Product Shape + +Use the existing instrument-console panel language rather than a dashboard page: +lamps for pass/warn/fail state, engraved metric groups, dense rows, physical +console keys for explicit actions, and an output well for the last diagnosis. + +The bar module should be tiny: + +- icon: a pulse/terminal glyph or compact =SYS= label. +- state lamp: green/yellow/red/grey. +- text: one word only: =OK=, =WARN=, =FAIL=, =STALE=, =RUNNING=, =UNKNOWN=. +- click: opens the system monitor panel. +- middle click or secondary action: run a cheap refresh only, never repairs. + +The panel should be one screen with internal scroll only where needed. The +first viewport should show the decision state and the Doctor controls without +scrolling. + +* Layout + +** Faceplate + +Top row: + +- State lamp + state word: =OK= / =WARN= / =FAIL= / =STALE= / =RUNNING= / + =UNKNOWN=. +- Unit label: =SYS·01=. +- Scope segmented control: =HOST= / =INSTALL= / =BUILD=. +- Badges: =ZFS= or =BTRFS=, =VM STALE=, =DB STALE=, =SNAPSHOT=, =BACKUP=, + =ROOT= when elevated actions are available. +- Close button. + +The scope control changes the metric emphasis, not the underlying data model: + +- =HOST= is this laptop/workstation right now. +- =INSTALL= is the last Archangel+Archsetup VM install result. +- =BUILD= is ISO, AUR repo, archzfs, and test artifact health. + +** Health Stack + +Arrange metrics as four horizontal bands. Each band has a section title, a +summary lamp, two to four row lamps, and a short "age" or "count" value. + +1. =BOOT + STORAGE= + - bootloader + - EFI redundancy + - pool/filesystem + - snapshots + +2. =INSTALL PIPELINE= + - first-upgrade reboot + - ZFS VM install + - Btrfs VM install + - ISO build + +3. =PACKAGES + SERVICES= + - pacman sync freshness + - archzfs/AUR health + - failed services + - journal errors + +4. =WORKSTATION CONTRACT= + - Archsetup markers/logs + - user/dotfiles + - desktop/session + - backups/replication + +Each row is clickable. Clicking a row opens the evidence drawer in the output +well with: + +- last command run +- normalized verdict +- raw excerpt, redacted where needed +- suggested Doctor action, if any + +** Console Keys + +Use physical console-key buttons, same family as net/bt: + +| Key | Purpose | +|--------------+------------------------------------------------------------| +| REFRESH | Cheap read-only probe of host state | +|--------------+------------------------------------------------------------| +| DOCTOR | Diagnose, classify, run safe mitigations, re-check | +|--------------+------------------------------------------------------------| +| TEST VM | Run or schedule Archsetup VM validation | +|--------------+------------------------------------------------------------| +| BUILD ISO | Run or schedule Archangel ISO build | +|--------------+------------------------------------------------------------| +| CLEAN | Clean old test artifacts, package cache, stale logs | +|--------------+------------------------------------------------------------| +| SNAPSHOT | Create a manual pre-change snapshot | +|--------------+------------------------------------------------------------| + +Keys that can take a long time stream progress into the output well. Mutating +keys must use arm-first behavior: + +- first click arms for 3 seconds and explains the action. +- second click runs. +- destructive cleanup names what will be deleted before it runs. + +* Metric Details + +Details below are grouped by workflow rather than priority. The authoritative +importance order is the priority table above. + +** 1. End-to-end VM install pass rate =P1/rank 5= + +Problem overcome: unit tests can pass while the real install is broken by +mirrors, bootloader state, pacstrap, SSH, disk layout, or the actual desktop +contract. This metric fights false confidence. + +Representation: + +- Lamp row in =INSTALL PIPELINE=. +- Two child lamps: =ZFS= and =BTRFS=. +- Age chip: =last pass 2d= / =never= / =stale 14d=. +- Red if either required filesystem has no recent pass. + +Tools: + +- =scripts/testing/run-test.sh= +- =scripts/testing/create-base-vm.sh= +- =pytest= testinfra suite under =scripts/testing/tests/= +- =qemu-img=, =qemu-system-x86_64=, =sshpass= + +Doctor: + +- read-only first: summarize last =test-results/*/test-report.txt= and failing + test names. +- mitigation: offer =TEST VM= for the failed filesystem. +- cleanup: remove stale temporary VM overlays before retrying. +- no automatic retry loop if the failure is in the installer itself. + +** 2. First-upgrade bootability =P1/rank 4= + +Problem overcome: the machine can boot immediately after install but fail after +the first =pacman -Syu= because initramfs hooks, ZFS modules, GRUB, ZFSBootMenu, +or kernel packages drift. + +Representation: + +- Lamp row: =first-upgrade reboot=. +- Badge: =not run=, =passed=, =failed=. +- Evidence drawer includes boot count, kernel version, and last reachable SSH + timestamp. + +Tools: + +- VM harness +- =pacman -Syu= +- =reboot= +- =ssh= reachability checks +- =journalctl -b -1= where available + +Doctor: + +- run upgrade-in-VM only, never on host without explicit confirmation. +- if failure is ZFS, collect =zpool import=, =lsinitcpio=, =mkinitcpio.conf=, + and EFI files. +- if failure is Btrfs, collect =grub.cfg=, =crypttab=, =fstab=, and snapper + config. + +** 3. Package database freshness and sync health =P1/rank 6= + +Problem overcome: Arch rolling-release state changes faster than installer +assumptions. Stale sync databases, stale keyrings, archzfs drift, or broken +mirrors are leading indicators of a failing install. + +Representation: + +- Row lamp in =PACKAGES + SERVICES=. +- Small meter: newest sync DB age vs threshold. +- Child chips: =core=, =extra=, =multilib=, =archzfs=. +- Yellow over 48 hours, red over 7 days or failed sync. + +Tools: + +- =find /var/lib/pacman/sync= +- =pacman -Syyu --needed archlinux-keyring= +- =checkupdates= +- =reflector= +- =pacman-conf= + +Doctor: + +- safe: refresh package databases. +- safe: update =archlinux-keyring= before full upgrades. +- mitigation: run =reflector= with the configured country/age policy. +- no unattended full system upgrade from the panel unless separately approved. + +** 4. Archangel ISO build reproducibility =P1/rank 7= + +Problem overcome: an old "good" ISO can hide broken current inputs. Archiso, +archzfs, DKMS, AUR package recipes, and pacoloco cache state can all break the +next install. + +Representation: + +- Row lamp in =INSTALL PIPELINE= or =BUILD= scope. +- Shows latest ISO date, kernel version, and AUR manifest age. +- Red if latest build failed or no ISO exists. +- Yellow if latest successful ISO is older than the configured freshness + window. + +Tools: + +- =make build= in =~/code/archangel= +- =build.sh --skip-aur= for fast non-AUR iteration +- =build-aur.sh= +- =mkarchiso= +- =pacoloco= status if installed + +Doctor: + +- read-only: parse latest =out/*.log= for pacman, DKMS, archzfs, AUR, and + mkarchiso failures. +- cleanup: safe build-work cleanup only through Archangel's cleanup function + or =make clean=. +- mitigation: suggest =--skip-aur= when the failure is unrelated to baked AUR. +- build retry is explicit via =BUILD ISO=, not automatic. + +** 5. ZFS/Btrfs storage health =P0/rank 1= + +Problem overcome: the root filesystem can degrade silently before the user +notices. For ZFS this means pool errors or degraded vdevs; for Btrfs this means +device stats, scrub failures, metadata pressure, or degraded RAID. + +Representation: + +- =BOOT + STORAGE= band. +- Filesystem-specific lamp grammar: + - ZFS green: =zpool status -x= healthy. + - Btrfs green: device stats clean and recent scrub clean. +- Capacity strip for root/home/EFI. + +Tools: + +- ZFS: =zpool status -x=, =zpool list=, =zfs list=. +- Btrfs: =btrfs device stats=, =btrfs filesystem usage=, + =btrfs scrub status=. +- Common: =df -h=, =findmnt=, =lsblk=. + +Doctor: + +- safe: start a scrub only with arm-first confirmation. +- safe: clear stale Btrfs stats only after a clean scrub and explicit + confirmation. +- mitigation: warn on low EFI/root space and offer package cache cleanup. +- never destroy snapshots, pools, subvolumes, or datasets from Doctor. + +** 6. Snapshot safety coverage =P0/rank 2= + +Problem overcome: rollback safety is assumed during upgrades but can disappear +when hooks, services, or snapshot tools drift. + +Representation: + +- Row lamp: =snapshots=. +- Child chips: =genesis=, =pre-pacman=, =recent=, =pruned=. +- Yellow if no recent snapshot. +- Red if genesis or pre-transaction hook is missing. + +Tools: + +- ZFS: =zfs list -t snapshot=, =zfs-pre-snapshot=, + =/etc/pacman.d/hooks/zfs-snapshot.hook=. +- Btrfs: =snapper list=, =snap-pac=, =grub-btrfs-mkconfig=, + =/.snapshots=. +- Common: =pacman -Q= for snapshot packages. + +Doctor: + +- safe: create a manual snapshot. +- safe: reinstall or re-enable missing hook only if the expected script exists. +- cleanup: prune only snapshots matching the tool-owned policy and prefix. +- mitigation: show exact command for manual rollback; do not perform rollback + from the panel. + +** 7. Bootloader and EFI redundancy =P0/rank 3= + +Problem overcome: single-disk bootloader success can mask missing redundant EFI +installs on multi-disk systems. A system can also pass install but lose a boot +entry or generate an invalid config. + +Representation: + +- Row lamp: =bootloader=. +- Child chips: =ZBM= or =GRUB=, =EFI=, =entries=, =all disks=. +- Yellow if redundancy cannot be proven. +- Red if the expected loader/config is missing. + +Tools: + +- =bootctl status= +- =efibootmgr -v= +- =findmnt /efi /boot= +- ZFS: check =/efi/EFI/ZBM/zfsbootmenu.efi=. +- Btrfs: check =/boot/grub/grub.cfg= and grub-btrfs entries. + +Doctor: + +- read-only by default. +- mitigation: regenerate GRUB config for Btrfs with arm-first confirmation. +- mitigation: rebuild initramfs with arm-first confirmation. +- no automatic EFI reinstall without an explicit advanced flow. + +** 8. Post-install service health =P1/rank 8= + +Problem overcome: the install can complete while the real workstation is +degraded: DNS broken, NetworkManager failed, fail2ban not responding, user +services not lingering, or Docker/Tailscale/Syncthing not in their expected +state. + +Representation: + +- =PACKAGES + SERVICES= band. +- Count badge: =0 failed= or =3 failed=. +- Child lamps: =network=, =dns=, =security=, =user services=. + +Tools: + +- =systemctl --failed= +- =systemctl is-enabled/is-active= +- =resolvectl status= +- =nmcli general status= +- =fail2ban-client status= +- =loginctl show-user= + +Doctor: + +- safe: restart known flaky non-destructive services such as + =NetworkManager= only after classifying the failure. +- safe: re-enable expected services from Archsetup's contract. +- mitigation: bounce DNS resolver and re-check. +- no blanket =systemctl restart --failed=. + +** 9. Archsetup state and log cleanliness =P1/rank 9= + +Problem overcome: a resumable installer can leave a half-finished system that +looks usable until a missing marker or skipped step matters later. + +Representation: + +- =WORKSTATION CONTRACT= band. +- Step-progress mini bar: completed markers / expected markers. +- Red if =archsetup --status= reports incomplete required steps. +- Red if latest log contains fatal errors. + +Tools: + +- =./archsetup --status= +- =/var/log/archsetup-*.log= +- marker files from the Archsetup state directory +- existing testinfra assertions in =scripts/testing/tests/test_archsetup.py= + +Doctor: + +- read-only: summarize incomplete steps and latest log errors. +- mitigation: offer to rerun =archsetup= in normal resumable mode. +- cleanup: archive old logs, keep the latest N. +- never run =--fresh= from Doctor. + +** 10. Workstation contract checks =P2/rank 10= + +Problem overcome: a fresh Arch system is not the goal. The goal is Craig's +working machine: user, shell, groups, dotfiles, Emacs, Hyprland/DWM, keyring, +VPN tools, Bluetooth tools, and local scripts. + +Representation: + +- =WORKSTATION CONTRACT= band. +- Child lamps: =user=, =dotfiles=, =desktop=, =tools=. +- Evidence drawer mirrors the testinfra checks. + +Tools: + +- =id=, =getent passwd= +- =test -L ~/.zshrc= +- =stow= via dotfiles Makefile +- =pacman -Q=, =yay -Qi yay= +- =hyprctl=, =gdbus= portal checks when session is running + +Doctor: + +- safe: restow dotfiles with the selected profile. +- safe: repair keyring directory permissions. +- mitigation: reinstall missing official packages. +- AUR package rebuilds require confirmation and stream output. + +** 11. Security and hardening drift =P2/rank 12= + +Problem overcome: security settings are easy to regress because they are small +file edits: SSH root login, EFI mount masks, firewall, issue banner, fail2ban, +quiet printk. + +Representation: + +- Compact row under =WORKSTATION CONTRACT= or =PACKAGES + SERVICES=. +- Red only for high-risk drift, yellow for unknown/unreadable state. + +Tools: + +- =sshd -T= or config file checks +- =ufw status= +- =fail2ban-client status= +- =findmnt /efi= +- =sysctl kernel.printk= + +Doctor: + +- safe: restore known Archsetup-owned config snippets. +- safe: re-enable firewall if policy file is present. +- mitigation: write missing drop-ins only from version-controlled templates. +- no broad hardening rewrite from panel state. + +** 12. Backup and rollback readiness =P2/rank 11= + +Problem overcome: rollback only helps local state. The install also needs +backups of edited system files and confidence that personal data replication is +not silently stale. + +Representation: + +- Row lamp: =backups=. +- Chips: =system-file .bak=, =replication=, =last run=. +- Yellow if last replication exceeds policy. +- Red if expected backup files for edited system config are missing. + +Tools: + +- Archsetup backup assertions in =scripts/testing/tests/test_backups.py=. +- =zfs-replicate= if configured. +- =systemctl list-timers= for backup timers. +- =journalctl -u= relevant backup units. + +Doctor: + +- safe: create missing =.archsetup.bak= for files before editing. +- safe: run dry-run replication check. +- mitigation: start a configured backup timer/unit with confirmation. +- never delete backup targets from Doctor. + +* Doctor Model + +Doctor is a classifier with bounded mitigations, not a magic repair button. + +Flow: + +1. Probe the selected scope. +2. Normalize each metric to =ok=, =warn=, =fail=, =unknown=, or =running=. +3. Classify failures as: + - =safe-fix= — local, reversible, low risk. + - =safe-cleanup= — removes only known generated artifacts. + - =mitigation= — improves the chance of success but does not claim repair. + - =needs-confirmation= — mutating, long-running, or system-wide. + - =manual= — too dangerous or context-heavy for Doctor. +4. Run only safe actions automatically after the user presses Doctor. +5. Arm-first for anything mutating beyond safe local cleanup. +6. Re-run the affected probe. +7. Stream a verdict into the output well. + +Doctor should say exactly what it did: + +#+BEGIN_EXAMPLE +doctor: package db stale + check: core.db age 4d, archzfs.db age 4d + action: refreshed sync databases + action: updated archlinux-keyring + result: ok, newest db age 2m +#+END_EXAMPLE + +* Common Tool Drivers + +** Host probes + +| Area | Commands | +|------------+----------------------------------------------------------------| +| systemd | =systemctl --failed=, =systemctl is-active=, =journalctl= | +|------------+----------------------------------------------------------------| +| packages | =pacman=, =checkupdates=, =pacman-conf=, =yay= | +|------------+----------------------------------------------------------------| +| storage | =zpool=, =zfs=, =btrfs=, =df=, =findmnt=, =lsblk= | +|------------+----------------------------------------------------------------| +| boot | =bootctl=, =efibootmgr=, =mkinitcpio=, =grub-mkconfig= | +|------------+----------------------------------------------------------------| +| network | =nmcli=, =resolvectl=, =ping= or HTTPS probe | +|------------+----------------------------------------------------------------| +| desktop | =hyprctl=, =gdbus=, =loginctl=, dotfiles Makefile | +|------------+----------------------------------------------------------------| + +** Project probes + +| Area | Commands | +|------------+----------------------------------------------------------------| +| archangel | =make test=, =make build=, =build.sh --skip-aur= | +|------------+----------------------------------------------------------------| +| archsetup | =make test-unit=, =make test=, =scripts/testing/run-test.sh= | +|------------+----------------------------------------------------------------| +| VM | =qemu-img=, =qemu-system-x86_64=, =sshpass=, =pytest= | +|------------+----------------------------------------------------------------| +| artifacts | latest =out/*.log=, =out/*aur-manifest.tsv=, =test-results/*= | +|------------+----------------------------------------------------------------| + +* Top-family Comparison + +This monitor should borrow the mature display ideas from =top=-style tools +without becoming another CPU/process viewer. The domain objects are install +contracts, boot/storage health, package freshness, snapshots, services, and +artifacts. The interaction model is still the same: sort the thing that hurts, +filter to the thing you care about, expand one row for evidence, and act only +when the diagnosis is clear. + +** Comparison table + +| Tool | What it represents well | Sorting/filtering model | Useful pattern for system monitor | Gaps for our domain | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =htop= | Dense live table plus configurable meters; process tree; direct process actions. | Interactive sort by column, search, filter, tree toggle. | Metric table should support column sort, search, filter, and tree/group mode. | No historical artifact model; actions are process-centric. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =btop++= | Boxed dashboard: CPU, memory, disks, network, processes, battery, GPU; strong graph language; selected process detail. | Easy switching between process sort modes; filter; tree view; pause. | Use boxed bands, mini time-series, detail pane, pause/freeze, and clickable controls. | Graph-first layout can overemphasize volatile values over install risk. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =bottom/btm= | Custom widget layout, per-widget focus/expand, zoomable time windows, basic mode. | Process widget supports sort, search, tree; widgets can be filtered/configured. | Every health band should be expandable; stale/history windows should be zoomable. | Mostly resource telemetry, not remediation workflow. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =atop= | Interval deltas, critical-resource highlighting, all active processes including exited ones, long-term logs. | Resource views and interval replay; emphasizes deviations and active load. | Add history/replay for health events and show "new since last good" changes. | Lower immediate visual polish; Linux-performance scoped. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =Glances= | Broad plugin dashboard, thresholds, remote/web/API modes, export to JSON/CSV/time-series backends. | Configurable visible plugins; API/stdout selectors instead of only interactive sorting. | Use plugin architecture, threshold config, JSON output, remote/headless mode. | Too broad; can become a generic monitoring surface. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =procs= | Modern table ergonomics: custom columns, keyword search across selected fields, sort by named column, tree view. | CLI sort asc/desc by partial column name; watch mode cycles sort columns; AND/OR/NAND/NOR search. | Use named metric columns, saved views, multi-keyword filters, and value-aware coloring. | Process-only; no graphs or remediation model. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =gotop/gtop= family | Fast glanceable terminal dashboard with compact graphs and gauges. | Usually lighter than htop/btop; less important than presentation density. | Use compact sparklines/gauges for "age", "last pass", and "failure count". | Not enough evidence/action depth for this monitor. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| + +** What to pull in + +*** htop: table discipline + +Pull: + +- Column headers that are real controls: click or key-cycle to sort by + =priority=, =state=, =age=, =last_checked=, =last_pass=, =failure_count=, + =scope=, and =doctor_class=. +- Search and filter as first-class actions, not hidden debug commands. +- Tree mode for ownership: + - =system= → =boot/storage= → =bootloader= → =efi entries=. + - =archangel= → =iso build= → =aur repo= → =manifest rows=. + - =archsetup= → =state markers= → =desktop= → =dotfiles=. +- Horizontal detail access for long evidence, like htop's horizontal scrolling + for full commands. + +Equivalent-or-better requirement: + +- htop sorts processes; this monitor sorts risk. The default sort is + live-state severity, then priority rank, then age. + +*** btop++: instrument boxes and live graphs + +Pull: + +- Boxed bands with stable geometry. +- Small time-series graphs, but only where history matters: + - package DB age over time + - failed-service count + - journal error count + - snapshot count / newest snapshot age + - VM pass/fail history + - ISO build duration/result history +- Selected-row detail pane with the last command, verdict, and raw excerpt. +- Pause/freeze button so a failure does not scroll away while reading. +- Mouse-clickable controls where every visible key has the same keyboard path. + +Equivalent-or-better requirement: + +- btop's graphs answer "what is hot right now?" Our graphs answer "is the + safety margin shrinking?" Trend charts should be muted unless the threshold + is crossed. + +*** bottom: focus/expand and layout presets + +Pull: + +- Expand one band full-height: + - =BOOT + STORAGE= expands into boot files, EFI entries, pools, snapshots. + - =INSTALL PIPELINE= expands into last VM runs and build artifacts. + - =PACKAGES + SERVICES= expands into DB ages, repo status, failed units. + - =WORKSTATION CONTRACT= expands into Archsetup markers and testinfra-style + checks. +- Zoomable history windows: 24h / 7d / 30d / all artifacts. +- Layout presets: + - =compact= for bar dropdown. + - =full= for terminal/TUI. + - =host-only= for laptop health. + - =release-gate= for Archangel/Archsetup changes. + +Equivalent-or-better requirement: + +- bottom expands widgets; this monitor expands evidence and remediation state. + The expanded view must show "what changed since last good" before raw logs. + +*** atop: history and vanished failures + +Pull: + +- Permanent, compact health-event log. +- Interval deltas instead of only current values: + - new failed services since last check + - new journal errors since last check + - packages/repos newly stale + - snapshot hook present before, missing now + - bootloader file changed since last known-good +- "Show active/deviating only" mode. In normal use, hide green rows unless + their age is approaching threshold. +- Replay mode: inspect the state at the time an install/test/build failed. + +Equivalent-or-better requirement: + +- atop can report processes that already exited. This monitor should report + failures that already passed through: a transient failed unit, a VM test that + failed last night, an ISO build that failed before the current successful + build, or a package DB that was stale until Doctor fixed it. + +*** Glances: plugin/API/export model + +Pull: + +- Plugin-like probes. Each metric owns: + - =probe= + - =normalize= + - =thresholds= + - =doctor_actions= + - =redaction= + - =evidence= +- JSON output as a stable contract before GTK work. +- Optional stdout selectors: + - =system-monitor --stdout packages.state,storage.state= + - =system-monitor --json boot,snapshots= +- Remote/headless mode for VMs and bare-metal test targets. +- Threshold config in one file, not hardcoded in the UI. + +Equivalent-or-better requirement: + +- Glances is broad; this must stay opinionated. A plugin is accepted only if it + maps to install health, rollback safety, workstation contract, or recovery + readiness. + +*** procs: custom columns and query grammar + +Pull: + +- Named columns and saved views: + - =risk=: state, priority, age, doctor class. + - =install=: last pass, filesystem, artifact, branch, commit. + - =host=: state, source, last checked, command. + - =doctor=: action class, requires root, reversible, last run. +- Multi-keyword search: + - =zfs failed= + - =doctor safe-fix= + - =archangel stale= + - =service red= +- Boolean query modes: + - AND default for narrowing. + - OR for "show any boot or storage issue". + - NOT for "hide green". +- Value-aware coloring for age, severity, and units. + +Equivalent-or-better requirement: + +- procs lets the user build a process table. This monitor should let Craig + build a risk table without editing code. + +*** gotop/gtop: glance density + +Pull: + +- Small sparklines for trend, not full charts. +- Big obvious state words. +- Compact gauges for bounded values: + - EFI usage + - root/home usage + - DB age as percent of freshness window + - VM evidence age + - snapshot age +- Simple default screen that is useful without learning keys. + +Equivalent-or-better requirement: + +- The first screen should answer "am I safe to upgrade or install?" in under + two seconds. + +* Sorting and Views + +The monitor needs two sorting layers: global row ordering and per-band evidence +tables. + +** Global row ordering + +Default: + +1. =state= severity: red, yellow, unknown, running, green. +2. =priority= rank: P0 before P1 before P2. +3. =age= or =staleness=, descending. +4. =last_changed=, newest first. + +Alternate sorts: + +| Sort key | Use case | +|-----------------+------------------------------------------------------| +| =priority= | Release-gate review; keep P0/P1 at the top. | +|-----------------+------------------------------------------------------| +| =state= | Triage; show all red/yellow rows first. | +|-----------------+------------------------------------------------------| +| =age= | Find stale tests, stale package DBs, old backups. | +|-----------------+------------------------------------------------------| +| =doctor_class= | Find what Doctor can safely fix now. | +|-----------------+------------------------------------------------------| +| =scope= | Group host vs install vs build. | +|-----------------+------------------------------------------------------| +| =last_changed= | See what recently regressed. | +|-----------------+------------------------------------------------------| +| =source= | Group by archangel, archsetup, dotfiles, host. | +|-----------------+------------------------------------------------------| + +** Per-band sorts + +| Band | Sorts | +|------------------------+------------------------------------------------------------| +| =BOOT + STORAGE= | severity, mountpoint, filesystem, capacity, last scrub, newest snapshot age | +|------------------------+------------------------------------------------------------| +| =INSTALL PIPELINE= | result, filesystem, duration, artifact age, commit age, last pass | +|------------------------+------------------------------------------------------------| +| =PACKAGES + SERVICES= | severity, unit name, repo name, DB age, error count, enabled/active state | +|------------------------+------------------------------------------------------------| +| =WORKSTATION CONTRACT= | severity, check name, owner repo, last pass, doctor class | +|------------------------+------------------------------------------------------------| + +** Filters + +Quick filters should be visible as chips: + +- =red= +- =yellow= +- =doctorable= +- =needs-root= +- =stale= +- =zfs= +- =btrfs= +- =host= +- =install= +- =build= +- =changed= +- =hidden-green= + +The default view can hide healthy low-priority rows, but it must show enough +green P0/P1 summary state to prove the monitor is working. + +* Display Requirements Borrowed from Tops + +1. Every table has sortable columns and a visible sort indicator. +2. Every visible metric row has a filterable state, priority, age, and source. +3. Every row can expand to evidence without losing the list context. +4. Every graph has a threshold marker; trend without threshold is decoration. +5. Every long-running action can be paused/frozen in the display. +6. Every mutating action has an equivalent CLI command shown in the output well. +7. Every Doctor action records before/after state so fixed failures remain + visible in history. +8. Every band has a compact mode and an expanded mode. +9. Green rows are quiet; new regressions are loud. +10. The system must be useful over SSH/TUI before GTK polish. + +* Source Notes + +- =htop=: upstream README describes configurable system/process display, + interactive sorting/filtering/search, tree view, and process actions. +- =btop++=: upstream README describes resource boxes, detailed process stats, + filter, sort switching, tree view, mouse support, auto-scaling network graphs, + disk IO, battery, GPU support, and themes. +- =bottom/btm=: upstream README describes customizable widgets, process sort + and search, tree mode, expand/focus, zoomable graph intervals, filters, and + basic mode. +- =atop=: upstream README describes interval resource accounting, critical + highlighting, long-term compressed logs, exited-process visibility, cgroup + views, and active/deviation-focused output. +- =Glances=: upstream README describes plugin-style broad monitoring, web/API + modes, stdout JSON/CSV, remote monitoring, exports, and threshold-oriented + dashboard use. +- =procs=: upstream README describes configurable columns, named-column sort, + watch mode, tree view, logical keyword search, value-aware coloring, and + pager behavior. + +* Data Model + +Emit JSON from a CLI first; the panel is a client. + +#+BEGIN_SRC json +{ + "v": 1, + "scope": "host", + "state": "warn", + "ts": "2026-07-04T12:00:00-04:00", + "metrics": [ + { + "id": "packages.sync_freshness", + "label": "package databases", + "state": "warn", + "summary": "archzfs.db age 4d", + "evidence": [ + {"command": "find /var/lib/pacman/sync", "excerpt": "archzfs.db 2026-06-30"} + ], + "doctor": { + "class": "safe-fix", + "actions": ["refresh-sync-db", "update-keyring"] + } + } + ] +} +#+END_SRC + +* Implementation Notes + +- Start with a CLI: =system-monitor status --json=, =system-monitor doctor + --json=, =system-monitor refresh=. +- Keep probes read-only by default. Actions live in separate verbs. +- Cache slow probes. The bar should read a cache, not run VM tests. +- VM/build actions should create job records and stream logs; the panel follows + the job rather than blocking the UI process. +- Reuse the net/bt panel architecture if this becomes a GTK panel: GTK-free + model + fake-command unit tests + one AT-SPI smoke. +- Redact secrets from logs and JSON: WiFi PSKs, tokens, private repo URLs with + credentials, SSH material, and backup target credentials. + +* Open Decisions + +** TODO Where should the first implementation live? + +Recommendation: dotfiles owns the user-facing panel and CLI wrapper because it +is workstation UI. Archsetup owns reusable install-contract probes and testinfra +assertions. Archangel owns ISO/build probes. + +** TODO Should Doctor run elevated actions through polkit or terminal? + +Recommendation: read-only checks run unprivileged; elevated actions launch a +terminal or polkit prompt with the exact command visible. Do not hide long +privileged operations inside the panel process. + +** TODO How fresh must VM evidence be? + +Recommendation: host checks go stale after 1 hour; package DB after 48 hours; +VM install evidence after 7 days; ISO build evidence after 14 days or whenever +Archangel/Archsetup has changed since the last successful artifact. + +** TODO Which actions are allowed on bare metal? + +Recommendation: host Doctor may refresh databases, update keyring, restow +dotfiles, create snapshots, run scrub, and restart narrowly classified services. +It may not perform full upgrades, bootloader reinstalls, destructive snapshot +prune, or filesystem repair without a separate advanced flow. + +* First Build Slice + +1. CLI read-only host status: + - package DB freshness + - failed services + - ZFS/Btrfs health + - snapshot presence + - Archsetup status/log check +2. Doctor safe actions: + - refresh package DB + - update keyring + - restow dotfiles + - create manual snapshot +3. Artifact parser: + - latest Archsetup VM test result + - latest Archangel ISO build result +4. Panel prototype: + - faceplate + - four health bands + - evidence output well + - REFRESH and DOCTOR keys only diff --git a/docs/homelab-inventory/2026-01-05-truenas-hardware-specs.org b/docs/homelab-inventory/2026-01-05-truenas-hardware-specs.org new file mode 100644 index 0000000..42d33ad --- /dev/null +++ b/docs/homelab-inventory/2026-01-05-truenas-hardware-specs.org @@ -0,0 +1,229 @@ +#+TITLE: TrueNAS Server Hardware Specifications +#+AUTHOR: Auto-generated via SSH +#+DATE: 2026-01-05 + +* Package Management Note + +**IMPORTANT:** Package management tools (apt) are disabled on TrueNAS appliances. Attempting to install packages with apt or methods other than the TrueNAS web interface can result in a nonfunctional system. + +All software installation and updates must be done through the TrueNAS web interface. + +* System Information + +** Hostname: truenas +** IP Address: 192.168.86.5 (static) +** Network Interface: eno1 +** Kernel: Linux 6.12.33-production+truenas +** Build Date: Wed Dec 17 21:17:21 UTC 2025 +** Architecture: x86_64 GNU/Linux + +* CPU Specifications + +** Model: Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz +** Architecture: x86_64 (Coffee Lake S) +** Cores: 8 physical cores +** Threads: 16 (2 threads per core) +** Base Clock: 3.60 GHz +** Max Turbo: 5.00 GHz +** Min Clock: 800 MHz + +** Cache: +- L1d cache: 256 KiB (8 instances) +- L1i cache: 256 KiB (8 instances) +- L2 cache: 2 MiB (8 instances) +- L3 cache: 16 MiB (1 instance) + +** Features: +- Virtualization: VT-x +- CPU family: 6 +- Model: 158 +- Stepping: 13 + +* Memory Specifications + +** Total RAM: 64 GiB (62 GiB usable) +** Swap: 0 B (no swap configured) +** Current Usage: ~3.0 GiB used, 59 GiB free + +* Storage Controllers + +** SATA Controllers: +1. Intel Corporation Cannon Lake PCH SATA AHCI Controller (rev 10) +2. ASMedia Technology Inc. ASM1166 Serial ATA Controller (rev 02) + +** NVMe Controllers: +1. Sandisk Corp WD PC SN810 / Black SN850 NVMe SSD (rev 01) + +* Block Devices + +** Hard Drives: +- sda: 10.9 TB (ZFS member) +- sdb: 10.9 TB (ZFS member) +- sdc: 10.9 TB (ZFS member) +- sdd: 10.9 TB (ZFS member) +- sdg: 18.2 TB (ZFS member) +- sdh: 18.2 TB (ZFS member) +- sdi: 18.2 TB (ZFS member) +- sdj: 18.2 TB (ZFS member) + +** System Drives: +- sde: 1.9 TB (Boot pool, ZFS member) + - sde1: 1M partition + - sde2: 512M VFAT partition + - sde3: 1.9T ZFS partition +- sdf: 1.9 TB (ZFS member) + +** NVMe: +- nvme0n1: 1.8 TB + +** Total Raw Capacity: +- 4× 10.9 TB = 43.6 TB +- 4× 18.2 TB = 72.8 TB +- 2× 1.9 TB = 3.8 TB +- 1× 1.8 TB NVMe = 1.8 TB +- **Total: ~122 TB raw storage** + +* Network Interfaces + +** eno1 (Primary - Active): +- Type: Ethernet controller - Intel Corporation Ethernet Connection (7) I219-V (rev 10) +- MAC: 70:85:c2:db:9d:94 +- IP: 192.168.86.5/21 +- Subnet broadcast: 192.168.87.255 +- State: UP +- MTU: 1500 +- Alternative name: enp0s31f6 + +** enp2s0 (Secondary - Down): +- Type: Ethernet controller - Intel Corporation I211 Gigabit Network Connection (rev 03) +- MAC: 70:85:c2:db:9d:94 +- State: DOWN (NO-CARRIER) + +** Wireless: +- Intel Corporation Dual Band Wireless-AC 3168NGW [Stone Peak] (rev 10) +- Status: Not configured + +** Docker Bridge: +- docker0: 172.16.0.1/24 +- State: DOWN (NO-CARRIER) + +* Motherboard/Chipset + +** Chipset: Intel Z390 +- Host Bridge: 8th/9th Gen Core 8-core Desktop Processor (Coffee Lake S) +- ISA Bridge: Z390 Chipset LPC/eSPI Controller (rev 10) +- PCIe Root Ports: Multiple Cannon Lake PCH PCI Express ports + +** Integrated Components: +- Graphics: Intel CoffeeLake-S GT2 [UHD Graphics 630] (rev 02) +- Audio: Intel Cannon Lake PCH cAVS (rev 10) +- USB: Cannon Lake PCH USB 3.1 xHCI Host Controller (rev 10) +- Thermal: Cannon Lake PCH Thermal Controller (rev 10) +- HECI: Cannon Lake PCH HECI Controller (rev 10) +- SMBus: Cannon Lake PCH SMBus Controller (rev 10) +- SPI: Cannon Lake PCH SPI Controller (rev 10) + +* ZFS Pool Configuration + +** boot-pool (1.86 TB): +- Allocated: 3.06 GB +- Free: 1.86 TB +- Capacity: 0% +- Health: ONLINE + +** sysdata (1.86 TB): +- Allocated: 314 MB +- Free: 1.86 TB +- Capacity: 0% +- Health: ONLINE +- Purpose: System data and applications + +** tank (72.8 TB): +- Allocated: 1.17 MB +- Free: 72.7 TB +- Capacity: 0% +- Health: ONLINE +- Purpose: Large storage pool (empty, ready for expansion) + +** vault (43.6 TB): +- Allocated: 24.5 TB +- Free: 19.1 TB +- Capacity: 56% +- Health: ONLINE +- Purpose: Main media storage + +* Vault Datasets + +** Media (Movies, Music, TV Shows): +- Size: 30 TB (allocated) +- Used: 17 TB +- Available: 14 TB +- Capacity: 54% + +** Lectures: +- Size: 15 TB (allocated) +- Used: 878 GB +- Available: 14 TB +- Capacity: 6% + +** Audiobooks: +- Size: 14 TB (allocated) +- Used: 89 GB +- Available: 14 TB +- Capacity: 1% + +** Magic: +- Size: 15 TB (allocated) +- Used: 543 GB +- Available: 14 TB +- Capacity: 4% + +** Books: +- Size: 14 TB (allocated) +- Used: 177 GB +- Available: 14 TB +- Capacity: 2% + +* Security Mitigations + +** CPU Vulnerabilities (Status): +- Gather data sampling: Mitigated (Microcode) +- Indirect target selection: Mitigated (Aligned branch/return thunks) +- Itlb multihit: KVM Mitigation (Split huge pages) +- L1tf: Not affected +- Mds: Not affected +- Meltdown: Not affected +- Mmio stale data: Mitigated (Clear CPU buffers; SMT vulnerable) +- Reg file data sampling: Not affected +- Retbleed: Mitigated (Enhanced IBRS) +- Spec rstack overflow: Not affected +- Spec store bypass: Mitigated (Disabled via prctl) +- Spectre v1: Mitigated (usercopy/swapgs barriers) +- Spectre v2: Mitigated (Enhanced/Automatic IBRS) +- Srbds: Mitigated (Microcode) +- Tsx async abort: Mitigated (TSX disabled) + +* Full PCI Device List + +#+BEGIN_EXAMPLE +00:00.0 Host bridge: Intel Corporation 8th/9th Gen Core 8-core Desktop Processor Host Bridge/DRAM Registers [Coffee Lake S] (rev 0d) +00:01.0 PCI bridge: Intel Corporation 6th-10th Gen Core Processor PCIe Controller (x16) (rev 0d) +00:02.0 VGA compatible controller: Intel Corporation CoffeeLake-S GT2 [UHD Graphics 630] (rev 02) +00:12.0 Signal processing controller: Intel Corporation Cannon Lake PCH Thermal Controller (rev 10) +00:14.0 USB controller: Intel Corporation Cannon Lake PCH USB 3.1 xHCI Host Controller (rev 10) +00:14.2 RAM memory: Intel Corporation Cannon Lake PCH Shared SRAM (rev 10) +00:16.0 Communication controller: Intel Corporation Cannon Lake PCH HECI Controller (rev 10) +00:17.0 SATA controller: Intel Corporation Cannon Lake PCH SATA AHCI Controller (rev 10) +00:1c.0 PCI bridge: Intel Corporation Cannon Lake PCH PCI Express Root Port #6 (rev f0) +00:1c.6 PCI bridge: Intel Corporation Cannon Lake PCH PCI Express Root Port #7 (rev f0) +00:1d.0 PCI bridge: Intel Corporation Cannon Lake PCH PCI Express Root Port #9 (rev f0) +00:1f.0 ISA bridge: Intel Corporation Z390 Chipset LPC/eSPI Controller (rev 10) +00:1f.3 Audio device: Intel Corporation Cannon Lake PCH cAVS (rev 10) +00:1f.4 SMBus: Intel Corporation Cannon Lake PCH SMBus Controller (rev 10) +00:1f.5 Serial bus controller: Intel Corporation Cannon Lake PCH SPI Controller (rev 10) +00:1f.6 Ethernet controller: Intel Corporation Ethernet Connection (7) I219-V (rev 10) +01:00.0 SATA controller: ASMedia Technology Inc. ASM1166 Serial ATA Controller (rev 02) +02:00.0 Ethernet controller: Intel Corporation I211 Gigabit Network Connection (rev 03) +03:00.0 Network controller: Intel Corporation Dual Band Wireless-AC 3168NGW [Stone Peak] (rev 10) +04:00.0 Non-Volatile memory controller: Sandisk Corp WD PC SN810 / Black SN850 NVMe SSD (rev 01) +#+END_EXAMPLE diff --git a/docs/homelab-inventory/mybitch-laptop.org b/docs/homelab-inventory/mybitch-laptop.org new file mode 100644 index 0000000..e522c40 --- /dev/null +++ b/docs/homelab-inventory/mybitch-laptop.org @@ -0,0 +1,238 @@ +#+TITLE: mybitch - Christine's Laptop +#+DATE: 2026-01-31 +#+HOSTNAME: mybitch + +* Automated Capabilities + +# Maintained by the system-health-check workflow. Manual edits may be +# overwritten when the workflow detects drift between this drawer and the +# live system. Machine-readable capability signals used to dispatch which +# checks run on this host. + +:PROPERTIES: +:FS: ext4 +:PM: apt +:ORCH: none +:SNAPSHOT: timeshift +:MESH: tailscale +:BACKUP: source +:VIRT: libvirt +:INIT: systemd +:LAST_AUDIT: 2026-06-23 +:END: + +* Overview + +| Field | Value | +|----------+-----------------------------------| +| Hostname | mybitch | +|----------+-----------------------------------| +| Type | Laptop | +|----------+-----------------------------------| +| Owner | Christine | +|----------+-----------------------------------| +| Maker | Framework | +|----------+-----------------------------------| +| Model | Laptop 16 (AMD Ryzen 7040 Series) | +|----------+-----------------------------------| +| OS | Linux Mint 22.3 (Zena) | +|----------+-----------------------------------| +| Kernel | 6.17.0-23-generic | +|----------+-----------------------------------| +| Status | In use | +|----------+-----------------------------------| + +* Hardware Specifications + +** CPU + +| Field | Value | +|--------------+-----------------------------------| +| Model | AMD Ryzen 9 7940HS w/ Radeon 780M | +|--------------+-----------------------------------| +| Architecture | Zen 4 (TSMC 5nm) | +|--------------+-----------------------------------| +| Cores | 8 | +|--------------+-----------------------------------| +| Threads | 16 | +|--------------+-----------------------------------| +| Base Clock | 4.0 GHz | +|--------------+-----------------------------------| +| Boost Clock | 5.25 GHz | +|--------------+-----------------------------------| +| L2 Cache | 8 MiB (8x1024 KiB) | +|--------------+-----------------------------------| +| L3 Cache | 16 MiB | +|--------------+-----------------------------------| + +** Memory + +| Field | Value | +|-----------+------------------------------| +| Total | 32 GB | +|-----------+------------------------------| +| Type | DDR5-5600 | +|-----------+------------------------------| +| Installed | 1x 32GB (A-DATA AD5S560032G) | +|-----------+------------------------------| +| Slots | 2 (1 free) | +|-----------+------------------------------| +| Max | 64 GB | +|-----------+------------------------------| + +** Graphics + +| Device | Model | Driver | Notes | +|------------+---------------------+--------+-----------------| +| Dedicated | AMD Radeon RX 7700S | amdgpu | RDNA 3, Navi 33 | +|------------+---------------------+--------+-----------------| +| Integrated | AMD Radeon 780M | amdgpu | RDNA 3, Phoenix | +|------------+---------------------+--------+-----------------| + +** Storage + +| Device | Model | Size | Interface | +|--------------+---------------------+----------+--------------| +| /dev/nvme0n1 | WD BLACK SN770M 2TB | 1.82 TiB | PCIe Gen4 x4 | +|--------------+---------------------+----------+--------------| +| /dev/nvme1n1 | WD BLACK SN770 2TB | 1.82 TiB | PCIe Gen4 x4 | +|--------------+---------------------+----------+--------------| +| Total | | 3.64 TiB | | +|--------------+---------------------+----------+--------------| + +*** Partitions + +| Partition | Size | Used | Filesystem | Mount | Label | +|----------------+----------+-------------+------------+-----------+--------| +| /dev/nvme1n1p2 | 1.82 TiB | 17.4% | ext4 | / | | +|----------------+----------+-------------+------------+-----------+--------| +| /dev/nvme1n1p1 | 512 MiB | 1.2% | vfat | /boot/efi | | +|----------------+----------+-------------+------------+-----------+--------| +| /dev/nvme0n1p1 | 1.82 TiB | (unmounted) | ext4 | | Backup | +|----------------+----------+-------------+------------+-----------+--------| + +** Display + +| Field | Value | +|------------+--------------------| +| Panel | BOE Display 0x0bc9 | +|------------+--------------------| +| Resolution | 2560x1600 | +|------------+--------------------| +| Size | 16" (345x215mm) | +|------------+--------------------| +| Ratio | 16:10 | +|------------+--------------------| +| DPI | 188 | +|------------+--------------------| + +** Battery + +| Field | Value | +|----------+------------------| +| Model | NVT FRANDBA | +|----------+------------------| +| Capacity | 85.1 Wh (design) | +|----------+------------------| +| Current | 87.5 Wh (102.9%) | +|----------+------------------| +| Cycles | 13 | +|----------+------------------| +| Type | Li-ion | +|----------+------------------| + +** Network + +| Interface | Type | Chipset | +|-----------+--------+--------------------------| +| wlp5s0 | WiFi | MediaTek MT7922 802.11ax | +|-----------+--------+--------------------------| +| Bluetooth | BT 5.2 | MediaTek Wireless_Device | +|-----------+--------+--------------------------| + +** Expansion Cards (Framework) + +| Slot | Card | +|------+---------------------| +| 1 | HDMI Expansion Card | +|------+---------------------| + +** Input Modules (Framework) + +| Module | Notes | +|----------+-------------------------| +| Keyboard | ANSI layout | +|----------+-------------------------| +| Numpad | Laptop 16 Numpad Module | +|----------+-------------------------| + +* Connected Peripherals + +| Device | Connection | +|---------------------------+-------------------------| +| Logitech MX Master 3 | Unifying Receiver (USB) | +|---------------------------+-------------------------| +| Realtek Laptop Camera | Internal USB | +|---------------------------+-------------------------| +| Goodix Fingerprint Reader | Internal USB | +|---------------------------+-------------------------| + +* Network Access + +| Method | Address | +|--------+---------------| +| mDNS | mybitch.local | +|--------+---------------| +| SSH | Yes (OpenSSH) | +|--------+---------------| + +* Notes + +- Framework Laptop 16 with modular expansion card system +- Discrete AMD GPU (RX 7700S) in addition to integrated Radeon 780M +- Second NVMe slot contains 2TB backup drive (unmounted, labeled "Backup") +- Uptime at inventory: 4 days + +* Operational Changes Log + +Deliberate, non-default config changes applied to mybitch (so they can be found and reverted later). Most recent first. + +** 2026-05-12 — amdgpu.mes=0 added (escalation of the MES-hang freeze mitigation) + +The 2026-05-11 =amdgpu.cwsr_enable=0= mitigation didn't reduce the iGPU "MES failed to respond" errors (14 hits in 14.5h after the fix vs. 38 over 10 days before — rate went *up*), so escalated per the documented path: added =amdgpu.mes=0= to =GRUB_CMDLINE_LINUX_DEFAULT= in =/etc/default/grub= (now =quiet splash amdgpu.cwsr_enable=0 amdgpu.mes=0=). Backup: =/etc/default/grub.bak-2026-05-12-amdgpu-mes=. =update-grub= run; param verified in all 6 =/boot/grub/grub.cfg= entries. Takes effect on next reboot (planned for after the in-progress first rsyncshot backup). =amdgpu.mes=0= disables the GFX11 hardware MES scheduler and falls back to the legacy KIQ path — sidesteps the hanging MES firmware entirely. Tradeoff: KIQ-on-GFX11 is a less-traveled config (small chance of cosmetic display/modeset quirks); irrelevant for Christine's light desktop workload. =cwsr_enable=0= kept (harmless alongside mes=0). *Revert:* restore =/etc/default/grub.bak-2026-05-12-amdgpu-mes= (or sed out = amdgpu.mes=0=), =update-grub=, reboot. After the reboot, check =journalctl -k -b | grep -c 'MES failed'= → should be 0. See the 2026-05-11 entry in =.ai/project-workflows/system-health-check.org='s Known Issues Log for the full background. + +** 2026-05-12 — rsyncshot backups + travel keep-awake mode + +Set up the same =rsyncshot= → TrueNAS backup mybitch's siblings use, and made mybitch never suspend so backups keep running while Christine travels with it. + +*** rsyncshot install (mirrors ratio's setup) +- =/usr/local/bin/rsyncshot= — the script (source of truth: =~/code/rsyncshot/rsyncshot=). +- =/etc/rsyncshot/config= — =REMOTE_HOST="cjennings@truenas.tailf3bb8c.ts.net"=, =REMOTE_PATH="/mnt/vault/backups"=, =SSH_IDENTITY_FILE="/root/.ssh/id_ed25519"=. (See "destination pinned to Tailscale" below for why the full MagicDNS name.) +- =/etc/rsyncshot/include.txt= — =/home /etc /usr/local/bin=. =/etc/rsyncshot/exclude.txt= — copied verbatim from ratio. +- =/etc/logrotate.d/rsyncshot= — copied verbatim from ratio. =/var/log/rsyncshot.log= — the log. +- Root crontab: hourly at :30 (hours 0-1,3-23, keep 23) + daily at 2:30 (keep 30), each wrapped in =flock -x /tmp/rsyncshot.lock=. Identical to ratio's schedule. +- =/root/.ssh/id_ed25519= (+ =.pub=) — new keypair generated on mybitch, no passphrase, comment =root@mybitch-rsyncshot=. mybitch's =cjennings= account had no outbound key (it's a receive-only account), so the backup runs with a dedicated root key instead. The pubkey was appended to =~cjennings/.ssh/authorized_keys= on TrueNAS (the line tagged =root@mybitch-rsyncshot=). +- =/root/.ssh/config= — =Host truenas truenas.tailf3bb8c.ts.net 100.67.22.65= → =User cjennings=, that IdentityFile, =ServerAliveInterval 60 / ServerAliveCountMax 10 / TCPKeepAlive yes=. Host keys for those names added to =/root/.ssh/known_hosts=. +- TrueNAS side: =/mnt/vault/backups/mybitch/= directory created, owned =cjennings:cjennings= (=vault/backups= is a ZFS dataset; per-host backups are just subdirectories, like =ratio/= and =velox/=). +- *Revert:* =sudo crontab -r= (or remove the rsyncshot lines), =sudo rm -rf /usr/local/bin/rsyncshot /etc/rsyncshot /etc/logrotate.d/rsyncshot /var/log/rsyncshot.log /root/.ssh/id_ed25519* /root/.ssh/config=, restore =/root/.ssh/known_hosts= if desired, remove the =root@mybitch-rsyncshot= line from =~cjennings/.ssh/authorized_keys= on TrueNAS, and =rm -rf /mnt/vault/backups/mybitch= on TrueNAS. + +*** destination pinned to Tailscale +=REMOTE_HOST= uses the full Tailscale MagicDNS name =truenas.tailf3bb8c.ts.net= (not the bare =truenas=) so the backup always routes over the tailnet regardless of what a hotel/away-from-home router's DNS resolves =truenas= to. Tailscale uses a direct LAN path between same-LAN peers, so this is near-zero perf cost when mybitch is on the home network. *Revert:* set =REMOTE_HOST="cjennings@truenas"= in =/etc/rsyncshot/config=. + +*** keep-awake (never suspend) — three layers +1. =systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target= — the hard guarantee; suspend can't happen even if requested. *Revert:* =sudo systemctl unmask sleep.target suspend.target hibernate.target hybrid-sleep.target=. +2. Cinnamon power gsettings for user =cciarm= set to ='nothing'=: =sleep-inactive-ac-type=, =sleep-inactive-battery-type=, =lid-close-ac-action=, =lid-close-battery-action= (schema =org.cinnamon.settings-daemon.plugins.power=). Were ='suspend'/'suspend'/'nothing'/'suspend'=. *Revert:* set them back, or run =gsettings reset= on each key as =cciarm=. +3. =/etc/systemd/logind.conf.d/no-suspend-keep-awake.conf= — =HandleLidSwitch=ignore=, =HandleLidSwitchExternalPower=ignore=, =HandleLidSwitchDocked=ignore=, =IdleAction=ignore=. (Written but not applied live — =systemd-logind= wasn't restarted because sessions were active; takes effect on next reboot. Layers 1-2 cover the gap.) *Revert:* =sudo rm /etc/systemd/logind.conf.d/no-suspend-keep-awake.conf= then reboot or =systemctl restart systemd-logind=. + +When mybitch goes back to being a stationary machine (or this whole arrangement is no longer wanted), reverse layers 1-3. The rsyncshot backup itself is worth keeping regardless. + +*** IPv6 set to link-local-only (fixes the tailscale dropout) +mybitch dropped off the Tailscale tailnet on 2026-05-12 AM. Root cause: the home WiFi ("dupre") has device(s) advertising ULA-only IPv6 RAs (prefixes =fdc4:c4c6:3fb0:16cc::/64= and =fd67:8615:6780:1::/64=) with no default route. NetworkManager (=ipv6.method=auto=) SLAAC'd a "global"-scope ULA address onto wlp5s0 with no v6 internet path, so tailscaled kept trying IPv6 to controlplane/DERP, failed "network is unreachable", flapped, and lost its control connection. mybitch has no working global IPv6 anywhere (no ISP v6), so the fix is to ignore underlay v6: +- =/etc/NetworkManager/conf.d/ipv6-link-local-only.conf= → =[connection] ipv6.method=link-local= (connection default — covers home WiFi, travel WiFi, phone hotspot). +- =nmcli con modify dupre ipv6.method link-local= (explicit on the home connection). +- Effect: wlp5s0 keeps only fe80:: (mDNS still works), no SLAAC, no v6 routes; tailscale0's overlay v6 untouched; tailscaled uses the IPv4 underlay. Activates on reboot or =nmcli dev reapply wlp5s0=. +- *Revert if mybitch ever gets real IPv6:* =sudo rm /etc/NetworkManager/conf.d/ipv6-link-local-only.conf=; =sudo nmcli con modify dupre ipv6.method auto=; reboot or =nmcli dev reapply wlp5s0=. + +*** caveats (relayed to Craig for Christine) +- mybitch must stay plugged into AC for backups to keep running — masking suspend doesn't stop a battery dying with the lid closed. +- mybitch must be on WiFi at the destination and stay on the Tailscale tailnet. diff --git a/docs/homelab-inventory/ratio-desktop.org b/docs/homelab-inventory/ratio-desktop.org new file mode 100644 index 0000000..234c1a4 --- /dev/null +++ b/docs/homelab-inventory/ratio-desktop.org @@ -0,0 +1,175 @@ +#+TITLE: ratio - Desktop Workstation +#+DATE: 2026-01-27 +#+HOSTNAME: ratio + +* Automated Capabilities + +# Maintained by the system-health-check workflow. Manual edits may be +# overwritten when the workflow detects drift between this drawer and the +# live system. Machine-readable capability signals used to dispatch which +# checks run on this host. + +:PROPERTIES: +:FS: btrfs +:PM: pacman +:ORCH: topgrade +:SNAPSHOT: snapper +:MESH: tailscale +:BACKUP: source +:VIRT: libvirt,docker,podman +:INIT: systemd +:LAST_AUDIT: 2026-07-08 +:END: + +* Overview + +| Field | Value | +|-------------+------------------------------------------| +| Hostname | ratio | +|-------------+------------------------------------------| +| Type | Desktop workstation | +|-------------+------------------------------------------| +| Maker | Framework | +|-------------+------------------------------------------| +| Model | Desktop (AMD Ryzen AI Max 300 Series) | +|-------------+------------------------------------------| +| Board | FRANMFCP06 v A6 | +|-------------+------------------------------------------| +| Firmware | INSYDE UEFI v03.03 (2025-09-16) | +|-------------+------------------------------------------| +| OS | Arch Linux | +|-------------+------------------------------------------| +| Kernel | 6.12.67-1-lts | +|-------------+------------------------------------------| +| Desktop | Hyprland (Wayland) | +|-------------+------------------------------------------| +| Previous ID | cogito (repurposed and renamed to ratio) | +|-------------+------------------------------------------| + +* CPU + +| Field | Value | +|---------------+---------------------------------------| +| Model | AMD Ryzen AI MAX+ 395 w/ Radeon 8060S | +|---------------+---------------------------------------| +| Architecture | Zen 5 (TSMC 4nm) | +|---------------+---------------------------------------| +| Cores/Threads | 16 cores / 32 threads | +|---------------+---------------------------------------| +| Base/Boost | 3.0 GHz / 5.15 GHz | +|---------------+---------------------------------------| +| Cache L1 | 1.2 MiB (d-16x48 KiB; i-16x32 KiB) | +|---------------+---------------------------------------| +| Cache L2 | 16 MiB (16x1024 KiB) | +|---------------+---------------------------------------| +| Cache L3 | 64 MiB (2x32 MiB) | +|---------------+---------------------------------------| +| NPU | XDNA 2, 50+ peak AI TOPS | +|---------------+---------------------------------------| +| Peak Perf | 59.4 FP16/BF16 TFLOPS @ 2.9GHz | +|---------------+---------------------------------------| + +* GPU + +| Field | Value | +|--------------+-----------------------------------------| +| Model | AMD Radeon 8060S (Strix Halo) | +|--------------+-----------------------------------------| +| Architecture | RDNA 3.5 | +|--------------+-----------------------------------------| +| CUs | 40 Compute Units | +|--------------+-----------------------------------------| +| Driver | amdgpu (kernel) | +|--------------+-----------------------------------------| +| Max VRAM | 96GB (via AMD Variable Graphics Memory) | +|--------------+-----------------------------------------| +| GPU Arch ID | gfx1151 | +|--------------+-----------------------------------------| +| PCIe | Gen 4, 16 GT/s, 16 lanes | +|--------------+-----------------------------------------| + +* Memory + +| Field | Value | +|--------------+--------------------------| +| Total | 128 GiB unified (LPDDR5) | +|--------------+--------------------------| +| Speed | 8000 MT/s | +|--------------+--------------------------| +| Channels | 8 (8x 16 GiB) | +|--------------+--------------------------| +| Manufacturer | Micron Technology | +|--------------+--------------------------| +| Part Number | MT62F4G32D8DV-023 WT | +|--------------+--------------------------| + +* Storage + +| Device | Model | Size | Interface | +|--------------+------------------------+-----------+-----------| +| /dev/nvme0n1 | WD BLACK SN850X 8000GB | 7.28 TiB | NVMe | +|--------------+------------------------+-----------+-----------| +| /dev/nvme1n1 | WD BLACK SN850X 8000GB | 7.28 TiB | NVMe | +|--------------+------------------------+-----------+-----------| +| *Total* | | 14.55 TiB | | +|--------------+------------------------+-----------+-----------| + +Filesystem: btrfs with subvolumes (@, @home, @snapshots, @log, @pkg) + +* Network + +| Interface | Chipset | Speed | Type | +|-----------+-----------------+---------+----------| +| enp191s0 | Realtek RTL8126 | 5 GbE | Wired | +|-----------+-----------------+---------+----------| +| wlp192s0 | MediaTek MT7925 | Wi-Fi 7 | Wireless | +|-----------+-----------------+---------+----------| + +Also has: docker0, tailscale0, virbr0 (virtual interfaces) + +* Audio + +- AMD Radeon High Definition Audio (HDMI, snd_hda_intel) +- AMD Ryzen HD Audio (3.5mm, snd_hda_intel) +- PipeWire v1.4.10 (pipewire-pulse, wireplumber) + +* PCI Slots + +| Slot | Info | Status | +|------+------------+-----------| +| 1 | M.2 JWLAN | Available | +|------+------------+-----------| +| 2 | M.2 JSSD1 | In use | +|------+------------+-----------| +| 3 | M.2 JSSD2 | In use | +|------+------------+-----------| +| 4 | PCIe Gen 4 | Available | +|------+------------+-----------| + +* Connected Peripherals + +- Monitor: Dell U3419W (HDMI-A-1, 3440x1440@60Hz) +- Keyboard: Das Keyboard 4 (Cherry MX Blue) — replacing with Keychron Q6 Pro +- Mouse: Logitech M650 +- DAC/Amp: JDS Labs Element IV (ordered) + +* AI/LLM Capability + +Primary purpose includes local AI/LLM inference via Ollama. + +| Model | Size | Purpose | +|------------------+-------+----------------------| +| deepseek-r1:70b | 42 GB | Main reasoning model | +|------------------+-------+----------------------| +| qwen3:30b | 18 GB | MoE chat (256K ctx) | +|------------------+-------+----------------------| +| deepseek-r1:14b | 9 GB | Light reasoning | +|------------------+-------+----------------------| +| nomic-embed-text | 274MB | RAG embeddings | +|------------------+-------+----------------------| + +Inference benchmarks (AMD testing, LM Studio 0.3.11): +- Small models (1-3B): ~100+ tok/s +- Medium models (7-8B): ~60-80 tok/s +- Large models (20B): ~58 tok/s +- Very large models (120B): ~38 tok/s diff --git a/docs/homelab-inventory/truenas-server.org b/docs/homelab-inventory/truenas-server.org new file mode 100644 index 0000000..5ce8613 --- /dev/null +++ b/docs/homelab-inventory/truenas-server.org @@ -0,0 +1,114 @@ +#+TITLE: truenas - NAS/Storage Server +#+DATE: 2026-01-27 +#+HOSTNAME: truenas + +* Automated Capabilities + +# Maintained by the system-health-check workflow. Manual edits may be +# overwritten when the workflow detects drift between this drawer and the +# live system. Machine-readable capability signals used to dispatch which +# checks run on this host. +# +# NOTE: TrueNAS SCALE is middleware-managed. No user-level package manager +# or topgrade orchestration. Snapshot and update operations go through the +# TrueNAS UI / API, not pacman/apt. + +:PROPERTIES: +:FS: zfs +:PM: none +:ORCH: none +:SNAPSHOT: zfs-native +:MESH: tailscale +:BACKUP: target +:VIRT: docker +:INIT: systemd +:LAST_AUDIT: 2026-04-21 +:END: + +* Overview + +| Field | Value | +|----------+-----------------------------------------| +| Hostname | truenas (truenas.local / 192.168.86.5) | +|----------+-----------------------------------------| +| Type | NAS / storage server | +|----------+-----------------------------------------| +| OS | TrueNAS | +|----------+-----------------------------------------| +| Purpose | Media storage, backups, Plex, Syncthing | +|----------+-----------------------------------------| + +See [[file:2026-01-05-truenas-hardware-specs.org][TrueNAS Hardware Specs]] for full sysinfo output. + +* CPU + +| Field | Value | +|---------------+----------------------| +| Model | Intel Core i9-9900K | +|---------------+----------------------| +| Architecture | Coffee Lake | +|---------------+----------------------| +| Cores/Threads | 8 cores / 16 threads | +|---------------+----------------------| +| Base/Boost | 3.60 GHz / 5.00 GHz | +|---------------+----------------------| + +* Memory + +| Field | Value | +|-------+-------------| +| Total | 64 GiB DDR4 | +|-------+-------------| + +* Storage + +| Pool | Config | Raw Size | Purpose | +|-----------+-----------------+------------+---------------| +| boot-pool | Mirror | ~1.9 TB x2 | System | +|-----------+-----------------+------------+---------------| +| sysdata | NVMe | ~1.8 TB | System data | +|-----------+-----------------+------------+---------------| +| vault | RAIDZ1 (4 disk) | ~43.6 TB | Primary media | +|-----------+-----------------+------------+---------------| +| tank | RAIDZ1 (4 disk) | ~72.8 TB | Expansion | +|-----------+-----------------+------------+---------------| + +Total raw: ~122 TB + +*** Vault Datasets +| Dataset | Size | +|------------+-------| +| Media | 30 TB | +|------------+-------| +| Lectures | 15 TB | +|------------+-------| +| Audiobooks | 14 TB | +|------------+-------| +| Magic | 15 TB | +|------------+-------| +| Books | 14 TB | +|------------+-------| + +* Network + +| Interface | Chipset | Speed | Status | +|-----------+--------------+--------+--------------| +| eno1 | Intel I219-V | 1 GbE | Current | +|-----------+--------------+--------+--------------| +| enp2s0 | Intel I211 | 1 GbE | Available | +|-----------+--------------+--------+--------------| +| (pending) | Intel I226-V | 2.5GbE | Ordered, NIC | +|-----------+--------------+--------+--------------| + +* Motherboard + +| Field | Value | +|---------+------------| +| Chipset | Intel Z390 | +|---------+------------| + +* Services + +- Plex Media Server +- Syncthing +- Tailscale diff --git a/docs/homelab-inventory/velox-laptop.org b/docs/homelab-inventory/velox-laptop.org new file mode 100644 index 0000000..6e4f540 --- /dev/null +++ b/docs/homelab-inventory/velox-laptop.org @@ -0,0 +1,158 @@ +#+TITLE: velox - Laptop +#+DATE: 2026-01-27 +#+HOSTNAME: velox + +* Automated Capabilities + +# Maintained by the system-health-check workflow. Manual edits may be +# overwritten when the workflow detects drift between this drawer and the +# live system. Machine-readable capability signals used to dispatch which +# checks run on this host. +# +# NOTE: 2026-04 velox was reinstalled Arch-on-ZFS. Values below reflect +# that reinstall but remain TBD where a live probe has not yet confirmed. + +:PROPERTIES: +:FS: zfs +:PM: pacman +:ORCH: topgrade +:SNAPSHOT: sanoid +:MESH: tailscale +:BACKUP: source +:VIRT: libvirt,docker,podman +:INIT: systemd +:LAST_AUDIT: 2026-05-26 +:END: + +* Overview + +| Field | Value | +|----------+---------------------------------| +| Hostname | velox | +|----------+---------------------------------| +| Type | Laptop | +|----------+---------------------------------| +| Maker | Framework | +|----------+---------------------------------| +| Model | Laptop (13th Gen Intel Core) | +|----------+---------------------------------| +| Board | FRANMCCP07 v A7 | +|----------+---------------------------------| +| Firmware | INSYDE UEFI v03.07 (2024-12-26) | +|----------+---------------------------------| +| OS | Arch Linux | +|----------+---------------------------------| +| Kernel | 6.18.6-arch1-1 | +|----------+---------------------------------| +| Desktop | startx (X11) | +|----------+---------------------------------| + +* CPU + +| Field | Value | +|---------------+-----------------------------------------| +| Model | 13th Gen Intel Core i7-1370P | +|---------------+-----------------------------------------| +| Architecture | Raptor Lake (Intel 7 / 10nm) | +|---------------+-----------------------------------------| +| Cores/Threads | 14 cores / 20 threads (6P + 8E, hybrid) | +|---------------+-----------------------------------------| +| Base/Boost | 990 MHz / 5.2 GHz | +|---------------+-----------------------------------------| +| Cache L1 | 1.2 MiB (d-8x32K,6x48K; i-6x32K,8x64K) | +|---------------+-----------------------------------------| +| Cache L2 | 11.5 MiB (6x1.2M, 2x2M) | +|---------------+-----------------------------------------| +| Cache L3 | 24 MiB (1x24M) | +|---------------+-----------------------------------------| +| Socket | BGA1744 (U3E1) | +|---------------+-----------------------------------------| + +* GPU + +| Field | Value | +|--------------+------------------------| +| Model | Intel Iris Xe Graphics | +|--------------+------------------------| +| Architecture | Xe (Intel 7) | +|--------------+------------------------| +| Driver | i915 / modesetting | +|--------------+------------------------| + +* Memory + +| Field | Value | +|----------+------------------------------------| +| Total | 64 GiB (DDR4) | +|----------+------------------------------------| +| Speed | 3200 MT/s | +|----------+------------------------------------| +| Slots | 2 (both populated) | +|----------+------------------------------------| +| Device 1 | 32 GiB Crucial CT32G4SFD832A.C16FB | +|----------+------------------------------------| +| Device 2 | 32 GiB Crucial CT32G4SFD832A.C16FE | +|----------+------------------------------------| + +* Storage + +| Device | Model | Size | Interface | +|--------------+----------------------+----------+-----------| +| /dev/nvme0n1 | Sabrent SB-RKT4P-8TB | 7.28 TiB | NVMe | +|--------------+----------------------+----------+-----------| + +- Filesystem: ZFS on root (zroot pool on nvme0n1p2; reinstalled 2026-04) +- Datasets: zroot/ROOT/default, zroot/home, zroot/var/log, zroot/var/lib/pacman, zroot/var/lib/docker (per-layer), zroot/vms, zroot/media +- Snapshots: sanoid hourly + zfs-scrub-weekly@zroot +- SMART: PASSED (re-verified 2026-04-26); composite 43 °C; 0 media errors +- Power-on history pre-dates reinstall — refresh on next full re-inventory + +* Display + +| Field | Value | +|------------+---------------------------------| +| Panel | BOE Display 0x0bca (built 2022) | +|------------+---------------------------------| +| Resolution | 2256x1504 | +|------------+---------------------------------| +| Size | 13.5" (285x190mm) | +|------------+---------------------------------| +| DPI | 201 | +|------------+---------------------------------| +| Ratio | 3:2 | +|------------+---------------------------------| + +* Network + +| Interface | Chipset | Speed | Type | +|-------------+--------------------------------+---------+----------| +| wlp170s0 | Qualcomm Atheros AR9462 | Wi-Fi | Wireless | +|-------------+--------------------------------+---------+----------| +| enp0s13f0u3 | Realtek USB 10/100/1G/2.5G LAN | 2.5 GbE | USB | +|-------------+--------------------------------+---------+----------| + +Also has: docker0, tailscale0, virbr0 (virtual interfaces) + +* Audio + +- Intel Raptor Lake-P/U/H cAVS (snd_hda_intel) +- PipeWire v1.4.10 (pipewire-pulse, wireplumber) + +* Battery + +| Field | Value | +|-----------+-----------------------| +| Capacity | 55 Wh (design) | +|-----------+-----------------------| +| Condition | 44/55 Wh (80% health) | +|-----------+-----------------------| +| Cycles | 146 | +|-----------+-----------------------| +| Type | Li-ion | +|-----------+-----------------------| + +* Other Hardware + +- Webcam: Realtek Laptop Camera (UVC) +- Fingerprint: Shenzhen Goodix USB Device +- Bluetooth: Foxconn (BT 4.0) diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-1.html b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html new file mode 100644 index 0000000..6b199f9 --- /dev/null +++ b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html @@ -0,0 +1,693 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Timer panel — three redesigns · dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 6rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1320px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:92ch} +.masthead p b{color:var(--silver)} +h2{color:var(--steel);font-size:.8rem;letter-spacing:.22em;text-transform:uppercase; + margin:2.6rem 0 .2rem;display:flex;align-items:center;gap:12px} +h2 .tag{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.62rem;padding:1px 7px;letter-spacing:.12em} +h2::after{content:"";height:1px;background:var(--wash);flex:1} +.blurb{color:var(--dim);font-size:.82rem;max-width:90ch;margin:.5rem 0 1.1rem} +.blurb b{color:var(--steel);font-weight:400} +.desk{display:flex;justify-content:center;padding:1.2rem 0 .4rem} + +/* ---------- faceplate ---------- */ +.panel{width:396px;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + border-radius:14px;padding:15px 15px 16px;position:relative; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)} +.panel.wide{width:660px} +.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase} +.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em} +.phead .pcount b{color:var(--cream)} + +/* ---------- shared primitives (from the widget gallery) ---------- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px} +.key.icon{padding:6px 9px;font-size:14px;line-height:1} +.key.wide{width:100%;text-align:center;padding:9px} + +.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210; + border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em} +.chip:hover{color:var(--silver);border-color:var(--slate)} +.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.chip .x{color:inherit;opacity:.5;margin-left:5px} +.chip .x:hover{opacity:1;color:var(--fail)} + +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} +.badge.dim{background:var(--wash);color:var(--steel)} + +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b; + padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} +.seg.vert{flex-direction:column} +.seg.vert button{border-right:0;border-bottom:1px solid #33302b;padding:8px 10px} +.seg.vert button:last-child{border-bottom:0} + +.engrave{color:var(--steel);font-size:.6rem;letter-spacing:.28em;text-transform:uppercase; + display:flex;align-items:center;gap:9px;margin:2px 0} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:10px} +.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} + +.readout{color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em} +.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px; + padding:7px 9px;width:100%;outline:none} +.tin:focus{border-color:var(--gold)} +.tin::placeholder{color:var(--dim)} +.tin.bad{border-color:var(--fail);color:var(--fail)} + +.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; + border-radius:7px;padding:6px 9px} +.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)} + +/* radial ring */ +.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative} +.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)} +.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)} +.ring b{position:relative;z-index:1;text-align:center} + +/* linear bar */ +.bar{height:8px;background:#0d0f10;border:1px solid #231f18;border-radius:5px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));transition:width .25s linear} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} + +/* create strip common */ +.create{margin-top:13px;background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px} +.create .row{display:flex;gap:7px;align-items:center;margin-top:8px;flex-wrap:wrap} +.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px} + +/* toast */ +.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5} +.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px; + box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease} +.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)} +.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +/* empty state */ +.empty{color:var(--dim);font-size:12px;text-align:center;padding:18px 6px 12px} + +/* =============== A · RACK UNIT =============== */ +.qlist{display:flex;flex-direction:column;gap:8px} +.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px} +.qrow.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)} +.qrow.fire{animation:firef .6s ease-in-out 3} +@keyframes firef{50%{background:rgba(203,107,77,.22)}} +.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none} +.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:1px} +.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px} +.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.16em;text-transform:uppercase} +.qrow .rd{margin-left:auto;font-size:19px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap} +.qrow.paused .rd{color:var(--steel)} +.qrow .pomo{font-size:.56rem;color:var(--steel);letter-spacing:.1em;text-transform:uppercase} +.qrow .ctrls{display:flex;gap:5px;flex:none} + +/* =============== B · TRANSPORT DECK =============== */ +.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center} +.hero.fire{animation:firef .6s ease-in-out 3} +.hero .lhs{flex:none} +.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:6px} +.hero .htype{display:flex;align-items:center;gap:8px} +.hero .htype .g{color:var(--gold);font-size:17px} +.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hero .hbig{color:var(--cream);font-size:40px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.02em} +.hero.paused .hbig{color:var(--steel)} +.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.06em} +.transport{display:flex;gap:7px;margin-top:2px} +.tracks{margin-top:11px;display:flex;flex-direction:column;gap:5px} +.track{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;border:1px solid #1c1a16;cursor:pointer;font-size:12px} +.track:hover{background:var(--wash)} +.track.prim{outline:1px solid var(--gold);outline-offset:-1px} +.track .g{color:var(--gold);font-size:14px;width:16px;text-align:center} +.track b{color:var(--cream);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.track .trd{margin-left:auto;color:var(--silver);font-variant-numeric:tabular-nums;font-weight:700} +.track.paused .trd{color:var(--dim)} +.track .tx{color:var(--dim);font-size:14px;padding:0 2px} +.track .tx:hover{color:var(--fail)} + +/* =============== C · CHANNEL STRIP BOARD =============== */ +.board{display:flex;gap:9px;overflow-x:auto;padding:4px 2px 10px} +.strip{flex:none;width:96px;background:#141210;border:1px solid #201d17;border-radius:10px;padding:9px 8px; + display:flex;flex-direction:column;align-items:center;gap:8px} +.strip.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)} +.strip.fire{animation:firef .6s ease-in-out 3} +.strip .stitle{width:100%;display:flex;align-items:center;gap:5px;cursor:pointer} +.strip .stitle .g{color:var(--gold);font-size:13px} +.strip .stitle b{color:var(--cream);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.strip .styp{color:var(--dim);font-size:.5rem;letter-spacing:.14em;text-transform:uppercase;width:100%;text-align:left} +.column{width:26px;height:120px;position:relative;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden} +.column .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#8a7524,var(--gold));transition:height .25s linear} +.column.warn .fill{background:linear-gradient(0deg,#a35a3f,var(--fail))} +.column .cap{position:absolute;left:-2px;right:-2px;height:3px;background:var(--gold-hi);box-shadow:0 0 5px rgba(255,215,95,.6);transition:bottom .25s linear} +.column.sw .fill{background:linear-gradient(0deg,#3a4a5e,var(--slate-hi));animation:swpulse 1.6s ease-in-out infinite} +@keyframes swpulse{50%{opacity:.6}} +.strip .srd{color:var(--cream);font-size:14px;font-weight:700;font-variant-numeric:tabular-nums} +.strip.paused .srd{color:var(--steel)} +.strip .skeys{display:flex;gap:4px} +.strip.addstrip{justify-content:flex-start;width:150px;background:var(--well)} + +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · timer</div> + <h1>Timer panel — three redesigns</h1> + <p>Three ways to shape the timer dropdown, all in the shipped instrument-console faceplate language + (same tokens, lamps, console keys, engraved labels, and tabular readouts as the net / bt / sound panels). + Each is a <b>working prototype over one shared engine</b> that mirrors wtimer + the PanelModel: add / cancel / pause / + resume, promote to the bar slot, per-type presets (add and delete chips), freeform entry with the same validation, + stopwatch lap + stop-and-save, the soonest-fire queue sort, the 10-item cap, and a real completion + notify on fire. + Try each: add a timer, watch one count down and fire, promote a row, pause a stopwatch, delete a preset chip.</p> +</header> + +<h2><span class="tag">A</span> Rack unit — the faithful list</h2> +<p class="blurb">The closest sibling to the net / audio panels: a vertical stack you scan top-down. Header with the live + count and <b>CLEAR ALL</b>; one output-well row per item, soonest-firing on top; each row carries a lamp, glyph, label, + the big countdown, and inline pause / promote / cancel keys. Create strip lives at the bottom — pick a type, tap a preset + or type a duration, name it, ADD. Safest port of what already shipped.</p> +<div class="desk"><div class="panel" id="panelA"></div></div> + +<h2><span class="tag">B</span> Transport deck — one hero, a track list</h2> +<p class="blurb">A cassette-transport shape. The <b>primary</b> item (the one in the bar glyph slot) gets a hero readout with a + progress ring and chunky transport keys; everything else is a compact track list underneath. Click a track to promote it into + the hero seat; the ‹ › keys cycle the primary. Puts the timer you care about front-and-centre, the rest one glance away.</p> +<div class="desk"><div class="panel" id="panelB"></div></div> + +<h2><span class="tag">C</span> Channel-strip board — a mixing desk of timers</h2> +<p class="blurb">The mixing-console metaphor: every item is a vertical channel strip on a board, its fader draining from the top + as time runs out (a stopwatch fills instead, tinted slate). Read all your timers at once like meters on a desk. Click a strip + header to promote it; the trailing <b>+ NEW</b> strip is the create surface. The most spatial, most stereo of the three.</p> +<div class="desk"><div class="panel wide" id="panelC"></div></div> + +</div> + +<script> +"use strict"; +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const el = (tag, cls, html) => { const n=document.createElement(tag); if(cls)n.className=cls; if(html!=null)n.innerHTML=html; return n; }; + +/* nerd-font glyphs (mirrors timer/viewmodel.py GLYPH) */ +const GL = { + timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', + pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}', + play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}' +}; + +/* ---------- parsers (mirror parse.py behaviour) ---------- */ +function parseDuration(v){ + if(v==null) return null; + v=String(v).trim().toLowerCase(); + if(v==='') return null; + if(/^\d+$/.test(v)) return parseInt(v,10)*60; // bare number = minutes + if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null; // only h/m/s tokens + let m, tot=0; const re=/(\d+)\s*([hms])/g; + while((m=re.exec(v))) tot += m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1]; + return tot>0?tot:null; +} +function resolveAlarm(v, now){ + v=String(v||'').trim().toLowerCase(); + if(v.startsWith('+')){ const s=parseDuration(v.slice(1)); return s==null?null:now+s; } + if(v==='@hour'||v==='top of hour'){ const d=new Date(now*1000); d.setMinutes(0,0,0); d.setHours(d.getHours()+1); return d.getTime()/1000; } + const t=v.match(/^(\d{1,2}):(\d{2})$/); + if(t){ const hh=+t[1], mm=+t[2]; if(hh>23||mm>59) return null; + const d=new Date(now*1000); d.setHours(hh,mm,0,0); let e=d.getTime()/1000; if(e<=now) e+=86400; return e; } + return null; +} +function fmtTime(secs){ + secs=Math.max(0,Math.floor(secs)); + const h=Math.floor(secs/3600), m=Math.floor((secs%3600)/60), s=secs%60; + return h ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` : `${m}:${String(s).padStart(2,'0')}`; +} +function fmtClock(epoch){ const d=new Date(epoch*1000); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; } + +/* ---------- default presets (mirror panel._default_presets) ---------- */ +const DEFAULT_PRESETS = () => ({ + timer:[{label:'5m',value:'5m'},{label:'25m',value:'25m'},{label:'10m',value:'10m'}, + {label:'15m',value:'15m'},{label:'30m',value:'30m'},{label:'60m',value:'60m'},{label:'2h',value:'2h'}], + alarm:[{label:'+30m',value:'+30m'},{label:'top of hour',value:'@hour'},{label:'07:00',value:'07:00'}], + pomodoro:[{label:'default cycle',value:''}], + stopwatch:[] +}); +const TYPES=['timer','alarm','stopwatch','pomodoro']; +const COUNTDOWN=['timer','alarm','pomodoro']; +const MAX=10; +const POMO={work:25*60, short:5*60, long:15*60, interval:4}; + +/* ---------- the engine (mirrors PanelModel + wtimer state) ---------- */ +class Engine{ + constructor(){ this.items=[]; this.seq=0; this.primary=null; this.presets=DEFAULT_PRESETS(); this.onEvent=()=>{}; } + now(){ return Date.now()/1000; } + count(){ return this.items.length; } + full(){ return this.items.length>=MAX; } + + add(type,value,label){ + if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`}; + const now=this.now(); this.seq++; const id='t'+this.seq; + const it={id,type,label:label||''}; + if(type==='timer'){ const s=parseDuration(value); if(s==null) return {ok:false,reason:`bad duration: “${value}”`}; it.target=now+s; it.total=s; } + else if(type==='alarm'){ const e=resolveAlarm(value,now); if(e==null) return {ok:false,reason:`bad time: “${value}”`}; it.target=e; it.total=Math.max(1,e-now); } + else if(type==='pomodoro'){ it.phase='work'; it.cycle=1; it.interval=POMO.interval; it.target=now+POMO.work; it.total=POMO.work; } + else if(type==='stopwatch'){ it.start=now; it.laps=[]; } + else return {ok:false,reason:`unknown type: ${type}`}; + this.items.push(it); if(!this.primary) this.primary=id; + return {ok:true, id}; + } + find(id){ return this.items.find(i=>i.id===id); } + isPaused(it){ return it.type==='stopwatch' ? it.paused_elapsed!=null : it.paused_left!=null; } + remaining(it,ref){ + ref=ref==null?this.now():ref; + if(it.type==='stopwatch') return this.isPaused(it)? it.paused_elapsed : ref-it.start; + return this.isPaused(it)? it.paused_left : it.target-ref; + } + toggle(id){ const it=this.find(id); if(!it) return; const now=this.now(); + if(it.type==='stopwatch'){ + if(this.isPaused(it)){ it.start=now-it.paused_elapsed; it.paused_elapsed=null; } + else it.paused_elapsed=now-it.start; + } else { + if(this.isPaused(it)){ it.target=now+it.paused_left; it.paused_left=null; } + else it.paused_left=it.target-now; + } + } + cancel(id){ const i=this.items.findIndex(x=>x.id===id); if(i<0) return; this.items.splice(i,1); + if(this.primary===id) this.primary=null; } + cancelAll(){ this.items=[]; this.primary=null; } + promote(id){ if(this.find(id)) this.primary=id; } + cycle(dir){ const ids=this.items.map(i=>i.id); if(!ids.length) return; + let cur=ids.indexOf(this.effectivePrimary()); cur=cur<0?0:cur; + const n=dir==='prev' ? (cur-1+ids.length)%ids.length : (cur+1)%ids.length; + this.primary=ids[n]; } + lap(id,name){ const it=this.find(id); if(!it||it.type!=='stopwatch') return; + it.laps.push({t:this.remaining(it), name:name||''}); } + stopSave(id){ const it=this.find(id); if(!it||it.type!=='stopwatch') return null; + const run={label:it.label||'run', total:this.remaining(it), laps:it.laps.slice()}; this.cancel(id); return run; } + + effectivePrimary(){ + const items=this.items; if(!items.length) return null; + const ids=items.map(i=>i.id); + if(ids.includes(this.primary)) return this.primary; + const now=this.now(); + const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i)); + if(acd.length) return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id; + const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i)); + if(asw.length) return asw[0].id; + return ids[0]; + } + /* 4-bucket sort: active countdown < paused countdown < active sw < paused sw */ + sortKey(it){ const now=this.now(), paused=this.isPaused(it), sw=it.type==='stopwatch', rem=this.remaining(it,now); + return sw ? [paused?3:2, -rem, +it.id.slice(1)] : [paused?1:0, rem, +it.id.slice(1)]; } + rows(){ const prim=this.effectivePrimary(); const now=this.now(); + return this.items.slice().sort((a,b)=>{ const ka=this.sortKey(a),kb=this.sortKey(b); + for(let i=0;i<ka.length;i++){ if(ka[i]<kb[i])return -1; if(ka[i]>kb[i])return 1; } return 0; }) + .map(it=>this.row(it,prim,now)); } + row(it,prim,now){ + const rem=this.remaining(it,now), paused=this.isPaused(it); + let disp, sub='', warn=false, prog=null, glyph; + if(it.type==='alarm'){ disp=fmtClock(it.target); sub='at '+fmtClock(it.target); prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.alarm; } + else if(it.type==='pomodoro'){ disp=fmtTime(rem); sub=`${it.phase} · cycle ${it.cycle}/${it.interval}`; prog=Math.max(0,Math.min(1,rem/it.total)); + glyph=(it.phase==='work')?GL.pomo_work:GL.pomo_break; } + else if(it.type==='stopwatch'){ disp=fmtTime(rem); sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running'; glyph=GL.stopwatch; } + else { disp=fmtTime(rem); sub='timer'; prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.timer; } + if(prog!=null && rem<=Math.min(30, it.total*0.15)) warn=true; + if(paused) glyph=GL.paused; + return {id:it.id, type:it.type, glyph, label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type], + typeLabel:it.type, disp, sub, paused, primary:it.id===prim, prog, warn, laps:it.laps?it.laps.length:0}; + } + + /* advance fired items; returns list of fire events for the view to flash/notify */ + tick(){ + const now=this.now(); const fired=[]; + for(const it of this.items.slice()){ + if(!COUNTDOWN.includes(it.type) || this.isPaused(it)) continue; + if(it.target-now>0) continue; + if(it.type==='pomodoro'){ + if(it.phase==='work'){ + const isLong = it.cycle % it.interval === 0; + fired.push({id:it.id, kind:'pomo', title:`Pomodoro · ${isLong?'long':'short'} break`, body:it.label||`cycle ${it.cycle}`}); + it.phase = isLong?'long':'short'; const len = isLong?POMO.long:POMO.short; it.target=now+len; it.total=len; + } else if(it.phase==='long'){ + fired.push({id:it.id, kind:'done', title:'Pomodoro complete', body:it.label||`${it.interval} cycles done`}); + this.cancel(it.id); + } else { // short break over → next work + it.cycle+=1; fired.push({id:it.id, kind:'pomo', title:'Pomodoro · back to work', body:it.label||`cycle ${it.cycle}`}); + it.phase='work'; it.target=now+POMO.work; it.total=POMO.work; + } + } else { + fired.push({id:it.id, kind:'done', title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label|| (it.type==='alarm'?fmtClock(it.target):'done')), body:'time’s up'}); + this.cancel(it.id); + } + } + return fired; + } + /* presets */ + presetsFor(t){ return (this.presets[t]||[]).map(p=>({...p})); } + addPreset(t,label,value){ if(!TYPES.includes(t)) return {ok:false,reason:'bad type'}; + if(t==='timer' && parseDuration(value)==null) return {ok:false,reason:'bad duration'}; + (this.presets[t]||(this.presets[t]=[])).push({label,value}); return {ok:true}; } + deletePreset(t,label){ const a=this.presets[t]||[]; const i=a.findIndex(p=>p.label===label); if(i<0) return {ok:false}; a.splice(i,1); return {ok:true}; } +} + +/* ---------- browser notification (best-effort, mirrors the notify path) ---------- */ +let notifPerm = (typeof Notification!=='undefined') ? Notification.permission : 'denied'; +function tryNotify(title, body){ + if(typeof Notification==='undefined') return; + if(notifPerm==='granted'){ try{ new Notification(title,{body}); }catch(e){} } + else if(notifPerm==='default'){ Notification.requestPermission().then(p=>notifPerm=p); } +} + +/* ---------- toast helper ---------- */ +function toaster(host){ + const wrap=el('div','toasts'); host.appendChild(wrap); + return (msg,kind)=>{ const t=el('div','toast'+(kind?' '+kind:''),msg); wrap.appendChild(t); + setTimeout(()=>{ t.style.transition='opacity .3s'; t.style.opacity='0'; setTimeout(()=>t.remove(),300); }, 2600); }; +} + +/* ---------- create-strip controller (shared by all three views) ---------- */ +function makeCreate(engine, toast, rerender, opts){ + opts=opts||{}; + const box=el('div','create'); + const seg=el('div','seg'); + TYPES.forEach(t=>{ const b=el('button',t==='timer'?'on':'', t[0].toUpperCase()+t.slice(1)); b.dataset.t=t; seg.appendChild(b); }); + const chips=el('div','chips'); + const row=el('div','row'); + const val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2'; + const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2'; + const addk=el('button','key on', GL.add+' ADD'); addk.style.flex='1'; + row.append(val,lab,addk); + box.append(seg,chips,row); + + let selType='timer'; + function paintChips(){ + chips.innerHTML=''; + engine.presetsFor(selType).forEach(p=>{ + const c=el('span','chip', p.label + (opts.editablePresets?` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`:'')); + c.dataset.val=p.value; chips.appendChild(c); + }); + if(opts.editablePresets){ const c=el('span','chip','+ chip'); c.dataset.newchip='1'; c.style.opacity='.7'; chips.appendChild(c); } + // type-specific value affordance + val.disabled = (selType==='stopwatch'||selType==='pomodoro'); + val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour' + : selType==='stopwatch' ? 'no value — just ADD' + : selType==='pomodoro' ? 'default 25/5 cycle' : '5m · 1h30m · 90s'; + if(val.disabled) val.value=''; + } + seg.addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b) return; + selType=b.dataset.t; [...seg.children].forEach(x=>x.classList.toggle('on',x===b)); val.classList.remove('bad'); paintChips(); }); + chips.addEventListener('click', e=>{ + const del=e.target.closest('[data-del]'); + if(del){ engine.deletePreset(selType, decodeURIComponent(del.dataset.del)); paintChips(); toast('preset removed','gold'); return; } + if(e.target.closest('[data-newchip]')){ + const lb=prompt('Chip label (e.g. 45m):'); if(!lb) return; + let vv=lb; if(selType==='timer'||selType==='alarm'){ vv=prompt('Value for “'+lb+'” (e.g. 45m):', lb)||lb; } + const r=engine.addPreset(selType, lb, vv); paintChips(); toast(r.ok?'preset added':('preset: '+r.reason), r.ok?'gold':'red'); return; + } + const c=e.target.closest('.chip'); if(!c||c.dataset.val==null) return; + val.classList.remove('bad'); val.value=c.dataset.val; + doAdd(); + }); + function doAdd(){ + const r=engine.add(selType, val.value, lab.value.trim()); + if(!r.ok){ val.classList.add('bad'); toast(r.reason,'red'); return; } + val.classList.remove('bad'); if(!val.disabled) val.value=''; lab.value=''; + toast('added '+selType, 'gold'); rerender(); + } + addk.addEventListener('click', doAdd); + val.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); }); + lab.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); }); + paintChips(); + return box; +} + +/* =================================================================== */ +/* VIEW A — RACK UNIT */ +/* =================================================================== */ +function mountRack(host, engine){ + const toast=toaster(host); + const head=el('div','phead', + `<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm', GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px'; + head.appendChild(clear); + clear.addEventListener('click', ()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); }); + const list=el('div','qlist'); + host.append(head,list); + const create=makeCreate(engine, toast, ()=>render(), {editablePresets:true}); + host.appendChild(create); + + const flashing=new Set(); + list.addEventListener('click', e=>{ + const b=e.target.closest('[data-act]'); if(!b) return; + const id=b.dataset.id, act=b.dataset.act; + if(act==='toggle') engine.toggle(id); + else if(act==='promote') engine.promote(id); + else if(act==='cancel'){ + if(b.dataset.armed){ engine.cancel(id); toast('cancelled'); } + else { b.dataset.armed='1'; b.classList.add('armed'); b.textContent='sure?'; setTimeout(()=>{ if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;} },2000); return; } + } + else if(act==='lap'){ engine.lap(id); toast('lap recorded'); } + else if(act==='stop'){ const run=engine.stopSave(id); if(run) toast(`saved “${run.label}” · ${run.laps.length} laps → org`,'gold'); } + render(); + }); + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(); + list.innerHTML=''; + if(!rows.length){ list.appendChild(el('div','empty','No timers running — pick a type below and ADD.')); return; } + rows.forEach(r=>{ + const row=el('div','qrow'+(r.primary?' prim':'')+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':'')); + const ctrls = r.type==='stopwatch' + ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button> + <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>` + : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`; + row.innerHTML= + `<span class="lamp ${r.paused?'off':(r.primary?'gold':(r.warn?'red':''))}"></span> + <span class="g">${r.glyph}</span> + <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span> + <span class="rd">${r.disp}</span> + <span class="ctrls"> + ${ctrls} + <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot" ${r.primary?'disabled style=opacity:.4':''}>${GL.promote}</button> + <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button> + </span>`; + list.appendChild(row); + }); + } + engine._render=render; + engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>{flashing.delete(id);},1800); }; + engine._toast=toast; + render(); +} + +/* =================================================================== */ +/* VIEW B — TRANSPORT DECK */ +/* =================================================================== */ +function mountTransport(host, engine){ + const toast=toaster(host); + const head=el('div','phead', + `<span class="brand">Timer · Transport</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm',GL.clear+' CLEAR'); clear.style.marginLeft='8px'; + clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); }); + head.appendChild(clear); + const hero=el('div','hero'); + const tracks=el('div','tracks'); + host.append(head,hero,tracks); + const create=makeCreate(engine,toast,()=>render(),{editablePresets:true}); + host.appendChild(create); + + const flashing=new Set(); + function act(fn){ return e=>{ fn(); render(); }; } + hero.addEventListener('click', e=>{ const b=e.target.closest('[data-act]'); if(!b) return; const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');} + else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');} + else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}” · ${r.laps.length} laps`,'gold');} + render(); }); + tracks.addEventListener('click', e=>{ + const x=e.target.closest('[data-cancel]'); if(x){ engine.cancel(x.dataset.cancel); toast('cancelled'); render(); return; } + const t=e.target.closest('[data-id]'); if(t){ engine.promote(t.dataset.id); render(); } }); + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(); const primId=engine.effectivePrimary(); + // hero = the primary row + const h = rows.find(r=>r.id===primId); + hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':''); + if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers — add one below to load the deck.</div>'; } + else { + const ringP = h.prog!=null ? Math.round(h.prog*100) : (h.type==='stopwatch'? 100 : 0); + const ringInner = h.type==='stopwatch' + ? `<b style="color:var(--slate-hi);font-size:11px">SW</b>` + : `<b style="color:var(--cream);font-size:15px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`; + const transport = h.type==='stopwatch' + ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button> + <button class="key" data-act="lap" data-id="${h.id}">LAP</button> + <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>` + : `<button class="key icon" data-act="cycle" data-dir="prev">${'‹'}</button> + <button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button> + <button class="key red icon" data-act="cancel" data-id="${h.id}">${GL.cancel}</button> + <button class="key icon" data-act="cycle" data-dir="next">${'›'}</button>`; + hero.innerHTML= + `<div class="lhs"><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:88px;height:88px">${ringInner}</span></div> + <div class="rhs"> + <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span> + ${h.primary?'<span class="badge">BAR SLOT</span>':''}</div> + <div class="hlabel">${h.label}</div> + <div class="hbig">${h.disp}</div> + <div class="hsub">${h.sub}</div> + <div class="transport">${transport}</div> + </div>`; + } + // track list = everything except the hero + tracks.innerHTML=''; + const rest=rows.filter(r=>r.id!==primId); + if(rest.length){ tracks.appendChild(el('div','engrave','up next <span class="cnt">· '+rest.length+'</span>')); } + rest.forEach(r=>{ + const t=el('div','track'+(r.paused?' paused':''), + `<span class="g">${r.glyph}</span><b>${r.label}</b> + <span class="trd">${r.disp}</span> + <span class="tx" data-cancel="${r.id}" title="cancel">${GL.cancel}</span>`); + t.dataset.id=r.id; tracks.appendChild(t); + }); + } + engine._render=render; + engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); }; + engine._toast=toast; + render(); +} + +/* =================================================================== */ +/* VIEW C — CHANNEL STRIP BOARD */ +/* =================================================================== */ +function mountBoard(host, engine){ + const toast=toaster(host); + const head=el('div','phead', + `<span class="brand">Timer · Board</span><span class="pcount">channels <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px'; + clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); }); + head.appendChild(clear); + const board=el('div','board'); + host.append(head,board); + + // create controls live in the trailing add-strip; build once, reuse the shared controller inside it + const addStrip=el('div','strip addstrip'); + const create=makeCreate(engine,toast,()=>render(),{editablePresets:true}); + create.style.margin='0'; create.style.background='transparent'; create.style.border='0'; create.style.padding='0'; create.style.width='100%'; + addStrip.append(el('div','styp','+ new channel'), create); + + board.addEventListener('click', e=>{ + const b=e.target.closest('[data-act]'); + if(b){ const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');} + else if(a==='lap'){engine.lap(id);toast('lap');} else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}”`,'gold');} + render(); return; } + const h=e.target.closest('[data-promote]'); if(h){ engine.promote(h.dataset.promote); render(); } + }); + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(); + board.innerHTML=''; + rows.forEach(r=>{ + const strip=el('div','strip'+(r.primary?' prim':'')+(r.paused?' paused':'')); + const pct = r.prog!=null ? Math.round(r.prog*100) : 100; + const colCls = 'column'+(r.type==='stopwatch'?' sw':'')+(r.warn?' warn':''); + const fillH = r.type==='stopwatch' ? 100 : pct; + const keys = r.type==='stopwatch' + ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button> + <button class="key sm red" data-act="stop" data-id="${r.id}">${GL.cancel}</button>` + : `<button class="key icon" data-act="toggle" data-id="${r.id}">${r.paused?GL.play:GL.paused}</button> + <button class="key icon red" data-act="cancel" data-id="${r.id}">${GL.cancel}</button>`; + strip.innerHTML= + `<div class="stitle" data-promote="${r.id}" title="promote to bar slot"> + <span class="g">${r.glyph}</span><b>${r.label}</b></div> + <div class="styp">${r.typeLabel}${r.primary?' · bar':''}</div> + <div class="${colCls}"><div class="fill" style="height:${fillH}%"></div> + ${r.type!=='stopwatch'?`<div class="cap" style="bottom:${fillH}%"></div>`:''}</div> + <div class="srd">${r.disp}</div> + <div class="skeys">${keys}</div>`; + board.appendChild(strip); + }); + board.appendChild(addStrip); + } + engine._render=render; + engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); }; + const flashing=new Set(); + engine._flashSet=flashing; + engine._toast=toast; + render(); +} + +/* ---------- seed + wire the three panels ---------- */ +function seed(engine){ + engine.add('pomodoro','', 'Deep work'); + engine.add('timer','5m','Tea'); + engine.add('timer','45s','Egg'); // fires ~45s in, demonstrates completion + notify + const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id); + engine.add('alarm','@hour','Standup'); +} + +const engines=[]; +function boot(){ + const A=new Engine(), B=new Engine(), C=new Engine(); + seed(A); seed(B); seed(C); + mountRack(document.getElementById('panelA'), A); + mountTransport(document.getElementById('panelB'), B); + mountBoard(document.getElementById('panelC'), C); + engines.push(A,B,C); +} +boot(); + +/* ---------- global tick: fire timers, flash + notify, re-render ---------- */ +function loop(){ + for(const e of engines){ + const fired=e.tick(); + for(const f of fired){ + e._flash && e._flash(f.id); + if(e._flashSet) e._flashSet.add(f.id), setTimeout(()=>e._flashSet.delete(f.id),1800); + e._toast && e._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold'); + tryNotify(f.title, f.body); + } + e._render && e._render(); + } +} +setInterval(loop, reduced?1000:250); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-2.html b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html new file mode 100644 index 0000000..ffd4521 --- /dev/null +++ b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html @@ -0,0 +1,553 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Timer panel — hero + rack (iteration 2) · dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1100px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:92ch} +.masthead p b{color:var(--silver)} +.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.6rem;flex-wrap:wrap} +.side{flex:1;min-width:300px} +.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem; + display:flex;align-items:center;gap:10px} +.side h2::after{content:"";height:1px;background:var(--wash);flex:1} +.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px} +.side li{display:flex;gap:9px} +.side li::before{content:"›";color:var(--gold);flex:none} +.side li b{color:var(--silver);font-weight:400} + +/* ---------- faceplate ---------- */ +.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + border-radius:14px;padding:15px;position:relative; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)} +.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase} +.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em} +.phead .pcount b{color:var(--cream)} + +/* ---------- primitives ---------- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key:disabled{opacity:.4;cursor:default} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px} +.key.icon{padding:6px 9px;font-size:14px;line-height:1} + +.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210; + border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center} +.chip:hover{color:var(--silver);border-color:var(--slate)} +.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.chip.lock{padding-right:10px} +.chip .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px} +.chip .x:hover{opacity:1;color:var(--fail)} + +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.dim{background:var(--wash);color:var(--steel)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} + +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b; + padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} + +.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:20px;background:var(--gold)} + +.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase; + display:flex;align-items:center;gap:9px;margin:2px 0} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:10px} +.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} + +.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px; + padding:7px 9px;width:100%;outline:none} +.tin:focus{border-color:var(--gold)} +.tin::placeholder{color:var(--dim)} +.tin.bad{border-color:var(--fail);color:var(--fail)} +.tin:disabled{opacity:.45} +.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px; + padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums} +.numin:focus{border-color:var(--gold)} + +.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; + border-radius:7px;padding:6px 9px} +.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)} + +.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative} +.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)} +.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)} +.ring b{position:relative;z-index:1;text-align:center} + +.dots{display:flex;gap:4px;align-items:center} +.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none} +.dots i.on{background:var(--steel)} +.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.dots i.long{outline:1px solid var(--gold);outline-offset:1px} + +/* ---------- HERO (top) ---------- */ +.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center;margin-bottom:12px} +.hero.fire{animation:firef .6s ease-in-out 3} +@keyframes firef{50%{background:rgba(203,107,77,.22)}} +.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px} +.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.hero .htype .g{color:var(--gold);font-size:17px} +.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums} +.hero.paused .hbig{color:var(--steel)} +.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em} +.transport{display:flex;gap:7px;margin-top:3px;flex-wrap:wrap} + +/* ---------- CREATE (middle) ---------- */ +.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px} +.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap} +.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px} +.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px} +.cfg .crow{display:flex;align-items:center;gap:8px} +.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none} +.cfg .crow .u{color:var(--dim);font-size:10px} +.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px} + +/* ---------- LIST (bottom) ---------- */ +.qlist{display:flex;flex-direction:column;gap:8px} +.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px} +.qrow.fire{animation:firef .6s ease-in-out 3} +.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none} +.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px} +.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px} +.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase} +.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap} +.qrow.paused .rd{color:var(--steel)} +.qrow .ctrls{display:flex;gap:5px;flex:none} +.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px} + +/* toast */ +.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5} +.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease} +.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)} +.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · timer · iteration 2</div> + <h1>Timer panel — hero + rack</h1> + <p>The rack unit reshaped: the <b>hero</b> from the transport deck rides on top (the primary / bar-slot item, big), + the <b>create strip</b> sits under it, and the <b>queue list</b> runs below. Pomodoro is now a real configurable cycle — + work and rest each with a short and a long duration, a long break every N cycles, auto-advance, and progress dots — + with its default preset locked so it can't be deleted. Everything is live: add, count down, fire + notify, pause, promote, + lap / stop-save. Ideas pulled from Pomofocus, Todoist, and the classic technique (see the notes column).</p> +</header> + +<div class="cols"> + <div class="panel" id="panel"></div> + <div class="side"> + <h2>What changed this pass</h2> + <ul> + <li><b>Layout flipped:</b> hero on top → create strip → list (was list → create).</li> + <li><b>Pomodoro is configurable:</b> Work short/long, Rest short/long, long break every N, auto-advance toggle.</li> + <li><b>Deep cycle:</b> every Nth pomodoro uses the long work + long rest; the rest fill mark the long dots.</li> + <li><b>Default cycle is locked</b> — shipped presets have no ×; only chips you add are deletable.</li> + <li><b>Cycle dots</b> in the hero + row show where you are in the set.</li> + </ul> + <h2 style="margin-top:1.6rem">Borrowed from good pomodoro apps</h2> + <ul> + <li><b>Pomofocus:</b> separate work / short-break / long-break lengths + long-break interval.</li> + <li><b>Auto-start next</b> (Pomofocus, Pomodo): auto-advance rolls into the next phase; off = wait and press start.</li> + <li><b>Todoist / the technique:</b> long break of 15–30m after 4 pomodoros; all durations adjustable.</li> + <li><b>Preset cycles:</b> Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10 — one tap loads the fields.</li> + <li><b>Task label</b> on every item; cycle progress shown as dots.</li> + </ul> + </div> +</div> + +</div> + +<script> +"use strict"; +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;}; + +const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', + pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}', + play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}' }; + +/* ---- parsers ---- */ +function parseDuration(v){ + if(v==null) return null; v=String(v).trim().toLowerCase(); if(v==='') return null; + if(/^\d+$/.test(v)) return parseInt(v,10)*60; + if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null; + let m,tot=0; const re=/(\d+)\s*([hms])/g; + while((m=re.exec(v))) tot+= m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1]; + return tot>0?tot:null; +} +function resolveAlarm(v,now){ + v=String(v||'').trim().toLowerCase(); + if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;} + if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;} + const t=v.match(/^(\d{1,2}):(\d{2})$/); + if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;} + return null; +} +const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60; + return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;}; +const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;}; + +/* ---- presets: shipped defaults are locked (no delete) ---- */ +const DEFAULT_PRESETS=()=>({ + timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true}, + {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}], + alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'07:00',value:'07:00',locked:true}], + stopwatch:[] +}); +/* pomodoro preset = a full config (minutes). "Classic" is the default cycle — locked. */ +const POMO_PRESETS=[ + {label:'Classic', ws:25, wl:50, rs:5, rl:15, iv:4, locked:true}, + {label:'Deep', ws:50, wl:50, rs:10, rl:30, iv:3, locked:true}, + {label:'Sprint', ws:15, wl:25, rs:3, rl:10, iv:4, locked:true}, +]; +const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}; + +const TYPES=['timer','alarm','stopwatch','pomodoro']; +const COUNTDOWN=['timer','alarm','pomodoro']; +const MAX=10; + +class Engine{ + constructor(){ this.items=[]; this.seq=0; this.primary=null; + this.presets=DEFAULT_PRESETS(); this.pomoPresets=POMO_PRESETS.map(p=>({...p})); } + now(){return Date.now()/1000;} + count(){return this.items.length;} + full(){return this.items.length>=MAX;} + add(type,value,label,cfg){ + if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`}; + const now=this.now(); this.seq++; const id='t'+this.seq; const it={id,type,label:label||''}; + if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;} + else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`};it.target=e;it.total=Math.max(1,e-now);} + else if(type==='pomodoro'){ + const c=cfg||POMO_DEFAULT; + it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false}; + it.phase='work'; it.cycle=1; const deep=(1%it.cfg.iv===0); + const len=deep?it.cfg.wl:it.cfg.ws; it.target=now+len; it.total=len; + } + else if(type==='stopwatch'){it.start=now;it.laps=[];} + else return {ok:false,reason:`unknown type: ${type}`}; + this.items.push(it); if(!this.primary) this.primary=id; return {ok:true,id}; + } + find(id){return this.items.find(i=>i.id===id);} + isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;} + remaining(it,ref){ref=ref==null?this.now():ref; + if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start; + return this.isPaused(it)?it.paused_left:it.target-ref;} + toggle(id){const it=this.find(id);if(!it)return;const now=this.now(); + if(it.type==='stopwatch'){ if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;} else it.paused_elapsed=now-it.start; } + else { if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;} else it.paused_left=it.target-now; } } + cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;} + cancelAll(){this.items=[];this.primary=null;} + promote(id){if(this.find(id))this.primary=id;} + cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c; + this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];} + lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});} + stopSave(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return null;const run={label:it.label||'run',total:this.remaining(it),laps:it.laps.slice()};this.cancel(id);return run;} + effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id); + if(ids.includes(this.primary))return this.primary;const now=this.now(); + const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i)); + if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id; + const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];} + sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now); + return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];} + rows(){const prim=this.effectivePrimary(),now=this.now(); + return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b); + for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));} + row(it,prim,now){ + const rem=this.remaining(it,now),paused=this.isPaused(it); + let disp,sub='',warn=false,prog=null,glyph,pomo=null; + if(it.type==='alarm'){disp=fmtClock(it.target);sub='fires '+fmtClock(it.target);prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;} + else if(it.type==='pomodoro'){ + disp=fmtTime(rem); prog=Math.max(0,Math.min(1,rem/it.total)); + const deep=(it.cycle%it.cfg.iv===0); + const phLabel = it.phase==='work' ? (deep?'long work':'work') + : it.phase==='rest' ? ((it.cycle%it.cfg.iv===0)?'long break':'short break') : it.phase; + sub = it.awaiting ? `ready · start ${it.phase==='work'?'work':'break'}` : `${phLabel} · cycle ${it.cycle}/${it.cfg.iv}`; + glyph = it.phase==='work' ? GL.pomo_work : GL.pomo_break; + pomo={cycle:it.cycle, iv:it.cfg.iv, phase:it.phase, awaiting:!!it.awaiting, deep}; + } + else if(it.type==='stopwatch'){disp=fmtTime(rem);sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;} + else {disp=fmtTime(rem);sub='timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;} + if(prog!=null && rem<=Math.min(30,it.total*0.15)) warn=true; + if(paused) glyph=GL.paused; + return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type], + typeLabel:it.type,disp,sub,paused,primary:it.id===prim,prog,warn,pomo,laps:it.laps?it.laps.length:0}; + } + tick(){ + const now=this.now(),fired=[]; + for(const it of this.items.slice()){ + if(!COUNTDOWN.includes(it.type)||this.isPaused(it))continue; + if(it.target-now>0)continue; + if(it.type==='pomodoro'){ + const c=it.cfg; + if(it.phase==='work'){ + const deep=(it.cycle%c.iv===0); + fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`}); + it.phase='rest'; const len=deep?c.rl:c.rs; it.total=len; + if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; } + } else { // rest over → next work + it.cycle+=1; const deep=(it.cycle%c.iv===0); + fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`}); + it.phase='work'; const len=deep?c.wl:c.ws; it.total=len; + if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; } + } + } else { + fired.push({id:it.id,kind:'done',title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label||(it.type==='alarm'?fmtClock(it.target):'done')),body:'time’s up'}); + this.cancel(it.id); + } + } + return fired; + } + presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));} + addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom chip here'}; + if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'}; + (this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};} + deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label); + if(i<0)return {ok:false,reason:'not found'}; if(a[i].locked)return {ok:false,reason:'default — locked'}; + a.splice(i,1);return {ok:true};} +} + +/* ---- notifications ---- */ +let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied'; +function tryNotify(title,body){ if(typeof Notification==='undefined')return; + if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}} + else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);} } +function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap); + return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t); + setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};} + +function dotsHTML(p){ if(!p)return ''; let h='<span class="dots">'; const pos=(p.cycle-1)%p.iv; + for(let i=0;i<p.iv;i++){ const isLong=(i===p.iv-1); let cls=''; if(i<pos)cls='on'; if(i===pos)cls='now'; if(isLong)cls+=' long'; + h+=`<i class="${cls.trim()}"></i>`; } return h+'</span>'; } + +/* =================================================================== */ +/* THE PANEL: hero (top) · create (middle) · list (bottom) */ +/* =================================================================== */ +function mount(host, engine){ + const toast=toaster(host); + const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px'; + clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();}); + head.appendChild(clear); + const hero=el('div','hero'); + const create=buildCreate(); + const list=el('div','qlist'); + host.append(head,hero,create.box,list); + + const flashing=new Set(); + + /* ---- hero + list interactions ---- */ + hero.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');} + else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');} + else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');} + render();}); + list.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='promote'){engine.promote(id);toast('to bar slot');} + else if(a==='lap'){engine.lap(id);toast('lap recorded');} else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');} + else if(a==='cancel'){ if(b.dataset.armed){engine.cancel(id);toast('cancelled');} + else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;} } + render();}); + + /* ---- create strip (swaps body by type) ---- */ + function buildCreate(){ + const box=el('div','create'); + const seg=el('div','seg'); + TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);}); + const body=el('div'); + box.append(seg,body); + let selType='timer'; + // shared value/label/add controls (rebuilt per type) + seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return; + selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();}); + function labelAdd(hasVal){ + const row=el('div','row'); + let val=null; + if(hasVal){ val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2'; } + const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2'; + const addk=el('button','key on',GL.add+' ADD'); addk.style.flex='1'; + if(val) row.append(val); row.append(lab,addk); return {row,val,lab,addk}; + } + function paintBody(){ + body.innerHTML=''; + if(selType==='timer'||selType==='alarm'){ + const chips=el('div','chips'); + engine.presetsFor(selType).forEach(p=>{ + const c=el('span','chip'+(p.locked?' lock':''), p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`)); + c.dataset.val=p.value; chips.appendChild(c); + }); + const addch=el('span','chip','+ chip'); addch.dataset.newchip='1'; addch.style.opacity='.7'; chips.appendChild(addch); + const {row,val,lab,addk}=labelAdd(true); + val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour' : '5m · 1h30m · 90s'; + body.append(chips,row); + chips.addEventListener('click',e=>{ + const del=e.target.closest('[data-del]'); + if(del){const r=engine.deletePreset(selType,decodeURIComponent(del.dataset.del));toast(r.ok?'chip removed':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;} + if(e.target.closest('[data-newchip]')){const lb=prompt('Chip label:');if(!lb)return; + const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset(selType,lb,vv);toast(r.ok?'chip added':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;} + const c=e.target.closest('.chip');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;doAdd(selType,val,lab); + }); + addk.addEventListener('click',()=>doAdd(selType,val,lab)); + [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd(selType,val,lab);})); + } + else if(selType==='stopwatch'){ + const {row,lab,addk}=labelAdd(false); // no time entry — stopwatches count up from zero + body.append(row); + addk.addEventListener('click',()=>doAdd('stopwatch',null,lab)); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);}); + } + else { // pomodoro config + const chips=el('div','chips'); + engine.pomoPresets.forEach(p=>{const c=el('span','chip lock',p.label);c.dataset.pp=p.label;chips.appendChild(c);}); + const cfg=el('div','cfg'); + const mk=(v)=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;}; + const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv); + const auto=el('span','switch on'); auto.dataset.on='1'; + const rW=el('div','crow'); rW.append(el('span','lbl','Work'), el('span','sl','S'), ws, el('span','sl','L'), wl, el('span','u','min')); + const rR=el('div','crow'); rR.append(el('span','lbl','Rest'), el('span','sl','S'), rs, el('span','sl','L'), rl, el('span','u','min')); + const rI=el('div','crow'); rI.append(el('span','lbl','Long ev.'), iv, el('span','u','cycles → long work + long break')); + const rA=el('div','crow'); rA.append(el('span','lbl','Auto'), auto, el('span','u','advance into the next phase')); + cfg.append(rW,rR,rI,rA); + const {row,lab,addk}=labelAdd(false); lab.placeholder='label (optional)'; // pomodoro has no single value entry — config fields above + addk.innerHTML=GL.add+' ADD CYCLE'; + body.append(chips,cfg,row); + auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';}); + chips.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return; + const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return; + ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv; + [...chips.children].forEach(x=>x.classList.toggle('on',x===c));toast('loaded “'+p.label+'”','gold');}); + function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;} + addk.addEventListener('click',()=>{ + const cfgv={ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on}; + const r=engine.add('pomodoro','',lab.value.trim(),cfgv); + if(!r.ok){toast(r.reason,'red');return;} lab.value=''; toast('pomodoro added','gold'); render(); + }); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();}); + } + } + function doAdd(type,val,lab){ + const r=engine.add(type,val?val.value:'',lab.value.trim()); + if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;} + if(val){val.classList.remove('bad'); if(!val.disabled)val.value='';} lab.value=''; + toast('added '+type,'gold'); render(); + } + paintBody(); + return {box}; + } + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(), primId=engine.effectivePrimary(); + // HERO = primary + const h=rows.find(r=>r.id===primId); + hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':''); + if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>'; } + else { + const ringP=h.prog!=null?Math.round(h.prog*100):(h.type==='stopwatch'?100:0); + const inner=h.type==='stopwatch'?`<b style="color:var(--slate-hi);font-size:11px">SW</b>` + :`<b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`; + const startLabel = h.pomo&&h.pomo.awaiting ? (GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')) : (h.paused?GL.play+' RESUME':GL.paused+' PAUSE'); + const transport = h.type==='stopwatch' + ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button> + <button class="key" data-act="lap" data-id="${h.id}">LAP</button> + <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>` + : `<button class="key icon" data-act="cycle" data-dir="prev" title="prev primary">‹</button> + <button class="key" data-act="toggle" data-id="${h.id}">${startLabel}</button> + <button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button> + <button class="key icon" data-act="cycle" data-dir="next" title="next primary">›</button>`; + hero.innerHTML= + `<div><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px">${inner}</span></div> + <div class="rhs"> + <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span> + <span class="badge">BAR SLOT</span>${h.pomo?dotsHTML(h.pomo):''}</div> + <div class="hlabel">${h.label}</div> + <div class="hbig">${h.disp}</div> + <div class="hsub">${h.sub}</div> + <div class="transport">${transport}</div> + </div>`; + } + // LIST = the rest + list.innerHTML=''; + const rest=rows.filter(r=>r.id!==primId); + if(!rest.length){ list.appendChild(el('div','empty', h?'Only the hero is queued — add more below.':'')); } + list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>')); + rest.forEach(r=>{ + const row=el('div','qrow'+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':'')); + const ctrls = r.type==='stopwatch' + ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button> + <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>` + : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`; + row.innerHTML= + `<span class="lamp ${r.paused?'off':(r.warn?'red':'')}"></span> + <span class="g">${r.glyph}</span> + <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}${r.pomo?' ':''}</span></span> + <span class="rd">${r.disp}</span> + <span class="ctrls">${ctrls} + <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button> + <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button></span>`; + list.appendChild(row); + }); + } + engine._render=render; engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);}; engine._toast=toast; + render(); +} + +/* ---- seed ---- */ +const engine=new Engine(); +const pomo=engine.add('pomodoro','', 'Deep work', {ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}); +engine.add('timer','45s','Egg'); +engine.add('timer','5m','Tea'); +const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id); +engine.add('alarm','@hour','Standup'); +engine.promote(pomo.id); // show the pomodoro in the hero +mount(document.getElementById('panel'), engine); + +/* ---- global tick ---- */ +function loop(){ + const fired=engine.tick(); + for(const f of fired){ engine._flash(f.id); + engine._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold'); tryNotify(f.title,f.body); } + engine._render(); +} +setInterval(loop, reduced?1000:250); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-3.html b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html new file mode 100644 index 0000000..98778fa --- /dev/null +++ b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html @@ -0,0 +1,556 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Timer panel — iteration 3 (waybar + hero-right) · dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; --sage:#8a9a5b; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1120px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:94ch} +.masthead p b{color:var(--silver)} +.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.4rem;flex-wrap:wrap} +.side{flex:1;min-width:300px} +.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem;display:flex;align-items:center;gap:10px} +.side h2::after{content:"";height:1px;background:var(--wash);flex:1} +.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px} +.side li{display:flex;gap:9px}.side li::before{content:"›";color:var(--gold);flex:none} +.side li b{color:var(--silver);font-weight:400} + +/* ---------- waybar preview ---------- */ +.barcap{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;margin-bottom:7px;display:flex;align-items:center;gap:9px} +.barcap::after{content:"";height:1px;background:var(--wash);flex:1} +.wbar{width:420px;background:linear-gradient(180deg,#141312,#0e0d0c);border:1px solid #262320;border-radius:16px; + padding:6px 10px;display:flex;align-items:center;gap:8px;box-shadow:0 8px 20px rgba(0,0,0,.5);margin-bottom:6px} +.wbar .fillspace{flex:1;color:var(--dim);font-size:10px;letter-spacing:.1em;padding-left:4px} +.wmod{display:inline-flex;align-items:center;gap:7px;color:var(--silver);background:transparent;border:1.5px solid var(--gold); + border-radius:14px;padding:4px 12px;cursor:pointer;font-size:12.5px;white-space:nowrap;min-width:78px;justify-content:center} +.wmod:hover{background:var(--wash)} +.wmod .wg{font-size:16px;line-height:1} +.wmod .wt{font-variant-numeric:tabular-nums;font-weight:700} +.wmod .wp{color:var(--dim);font-size:11px} +.wmod.urgent{color:var(--fail)} .wmod.paused{color:var(--dim)} +.wmod.pomodoro-work{color:var(--gold)} .wmod.pomodoro-break{color:var(--sage)} +.wmod.idle{color:var(--silver);min-width:0;border-color:#4a463c} +.wtip{width:420px;background:var(--well);border:1px solid #201d17;border-radius:8px;padding:7px 10px;font-size:11px;color:var(--dim);margin-bottom:16px} +.wtip .th{color:var(--steel);letter-spacing:.14em;text-transform:uppercase;font-size:.56rem;margin-bottom:3px} +.wtip .tl{color:var(--silver);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* ---------- faceplate ---------- */ +.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + border-radius:14px;padding:15px;position:relative;box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)} +.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase} +.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em} +.phead .pcount b{color:var(--cream)} +.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto} +.x-btn:hover{background:var(--wash);color:var(--silver)} +.panel.closed{display:none} +.wbar.reopen{outline:1px dashed var(--slate);outline-offset:2px} + +/* ---------- primitives ---------- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)}.key:active{transform:translateY(1px)}.key:disabled{opacity:.4;cursor:default} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px} +.key.icon{padding:6px 9px;font-size:14px;line-height:1} +.preset{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center} +.preset:hover{color:var(--silver);border-color:var(--slate)} +.preset.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.preset .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px}.preset .x:hover{opacity:1;color:var(--fail)} +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase} +.badge.red{background:var(--fail);color:var(--cream)}.badge.dim{background:var(--wash);color:var(--steel)}.badge.sage{background:var(--sage);color:var(--panel)} +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} +.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)}.switch.on::after{left:20px;background:var(--gold)} +.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;display:flex;align-items:center;gap:9px;margin:2px 0} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}.engrave::before{max-width:10px}.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} +.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;padding:7px 9px;width:100%;outline:none} +.tin:focus{border-color:var(--gold)}.tin::placeholder{color:var(--dim)}.tin.bad{border-color:var(--fail);color:var(--fail)} +.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px;padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums} +.numin:focus{border-color:var(--gold)} +@keyframes fieldflash{0%{border-color:var(--gold-hi);background:rgba(218,181,61,.22)}100%{border-color:#231f18;background:#0d0f10}} +.tin.flash,.numin.flash{animation:fieldflash .7s ease} +.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;border-radius:7px;padding:6px 9px} +.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)} +.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative} +.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)} +.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)} +.ring b{position:relative;z-index:1;text-align:center} +.dots{display:flex;gap:4px;align-items:center} +.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none} +.dots i.on{background:var(--steel)}.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}.dots i.long{outline:1px solid var(--gold);outline-offset:1px} +.days7{display:flex;gap:4px} +.days7 button{font:inherit;font-size:10px;width:22px;height:22px;border-radius:50%;border:1px solid #33302b;background:#141210;color:var(--dim);cursor:pointer;padding:0} +.days7 button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);border-color:var(--gold-hi);font-weight:700} + +/* ---------- HERO (info left, donut right) ---------- */ +.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;flex-direction:column;gap:13px;margin-bottom:12px} +.hero .htop{display:flex;gap:15px;align-items:center} +.hero.fire{animation:firef .6s ease-in-out 3} +.hero.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)} +@keyframes firef{50%{background:rgba(203,107,77,.22)}} +@keyframes ringf{50%{background:rgba(203,107,77,.16)}} +.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px} +.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.hero .htype .g{color:var(--gold);font-size:17px} +.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;display:flex;align-items:center;gap:11px} +.hero.paused .hbig{color:var(--steel)} +.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em} +.hero .lapbadge{font-size:11px;letter-spacing:.12em;color:var(--silver);border:1px solid var(--slate);border-radius:6px; + padding:2px 8px;font-weight:400;font-variant-numeric:tabular-nums;background:transparent;line-height:1;align-self:center} +/* stopwatch sweep dial — analog second-hand, one revolution per minute */ +.swdial{width:86px;height:86px;border-radius:50%;background:var(--well);border:1px solid #201d17;position:relative;display:block} +.swdial::before{content:"";position:absolute;inset:7px;border-radius:50%;border:2px solid var(--wash)} +.swtick{position:absolute;top:5px;left:50%;width:2px;height:8px;margin-left:-1px;background:var(--steel);border-radius:1px;transform-origin:50% 38px} +.swtick.q{transform:rotate(90deg)}.swtick.h{transform:rotate(180deg)}.swtick.t{transform:rotate(270deg)} +.swhand{position:absolute;left:calc(50% - 1px);bottom:50%;width:2px;height:31px;background:var(--gold-hi); + transform-origin:50% 100%;border-radius:1px;box-shadow:0 0 5px rgba(255,215,95,.5)} +.swhub{position:absolute;left:50%;top:50%;width:9px;height:9px;margin:-4.5px 0 0 -4.5px;border-radius:50%; + background:var(--gold);box-shadow:0 0 0 2px var(--well),0 0 6px rgba(218,181,61,.5)} +.hero .donut{flex:none} +.transport{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-start} + +/* ---------- CREATE (middle) ---------- */ +.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px} +.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap} +.presets{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px} +.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px} +.cfg .crow{display:flex;align-items:center;gap:8px} +.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none} +.cfg .crow .u{color:var(--dim);font-size:10px} +.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px} + +/* ---------- LIST (bottom) ---------- */ +.qlist{display:flex;flex-direction:column;gap:8px} +.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px} +.qrow.fire{animation:firef .6s ease-in-out 3}.qrow.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)} +.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none} +.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px} +.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:104px} +.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.1em;text-transform:uppercase} +.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap} +.qrow.paused .rd{color:var(--steel)} +.qrow .ctrls{display:flex;gap:5px;flex:none} +.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px} +.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5} +.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease} +.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · timer · iteration 3</div> + <h1>Timer panel — waybar + hero-right</h1> + <p>Third pass. The <b>hero donut moved to the right</b> of the readout; the redundant "bar slot" badge is gone + (the hero <i>is</i> the bar slot). Above the panel sits a <b>live, accurate preview of the actual waybar module</b> — + the same glyph + countdown + "+N" and state colours <code>wtimer render</code> emits, with its hover tooltip. + Presets (renamed from "chips") <b>flash the fields</b> on load instead of toasting, alarms gain a <b>half-past</b> preset, + and each type picked up create-strip ideas from its category's best apps: timers <b>repeat</b>, alarms carry + <b>recurring days + snooze</b> (with a ringing state), pomodoro keeps its configurable cycle.</p> +</header> + +<div class="cols"> + <div> + <div class="barcap">the waybar module · live</div> + <div class="wbar" id="wbar"></div> + <div class="wtip" id="wtip"></div> + <div class="panel" id="panel"></div> + </div> + <div class="side"> + <h2>This pass</h2> + <ul> + <li><b>Donut on the right</b> of the hero info; <b>no "bar slot" label</b>.</li> + <li><b>Live waybar preview</b> — mirrors <code>wtimer render</code>: glyph + countdown + "+N", state colour, tooltip.</li> + <li><b>"Preset"</b> replaces "chip"; loading one <b>flashes the field(s)</b>, no toast.</li> + <li><b>Half-past</b> alarm preset — next X:30, the sibling of top-of-hour's X:00.</li> + </ul> + <h2 style="margin-top:1.5rem">Borrowed per category</h2> + <ul> + <li><b>Timer</b> (MultiTimer, Multi Timer): auto-<b>repeat</b> — restart on finish. Toggle in the create row.</li> + <li><b>Alarm</b> (Alarm Clock Xtreme, Alarmy): <b>recurring weekdays</b> + <b>snooze</b>; fires into a ringing state with SNOOZE / DISMISS.</li> + <li><b>Stopwatch</b> (Stopwatch Timer): sweep dial + infinite <b>laps</b> with the last lap beside the count; run-save deferred to a vNext.</li> + <li><b>Pomodoro</b> (Pomofocus): configurable work/rest short+long, long-break interval, auto-advance, cycle dots.</li> + </ul> + </div> +</div> +</div> + +<script> +"use strict"; +const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; +const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;}; + +const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', idle:'\u{F051B}', + pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}', + play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}', repeat:'\u{F0456}', bell:'\u{F0020}' }; + +function parseDuration(v){ if(v==null)return null;v=String(v).trim().toLowerCase();if(v==='')return null; + if(/^\d+$/.test(v))return parseInt(v,10)*60; if(!/^(\s*\d+\s*[hms])+$/.test(v))return null; + let m,tot=0;const re=/(\d+)\s*([hms])/g; while((m=re.exec(v)))tot+=m[2]==='h'?+m[1]*3600:m[2]==='m'?+m[1]*60:+m[1]; return tot>0?tot:null; } +function resolveAlarm(v,now){ v=String(v||'').trim().toLowerCase(); + if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;} + if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;} + if(v==='@half'||v==='half past'||v==='half-past'){const d=new Date(now*1000);d.setSeconds(0,0);d.setMinutes(30);let e=d.getTime()/1000;if(e<=now){d.setHours(d.getHours()+1);e=d.getTime()/1000;}return e;} + const t=v.match(/^(\d{1,2}):(\d{2})$/); if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;} return null; } +function nextAlarm(hh,mm,days,now){ const base=new Date(now*1000); + for(let d=0;d<=7;d++){const c=new Date(base);c.setDate(base.getDate()+d);c.setHours(hh,mm,0,0);const e=c.getTime()/1000; + if(e<=now)continue; if(!days.length||days.includes(c.getDay()))return e;} return now+86400; } +const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60; + return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;}; +const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;}; +const DAYNAMES=['S','M','T','W','T','F','S']; + +const DEFAULT_PRESETS=()=>({ + timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true}, + {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}], + alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'half-past',value:'@half',locked:true},{label:'07:00',value:'07:00',locked:true}], + stopwatch:[] +}); +const POMO_PRESETS=[{label:'Classic',ws:25,wl:50,rs:5,rl:15,iv:4,locked:true},{label:'Deep',ws:50,wl:50,rs:10,rl:30,iv:3,locked:true},{label:'Sprint',ws:15,wl:25,rs:3,rl:10,iv:4,locked:true}]; +const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}; +const TYPES=['timer','alarm','stopwatch','pomodoro']; +const COUNTDOWN=['timer','alarm','pomodoro']; +const MAX=10; + +class Engine{ + constructor(){this.items=[];this.seq=0;this.primary=null;this.presets=DEFAULT_PRESETS();this.pomoPresets=POMO_PRESETS.map(p=>({...p}));} + now(){return Date.now()/1000;} + count(){return this.items.length;} + full(){return this.items.length>=MAX;} + add(type,value,label,opts){ + opts=opts||{}; + if(this.full())return {ok:false,reason:`queue full (${MAX}/${MAX})`}; + const now=this.now();this.seq++;const id='t'+this.seq;const it={id,type,label:label||''}; + if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;it.repeat=!!opts.repeat;} + else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`}; + const d=new Date(e*1000);it.hh=d.getHours();it.mm=d.getMinutes();it.days=(opts.days||[]).slice();it.snooze=opts.snooze||9; + it.target=it.days.length?nextAlarm(it.hh,it.mm,it.days,now):e;it.total=Math.max(1,it.target-now);} + else if(type==='pomodoro'){const c=opts.ws?opts:POMO_DEFAULT;it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false}; + it.phase='work';it.cycle=1;const deep=(1%it.cfg.iv===0);const len=deep?it.cfg.wl:it.cfg.ws;it.target=now+len;it.total=len;} + else if(type==='stopwatch'){it.start=now;it.laps=[];} + else return {ok:false,reason:`unknown type: ${type}`}; + this.items.push(it);if(!this.primary)this.primary=id;return {ok:true,id}; + } + find(id){return this.items.find(i=>i.id===id);} + isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;} + remaining(it,ref){ref=ref==null?this.now():ref;if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start;return this.isPaused(it)?it.paused_left:it.target-ref;} + toggle(id){const it=this.find(id);if(!it)return;const now=this.now(); + if(it.type==='stopwatch'){if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;}else it.paused_elapsed=now-it.start;} + else{if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;}else it.paused_left=it.target-now;}} + cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;} + cancelAll(){this.items=[];this.primary=null;} + promote(id){if(this.find(id))this.primary=id;} + cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c;this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];} + lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});} + snooze(id){const it=this.find(id);if(!it)return;it.ringing=false;const m=it.snooze>0?it.snooze:9;it.target=this.now()+m*60;it.total=m*60;} + dismiss(id){const it=this.find(id);if(!it)return;if(it.days&&it.days.length){it.ringing=false;it.target=nextAlarm(it.hh,it.mm,it.days,this.now());it.total=Math.max(1,it.target-this.now());}else this.cancel(id);} + effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id); + const ring=items.find(i=>i.ringing);if(ring)return ring.id; + if(ids.includes(this.primary))return this.primary;const now=this.now(); + const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i)); + if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id; + const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];} + sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now); + if(it.ringing)return [-1,0,+it.id.slice(1)]; return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];} + rows(){const prim=this.effectivePrimary(),now=this.now(); + return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b);for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));} + daysLabel(it){ if(!it.days||!it.days.length)return 'once'; if(it.days.length===7)return 'daily'; + const wk=[1,2,3,4,5],we=[0,6]; + if(wk.every(d=>it.days.includes(d))&&it.days.length===5)return 'weekdays'; + if(we.every(d=>it.days.includes(d))&&it.days.length===2)return 'weekends'; + return it.days.slice().sort().map(d=>DAYNAMES[d]).join(''); } + row(it,prim,now){ + const rem=this.remaining(it,now),paused=this.isPaused(it); + let disp,sub='',warn=false,prog=null,glyph,pomo=null,ringing=!!it.ringing,badges=[],lastLap=null,sweep=null; + if(it.type==='alarm'){ + if(ringing){disp='RING';sub='alarm ringing';warn=true;prog=1;glyph=GL.alarm;} + else{disp=fmtClock(it.target);sub=`${this.daysLabel(it)} · fires ${fmtClock(it.target)}`;prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;} + if(it.days&&it.days.length)badges.push({t:this.daysLabel(it),c:'sage'}); + } + else if(it.type==='pomodoro'){disp=fmtTime(rem);prog=Math.max(0,Math.min(1,rem/it.total));const deep=(it.cycle%it.cfg.iv===0); + const ph=it.phase==='work'?(deep?'long work':'work'):(deep?'long break':'short break'); + sub=it.awaiting?`ready · start ${it.phase==='work'?'work':'break'}`:`${ph} · cycle ${it.cycle}/${it.cfg.iv}`; + glyph=it.phase==='work'?GL.pomo_work:GL.pomo_break;pomo={cycle:it.cycle,iv:it.cfg.iv,phase:it.phase,awaiting:!!it.awaiting};} + else if(it.type==='stopwatch'){disp=fmtTime(rem);lastLap=it.laps.length?it.laps[it.laps.length-1].t:null; + sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;sweep=(Math.max(0,rem)%60)/60;} + else {disp=fmtTime(rem);sub=it.repeat?'timer · repeats':'timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;if(it.repeat)badges.push({t:'repeat',c:''});} + if(prog!=null&&!ringing&&rem<=Math.min(30,it.total*0.15))warn=true; + if(paused)glyph=GL.paused; + return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type], + typeLabel:it.type,disp,sub,paused,ringing,primary:it.id===prim,prog,warn,pomo,badges,lastLap,sweep,laps:it.laps?it.laps.length:0}; + } + /* mirror wtimer render_payload for the bar */ + barPayload(){ const now=this.now(); const items=this.items; + if(!items.length)return {glyph:GL.idle,text:'',plus:0,cls:'idle',tip:['No timers']}; + const pid=this.effectivePrimary(); const p=this.find(pid); + let cls; if(p.ringing)cls='urgent'; else if(this.isPaused(p))cls='paused'; + else if(p.type==='pomodoro')cls=(p.phase==='work'?'pomodoro-work':'pomodoro-break'); + else if((p.type==='timer'||p.type==='alarm')&&this.remaining(p,now)<60)cls='urgent'; else cls=p.type; + const glyph=this.isPaused(p)?GL.paused:(p.type==='pomodoro'?(p.phase==='work'?GL.pomo_work:GL.pomo_break):GL[p.type]); + const text=p.ringing?'RING':fmtTime(this.remaining(p,now)); + const tip=[items.length!==1?`${items.length} active`:'1 timer']; + for(const i of items){const g=this.isPaused(i)?GL.paused:(i.type==='pomodoro'?(i.phase==='work'?GL.pomo_work:GL.pomo_break):GL[i.type]); + const lb=i.label||i.type;const st=this.isPaused(i)?' (paused)':(i.ringing?' (ringing)':''); + const val=i.type==='alarm'?(i.ringing?'RING':fmtClock(i.target)):(i.type==='pomodoro'?`${i.cycle}/${i.cfg.iv} ${fmtTime(this.remaining(i,now))}`:fmtTime(this.remaining(i,now))); + tip.push(`${g} ${lb} ${val}${st}`);} + return {glyph,text,plus:items.length-1,cls,tip}; + } + presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));} + addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom preset here'}; + if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'};(this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};} + deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label);if(i<0)return {ok:false,reason:'not found'};if(a[i].locked)return {ok:false,reason:'default — locked'};a.splice(i,1);return {ok:true};} +} + +let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied'; +function tryNotify(title,body){if(typeof Notification==='undefined')return;if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}}else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);}} +function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap);return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t);setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};} +function flash(...inputs){for(const i of inputs){if(!i)continue;i.classList.remove('flash');void i.offsetWidth;i.classList.add('flash');}} +function dotsHTML(p){if(!p)return '';let h='<span class="dots">';const pos=(p.cycle-1)%p.iv;for(let i=0;i<p.iv;i++){const isLong=(i===p.iv-1);let cls='';if(i<pos)cls='on';if(i===pos)cls='now';if(isLong)cls+=' long';h+=`<i class="${cls.trim()}"></i>`;}return h+'</span>';} +function badgesHTML(bs){return (bs||[]).map(b=>`<span class="badge ${b.c}">${b.t}</span>`).join(' ');} + +function mount(host, engine, bar, tip){ + const toast=toaster(host); + const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm',GL.clear+' CLEAR ALL');clear.style.marginLeft='8px'; + clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();}); + head.appendChild(clear); + // close button — flat circular ✕ like the net/bt/audio panels (Close/Esc); the + // waybar module reopens it, mirroring the real on-click: timer-panel toggle. + const closeBtn=el('button','x-btn','✕');closeBtn.title='Close (Esc)'; + const barcap=document.querySelector('.barcap'); + function setClosed(c){host.classList.toggle('closed',c);if(barcap)barcap.textContent=c?'the waybar module · click it to reopen the panel':'the waybar module · live';} + closeBtn.addEventListener('click',()=>setClosed(true)); + head.appendChild(closeBtn); + bar.addEventListener('click',()=>setClosed(!host.classList.contains('closed'))); + document.addEventListener('keydown',e=>{if(e.key==='Escape')setClosed(true);}); + const hero=el('div','hero'); + const create=buildCreate(); + const list=el('div','qlist'); + host.append(head,hero,create,list); + const flashing=new Set(); + + function itemClick(e){const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); + else if(a==='promote'){engine.promote(id);toast('to bar slot');} + else if(a==='cycle')engine.cycle(b.dataset.dir); + else if(a==='lap'){engine.lap(id);toast('lap recorded');} + else if(a==='stop'){engine.cancel(id);toast('stopped');} + else if(a==='snooze'){engine.snooze(id);toast('snoozed','gold');} + else if(a==='dismiss'){engine.dismiss(id);toast('dismissed');} + else if(a==='cancel'){if(b.dataset.armed){engine.cancel(id);toast('cancelled');} + else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;}} + render();} + hero.addEventListener('click',itemClick); + list.addEventListener('click',itemClick); + + function buildCreate(){ + const box=el('div','create'); + const seg=el('div','seg'); + TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);}); + const body=el('div');box.append(seg,body); + let selType='timer'; + seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();}); + function doAdd(type,val,lab,opts){const r=engine.add(type,val?val.value:'',lab.value.trim(),opts); + if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;} + if(val){val.classList.remove('bad');if(!val.disabled)val.value='';}lab.value='';toast('added '+type,'gold');render();} + function paintBody(){ + body.innerHTML=''; + if(selType==='timer'){ + const ps=el('div','presets'); + engine.presetsFor('timer').forEach(p=>{const c=el('span','preset'+(p.locked?'':''),p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);}); + ps.appendChild(Object.assign(el('span','preset','+ preset'),{}) );ps.lastChild.dataset.newp='1';ps.lastChild.style.opacity='.7'; + const row=el('div','row'); + const val=el('input','tin');val.placeholder='5m · 1h30m · 90s';val.style.flex='2'; + const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2'; + const rep=el('span','switch');const reptag=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">repeat</span>'); + const addk=el('button','key on',GL.add+' ADD'); + const repwrap=el('span',null,'');repwrap.style.display='inline-flex';repwrap.style.alignItems='center';repwrap.style.gap='6px';repwrap.append(rep,reptag); + row.append(val,lab,repwrap,addk); + body.append(ps,row); + rep.addEventListener('click',()=>rep.classList.toggle('on')); + ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]'); + if(del){const r=engine.deletePreset('timer',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;} + if(e.target.closest('[data-newp]')){const lb=prompt('Preset label:');if(!lb)return;const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset('timer',lb,vv);toast(r.ok?'preset added':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;} + const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);}); + addk.addEventListener('click',()=>doAdd('timer',val,lab,{repeat:rep.classList.contains('on')})); + [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('timer',val,lab,{repeat:rep.classList.contains('on')});})); + } + else if(selType==='alarm'){ + const ps=el('div','presets'); + engine.presetsFor('alarm').forEach(p=>{const c=el('span','preset',p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);}); + const row=el('div','row'); + const val=el('input','tin');val.placeholder='HH:MM · +30m · @hour · @half';val.style.flex='2'; + const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2'; + const addk=el('button','key on',GL.add+' ADD'); + row.append(val,lab,addk); + const drow=el('div','row');const days=el('div','days7');const sel=new Set(); + DAYNAMES.forEach((d,i)=>{const b=el('button',null,d);b.dataset.d=i;b.title=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][i];days.appendChild(b);}); + const quick=el('span',null,'');const wkd=el('button','key sm','weekdays');const evd=el('button','key sm','daily'); + quick.style.display='inline-flex';quick.style.gap='5px';quick.append(wkd,evd); + const srow=el('div','row');const slbl=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">snooze</span>'); + const sn=el('input','numin');sn.value='9';sn.inputMode='numeric';const smin=el('span',null,'<span style="color:var(--dim);font-size:10px">min</span>'); + srow.append(slbl,sn,smin); + drow.append(days,quick); + body.append(ps,row,drow,srow); + function paintDays(){[...days.children].forEach(b=>b.classList.toggle('on',sel.has(+b.dataset.d)));} + days.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;const d=+b.dataset.d;sel.has(d)?sel.delete(d):sel.add(d);paintDays();}); + wkd.addEventListener('click',()=>{sel.clear();[1,2,3,4,5].forEach(d=>sel.add(d));paintDays();}); + evd.addEventListener('click',()=>{sel.clear();[0,1,2,3,4,5,6].forEach(d=>sel.add(d));paintDays();}); + function opts(){return {days:[...sel],snooze:parseInt(sn.value,10)||9};} + ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]'); + if(del){const r=engine.deletePreset('alarm',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;} + const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);}); + addk.addEventListener('click',()=>doAdd('alarm',val,lab,opts())); + [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('alarm',val,lab,opts());})); + } + else if(selType==='stopwatch'){ + const row=el('div','row'); + const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2'; + const addk=el('button','key on',GL.add+' ADD'); + row.append(lab,addk); + const note=el('div','engrave','counts up from zero · lap while running'); + body.append(row,note); + addk.addEventListener('click',()=>doAdd('stopwatch',null,lab)); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);}); + } + else { + const ps=el('div','presets'); + engine.pomoPresets.forEach(p=>{const c=el('span','preset',p.label);c.dataset.pp=p.label;ps.appendChild(c);}); + const cfg=el('div','cfg');const mk=v=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;}; + const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv); + const auto=el('span','switch on');auto.dataset.on='1'; + const rW=el('div','crow');rW.append(el('span','lbl','Work'),el('span','sl','S'),ws,el('span','sl','L'),wl,el('span','u','min')); + const rR=el('div','crow');rR.append(el('span','lbl','Rest'),el('span','sl','S'),rs,el('span','sl','L'),rl,el('span','u','min')); + const rI=el('div','crow');rI.append(el('span','lbl','Long ev.'),iv,el('span','u','cycles → long work + long break')); + const rA=el('div','crow');rA.append(el('span','lbl','Auto'),auto,el('span','u','advance into the next phase')); + cfg.append(rW,rR,rI,rA); + const row=el('div','row');const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2'; + const addk=el('button','key on',GL.add+' ADD CYCLE');row.append(lab,addk); + body.append(ps,cfg,row); + auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';}); + ps.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return;const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return; + ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv;[...ps.children].forEach(x=>x.classList.toggle('on',x===c));flash(ws,wl,rs,rl,iv);}); + function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;} + addk.addEventListener('click',()=>{const r=engine.add('pomodoro','',lab.value.trim(),{ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on}); + if(!r.ok){toast(r.reason,'red');return;}lab.value='';toast('pomodoro added','gold');render();}); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();}); + } + } + paintBody(); + return box; + } + + function renderBar(){ + const p=engine.barPayload(); + bar.className='wmod '+p.cls;bar.title=p.tip.join('\n'); + bar.innerHTML=`<span class="wg">${p.glyph}</span>`+(p.text?`<span class="wt">${p.text}</span>`:'')+(p.plus>0?`<span class="wp">+${p.plus}</span>`:''); + tip.innerHTML=`<div class="th">hover tooltip</div>`+p.tip.map((l,i)=>`<div class="tl" style="${i===0?'color:var(--steel)':''}">${l}</div>`).join(''); + } + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(),primId=engine.effectivePrimary(); + const h=rows.find(r=>r.id===primId); + hero.className='hero'+(h&&h.paused?' paused':'')+(h&&h.ringing?' ringing':'')+(h&&flashing.has(h.id)?' fire':''); + if(!h){hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>';} + else{ + const ringP=h.prog!=null?Math.round(h.prog*100):0; + const donutHTML = h.type==='stopwatch' + ? `<span class="swdial" title="seconds sweep"><span class="swtick"></span><span class="swtick q"></span><span class="swtick h"></span><span class="swtick t"></span><span class="swhand" style="transform:rotate(${Math.round((h.sweep||0)*360)}deg)"></span><span class="swhub"></span></span>` + : `<span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px"><b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b></span>`; + let transport; + if(h.ringing) transport=`<button class="key" data-act="snooze" data-id="${h.id}">${GL.play} SNOOZE ${engine.find(h.id).snooze}m</button><button class="key red" data-act="dismiss" data-id="${h.id}">DISMISS</button>`; + else if(h.type==='stopwatch') transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button><button class="key" data-act="lap" data-id="${h.id}">LAP</button><button class="key red" data-act="stop" data-id="${h.id}">STOP</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`; + else {const start=h.pomo&&h.pomo.awaiting?(GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')):(h.paused?GL.play+' RESUME':GL.paused+' PAUSE'); + transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${start}</button><button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`;} + hero.innerHTML= + `<div class="htop"> + <div class="rhs"> + <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>${badgesHTML(h.badges)}${h.pomo?dotsHTML(h.pomo):''}</div> + <div class="hlabel">${h.label}</div> + <div class="hbig">${h.disp}${h.type==='stopwatch'&&h.lastLap!=null?`<span class="lapbadge">LAP ${fmtTime(h.lastLap)}</span>`:''}</div> + <div class="hsub">${h.sub}</div> + </div> + <div class="donut">${donutHTML}</div> + </div> + <div class="transport">${transport}</div>`; + } + list.innerHTML=''; + const rest=rows.filter(r=>r.id!==primId); + list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>')); + if(!rest.length)list.appendChild(el('div','empty',h?'Only one item is queued. Add more above.':'')); + rest.forEach(r=>{ + const row=el('div','qrow'+(r.paused?' paused':'')+(r.ringing?' ringing':'')+(flashing.has(r.id)?' fire':'')); + let ctrls; + if(r.ringing)ctrls=`<button class="key sm" data-act="snooze" data-id="${r.id}">SNOOZE</button><button class="key sm red" data-act="dismiss" data-id="${r.id}">OFF</button>`; + else if(r.type==='stopwatch')ctrls=`<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button><button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button>`; + else ctrls=`<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button><button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button>`; + row.innerHTML=`<span class="lamp ${r.ringing?'red':(r.paused?'off':(r.warn?'red':''))}"></span><span class="g">${r.glyph}</span><span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span><span class="rd">${r.disp}</span><span class="ctrls">${ctrls}</span>`; + list.appendChild(row); + }); + renderBar(); + } + engine._render=render;engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);};engine._toast=toast; + render(); +} + +const engine=new Engine(); +const pomo=engine.add('pomodoro','','Deep work',{ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}); +engine.add('timer','45s','Egg',{repeat:false}); +engine.add('timer','5m','Tea',{repeat:true}); +const sw=engine.add('stopwatch','','Debug run'); +{const s=engine.find(sw.id);s.start=engine.now()-215;s.laps=[{t:72},{t:158}];} // ~3:35 elapsed, last lap 2:38 +engine.add('alarm','07:00','Wake',{days:[1,2,3,4,5],snooze:9}); +engine.promote(sw.id); // show the new stopwatch sweep-dial + lap badge in the hero +mount(document.getElementById('panel'), engine, document.getElementById('wbar'), document.getElementById('wtip')); + +function loop(){const fired=engine.tick?engine.tick():tickEngine(engine); + for(const f of fired){engine._flash(f.id);engine._toast((f.kind==='done'?GL.bell+' ':'')+f.title,f.kind==='done'?'red':'gold');tryNotify(f.title,f.body);} + engine._render();} +/* engine.tick lives on the class below via prototype patch to keep add()/tick together readable */ +Engine.prototype.tick=function(){const now=this.now(),fired=[]; + for(const it of this.items.slice()){ + if(!COUNTDOWN.includes(it.type)||this.isPaused(it)||it.ringing)continue; + if(it.target-now>0)continue; + if(it.type==='pomodoro'){const c=it.cfg; + if(it.phase==='work'){const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`}); + it.phase='rest';const len=deep?c.rl:c.rs;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}} + else{it.cycle+=1;const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`}); + it.phase='work';const len=deep?c.wl:c.ws;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}}} + else if(it.type==='alarm'){fired.push({id:it.id,kind:'done',title:'Alarm · '+(it.label||fmtClock(it.target)),body:'alarm ringing'});it.ringing=true;} + else{if(it.repeat){fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done')+' · repeating',body:'restarted'});it.target=now+it.total;} + else{fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done'),body:'time’s up'});this.cancel(it.id);}} + } + return fired;}; +setInterval(loop, reduced?1000:250); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html new file mode 100644 index 0000000..0258f20 --- /dev/null +++ b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html @@ -0,0 +1,1359 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Instrument consoles — network + bluetooth</title> +<style> +:root { + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.masthead{max-width:1280px;margin:0 auto 1.8rem} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:80ch} +.masthead b{color:var(--silver);font-weight:700} + +.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start} +.slot{width:400px} +.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem} +.aside{flex:1 1 300px;min-width:280px} +.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.24em;text-transform:uppercase;margin:1.1rem 0 .5rem} +.aside h3:first-child{margin-top:.2rem} +.aside ul{list-style:none} +.aside li{font-size:.82rem;padding:.22rem 0 .22rem 1.1rem;position:relative} +.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.aside li em{color:var(--dim);font-style:normal} +.aside li b{color:var(--cream);font-weight:700} +.demo-box{border:1px dashed var(--wash);border-radius:10px;padding:.8rem 1rem;margin-top:1rem} +.demo-box label{display:flex;gap:.6rem;align-items:center;font-size:.82rem;cursor:pointer;color:var(--silver);margin-top:.45rem} +.demo-box label:first-child{margin-top:0} +.demo-box input{accent-color:#dab53d} +.demo-box .hint{color:var(--dim);font-size:.73rem;margin:.15rem 0 0 1.5rem} +.reset{font:inherit;font-size:.78rem;color:var(--silver);background:transparent;border:1px solid var(--wash); + border-radius:8px;padding:.4rem .9rem;cursor:pointer;margin-top:.8rem} +.reset:hover{background:var(--wash)} + +.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative; + transition:opacity .25s,transform .25s} +.panel.closed{opacity:0;transform:translateY(-8px);pointer-events:none} +.reopen{display:none;font:inherit;font-size:.75rem;color:var(--gold);background:transparent; + border:1px dashed var(--gold);border-radius:8px;padding:.5rem 1rem;cursor:pointer;margin-top:.6rem} + +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto; + box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.55)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px} +.b-id{display:flex;align-items:center;gap:9px} +.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em} +.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto} +.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold); + border-radius:4px;padding:1px 6px;display:none} +.badge.show{display:inline-block} +.badge.red{background:var(--fail);color:var(--cream)} +.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem; + cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto} +.x-btn:hover{background:var(--wash);color:var(--silver)} +.switch{width:38px;height:20px;border-radius:10px;background:var(--wash); + border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px; + border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:19px;background:var(--gold)} + +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;margin:12px 0 6px} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:12px} +.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer} +.engrave .act:hover{color:var(--gold)} + +.chan .line1{display:flex;align-items:baseline;gap:9px} +.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px} +.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px} +.chan .chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash)} +.chan .chip:hover{color:var(--gold)} +.chan .chip.on{color:var(--gold)} +.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px} +.ladder i{width:4px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px} +.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px} +.ladder.l1 i:nth-child(-n+1){background:var(--gold)} +.ladder.l2 i:nth-child(-n+2){background:var(--gold)} +.ladder.l3 i:nth-child(-n+3){background:var(--gold)} +.ladder.l4 i{background:var(--gold)} + +/* section row budgets: lists never grow the panel — they scroll inside it, + cut at a half row so the peek says "there's more" */ +.sec-scroll{overflow-y:auto;overscroll-behavior:contain} +#networks.sec-scroll{max-height:160px} +#tunnels.sec-scroll{max-height:131px} +#b-paired.sec-scroll{max-height:160px} +#b-nearby.sec-scroll{max-height:131px} +.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px} +.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer;position:relative} +.lamp-row:hover{background:var(--wash)} +.lamp-row .who{color:var(--silver);white-space:nowrap} +.lamp-row .who b{color:var(--cream)} +.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.lamp-row.busy{pointer-events:none} +.lamp-row .zap,.lamp-row .pen{display:none;color:var(--dim);border:0;background:transparent;font:inherit;font-size:.85rem; + cursor:pointer;border-radius:5px;padding:0 5px;flex:0 0 auto} +.lamp-row:hover .zap,.lamp-row:hover .pen{display:inline-block} +.lamp-row .zap:hover{color:var(--fail)} +.lamp-row .pen:hover{color:var(--gold)} +.lamp-row.armed-soft{background:rgba(218,181,61,.10)} +.lamp-row.armed-soft .what{color:var(--gold)} +.lamp-row.armed{background:rgba(203,107,77,.12)} +.lamp-row.armed .what{color:var(--fail)} +.lamp-row.armed .zap{display:inline-block;color:var(--fail)} + +.console-btns{display:flex;gap:8px;margin-top:2px} +.c-btn{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11.5px; + background:linear-gradient(180deg,#23211e,#191715);color:var(--silver); + border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.c-btn:hover{color:var(--gold);border-color:var(--gold)} +.c-btn:active{transform:translateY(1px)} +.c-btn:disabled{opacity:.4;pointer-events:none} + +.meters{display:flex;gap:12px;margin-top:10px} +.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;cursor:default;position:relative} +.meter.testing{animation:flash 1s ease-in-out infinite;border-color:var(--gold)} +@keyframes flash{50%{box-shadow:0 0 10px 1px rgba(218,181,61,.35)}} +.meter.held{border-color:var(--gold);cursor:pointer} +.meter .hold-tag{display:none;position:absolute;top:6px;right:8px;font-size:.56rem;letter-spacing:.2em; + color:var(--panel);background:var(--gold);border-radius:3px;padding:0 4px} +.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)} +.meter .mode-tag.test{color:var(--gold)} +.meter .mode-tag.off{color:var(--dim)} +.meter.held .hold-tag{display:block} +.meter .dial{position:relative;height:52px;overflow:hidden;margin-top:13px} +.meter .arc{position:absolute;inset:0 0 -52px 0;border:2px solid var(--wash);border-radius:50%} +.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:10px;background:var(--steel);transform-origin:50% 52px} +.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:44px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(-60deg);border-radius:2px; + box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .45s cubic-bezier(.3,1.3,.5,1)} +.meter .needle.dead{background:var(--wash);box-shadow:none} +.meter .needle.low{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)} +.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)} +.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums} +.meter .m-value small{color:var(--dim);font-weight:400} +.meter .m-value.low{color:var(--fail)} +.meter .m-label{color:var(--steel);font-size:.62rem;letter-spacing:.26em;text-align:center;margin-top:2px; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.meter-note{color:var(--dim);font-size:10px;text-align:center;margin-top:5px;min-height:1.2em} + +.well{border:1px solid var(--wash);border-radius:10px;background:var(--well)} +.outwrap{position:relative;margin-top:10px} +.outwrap .output{margin-top:0} +.o-clear{display:none;position:absolute;top:3px;right:5px;z-index:2;color:var(--dim); + border:0;background:var(--well);font:inherit;font-size:.8rem;cursor:pointer; + border-radius:50%;width:20px;height:20px;line-height:1} +.o-clear:hover{color:var(--silver);background:var(--wash)} +.outwrap.has .o-clear{display:block} +.output{margin-top:10px;padding:8px 10px;max-height:170px;overflow-y:auto;font-size:11.5px} +.output:empty{padding:4px 10px;min-height:10px} +.o-step{display:flex;gap:8px;align-items:flex-start;padding:2.5px 0} +.o-step .lamp{margin-top:4px;width:7px;height:7px} +.o-step .t b{color:var(--cream);font-weight:700} +.o-step .t .why{color:var(--dim);display:block;font-size:10.5px} +.o-step .t .ev{color:var(--steel);display:block;font-size:11px} +.o-step.repair .t b{color:var(--gold)} +.o-line{padding:2px 0;color:var(--silver)} +.o-line b{color:var(--steel);font-weight:400} +.o-verdict{margin-top:5px;padding-top:5px;border-top:1px solid var(--wash);color:var(--gold);font-weight:700} +.o-verdict.ok{color:var(--pass)} +.o-tip{color:var(--dim);font-size:10.5px;margin-top:4px} + +.toast{margin-top:9px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px; + padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em} +.toast.show{opacity:1} +.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)} + +.overlay{position:absolute;inset:0;background:rgba(10,12,13,.82);border-radius:14px;display:none; + align-items:center;justify-content:center;z-index:5} +.overlay.show{display:flex} +.dlg{background:var(--panel);border:1px solid var(--gold);border-radius:12px;padding:16px 18px;width:300px} +.dlg h4{color:var(--cream);font-size:13px;margin-bottom:4px} +.dlg .sub{color:var(--dim);font-size:11px;margin-bottom:10px} +.dlg .passkey{color:var(--gold-hi);font-size:22px;font-weight:700;letter-spacing:.18em; + text-align:center;margin:6px 0 12px;font-variant-numeric:tabular-nums} +.dlg input{width:100%;font:inherit;font-size:12.5px;color:var(--silver);background:var(--well); + border:1px solid var(--wash);border-radius:7px;padding:7px 9px;margin-bottom:8px;caret-color:var(--gold)} +.dlg input:focus{outline:none;border-color:var(--gold)} +.dlg .dlg-btns{display:flex;gap:8px;justify-content:flex-end;margin-top:4px} +.btn{font:inherit;font-size:12px;cursor:pointer;background:var(--slate);color:var(--cream); + border:1px solid var(--gold);border-radius:8px;padding:5px 12px} +.btn:hover{background:var(--slate-hi)} +.btn.quiet{background:transparent;border-color:var(--wash);color:var(--silver)} +.btn.quiet:hover{background:var(--wash)} + +*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent} +::-webkit-scrollbar{width:6px;height:6px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px} +::-webkit-scrollbar-thumb:hover{background:var(--slate-hi)} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div> + <h1>Network + Bluetooth — the pair</h1> + <p>Same faceplate, same idioms: lamp rows act on click, hover reveals ✎ rename and the + arm-to-fire ✕, gauges under the console keys (throughput needles on NET, battery fuel + gauges on BT), doctor streams and repairs in the output well. <b>Try the power switch + on BT·01</b> — everything follows it.</p> +</header> + +<div class="stage"> + + <!-- ============================ NET·01 ============================ --> + <div class="slot"> + <div class="slot-label">net·01 — as iterated</div> + <div class="panel" id="p"> + <div class="overlay" id="ov"> + <div class="dlg"> + <h4 id="dlg-title">Join network</h4> + <div class="sub" id="dlg-sub">WPA2 — password required</div> + <input id="dlg-ssid" placeholder="SSID" style="display:none"> + <input id="dlg-pass" type="password" placeholder="password"> + <div class="dlg-btns"> + <button class="btn quiet" onclick="dlgClose()">Cancel</button> + <button class="btn" id="dlg-go" onclick="dlgGo()">Join</button> + </div> + </div> + </div> + + <div class="b-face"> + <div class="b-id"> + <span class="lamp" id="lamp"></span> + <span class="state-word" id="state">ONLINE</span> + <span class="badge" id="badge">TUNNEL</span> + <span class="badge" id="air-badge">AIRPLANE</span> + <span class="unit">NET·01</span> + <span class="switch on" id="n-power" onclick="wifiPower()" title="WiFi radio"></span> + <button class="x-btn" onclick="closePanel()" title="Close (Esc)">✕</button> + </div> + </div> + + <div class="engrave">channel</div> + <div class="chan"> + <div class="line1"><span class="ssid" id="ch-ssid">@Hyatt_WiFi</span> + <span class="ladder l3" id="ch-ladder"><i></i><i></i><i></i><i></i></span> + <span class="dim" style="font-size:11px;white-space:nowrap" id="ch-dbm">-59 dBm · 44 ms</span></div> + <div class="line2" id="ch-route">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div> + </div> + + <div class="engrave">networks<span class="cnt" id="net-count"></span><span class="act" onclick="dlgHidden()" title="Join a hidden SSID">+ hidden</span></div> + <div id="networks" class="sec-scroll"></div> + + <div class="engrave">tunnels<span class="cnt" id="tun-count"></span></div> + <div id="tunnels" class="sec-scroll"></div> + + <div class="engrave">console</div> + <div class="console-btns"> + <button class="c-btn" id="b-doctor" onclick="runDoctor()">DOCTOR</button> + <button class="c-btn" id="b-speed" onclick="runSpeed()">SPEED TEST</button> + </div> + + <div class="meters"> + <div class="meter" id="m-rx" onclick="release('rx')"> + <span class="mode-tag" id="mt-rx">LIVE</span> + <span class="hold-tag">HOLD</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-rx"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-rx">0.1</span> <small>Mbps</small></div> + <div class="m-label">RX · DOWN</div> + </div> + <div class="meter" id="m-tx" onclick="release('tx')"> + <span class="mode-tag" id="mt-tx">LIVE</span> + <span class="hold-tag">HOLD</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-tx"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-tx">0.1</span> <small>Mbps</small></div> + <div class="m-label">TX · UP</div> + </div> + </div> + <div class="meter-note" id="m-note"></div> + + <div class="outwrap" id="outwrap"> + <button class="o-clear" onclick="clearOut('out')" title="Dismiss results">✕</button> + <div class="well output" id="out"></div> + </div> + <div class="toast" id="toastEl"></div> + </div> + <button class="reopen" id="reopen" onclick="openPanel()">reopen NET·01 (bar click)</button> + </div> + + <!-- ============================ BT·01 ============================ --> + <div class="slot"> + <div class="slot-label">bt·01 — new, same idioms</div> + <div class="panel" id="bp"> + <div class="overlay" id="bov"> + <div class="dlg"> + <h4 id="bdlg-title">Pair device</h4> + <div class="sub" id="bdlg-sub">confirm the passkey matches the device</div> + <div class="passkey" id="bdlg-key" style="display:none">847 291</div> + <input id="bdlg-name" placeholder="device name" style="display:none"> + <div class="dlg-btns"> + <button class="btn quiet" onclick="bdlgClose()">Cancel</button> + <button class="btn" id="bdlg-go" onclick="bdlgGo()">Confirm</button> + </div> + </div> + </div> + + <div class="b-face"> + <div class="b-id"> + <span class="lamp" id="b-lamp"></span> + <span class="state-word" id="b-state">POWERED</span> + <span class="badge red" id="b-badge">LOW BATT</span> + <span class="badge" id="b-air-badge">AIRPLANE</span> + <span class="unit">BT·01</span> + <span class="switch on" id="b-power" onclick="btPower()" title="Adapter power"></span> + <button class="x-btn" onclick="closeBt()" title="Close (Esc)">✕</button> + </div> + </div> + + <div class="engrave">adapter</div> + <div class="chan"> + <div class="line1"><span class="ssid">intel ax211</span> + <span class="dim" style="font-size:11px;margin-left:auto">hci0</span></div> + <div class="line2" id="b-adapter-line"> + <span class="chip" id="b-disco" onclick="btDisco()">discoverable off</span> + <span> · </span><span id="b-conn-count">1 device connected</span></div> + </div> + + <div class="engrave">paired<span class="cnt" id="b-paired-count"></span></div> + <div id="b-paired" class="sec-scroll"></div> + + <div class="engrave">nearby<span class="cnt" id="b-nearby-count"></span><span class="act" id="b-scan-note"></span></div> + <div id="b-nearby" class="sec-scroll"></div> + + <div class="engrave">console</div> + <div class="console-btns"> + <button class="c-btn" id="bb-doctor" onclick="btDoctor()">DOCTOR</button> + <button class="c-btn" id="bb-scan" onclick="btScan()">SCAN</button> + </div> + + <div class="meters"> + <div class="meter" id="bm-0"> + <span class="mode-tag" id="bmt-0">LIVE</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="bn-0"></div><div class="hub"></div></div> + <div class="m-value" id="bvw-0"><span id="bv-0">72</span> <small>%</small></div> + <div class="m-label" id="bl-0">LOGI M650</div> + </div> + <div class="meter" id="bm-1"> + <span class="mode-tag off" id="bmt-1">—</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle dead" id="bn-1"></div><div class="hub"></div></div> + <div class="m-value" id="bvw-1"><span id="bv-1">—</span></div> + <div class="m-label" id="bl-1">NO DEVICE</div> + </div> + </div> + <div class="meter-note">battery · connected devices</div> + + <div class="outwrap" id="b-outwrap"> + <button class="o-clear" onclick="clearOut('b-out')" title="Dismiss results">✕</button> + <div class="well output" id="b-out"></div> + </div> + <div class="toast" id="b-toastEl"></div> + </div> + <button class="reopen" id="b-reopen" onclick="openBt()">reopen BT·01 (bar click)</button> + </div> + + <!-- ============================ NOTES ============================ --> + <div class="aside"> + <h3>The bt mapping</h3> + <ul> + <li><b>Power switch on the faceplate</b> — flip it: devices drop, gauges die, keys disable, state goes OFF. Flip back: the mouse auto-reconnects. <em>(the switch-placement ask, in console form)</em></li> + <li><b>Battery fuel gauges</b> are BT's meters — one per connected device, needle at charge, red under 15% with a LOW BATT badge on the faceplate.</li> + <li><b>Paired rows toggle on click</b> (connect/disconnect), exactly like tunnels. Hover: ✎ rename <em>(the rename ask)</em>, ✕ arm-to-forget.</li> + <li><b>Nearby rows pair on click</b> — passkey confirm dialog, then the device moves up to PAIRED and connects. SCAN refreshes the neighborhood.</li> + <li><b>discoverable off</b> in the adapter line is a click-toggle (gold when on).</li> + <li><b>Disconnect is arm-first on the active row</b> — first click arms in gold ("disconnect? click again"), second fires. Gold, not terracotta: disruptive, not destructive.</li> + <li><b>NET·01 grew the wifi radio switch</b> (faceplate, same spot as BT's). Airplane mode is system-level: both switches drop, AIRPLANE badges light, and a switch flipped under airplane refuses with the way out. A plugged ethernet cable keeps NET·01 online through it.</li> + <li><b>DOCTOR does it all here too</b>: adapter → radio → service → powered → devices → audio profile. Tick the degraded-audio switch and run it: it finds HSP, flips to A2DP, verifies the sink followed.</li> + </ul> + <div class="demo-box"> + <label><input type="checkbox" id="cafe" onchange="setScenario(this.checked)"> net: walk into a new café</label> + <label><input type="checkbox" id="breakdns"> net: broken hotel DNS (then DOCTOR)</label> + <label><input type="checkbox" id="ethercb" onchange="setEther(this.checked)"> net: plug in an ethernet cable</label> + <label><input type="checkbox" id="aircb" onchange="setAirplane(this.checked)"> both: airplane mode (Super+Shift+A)</label> + <label><input type="checkbox" id="airportcb" onchange="setAirport(this.checked)"> both: airport terminal (crowded airspace)</label> + <label><input type="checkbox" id="lowbatt" onchange="btLowBatt(this.checked)"> bt: mouse battery low</label> + <label><input type="checkbox" id="badaudio"> bt: degraded audio profile (then DOCTOR)</label> + <div class="hint">the audio one needs the headphones connected — click WH-1000XM4 first.</div> + <button class="reset" onclick="location.search=''">reset prototypes</button> + </div> + </div> +</div> + +<script> +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const $ = id => document.getElementById(id); +const T = f => reduced ? Math.max(10, f*0.02) : f; + +/* =========================================================== NET·01 */ +let busy = false; +const HOTEL_NETS = () => [ + {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:true, sig:3, active:true}, + {id:'meeting', ssid:'Hyatt_Meeting', sec:'WPA2', stored:false, range:true, sig:3, active:false}, + {id:'roku', ssid:'DIRECT-roku-882',sec:'WPA2',stored:false, range:true, sig:2, active:false}, + {id:'xfinity', ssid:'xfinitywifi', sec:null, stored:false, range:true, sig:1, active:false}, + {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false}, +]; +const CAFE_NETS = () => [ + {id:'cafe5g', ssid:'CafeAmore_5G', sec:'WPA2', stored:false, range:true, sig:4, active:false, ip:'10.11.4.27/22 · gw 10.11.4.1'}, + {id:'cafeg', ssid:'CafeAmore_Guest',sec:null, stored:false, range:true, sig:3, active:false, ip:'10.11.8.102/22 · gw 10.11.8.1'}, + {id:'iot', ssid:'Neighbor_IoT', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false}, + {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false}, +]; +const AIRPORT_NETS = () => [ + {id:'ord', ssid:'ORD Free Wi-Fi', sec:null, stored:false, range:true, sig:4, active:false, ip:'10.40.2.19/18 · gw 10.40.0.1'}, + {id:'boingo',ssid:'Boingo Hotspot', sec:null, stored:false, range:true, sig:3, active:false}, + {id:'united',ssid:'United_Club', sec:'WPA2', stored:false, range:true, sig:3, active:false}, + {id:'sky', ssid:'SkyClub_5G', sec:'WPA2', stored:false, range:true, sig:3, active:false}, + {id:'aa', ssid:'AmericanAir-Lounge',sec:'WPA2', stored:false, range:true, sig:2, active:false}, + {id:'sbux', ssid:'Starbucks WiFi', sec:null, stored:false, range:true, sig:2, active:false}, + {id:'tom', ssid:"Tom's iPhone", sec:'WPA2', stored:false, range:true, sig:2, active:false}, + {id:'hp', ssid:'HP-Print-88-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'gate', ssid:'Gate B12 Display', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'clear', ssid:'CLEAR-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'tsa', ssid:'TSA-Ops', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'dfw', ssid:'ORD-Employee', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false}, + {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false}, +]; +const AIRPORT_NEARBY = () => [ + {id:'ap1', name:'AirPods Pro', kind:'audio', passkey:'118 402'}, + {id:'ap2', name:'AirPods (3rd gen)', kind:'audio', passkey:'220 981'}, + {id:'gb2', name:'Galaxy Buds2', kind:'audio', passkey:'914 555'}, + {id:'pbp', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'}, + {id:'xm5', name:'Sony WF-1000XM5', kind:'audio', passkey:'774 210'}, + {id:'jab', name:'Jabra Elite 7', kind:'audio', passkey:'333 190'}, + {id:'aw', name:'Apple Watch', kind:'wearable',passkey:'602 118'}, + {id:'gar', name:'Garmin Fenix 8', kind:'wearable',passkey:'488 007'}, + {id:'tile', name:'Tile Tracker', kind:'tracker', passkey:'150 129'}, + {id:'jbl2', name:'JBL Charge 5', kind:'audio', passkey:'847 291'}, + {id:'bose', name:'Bose QC Ultra', kind:'audio', passkey:'962 340'}, + {id:'gate2',name:'[TV] Gate B12', kind:'display', passkey:'302 118'}, +]; +let NETS = HOTEL_NETS(); +const WG = (id, who) => ({id, who, upWhat:'10.2.0.2/32 · route owner', + downWhat:'wireguard (NM) · down', up:false, ownsRoute:true, dev:'wgpvpn'}); +const TUNNELS = [ + {id:'ts', who:'tailscale · velox', upWhat:'100.127.238.103 · 4/6 peers', downWhat:'down', + up:true, ownsRoute:false, dev:'tailscale0'}, + WG('usny','USNY'), WG('usdc','USDC'), WG('uscala','USCALA'), WG('uscasf','USCASF'), + WG('usgaat','USGAAT'), WG('szur1','switzerlan-zurich1'), WG('szur2','switzerlan-zurich2'), + {id:'proton', who:'Proton VPN CLI', upWhat:'', downWhat:'down', up:false, needsLogin:true}, +]; +const tinit = () => ({ts:true, usny:false, usdc:false, uscala:false, uscasf:false, + usgaat:false, szur1:false, szur2:false, proton:false}); +const tstate = tinit(); +let connected = true; +let routeBase = '172.20.2.108/20 · gw 172.20.0.1'; +let ether = { present:false, routed:false, + ip:'172.20.7.44/20 · gw 172.20.0.1', dev:'enp3s0' }; +let armed = null, armTimer = null; +let wifiOn = true, airplane = false; +let armedDisc = null, armDiscTimer = null; +let lastSsid = '@Hyatt_WiFi'; + +function renderNets(){ + const host = $('networks'); host.innerHTML = ''; + if (ether.present){ + const row = document.createElement('div'); + row.className = 'lamp-row'; + row.innerHTML = `<span class="${ether.routed?'lamp':'lamp gold'}" id="eth-lamp"></span>`+ + `<span class="who">${ether.routed?'<b>'+ether.dev+'</b>':ether.dev}</span>`+ + `<span class="what" id="eth-what">${ether.routed?'active · wired · 1.0 Gbps':'wired · standby'}</span>`; + row.onclick = () => toggleEther(); + host.appendChild(row); + } + if (!wifiOn){ + const note = document.createElement('div'); + note.className = 'lamp-row'; + note.style.cursor = 'default'; + note.innerHTML = `<span class="lamp off"></span><span class="who dim">wifi radio off</span>`+ + `<span class="what">${airplane ? 'airplane mode' : 'flip the switch to scan'}</span>`; + host.appendChild(note); + tips('networks'); + return; + } + const inRange = NETS.filter(n => n.range).sort((a,b) => (b.active-a.active) || (b.sig-a.sig)); + const out = NETS.filter(n => !n.range); + $('net-count').textContent = '· ' + inRange.length + ' in range'; + for (const n of [...inRange, ...out]){ + const row = document.createElement('div'); + row.className = 'lamp-row' + (armed===n.id ? ' armed' : '') + + (armedDisc===n.id ? ' armed-soft' : ''); + const lamp = n.active ? 'lamp' : (n.range ? 'lamp gold' : 'lamp off'); + const what = armed===n.id ? 'forget? click ✕ again' + : armedDisc===n.id ? 'disconnect? click again' + : n.active ? (ether.present && ether.routed ? 'connected · standby · ' : 'active · ') + (n.sec || 'open') + : !n.range ? 'stored · out of range' + : (n.stored ? 'stored · ' : '') + (n.sec || 'open') + ' · ' + [null,'22%','44%','61%','78%'][n.sig]; + row.innerHTML = `<span class="${lamp}"></span>`+ + `<span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+ + (n.range && !n.active ? `<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>` : '')+ + `<span class="what" id="nw-${n.id}">${what}</span>`+ + (n.stored ? `<button class="zap" title="Forget ${n.ssid}">✕</button>` : ''); + if (n.stored) row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); armForget(n.id); }; + row.onclick = n.active ? () => armDisconnect(n.id) : () => joinNet(n.id); + host.appendChild(row); + } + tips('networks'); +} +function armDisconnect(id){ + if (busy) return; + const n = NETS.find(x=>x.id===id); + if (armedDisc === id){ // second click: disconnect + clearTimeout(armDiscTimer); armedDisc = null; + busy = true; + const what = $('nw-'+id); + if (what) what.textContent = 'disconnecting…'; + setTimeout(() => { + busy = false; + n.active = false; connected = false; + if (!(ether.present && ether.routed)) tstate.ts = false; + netFace(); + renderTunnels(); renderNets(); + toast('disconnected from ' + n.ssid); + }, T(1100)); + return; + } + armedDisc = id; renderNets(); // first click: arm (gold — disruptive, not destructive) + clearTimeout(armDiscTimer); + armDiscTimer = setTimeout(() => { armedDisc = null; renderNets(); }, 3000); +} +function armForget(id){ + const n = NETS.find(x=>x.id===id); + if (armed === id){ + clearTimeout(armTimer); armed = null; + NETS.splice(NETS.indexOf(n), 1); renderNets(); + toast(`${n.ssid} forgotten`); + return; + } + armed = id; renderNets(); + clearTimeout(armTimer); + armTimer = setTimeout(() => { armed = null; renderNets(); }, 3000); +} +let joining = null; +function joinNet(id){ + if (busy) return; + if (!wifiOn){ toast('wifi radio is off', true); return; } + const n = NETS.find(x=>x.id===id); + if (!n.range){ toast(n.ssid + ' is out of range', true); return; } + if (n.sec && !n.stored){ joining = n; dlgJoin(n); return; } + doJoin(n); +} +function doJoin(n){ + busy = true; + const what = $('nw-'+n.id); + if (what) what.textContent = 'joining…'; + lampState('JOINING','gold'); + setTimeout(() => { + NETS.forEach(x => x.active = false); + n.active = true; n.stored = true; + busy = false; + $('ch-ssid').textContent = n.ssid; + $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][n.sig] + ' · 41 ms'; + $('ch-ladder').className = 'ladder l'+n.sig; + connected = true; + lastSsid = n.ssid; + routeBase = n.ip || '172.20.2.108/20 · gw 172.20.0.1'; + netFace(); + if (!tstate.ts) tstate.ts = true; + renderTunnels(); renderNets(); + toast('joined ' + n.ssid + ' — saved for next time'); + }, T(1600)); +} +function dlgJoin(n){ + $('dlg-title').textContent = 'Join ' + n.ssid; + $('dlg-sub').textContent = n.sec + ' — password required'; + $('dlg-ssid').style.display = 'none'; + $('dlg-pass').value = ''; + $('ov').classList.add('show'); + $('dlg-pass').focus(); +} +function dlgHidden(){ + joining = 'hidden'; + $('dlg-title').textContent = 'Join hidden network'; + $('dlg-sub').textContent = 'SSID is not broadcast — enter it exactly'; + $('dlg-ssid').style.display = 'block'; $('dlg-ssid').value = ''; + $('dlg-pass').value = ''; + $('ov').classList.add('show'); + $('dlg-ssid').focus(); +} +function dlgClose(){ $('ov').classList.remove('show'); joining = null; } +function dlgGo(){ + if (joining === 'hidden'){ + const ssid = $('dlg-ssid').value.trim() || 'hidden-net'; + const n = {id:'h'+Date.now(), ssid, sec:'WPA2', stored:true, range:true, sig:2, active:false}; + NETS.splice(0,0,n); renderNets(); dlgClose(); doJoin(n); + return; + } + const n = joining; dlgClose(); if (n) doJoin(n); +} + +function renderTunnels(){ + const host = $('tunnels'); host.innerHTML = ''; + for (const t of TUNNELS){ + const up = tstate[t.id]; + const row = document.createElement('div'); + row.className = 'lamp-row'; + row.innerHTML = `<span class="${up?'lamp':'lamp off'}" id="tl-${t.id}"></span>`+ + `<span class="who">${t.who}</span><span class="what" id="tw-${t.id}">${up?(t.upWhat||'up'):t.downWhat}</span>`; + row.onclick = () => toggleTunnel(t.id); + host.appendChild(row); + } + $('tun-count').textContent = '· ' + TUNNELS.filter(t=>tstate[t.id]).length + ' up of ' + TUNNELS.length; + tips('tunnels'); + updateRoute(); +} +function updateRoute(){ + if (!connected && !(ether.present && ether.routed)){ + $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off'; + $('ch-ladder').style.display = 'none'; + $('ch-dbm').textContent = ''; + $('ch-route').textContent = !wifiOn ? (airplane ? 'airplane mode' : 'flip the radio switch to scan') + : 'join a network below'; + $('badge').classList.remove('show'); + return; + } + const owner = TUNNELS.find(t => t.ownsRoute && tstate[t.id]); + const wired = ether.present && ether.routed; + const base = wired ? ether.ip : routeBase; + const dev = wired ? ether.dev : 'wlp170s0'; + $('ch-route').textContent = base + ' · route ' + (owner ? owner.dev + ' (tunnel)' : dev); + $('badge').classList.toggle('show', !!owner); + /* channel headline follows the routed link */ + if (wired){ + $('ch-ssid').textContent = ether.dev; + $('ch-ladder').style.display = 'none'; + $('ch-dbm').textContent = 'wired · 1.0 Gbps full-duplex'; + } else if (connected){ + const act = NETS.find(n => n.active); + if (act){ + $('ch-ssid').textContent = act.ssid; + $('ch-ladder').style.display = ''; + $('ch-ladder').className = 'ladder l' + act.sig; + $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][act.sig] + ' · 44 ms'; + } + } else { + $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off'; + $('ch-ladder').style.display = 'none'; + $('ch-dbm').textContent = ''; + } +} +/* the faceplate state word, derived from one place */ +function netFace(){ + const wired = ether.present && ether.routed; + $('air-badge').classList.toggle('show', airplane); + if (wired){ lampState('ONLINE'); return; } + if (connected){ lampState('ONLINE'); return; } + if (airplane){ lampState('AIRPLANE','gold'); return; } + if (!wifiOn){ lampState('OFF','off'); $('lamp').className='lamp off'; return; } + lampState('OFFLINE','red'); +} +function toggleTunnel(id){ + if (busy) return; + const t = TUNNELS.find(x=>x.id===id), up = tstate[id]; + const lamp = $('tl-'+id), what = $('tw-'+id); + busy = true; + lamp.className = 'lamp busy'; + what.textContent = t.needsLogin && !up ? 'connecting…' : up ? 'bringing down…' : 'bringing up…'; + setTimeout(() => { + busy = false; + if (t.needsLogin && !up){ + lamp.className = 'lamp red'; + what.textContent = 'sign in first: protonvpn login'; + toast('Proton: no account signed in — run: protonvpn login', true); + setTimeout(() => { lamp.className = 'lamp off'; what.textContent = t.downWhat; }, 2600); + return; + } + tstate[id] = !up; + renderTunnels(); + toast(tstate[id] ? `${t.who} up` + (t.ownsRoute ? ' — default route moved to '+t.dev : '') + : `${t.who} down` + (t.ownsRoute ? ' — route back on wlp170s0' : '')); + }, T(1500)); +} + +let toastTimer; +function toast(msg, err){ + const el = $('toastEl'); + el.textContent = msg; el.className = 'toast show' + (err ? ' err' : ''); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600); +} +function lampState(word, cls){ + $('state').textContent = word; + $('lamp').className = 'lamp' + (cls && cls !== 'off' ? ' '+cls : cls === 'off' ? ' off' : ''); +} + +const held = {rx:false, tx:false}; +let testing = false, amb = 0; +function meter(side, val){ + const deg = -60 + Math.min(1, val/100) * 120; + $('n-'+side).style.transform = `rotate(${deg}deg)`; + $('v-'+side).textContent = val.toFixed(1); +} +function setMeterMode(side, mode){ + const m = $('m-'+side), t = $('mt-'+side); + m.classList.toggle('testing', mode==='test'); + m.classList.toggle('held', mode==='hold'); + held[side] = mode==='hold'; + t.textContent = mode==='live' ? 'LIVE' : 'TEST'; + t.className = 'mode-tag' + (mode==='live' ? '' : ' test'); +} +function release(side){ + if (!held[side]) return; + setMeterMode(side, 'live'); + if (!held.rx && !held.tx) $('m-note').textContent = ''; +} + +function runSpeed(){ + if (busy) return; + busy = true; testing = true; + $('b-doctor').disabled = $('b-speed').disabled = true; + const out = $('out'); out.innerHTML = ''; + setMeterMode('rx','test'); setMeterMode('tx','test'); + $('m-note').textContent = 'measuring — needles follow the live rate'; + const line = (k, v) => { + const el = document.createElement('div'); + el.className = 'o-line'; el.innerHTML = `<b>${k}</b> ${v}`; + out.appendChild(el); out.scrollTop = out.scrollHeight; + }; + setTimeout(() => line('location', 'Tulsa, OK (US) by Encore Communications'), T(900)); + setTimeout(() => line('ping', '44.5 ms (jitter 2.1 ms)'), T(1800)); + let dv = 0; const dT = 25.3; + setTimeout(() => { + const dTick = setInterval(() => { + dv = Math.min(dT, dv + 1.3 + Math.random()*2.1); + meter('rx', dv); + if (dv >= dT){ + clearInterval(dTick); + setMeterMode('rx','hold'); meter('rx', dT); + let uv = 0; const uT = 90.8; + const uTick = setInterval(() => { + uv = Math.min(uT, uv + 4.5 + Math.random()*7); + meter('tx', uv); + if (uv >= uT){ + clearInterval(uTick); + setMeterMode('tx','hold'); meter('tx', uT); + line('final', '25.3↓ 90.8↑ Mbps · 44.5 ms · loss 0.0%'); + const tip = document.createElement('div'); + tip.className = 'o-tip'; + tip.textContent = 'tip: download well below upload — typical of a congested or shaped venue network. Try 5 GHz, move closer, or retest off-peak.'; + out.appendChild(tip); out.scrollTop = out.scrollHeight; + $('m-note').textContent = 'result held — click a meter to go back to live'; + testing = false; busy = false; + $('b-doctor').disabled = $('b-speed').disabled = false; + } + }, T(200)); + } + }, T(185)); + }, T(2100)); +} + +const CHECKS = [ + {t:'Link', why:'is the adapter connected to a network (every later check rides the link)', + ev:'wlp170s0 connected (@Hyatt_WiFi)'}, + {t:'DHCP / IPv4', why:'did the network lease us an IP address (nothing routes without one)', + ev:'172.20.2.108/20'}, + {t:'Gateway', why:'does the router (first hop) answer a ping', ev:'172.20.0.1 [5 ms]'}, + {t:'DNS config', why:'is a DNS resolver configured on the link', ev:'172.20.0.1'}, + {t:'DNS resolution', why:'does a known hostname resolve (catches dead DNS and portal hijacks)', + ev:'names resolve (captive.apple.com) [48 ms]', + evBroken:'no resolution (portal may be stalling DNS)', canBreak:true}, + {t:'Internet', why:'does an HTTP probe reach the open internet (the online/captive verdict)', + ev:'open internet (HTTP 204) [112 ms]', + evBroken:'link up but no clean internet (DNS or egress issue)', canBreak:true}, +]; +const REPAIR = {t:'repair: dns-test', why:'points DNS at 1.1.1.1, tests resolution, then reverts (tells a broken venue resolver from blocked egress)', + ev:'1.1.1.1 resolved captive.apple.com — the hotel resolver is broken, not the link'}; +const REPAIR2 = {t:'repair: dns-override', why:'sets 1.1.1.1 on the link until reconnect (works around the broken venue resolver)', + ev:'DNS set to 1.1.1.1 on wlp170s0'}; + +function addStep(host, step, repair){ + const el = document.createElement('div'); + el.className = 'o-step' + (repair ? ' repair' : ''); + el.innerHTML = `<span class="lamp busy"></span><span class="t"><b>${step.t}</b>`+ + `<span class="why">${step.why}</span><span class="ev">…</span></span>`; + host.appendChild(el); host.scrollTop = host.scrollHeight; + return el; +} +function landStep(el, ev, cls){ + el.querySelector('.lamp').className = 'lamp' + (cls ? ' '+cls : ''); + el.querySelector('.ev').textContent = ev; + el.closest('.output').scrollTop = 1e6; +} +function verdict(host, text, ok){ + const v = document.createElement('div'); + v.className = 'o-verdict' + (ok ? ' ok' : ''); + v.textContent = text; + host.appendChild(v); host.scrollTop = host.scrollHeight; +} + +function runDoctor(){ + if (busy) return; + busy = true; + $('b-doctor').disabled = $('b-speed').disabled = true; + const brokenDNS = $('breakdns').checked; + const out = $('out'); out.innerHTML = ''; + lampState('CHECKING','gold'); + const gap = T(620); + let i = 0; + const next = () => { + if (i >= CHECKS.length){ return brokenDNS ? repairPhase() : finish('Overall — everything checks out'); } + const c = CHECKS[i]; + const el = addStep(out, c); + setTimeout(() => { + const broken = brokenDNS && c.canBreak; + landStep(el, broken ? c.evBroken : c.ev, broken ? 'red' : ''); + i++; setTimeout(next, gap*0.35); + }, gap); + }; + const repairPhase = () => { + lampState('FIXING','gold'); + verdict(out, 'DNS not resolving — trying the lightest repair'); + const r1 = addStep(out, REPAIR, true); + setTimeout(() => { + landStep(r1, REPAIR.ev); + const r2 = addStep(out, REPAIR2, true); + setTimeout(() => { + landStep(r2, REPAIR2.ev); + const re = addStep(out, {t:'re-check: Internet', why:'probe again through the repaired resolver', ev:''}); + setTimeout(() => { + landStep(re, 'open internet (HTTP 204) [96 ms]'); + $('breakdns').checked = false; + finish('fixed — back online after dns-override'); + }, gap); + }, gap*1.2); + }, gap*1.4); + }; + const finish = (text) => { + verdict(out, text, true); + lampState('ONLINE'); + busy = false; + $('b-doctor').disabled = $('b-speed').disabled = false; + }; + next(); +} + +function setEther(present){ + if (busy) return; + ether.present = present; + if (present){ + ether.routed = true; // cable wins the route by metric + lampState('ONLINE'); + toast('link detected on ' + ether.dev + ' — route moved to wired'); + } else { + ether.routed = false; + toast(connected ? 'cable unplugged — route back on wlp170s0' + : 'cable unplugged', !connected); + if (!connected) lampState('OFFLINE','red'); + } + renderTunnels(); renderNets(); +} +function toggleEther(){ + if (busy) return; + const lamp = $('eth-lamp'), what = $('eth-what'); + busy = true; + lamp.className = 'lamp busy'; + what.textContent = ether.routed ? 'standing by…' : 'taking the route…'; + setTimeout(() => { + busy = false; + ether.routed = !ether.routed; + renderTunnels(); renderNets(); + toast(ether.routed ? 'route moved to ' + ether.dev + ' (wired)' + : 'route back on wlp170s0 — ' + ether.dev + ' standing by'); + }, T(1200)); +} + +function wifiPower(){ + if (busy) return; + if (airplane){ toast('airplane mode is on — Super+Shift+A to leave it', true); return; } + setWifi(!wifiOn); +} +function setWifi(on, quiet){ + wifiOn = on; + $('n-power').classList.toggle('on', on); + if (!on){ + const act = NETS.find(n => n.active); + if (act){ lastSsid = act.ssid; act.active = false; } + connected = false; + if (!(ether.present && ether.routed)) tstate.ts = false; + netFace(); renderTunnels(); renderNets(); + if (!quiet) toast('wifi radio off'); + } else { + netFace(); renderTunnels(); renderNets(); + if (!quiet) toast('wifi radio on — rejoining ' + lastSsid); + const n = NETS.find(x => x.ssid === lastSsid && x.range); + if (n) setTimeout(() => doJoin(n), T(700)); + } +} +function setAirplane(on){ + airplane = on; + if (on){ + if (wifiOn) setWifi(false, true); + if (bpower) btPowerSet(false, true); + netFace(); + $('b-air-badge').classList.add('show'); + toast('airplane mode — all radios off'); + } else { + $('b-air-badge').classList.remove('show'); + setWifi(true, true); + btPowerSet(true, true); + netFace(); + toast('airplane mode off — radios back up'); + } +} + +function setAirport(on){ + if (busy || bbusy) return; + if (on && $('cafe').checked){ $('cafe').checked = false; } + NETS = on ? AIRPORT_NETS() : HOTEL_NETS(); + NEARBY.length = 0; + NEARBY.push(...(on ? AIRPORT_NEARBY() + : [{id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'}, + {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'}])); + if (on){ + tstate.ts = false; connected = false; + lampState('OFFLINE','red'); + toast('ORD concourse B — pick a network'); + } else { + tstate.ts = true; connected = true; + routeBase = '172.20.2.108/20 · gw 172.20.0.1'; + NETS.find(n=>n.id==='hyatt').active = true; + lampState('ONLINE'); + } + renderTunnels(); renderNets(); renderBt(); +} + +function setScenario(cafe){ + if (busy) return; + if (cafe && $('airportcb') && $('airportcb').checked){ $('airportcb').checked = false; } + NETS = cafe ? CAFE_NETS() : HOTEL_NETS(); + if (cafe){ + tstate.ts = false; connected = false; + $('ch-ssid').textContent = '— not connected'; + $('ch-dbm').textContent = ''; + $('ch-ladder').className = 'ladder'; + lampState('OFFLINE','red'); + } else { + tstate.ts = true; connected = true; + routeBase = '172.20.2.108/20 · gw 172.20.0.1'; + $('ch-ssid').textContent = '@Hyatt_WiFi'; + $('ch-dbm').textContent = '-59 dBm · 44 ms'; + $('ch-ladder').className = 'ladder l3'; + lampState('ONLINE'); + } + renderTunnels(); renderNets(); +} + +function closePanel(){ $('p').classList.add('closed'); $('reopen').style.display='inline-block'; } +function openPanel(){ $('p').classList.remove('closed'); $('reopen').style.display='none'; } + +/* =========================================================== BT·01 */ +let bbusy = false, bpower = true, bdisco = false, blow = false; +const DEVS = [ + {id:'m650', name:'Logi M650', kind:'mouse', paired:true, conn:true, batt:72, audio:false}, + {id:'xm4', name:'WH-1000XM4', kind:'audio', paired:true, conn:false, batt:58, audio:true}, + {id:'k3', name:'Keychron K3', kind:'keyboard', paired:true, conn:false, batt:34, audio:false}, +]; +const NEARBY = [ + {id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'}, + {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'}, +]; +let barmed = null, barmTimer = null; + +function btConnected(){ return DEVS.filter(d => d.conn); } + +function renderBt(){ + /* paired rows */ + const host = $('b-paired'); host.innerHTML = ''; + for (const d of DEVS){ + const row = document.createElement('div'); + row.className = 'lamp-row' + (barmed===d.id ? ' armed' : ''); + const lamp = !bpower ? 'lamp off' : d.conn ? 'lamp' : 'lamp off'; + const battTxt = d.batt !== null && d.conn ? ` · battery ${d.batt}%` : ''; + const what = barmed===d.id ? 'forget? click ✕ again' + : !bpower ? 'adapter off' + : d.conn ? d.kind + battTxt : d.kind + ' · not connected'; + row.innerHTML = `<span class="${lamp}" id="bl-${d.id}"></span>`+ + `<span class="who">${d.conn && bpower ? '<b>'+d.name+'</b>' : d.name}</span>`+ + `<span class="what" id="bw-${d.id}">${what}</span>`+ + `<button class="pen" title="Rename ${d.name}">✎</button>`+ + `<button class="zap" title="Forget ${d.name}">✕</button>`; + row.querySelector('.pen').onclick = (e) => { e.stopPropagation(); bdlgRename(d.id); }; + row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); btArmForget(d.id); }; + if (bpower) row.onclick = () => btToggleDev(d.id); + host.appendChild(row); + } + /* nearby rows */ + const nb = $('b-nearby'); nb.innerHTML = ''; + for (const n of NEARBY){ + const row = document.createElement('div'); + row.className = 'lamp-row'; + row.innerHTML = `<span class="${bpower?'lamp gold':'lamp off'}" id="bnl-${n.id}"></span>`+ + `<span class="who">${n.name}</span><span class="what" id="bnw-${n.id}">${bpower ? n.kind+' · pairable' : 'adapter off'}</span>`; + if (bpower) row.onclick = () => btPair(n.id); + nb.appendChild(row); + } + /* adapter line + faceplate */ + const c = btConnected().length; + $('b-conn-count').textContent = bpower ? (c === 1 ? '1 device connected' : c + ' devices connected') : 'adapter off'; + $('b-paired-count').textContent = '· ' + DEVS.length; + $('b-nearby-count').textContent = '· ' + NEARBY.length; + $('b-disco').textContent = 'discoverable ' + (bdisco && bpower ? 'on' : 'off'); + $('b-disco').className = 'chip' + (bdisco && bpower ? ' on' : ''); + renderGauges(); + updateBtBadge(); + tips('b-paired'); tips('b-nearby'); + for (const el of document.querySelectorAll('#bp .m-label')) el.title = el.textContent; +} + +function renderGauges(){ + const conns = btConnected(); + for (let i = 0; i < 2; i++){ + const d = bpower ? conns[i] : null; + const needle = $('bn-'+i), val = $('bv-'+i), label = $('bl-'+i), + tag = $('bmt-'+i), wrap = $('bvw-'+i); + if (!d){ + needle.className = 'needle dead'; + needle.style.transform = 'rotate(-60deg)'; + val.textContent = '—'; wrap.className = 'm-value'; + label.textContent = bpower ? 'NO DEVICE' : 'ADAPTER OFF'; + tag.textContent = '—'; tag.className = 'mode-tag off'; + continue; + } + const low = d.batt < 15; + needle.className = 'needle' + (low ? ' low' : ''); + needle.style.transform = `rotate(${-60 + (d.batt/100)*120}deg)`; + val.innerHTML = d.batt; wrap.className = 'm-value' + (low ? ' low' : ''); + wrap.innerHTML = `<span id="bv-${i}">${d.batt}</span> <small>%</small>`; + label.textContent = d.name.toUpperCase(); + tag.textContent = 'LIVE'; tag.className = 'mode-tag'; + } +} + +function updateBtBadge(){ + const low = bpower && btConnected().some(d => d.batt < 15); + $('b-badge').classList.toggle('show', low); +} + +function btToggleDev(id){ + if (bbusy || !bpower) return; + const d = DEVS.find(x=>x.id===id); + const lamp = $('bl-'+id), what = $('bw-'+id); + bbusy = true; + lamp.className = 'lamp busy'; + what.textContent = d.conn ? 'disconnecting…' : 'connecting…'; + setTimeout(() => { + bbusy = false; + d.conn = !d.conn; + renderBt(); + btToast(d.conn ? `${d.name} connected` : `${d.name} disconnected`); + }, T(1300)); +} + +function btArmForget(id){ + const d = DEVS.find(x=>x.id===id); + if (barmed === id){ + clearTimeout(barmTimer); barmed = null; + DEVS.splice(DEVS.indexOf(d), 1); renderBt(); + btToast(`${d.name} forgotten`); + return; + } + barmed = id; renderBt(); + clearTimeout(barmTimer); + barmTimer = setTimeout(() => { barmed = null; renderBt(); }, 3000); +} + +/* pairing + rename dialogs */ +let bdlgMode = null, bdlgTarget = null; +function btPair(id){ + if (bbusy) return; + const n = NEARBY.find(x=>x.id===id); + const lamp = $('bnl-'+id), what = $('bnw-'+id); + bbusy = true; + lamp.className = 'lamp busy'; + what.textContent = 'pairing…'; + setTimeout(() => { + bdlgMode = 'pair'; bdlgTarget = n; + $('bdlg-title').textContent = 'Pair ' + n.name; + $('bdlg-sub').textContent = 'confirm this passkey shows on the device'; + $('bdlg-key').style.display = 'block'; + $('bdlg-key').textContent = n.passkey; + $('bdlg-name').style.display = 'none'; + $('bdlg-go').textContent = 'Confirm'; + $('bov').classList.add('show'); + }, T(1200)); +} +function bdlgRename(id){ + const d = DEVS.find(x=>x.id===id); + bdlgMode = 'rename'; bdlgTarget = d; + $('bdlg-title').textContent = 'Rename device'; + $('bdlg-sub').textContent = 'the alias lives on this machine (bluez set-alias)'; + $('bdlg-key').style.display = 'none'; + $('bdlg-name').style.display = 'block'; + $('bdlg-name').value = d.name; + $('bdlg-go').textContent = 'Rename'; + $('bov').classList.add('show'); + $('bdlg-name').focus(); +} +function bdlgClose(){ + $('bov').classList.remove('show'); + if (bdlgMode === 'pair'){ bbusy = false; renderBt(); btToast('pairing cancelled'); } + bdlgMode = null; bdlgTarget = null; +} +function bdlgGo(){ + if (bdlgMode === 'pair'){ + const n = bdlgTarget; + $('bov').classList.remove('show'); + const what = $('bnw-'+n.id); + if (what) what.textContent = 'connecting…'; + setTimeout(() => { + bbusy = false; + NEARBY.splice(NEARBY.indexOf(n), 1); + DEVS.push({id:n.id, name:n.name, kind:n.kind, paired:true, conn:true, + batt:n.kind==='audio' ? 91 : null, audio:n.kind==='audio'}); + renderBt(); + btToast(`${n.name} paired and connected`); + }, T(1100)); + } else if (bdlgMode === 'rename'){ + const d = bdlgTarget; + const name = $('bdlg-name').value.trim() || d.name; + d.name = name; + $('bov').classList.remove('show'); + renderBt(); + btToast(`renamed to ${name}`); + } + bdlgMode = null; bdlgTarget = null; +} + +/* scan */ +const MORE_NEARBY = [ + {id:'buds', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'}, +]; +function btScan(){ + if (bbusy || !bpower) return; + $('b-scan-note').textContent = 'scanning…'; + $('bb-scan').disabled = true; + setTimeout(() => { + if (MORE_NEARBY.length){ + NEARBY.push(MORE_NEARBY.shift()); + renderBt(); + } + $('b-scan-note').textContent = ''; + $('bb-scan').disabled = false; + btToast('scan complete — ' + NEARBY.length + ' nearby'); + }, T(2200)); +} + +/* discoverable + power */ +function btDisco(){ + if (!bpower) return; + bdisco = !bdisco; + renderBt(); + btToast(bdisco ? 'discoverable for 2 minutes' : 'discoverable off'); +} +function btPower(){ + if (bbusy) return; + if (airplane){ btToast('airplane mode is on — Super+Shift+A to leave it', true); return; } + btPowerSet(!bpower); +} +function btPowerSet(on, quiet){ + if (bbusy) return; + bpower = on; + $('b-power').classList.toggle('on', bpower); + if (!bpower){ + bdisco = false; + DEVS.forEach(d => d._wasConn = d.conn); + DEVS.forEach(d => d.conn = false); + $('b-state').textContent = airplane ? 'AIRPLANE' : 'OFF'; + $('b-lamp').className = airplane ? 'lamp gold' : 'lamp off'; + $('bb-doctor').disabled = $('bb-scan').disabled = true; + renderBt(); + if (!quiet) btToast('adapter powered off'); + } else { + $('b-state').textContent = 'POWERED'; + $('b-lamp').className = 'lamp'; + $('bb-doctor').disabled = $('bb-scan').disabled = false; + renderBt(); + if (quiet === true) { /* airplane restore: quiet */ } + /* the mouse auto-reconnects, like real life */ + const mouse = DEVS.find(d => d._wasConn); + if (mouse){ + setTimeout(() => { + const lamp = $('bl-'+mouse.id), what = $('bw-'+mouse.id); + if (lamp){ lamp.className = 'lamp busy'; what.textContent = 'reconnecting…'; } + setTimeout(() => { mouse.conn = true; renderBt(); btToast(mouse.name + ' reconnected'); }, T(1200)); + }, T(600)); + } + } +} + +let btoastTimer; +function btToast(msg, err){ + const el = $('b-toastEl'); + el.textContent = msg; el.className = 'toast show' + (err ? ' err' : ''); + clearTimeout(btoastTimer); + btoastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600); +} + +/* bt doctor — the real chain: adapter → radio → service → powered → devices → audio */ +function btChecks(){ + const badAudio = $('badaudio').checked && DEVS.some(d => d.audio && d.conn); + return [ + {t:'Adapter', why:'is a bluetooth adapter visible to the stack', ev:'intel ax211 (hci0)'}, + {t:'Radio', why:'rfkill can block the radio in software or hardware', ev:'unblocked'}, + {t:'Service', why:'is the bluez daemon running', ev:'bluetooth.service active'}, + {t:'Powered', why:'radio on and ready to connect', ev:'powered on'}, + {t:'Devices', why:'are paired devices reachable', + ev: btConnected().length ? btConnected().map(d=>d.name).join(', ') + ' connected' : 'none connected'}, + {t:'Audio profile', why:'is a connected audio device on the high-quality profile (A2DP)', + ev: DEVS.some(d=>d.audio && d.conn) ? 'a2dp-sink' : 'no audio device connected', + evBroken:'stuck on headset-head-unit (HSP) — phone-call-grade audio', canBreak:badAudio}, + ]; +} +const BT_REPAIR = {t:'repair: a2dp-switch', why:'flips the card profile to A2DP and verifies the sink followed', + ev:'card profile set to a2dp-sink — sink followed'}; + +function btDoctor(){ + if (bbusy || !bpower) return; + bbusy = true; + $('bb-doctor').disabled = $('bb-scan').disabled = true; + const checks = btChecks(); + const willRepair = checks.some(c => c.canBreak); + const out = $('b-out'); out.innerHTML = ''; + $('b-state').textContent = 'CHECKING'; $('b-lamp').className = 'lamp gold'; + const gap = T(620); + let i = 0; + const next = () => { + if (i >= checks.length){ return willRepair ? repairPhase() : finish('Overall — everything checks out'); } + const c = checks[i]; + const el = addStep(out, c); + setTimeout(() => { + landStep(el, c.canBreak ? c.evBroken : c.ev, c.canBreak ? 'red' : ''); + i++; setTimeout(next, gap*0.35); + }, gap); + }; + const repairPhase = () => { + $('b-state').textContent = 'FIXING'; + verdict(out, 'audio degraded — trying the lightest repair'); + const r = addStep(out, BT_REPAIR, true); + setTimeout(() => { + landStep(r, BT_REPAIR.ev); + const re = addStep(out, {t:'re-check: Audio profile', why:'read the sink profile again after the switch', ev:''}); + setTimeout(() => { + landStep(re, 'a2dp-sink [verified]'); + $('badaudio').checked = false; + finish('fixed — high-quality audio restored'); + }, gap); + }, gap*1.4); + }; + const finish = (text) => { + verdict(out, text, true); + $('b-state').textContent = 'POWERED'; $('b-lamp').className = 'lamp'; + bbusy = false; + $('bb-doctor').disabled = $('bb-scan').disabled = false; + }; + next(); +} + +function btLowBatt(low){ + blow = low; + const m = DEVS.find(d => d.id === 'm650'); + if (m) m.batt = low ? 9 : 72; + renderBt(); + if (low) btToast('Logi M650 battery low (9%)', true); +} + +function closeBt(){ $('bp').classList.add('closed'); $('b-reopen').style.display='inline-block'; } +function openBt(){ $('bp').classList.remove('closed'); $('b-reopen').style.display='none'; } + +/* shared ambience + keys */ +if (!reduced) setInterval(() => { + amb++; + if (!testing){ + if (!held.rx) meter('rx', 0.1 + Math.abs(Math.sin(amb/3))*0.35); + if (!held.tx) meter('tx', 0.08 + Math.abs(Math.cos(amb/4))*0.25); + } +}, 900); +document.addEventListener('keydown', e => { + if (e.key === 'Escape'){ + if ($('ov').classList.contains('show')) return dlgClose(); + if ($('bov').classList.contains('show')) return bdlgClose(); + closePanel(); closeBt(); + } +}); + +function clearOut(id){ $(id).innerHTML = ''; } +function tips(hostId){ + for (const el of $(hostId).querySelectorAll('.what,.who,.m-label')) + el.title = el.textContent; +} +[['out','outwrap'],['b-out','b-outwrap']].forEach(([oid, wid]) => { + new MutationObserver(() => { + $(wid).classList.toggle('has', $(oid).childElementCount > 0); + }).observe($(oid), {childList: true}); +}); + +renderNets(); renderTunnels(); renderBt(); + +/* headless hooks */ +const auto = new URLSearchParams(location.search).get('auto'); +if (auto === 'btdoctor') btDoctor(); +if (auto === 'btdoctorfix'){ + const xm4 = DEVS.find(d=>d.id==='xm4'); xm4.conn = true; renderBt(); + $('badaudio').checked = true; btDoctor(); +} +if (auto === 'btpair'){ btPair('jbl'); setTimeout(() => bdlgGo(), T(1800)); } +if (auto === 'btpower'){ btPower(); } +if (auto === 'btpoweron'){ btPower(); setTimeout(() => btPower(), T(600)); } +if (auto === 'btlow'){ $('lowbatt').checked = true; btLowBatt(true); } +if (auto === 'speed') runSpeed(); +if (auto === 'ether'){ $('ethercb').checked = true; setEther(true); } +if (auto === 'air'){ $('aircb').checked = true; setAirplane(true); } +if (auto === 'airether'){ $('ethercb').checked = true; setEther(true); $('aircb').checked = true; setAirplane(true); } +if (auto === 'disc'){ armDisconnect('hyatt'); setTimeout(() => armDisconnect('hyatt'), T(600)); } +if (auto === 'wifioff'){ setWifi(false); } +if (auto === 'airport'){ $('airportcb').checked = true; setAirport(true); } +if (auto === 'doctorfix'){ $('breakdns').checked = true; runDoctor(); } +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html new file mode 100644 index 0000000..3329cdb --- /dev/null +++ b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html @@ -0,0 +1,251 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Net panel — rescan affordance</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.masthead{max-width:1200px;margin:0 auto 1.8rem} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:82ch} +.masthead p b{color:var(--silver)} + +.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1200px;margin:0 auto;align-items:flex-start} +.slot{width:400px} +.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem} +.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px} + +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px} +.b-id{display:flex;align-items:center;gap:9px} +.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em} +.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto} + +.chan{margin-top:12px} +.chan .line1{display:flex;align-items:baseline;gap:9px} +.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px} +.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px} +.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px} +.ladder i{width:4px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px} +.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px} +.ladder.l1 i:nth-child(-n+1){background:var(--gold)}.ladder.l2 i:nth-child(-n+2){background:var(--gold)} +.ladder.l3 i:nth-child(-n+3){background:var(--gold)}.ladder.l4 i{background:var(--gold)} + +/* engrave header with the rescan action */ +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.24em;text-transform:uppercase; + display:flex;align-items:center;gap:8px;margin:14px 0 6px} +.engrave::before{content:"";height:1px;background:var(--wash);width:10px;flex:0 0 auto} +.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;cursor:pointer; + border-bottom:1px dotted transparent} +.engrave .cnt:hover{color:var(--gold);border-bottom-color:var(--wash)} +.engrave .cnt.scanning{color:var(--gold);animation:breathe 1.1s ease-in-out infinite;cursor:default;border-bottom-color:transparent} +.engrave .spacer{flex:1;height:1px;background:var(--wash)} +/* compact rescan glyph — sits right after the count, spins while scanning */ +.engrave .ricon{color:var(--dim);cursor:pointer;font-size:.82rem;display:inline-flex;line-height:1} +.engrave .ricon:hover{color:var(--gold)} +.engrave .ricon.spin{color:var(--gold);cursor:default;animation:spin .9s linear infinite} +.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer} +.engrave .act:hover{color:var(--gold)} +@keyframes spin{to{transform:rotate(360deg)}} +@keyframes breathe{0%,100%{opacity:1}50%{opacity:.35}} + +/* the list; section-breathe busy style pulses the whole well */ +#networks{border-radius:8px;transition:background .3s} +#networks.breathe{animation:sectionbreathe 1.4s ease-in-out infinite} +@keyframes sectionbreathe{0%,100%{background:transparent}50%{background:rgba(218,181,61,.06)}} + +.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer} +.lamp-row:hover{background:var(--wash)} +.lamp-row .who{color:var(--silver);white-space:nowrap} +.lamp-row .who b{color:var(--cream)} +.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.lamp-row.fresh{animation:fadein .5s ease} +@keyframes fadein{from{opacity:0;transform:translateY(-3px);background:rgba(218,181,61,.12)}to{opacity:1}} + +.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px; + padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em} +.toast.show{opacity:1} + +.aside{flex:1 1 320px;min-width:290px} +.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem} +.aside h3:first-child{margin-top:.2rem} +.aside ul{list-style:none} +.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative} +.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.aside li b{color:var(--cream);font-weight:700} +.aside li em{color:var(--dim);font-style:normal} +.demo{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:1rem} +.demo .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.2em;text-transform:uppercase;margin-bottom:.5rem} +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;margin-bottom:.7rem} +.seg button{flex:1;font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 6px;cursor:pointer} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} +.demo .go{font:inherit;font-size:.8rem;color:var(--silver);background:transparent;border:1px solid var(--gold); + border-radius:8px;padding:.45rem 1rem;cursor:pointer} +.demo .go:hover{background:rgba(218,181,61,.12)} +.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem} +.rec b{color:var(--gold)} +@media (prefers-reduced-motion:reduce){*{animation:none!important}} +</style> +</head> +<body> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · net·01</div> + <h1>Networks — the rescan affordance</h1> + <p>Where does a WiFi rescan live, and how does it show it's working? The count "· N in range" is + really a live status field — the natural home for "scanning…". My lean: an explicit <b>⟳ rescan</b> + action in the engrave line (discoverable, same slot as "+ hidden"), with a <b>flash-and-fade</b> + busy state — the glyph spins, the count breathes, new rows fade in as found. The count is also + click-to-rescan as a shortcut. Use the selector on the right to feel each busy treatment.</p> +</header> + +<div class="stage"> + <div class="slot"> + <div class="slot-label">net·01 — networks section</div> + <div class="panel"> + <div class="b-face"> + <div class="b-id"> + <span class="lamp"></span> + <span class="state-word">ONLINE</span> + <span class="unit">NET·01</span> + </div> + </div> + + <div class="engrave">channel</div> + <div class="chan"> + <div class="line1"><span class="ssid">@Hyatt_WiFi</span> + <span class="ladder l3"><i></i><i></i><i></i><i></i></span> + <span class="dim" style="font-size:11px">-59 dBm · 44 ms</span></div> + <div class="line2">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div> + </div> + + <div class="engrave">networks<span class="cnt" id="cnt" onclick="rescan()">· 5 in range</span> + <span class="ricon" id="rescan" onclick="rescan()" title="Rescan for networks">⟳</span> + <span class="spacer"></span> + <span class="act" onclick="toast('would open the hidden-SSID dialog')">+ hidden</span> + </div> + <div id="networks"></div> + + <div class="toast" id="toast"></div> + </div> + </div> + + <div class="aside"> + <div class="demo"> + <div class="lbl">busy feedback style</div> + <div class="seg" id="styleSeg"> + <button class="on" data-s="all">all (rec)</button> + <button data-s="spin">spinner</button> + <button data-s="count">count</button> + <button data-s="section">section</button> + </div> + <button class="go" onclick="rescan()">▶ run a rescan</button> + </div> + + <h3>The three busy signals</h3> + <ul> + <li><b>Spinner</b> — the ⟳ glyph rotates while scanning. The clearest "working" cue; universal.</li> + <li><b>Count breathe</b> — "· 5 in range" becomes "scanning…" and slow-pulses. Your idea: the status field animates in place, no extra chrome.</li> + <li><b>Section breathe</b> — the whole list gives a faint gold breath while the scan runs; found rows fade in gold. Ambient, ties the animation to what's changing.</li> + <li><b>All (recommended)</b> — spinner + count + fade-in together. The section breathe is optional; it can read as busy on a small panel, so it's off in "all" by default and its own option to try.</li> + </ul> + + <h3>Why an explicit action, not only the count</h3> + <div class="rec"> + Overloading the count as the sole trigger is elegant but a first look doesn't know it's clickable. + An explicit <b>⟳ rescan</b> in the engrave line is discoverable, sits in the same slot as "+ hidden" + (consistent), and doesn't cost a heavy console key. Keeping the count clickable too gives power users + the shortcut without hiding the affordance. A dedicated <b>RESCAN console key</b> (next to DOCTOR / + SPEED TEST) is the third option — heavier, and rescan is a list action, not a diagnostic, so it fits + the engrave line better. + </div> + </div> +</div> + +<script> +const $=id=>document.getElementById(id); +let busy=false, style='all'; +let NETS=[ + {ssid:'@Hyatt_WiFi', sig:3, sec:'WPA2', stored:true, active:true}, + {ssid:'Hyatt_Meeting', sig:3, sec:'WPA2', stored:false, active:false}, + {ssid:'DIRECT-roku-882',sig:2, sec:'WPA2', stored:false, active:false}, + {ssid:'xfinitywifi', sig:1, sec:null, stored:false, active:false}, + {ssid:'HomeNet', sig:0, sec:'WPA2', stored:true, active:false, oor:true}, +]; +const NEWFOUND=[ + {ssid:'Hyatt_Guest', sig:2, sec:null, stored:false, active:false}, + {ssid:'Marriott_CONF', sig:1, sec:'WPA2', stored:false, active:false}, +]; +const pctFor=[null,'22%','44%','61%','78%']; + +$('styleSeg').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return; + style=b.dataset.s;[...$('styleSeg').children].forEach(x=>x.classList.toggle('on',x===b));}); + +function rowEl(n,fresh){ + const r=document.createElement('div'); r.className='lamp-row'+(fresh?' fresh':''); + const lamp=n.active?'lamp':(n.oor?'lamp off':'lamp gold'); + const what=n.active?'active · '+(n.sec||'open') + :n.oor?'stored · out of range' + :(n.stored?'stored · ':'')+(n.sec||'open')+' · '+pctFor[n.sig]; + r.innerHTML=`<span class="${lamp}"></span><span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+ + (!n.active&&!n.oor?`<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>`:'')+ + `<span class="what">${what}</span>`; + r.onclick=()=>{ if(busy)return; toast(n.active?'already on '+n.ssid:'would join '+n.ssid); }; + return r; +} +function render(freshSet){ + const host=$('networks'); host.innerHTML=''; + const inRange=NETS.filter(n=>!n.oor).sort((a,b)=>(b.active-a.active)||(b.sig-a.sig)); + const oor=NETS.filter(n=>n.oor); + [...inRange,...oor].forEach(n=>host.appendChild(rowEl(n,freshSet&&freshSet.has(n.ssid)))); + if(!busy) $('cnt').textContent='· '+inRange.length+' in range'; +} +function rescan(){ + if(busy) return; busy=true; + const useSpin = style==='all'||style==='spin'; + const useCount= style==='all'||style==='count'; + const useSec = style==='section'; + if(useSpin) $('rescan').classList.add('spin'); + if(useCount){ $('cnt').classList.add('scanning'); $('cnt').textContent='scanning…'; } + if(useSec) $('networks').classList.add('breathe'); + toast('scanning for networks…'); + // networks trickle in as "found" + const fresh=new Set(); + setTimeout(()=>{ NETS.splice(1,0,NEWFOUND[0]); fresh.add(NEWFOUND[0].ssid); render(fresh); },900); + setTimeout(()=>{ NETS.splice(3,0,NEWFOUND[1]); fresh.add(NEWFOUND[1].ssid); render(fresh); },1700); + setTimeout(()=>{ + busy=false; + $('rescan').classList.remove('spin'); + $('cnt').classList.remove('scanning'); + $('networks').classList.remove('breathe'); + const n=NETS.filter(x=>!x.oor).length; + $('cnt').textContent='· '+n+' in range'; + toast('scan complete — '+n+' networks in range'); + // reset for the next run so the demo is repeatable + NETS=NETS.filter(x=>x.ssid!=='Hyatt_Guest'&&x.ssid!=='Marriott_CONF'); + },2600); +} +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html b/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html new file mode 100644 index 0000000..8e642f4 --- /dev/null +++ b/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html @@ -0,0 +1,338 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Panel widget gallery — dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1320px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:86ch} +.masthead p b{color:var(--silver)} +h2{color:var(--steel);font-size:.74rem;letter-spacing:.24em;text-transform:uppercase; + margin:2.2rem 0 .2rem;display:flex;align-items:center;gap:12px} +h2::after{content:"";height:1px;background:var(--wash);flex:1} + +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:14px;margin-top:1rem} +.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px; + padding:13px 14px 12px;display:flex;flex-direction:column;gap:9px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)} +.wname{color:var(--cream);font-size:.82rem;font-weight:700;display:flex;align-items:center;gap:8px} +.wname .no{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.6rem;padding:0 5px;font-weight:400} +.stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px; + min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap} +.wnote{color:var(--dim);font-size:.72rem;line-height:1.4} +.wnote b{color:var(--steel);font-weight:400} + +/* ---- shared primitives ---- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.switch{width:40px;height:21px;border-radius:11px;background:var(--wash);border:1px solid var(--slate); + position:relative;cursor:pointer} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:15px;height:15px;border-radius:50%; + background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:21px;background:var(--gold)} +.switch.red{background:rgba(203,107,77,.2);border-color:var(--fail)} +.switch.red::after{background:var(--fail);left:2px} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.off{opacity:.4} + +.chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px} +.chip.on{color:var(--gold);border-color:var(--gold)} + +.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} + +/* fader */ +.fader{width:150px;height:16px;position:relative;cursor:pointer} +.fader .slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold))} +.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; + background:linear-gradient(180deg,#f0d879,#caa233);border:1px solid #7a6414;box-shadow:0 1px 2px rgba(0,0,0,.5)} +/* vertical fader */ +.vfader{width:16px;height:64px;position:relative;cursor:pointer} +.vfader .slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.vfader .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#8a7524,var(--gold))} +.vfader .cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px; + background:linear-gradient(90deg,#caa233,#f0d879);border:1px solid #7a6414} + +/* rotary knob */ +.knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:pointer; + background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.knob .ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi); + margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(255,215,95,.6)} +.knob-scale{position:relative;width:64px;height:40px} +.knob-scale .arc{position:absolute;inset:0 0 -24px 0;border:1.5px solid var(--wash);border-top-color:var(--gold);border-radius:50%;opacity:.5} + +/* needle gauge */ +.gauge{width:96px} +.gauge .dial{position:relative;height:48px;overflow:hidden} +.gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%} +.gauge .tk{position:absolute;left:50%;bottom:0;width:1.5px;height:8px;background:var(--steel);transform-origin:50% 48px} +.gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(255,215,95,.5); + transition:transform .5s cubic-bezier(.3,1.3,.5,1)} +.gauge .hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)} +.gauge .gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums} + +/* segmented VU / LED bar */ +.vu{width:170px;display:flex;flex-direction:column;gap:5px} +.vurow{display:flex;align-items:center;gap:7px} +.vurow .ch{color:var(--steel);font-size:.6rem;width:8px} +.vubar{flex:1;display:flex;gap:2px;height:9px} +.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} +.vubar i.on{opacity:1;background:var(--pass)}.vubar i.hot{opacity:1;background:var(--gold)} +.vubar i.clip{opacity:1;background:var(--fail)}.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px} + +/* mini 4-bar signal */ +.sig{display:flex;align-items:flex-end;gap:2px;height:18px} +.sig i{width:4px;background:var(--wash);border-radius:1px} +.sig i:nth-child(1){height:5px}.sig i:nth-child(2){height:9px}.sig i:nth-child(3){height:13px}.sig i:nth-child(4){height:17px} +.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)} + +/* signal ladder (wifi bars) */ +.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px} +.ladder i{width:5px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:6px}.ladder i:nth-child(2){height:10px} +.ladder i:nth-child(3){height:14px}.ladder i:nth-child(4){height:18px} +.ladder.l3 i:nth-child(-n+3){background:var(--gold)} + +/* linear progress / fuel bar */ +.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} + +/* radial ring */ +.ring{width:60px;height:60px;border-radius:50%; + background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums} + +/* tabular readout */ +.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em} +.readout small{color:var(--dim);font-size:12px;font-weight:400} +.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px} + +/* sparkline */ +.spark{width:170px;height:44px} +.spark svg{display:block;width:100%;height:100%} + +/* lamp row (list item) */ +.lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px} +.lrow:hover{background:var(--wash)} +.lrow .who{color:var(--silver)}.lrow .who b{color:var(--cream)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:11px} + +/* arm-to-fire */ +.arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; + border-radius:8px;padding:7px 12px} +.arm.armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)} + +/* stepper / segmented selector */ +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} + +/* engraved section label */ +.engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase; + display:flex;align-items:center;gap:9px} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:10px} +.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} + +/* waveform strip */ +.wave{width:170px;height:38px} +.wave svg{width:100%;height:100%;display:block} + +/* toast */ +.toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px} + +/* output well (log step) */ +.owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px} +.ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0} +.ostep .lamp{margin-top:3px;width:7px;height:7px} +.ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px} + +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family</div> + <h1>Widget gallery — the instrument-console kit</h1> + <p>Every control + display idiom we can build in the dupre faceplate language, all rendering from + the same tokens the net / bt / sound panels use. <b>Controls</b> take input; <b>meters & + gauges</b> show a live analog value; <b>indicators & readouts</b> show state or a number. + Live ones animate. Pick what fits each job — most cost pure CSS; the two that need a real + drawing surface (needle gauge, waveform) are flagged in their notes.</p> +</header> + +<h2>Controls — take input</h2> +<div class="grid" id="controls"></div> + +<h2>Meters & gauges — live analog value</h2> +<div class="grid" id="meters"></div> + +<h2>Indicators & readouts — state or number</h2> +<div class="grid" id="indicators"></div> + +</div> +<script> +const $ = id => document.getElementById(id); +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +function card(host, no, name, html, note){ + const c=document.createElement('div'); c.className='card'; + c.innerHTML=`<div class="wname"><span class="no">${no}</span>${name}</div>`+ + `<div class="stagew">${html}</div><div class="wnote">${note}</div>`; + host.appendChild(c); return c; +} +function buildBars(el,n){el.innerHTML='';for(let k=0;k<n;k++)el.appendChild(document.createElement('i'));} + +/* ============ CONTROLS ============ */ +const C=$('controls'); +card(C,'01','Toggle switch', + `<span class="switch on" onclick="this.classList.toggle('on')"></span> + <span class="switch red"></span>`, + '<b>on / off / muted.</b> The faceplate master control — wifi radio, bt power, master-mute. Click to flip.'); +card(C,'02','Console key', + `<button class="key on">LIVE</button><button class="key">SCAN</button><button class="key red">MUTED</button>`, + '<b>physical push button.</b> DOCTOR / SPEED TEST / mic mode. Gold = engaged, terracotta = off.'); +card(C,'03','Horizontal fader', + `<div class="fader" id="f1"><div class="slot"><div class="fill" style="width:68%"></div></div><div class="cap" style="left:68%"></div></div>`, + '<b>continuous 0-100.</b> Per-device volume, brightness, kbd backlight. Drag; the gold cap tracks.'); +card(C,'04','Vertical fader', + `<div class="vfader"><div class="slot"><div class="fill" style="height:60%"></div></div><div class="cap" style="bottom:60%"></div></div> + <div class="vfader"><div class="slot"><div class="fill" style="height:35%"></div></div><div class="cap" style="bottom:35%"></div></div>`, + '<b>channel-strip style.</b> A mixer column per device if you want the classic board look.'); +card(C,'05','Rotary knob', + `<span class="knob" id="knob" onclick="bumpKnob()"><span class="ind" id="kind"></span></span>`, + '<b>dial in a value.</b> Volume/gain the analog way. Click to turn; drag in the real build. Pairs with a scale arc.'); +card(C,'06','Segmented selector', + `<div class="seg"><button class="on">TIMER</button><button>ALARM</button><button>POMO</button></div>`, + '<b>pick one of a few.</b> Timer type, layout mode, theme. One press-lit segment.'); +card(C,'07','Chip toggle', + `<span class="chip on" onclick="this.classList.toggle('on')">discoverable on</span>`, + '<b>inline binary.</b> A soft toggle inside a line of text — discoverable, auto-dim, DND. Gold when on.'); +card(C,'08','Arm-to-fire', + `<button class="arm" id="arm" onclick="armFire()">forget</button>`, + '<b>two-stage confirm.</b> Destructive/disruptive actions — forget network, disconnect. First click arms (red), second fires.'); +card(C,'09','Lamp row', + `<div class="lrow"><span class="lamp gold"></span><span class="who"><b>WH-1000XM4</b></span><span class="what">tap to connect</span></div>`, + '<b>actionable list item.</b> The net/bt/sound row: lamp + name + status, click acts. The workhorse.'); + +/* ============ METERS & GAUGES ============ */ +const M=$('meters'); +card(M,'10','Needle gauge', + `<div class="gauge"><div class="dial"><div class="arc"></div> + <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div> + <div class="ndl" id="g1"></div><div class="hub"></div></div><div class="gv"><span id="g1v">0</span>%</div></div>`, + '<b>analog dial.</b> Throughput, battery, volume level. <b>Needs a Cairo/GTK drawing area</b> — CSS can fake a fixed angle but not a smooth sweep in waybar.'); +card(M,'11','Stereo VU (LED bar)', + `<div class="vu"><div class="vurow"><span class="ch">L</span><span class="vubar" id="vuL"></span></div> + <div class="vurow"><span class="ch">R</span><span class="vubar" id="vuR"></span></div></div>`, + '<b>live signal level.</b> The sound panel\'s second meter row. Peak-hold outline. Pure CSS — pango/box segments.'); +card(M,'12','Mini signal (4-bar)', + `<span class="sig" id="mini"></span>`, + '<b>compact activity.</b> Per-row "is this device playing" indicator. Cheap enough to sit in every list row.'); +card(M,'13','Signal ladder', + `<span class="ladder l3"><i></i><i></i><i></i><i></i></span>`, + '<b>discrete strength.</b> Wifi bars, bt RSSI — a stepped 0-4. Already in the net panel.'); +card(M,'14','Linear fuel bar', + `<div class="bar"><span style="width:72%"></span></div><div class="bar warn"><span style="width:12%"></span></div>`, + '<b>a single 0-100.</b> Battery, disk, download progress. Warn tint under threshold. Trivial in CSS.'); +card(M,'15','Radial ring', + `<span class="ring" style="--p:68"><b>68</b></span>`, + '<b>percentage as a donut.</b> CPU, battery, a single meter where a needle is overkill. conic-gradient, pure CSS.'); +card(M,'16','Sparkline', + `<span class="spark" id="spark"><svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline id="sparkp" fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg></span>`, + '<b>recent history.</b> Throughput/CPU over the last minute. SVG here; a drawing area in GTK.'); +card(M,'17','Waveform strip', + `<span class="wave" id="wave"><svg viewBox="0 0 170 38" preserveAspectRatio="none"><path id="wavep" fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg></span>`, + '<b>audio waveform / scope.</b> A richer signal view for the sound panel. <b>Needs a drawing surface.</b>'); + +/* ============ INDICATORS & READOUTS ============ */ +const I=$('indicators'); +card(I,'18','Status lamp', + `<span class="lamp"></span><span class="lamp gold"></span><span class="lamp red"></span><span class="lamp off"></span><span class="lamp busy"></span>`, + '<b>one-glance health.</b> Green ok · gold engaged · red fail · dim off · pulsing busy. The family signature.'); +card(I,'19','Badge / tag', + `<span class="badge">TUNNEL</span> <span class="badge red">LOW BATT</span> <span class="badge ghost">2.4G</span>`, + '<b>a labelled flag.</b> On the faceplate or a row — MUTED, AIRPLANE, DEF, a band tag.'); +card(I,'20','Tabular readout', + `<div style="text-align:center"><div class="readout">24:10</div><span class="u">timer</span></div> + <div style="text-align:center"><div class="readout">68<small>%</small></div></div>`, + '<b>a precise number.</b> Clock, countdown, volume %. BerkeleyMono tabular-nums so digits don\'t jitter.'); +card(I,'21','Engraved label', + `<span class="engrave">outputs<span class="cnt">· 3</span></span>`, + '<b>section divider.</b> The hairline-flanked caps label with a count. Groups a panel into readable blocks.'); +card(I,'22','Output well', + `<div class="owell"><div class="ostep"><span class="lamp"></span><span><b>Link</b><span class="ev">wlp170s0 · @Hyatt</span></span></div> + <div class="ostep"><span class="lamp gold"></span><span><b>DNS</b><span class="ev">resolving…</span></span></div></div>`, + '<b>streaming step log.</b> The doctor/scan output — lamp-per-step with evidence. For any run-and-report action.'); +card(I,'23','Toast / status line', + `<span class="toastw">joined @Hyatt_WiFi — saved</span>`, + '<b>transient confirmation.</b> The one-line result after an action. Auto-dismiss; red variant for errors.'); + +/* ---- live animation ---- */ +let ph=0, kang=140; +function bumpKnob(){ kang=(kang+35)%300-0; $('kind').style.transform=`rotate(${kang-150}deg)`; } +function armFire(){ const a=$('arm'); if(a.classList.contains('armed')){a.classList.remove('armed');a.textContent='forget';} + else{a.classList.add('armed');a.textContent='forget? again';} } +buildBars($('vuL'),16); buildBars($('vuR'),16); buildBars($('mini'),0); +$('mini').innerHTML='<i></i><i></i><i></i><i></i>'; +$('kind').style.transform=`rotate(${kang-150}deg)`; +const hist=Array.from({length:40},()=>0.5); +function paintVU(el,l,pk){const b=el.children,n=b.length,lit=Math.round(l*n); + pk.v=Math.max(lit,(pk.v||0)-0.4);const p=Math.round(pk.v); + for(let k=0;k<n;k++){let c=k<lit?(k>=n-2?'clip':k>=n-4?'hot':'on'):'';if(p>0&&k===p-1)c=(c?c+' ':'')+'peak';b[k].className=c;}} +const pkL={v:0},pkR={v:0}; +function paintMini(el,l){const b=el.children,lit=Math.round(l*4);for(let k=0;k<4;k++)b[k].className=k<lit?(k>=3?'clip':k>=2?'hot':'on'):'';} +function lvl(){return Math.max(0,Math.min(1,0.5+0.4*Math.sin(ph*1.3)+ (Math.random()<0.15?Math.random()*0.4:0) - Math.random()*0.08));} +function tick(){ + ph+=0.09; + const a=lvl(), b=lvl(); + paintVU($('vuL'),a,pkL); paintVU($('vuR'),b,pkR); paintMini($('mini'),a); + // needle sweeps 0..100 + const gv=Math.round(50+45*Math.sin(ph*0.7)); + $('g1').style.transform=`rotate(${-60+gv/100*120}deg)`; $('g1v').textContent=gv; + // sparkline + hist.push(0.5+0.42*Math.sin(ph*0.9)+ (Math.random()-0.5)*0.25); hist.shift(); + $('sparkp').setAttribute('points',hist.map((v,i)=>`${i/(hist.length-1)*170},${44-Math.max(0,Math.min(1,v))*40-2}`).join(' ')); + // waveform + let d='M0 19'; for(let x=0;x<=170;x+=3){const y=19+Math.sin(x*0.18+ph*3)*Math.sin(x*0.05)*14; d+=` L${x} ${y.toFixed(1)}`;} + $('wavep').setAttribute('d',d); +} +if(!reduced) setInterval(tick,80); else tick(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-sound-panel-prototype.html b/docs/prototypes/2026-07-03-sound-panel-prototype.html new file mode 100644 index 0000000..d75f566 --- /dev/null +++ b/docs/prototypes/2026-07-03-sound-panel-prototype.html @@ -0,0 +1,417 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Sound — instrument console (pulsemixer)</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.masthead{max-width:1280px;margin:0 auto 1.8rem} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:82ch} +.masthead p b{color:var(--silver);font-weight:700} + +.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start} +.slot{width:400px} +.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem} + +.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative;overflow:hidden} + +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto; + box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} + +.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px} +.b-id{display:flex;align-items:center;gap:9px} +.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em} +.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto} +.b-id .g{font-size:17px;color:var(--cream)} +.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold); + border-radius:4px;padding:1px 6px;display:none} +.badge.show{display:inline-block} +.badge.red{background:var(--fail);color:var(--cream)} +.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem; + cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto} +.x-btn:hover{background:var(--wash);color:var(--silver)} +/* faceplate master quick-mute — same switch idiom as net wifi / bt power */ +.switch{width:38px;height:20px;border-radius:10px;background:var(--wash); + border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px; + border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:19px;background:var(--gold)} +.switch.muted{background:rgba(203,107,77,.2);border-color:var(--fail)} +.switch.muted::after{background:var(--fail);left:2px} + +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;margin:13px 0 6px} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:12px} +.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px;text-transform:none;font-size:.62rem} + +/* device row — a FIXED grid so nothing can overflow the plate: + [signal] [name] [fader] [pct] [mute]. minmax(0,1fr) lets the name shrink + and ellipsis instead of forcing the row wider than the panel. */ +.dev{display:grid;grid-template-columns:15px minmax(0,1fr) 84px 32px 20px; + align-items:center;gap:8px;padding:6px 5px;border-radius:8px;cursor:default} +.dev:hover{background:var(--wash)} +.dev .who{min-width:0;display:flex;align-items:center;gap:6px;color:var(--silver)} +.dev .who .g{font-size:14px;color:var(--dim);flex:0 0 auto} +.dev.active .who .g{color:var(--gold)} +.dev .who .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.dev.active .who .nm{color:var(--cream);font-weight:700} +.dev.muted .who .nm{color:var(--dim)} +.dev .def{font-size:.5rem;letter-spacing:.14em;color:var(--panel);background:var(--gold); + border-radius:3px;padding:0 4px;flex:0 0 auto;display:none} +.dev.active .def{display:inline-block} +/* fader — machined slot + gold cap; width-based fill, bounded to its cell */ +.fader{height:16px;position:relative;cursor:pointer} +.fader .trk{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:var(--well); + border:1px solid #231f18;overflow:hidden} +.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold))} +.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; + background:linear-gradient(180deg,#f0d879,#caa233);border:1px solid #7a6414;box-shadow:0 1px 2px rgba(0,0,0,.5)} +.dev.muted .fill{background:var(--wash)} +.dev.muted .cap{background:linear-gradient(180deg,#6a6a6a,#3f3f3f);border-color:#2a2a2a} +.pct{color:var(--cream);font-size:11.5px;font-variant-numeric:tabular-nums;text-align:right} +.dev.muted .pct{color:var(--fail)} +.mute-b{color:var(--dim);border:0;background:transparent;font:inherit;font-size:.9rem;cursor:pointer; + border-radius:5px;padding:0;justify-self:center;line-height:1} +.mute-b:hover{color:var(--silver)} +.dev.muted .mute-b{color:var(--fail)} +/* per-row signal mini-meter — 4 bars that light when THIS device has a live + stream, so the sink/source actually playing is visible before you pick it */ +.sig{display:flex;align-items:flex-end;gap:1.5px;height:14px;justify-self:center} +.sig i{width:2.5px;background:var(--wash);border-radius:1px} +.sig i:nth-child(1){height:4px}.sig i:nth-child(2){height:7px} +.sig i:nth-child(3){height:10px}.sig i:nth-child(4){height:13px} +.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)} + +/* mic mode — three console keys */ +.modes{display:flex;gap:8px;margin-top:2px} +.mode{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11px;letter-spacing:.06em; + background:linear-gradient(180deg,#23211e,#191715);color:var(--silver); + border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.mode:hover{color:var(--gold);border-color:var(--gold)} +.mode.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.mode.on.mute{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream)} +.mode .k{display:block;font-size:.54rem;letter-spacing:.14em;color:var(--dim);margin-top:2px} +.mode.on .k{color:rgba(16,15,15,.7)} +.ptt-hint{color:var(--dim);font-size:10.5px;text-align:center;margin-top:6px;min-height:1.2em} +.ptt-hint.live{color:var(--gold)} + +/* meter row 1 — the volume dials you set (needles), OUT + IN */ +.meters{display:flex;gap:12px;margin-top:10px} +.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;position:relative} +.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)} +.meter .mode-tag.mut{color:var(--fail)} +.meter .dial{position:relative;height:50px;overflow:hidden;margin-top:13px} +.meter .arc{position:absolute;inset:0 0 -50px 0;border:2px solid var(--wash);border-radius:50%} +.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:9px;background:var(--steel);transform-origin:50% 50px} +.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:42px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(20deg);border-radius:2px; + box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .3s cubic-bezier(.3,1.3,.5,1)} +.meter .needle.mut{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)} +.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)} +.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums} +.meter .m-value small{color:var(--dim);font-weight:400} +.meter .m-label{color:var(--steel);font-size:.6rem;letter-spacing:.2em;text-align:center;margin-top:2px; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* meter row 2 — the stereo VU pair: the live SIGNAL through the selected + output. Confirms which device is actually carrying the audio. */ +.vupair{margin-top:10px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 11px 8px} +.vuhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:7px} +.vuhead .t{color:var(--steel);font-size:.56rem;letter-spacing:.2em;text-transform:uppercase} +.vuhead .src{color:var(--cream);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%} +.vurow{display:flex;align-items:center;gap:8px;margin:4px 0} +.vurow .ch{color:var(--steel);font-size:.62rem;width:9px;flex:0 0 auto} +.vubar{flex:1;display:flex;gap:2px;height:9px} +.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} +.vubar i.on{opacity:1;background:var(--pass)} +.vubar i.hot{opacity:1;background:var(--gold)} +.vubar i.clip{opacity:1;background:var(--fail)} +.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px} +.vupair.mute .vubar i{background:var(--wash);opacity:.22} +.vupair.mute .vuhead .src{color:var(--fail)} + +.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px; + padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em} +.toast.show{opacity:1} +.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)} + +.aside{flex:1 1 320px;min-width:300px} +.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem} +.aside h3:first-child{margin-top:.2rem} +.aside ul{list-style:none} +.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative} +.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.aside li b{color:var(--cream);font-weight:700} +.aside li em{color:var(--dim);font-style:normal} +.barbits{display:flex;gap:14px;margin:.4rem 0 .2rem;align-items:center;flex-wrap:wrap} +.barbits span{display:flex;align-items:center;gap:7px;color:var(--silver);font-size:.82rem} +.barbits .g{font-size:19px;color:var(--cream)} +.barbits .g.mut{color:var(--fail)} +.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem;color:var(--silver)} +.rec b{color:var(--gold)} +*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent} +::-webkit-scrollbar{width:6px;height:6px} +::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px} +@media (prefers-reduced-motion:reduce){.needle{transition:none}} +</style> +</head> +<body> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div> + <h1>Sound — the pulsemixer console</h1> + <p>Same faceplate as net + bluetooth. The bar's <b>sound glyph</b> opens this. Every sink and + source is a row: <b>click the row body to make it default</b>, drag the fader for its volume, + hit the glyph to mute just it. Each row has a <b>live-signal meter</b> — the device actually + carrying audio dances even when it isn't the default, so you can <b>find the one playing the + meeting</b> and click it. The faceplate switch is the <b>master quick-mute</b>; the mic carries + <b>live · muted · push-to-talk</b> (hold Space). Row 1 of gauges is the volume you set; row 2 is + the <b>stereo VU</b> of the selected output's live signal.</p> +</header> + +<div class="stage"> + <div class="slot"> + <div class="slot-label">snd·01 — pulsemixer in console form</div> + <div class="panel"> + + <div class="b-face"> + <div class="b-id"> + <span class="lamp" id="lamp"></span> + <span class="g" id="face-g"></span> + <span class="state-word" id="state">PLAYBACK</span> + <span class="badge red" id="mute-badge">MUTED</span> + <span class="unit">SND·01</span> + <span class="switch on" id="master" onclick="masterMute()" title="Master quick-mute (Super+Shift+M)"></span> + <button class="x-btn" title="Close (Esc)">✕</button> + </div> + </div> + + <div class="engrave">outputs<span class="cnt" id="out-cnt"></span></div> + <div id="outputs"></div> + + <div class="engrave">inputs<span class="cnt" id="in-cnt"></span></div> + <div id="inputs"></div> + + <div class="engrave">mic mode</div> + <div class="modes"> + <button class="mode" id="md-toggle" onclick="micToggle()">LIVE<span class="k">Super+Shift+A</span></button> + <button class="mode" id="md-ptt" onclick="micPtt()">PUSH·TALK<span class="k">hold Space</span></button> + </div> + <div class="ptt-hint" id="ptt-hint"></div> + + <!-- meter row 1 — volume you set --> + <div class="meters"> + <div class="meter"> + <span class="mode-tag" id="vt-out">OUT VOL</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-out"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-out">68</span> <small>%</small></div> + <div class="m-label" id="l-out">SPEAKERS</div> + </div> + <div class="meter"> + <span class="mode-tag" id="vt-in">IN VOL</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-in"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-in">54</span> <small>%</small></div> + <div class="m-label" id="l-in">BUILT-IN MIC</div> + </div> + </div> + + <!-- meter row 2 — stereo VU of the selected output's live signal --> + <div class="vupair" id="vupair"> + <div class="vuhead"><span class="t">signal · VU peak</span><span class="src" id="vu-src">SPEAKERS</span></div> + <div class="vurow"><span class="ch">L</span><span class="vubar" id="vu-l"></span></div> + <div class="vurow"><span class="ch">R</span><span class="vubar" id="vu-r"></span></div> + </div> + + <div class="toast" id="toast"></div> + </div> + </div> + + <div class="aside"> + <h3>The bar glyph</h3> + <div class="barbits"> + <span><span class="g"></span> normal — speaker + arcs</span> + <span><span class="g mut"></span> muted — speaker ✕</span> + <span><span class="g" style="color:var(--gold)"></span> ptt armed</span> + </div> + <h3>Idiom map (same as net / bt)</h3> + <ul> + <li><b>Faceplate switch = master quick-mute</b> — the net wifi / bt power switch, here muting all output. Flip: state → MUTED, lamp red, bar glyph → speaker-✕.</li> + <li><b>Rows are devices</b> — every sink + source. <b>Click the row body</b> to set default (gold DEF moves); the fader sets that device's volume; the trailing glyph mutes just it.</li> + <li><b>Per-row signal meter</b> — the 4 bars at the left of each row read that device's <b>live</b> level. A sink can carry a stream without being default, so the one playing lights up — <em>demo: the music is on WH-1000XM4 while Speakers is still default. Click WH-1000XM4 to move to it.</em></li> + <li><b>Two meter rows</b> — row 1 = the volume you set (OUT + IN needles); row 2 = the <b>stereo VU</b> (L/R) of the selected output's live signal, red when muted.</li> + <li><b>Mic = two console keys</b> — one toggles LIVE↔MUTED (the label flips to show the state), the other is PUSH·TALK: the mic sits muted and un-mutes only while Space is held.</li> + <li><b>Verify-everything</b> — every action re-reads pactl/wpctl state after firing, like net/bt.</li> + </ul> + <h3>Push-to-talk — the one hard part</h3> + <div class="rec"> + Hold-to-talk needs a global key grab under Wayland. Two routes to spec: <b>(a)</b> a Hyprland + <b>bind pair</b> — <em>bindp</em> Space press → unmute, release → re-mute, armed only while PTT + mode is active (so it doesn't steal Space everywhere); or <b>(b)</b> an <b>evdev/libinput + listener</b> reading the key directly. (a) is lighter; (b) survives focus changes but needs + input-group permissions. Feasibility research is phase 1 of the spec. + </div> + </div> +</div> + +<script> +const $ = id => document.getElementById(id); +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + +/* sig = base live level 0..1 (what stream is on this device right now). + Note the demo: music is on WH-1000XM4 (sig .72) while Speakers is default. */ +let OUT = [ + {id:'spk', name:'Built-in Speakers', g:'', vol:68, mute:false, def:true, sig:0, ph:0.0}, + {id:'xm4', name:'WH-1000XM4', g:'', vol:80, mute:false, def:false, sig:0.72,ph:1.7}, + {id:'hdmi',name:'HDMI · Dell U2720', g:'', vol:100,mute:true, def:false, sig:0, ph:2.9}, +]; +let IN = [ + {id:'bmic', name:'Built-in Mic', g:'', vol:54, mute:false, def:true, sig:0.16,ph:0.6}, + {id:'yeti', name:'Blue Yeti USB', g:'', vol:70, mute:false, def:false, sig:0, ph:2.1}, + {id:'xm4m', name:'WH-1000XM4 Headset',g:'', vol:60, mute:true, def:false, sig:0, ph:3.3}, +]; +let master = false, micMode = 'live', pttHeld = false; +const pkL={v:0}, pkR={v:0}; + +function outMutedOf(d){ return master || d.mute; } +function inMutedOf(d){ return d.mute || (d.def && (micMode==='mute' || (micMode==='ptt' && !pttHeld))); } + +function fader(dev){ + const w=document.createElement('div'); w.className='fader'; + w.innerHTML=`<div class="trk"><div class="fill" style="width:${dev.vol}%"></div></div>`+ + `<div class="cap" style="left:${dev.vol}%"></div>`; + w.onclick=(e)=>{e.stopPropagation(); + const r=w.getBoundingClientRect(); + dev.vol=Math.max(0,Math.min(100,Math.round((e.clientX-r.left)/r.width*100))); + if(dev.vol>0) dev.mute=false; + render(); toast(`${dev.name} → ${dev.vol}%`); + }; + return w; +} +function row(dev,kind){ + const muted = kind==='out'?outMutedOf(dev):inMutedOf(dev); + const r=document.createElement('div'); + r.className='dev'+(dev.def?' active':'')+(muted?' muted':''); + r.title=dev.name; + const sig=document.createElement('span'); sig.className='sig'; sig.id='sig-'+dev.id; + sig.innerHTML='<i></i><i></i><i></i><i></i>'; + const who=document.createElement('div'); who.className='who'; + who.innerHTML=`<span class="g">${dev.g}</span><span class="nm">${dev.name}</span><span class="def">DEF</span>`; + const pct=document.createElement('span'); pct.className='pct'; pct.textContent=dev.mute?'mute':dev.vol+'%'; + const mb=document.createElement('button'); mb.className='mute-b'; mb.textContent=dev.mute?'':''; + mb.title='mute '+dev.name; + mb.onclick=(e)=>{e.stopPropagation();dev.mute=!dev.mute;render();toast(`${dev.name} ${dev.mute?'muted':'unmuted'}`);}; + r.append(sig,who,fader(dev),pct,mb); + r.onclick=()=>{ if(dev.def) return; + (kind==='out'?OUT:IN).forEach(d=>d.def=false); dev.def=true; render(); + toast(`default ${kind==='out'?'output':'input'} → ${dev.name}`); + }; + return r; +} +function render(){ + const o=$('outputs'); o.innerHTML=''; OUT.forEach(d=>o.appendChild(row(d,'out'))); + const i=$('inputs'); i.innerHTML=''; IN.forEach(d=>i.appendChild(row(d,'in'))); + $('out-cnt').textContent='· '+OUT.length; + $('in-cnt').textContent='· '+IN.length; + const od=OUT.find(d=>d.def), id=IN.find(d=>d.def); + const oM=outMutedOf(od), iM=inMutedOf(id); + $('master').className='switch'+(master?' muted':' on'); + $('state').textContent=oM?'MUTED':'PLAYBACK'; + $('lamp').className='lamp'+(oM?' red':''); + $('face-g').textContent=oM?'':''; + $('mute-badge').classList.toggle('show',oM); + setNeedle('out',oM?0:od.vol,oM); $('v-out').textContent=oM?0:od.vol; $('l-out').textContent=od.name.toUpperCase(); + setNeedle('in',iM?0:id.vol,iM); $('v-in').textContent=iM?0:id.vol; $('l-in').textContent=id.name.toUpperCase(); + $('vt-out').textContent=oM?'OUT·MUTE':'OUT VOL'; $('vt-out').className='mode-tag'+(oM?' mut':''); + $('vt-in').textContent=iM?'IN·MUTE':'IN VOL'; $('vt-in').className='mode-tag'+(iM?' mut':''); + $('vu-src').textContent=od.name.toUpperCase(); + $('vupair').classList.toggle('mute',oM); + // mic controls: one live/muted toggle + one push-to-talk key + const tg=$('md-toggle'); + tg.textContent = micMode==='mute' ? 'MUTED' : 'LIVE'; + tg.appendChild(Object.assign(document.createElement('span'),{className:'k',textContent:'Super+Shift+A'})); + tg.className = 'mode' + (micMode==='mute' ? ' on mute' : micMode==='live' ? ' on' : ''); + $('md-ptt').className='mode'+(micMode==='ptt'?' on':''); + $('ptt-hint').textContent = micMode==='ptt' + ? (pttHeld?'▸ transmitting — Space held':'mic muted — hold Space to talk') + : micMode==='mute' ? 'mic muted' : ''; + $('ptt-hint').className='ptt-hint'+(micMode==='ptt'&&pttHeld?' live':''); +} +function setNeedle(side,val,muted){ + const deg=-60+Math.max(0,Math.min(1,val/100))*120; + const n=$('n-'+side); n.style.transform=`rotate(${deg}deg)`; + n.className='needle'+(muted?' mut':''); +} + +/* live-signal animation — the per-row minis + the stereo VU pair */ +let phase=0; +function level(dev,muted){ + if(muted||!dev.sig) return 0; + const env=0.5+0.5*Math.abs(Math.sin(phase*1.25+dev.ph)); + const trans=Math.random()<0.14?Math.random()*0.4:0; + return Math.max(0,Math.min(1,dev.sig*env+trans-Math.random()*0.07)); +} +function paintMini(el,lvl){ + const b=el.children, lit=Math.round(lvl*4); + for(let k=0;k<4;k++){ b[k].className = k<lit ? (k>=3?'clip':k>=2?'hot':'on') : ''; } +} +function paintVU(el,lvl,pk){ + const b=el.children, n=b.length, lit=Math.round(lvl*n); + pk.v=Math.max(lit,pk.v-0.35); const p=Math.round(pk.v); + for(let k=0;k<n;k++){ let c = k<lit ? (k>=n-2?'clip':k>=n-4?'hot':'on') : ''; + if(p>0 && k===p-1) c=(c?c+' ':'')+'peak'; b[k].className=c; } +} +function buildVU(el,n){ el.innerHTML=''; for(let k=0;k<n;k++) el.appendChild(document.createElement('i')); } +function tick(){ + phase+=0.09; + OUT.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,outMutedOf(d)));}); + IN.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,inMutedOf(d)));}); + const od=OUT.find(d=>d.def), oM=outMutedOf(od); + const base=oM?0:level(od,false); + paintVU($('vu-l'),Math.min(1,base*(0.92+Math.random()*0.16)),pkL); + paintVU($('vu-r'),Math.min(1,base*(0.92+Math.random()*0.16)),pkR); +} + +function masterMute(){ master=!master; render(); toast(master?'ALL OUTPUT MUTED':'output unmuted'); } +/* two mic buttons: toggle flips live<->muted (and leaves ptt); ptt arms/disarms */ +function micToggle(){ micMode = micMode==='mute' ? 'live' : 'mute'; pttHeld=false; render(); + toast(micMode==='mute'?'mic muted':'mic live'); } +function micPtt(){ micMode = micMode==='ptt' ? 'live' : 'ptt'; pttHeld=false; render(); + toast(micMode==='ptt'?'push-to-talk armed — hold Space':'mic live'); } +let tT; +function toast(msg,err){ const t=$('toast'); t.textContent=msg; + t.className='toast show'+(err?' err':''); clearTimeout(tT); tT=setTimeout(()=>t.className='toast',2200); } +addEventListener('keydown',e=>{ if(e.code==='Space'&&micMode==='ptt'&&!e.repeat){e.preventDefault();pttHeld=true;render();}}); +addEventListener('keyup', e=>{ if(e.code==='Space'&&micMode==='ptt'){e.preventDefault();pttHeld=false;render();}}); + +buildVU($('vu-l'),16); buildVU($('vu-r'),16); +render(); +if(!reduced) setInterval(tick,70); else tick(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-waybar-redesign-prototype.html b/docs/prototypes/2026-07-03-waybar-redesign-prototype.html new file mode 100644 index 0000000..3f3e7c1 --- /dev/null +++ b/docs/prototypes/2026-07-03-waybar-redesign-prototype.html @@ -0,0 +1,321 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Waybar redesign — dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1400px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:88ch} +.masthead p b{color:var(--silver);font-weight:700} + +/* each variation sits on a "desk" — a strip of desktop so the bar reads as a + real top bar floating over a window, exactly how it looks on-screen */ +.desk{margin:1.9rem 0 0;border-radius:14px;overflow:hidden; + border:1px solid #211e1a; + background: + linear-gradient(180deg,#0e0d0c 0 62px,transparent 62px), + repeating-linear-gradient(135deg,#191613 0 14px,#171512 14px 28px); + position:relative} +.desk-label{display:flex;align-items:baseline;gap:.7rem;padding:.5rem .2rem .1rem} +.desk-label .n{color:var(--gold);font-size:.82rem;letter-spacing:.08em} +.desk-label .d{color:var(--dim);font-size:.76rem} +.desk-window{position:absolute;inset:70px 26px 22px;border:1px solid #262320;border-radius:10px; + background:linear-gradient(180deg,#131110,#0e0d0c);opacity:.6} +.desk-window::before{content:"emacs — instrument-console.el";position:absolute;top:8px;left:14px; + color:#3a3630;font-size:.72rem} + +/* the bar frame: matches waybar's -54 top strip. two clusters, gold-bordered, + floating with a gap between them (modules-center is empty in the real config) */ +.bar{position:relative;z-index:2;display:flex;justify-content:space-between;align-items:flex-start; + gap:1rem;padding:10px 12px;height:132px} +.cluster{display:flex;align-items:center;gap:2px; + background:var(--panel);border:1.4px solid var(--gold);border-radius:15px; + padding:2px 9px;box-shadow:0 4px 9px rgba(0,0,0,.5)} +.mod{display:flex;align-items:center;gap:7px;color:var(--silver); + padding:7px 9px;border-radius:11px;font-size:14px;cursor:default;white-space:nowrap;position:relative} +.mod .g{font-size:16px;line-height:1} +.mod .g.xl{font-size:19px} +.mod:hover{background:var(--wash)} +.val{font-variant-numeric:tabular-nums} +.cream{color:var(--cream)}.gold{color:var(--gold)}.dim{color:var(--dim)} +.fail{color:var(--fail)}.pass{color:var(--pass)}.steel{color:var(--steel)} + +/* workspaces — circular tokens like the real ws-icons */ +.ws{display:flex;gap:5px;padding:0 3px} +.ws b{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-size:13px; + color:var(--silver);border:1.4px solid var(--slate);background:#141210} +.ws b.on{color:var(--panel);background:var(--gold);border-color:var(--gold);font-weight:700; + box-shadow:0 0 8px 1px rgba(218,181,61,.4)} +.ws b.busy{border-color:var(--steel);color:var(--cream)} +.menu{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;color:var(--gold); + font-size:17px;background:linear-gradient(180deg,#211e19,#151210);border:1px solid #33302b} +.title{color:var(--dim);font-size:13px;max-width:230px;overflow:hidden;text-overflow:ellipsis} + +/* collapse arrows — recessed dim wells, per the current design */ +.arrow{color:var(--dim);font-size:12px;background:rgba(0,0,0,.35); + box-shadow:inset 0 1px 2px rgba(0,0,0,.7);border-radius:7px;padding:8px 7px} + +/* lamp — the panel's signature status dot, glow and all */ +.lamp{width:8px;height:8px;border-radius:50%;background:var(--pass);flex:0 0 auto; + box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} + +/* engraved hairline divider between functional groups (echoes .engrave rules) */ +.sep{width:1px;align-self:stretch;margin:6px 3px; + background:linear-gradient(180deg,transparent,var(--wash) 22%,var(--wash) 78%,transparent)} + +.notes{margin-top:2.4rem;display:flex;gap:2rem;flex-wrap:wrap} +.note{flex:1 1 300px;min-width:280px} +.note h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem} +.note ul{list-style:none} +.note li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative;color:var(--silver)} +.note li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.note li b{color:var(--cream);font-weight:700} +.note li em{color:var(--dim);font-style:normal} +.rec{border:1px dashed var(--wash);border-radius:10px;padding:.9rem 1.1rem;margin-top:1.1rem; + font-size:.83rem;color:var(--silver)} +.rec b{color:var(--gold)} + +/* ============ V1 · FACEPLATE ============ */ +/* machined faceplate: vertical gradient + a 1px top highlight + deeper shadow. + otherwise the current layout — lowest-risk, faithful to GTK CSS. */ +.v1 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel)); + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)} +.v1 .val.clock{color:var(--cream)} + +/* ============ V2 · INSTRUMENT SEGMENTS ============ */ +/* each functional group is its own recessed sub-faceplate with an engraved unit + label underneath, lamps on status modules, gold-hi active values */ +.v2 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel));padding:3px 6px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)} +.v2 .seg{display:flex;flex-direction:column;align-items:center;gap:2px; + background:var(--well);border:1px solid #232019;border-radius:10px;padding:3px 5px 2px;margin:0 2px} +.v2 .seg .row{display:flex;align-items:center;gap:6px} +.v2 .seg .unit{color:var(--steel);font-size:.52rem;letter-spacing:.24em;text-transform:uppercase} +.v2 .mod{padding:5px 7px} +.v2 .mod:hover{background:var(--wash)} +.v2 .menu{background:linear-gradient(180deg,#2a251d,#161310);border-color:var(--gold)} +/* mini gauge for sysmon — a squat needle echoing the panel meters */ +.gauge{width:26px;height:15px;position:relative;overflow:hidden} +.gauge .arc{position:absolute;inset:0 0 -26px 0;border:1.5px solid var(--wash);border-radius:50%} +.gauge .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:13px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(18deg);border-radius:1px; + box-shadow:0 0 4px rgba(255,215,95,.5)} +.gauge .hub{position:absolute;left:50%;bottom:-2px;width:5px;height:5px;margin-left:-2.5px; + border-radius:50%;background:var(--gold)} + +/* ============ V3 · FULL CONSOLE ============ */ +/* every module a recessed well with a lamp; console-key toggles with full + physical-key gradient + inset; sysmon as twin analog gauges; cream clock */ +.v3 .cluster{background:linear-gradient(180deg,#0d0c0b,#080807);border-width:1.8px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 8px 18px rgba(0,0,0,.6);padding:3px 8px} +.v3 .mod{background:var(--well);border:1px solid #201d17;border-radius:9px;margin:0 2px;padding:6px 9px} +.v3 .mod:hover{background:#141210;border-color:var(--slate)} +/* console-key toggles — the physical key from the panels' .c-btn */ +.v3 .key{background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b; + border-bottom-color:#0c0b0a;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.v3 .key.engaged{color:var(--gold);border-color:var(--gold)} +.v3 .key.off{color:var(--fail)} +.v3 .menu{background:linear-gradient(180deg,#2a251d,#141110);border:1px solid var(--gold); + box-shadow:0 0 8px rgba(218,181,61,.25)} +.v3 .ws b{background:var(--well);border-color:#201d17} +.v3 .ws b.on{background:var(--gold);border-color:var(--gold)} +.v3 .val.clock{color:var(--cream);font-weight:700} +.v3 .tz{color:var(--steel);font-size:.55rem;letter-spacing:.2em} +.v3 .twin{display:flex;gap:6px} +.v3 .gauge2{width:22px;height:14px;position:relative;overflow:hidden} +.v3 .gauge2 .arc{position:absolute;inset:0 0 -22px 0;border:1.5px solid var(--wash);border-radius:50%} +.v3 .gauge2 .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:12px;transform-origin:50% 100%; + border-radius:1px;background:var(--gold-hi);box-shadow:0 0 4px rgba(255,215,95,.5)} +.v3 .gauge2 .ndl.warn{background:var(--gold)} +.v3 .gauge2 .hub{position:absolute;left:50%;bottom:-2px;width:4px;height:4px;margin-left:-2px; + border-radius:50%;background:var(--gold)} +</style> +</head> +<body> +<div class="wrap"> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · waybar</div> + <h1>Waybar — three ways to spruce it</h1> + <p>The bar already runs the dupre palette and a gold border. These three push it toward the + <b>instrument-console faceplate</b> language of the net + bluetooth panels — machined + gradient plates, engraved unit labels, glowing status lamps, physical console keys, analog + gauges — dialing the intensity up from left-touch to full console. Same real module set in + each so you're comparing <b>treatment, not content</b>. All three stay inside what GTK3 CSS + (waybar's engine) can actually render.</p> +</header> + +<!-- ============ CURRENT (reference) ============ --> +<div class="desk"> + <div class="bar" style="padding-top:14px"> + <div class="cluster"> + <span class="menu"></span> + <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span> + <span class="mod"><span class="g"></span></span> + <span class="title">instrument-console.el</span> + <span class="mod arrow"></span> + </div> + <div class="cluster"> + <span class="mod arrow"></span> + <span class="mod"><span class="g gold"></span></span> + <span class="mod"><span class="g xl"></span></span> + <span class="mod"><span class="g xl"></span> <span class="val">62%</span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span> <span class="val">8%</span></span> + <span class="mod"><span class="g"></span> <span class="val">24:10</span></span> + <span class="mod"><span class="g"></span> <span class="val">Fri Jul 3</span></span> + </div> + </div> + <div class="desk-window"></div> +</div> +<div class="desk-label"><span class="n">current</span><span class="d">— flat pills, colour-only states. the baseline these three build on.</span></div> + +<!-- ============ V1 · FACEPLATE ============ --> +<div class="desk v1"> + <div class="bar" style="padding-top:14px"> + <div class="cluster"> + <span class="menu"></span> + <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span> + <span class="sep"></span> + <span class="mod"><span class="g"></span></span> + <span class="title">instrument-console.el</span> + <span class="mod arrow"></span> + </div> + <div class="cluster"> + <span class="mod arrow"></span> + <span class="mod"><span class="lamp gold"></span><span class="g gold"></span></span> + <span class="sep"></span> + <span class="mod"><span class="g xl"></span></span> + <span class="mod"><span class="g xl"></span> <span class="val cream">62%</span></span> + <span class="sep"></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g gold"></span></span> + <span class="mod"><span class="lamp"></span><span class="g"></span></span> + <span class="sep"></span> + <span class="mod"><span class="g"></span> <span class="val">8%</span></span> + <span class="mod"><span class="g gold"></span> <span class="val gold">24:10</span></span> + <span class="sep"></span> + <span class="mod"><span class="g dim"></span> <span class="val clock">Fri Jul 3</span></span> + </div> + </div> + <div class="desk-window"></div> +</div> +<div class="desk-label"><span class="n">variation 1 · faceplate</span><span class="d">— machined gradient + top highlight, engraved hairline dividers, status lamps on net/bt, cream clock. Nearest to today; drop-in GTK CSS.</span></div> + +<!-- ============ V2 · INSTRUMENT SEGMENTS ============ --> +<div class="desk v2"> + <div class="bar" style="padding-top:12px"> + <div class="cluster"> + <span class="menu"></span> + <span class="seg"><span class="row ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span><span class="unit">wksp</span></span> + <span class="seg"><span class="row"><span class="g"></span><span class="title" style="max-width:180px">instrument-console.el</span></span><span class="unit">layout · window</span></span> + <span class="mod arrow"></span> + </div> + <div class="cluster"> + <span class="mod arrow"></span> + <span class="seg"><span class="row"><span class="lamp gold"></span><span class="g gold"></span><span class="gold">CAPTIVE</span></span><span class="unit">net</span></span> + <span class="seg"><span class="row"><span class="g xl"></span><span class="g xl"></span><span class="val cream">62%</span></span><span class="unit">sound</span></span> + <span class="seg"><span class="row"><span class="g"></span><span class="g"></span><span class="g gold"></span></span><span class="unit">toggles</span></span> + <span class="seg"><span class="row"><span class="lamp"></span><span class="g"></span><span class="dim">M650</span></span><span class="unit">bt</span></span> + <span class="seg"><span class="row"><span class="gauge"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span><span class="val">8%</span></span><span class="unit">cpu</span></span> + <span class="seg"><span class="row"><span class="g gold"></span><span class="val gold">24:10</span></span><span class="unit">timer</span></span> + <span class="seg"><span class="row"><span class="val cream">Fri Jul 3</span><span class="val">11:23</span></span><span class="unit">clock</span></span> + </div> + </div> + <div class="desk-window"></div> +</div> +<div class="desk-label"><span class="n">variation 2 · instrument segments</span><span class="d">— each group a recessed sub-plate with an engraved unit label; a squat needle gauge for cpu. Reads like a row of instruments. Taller; label row costs a few px.</span></div> + +<!-- ============ V3 · FULL CONSOLE ============ --> +<div class="desk v3"> + <div class="bar" style="padding-top:12px"> + <div class="cluster"> + <span class="menu"></span> + <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span> + <span class="sep"></span> + <span class="mod key"><span class="g"></span></span> + <span class="title">instrument-console.el</span> + <span class="mod arrow"></span> + </div> + <div class="cluster"> + <span class="mod arrow"></span> + <span class="mod"><span class="lamp gold"></span><span class="g gold"></span> <span class="gold">CAPTIVE</span></span> + <span class="sep"></span> + <span class="mod key"><span class="g xl"></span></span> + <span class="mod"><span class="g xl"></span> <span class="val cream">62%</span></span> + <span class="sep"></span> + <span class="mod key engaged"><span class="g"></span></span> + <span class="mod key"><span class="g"></span></span> + <span class="mod key engaged"><span class="g"></span></span> + <span class="mod"><span class="lamp"></span><span class="g"></span> <span class="dim val">72%</span></span> + <span class="sep"></span> + <span class="mod"><span class="twin"> + <span class="gauge2"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span> + <span class="gauge2"><span class="arc"></span><span class="ndl warn" style="transform:rotate(38deg)"></span><span class="hub"></span></span> + </span></span> + <span class="mod key"><span class="g gold"></span> <span class="val gold">24:10</span></span> + <span class="sep"></span> + <span class="mod"><span class="g dim"></span> <span class="val clock">Fri Jul 3</span> <span class="val clock">11:23</span><span class="tz"> EDT</span></span> + </div> + </div> + <div class="desk-window"></div> +</div> +<div class="desk-label"><span class="n">variation 3 · full console</span><span class="d">— every module a recessed well, physical console keys for toggles (gold when engaged, terracotta when off), twin cpu/mem gauges, cream tabular clock with engraved TZ. Furthest from today; closest to the panels.</span></div> + +<!-- ============ NOTES ============ --> +<div class="notes"> + <div class="note"> + <h3>What carries over from the panels</h3> + <ul> + <li><b>Lamps</b> — the glowing status dot lands on net + bt so health reads at a glance, not just by glyph colour <em>(gold = captive/engaged, green = ok, red = fail, dim = off)</em>.</li> + <li><b>Machined faceplate</b> — the cluster gets the b-face vertical gradient + 1px top highlight + deeper shadow, so it looks milled rather than printed.</li> + <li><b>Engraved dividers</b> — hairline separators group the right cluster into net · sound · toggles · system · clock, echoing the panels' engraved section rules.</li> + <li><b>Console keys</b> — the toggles (touchpad, dim, caffeine) borrow .c-btn: gradient fill, inset highlight, gold border when engaged.</li> + <li><b>Gauges</b> — sysmon becomes a squat needle (or twin needles for cpu/mem), the same instrument the panels use for throughput and battery.</li> + <li><b>Cream + tabular</b> — the clock and live values shift to cream with tabular-nums, matching the panels' readouts.</li> + </ul> + </div> + <div class="note"> + <h3>GTK3 translation caveats</h3> + <ul> + <li><b>Dividers</b> need real separator modules or per-module borders — waybar can't inject <em>::before</em> content between modules the way this HTML does.</li> + <li><b>Lamps</b> render as a small pango glyph (● with colour + text-shadow glow) prepended in each script, or a tiny bordered box widget — both are GTK-safe.</li> + <li><b>Gauges</b> are the real work: GTK CSS can't draw a rotating needle. Options — a Cairo/GTK drawing area in a custom module, or fake it with a unicode gauge glyph that steps by load band. V2's single gauge is cheaper than V3's twin.</li> + <li><b>V2's unit labels</b> raise the bar height (the label row). Fine at 54px reserved, but worth eyeballing against the -54 margin strip.</li> + <li>Gradients, inset box-shadow, border colour states, tabular-nums — all already proven in the current stylesheet.</li> + </ul> + </div> + <div class="note"> + <h3>My read</h3> + <div class="rec"> + <b>Variation 1 (faceplate)</b> is the one I'd ship first: it lands ~80% of the instrument-console feel — lamps, milled plates, engraved grouping, cream clock — for pure CSS plus a lamp glyph in the net/bt scripts. No custom drawing, no height risk. + <br><br> + <b>Variation 3</b> is the aspirational target once a gauge-drawing module exists (it'd also upgrade the sysmon popup). <b>Variation 2</b> is the middle path if you want the unit labels' legibility but not the full recessed-well density. They're not exclusive — 1 can grow into 3. + </div> + </div> +</div> + +</div> +</body> +</html> diff --git a/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html b/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html new file mode 100644 index 0000000..0b5d6e8 --- /dev/null +++ b/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html @@ -0,0 +1,133 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>PTT waybar indicator — prototype</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --bar:#0d0c0b; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono","JetBrainsMono Nerd Font",monospace; +} +*{box-sizing:border-box} +body{font-family:var(--mono);color:var(--silver);margin:0;padding:2.6rem 2rem 5rem;line-height:1.5; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +h1{font-size:1.05rem;letter-spacing:.02em;color:var(--cream);font-weight:700;margin:0 0 .3rem} +.sub{color:var(--dim);font-size:.82rem;margin:0 0 2rem;max-width:60ch} +h2{font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;color:var(--steel); + margin:2.2rem 0 .8rem;display:flex;align-items:center;gap:10px} +h2::after{content:"";height:1px;background:var(--wash);flex:1} + +/* a slice of the real waybar: dark rounded bar, right-aligned modules */ +.bar{display:inline-flex;align-items:center;gap:2px;background:var(--bar); + border:1px solid #201d17;border-radius:10px;padding:5px 8px; + box-shadow:0 2px 10px rgba(0,0,0,.45)} +.mod{display:flex;align-items:center;gap:7px;padding:4px 9px;border-radius:7px;color:var(--silver); + font-size:14px} +.mod .g{font-size:17px;line-height:1} +.mod.muted .g{color:var(--fail)} +.mod.live .g{color:var(--silver)} +.sep{width:1px;height:16px;background:#26231d;margin:0 2px} +.neighbor{color:var(--dim);font-size:13px;padding:4px 8px} + +/* the PTT tag — outline only, ALWAYS present (so the bar never reflows), and + click-to-toggle. Off = barely-visible grey; on = glowing gold; transmitting + = brighter gold. Like the gallery 2.4G ghost tag, lit when engaged. */ +.badge{font-size:15px;line-height:1;border-radius:4px;padding:3px 7px; + background:transparent;border:1px solid #2a2723;color:#54585c; + text-shadow:none;box-shadow:none;cursor:pointer;transition:all .15s ease} +.badge.txt{font-size:.62rem;letter-spacing:.18em;font-weight:700;padding:2px 7px} +.badge.on{color:var(--gold);border-color:var(--gold); + text-shadow:0 0 6px rgba(218,181,61,.75); + box-shadow:0 0 6px rgba(218,181,61,.28),inset 0 0 5px rgba(218,181,61,.15)} +.badge.talk{color:var(--gold-hi);border-color:var(--gold-hi); + text-shadow:0 0 10px rgba(255,215,95,.95); + box-shadow:0 0 11px rgba(255,215,95,.5),inset 0 0 6px rgba(255,215,95,.2)} +.badge.ghost{border-color:var(--wash);color:var(--dim)} + +.row{display:flex;align-items:center;gap:22px;flex-wrap:wrap;margin:10px 0} +.label{width:16rem;color:var(--dim);font-size:.8rem} +.label b{color:var(--cream);font-weight:700} +.note{color:var(--dim);font-size:.78rem;margin:.6rem 0 0;max-width:70ch} +.opt{margin:2.4rem 0 0;padding:14px 16px;border:1px solid #262320;border-radius:10px; + background:linear-gradient(180deg,var(--raise),var(--panel));max-width:70ch} +.opt h3{margin:0 0 .5rem;color:var(--cream);font-size:.86rem;letter-spacing:.04em} +.opt p{margin:.3rem 0;color:var(--silver);font-size:.82rem} +.k{color:var(--gold);font-weight:700} +</style> +</head> +<body> + +<h1>Push-to-talk — waybar indicator</h1> +<p class="sub">How the bar reads in each state, so you can tell at a glance whether you're in PTT mode. +Two elements move together: the <b>mic glyph</b> (live vs muted, the module you already have) and a +<b>PTT tag</b> — an outline flag that's <b>always present</b> (dim grey when off, so the bar never jumps), +glows gold when PTT is engaged, and is itself <b>click-to-toggle</b> for PTT mode.</p> + +<h2>Not in PTT mode — the tag sits dim (present, barely visible)</h2> + +<div class="row"> + <div class="label"><b>Mic live.</b> Tag dim grey.</div> + <div class="bar"> + <span class="neighbor"></span><span class="sep"></span> + <div class="mod live"><span class="badge">󰗋</span> <span class="g"></span></div> + <span class="neighbor">85%</span> + </div> +</div> + +<div class="row"> + <div class="label"><b>Mic muted</b> (plain mic-toggle). Tag still dim.</div> + <div class="bar"> + <span class="neighbor"></span><span class="sep"></span> + <div class="mod muted"><span class="badge">󰗋</span> <span class="g"></span></div> + <span class="neighbor">85%</span> + </div> +</div> + +<p class="note">The tag is <b>always there</b> — dim grey when off — so the bar never reflows when PTT flips. +<b>Click the tag to toggle PTT mode</b> (same as Super+Shift+A). A plain mic-mute leaves the tag dim; only PTT lights it gold.</p> + +<h2>In PTT mode — the tag lights gold</h2> + +<div class="row"> + <div class="label"><b>Armed, not talking.</b> Mic muted, tag glows gold.</div> + <div class="bar"> + <span class="neighbor"></span><span class="sep"></span> + <div class="mod muted"><span class="badge on">󰗋</span> <span class="g"></span></div> + <span class="neighbor">85%</span> + </div> +</div> + +<div class="row"> + <div class="label"><b>Holding the key — transmitting.</b> Mic live, tag brightens.</div> + <div class="bar"> + <span class="neighbor"></span><span class="sep"></span> + <div class="mod live"><span class="badge talk">󰗋</span> <span class="g"></span></div> + <span class="neighbor">85%</span> + </div> +</div> + +<p class="note">Only the letters and outline change colour — the box holds its place, so nothing shifts. While you hold to talk the mic flips live and the tag brightens, so you see you're transmitting, not just armed.</p> + +<h2>The four states side by side — the tag never moves</h2> +<div class="row"> + <div class="bar"> + <div class="mod live"><span class="badge">󰗋</span> <span class="g"></span></div><span class="sep"></span> + <div class="mod muted"><span class="badge">󰗋</span> <span class="g"></span></div><span class="sep"></span> + <div class="mod muted"><span class="badge on">󰗋</span> <span class="g"></span></div><span class="sep"></span> + <div class="mod live"><span class="badge talk">󰗋</span> <span class="g"></span></div> + </div> +</div> +<p class="note">left → right: off + live · off + muted · PTT armed · PTT transmitting. The tag holds its position through all four — no jump.</p> + +<div class="opt"> + <h3>Variants to consider</h3> + <p>• <span class="k">Tag icon</span> — the account-voice glyph (shown). Alternatives if it doesn't read right: radio-handheld , broadcast , or plain "PTT"/"TALK" text.</p> + <p>• <span class="k">Tag colour</span> — gold (shown, matches the panel's PUSH·TALK lamp). A red tag would read more like "mic hot/muted"; gold reads "mode engaged".</p> + <p>• <span class="k">Tag placement</span> — left of the mic glyph (shown) so the flag leads. Could sit right of it instead.</p> + <p>• <span class="k">Transmit cue</span> — brighten + glow the tag while holding (shown), or leave the tag steady and let only the mic glyph carry the live/muted change.</p> +</div> + +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-A-two-column.html b/docs/prototypes/2026-07-07-maint-console-A-two-column.html new file mode 100644 index 0000000..23d0e60 --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-A-two-column.html @@ -0,0 +1,335 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Maintenance console — Approach A · two-column board</title> +<style> +:root{ + --ground:#0a0c0d; --panel:#100f0f; --well:#0a0c0d; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);line-height:1.4; + padding:2.2rem 1.4rem 4rem; + background:radial-gradient(1100px 560px at 72% -12%,#141210 0%,transparent 60%),var(--ground); + display:flex;flex-direction:column;align-items:center;gap:1.5rem} + +/* ---- capsule ---- */ +.capsule{width:1000px;max-width:100%;background:var(--panel);border:1px solid var(--gold); + border-radius:16px;padding:18px 20px;box-shadow:0 20px 55px rgba(0,0,0,.6)} + +/* ---- header strip ---- */ +.hdr{display:flex;align-items:center;gap:14px;padding-bottom:12px;border-bottom:1px solid var(--wash)} +.hdr .mark{color:var(--gold);font-weight:700;font-size:1.15rem;letter-spacing:.12em} +.hdr .mark .host{color:var(--cream)} +.hdr .verdict{margin-left:auto;display:flex;align-items:center;gap:8px;font-size:.92rem} +.hdr .verdict .word{color:var(--ok);font-weight:700;letter-spacing:.08em;text-transform:uppercase} +.subhdr{color:var(--dim);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase;margin-top:8px} + +/* ---- lamps ---- */ +.lamp{font-size:.72rem;line-height:1;flex:0 0 auto;position:relative;top:-.5px} +.lamp.ok{color:var(--ok)} +.lamp.warn{color:var(--warn)} +.lamp.fail{color:var(--fail)} +.lamp.off{color:var(--dim)} +.lamp.run{color:var(--warn)} +.lamp.big{font-size:.85rem} + +/* ---- board ---- */ +.board{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:16px} +.col{min-width:0} +.col-h{display:flex;align-items:baseline;gap:10px;margin-bottom:10px} +.col-h .tag{color:var(--gold);font-weight:700;font-size:.86rem;letter-spacing:.22em} +.col-h .sub{color:var(--dim);font-size:.68rem;letter-spacing:.04em} + +/* doctor buttons */ +.doctor{display:flex;gap:10px;margin-bottom:14px} +.btn{font:inherit;cursor:pointer;border-radius:10px;border:1px solid transparent; + background:var(--slate);color:var(--cream);padding:.5rem .9rem;font-size:.82rem;letter-spacing:.02em; + transition:background .12s,border-color .12s} +.btn:hover{background:var(--slate-hi)} +.btn.big{flex:1;text-align:center;padding:.62rem .8rem;font-size:.86rem} +.btn.big .k{display:block;color:var(--cream);font-weight:700} +.btn.big .d{display:block;color:var(--silver);font-size:.66rem;letter-spacing:.02em;margin-top:2px;opacity:.85} +.btn.ghost{background:transparent;border-color:var(--wash);color:var(--silver)} +.btn.ghost:hover{border-color:var(--slate-hi);background:rgba(66,79,94,.18)} +.btn.lever{padding:.28rem .66rem;font-size:.74rem;border:1px solid var(--slate);background:rgba(66,79,94,.32);color:var(--cream)} +.btn.lever:hover{background:var(--slate);border-color:var(--slate-hi)} +.btn.done{background:var(--slate);color:var(--cream);padding:.5rem 1.4rem} + +/* inset group */ +.group{background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:8px 10px 6px;margin-bottom:11px} +.group-h{color:var(--gold);font-weight:700;font-size:.7rem;letter-spacing:.2em;text-transform:uppercase; + display:flex;align-items:center;gap:8px;padding:1px 2px 7px} +.group-h::after{content:"";height:1px;background:var(--wash);flex:1} + +/* metric row */ +.row{display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:9px; + padding:4px 4px;border-radius:6px;font-size:.82rem} +.row:hover{background:rgba(44,47,50,.4)} +.row .name{color:var(--silver);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.row .cls{color:var(--dim);font-size:.6rem;letter-spacing:.14em;border:1px solid var(--wash); + border-radius:4px;padding:0 4px;flex:0 0 auto;text-transform:uppercase} +.row .val{color:var(--dim);text-align:right;white-space:nowrap;font-size:.78rem} +.row .val.warn{color:var(--warn)} +.row .val.fail{color:var(--fail)} +.row .lev-slot{justify-self:end;min-width:0} +.wf-link{font:inherit;background:transparent;border:0;cursor:pointer;color:var(--warn); + font-size:.72rem;letter-spacing:.02em;white-space:nowrap;padding:.1rem 0} +.wf-link:hover{color:var(--gold-hi);text-decoration:underline} +.wf-link.fail{color:var(--fail)} +.dash{color:var(--wash);font-size:.8rem;padding-right:6px} + +/* ---- updates quarantine strip ---- */ +.updates{margin-top:6px;padding-top:15px;border-top:1px solid var(--gold); + display:flex;align-items:center;gap:16px;flex-wrap:wrap} +.updates .u-tag{color:var(--gold);font-weight:700;font-size:.8rem;letter-spacing:.22em} +.updates .u-body{color:var(--silver);font-size:.82rem;flex:1;min-width:260px} +.updates .u-body .n{color:var(--cream);font-weight:700} +.updates .u-body .cve{color:var(--fail);font-weight:700} +.updates .u-body .pk{color:var(--dim)} +.updates .btn{white-space:nowrap} + +/* ---- running / output-wall capsule ---- */ +.run-cap .hdr .mark .state{color:var(--warn)} +.wall{margin-top:14px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:12px 14px; + display:flex;flex-direction:column;gap:9px} +.wall .act{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;font-size:.84rem} +.wall .act .step{color:var(--silver)} +.wall .act .dots{color:var(--wash);letter-spacing:.1em;overflow:hidden} +.wall .act .res{color:var(--dim);font-size:.76rem;white-space:nowrap;text-align:right} +.wall .act.running .step{color:var(--cream)} +.wall .act.running .res{color:var(--warn)} +.wall .act.done .res{color:var(--ok)} +.wall .act.queued .step{color:var(--dim)} +.run-foot{display:flex;align-items:center;justify-content:space-between;margin-top:14px} +.run-foot .prog{color:var(--dim);font-size:.76rem;letter-spacing:.04em} +.run-foot .prog b{color:var(--cream);font-weight:700} + +/* ---- legend ---- */ +.legend{width:1000px;max-width:100%;display:flex;gap:18px;flex-wrap:wrap; + font-size:.7rem;color:var(--dim);padding:0 4px} +.legend .item b{color:var(--silver);font-weight:700} +.legend .item{white-space:nowrap} +.legend .swatch{color:var(--gold)} + +@media(max-width:1040px){ + .capsule,.legend{width:100%} + .board{grid-template-columns:1fr} +} +</style> +</head> +<body> + +<!-- =================== RESTING BOARD =================== --> +<section class="capsule"> + <div class="hdr"> + <div class="mark">MAINT · <span class="host">ratio</span></div> + <div class="verdict"> + <span class="lamp ok big">●</span> + <span class="word">healthy</span> + </div> + </div> + <div class="subhdr">Approach A — Two-column board</div> + + <div class="board"> + + <!-- ============ LEFT / FIX ============ --> + <div class="col"> + <div class="col-h"><span class="tag">FIX</span><span class="sub">actionable — auto & confirm</span></div> + + <div class="doctor"> + <button class="btn big"> + <span class="k">Clean up</span><span class="d">runs the Auto set unattended</span> + </button> + <button class="btn big ghost"> + <span class="k">Review & fix</span><span class="d">preview the Confirm set</span> + </button> + </div> + + <!-- Storage --> + <div class="group"> + <div class="group-h">Storage</div> + <div class="row"><span class="lamp warn">●</span><span class="name">cache size</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Clean</button></span></div> + <div class="row"><span class="lamp warn">●</span><span class="name">btrfs scrub age</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Scrub</button></span></div> + <div class="row"><span class="lamp fail">●</span><span class="name">fstrim.timer</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Enable</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">deep trim (keep-1)</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Deep trim</button></span></div> + </div> + + <!-- Snapshots --> + <div class="group"> + <div class="group-h">Snapshots</div> + <div class="row"><span class="lamp ok">●</span><span class="name">snapper count/retention</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Prune</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">snapshot auto-timer</span><span class="cls">Conf</span><span class="val">active</span></div> + </div> + + <!-- Packages --> + <div class="group"> + <div class="group-h">Packages</div> + <div class="row"><span class="lamp warn">●</span><span class="name">orphaned packages</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Review</button></span></div> + <div class="row"><span class="lamp warn">●</span><span class="name">.pacnew files</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Review</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">keyring freshness</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Refresh</button></span></div> + </div> + + <!-- Logs --> + <div class="group"> + <div class="group-h">Logs & coredumps</div> + <div class="row"><span class="lamp warn">●</span><span class="name">coredumps</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Clear</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">journald disk usage</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Vacuum</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">app-log cleanup</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Run</button></span></div> + </div> + + <!-- systemd --> + <div class="group"> + <div class="group-h">systemd</div> + <div class="row"><span class="lamp warn">●</span><span class="name">maintenance timers firing</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Enable</button></span></div> + </div> + + <!-- Network confirm --> + <div class="group"> + <div class="group-h">Network</div> + <div class="row"><span class="lamp ok">●</span><span class="name">firewall active</span><span class="cls">Conf</span><span class="val">active</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">tailscale peers</span><span class="cls">Conf</span><span class="val">4/4</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">fail2ban</span><span class="cls">Conf</span><span class="val">active</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">NTP sync</span><span class="cls">Conf</span><span class="val">synced</span></div> + </div> + + <!-- Services confirm --> + <div class="group"> + <div class="group-h">Services</div> + <div class="row"><span class="lamp warn">●</span><span class="name">docker reclaimable</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Prune</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">cron</span><span class="cls">Conf</span><span class="val">ok</span></div> + </div> + </div> + + <!-- ============ RIGHT / WATCH ============ --> + <div class="col"> + <div class="col-h"><span class="tag">WATCH</span><span class="sub">read-only — none, human & forensic</span></div> + + <!-- Hardware --> + <div class="group"> + <div class="group-h">Hardware</div> + <div class="row"><span class="lamp ok">●</span><span class="name">SMART health</span><span class="cls">None</span><span class="val">PASSED</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">SMART wear</span><span class="cls">None</span><span class="val">0%</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">btrfs device errors</span><span class="cls">None</span><span class="val">0/0</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">CPU/GPU temps</span><span class="cls">None</span><span class="val">61°/54°</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">thermal throttling</span><span class="cls">None</span><span class="val">no</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">kernel/hardware events</span><span class="cls">None</span><span class="val">clean</span></div> + </div> + + <!-- System --> + <div class="group"> + <div class="group-h">System & disk</div> + <div class="row"><span class="lamp ok">●</span><span class="name">is-system-running</span><span class="cls">None</span><span class="val">running</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">disk usage</span><span class="cls">None</span><span class="val">69%</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">btrfs unallocated</span><span class="cls">None</span><span class="val">118 GiB</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">kernel taint flag</span><span class="cls">None</span><span class="val">0</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">pacman file integrity</span><span class="cls">Wflow</span><span class="val">clean</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">reboot required</span><span class="cls">Human</span><span class="val">no</span></div> + </div> + + <!-- Forensic / systemd --> + <div class="group"> + <div class="group-h">Forensic (workflow)</div> + <div class="row"><span class="lamp warn">●</span><span class="name">failed units</span><span class="cls">Wflow</span><span class="val warn">1</span><span class="lev-slot"><button class="wf-link">→ workflow</button></span></div> + <div class="row"><span class="lamp warn">●</span><span class="name">unclean-shutdown rate</span><span class="cls">Wflow</span><span class="val warn">75%</span><span class="lev-slot"><button class="wf-link">→ workflow</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">journal error count</span><span class="cls">Wflow</span><span class="val">12 real</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">memory free / OOM</span><span class="cls">Wflow</span><span class="val">104 GB free</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">rsyncshot backup</span><span class="cls">Wflow</span><span class="val">3h ago</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">DNS / NetworkManager</span><span class="cls">Wflow</span><span class="val">ok</span></div> + </div> + + <!-- Power / memory --> + <div class="group"> + <div class="group-h">Memory & power</div> + <div class="row"><span class="lamp ok">●</span><span class="name">swap / zram</span><span class="cls">None</span><span class="val">16 GiB</span></div> + <div class="row"><span class="lamp off">○</span><span class="name">battery health</span><span class="cls">None</span><span class="val">n/a</span></div> + </div> + + <!-- Network posture --> + <div class="group"> + <div class="group-h">Network posture</div> + <div class="row"><span class="lamp ok">●</span><span class="name">unexpected listeners</span><span class="cls">None</span><span class="val">3</span></div> + </div> + + <!-- Services / virt --> + <div class="group"> + <div class="group-h">Services & virt</div> + <div class="row"><span class="lamp ok">●</span><span class="name">docker stopped containers</span><span class="cls">None</span><span class="val">2</span></div> + <div class="row"><span class="lamp off">○</span><span class="name">libvirt VMs</span><span class="cls">None</span><span class="val">off</span></div> + </div> + </div> + + </div><!-- /board --> + + <!-- quarantined updates strip --> + <div class="updates"> + <span class="u-tag">UPDATES</span> + <span class="u-body"><span class="n">47 pending</span> · <span class="pk">mesa, linux-lts, systemd</span> · <span class="cve">3 CVEs</span></span> + <button class="btn">Run workflow</button> + </div> +</section> + +<!-- =================== RUNNING / OUTPUT WALL =================== --> +<section class="capsule run-cap"> + <div class="hdr"> + <div class="mark">MAINT · <span class="host">ratio</span> · <span class="state">CLEANING</span></div> + <div class="verdict"> + <span class="lamp run big">◐</span> + <span class="word" style="color:var(--warn)">working</span> + </div> + </div> + <div class="subhdr">Doctor has the surface — Auto set streaming</div> + + <div class="wall"> + <div class="act running"> + <span class="lamp run">◐</span> + <span class="step">cache trim</span> + <span class="res">running</span> + </div> + <div class="act done"> + <span class="lamp ok">●</span> + <span class="step">journal vacuum</span> + <span class="res">done · reclaimed 0.9 GB</span> + </div> + <div class="act done"> + <span class="lamp ok">●</span> + <span class="step">coredump clear</span> + <span class="res">done · 12 cleared</span> + </div> + <div class="act queued"> + <span class="lamp off">○</span> + <span class="step">app-log cleanup</span> + <span class="res">queued</span> + </div> + </div> + + <div class="run-foot"> + <span class="prog"><b>2</b> of 4 done · 1 running · 1 queued</span> + <button class="btn done">Done</button> + </div> +</section> + +<!-- =================== AUTOMATION-CLASS LEGEND =================== --> +<div class="legend"> + <span class="item"><span class="swatch">●</span> <b>Auto</b> — doctor "Clean up", fires unattended</span> + <span class="item"><span class="swatch">●</span> <b>Confirm</b> — doctor "Review & fix", preview then one click</span> + <span class="item"><span class="swatch">●</span> <b>Human</b> — panel nudges, you act</span> + <span class="item"><span class="swatch">●</span> <b>Workflow</b> — escalate to the agent health-check</span> + <span class="item"><span class="swatch">●</span> <b>None</b> — diagnostic only, no fix</span> +</div> +<div class="legend" style="margin-top:-8px"> + <span class="item"><span class="lamp ok">●</span> ok/pass</span> + <span class="item"><span class="lamp warn">●</span> warn</span> + <span class="item"><span class="lamp fail">●</span> fail</span> + <span class="item"><span class="lamp run">◐</span> running</span> + <span class="item"><span class="lamp off">○</span> off / n/a</span> +</div> + +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html b/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html new file mode 100644 index 0000000..ebc3acf --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html @@ -0,0 +1,382 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — Approach B</title> +<style> + :root{ + --bg:#0a0c0d; --surface:#100f0f; --gold:#dab53d; --silver:#bfc4d0; + --dim:#7c838a; --cream:#f3e7c5; --slate:#424f5e; --slate-h:#54677d; + --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d; --na:#7c838a; + --inset:#0a0c0d; --inset-bd:#2c2f32; + } + *{box-sizing:border-box} + body{ + margin:0; padding:34px 16px; background:var(--bg); + font-family:"BerkeleyMono Nerd Font", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + color:var(--silver); font-size:13px; line-height:1.45; + display:flex; flex-direction:column; align-items:center; gap:20px; + } + .console{ + width:1000px; max-width:100%; background:var(--surface); + border:1px solid var(--gold); border-radius:16px; padding:18px 20px 22px; + } + /* header */ + .hdr{display:flex; align-items:baseline; justify-content:space-between; gap:16px;} + .hdr .title{color:var(--cream); font-size:16px; letter-spacing:.5px;} + .hdr .title b{color:var(--gold);} + .hdr .verdict{font-size:14px; color:var(--ok); font-weight:bold; letter-spacing:.5px;} + .sub{color:var(--dim); font-size:12px; margin-top:2px; margin-bottom:14px;} + + /* global action bar */ + .actionbar{ + display:flex; align-items:center; gap:10px; flex-wrap:wrap; + padding:11px 13px; margin-bottom:16px; + background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px; + } + .actionbar .grp{display:flex; gap:8px;} + .actionbar .spacer{flex:1 1 auto;} + button{ + font-family:inherit; font-size:12px; cursor:pointer; + background:var(--slate); color:var(--cream); border:1px solid #33404d; + border-radius:10px; padding:6px 12px; + } + button:hover{background:var(--slate-h);} + button.ghost{background:transparent; color:var(--silver); border-color:var(--inset-bd); padding:5px 10px;} + button.ghost:hover{background:#161a1d; color:var(--cream);} + button.tiny{font-size:11px; padding:3px 9px; border-radius:8px;} + + /* updates chip */ + .updates-chip{ + display:flex; align-items:center; gap:12px; margin-left:auto; + padding:6px 12px; border:1px solid var(--fail); border-radius:999px; + background:rgba(203,107,77,.09); + } + .updates-chip .u-lamp{color:var(--fail);} + .updates-chip .u-txt{color:var(--cream); font-size:12px;} + .updates-chip .u-txt b{color:var(--warn);} + + /* grid */ + .grid{ + display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; + } + .tile{ + background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px; + padding:11px 12px 12px; display:flex; flex-direction:column; gap:8px; + cursor:pointer; transition:border-color .12s; + } + .tile:hover{border-color:#3c4a58;} + .tile.expanded{grid-column:1 / -1; cursor:default; border-color:var(--slate-h);} + .tile-top{display:flex; align-items:flex-start; gap:9px;} + .rollup{font-size:16px; line-height:1.1; margin-top:1px;} + .tile-head{flex:1 1 auto; min-width:0;} + .tile-name{color:var(--gold); font-weight:bold; letter-spacing:.3px;} + .tile-finding{color:var(--silver); font-size:12px; margin-top:2px; + white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} + .tile-finding.warnt{color:var(--warn);} + .tile-finding.failt{color:var(--fail);} + .tile-foot{display:flex; align-items:center; gap:8px; justify-content:space-between;} + .counts{color:var(--dim); font-size:11px;} + .counts .fix{color:var(--silver);} + .expander{color:var(--dim); font-size:11px;} + + /* lamps */ + .lamp{font-size:13px;} + .l-ok{color:var(--ok);} .l-warn{color:var(--warn);} .l-fail{color:var(--fail);} + .l-na{color:var(--na);} .l-prog{color:var(--warn);} + + /* expanded detail */ + .detail{border-top:1px solid var(--inset-bd); padding-top:9px; margin-top:2px;} + .detail-hint{color:var(--dim); font-size:11px; margin-bottom:7px;} + .row{ + display:grid; grid-template-columns:16px 1fr auto auto; + align-items:center; gap:10px; padding:4px 4px; border-radius:6px; + } + .row:hover{background:#141719;} + .row + .row{border-top:1px solid #17191b;} + .r-name{color:var(--silver);} + .r-val{color:var(--dim); font-size:12px; text-align:right; white-space:nowrap;} + .r-lever{justify-self:end; min-width:96px; text-align:right;} + .r-lever .none{color:#4d5358;} + .r-auto{font-size:10px; color:var(--dim); text-transform:uppercase; letter-spacing:.5px; + border:1px solid var(--inset-bd); border-radius:5px; padding:1px 5px; margin-left:6px;} + + /* running capsule */ + .capsule{ + width:1000px; max-width:100%; background:var(--surface); + border:1px solid var(--gold); border-radius:16px; padding:14px 18px 16px; + } + .cap-hdr{display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;} + .cap-hdr .title{color:var(--cream); font-size:14px; letter-spacing:.5px;} + .cap-hdr .title b{color:var(--gold);} + .cap-hdr .running{color:var(--warn); font-weight:bold;} + .wall{ + background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px; + padding:10px 12px; display:flex; flex-direction:column; gap:6px; + } + .wall .line{display:flex; align-items:center; gap:10px;} + .wall .line .glyph{width:14px; text-align:center;} + .wall .line .act{color:var(--silver); min-width:180px;} + .wall .line .st{color:var(--dim); font-size:12px;} + .wall .line .st b{color:var(--ok);} + .wall .line.done .act{color:var(--dim);} + .wall .line.queued .act{color:var(--dim);} + .cap-foot{display:flex; justify-content:flex-end; margin-top:11px;} + .legend{ + width:1000px; max-width:100%; color:var(--dim); font-size:11px; + padding:0 6px; line-height:1.6; + } + .legend b{color:var(--silver); font-weight:normal;} +</style> +</head> +<body> + +<div class="console"> + <div class="hdr"> + <div> + <div class="title">MAINT · <b>ratio</b></div> + </div> + <div class="verdict">● healthy</div> + </div> + <div class="sub">Approach B — Domain-tile grid</div> + + <!-- global actions --> + <div class="actionbar"> + <div class="grp"> + <button>Clean up</button> + <button class="ghost">Review & fix</button> + </div> + <div class="spacer"></div> + <div class="updates-chip"> + <span class="u-lamp">●</span> + <span class="u-txt"><b>47 pending</b> · 3 CVE · mesa,linux-lts</span> + <button class="tiny">Run workflow</button> + </div> + </div> + + <!-- domain grid --> + <div class="grid"> + + <!-- STORAGE (expanded) --> + <div class="tile expanded" id="t-storage"> + <div class="tile-top"> + <span class="rollup l-fail">●</span> + <div class="tile-head"> + <div class="tile-name">Storage</div> + <div class="tile-finding failt">fstrim off · scrub 34d</div> + </div> + <div class="tile-foot" style="flex-direction:column; align-items:flex-end; gap:6px;"> + <button class="tiny">Clean</button> + <span class="counts"><span class="fix">4 fix</span> · 6 watch</span> + </div> + </div> + <div class="detail"> + <div class="detail-hint">10 checks — lever shown where the doctor can act</div> + + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">cache<span class="r-auto">auto</span></span><span class="r-val">8.8 GB</span><span class="r-lever"><button class="tiny">Clean</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">deep-trim<span class="r-auto">confirm</span></span><span class="r-val">keep-3</span><span class="r-lever"><button class="tiny">Deep trim</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">disk</span><span class="r-val">69%</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">btrfs unalloc</span><span class="r-val">118 GiB</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">btrfs scrub<span class="r-auto">confirm</span></span><span class="r-val">34 d</span><span class="r-lever"><button class="tiny">Scrub</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">btrfs device-err</span><span class="r-val">0 / 0</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">SMART</span><span class="r-val">PASSED</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">SMART wear</span><span class="r-val">0%</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-fail">●</span><span class="r-name">fstrim.timer<span class="r-auto">confirm</span></span><span class="r-val">off</span><span class="r-lever"><button class="tiny">Enable</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">pacman integrity<span class="r-auto">workflow</span></span><span class="r-val">clean</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- SNAPSHOTS --> + <div class="tile" onclick="toggle('snap')" id="t-snap"> + <div class="tile-top"> + <span class="rollup l-ok">●</span> + <div class="tile-head"> + <div class="tile-name">Snapshots</div> + <div class="tile-finding">42 snaps · timer active</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">1 fix</span> · 1 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-snap" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">snapper count<span class="r-auto">confirm</span></span><span class="r-val">42</span><span class="r-lever"><button class="tiny">Prune</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">snapshot timer<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- PACKAGES & SECURITY --> + <div class="tile" onclick="toggle('pkg')" id="t-pkg"> + <div class="tile-top"> + <span class="rollup l-fail">●</span> + <div class="tile-head"> + <div class="tile-name">Packages & Security</div> + <div class="tile-finding failt">3 CVE · 47 updates</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">3 fix</span> · 4 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-pkg" style="display:none;"> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">orphans<span class="r-auto">confirm</span></span><span class="r-val">13</span><span class="r-lever"><button class="tiny">Review</button></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">.pacnew<span class="r-auto">confirm</span></span><span class="r-val">2</span><span class="r-lever"><button class="tiny">Review</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">keyring<span class="r-auto">confirm</span></span><span class="r-val">12 d</span><span class="r-lever"><button class="tiny">Refresh</button></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">pending updates<span class="r-auto">workflow</span></span><span class="r-val">47</span><span class="r-lever"><button class="tiny">Updates</button></span></div> + <div class="row"><span class="lamp l-fail">●</span><span class="r-name">arch-audit<span class="r-auto">workflow</span></span><span class="r-val">3 CVE</span><span class="r-lever"><button class="tiny">Updates</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">AUR staleness<span class="r-auto">workflow</span></span><span class="r-val">5</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">reboot-required<span class="r-auto">human</span></span><span class="r-val">no</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- SYSTEMD & BOOT --> + <div class="tile" onclick="toggle('sd')" id="t-sd"> + <div class="tile-top"> + <span class="rollup l-warn">●</span> + <div class="tile-head"> + <div class="tile-name">systemd & Boot</div> + <div class="tile-finding warnt">1 failed · timers 4/5</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">1 fix</span> · 3 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-sd" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">is-system-running</span><span class="r-val">running</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">failed units<span class="r-auto">workflow</span></span><span class="r-val">1</span><span class="r-lever"><button class="tiny">Run workflow</button></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">maintenance timers<span class="r-auto">confirm</span></span><span class="r-val">4 / 5</span><span class="r-lever"><button class="tiny">Enable</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">kernel taint</span><span class="r-val">0</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- LOGS & COREDUMPS --> + <div class="tile" onclick="toggle('log')" id="t-log"> + <div class="tile-top"> + <span class="rollup l-warn">●</span> + <div class="tile-head"> + <div class="tile-name">Logs & Coredumps</div> + <div class="tile-finding warnt">18 coredumps / 7d</div> + </div> + <div class="tile-foot" style="flex-direction:column; align-items:flex-end; gap:6px;"> + <button class="tiny" onclick="event.stopPropagation()">Clean</button> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">3 fix</span> · 2 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-log" style="display:none;"> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">coredumps<span class="r-auto">auto</span></span><span class="r-val">18 / 7d</span><span class="r-lever"><button class="tiny">Clear</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">journald<span class="r-auto">auto</span></span><span class="r-val">1.2 GB</span><span class="r-lever"><button class="tiny">Vacuum</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">app-log cleanup<span class="r-auto">auto</span></span><span class="r-val">ok</span><span class="r-lever"><button class="tiny">Run</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">journal errors<span class="r-auto">workflow</span></span><span class="r-val">12 real</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">kernel/hw events</span><span class="r-val">clean</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- MEMORY / THERMAL / POWER --> + <div class="tile" onclick="toggle('mem')" id="t-mem"> + <div class="tile-top"> + <span class="rollup l-warn">●</span> + <div class="tile-head"> + <div class="tile-name">Memory / Thermal / Power</div> + <div class="tile-finding warnt">unclean-shutdown 75%</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">0 fix</span> · 6 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-mem" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">memory<span class="r-auto">workflow</span></span><span class="r-val">104 GB free</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">swap / zram</span><span class="r-val">16 GiB</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">temps</span><span class="r-val">61° / 54°</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">thermal throttle</span><span class="r-val">no</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-na">○</span><span class="r-name">battery</span><span class="r-val">n/a</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">unclean-shutdown<span class="r-auto">workflow</span></span><span class="r-val">75%</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- NETWORK & POSTURE --> + <div class="tile" onclick="toggle('net')" id="t-net"> + <div class="tile-top"> + <span class="rollup l-ok">●</span> + <div class="tile-head"> + <div class="tile-name">Network & Posture</div> + <div class="tile-finding">all posture green</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">4 fix</span> · 2 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-net" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">DNS / NM<span class="r-auto">workflow</span></span><span class="r-val">ok</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">firewall<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">listeners</span><span class="r-val">3</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">tailscale<span class="r-auto">confirm</span></span><span class="r-val">4 / 4</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">fail2ban<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">NTP<span class="r-auto">confirm</span></span><span class="r-val">synced</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- SERVICES / BACKUPS / VIRT --> + <div class="tile" onclick="toggle('svc')" id="t-svc"> + <div class="tile-top"> + <span class="rollup l-warn">●</span> + <div class="tile-head"> + <div class="tile-name">Services & Backups</div> + <div class="tile-finding warnt">docker reclaim 3 GB</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">2 fix</span> · 3 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-svc" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">rsyncshot<span class="r-auto">workflow</span></span><span class="r-val">3h ago</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">docker reclaim<span class="r-auto">confirm</span></span><span class="r-val">3 GB</span><span class="r-lever"><button class="tiny">Prune</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">docker stopped</span><span class="r-val">2</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-na">○</span><span class="r-name">libvirt</span><span class="r-val">off</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">cron<span class="r-auto">confirm</span></span><span class="r-val">ok</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + </div> +</div> + +<!-- running / output-wall capsule --> +<div class="capsule"> + <div class="cap-hdr"> + <div class="title">MAINT · <b>ratio</b> · <span class="running">CLEANING ◐</span></div> + <span style="color:var(--dim); font-size:12px;">4 Auto actions</span> + </div> + <div class="wall"> + <div class="line"><span class="glyph l-prog">◐</span><span class="act">cache trim</span><span class="st">running…</span></div> + <div class="line done"><span class="glyph l-ok">●</span><span class="act">journal vacuum</span><span class="st">done · reclaimed <b>0.9 GB</b></span></div> + <div class="line done"><span class="glyph l-ok">●</span><span class="act">coredump clear</span><span class="st">done · <b>12 cleared</b></span></div> + <div class="line queued"><span class="glyph l-na">○</span><span class="act">app-log cleanup</span><span class="st">queued</span></div> + </div> + <div class="cap-foot"><button>Done</button></div> +</div> + +<div class="legend"> + Automation — <b>Auto</b> doctor cleans unattended · <b>Confirm</b> doctor previews then you click · <b>Human</b> panel nudges you to act · <b>Workflow</b> escalates to an agent workflow · <b>None</b> diagnostic only. +</div> + +<script> + function toggle(id){ + var t = document.getElementById('t-'+id); + var d = document.getElementById('d-'+id); + if(!d) return; + var open = d.style.display !== 'none'; + d.style.display = open ? 'none' : 'block'; + t.classList.toggle('expanded', !open); + var ex = t.querySelector('.expander'); + if(ex) ex.textContent = open ? '▸ detail' : '▾ detail'; + } +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html b/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html new file mode 100644 index 0000000..90182d6 --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html @@ -0,0 +1,410 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — Approach C · Severity feed</title> +<style> + :root{ + --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d; + --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5; + --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32; + --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d; + } + *{box-sizing:border-box} + html,body{margin:0;padding:0} + body{ + background:var(--page); + color:var(--silver); + font-family:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + font-size:13px; line-height:1.45; + display:flex; flex-direction:column; align-items:center; + padding:28px 16px 60px; + -webkit-font-smoothing:antialiased; + } + .console{ + width:1000px; max-width:100%; + background:var(--surface); + border:1px solid var(--gold); + border-radius:16px; + padding:18px; + margin-bottom:20px; + } + + /* ---- header ---- */ + .top{display:flex; align-items:baseline; justify-content:space-between; gap:12px} + .title{font-weight:700; color:var(--cream); font-size:16px; letter-spacing:.5px} + .title .sub-host{color:var(--dim); font-weight:400; font-size:13px} + .verdict{font-weight:700; font-size:14px; color:var(--ok)} + .subtitle{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.3px} + + /* ---- action bar ---- */ + .actionbar{ + display:flex; align-items:center; gap:10px; flex-wrap:wrap; + margin:14px 0 6px; + } + .btn{ + background:var(--slate); color:var(--cream); + border:1px solid #2f3a45; border-radius:10px; + padding:7px 14px; font-family:inherit; font-size:12px; font-weight:600; + cursor:pointer; letter-spacing:.3px; + } + .btn:hover{background:var(--slate-hi)} + .btn.small{padding:4px 10px; font-size:11px; font-weight:500} + .btn.wide{padding:7px 18px} + .spacer{flex:1 1 auto} + + /* quarantined updates pill */ + .updates-pill{ + display:flex; align-items:center; gap:12px; + background:var(--inset); border:1px solid var(--line); + border-left:3px solid var(--warn); + border-radius:10px; padding:6px 8px 6px 12px; + } + .updates-pill .u-text{font-size:12px; color:var(--silver)} + .updates-pill .u-text b{color:var(--cream); font-weight:600} + .updates-pill .u-cve{color:var(--fail); font-weight:600} + .updates-pill .u-pkgs{color:var(--dim)} + + /* ---- feed ---- */ + .feed{margin-top:14px} + .band-head{ + display:flex; align-items:center; gap:10px; + color:var(--gold); font-weight:700; font-size:12px; + letter-spacing:1.2px; text-transform:uppercase; + margin:16px 0 6px; padding-bottom:5px; + border-bottom:1px solid var(--line); + } + .band-head .count{color:var(--dim); font-weight:400; letter-spacing:.3px; text-transform:none} + .band-head .accent{ + display:inline-block; width:8px; height:8px; border-radius:2px; + } + .band-fail .accent{background:var(--fail)} + .band-warn .accent{background:var(--warn)} + .band-ok .accent{background:var(--ok)} + + .rows{ + background:var(--inset); border:1px solid var(--line); + border-radius:10px; padding:4px 2px; + } + + .row{ + display:flex; align-items:center; gap:10px; + padding:6px 12px; + border-bottom:1px solid rgba(44,47,50,.5); + } + .row:last-child{border-bottom:none} + .row .lamp{flex:0 0 auto; font-size:13px; width:14px; text-align:center} + .row .name{flex:0 0 auto; color:var(--silver); white-space:nowrap} + .row .dots{ + flex:1 1 auto; min-width:16px; + border-bottom:1px dotted #3a3f43; + transform:translateY(-3px); + margin:0 4px; + } + .row .value{flex:0 0 auto; color:var(--cream); white-space:nowrap; text-align:right} + .row .value.warnval{color:var(--warn)} + .row .value.failval{color:var(--fail)} + .row .chip{ + flex:0 0 auto; width:64px; text-align:center; + font-size:10px; letter-spacing:.4px; text-transform:uppercase; + padding:2px 0; border-radius:6px; border:1px solid var(--line); + color:var(--dim); + } + .chip.auto{color:#a9c05a; border-color:#3c4a24} + .chip.confirm{color:var(--gold); border-color:#4a4023} + .chip.workflow{color:var(--slate-hi); border-color:#33414f} + .chip.human{color:var(--cream); border-color:#4a4436} + .chip.none{color:var(--dim); border-color:var(--line)} + .row .control{flex:0 0 auto; width:118px; text-align:right} + .row .control .lever{ + background:var(--slate); color:var(--cream); + border:1px solid #2f3a45; border-radius:8px; + padding:3px 10px; font-family:inherit; font-size:11px; cursor:pointer; + } + .row .control .lever:hover{background:var(--slate-hi)} + .row .control .wflink{color:var(--slate-hi); font-size:11px; cursor:pointer} + .row .control .wflink:hover{color:var(--cream); text-decoration:underline} + .row .control .nolever{color:#4a5054; font-size:11px} + + /* lamps */ + .l-ok{color:var(--ok)} + .l-warn{color:var(--warn)} + .l-fail{color:var(--fail)} + .l-off{color:var(--dim)} + .l-prog{color:var(--warn)} + + /* OK band quieting */ + .band-ok-summary{ + display:flex; align-items:center; gap:8px; + color:var(--dim); font-size:12px; cursor:pointer; + margin:16px 0 6px; padding-bottom:5px; + border-bottom:1px solid var(--line); + } + .band-ok-summary .tri{color:var(--gold)} + .band-ok .rows{opacity:.72} + .band-ok .row{padding:4px 12px} + .band-ok .row .name{color:var(--dim)} + .band-ok .row .value{color:var(--silver)} + + /* ---- running capsule ---- */ + .capsule{ + width:1000px; max-width:100%; + background:var(--surface); + border:1px solid var(--gold); + border-radius:16px; padding:16px 18px; + } + .cap-head{ + display:flex; align-items:center; justify-content:space-between; + color:var(--cream); font-weight:700; font-size:13px; letter-spacing:.5px; + margin-bottom:10px; + } + .cap-head .run{color:var(--warn)} + .wall{ + background:var(--inset); border:1px solid var(--line); border-radius:10px; + padding:10px 14px; font-size:12px; + } + .wall .line{display:flex; align-items:center; gap:10px; padding:3px 0} + .wall .line .lamp{width:14px; text-align:center} + .wall .line .act{color:var(--silver)} + .wall .line .act .done{color:var(--dim)} + .wall .line .res{color:var(--ok); margin-left:auto} + .wall .line.queued .act{color:var(--dim)} + .cap-foot{display:flex; justify-content:flex-end; margin-top:12px} + + .legend{ + width:1000px; max-width:100%; + color:var(--dim); font-size:11px; line-height:1.6; + margin-top:16px; padding:0 4px; + } + .legend b{color:var(--silver); font-weight:600} +</style> +</head> +<body> + +<div class="console"> + <div class="top"> + <div> + <div class="title">MAINT <span class="sub-host">· ratio</span></div> + <div class="subtitle">Approach C — Severity feed</div> + </div> + <div class="verdict">● healthy</div> + </div> + + <!-- action bar --> + <div class="actionbar"> + <button class="btn wide">Clean up <span style="color:var(--dim);font-weight:400">· 4 Auto</span></button> + <button class="btn wide">Review & fix <span style="color:var(--dim);font-weight:400">· 15 Confirm</span></button> + <div class="spacer"></div> + <div class="updates-pill"> + <span class="u-text"><b>47 pending</b> · <span class="u-cve">3 CVE</span> · <span class="u-pkgs">mesa, linux-lts</span></span> + <button class="btn small">Run workflow</button> + </div> + </div> + + <div class="feed"> + + <!-- ===================== FAIL ===================== --> + <div class="band-head band-fail"><span class="accent"></span>Fail <span class="count">— act now · 2</span></div> + <div class="rows"> + <div class="row"> + <span class="lamp l-fail">●</span> + <span class="name">fstrim.timer</span> + <span class="dots"></span> + <span class="value failval">off</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Enable</button></span> + </div> + <div class="row"> + <span class="lamp l-fail">●</span> + <span class="name">arch-audit</span> + <span class="dots"></span> + <span class="value failval">3 CVE</span> + <span class="chip workflow">Workflow</span> + <span class="control"><span class="wflink">→ workflow</span></span> + </div> + </div> + + <!-- ===================== WARN ===================== --> + <div class="band-head band-warn"><span class="accent"></span>Warn <span class="count">— review · 13</span></div> + <div class="rows"> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">cache</span> + <span class="dots"></span> + <span class="value warnval">8.8 GB</span> + <span class="chip auto">Auto</span> + <span class="control"><button class="lever">Clean</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">btrfs scrub</span> + <span class="dots"></span> + <span class="value warnval">34d ago</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Scrub</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">orphans</span> + <span class="dots"></span> + <span class="value warnval">13</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Review</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">.pacnew files</span> + <span class="dots"></span> + <span class="value warnval">2</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Review</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">pending updates</span> + <span class="dots"></span> + <span class="value warnval">47</span> + <span class="chip workflow">Workflow</span> + <span class="control"><span class="wflink">→ workflow</span></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">failed units</span> + <span class="dots"></span> + <span class="value warnval">1</span> + <span class="chip workflow">Workflow</span> + <span class="control"><span class="wflink">→ workflow</span></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">maintenance timers</span> + <span class="dots"></span> + <span class="value warnval">4/5</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Enable</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">coredumps</span> + <span class="dots"></span> + <span class="value warnval">18 / 7d</span> + <span class="chip auto">Auto</span> + <span class="control"><button class="lever">Clear</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">unclean-shutdown</span> + <span class="dots"></span> + <span class="value warnval">75%</span> + <span class="chip workflow">Workflow</span> + <span class="control"><span class="wflink">→ workflow</span></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">docker reclaim</span> + <span class="dots"></span> + <span class="value warnval">3 GB</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Prune</button></span> + </div> + </div> + + <!-- ===================== OK (quieted) ===================== --> + <div class="band-ok" id="okband"> + <div class="band-ok-summary" onclick="var b=document.getElementById('okrows');b.style.display=b.style.display==='none'?'block':'none';this.querySelector('.tri').textContent=b.style.display==='none'?'▸':'▾';"> + <span class="tri">▾</span> + <span style="color:var(--ok)">●</span> + <span>29 OK — clean, no action</span> + <span style="flex:1"></span> + <span style="color:#565c60">collapse</span> + </div> + <div class="rows" id="okrows"> + <div class="row"><span class="lamp l-ok">●</span><span class="name">deep-trim keep-3</span><span class="dots"></span><span class="value">available</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Deep trim</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">disk usage</span><span class="dots"></span><span class="value">69%</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">btrfs unalloc</span><span class="dots"></span><span class="value">118 GiB</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">btrfs device-err</span><span class="dots"></span><span class="value">0 / 0</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">SMART status</span><span class="dots"></span><span class="value">PASSED</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">SMART wear</span><span class="dots"></span><span class="value">0%</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">pacman integrity</span><span class="dots"></span><span class="value">clean</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">snapper count</span><span class="dots"></span><span class="value">42</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Prune</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">snapshot timer</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">keyring</span><span class="dots"></span><span class="value">12d</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Refresh</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">AUR staleness</span><span class="dots"></span><span class="value">5</span><span class="chip workflow">Workflow</span><span class="control"><span class="wflink">→ workflow</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">reboot-required</span><span class="dots"></span><span class="value">no</span><span class="chip human">Human</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">is-system-running</span><span class="dots"></span><span class="value">running</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">kernel taint</span><span class="dots"></span><span class="value">0</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">journald size</span><span class="dots"></span><span class="value">1.2 GB</span><span class="chip auto">Auto</span><span class="control"><button class="lever">Vacuum</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">app-log cleanup</span><span class="dots"></span><span class="value">ok</span><span class="chip auto">Auto</span><span class="control"><button class="lever">Run</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">journal errors</span><span class="dots"></span><span class="value">12 real</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">kernel/hw events</span><span class="dots"></span><span class="value">clean</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">memory</span><span class="dots"></span><span class="value">104 GB free</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">swap/zram</span><span class="dots"></span><span class="value">16 GiB</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">temps</span><span class="dots"></span><span class="value">61° / 54°</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">thermal throttle</span><span class="dots"></span><span class="value">no</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">DNS / NM</span><span class="dots"></span><span class="value">ok</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">firewall</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">listeners</span><span class="dots"></span><span class="value">3</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">tailscale</span><span class="dots"></span><span class="value">4/4</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">fail2ban</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">NTP</span><span class="dots"></span><span class="value">synced</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">rsyncshot</span><span class="dots"></span><span class="value">3h ago</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">docker stopped</span><span class="dots"></span><span class="value">2</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">cron</span><span class="dots"></span><span class="value">ok</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + </div> + </div> + + <!-- ===================== OFF / N-A ===================== --> + <div class="band-ok" style="margin-top:6px"> + <div class="rows" style="opacity:.55"> + <div class="row"><span class="lamp l-off">○</span><span class="name" style="color:var(--dim)">battery</span><span class="dots"></span><span class="value" style="color:var(--dim)">n/a</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-off">○</span><span class="name" style="color:var(--dim)">libvirt</span><span class="dots"></span><span class="value" style="color:var(--dim)">off</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + </div> + </div> + + </div> +</div> + +<!-- =============== RUNNING / OUTPUT-WALL capsule =============== --> +<div class="capsule"> + <div class="cap-head"> + <span>MAINT · ratio · CLEANING <span class="run">◐</span></span> + <span style="color:var(--dim); font-weight:400; font-size:12px">4 Auto actions</span> + </div> + <div class="wall"> + <div class="line"> + <span class="lamp l-prog">◐</span> + <span class="act">cache trim <span style="color:var(--dim)">running…</span></span> + </div> + <div class="line"> + <span class="lamp l-ok">●</span> + <span class="act"><span class="done">journal vacuum — done</span></span> + <span class="res">reclaimed 0.9 GB</span> + </div> + <div class="line"> + <span class="lamp l-ok">●</span> + <span class="act"><span class="done">coredump clear — done</span></span> + <span class="res">12 cleared</span> + </div> + <div class="line queued"> + <span class="lamp l-off">○</span> + <span class="act">app-log cleanup <span style="color:#565c60">queued</span></span> + </div> + </div> + <div class="cap-foot"> + <button class="btn">Done</button> + </div> +</div> + +<div class="legend"> + <b>Automation classes:</b> + <b>Auto</b> — doctor "Clean up" runs unattended · + <b>Confirm</b> — doctor "Review & fix" preview then click · + <b>Human</b> — you act · + <b>Workflow</b> — escalate to an agent workflow · + <b>None</b> — diagnostic only. + <b>Lamps:</b> ● ok / warn / fail · ◐ in-progress · ○ off-or-n/a. ZFS rows omitted (btrfs host). +</div> + +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html b/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html new file mode 100644 index 0000000..7f2a86e --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html @@ -0,0 +1,364 @@ +<!-- Approach D — Doctor-first maintenance console mockup (ratio) --> +<title>MAINT · ratio — Approach D Doctor-first</title> +<style> + :root{ + --bg:#0a0c0d; --surface:#100f0f; --gold:#dab53d; --silver:#bfc4d0; + --dim:#7c838a; --cream:#f3e7c5; --slate:#424f5e; --slate-h:#54677d; + --inset:#0a0c0d; --line:#2c2f32; + --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d; + } + *{box-sizing:border-box} + html,body{margin:0;padding:0} + body{ + background:var(--bg); + color:var(--silver); + font-family:"BerkeleyMono Nerd Font", ui-monospace, monospace; + font-size:13px; line-height:1.45; + display:flex; justify-content:center; + padding:28px 16px 60px; + -webkit-font-smoothing:antialiased; + } + .console{ + width:1000px; max-width:100%; + background:var(--surface); + border:1px solid var(--gold); + border-radius:16px; + padding:18px; + } + + /* ---- header ---- */ + .head{display:flex; align-items:baseline; justify-content:space-between; gap:12px} + .head .title{color:var(--cream); font-size:15px; letter-spacing:.06em; font-weight:700} + .head .title .sub{display:block; color:var(--dim); font-size:11px; font-weight:400; letter-spacing:.02em; margin-top:2px} + .verdict{font-size:14px; font-weight:700; letter-spacing:.03em} + .verdict .lamp{font-size:13px} + + .rule{height:1px; background:var(--line); border:0; margin:14px 0} + + /* ---- lamps ---- */ + .lamp{font-style:normal} + .ok{color:var(--olive)} + .warn{color:var(--amber)} + .fail{color:var(--terra)} + .na{color:var(--dim)} + .run{color:var(--amber)} + + /* ---- generic inset ---- */ + .inset{background:var(--inset); border:1px solid var(--line); border-radius:10px; padding:14px} + .sec-h{color:var(--gold); font-weight:700; letter-spacing:.05em; font-size:12px; text-transform:uppercase; margin:0 0 10px} + + /* ---- buttons ---- */ + button{font-family:inherit; cursor:pointer} + .btn{ + background:var(--slate); color:var(--cream); border:1px solid #33414d; + border-radius:10px; padding:9px 16px; font-size:12px; letter-spacing:.03em; + transition:background .12s; + } + .btn:hover{background:var(--slate-h)} + .btn-lever{padding:4px 10px; font-size:11px; border-radius:8px} + + /* ================= HERO ================= */ + .hero{ + background:linear-gradient(180deg,#141312,#0d0c0c); + border:1px solid var(--line); border-radius:14px; + padding:22px 22px 24px; text-align:center; + } + .hero .stethoscope{font-size:26px; color:var(--gold); letter-spacing:.3em; margin-bottom:6px} + .hero .stat-line{color:var(--silver); font-size:13px; letter-spacing:.04em} + .hero .stat-line b{color:var(--cream); font-weight:700} + .hero .stat-line .fail{font-weight:700} + + /* summary lamp strip */ + .strip{display:flex; flex-wrap:wrap; gap:3px; justify-content:center; margin:14px auto 4px; max-width:720px} + .strip .dot{font-size:11px; line-height:1} + + .dbig{display:flex; gap:16px; justify-content:center; margin-top:20px; flex-wrap:wrap} + .doc-btn{ + background:var(--slate); color:var(--cream); + border:1px solid #33414d; border-radius:12px; + padding:16px 26px; min-width:280px; text-align:center; + transition:background .12s; + } + .doc-btn:hover{background:var(--slate-h)} + .doc-btn .big{display:block; font-size:17px; font-weight:700; letter-spacing:.08em; color:var(--cream)} + .doc-btn .sub{display:block; font-size:11px; color:#cdd7cf; margin-top:6px; letter-spacing:.02em; opacity:.85} + .doc-btn.primary{border-color:var(--gold); box-shadow:0 0 0 1px rgba(218,181,61,.25) inset} + .doc-btn.primary .big{color:var(--cream)} + + .hero-foot{color:var(--dim); font-size:11px; margin-top:18px; letter-spacing:.02em} + + /* ================= DETAILS DISCLOSURE ================= */ + .disc{margin-top:16px} + .disc-head{ + display:flex; align-items:center; gap:8px; + color:var(--gold); font-weight:700; font-size:12px; letter-spacing:.05em; + cursor:pointer; user-select:none; padding:4px 2px; + } + .disc-head .caret{transition:transform .12s} + .disc-body{margin-top:12px} + + .domain{margin-bottom:14px} + .domain-h{color:var(--dim); font-size:10px; letter-spacing:.14em; text-transform:uppercase; margin:0 0 6px; border-bottom:1px solid var(--line); padding-bottom:4px} + table.metrics{width:100%; border-collapse:collapse} + table.metrics td{padding:3px 8px; vertical-align:middle; white-space:nowrap} + table.metrics tr:hover{background:rgba(66,79,94,.14)} + td.mlamp{width:16px; text-align:center} + td.mname{color:var(--silver)} + td.mval{color:var(--dim); text-align:right; width:150px} + td.mlever{width:110px; text-align:right} + .auto-tag{font-size:9px; letter-spacing:.08em; color:var(--dim); text-transform:uppercase; padding-right:6px} + + /* ================= UPDATES QUARANTINE ================= */ + .updates{margin-top:16px; border:1px dashed #4a4535; background:#12100c} + .updates .sec-h{color:var(--amber)} + .updates-row{display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap} + .updates-info b{color:var(--cream)} + .updates-info .cve{color:var(--terra); font-weight:700} + .updates-info .pkgs{color:var(--dim)} + .updates-note{color:var(--dim); font-size:10px; margin-top:8px; letter-spacing:.02em} + + /* ================= OUTPUT WALL ================= */ + .wall{ + margin-top:18px; + background:#0b0a09; border:1px solid var(--gold); border-radius:14px; + padding:18px 20px; + } + .wall-h{display:flex; align-items:center; justify-content:space-between; margin-bottom:4px} + .wall-h .t{color:var(--gold); font-weight:700; letter-spacing:.06em; font-size:13px} + .wall-h .meta{color:var(--dim); font-size:11px} + .wall-sub{color:var(--dim); font-size:11px; margin-bottom:12px} + .prog{height:6px; background:#1a1712; border:1px solid var(--line); border-radius:4px; overflow:hidden; margin-bottom:14px} + .prog i{display:block; height:100%; width:62%; background:linear-gradient(90deg,#74932f,#dab53d)} + .stream{font-size:12.5px; line-height:1.9} + .srow{display:flex; align-items:baseline; gap:10px} + .srow .l{width:14px; text-align:center; flex:0 0 auto} + .srow .task{color:var(--silver); flex:0 0 200px} + .srow .status{color:var(--dim)} + .srow.done .task{color:var(--silver)} + .srow.done .status{color:var(--olive)} + .srow.run .task{color:var(--cream)} + .srow.run .status{color:var(--amber)} + .srow.queued .task{color:var(--dim)} + .srow.queued .status{color:var(--dim)} + .srow .result{color:var(--cream); opacity:.85} + .wall-foot{display:flex; justify-content:space-between; align-items:center; margin-top:16px} + .wall-foot .summary{color:var(--dim); font-size:11px} + .wall-foot .summary b{color:var(--olive)} + + .stagelabel{color:var(--dim); font-size:10px; letter-spacing:.18em; text-transform:uppercase; margin:26px 0 8px; text-align:center} +</style> + +<div class="console"> + + <!-- ================= HEADER ================= --> + <div class="head"> + <div class="title">MAINT · ratio + <span class="sub">Approach D — Doctor-first</span> + </div> + <div class="verdict ok"><span class="lamp">●</span> healthy</div> + </div> + <hr class="rule"> + + <div class="stagelabel">— resting state · the doctor is what you see first —</div> + + <!-- ================= HERO (resting) ================= --> + <div class="hero"> + <div class="stethoscope">✚ ✚ ✚</div> + <div class="stat-line"> + <b>ratio</b> · <span class="fail">1 fail</span> · <span class="warn">8 warnings</span> · <span class="ok">34 ok</span> + </div> + + <!-- summary lamp strip: one tiny lamp per metric --> + <div class="strip" title="one lamp per metric, colored by state"> + <!-- 44 dots; mostly olive, some amber, one terracotta --> + <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot fail">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot warn">●</span><span class="dot warn">●</span><span class="dot ok">●</span> + <span class="dot warn">●</span><span class="dot fail">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot warn">●</span> + <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot warn">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot na">○</span><span class="dot warn">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot na">○</span> + <span class="dot ok">●</span><span class="dot ok">●</span> + </div> + + <!-- big doctor actions --> + <div class="dbig"> + <button class="doc-btn primary" onclick="document.getElementById('wall').scrollIntoView({behavior:'smooth'})"> + <span class="big">CLEAN UP</span> + <span class="sub">4 safe tasks · reclaims ~2 GB</span> + </button> + <button class="doc-btn"> + <span class="big">REVIEW & FIX</span> + <span class="sub">9 items need a look</span> + </button> + </div> + + <div class="hero-foot"> + "Should I run the doctor?" — press Clean up for the 4 unattended tasks, or Review & fix to preview the 9 confirm items. + </div> + </div> + + <!-- ================= DETAILS DISCLOSURE (shown expanded statically) ================= --> + <div class="disc inset"> + <div class="disc-head" onclick="var b=document.getElementById('discbody'); var c=this.querySelector('.caret'); if(b.style.display==='none'){b.style.display='block'; c.textContent='▾';} else {b.style.display='none'; c.textContent='▸';}"> + <span class="caret">▾</span> 44 metrics · the fine print + </div> + <div class="disc-body" id="discbody"> + + <!-- STORAGE / FILESYSTEM --> + <div class="domain"> + <div class="domain-h">Storage & filesystem</div> + <table class="metrics"> + <tr><td class="mlamp warn">●</td><td class="mname">cache</td><td class="mval">8.8 GB</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Clean</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">deep-trim keep-3</td><td class="mval">ready</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Deep trim</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">disk</td><td class="mval">69%</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">btrfs unalloc</td><td class="mval">118 GiB</td><td class="mlever">—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">btrfs scrub</td><td class="mval">34 d ago</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Scrub</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">btrfs device-err</td><td class="mval">0 / 0</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">SMART</td><td class="mval">PASSED</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">SMART wear</td><td class="mval">0%</td><td class="mlever">—</td></tr> + <tr><td class="mlamp fail">●</td><td class="mname">fstrim.timer</td><td class="mval">off</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Enable</button></td></tr> + </table> + </div> + + <!-- PACKAGES --> + <div class="domain"> + <div class="domain-h">Packages & snapshots</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">pacman integrity</td><td class="mval">clean</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">snapper count</td><td class="mval">42</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Prune</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">snapshot timer</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">orphans</td><td class="mval">13</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Review</button></td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">.pacnew</td><td class="mval">2</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Review</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">keyring</td><td class="mval">12 d</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Refresh</button></td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">pending updates</td><td class="mval">47</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr> + <tr><td class="mlamp fail">●</td><td class="mname">arch-audit</td><td class="mval">3 CVE</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">AUR staleness</td><td class="mval">5</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr> + </table> + </div> + + <!-- SYSTEM STATE --> + <div class="domain"> + <div class="domain-h">System state</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">reboot-required</td><td class="mval">no</td><td class="mlever"><span class="auto-tag">human</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">is-system-running</td><td class="mval">running</td><td class="mlever">—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">failed units</td><td class="mval">1</td><td class="mlever"><span class="auto-tag">workflow</span><button class="btn btn-lever">Run</button></td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">maintenance timers</td><td class="mval">4 / 5</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Enable</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">kernel taint</td><td class="mval">0</td><td class="mlever">—</td></tr> + </table> + </div> + + <!-- LOGS --> + <div class="domain"> + <div class="domain-h">Logs & coredumps</div> + <table class="metrics"> + <tr><td class="mlamp warn">●</td><td class="mname">coredumps</td><td class="mval">18 / 7 d</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Clear</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">journald</td><td class="mval">1.2 GB</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Vacuum</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">app-log cleanup</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Run</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">journal errors</td><td class="mval">12 real</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">kernel/hw events</td><td class="mval">clean</td><td class="mlever">—</td></tr> + </table> + </div> + + <!-- RESOURCES --> + <div class="domain"> + <div class="domain-h">Resources & thermals</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">memory</td><td class="mval">104 GB free</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">swap / zram</td><td class="mval">16 GiB</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">temps</td><td class="mval">61° / 54°</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">thermal throttle</td><td class="mval">no</td><td class="mlever">—</td></tr> + <tr><td class="mlamp na">○</td><td class="mname na">battery</td><td class="mval">n/a</td><td class="mlever">—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">unclean-shutdown</td><td class="mval">75%</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + </table> + </div> + + <!-- NETWORK / SECURITY --> + <div class="domain"> + <div class="domain-h">Network & security</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">DNS / NM</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">firewall</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">listeners</td><td class="mval">3</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">tailscale</td><td class="mval">4 / 4</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">fail2ban</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">NTP</td><td class="mval">synced</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + </table> + </div> + + <!-- SERVICES / BACKUP --> + <div class="domain"> + <div class="domain-h">Backup & services</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">rsyncshot</td><td class="mval">3 h ago</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">docker reclaim</td><td class="mval">3 GB</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Prune</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">docker stopped</td><td class="mval">2</td><td class="mlever">—</td></tr> + <tr><td class="mlamp na">○</td><td class="mname na">libvirt</td><td class="mval">off</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">cron</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + </table> + </div> + + </div> + </div> + + <!-- ================= UPDATES QUARANTINE ================= --> + <div class="updates inset"> + <div class="sec-h">Updates — quarantined from the doctor</div> + <div class="updates-row"> + <div class="updates-info"> + <b>47 pending</b> · <span class="cve">3 CVE</span> · <span class="pkgs">mesa, linux-lts, systemd</span> + </div> + <button class="btn">Run workflow</button> + </div> + <div class="updates-note">Updates run through the workflow, never the doctor. Clean up will not touch these.</div> + </div> + + <div class="stagelabel">— running state · press CLEAN UP and the hero becomes the output wall —</div> + + <!-- ================= OUTPUT WALL (the star) ================= --> + <div class="wall" id="wall"> + <div class="wall-h"> + <span class="t">✚ DOCTOR · CLEAN UP</span> + <span class="meta">4 tasks · unattended · started 14:22:07</span> + </div> + <div class="wall-sub">Running the 4 safe (auto) tasks. Confirm items and updates are untouched.</div> + <div class="prog"><i></i></div> + + <div class="stream"> + <div class="srow run"> + <span class="l run">◐</span> + <span class="task">cache trim</span> + <span class="status">running… 8.8 GB → clearing</span> + </div> + <div class="srow done"> + <span class="l ok">●</span> + <span class="task">journald vacuum</span> + <span class="status">done · <span class="result">reclaimed 0.9 GB</span></span> + </div> + <div class="srow done"> + <span class="l ok">●</span> + <span class="task">coredump clear</span> + <span class="status">done · <span class="result">12 cleared</span></span> + </div> + <div class="srow queued"> + <span class="l na">○</span> + <span class="task">app-log cleanup</span> + <span class="status">queued</span> + </div> + </div> + + <div class="wall-foot"> + <span class="summary">3 of 4 complete · <b>reclaimed 0.9 GB</b> so far · ~1.1 GB pending</span> + <button class="btn">Done</button> + </div> + </div> + +</div> diff --git a/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html b/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html new file mode 100644 index 0000000..52ecfad --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html @@ -0,0 +1,475 @@ +<meta charset="utf-8"> +<title>MAINT · ratio — Approach E · Instrument Dashboard</title> +<style> + :root{ + --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d; + --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5; + --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32; + --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d; + --font:"BerkeleyMono Nerd Font", ui-monospace, monospace; + } + *{box-sizing:border-box} + html,body{margin:0;background:var(--page);} + body{ + font-family:var(--font); color:var(--silver); + display:flex; justify-content:center; padding:26px 14px 60px; + -webkit-font-smoothing:antialiased; + } + .console{ + width:1050px; max-width:100%; + background:var(--surface); + border:1px solid var(--gold); border-radius:16px; + padding:18px; + } + + /* ---- header ---- */ + .head{display:flex; align-items:baseline; justify-content:space-between; gap:16px;} + .head .title{font-size:19px; color:var(--cream); letter-spacing:.5px;} + .head .title b{color:var(--gold); font-weight:700;} + .verdict{font-size:15px; color:var(--olive); font-weight:700; white-space:nowrap;} + .sub{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.4px;} + + /* ---- top action bar ---- */ + .actionbar{ + display:flex; align-items:center; gap:10px; + margin:14px 0 16px; padding:10px 12px; + background:var(--inset); border:1px solid var(--line); border-radius:10px; + } + .actionbar .lbl{color:var(--dim); font-size:11.5px; letter-spacing:.5px; text-transform:uppercase; margin-right:2px;} + .btn{ + font-family:var(--font); cursor:pointer; + background:var(--slate); color:var(--cream); + border:none; border-radius:10px; padding:7px 14px; font-size:13px; + } + .btn:hover{background:var(--slate-hi);} + .btn.lever{padding:3px 9px; font-size:11px; border-radius:8px;} + .actionbar .spacer{flex:1;} + .legend{color:var(--dim); font-size:11px; line-height:1.5;} + .legend b{color:var(--silver); font-weight:400;} + + /* ---- grid ---- */ + .cluster{display:grid; grid-template-columns:repeat(12,1fr); gap:10px;} + .card{ + background:var(--inset); border:1px solid var(--line); border-radius:10px; + padding:11px 12px 12px; position:relative; min-height:96px; + display:flex; flex-direction:column; + } + .card .lever{position:absolute; top:9px; right:9px;} + .card .cap{ + color:var(--dim); font-size:10.5px; letter-spacing:.6px; + text-transform:uppercase; margin-bottom:8px; padding-right:56px; + } + .card .big{color:var(--cream); font-size:23px; line-height:1; font-weight:700;} + .card .unit{color:var(--dim); font-size:12px; font-weight:400;} + .card .note{color:var(--dim); font-size:11px; margin-top:6px;} + + /* domain band label */ + .band{ + grid-column:1/-1; color:var(--gold); font-weight:700; font-size:12px; + letter-spacing:1.2px; text-transform:uppercase; margin:6px 2px 0; + display:flex; align-items:center; gap:10px; + } + .band::after{content:""; flex:1; height:1px; background:var(--line);} + + /* span helpers */ + .s3{grid-column:span 3;} .s4{grid-column:span 4;} + .s5{grid-column:span 5;} .s6{grid-column:span 6;} + .s7{grid-column:span 7;} .s8{grid-column:span 8;} + .s9{grid-column:span 9;} .s12{grid-column:span 12;} + + /* ---- horizontal capacity meter ---- */ + .meter{margin-top:auto;} + .meter .track{ + position:relative; height:14px; background:#07090a; + border:1px solid var(--line); border-radius:7px; overflow:hidden; + } + .meter .fill{height:100%; border-radius:6px 0 0 6px;} + .fill.ok{background:linear-gradient(90deg,#5c7626,#74932f);} + .fill.warn{background:linear-gradient(90deg,#a98a2c,#dab53d);} + .fill.fail{background:linear-gradient(90deg,#a8543b,#cb6b4d);} + .meter .tick{position:absolute; top:-2px; bottom:-2px; width:2px; background:var(--gold);} + .meter .zone{position:absolute; top:0; bottom:0; opacity:.16;} + .zone.amber{background:var(--amber);} .zone.terra{background:var(--terra);} + .meter .scale{display:flex; justify-content:space-between; color:var(--dim); font-size:9.5px; margin-top:4px;} + + /* ---- lamps ---- */ + .lamp{display:inline-block; width:1em; text-align:center;} + .l-ok{color:var(--olive);} .l-warn{color:var(--amber);} + .l-fail{color:var(--terra);} .l-off{color:var(--dim);} .l-prog{color:var(--amber);} + + /* ---- lamp board ---- */ + .lampboard{ + grid-column:1/-1; background:var(--inset); border:1px solid var(--line); + border-radius:10px; padding:12px 14px; + display:grid; grid-template-columns:repeat(4,1fr); gap:7px 18px; + } + .lampboard .row{display:flex; align-items:center; gap:8px; font-size:12px;} + .lampboard .row .nm{color:var(--silver);} + .lampboard .row .vv{color:var(--dim); margin-left:auto; font-size:11px;} + + /* ---- counter tile ---- */ + .tile .row{display:flex; align-items:center; gap:8px;} + .tile .big{font-size:26px;} + + /* ---- updates quarantine ---- */ + .updates{ + grid-column:1/-1; border:1px solid var(--amber); + background:linear-gradient(180deg,#141210,#0a0c0d); + border-radius:10px; padding:13px 16px; + display:flex; align-items:center; gap:26px; flex-wrap:wrap; + } + .updates .q{ + color:var(--amber); font-size:10.5px; letter-spacing:1.5px; + text-transform:uppercase; border:1px solid var(--amber); + padding:3px 8px; border-radius:6px; align-self:flex-start; + } + .updates .metric{display:flex; flex-direction:column; gap:2px;} + .updates .metric .n{font-size:30px; color:var(--cream); font-weight:700; line-height:1;} + .updates .metric .n.cve{color:var(--terra);} + .updates .metric .k{color:var(--dim); font-size:10.5px; text-transform:uppercase; letter-spacing:.6px;} + .updates .spacer{flex:1;} + + /* ---- svg arc/dial labels ---- */ + .gaugewrap{display:flex; gap:14px; margin-top:2px;} + .gauge{display:flex; flex-direction:column; align-items:center; gap:3px;} + .gauge .rd{color:var(--cream); font-size:13px; font-weight:700;} + .gauge .lb{color:var(--dim); font-size:10px;} + .dialwrap{display:flex; justify-content:center; margin-top:2px;} + + /* ---- output wall ---- */ + .wall{ + margin-top:16px; background:var(--inset); + border:1px solid var(--line); border-radius:10px; padding:13px 15px; + } + .wall .wh{ + display:flex; align-items:center; justify-content:space-between; + color:var(--cream); font-size:13px; letter-spacing:.5px; margin-bottom:10px; + } + .wall .wh .l{color:var(--gold);} + .wall .stream{font-size:12.5px; line-height:1.85;} + .wall .stream .ln{display:flex; gap:9px; align-items:baseline;} + .wall .stream .msg{color:var(--silver);} + .wall .stream .meta{color:var(--dim);} + .wall .stream .queued .msg{color:var(--dim);} + .wall .wfoot{margin-top:11px; display:flex; justify-content:flex-end;} + + @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}} + .spin{animation:pulse 1.1s ease-in-out infinite;} +</style> + +<div class="console"> + + <!-- HEADER --> + <div class="head"> + <div> + <div class="title">MAINT · <b>ratio</b></div> + <div class="sub">Approach E — Instrument dashboard</div> + </div> + <div class="verdict"><span class="lamp l-ok">●</span> healthy</div> + </div> + + <!-- ACTION BAR --> + <div class="actionbar"> + <span class="lbl">Doctor</span> + <button class="btn">Clean up</button> + <button class="btn">Review & fix</button> + <span class="spacer"></span> + <span class="legend"> + <b>Auto</b> unattended · <b>Confirm</b> preview+click · <b>Human</b> you act · <b>Workflow</b> escalate · <b>None</b> diagnostic + </span> + </div> + + <!-- CLUSTER --> + <div class="cluster"> + + <!-- STORAGE & FILESYSTEM --> + <div class="band">Storage & Filesystem</div> + + <div class="card s4"> + <div class="cap">Disk usage</div> + <div class="big">69<span class="unit">%</span></div> + <div class="note">root · btrfs</div> + <div class="meter"> + <div class="track"> + <div class="zone amber" style="left:80%;right:10%"></div> + <div class="zone terra" style="left:90%;right:0"></div> + <div class="fill ok" style="width:69%"></div> + </div> + <div class="scale"><span>0</span><span>80</span><span>90</span><span>100%</span></div> + </div> + </div> + + <div class="card s4"> + <button class="btn lever">Clean</button> + <div class="cap">Package cache <span class="lamp l-warn">●</span></div> + <div class="big">8.8<span class="unit"> GB</span></div> + <div class="note">Auto · threshold ~10 GB</div> + <div class="meter"> + <div class="track"> + <div class="tick" style="left:88%"></div> + <div class="fill warn" style="width:88%"></div> + </div> + <div class="scale"><span>0</span><span>threshold 10 GB</span></div> + </div> + </div> + + <div class="card s4"> + <button class="btn lever">Deep trim</button> + <div class="cap">Deep-trim keep-3 <span class="lamp l-ok">●</span></div> + <div class="big">keep 3</div> + <div class="note">Confirm · retain 3 versions</div> + <div class="meter"> + <div class="track"><div class="fill ok" style="width:100%"></div></div> + <div class="scale"><span>staged & ready</span></div> + </div> + </div> + + <div class="card s3"> + <button class="btn lever">Scrub</button> + <div class="cap">btrfs scrub</div> + <div class="dialwrap"> + <svg width="98" height="98" viewBox="0 0 98 98"> + <circle cx="49" cy="49" r="40" fill="none" stroke="#1a1d1f" stroke-width="8"/> + <!-- 34d of ~30d cadence => over => amber, ~0.62 of ring --> + <circle cx="49" cy="49" r="40" fill="none" stroke="#dab53d" stroke-width="8" + stroke-linecap="round" stroke-dasharray="251.3" stroke-dashoffset="95" + transform="rotate(-90 49 49)"/> + <text x="49" y="46" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="20" font-weight="700">34</text> + <text x="49" y="63" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="10">days</text> + </svg> + </div> + <div class="note" style="text-align:center"><span class="lamp l-warn">●</span> past 30d cadence</div> + </div> + + <div class="card s3"> + <div class="cap">btrfs unalloc <span class="lamp l-ok">●</span></div> + <div class="big">118<span class="unit"> GiB</span></div> + <div class="note">unallocated headroom</div> + <div class="meter"> + <div class="track"><div class="fill ok" style="width:46%"></div></div> + <div class="scale"><span>ample slack</span></div> + </div> + </div> + + <div class="card s3"> + <div class="cap">btrfs device-err <span class="lamp l-ok">●</span></div> + <div class="big">0 / 0</div> + <div class="note">read / write errors</div> + </div> + + <div class="card s3"> + <button class="btn lever">Enable</button> + <div class="cap">fstrim.timer <span class="lamp l-fail">●</span></div> + <div class="big" style="color:var(--terra)">OFF</div> + <div class="note">Confirm · SSD discard disabled</div> + </div> + + <!-- THERMAL & POWER --> + <div class="band">Thermal & Power</div> + + <div class="card s5"> + <div class="cap">Temperatures <span class="lamp l-ok">●</span></div> + <div class="gaugewrap"> + <div class="gauge"> + <svg width="112" height="64" viewBox="0 0 112 64"> + <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/> + <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/> + <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/> + <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/> + <!-- needle @ 61C (~61% of arc) --> + <line x1="56" y1="60" x2="86" y2="30" stroke="#f3e7c5" stroke-width="2.5"/> + <circle cx="56" cy="60" r="4" fill="#dab53d"/> + </svg> + <div class="rd">61°C</div><div class="lb">CPU package</div> + </div> + <div class="gauge"> + <svg width="112" height="64" viewBox="0 0 112 64"> + <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/> + <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/> + <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/> + <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/> + <!-- needle @ 54C (~54%) --> + <line x1="56" y1="60" x2="79" y2="34" stroke="#f3e7c5" stroke-width="2.5"/> + <circle cx="56" cy="60" r="4" fill="#dab53d"/> + </svg> + <div class="rd">54°C</div><div class="lb">NVMe</div> + </div> + </div> + </div> + + <div class="card s3"> + <div class="cap">Thermal throttle <span class="lamp l-ok">●</span></div> + <div class="big">NO</div> + <div class="note">no throttle events</div> + </div> + + <div class="card s2 s3" style="grid-column:span 2;"> + <div class="cap">Battery <span class="lamp l-off">○</span></div> + <div class="big" style="color:var(--dim)">n/a</div> + <div class="note">desktop</div> + </div> + + <div class="card s2 s3" style="grid-column:span 2;"> + <div class="cap">Swap / zram <span class="lamp l-ok">●</span></div> + <div class="big">16<span class="unit"> GiB</span></div> + <div class="note">zram active</div> + </div> + + <!-- MEMORY --> + <div class="card s6"> + <div class="cap">Memory free <span class="lamp l-ok">●</span></div> + <div class="big">104<span class="unit"> GB free</span></div> + <div class="note">of 125 GB total · Workflow</div> + <div class="meter"> + <div class="track"><div class="fill ok" style="width:83%"></div></div> + <div class="scale"><span>0</span><span>free 104 / 125 GB</span></div> + </div> + </div> + + <div class="card s6"> + <div class="cap">Unclean-shutdown ratio <span class="lamp l-warn">●</span></div> + <div class="big" style="color:var(--amber)">75<span class="unit">%</span></div> + <div class="note">clean boots · Workflow — investigate</div> + <div class="meter"> + <div class="track"> + <div class="zone amber" style="left:0;right:0"></div> + <div class="fill warn" style="width:75%"></div> + </div> + <div class="scale"><span>0</span><span>clean-boot fraction</span><span>100%</span></div> + </div> + </div> + + <!-- UPDATES QUARANTINE --> + <div class="band">Updates — quarantined</div> + <div class="updates"> + <div class="q">Updates</div> + <div class="metric"><span class="n">47</span><span class="k">pending</span></div> + <div class="metric"><span class="n cve">3</span><span class="k">CVE · arch-audit</span></div> + <div class="metric"><span class="n">5</span><span class="k">AUR stale</span></div> + <div class="spacer"></div> + <div class="note" style="max-width:230px; margin:0;"> + <span class="lamp l-fail">●</span> Security advisories present. + Applying updates escalates to the update workflow — not an unattended clean. + </div> + <button class="btn">Open updates</button> + </div> + + <!-- PACKAGES & JOURNAL COUNTERS --> + <div class="band">Packages · Logs · Counters</div> + + <div class="card s3 tile"> + <button class="btn lever">Review</button> + <div class="cap">Orphans</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">13</span></div> + <div class="note">Confirm · unowned pkgs</div> + </div> + + <div class="card s3 tile"> + <button class="btn lever">Review</button> + <div class="cap">.pacnew files</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">2</span></div> + <div class="note">Confirm · config merges</div> + </div> + + <div class="card s3 tile"> + <button class="btn lever">Clear</button> + <div class="cap">Coredumps 7d</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">18</span></div> + <div class="note">Auto · clearable</div> + </div> + + <div class="card s3 tile"> + <button class="btn lever">Run workflow</button> + <div class="cap">Failed units</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">1</span></div> + <div class="note">Workflow · systemd</div> + </div> + + <div class="card s3"> + <button class="btn lever">Prune</button> + <div class="cap">Snapper snapshots <span class="lamp l-ok">●</span></div> + <div class="big">42</div> + <div class="note">Confirm · retention ok</div> + <div class="meter"> + <div class="track"><div class="fill ok" style="width:52%"></div></div> + <div class="scale"><span>within budget</span></div> + </div> + </div> + + <div class="card s3"> + <button class="btn lever">Refresh</button> + <div class="cap">Keyring age</div> + <div class="dialwrap"> + <svg width="90" height="90" viewBox="0 0 90 90"> + <circle cx="45" cy="45" r="36" fill="none" stroke="#1a1d1f" stroke-width="7"/> + <circle cx="45" cy="45" r="36" fill="none" stroke="#74932f" stroke-width="7" + stroke-linecap="round" stroke-dasharray="226.2" stroke-dashoffset="176" + transform="rotate(-90 45 45)"/> + <text x="45" y="43" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="18" font-weight="700">12</text> + <text x="45" y="59" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="9">days</text> + </svg> + </div> + <div class="note" style="text-align:center"><span class="lamp l-ok">●</span> Confirm · fresh</div> + </div> + + <div class="card s3 tile"> + <div class="cap">Journal errors</div> + <div class="row"><span class="lamp l-ok">●</span><span class="big">12</span></div> + <div class="note">Workflow · real errors</div> + </div> + + <div class="card s3 tile"> + <div class="cap">Listeners</div> + <div class="row"><span class="lamp l-ok">●</span><span class="big">3</span></div> + <div class="note">None · open sockets</div> + </div> + + <!-- LAMP BOARD --> + <div class="band">Status board — binary & diagnostic</div> + <div class="lampboard"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">SMART</span><span class="vv">PASSED</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">SMART wear</span><span class="vv">0%</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">pacman integrity</span><span class="vv">clean</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">snapshot timer</span><span class="vv">active</span></div> + + <div class="row"><span class="lamp l-ok">●</span><span class="nm">is-system-running</span><span class="vv">running</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">reboot-required</span><span class="vv">no</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">kernel taint</span><span class="vv">0</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">kernel/hw events</span><span class="vv">clean</span></div> + + <div class="row"><span class="lamp l-ok">●</span><span class="nm">firewall</span><span class="vv">active</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">fail2ban</span><span class="vv">active</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">tailscale</span><span class="vv">4/4</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">NTP</span><span class="vv">synced</span></div> + + <div class="row"><span class="lamp l-ok">●</span><span class="nm">DNS / NM</span><span class="vv">ok</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">rsyncshot</span><span class="vv">3h ago</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">cron</span><span class="vv">ok</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">app-log cleanup</span><span class="vv">ok</span></div> + + <div class="row"><span class="lamp l-ok">●</span><span class="nm">journald size</span><span class="vv">1.2 GB</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">docker stopped</span><span class="vv">2</span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="nm">maintenance timers</span><span class="vv">4/5</span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="nm">docker reclaim</span><span class="vv">3 GB · Prune</span></div> + + <div class="row"><span class="lamp l-off">○</span><span class="nm">libvirt</span><span class="vv">off</span></div> + </div> + + </div><!-- /cluster --> + + <!-- RUNNING / OUTPUT WALL --> + <div class="wall"> + <div class="wh"> + <span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp l-prog spin">◐</span></span> + <span class="meta" style="font-size:11px">4 Auto actions</span> + </div> + <div class="stream"> + <div class="ln"><span class="lamp l-prog spin">◐</span><span class="msg">cache trim</span><span class="meta">running…</span></div> + <div class="ln"><span class="lamp l-ok">●</span><span class="msg">journal vacuum</span><span class="meta">done · reclaimed 0.9 GB</span></div> + <div class="ln"><span class="lamp l-ok">●</span><span class="msg">coredump clear</span><span class="meta">done · 12 cleared</span></div> + <div class="ln queued"><span class="lamp l-off">○</span><span class="msg">app-log cleanup</span><span class="meta">queued</span></div> + </div> + <div class="wfoot"><button class="btn">Done</button></div> + </div> + +</div> diff --git a/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html b/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html new file mode 100644 index 0000000..b818006 --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html @@ -0,0 +1,443 @@ +<meta charset="utf-8"> +<title>MAINT · ratio — Approach E2 · Grouped Instrument Dashboard</title> +<style> + :root{ + --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d; + --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5; + --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32; + --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d; + --font:"BerkeleyMono Nerd Font", ui-monospace, monospace; + } + *{box-sizing:border-box} + html,body{margin:0;background:var(--page);} + body{ + font-family:var(--font); color:var(--silver); + display:flex; justify-content:center; padding:26px 14px 60px; + -webkit-font-smoothing:antialiased; + } + .console{ width:1050px; max-width:100%; background:var(--surface); + border:1px solid var(--gold); border-radius:16px; padding:18px; } + + /* header */ + .head{display:flex; align-items:baseline; justify-content:space-between; gap:16px;} + .head .title{font-size:19px; color:var(--cream); letter-spacing:.5px;} + .head .title b{color:var(--gold); font-weight:700;} + .verdict{font-size:15px; color:var(--olive); font-weight:700; white-space:nowrap;} + .sub{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.4px;} + + /* action bar */ + .actionbar{display:flex; align-items:center; gap:10px; margin:14px 0 14px; + padding:10px 12px; background:var(--inset); border:1px solid var(--line); border-radius:10px;} + .actionbar .lbl{color:var(--dim); font-size:11.5px; letter-spacing:.5px; text-transform:uppercase; margin-right:2px;} + .btn{font-family:var(--font); cursor:pointer; background:var(--slate); color:var(--cream); + border:none; border-radius:10px; padding:7px 14px; font-size:13px;} + .btn:hover{background:var(--slate-hi);} + .btn.lever{padding:3px 9px; font-size:11px; border-radius:8px;} + .btn.mini{padding:2px 8px; font-size:10.5px; border-radius:7px;} + .actionbar .spacer{flex:1;} + .legend{color:var(--dim); font-size:11px; line-height:1.5;} + .legend b{color:var(--silver); font-weight:400;} + + /* updates quarantine */ + .updates{border:1px solid var(--amber); + background:linear-gradient(180deg,#141210,#0a0c0d); + border-radius:10px; padding:12px 16px; margin-bottom:14px; + display:flex; align-items:center; gap:26px; flex-wrap:wrap;} + .updates .q{color:var(--amber); font-size:10.5px; letter-spacing:1.5px; text-transform:uppercase; + border:1px solid var(--amber); padding:3px 8px; border-radius:6px; align-self:flex-start;} + .updates .metric{display:flex; flex-direction:column; gap:2px;} + .updates .metric .n{font-size:28px; color:var(--cream); font-weight:700; line-height:1;} + .updates .metric .n.cve{color:var(--terra);} + .updates .metric .k{color:var(--dim); font-size:10.5px; text-transform:uppercase; letter-spacing:.6px;} + .updates .spacer{flex:1;} + + /* ---- category panel ---- */ + .panel{border:1px solid var(--line); border-left:3px solid var(--line); + border-radius:11px; margin-bottom:10px; overflow:hidden; background:#0d0c0c;} + .panel.attn{border-left-color:var(--amber);} + .panel.crit{border-left-color:var(--terra);} + .panel.clear{border-left-color:var(--olive); opacity:.85;} + .phead{display:flex; align-items:center; gap:11px; padding:9px 14px; cursor:pointer;} + .phead:hover{background:#141313;} + .phead .nm{color:var(--gold); font-weight:700; letter-spacing:.9px; text-transform:uppercase; font-size:12.5px;} + .phead .cnt{color:var(--dim); font-size:11px; margin-left:auto;} + .phead .chev{color:var(--dim); font-size:12px; width:1em; text-align:center;} + .phead .attnword{color:var(--amber); font-size:11px;} + .phead .attnword.crit{color:var(--terra);} + .phead .okword{color:var(--olive); font-size:11px;} + .pbody{padding:2px 14px 13px;} + + .heroes{display:grid; grid-template-columns:repeat(12,1fr); gap:10px; margin-bottom:11px;} + .card{background:var(--inset); border:1px solid var(--line); border-radius:10px; + padding:10px 12px 11px; position:relative; min-height:88px; display:flex; flex-direction:column;} + .card .lever{position:absolute; top:8px; right:8px;} + .card .cap{color:var(--dim); font-size:10.5px; letter-spacing:.6px; text-transform:uppercase; + margin-bottom:7px; padding-right:52px;} + .card .big{color:var(--cream); font-size:22px; line-height:1; font-weight:700;} + .card .unit{color:var(--dim); font-size:12px; font-weight:400;} + .card .note{color:var(--dim); font-size:11px; margin-top:6px;} + .s2{grid-column:span 2;} .s3{grid-column:span 3;} .s4{grid-column:span 4;} + .s5{grid-column:span 5;} .s6{grid-column:span 6;} + + /* meter */ + .meter{margin-top:auto;} + .meter .track{position:relative; height:13px; background:#07090a; border:1px solid var(--line); + border-radius:7px; overflow:hidden;} + .meter .fill{height:100%; border-radius:6px 0 0 6px;} + .fill.ok{background:linear-gradient(90deg,#5c7626,#74932f);} + .fill.warn{background:linear-gradient(90deg,#a98a2c,#dab53d);} + .fill.fail{background:linear-gradient(90deg,#a8543b,#cb6b4d);} + .meter .tick{position:absolute; top:-2px; bottom:-2px; width:2px; background:var(--gold);} + .meter .zone{position:absolute; top:0; bottom:0; opacity:.16;} + .zone.amber{background:var(--amber);} .zone.terra{background:var(--terra);} + .meter .scale{display:flex; justify-content:space-between; color:var(--dim); font-size:9.5px; margin-top:4px;} + + /* lamps */ + .lamp{display:inline-block; width:1em; text-align:center;} + .l-ok{color:var(--olive);} .l-warn{color:var(--amber);} + .l-fail{color:var(--terra);} .l-off{color:var(--dim);} .l-prog{color:var(--amber);} + + .tile .row{display:flex; align-items:center; gap:8px;} + .tile .big{font-size:25px;} + + .gaugewrap{display:flex; gap:14px; margin-top:2px;} + .gauge{display:flex; flex-direction:column; align-items:center; gap:3px;} + .gauge .rd{color:var(--cream); font-size:13px; font-weight:700;} + .gauge .lb{color:var(--dim); font-size:10px;} + .dialwrap{display:flex; justify-content:center; margin-top:2px;} + + /* compact strip (the boring/green/binary metrics) */ + .strip{display:grid; grid-template-columns:repeat(3,1fr); gap:5px 22px; + padding-top:9px; border-top:1px solid var(--line);} + .strip .row{display:flex; align-items:center; gap:8px; font-size:12px; min-height:22px;} + .strip .row .nm2{color:var(--silver);} + .strip .row .vv{color:var(--dim); margin-left:auto; font-size:11px;} + .strip .row .lever{margin-left:auto;} + .strip .row .lever + .vv{margin-left:8px;} + + /* output wall */ + .wall{margin-top:16px; background:var(--inset); border:1px solid var(--line); + border-radius:10px; padding:13px 15px;} + .wall .wh{display:flex; align-items:center; justify-content:space-between; + color:var(--cream); font-size:13px; letter-spacing:.5px; margin-bottom:10px;} + .wall .wh .l{color:var(--gold);} + .wall .stream{font-size:12.5px; line-height:1.85;} + .wall .stream .ln{display:flex; gap:9px; align-items:baseline;} + .wall .stream .msg{color:var(--silver);} + .wall .stream .meta{color:var(--dim);} + .wall .stream .queued .msg{color:var(--dim);} + .wall .wfoot{margin-top:11px; display:flex; justify-content:flex-end;} + + .footnote{color:var(--dim); font-size:11px; margin-top:14px; line-height:1.6;} + @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}} + .spin{animation:pulse 1.1s ease-in-out infinite;} +</style> + +<div class="console"> + + <!-- HEADER --> + <div class="head"> + <div> + <div class="title">MAINT · <b>ratio</b></div> + <div class="sub">Approach E2 — Grouped instrument dashboard (categories collapse when clean)</div> + </div> + <div class="verdict"><span class="lamp l-ok">●</span> healthy</div> + </div> + + <!-- ACTION BAR --> + <div class="actionbar"> + <span class="lbl">Doctor</span> + <button class="btn">Clean up</button> + <button class="btn">Review & fix</button> + <span class="spacer"></span> + <span class="legend"> + <b>Auto</b> unattended · <b>Confirm</b> preview+click · <b>Human</b> you act · <b>Workflow</b> escalate · <b>None</b> diagnostic + </span> + </div> + + <!-- UPDATES (pulled up: the 3 CVEs are the one real fail) --> + <div class="updates"> + <div class="q">Updates</div> + <div class="metric"><span class="n">47</span><span class="k">pending</span></div> + <div class="metric"><span class="n cve">3</span><span class="k">CVE · arch-audit</span></div> + <div class="metric"><span class="n">5</span><span class="k">AUR stale</span></div> + <div class="spacer"></div> + <div class="note" style="max-width:250px; margin:0; color:var(--dim); font-size:11px;"> + <span class="lamp l-fail">●</span> Security advisories present. Updates run through the workflow — never an unattended clean. + </div> + <button class="btn">Run workflow</button> + </div> + + <!-- ============ CATEGORY PANELS (severity-ordered) ============ --> + + <!-- STORAGE (has a fail: fstrim) --> + <div class="panel crit"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-fail">●</span> + <span class="nm">Storage & Filesystem</span> + <span class="cnt"><span class="attnword crit">3 need attention</span> · 6 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3"> + <div class="cap">Disk usage</div> + <div class="big">69<span class="unit">%</span></div> + <div class="note">root · btrfs</div> + <div class="meter"><div class="track"> + <div class="zone amber" style="left:80%;right:10%"></div> + <div class="zone terra" style="left:90%;right:0"></div> + <div class="fill ok" style="width:69%"></div></div> + <div class="scale"><span>0</span><span>80</span><span>90</span><span>100</span></div> + </div> + </div> + <div class="card s3"> + <button class="btn lever">Clean</button> + <div class="cap">Cache <span class="lamp l-warn">●</span></div> + <div class="big">8.8<span class="unit"> GB</span></div> + <div class="note">Auto · thr ~10 GB</div> + <div class="meter"><div class="track"><div class="tick" style="left:88%"></div> + <div class="fill warn" style="width:88%"></div></div> + <div class="scale"><span>0</span><span>10 GB</span></div> + </div> + </div> + <div class="card s3"> + <button class="btn lever">Scrub</button> + <div class="cap">btrfs scrub</div> + <div class="dialwrap"> + <svg width="86" height="86" viewBox="0 0 98 98"> + <circle cx="49" cy="49" r="40" fill="none" stroke="#1a1d1f" stroke-width="8"/> + <circle cx="49" cy="49" r="40" fill="none" stroke="#dab53d" stroke-width="8" + stroke-linecap="round" stroke-dasharray="251.3" stroke-dashoffset="95" transform="rotate(-90 49 49)"/> + <text x="49" y="46" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="20" font-weight="700">34</text> + <text x="49" y="63" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="10">days</text> + </svg> + </div> + <div class="note" style="text-align:center"><span class="lamp l-warn">●</span> past 30d</div> + </div> + <div class="card s3"> + <button class="btn lever">Enable</button> + <div class="cap">fstrim.timer <span class="lamp l-fail">●</span></div> + <div class="big" style="color:var(--terra)">OFF</div> + <div class="note">Confirm · SSD discard off</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">SMART</span><span class="vv">PASSED</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">SMART wear</span><span class="vv">0%</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">device errors</span><span class="vv">0 / 0</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">btrfs unalloc</span><span class="vv">118 GiB</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">pacman integrity</span><span class="vv">clean</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">deep-trim</span><button class="btn mini lever" style="position:static">Deep trim</button><span class="vv">keep 3</span></div> + </div> + </div> + </div> + + <!-- PACKAGES & SECURITY --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">Packages & Security</span> + <span class="cnt"><span class="attnword">2 need attention</span> · 2 ok · updates above</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3 tile"> + <button class="btn lever">Review</button> + <div class="cap">Orphans</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">13</span></div> + <div class="note">Confirm · unowned pkgs</div> + </div> + <div class="card s3 tile"> + <button class="btn lever">Review</button> + <div class="cap">.pacnew files</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">2</span></div> + <div class="note">Confirm · config merges</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">keyring age</span><button class="btn mini lever" style="position:static">Refresh</button><span class="vv">12d</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">reboot required</span><span class="vv">no</span></div> + </div> + </div> + </div> + + <!-- SYSTEMD & BOOT --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">systemd & Boot</span> + <span class="cnt"><span class="attnword">2 need attention</span> · 2 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3 tile"> + <button class="btn lever">Run workflow</button> + <div class="cap">Failed units</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">1</span></div> + <div class="note">Workflow · systemd</div> + </div> + <div class="card s3 tile"> + <button class="btn lever">Enable</button> + <div class="cap">Maintenance timers</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">4/5</span></div> + <div class="note">Confirm · one not firing</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">is-system-running</span><span class="vv">running</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">kernel taint</span><span class="vv">0</span></div> + </div> + </div> + </div> + + <!-- LOGS & COREDUMPS --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">Logs & Coredumps</span> + <span class="cnt"><span class="attnword">1 needs attention</span> · 4 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3 tile"> + <button class="btn lever">Clear</button> + <div class="cap">Coredumps 7d</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">18</span></div> + <div class="note">Auto · clearable</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">journald size</span><button class="btn mini lever" style="position:static">Vacuum</button><span class="vv">1.2 GB</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">app-log cleanup</span><button class="btn mini lever" style="position:static">Run</button><span class="vv">ok</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">journal errors</span><span class="vv">12 real</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">kernel/hw events</span><span class="vv">clean</span></div> + </div> + </div> + </div> + + <!-- MEMORY / THERMAL / POWER --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">Memory · Thermal · Power</span> + <span class="cnt"><span class="attnword">1 needs attention</span> · 4 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s5"> + <div class="cap">Temperatures <span class="lamp l-ok">●</span></div> + <div class="gaugewrap"> + <div class="gauge"> + <svg width="104" height="60" viewBox="0 0 112 64"> + <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/> + <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/> + <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/> + <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/> + <line x1="56" y1="60" x2="86" y2="30" stroke="#f3e7c5" stroke-width="2.5"/> + <circle cx="56" cy="60" r="4" fill="#dab53d"/> + </svg> + <div class="rd">61°C</div><div class="lb">CPU</div> + </div> + <div class="gauge"> + <svg width="104" height="60" viewBox="0 0 112 64"> + <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/> + <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/> + <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/> + <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/> + <line x1="56" y1="60" x2="79" y2="34" stroke="#f3e7c5" stroke-width="2.5"/> + <circle cx="56" cy="60" r="4" fill="#dab53d"/> + </svg> + <div class="rd">54°C</div><div class="lb">NVMe</div> + </div> + </div> + </div> + <div class="card s4"> + <div class="cap">Memory free <span class="lamp l-ok">●</span></div> + <div class="big">104<span class="unit"> GB</span></div> + <div class="note">of 125 GB · Workflow</div> + <div class="meter"><div class="track"><div class="fill ok" style="width:83%"></div></div> + <div class="scale"><span>0</span><span>free 104 / 125</span></div></div> + </div> + <div class="card s3"> + <div class="cap">Unclean boot <span class="lamp l-warn">●</span></div> + <div class="big" style="color:var(--amber)">75<span class="unit">%</span></div> + <div class="note">Workflow · investigate</div> + <div class="meter"><div class="track"><div class="zone amber" style="left:0;right:0"></div> + <div class="fill warn" style="width:75%"></div></div> + <div class="scale"><span>0</span><span>100</span></div></div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">swap / zram</span><span class="vv">16 GiB</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">thermal throttle</span><span class="vv">no</span></div> + <div class="row"><span class="lamp l-off">○</span><span class="nm2">battery</span><span class="vv">n/a · desktop</span></div> + </div> + </div> + </div> + + <!-- SERVICES & BACKUPS --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">Services & Backups</span> + <span class="cnt"><span class="attnword">1 needs attention</span> · 3 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3 tile"> + <button class="btn lever">Prune</button> + <div class="cap">Docker reclaim</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">3<span class="unit"> GB</span></span></div> + <div class="note">Confirm · reclaimable</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">rsyncshot backup</span><span class="vv">3h ago</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">docker stopped</span><span class="vv">2</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">cron</span><span class="vv">ok</span></div> + <div class="row"><span class="lamp l-off">○</span><span class="nm2">libvirt VMs</span><span class="vv">off</span></div> + </div> + </div> + </div> + + <!-- ===== COLLAPSED (all-green) categories — the space win ===== --> + + <div class="panel clear"> + <div class="phead"> + <span class="chev">▸</span><span class="lamp l-ok">●</span> + <span class="nm">Snapshots</span> + <span class="cnt"><span class="okword">all clear · 2 ✓</span></span> + </div> + </div> + + <div class="panel clear"> + <div class="phead"> + <span class="chev">▸</span><span class="lamp l-ok">●</span> + <span class="nm">Network & Posture</span> + <span class="cnt"><span class="okword">all clear · 6 ✓</span></span> + </div> + </div> + + <div class="footnote"> + Collapsed panels are all-green — one header line each (chevron ▸ to expand). On a fully-healthy day every + panel collapses to this and the console is ~4 header rows plus the updates strip. The board's height tracks + how much needs you, not how many metrics exist. Verdict lamp (top-right) reads the worst <i>diagnostic</i> + state only — the fstrim fail and the 3 CVEs are actionable/updates, so the box still reads "healthy." + </div> + + <!-- RUNNING / OUTPUT WALL --> + <div class="wall"> + <div class="wh"> + <span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp l-prog spin">◐</span></span> + <span class="meta" style="font-size:11px">4 Auto actions</span> + </div> + <div class="stream"> + <div class="ln"><span class="lamp l-prog spin">◐</span><span class="msg">cache trim</span><span class="meta">running…</span></div> + <div class="ln"><span class="lamp l-ok">●</span><span class="msg">journal vacuum</span><span class="meta">done · reclaimed 0.9 GB</span></div> + <div class="ln"><span class="lamp l-ok">●</span><span class="msg">coredump clear</span><span class="meta">done · 12 cleared</span></div> + <div class="ln queued"><span class="lamp l-off">○</span><span class="msg">app-log cleanup</span><span class="meta">queued</span></div> + </div> + <div class="wfoot"><button class="btn">Done</button></div> + </div> + +</div> diff --git a/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html b/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html new file mode 100644 index 0000000..fbfb03d --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html @@ -0,0 +1,488 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — Approach E3 · Hi-fi drill-down console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);line-height:1.45; + padding:2rem 1.4rem 5rem; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground); + display:flex;justify-content:center} +.console{width:1080px;max-width:100%} + +/* ---- shared primitives (from the widget gallery) ---- */ +.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass); + box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.sm{font-size:10.5px;padding:4px 9px;border-radius:7px} + +.badge{font-size:.62rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} + +.bar{width:100%;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} +.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)} + +.ring{width:58px;height:58px;border-radius:50%; + background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative;flex:none} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1} +.ring small{position:relative;color:var(--dim);font-size:8.5px} + +.readout{color:var(--cream);font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em} +.readout small{color:var(--dim);font-size:11px;font-weight:400} + +/* engraved section label */ +.engrave{color:var(--steel);font-size:.66rem;letter-spacing:.28em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;flex:1} +.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.7rem} + +/* lamp row (list item) */ +.lrow{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;font-size:12.5px} +.lrow .who{color:var(--silver)} .lrow .who b{color:var(--cream)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:11px;display:flex;align-items:center;gap:8px} + +/* faceplate card */ +.plate{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)} + +/* ---- masthead ---- */ +.mast{padding:15px 18px;margin-bottom:14px;display:flex;align-items:center;gap:18px;flex-wrap:wrap} +.mast .brand{display:flex;flex-direction:column;gap:2px} +.mast .brand .ey{color:var(--steel);font-size:.64rem;letter-spacing:.28em;text-transform:uppercase} +.mast .brand .ti{color:var(--cream);font-size:19px;letter-spacing:.5px} +.mast .brand .ti b{color:var(--gold)} +.mast .verdict{display:flex;align-items:center;gap:8px;color:var(--pass);font-size:14px;font-weight:700} +.mast .spacer{flex:1} +.mast .doctor{display:flex;align-items:center;gap:8px} +.mast .doctor .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;margin-right:2px} + +/* updates readout inline in masthead */ +.updbar{display:flex;align-items:center;gap:16px;padding:9px 18px;margin-bottom:16px; + border:1px solid var(--fail);border-radius:11px; + background:linear-gradient(180deg,#181210,#0a0c0d)} +.updbar .q{color:var(--fail);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase; + border:1px solid var(--fail);border-radius:5px;padding:2px 7px} +.updbar .rd{display:flex;flex-direction:column;line-height:1.05} +.updbar .rd .n{color:var(--cream);font-size:20px;font-weight:700;font-variant-numeric:tabular-nums} +.updbar .rd .n.cve{color:var(--fail)} +.updbar .rd .k{color:var(--dim);font-size:.6rem;letter-spacing:.12em;text-transform:uppercase} +.updbar .spacer{flex:1} +.updbar .msg{color:var(--dim);font-size:11px;max-width:300px} + +/* ---- OVERVIEW: station grid ---- */ +.ovhead{margin:0 4px 9px;display:flex;align-items:center;gap:10px} +.overview{display:grid;grid-template-columns:repeat(4,1fr);gap:11px;margin-bottom:22px} +.station{padding:12px 13px;cursor:pointer;display:flex;flex-direction:column;gap:9px; + transition:border-color .12s,transform .06s;border-left:3px solid var(--wash)} +.station:hover{border-color:#3a352c;transform:translateY(-1px)} +.station.attn{border-left-color:var(--amber)} +.station.crit{border-left-color:var(--fail)} +.station.clear{border-left-color:var(--pass)} +.station .top{display:flex;align-items:center;gap:9px} +.station .top .nm{color:var(--cream);font-size:12.5px;font-weight:700;letter-spacing:.02em} +.station .top .chev{margin-left:auto;color:var(--dim);font-size:11px} +.station .st{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)} +.station .st .att{color:var(--amber)} .station .st .att.crit{color:var(--fail)} +.station .st .okc{color:var(--pass)} +.station .spark{margin-top:1px} + +/* ---- CATEGORY drill-down sections ---- */ +.cat{margin-bottom:11px;overflow:hidden} +.cathead{display:flex;align-items:center;gap:12px;padding:11px 16px;cursor:pointer;user-select:none} +.cathead:hover{background:#161412} +.cathead .chev{color:var(--gold);font-size:12px;width:1em;text-align:center;transition:transform .15s} +.cat.open .cathead .chev{transform:rotate(90deg)} +.cathead .nm{color:var(--gold);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:12.5px} +.cathead .cnt{margin-left:auto;color:var(--dim);font-size:11px;display:flex;gap:10px;align-items:center} +.cathead .cnt .att{color:var(--amber)} .cathead .cnt .att.crit{color:var(--fail)} +.catbody{display:none;padding:2px 16px 15px} +.cat.open .catbody{display:block} + +.heroes{display:grid;grid-template-columns:repeat(12,1fr);gap:11px;margin-bottom:12px} +.inst{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:11px 12px; + display:flex;flex-direction:column;gap:8px;position:relative;min-height:84px} +.inst .cap{color:var(--steel);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase; + display:flex;align-items:center;gap:7px} +.inst .cap .key{position:absolute;top:9px;right:9px} +.inst .val{display:flex;align-items:baseline;gap:8px;margin-top:auto} +.inst.s3{grid-column:span 3} .inst.s4{grid-column:span 4} .inst.s5{grid-column:span 5} .inst.s6{grid-column:span 6} +.inst .sub{color:var(--dim);font-size:10.5px} +.gaugepair{display:flex;gap:16px;align-items:center;justify-content:center;margin-top:auto} +.gaugepair .g{display:flex;flex-direction:column;align-items:center;gap:3px} +.gaugepair .g .lb{color:var(--steel);font-size:9px;letter-spacing:.1em} + +.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 12px} + +/* ---- output well ---- */ +.owell{margin-top:18px;padding:13px 15px} +.owell .oh{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px; + color:var(--cream);font-size:13px} +.owell .oh .l{color:var(--gold)} +.owell .ostep{display:flex;gap:9px;align-items:flex-start;padding:3px 0;font-size:12.5px} +.owell .ostep .lamp{margin-top:3px;width:8px;height:8px} +.owell .ostep b{color:var(--cream);font-weight:700} +.owell .ostep .ev{color:var(--steel);font-size:11px} +.owell .queued b{color:var(--dim)} +.owell .ofoot{margin-top:11px;display:flex;justify-content:flex-end} + +.hint{color:var(--dim);font-size:11px;margin:0 4px 14px;display:flex;align-items:center;gap:8px} +.controls-row{display:flex;gap:8px;margin:0 2px 12px} +.footnote{color:var(--dim);font-size:11px;margin-top:16px;line-height:1.6;padding:0 4px} +.footnote i{color:var(--steel);font-style:normal} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="console"> + + <!-- ===== MASTHEAD ===== --> + <div class="plate mast"> + <div class="brand"> + <div class="ey">archsetup · maintenance console</div> + <div class="ti">MAINT · <b>ratio</b></div> + </div> + <div class="verdict"><span class="lamp"></span> HEALTHY</div> + <div class="spacer"></div> + <div class="doctor"> + <span class="lbl">Doctor</span> + <button class="key" onclick="runDoctor()">CLEAN UP</button> + <button class="key">REVIEW & FIX</button> + </div> + </div> + + <!-- ===== UPDATES (quarantined, top billing for the 3 CVEs) ===== --> + <div class="updbar"> + <span class="q">Updates</span> + <div class="rd"><span class="n">47</span><span class="k">pending</span></div> + <div class="rd"><span class="n cve">3</span><span class="k">CVE</span></div> + <div class="rd"><span class="n">5</span><span class="k">AUR stale</span></div> + <span class="msg"><span class="lamp red" style="width:8px;height:8px"></span> Security advisories present — updates run through the workflow, never the doctor.</span> + <span class="spacer"></span> + <button class="key red">RUN WORKFLOW</button> + </div> + + <!-- ===== OVERVIEW: all categories at a glance ===== --> + <div class="ovhead"><span class="engrave">Overview<span class="cnt">· 8 systems</span></span></div> + <div class="overview" id="overview"></div> + + <!-- ===== DRILL-DOWN CATEGORY SECTIONS ===== --> + <div class="controls-row"> + <button class="key sm" onclick="allCats(true)">EXPAND ALL</button> + <button class="key sm" onclick="allCats(false)">COLLAPSE ALL</button> + <span class="hint" style="margin:0 0 0 6px">click any category header — or its overview tile — to drill in</span> + </div> + + <!-- STORAGE --> + <div class="cat plate open" id="cat-storage"> + <div class="cathead" onclick="toggleCat('storage')"> + <span class="chev">▸</span><span class="lamp red"></span> + <span class="nm">Storage & Filesystem</span> + <span class="cnt"><span class="att crit">3 attention</span> · 6 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <div class="cap">Disk usage</div> + <div class="val"><span class="readout">69<small>%</small></span></div> + <div class="bar"><span style="width:69%"></span></div> + <div class="sub">root · btrfs · thr 80 / 90</div> + </div> + <div class="inst s3"> + <button class="key sm">CLEAN</button> + <div class="cap"><span class="lamp gold"></span> Package cache</div> + <div class="val"><span class="readout">8.8<small> GB</small></span></div> + <div class="bar warn"><span style="width:88%"></span><span class="tk" style="left:100%"></span></div> + <div class="sub">Auto · threshold ~10 GB</div> + </div> + <div class="inst s3"> + <button class="key sm">SCRUB</button> + <div class="cap"><span class="lamp gold"></span> btrfs scrub</div> + <div style="display:flex;justify-content:center;margin-top:auto"> + <span class="ring" style="--p:62;--rc:var(--amber)"><b>34</b><small>days</small></span> + </div> + <div class="sub" style="text-align:center">past 30d cadence</div> + </div> + <div class="inst s3"> + <button class="key sm red">ENABLE</button> + <div class="cap"><span class="lamp red"></span> fstrim.timer</div> + <div class="val"><span class="readout" style="color:var(--fail)">OFF</span></div> + <div class="sub">Confirm · SSD discard disabled</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">SMART</span><span class="what">PASSED</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">SMART wear</span><span class="what">0%</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">device errors</span><span class="what">0 / 0</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">btrfs unalloc</span><span class="what">118 GiB</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">pacman integrity</span><span class="what">clean</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">deep-trim</span><span class="what"><button class="key sm">DEEP TRIM</button> keep 3</span></div> + </div> + </div> + </div> + + <!-- PACKAGES --> + <div class="cat plate open" id="cat-packages"> + <div class="cathead" onclick="toggleCat('packages')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">Packages & Security</span> + <span class="cnt"><span class="att">2 attention</span> · 2 ok · updates above</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <button class="key sm">REVIEW</button> + <div class="cap"><span class="lamp gold"></span> Orphans</div> + <div class="val"><span class="readout">13</span></div> + <div class="sub">Confirm · unowned pkgs</div> + </div> + <div class="inst s3"> + <button class="key sm">REVIEW</button> + <div class="cap"><span class="lamp gold"></span> .pacnew files</div> + <div class="val"><span class="readout">2</span></div> + <div class="sub">Confirm · config merges</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">keyring age</span><span class="what"><button class="key sm">REFRESH</button> 12d</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">reboot required</span><span class="what">no</span></div> + </div> + </div> + </div> + + <!-- SYSTEMD --> + <div class="cat plate open" id="cat-systemd"> + <div class="cathead" onclick="toggleCat('systemd')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">systemd & Boot</span> + <span class="cnt"><span class="att">2 attention</span> · 2 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <button class="key sm">WORKFLOW</button> + <div class="cap"><span class="lamp gold"></span> Failed units</div> + <div class="val"><span class="readout">1</span></div> + <div class="sub">Workflow · systemd</div> + </div> + <div class="inst s3"> + <button class="key sm">ENABLE</button> + <div class="cap"><span class="lamp gold"></span> Maint timers</div> + <div class="val"><span class="readout">4<small>/5</small></span></div> + <div class="sub">Confirm · one not firing</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">is-system-running</span><span class="what">running</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">kernel taint</span><span class="what">0</span></div> + </div> + </div> + </div> + + <!-- LOGS --> + <div class="cat plate open" id="cat-logs"> + <div class="cathead" onclick="toggleCat('logs')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">Logs & Coredumps</span> + <span class="cnt"><span class="att">1 attention</span> · 4 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <button class="key sm">CLEAR</button> + <div class="cap"><span class="lamp gold"></span> Coredumps 7d</div> + <div class="val"><span class="readout">18</span></div> + <div class="sub">Auto · clearable</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">journald size</span><span class="what"><button class="key sm">VACUUM</button> 1.2 GB</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">app-log cleanup</span><span class="what"><button class="key sm">RUN</button> ok</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">journal errors</span><span class="what">12 real</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">kernel/hw events</span><span class="what">clean</span></div> + </div> + </div> + </div> + + <!-- MEMORY/THERMAL/POWER --> + <div class="cat plate open" id="cat-power"> + <div class="cathead" onclick="toggleCat('power')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">Memory · Thermal · Power</span> + <span class="cnt"><span class="att">1 attention</span> · 4 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s5"> + <div class="cap"><span class="lamp"></span> Temperatures</div> + <div class="gaugepair"> + <div class="g"><span class="ring" style="--p:61;--rc:var(--pass)"><b>61°</b></span><span class="lb">CPU</span></div> + <div class="g"><span class="ring" style="--p:54;--rc:var(--pass)"><b>54°</b></span><span class="lb">NVMe</span></div> + </div> + </div> + <div class="inst s4"> + <div class="cap"><span class="lamp"></span> Memory free</div> + <div class="val"><span class="readout">104<small> GB</small></span></div> + <div class="bar"><span style="width:83%"></span></div> + <div class="sub">of 125 GB · 0 OOM</div> + </div> + <div class="inst s3"> + <div class="cap"><span class="lamp gold"></span> Unclean boot</div> + <div class="val"><span class="readout" style="color:var(--amber)">75<small>%</small></span></div> + <div class="bar warn"><span style="width:75%"></span></div> + <div class="sub">Workflow · investigate</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">swap / zram</span><span class="what">16 GiB</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">thermal throttle</span><span class="what">no</span></div> + <div class="lrow"><span class="lamp off"></span><span class="who">battery</span><span class="what">n/a · desktop</span></div> + </div> + </div> + </div> + + <!-- SERVICES --> + <div class="cat plate open" id="cat-services"> + <div class="cathead" onclick="toggleCat('services')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">Services & Backups</span> + <span class="cnt"><span class="att">1 attention</span> · 3 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <button class="key sm">PRUNE</button> + <div class="cap"><span class="lamp gold"></span> Docker reclaim</div> + <div class="val"><span class="readout">3<small> GB</small></span></div> + <div class="sub">Confirm · reclaimable</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot backup</span><span class="what">3h ago</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">docker stopped</span><span class="what">2</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">cron</span><span class="what">ok</span></div> + <div class="lrow"><span class="lamp off"></span><span class="who">libvirt VMs</span><span class="what">off</span></div> + </div> + </div> + </div> + + <!-- SNAPSHOTS (collapsed by default — all green) --> + <div class="cat plate" id="cat-snapshots"> + <div class="cathead" onclick="toggleCat('snapshots')"> + <span class="chev">▸</span><span class="lamp"></span> + <span class="nm">Snapshots</span> + <span class="cnt"><span class="okc" style="color:var(--pass)">all clear · 2 ✓</span></span> + </div> + <div class="catbody"> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">snapper count</span><span class="what"><button class="key sm">PRUNE</button> 42 · retention ok</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">snapshot timer</span><span class="what">active</span></div> + </div> + </div> + </div> + + <!-- NETWORK (collapsed by default — all green) --> + <div class="cat plate" id="cat-network"> + <div class="cathead" onclick="toggleCat('network')"> + <span class="chev">▸</span><span class="lamp"></span> + <span class="nm">Network & Posture</span> + <span class="cnt"><span class="okc" style="color:var(--pass)">all clear · 6 ✓</span></span> + </div> + <div class="catbody"> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">firewall</span><span class="what">active</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">unexpected listeners</span><span class="what">3</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">tailscale</span><span class="what">4/4</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">fail2ban</span><span class="what">active</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">NTP sync</span><span class="what">synced</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">DNS / NM</span><span class="what">ok</span></div> + </div> + </div> + </div> + + <!-- ===== OUTPUT WELL (doctor running) ===== --> + <div class="plate owell" id="owell" style="display:none"> + <div class="oh"><span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp busy"></span></span> + <span style="color:var(--dim);font-size:11px">4 Auto actions</span></div> + <div class="ostep"><span class="lamp busy"></span><span><b>cache trim</b> <span class="ev">running…</span></span></div> + <div class="ostep"><span class="lamp"></span><span><b>journal vacuum</b> <span class="ev">done · reclaimed 0.9 GB</span></span></div> + <div class="ostep"><span class="lamp"></span><span><b>coredump clear</b> <span class="ev">done · 12 cleared</span></span></div> + <div class="ostep queued"><span class="lamp off"></span><span><b>app-log cleanup</b> <span class="ev">queued</span></span></div> + <div class="ofoot"><button class="key" onclick="document.getElementById('owell').style.display='none'">DONE</button></div> + </div> + + <div class="footnote"> + Two levels: the <i>Overview</i> grid is every system at a glance — click a tile to jump to and open its section. + Each <i>category</i> below is a drill-down you expand or collapse (click the header, or use Expand/Collapse all). + All-green categories (Snapshots, Network) start collapsed — on a fully-healthy day every section collapses and the + console is the masthead + overview. Verdict lamp reads worst <i>diagnostic</i> state only — the fstrim fail and the + 3 CVEs are actionable/updates, so the box still reads HEALTHY. + </div> + +</div> + +<script> +const CATS = [ + {id:'storage', name:'Storage & FS', cls:'crit', att:'3', ok:'6', crit:true}, + {id:'packages', name:'Packages & Sec', cls:'attn', att:'2', ok:'2'}, + {id:'systemd', name:'systemd & Boot', cls:'attn', att:'2', ok:'2'}, + {id:'logs', name:'Logs & Cores', cls:'attn', att:'1', ok:'4'}, + {id:'power', name:'Mem·Therm·Power', cls:'attn', att:'1', ok:'4'}, + {id:'services', name:'Services & Backup', cls:'attn', att:'1', ok:'3'}, + {id:'snapshots',name:'Snapshots', cls:'clear', att:'0', ok:'2'}, + {id:'network', name:'Network & Posture',cls:'clear', att:'0', ok:'6'}, +]; +const lampFor = c => c.cls==='crit'?'red':c.cls==='attn'?'gold':''; +// build overview station tiles +const ov = document.getElementById('overview'); +CATS.forEach(c=>{ + const el=document.createElement('div'); + el.className='plate station '+c.cls; + const att = c.att!=='0' + ? `<span class="att ${c.crit?'crit':''}">${c.att} attention</span> · <span class="okc">${c.ok} ok</span>` + : `<span class="okc">all clear · ${c.ok} ✓</span>`; + el.innerHTML = + `<div class="top"><span class="lamp ${lampFor(c)}"></span><span class="nm">${c.name}</span><span class="chev">▸</span></div> + <div class="st">${att}</div>`; + el.onclick=()=>openCat(c.id); + ov.appendChild(el); +}); +function toggleCat(id){ document.getElementById('cat-'+id).classList.toggle('open'); } +function openCat(id){ + const el=document.getElementById('cat-'+id); + el.classList.add('open'); + el.scrollIntoView({behavior:'smooth',block:'center'}); +} +function allCats(open){ CATS.forEach(c=>{ + document.getElementById('cat-'+c.id).classList.toggle('open',open); }); } +function runDoctor(){ document.getElementById('owell').style.display='block'; + document.getElementById('owell').scrollIntoView({behavior:'smooth',block:'center'}); } +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-E4-interactive.html b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html new file mode 100644 index 0000000..c678a8d --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html @@ -0,0 +1,546 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — Approach E4 · Interactive view-swap console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);line-height:1.45; + padding:2rem 1.4rem 5rem; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground); + display:flex;justify-content:center} +.console{width:1060px;max-width:100%} + +/* ---- primitives (widget-gallery kit) ---- */ +.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass); + box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4); + white-space:nowrap} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.armed{background:rgba(203,107,77,.15);border-color:var(--fail);color:var(--fail)} +.key.sm{font-size:10.5px;padding:4px 9px;border-radius:7px} +.key.done{opacity:.45;pointer-events:none} + +.bar{width:100%;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px; + transition:width .5s ease} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} +.bar.okc>span{background:linear-gradient(90deg,#5c7626,var(--pass))} +.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)} + +.ring{width:58px;height:58px;border-radius:50%; + background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative;flex:none} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1} +.ring small{position:relative;color:var(--dim);font-size:8.5px} + +.readout{color:var(--cream);font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em} +.readout small{color:var(--dim);font-size:11px;font-weight:400} + +.engrave{color:var(--steel);font-size:.66rem;letter-spacing:.28em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;flex:1} +.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.7rem} + +.lrow{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;font-size:12.5px} +.lrow .who{color:var(--silver)} .lrow .who b{color:var(--cream)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:11px;display:flex;align-items:center;gap:8px} + +.plate{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)} + +/* masthead */ +.mast{padding:15px 18px;margin-bottom:14px;display:flex;align-items:center;gap:16px;flex-wrap:wrap} +.mast .brand{display:flex;flex-direction:column;gap:2px} +.mast .brand .ey{color:var(--steel);font-size:.64rem;letter-spacing:.28em;text-transform:uppercase} +.mast .brand .ti{color:var(--cream);font-size:19px;letter-spacing:.5px} +.mast .brand .ti b{color:var(--gold)} +.mast .verdict{display:flex;align-items:center;gap:8px;color:var(--pass);font-size:14px;font-weight:700} +.mast .spacer{flex:1} +.mast .doctor{display:flex;align-items:center;gap:8px} +.mast .doctor .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;margin-right:2px} + +/* updates */ +.updbar{display:flex;align-items:center;gap:16px;padding:9px 18px;margin-bottom:16px; + border:1px solid var(--fail);border-radius:11px; + background:linear-gradient(180deg,#181210,#0a0c0d)} +.updbar .q{color:var(--fail);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase; + border:1px solid var(--fail);border-radius:5px;padding:2px 7px} +.updbar .rd{display:flex;flex-direction:column;line-height:1.05} +.updbar .rd .n{color:var(--cream);font-size:20px;font-weight:700;font-variant-numeric:tabular-nums} +.updbar .rd .n.cve{color:var(--fail)} +.updbar .rd .k{color:var(--dim);font-size:.6rem;letter-spacing:.12em;text-transform:uppercase} +.updbar .spacer{flex:1} +.updbar .msg{color:var(--dim);font-size:11px;max-width:300px} + +/* overview stations */ +.overview{display:grid;grid-template-columns:repeat(4,1fr);gap:11px} +.station{padding:12px 13px;cursor:pointer;display:flex;flex-direction:column;gap:9px; + transition:border-color .12s,transform .06s;border-left:3px solid var(--wash)} +.station:hover{border-color:#3a352c;transform:translateY(-1px)} +.station.attn{border-left-color:var(--amber)} +.station.crit{border-left-color:var(--fail)} +.station.clear{border-left-color:var(--pass)} +.station .top{display:flex;align-items:center;gap:9px} +.station .top .nm{color:var(--cream);font-size:12.5px;font-weight:700} +.station .top .chev{margin-left:auto;color:var(--dim);font-size:11px} +.station .st{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)} +.station .st .att{color:var(--amber)} .station .st .att.crit{color:var(--fail)} +.station .st .okc{color:var(--pass)} +.ovhead{margin:0 4px 9px;display:flex;align-items:center;gap:10px} + +/* category detail view */ +.catplate{padding:0 0 15px} +.cathead{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid #201d17;margin-bottom:13px} +.cathead .nm{color:var(--gold);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:13px} +.cathead .cnt{color:var(--dim);font-size:11px;display:flex;gap:10px;align-items:center} +.cathead .cnt .att{color:var(--amber)} .cathead .cnt .att.crit{color:var(--fail)} +.cathead .cnt .okc{color:var(--pass)} +.cathead .navs{margin-left:auto;display:flex;gap:7px} +.catbody{padding:0 16px} +.heroes{display:grid;grid-template-columns:repeat(12,1fr);gap:11px;margin-bottom:12px} +.inst{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:11px 12px; + display:flex;flex-direction:column;gap:8px;position:relative;min-height:86px} +.inst .cap{color:var(--steel);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase; + display:flex;align-items:center;gap:7px;padding-right:70px} +.inst .lever{position:absolute;top:9px;right:9px} +.inst .val{display:flex;align-items:baseline;gap:8px;margin-top:auto} +.inst.s3{grid-column:span 3} .inst.s4{grid-column:span 4} .inst.s5{grid-column:span 5} .inst.s6{grid-column:span 6} +.inst .sub{color:var(--dim);font-size:10.5px} +.gaugepair{display:flex;gap:16px;align-items:center;justify-content:center;margin-top:auto} +.gaugepair .g{display:flex;flex-direction:column;align-items:center;gap:3px} +.gaugepair .g .lb{color:var(--steel);font-size:9px;letter-spacing:.1em} +.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 12px} + +/* doctor / review views */ +.owell{padding:13px 16px} +.owell .oh{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px; + color:var(--cream);font-size:13px} +.owell .oh .l{color:var(--gold)} +.ostep{display:flex;gap:9px;align-items:flex-start;padding:4px 0;font-size:12.5px} +.ostep .lamp{margin-top:3px;width:8px;height:8px} +.ostep b{color:var(--cream);font-weight:700} +.ostep .ev{color:var(--steel);font-size:11px;display:block} +.ostep.queued b{color:var(--dim)} +.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px} + +.rvrow{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;background:#141210; + font-size:12.5px;margin-bottom:6px} +.rvrow .who{color:var(--cream);font-weight:700} +.rvrow .why{color:var(--dim);font-size:11px} +.rvrow .keys{margin-left:auto;display:flex;gap:7px;align-items:center} +.rvrow .res{color:var(--pass);font-size:11px} +.rvnote{color:var(--dim);font-size:11px;margin:10px 2px 0;line-height:1.5} + +/* toast */ +#toast{position:fixed;bottom:26px;right:26px;display:flex;flex-direction:column;gap:8px;z-index:9} +.toastw{font-size:11.5px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 11px; + box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .18s ease} +.toastw.err{background:#5e3a2e} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +.crumb{color:var(--dim);font-size:11px;margin:0 4px 9px;display:flex;gap:6px;align-items:center} +.crumb a{color:var(--gold);cursor:pointer;text-decoration:none} +.footnote{color:var(--dim);font-size:11px;margin-top:16px;line-height:1.6;padding:0 4px} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="console"> + + <!-- persistent masthead --> + <div class="plate mast"> + <div class="brand"> + <div class="ey">archsetup · maintenance console · E4 interactive</div> + <div class="ti">MAINT · <b>ratio</b></div> + </div> + <div class="verdict" id="verdict"><span class="lamp"></span> HEALTHY</div> + <div class="spacer"></div> + <div class="doctor"> + <button class="key" id="homekey" onclick="go('home')">HOME</button> + <span class="lbl" style="margin-left:8px">Doctor</span> + <button class="key" onclick="go('doctor')">CLEAN UP</button> + <button class="key" onclick="go('review')">REVIEW & FIX</button> + </div> + </div> + + <!-- swapped view --> + <div id="view"></div> + + <div class="footnote"> + Fully interactive: views replace each other (no inline collapse). HOME = overview of all systems; + click a station to drill in; PREV/NEXT walk the categories; CLEAN UP streams the output well and + actually updates the metrics; REVIEW & FIX lists every Confirm item — destructive ones arm on + first press, fire on second. Workflow items always escalate (toast) — the console never runs them. + </div> +</div> +<div id="toast"></div> + +<script> +/* ================= state ================= */ +const S = { + cache:{v:8.8, st:'warn'}, scrub:{v:34, st:'warn'}, fstrim:{v:'OFF', st:'fail'}, + disk:{v:69, st:'ok'}, smart:{v:'PASSED', st:'ok'}, wear:{v:'0%', st:'ok'}, + deverr:{v:'0 / 0', st:'ok'}, unalloc:{v:'118 GiB', st:'ok'}, integ:{v:'clean', st:'ok'}, + deeptrim:{v:'keep 3', st:'ok'}, + orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'no', st:'ok'}, + failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'}, + cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'}, + jerr:{v:'12 real', st:'ok'}, khw:{v:'clean', st:'ok'}, + temps:{v:'61/54', st:'ok'}, mem:{v:104, st:'ok'}, unclean:{v:75, st:'warn'}, + zram:{v:'16 GiB', st:'ok'}, throttle:{v:'no', st:'ok'}, battery:{v:'n/a', st:'off'}, + docker:{v:3, st:'warn'}, rsync:{v:'3h ago', st:'ok'}, dstopped:{v:2, st:'ok'}, + cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'}, + snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'}, + fw:{v:'active', st:'ok'}, listen:{v:3, st:'ok'}, ts:{v:'4/4', st:'ok'}, + f2b:{v:'active', st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'}, +}; + +const CATS = [ + {id:'storage', name:'Storage & Filesystem', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']}, + {id:'packages', name:'Packages & Security', ids:['orphans','pacnew','keyring','reboot']}, + {id:'systemd', name:'systemd & Boot', ids:['failed','timers','sysrun','taint']}, + {id:'logs', name:'Logs & Coredumps', ids:['cores','journald','applog','jerr','khw']}, + {id:'power', name:'Memory · Thermal · Power', ids:['temps','mem','unclean','zram','throttle','battery']}, + {id:'services', name:'Services & Backups', ids:['docker','rsync','dstopped','cron','libvirt']}, + {id:'snapshots',name:'Snapshots', ids:['snapper','stimer']}, + {id:'network', name:'Network & Posture', ids:['fw','listen','ts','f2b','ntp','dns']}, +]; + +let view = 'home'; +let armed = null; // id of armed destructive key +let doctorTimer = null; + +/* ================= helpers ================= */ +const $ = id => document.getElementById(id); +function counts(cat){ + let attn=0, ok=0, crit=false; + cat.ids.forEach(i=>{ const st=S[i].st; + if(st==='warn'){attn++} else if(st==='fail'){attn++;crit=true} else ok++; }); + return {attn, ok, crit}; +} +function lampCls(st){ return st==='ok'?'':st==='warn'?'gold':st==='fail'?'red':'off'; } +function catLamp(cat){ const c=counts(cat); return c.crit?'red':c.attn?'gold':''; } +function toast(msg, err){ + const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg; + $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600); +} +function esc(id){ toast('escalates to the system-health-check workflow — not run from the console', true); } + +/* generic lever runner: busy → mutate → re-render */ +function fire(act){ + const a = ACTIONS[act]; + if(a.arm && armed!==act){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; } + armed=null; + toast(a.busy||'running…'); + setTimeout(()=>{ a.run(); toast(a.doneMsg); render(); }, a.ms||800); +} + +const ACTIONS = { + clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB', + run(){ S.cache={v:0.4, st:'ok'}; } }, + scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion', + run(){ S.scrub={v:0, st:'ok'}; } }, + fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active', + run(){ S.fstrim={v:'ON', st:'ok'}; S.timers={v:'5/5', st:'ok'}; } }, + deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press DEEP TRIM again', busy:'paccache -rk1 …', ms:1100, + doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more', run(){ S.deeptrim={v:'keep 1', st:'ok'}; if(S.cache.st==='ok') S.cache={v:0.2,st:'ok'}; } }, + orphans:{ arm:true, armMsg:'remove 13 orphaned packages — press again', busy:'pacman -Rns (13 pkgs) …', ms:1200, + doneMsg:'orphans removed — 13 pkgs', run(){ S.orphans={v:0, st:'ok'}; } }, + pacnew:{ busy:'diffing 2 .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged', + run(){ S.pacnew={v:0, st:'ok'}; } }, + keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed', + run(){ S.keyring={v:0, st:'ok'}; } }, + timers:{ busy:'systemctl enable --now (1 timer) …', ms:800, doneMsg:'all 5 maintenance timers firing', + run(){ S.timers={v:'5/5', st:'ok'}; S.fstrim.st==='fail' && (S.fstrim={v:'ON',st:'ok'}); } }, + cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared — 18 removed', + run(){ S.cores={v:0, st:'ok'}; } }, + journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB', + run(){ S.journald={v:0.3, st:'ok'}; } }, + applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d', run(){} }, + docker:{ arm:true, armMsg:'docker prune tier 1+2 — press again', busy:'docker container/image prune …', ms:1200, + doneMsg:'docker pruned — reclaimed 2.8 GB', run(){ S.docker={v:0.2, st:'ok'}; } }, + snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900, + doneMsg:'snapper — retention applied', run(){ S.snapper={v:31, st:'ok'}; } }, +}; + +/* ================= view renderers ================= */ +function lever(act, label, cls){ + const armedNow = armed===act; + return `<button class="key sm ${cls||''} ${armedNow?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${armedNow?label+'?':label}</button>`; +} +function wkey(label){ return `<button class="key sm" onclick="event.stopPropagation();esc()">${label||'WORKFLOW'}</button>`; } +function lrow(id, name, extra){ + const m=S[id]; + return `<div class="lrow"><span class="lamp ${lampCls(m.st)}"></span><span class="who">${name}</span> + <span class="what">${extra||''}${m.v}</span></div>`; +} + +function renderHome(){ + const stations = CATS.map(c=>{ + const k=counts(c); + const st = k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>` + : `<span class="okc">all clear · ${k.ok} ✓</span>`; + return `<div class="plate station ${k.crit?'crit':k.attn?'attn':'clear'}" onclick="go('cat:${c.id}')"> + <div class="top"><span class="lamp ${catLamp(c)}"></span><span class="nm">${c.name}</span><span class="chev">▸</span></div> + <div class="st">${st}</div></div>`; + }).join(''); + return ` + <div class="updbar"> + <span class="q">Updates</span> + <div class="rd"><span class="n">47</span><span class="k">pending</span></div> + <div class="rd"><span class="n cve">3</span><span class="k">CVE</span></div> + <div class="rd"><span class="n">5</span><span class="k">AUR stale</span></div> + <span class="msg"><span class="lamp red" style="width:8px;height:8px"></span> Security advisories present — updates run through the workflow, never the doctor.</span> + <span class="spacer"></span> + <button class="key red" onclick="esc()">RUN WORKFLOW</button> + </div> + <div class="ovhead"><span class="engrave">Overview<span class="cnt">· ${CATS.length} systems</span></span></div> + <div class="overview">${stations}</div>`; +} + +function heroBar(id, cap, unit, pct, opts){ + const m=S[id]; const o=opts||{}; + return `<div class="inst s3"> + ${o.lever||''} + <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div> + <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div> + <div class="bar ${m.st==='warn'?'warn':m.st==='ok'?'okc':''}">${o.tick?'<span class="tk" style="left:'+o.tick+'%"></span>':''}<span style="width:${pct}%"></span></div> + <div class="sub">${o.sub||''}</div></div>`; +} +function heroCount(id, cap, opts){ + const m=S[id]; const o=opts||{}; + return `<div class="inst s3"> + ${o.lever||''} + <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div> + <div class="val"><span class="readout" ${m.st==='fail'?'style="color:var(--fail)"':m.st==='warn'&&o.tint?'style="color:var(--amber)"':''}>${m.v}${o.unit?'<small>'+o.unit+'</small>':''}</span></div> + <div class="sub">${o.sub||''}</div></div>`; +} +function heroRing(id, cap, pct, color, small, opts){ + const m=S[id]; const o=opts||{}; + return `<div class="inst s3"> + ${o.lever||''} + <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div> + <div style="display:flex;justify-content:center;margin-top:auto"> + <span class="ring" style="--p:${pct};--rc:${color}"><b>${m.v}</b><small>${small}</small></span></div> + <div class="sub" style="text-align:center">${o.sub||''}</div></div>`; +} + +const CATVIEW = { + storage(){ return { + heroes: + heroBar('disk','Disk usage','%',S.disk.v,{sub:'root · btrfs · thr 80 / 90'})+ + heroBar('cache','Package cache','GB',Math.min(100,S.cache.v/10*100),{lever:`<span class="lever">${lever('clean_cache','CLEAN')}</span>`,tick:100,sub:'Auto · threshold ~10 GB'})+ + heroRing('scrub','btrfs scrub',Math.min(100,S.scrub.v/55*100),S.scrub.st==='ok'?'var(--pass)':'var(--amber)','days',{lever:`<span class="lever">${lever('scrub','SCRUB')}</span>`,sub:S.scrub.st==='ok'?'scrub fresh':'past 30d cadence'})+ + heroCount('fstrim','fstrim.timer',{lever:`<span class="lever">${lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':'')}</span>`,sub:'Confirm · weekly SSD TRIM'}), + strip: + lrow('smart','SMART')+lrow('wear','SMART wear')+lrow('deverr','device errors')+ + lrow('unalloc','btrfs unalloc')+lrow('integ','pacman integrity')+ + lrow('deeptrim','deep-trim', lever('deeptrim','DEEP TRIM')+' ') + };}, + packages(){ return { + heroes: + heroCount('orphans','Orphans',{lever:`<span class="lever">${lever('orphans','REMOVE')}</span>`,sub:'Confirm · unowned pkgs',tint:1})+ + heroCount('pacnew','.pacnew files',{lever:`<span class="lever">${lever('pacnew','REVIEW')}</span>`,sub:'Confirm · config merges',tint:1}), + strip: + lrow('keyring','keyring age (d)', lever('keyring','REFRESH')+' ')+ + lrow('reboot','reboot required') + };}, + systemd(){ return { + heroes: + heroCount('failed','Failed units',{lever:`<span class="lever">${wkey()}</span>`,sub:'Workflow · investigate why',tint:1})+ + heroCount('timers','Maint timers',{lever:`<span class="lever">${lever('timers','ENABLE')}</span>`,sub:'Confirm · paccache/scrub/fstrim/…',tint:1}), + strip: lrow('sysrun','is-system-running')+lrow('taint','kernel taint') + };}, + logs(){ return { + heroes: + heroCount('cores','Coredumps 7d',{lever:`<span class="lever">${lever('cores','CLEAR')}</span>`,sub:'Auto · keeps last 3 days',tint:1}), + strip: + lrow('journald','journald size (GB)', lever('journald','VACUUM')+' ')+ + lrow('applog','app-log cleanup', lever('applog','RUN')+' ')+ + lrow('jerr','journal errors')+lrow('khw','kernel/hw events') + };}, + power(){ return { + heroes: + `<div class="inst s5"><div class="cap"><span class="lamp"></span> Temperatures</div> + <div class="gaugepair"> + <div class="g"><span class="ring" style="--p:61;--rc:var(--pass)"><b>61°</b></span><span class="lb">CPU</span></div> + <div class="g"><span class="ring" style="--p:54;--rc:var(--pass)"><b>54°</b></span><span class="lb">NVMe</span></div> + </div></div>`+ + `<div class="inst s4"><div class="cap"><span class="lamp"></span> Memory free</div> + <div class="val"><span class="readout">104<small> GB</small></span></div> + <div class="bar okc"><span style="width:83%"></span></div><div class="sub">of 125 GB · 0 OOM</div></div>`+ + `<div class="inst s3"><span class="lever">${wkey()}</span><div class="cap"><span class="lamp gold"></span> Unclean boot</div> + <div class="val"><span class="readout" style="color:var(--amber)">75<small>%</small></span></div> + <div class="bar warn"><span style="width:75%"></span></div><div class="sub">Workflow · investigate</div></div>`, + strip: lrow('zram','swap / zram')+lrow('throttle','thermal throttle')+lrow('battery','battery') + };}, + services(){ return { + heroes: + heroCount('docker','Docker reclaim',{lever:`<span class="lever">${lever('docker','PRUNE')}</span>`,unit:' GB',sub:'Confirm · tiers 1+2',tint:1}), + strip: + lrow('rsync','rsyncshot backup')+lrow('dstopped','docker stopped')+ + lrow('cron','cron')+lrow('libvirt','libvirt VMs') + };}, + snapshots(){ return { + heroes:'', + strip: + lrow('snapper','snapper count', lever('snapper','PRUNE')+' ')+ + lrow('stimer','snapshot timer') + };}, + network(){ return { + heroes:'', + strip: + lrow('fw','firewall')+lrow('listen','unexpected listeners')+lrow('ts','tailscale')+ + lrow('f2b','fail2ban')+lrow('ntp','NTP sync')+lrow('dns','DNS / NM') + };}, +}; + +function renderCat(cid){ + const idx = CATS.findIndex(c=>c.id===cid); + const cat = CATS[idx]; + const k = counts(cat); + const prev = CATS[(idx+CATS.length-1)%CATS.length].id; + const next = CATS[(idx+1)%CATS.length].id; + const body = CATVIEW[cid](); + const cnt = k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>` + : `<span class="okc">all clear · ${k.ok} ✓</span>`; + return ` + <div class="crumb"><a onclick="go('home')">overview</a> ▸ ${cat.name.toLowerCase()}</div> + <div class="plate catplate"> + <div class="cathead"> + <span class="lamp ${catLamp(cat)}"></span> + <span class="nm">${cat.name}</span> + <span class="cnt">${cnt}</span> + <span class="navs"> + <button class="key sm" onclick="go('cat:${prev}')">◂ PREV</button> + <button class="key sm" onclick="go('home')">HOME</button> + <button class="key sm" onclick="go('cat:${next}')">NEXT ▸</button> + </span> + </div> + <div class="catbody"> + ${body.heroes?`<div class="heroes">${body.heroes}</div>`:''} + <div class="strip">${body.strip}</div> + </div> + </div>`; +} + +/* ---- doctor (clean up) ---- */ +let docSteps = []; +function renderDoctor(){ + const rows = docSteps.map(s=>{ + const lamp = s.st==='run'?'busy':s.st==='done'?'':'off'; + return `<div class="ostep ${s.st==='wait'?'queued':''}"><span class="lamp ${lamp}"></span> + <span><b>${s.name}</b> <span class="ev">${s.ev}</span></span></div>`; + }).join(''); + const running = docSteps.some(s=>s.st!=='done'); + return ` + <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · clean up</div> + <div class="plate owell"> + <div class="oh"><span>MAINT · <span class="l">ratio</span> · CLEANING ${running?'<span class="lamp busy"></span>':'<span class="lamp"></span>'}</span> + <span style="color:var(--dim);font-size:11px">${docSteps.length} Auto actions</span></div> + ${rows} + <div class="ofoot"><button class="key ${running?'done':''}" onclick="go('home')">DONE</button></div> + </div>`; +} +function startDoctor(){ + clearTimeout(doctorTimer); + docSteps = [ + {name:'cache trim', act:'clean_cache', st:'wait', ev:'queued', + res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'done · reclaimed 8.4 GB') : 'done · nothing to reclaim'}, + {name:'journal vacuum',act:'journald', st:'wait', ev:'queued', + res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'done · reclaimed 0.9 GB') : 'done · already tight'}, + {name:'coredump clear',act:'cores', st:'wait', ev:'queued', + res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return 'done · '+n+' cleared';})() : 'done · none to clear'}, + {name:'app-log cleanup',act:'applog', st:'wait', ev:'queued', + res:()=> 'done · nothing older than 7d'}, + ]; + let i = 0; + function step(){ + if(i>0){ docSteps[i-1].st='done'; docSteps[i-1].ev=docSteps[i-1].res(); } + if(i<docSteps.length){ docSteps[i].st='run'; docSteps[i].ev='running…'; i++; + if(view==='doctor') $('view').innerHTML=renderDoctor(); + doctorTimer=setTimeout(step, 1000); + } else { + if(view==='doctor') $('view').innerHTML=renderDoctor(); + toast('clean up complete'); + } + } + step(); +} + +/* ---- review & fix ---- */ +const REVIEW = [ + {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', okv:()=>S.fstrim.st==='ok'}, + {act:'scrub', who:'btrfs scrub', why:'34d since last scrub (30d cadence)', id:'scrub', okv:()=>S.scrub.st==='ok'}, + {act:'orphans', who:'orphans', why:'13 unowned packages', id:'orphans', okv:()=>S.orphans.st==='ok'}, + {act:'pacnew', who:'.pacnew files', why:'2 configs awaiting merge', id:'pacnew', okv:()=>S.pacnew.st==='ok'}, + {act:'timers', who:'maint timers', why:'1 of 5 not firing', id:'timers', okv:()=>S.timers.st==='ok'}, + {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', okv:()=>S.keyring.st==='ok'}, + {act:'docker', who:'docker reclaim', why:'3 GB reclaimable (tiers 1+2)', id:'docker', okv:()=>S.docker.st==='ok'}, + {act:'snapper', who:'snapper', why:'retention within budget — optional', id:'snapper', okv:()=>S.snapper.v<42}, + {act:'deeptrim',who:'deep-trim', why:'keep 1 version — frees most, less downgrade headroom', id:'deeptrim', okv:()=>S.deeptrim.v==='keep 1'}, +]; +function renderReview(){ + const rows = REVIEW.map(r=>{ + const done = r.okv(); + return `<div class="rvrow"> + <span class="lamp ${done?'':lampCls(S[r.id].st)}"></span> + <span class="who">${r.who}</span><span class="why">${r.why}</span> + <span class="keys">${done?'<span class="res">done ✓</span>':lever(r.act, ACTIONS[r.act].arm?'FIX':'FIX')}</span> + </div>`; + }).join(''); + return ` + <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · review & fix</div> + <div class="plate owell"> + <div class="oh"><span>MAINT · <span class="l">ratio</span> · REVIEW & FIX</span> + <span style="color:var(--dim);font-size:11px">${REVIEW.filter(r=>!r.okv()).length} Confirm items</span></div> + ${rows} + <div class="rvnote">Destructive fixes (orphan removal, deep-trim, prunes) arm on first press and fire on the + second. Workflow items — failed units, unclean boots, updates, CVEs — are deliberately absent here: they + escalate to the health-check workflow.</div> + <div class="ofoot"><button class="key" onclick="go('home')">DONE</button></div> + </div>`; +} + +/* ================= router ================= */ +function go(v){ + armed = null; + view = v; + if(v==='doctor') startDoctor(); + render(); + window.scrollTo({top:0, behavior:'smooth'}); +} +function render(){ + $('homekey').classList.toggle('on', view==='home'); + const el = $('view'); + if(view==='home') el.innerHTML = renderHome(); + else if(view==='doctor') el.innerHTML = renderDoctor(); + else if(view==='review') el.innerHTML = renderReview(); + else if(view.startsWith('cat:')) el.innerHTML = renderCat(view.slice(4)); +} +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html new file mode 100644 index 0000000..95ec4c9 --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html @@ -0,0 +1,1696 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — E5 · Selector + dense subpanel</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);line-height:1.45; + padding:1.6rem 1.2rem 4rem; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground); + display:flex;justify-content:center;align-items:flex-start} + +.capsule{width:960px;max-width:100%;background:var(--panel);color:var(--silver); + border:1.6px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);position:relative} + +/* ---- primitives ---- */ +.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass); + box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite; + box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4); + white-space:nowrap} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.armed{background:rgba(203,107,77,.15);border-color:var(--fail);color:var(--fail)} +.key.dis{opacity:.3;cursor:not-allowed} +.key.dis:hover{color:var(--silver);border-color:#33302b} +.key.sm{font-size:10px;padding:3px 8px;border-radius:7px} + +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;white-space:nowrap} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} +.badge.pass{background:var(--pass);color:var(--cream)} + +.bar{width:100%;height:11px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px;transition:width .5s ease} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} +.bar.okc>span{background:linear-gradient(90deg,#5c7626,var(--pass))} +.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)} + +.ring{width:54px;height:54px;border-radius:50%; + background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative;flex:none} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1} +.ring small{position:relative;color:var(--dim);font-size:8px} + +.readout{color:var(--cream);font-size:21px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em;line-height:1} +.readout small{color:var(--dim);font-size:11px;font-weight:400} + +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.26em;text-transform:uppercase; + display:flex;align-items:center;gap:10px} +.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.68rem} + +.lrow{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:7px;background:#141210;font-size:12px} +.lrow .who{color:var(--silver)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:10.5px;display:flex;align-items:center;gap:7px} + +.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:16px} +.ladder i{width:5px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:5px}.ladder i:nth-child(2){height:9px} +.ladder i:nth-child(3){height:12px}.ladder i:nth-child(4){height:16px} +.ladder i.on{background:var(--pass)} +.ladder.bad i.on{background:var(--fail)} + +/* faceplate */ +.face{display:flex;align-items:center;gap:11px;padding:11px 13px;margin-bottom:11px; + background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:11px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04)} +.face .word{color:var(--cream);font-size:15.5px;font-weight:700;letter-spacing:.22em} +.face .spacer{flex:1} +.face .unit{color:var(--steel);font-size:.72rem;letter-spacing:.1em} +.face .xbtn{width:24px;height:24px;border-radius:50%;border:1px solid #33302b;background:transparent; + color:var(--dim);cursor:pointer;font-size:12px;line-height:1;display:grid;place-items:center;font-family:var(--mono)} +.face .xbtn:hover{color:var(--fail);border-color:var(--fail)} + +.ctlrow{display:flex;align-items:center;gap:8px;margin-bottom:11px;padding:0 2px} +.ctlrow .lbl{color:var(--steel);font-size:.6rem;letter-spacing:.22em;text-transform:uppercase;margin-right:3px} +.ctlrow .spacer{flex:1} + +.updbar{display:flex;align-items:center;gap:13px;padding:7px 12px;margin-bottom:12px; + border:1px solid var(--wash);border-radius:9px;background:linear-gradient(180deg,#141310,#0a0c0d);font-size:11.5px} +.updbar .q{color:var(--dim);font-size:.58rem;letter-spacing:.2em;text-transform:uppercase; + border:1px solid var(--wash);border-radius:5px;padding:1px 6px} +.updbar.ok{border-color:var(--pass)} .updbar.ok .q{color:var(--pass);border-color:var(--pass)} +.updbar.warn{border-color:var(--amber)} .updbar.warn .q{color:var(--amber);border-color:var(--amber)} +.updbar.crit{border-color:var(--fail)} .updbar.crit .q{color:var(--fail);border-color:var(--fail)} +.updbar b{color:var(--cream);font-variant-numeric:tabular-nums} +.updbar .cve{color:var(--fail);font-weight:700} +.updbar .spacer{flex:1} +.updbar .dimtx{color:var(--dim);font-size:10.5px} + +/* selector */ +.selector{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-bottom:13px} +.selbtn{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;border-radius:9px; + background:linear-gradient(180deg,#191715,#131110);border:1px solid #262320;border-left:3px solid var(--wash); + transition:border-color .1s} +.selbtn:hover{border-color:#3a352c} +.selbtn .nm{color:var(--silver);font-size:11.5px;font-weight:700;letter-spacing:.02em} +.selbtn .ct{margin-left:auto;font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap} +.selbtn .ct .a{color:var(--amber)} .selbtn .ct .a.crit{color:var(--fail)} .selbtn .ct .k{color:var(--pass)} +.selbtn.attn{border-left-color:var(--amber)} +.selbtn.crit{border-left-color:var(--fail)} +.selbtn.clear{border-left-color:var(--pass)} +.selbtn.loading{border-left-color:var(--amber)} +.selbtn.sel{background:linear-gradient(180deg,#4a5768,var(--slate));border-color:var(--gold);border-left-width:3px} +.selbtn.sel .nm{color:var(--gold)} +.selbtn.sel .ct, .selbtn.sel .ct .a, .selbtn.sel .ct .k{color:var(--cream)} + +/* subpanel */ +.sub{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:13px 14px;min-height:330px} +.subhead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.subhead .nm{color:var(--gold);font-weight:700;letter-spacing:.16em;text-transform:uppercase;font-size:12.5px} +.subhead .cnt{color:var(--dim);font-size:11px;margin-left:auto;display:flex;gap:9px;align-items:center} +.subhead .cnt .att{color:var(--amber)} .subhead .cnt .att.crit{color:var(--fail)} .subhead .cnt .okc{color:var(--pass)} + +.wgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-bottom:10px} +.cell{background:#100f0e;border:1px solid #1f1c18;border-radius:9px;padding:9px 10px; + display:flex;flex-direction:column;gap:7px;position:relative;min-height:76px} +.cell .cap{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase; + display:flex;align-items:center;gap:6px;padding-right:56px} +.cell .lever{position:absolute;top:7px;right:7px} +.cell .val{display:flex;align-items:baseline;gap:7px;margin-top:auto} +.cell .sub2{color:var(--dim);font-size:10px} +.cell .center{display:flex;justify-content:center;margin-top:auto} +.gpair{display:flex;gap:14px;align-items:center;justify-content:center;margin-top:auto} +.gpair .g{display:flex;flex-direction:column;align-items:center;gap:2px} +.gpair .g .lb{color:var(--steel);font-size:8.5px;letter-spacing:.1em} + +.striph{margin:9px 0 7px} +.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:5px 9px} +.strip.two{grid-template-columns:repeat(2,1fr)} + +/* segmented selector (gallery widget 06) */ +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;width:max-content} +.seg button{font:inherit;font-size:10px;color:var(--silver);background:#191715;border:0; + border-right:1px solid #33302b;padding:5px 9px;cursor:pointer;letter-spacing:.06em} +.seg button:last-child{border-right:0} +.seg button:hover{color:var(--gold)} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} + +/* evidence column headers */ +.evh{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;margin-bottom:4px} +.colh{display:flex;align-items:center;gap:8px;min-width:0} +.colh .engrave{flex:1;min-width:0} + +/* ===== rotary band selector (amplifier input-selector idiom) ===== */ +.deck{display:flex;align-items:center;gap:22px;margin:13px 4px 11px;padding:9px 14px; + background:linear-gradient(180deg,#161412,#100f0e);border:1px solid #201d17;border-radius:10px} +.knob{width:46px;height:46px;border-radius:50%;position:relative;cursor:pointer;flex:none; + background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.knob:hover{border-color:var(--gold)} +.knob .ind{position:absolute;left:50%;top:4px;width:2px;height:14px;background:var(--gold-hi); + margin-left:-1px;transform-origin:50% 19px;border-radius:1px; + box-shadow:0 0 5px rgba(255,215,95,.6);transition:transform .25s cubic-bezier(.3,1.3,.5,1)} +.bands{display:flex;gap:26px;align-items:flex-end} +.band{display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;user-select:none} +.band .lamp{width:7px;height:7px} +.band .bl{color:var(--steel);font-size:.6rem;letter-spacing:.18em;text-transform:uppercase;white-space:nowrap} +.band:hover .bl{color:var(--silver)} +.band.on .bl{color:var(--cream)} +.band .ul{height:2px;width:100%;border-radius:1px;background:transparent;transition:background .15s} +.band.on .ul{background:var(--gold);box-shadow:0 0 6px rgba(218,181,61,.5)} + +/* journal digest rows — capped at ~3.5 rows; the half row signals scrollability */ +/* rows are fixed 30px so the 3.5-row cap is exact: 3×30 + 3×4 gap + 15 half = 117 */ +.jlist{display:flex;flex-direction:column;gap:4px;max-height:117px;overflow-y:auto;padding-right:4px} +.jlist .jrow{height:30px;min-height:30px;padding:2px 8px;flex:none} + +/* dark scrollbars, consistent with the panel family (slate thumb on dark track) */ +.capsule ::-webkit-scrollbar{width:8px;height:8px} +.capsule ::-webkit-scrollbar-track{background:#0a0c0d;border-radius:4px} +.capsule ::-webkit-scrollbar-thumb{background:#2c2f32;border-radius:4px} +.capsule ::-webkit-scrollbar-thumb:hover{background:#54677d} +.capsule *{scrollbar-width:thin;scrollbar-color:#2c2f32 #0a0c0d} +.jrow{gap:8px} +.jrow .jmsg{color:var(--dim);font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0} +.jrow .what{margin-left:0;flex:none} +.jrow.known .who{color:var(--dim)} + +.skel{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px; + min-height:260px;color:var(--dim);font-size:11px;letter-spacing:.24em;text-transform:uppercase} + +/* doctor / review */ +.ostep{display:flex;gap:9px;align-items:flex-start;padding:4px 0;font-size:12.5px} +.ostep .lamp{margin-top:3px;width:8px;height:8px} +.ostep b{color:var(--cream);font-weight:700} +.ostep .ev{color:var(--steel);font-size:11px;display:block} +.ostep .ev.evfail{color:var(--fail)} +.ostep.queued b{color:var(--dim)} +.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px} +/* review list: fixed 32px rows + 5px gap (37 stride); cap = 8.5 rows so the + panel holds its shape and the half row cues the scroll */ +.rvlist{max-height:312px;overflow-y:auto;padding-right:4px} +.rvrow{display:flex;align-items:center;gap:10px;padding:2px 10px;border-radius:8px;background:#141210; + font-size:12px;margin-bottom:5px;height:32px;min-height:32px} +.rvrow .who{color:var(--cream);font-weight:700} +.rvrow .why{color:var(--dim);font-size:10.5px} +.rvrow .why.errtx{color:var(--fail)} +.rvrow .keys{margin-left:auto;display:flex;gap:7px;align-items:center} +.rvrow .res{color:var(--pass);font-size:11px} +.rvnote{color:var(--dim);font-size:10.5px;margin:9px 2px 0;line-height:1.5} + +.errline{color:var(--fail);font-size:10px;line-height:1.35;margin-top:3px} + +/* results wall (output-well idiom from the net/bt panels) */ +.rwall{margin-top:12px;background:var(--well);border:1px solid #201d17;border-radius:11px;padding:10px 14px 12px} +.rwall .rhead{display:flex;align-items:center;gap:10px;margin-bottom:4px} +.rwall .rhead .keys{margin-left:auto;display:flex;gap:6px} +/* rows fixed 30px so the 3.5-entry cap is exact: 3.5 × 30 = 105 */ +.rwall .rbody{max-height:105px;overflow-y:auto;padding-right:4px} +.rwall .rstep{display:flex;gap:9px;align-items:center;height:30px;min-height:30px;font-size:12px} +.rwall .rstep .tm{color:var(--dim);font-size:10px;font-variant-numeric:tabular-nums;flex:none; + display:flex;flex-direction:column;line-height:1.2} +.rwall .rstep .tm .dt{font-size:8.5px;color:#5d636a;letter-spacing:.04em} +.rwall .rstep .lamp{width:8px;height:8px} +.rwall .rstep .rtxt{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.rwall .rstep b{color:var(--cream);font-weight:700} +.rwall .rstep .ev{color:var(--steel);font-size:11px} +.rwall .rstep .ev.evfail{color:var(--fail)} + +#toast{position:absolute;bottom:16px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:9; + align-items:flex-end;pointer-events:none;max-width:70%} +.toastw{font-size:11.5px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 11px; + box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .18s ease} +.toastw.err{background:#5e3a2e} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +#closed{display:none;color:var(--dim);font-size:12px;border:1px dashed var(--wash); + border-radius:10px;padding:14px 20px;cursor:pointer} +#closed:hover{color:var(--gold);border-color:var(--gold)} + +/* page-level prototype chrome (NOT part of the panel) */ +.pagectl{max-width:960px;margin:0 0 12px;display:flex;align-items:center;gap:8px; + color:var(--dim);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase} +.footnote{color:var(--dim);font-size:10.5px;margin-top:14px;line-height:1.55;max-width:960px} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div> + +<!-- ===== prototype controls (page level, not panel UI) ===== --> +<div class="pagectl"> + prototype controls — + <button class="key sm" id="k-fail" onclick="toggleFail()" title="make every action fail so the error UX is visible">SIM FAIL</button> + <button class="key sm" id="k-bad" onclick="toggleBad()" title="load the degraded-machine snapshot: every metric in its warn/fail state">SIM BAD DAY</button> + <button class="key sm" id="k-zfs" onclick="toggleZfs()" title="show the velox/ZFS storage capability set instead of btrfs">SIM ZFS</button> + <button class="key sm" onclick="reloadPanel()" title="replay the open-and-hydrate sequence">SIM RELOAD</button> +</div> + +<div class="capsule" id="capsule"> + + <!-- faceplate --> + <div class="face"> + <span class="lamp" id="facelamp"></span> + <span class="word">MAINTENANCE</span> + <span class="badge red" id="cvebadge">3 CVE</span> + <span class="badge ghost" id="attbadge">9 ATTN</span> + <span class="badge red" id="errbadge" style="display:none">0 FAILED</span> + <span class="spacer"></span> + <span class="unit">MNT·01</span> + <button class="xbtn" title="Close (Esc)" onclick="closePanel()">✕</button> + </div> + + <!-- doctor control row --> + <div class="ctlrow"> + <span class="lbl">Doctor</span> + <button class="key" id="k-clean" onclick="go('doctor')">CLEAN UP</button> + <button class="key" id="k-review" onclick="go('review')">REVIEW & FIX</button> + <span class="spacer"></span> + <span class="lbl" style="margin-right:0" id="lastscan">last scan 07:41 · 44 checks · 0.9s</span> + </div> + + <!-- updates quarantine --> + <div class="updbar" id="updbar"> + <span class="q">Updates</span> + <span><b id="upd-p">47</b> pending</span> + <span class="cve" id="upd-c">3 CVE</span> + <span><b id="upd-a">5</b> AUR stale</span> + <span><b id="upd-f">0</b> firmware</span> + <span class="dimtx">mesa · linux-lts · systemd — guard-checked before any apply</span> + <span class="spacer"></span> + <span id="upd-rb"></span> + <button class="key sm" id="k-update" onclick="fire('sysupdate')">UPDATE</button> + <button class="key sm" id="k-topgrade" onclick="fire('topgrade')">TOPGRADE</button> + </div> + + <!-- persistent selector --> + <div class="selector" id="selector"></div> + + <!-- swapped subpanel --> + <div class="sub" id="sub"></div> + + <!-- results wall: appears on first action; toggle / copy / close --> + <div class="rwall" id="rwall" style="display:none"> + <div class="rhead"> + <span class="engrave" style="flex:1">Results<span class="cnt" id="rcount">· 0</span></span> + <span class="keys"> + <button class="key sm" id="rtoggle" onclick="toggleWall()">HIDE</button> + <button class="key sm" onclick="copyWall()">COPY</button> + </span> + </div> + <div class="rbody" id="rbody"></div> + </div> + + <div id="toast"></div> +</div> + +<div id="closed" onclick="openPanel()">MNT·01 closed — click to reopen (waybar: click the maint tag)</div> + +<div class="footnote"> + Prototype notes (not part of the panel): selector stays put — the gold/slate tile is the selection; the + subpanel below swaps. ✕ or Esc closes (chip reopens; reopening replays the hydrate sequence). CLEAN UP + streams into the subpanel and mutates real state; REVIEW & FIX lists every Confirm lever, destructive + ones arm-then-fire. Metrics without a determinate lever are read-only telemetry (agent/workflow assistance + is vLater). SIM FAIL makes every action fail (red lamp, error line, RETRY key, FAILED badge); SIM BAD DAY + loads a degraded-machine snapshot so every metric shows its warn/fail face, doctor or not; SIM RELOAD + replays the open: tiles pulse amber per tier (local reads land first, process probes next, package scans + last) and settle individually — cached values would fill the board instantly in the real build, with lamps + confirming freshness as probes return. +</div> +</div> + +<script> +/* ================= state snapshots ================= */ +function GOOD(){ return { + cache:{v:8.8, st:'warn'}, scrub:{v:34, st:'warn'}, fstrim:{v:'OFF', st:'fail'}, + disk:{v:69, st:'ok'}, smart:{v:'PASSED', st:'ok'}, wear:{v:0, st:'ok'}, + deverr:{v:'0 / 0', st:'ok'}, unalloc:{v:'118', st:'ok'}, integ:{v:'clean', st:'ok'}, + deeptrim:{v:'keep 3', st:'ok'}, smartlast:'passed 2026-07-01', devrows:[], + dtop:[['~/videos','620 GB'],['~/music','240 GB'],['~/pictures','180 GB'],['/var/lib/docker','38 GB'],['~/.cache','22 GB']], + orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'NO', st:'ok'}, + orphlist:[['rust','1.2 GB'],['electron28','210 MB'],['python-build','84 MB'],['go-tools','61 MB'], + ['libplacebo','44 MB'],['ocaml','38 MB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB'], + ['xcb-util-old','7 MB'],['libdazzle','6 MB'],['gtest','5 MB'],['orc-old','4 MB']], + pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'}, + {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}], + cves:[['openssh','CVE-2026-2892','high'],['mesa','CVE-2026-1104','medium'],['libxml2','CVE-2026-0517','low']], + aurnames:'zoom · spotify · ttf-berkeley-mono +2', fwnames:'', + failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'}, + funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'}], + cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'}, + jerr:{v:12, st:'ok'}, khw:{v:'clean', st:'ok'}, + coregroups:[['chrome',12,'13:02'],['hyprland',3,'06-07'],['hypridle',2,'11:40'],['dunst',1,'10:05']], + khwev:[], + jgroups:[ + {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:26, first:'07:02', last:'13:58', unit:'bluetooth.service'}, + {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:140, first:'07:02', last:'13:59', unit:null}, + {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:8, first:'07:02', last:'12:10', unit:null}, + {id:'insync', msg:'QSocketNotifier: invalid socket', n:6, first:'08:15', last:'13:20', unit:null}, + {id:'gammastep', msg:'Wayland connection experienced a fatal error', n:3, first:'07:02', last:'07:03', unit:'gammastep.service'}, + {id:'hypridle', msg:'Failed to inhibit idle', n:2, first:'09:41', last:'11:12', unit:'hypridle.service'}, + {id:'dunst', msg:'gdk_pixbuf assertion failure', n:1, first:'10:05', last:'10:05', unit:'dunst.service'}, + ], + temps:{v:'61/54', st:'ok'}, mem:{v:104, st:'ok'}, unclean:{v:75, st:'warn'}, + zram:{v:'16 GiB', st:'ok'}, throttle:{v:'NO', st:'ok'}, battery:{v:'n/a', st:'off'}, + cpumode:{v:'power', st:'ok'}, batt:{v:94, st:'ok'}, blimit:{v:'none', st:'warn'}, + memtop:[['chrome','4.2 GB'],['emacs','1.9 GB'],['insync','0.9 GB'],['waybar','0.3 GB'],['mpd','0.2 GB']], + boots:[['07-07 07:01','clean'],['07-06 08:12','unclean'],['07-05 09:45','unclean'],['07-04 07:58','clean'],['07-03 08:30','unclean']], + pevents:[], + docker:{v:3, st:'warn'}, rsync:{v:'3h', st:'ok'}, dstopped:{v:2, st:'ok'}, + cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'}, + ddf:[['images','14.2 GB','1.8 GB'],['containers','0.4 GB','0.1 GB'],['volumes','6.8 GB','0.3 GB'],['build cache','0.8 GB','0.8 GB']], + ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0}], + snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'}, + snaptypes:{timeline:31, single:8, prepost:3, oldsingle:'2026-06-12'}, + oldest:{v:8, st:'ok'}, retention:{v:'H6 D7 W2 M2 Y0', st:'ok'}, + fw:{v:'active', st:'ok'}, listen:{v:0, st:'ok'}, ts:{v:'4/4', st:'ok'}, tsdown:[], + lsn:[ + {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'}, + {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'}, + {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'}, + ], + f2b:{v:0, st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'}, + tgage:{v:9, st:'warn'}, + upd:{p:47, c:3, a:5, f:0}, + zcap:{v:62, st:'ok'}, zfrag:{v:31, st:'ok'}, ztrim:{v:'OFF', st:'fail'}, + zhealth:{v:'ONLINE', st:'ok'}, zerr:{v:'0 / 0 / 0', st:'ok'}, +};} +function BAD(){ return { + cache:{v:17, st:'warn'}, scrub:{v:74, st:'fail'}, fstrim:{v:'OFF', st:'fail'}, + disk:{v:92, st:'fail'}, smart:{v:'FAILING', st:'fail'}, wear:{v:91, st:'warn'}, + deverr:{v:'14 / 2', st:'fail'}, unalloc:{v:'9', st:'warn'}, integ:{v:'3 modified', st:'warn'}, + deeptrim:{v:'never run', st:'warn'}, smartlast:'failed 2026-07-06 — reallocated sectors', + devrows:[['nvme0n1','12 read · 2 write · 3 corruption'],['nvme1n1','2 read · 0 write · 1 corruption']], + dtop:[['~/videos','1.4 TB'],['/var/lib/docker','160 GB'],['~/music','240 GB'],['~/.cache','96 GB'],['~/downloads','88 GB']], + orphans:{v:51, st:'warn'}, pacnew:{v:7, st:'warn'}, keyring:{v:94, st:'warn'}, reboot:{v:'YES', st:'warn'}, + orphlist:[['rust','1.2 GB'],['cuda-11','2.8 GB'],['electron28','210 MB'],['electron25','195 MB'], + ['python-build','84 MB'],['go-tools','61 MB'],['libplacebo','44 MB'],['ocaml','38 MB'], + ['texlive-extra','1.1 GB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB']], + pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'}, + {f:'/etc/sudoers.pacnew', safe:false, why:'needs merge — review carefully'}, + {f:'/etc/ssh/sshd_config.pacnew', safe:false, why:'needs merge'}, + {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}], + cves:[['openssh','CVE-2026-2892','high'],['sudo','CVE-2026-3011','high'],['mesa','CVE-2026-1104','medium'], + ['curl','CVE-2026-2440','medium'],['libxml2','CVE-2026-0517','low']], + aurnames:'zoom · spotify · ttf-berkeley-mono · paru-git +8', fwnames:'UEFI 3.07→3.09 · NVMe fw', + failed:{v:4, st:'fail'}, timers:{v:'2/5', st:'fail'}, sysrun:{v:'degraded', st:'fail'}, taint:{v:'W', st:'warn'}, + funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'}, + {u:'fail2ban', since:'07:44', info:'exit 1 — bad jail'}, + {u:'cronie', since:'07:02', info:'exit 203'}, + {u:'smartd', since:'07:02', info:'exit 1'}], + cores:{v:112, st:'fail'}, journald:{v:3.8, st:'warn'}, applog:{v:'stale', st:'warn'}, + jerr:{v:214, st:'warn'}, khw:{v:'MCE + I/O', st:'fail'}, + coregroups:[['chrome',61,'13:41'],['hyprland',23,'12:15'],['electron',17,'11:03'],['dunst',11,'13:10']], + khwev:[['12:44','MCE: CPU 3 bank 5 — corrected'],['11:02','nvme0n1: I/O error, sector 88121'],['10:58','thermal: package throttle 91°C']], + jgroups:[ + {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:31, first:'07:02', last:'13:58', unit:'bluetooth.service'}, + {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:180, first:'07:02', last:'13:59', unit:null}, + {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:9, first:'07:02', last:'12:10', unit:null}, + {id:'systemd-oomd', msg:'Failed to acquire memory pressure info', n:88, first:'07:05', last:'13:55', unit:'systemd-oomd.service'}, + {id:'wpa_supplicant', msg:'bgscan simple: Failed to enable signal monitoring', n:61, first:'07:02', last:'13:59', unit:'wpa_supplicant.service'}, + {id:'hyprland', msg:'GLES2 framebuffer incomplete', n:31, first:'08:11', last:'13:40', unit:null}, + {id:'rsyncshot', msg:'rsync error: partial transfer (code 23)', n:23, first:'02:30', last:'13:30', unit:'rsyncshot.service'}, + {id:'dunst', msg:'gdk_pixbuf assertion failure', n:11, first:'07:20', last:'13:10', unit:'dunst.service'}, + ], + temps:{v:'91/78', st:'fail'}, mem:{v:6, st:'warn'}, unclean:{v:82, st:'warn'}, + zram:{v:'none', st:'warn'}, throttle:{v:'YES', st:'fail'}, battery:{v:'n/a', st:'off'}, + cpumode:{v:'perf', st:'ok'}, batt:{v:71, st:'warn'}, blimit:{v:'none', st:'warn'}, + memtop:[['chrome','38.4 GB'],['java (deepsat)','22.1 GB'],['emacs','2.1 GB'],['Hyprland','1.9 GB'],['insync','1.4 GB'],['dockerd','1.1 GB']], + boots:[['07-07 07:05','unclean'],['07-06 21:12','unclean'],['07-06 08:12','unclean'],['07-05 09:45','clean'],['07-04 07:58','unclean']], + pevents:[['13:41','OOM killed chrome (38 GB)'],['12:20','thermal throttle 91°C · 340 s'],['09:15','OOM killed java (22 GB)']], + docker:{v:22, st:'warn'}, rsync:{v:'52h', st:'fail'}, dstopped:{v:5, st:'warn'}, + cron:{v:'down', st:'fail'}, libvirt:{v:'off', st:'off'}, + ddf:[['images','38.1 GB','18.2 GB'],['containers','1.2 GB','0.4 GB'],['volumes','9.8 GB','2.1 GB'],['build cache','1.3 GB','1.3 GB']], + ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0}, + {name:'jellyfin', when:'2h ago', code:137},{name:'pihole', when:'9h ago', code:1}, + {name:'registry', when:'3d ago', code:0}], + snapper:{v:214, st:'warn'}, stimer:{v:'inactive', st:'fail'}, + snaptypes:{timeline:62, single:140, prepost:12, oldsingle:'2026-01-03'}, + oldest:{v:194, st:'fail'}, retention:{v:'H6 D7 W2 M10 Y2', st:'warn'}, + fw:{v:'inactive', st:'fail'}, listen:{v:9, st:'warn'}, ts:{v:'1/4', st:'fail'}, + tsdown:['truenas','worker','cjennings'], + lsn:[ + {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'}, + {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'}, + {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'}, + {proc:'python', port:8000, bind:'0.0.0.0', unit:null}, + {proc:'ncat', port:4444, bind:'0.0.0.0', unit:null}, + {proc:'smbd', port:445, bind:'0.0.0.0', unit:'smb'}, + {proc:'node', port:3000, bind:'127.0.0.1', unit:null}, + {proc:'postgres', port:5432, bind:'127.0.0.1', unit:'postgresql'}, + {proc:'cupsd', port:631, bind:'127.0.0.1', unit:'cups'}, + ], + f2b:{v:'down', st:'fail'}, ntp:{v:'unsynced', st:'warn'}, dns:{v:'FAIL', st:'fail'}, + tgage:{v:34, st:'fail'}, + upd:{p:214, c:9, a:12, f:2}, + zcap:{v:91, st:'fail'}, zfrag:{v:68, st:'warn'}, ztrim:{v:'OFF', st:'fail'}, + zhealth:{v:'DEGRADED', st:'fail'}, zerr:{v:'3 / 0 / 1', st:'fail'}, +};} +let S = GOOD(); + +const CATS = [ + {id:'storage', name:'STORAGE', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']}, + {id:'packages', name:'PACKAGES', ids:['orphans','pacnew','keyring','reboot','tgage']}, + {id:'systemd', name:'SYSTEMD', ids:['failed','timers','sysrun','taint']}, + {id:'logs', name:'LOGS', ids:['cores','journald','applog','jerr','khw']}, + {id:'power', name:'MEM·PWR', ids:['temps','mem','unclean','zram','throttle','battery']}, + {id:'services', name:'SERVICES', ids:['docker','rsync','dstopped','cron','libvirt']}, + {id:'snapshots',name:'SNAPSHOTS', ids:['snapper','stimer','oldest','retention']}, + {id:'network', name:'NETWORK', ids:['fw','listen','ts','f2b','ntp','dns']}, +]; +/* diagnostic (no lever here) set — drives the faceplate verdict lamp */ +const DIAG = ['disk','smart','wear','deverr','unalloc','integ','sysrun','taint','jerr','khw', + 'temps','mem','zram','throttle','battery','listen','dstopped','libvirt','oldest','retention', + 'failed','unclean','rsync','dns','zcap','zfrag','zhealth','zerr','batt']; + +let view = 'cat:storage'; +let armed = null; +let doctorTimer = null; +let failMode = false; +let badMode = false; +const ERR = {}; +const loaded = new Set(); // categories whose probes have landed +let loadTimers = []; + +/* ================= helpers ================= */ +const $ = id => document.getElementById(id); +const BTRFS_IDS = ['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']; +const ZFS_IDS = ['zcap','cache','scrub','ztrim','smart','wear','zhealth','zerr','zfrag','integ','deeptrim']; +const PWR_RATIO = ['temps','mem','unclean','zram','throttle','battery','cpumode']; +const PWR_VELOX = ['temps','mem','unclean','zram','throttle','batt','blimit','cpumode']; +function catIds(cat){ + if(cat.id==='storage') return zfsMode?ZFS_IDS:BTRFS_IDS; + if(cat.id==='power') return zfsMode?PWR_VELOX:PWR_RATIO; + return cat.ids; +} +/* fixable/watch split per category — sets expectations before a single cell is read */ +function fixwatch(cid){ + switch(cid){ + case 'storage': return zfsMode?[7,4]:[8,2]; + case 'packages': return [5,0]; + case 'systemd': return [2,2]; + case 'logs': return [4,1]; + case 'power': return zfsMode?[3,5]:[2,5]; + case 'services': return [4,1]; + case 'snapshots':return [3,1]; + case 'network': return [6,0]; + } +} +function counts(cat){ + let attn=0, ok=0, crit=false; + catIds(cat).forEach(i=>{ const st=S[i].st; + if(st==='warn'){attn++} else if(st==='fail'){attn++;crit=true} else ok++; }); + return {attn, ok, crit}; +} +function lampCls(st){ return st==='ok'?'':st==='warn'?'gold':st==='fail'?'red':'off'; } +function toast(msg, err){ + const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg; + $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600); +} + +/* ---- prototype toggles ---- */ +function toggleFail(){ + failMode = !failMode; + const k=$('k-fail'); k.classList.toggle('red', failMode); + k.textContent = failMode ? 'SIM FAIL ON' : 'SIM FAIL'; + toast(failMode ? 'failure simulation ON — every action will fail' : 'failure simulation off', failMode); +} +function toggleBad(){ + badMode = !badMode; + const k=$('k-bad'); k.classList.toggle('red', badMode); + k.textContent = badMode ? 'BAD DAY ON' : 'SIM BAD DAY'; + S = badMode ? BAD() : GOOD(); + Object.keys(ERR).forEach(k2=>delete ERR[k2]); + render(); + toast(badMode ? 'degraded snapshot loaded — every metric at its warn/fail face' : 'healthy snapshot restored', badMode); +} +/* ---- journal noise list: shipped defaults + user marks (two layers) ---- */ +const NOISE = [ + {id:'bluetoothd', pat:'Hands-Free Voice gateway connect failed', date:'2026-02-27', shipped:true, disabled:false}, + {id:'pixman', pat:'_pixman_log_error: BadDrawable', date:'2026-02-27', shipped:true, disabled:false}, + {id:'xkbcomp', pat:'Errors from xkbcomp are not fatal', date:'2026-03-24', shipped:true, disabled:false}, +]; +const nkey = g => g.id+'|'+(g.pat||g.msg); +function noiseFor(g){ return NOISE.find(n=>!n.disabled && n.id===g.id && n.pat===g.msg); } +function jSignal(){ return S.jgroups.filter(g=>!noiseFor(g)).sort((a,b)=>b.n-a.n); } +function jKnown(){ return S.jgroups.filter(g=>noiseFor(g)); } +function recalcJerr(){ + const sum = jSignal().reduce((s,g)=>s+g.n,0); + S.jerr = {v:sum, st: sum===0?'ok': sum<=25?'ok':'warn'}; +} +function markKnown(key){ + const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return; + if(armed!=='mark:'+key){ armed='mark:'+key; render(); + toast('mark known: '+g.id+' · "'+g.msg.slice(0,40)+'…" — press again', true); return; } + armed=null; + NOISE.push({id:g.id, pat:g.msg, date:today(), shipped:false, disabled:false}); + resAdd('mark known', g.id+' · '+g.msg.slice(0,50)+' ('+g.n+' this boot)', 'done'); + render(); +} +function unmarkKnown(key){ + const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return; + const n = noiseFor(g); if(!n) return; + if(n.shipped) n.disabled = true; + else NOISE.splice(NOISE.indexOf(n),1); + resAdd('unmark', g.id+' — back in signal'+(n.shipped?' (shipped default disabled)':''), 'done'); + render(); +} +function clearMarks(){ + if(armed!=='clearmarks'){ armed='clearmarks'; render(); + toast('clear all user marks + re-enable shipped defaults — press again', true); return; } + armed=null; + const removed = NOISE.filter(n=>!n.shipped).length; + for(let i=NOISE.length-1;i>=0;i--){ if(!NOISE[i].shipped) NOISE.splice(i,1); else NOISE[i].disabled=false; } + resAdd('clear marks', removed+' user mark(s) removed · shipped defaults restored', 'done'); + render(); +} + +/* ---- listeners: expected-list curation + guarded per-socket remedies ---- */ +const LEXPECT = [ + {proc:'sshd', port:22, date:'2026-02-27', shipped:true, disabled:false}, + {proc:'mpd', port:6600, date:'2026-02-27', shipped:true, disabled:false}, + {proc:'tailscaled', port:41641, date:'2026-02-27', shipped:true, disabled:false}, +]; +const lkey = r => r.proc+'|'+r.port; +const isPub = b => b==='0.0.0.0'||b==='::'; +function lexpFor(r){ return LEXPECT.find(p=>!p.disabled && p.proc===r.proc && p.port===r.port); } +function lsnSignal(){ return S.lsn.filter(r=>!lexpFor(r)); } +function recalcListen(){ + const sig = lsnSignal(); + const pub = sig.filter(r=>isPub(r.bind)).length; + S.listen = {v:sig.length, st: sig.length===0?'ok': pub?'fail':'warn'}; +} +function markExpected(key){ + const r = S.lsn.find(x=>lkey(x)===key); if(!r) return; + if(armed!=='lexp:'+key){ armed='lexp:'+key; render(); + toast('mark expected: '+r.proc+' :'+r.port+' — press again', true); return; } + armed=null; + LEXPECT.push({proc:r.proc, port:r.port, date:today(), shipped:false, disabled:false}); + resAdd('mark expected', r.proc+' :'+r.port+' ('+r.bind+')', 'done'); + render(); +} +function unmarkExpected(key){ + const p = LEXPECT.find(x=>!x.disabled && x.proc+'|'+x.port===key); if(!p) return; + if(p.shipped) p.disabled = true; else LEXPECT.splice(LEXPECT.indexOf(p),1); + resAdd('unmark expected', p.proc+' :'+p.port+' — back in signal'+(p.shipped?' (shipped default disabled)':''), 'done'); + render(); +} +function clearLsn(){ + if(armed!=='clearlsn'){ armed='clearlsn'; render(); + toast('clear user expected-marks + re-enable shipped — press again', true); return; } + armed=null; + const removed = LEXPECT.filter(p=>!p.shipped).length; + for(let i=LEXPECT.length-1;i>=0;i--){ if(!LEXPECT[i].shipped) LEXPECT.splice(i,1); else LEXPECT[i].disabled=false; } + resAdd('clear expected', removed+' user mark(s) removed · shipped defaults restored', 'done'); + render(); +} +function lsnFix(key){ + const r = S.lsn.find(x=>lkey(x)===key); if(!r) return; + const verb = r.unit ? 'systemctl stop '+r.unit : 'SIGTERM '+r.proc; + if(armed!=='lsnfix:'+key){ armed='lsnfix:'+key; render(); + toast((r.unit?'STOP ':'KILL ')+r.proc+' :'+r.port+' — '+verb+' · press again', true); return; } + armed=null; + const e = resAdd(r.unit?'stop unit':'kill listener', verb+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', r.unit?'systemctl: unit busy — stop refused':r.proc+' respawned — supervised? check its unit'); } + else { S.lsn.splice(S.lsn.indexOf(r),1); + resDone(e,'done', r.proc+' stopped — port '+r.port+' closed'); } + render(); + }, 800); +} + +/* ---- rotary sub-section selector state ---- */ +const SUBSEC = {packages:'orphans', logs:'signal', services:'containers'}; +const DECKDEFS = {}; +function setSubsec(cat,id){ SUBSEC[cat]=id; armed=null; render(); } +function cycleSubsec(cat){ + const defs = DECKDEFS[cat]; if(!defs||!defs.length) return; + const i = defs.findIndex(d=>d.id===SUBSEC[cat]); + setSubsec(cat, defs[(i+1)%defs.length].id); +} +function deck(cat, defs){ + DECKDEFS[cat] = defs; + const cur = SUBSEC[cat]; + const i = Math.max(0, defs.findIndex(d=>d.id===cur)); + const ang = defs.length>1 ? -50 + i*(100/(defs.length-1)) : 0; + const bands = defs.map(d=>`<span class="band ${d.id===cur?'on':''}" onclick="setSubsec('${cat}','${d.id}')"> + <span class="lamp ${d.lamp||''}"></span> + <span class="bl">${d.label}${d.count!==undefined&&d.count!==''?' · '+d.count:''}</span> + <span class="ul"></span></span>`).join(''); + return `<div class="deck"> + <span class="knob" onclick="cycleSubsec('${cat}')" title="cycle sections"> + <span class="ind" style="transform:rotate(${ang}deg)"></span></span> + <span class="bands">${bands}</span></div>`; +} + +/* ---- orphans: KEEP curation (the rust lesson) + per-package REMOVE ---- */ +const OKEEP = []; // user layer: packages marked intentional +const okept = name => OKEEP.some(k=>k.name===name); +function orphKeep(name){ + if(okept(name)) return; + OKEEP.push({name, date:today()}); + S.orphans.v = Math.max(0, S.orphans.v-1); + if(S.orphans.v===0) S.orphans.st='ok'; + resAdd('keep', name+' marked intentional — excluded from removal', 'done'); + render(); +} +function orphUnkeep(name){ + const k = OKEEP.find(x=>x.name===name); if(!k) return; + OKEEP.splice(OKEEP.indexOf(k),1); + S.orphans.v++; S.orphans.st='warn'; + resAdd('unkeep', name+' — back in the orphan set', 'done'); + render(); +} +function orphRemove(name){ + const row = S.orphlist.find(r=>r[0]===name); if(!row) return; + if(armed!=='orem:'+name){ armed='orem:'+name; render(); + toast('pacman -Rns '+name+' ('+row[1]+') — press again', true); return; } + armed=null; + const e = resAdd('orphan remove', 'pacman -Rns '+name+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail','pacman: breaking dependency — '+name+' required by another package'); } + else { S.orphlist.splice(S.orphlist.indexOf(row),1); + S.orphans.v = Math.max(0, S.orphans.v-1); + if(S.orphans.v===0) S.orphans.st='ok'; + resDone(e,'done',name+' removed — '+row[1]+' freed'); } + render(); + }, 900); +} + +/* ---- pacnew: per-file DELETE (safe) / MERGE (terminal delegation) ---- */ +function pacnewAct(file){ + const p = S.pacnews.find(x=>x.f===file); if(!p) return; + const e = resAdd(p.safe?'pacnew delete':'pacnew merge', + p.safe ? 'rm '+file+' — '+p.why+' …' : 'opening diff in terminal — '+file+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', p.safe?'rm: permission denied':'diff tool not found'); } + else { S.pacnews.splice(S.pacnews.indexOf(p),1); + S.pacnew.v = Math.max(0, S.pacnew.v-1); + if(S.pacnew.v===0) S.pacnew.st='ok'; + resDone(e,'done', p.safe ? file+' deleted — current config is authoritative' + : file+' merged in terminal · pacnew deleted'); } + render(); + }, 700); +} + +/* ---- failed units: per-unit roster remedies ---- */ +function funitAct(u, reset){ + const r = S.funits.find(x=>x.u===u); if(!r) return; + const e = resAdd(reset?'reset-failed':'unit restart', + (reset?'systemctl reset-failed ':'systemctl restart ')+u+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', u+': start request repeated too quickly'); } + else { S.funits.splice(S.funits.indexOf(r),1); + S.failed.v = Math.max(0, S.failed.v-1); + if(S.failed.v===0){ S.failed.st='ok'; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } + resDone(e,'done', u+(reset?' cleared from failed list':' restarted — running')); } + render(); + }, 800); +} + +/* ---- containers: expected-list curation + per-container START ---- */ +const CEXPECT = [ + {name:'winvm', date:'2026-02-27', shipped:true, disabled:false}, + {name:'deepsat-pg', date:'2026-06-20', shipped:false, disabled:false}, +]; +const cexp = name => CEXPECT.some(p=>!p.disabled && p.name===name); +function recalcCtrs(){ + const unexp = S.ctrs.filter(c=>!cexp(c.name)).length; + S.dstopped = {v:S.ctrs.length, st: unexp?'warn':'ok'}; +} +function ctrStartOne(name){ + const c = S.ctrs.find(x=>x.name===name); if(!c) return; + const e = resAdd('container start', 'docker start '+name+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', name+' exits immediately (code 1) — see docker logs'); } + else { S.ctrs.splice(S.ctrs.indexOf(c),1); + resDone(e,'done', name+' running'); } + render(); + }, 800); +} +function ctrMark(name){ + if(armed!=='cexp:'+name){ armed='cexp:'+name; render(); + toast('mark expected: '+name+' stays stopped by design — press again', true); return; } + armed=null; + CEXPECT.push({name, date:today(), shipped:false, disabled:false}); + resAdd('mark expected', name+' — expected-stopped (on-demand)', 'done'); + render(); +} +function ctrUnmark(name){ + const p = CEXPECT.find(x=>!x.disabled && x.name===name); if(!p) return; + if(p.shipped) p.disabled=true; else CEXPECT.splice(CEXPECT.indexOf(p),1); + resAdd('unmark expected', name+' — back in signal', 'done'); + render(); +} + +/* ---- snapshots: stale manual (single) snapshot delete ---- */ +function snapDelStale(){ + if(armed!=='snapstale'){ armed='snapstale'; render(); + toast('delete '+(S.snaptypes.single-2)+' stale manual snapshots (keep newest 2) — press again', true); return; } + armed=null; + const n = S.snaptypes.single-2; + const e = resAdd('stale snapshots', 'snapper delete ('+n+' singles) …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail','snapper: config busy — snapshot in progress'); } + else { S.snaptypes.single=2; S.snaptypes.oldsingle='recent'; + S.snapper.v = S.snaptypes.timeline+S.snaptypes.single+S.snaptypes.prepost; + if(S.snapper.v<100) S.snapper.st='ok'; + resDone(e,'done', n+' manual snapshots deleted — space freed on next cleanup'); } + render(); + }, 900); +} + +/* kill process: arm-to-fire SIGTERM on a top-memory item; session-critical names protected */ +const PROTECTED = ['systemd','Hyprland','maintenance-panel']; +function killProc(name){ + const row = S.memtop.find(r=>r[0]===name); if(!row) return; + if(armed!=='kill:'+name){ armed='kill:'+name; render(); + toast('KILL '+name+' ('+row[1]+') — SIGTERM · press again', true); return; } + armed=null; + const e = resAdd('kill process', 'SIGTERM '+name+' ('+row[1]+') …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail',name+' ignored SIGTERM — state D (uninterruptible IO)'); } + else { + S.memtop.splice(S.memtop.indexOf(row),1); + const gb = parseFloat(row[1])||0; + S.mem.v = Math.min(125, Math.round((S.mem.v+gb)*10)/10); + if(S.mem.v>16 && S.mem.st!=='ok') S.mem={v:S.mem.v, st:'ok'}; + resDone(e,'done',name+' exited cleanly · '+row[1]+' freed'); + } + render(); + }, 700); +} + +/* cpu mode: free selector — set the active EPP mode to anything */ +const MODE_LABELS = {perf:'performance', bal:'balance_performance', power:'power'}; +function setMode(m){ + if(S.cpumode.v===m) return; + const e = resAdd('cpu mode', 'writing EPP hint: '+MODE_LABELS[m]+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail','sysfs: energy_performance_preference write refused'); } + else { S.cpumode.v=m; resDone(e,'done','EPP → '+MODE_LABELS[m]+' (all cores)'); } + render(); + }, 500); +} + +let zfsMode = false; +function toggleZfs(){ + zfsMode = !zfsMode; + const k=$('k-zfs'); k.classList.toggle('on', zfsMode); + k.textContent = zfsMode ? 'ZFS ON' : 'SIM ZFS'; + Object.keys(ERR).forEach(k2=>{ if(ACT2CAT[k2]==='storage') delete ERR[k2]; }); + render(); + toast(zfsMode ? 'storage shows the velox/ZFS capability set' : 'storage shows the ratio/btrfs capability set'); +} +function reloadPanel(){ openPanel(); } + +/* ---- actions ---- */ +/* ---- results wall ---- */ +const RES = []; +let wallHidden = false; +const ANAMES = { clean_cache:'cache trim', scrub:'btrfs scrub', fstrim:'fstrim enable', + deeptrim:'deep trim', orphans:'orphan removal', pacnew:'pacnew merge', keyring:'keyring refresh', + timers:'timer enable', cores:'coredump clear', journald:'journal vacuum', applog:'app-log cleanup', + docker:'docker prune', snapper:'snapper cleanup', unit_restart:'unit restart', + f2b_fix:'fail2ban restart', cron_fix:'cronie start', ntp_fix:'chrony restart', + ts_fix:'tailscaled restart', stimer_fix:'snapshot timers', zram_fix:'zram enable', + netdoc:'net doctor', balance:'btrfs balance', reinstall:'pkg reinstall', rsyncnow:'backup run', + smarttest:'smart self-test', retention:'retention repair', reboot:'reboot', + sysupdate:'system update', topgrade:'topgrade', ztrim_fix:'zpool autotrim', + openjournal:'open journal', bat_limit:'charge limit', + ctr_start:'container start', fw_fix:'firewall enable' }; +function now(){ return new Date().toTimeString().slice(0,8); } +function today(){ const d=new Date(); + return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } +function resAdd(name, ev, st){ + const e = {d:today(), t:now(), name, ev, st:st||'run'}; + RES.push(e); renderWall(); return e; +} +function resDone(e, st, ev){ e.st=st; e.ev=ev; e.d=today(); e.t=now(); renderWall(); } +function renderWall(){ + const w = $('rwall'); + if(!RES.length){ w.style.display='none'; return; } + w.style.display='block'; + $('rcount').textContent = '· '+RES.length; + $('rtoggle').textContent = wallHidden ? 'SHOW' : 'HIDE'; + const b = $('rbody'); + b.style.display = wallHidden ? 'none' : 'block'; + b.innerHTML = RES.map(e=>{ + const lamp = e.st==='run'?'busy':e.st==='fail'?'red':''; + return `<div class="rstep"><span class="tm"><span class="dt">${e.d}</span>${e.t}</span><span class="lamp ${lamp}"></span> + <span class="rtxt" title="${e.name} — ${e.ev}"><b>${e.name}</b> <span class="ev ${e.st==='fail'?'evfail':''}">${e.ev}</span></span></div>`; + }).join(''); + b.scrollTop = b.scrollHeight; +} +function toggleWall(){ wallHidden=!wallHidden; renderWall(); } +function copyWall(){ + const txt = RES.map(e=>`${e.d} ${e.t} ${e.st.toUpperCase().padEnd(4)} ${e.name} — ${e.ev}`).join('\n'); + (navigator.clipboard ? navigator.clipboard.writeText(txt) : Promise.reject()) + .then(()=>toast('copied '+RES.length+' result lines')) + .catch(()=>toast('clipboard unavailable', true)); +} + +function fire(act){ + const a = ACTIONS[act]; + // guard-armed updates: first press runs the guard check and arms; second press overrides + if(a.guardArm && armed!==act){ + armed = act; + const e = resAdd(ANAMES[act]||act, a.busy||'checking guard…'); + setTimeout(()=>{ + resDone(e,'fail','guard: mesa in update set — press again to run anyway, or apply from a TTY'); + render(); + }, 600); + render(); + return; + } + if(a.arm && armed!==act && !ERR[act]){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; } + const overridden = a.guardArm && armed===act; + armed=null; + const e = resAdd(ANAMES[act]||act, (overridden && a.busy2) ? a.busy2 : (a.busy||'running…')); + setTimeout(()=>{ + if(failMode){ ERR[act]=a.err||'exited 1'; resDone(e,'fail',ERR[act]); } + else { delete ERR[act]; a.run(); resDone(e,'done',a.doneMsg); + if(a.guardArm && S.reboot.v==='YES') + resAdd('reboot offer','kernel/mesa updated — REBOOT key armed on the updates strip','done'); + } + render(); + }, a.ms||800); +} +const ACTIONS = { + clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB', + err:'paccache: /var/cache/pacman/pkg — permission denied (polkit dismissed)', + run(){ S.cache={v:0.4, st:'ok'}; } }, + scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion', + err:'btrfs: scrub already running on / — see dmesg', + run(){ S.scrub={v:0, st:'ok'}; } }, + fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active', + err:'systemctl: unit fstrim.timer is masked', + run(){ S.fstrim={v:'ON', st:'ok'}; if(S.timers.st==='warn') S.timers={v:'5/5', st:'ok'}; } }, + deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press again', busy:'paccache -rk1 …', ms:1100, + doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more', + err:'paccache -rk1: exited 1 — cache dir busy (pacman lock held)', + run(){ S.deeptrim={v:'keep 1', st:'ok'}; if(S.cache.st==='ok') S.cache={v:0.2,st:'ok'}; } }, + orphans:{ arm:true, armMsg:'remove all unkept orphans — press again', busy:'pacman -Rns (unkept set) …', ms:1200, + doneMsg:'orphans removed — kept packages skipped', + err:'pacman -Rns: breaking dependency — rust required by cargo-audit', + run(){ S.orphlist = S.orphlist.filter(r=>okept(r[0])); S.orphans={v:0, st:'ok'}; } }, + pacnew:{ busy:'diffing .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged', + err:'merge aborted — locale.gen.pacnew conflicts, needs manual merge', + run(){ S.pacnew={v:0, st:'ok'}; } }, + keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed', + err:'pacman -Sy: mirror timeout — no network?', + run(){ S.keyring={v:0, st:'ok'}; } }, + timers:{ busy:'systemctl enable --now …', ms:800, doneMsg:'all 5 maintenance timers firing', + err:'systemctl: failed to enable — unit file not found', + run(){ S.timers={v:'5/5', st:'ok'}; if(S.fstrim.st==='fail') S.fstrim={v:'ON',st:'ok'}; } }, + cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared', + err:'coredumpctl: permission denied — /var/lib/systemd/coredump', + run(){ S.cores={v:0, st:'ok'}; S.coregroups=[]; } }, + journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB', + err:'journalctl: vacuum failed — rotation in progress, retry', + run(){ S.journald={v:0.3, st:'ok'}; } }, + applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d', + err:'log-cleanup: exited 1 — ~/.local/var/log not writable', + run(){ S.applog={v:'ok', st:'ok'}; } }, + docker:{ arm:true, armMsg:'docker prune tier 1+2 — press again', busy:'docker container/image prune …', ms:1200, + doneMsg:'docker pruned — reclaimed 2.8 GB', + err:'docker: cannot connect to daemon — is docker.service running?', + run(){ S.docker={v:0.2, st:'ok'}; S.ddf = S.ddf.map(r=>[r[0], r[1], '0 GB']); } }, + snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900, + doneMsg:'snapper — retention applied', + err:'snapper: config busy — snapshot in progress', + run(){ S.snapper={v:Math.min(S.snapper.v,31), st:'ok'}; } }, + /* --- group A: service restarts / enables --- */ + unit_restart:{ busy:'systemctl restart (all failed units) …', ms:900, doneMsg:'units restarted — running', + err:'systemd-oomd: start request repeated too quickly', + run(){ S.funits=[]; S.failed={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } }, + f2b_fix:{ busy:'systemctl restart fail2ban …', ms:800, doneMsg:'fail2ban running — sshd jail active', + err:'fail2ban: failed to start — bad jail config', + run(){ S.f2b={v:0, st:'ok'}; } }, + cron_fix:{ busy:'systemctl start cronie …', ms:700, doneMsg:'cronie running — entries live', + err:'cronie: unit masked', + run(){ S.cron={v:'ok', st:'ok'}; } }, + ntp_fix:{ busy:'systemctl restart chronyd + makestep …', ms:800, doneMsg:'clock stepped — synchronized', + err:'chronyd: no reachable sources', + run(){ S.ntp={v:'synced', st:'ok'}; } }, + ts_fix:{ busy:'systemctl restart tailscaled …', ms:900, doneMsg:'tailscaled up — peers reconnecting', + err:'tailscaled: bind: address already in use', + run(){ S.ts={v:'4/4', st:'ok'}; S.tsdown=[]; } }, + stimer_fix:{ busy:'systemctl enable --now snapper timers …', ms:700, doneMsg:'snapshot timers firing', + err:'systemctl: unit not found', + run(){ S.stimer={v:'active', st:'ok'}; } }, + zram_fix:{ busy:'re-applying zram-generator config …', ms:800, doneMsg:'zram active — 16 GiB zstd', + err:'zram-generator: module not loaded', + run(){ S.zram={v:'16 GiB', st:'ok'}; } }, + netdoc:{ busy:'opening NET·01 …', ms:400, doneMsg:'delegated to the net panel doctor (NET·01)', + err:'net panel not running', + run(){} }, + /* --- group B: deterministic maintenance ops --- */ + balance:{ arm:true, armMsg:'btrfs balance is IO-heavy and slow — press again', busy:'btrfs balance -dusage=50 …', ms:1500, + doneMsg:'balance complete — unallocated recovered', + err:'balance cancelled — ENOSPC during relocation', + run(){ S.unalloc={v:'118', st:'ok'}; } }, + reinstall:{ busy:'pacman -S (owning packages) …', ms:1100, doneMsg:'modified files restored — integrity clean', + err:'pacman: failed to commit transaction', + run(){ S.integ={v:'clean', st:'ok'}; } }, + rsyncnow:{ busy:'rsyncshot manual run …', ms:1400, doneMsg:'backup complete — synced to truenas', + err:'rsync exit 23 — partial transfer (permissions)', + run(){ S.rsync={v:'now', st:'ok'}; } }, + smarttest:{ busy:'smartctl -t short (both NVMe) …', ms:900, doneMsg:'short self-test started — results in ~2 min', + err:'smartctl: device busy', + run(){ S.smartlast='passed just now (short test)'; } }, + retention:{ busy:'writing sane retention (M2 Y0) + cleanup …', ms:1000, doneMsg:'retention repaired — old chain pruned', + err:'snapperd: config locked', + run(){ S.retention={v:'H6 D7 W2 M2 Y0', st:'ok'}; S.oldest={v:8, st:'ok'}; S.snapper={v:Math.min(S.snapper.v,42), st:'ok'}; } }, + /* --- group D: disruptive but determinate --- */ + reboot:{ arm:true, armMsg:'reboot the machine — press again', busy:'rebooting … (simulated)', ms:1200, + doneMsg:'rebooted — new kernel running, taint cleared', + err:'logind: inhibitor lock held (backup in progress)', + run(){ S.reboot={v:'NO', st:'ok'}; S.taint={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } }, + ctr_start:{ busy:'docker start (all unexpected exited) …', ms:1000, + doneMsg:'containers started — running again', + err:'docker: container exits immediately (code 1) — see docker logs', + run(){ S.ctrs = S.ctrs.filter(c=>cexp(c.name)); } }, + fw_fix:{ busy:'ufw enable …', ms:800, + doneMsg:'firewall active — mosh rule loaded', + err:'ufw: iptables lock held — retry', + run(){ S.fw={v:'active', st:'ok'}; } }, + bat_limit:{ busy:'writing charge_control_end_threshold=80 …', ms:700, + doneMsg:'charge limit set — 80% (battery longevity)', + err:'sysfs: permission denied', + run(){ S.blimit={v:'80%', st:'ok'}; } }, + openjournal:{ busy:'spawning terminal: journalctl -p err -b …', ms:400, + doneMsg:'opened in terminal — journalctl -p err -b', + err:'no terminal available', + run(){} }, + /* --- zfs (velox capability set) --- */ + ztrim_fix:{ busy:'zpool set autotrim=on + zpool trim …', ms:1000, + doneMsg:'autotrim enabled — initial trim running', + err:'zpool: pool is busy (resilver in progress)', + run(){ S.ztrim={v:'ON', st:'ok'}; } }, + /* --- updates (Confirm; guard arms instead of blocking — user decides) --- */ + sysupdate:{ guardArm:true, busy:'checking update set against the live-update guard …', + busy2:'pacman -Syu … (guard overridden)', ms:1400, + doneMsg:'system updated — 47 packages · reboot recommended', + err:'pacman: failed to commit transaction (conflicting files)', + run(){ S.upd.p=0; S.upd.c=0; S.reboot={v:'YES', st:'warn'}; } }, + topgrade:{ guardArm:true, busy:'guard check, then topgrade (--disable git) …', + busy2:'topgrade full run … (guard overridden)', ms:1800, + doneMsg:'topgrade complete — system + ecosystems · reboot recommended', + err:'topgrade: system step failed — pacman lock held', + run(){ S.upd.p=0; S.upd.c=0; S.upd.a=0; S.tgage={v:0, st:'ok'}; S.reboot={v:'YES', st:'warn'}; } }, +}; + +/* ================= widget builders ================= */ +function lever(act, label, cls){ + const a = armed===act; + if(ERR[act]) return `<button class="key sm red" onclick="event.stopPropagation();fire('${act}')">RETRY</button>`; + return `<button class="key sm ${cls||''} ${a?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${a?label+'?':label}</button>`; +} +function errline(act){ return act && ERR[act] ? `<div class="errline">✗ ${ERR[act]}</div>` : ''; } +function lampFor(id, act){ return (act && ERR[act]) ? 'red' : lampCls(S[id].st); } +function lrow(id, name, extra, valOverride, act){ + return `<div class="lrow"><span class="lamp ${lampFor(id,act)}"></span><span class="who">${name}</span> + <span class="what">${extra||''}${(act&&ERR[act])?'✗ failed':(valOverride!==undefined?valOverride:S[id].v)}</span></div>`; +} +function barCls(st){ return st==='ok'?'okc':'warn'; } +function cellBar(id,cap,unit,pct,o){ o=o||{}; const m=S[id]; + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div> + <div class="bar ${barCls(m.st)}">${o.tick?`<span class="tk" style="left:${o.tick}%"></span>`:''}<span style="width:${Math.min(100,pct)}%"></span></div> + <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; } +function cellCount(id,cap,o){ o=o||{}; const m=S[id]; + const col = m.st==='fail'?'style="color:var(--fail)"':(m.st==='warn'?'style="color:var(--amber)"':''); + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="val"><span class="readout" ${col}>${m.v}${o.unit?`<small>${o.unit}</small>`:''}</span>${o.badge||''}</div> + <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; } +function cellRing(id,cap,pct,small,o){ o=o||{}; const m=S[id]; + const rc = (o.act&&ERR[o.act])?'var(--fail)':m.st==='ok'?'var(--pass)':m.st==='fail'?'var(--fail)':'var(--amber)'; + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="center"><span class="ring" style="--p:${Math.min(100,pct)};--rc:${rc}"><b>${m.v}</b><small>${small}</small></span></div> + <div class="sub2" style="text-align:center">${o.sub||''}</div>${errline(o.act)}</div>`; } +function cellBadge(id,cap,o){ o=o||{}; const m=S[id]; + const cls = m.st==='ok'?'pass':m.st==='fail'?'red':m.st==='warn'?'':'ghost'; + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="val"><span class="badge ${cls}">${String(m.v).toUpperCase()}</span></div> + <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; } + +/* dynamic rosters */ +function timerRoster(){ + const st = S.timers.st; + const rows = [ + ['paccache.timer','weekly', true], + ['btrfs-scrub@-.timer','monthly', st!=='fail'], + ['fstrim.timer','weekly', S.fstrim.st==='ok'], + ['reflector.timer','weekly', st!=='fail'], + ['snapper-cleanup.timer','daily', true], + ]; + return rows.map(([n,c,ok])=>`<div class="lrow"><span class="lamp ${ok?'':'red'}"></span> + <span class="who">${n}</span><span class="what">${ok?c+' · ok':'disabled'}</span></div>`).join(''); +} +function backupRoster(){ + if(S.rsync.st==='ok') return ` + <div class="lrow"><span class="lamp"></span><span class="who">hourly</span><span class="what">3h ago · ok</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">daily 02:30</span><span class="what">11h ago · ok</span></div>`; + return ` + <div class="lrow"><span class="lamp red"></span><span class="who">hourly</span><span class="what">52h ago · stale</span></div> + <div class="lrow"><span class="lamp red"></span><span class="who">daily 02:30</span><span class="what">rsync exit 23 · failed</span></div>`; +} +function peerRoster(){ + const down = S.tsdown||[]; + return ['truenas','velox','worker','cjennings'].map(p=>{ + const d = down.includes(p); + return `<div class="lrow"><span class="lamp ${d?'red':''}"></span><span class="who">${p}</span> + <span class="what">${d?'offline':'online'}</span></div>`;}).join(''); +} + +/* ================= subpanel templates ================= */ +const SUB = { +storage(){ + if(zfsMode) return ` + <div class="wgrid"> + ${cellBar('zcap','zpool capacity','%',S.zcap.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'CoW slows > 80 · best-fit > 90'})} + ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})} + ${cellRing('scrub','zpool scrub',S.scrub.v/60*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'warn 35d · crit 60d'})} + ${cellCount('ztrim','zpool autotrim',{lever:S.ztrim.st!=='ok'?lever('ztrim_fix','ENABLE',S.ztrim.st==='fail'?'red':''):'',act:'ztrim_fix',sub:'the fstrim counterpart on ZFS'})} + </div> + <div class="wgrid"> + ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'NVMe'})} + ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0%':'high wear — plan replacement'})} + ${cellBadge('zhealth','Pool health',{sub:'zpool status -x'})} + ${cellCount('zerr','Pool errors',{sub:'read / write / cksum — no reset lever; investigate'})} + </div> + <div class="engrave striph">filesystem<span class="cnt">· 3</span></div> + <div class="strip"> + ${lrow('zfrag','fragmentation', '', S.zfrag.v+'% — no defrag exists; keep < 80% full')} + ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')} + ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')} + </div>`; + return ` + <div class="wgrid"> + ${cellBar('disk','Disk usage','%',S.disk.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'root · btrfs · thr 80/90'})} + ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})} + ${cellRing('scrub','btrfs scrub',S.scrub.v/55*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'past 30d cadence'})} + ${cellCount('fstrim','fstrim.timer',{lever:lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':''),act:'fstrim',sub:'Confirm · weekly TRIM'})} + </div> + <div class="wgrid"> + ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'last test: '+S.smartlast})} + ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0% both drives':'high wear — plan replacement'})} + ${cellCount('deverr','Device errors',{sub:S.devrows.length?'per-device below · reset stays manual':'read / write · RAID1 · watch only'})} + ${cellCount('unalloc','btrfs unallocated',{lever:S.unalloc.st!=='ok'?lever('balance','BALANCE'):'',act:'balance',unit:' GiB',sub:'chunk headroom — raw space not yet carved into chunks'})} + </div> + ${S.devrows.length?` + <div class="engrave striph">device errors — which drive<span class="cnt">· cross-check SMART</span></div> + <div class="jlist">${S.devrows.map(r=>`<div class="lrow jrow"><span class="lamp red"></span> + <span class="who"><b>${r[0]}</b></span><span class="what">${r[1]}</span></div>`).join('')}</div>`:''} + <div class="engrave striph">disk — top consumers<span class="cnt">· evidence only</span></div> + <div class="jlist">${S.dtop.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span> + <span class="what">${r[1]}</span></div>`).join('')}</div> + <div class="engrave striph">filesystem<span class="cnt">· 2</span></div> + <div class="strip two"> + ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')} + ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')} + </div>`; }, + +packages(){ + const kept = OKEEP.map(k=>k.name); + const orows = S.orphlist.filter(r=>!kept.includes(r[0])).map(r=>{ + const a = armed==='orem:'+r[0]; + return `<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${r[0]}</b></span><span class="jmsg">${r[1]}</span> + <span class="what"> + <button class="key sm" onclick="event.stopPropagation();orphKeep('${r[0]}')">KEEP</button> + <button class="key sm ${a?'armed':''}" onclick="event.stopPropagation();orphRemove('${r[0]}')">${a?'REMOVE?':'REMOVE'}</button> + </span></div>`; + }).join(''); + const krows = OKEEP.map(k=>`<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${k.name}</span><span class="what">kept ${k.date} + <button class="key sm" onclick="event.stopPropagation();orphUnkeep('${k.name}')">UNKEEP</button></span></div>`).join(''); + const prows = S.pacnews.map(p=>`<div class="lrow jrow"><span class="lamp ${p.safe?'':'gold'}"></span> + <span class="who"><b>${p.f.split('/').pop()}</b></span><span class="jmsg">${p.f} · ${p.why}</span> + <span class="what"><button class="key sm" onclick="event.stopPropagation();pacnewAct('${p.f}')">${p.safe?'DELETE':'MERGE'}</button></span></div>`).join(''); + const crows = S.upd.c ? S.cves.map(c=>`<div class="lrow jrow"> + <span class="lamp ${c[2]==='high'?'red':c[2]==='medium'?'gold':''}"></span> + <span class="who"><b>${c[0]}</b></span><span class="jmsg">${c[1]}</span> + <span class="what" style="color:${c[2]==='high'?'var(--fail)':'var(--dim)'}">${c[2]} · fix: UPDATE</span></div>`).join('') : ''; + return ` + <div class="wgrid"> + ${cellCount('orphans','Orphans',{lever:lever('orphans','REMOVE ALL'),act:'orphans',sub:'digest below · kept pkgs skipped'})} + ${cellCount('pacnew','.pacnew',{lever:lever('pacnew','REVIEW'),act:'pacnew',sub:'per-file below'})} + ${cellRing('keyring','Keyring age',S.keyring.v/30*100,'days',{lever:lever('keyring','REFRESH'),act:'keyring',sub:'refresh < 30d'})} + ${cellBadge('reboot','Reboot req.',{lever:S.reboot.v==='YES'?lever('reboot','REBOOT','red'):'',act:'reboot',sub:'kernel == modules'})} + </div> + <div class="wgrid"> + ${cellRing('tgage','Topgrade age',S.tgage.v/14*100,'days',{lever:lever('topgrade','TOPGRADE'),act:'topgrade',sub:'ecosystems · guard-aware'})} + </div> + ${deck('packages',[ + {id:'orphans', label:'ORPHANS', count:S.orphans.v, lamp:S.orphans.v?'gold':''}, + {id:'pacnew', label:'PACNEW', count:S.pacnews.length, lamp:S.pacnews.length?'gold':''}, + {id:'advis', label:'ADVISORIES', count:S.upd.c, lamp:S.upd.c?'red':''}, + ])} + ${SUBSEC.packages==='orphans'?` + <div class="engrave striph">orphans — top by size<span class="cnt">· KEEP = intentional (the rust lesson)</span></div> + <div class="jlist">${orows || '<div class="rvnote">no unkept orphans.</div>'}</div> + ${OKEEP.length?`<div class="evh" style="margin-top:8px">kept · ${OKEEP.length}</div><div class="jlist">${krows}</div>`:''}`:''} + ${SUBSEC.packages==='pacnew'?` + <div class="engrave striph">pacnew files<span class="cnt">· safe-delete vs needs-merge</span></div> + <div class="jlist">${prows || '<div class="rvnote">none pending ✓</div>'}</div>`:''} + ${SUBSEC.packages==='advis'?` + <div class="engrave striph">security advisories<span class="cnt">· arch-audit</span></div> + <div class="jlist">${crows || '<div class="rvnote">clean ✓</div>'}</div>`:''} + <div class="engrave striph">update surface<span class="cnt">· informational</span></div> + <div class="strip"> + <div class="lrow"><span class="lamp ${S.upd.c?'red':''}"></span><span class="who">arch-audit</span><span class="what">${S.upd.c?S.upd.c+' CVE — named above':'clean'}</span></div> + <div class="lrow"><span class="lamp gold"></span><span class="who">pending updates</span><span class="what">${S.upd.p}</span></div> + <div class="lrow"><span class="lamp ${S.upd.a>8?'gold':''}"></span><span class="who">AUR / foreign</span><span class="what">${S.upd.a?S.upd.a+' — '+S.aurnames:'fresh'}</span></div> + ${S.upd.f?`<div class="lrow"><span class="lamp gold"></span><span class="who">firmware</span><span class="what">${S.upd.f} — ${S.fwnames}</span></div>`:''} + </div>`; }, + +systemd(){ + const frows = S.funits.map(f=>`<div class="lrow jrow"><span class="lamp red"></span> + <span class="who"><b>${f.u}</b></span><span class="jmsg">since ${f.since} · ${f.info} · journalctl -u ${f.u}</span> + <span class="what"> + <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}')">RESTART</button> + <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}',true)">RESET</button> + </span></div>`).join(''); + return ` + <div class="wgrid"> + ${cellCount('failed','Failed units',{lever:S.failed.v>1?lever('unit_restart','RESTART ALL'):'',act:'unit_restart',sub:S.failed.v?'roster below · per-unit keys':'none failed'})} + ${cellCount('timers','Maint timers',{lever:lever('timers','ENABLE'),act:'timers',sub:'Confirm · enable missing'})} + ${cellBadge('sysrun','is-system-running',{sub:S.sysrun.st==='ok'?'whole-system verdict · watch':'degraded — '+S.failed.v+' failed unit'+(S.failed.v===1?'':'s')+' below'})} + ${cellCount('taint','Kernel taint',{sub:S.taint.st==='ok'?'0 = untainted · watch':'W = kernel warned once — clears on reboot'})} + </div> + ${S.funits.length?`<div class="engrave striph">failed units<span class="cnt">· ${S.funits.length}</span></div> + <div class="jlist">${frows}</div>`:''} + <div class="engrave striph">timer roster<span class="cnt">· 5</span></div> + <div class="strip">${timerRoster()}</div>`; }, + +logs(){ + const sig = jSignal(), kn = jKnown(); + const sigRows = sig.slice(0,10).map(g=>{ + const key = nkey(g); + const armedNow = armed==='mark:'+key; + return `<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${g.id}</b></span><span class="jmsg">${g.msg}</span> + <span class="what">×${g.n} · ${g.first}–${g.last}${g.unit?' · journalctl -u '+g.unit.replace('.service',''):''} + <button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();markKnown('${key}')">${armedNow?'MARK?':'MARK KNOWN'}</button></span></div>`; + }).join(''); + const knRows = kn.map(g=>{ + const key = nkey(g); const n = noiseFor(g); + return `<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${g.id}</span><span class="jmsg">${g.msg}</span> + <span class="what">×${g.n} · ${n.shipped?'shipped default':'marked '+n.date} + <button class="key sm" onclick="event.stopPropagation();unmarkKnown('${key}')">UNMARK</button></span></div>`; + }).join(''); + const cgrows = S.coregroups.map(g=>`<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${g[0]}</b></span><span class="jmsg">coredumpctl info ${g[0]}</span> + <span class="what">×${g[1]} · last ${g[2]}</span></div>`).join(''); + const khrows = S.khwev.map(e=>`<div class="lrow jrow"><span class="who" style="color:var(--dim)">${e[0]}</span> + <span class="jmsg" style="color:var(--fail)">${e[1]}</span></div>`).join(''); + return ` + <div class="wgrid"> + ${cellCount('cores','Coredumps 7d',{lever:lever('cores','CLEAR'),act:'cores',sub:S.coregroups.length?'by binary below · keep 3d':'Auto · keep 3d'})} + ${cellBar('journald','journald','GB',S.journald.v/2*100,{lever:lever('journald','VACUUM'),act:'journald',sub:'Auto · cap 2 GB'})} + ${cellCount('jerr','Journal errors',{sub:'this boot · noise-filtered'})} + ${cellBadge('khw','Kernel / HW',{sub:S.khw.st==='ok'?'MCE · USB · I/O · thermal — watch':'events below — hardware, watch only'})} + </div> + ${deck('logs',[ + {id:'signal', label:'SIGNAL', count:S.jerr.v, lamp:S.jerr.st==='ok'?(S.jerr.v?'gold':''):'gold'}, + {id:'noise', label:'KNOWN NOISE', count:kn.length, lamp:'off'}, + {id:'cores', label:'COREDUMPS', count:S.cores.v, lamp:lampCls(S.cores.st)}, + {id:'hw', label:'KERNEL/HW', count:S.khwev.length||'', lamp:lampCls(S.khw.st)}, + ])} + ${SUBSEC.logs==='signal'?` + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">journal — signal<span class="cnt">· ${sig.length} group${sig.length===1?'':'s'} · ${S.jerr.v} entries</span></span> + ${lever('openjournal','OPEN JOURNAL')} + </div> + <div class="jlist">${sigRows || '<div class="rvnote">no unexplained errors this boot ✓</div>'}</div>`:''} + ${SUBSEC.logs==='noise'?` + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">known noise<span class="cnt">· ${kn.length} pattern${kn.length===1?'':'s'}</span></span> + <button class="key sm ${armed==='clearmarks'?'armed':''}" onclick="event.stopPropagation();clearMarks()">${armed==='clearmarks'?'CLEAR?':'CLEAR MARKS'}</button> + </div> + <div class="jlist">${knRows || '<div class="rvnote">no known-noise patterns match this boot.</div>'}</div>`:''} + ${SUBSEC.logs==='cores'?` + <div class="engrave striph">coredumps — by binary<span class="cnt">· ${S.cores.v} total · coredumpctl info</span></div> + <div class="jlist">${cgrows || '<div class="rvnote">none ✓</div>'}</div> + <div class="evh" style="margin-top:8px">rotation</div> + <div class="jlist">${lrow('applog','app-log cleanup', lever('applog','RUN')+' ', S.applog.v==='ok'?'7d rotation':'stale >7d', 'applog')}</div>`:''} + ${SUBSEC.logs==='hw'?` + <div class="engrave striph">kernel / hw events<span class="cnt">· hardware — watch only</span></div> + <div class="jlist">${khrows || '<div class="rvnote">clean — no MCE, I/O, USB, or thermal events ✓</div>'}</div>`:''}`; }, + +power(){ + const [tc,tg] = String(S.temps.v).split('/'); + const trc = S.temps.st==='ok'?'var(--pass)':S.temps.st==='fail'?'var(--fail)':'var(--amber)'; + const seg = ['perf','bal','power'].map(m=> + `<button class="${S.cpumode.v===m?'on':''}" onclick="event.stopPropagation();setMode('${m}')">${m.toUpperCase()}</button>`).join(''); + const modeCell = ` + <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.cpumode.st)}"></span> CPU mode</div> + <div class="val"><span class="seg">${seg}</span></div> + <div class="sub2">EPP hint · ${zfsMode?'intel_pstate':'amd-pstate'} active</div></div>`; + const veloxCells = zfsMode ? ` + ${cellRing('batt','Battery health',S.batt.v,'%',{sub:S.batt.st==='ok'?'of design · 187 cycles · hardware — watch only':'degraded · plan replacement — watch only'})} + ${cellCount('blimit','Charge limit',{lever:S.blimit.st!=='ok'?lever('bat_limit','SET 80%'):'',act:'bat_limit',sub:'longevity cap · sysfs threshold'})}` : ''; + const evRows = (rows,kind) => rows.length + ? rows.map(r=>{ + if(kind==='boot') return `<div class="lrow jrow"><span class="lamp ${r[1]==='clean'?'':'red'}"></span><span class="who">${r[0]}</span><span class="what">${r[1]}</span></div>`; + if(kind==='ev') return `<div class="lrow jrow"><span class="who" style="color:var(--dim)">${r[0]}</span><span class="jmsg" style="color:var(--fail)">${r[1]}</span></div>`; + // top-memory rows: KILL (arm-to-fire) — disabled on session-critical names + const prot = PROTECTED.includes(r[0]); + const armedNow = armed==='kill:'+r[0]; + const key = prot + ? `<button class="key sm dis" title="session-critical — protected">KILL</button>` + : `<button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();killProc('${r[0]}')">${armedNow?'KILL?':'KILL'}</button>`; + return `<div class="lrow jrow"><span class="who">${r[0]}</span><span class="what">${r[1]} ${key}</span></div>`; + }).join('') + : `<div class="lrow jrow"><span class="what" style="margin-left:0">none this boot ✓</span></div>`; + return ` + <div class="wgrid"> + <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.temps.st)}"></span> Temperatures</div> + <div class="gpair"> + <div class="g"><span class="ring" style="--p:${tc};--rc:${trc}"><b>${tc}°</b></span><span class="lb">CPU</span></div> + <div class="g"><span class="ring" style="--p:${tg};--rc:${trc}"><b>${tg}°</b></span><span class="lb">NVME</span></div> + </div> + <div class="sub2" style="text-align:center">hardware — watch only</div></div> + ${cellBar('mem','Memory free','GB',S.mem.v/125*100,{sub:'of 125 GB · evidence below'})} + ${cellBar('unclean','Unclean boots','%',S.unclean.v,{sub:'evidence below — watch only'})} + ${cellCount('zram','Swap / zram',{lever:S.zram.st!=='ok'?lever('zram_fix','ENABLE'):'',act:'zram_fix',sub:S.zram.st==='ok'?'zstd · active':'no swap configured'})} + </div> + <div class="wgrid"> + ${modeCell} + ${veloxCells} + ${!zfsMode?`<div class="cell"><div class="cap"><span class="lamp off"></span> Battery</div> + <div class="val"><span class="readout" style="color:var(--dim)">n/a</span></div> + <div class="sub2">desktop — no battery</div></div>`:''} + ${cellBadge('throttle','Thermal throttle',{sub:S.throttle.st==='ok'?'no events · hardware — watch only':'active — cooling issue · watch only'})} + </div> + <div class="strip striph" style="align-items:center"> + <span class="engrave">top memory</span> + <span class="engrave" style="grid-column:span 2">evidence — watch only</span> + </div> + <div class="strip"> + <div><div class="evh">kill: sigterm · arm-to-fire</div><div class="jlist">${evRows(S.memtop,'mem')}</div></div> + <div><div class="evh">recent boots</div><div class="jlist">${evRows(S.boots,'boot')}</div></div> + <div><div class="evh">throttle / oom</div><div class="jlist">${evRows(S.pevents,'ev')}</div></div> + </div>`; }, + +services(){ + const unexp = S.ctrs.filter(c=>!cexp(c.name)); + const expd = S.ctrs.filter(c=>cexp(c.name)); + const urows = unexp.map(c=>{ + const aM = armed==='cexp:'+c.name; + return `<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${c.name}</b></span><span class="jmsg">exited ${c.when} · code ${c.code}${c.code?' · docker logs '+c.name:''}</span> + <span class="what"> + <button class="key sm ${aM?'armed':''}" onclick="event.stopPropagation();ctrMark('${c.name}')">${aM?'MARK?':'MARK EXPECTED'}</button> + <button class="key sm" onclick="event.stopPropagation();ctrStartOne('${c.name}')">START</button> + </span></div>`; + }).join(''); + const erows = expd.map(c=>{ + const p = CEXPECT.find(x=>!x.disabled&&x.name===c.name)||{}; + return `<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${c.name}</span><span class="jmsg">exited ${c.when}</span> + <span class="what">${p.shipped?'shipped default':'marked '+(p.date||'')} + <button class="key sm" onclick="event.stopPropagation();ctrUnmark('${c.name}')">UNMARK</button></span></div>`; + }).join(''); + const dfrows = S.ddf.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span> + <span class="what">${r[1]} · reclaimable ${r[2]}</span></div>`).join(''); + const cronRows = S.cron.st==='ok' ? ` + <div class="lrow"><span class="lamp"></span><span class="who">log-cleanup (12:00)</span><span class="what">present</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot hourly</span><span class="what">present</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot daily 02:30</span><span class="what">present</span></div>` : ` + <div class="lrow"><span class="lamp red"></span><span class="who">log-cleanup (12:00)</span><span class="what">not running — cronie down</span></div> + <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot hourly</span><span class="what">not running — cronie down</span></div> + <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot daily 02:30</span><span class="what">not running — cronie down</span></div>`; + return ` + <div class="wgrid"> + ${cellCount('docker','Docker reclaim',{lever:lever('docker','PRUNE'),act:'docker',unit:' GB',sub:'breakdown below · tiers 1+2'})} + ${cellCount('rsync','Last backup',{lever:lever('rsyncnow','RUN NOW'),act:'rsyncnow',unit:' ago',sub:'rsyncshot → truenas'})} + ${cellCount('dstopped','Stopped containers',{lever:unexp.length>1?lever('ctr_start','START ALL'):'',act:'ctr_start',sub:unexp.length?unexp.length+' unexpected · '+expd.length+' expected':'all expected'})} + ${cellBadge('cron','cron',{lever:S.cron.st!=='ok'?lever('cron_fix','START'):'',act:'cron_fix',sub:'entry roster below'})} + </div> + ${deck('services',[ + {id:'containers', label:'CONTAINERS', count:unexp.length?unexp.length+'!':expd.length, lamp:unexp.length?'gold':''}, + {id:'docker', label:'DOCKER DISK', count:S.docker.v+' GB', lamp:lampCls(S.docker.st)}, + {id:'cron', label:'CRON & BACKUPS', count:'', lamp:(S.cron.st!=='ok'||S.rsync.st!=='ok')?'red':''}, + ])} + ${SUBSEC.services==='containers'?` + ${unexp.length?`<div class="engrave striph">containers — signal<span class="cnt">· ${unexp.length} unexpected</span></div> + <div class="jlist">${urows}</div>`:''} + <div class="engrave striph">containers — expected<span class="cnt">· ${expd.length}</span></div> + <div class="jlist">${erows || '<div class="rvnote">no expected-stopped containers present.</div>'}</div>`:''} + ${SUBSEC.services==='docker'?` + <div class="engrave striph">docker disk<span class="cnt">· system df · reclaimable by type</span></div> + <div class="jlist">${dfrows}</div> + <div class="evh" style="margin-top:8px">virt</div> + <div class="jlist">${lrow('libvirt','libvirt VMs','', 'shut off · expected')}</div>`:''} + ${SUBSEC.services==='cron'?` + <div class="engrave striph">cron entries — expected<span class="cnt">· 3</span></div> + <div class="jlist">${cronRows}</div> + <div class="evh" style="margin-top:8px">backups</div> + <div class="jlist">${backupRoster()}</div>`:''}`; }, + +snapshots(){ + const t = S.snaptypes; + const staleN = Math.max(0, t.single-2); + const aS = armed==='snapstale'; + return ` + <div class="wgrid"> + ${cellCount('snapper','Snapper count',{lever:lever('snapper','PRUNE'),act:'snapper',sub:S.snapper.st==='ok'?'by type below':'pile-up — see singles below'})} + ${cellBadge('stimer','Auto-timer',{lever:S.stimer.st!=='ok'?lever('stimer_fix','ENABLE'):'',act:'stimer_fix',sub:'timeline + cleanup'})} + ${cellCount('oldest','Oldest',{unit:' days',sub:S.oldest.st==='ok'?'within budget':'chain hoards space'})} + <div class="cell">${S.retention.st!=='ok'?`<span class="lever">${lever('retention','FIX')}</span>`:''} + <div class="cap"><span class="lamp ${lampFor('retention','retention')}"></span> Retention</div> + <div class="val" style="font-size:12px;color:${S.retention.st==='ok'?'var(--cream)':'var(--amber)'}">${S.retention.v}</div> + <div class="sub2">${S.retention.st==='ok'?'monthly=2 (the /home lesson)':'monthly=10 — silently hoards space'}</div>${errline('retention')}</div> + </div> + <div class="engrave striph">by type<span class="cnt">· singles escape timeline cleanup</span></div> + <div class="jlist"> + <div class="lrow jrow"><span class="lamp"></span><span class="who">timeline</span><span class="what">${t.timeline} · auto-pruned</span></div> + <div class="lrow jrow"><span class="lamp ${staleN>3?'gold':''}"></span><span class="who"><b>single (manual)</b></span> + <span class="jmsg">oldest ${t.oldsingle}</span> + <span class="what">${t.single}${staleN>3?` <button class="key sm ${aS?'armed':''}" onclick="event.stopPropagation();snapDelStale()">${aS?'DELETE?':'DELETE STALE'}</button>`:''}</span></div> + <div class="lrow jrow"><span class="lamp"></span><span class="who">pre / post</span><span class="what">${t.prepost} · paired with pacman ops</span></div> + </div>`; }, + +network(){ + const n = parseInt(S.ts.v)||0; + const bars = [1,2,3,4].map(i=>`<i class="${i<=n?'on':''}"></i>`).join(''); + const sig = lsnSignal(); + const exp = LEXPECT.filter(p=>!p.disabled); + const pubN = sig.filter(r=>isPub(r.bind)).length; + const sigRows = sig.map(r=>{ + const key = lkey(r); + const aFix = armed==='lsnfix:'+key, aMark = armed==='lexp:'+key; + const bindTag = isPub(r.bind) + ? `<span class="badge red" style="font-size:.55rem">PUBLIC</span>` + : `<span style="color:var(--dim);font-size:10px">loopback</span>`; + return `<div class="lrow jrow"><span class="lamp ${isPub(r.bind)?'red':'gold'}"></span> + <span class="who"><b>${r.proc}</b></span> + <span class="jmsg">:${r.port} · ${r.bind}${r.unit?' · '+r.unit+'.service':''}</span> + <span class="what">${bindTag} + <button class="key sm ${aMark?'armed':''}" onclick="event.stopPropagation();markExpected('${key}')">${aMark?'MARK?':'MARK EXPECTED'}</button> + <button class="key sm ${aFix?'armed':''}" onclick="event.stopPropagation();lsnFix('${key}')">${aFix?(r.unit?'STOP?':'KILL?'):(r.unit?'STOP':'KILL')}</button> + </span></div>`; + }).join(''); + const expRows = exp.map(p=>{ + const key = p.proc+'|'+p.port; + return `<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${p.proc}</span><span class="jmsg">:${p.port}</span> + <span class="what">${p.shipped?'shipped default':'marked '+p.date} + <button class="key sm" onclick="event.stopPropagation();unmarkExpected('${key}')">UNMARK</button></span></div>`; + }).join(''); + const fwNote = S.fw.st!=='ok' && pubN + ? ` <span style="color:var(--fail);letter-spacing:0;text-transform:none">· ufw down — ${pubN} public bind${pubN===1?'':'s'} exposed</span>` : ''; + return ` + <div class="wgrid"> + ${cellBadge('fw','Firewall',{lever:S.fw.st!=='ok'?lever('fw_fix','ENABLE',S.fw.st==='fail'?'red':''):'',act:'fw_fix',sub:'ufw · mosh rule'})} + ${cellCount('listen','Unexpected listeners',{sub:S.listen.st==='ok'?'all matched · '+exp.length+' expected':S.listen.v+' unmatched · '+pubN+' public-bind'})} + <div class="cell">${S.ts.st!=='ok'?`<span class="lever">${lever('ts_fix','RESTART')}</span>`:''} + <div class="cap"><span class="lamp ${lampFor('ts','ts_fix')}"></span> Tailscale</div> + <div class="val"><span class="ladder ${S.ts.st==='fail'?'bad':''}">${bars}</span> + <span class="readout" style="font-size:15px">${S.ts.v}</span></div> + <div class="sub2">peers online</div>${errline('ts_fix')}</div> + ${cellCount('f2b','fail2ban',{lever:S.f2b.st!=='ok'?lever('f2b_fix','RESTART'):'',act:'f2b_fix',sub:S.f2b.st==='ok'?'sshd jail · '+S.f2b.v+' bans 7d':'service down'})} + </div> + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">listeners — signal<span class="cnt">· ${sig.length} unexpected</span>${fwNote}</span> + </div> + <div class="jlist">${sigRows || '<div class="rvnote">every listener matches the expected list.</div>'}</div> + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">expected<span class="cnt">· ${exp.length} pattern${exp.length===1?'':'s'}</span></span> + <button class="key sm ${armed==='clearlsn'?'armed':''}" onclick="event.stopPropagation();clearLsn()">${armed==='clearlsn'?'CLEAR?':'CLEAR MARKS'}</button> + </div> + <div class="jlist">${expRows}</div> + <div class="engrave striph">peers<span class="cnt">· 4</span></div> + <div class="strip">${peerRoster()}</div> + <div class="engrave striph">resolution<span class="cnt">· 2</span></div> + <div class="strip two"> + ${lrow('ntp','NTP (chrony)', S.ntp.st!=='ok'?lever('ntp_fix','RESTART')+' ':'', S.ntp.st==='ok'?'synced · +2 ms':undefined, 'ntp_fix')} + ${lrow('dns','DNS / NetworkManager', S.dns.st!=='ok'?lever('netdoc','NET DOCTOR')+' ':'', undefined, 'netdoc')} + </div>`; }, +}; + +/* ---- doctor: streams into the results wall; the metrics subpanel stays put ---- */ +let doctorRunning = false; +function startDoctor(mode){ + if(doctorRunning){ toast('doctor already running'); return; } + const title = mode==='reclaim' ? 'reclaim space' : 'clean up'; + const steps = [ + {name:'cache trim', act:'clean_cache', + res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'reclaimed 8.4 GB') : 'nothing to reclaim'}, + {name:'journal vacuum', act:'journald', + res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'reclaimed 0.9 GB') : 'already tight'}, + {name:'coredump clear', act:'cores', + res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return n+' cleared';})() : 'none to clear'}, + {name:'app-log cleanup', act:'applog', + res:()=> (S.applog={v:'ok',st:'ok'}, 'nothing older than 7d')}, + ]; + if(mode==='reclaim'){ + steps.push( + {name:'docker prune (tier 1)', act:'docker', + res:()=> S.docker.st==='warn' ? (S.docker={v:0.2,st:'ok'}, 'reclaimed 2.8 GB') : 'nothing to prune'}, + {name:'snapper cleanup', act:'snapper', + res:()=> (S.snapper={v:Math.min(S.snapper.v,31),st:'ok'}, 'retention applied')}, + {name:'disk re-read', act:'applog', + res:()=> { S.disk={v:Math.max(60,S.disk.v-11), st:S.disk.v-11>90?'fail':S.disk.v-11>80?'warn':'ok'}; return 'now '+S.disk.v+'%'; }}, + ); + } + doctorRunning = true; + resAdd('doctor · '+title, 'started · '+steps.length+' actions', 'done'); + render(); + let i=0, entry=null, failed=0; + function step(){ + if(entry){ + const p = steps[i-1]; + if(failMode){ ERR[p.act]=ACTIONS[p.act].err; failed++; resDone(entry,'fail',ERR[p.act]); } + else { delete ERR[p.act]; resDone(entry,'done',p.res()); } + render(); + } + if(i<steps.length){ + entry = resAdd(steps[i].name, 'running…'); i++; + doctorTimer = setTimeout(step, 1000); + } else { + doctorRunning = false; + resAdd('doctor · '+title, + `${steps.length-failed} done${failed?' · '+failed+' failed':''}`, + failed?'fail':'done'); + render(); + } + } + step(); +} + +/* ---- review & fix ---- */ +const REVIEW = [ + {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', ok:()=>zfsMode||S.fstrim.st==='ok'}, + {act:'ztrim_fix', who:'zpool autotrim', why:'TRIM disabled on the pool', id:'ztrim', ok:()=>!zfsMode||S.ztrim.st==='ok'}, + {act:'scrub', who:'scrub', why:'past the scrub cadence', id:'scrub', ok:()=>S.scrub.st==='ok'}, + {act:'orphans', who:'orphans', why:'unowned packages present', id:'orphans', ok:()=>S.orphans.st==='ok'}, + {act:'pacnew', who:'.pacnew files', why:'configs awaiting merge', id:'pacnew', ok:()=>S.pacnew.st==='ok'}, + {act:'timers', who:'maint timers', why:'timers not firing', id:'timers', ok:()=>S.timers.st==='ok'}, + {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', ok:()=>S.keyring.st==='ok'}, + {act:'docker', who:'docker reclaim', why:'space reclaimable (tiers 1+2)', id:'docker', ok:()=>S.docker.st==='ok'}, + {act:'snapper', who:'snapper', why:'retention pass', id:'snapper', ok:()=>S.snapper.st==='ok'&&S.snapper.v<42}, + {act:'deeptrim',who:'deep-trim', why:'keep 1 version — max reclaim, less downgrade headroom', id:'deeptrim', ok:()=>S.deeptrim.v==='keep 1'}, + {act:'unit_restart', who:'failed units', why:'restart the failed unit(s)', id:'failed', ok:()=>S.failed.st==='ok'}, + {act:'tgage', who:'topgrade', why:'ecosystem updates stale', id:'tgage', ok:()=>S.tgage.st==='ok', useAct:'topgrade'}, + {act:'f2b_fix', who:'fail2ban', why:'service down', id:'f2b', ok:()=>S.f2b.st==='ok'}, + {act:'cron_fix',who:'cronie', why:'service down', id:'cron', ok:()=>S.cron.st==='ok'}, + {act:'ntp_fix', who:'NTP (chrony)', why:'clock unsynchronized', id:'ntp', ok:()=>S.ntp.st==='ok'}, + {act:'ts_fix', who:'tailscaled', why:'peers unreachable', id:'ts', ok:()=>S.ts.st==='ok'}, + {act:'stimer_fix', who:'snapshot timer', why:'auto-snapshots not firing', id:'stimer', ok:()=>S.stimer.st==='ok'}, + {act:'zram_fix',who:'zram', why:'no swap configured', id:'zram', ok:()=>S.zram.st==='ok'}, + {act:'balance', who:'btrfs balance', why:'unallocated headroom low', id:'unalloc', ok:()=>zfsMode||S.unalloc.st==='ok'}, + {act:'reinstall', who:'file integrity', why:'modified package files', id:'integ', ok:()=>S.integ.st==='ok'}, + {act:'retention', who:'snapshot retention', why:'monthly limit hoards space', id:'retention', ok:()=>S.retention.st==='ok'}, + {act:'rsyncnow', who:'backup', why:'stale or failed — run now', id:'rsync', ok:()=>S.rsync.st==='ok'}, + {act:'reboot', who:'reboot', why:'running kernel != installed', id:'reboot', ok:()=>S.reboot.v!=='YES'}, + {act:'bat_limit', who:'charge limit', why:'no charge cap — battery longevity', id:'blimit', ok:()=>!zfsMode||S.blimit.st==='ok'}, + {act:'ctr_start', who:'stopped containers', why:'unexpected exited containers', id:'dstopped', ok:()=>S.dstopped.st==='ok'}, + {act:'fw_fix', who:'firewall', why:'ufw inactive — host exposed', id:'fw', ok:()=>S.fw.st==='ok'}, +]; +function renderReview(){ + const items = REVIEW.filter(r=>!r.ok()||ERR[r.useAct||r.act]); + const rows = items.map(r=>{ + const act = r.useAct||r.act; + const done = r.ok(); + const failed = !!ERR[act]; + return `<div class="rvrow"> + <span class="lamp ${failed?'red':done?'':lampCls(S[r.id].st)}"></span> + <span class="who">${r.who}</span> + <span class="why ${failed?'errtx':''}">${failed?'✗ '+ERR[act]:r.why}</span> + <span class="keys">${done?'<span class="res">done ✓</span>':lever(act,'FIX')}</span></div>`; + }).join(''); + return ` + <div class="subhead"><span class="lamp gold"></span><span class="nm">Doctor · Review & fix</span> + <span class="cnt">${items.length} Confirm items</span></div> + <div class="rvlist">${rows||'<div class="rvnote">nothing needs review — all Confirm items clear.</div>'}</div> + <div class="rvnote">Destructive fixes arm on first press, fire on the second. Metrics without a determinate + lever — failed units, unclean boots, updates, CVEs — stay on their subpanels as read-only telemetry.</div> + <div class="ofoot"><button class="key" onclick="go('cat:storage')">DONE</button></div>`; +} + +/* ================= loading / hydration ================= */ +/* tiers mirror real probe cost: t0 = local file/daemon reads, t1 = process probes + (smartctl, docker df, snapper, du), t2 = package scans (cached in real build) */ +const TIERS = [ + {ms:220, cats:['systemd','power','network','snapshots']}, + {ms:650, cats:['storage','logs','services']}, + {ms:1150, cats:['packages']}, +]; +function startLoad(){ + loadTimers.forEach(clearTimeout); loadTimers=[]; + loaded.clear(); + $('lastscan').textContent = 'scanning…'; + render(); + TIERS.forEach(t=>{ + loadTimers.push(setTimeout(()=>{ + t.cats.forEach(c=>loaded.add(c)); + if(loaded.size===CATS.length) $('lastscan').textContent='last scan just now · 44 checks · 1.2s'; + render(); + }, t.ms)); + }); +} + +/* ================= render ================= */ +const ACT2CAT = { clean_cache:'storage', scrub:'storage', fstrim:'storage', deeptrim:'storage', + balance:'storage', reinstall:'storage', smarttest:'storage', ztrim_fix:'storage', + orphans:'packages', pacnew:'packages', keyring:'packages', reboot:'packages', topgrade:'packages', + timers:'systemd', unit_restart:'systemd', + cores:'logs', journald:'logs', applog:'logs', openjournal:'logs', + zram_fix:'power', bat_limit:'power', + docker:'services', cron_fix:'services', rsyncnow:'services', ctr_start:'services', fw_fix:'network', + snapper:'snapshots', stimer_fix:'snapshots', retention:'snapshots', + f2b_fix:'network', ntp_fix:'network', ts_fix:'network', netdoc:'network' }; +function catErr(cid){ return Object.keys(ERR).some(a=>ACT2CAT[a]===cid); } + +function renderSelector(){ + $('selector').innerHTML = CATS.map(c=>{ + const sel = view==='cat:'+c.id; + if(!loaded.has(c.id)){ + return `<div class="selbtn loading ${sel?'sel':''}" onclick="go('cat:${c.id}')"> + <span class="lamp busy"></span><span class="nm">${c.name}</span><span class="ct">···</span></div>`; + } + const k = counts(c); + const err = catErr(c.id); + const ct = err ? `<span class="a crit">✗ fail</span> · <span class="k">${k.ok}✓</span>` + : k.attn ? `<span class="a ${k.crit?'crit':''}">${k.attn}!</span> · <span class="k">${k.ok}✓</span>` + : `<span class="k">${k.ok} ✓</span>`; + return `<div class="selbtn ${(k.crit||err)?'crit':k.attn?'attn':'clear'} ${sel?'sel':''}" onclick="go('cat:${c.id}')"> + <span class="lamp ${(k.crit||err)?'red':k.attn?'gold':''}"></span> + <span class="nm">${c.name}</span><span class="ct">${ct}</span></div>`; + }).join(''); +} +function renderSub(){ + const el = $('sub'); + if(view==='review'){ el.innerHTML = renderReview(); return; } + const cid = view.slice(4); + if(!loaded.has(cid)){ + const cat = CATS.find(c=>c.id===cid); + el.innerHTML = `<div class="subhead"><span class="lamp busy"></span><span class="nm">${cat.name}</span> + <span class="cnt">gathering…</span></div> + <div class="skel"><span class="lamp busy" style="width:14px;height:14px"></span>probing</div>`; + return; + } + const cat = CATS.find(c=>c.id===cid); + const k = counts(cat); + const [nfix,nwatch] = fixwatch(cid); + const cnt = (k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>` + : `<span class="okc">all clear · ${k.ok} ✓</span>`) + + ` · ${nfix} fixable · ${nwatch} watch`; + el.innerHTML = `<div class="subhead"> + <span class="lamp ${k.crit?'red':k.attn?'gold':''}"></span> + <span class="nm">${cat.name}</span><span class="cnt">${cnt}</span></div>` + SUB[cid](); +} +function render(){ + recalcJerr(); recalcListen(); recalcCtrs(); + renderSelector(); renderSub(); + // faceplate verdict = worst diagnostic state + let worst='ok'; + DIAG.forEach(d=>{ const st=S[d].st; + if(st==='fail') worst='fail'; else if(st==='warn'&&worst!=='fail') worst='warn'; }); + const fl=$('facelamp'); + fl.className = 'lamp ' + (loaded.size<CATS.length?'busy':worst==='fail'?'red':worst==='warn'?'gold':''); + // badges + let attn=0; CATS.forEach(c=>{ if(loaded.has(c.id)){ attn+=counts(c).attn; } }); + $('attbadge').textContent = loaded.size<CATS.length ? 'SCANNING' : (attn ? attn+' ATTN' : 'ALL CLEAR'); + const errN = Object.keys(ERR).length; + $('errbadge').style.display = errN ? '' : 'none'; + $('errbadge').textContent = errN + ' FAILED'; + // updates strip: green when nothing, amber for a few, red for CVEs / a lot + const u = S.upd; + const ucls = (u.c>0 || u.p>100) ? 'crit' : (u.p>0 || u.a>0 || u.f>0) ? 'warn' : 'ok'; + $('updbar').className = 'updbar ' + ucls; + // CVE badge: red and visible only when there are CVEs to show + $('cvebadge').style.display = S.upd.c ? '' : 'none'; + $('cvebadge').textContent = S.upd.c + ' CVE'; + $('upd-p').textContent = S.upd.p; $('upd-c').textContent = S.upd.c + ' CVE'; + $('upd-c').classList.toggle('cve', S.upd.c>0); + $('upd-c').style.color = S.upd.c ? '' : 'var(--dim)'; + $('upd-a').textContent = S.upd.a; $('upd-f').textContent = S.upd.f; + $('k-clean').classList.toggle('on', doctorRunning); + $('k-review').classList.toggle('on', view==='review'); + // guard-armed update keys: red + '?' while awaiting the override press + $('k-update').classList.toggle('armed', armed==='sysupdate'); + $('k-update').textContent = armed==='sysupdate' ? 'UPDATE?' : 'UPDATE'; + $('k-topgrade').classList.toggle('armed', armed==='topgrade'); + $('k-topgrade').textContent = armed==='topgrade' ? 'TOPGRADE?' : 'TOPGRADE'; + // reboot offer key appears on the updates strip once an update lands + $('upd-rb').innerHTML = S.reboot.v==='YES' ? lever('reboot','REBOOT','red') : ''; +} +function go(v){ + armed=null; + if(v==='doctor'||v==='reclaim'){ startDoctor(v==='reclaim'?'reclaim':undefined); return; } + view=v; render(); +} +function closePanel(){ $('capsule').style.display='none'; $('closed').style.display='block'; } +function openPanel(){ $('capsule').style.display='block'; $('closed').style.display='none'; startLoad(); } +document.addEventListener('keydown', e=>{ if(e.key==='Escape') closePanel(); }); +startLoad(); + +/* live refresh sim: the thermal/memory group re-reads every 3 s while its + subpanel is visible (panel-open gated, like the audio panel's meters) */ +setInterval(()=>{ + if($('capsule').style.display==='none') return; + if(view!=='cat:power' || !loaded.has('power')) return; + const j = (v,lo,hi)=>Math.max(lo,Math.min(hi, v + (Math.random()<0.5?-1:1))); + let [tc,tg] = String(S.temps.v).split('/').map(Number); + if(badMode){ tc=j(tc,88,94); tg=j(tg,74,80); } else { tc=j(tc,57,65); tg=j(tg,50,58); } + S.temps.v = tc+'/'+tg; + S.mem.v = Math.round((S.mem.v + (Math.random()-0.5)*0.6)*10)/10; + render(); +}, 3000); +</script> +</body> +</html> diff --git a/docs/prototypes/README.org b/docs/prototypes/README.org new file mode 100644 index 0000000..9df85cb --- /dev/null +++ b/docs/prototypes/README.org @@ -0,0 +1,21 @@ +#+TITLE: Panel & Waybar Design Prototypes +#+AUTHOR: Craig Jennings + +Self-contained HTML/CSS design prototypes for the instrument-console panel +family and the waybar redesign. Each opens standalone in a browser (no external +assets). These are the normative visual references the specs in [[file:../specs/][docs/specs/]] +point at. + +* Prototypes + +- [[file:2026-07-03-instrument-console-panels-prototype.html][2026-07-03-instrument-console-panels-prototype.html]] — the net + bluetooth + pair; the approved faceplate design that shipped. Normative reference for + [[file:../specs/2026-07-03-instrument-console-panels-spec.org][the instrument-console spec]]. +- [[file:2026-07-03-net-panel-rescan-prototype.html][2026-07-03-net-panel-rescan-prototype.html]] — the manual rescan/scan ⟳ + affordance for the NETWORKS/NEARBY headers (busy-style throbber + list fade). +- [[file:2026-07-03-sound-panel-prototype.html][2026-07-03-sound-panel-prototype.html]] — the audio/pulsemixer console; layout + reference for [[file:../specs/2026-07-03-audio-panel-spec.org][the audio-panel spec]]. +- [[file:2026-07-03-panel-widget-gallery-prototype.html][2026-07-03-panel-widget-gallery-prototype.html]] — the shared instrument-console + widget kit (lamps, engraved sections, console keys, needle gauges). +- [[file:2026-07-03-waybar-redesign-prototype.html][2026-07-03-waybar-redesign-prototype.html]] — three directions for sprucing up + waybar in the dupre instrument-console aesthetic (future work). diff --git a/docs/specs/2026-07-02-bluetooth-panel-spec.org b/docs/specs/2026-07-02-bluetooth-panel-spec.org new file mode 100644 index 0000000..f1b3ac1 --- /dev/null +++ b/docs/specs/2026-07-02-bluetooth-panel-spec.org @@ -0,0 +1,476 @@ +#+TITLE: Bluetooth Panel — CLI-Driven, Net-Panel Kin +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Bluetooth Panel — CLI-Driven, Net-Panel Kin +:PROPERTIES: +:ID: 8af6a76a-5665-4d20-9efd-ffdf7460c981 +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Shipped through phase 3; build task DONE and manual tests filed.) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: 1271a845-4463-4831-9902-990eda6b2265 +:END: +- [2026-07-02 Thu] IMPLEMENTED — all five phases shipped the same day + (dotfiles eb2230f / 76b2c05 / e372de3 / 2a026b1; archsetup d8d8c53): + engine, panel, bar module + blueman retirement, bt-priv + package swap, + install wiring proven by VM assertions. 43 dotfiles suites green, both + AT-SPI smokes green, panels verified live; the phase 4-5 VM assertions + run on the next VM pass. +- [2026-07-02 Thu] DOING — spec-response decomposed the five phases into + build sub-tasks under the todo.org parent (:SPEC_ID: bound); build + started same day per Craig ("4 first, then 1" — bugs then bluetooth). +- [2026-07-02 Thu] READY — spec-review passed the gate: all four + decisions resolved, phases decomposable, CLI verbs verified against + bluez 5.86. Two non-blocking findings recorded and dispositioned in + the same pass (donor-pattern answers). +- [2026-07-02 Thu] DRAFT — initial spec from Craig's request: a bluetooth + module driving a CLI underneath, consistent with the net panel, minimal + interface, full functionality, diagnostics section, visual mockups. + +* Metadata + +| Field | Value | +|--------+---------------------------------------------------------------------------------| +| Status | implemented | +|--------+---------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|--------+---------------------------------------------------------------------------------| +| Repo | dotfiles (bt module); archsetup (packages, sudoers, keybind defaults) | +|--------+---------------------------------------------------------------------------------| +| Kin | net panel (architecture donor), desktop-settings panel (same donor, shared css) | +|--------+---------------------------------------------------------------------------------| + +* Problem + +Bluetooth on both daily drivers runs through blueman: a tray applet plus a +GTK3 manager window (Super+Shift+B). It's the odd one out on the desktop — +a foreign visual style next to the dupre-themed panels, a tray icon where +every other indicator is a first-class waybar module, and no diagnostics +story at all. When the BT mouse fails to reconnect at boot (a recurring +gotcha — touchpad-auto exists because of it) or headphones pair but route +no audio, the fix is a terminal séance: bluetoothctl, rfkill, systemctl, +wpctl, in whatever order folklore suggests. + +The net panel proved the shape that fixes this: a minimal layer-shell +popup over a GTK-free engine that drives a CLI, with a diagnostics tab +that names the failure and offers the repair. Bluetooth is the same +problem with a smaller surface: one adapter, a handful of devices, a +short list of well-known failure modes. + +* Goals + +1. Visibility: adapter power state and every known device with live state + (connected, battery, signal) in one glance — panel and bar module agree. +2. Control: power, scan, pair, connect, disconnect, forget — full + functionality from the panel, zero terminals (the net panel's V2 + contract). +3. Diagnostics: a doctor that walks the known failure chain (adapter → + rfkill → service → power → device → audio profile), names the broken + link in evidence rows, and offers tiered repairs. +4. Consistency: same stack, same window shape, same interaction grammar, + same palette as the net panel. A user who knows one panel knows both. + +Audio-profile switching is in scope for v1 (Craig, 2026-07-02 — "bitten +by this too many times to count"): the doctor's audio-profile step +carries a one-click repair, not just a diagnosis, and connected audio +devices surface their active profile (details in the doctor chain below). + +Non-goals (this iteration): OBEX file transfer, multi-adapter support +(both machines have one controller), BLE sensor/GATT browsing. + +* Design sketch + +** Architecture — the net panel's stack, verbatim + +- GTK4 + gtk4-layer-shell, Blueprint .blp compiled to committed .ui + (=make ui=), PyGObject at runtime. +- Humble-object split: GTK-free =PanelModel= presenter (unit-tested like + net's), thin composite-widget pages, =bg(work, done)= worker-thread + helper for every slow call. +- Engine: a new =bt= package in dotfiles (=bluetooth/src/bt/=, sibling of + =net/=), CLI entry =bt= with =bt status= / =bt panel= / =bt doctor= — + the same cmd/cli layout as net. +- Layer-shell OVERLAY popup anchored TOP+RIGHT, 380x520, Esc closes, + focus-out auto-hides, single-instance toggle via a =bt-panel= wrapper. + Dupre palette css shared with the net panel (the factored css asset the + desktop-settings spec calls for — three consumers now, so the factoring + happens in this project's phase 1 if settings hasn't landed it). +- Testing: engine TDD with fake binaries on a temp PATH (fake-bluetoothctl, + fake-rfkill, fake-systemctl, fake-wpctl); PanelModel unit suite; one + gated AT-SPI smoke (=make test-panel= pattern). + +** CLI backing — bluetoothctl one-shot verbs + +bluez 5.86 (installed) supports everything non-interactive: + +- Adapter: =bluetoothctl show= (powered, discoverable, pairable), + =bluetoothctl power on|off=. +- Device lists: =bluetoothctl devices Paired|Connected|Trusted= — the + Paired view is a merge of Paired + Connected states; =bluetoothctl info + <mac>= per row fills caption detail (battery percentage rides bluez's + built-in Battery1 profile and appears in info output; RSSI appears + during discovery). +- Scan: =bluetoothctl --timeout N scan on= (bounded discovery burst), + then =devices= diffed against Paired for the Nearby list. The panel + scans in 8s bursts with a live "Scanning…" state rather than an + unbounded scan. +- Connect/disconnect/forget: =bluetoothctl connect|disconnect|remove <mac>=. +- Pairing: the one interactive corner. =bluetoothctl pair <mac>= can demand + a passkey confirmation. The engine drives bluetoothctl's line protocol + over a pty with a bounded state machine (expect "Confirm passkey", + reply yes/no); a passkey prompt surfaces as a panel dialog showing the + six digits, mirroring the net panel's password dialog. NoInputNoOutput + devices (mice, most headphones) sail through without the dialog. +- rfkill: the user is in the =rfkill= group, so block/unblock is + unprivileged (=rfkill unblock bluetooth=). +- Privileged path: exactly one verb needs root — =systemctl restart + bluetooth= — so =bt-priv= is a one-verb closed helper with its own + NOPASSWD sudoers rule placed by archsetup, cloning net-priv's + regex-validated pattern rather than widening net-priv's scope. + +** Panel anatomy + +Two tabs. Devices is the panel; Diagnostics is the escape hatch. + +Devices tab, Paired sub-view (the default — daily use is reconnecting +known devices, not discovering new ones): + +#+begin_example +╭──────────────────────────────────────────────╮ +│ [ Devices ] [ Diagnostics ] │ ← top switcher +│ │ +│ Bluetooth ●──○ hci0 on │ ← adapter row: power switch +│ ──────────────────────────────────────────── │ +│ [ Paired ] [ Nearby ] │ ← sub-view switcher +│ ┌──────────────────────────────────────────┐ │ +│ │ MX Master 3 │ │ +│ │ Connected · battery 80% │ │ +│ │ WH-1000XM4 │ │ +│ │ Paired, not connected │ │ +│ │ K380 Keyboard │ │ +│ │ Paired, not connected │ │ +│ │ │ │ +│ └──────────────────────────────────────────┘ │ +│ [ Disconnect ] [ Forget ] │ ← acts on selected row +╰──────────────────────────────────────────────╯ +#+end_example + +The primary button is one control with a state-following label: +"Connect" when the selection is disconnected (suggested-action styling), +"Disconnect" when connected. Row-activate (Enter / double-click) +connects — never disconnects — matching the net panel's asymmetry. +Captions carry the human state line; the MAC lives in the row tooltip, +not the visible caption. + +Devices tab, Nearby sub-view: + +#+begin_example +╭──────────────────────────────────────────────╮ +│ [ Devices ] [ Diagnostics ] │ +│ │ +│ Bluetooth ●──○ hci0 on │ +│ ──────────────────────────────────────────── │ +│ [ Paired ] [ Nearby ] │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Scanning… (6s) │ │ ← overlay state label +│ │ JBL Flip 6 −58 dBm │ │ +│ │ Pixel 9 −71 dBm │ │ +│ │ (unnamed) 74:A5:… −83 dBm │ │ +│ └──────────────────────────────────────────┘ │ +│ [ Pair ] [ Rescan ] [ Discoverable ⊙ ] │ +╰──────────────────────────────────────────────╯ +#+end_example + +Pair does the whole intended thing — pair, then trust, then connect — +because pairing a device means "use it now and reconnect on its own +later" (decision below). Discoverable is a toggle for the inbound case +(pairing a phone TO the laptop), off by default, auto-off with bluez's +discoverable-timeout. Rows sort by RSSI, strongest first; named devices +above unnamed ones. + +Diagnostics tab (mirrors the net panel's shape: one big verb + streaming +evidence rows + tiered repairs behind confirmation): + +#+begin_example +╭──────────────────────────────────────────────╮ +│ [ Devices ] [ Diagnostics ] │ +│ │ +│ [ Get Bluetooth Working ] [ Advanced ▸]│ +│ ┌──────────────────────────────────────────┐ │ +│ │ ✓ Adapter present (hci0) │ │ +│ │ ✓ Not blocked (rfkill clear) │ │ +│ │ ✓ bluetooth.service active │ │ +│ │ ✓ Adapter powered │ │ +│ │ ✗ MX Master 3: paired but unreachable │ │ +│ │ … Re-pair suggested — see below │ │ +│ │ │ │ +│ │ Fix: [ Reconnect ] [ Re-pair device ] │ │ +│ └──────────────────────────────────────────┘ │ +│ power-cycle · restart service · unblock │ ← tiered repairs (confirm) +╰──────────────────────────────────────────────╯ +#+end_example + +The doctor chain, in order, each an evidence row: + +1. Adapter present — =bluetoothctl list= / rfkill has an hci entry. + Absent → hardware/driver verdict, no repair offered. +2. rfkill state — soft-blocked names the likely cause when the + airplane-mode state file says airplane is on ("Blocked by airplane + mode — turn airplane mode off"), otherwise offers Unblock (no root + needed, rfkill group). +3. bluetooth.service — inactive/failed → offer restart (the one bt-priv + verb), evidence quotes the last journal line. +4. Adapter powered — off → offer power on (and note if a boot-time + policy keeps turning it off). +5. Per-device reachability — paired-but-connect-fails distinguishes + "device off/out of range" (RSSI absent in a scan burst) from "bond + corrupt" (connect error string), and only the latter suggests the + re-pair repair (remove + pair + trust + connect, confirmed first — + it's the destructive tier). +6. Audio profile (audio devices only) — device connected but no wpctl + sink/source, or the card stuck in HSP/HFP when A2DP is expected: + evidence names the active profile and offers the repair inline — + "Switch to A2DP" drives =wpctl set-profile <card> <index>= (profile + inventory from =pw-dump= — ground truth 2026-07-02: wpctl can't + enumerate a card's profiles, and the card's =bluez5.profile= prop + reads "off" mid-stream; the card's Profile param and the sink node's + =api.bluez5.profile= are authoritative), verifies the sink came back + in the expected profile, and reports fixed or no-change. In v1 per + Craig (2026-07-02): this failure mode has bitten repeatedly, so it + gets the one-click fix, not just a diagnosis. Connected audio-device + row captions also show the profile when it's the degraded one + ("Connected · mic mode (HSP)") so the state is visible before the + doctor runs. + +Repairs confirm with the net panel's future-tense scope copy ("This will +restart the Bluetooth service. Connected devices will drop and +reconnect."), run on the worker thread, verify after (re-read state, +report "fixed" or "no change"), and never chain silently. + +** Bar module + +=custom/bluetooth= replacing the blueman-applet tray icon: the panel's +glanceable layer, one glyph, state-following like =custom/net=: + +#+begin_example + off / blocked (dim; red slash variant when rfkill-blocked) + on, nothing connected (dim) + connected (white; tooltip lists devices + battery) +#+end_example + +Tooltip carries device names, battery percentages, and the keybind hints +(the module-tooltip convention shipped 2026-07-02). Click opens the +panel (=bt-panel= toggle wrapper); the existing Super+Shift+B bind moves +from blueman-manager to =bt panel=. Low-battery on a connected device +(<15%) adds a red percentage to the glyph text — the mouse dying +mid-meeting is the one state worth surfacing unprompted. + +** UX conformance notes + +Named against the heuristics the panel family follows (Nielsen's ten, +plus the rulesets patterns catalog): + +- Visibility of status: live captions, scan countdown, elapsed ticker on + long ops, verify-after-repair rows. +- Match to the real world: device-kind glyphs + plain state lines; MACs + demoted to tooltips; "Forget" not "Remove bond". +- User control: Esc closes, Rescan is idempotent, scan bursts are + bounded, repairs confirm, running ops show a Stop where stoppable. +- Consistency: interaction grammar is the net panel's — same switcher + layout, same primary-button contract, same confirm copy shape. +- Error prevention: Forget and Re-pair confirm; power-off while devices + are connected states the consequence in the confirm body. +- Recognition over recall: every action is a visible button; no context + menus, no hidden gestures (transient-state-buttons pattern). +- Minimalism: two tabs, one primary action per view, detail behind + tooltips and the Advanced reveal. +- Help users recover: the doctor's evidence rows name the broken link + and carry the repair inline (default-most-common-friction-proportional: + the likely fix is one click, the destructive one is confirmed). + +Tension found with the net panel while writing this (filed as todo.org +tasks per Craig's instruction, 2026-07-02): transient error toasts +auto-dismiss in 4s, and the V2 spec's keyboard-navigation claims +(tab-between-sections, arrow rows, type-to-filter) aren't verifiably +implemented. Both filed against the net panel rather than cloned here; +this panel adopts whatever resolution those tasks land on. + +* Decisions (Craig) [4/4] + +** DONE Pair implies trust + connect? +CLOSED: [2026-07-02 Thu] +Decided (Craig, 2026-07-02): yes — one Pair verb does pair → trust → +connect. A device that shouldn't auto-reconnect gets untrusted later; a +per-device auto-reconnect toggle can ride a later pass. + +** DONE Retire blueman entirely? +CLOSED: [2026-07-02 Thu] +Decided (Craig, 2026-07-02): drop it outright, no bake-in period — the +package leaves archsetup and both machines once phase 2 lands, +bluetoothctl stays as the terminal fallback. Craig's framing: any issue +after retirement is a signal the doctor needs another check or the panel +has a real bug, and it gets fixed there rather than papered over by +keeping blueman around. + +** DONE Battery in the row caption or tooltip only? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): caption when the device reports it +("Connected · battery 80%"), tooltip otherwise. + +** DONE Scan burst length and auto-rescan? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): 8s bursts, no auto-repeat — Rescan stays +explicit, matching the net panel's Available view. + +* Review findings [2/2] + +** DONE Empty-state and no-adapter presentation copy undefined :nonblocking: +CLOSED: [2026-07-02 Thu] +The mockups show populated lists; the spec didn't say what an empty Paired +list, an empty post-scan Nearby list, or a machine with no adapter shows +in the panel and on the bar glyph. Dispositioned same pass: clone the +donor — the net panel's in-box overlay message pattern (=show_loading= / +placeholder label) carries the copy. Paired empty: "No paired devices — +switch to Nearby to pair one." Nearby post-scan empty: "Nothing found — +Rescan, or make the device discoverable." No adapter: adapter row reads +"No Bluetooth adapter", Devices controls disable, Diagnostics stays +usable (the doctor's step 1 names the hardware/driver verdict); bar +glyph shows the off/blocked state. Non-blocking; recorded so the +implementer doesn't invent copy mid-build. + +** DONE Logging/redaction carry-over unstated :nonblocking: +CLOSED: [2026-07-02 Thu] +The spec says "the net panel's stack, verbatim" but didn't name whether +the engine adopts net's =eventlog= (structured op log) and =redact= +(sensitive-field scrubbing) modules. Dispositioned same pass: yes, both +carry over — every mutating verb (pair/connect/forget/repair) logs an +eventlog entry, and MACs are the redaction surface (device names stay, +MACs redact in copied reports, mirroring net's report redaction). +Non-blocking; it's the donor default made explicit. + +* Implementation phases + +1. Engine =bt= package: adapter/device/scan probes over fake-bluetoothctl, + status + doctor chain (rfkill, service, powered, reachability, audio + profile probe + A2DP switch repair over fake-wpctl) — pure TDD, no + GTK. =bt status= and =bt doctor= work from a terminal. Shared dupre + css factored to the common asset if the settings panel hasn't already + done it. +2. Panel: PanelModel presenter + Blueprint pages (Devices with + Paired/Nearby, Diagnostics), worker-thread wiring, pairing-dialog + state machine, bt-panel toggle wrapper, AT-SPI smoke. Super+Shift+B + rebind. +3. Bar module =custom/bluetooth= (glyph states, tooltip, low-battery + surface, refresh signal), waybar config + suite coverage; blueman + retirement per the decision. +4. bt-priv one-verb helper + sudoers rule in archsetup; package-list + swap (blueman out per decision, bluez-utils stays); VM test + assertions. +5. archsetup keybind/config defaults so a fresh install lands the panel + wired (waybar module present, bind set, sudoers placed). + +* Review and iteration history + +** 2026-07-02 Thu @ 15:19:58 -0400 — Claude Code (archsetup) — phase 5 builder, spec closed +- *What changed or was recommended:* Phase 5 shipped and the spec flipped + to IMPLEMENTED. No new install code was needed — the waybar module, the + =Super+Shift+B= bind, and the shared panel css all ride the dotfiles + hyprland tier that a fresh install already clones and stows, and sudoers + is covered by the blanket grant. The phase's substance is proof: + =test_desktop.py= gained hyprland-gated assertions for the four stowed + bt bins, the =custom/bluetooth= waybar entry, the =bt-panel= keybind, + and the stowed =panel.css=. +- *Why:* Final phase of the DOING decomposition; with it the todo parent + closed and the lifecycle keyword flipped with a history line. +- *Artifacts:* archsetup =scripts/testing/tests/test_desktop.py=; todo.org + parent DONE + dated phase 5 / test-surface entries; this spec's Status + heading. + +** 2026-07-02 Thu @ 15:16:51 -0400 — Claude Code (archsetup) — phase 4 builder +- *What changed or was recommended:* Phase 4 shipped. Dotfiles =2a026b1=: + the stowed =bt-priv= shim (one verb, verified against the fake-systemctl) + and the sxhkd =Super+Shift+B= bind repointed from blueman-manager to + =st -e bluetoothctl= (terminal fallback per the retirement decision — the + panel is Wayland-only). archsetup: blueman dropped from the + =desktop_environment= package loop; VM assertions added (bluez/bluez-utils + present, blueman absent). blueman also removed live from velox. +- *Why:* Build order per the DOING decomposition. The spec's "sudoers rule" + item resolved as net-priv's did: archsetup already grants the primary + user blanket =NOPASSWD: ALL= (archsetup:1089), so a narrow bt-priv rule + would be dead config — no new sudoers needed, and phase 5's "sudoers + placed" is satisfied by the existing grant. +- *Artifacts:* dotfiles =hyprland/.local/bin/bt-priv=, + =common/.config/sxhkd/sxhkdrc=; archsetup =archsetup= (bluetooth loop), + =scripts/testing/tests/test_packages.py=; dated phase 4 entry under the + todo.org parent. + +** 2026-07-02 Thu @ 15:06:00 -0400 — Claude Code (archsetup) — phase 3 builder +- *What changed or was recommended:* Phase 3 shipped (dotfiles =e372de3=): + the =custom/bluetooth= bar module (state-following glyph, low-battery red + percentage, device+battery tooltip with the keybind hint, signal 10 with + the panel poking it after each reload) and the blueman retirement from the + Hyprland session (exec-once + windowrules removed, applet killed live). + The phase 2 deferred items also closed this pass: both AT-SPI smokes green + (the bt smoke's primary-button assertion fixed for the state-following + label, =c1a8219=), both panels eyeballed correct in dupre, and the + net-panel keyboard claims verified live (archsetup =e80df2b= — false + claims struck from the net spec). +- *Why:* Build order per the DOING decomposition; the Zoom meeting ended, + unblocking the visual work. Phases 4-5 (bt-priv/sudoers/packages, install + defaults — archsetup side) remain. +- *Artifacts:* dotfiles =bluetooth/src/bt/indicator.py=, =waybar-bt=, + waybar config + three css files; dated phase 3 entry under the todo.org + parent. + +** 2026-07-02 Thu @ 14:15:27 -0400 — Claude Code (archsetup) — phase 2 builder +- *What changed or was recommended:* Phase 2 shipped (dotfiles =76b2c05=): + the GTK panel — PanelModel/viewmodel presenter pair (69 tests), Blueprint + pages, pairing pty state machine with default-deny passkey confirms, + manage.py op envelopes shared by CLI and panel (power + discoverable verbs + added), =bt-panel= toggle, Super+Shift+B rebind. The shared dupre css + factoring landed as planned: net's inline =_CSS= became + =themes/dupre/panel.css= with =dupre-*= classes, both panels consume it. + 43 suites green. The AT-SPI smoke (=make test-panel-bt=) is written but + not yet run live — a Zoom meeting occupied the compositor; it runs when + the meeting ends, along with a visual check of both panels. +- *Why:* Build order per the DOING decomposition; phases 3-5 (bar module, + bt-priv/sudoers, install defaults) remain. +- *Artifacts:* dotfiles =bluetooth/src/bt/{panel,viewmodel,pairing,manage, + gui,pages}.py=, =ui/*.blp=, =tests/bt/test_btpanel.py=, the panel smoke; + dated phase 2 entry under the todo.org parent. + +** 2026-07-02 Thu @ 13:31:00 -0400 — Claude Code (archsetup) — phase 1 builder +- *What changed or was recommended:* Phase 1 shipped (dotfiles =eb2230f=): + the =bt= engine package, 101 tests over fakes, live-verified read-only + on velox. Two spec corrections from ground truth: profile inventory + comes from =pw-dump= (wpctl can't enumerate profiles), and the active + profile reads from the card's Profile param / sink's + =api.bluez5.profile= (the card's =bluez5.profile= prop is unreliable). + The shared-css factoring moved into phase 2 — net's css is an inline + string in its =gui.py=, so extracting it belongs with the first second + consumer rather than as a standalone poke at the working net panel. +- *Why:* Build order per the DOING decomposition; corrections keep the + spec honest for the phase 2 implementer. +- *Artifacts:* dotfiles =bluetooth/src/bt/=, =tests/bt/=, the stowed + =bt= shim; dated phase 1 entry under the todo.org parent. + +** 2026-07-02 Thu @ 13:10:00 -0400 — Claude Code (archsetup) — reviewer + responder +- *What changed or was recommended:* Ran the spec-review gate: passed. + All four decisions were already DONE (cookie added to the heading); + the five phases are each a clean single-session stop; CLI verbs are + verified against installed bluez 5.86. Two non-blocking findings + recorded and dispositioned in the same fused pass (empty-state / + no-adapter copy, eventlog + redaction carry-over) — both resolve to + "clone the net-panel donor," now stated explicitly. Flipped DRAFT → + READY → DOING and decomposed the phases into build sub-tasks under the + todo.org parent with :SPEC_ID: bound. +- *Why:* Craig queued the build ("4 first, then 1", 2026-07-02) after + resolving all decisions the same morning; the gate held nothing back, + so review and response fused to keep the speedrun moving. +- *Artifacts:* Findings in =* Review findings [2/2]= above; build parent + in todo.org ("Bluetooth panel + bar module"); net-panel toast fix the + UX-conformance note references landed as dotfiles =0f017d4=. diff --git a/docs/specs/2026-07-02-desktop-settings-panel-spec.org b/docs/specs/2026-07-02-desktop-settings-panel-spec.org new file mode 100644 index 0000000..50853f3 --- /dev/null +++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org @@ -0,0 +1,161 @@ +#+TITLE: Desktop-Settings Dropdown Panel +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Desktop-Settings Dropdown Panel +:PROPERTIES: +:ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed) + +* DRAFT Status +:PROPERTIES: +:ID: fb7eec22-a214-4568-82c4-903612f4832f +:END: +- [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (dim + airplane collapse into the panel, touchpad + caffeine stay on the bar; Super+Shift+G keybind; code in dotfiles settings/ beside net/; 5% brightness floor). The four collapse/keybind/location/floor decisions are closed; one open scoping question remains ("few other things" — see Decisions) before a spec-review can flip it READY. +- [2026-07-03 Fri] DRAFT update — from the waybar/panel-family design + discussion: adopt the instrument-console faceplate aesthetic net + bt + shipped (lamps, engraved sections, console keys, machined plate), not just + the raw palette; add the audio panel as a sibling in the panel family; + cross-reference the shared faceplate CSS. Toggle-consolidation scope (the + "few other things") still open — see the Decisions section. +- [2026-07-02 Thu] DRAFT — initial spec from the todo.org task "Desktop-settings + dropdown panel" (2026-06-24 review), updated for the Blueprint/GTK4 pipeline + the net panel stood up 2026-07-01. + +* Metadata + +| Field | Value | +|--------+----------------------------------------------| +| Status | draft | +|--------+----------------------------------------------| +| Owner | Craig Jennings | +|--------+----------------------------------------------| +| Repo | dotfiles | +|--------+----------------------------------------------| +| Kin | net panel (architecture donor), theme studio | +| | audio panel (sibling), bt panel (aesthetic) | +|--------+----------------------------------------------| + +* Problem + +Desktop toggles are scattered: dim, caffeine/idle, touchpad/mouse, airplane +mode each own a bar module and a keybind; brightness and keyboard-backlight +have keybinds but no visible control or level readout. The bar is running out +of glanceable width (hence the collapse arrows), and sliders can't live in +waybar at all. One settings dropdown — a gear glyph opening a small panel — +gathers them. + +* Goals + +1. One panel with every desktop toggle + slider: auto-dim, idle/caffeine, + touchpad, mouse, airplane (laptop-only), screen brightness, keyboard + backlight. +2. Conditional rows appear only when the hardware/context applies (mouse + present, trackpad present, battery present) — reuse the detection the + airplane/touchpad indicators already do. +3. Every control reflects live state and verifies its action took (the net + panel's verify-everything contract). +4. Bar stays the quick layer: which standalone indicators survive is a + decision below. + +* Design sketch + +** Architecture — clone the net panel's proven stack + +- GTK4 + gtk4-layer-shell, Blueprint .blp sources compiled to committed .ui + (=make ui=; dev-only build dependency, fresh clones run without the + compiler). +- Humble-object split: a GTK-free PanelModel presenter (unit-tested to 100% + like the net PanelModel) + thin composite-widget pages. Backing actions in + a GTK-free settings.py that shells out to brightnessctl / hyprctl / the + existing toggle scripts, TDD'd with fake binaries like every dotfiles + suite. +- One gated AT-SPI smoke (the run-panel-smoke.sh pattern), no bespoke + headless widget suite. +- Instrument-console faceplate aesthetic, consistent with net + bt + audio: + the machined gradient plate, glowing status lamps, engraved section labels, + physical console keys for the toggles, and (where a level applies) needle + gauges. Load the shared instrument-console palette/faceplate CSS asset all + the family panels use — factor it once, don't duplicate (feeds the + theme-studio task later). + +** Controls and their backings + +| Control | Backing | +|--------------------+----------------------------------------------| +| Auto-dim toggle | hyprctl decoration:dim_inactive (dim-toggle) | +|--------------------+----------------------------------------------| +| Idle / caffeine | hypridle start/stop (caffeine-toggle) | +|--------------------+----------------------------------------------| +| Touchpad toggle | toggle-touchpad + touchpad-state file | +|--------------------+----------------------------------------------| +| Mouse toggle | same mechanism, mouse-state file | +|--------------------+----------------------------------------------| +| Airplane mode | airplane-mode script (laptop-only row) | +|--------------------+----------------------------------------------| +| Screen brightness | brightnessctl (backlight class), slider + % | +|--------------------+----------------------------------------------| +| Keyboard backlight | brightnessctl (kbd_backlight class), slider | +|--------------------+----------------------------------------------| + +Slider changes apply live (throttled) and read back the actual level after +apply — verify-everything. Toggles re-read their source of truth after +firing, same as the bar indicators do, and the bar modules get their refresh +signals so both surfaces agree. + +** Open/close behavior + +Gear glyph module on the bar right cluster; click toggles the panel +(layer-shell anchored under the bar, right-aligned). Focus-out auto-hide + +Close button, matching the net panel. Keybind: Super+Shift+G (decision B). + +* Decisions (Craig) + +** DONE Which standalone bar indicators collapse into the panel? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): touchpad and caffeine stay on the bar (glanceable state); auto-dim and airplane move into the panel (panel-only, freeing bar width). The airplane Super+Shift+A toggle keybind stays as the quick lane — only its bar indicator collapses in. + +** DONE Keybind for the panel? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): Super+Shift+G (gear), for parity with the other panels' fast path. + +** DONE Where does the code live? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): dotfiles =settings/= sibling to =net/= (same src-layout, tests in tests/settings/), sharing the palette css. The net panel is the architecture donor; the old in-tree pocketbook-style note is out. + +** DONE Slider granularity and floor +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): 5% floor on the brightness slider, so a dark-room drag can't black the screen out and lock you out. brightnessctl's 0-100% range clamps to a 5% minimum. + +** TODO What are the "few other things" beyond the toggles? +The 2026-07-03 discussion named consolidating the toggle buttons "and a few +other things" into this panel, but the extras weren't enumerated. Current +control list (above): auto-dim, idle/caffeine, touchpad, mouse, airplane, +screen brightness, keyboard backlight. Candidates raised or adjacent — +confirm which belong here vs the audio panel vs the bar: night-light / color +temperature, a theme/dupre-vs-hudson switch (theme-studio kin), volume or a +master-mute mirror (or leave all audio to the audio panel), a +notifications/do-not-disturb toggle (dunst), lock/suspend actions. Craig to +name the set. + +*** 2026-07-04 Sat — Craig's input (roam capture): the set includes a wallpaper manager +Confirmed the panel gathers the mouse/trackpad toggle, a no-sleep (idle-inhibit) toggle, and the auto-dim toggle, and adds a *wallpaper manager* (this is where the displaced waypaper functionality lands — see the media/keybind change that freed Super+Shift+P). The wallpaper manager needs its own depth: +- take a number of directories to look in; +- switch the wallpaper with the change persisting across sessions; +- switch between two pictures at sunup / sundown (a day/night pair). +That last one implies a sun-time source (a lat/long or a sunrise/sunset lookup). The wallpaper manager is sizable enough it may want its own sub-spec rather than a single panel row; decide during the spec-review whether it's a row that opens a sub-view or a separate panel. Remaining "few other things" candidates above (night-light, theme switch, DND, lock/suspend) still await Craig's yes/no. + +* Implementation phases + +1. settings.py backings (brightness get/set, kbd backlight, toggle + state readers) — pure engine, TDD with fake brightnessctl/hyprctl. +2. PanelModel presenter (rows, conditional visibility, verify-after-apply + semantics) — unit-tested, no GTK. +3. Blueprint UI + gear bar module + open/close wiring; palette css factored + to a shared asset; AT-SPI smoke. +4. Bar-module consolidation per decision A: drop the dim and airplane bar + modules (now panel-only), keep touchpad and caffeine on the bar, wire the + refresh signals so bar and panel agree, and bind Super+Shift+G. diff --git a/docs/specs/2026-07-02-file-manager-swallow-spec.org b/docs/specs/2026-07-02-file-manager-swallow-spec.org new file mode 100644 index 0000000..b898f11 --- /dev/null +++ b/docs/specs/2026-07-02-file-manager-swallow-spec.org @@ -0,0 +1,147 @@ +#+TITLE: File-Manager Swallow Pattern +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* CANCELLED File-Manager Swallow Pattern +:PROPERTIES: +:ID: 179a1cd2-7a02-4c44-a09d-685c5a154895 +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to CANCELLED (reason: Native swallow ruled out by test; reassigned to .emacs.d dirvish handling.) + +* CANCELLED Status +:PROPERTIES: +:ID: d92e0074-f594-4e83-81a0-faf282e15ed0 +:END: +- [2026-07-02 Thu] CANCELLED — targeted the wrong file manager. Craig's ask + is about the dirvish popup (Super+F, an Emacs frame), not nautilus (the + Super+Shift+F bind that misled the grounding). For dirvish the right + design is elisp-side and strictly better: Emacs is the launcher, so it + can spawn the handler directly (=start-process=), hide the popup frame, + and restore it from a process sentinel — exact exit tracking plus a + failure notify, no window-event heuristics. Reassigned to .emacs.d via + its inbox (2026-07-02-2231-from-archsetup-dirvish-popup-swallow-handoff). + The gio double-fork finding below stands for any gio-launching file + manager; the daemon design is kept for reference only. +- [2026-07-02 Thu] DRAFT — initial spec from Craig's roam capture ("when the + file manager launches another app, it should hide and return when that + process ends"). Feasibility ground truth sampled live on velox same + evening: Hyprland's native swallow cannot work here (see Problem), so the + design is an event-listener daemon. + +* Metadata + +| Field | Value | +|--------+----------------------------------------------------| +| Status | cancelled | +|--------+----------------------------------------------------| +| Owner | Craig Jennings | +|--------+----------------------------------------------------| +| Repo | dotfiles (daemon + config); archsetup (none) | +|--------+----------------------------------------------------| +| Kin | touchpad-auto (socket-listener donor), | +| | hypr-refocus-scratchpad (event-daemon sibling) | +|--------+----------------------------------------------------| + +* Problem + +Opening a file from nautilus (Super+Shift+F, tiled, class +=org.gnome.Nautilus=) spawns a viewer window while nautilus stays in the +layout. The wanted behavior is the swallow pattern: the file manager hides +while the app it launched runs, and returns when that app exits. Today +there's no signal connecting the two windows — the viewer lands wherever +the layout puts it, nautilus lingers, and quitting is manual. + +*Hyprland's native swallow is ruled out — measured, not assumed.* +=misc:enable_swallow= + =swallow_regex= would be exactly this feature in two +config lines, but it matches by walking the new window's PID ancestry to +the swallow candidate's PID. Nautilus launches handlers through GLib +(=g_app_info_launch_default_for_uri=), and that path orphans the child: +reproduced live on velox 2026-07-02 with a python-gi launcher — feh came up +with PPID 1 (reparented to init) while the launcher was still alive. The +ancestry walk hits init before it hits nautilus, every time, for every +handler. Any design that depends on PID parentage is dead on arrival; the +signal has to come from window events instead. + +Ground truth on handlers (velox, 2026-07-02): pdf → zathura, image → feh, +video → mpv, text/code → emacsclient (window belongs to the emacs daemon). +Side-note, out of scope here: feh is X11 — an XWayland viewer on a +no-XWayland-by-preference setup; a default-handler review is its own task. + +* Goals + +- Double-click a file in nautilus → the viewer takes its place; nautilus is + gone (special workspace, not killed — state and tabs survive). +- Quit the viewer → nautilus returns and has focus. +- Nothing else changes: terminals, scratchpads, and every other window keep + their current behavior. +- Config-driven, testable logic, one small daemon — the touchpad-auto shape. + +* Design sketch + +A =hypr-swallow= daemon (dotfiles, =hyprland/.local/bin/=) listening on the +Hyprland IPC event socket (socket2), same as =touchpad-auto=: + +- Track the active window (=activewindow>>= events carry class + title; + =activewindowv2>>= carries the address). +- On =openwindow>>= (address, workspace, class, title) while the active + window's class is a configured *parent* (nautilus): dispatch + =movetoworkspacesilent special:swallow,address:0x<parent>=, record + child-address → {parent-address, origin workspace}. +- On =closewindow>>= of a recorded child: bring the parent back + (=movetoworkspace=) and focus it; drop the record. +- On =closewindow>>= of a hidden parent (nautilus quit while hidden): drop + the record, nothing to restore. +- Exception classes (fuzzel, dunst, scratchpad classes, the panels) never + trigger a swallow even when they open over nautilus. +- Pure event-machine core (parse lines → state transitions → dispatch list), + unit-tested against recorded event streams; a thin socket loop around it. + +Known edge, handled: Super+Shift+F while nautilus is hidden re-runs +=nautilus=, which activates the existing (hidden) instance instead of +opening a window. The daemon (or the bind) must restore-and-untrack in that +case so the bind never appears dead. + +Known limitation, accepted: the emacsclient case never swallows — the +window belongs to the long-running emacs daemon and =closewindow= for it +means a frame closed, not "the file is done." The parent-class trigger plus +exception list naturally leaves it alone only if we exclude it explicitly — +see decision 2. + +* Decisions (Craig) + +** TODO Trigger breadth: any new window while nautilus is active, or an allowlist of viewer classes? +"Any window" is simple and catches every handler, but a false positive +exists: an app you launched seconds earlier from elsewhere finishes starting +while you're focused on nautilus → nautilus gets swallowed by an unrelated +window. An allowlist (zathura, mpv, imv, feh, …) can't be surprised but +needs maintaining. Recommendation: any-window + exception list — the false +positive is rare and self-healing (close the window or refocus). + +** TODO The emacs frame case: swallow or exempt? +Opening a text file from nautilus raises/creates an emacs frame. Swallowing +nautilus under it "works" going in, but the restore fires when *any* frame +closes, which may be much later or never. Recommendation: exempt =emacs= — +text files just open, nautilus stays. + +** TODO Restore destination: the workspace nautilus came from, or the one you're on when the viewer closes? +If you move the viewer to another workspace and quit it there, "origin" +teleports you back; "current" brings nautilus to you. Recommendation: +current workspace — the restore should land where your attention is. + +** TODO Multiple children: refcount or single-slot? +You can only launch a second file after restoring nautilus manually, so +overlap is rare — but a fast double-launch can record two children. +Recommendation: refcount — restore when the last tracked child closes. + +* Implementation phases + +1. =hypr-swallow= core: pure event-machine (TDD over recorded event + streams; fake hyprctl for dispatch assertions), config block at the top + (parent classes, exception classes), unittest suite in =tests/=. +2. Socket loop + wiring: exec-once in hyprland.conf, the Super+Shift+F + restore-if-hidden interplay, daemon single-instance guard. +3. Live verification on velox (zathura + mpv round-trips, the emacs case, + the false-positive probe) + manual-testing entries; ratio rides the + dotfiles pull. diff --git a/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org b/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org new file mode 100644 index 0000000..0d63feb --- /dev/null +++ b/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org @@ -0,0 +1,195 @@ +#+TITLE: Net Panel — Tailscale, VPN, and WireGuard Interfaces +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Net Panel — Tailscale, VPN, and WireGuard Interfaces +:PROPERTIES: +:ID: 09f4cd40-f391-4eba-a4ff-c22bad00ad7f +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Tunnels track shipped detection, diagnose, and panel bring-up; build task DONE.) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: 79a1075a-4b56-4f25-a861-b69f120a636a +:END: +- [2026-07-02 Thu] IMPLEMENTED — all six phases shipped (dotfiles 2d9d060, + 21db05a, 31ba056, b4010bf, b5c8442; archsetup 0389790 + the wireguard + import script): probes, panel Tunnels view, diagnose/doctor route + awareness, bar badge, installer swap + operator, velox config migration. + Residual human steps filed under todo.org "Manual testing and + validation": proton CLI sign-in (per machine) and the first live + badge/tunnel round-trip. Ratio picks up the import + package swap on its + trip. +- [2026-07-02 Thu] DOING — decomposed into six build phases under the + todo.org parent (:SPEC_ID: bound); build started same evening per Craig + ("tunnels build now + audio-panel spec alongside"). +- [2026-07-02 Thu] READY — fused review passed the gate: 4/4 decisions + resolved, phases decomposable, claims re-verified live (proton-vpn-cli + 1.0.1 in extra, binary =/usr/bin/protonvpn=, no package conflict with the + GTK app; =tailscale status --json= shape confirmed on velox — Self/Peer/ + CurrentTailnet.Name/MagicDNSSuffix; zero NM wireguard connections yet, + seven configs in assets awaiting the phase 6 import). +- [2026-07-02 Thu] DRAFT — initial spec from the roam capture "other network + interfaces (tailscale, VPNs, wireguard)" filed in todo.org 2026-07-02. + +* Metadata + +| Field | Value | +|--------+---------------------------------------------------| +| Status | implemented | +|--------+---------------------------------------------------| +| Owner | Craig Jennings | +|--------+---------------------------------------------------| +| Repo | dotfiles (net module); archsetup (packages) | +|--------+---------------------------------------------------| +| Parent | Waybar network module spec (2026-06-29), V2 panel | +|--------+---------------------------------------------------| + +* Problem + +The net panel's Connections tab shows what NetworkManager knows: WiFi networks +and wired links. The machines also run overlay and tunnel interfaces the panel +is blind to: + +- Tailscale (tailscaled, both daily drivers; the tailnet is how the machines + reach each other; not an NM device) +- WireGuard configs (assets/wireguard-config/ carries Proton VPN configs; + importable as NM connections of type wireguard or run via wg-quick) +- Commercial VPN clients (Proton VPN GTK app is installed on velox; owns its + own tunnel device) + +When one of these is up it changes routing, DNS, and reachability — exactly +the things the Diagnostics tab reasons about — yet the panel neither shows nor +controls them, and the doctor can misattribute a VPN-caused failure to the +underlying link. + +* Goals + +1. Visibility: the Connections tab shows overlay/tunnel interfaces with live + state (up/down, address, and for tailscale the tailnet peers summary). +2. Control: bring each up or down from the panel row, same interaction shape + as Join/Disconnect on WiFi rows (no terminals — V2 contract). +3. Diagnostics awareness: diagnose/doctor know when a tunnel owns the default + route or DNS, name it in evidence rows, and stop misattributing its + failures to the physical link. + +Non-goals (this iteration): installing or configuring VPN providers, tailnet +ACL management, exit-node selection UI (a "use exit node" affordance can ride +a later pass), kill-switch management (tracked separately in the spec's +failure catalog). + +* Design sketch + +** Data sources — one probe per backend, engine-side + +New GTK-free module net/src/net/overlays.py with one probe per backend, +each returning the same small dict shape ({kind, name, state, addr, detail, +can_toggle}): + +- tailscale: =tailscale status --json= (rich: self, peers, exit node, health + messages). Daemon down → state "stopped". Binary absent → backend absent. +- wireguard-nm: =nmcli -t connection show= filtered to type wireguard — + up/down via the existing nmcli wrapper (activate/deactivate connection). + The seven Proton configs in assets/wireguard-config/ import cleanly + (=nmcli connection import type wireguard file <conf>=, then + =connection.autoconnect no= immediately — imports default to autoconnect + yes). They use only PrivateKey/Address/DNS + PublicKey/AllowedIPs/Endpoint, + no PostUp/PostDown anywhere, so no wg-quick path is needed at all + (Craig, 2026-07-02). All are full-tunnel (AllowedIPs 0.0.0.0/0) — the + panel should treat them as mutually exclusive. +- proton: drive the official proton-vpn-cli (Arch extra repo, v1.0.x, + stable since 2026-04) — connect/disconnect/status verbs. It drives NM + underneath (python-proton-vpn-network-manager), so the panel still sees + connection events through NM. Runtime-exclusive with the GTK app, which + gets dropped from the install. The imported NM wireguard configs remain + a raw fallback when the CLI/API path is down; the CLI stays primary + because the raw configs lack kill switch, port forwarding, and server + rotation. + +** Panel + +A fourth Connections group "Tunnels" (after Saved / Available now / Wired) +using the existing group-header + row machinery. Row: glyph per kind, name, +state caption; primary action Up/Down where can_toggle, else Open app. +Tailscale row detail (subtitle or tooltip): tailnet name, peer count online, +exit node if any. + +** Privileged path + +- tailscale up/down: needs root or operator — =tailscale set --operator= at + install time (archsetup) makes the user an operator, so no sudo needed at + runtime. Fallback: the V2 net-priv helper gains tailscale-up/down verbs. +- NM wireguard connections: no privilege needed (NM polkit default for the + active user). + +** Diagnostics awareness + +- diag gains an "overlay owns default route/DNS" detection step: when the + default route or resolv.conf points at a tunnel interface, evidence names + it ("default route via tailscale0") and failure classification runs the + physical-link checks against the underlying device instead. +- doctor: a tunnel-caused egress failure (VPN up but its endpoint dead) + classifies fixable with next_action "bring the tunnel down / reconnect", + not a WiFi reset. + +** Bar indicator + +Part of v1 (Craig, 2026-07-02 — "shouldn't be optional"): a small overlay +badge on the net glyph when a tunnel owns the default route. Rides the same +route/DNS-ownership detection the diagnostics step adds. + +* Decisions (Craig) + +** DONE Which backends ship in the first pass? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): tailscale + NM-managed wireguard. Craig asked +whether the wireguard configs can be ported to NM so wg-quick drops out +entirely — yes: all seven configs in assets/wireguard-config/ use only the +six directives NM imports cleanly (verified 2026-07-02; import command and +autoconnect caveat now in the design sketch). wg-quick is out of the spec, +not deferred. Proton control is CLI-driven per the Proton decision below, +superseding the detection-only recommendation here. + +** DONE Tailscale control path: operator flag at install vs net-priv verbs? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): =tailscale set --operator=$USER= in archsetup's +tailscale step (declarative, no sudo at runtime); net-priv verbs only if +operator mode proves insufficient (e.g. up with flags). +** DONE Does "Tunnels" belong in Connections or its own tab? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): a Connections group. A fourth top tab dilutes +the V2 nav for three rows. + +** DONE Proton VPN: detect-only or drive its CLI? +CLOSED: [2026-07-02 Thu] +Decided (Craig, 2026-07-02): drive it through a CLI. Research (2026-07-02): +Proton shipped an official Linux CLI — first release 2025-11, stable v1.0.0 +2026-04, packaged in Arch extra as proton-vpn-cli (1.0.1 at check time), +with kill switch, port forwarding, NetShield, server selection, and a +status command. It drives NM underneath, so the panel sees its connections +through the existing NM event path. Spec changes: the proton backend calls +protonvpn connect/disconnect/status instead of device-detection +(can_toggle true); archsetup installs proton-vpn-cli and drops +proton-vpn-gtk-app (the two can't run concurrently per the project README — +untested locally); the imported NM wireguard configs stay as a raw fallback. +Sources: [[https://protonvpn.com/support/linux-cli][Proton Linux CLI guide]], +[[https://protonvpn.com/support/release-notes-linux-cli][CLI release notes]], +[[https://github.com/ProtonVPN/proton-vpn-cli][proton-vpn-cli repo]]. +* Implementation phases + +1. overlays.py probes (tailscale JSON, nmcli wireguard filter, proton-vpn-cli + status) — pure engine, TDD with fake binaries; =net status= grows an + overlays section. +2. Panel Tunnels group + Up/Down wiring through the worker thread; AT-SPI + smoke extension. +3. Diagnose/doctor overlay awareness (route/DNS ownership step, classifier + rows, evidence text) — TDD against the diag harness. +4. waybar-net tunnel badge on the net glyph (v1 per the bar-indicator + decision), riding phase 3's route-ownership detection; suite coverage. +5. archsetup: tailscale operator flag in the tailscale install step; + proton-vpn-cli replaces proton-vpn-gtk-app in the package list; VM test + assertions. +6. One-time per-machine migration: import the seven assets/wireguard-config + configs into NM with autoconnect off (scriptable; both daily drivers). diff --git a/docs/specs/2026-07-02-timer-panel-spec.org b/docs/specs/2026-07-02-timer-panel-spec.org new file mode 100644 index 0000000..275bb2c --- /dev/null +++ b/docs/specs/2026-07-02-timer-panel-spec.org @@ -0,0 +1,221 @@ +#+TITLE: Timer GTK Panel +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Timer GTK Panel +:PROPERTIES: +:ID: 25ed5321-f035-42b3-b115-69364d775f41 +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: 1770af2e-b093-4024-a512-ae4324a2869f +:END: +- [2026-07-05 Sun] IMPLEMENTED — redesign built and shipped to dotfiles in a no-approvals speedrun (5 commits =c7ac193=..=5a863b5=): the wtimer engine (timer repeat, recurring alarms with snooze/ringing/dismiss, =@half=/=@hour=/=+dur= alarm parse, the rebuilt configurable pomodoro cycle, bar-tooltip parity), the PanelModel view-data rebuild (=row_view=, ringing-first sort, per-type create options as flags, locked presets + half-past + named pomodoro cycles), the GTK hero-on-top panel (Cairo progress ring + stopwatch sweep dial, per-type create strips, one transport row, close ✕/Esc), and the bar tooltip parity. wtimer + timer suites 231 green, full =make test= green. Live GTK render is the manual checklist (todo.org). Stopwatch run-save deferred to vNext. +- [2026-07-05 Sun] DOING — UI/UX redesign decided through a prototype process (research → brainstorm → several directions → iterate to final; see Prototype iterations below). The shipped v1 panel stands, but this rewrite supersedes its layout and adds functionality: a hero-on-top + rack layout, a live waybar-module preview at parity with =wtimer render=, per-type create-strip features (timer auto-repeat; alarm recurring weekdays + snooze + a ringing state; a configurable pomodoro cycle with work/rest short+long and a long-break interval; a stopwatch analog sweep dial + last-lap badge), presets (renamed from "chips") whose shipped defaults are locked and whose load flashes the fields, and a header close button. Stopwatch run-save is cut to a vNext. Rebuild pending — folds this decided design into the shipped =timer/= package. +- [2026-07-05 Sun] IMPLEMENTED — v1 built and shipped to dotfiles in a no-approvals speedrun (4 commits 1f4f270..78d3cbb): wtimer watch/lap/save; a new timer/ package with a GTK-free PanelModel (62 tests) + the GTK instrument-console panel; bar integration (custom/timer opens the panel, the fuzzel creation flow retired, Hyprland float rule added). This is the base the 2026-07-05 redesign iterates on. +- [2026-07-05 Sun] DOING — Craig directed the build (no-approvals speedrun). Folded in the cj input from the sibling waybar-timer-module spec (GTK app styled like the panels; a queue/output-wall auto-sorted by fire time; stopwatch lap/stop + saveable runs; notify integration; 5/25-min configurable+deletable defaults; up to 10 timers; widget-gallery elements) — see Build scope below. Bypassed the READY spec-review step at Craig's direction; the four decisions were already resolved. +- [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (standalone; retire fuzzel once the panel lands; timer chips gain 10m/30m/2h; wtimer watch mode over polling). Decision-complete; ready for a spec-review to flip it READY before build. +- [2026-07-02 Thu] DRAFT — initial spec from Craig's roam capture "give the + timer a gtk UI/UX like the network panel. spec this out." + +** Prototype iterations +The redesign ran through the UI/UX prototype process (see the =ui-prototyping= rule proposed to rulesets, 2026-07-05). Full working HTML prototypes over one shared engine, in the dupre instrument-console aesthetic; each iteration links here, newest last, so the design history is walkable. +- [[file:../prototypes/2026-07-02-timer-panel-prototype-1.html][prototype-1]] — three initial directions over one shared engine: rack unit (faithful vertical list), transport deck (hero + track list), channel-strip board (vertical faders). Predates the formalized five-direction count. +- [[file:../prototypes/2026-07-02-timer-panel-prototype-2.html][prototype-2]] — chose the rack direction; flipped to hero-on-top → create strip → list; made pomodoro a configurable cycle; locked the shipped presets (default cycle undeletable); dropped the stopwatch/pomodoro value entry. +- [[file:../prototypes/2026-07-02-timer-panel-prototype-3.html][prototype-3]] — FINAL. Live waybar preview; hero donut moved right with one full-width button row; stopwatch sweep dial + ghost lap badge; alarm recurring days + snooze + ringing state; timer repeat; half-past alarm preset; presets flash on load; header close (Esc / bar-click reopen); verbatim tooltip labels; stopwatch save deferred. + +* Metadata + +| Field | Value | +|--------+-----------------------------------------------------------------------------------------| +| Status | implemented | +|--------+-----------------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|--------+-----------------------------------------------------------------------------------------| +| Repo | dotfiles | +|--------+-----------------------------------------------------------------------------------------| +| Kin | net panel (architecture donor), wtimer (backing), desktop-settings panel spec (sibling) | +|--------+-----------------------------------------------------------------------------------------| + +* Problem + +The timer's whole UI is a chain of three fuzzel prompts (type, value, label) +plus a fourth for cancel. That flow can't show what's already running while +you create, can't offer one-tap presets, gives no feedback on a typo until +the add silently fails, and pomodoro state (phase, cycle) is only visible in +a tooltip. The 2026-07-02 styling pass made the dialogs presentable, but the +shape is still four blind modals for what is really one small control +surface. + +* Goals + +1. One panel, opened from the bar's timer module, that shows everything + running (live countdowns, pomodoro phase/cycle, paused state) and creates + new items without leaving it. +2. One-tap presets for the common cases (tea, pomodoro, quick alarm) next to + freeform entry, with inline validation before the add. +3. Per-item controls: pause/resume, cancel, promote to primary (the bar + glyph slot). +4. wtimer stays the single owner of timer state and the notification path; + the panel is a view over it, never a second engine. + +* Design sketch + +** Architecture — clone the net panel's proven stack + +- GTK4 + gtk4-layer-shell dropdown anchored under the timer module, Blueprint + .blp compiled to committed .ui (=make ui=; compiler is dev-only). +- Humble-object split: GTK-free PanelModel presenter, unit-tested to 100%, + with thin widget bindings; one gated AT-SPI smoke via the + run-panel-smoke.sh pattern. +- Backing: shell out to the existing wtimer CLI (=add=, =toggle=, =cancel=, + =cycle=, =render=). =render= already emits a JSON payload. Live state comes + from a new wtimer watch/subscribe mode (decision D), which the panel + subscribes to for push updates instead of polling =render= on a timer. + wtimer's 89-case suite keeps owning the logic; panel tests fake the CLI + like every dotfiles suite fakes binaries. +- Dupre WIP palette CSS shared with the net panel (same factoring the + desktop-settings spec calls for — one palette asset, three panels). + +** Layout sketch (decided in prototype-3) + +Top-to-bottom, one column: + +- Header: brand + live item count + Clear All + a flat circular close ✕ + (tooltip "Close (Esc)"), matching the net/bt/audio panels. Esc closes; + clicking the bar's timer module reopens it (mirrors =on-click: timer-panel=). +- Hero (the primary / bar-slot item): the info block (type badge, any feature + badges, pomodoro cycle dots, label, big countdown, subline) on the left with + the progress donut on the right, and all its controls in one full-width, + left-justified button row beneath. Countdown types show a filling progress + ring; a stopwatch shows an analog sweep dial (a gold second-hand, one + revolution per minute) with its last lap as a bordered ghost badge beside the + count — no fake progress ring for a count-up. The ‹ › keys cycle the primary + through the whole queue, wrapping at either end. +- Create strip: the four type buttons, then a per-type body — presets (renamed + from "chips") + a freeform entry validated by wtimer's parsers + an optional + label, plus per-type extras (see Build scope). Loading a preset flashes the + target fields rather than toasting. Shipped presets are locked (no delete); + only presets you add carry a ×. +- Queue list: the rest of the items (everything but the hero), soonest-fire + first, one rack row each — lamp, glyph, label, subline, countdown, and inline + pause / promote / cancel (two-stage arm). Stopwatches are promotable to the + hero like any other item. With a single item the list reads "Only one item is + queued. Add more above." Empty state: hero shows "No timers running", create + strip below. + +** Waybar module parity + +A live preview above the panel renders exactly what =wtimer render= emits for +the bar: =<large glyph> <countdown>= plus =+N= for the other items, recoloured +by state (urgent < 60 s terracotta, paused dim, pomodoro-work gold, +pomodoro-break sage, idle silver), with the full per-item hover tooltip. Tooltip +lines show each item's label verbatim — no phase word appended. The panel and +the bar stay one source of truth via the wtimer watch subscription. + +** What happens to the fuzzel flow + +Decision B (below) resolved this: the fuzzel chain retires once the panel +lands. The panel becomes the single creation surface, replacing both the +click-driven bar path and the keybind/fuzzel path. Until the panel ships the +fuzzel flow stays (it's styled and tested); phase 4 removes it after the +panel proves out. + +* Build scope (decided design — folds the prototype-3 redesign into the shipped =timer/= package) + +The panel is the existing =timer/= dotfiles package (src-layout, GTK4 + gtk4-layer-shell, humble-object PanelModel, instrument-console faceplate). wtimer stays the state engine; the panel is a view over it. This rebuild reshapes the layout (see Layout sketch) and adds the per-type functionality below. UI idioms draw from the widget gallery (=docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html=); the reference build is prototype-3. + +Queue + primary: +- Up to 10 items, auto-sorted by soonest fire time (four buckets: active countdown < paused countdown < active stopwatch < paused stopwatch). The soonest-firing is the hero/primary (the bar glyph slot). Promote via a row's promote key or by cycling ‹ ›; cycling and promotion include stopwatches and wrap around the whole list. +- The hero shows the primary big; the list shows the rest. Clear All cancels everything. + +Types + create strip: +- *Timer*: preset durations 5m / 25m / 10m / 15m / 30m / 60m / 2h (5m and 25m first), freeform entry (wtimer parser), optional label, and a *repeat* toggle — a repeating timer re-arms itself on fire instead of clearing. +- *Alarm*: presets +30m / top-of-hour / *half-past* (next X:30) / 07:00, freeform HH:MM, optional label, a *recurring weekday* selector (S M T W T F S, with weekdays / daily shortcuts) and a *snooze* duration. An alarm fires into a *ringing* state rather than silently vanishing: the hero/row shows SNOOZE (re-arm by the snooze minutes) and DISMISS (a recurring alarm re-arms to its next matching day; a one-shot clears). +- *Stopwatch*: no value entry — counts up from zero. Lap (unlimited) and Stop. The hero shows an analog sweep dial and the last lap as a ghost badge beside the count. *Run-save is deferred to a vNext* (cut from v1's org-save plan — see the status history). +- *Pomodoro*: a configurable cycle — Work and Rest each with a short and a long duration, a "long break every N cycles" interval, and an auto-advance toggle. Every Nth ("deep") cycle uses the long work + long rest together. Cycle progress shows as dots in the hero and row. With auto-advance off, each phase end waits for a Start press. Preset cycles (Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10) load the fields. +- *Presets*: shipped defaults are locked (undeletable — the pomodoro default cycle can't be removed); presets you add carry a × and are deletable. Loading any preset flashes the target fields (no toast). + +Live updates + notifications: +- A =wtimer watch= subcommand emits state on every change (state-file watch → JSON lines on stdout); the panel subscribes for push updates instead of polling (decision D). Notifications for alarms and timers go through the =notify= script — wtimer stays the single notification owner. + +Bar + window: +- =custom/timer= left-click opens the panel; =wtimer render= stays the bar indicator (glyph + countdown + =+N=, state-coloured, verbatim tooltip labels). A header close ✕ and Esc close the panel; clicking the bar module reopens it. The =wtimer new= fuzzel creation flow is retired (decision B). + +* Decisions (Craig) + +** DONE Panel scope: standalone timer panel, or a page in the desktop-settings panel? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): standalone, sharing the palette/css asset. Matches the net panel's one-domain-one-panel shape and keeps the timer dropdown small. + +** DONE Fuzzel flow: keep as keyboard fast lane, or retire once the panel lands? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): retire the fuzzel flow once the panel lands. The panel becomes the single creation surface; the keybind chain goes away rather than staying as a parallel path. (Implementation phase 4's "decide the fuzzel flow's future" is now decided — retire, don't keep.) + +** DONE Presets: which chips per type? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): timer chips are 5m / 10m / 15m / 25m / 30m / 60m / 2h (the strawman plus 10m, 30m, 2h). Alarm +30m / top-of-hour / 07:00, pomodoro default cycle only, stopwatch none — as the strawman. + +** DONE Live updates: poll render (1s, like the bar) or a wtimer "watch" mode? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): a wtimer watch/subscribe mode, not 1s polling. This grows wtimer with a new watch capability that the panel (and potentially the bar) subscribes to for live state, rather than reusing the poll cadence — cleaner at the cost of a wtimer addition. Fold the watch mode into the phase 1 CLI-backing seam. + +The decisions below were resolved live through the prototype iteration (2026-07-05), each seen working in a prototype before being written down. + +** DONE Layout: hero on top, then create strip, then the queue list +CLOSED: [2026-07-05 Sun] +Resolved: the primary item rides a hero at the top (info left, donut right, all controls in one full-width button row), the create strip sits under it, the rest of the queue below. Chosen over the transport-deck and channel-strip directions in prototype-1. + +** DONE Stopwatch hero visual: analog sweep dial, not a progress ring +CLOSED: [2026-07-05 Sun] +Resolved: a count-up stopwatch shows a gold second-hand sweeping once per minute, with its last lap as a bordered ghost badge beside the count — not a fake progress ring (a stopwatch has no target to be a fraction of). + +** DONE Alarm: recurring weekdays + snooze + a ringing state; add a half-past preset +CLOSED: [2026-07-05 Sun] +Resolved: alarms carry a weekday-repeat selector and a snooze duration, and fire into a ringing state with SNOOZE / DISMISS rather than vanishing. A half-past preset joins top-of-hour (fires at the next X:30). Drawn from Alarm Clock Xtreme / Alarmy. + +** DONE Timer: auto-repeat toggle +CLOSED: [2026-07-05 Sun] +Resolved: a timer can repeat — it re-arms itself on fire instead of clearing. Drawn from MultiTimer / Multi Timer. + +** DONE Pomodoro: a fully configurable cycle +CLOSED: [2026-07-05 Sun] +Resolved: Work and Rest each get a short and a long duration, plus a long-break-every-N interval and an auto-advance toggle; every Nth deep cycle uses the long work + long rest; progress shows as cycle dots. The default cycle preset is locked (undeletable). Drawn from Pomofocus / the classic technique. + +** DONE Presets (formerly "chips"): lock defaults, flash on load +CLOSED: [2026-07-05 Sun] +Resolved: rename "chips" to "presets"; shipped defaults are locked (no delete), presets you add are deletable; loading a preset flashes the target fields instead of firing a toast. + +** DONE Stopwatch run-save: deferred to a vNext +CLOSED: [2026-07-05 Sun] +Resolved: v1's "save the run's splits to an org file on stop" is cut from this build. Stop just stops. Revisit in a vNext if the need is real. + +* Implementation phases (redesign rebuild) + +Folding prototype-3 into the shipped =timer/= package. TDD throughout — GTK-free +logic first, GUI last — reviewing between phases. Each phase is a dotfiles commit +under the archsetup-owns-dotfiles rule. + +1. wtimer engine: alarm recurring-days + snooze + a ringing state, timer repeat, + the configurable pomodoro cycle (work/rest short+long, long-break interval, + auto-advance, deep cycle), half-past parsing, and the watch/subscribe mode + (decision D). Extend wtimer's own suite per addition. +2. PanelModel: the four-bucket soonest-fire sort, promote/cycle wrap (stopwatches + included), per-type create validation + presets (locked defaults, custom + delete, flash-on-load), and the row/hero view data (sweep fraction, cycle + dots, last lap, feature badges, ringing controls). GTK-free, unit-tested like + the net PanelModel. +3. GTK GUI: the hero (progress ring / sweep dial, one full-width button row, lap + badge), the per-type create strip (timer repeat toggle; alarm weekday selector + + snooze; pomodoro config grid; presets that flash), the header close ✕, + Esc-to-close, and bar-click reopen. +4. Bar parity: =wtimer render= tooltip labels verbatim, state classes confirmed; + panel and bar track one state via the watch subscription. +5. AT-SPI smoke + a manual-testing checklist (todo.org). Retire the =wtimer new= + fuzzel flow (decision B) after the panel proves out. + +Deferred to a vNext: stopwatch run-save (an org log of splits). diff --git a/docs/specs/2026-07-03-audio-panel-spec.org b/docs/specs/2026-07-03-audio-panel-spec.org new file mode 100644 index 0000000..5b678a8 --- /dev/null +++ b/docs/specs/2026-07-03-audio-panel-spec.org @@ -0,0 +1,166 @@ +#+TITLE: Audio Panel — the pulsemixer console +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-03 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Audio Panel — the pulsemixer console +:PROPERTIES: +:ID: 9175e017-46ad-4887-ae45-887e9551c005 +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Shipped; build task DONE, manual tests filed.) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: 71f556c6-ee02-47cc-a3be-68c8289380f3 +:END: +- [2026-07-03 Fri] IMPLEMENTED — built in a no-approvals speedrun in the + dotfiles repo (branch panel-bugfixing): engine (pactl), presenter, GTK + panel, PTT arming, bar indicator, and the bar/keybind wiring, across four + commits 65e5bb0..9601420. 102 unit tests + a passing AT-SPI smoke on velox. + All five Decisions below resolved. Live-eyeball validation (visual polish, + PTT-in-a-meeting, fader feel, the master-mute hardware key) is the one open + follow-up, tracked as a manual-testing task in todo.org. +- [2026-07-03 Fri] DRAFT — stub from the todo.org task "Audio panel spec" + (roam ask 2026-07-02) plus the 2026-07-03 waybar/sound design discussion. + Written to iterate alongside the prototype + (=docs/prototypes/2026-07-03-sound-panel-prototype.html=). Spine is present; the + Decisions and Design detail get filled in as we go. + +* Metadata + +| Field | Value | +|--------+---------------------------------------------------| +| Status | implemented | +|--------+---------------------------------------------------| +| Owner | Craig Jennings | +|--------+---------------------------------------------------| +| Repo | dotfiles | +|--------+---------------------------------------------------| +| Kin | net panel + bt panel (architecture + aesthetic | +| | donors), desktop-settings panel (sibling) | +|--------+---------------------------------------------------| + +* Problem + +Audio control today is the pyprland audio scratchpad (Super+A) — a floating +pulsemixer TUI — plus scattered bar affordances: =pulseaudio= (volume, click +to mute sink), =pulseaudio#mic= (mic glyph + mic-toggle), Super+M audio-cycle +ring, Super+Shift+A mic-toggle. There's no single glanceable surface that +shows every sink and source, lets you set the default output/input, and +carries the meeting-grade mic controls Craig wants (a clean muted mode and a +hold-to-talk mode). The net + bluetooth panels set the pattern for exactly +this shape; audio is the third instrument in the family. + +* Goals + +1. One panel, opened from the bar's sound glyph, exposing the full pulsemixer + surface: every sink and source, per-device volume, per-device mute, and + switching the default output and input. +2. Replace the pyprland audio scratchpad (Super+A) as the primary audio UI. +3. Mic modes for meetings: *live*, *muted*, and *push-to-talk* (mic stays + muted except while Space is held, releasing re-mutes). +4. A *master quick-mute* — one action mutes all output — reachable from the + faceplate and a keybind. +5. Instrument-console aesthetic and architecture consistent with net + bt: + same faceplate, lamps, engraved sections, console keys, needle gauges, + verify-everything contract. +6. The bar glyph reflects live state: speaker + three arcs normally, a + speaker-with-✕ when muted (Craig's called glyphs). + +* Design sketch + +Prototype: =docs/prototypes/2026-07-03-sound-panel-prototype.html= (the reference for +layout + idioms below). + +** Surface (from the prototype) + +- *Faceplate* — status lamp, sound glyph, state word (PLAYBACK / MUTED), a + MUTED badge, the SND·01 unit label, and the *master quick-mute switch* + (same switch idiom as net wifi / bt power), plus the close ✕. +- *OUTPUTS section* — one row per sink. Row body click = set default (gold + DEF tag). A machined fader sets that sink's volume; the trailing glyph + mutes just that device. Active/default row is emphasized (cream name, gold + lamp/glyph). +- *INPUTS section* — one row per source, same idioms. +- *Mic mode* — three console keys: LIVE / MUTED / PUSH·TALK. Push-to-talk + keeps the mic muted (red IN needle) and un-mutes only while Space is held. +- *Twin VU needles* — output level + input level, the sound analog of net + throughput and bt battery gauges. Needle goes red when its side is muted. + +** Architecture — clone the net/bt panel stack + +- GTK4 + gtk4-layer-shell, Blueprint =.blp= → committed =.ui= (=make ui=, + dev-only build dep). +- Humble-object split: a GTK-free PanelModel presenter (unit-tested like the + net/bt PanelModels) + thin composite-widget pages. Backing actions in a + GTK-free =audio.py= that shells to the audio control layer (pactl / + wpctl / pulsemixer — pick below), TDD'd with fake binaries. +- One gated AT-SPI smoke (=run-panel-smoke.sh= pattern). +- Shared instrument-console palette CSS asset (the one net/bt/settings all + load) — do not duplicate the palette block. +- Code lives in dotfiles =audio/= sibling to =net/= (src-layout, tests in + =tests/audio/=). + +* Decisions (Craig) + +** DONE Audio control backend — pactl vs wpctl vs pulsemixer +CLOSED: [2026-07-03 Fri] +Resolved: =pactl= (the engine module is =pactl.py=). Both ratio and velox run +PipeWire with the pipewire-pulse compat layer and no PulseAudio daemon, so +pactl and wpctl hit the same graph — but =pactl -f json= gives structured, +name-addressable output where wpctl offers only a volatile-id tree. Reads go +through =pactl -f json list sinks|sources= + =get-default-*=; writes target +devices by stable name behind an argv-charset guard. + +** DONE Push-to-talk mechanism under Wayland (feasibility — phase 1) +CLOSED: [2026-07-03 Fri] +Resolved: route (a), Hyprland dynamic binds. The phase-1 spike confirmed all +three primitives on velox (Hyprland 0.55.4): =hyprctl keyword bind/unbind= +adds and removes a bind live, =bindr= fires on release, and =pactl +set-source-mute @DEFAULT_SOURCE@ 0|1= toggles the mic cleanly. =ptt.py= arms a +press bind (un-mute) + a bindr (re-mute) on entering PTT mode and unbinds on +leaving, so the talk key isn't grabbed globally otherwise. No evdev needed. +Documented behavior: while PTT is armed, the talk key is the talk key. + +** DONE Quick-mute keybind + scope +CLOSED: [2026-07-03 Fri] +Resolved: the XF86AudioMute hardware key (Super+Shift+M turned out to be taken +by the monocle-layout bind, so the spec's assumption was wrong). The mute key +now runs =audio quick-mute=, which mutes every output (master), not just the +default sink — identical on a single-sink machine, correct on a multi-sink +one. Also reachable from the faceplate master switch and the panel. Scope: +master mute of all sinks, with verify-after-apply per sink. + +** DONE Bar glyph click map +CLOSED: [2026-07-03 Fri] +Resolved with the low-regret wiring: kept the existing =pulseaudio= waybar +module (left-click mute, scroll volume — no regression) and repointed its +right-click from the retired pulsemixer scratchpad to =audio-panel=. So: left += mute, right = open panel, scroll = volume. A fuller =custom/audio= indicator +(state-following speaker glyph + its own click map) is built and tested +(=indicator.py= + =waybar-audio=) but stays unwired until the new bar glyph +gets a live eyeball — the swap is a one-line waybar edit when Craig's ready. + +** DONE Fate of the existing audio affordances +CLOSED: [2026-07-03 Fri] +Resolved: Super+A repurposed from =pypr toggle audio= (the pulsemixer +scratchpad) to =audio-panel= — the panel is the primary audio UI now, so the +scratchpad is retired. Its definition still sits in the machine-local +=pyprland.toml= (not stowed) and can be deleted by hand. Kept: =pulseaudio= + +=pulseaudio#mic= waybar modules (glance + scroll + the mic-mute glance), +Super+M cycle, Super+Shift+A + XF86AudioMicMute mic-toggle. Changed: +XF86AudioMute → master quick-mute (see the quick-mute decision above). + +* Implementation phases + +1. Push-to-talk feasibility spike (decision above) — the one unknown; settle + the mechanism before committing the mic-mode design. +2. =audio.py= backings (list/get/set/mute/default for sinks + sources) — + pure engine, TDD with a fake audio backend. +3. PanelModel presenter (rows, default tracking, mic modes, master mute, + verify-after-apply) — unit-tested, no GTK. +4. Blueprint UI + sound bar glyph (normal / muted / ptt states) + open/close + wiring; shared palette css; AT-SPI smoke. +5. Bar-affordance consolidation per the decision above; retire the Super+A + scratchpad; keybinds. diff --git a/docs/specs/2026-07-03-instrument-console-panels-spec.org b/docs/specs/2026-07-03-instrument-console-panels-spec.org new file mode 100644 index 0000000..2c80aa9 --- /dev/null +++ b/docs/specs/2026-07-03-instrument-console-panels-spec.org @@ -0,0 +1,174 @@ +#+TITLE: Instrument-console rebuild — net + bluetooth panels +#+DATE: 2026-07-03 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Instrument-console rebuild — net + bluetooth panels +:PROPERTIES: +:ID: ac23e996-a51a-466b-ad80-2faff46447bf +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Panel rebuild shipped (dotfiles e993c3f); build task DONE.) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5 +:END: +- 2026-07-03 Fri @ 17:35 -0400 :: Post-impl increment (stays IMPLEMENTED): added + a manual rescan/scan ⟳ affordance to both panels — net NETWORKS header + (drives manage.rescan) and bt NEARBY header (drives on_scan / pair-mode + discovery), with the approved "all" busy style (Gtk.Spinner throbber + a + GLib breathe on "scanning…" + a one-shot list fade; no CSS keyframes exist in + this GTK setup). Prototype: archsetup docs/prototypes/2026-07-03-net-panel-rescan-prototype.html. Code shipped + UNCOMMITTED into the dotfiles repo from an archsetup session (cross-project); + handoff at ~/.dotfiles/inbox/2026-07-03-1733-from-archsetup-rescan-handoff.org. + Verified: net 584 + bt 223 unit OK, both AT-SPI smokes green (⟳ present); + live busy-animation feel pending Craig's eyeball. +- 2026-07-03 Fri @ 06:49 -0400 :: DOING → IMPLEMENTED: all six phases shipped + (net GTK-free layer 81ec9c3, net view 800ef60; bt GTK-free layer 5318b34, bt + view 66f03d9; phase-6 dead-code removal f4e688e). Both panels are single-screen + instrument consoles, verified live on velox — 46 suites + full make test green, + both AT-SPI smokes green end to end, render matching the approved prototype. The + three folded tasks (network panel redesign, bt switch placement + title, bt + rename devices) closed with the build. +- 2026-07-03 Fri @ 02:07 -0400 :: DRAFT → READY → DOING in one stroke: Craig + approved the design through five interactive prototype iterations and + authorized the no-approvals speedrun ("let's build them now... go"). The + review gate was the live prototype session itself. +- 2026-07-03 Fri @ 02:07 -0400 :: Created (DRAFT) from the prototype session. + +* Metadata + +| Field | Value | +|---------------------+------------------------------------------------------------| +| Status | implemented | +|---------------------+------------------------------------------------------------| +| Owner | Craig Jennings | +|---------------------+------------------------------------------------------------| +| Repos | dotfiles (net/, bluetooth/, themes), archsetup | +|---------------------+------------------------------------------------------------| +| Normative reference | [[file:../prototypes/2026-07-03-instrument-console-panels-prototype.html][docs/prototypes/2026-07-03-instrument-console-panels-prototype.html]] | +|---------------------+------------------------------------------------------------| + +* Summary + +Rebuild both GTK layer-shell panels (net, bluetooth) from the tabbed layout +to the instrument-console design: one screen, no tabs, a faceplate with a +state word + badges + radio switch + close, engraved section labels, lamp +rows that act on click, dial meters under the console keys, and a doctor +that does it all. The interactive prototype =panel-console-v3.html= is the +normative design reference — when this spec and the prototype disagree on a +visual or interaction, the prototype wins. + +* Decisions (all resolved — Craig, prototype session 2026-07-02/03) + +- Replace the tabbed panels outright. No fallback flag; git history is the + rollback. Net panel first, bluetooth second. +- Advanced repair tiers leave the panel entirely. DOCTOR runs the full + diagnose → classify → lightest-repair → re-verify escalation (the engine + already does this). The surgical tiers stay CLI-only (=net repair ...=). +- Faceplate (both panels): state lamp + state word, badges, unit label + (NET·01 / BT·01), radio switch (wifi radio / bt adapter power), close ✕. + Badges: TUNNEL (gold, net), AIRPLANE (gold, both), LOW BATT (red, bt). +- Sections in order — net: CHANNEL, NETWORKS (+ hidden action), TUNNELS, + CONSOLE (DOCTOR / SPEED TEST keys), meters, output. bt: ADAPTER (with + clickable =discoverable= chip), PAIRED, NEARBY (+ scanning note), CONSOLE + (DOCTOR / SCAN), battery gauges, output. +- Section row budgets, half-row peek, internal scroll (thin slate + scrollbar): NETWORKS 5.5 rows, TUNNELS 4.5, PAIRED 5.5, NEARBY 4.5. + In-range networks sort active-first then strongest-signal-first. Counts on + the engraved headers ("networks · 12 in range", "tunnels · 1 up of 9", + "paired · 3", "nearby · 12"). The panel silhouette never grows with list + length; only the output well is variable and it caps at ~170px. +- Lamp-row grammar: green = live/connected, gold = available/actionable, + off = down/stored, red = failed; busy = pulsing gold during transitions. + Rows act on click (tunnels toggle, networks join, paired devices + connect/disconnect toggle, nearby devices pair). +- Arm-first for anything disruptive or destructive, 3s auto-disarm: + - forget (network or bt device): hover reveals ✕; first click arms the + row terracotta ("forget? click ✕ again"), second fires. No dialog. + - disconnect (active network): click the active row; first click arms in + GOLD ("disconnect? click again") — disruptive, not destructive — second + fires. +- Meters (net): two dials, RX·DOWN / TX·UP, gold needles, mode tag top-left + (LIVE green / TEST gold), HOLD tag top-right. Idle: live link throughput. + Speed test: cards flash gold, needles sweep the measured rate, then PIN + the final value with HOLD; clicking a held meter releases it to LIVE. + Scale 0–100 Mbps, auto-relabel to 0–1000 when a reading exceeds 100. + Dial top inset ~13px so the corner tags never touch the arc. +- Speed test output well gets ONLY: location line ("location: <city> by + <sponsor>"), ping (+jitter), final line, conditioned tip(s). The rates + live in the meters, not the text. +- Battery gauges (bt): same dial chrome; one per connected device (two + slots; empty slot dim "NO DEVICE"/"ADAPTER OFF"); needle+value red under + 15% and the LOW BATT faceplate badge lights. +- Output well: doctor streams the checks with their narration lines + (viewmodel.STEP_NARRATION) and repair steps in gold; verdict line closes + (olive for pass/fixed). A dismiss ✕ appears in the well's corner whenever + it has content. Both panels. +- WiFi radio switch: =nmcli radio wifi on|off=. Off empties NETWORKS to one + dim "wifi radio off" row, drops the connection, kills tailscale rows' + reachability; on rejoins the last network (NM autoconnect does this for + real). Airplane mode is system-level (Super+Shift+A owns it): both panels + reflect it (state word AIRPLANE, gold badge, switches down); a switch + flipped under airplane refuses with a toast naming the exit. A routed + ethernet link keeps the net panel ONLINE through airplane mode. +- Ethernet: presence-based row pinned atop NETWORKS when a cable is up + ("enp… · active · wired · 1.0 Gbps" / "wired · standby"); CHANNEL swaps + the signal ladder for "wired · <speed> full-duplex" when routed; clicking + the row toggles route ownership via device disconnect/connect. +- Pairing (bt): nearby row click → busy → passkey-confirm dialog (large + gold digits) → device moves to PAIRED and connects. SCAN key refreshes + with a "scanning…" note on the header. +- Rename (bt): hover ✎ on a paired row → dialog prefilled → bluez + =set-alias= (closes the filed rename task). +- Tooltips: any ellipsized row label carries its full text as the tooltip. +- Dialogs (join / hidden SSID / passkey / rename) keep the in-panel dupre + dialog style (gold border, dark well inputs, gold caret). +- Close: ✕ on the faceplate + Esc (already shipped; keep). +- Folded tasks: "Network panel redesign", "Bluetooth panel: switch placement + + panel title", "Bluetooth panel: rename devices" — all close with this + build's phases. + +* Engine gaps (small, close during phases) + +- radio verb: =nmcli radio wifi on|off= helper (manage or sysio) + tests. +- hidden-SSID join: =manage.add= grows a hidden flag + (=802-11-wireless.hidden yes=). +- ethernet: device rows from =nmcli dev= (type ethernet) + disconnect/ + connect verbs (device-level; =net down --iface= already disconnects). +- bt rename: btctl =set-alias= one-shot verb + verify-after read. +- bt battery: already exposed (indicator uses it). +- speedtest meters: =run_speedtest_stream= on_update already ticks (pty). +- link speed for wired channel line: =ethtool=-free read from + =/sys/class/net/<dev>/speed=. + +* Implementation phases + +1. [X] Spec + task wiring (this file; todo.org parent task with :SPEC_ID:). +2. [X] Net GTK-free layer (TDD): viewmodel row composers for the console + sections (network rows sorted+counted, tunnel rows, channel facts, + faceplate state word derivation, meter scale logic, arm state machines + for forget/disconnect), PanelModel restructure (sections, no tabs). + Engine gaps: radio verb, hidden join, ethernet rows, wired link speed. +3. [X] Net view rebuild: gui.py single-page console built in Python + (faceplate, engraved scrolled sections, console keys, cairo dial meters + with mode/hold tags, output well + dismiss), panel.css additions + (engrave, lamps, dial, badges, arm tints). AT-SPI smoke + driver + rewritten for the console layout. Shipped with phase 4 (dotfiles + 800ef60): a view-only intermediate is a broken panel (rows and switches + that do nothing), so view + interactions landed together. +4. [X] Net interactions: join/hidden/forget (arm terracotta)/disconnect + (arm gold)/radio switch/ethernet toggle/doctor stream/speed-test-drives- + meters, toasts. Verified live on velox (DOCTOR streams, SPEED TEST sweeps + both dials then HOLD). Shipped in dotfiles 800ef60 with phase 3. +5. [X] Bluetooth panel: same treatment end to end (faceplate + power + switch, adapter chip, paired/nearby lamp rows, pair passkey flow, + rename via set-alias, forget arm, battery gauges + LOW BATT, DOCTOR / + SCAN keys, output). bt smoke rewritten. Shipped in two commits mirroring + net: dotfiles 5318b34 (GTK-free layer + engine gaps) and 66f03d9 (view + + interactions + smoke). rename lands on the bluez Alias via busctl + (set-alias has no MAC-addressed one-shot); verified live on velox (smoke + green end to end, screenshot matches the prototype). +6. [X] Live verification both panels on velox + all suites + smokes green; + summary of findings written to file; folded tasks closed; dead code + removed; session context finalized. diff --git a/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org b/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org new file mode 100644 index 0000000..f23e97d --- /dev/null +++ b/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org @@ -0,0 +1,232 @@ +#+TITLE: Audio Panel — signal metering (which inputs/outputs actually have sound) +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-06 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Audio panel signal metering +:PROPERTIES: +:ID: baef9e7b-d255-4e80-9d10-68a7a3dd04fd +:END: +- 2026-07-06 Mon @ 10:40:00 -0500 — IMPLEMENTED — all 4 phases built TDD in dotfiles (6054d3d activity-state engine, 174ce14 three-state lamp, 55ab9f9 peak engine, 21437b4 live needles + lifecycle). Audio suite 124→161; full dotfiles make test green (49 suites). One residual: live eyeball checks (needle deflects under real audio, PTT INPUT registration, three-state lamp, no-CPU-when-closed) filed as a manual-testing task in todo.org. +- 2026-07-06 Mon @ 10:02:00 -0500 — DOING — decomposing the four phases into the build (no-approvals speedrun). +- 2026-07-06 Mon @ 10:00:00 -0500 — READY — spec-review round 2 passed (all 4 round-1 findings resolved DONE; both cookies complete; no blockers). +- 2026-07-06 Mon @ 09:37:52 -0500 — DRAFT — drafted. Scopes "option 2" from the 2026-07-06 design discussion: an activity/peak metering layer on the audio panel, with a per-device-keyed engine so per-device rendering (option 3) is a later GUI-only change. + +* Metadata + +| Field | Value | +|----------+--------------------------------------------------------------| +| Status | implemented | +|----------+--------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+--------------------------------------------------------------| +| Reviewer | Claude Code (archsetup) | +|----------+--------------------------------------------------------------| +| Repo | dotfiles (=audio/= package); archsetup owns the lifecycle | +|----------+--------------------------------------------------------------| +| Related | [[file:2026-07-03-audio-panel-spec.org][audio-panel-spec (parent, IMPLEMENTED)]]; net/bt panels (kin) | +|----------+--------------------------------------------------------------| + +* Summary + +The audio panel shows volume and mute per device, but nothing tells you which output or input actually has sound moving through it right now. The "twin VU needles" today are fed by =dev.get("volume")= (=gui.py:522=) — they track the fader, not the signal, so a muted-at-source-but-fader-up device reads full and an actively-playing device at low volume reads low. This spec adds a real signal-presence layer: a cheap activity state (is audio flowing?) driving a three-state lamp, plus a windowed peak meter (is there actual level?) feeding the needles, both keyed per device in the engine so the aggregate v1 view and a future per-device view (option 3) share one metering core. + +* Problem / Context + +Craig's ask, verbatim: "I would like an indication on the audio panel which outputs or inputs actually have sound coming out of them." The panel (parent spec =2026-07-03-audio-panel-spec.org=, IMPLEMENTED) presents each sink/source with volume + mute and an aggregate INPUT/OUTPUT console-key pair whose lamp is green-when-unmuted / red-when-muted (=panel.py:133= =control_lamp=). Neither surface answers "is this device carrying sound." Two things are conflated today: + +- *Mute state* — is the device armed? (Have now.) +- *Signal presence* — is audio actually flowing / audible through it? (Missing.) + +"Which have sound coming out" is really two questions with two mechanisms: + +1. *Audio is flowing* (routing level) — the device is =RUNNING= with an uncorked stream. Cheap, already 90% available: =pactl -f json list sinks|sources= returns a per-device =state= field the engine currently drops in =pactl._device()= (=pactl.py:94=). Answers "is this device in use." Won't distinguish a stream playing silence from one making real sound. +2. *Audible signal* (peak level) — real PCM peak above a floor, read from the sink's =.monitor= source (outputs) or the source directly (inputs). The only thing that truly answers "sound coming out," and what pavucontrol's moving bars show. Costs a live capture stream. + +The parent panel already carries the widgets to express both (the INPUT/OUTPUT lamps and the twin VU needles); they just have no signal behind them. + +* Goals and Non-Goals + +** Goals +1. A per-device *activity* flag in the engine (device is carrying audio now), derived from the =pactl= =state= field, exposed through the status envelope. +2. A *three-state* INPUT/OUTPUT lamp: muted, live-idle (unmuted, no signal), live-active (unmuted, audio flowing). +3. A *windowed peak meter* feeding the twin VU needles from real level on the default sink's monitor and the default source — replacing the current volume-fed needle. +4. Build the metering *engine keyed by device name* so a later per-device view (option 3) runs the same core over N devices with no engine rework. +5. Zero cost when the panel is closed — the live meter runs only while the panel is visible. + +** Non-Goals +- *Per-device rows with their own meters* (option 3). The engine is built to support it; the v1 GUI renders aggregate INPUT/OUTPUT only. Deferred to vNext. +- *Live peak on the bar indicator.* The bar glyph stays state-based (=indicator.py=); a bar that meters continuously would run a capture stream forever. Out of scope. +- *Per-application / per-stream metering* (which app is making the sound). Device-level only. +- *Changing volume/mute/default/PTT behavior.* This is an additive read-only display layer. +- *Configurable meter ballistics beyond a rate + a floor.* No user-facing VU calibration. + +** Scope tiers +- v1: activity flag in engine; three-state aggregate INPUT/OUTPUT lamps; peak-fed twin VU needles for default sink + default source; panel-open-gated metering; per-device-keyed engine. +- Out of scope: bar-indicator live peak; per-app metering; VU calibration UI. +- vNext (log to todo.org): per-device *live level meter* — one =parec= reader per active device on each row (the activity *lamp* part of option 3 shipped in Phase 5); uncorked-stream refinement of the activity flag (see Decisions). + +* Design + +** For the user + +Open the panel. Each of the INPUT and OUTPUT console keys now carries a lamp with three readings instead of two: dark-red when that side is muted, a dim/idle green when it's live but nothing is playing, and a bright green when audio is actually flowing. The twin VU needles below move with the *real* signal — the OUTPUT needle rises when sound is genuinely coming out of the speakers (not merely because the fader is up), and the INPUT needle rises when the mic is picking up sound. Hold PTT and watch your own voice register on the INPUT needle — a live confirmation the mic works. Close the panel and nothing keeps running. + +The distinction is honest: the lamp says "this device is carrying audio"; the needle says "and here's how loud." A device that's RUNNING but playing silence shows a live-active lamp with a flat needle — which is the truth. + +** For the implementer + +Two independent signals, both per-device, layered onto the existing humble-object stack (=pactl.py= engine → =status.py= envelope → =panel.py= PanelModel → =viewmodel.py= pure helpers → =gui.py= thin view). + +*Activity (cheap, no new process).* Extend =pactl._device()= to carry =state= (=entry.get("state")=, normalized lower-case: =running= / =idle= / =suspended= / =None=). It rides through =build_status()= on each device dict already flagged =default=. In =panel.py=, add =control_active(control)= (the control's default device has =state == "running"=) and a three-state =control_lamp3(control)= → =muted= / =live-idle= / =live-active=. =gui.py= maps those to CSS classes on the existing lamp. Fully unit-testable — feed a status dict, assert the lamp. + +*Peak (richer, one managed process per metered device).* A new GTK-free =peak.py=: a =PeakReader(device_name, kind)= that spawns a native-rate mono float capture in its own process group (=parec -d <dev> --format=float32le --channels=1 --rate=44100 --latency-msec=40 --raw=, =start_new_session=True=; for a sink =<dev>= is its monitor resolved from =pactl.list_sources(include_monitors=True)= with =@DEFAULT_MONITOR@= as the default-sink fallback, for a source it's the source name), reads each ~40 ms flush on a worker thread, and reduces it to a window-peak in =[0.0, 1.0]=. A =PeakMeter= manager holds readers keyed by device name and kills each reader's process group on stop — v1 starts two (default sink's monitor, default source); option 3 starts N. Pure ballistics (decimate window-peaks to the display rate =AUDIO_METER_RATE=, normalize, attack/decay smoothing, noise floor) live in =viewmodel.peak_fraction()=, replacing the =volume=-fed =vu_fraction()= call at =gui.py:523=. =gui.py= starts the meter when the panel is shown and tears it down from a =close-request= handler on the window and =do_shutdown= on the application — the real close/quit path, since the app has no =hide= signal and quits when its last window closes — pushing peaks to the needle via =GLib.idle_add= (the existing =bg()= worker pattern). The lamp and the needle are independent: the lamp works with no peak stream at all, so a metering failure degrades to activity-only, never to a dead panel. + +Integration points (named): =pactl._device()= / =pactl.py:94=; =status.build_status()=; =panel.PanelModel.control_lamp= / =:133=; =viewmodel.vu_fraction= / =:72= and its caller =gui._set_vu= / =:520-523=; the panel =close-request= / =do_shutdown= lifecycle in =gui.py=; =tests/audio/= (fake-binary-on-PATH harness, mirroring the =pactl= fakes). + +* Alternatives Considered + +** Peak tool: parec vs pw-cat/pw-record vs a PA peak-detect stream +- Good, because =parec= ships with =pipewire-pulse= (already the whole stack — =pactl= is the same layer), needs no new dependency, and float32 raw output reduces to a peak with trivial code. The classic VU-from-a-pipe approach. +- Bad, because it's a managed subprocess per device — start/stop discipline matters (orphan =parec= = silent CPU leak). +- Neutral, because =pw-cat --record= would work equally and =pw-dump= can't stream peaks; the choice is ergonomic, not capability-bound. + +** Activity source: state field alone vs state + uncorked sink-input check +- Good (state alone), because it's one field already in the JSON, zero extra calls, and =RUNNING= is a strong "audio flowing" signal. +- Bad, because PipeWire lingers a sink in =RUNNING= briefly after playback stops and can show =IDLE= with a corked stream attached — so state alone is slightly coarse. +- Neutral, because the uncorked =sink-input=/=source-output= cross-check is a strictly-additive refinement (vNext) that doesn't change the v1 shape. + +** Metering lifecycle: always-on vs panel-open-only +- Good (open-only), because it's zero cost when closed and the panel is the only place the meter renders. +- Bad, because the bar indicator can't show live peak (acceptable — it stays state-based). +- Neutral, because always-on would only matter if the bar wanted a live meter, which is a non-goal. + +** Aggregate v1 vs jump straight to per-device (option 3) +- Good (aggregate first), because it de-risks the live-metering runtime (does peak read cleanly, is CPU acceptable, does the needle feel right) against one lamp before investing in per-device row layout + hotplug; and the parent panel's "aggregate only" decision stands. +- Bad, because a second GUI pass is needed later for option 3. +- Neutral, because the engine is keyed per-device now, so option 3 is GUI-only — no throwaway. + +* Decisions [/] + +** DONE Meter only while the panel is open — reap on app-quit, not just a hide signal +CLOSED: [2026-07-06 Mon] +- Context: a live peak stream is a running =parec= subprocess; leaving it on continuously (e.g. to feed the bar) burns CPU forever. *Round-1 review (code fact):* =gui.py= connects no =show=/=map=/=hide= signals, the window has no =set_hide_on_close(True)=, and the app takes no =hold()= — so closing the window (=do_activate= toggle =gui.py:218=, close button =:280=, Escape/q =:456=) destroys the last window and the =Gtk.Application= quits, exiting the process. Children spawned via =subprocess= are *not* reaped on interpreter exit; they reparent to init and keep running — exactly the orphan-CPU leak this feature names as its top risk. +- Decision: We will (a) spawn each =parec= in its own process group (=start_new_session=True=) and kill the group on teardown, and (b) tear down from the real lifecycle — a =close-request= handler on the window *and* =do_shutdown= on the application — not a =hide= signal this app doesn't emit. Start metering when the panel is realized/shown. Resolve the sink's monitor from the graph (=pactl.list_sources(include_monitors=True)=, since =list_sources= drops monitors via =_is_monitor= at =pactl.py:83=), falling back to =@DEFAULT_MONITOR@= for the default sink. The bar indicator stays state-based; only the open panel meters. +- Consequences: easier — near-zero cost when closed, bounded process lifetime, no orphans even on a straight quit; harder — teardown must hook the actual close/quit path, verified by a test that drives a real window-close / app-quit (not a synthetic hide) and asserts no surviving =parec= children. + +** DONE Peak via parec — capture at native rate, window in Python to the display rate +CLOSED: [2026-07-06 Mon] +- Context: need a per-device level stream; the stack is pipewire-pulse (no PulseAudio daemon), and =pactl= gives no peak. *Round-1 review verified live on ratio (2026-07-06):* =parec --rate= is the *sample* rate, not an update rate — there is no window flag — and at =--rate=25= with default latency parec flushed ~0 bytes in 3s; only an explicit =--latency-msec= produced timely output. parec has no peak-detect resampler (unlike pavucontrol's PA_RESAMPLER_PEAK), so a low =--rate= just decimates/aliases the transients a meter should show. +- Decision: We will *capture at a real rate with a low flush latency and window in Python*: =parec -d <dev> --format=float32le --channels=1 --rate=44100 --latency-msec=40 --raw=, reading each ~40 ms flush and reducing it to =max(abs(sample))=, then decimating those window-peaks to the *display* rate =AUDIO_METER_RATE= (default 25 Hz). Capture rate and display rate are separate concepts: capture stays native/high for real transients; the needle updates at the display rate. =<dev>= is the sink's monitor for outputs (resolved from the graph — see the metering-lifecycle decision — with =@DEFAULT_MONITOR@= as the default-sink fallback) and the source name for inputs. +- Consequences: easier — no new dependency, a genuine windowed level, testable with a fake =parec= emitting scripted float windows; harder — the reader owns the capture-vs-display-rate windowing (pure math in =viewmodel=), subprocess management, and a charset/validity guard on the device name before argv (reuse =pactl.valid_name=). This is a *windowed peak/level meter*, not a hardware true-peak meter — the wording elsewhere is tempered to match. + +** DONE Activity from the pactl state field (RUNNING = active) +CLOSED: [2026-07-06 Mon] +- Context: the three-state lamp needs an "is audio flowing" bit that's cheap and event-cheap. +- Decision: We will capture the =state= field in =pactl._device()= and treat =running= as active; =idle=/=suspended=/=None= are not active. The uncorked-stream cross-check is deferred to vNext. +- Consequences: easier — one field, no extra call, the lamp updates on the panel's existing re-reads; harder — a brief RUNNING linger after playback and a corked-stream-on-IDLE case make the lamp slightly coarse (documented; refinement is vNext). + +** DONE Engine keyed per device; v1 GUI renders aggregate +CLOSED: [2026-07-06 Mon] +- Context: Craig will want per-device metering (option 3) later; the peak primitive is inherently per-device. +- Decision: We will build the activity flag on every device dict and the =PeakMeter= manager keyed by device name, while the v1 GUI meters only the default sink + default source. Option 3 becomes a GUI-only change. +- Consequences: easier — no engine rework for option 3, per-device state is unit-testable now; harder — the manager carries multi-reader machinery the v1 view doesn't exercise (kept minimal, exercised by tests). + +** DONE Lamp and needle are independent; metering failure degrades gracefully +CLOSED: [2026-07-06 Mon] +- Context: the peak path can fail (no =parec=, monitor unavailable, permission) where the cheap activity path still works. +- Decision: We will drive the three-state lamp from the (cheap, always-available) activity flag and the needle from peak, as independent signals. A peak failure leaves the lamp working and the needle at rest; it never breaks the panel. +- Consequences: easier — honest partial degradation, matches the panel's verify-everything contract; harder — two code paths to keep decoupled (no shared failure). + +* Review findings [4/4] + +** DONE parec --rate=25 produces no usable meter stream :blocking: +Round-1 review verified live on ratio: =parec --rate= is the sample rate, not an update rate, and at 25 with default latency it flushed ~0 bytes; there's no peak resampler. Resolved: the "Peak via parec" decision now captures at native rate with =--latency-msec=40= and windows to the display rate in Python; "true peak meter" tempered to "windowed peak/level meter"; acceptance now demands a live-audio deflection check. + +** DONE Orphan parec on app-quit (teardown hooks absent) +Round-1 review (code fact): =gui.py= emits no show/hide, and the app quits when its last window closes, leaking =subprocess= children. Resolved: the metering-lifecycle decision now spawns each reader in its own process group (=start_new_session=True=), kills the group, and tears down from =close-request= + =do_shutdown= (not a hide); acceptance verifies reap through the real close/quit path. + +** DONE Three-state lamp needs the _set_lamp removal set extended +Round-1 review (code fact): =_set_lamp= (=gui.py:100-104=) removes a hardcoded class list and treats no-class as green, so new lamp classes stack. Resolved: Phase 2 now specifies adding the three classes to the removal set (or a dedicated setter) via a =control_lamp3= render path. + +** DONE state is uppercase; monitors filtered from list_sources +Round-1 review (live fact): =pactl -f json= emits =state= uppercase and =list_sources= drops monitors via =_is_monitor=. Resolved: Phase 1 now =.lower()=s state with uppercase fixtures; monitor resolution uses =list_sources(include_monitors=True)= with =@DEFAULT_MONITOR@= fallback. + +* Implementation phases + +** Phase 1 — Engine: capture device activity state +Extend =pactl._device()= to carry a normalized =state= — =pactl -f json= emits it *uppercase* (=RUNNING=/=IDLE=/=SUSPENDED=), so =.lower()= it to =running=/=idle=/=suspended=/=None=; thread it through =status.build_status()= so each sink/source dict exposes it. Pure parser change, TDD against real =pactl -f json= sample shapes (fixtures carry the uppercase inputs so the =.lower()= is exercised). Tree stays working — added field, nothing consumes it yet. + +** Phase 2 — PanelModel + GUI: three-state activity lamp +Add =panel.control_active(control)= and =panel.control_lamp3(control)= (=muted= / =live-idle= / =live-active=); unit-test the state machine. Wire =gui.py= via a three-state render path — the existing =_set_lamp= (=gui.py:100-104=) removes a *hardcoded* class list (=gold/red/off/busy=) and treats no-class as green, so the three new classes must be added to that removal set (or a dedicated lamp-setter) or stale classes stack. Ships the activity indicator (aggregate) with no peak stream involved. + +** Phase 3 — Peak engine: per-device peak reader +New GTK-free =peak.py=: =PeakReader(device_name, kind)= (spawns =parec= at native rate + low =--latency-msec= in its own process group, reads/reduces flush windows, validity-guards the name) and a =PeakMeter= manager keyed by device name (start/stop/read, kills each reader's process group on stop). Pure ballistics in =viewmodel.peak_fraction()= (decimate window-peaks to the display rate, normalize, attack/decay, noise floor). TDD with a fake =parec= on PATH emitting scripted float windows + pure math unit tests, plus a reader-lifecycle test asserting the process group is reaped. Nothing renders it yet — tree works. + +** Phase 5 — per-device activity lamp (option 3, GUI-only) +Promoted from vNext at Craig's request right after Phase 4. Each OUTPUTS/INPUTS row leads with its own three-state activity lamp (=viewmodel.device_lamp3(dev)= → =lamp3_class=): muted (red) / live-idle (dim) / live-active (bright green) for THAT device, so the panel shows which specific output/input is carrying audio, not just the default. Pure GUI + a pure viewmodel helper over the per-device =state= field already shipped in Phase 1 — no engine change, no new processes, rebuilt with each row on the status refresh. The per-row *live level meter* (N =parec= readers, one per active device) is the heavier remaining increment, still deferred. +Repoint =gui._set_vu= from =viewmodel.vu_fraction(volume)= to the peak source via =viewmodel.peak_fraction=. Start the =PeakMeter= (default sink's monitor + default source) when the panel is shown; tear it down from a =close-request= handler *and* =do_shutdown= (the real quit path — the app quits when its last window closes), pushing peaks to the needles via =GLib.idle_add=. Update the AT-SPI smoke to assert the meter starts on show and that a window-close/app-quit leaves no =parec= child. Ships windowed signal metering (aggregate). + +* Acceptance criteria +- [ ] Each device dict in =build_status()= carries a =state= of =running=/=idle=/=suspended=/=None=. +- [ ] INPUT/OUTPUT lamp reads muted when the default device is muted, live-idle when unmuted and not =running=, live-active when unmuted and =running=. +- [ ] With *real audio playing* to the default sink, the OUTPUT needle deflects and tracks level (manual live check — a resting needle under silence does not confirm this); with the sink silent (fader up, no stream), the needle rests near zero. +- [ ] With the mic capturing sound, the INPUT needle deflects; muted or silent, it rests. +- [ ] A window-close *and* an app-quit each leave no =parec= process running (verified through the real close/quit path, not a synthetic hide — the process-group kill reaps children). +- [ ] A missing/failing =parec= leaves the three-state lamp working and the needle at rest — the panel still opens and functions. +- [ ] Full dotfiles =make test= green; audio suite grows with new unit tests; AT-SPI smoke passes. + +* Readiness dimensions +- *Data model & ownership:* all signal data is generated/live (never user-authored) — =state= per read, =peak= per window. Owned by the engine; the view renders, never persists. Device identity keyed by stable =name=. +- *Errors, empty states & failure:* peak-path failure (no =parec=, monitor unavailable, permission) degrades to activity-lamp-only + resting needle, never a broken panel (Decision 5). Empty/absent default device → lamp reads muted/absent (existing =control_lamp= behavior), needle rests. No new user-facing error strings; failures are silent-graceful by design since this is a passive display layer. +- *Security & privacy:* the peak stream reads amplitude only, never audio content, and nothing is recorded or logged — only a float level reaches the needle. Device names pass the existing =pactl.valid_name= charset guard before hitting =parec= argv. No secrets. +- *Observability:* the meter *is* the observability surface. A =parec= spawn failure is not surfaced as an error toast (passive layer) but leaves the needle at rest; a debug hook (log the reader lifecycle under the audio package's existing debug gate, if any) is a nice-to-have, not required for v1. +- *Performance & scale:* one =parec= per metered device, mono, 25 Hz — negligible CPU, and only while the panel is open. v1 meters exactly 2 devices. Option 3 scales to N sinks+sources; the manager caps readers to the visible set. Window reduction is O(samples/window); no per-frame allocation beyond the small buffer. +- *Reuse & lost opportunities:* reuses the existing VU-needle Cairo widget (=gui.py:160=, shared with net/bt), =pactl.valid_name=, the =bg()= worker pattern, the fake-binary test harness, and the humble-object split. Repoints the already-present but volume-fed needle rather than adding a new widget. +- *Architecture fit & weak points:* fits the =pactl→status→panel→viewmodel→gui= layering exactly; peak is a new peer engine module (=peak.py=) beside =pactl.py=. Weak point: subprocess lifecycle (orphan =parec=) — mitigated by process-group spawn + =close-request=/=do_shutdown= teardown + a child-reaping test. Second weak point: two independent signal paths — mitigated by keeping them decoupled (Decision 5). +- *Config surface:* two optional keys with safe defaults — =AUDIO_METER_RATE= (the *display*/needle update rate, default 25 Hz; distinct from the fixed native =parec= capture rate, which is not a user knob) and =AUDIO_METER= on/off (default on; off falls back to activity-lamp-only). Named in the audio package =config.py=. No calibration surface (non-goal). +- *Documentation plan:* update the audio package README/help to describe the three-state lamp and the signal needles (vs the old volume needle) and the two config keys. No migration doc — additive. +- *Dev tooling:* existing =make test= / audio suite / AT-SPI smoke cover it; a fake =parec= binary joins the existing fakes. No new make targets needed. +- *Rollout, compatibility & rollback:* additive display layer, no persisted data or API change. Rollback = set =AUDIO_METER=off= (needle falls back to resting / the prior volume feed can be kept as the off-state fallback) — spelled out at build. Ships to both machines via the dotfiles =common=/hyprland stow path like the rest of the panel. +- *External APIs & deps:* =parec= (pipewire-pulse) and the =pactl -f json= =state= field are the two external assumptions. =parec= presence verified on ratio at build (it ships with the already-installed pipewire-pulse); the =state= field presence verified against live =pactl -f json list sinks= output in Phase 1 fixtures. No new package install expected — confirm =parec= on ratio before Phase 3, add to archsetup deps only if absent. + +* Risks, Rabbit Holes, and Drawbacks +- *Orphan =parec= processes* — the top risk. Mitigation: process-group spawn (=start_new_session=True=), a single =close-request=/=do_shutdown= teardown owner in =gui.py= calling =PeakMeter.stop_all()= (kills each group), and a test asserting no surviving children through the real close/quit path. +- *Monitor naming edge cases* — a sink whose =.monitor= name doesn't follow =<sink>.monitor= (rare, but bluez/virtual sinks vary). Mitigation: resolve the monitor source from the sinks/sources graph rather than string-appending =.monitor= where possible; fall back to the appended name; degrade to resting needle if the monitor can't be opened. +- *RUNNING linger / corked-on-IDLE coarseness* — the activity lamp may show live-active a beat after sound stops. Accepted for v1; the uncorked cross-check is the vNext refinement. +- *Meter ballistics feel* — attack/decay tuning is a taste call; the pure =peak_fraction= math makes it adjustable without touching GTK, and the live feel is Craig's eyeball check (manual-testing task). + +* Testing / Verification / Rollout +Unit: =pactl._device()= state parsing (each state + missing); =panel.control_active/control_lamp3= truth table (muted / live-idle / live-active / absent); =viewmodel.peak_fraction= ballistics (floor, clamp, decay, None); =peak.PeakReader= window→peak reduction and name-guard rejection via a fake =parec=; =PeakMeter= start/stop/reap (no orphan). Integration/smoke: AT-SPI smoke asserts meter starts on show and stops on close. Manual (Craig, filed to todo.org): play audio and confirm the OUTPUT needle tracks real level not fader; speak and confirm the INPUT needle + PTT registration; confirm the lamp's three states live; confirm no CPU when closed. Rollout: per-phase commit+push to dotfiles main, =make test= green gate each phase, note dotfiles at the end (archsetup-owns-dotfiles). + +* Review and iteration history +** 2026-07-06 Mon @ 09:37:52 -0500 — Claude Code (archsetup) — author +- What: initial draft scoping option 2 (activity + peak metering, per-device-keyed engine, aggregate GUI). +- Why: Craig asked for an indication of which inputs/outputs actually have sound; the design was settled in the 2026-07-06 session (two signal levels, three-state lamp, engine keyed per device so option 3 is GUI-only). +- Artifacts: parent spec 2026-07-03-audio-panel-spec.org; engine facts grounded in pactl.py:94, panel.py:133, viewmodel.py:72, gui.py:520-523; audio suite green baseline 124 tests. + +** 2026-07-06 Mon @ 09:52:00 -0500 — Claude Code (archsetup) — reviewer +- What: round-1 spec-review with live verification on ratio. One blocking finding (parec --rate is sample-rate not update-rate; no peak resampler; flushes nothing at rate 25 without --latency-msec) and three non-blocking (orphan parec on app-quit since gui has no hide signal; _set_lamp hardcoded class-removal list; uppercase state + monitors filtered from list_sources). Rubric: Not-ready pending the parec-command fix. Every other code reference confirmed accurate. +- Why: the peak half of the feature rested on an incorrect external assumption verified false live; the teardown risk the spec named as top-risk had no concrete hook in the real lifecycle. +- Artifacts: findings recorded in * Review findings. + +** 2026-07-06 Mon @ 09:54:00 -0500 — Claude Code (archsetup) — responder +- What: accepted all four findings and folded them in — rewrote the parec decision (native capture + --latency-msec + Python windowing, display/capture rates separated), the metering-lifecycle decision (process-group spawn + close-request/do_shutdown teardown + graph-resolved monitor with @DEFAULT_MONITOR@ fallback), Phase 1 (.lower() uppercase state), Phase 2 (_set_lamp removal set), Phase 3/4 (command + real-quit teardown), acceptance (live deflection + real-quit reap), and config (display vs capture rate). +- Why: close the blocking gap and the three code-accuracy gaps before a re-review. +- Artifacts: * Review findings all DONE; Decisions updated. + +** 2026-07-06 Mon @ 10:15:00 -0500 — Claude Code (archsetup) — reviewer +- What: round-2 re-review of the folded spec. Confirmed the parec fix (native 44100 + --latency-msec=40 → ~25 workable window-peaks/sec), the process-group + close-request/do_shutdown teardown, the _set_lamp removal-set fix, and the uppercase-state/.lower() + @DEFAULT_MONITOR@ fixes. Rubric: Ready, no remaining blockers; one cosmetic wording note (stale "show/hide"/"true peak" in Goals/Risks) cleaned in the same pass. Flipped DRAFT→READY→DOING. +- Why: verify the fixes resolve the round-1 findings before build. +- Artifacts: review verdict; spec cosmetic cleanup. + +** 2026-07-06 Mon @ 10:40:00 -0500 — Claude Code (archsetup) — implementer +- What: built all four phases TDD in the dotfiles audio/ package and flipped the spec IMPLEMENTED. Phase 1 pactl state capture (6054d3d); Phase 2 three-state activity lamp + viewmodel.lamp3_class + CSS (174ce14); Phase 3 peak.py windowed-peak engine + viewmodel.peak_fraction + fake-parec harness (55ab9f9); Phase 4 live VU needles + PeakMeter lifecycle + config keys + smoke orphan-check (21437b4). Audio suite 124→161; full make test green (49 suites); no-orphan reaping unit-tested. +- Why: complete the no-approvals speedrun through to shipped code. +- Artifacts: dotfiles commits 6054d3d..21437b4; manual-testing task filed in archsetup todo.org. + +** 2026-07-06 Mon @ 11:10:00 -0500 — Claude Code (archsetup) — implementer +- What: promoted the per-device activity lamp (option 3) from vNext to shipped (Phase 5). viewmodel.device_lamp3 + a leading three-state lamp on every OUTPUTS/INPUTS row; dotfiles 1faae65. Audio suite 161→166. Verified live via geometry-cropped grim: with a tone to the default sink, pactl reads it RUNNING (others SUSPENDED) and the row lamps render red=muted / green=live per device. The per-row live level meter (N parec) stays the deferred vNext. +- Why: Craig wanted the per-device view; the engine was already keyed per device so this was a GUI-only follow-on. +- Artifacts: dotfiles 1faae65; smoke green; manual check added to todo.org. diff --git a/docs/specs/2026-07-07-maintenance-console-spec.org b/docs/specs/2026-07-07-maintenance-console-spec.org new file mode 100644 index 0000000..bff187b --- /dev/null +++ b/docs/specs/2026-07-07-maintenance-console-spec.org @@ -0,0 +1,350 @@ +#+TITLE: Maintenance Console — Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-07 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Maintenance console +:PROPERTIES: +:ID: 9d9df833-c592-4aec-a7df-50d588e943ce +:END: +- 2026-07-08 Wed @ 06:18:14 -0500 — IMPLEMENTED — all 14 build phases shipped (1-11 dotfiles 43a39ac..10033be, 11b 3ee22a8, 12 archsetup d6993d3 + dotfiles 0636554, 13 archsetup bef7053 + 18c081f + dotfiles 9a3f0c7). Closing state: maint CLI + GTK panel + waybar glyph live on both hosts; installer wires TOML, timers, and deps; system-health-check workflow adopted and TOML-rewired; VM scenario harness 9/9, nspawn 4/4, AT-SPI smoke green; per-phase review subagents all landed Approve. Residuals tracked in todo.org: manual-testing checklist, zfs base-image DKMS bug [#C], vNext [#D]. +- 2026-07-07 Tue @ 19:54:04 -0500 — DOING — decomposed into the build: 13 phase tasks + manual-testing + flip task under the todo.org parent "Maintenance console build" (:SPEC_ID: stamped); vNext logged [#D]. +- 2026-07-07 Tue @ 19:05:00 -0500 — READY — round-2 spec-review passed (all 10 round-1 findings resolved, no new blockers); Craig approved the config-paths proposal and the sysmon right-click re-homing, closing the last open decision (cookies 13/13, 10/10). +- 2026-07-07 Tue @ 18:30:04 -0500 — DRAFT — drafted from the completed design arc (docs/design/maintenance-console-design-ideas.org, all decisions dated 2026-07-06/07) and the converged E5 interactive prototype. + +* Metadata + +| Field | Value | +|----------+-----------------------------------------------------------------------------------------------------------| +| Status | implemented | +|----------+-----------------------------------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+-----------------------------------------------------------------------------------------------------------| +| Reviewer | Claude Code (archsetup) | +|----------+-----------------------------------------------------------------------------------------------------------| +| Repo | dotfiles (=maint/= package); archsetup owns lifecycle, thresholds TOML, VM test harness | +|----------+-----------------------------------------------------------------------------------------------------------| +| Related | [[file:../design/maintenance-console-design-ideas.org][design doc (source of decisions)]] ; [[file:../prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5 prototype (converged UI)]] ; [[file:2026-07-03-instrument-console-panels-spec.org][instrument-console-panels-spec]] ; | +| | todo.org "Maintenance console" | +|----------+-----------------------------------------------------------------------------------------------------------| + +* Summary + +A single-host maintenance console for the two daily drivers (ratio, velox): a =maint= CLI + GTK panel in dotfiles (fourth panel sibling beside net/bt/audio) that surfaces every Arch health metric and runs the safe remediations behind Confirm/arm guards, streaming results to a live output wall. archsetup owns the severity thresholds (one TOML both the console and the system-health-check workflow read) and the VM harness that tests the destructive remedies. + +* Problem / Context + +Routine maintenance on these machines today is either the ~1000-line agent-driven =system-health-check.org= workflow (forensic, slow, requires a session) or ad-hoc habit — and things fall through: ratio ran at ~75% unclean shutdowns for weeks unnoticed (2026-06-08), a snapper TIMELINE misconfig nearly filled =/home=, a live =-Syu= swapped mesa+hyprland under the running session and crashed the desktop (2026-06-07), and =rust= sat looking orphaned until a manual review found it was intentional. There is no glanceable surface that says "your machine is healthy" or "these three things need a press." + +The console owns the routine-maintenance and at-a-glance slice; the workflow remains the escalation path for forensic investigation. The design converged over 2026-07-06/07 through five divergent prototypes to E5 (selector + dense subpanels + results wall); every open question in the design doc is resolved and dated there. This spec makes that design buildable. + +* Goals and Non-Goals + +** Goals +1. Every metric in the design doc's tables collected read-only on both hosts, capability-dispatched (btrfs/AMD on ratio, ZFS/Intel/battery on velox), exposed as =maint status --json=. +2. Every determinate remedy as =maint fix <thing>= — allowlisted exact argv, global =--dry-run=, privileged verbs through a validated priv table. +3. Two doctor actions — CLEAN UP (all Auto metrics, unattended) and REVIEW & FIX (Confirm metrics, per-item approval) — streaming one lamp per action to the results wall. +4. The E5 GUI: category selector, dense faceplate subpanels, rotary band selectors on tall subpanels, evidence digests, curation lifecycles (MARK KNOWN / KEEP / MARK EXPECTED with UNMARK/CLEAR), arm-to-fire guards, updates strip with the live-update guard. +5. Severity thresholds in one archsetup-owned TOML; user curation in a separate layer a template sync can never eat. +6. Waybar glyph (replacing =custom/sysmon=) tracking the worst *Diagnostic* state, fed by a background timer so the bar stays honest with the panel closed; on battery hosts the glyph doubles as the bar's battery level display. +7. Four-layer test coverage: unit fakes, read-only live, VM remedies via archsetup's harness with qcow2 snapshot/restore, AT-SPI smoke on fixtures derived from the E5 GOOD/BAD snapshots. + +** Non-Goals +- *No AI/workflow assistance from the panel.* Workflow buttons were removed in prototyping (2026-07-07); metrics that need judgment render read-only. vLater. +- *No auto-updates, ever.* Updates run only via the guarded Confirm levers; the guard arms, the user fires. +- *No file-deletion keys in the disk top-consumers digest* — evidence only. +- *No SIGKILL escalation* on the KILL lever (SIGTERM only; escalation is vLater). +- *No btrfs device-error counter reset button* — resetting without diagnosis masks a dying drive; stays manual. +- *No per-ecosystem update metrics* (cargo/npm/pipx/…): one topgrade-freshness metric + the fwupd count instead. +- *No net/bt doctor retrofit here* — separate todo task; only the results-wall idiom is shared. + +** Scope tiers +- v1: everything under Goals — all metrics from the design-doc tables including the full-sweep findings ("all committed to v1", 2026-07-07), both hosts, CLI + GUI + glyph + timers + four test layers + archsetup TOML install. +- Out of scope: the Non-Goals above. +- vNext (logged to todo.org at hand-off): AI-assist actions on read-only metrics; SIGKILL escalation; per-row live meters where evidence rows could stream. + +* Design + +** For the user + +Click the bar glyph (it replaced the old sysmon readout; its color is the worst diagnostic state — green/amber/red; on the laptop its text is the live battery level). The console opens: a faceplate row of category keys (STORAGE · SNAPSHOTS · PACKAGES · SYSTEMD · LOGS · MEM·PWR · NETWORK · SERVICES), each carrying its own status lamp and a "N fixable · M watch" split so you know your agency before reading a cell. Selecting a category renders its dense subpanel: every metric a row with lamp, value, and — only when off-nominal — a contextual lever. Tall subpanels (Packages, Logs, Services) carry a rotary band selector (click to cycle: e.g. ORPHANS · PACNEW · ADVISORIES), one band rendered at a time at full width, each band showing its own lamp + count on the dial. + +Counts never hide detail: orphans list name + size with per-package REMOVE (armed) and KEEP; journal errors group by identifier with first/last seen and the exact =journalctl= command; listeners show process · port · bind address. Where "expected" is knowledge, it's curated: MARK KNOWN / MARK EXPECTED / KEEP move items to a dim section (never vanish) with UNMARK per row and CLEAR for the layer; every mark logs to the wall. + +Anything consequential arms before it fires: first press turns the key red and shows exactly what will run (the dry-run argv); second press runs it. The two doctor keys — CLEAN UP and REVIEW & FIX — and every remedy stream into the results wall at the bottom: one lamp per action, amber while running, green with the result inline (reclaimed MBs, restarted unit), red on failure; date+time stamped, HIDE/COPY, capped at 3.5 visible entries with the half-entry as scroll cue. The updates strip shows pending/AUR/firmware/CVE counts with a state-tiered border; UPDATE and TOPGRADE sit behind the live-update guard — when mesa/hyprland/wayland runtime is in the pending set the key arms with "press again to run anyway — or apply from a TTY". After an update lands, a REBOOT key (armed) appears. + +Temps, memory, and throttle re-read every ~3 s while visible; local probes every ~30 s and immediately after any action that touches them; network-tier counts (updates, CVEs, AUR, firmware) come from an hourly timer cache with their age shown. Close the panel and nothing keeps running except the light half-hourly background scan that feeds the glyph. + +** For the implementer + +*Package shape.* =maint/= in dotfiles mirrors the sibling humble-object stack (=net/=, =audio/=): =src/maint/= with =probes/= (one module per category, read-only collectors that parse command output), =schema.py= (the status envelope), =status.py= (assembles =maint status --json=), =thresholds.py= (TOML load + user-layer merge), =curation.py= (marks/keeps/expected stores, read+write), =remedies.py= (every lever as an allowlisted exact argv), =priv.py= (the closed privileged-verb table, validated args, =sudo= via =MAINT_SUDO= env — the net =priv.py= pattern verbatim), =guard.py= (pure live-update guard over a package list), =doctor.py= (CLEAN UP / REVIEW & FIX sequencing, streaming), =cli.py=, =viewmodel.py= (pure render helpers), =gui.py= (thin GTK4 view), =indicator.py= (glyph state). Tests in dotfiles =tests/maint/= with fake binaries on PATH (the fake-pactl/fake-parec pattern) — fake-smartctl, fake-btrfs, fake-zpool, fake-pacman, fake-journalctl, fake-ss, fake-docker, fake-systemctl, etc. + +*The contract.* =maint status --json= emits one envelope: host capabilities (fs type, pstate driver, battery present, docker/libvirt present — "battery present" means a =/sys/class/power_supply/= entry with =type == Battery=, never a non-empty dir: ratio carries Mains + USB-C PD entries with no battery), then per-category metric dicts each carrying =id=, =value=, =severity= (=ok=/=warn=/=crit=), =label=, optional =evidence= (list of digest rows), optional =levers= (list of remedy ids applicable right now), =probed_at=, and =cache_age= for cached metrics (network-tier and slow-local alike). Severity is always computed engine-side from the thresholds TOML — the GUI never holds a threshold. The E5 prototype's GOOD/BAD data snapshots are reshaped to conform to this schema and become the GUI fixtures (=MAINT_PANEL_FIXTURE=bad= renders the degraded board on a healthy machine). + +*Remedies.* Each remedy is =(id, argv, priv_verb_or_none, arm_required, re_probe_ids)=. =maint fix <id> [--dry-run]= prints the exact argv under =--dry-run= — the same string the GUI's arm-press displays. Collectors never elevate; only =priv.py= verbs touch =sudo=, and archsetup's install already grants NOPASSWD (=archsetup:1178=). After a remedy fires, the engine re-probes the metrics named in =re_probe_ids= (fire CLEAN → cache re-measured, not assumed). Long-running remedies (btrfs/zfs scrub, SMART self-test, rsyncshot RUN NOW) report a running-% state to the wall, not an instant reset. KILL revalidates PID + process name at fire time and refuses session-critical names (systemd, the compositor, the panel itself → disabled key). + +*Curation stores.* Shipped defaults (known-noise patterns, expected listeners sshd/mpd/tailscaled, expected containers winvm, pacnew safe-delete allowlist) live in the archsetup-shipped TOML; user marks live in the user layer, merged over it. Journal noise patterns bind to identifier + message snippet, never a whole unit. Unmarking a shipped default writes a disable flag in the user layer; CLEAR MARKS empties the user layer only. + +*Refresh tiers* (all engine-side, GUI subscribes): live group ~3 s panel-open + subpanel-visible; fast local ~30 s panel-open + post-action re-probe; hydration on open (fast reads first, process probes behind, sub-second perceived); network tier from the hourly timer cache, on-demand refresh only; *slow local tier* — probes whose cost class matches the network tier despite being local (full =pacman -Qkk=, measured 47 s on ratio; the disk top-consumers scan) ride the same hourly timer cache with age shown, on-demand refresh only, never in hydration or fast-local; closed-panel glyph fed by a ~30-min systemd user timer running =maint scan --glyph= to write the state file =waybar-maint= reads. Two user units ship with the package: =maint-scan.timer= (glyph) and =maint-net-scan.timer= (hourly network-tier + slow-local cache). + +*Waybar wiring.* =custom/maint= replaces =custom/sysmon= in the modules list and config; glyph state file + SIGRTMIN signal refresh like =custom/ptt=. Gotcha (from the PTT build): waybar runs a *generated runtime config* — the module lands via =waybar-active-config= + SIGUSR2, not just a canonical config edit. Theme CSS mirrored in both themes (dupre, hudson). + +*Cross-links, not duplicates.* DNS/NetworkManager repair deep-links to the net panel's doctor; OPEN JOURNAL launches a terminal running =journalctl -p err -b= (the NET DOCTOR delegation pattern). + +* Alternatives Considered + +** Console vs workflow-only maintenance +- Good (console), because glanceable health + one-press remedies catch the rot the workflow only finds when explicitly run; the workflow stays for forensics. +- Bad, because it's a large build (this spec's phase count) duplicating some workflow probe logic. +- Neutral, because the shared thresholds TOML keeps the two from drifting — the duplication is probes, not policy. + +** GUI-first vs CLI-first +- Good (CLI-first), because =maint status --json= + =maint fix= are testable without a display, the VM harness needs exactly that, and the GUI reduces to a face over a verified engine (the sibling panels' proven shape). +- Bad, because the GUI lands late in the phase order — no visible console until ~P7. +- Neutral, because the E5 prototype already de-risked the visual design; what's left to prove is the engine. + +** Layout: A–E divergents, then 3-column compression vs rotary bands +- Good (E5 selector + subpanel), because Craig picked E from five divergent prototypes and iterated it live through E2–E5; the selector gives every category full width. +- Bad, because one-category-at-a-time hides cross-category state — mitigated by per-key lamps + fixable/watch splits on the selector row. +- Neutral, because the rotary band selector replaced the failed 3-column compression (third-width truncation lost row detail — Craig's verdict after use); MEM·PWR keeps its 3-column strip because its rows are short. + +** Update visibility: per-ecosystem metrics vs topgrade freshness +- Good (freshness), because topgrade's step set has no cheap offline "updates available?" probe — mirroring it costs a network round-trip per registry at panel-open. +- Bad, because the panel can't say "3 cargo crates stale" — only "topgrade last ran N days ago". +- Neutral, because fwupd refreshes its own metadata cache, so firmware gets a real count for free. + +** Elevation: polkit/pkexec per action vs priv-verb table + sudo +- Good (priv table), because the net panel already proved it: a closed validated verb set, NOPASSWD covered by archsetup's install, no auth dialog stacking on top of the arm-to-fire press. +- Bad, because the sudoers grant is broad (archsetup currently writes NOPASSWD: ALL) — the verb table is the real narrow waist, so it must stay small and auditable. +- Neutral, because pkexec would add a second confirmation UI the arm press already provides. + +* Decisions [13/13] + +** DONE CLI-first architecture; GUI is a face +CLOSED: [2026-07-07 Tue] +- Context: four test layers need a display-free engine; the sibling panels' humble-object stack is proven; the VM harness can only drive a CLI. +- Decision: We will ship =maint= as engine + =cli.py= with =maint status --json= as the contract and =maint fix <id>= as every lever; =gui.py= renders the same code and never does anything the CLI can't. Global =--dry-run= prints the exact argv. +- Consequences: easier — testability, VM scenarios, the arm-press preview is the dry-run string for free; harder — the GUI arrives late in the phase order, and the JSON schema becomes a real contract that fixtures and tests pin. + +** DONE Determinate remedies only in v1 +CLOSED: [2026-07-07 Tue] +- Context: workflow buttons were prototyped and removed (E5, 2026-07-07); indeterminate fixes (failed units with unknown cause, journal errors, OOM) need judgment. +- Decision: We will ship only fixed, scriptable actions with predictable outcomes — the design doc's determinate-remedies list. Judgment metrics render read-only with expectation tags ("hardware — watch only", "evidence below"). AI/workflow assistance is vLater. +- Consequences: easier — every remedy is an exact argv, auditable and VM-testable; harder — red diagnostic cells offer no in-panel next step beyond evidence + the named command. + +** DONE Updates behind an arming live-update guard +CLOSED: [2026-07-07 Tue] +- Context: the 2026-06-07 crash was a live =-Syu= swapping mesa+hyprland under the session; the standing rule is never update the graphics/wayland runtime live. Hard-refusing infantilizes the user. +- Decision: We will put UPDATE (repo+AUR) and TOPGRADE on the updates strip as Confirm levers; when the pending set contains mesa/hyprland/wayland-runtime packages the key arms red with "press again to run anyway — or apply from a TTY". The topgrade wrapper always passes =--disable git= (its git step rebase-autostashes =~/code/*/=). After an update lands, an armed REBOOT key appears and the reboot-required metric flips. The strip border is state-tiered (green/amber/red by pending/CVE/staleness thresholds in the TOML). +- Consequences: easier — updates finally have a sanctioned in-panel path with the footgun named at press time; harder — the guard's package-set matcher must be right (pure function, heavily unit-tested), and the wrapper owns topgrade's flag discipline. + +** DONE Doctor actions stream to a results wall +CLOSED: [2026-07-06 Mon] +- Context: fire-and-forget buttons hide failures; Q1 resolved 2026-07-06. +- Decision: We will stream every doctor and every individual remedy to the wall — one lamp per action, amber running / green with result inline / red failed, always shown, date+time stamped, HIDE/COPY, 3.5-entry scroll cap. Long ops report running-%. +- Consequences: easier — failures are visible the moment they happen, and mark/unmark curation events get a durable in-session log; harder — remedies need incremental output plumbing (engine yields events; GUI appends), not just exit codes. + +** DONE Both hosts first-class; capability dispatch from day one +CLOSED: [2026-07-06 Mon] +- Context: Q2 resolved 2026-07-06 — velox travels and suspends; its GAP metrics (battery health, unclean-shutdown rate) motivated the console. +- Decision: We will probe host capabilities at startup (fs types, pstate driver, battery, docker/libvirt) and run only applicable collectors; ZFS paths (scrub, autotrim, capacity, frag) are built and tested, not stubbed; velox-only metrics ship in v1. +- Consequences: easier — one codebase, no ratio-first debt; harder — every collector's tests need both-host fixtures. ZFS remedy scenarios are possible in the VM (the harness supports =FS_PROFILE=zfs= base images — =vm-utils.sh:23,53-55=; prerequisite: confirm the zfs base image builds); the floor if that image proves troublesome is argv-construction tests + read-only live runs on velox. + +** DONE Thresholds and curation as layered TOML, archsetup-owned +CLOSED: [2026-07-06 Mon] +- Context: Q3 resolved 2026-07-06 — severity values must not drift between the console and the system-health-check workflow; template syncs must never eat user curation. +- Decision: We will put every severity value (cache trigger, disk %, scrub-age bands, snapshot limits, temp bands, staleness windows, "a lot" of updates) plus shipped curation defaults in =maintenance-thresholds.toml=, owned and installed by archsetup, seeded from the workflow's hard-won values. User marks/keeps/expected live in a separate user-layer file merged over it. Both the console and the workflow read the installed TOML. +- Consequences: easier — one source of truth, workflow and console can't disagree, thresholds tunable without touching code; harder — archsetup grows an install/update step for the TOML, and the merge semantics (including disable-flags for shipped defaults) need explicit tests. + +** DONE Evidence digests + curation lifecycles on every count metric +CLOSED: [2026-07-07 Tue] +- Context: the full-sweep audit (2026-07-07) committed all findings to v1; a bare count trains you to ignore it, and "expected" is configuration knowledge that today lives in Craig's head (the rust orphan lesson, winvm, sshd). +- Decision: We will give every count an evidence digest (orphans name+size, journal groups by identifier with first/last seen + next command, listeners process·port·bind, coredumps by binary, docker df by type, snapshots split timeline/single/pre-post, top-5 RAM, recent boots clean/unclean) and every "expected"-shaped metric the full curation lifecycle: MARK KNOWN / KEEP / MARK EXPECTED with per-row UNMARK, CLEAR for the user layer, marked items dimmed but never hidden, every transition logged to the wall. Guarded per-item remedies ride the digests (orphan REMOVE armed + REMOVE ALL skipping keeps, pacnew MERGE via terminal diff, per-unit RESTART+RESET, per-container START, per-socket STOP/KILL armed, DELETE STALE keeping newest 2). KILL carries four guards: arm shows exact victim, SIGTERM only, PID+name revalidated at fire, session-critical names disabled. +- Consequences: easier — the panel is honest and the curation encodes system knowledge durably; harder — this is most of the GUI surface area and the reason the subpanel phases are the fattest. + +** DONE Rotary band selector on tall subpanels +CLOSED: [2026-07-07 Tue] +- Context: 3-column compression on Packages/Logs/Services lost row detail to third-width truncation (Craig's verdict after use). +- Decision: We will render tall subpanels one band at a time behind a rotary selector (click to cycle; engraved band labels each with own lamp + count; gold underline + needle on the selected band): Packages ORPHANS·PACNEW·ADVISORIES, Logs SIGNAL·KNOWN NOISE·COREDUMPS·KERNEL/HW, Services CONTAINERS·DOCKER DISK·CRON & BACKUPS. MEM·PWR keeps its 3-column strip (short rows fit). +- Consequences: easier — full-width row detail restored, and each selection idiom in the panel keeps a distinct visual voice; harder — off-band state must surface on the dial lamps or a red band goes unseen. + +** DONE Four-tier refresh cadence + glyph timer; glyph replaces custom/sysmon, shows battery on laptops +CLOSED: [2026-07-07 Tue] +- Context: probe cost varies from a sysfs read to a network round-trip; the bar must stay honest with the panel closed. Craig (2026-07-07, spec session): the maint glyph replaces =custom/sysmon= rather than adding a module beside it, and on a laptop it should function as the bar's battery level display. +- Decision: We will refresh in tiers matched to probe cost (live ~3 s visible; fast local ~30 s open + post-action; hydration on open; network hourly cache with age shown, on-demand refresh; a slow-local tier for expensive local probes — full =-Qkk= at 47 s measured, disk top-consumers — riding the same hourly cache), run a ~30-min =maint-scan.timer= writing the glyph state file, and wire =custom/maint= in waybar in place of =custom/sysmon=. The glyph's *color* tracks the worst *Diagnostic* state only — actionable clutter (a fat cache) never colors the bar. On battery hosts (capability-gated, same host probe) the module's *text* is the battery level with a charging indicator, read directly by =waybar-maint= on waybar's own interval (a 30-min scan is too coarse for charge %); a critically-low charge (threshold in the TOML) feeds the diagnostic state so the color stays honest when the battery is the emergency. Any existing waybar battery module on the laptop retires in favor of this one. +- Consequences: easier — panel-open cost is bounded, the bar signal stays high-trust, and the laptop bar loses a module instead of gaining one; harder — the glyph script has two data sources (state file for diagnostics, sysfs for charge), and the old sysmon affordances resolve per Craig's 2026-07-07 ruling: the btop scratchpad (=pypr toggle monitor=) re-homes to the maint glyph's right-click; =sysmon-cycle= and the =waybar-sysmon= readout retire. + +** DONE Privileged remedies via a maint-priv verb table +CLOSED: [2026-07-07 Tue] +- Context: most remedies need root (systemctl, paccache, journalctl --vacuum, scrub, charge threshold); collectors must never elevate; the net panel's =priv.py= already proved the pattern. +- Decision: We will route every privileged remedy through =maint/priv.py=: a closed verb table, one exact command per verb, args validated by type-specific regexes before reaching sudo, =MAINT_SUDO= env override so tests run fakes directly. Read/write split is hard: probes never import priv. +- Consequences: easier — one small auditable module is the entire privilege surface, VM tests exercise the same verbs; harder — every new remedy is a deliberate verb-table addition, never an inline =sudo=. + +** DONE Four-layer test strategy; E5 snapshots become fixtures +CLOSED: [2026-07-07 Tue] +- Context: remedies mutate systems; the archsetup VM harness (=scripts/testing/run-test.sh=) exists; the sibling panels' fake-binary and AT-SPI patterns are proven. +- Decision: We will test in four layers — (1) unit with fake binaries on PATH (~90% of surface: probes as parsers, remedies as argv construction, guard as pure function), (2) read-only =maint status --json= on the live hosts, (3) remedies in the VM with scenario scripts that break things over ssh then assert post-state, systemd-nspawn for pure pacman-level tests, (4) AT-SPI GUI smoke on the host driven by fixtures conforming to the JSON schema, derived from the E5 GOOD/BAD snapshots. No GUI in the VM. VM isolation policy: the snapshot primitives already exist (=lib/vm-utils.sh:303-357= — create/restore/delete via =qemu-img=, VM must be stopped for any snapshot op; =run-test.sh= already reverts to a =clean-install= snapshot), so the new work is scenario *orchestration* only. Scenarios are grouped into non-conflicting batches that run within one VM boot (a stopped-cronie scenario doesn't contaminate a fat-cache scenario); a stop → restore → reboot cycle runs only *between destructive groups* whose post-state would contaminate the next batch. The scenario runner declares each scenario's group so the batching is explicit, not inferred. +- Consequences: easier — destructive coverage without risking real machines, and the harness work is smaller than feared (orchestration over existing primitives); harder — VM runs are slow (~40–60 min), so grouping discipline matters, and a scenario mis-grouped as non-conflicting can produce order-dependent failures (the runner can randomize in-group order to surface these). + +** DONE system-health-check workflow moves home → archsetup +CLOSED: [2026-07-06 Mon] +- Context: Craig's call (2026-07-06) — home is scoped to finances/health/personal; system maintenance is archsetup's domain, and the move collapses the TOML ownership coupling. +- Decision: We will move =system-health-check.org= + =homelab-inventory/*.org= into archsetup in the final phase, rewire the workflow to read the installed TOML instead of prose severity rules, and hand the home-side removal (originals, startup references, index) to home via an inbox note — never edited blind from here. +- Consequences: easier — TOML producer and both consumers land in one project; harder — sequencing: the move waits for the TOML to exist and ship, so it anchors the last phase. + +** DONE Config and state file locations +CLOSED: [2026-07-07 Tue] +- Context: four files need stable homes both consumers reach: the shipped thresholds+defaults TOML (archsetup-installed, design doc suggests =~/.config/archsetup/maintenance-thresholds.toml=), the user curation layer, the glyph state file, and the hourly network-tier + slow-local cache (round-1 review: this one must survive reboots, or every boot shows "no data" for up to an hour — =$XDG_RUNTIME_DIR= is wrong for it). Sibling precedent: panels read =~/.config/<pkg>/config= (audio =config.py:29=). Craig approved the proposal 2026-07-07. +- Decision: We will install the shipped TOML at =~/.config/archsetup/maintenance-thresholds.toml=; the user layer at =~/.config/maint/curation.toml= (alongside an optional =~/.config/maint/config= for GUI prefs per sibling convention); the glyph state and the hourly cache both under =~/.local/state/maint/= (XDG state — persists across reboots; the glyph file is cheap to rebuild but gains nothing from being ephemeral). Env overrides for all four so tests and fixtures never touch real files. +- Consequences: easier — XDG-conventional, stow-safe, sync-safe (curation outside any synced tree), honest counts right after boot; harder — two config roots (=archsetup/= for shipped, =maint/= for user) must be documented or they'll confuse future edits. + +* Review findings [10/10] + +** DONE Automation-tier normativity contradiction :blocking: +Round-1: the References section declared the design-doc tables normative for Automation tier, but the tables mark failed-unit restart / pending updates / AUR staleness / =-Qkk= reinstall as Workflow while the 2026-07-07 decisions give them Confirm levers — an implementer couldn't derive the REVIEW & FIX set. Resolved: References now scopes the tables to metric *existence* only; the dated decisions win on tier. + +** DONE VM scenario isolation policy undefined :blocking: +Round-1 (code fact): =vm-utils.sh:301= requires the VM stopped for snapshot ops, contradicting "restore between scenarios" + "batch per boot" as written; snapshot primitives also already exist (=vm-utils.sh:303-357=), so "new harness work" overstated. Resolved: Decision 11 now states the grouped-batch policy (non-conflicting scenarios per boot; stop/restore/reboot only between destructive groups; runner declares groups) and reframes the work as orchestration over existing primitives. + +** DONE Expensive local probes had no refresh tier :blocking: +Round-1 (measured live): full =pacman -Qkk= runs 47 s on ratio; disk top-consumers is the same cost class; neither fit fast-local (~30 s) nor hydration. Resolved: a slow-local tier added — both ride the hourly timer cache with age shown, on-demand refresh only. + +** DONE ZFS-in-VM claim verified false as a constraint +Round-1 (code fact): the harness supports =FS_PROFILE=zfs= base images (=vm-utils.sh:23,53-55=). Resolved: Decision 5 corrected — ZFS scenarios are possible (prerequisite: zfs base image builds); argv+live-read-only becomes the fallback, not the plan. + +** DONE Battery capability detection underspecified +Round-1 (verified live): ratio's =/sys/class/power_supply/= is non-empty (Mains + 2 USB entries) — a naive non-empty-dir probe false-positives. Resolved: contract now defines battery present ⇔ an entry with =type == Battery=. + +** DONE sysmon retirement collateral unnamed +Round-1: retiring =waybar-sysmon=/=sysmon-cycle= without touching =tests/waybar-sysmon/= + =tests/sysmon-cycle/= reds =make test= in the same commit; =#custom-sysmon= CSS lives in three files. Resolved: Phase 11 now names the scripts, suites, and CSS blocks as same-commit retirement collateral. + +** DONE Test-gate discovery answered +Round-1 (code fact): =make test= discovers =tests/maint/test_*.py= via glob (=Makefile:266-271=) — no enumeration needed; AT-SPI smokes ARE enumerated (=Makefile:279-286=). Resolved: Dev-tooling records both; =test-panel-maint= target added to Phase 12 deliverables. + +** DONE arch-audit absent on ratio +Round-1 (verified live): every other assumed tool present; =arch-audit= is not. Resolved: External deps flips it from verify-item to known — add to archsetup deps + install both hosts before Phase 3's CVE collector is live-testable. + +** DONE Fourth unhomed persistent file +Round-1: the hourly network-tier/slow-local cache must survive reboots (else "no data" for up to an hour after boot) — =$XDG_RUNTIME_DIR= wrong for it. Resolved: added to the open config-paths decision; proposal now puts glyph state + cache under =~/.local/state/maint/=. + +** DONE Panel-CSS "both themes" ambiguity +Round-1 (code fact): sibling panels load =themes/dupre/panel.css= hardcoded (=audio/gui.py:52-61=); hudson has no panel.css. Resolved: Phase 7 follows the sibling dupre-only panel-CSS convention; Phase 11 scopes both-themes CSS to the waybar glyph stylesheets. + +* Implementation phases + +Each phase leaves the tree working and gates on dotfiles =make test= green (archsetup phases gate on their own checks). Commit + push per phase; dotfiles note at milestones (archsetup-owns-dotfiles). + +** Phase 1 — Package skeleton, thresholds, contract +=maint/= scaffold (src layout, stow wiring, =__main__=), =thresholds.py= (TOML load + user-layer merge + disable flags, env overrides), =schema.py= severity model, capability probe, =maint status --json= envelope with three pilot collectors (disk usage, package cache size, failed-unit count), fake-binary harness in =tests/maint/=. Seed =maintenance-thresholds.toml= content in archsetup (workflow's values: snapshot limits, 10 GB cache trigger, scrub-age bands). + +** Phase 2 — Collectors: storage & snapshots +btrfs (unallocated, scrub age, per-device error counters cross-checked vs SMART), ZFS (health, capacity, frag, autotrim, scrub age), SMART (health, wear, temp, last self-test), fstrim.timer, disk top-consumers digest, snapper/ZFS snapshot counts split timeline·single·pre-post with oldest-single named. Both-host fixtures. + +** Phase 3 — Collectors: packages, security, systemd +Cache size, orphans (name+size), pacnew classified safe-delete vs needs-merge, keyring freshness, reboot-required, pacman -Qkk; network-tier cache readers (checkupdates, arch-audit CVEs named, AUR staleness, fwupd count, topgrade freshness stamp) with age; is-system-running + cause, failed-unit roster (name·since·exit·journalctl hint), maintenance-timers meta-metric, taint letters decoded. + +** Phase 4 — Collectors: logs, memory, power +Journal error digest (grouped by identifier, first/last seen, next command, two noise layers applied), coredumps by binary, kernel/hw events; memory free + top-5 consumers, OOM kills, swap/zram, temps, throttle state, EPP mode read, battery health + charge threshold read (velox), unclean-shutdown rate from recent boots. + +** Phase 5 — Collectors: network posture, services +DNS/NM reachability, firewall state (+ public-bind exposure naming when down), listeners digest with expected-layer merge and unexpected/public severity, tailscale, fail2ban + recent bans, NTP offset; rsyncshot freshness, docker system df by type + stopped containers with expected-layer, libvirt state, cron expected-entries drift. Curation read side complete. + +** Phase 6 — Remedies, priv, guard, doctor CLI +=remedies.py= (full allowlisted table with =re_probe_ids= + running-% for long ops), =priv.py= verb table, =guard.py= live-update matcher (pure), curation writes (mark/unmark/clear/keep/expected), =maint fix <id> [--dry-run]=, =maint doctor clean|review= streaming an event feed (text wall in the terminal). Topgrade wrapper with =--disable git=. KILL's four guards. Argv-construction tests for every remedy; no execution. Splits naturally into 6a (remedies/priv/guard) + 6b (curation writes + doctor + CLI) if the session runs long — 6a alone leaves a working tree. + +** Phase 7 — GUI shell +GTK4 window, faceplate, category selector row (lamps + fixable/watch splits), subpanel scaffolding, hydration tiers + refresh cadence (live/fast, visibility-gated like the audio meters), =MAINT_PANEL_FIXTURE= loading, panel CSS following the sibling convention (=themes/dupre/panel.css=, the shared panel stylesheet — hudson carries waybar CSS only, no panel.css). Renders real =status= data read-only — no levers yet. + +** Phase 8 — GUI subpanels: storage, snapshots, packages, updates strip +Evidence digests, armed per-item keys (orphan REMOVE/KEEP, pacnew MERGE, DELETE STALE, scrub with running-%), the Packages rotary band selector, updates strip with state-tiered border, UPDATE/TOPGRADE behind the arming guard, armed REBOOT on completion. + +** Phase 9 — GUI subpanels: systemd, logs, mem·pwr, network, services +Failed-unit roster keys, journal digest + MARK KNOWN lifecycle UI + OPEN JOURNAL, Logs/Services rotary bands, MEM·PWR 3-column strip + CPU-mode segmented control + velox SET 80% + guarded KILL, listeners curation + STOP/KILL, firewall ENABLE, container START, RECLAIM SPACE composite macro. The fattest phase — split by subpanel pair (9a systemd+logs, 9b mem·pwr+network+services) if a session runs long; each pair gates independently. + +** Phase 10 — Results wall + doctor keys +The wall widget (lamp stream, date+time, inline results, running-%, HIDE/COPY, 3.5-entry cap, dark scrollbar), CLEAN UP / REVIEW & FIX wired through =doctor.py=, every armed key streaming, curation events logged, post-action re-probes visible. + +** Phase 11 — Waybar glyph + timers +=indicator.py= + =waybar-maint=, =custom/maint= replacing =custom/sysmon= (canonical config + =waybar-active-config= runtime path + SIGUSR2), signal-driven refresh, =maint-scan.timer= + =maint-net-scan.timer= user units, glyph color tracks worst diagnostic from the state file; on battery hosts the module text is the live battery level (sysfs read per waybar interval, charging indicator, low-charge threshold feeding the diagnostic state) and any existing battery module retires. Glyph CSS lands in both theme waybar stylesheets (dupre, hudson). Retirement collateral in the same commit (Craig ruled 2026-07-07): =custom/maint= right-click re-homes the btop scratchpad (=pypr toggle monitor=); the =waybar-sysmon= + =sysmon-cycle= scripts and their suites (=tests/waybar-sysmon/=, =tests/sysmon-cycle/=) retire, and the =#custom-sysmon= CSS blocks come out of =waybar/style.css= and both themes. + +** Phase 11b — Prototype fidelity pass (added 2026-07-08) +Added mid-build from Craig's live-board review: phases 7-10 delivered E5's structure and behavior but rendered subpanel metrics as one-line list rows (the sibling panels' idiom) instead of the prototype's instrument-card grid; the per-phase screenshot checks verified structure, not presentation. Scope: card-grid subpanels (4-up metric cards — big-number value, caption line, progress bars, radial gauges for scrub cadence and NVMe wear, status chips, corner lever key), two-row selector tiles with compact count chips, subpanel attention/ok/fixable/watch header line, evidence digests as full-width rosters under the grid. Engine and viewmodel contracts unchanged; =gui.py= layout + =panel.css=. Sequenced before Phase 12 so the AT-SPI smoke targets the final layout. Verification is a pixel-level comparison against the settled E5 render (headless Chrome), not structural spot checks. + +** Phase 12 — VM remedy scenarios + AT-SPI smoke (archsetup + dotfiles) +Scenario orchestration over the existing snapshot primitives (=vm-utils.sh:303-357=): grouped batches per Decision 11, maint scenario scripts (stop cronie, mask fstrim, orphan packages, fill cache, break timers → =maint fix= → assert post-state); systemd-nspawn variant for pacman-level cases; AT-SPI smoke on GOOD/BAD fixtures asserting board render, arm behavior, and no leaked processes, plus the enumerated =test-panel-maint= Makefile target. May split VM work (archsetup) from AT-SPI smoke (dotfiles) into two sessions — each half gates independently. + +** Phase 13 — Install wiring, workflow move, docs +archsetup installs the TOML (idempotent, update-safe); deps added; =maint= README + keybind/user docs; =system-health-check.org= + homelab inventories moved into archsetup and rewired to the TOML; handoff note to home's inbox for the home-side removal; spec flipped IMPLEMENTED with history line. + +* Acceptance criteria +- [ ] =maint status --json= runs read-only on both hosts; ratio emits btrfs/AMD metrics and no ZFS/battery; velox emits ZFS/Intel/battery — same binary, capability-dispatched. +- [ ] Every metric's severity comes from the installed TOML; editing a threshold changes the verdict with no code change. +- [ ] =maint fix <id> --dry-run= prints the exact argv for every remedy; the GUI arm-press shows the same string. +- [ ] Probe modules import no elevation path (verifiable: nothing outside =priv.py= references sudo). +- [ ] With mesa/hyprland in the pending set, UPDATE arms with the override wording instead of running; without them it runs on confirm. Topgrade always receives =--disable git=. +- [ ] Doctor runs stream per-action lamps to the wall with results inline; a long op shows running-%; a failure shows red without aborting the remaining stream. +- [ ] Marks/keeps/expected survive a dotfiles template sync and a package update; marked items render dimmed, UNMARK restores, CLEAR empties only the user layer. +- [ ] KILL never fires on a recycled PID (name revalidated) and renders disabled for session-critical names. +- [ ] The bar glyph reflects worst diagnostic within one scan interval with the panel closed, and never colors for actionable-only findings. +- [ ] On velox the glyph shows live battery level + charging state; a charge below the TOML low threshold turns the diagnostic state (and the glyph) red; on ratio no battery text renders. +- [ ] =MAINT_PANEL_FIXTURE=bad= renders the fully degraded board on a healthy machine; AT-SPI smoke passes against it. +- [ ] VM scenarios: each broken-state → =maint fix= → asserted post-state passes from a pristine qcow2 snapshot. +- [ ] Full dotfiles =make test= green; closing the panel leaves no maint processes beyond the timers. + +* Readiness dimensions +- *Data model & ownership:* live probe data is generated, never persisted (results wall is session-only, in-memory); curation files are user-authored (user layer) vs shipped (archsetup TOML); glyph state file and hourly cache are generated state rebuilt by the timers (their home is the open config-paths decision). The JSON envelope is the one contract fixtures pin. +- *Errors, empty states & failure:* a failing probe degrades its own metric to an "unprobed" state with the failure named — never a dead board; remedy failures stream red to the wall with stderr excerpt and the metric re-probed (truth, not assumption); network-tier cache absent → counts show "no data · refresh" with the timer named; every guard names the operation and the next step at arm time. +- *Security & privacy:* privilege surface is the priv verb table alone; args regex-validated pre-sudo; listeners digest shows local process/port data only in the local GUI — never logged off-machine; no secrets read; charge/EPP writes are fixed-value verbs. +- *Observability:* the results wall is the action log; =maint status --json= + =--dry-run= make any GUI claim reproducible in a terminal; cache ages displayed; scan timers visible via systemctl --user. +- *Performance & scale:* hydration budget sub-second perceived (fast reads first); live tier only while visible; N of everything is small (tens) except journal lines — the digest groups engine-side with a top-10 cap. Long ops never block the GUI thread (worker + event feed, the sibling =bg()= pattern). +- *Reuse & lost opportunities:* reuses the sibling panels' humble-object stack, fake-binary harness, AT-SPI smoke pattern, priv.py design, console-key/lamp CSS kit from the widget gallery, the existing archsetup VM harness, and the E5 prototype as both fixture source and pixel reference. Deliberately not reused: waybar-sysmon (replaced), the workflow's prose probes (superseded by TOML-driven collectors). +- *Architecture fit & weak points:* fourth panel sibling, same layering. Weak points: subprocess sprawl (every probe shells out — bounded by tiering + hydration order); the guard matcher (a miss means a live mesa swap — mitigated by pure-function pairwise tests over package-set cases); scenario orchestration is new harness surface, though the snapshot primitives already exist in =vm-utils.sh= (mitigated: archsetup-side, tested by the scenario suite itself); ZFS scenarios depend on the =FS_PROFILE=zfs= base image building (fallback: argv tests + read-only live on velox + Confirm-tier gating). +- *Config surface:* the thresholds TOML (all severity values, shipped curation defaults, staleness windows, "a lot" threshold), user layer (marks/keeps/expected/disables), =~/.config/maint/config= optional GUI prefs (refresh rates, fixture env), =MAINT_SUDO= / =MAINT_PANEL_FIXTURE= / path-override envs for tests. All named with defaults in Phase 1/6 docs. +- *Documentation plan:* maint package README (user: what each key does, guard semantics; developer: schema, adding a remedy = verb-table discipline); archsetup docs for the TOML install + VM scenario harness; the moved workflow's own doc updates. Keybind doc deferred to the panel-chord-family task. +- *Dev tooling:* dotfiles =make test= discovers =tests/maint/test_*.py= automatically via its glob (=Makefile:266-271= — verified, no enumeration needed); the AT-SPI smoke *is* enumerated per panel (=Makefile:279-286=), so a new =test-panel-maint= target is a Phase 12 deliverable; archsetup gains a =make= target or documented invocation for the maint VM scenario; fixture regeneration script from E5 snapshots. +- *Rollout, compatibility & rollback:* additive package; stow to install, unstow to remove; waybar module swap is one config change reverted by restoring =custom/sysmon=; TOML install is idempotent and never overwrites user layer; remedies themselves are the dangerous surface — each is Confirm/armed, dry-run-previewable, and the destructive ones (snapshot delete, docker prune tiers 2–3, updates) arm individually. +- *External APIs & deps:* all local binaries, no web APIs. Verified on ratio (2026-07-07 review): =checkupdates= (1.13.1), =topgrade= (=--disable=/=--dry-run= flags confirmed), =paccache=, =snapper=, =smartctl= 7.5 with working =--json=, =coredumpctl=, =ss=, =docker=, =fwupdmgr=, =ufw=, =fail2ban-client=, =chronyc=, =tailscale= all present. Known: =arch-audit= is ABSENT on ratio — add to archsetup deps and install on both hosts before Phase 3's CVE collector is live-testable. Still prerequisites: same presence sweep on velox; =zpool status -j= support (OpenZFS ≥ 2.3) — fall back to text parsing where absent; =charge_control_end_threshold= sysfs path on velox's hardware; =coredumpctl=/=journalctl= field names pinned by fixtures from live output. =pacman -Qkk= exits 0 even with altered files (summary on stdout, warnings on stderr) — the collector parses output, never trusts the exit code. + +* Risks, Rabbit Holes, and Drawbacks +- *Scope mass* — the biggest risk is the build's sheer surface. Dodge: CLI-first phasing means every phase lands standalone value (collectors are useful from P2 via the CLI), and the GUI phases consume a verified engine. The phase order is also an abort-friendly ladder — stopping after P6 still yields a complete maintenance CLI. +- *Parsing drift* — probes parse a dozen tools' output. Dodge: prefer machine formats where they exist, pin real both-host output as fixtures, and treat a parse failure as an "unprobed" metric, never a crash. +- *Guard false-negative* — the one place a bug bricks a session. Dodge: pure function, pairwise-tested over package-name sets, and the TTY escape wording keeps the human in the loop. +- *VM harness time* — 40–60 min per run makes remedy-test iteration slow. Dodge: nspawn for pacman-level cases; scenarios grouped into non-conflicting batches per VM boot, with a stop/restore/reboot cycle only between destructive groups (Decision 11 — snapshot ops need the VM stopped, =vm-utils.sh:301=). +- *Curation-merge subtleties* — disable-flags over shipped defaults is the fiddly corner. Dodge: it's a pure merge function; exhaustive unit cases in Phase 1 before anything depends on it. +- *Results-wall event plumbing* — streaming per-action state through a worker into GTK is the panel's newest moving part. Dodge: the event feed is engine-side and CLI-proven in Phase 6 before the GUI touches it (the doctor's terminal wall is the same feed). + +* Testing / Verification / Rollout +The four-layer strategy is Decision 11; per-phase gates are in the phase list. Verification beyond tests: read-only =maint status --json= eyeballed on both hosts after P5; the doctor CLI exercised live on ratio (Auto tier only) after P6; GUI walked on fixtures + live after P10; glyph observed through a full timer cycle after P11; the E5 prototype is the pixel reference for GUI review. Manual eyeball checks (needle-level look-and-feel, arm wording, wall readability) get filed as a structured manual-testing task in todo.org at the end, per house convention. Rollout: per-phase dotfiles commits to main; archsetup commits for TOML/harness/workflow phases; both machines pick the package up via the normal stow path — velox needs a one-time stow after P11 lands (flagged in the dotfiles note). + +* References / Appendix +- Design doc (all dated decisions + the full metric tables with per-metric Automation/levers/notes): [[file:../design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]]. The metric inventory is deliberately not duplicated here — the tables there are normative for *which metrics exist*. Where a table's Automation column disagrees with the doc's later dated decisions (failed-unit restart, pending updates, AUR staleness, and =-Qkk= reinstall all read Workflow in the tables but gained Confirm levers in the 2026-07-07 determinate-remedies and updates decisions), the dated decision prose — mirrored in Decisions 2, 3, and 7 here — wins. The Confirm set that defines REVIEW & FIX membership derives from the decisions, never from the table column. +- Converged prototype (interactive; SIM FAIL / SIM BAD DAY / SIM ZFS controls): [[file:../prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5]]; exploration path A–E4 beside it. +- Sibling precedent: net =priv.py= (privilege pattern), audio =peak.py=/meters (visibility-gated live refresh), instrument-console-panels-spec (visual kit), PTT build (waybar runtime-config gotcha). +- Home workflow to move: =~/projects/home/.ai/project-workflows/system-health-check.org= + =homelab-inventory/*.org=. + +* Review and iteration history +** 2026-07-07 Tue @ 18:30:04 -0500 — Claude Code (archsetup) — author +- What: initial draft from the completed 2026-07-06/07 design arc; consolidated the design doc's dated decisions into twelve DONE decisions + one open (config paths), decomposed the build into thirteen phases, grounded implementer facts against the live tree (net priv.py, audio config.py:29, archsetup:1178 NOPASSWD, waybar custom/sysmon, tests/ fake-binary layout). +- Why: the [#B] todo task queued spec-create as the next step after the design converged; Craig gave the go this session and added two calls mid-authoring: the glyph replaces custom/sysmon, and on laptops it doubles as the battery level display. +- Artifacts: design doc; E5 prototype; todo.org task (cross-link updated this session). + +** 2026-07-07 Tue @ 18:50:00 -0500 — Claude Code (archsetup) — reviewer +- What: round-1 review with live read-only verification on ratio. Rubric: Not ready — three blocking findings (Automation-tier normativity contradiction, VM isolation policy undefined vs vm-utils.sh:301, no refresh tier for 47 s-class local probes) + seven non-blocking, all small spec-text fixes. Every code citation in the draft verified accurate; tool sweep on ratio held except arch-audit (absent); found the qcow2 snapshot primitives already implemented (vm-utils.sh:303-357) and make test's glob auto-discovery (Makefile:266-271). +- Why: the spec's buildability rested on harness and cadence claims that only a code read + live measurement could confirm. +- Artifacts: findings recorded in * Review findings; verified facts cited inline per finding. + +** 2026-07-07 Tue @ 18:53:51 -0500 — Claude Code (archsetup) — responder +- What: accepted all ten findings and folded them in — References normativity scoping, Decision 11 grouped-batch VM isolation policy, the slow-local refresh tier (Design + Decision 8), Decision 5 ZFS-in-VM correction, battery-detection definition in the contract, Phase 11 retirement collateral, Dev-tooling gate answers + Phase 12 test-panel-maint, External-deps verified-on-ratio state + arch-audit known-absent, the fourth file added to the open config-paths decision, and the panel-CSS convention clarification. Added session-split notes to Phases 6/9/12 per the decomposition check. +- Why: close the three blocking gaps and bank the verified facts before a re-review. +- Artifacts: * Review findings all DONE; Decisions 5/8/11 updated; one decision (config paths) remains open for Craig. + +** 2026-07-07 Tue @ 18:55:50 -0500 — Claude Code (archsetup) — reviewer + responder +- What: round-2 re-review confirmed all ten findings resolved with no substantive new gaps. Rubric: Ready pending the one open decision (config/state paths — Craig's call). Three wording nits found and fixed in the same pass: cache_age scoped to network-tier and slow-local alike, the stale "ephemeral" glyph-state wording reconciled to the open decision, the Decisions cookie recomputed to 12/13, plus a CI-that-doesn't-exist phrase softened in Decision 11. +- Why: verify the round-1 folds before handing the last decision to Craig; the spec stays DRAFT until he rules and the cookie reads complete. +- Artifacts: round-2 verdict (per-finding resolved list); Craig paged with the config-paths proposal and the sysmon-affordance re-homing question. + +** 2026-07-07 Tue @ 19:54:04 -0500 — Claude Code (archsetup) — responder +- What: folded Craig's two rulings (config/state paths as proposed — shipped TOML =~/.config/archsetup/=, user curation =~/.config/maint/=, glyph state + hourly cache =~/.local/state/maint/=; btop scratchpad re-homed to the maint glyph's right-click, sysmon-cycle + waybar-sysmon retired), flipped DRAFT → READY → DOING, and decomposed the build into todo.org: 13 phase tasks (all =:solo:=), a Manual-testing task seeded with the eyeball checks, and the flip-to-IMPLEMENTED closer, under a parent stamped =:SPEC_ID:=. vNext (AI assistance, SIGKILL escalation, evidence-row meters) logged =[#D]=. +- Why: Craig approved both recommendations ("go with your recommendations", 2026-07-07); Phase 6 of spec-response owns the READY → DOING flip when the decomposition lands. +- Artifacts: todo.org "Maintenance console build" parent + children; status heading history lines. diff --git a/docs/workflows/system-health-check.org b/docs/workflows/system-health-check.org new file mode 100644 index 0000000..b66a186 --- /dev/null +++ b/docs/workflows/system-health-check.org @@ -0,0 +1,1034 @@ +#+TITLE: System Health Check Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-02-27 + +* Overview + +This workflow performs a comprehensive diagnostic scan of whatever host it runs on (ratio, velox, mybitch, or truenas via SSH). Unlike status-check.org (which monitors a single long-running job in real-time), this scans the entire system, produces a severity-ranked report, then investigates and proposes fixes one by one. + +Run this when Craig asks "how is <host> doing?", "check my system health", or as a periodic maintenance check. + +Owned by archsetup (moved from the home project 2026-07-08 — system maintenance is archsetup's domain). Canonical location: =docs/workflows/system-health-check.org= in the archsetup repo; the per-host inventories it cross-references live beside it in =docs/homelab-inventory/=. + +Relationship to the =maint= console: on the Arch daily drivers (ratio, velox), =maint status --json= collects most of Phases 0–1 in about a second from the same severity thresholds, and =maint doctor= runs the safe remedies — prefer it for routine checks there. This workflow remains the tool for the non-Arch hosts (mybitch, truenas), for forensic deep dives (Phase 2 investigation, the case files), and for the update/reboot choreography in Phase 3. + +* Severity thresholds — the TOML is authoritative + +The graded severity thresholds in this workflow are defined in the installed thresholds file, shared with the =maint= console so the two can never drift: + +- Installed (read this): =~/.config/archsetup/maintenance-thresholds.toml= +- Canonical seed: =configs/maintenance-thresholds.toml= in the archsetup repo + +Read the TOML at the start of Phase 1 and grade against its values. Where a check below names a threshold, it cites the TOML key (e.g. =storage.df_warn_pct=); any inline number is the seeded default kept for readability, and the TOML wins on disagreement. User overrides live in =~/.config/maint/curation.toml= and merge over the shipped layer. + +* Hosts & Capabilities + +The workflow is capability-dispatched: it probes the live system in Phase 0 and runs only the checks that apply (Btrfs vs ZFS vs ext4 on storage; pacman vs apt vs none on package maintenance; etc.). The per-host inventory files under =docs/homelab-inventory/= declare the expected capabilities in a property drawer and act as a cross-check for drift. + +Host inventory files (match by =#+HOSTNAME:= keyword inside each): + +| Host | Inventory file | Notes | +|---------+-------------------------------------------+----------------------------------| +| ratio | [[file:../homelab-inventory/ratio-desktop.org][docs/homelab-inventory/ratio-desktop.org]] | Arch, Btrfs RAID1, pacman | +|---------+-------------------------------------------+----------------------------------| +| velox | [[file:../homelab-inventory/velox-laptop.org][docs/homelab-inventory/velox-laptop.org]] | Arch, ZFS (re-installed 2026-04) | +|---------+-------------------------------------------+----------------------------------| +| mybitch | [[file:../homelab-inventory/mybitch-laptop.org][docs/homelab-inventory/mybitch-laptop.org]] | Mint, ext4, apt | +|---------+-------------------------------------------+----------------------------------| +| truenas | [[file:../homelab-inventory/truenas-server.org][docs/homelab-inventory/truenas-server.org]] | TrueNAS SCALE, ZFS, no PM | +|---------+-------------------------------------------+----------------------------------| + +Each inventory file contains an =* Automated Capabilities= section with a property drawer (=:FS:=, =:PM:=, =:ORCH:=, =:SNAPSHOT:=, =:MESH:=, =:BACKUP:=, =:VIRT:=, =:INIT:=, =:LAST_AUDIT:=). Phase 0 parses this drawer, compares it to the live probe, and announces + writes back any drift. + +* The Workflow + +** Phase 0: Capability Detection + +Before running any health checks, probe the live system to determine which checks apply, then cross-reference against the inventory file for this host. + +*** Probe the live system + +#+begin_src bash +# Hostname — prefer hostnamectl (always present on systemd hosts), fall back to uname +host=$(hostnamectl --static 2>/dev/null || uname -n) + +# Filesystem of / +root_fs=$(findmnt -n -o FSTYPE /) + +# Any btrfs present? +has_btrfs=$(findmnt -t btrfs -n 2>/dev/null | wc -l) + +# Any zfs pool imported? +has_zfs=$(command -v zpool >/dev/null 2>&1 && zpool list -H -o name 2>/dev/null | wc -l || echo 0) + +# Package manager +pm=none +command -v pacman >/dev/null && pm=pacman +command -v apt >/dev/null && pm=apt +command -v dnf >/dev/null && pm=dnf + +# Orchestrator +orch=none +command -v topgrade >/dev/null && orch=topgrade + +# Snapshot tool +snapshot=none +command -v snapper >/dev/null && snapshot=snapper +# ZFS-native snapshots present? +[ "$has_zfs" -gt 0 ] && zfs list -t snapshot -H 2>/dev/null | head -1 | grep -q . && snapshot=zfs-native +command -v sanoid >/dev/null && snapshot=sanoid + +# Mesh +mesh=none +command -v tailscale >/dev/null && mesh=tailscale + +# Backup role +backup=none +[ -f /var/log/rsyncshot.log ] && backup=source +# Target role is declared by inventory, not probed (target hosts are discovered by their role, not a local artifact). + +# Virt / containers +virt="" +command -v virsh >/dev/null && virt="${virt}libvirt," +command -v docker >/dev/null && virt="${virt}docker," +command -v podman >/dev/null && virt="${virt}podman," +virt="${virt%,}" +[ -z "$virt" ] && virt=none + +# Init +init=$(ps -p 1 -o comm=) + +echo "host=$host fs=$root_fs btrfs=$has_btrfs zfs=$has_zfs pm=$pm orch=$orch snapshot=$snapshot mesh=$mesh backup=$backup virt=$virt init=$init" +#+end_src + +Present the probe result as a one-line capability summary at the top of the report. + +*** Cross-reference against inventory + +Locate the inventory file for this host: + +#+begin_src bash +inv=$(grep -l "^#+HOSTNAME: $host\b" "${ARCHSETUP_DIR:-$HOME/code/archsetup}"/docs/homelab-inventory/*.org 2>/dev/null | head -1) +#+end_src + +If no inventory file matches → print =NO INVENTORY FILE for $host= as a WARNING and skip the drift check. + +If found: parse the =:PROPERTIES:= drawer under the =* Automated Capabilities= heading and compare each key to the live probe. + +*Canonical parser* (the naive awk range =/^\* Automated Capabilities/,/^\* /= does NOT work — the end pattern matches the start line, truncating the range to one line): + +#+begin_src bash +get_inv() { + local key="$1" inv="$2" + awk -v key="$key" ' + /^\* Automated Capabilities/ { in_section=1; next } + /^\* / && in_section { in_section=0 } + in_section && $0 ~ "^:"key":" { + sub("^:"key":[[:space:]]*", "") + sub("[[:space:]]+$", "") + print; exit + } + ' "$inv" +} +#+end_src + +Key-to-probed-variable mapping: + +| Key | Probed variable | +|--------------+-----------------| +| =:FS:= | =root_fs= | +|--------------+-----------------| +| =:PM:= | =pm= | +|--------------+-----------------| +| =:ORCH:= | =orch= | +|--------------+-----------------| +| =:SNAPSHOT:= | =snapshot= | +|--------------+-----------------| +| =:MESH:= | =mesh= | +|--------------+-----------------| +| =:BACKUP:= | =backup= | +|--------------+-----------------| +| =:VIRT:= | =virt= | +|--------------+-----------------| +| =:INIT:= | =init= | +|--------------+-----------------| + +*** Announce-then-update on drift + +For each mismatch (excluding =TBD= on the inventory side, which means "not yet probed"): + +1. Print in the Phase 0 summary: =DRIFT: <host> <key>: inventory=X probed=Y — updating= +2. Edit the inventory file: replace the drawer value with the probed value. +3. Update =:LAST_AUDIT:= to today's date. + +Skip the write-back if the workflow is running on a host where the repo isn't checked out (e.g., truenas accessed via SSH). In that case, print the drift but flag it as =DRIFT (read-only host): update <file> manually=. + +*** Capability summary dictates which checks run + +Each check in the Reference section declares =Applies when: <capability>=. Phase 1 runs the check only if the capability is present; otherwise it prints =N/A — <capability> not present= for that check and moves on. + +** Phase 1: Scan & Report + +Run all checks from the Health Checks Reference below. Collect every finding into a ranked table sorted by severity: + +| Severity | Meaning | +|----------+-------------------------------------------------------------| +| CRITICAL | Service down, disk failing, backups not running | +|----------+-------------------------------------------------------------| +| WARNING | Disk space low, stale logs, failed units, overdue maint | +|----------+-------------------------------------------------------------| +| INFO | Informational (uptime, versions, counts) — no action needed | +|----------+-------------------------------------------------------------| + +*Before presenting findings:* cross-reference each finding against the Known Issues Log at the bottom of this file. If a finding matches a known issue with a decided resolution (won't-fix, accepted-noise, fixed-in-config), annotate it as =KNOWN — <short note>= rather than presenting it as a fresh WARNING. This prevents re-investigating previously-decided items and keeps the report focused on actionable new state. + +Present the report as described in Report Format, then proceed to Phase 2. + +** Phase 2: Investigate & Fix + +After presenting the ranked report: + +1. Start with the highest-severity issue +2. Investigate root cause with detailed commands +3. Propose a fix, explain what it does +4. Wait for Craig's approval before applying any changes +5. Verify the fix worked +6. Move to next issue +7. Repeat until all CRITICAL and WARNING items are addressed +8. INFO items are presented for awareness only (no action unless Craig wants to dig in) + +Handle issues one at a time — don't batch fixes together. Craig prefers to approve each individually. + +*** Defer Transient Warnings Until After Phase 3 Reboot + +If the Phase 1 pending-updates list includes any of these packages, =DEFER= Phase 2 deep-dive on network/DNS/VPN/uptime-accumulation warnings — investigate only if they survive the reboot: + +| Package (any of) | Defers these warning categories | +|---------------------------+---------------------------------------------------------------| +| kernel (linux, linux-lts) | bgscan/wifi driver spam, firmware-related dmesg noise | +|---------------------------+---------------------------------------------------------------| +| iproute2 | VPN daemon failures, ip-rule/routing-related service failures | +|---------------------------+---------------------------------------------------------------| +| systemd | resolved/networkd/logind service anomalies | +|---------------------------+---------------------------------------------------------------| +| NetworkManager | DNS reachability health checks, Tailscale DNS warnings | +|---------------------------+---------------------------------------------------------------| +| wpa_supplicant | WiFi connection/scan errors | +|---------------------------+---------------------------------------------------------------| + +*Rationale:* Warnings on a long-uptime system (>7 days) frequently reflect accumulated mid-transition state from earlier package updates that weren't rebooted into. A fresh reboot resolves these automatically. Investigation pre-reboot is wasted effort. + +*Procedure:* Mark the deferred warnings in the Phase 1 report as =DEFER-TO-POST-REBOOT=. Proceed directly from Phase 2 (investigating any non-deferrable warnings) to Phase 3. After reboot (see two-stage pattern in Phase 3), re-evaluate the deferred warnings — most will have vanished. Any that survive get the full Phase 2 treatment. + +** Phase 3: System Update + +Updates are separate from issue investigation. After all issues are addressed (or deferred per Phase 2's transient-warning rule): + +1. Review the pending update list (already gathered in Phase 1) +2. Identify notable packages (major version bumps, GPU drivers, kernel, firmware) +3. Check the [[https://archlinux.org/news/][Arch Linux News]] page for any manual intervention notices +4. Check =/var/log/pacman.log= for recent failed transactions +5. *Host-specific kernel watches.* On ratio: if =linux=, =linux-lts=, =linux-firmware=, or a major =mesa= bump is pending, run the addendum at =strix-soak-watch.org= before topgrade. Retire the addendum (delete the file + this bullet) when the strix-lts custom kernel is retired. +6. Run =topgrade= for the actual update (config at =~/.config/topgrade.toml=). On maint hosts (ratio, velox) plain =topgrade= resolves to the dotfiles PATH wrapper, which stamps the console's topgrade-freshness metric on success — no extra step. If the run happened outside the wrapper somehow, =maint stamp topgrade= records it by hand. +7. If linux-firmware, kernel, or Mesa were updated, recommend a reboot + +*** Two-Stage Reboot Pattern (MANDATORY if Phase 3 installed kernel / iproute2 / systemd / NetworkManager) + +When a core networking or kernel package is updated, the workflow formally splits into two sessions: + +*Stage 1 — Pause before reboot:* +1. Update =docs/session-context.org= with: + - What's been completed (Phase 1 findings, Phase 3 summary, packages bumped) + - What's deferred to post-reboot (the =DEFER-TO-POST-REBOOT= warnings from Phase 2) + - Remaining work: Phase 4, wrap-up +2. Tell Craig to reboot +3. End session (do NOT delete session-context.org — its presence signals an in-progress health check) + +*Stage 2 — Resume in new session post-reboot:* +1. Read =docs/session-context.org= first +2. Run post-reboot verification: + - =uname -r= → confirm new kernel is running + - =systemctl --failed= → confirm zero failed services + - =journalctl -p err -b= → count and compare to pre-reboot (sharp drop = transient warnings were real, accumulated noise) +3. Re-evaluate the deferred warnings — most are gone without action +4. Full Phase 2 treatment for any survivors +5. Complete Phase 4, then wrap up (writes session entry to notes.org covering both halves as one unified session, spanning the reboot) +6. Delete =session-context.org= only after wrap-up is committed + +*Rationale:* Trying to cram the whole flow into one session risks either (a) wasting time investigating transient warnings that would have self-healed, or (b) losing context if a crash happens during the reboot gap. The two-stage pattern is explicit about the pause and survives interruptions. + +** Phase 4: Resolve .pacnew Files + +After updates, check for and resolve .pacnew files: + +#+begin_src bash +find /etc -name "*.pacnew" 2>/dev/null +#+end_src + +For each .pacnew file: +1. Diff the current config against the .pacnew version +2. Determine if the current config is actively managed (e.g., by Reflector, or manually customized) +3. If the current config is correct and the .pacnew adds nothing useful → delete the .pacnew +4. If the .pacnew has meaningful changes → merge them into the current config, then delete the .pacnew +5. Always explain the diff to Craig before deleting + +Common .pacnew files on ratio: +- =/etc/pacman.d/mirrorlist.pacnew= — safe to delete; mirrorlist is managed by Reflector +- =/etc/locale.gen.pacnew= — safe to delete if =en_US.UTF-8= is already uncommented + +* Health Checks Reference + +Run these checks in order. Each check produces one or more findings for the report. + +** 1. System Basics + +#+begin_src bash +echo "Date: $(date)" +echo "Hostname: $(hostnamectl hostname)" +echo "Kernel: $(uname -r)" +echo "Uptime: $(uptime -p)" +#+end_src + +Severity: INFO (always). + +** 2. Failed systemd Units + +#+begin_src bash +systemctl --failed +#+end_src + +- Any failed units → WARNING (CRITICAL if essential service like NetworkManager, fail2ban, cronie) +- No failed units → INFO "All units healthy" + +** 3. Journal Errors (Current Boot) + +#+begin_src bash +# Get total error count first +journalctl -p err -b --no-pager | wc -l + +# Then separate known-noisy services from real errors +journalctl -p err -b --no-pager | grep -v "Hands-Free Voice gateway" | tail -50 +#+end_src + +Known noisy patterns to filter when counting: +- bluetoothd "Hands-Free Voice gateway" — paired device out of range, polls every 60s +- pixman "_pixman_log_error" — benign Hyprland rendering debug message +- xkbcomp "Errors from xkbcomp are not fatal" — X compatibility noise + +When bluetooth spam is found, identify the device with =bluetoothctl devices Paired= and offer to remove stale pairings. + +- Recurring or service-impacting errors → WARNING +- Hardware errors (MCE, disk I/O) → CRITICAL +- Noise-level errors (known benign) → skip, but note the noise source + +** 4. Kernel & Hardware Events (journalctl -k) + +Check #3 catches error-priority journal messages, but many hardware issues show up as +patterns of lower-severity kernel messages (e.g., repeated USB disconnects, I/O retries). +This check scans the kernel log for those patterns. + +*Why =journalctl -k -b= and not =dmesg=:* on systems that suspend (laptops especially), =dmesg -T= computes wall-clock timestamps using =current_realtime − CLOCK_MONOTONIC=, but kernel printk timestamps include suspend time. After a suspend-heavy uptime, dmesg displays timestamps drifted forward by the cumulative suspend duration (e.g., May 17 displayed for messages logged on May 10). =journalctl -k= stamps CLOCK_REALTIME at message-write time, so dates are always correct. Performance difference is ~100 ms vs ~10 ms — negligible at this cadence. (See the 2026-05-10 entry in the Known Issues Log.) + +#+begin_src bash +# USB disconnect/reconnect cycles (repeated = hardware or power management issue) +journalctl -k -b --no-pager | grep -c "USB disconnect" + +# If disconnects found, show the devices and timestamps +journalctl -k -b --no-pager | grep "USB disconnect" | tail -20 + +# USB protocol errors (error -71 = EPROTO, error -110 = timeout) +journalctl -k -b --no-pager | grep -iE "usbhid.*error|usb.*error -[0-9]+" | tail -10 + +# I/O errors on block devices +journalctl -k -b --no-pager | grep -iE "I/O error|blk_update_request|Buffer I/O error" | tail -10 + +# PCIe errors +journalctl -k -b --no-pager | grep -iE "pcie.*error|AER|corrected error|uncorrected error" | tail -10 + +# Machine Check Exceptions (CPU hardware errors) +journalctl -k -b --no-pager | grep -iE "mce:|hardware error" | tail -10 + +# GPU faults or resets +journalctl -k -b --no-pager | grep -iE "amdgpu.*error|amdgpu.*fault|gpu reset|gpu hang" | tail -10 + +# Thermal throttling +journalctl -k -b --no-pager | grep -iE "thermal.*throttl|cpu.*throttl|prochot" | tail -10 + +# ACPI errors (power management issues) +journalctl -k -b --no-pager | grep -iE "acpi.*error|acpi.*warning" | tail -10 + +# USB autosuspend state on internal hubs (Framework 16 specific) +for d in /sys/bus/usb/devices/1-[234]; do + if [ -d "$d" ]; then + echo "$d: $(cat $d/product 2>/dev/null) | control=$(cat $d/power/control) | delay=$(cat $d/power/autosuspend_delay_ms)" + fi +done +#+end_src + +What to look for: +- *USB disconnects*: A few at boot or after resume is normal. >5 during a session, or repeated disconnect/reconnect of the same device = problem. On Framework 16, the keyboard and numpad modules are internal USB — disconnects here mean input loss. +- *Error -71 (EPROTO)*: USB protocol error, often caused by aggressive hub autosuspend. Check hub power settings. +- *I/O errors*: Any I/O error on NVMe → immediate CRITICAL. +- *MCE/hardware errors*: Always CRITICAL. +- *GPU faults*: Occasional corrected errors are INFO. Resets or hangs are WARNING. +- *Thermal throttling*: WARNING if active. Check if cooling is obstructed. +- *USB hub autosuspend*: Internal hubs with =control=auto= and =delay=0= → WARNING (causes input disconnects on Framework 16). + +- Repeated USB disconnects of input devices → WARNING +- USB protocol errors (EPROTO) → WARNING +- I/O errors, MCE, or hardware errors → CRITICAL +- GPU resets or hangs → WARNING +- Thermal throttling active → WARNING +- Aggressive hub autosuspend on internal devices → WARNING +- Clean dmesg → INFO + +** 5. Disk Health (SMART) + +#+begin_src bash +sudo smartctl -H /dev/nvme0n1 +sudo smartctl -H /dev/nvme1n1 +sudo smartctl -A /dev/nvme0n1 | grep -i "critical\|temperature\|wear\|error" +sudo smartctl -A /dev/nvme1n1 | grep -i "critical\|temperature\|wear\|error" +#+end_src + +- SMART overall health != PASSED → CRITICAL +- NVMe wear > =storage.smart_wear_warn_pct= or temp > =storage.smart_temp_warn_c= (TOML; seeded 80% / 70°C) → WARNING +- All healthy → INFO + +** 6. Disk Usage + +Dispatched by filesystem. Run every sub-check for which the capability is present — a host with both Btrfs and ZFS (hypothetical: ratio with a ZFS backup pool) runs 6a AND 6b. + +*** 6a. Btrfs Usage + +*Applies when:* =has_btrfs > 0= + +#+begin_src bash +sudo btrfs filesystem usage / +/usr/bin/df -h / /boot 2>/dev/null +#+end_src + +Btrfs reports two numbers that can look confusing: +- *Data allocated %* (e.g., 99.43%) — how full the allocated data chunks are. Btrfs allocates in chunks and can allocate more from the unallocated pool. +- *df %* (e.g., 69%) — actual used space vs total device size. This is what matters. + +Check the "Device unallocated" line — as long as there's unallocated space, Btrfs can grow its data chunks. + +Severity rules: +- df > =storage.df_crit_pct= (TOML; seeded 90%) → CRITICAL +- df > =storage.df_warn_pct= (seeded 80%) → WARNING +- df under the warn line → INFO (report both df% and unallocated space) + +*** 6b. ZFS Pool Health + +*Applies when:* =has_zfs > 0= + +#+begin_src bash +# Quick health summary across all pools +sudo zpool status -x # expect "all pools are healthy" + +# Full detail — state, errors, scrub, resilver +sudo zpool status + +# Capacity, fragmentation, health per pool +sudo zpool list -o name,size,alloc,free,capacity,health,frag + +# Per-dataset usage (top-level only; -d1 depth) +sudo zfs list -o name,used,avail,refer -s used +#+end_src + +Severity rules: +- Pool state != ONLINE → CRITICAL +- Any read/write/cksum error > 0 → CRITICAL +- Capacity > =storage.zfs_capacity_crit_pct= (TOML; seeded 90%) → CRITICAL +- Capacity > =storage.zfs_capacity_warn_pct= (seeded 80%) → WARNING (ZFS performance degrades) +- Scrub age > =storage.zfs_scrub_crit_days= (seeded 60) → CRITICAL +- Scrub age > =storage.zfs_scrub_warn_days= (seeded 35) → WARNING +- Resilver in progress → INFO (but prominent in the report — mention drive being replaced) +- Fragmentation > =storage.zfs_frag_info_pct= (seeded 50%) → INFO + +Parse scrub age from =zpool status= output: look for the =scan:= line — either =scrub repaired … in … with … errors on <date>= (completed) or =scrub in progress …= (running). + +*** 6c. Generic filesystem fallback + +*Applies when:* =root_fs= is ext4, xfs, or any other non-Btrfs non-ZFS filesystem + +#+begin_src bash +/usr/bin/df -h / /home /boot 2>/dev/null +#+end_src + +Severity rules: +- > =storage.df_crit_pct= on any mounted partition → CRITICAL +- > =storage.df_warn_pct= → WARNING +- Normal → INFO + +** 7. Snapshots + +Dispatched by snapshot tool. + +*** 7a. Snapper (Btrfs) + +*Applies when:* =snapshot = snapper= + +#+begin_src bash +snapper -c home list --columns number,date,description | head -5 +snapper -c home list --columns number,date,description | tail -5 +snapper -c home list | wc -l +snapper -c home get-config | grep -iE "cleanup|TIMELINE_LIMIT|NUMBER_LIMIT" +# oldest snapshot age (pile-up signal) +oldest=$(sudo ls /home/.snapshots/ 2>/dev/null | grep -E '^[0-9]+$' | sort -n | head -1) +[ -n "$oldest" ] && echo "oldest: $(sudo grep -oP '(?<=<date>).*(?=</date>)' "/home/.snapshots/$oldest/info.xml" 2>/dev/null | head -1)" +#+end_src + +Severity rules: +- Zero snapshots → WARNING +- Cleanup disabled → WARNING +- Snapshot count very high, OR oldest snapshot far older than intended retention (e.g. months back) → WARNING (pile-up) +- =TIMELINE_LIMIT_MONTHLY= / =_YEARLY= set high (e.g. 10) on a large, churny subvolume → WARNING: that keeps ~10 months of monthly snapshots, which silently hoard space (this bit /home on 2026-05-26 — monthly snapshots back to Feb were the top space holders). Sane values are the TOML's =[snapshots]= =timeline_*= keys (seeded HOURLY 6, DAILY 7, WEEKLY 2, MONTHLY 2, QUARTERLY 0, YEARLY 0). Manual (=single=) snapshots are NOT touched by timeline cleanup — delete stale ones explicitly. +- Normal range → INFO with count and date range + +*Disk-space deep-dive (on demand, only when /home is actually filling).* Per-snapshot reclaimable space is invisible without btrfs quotas. TEMPORARILY enable quota, rank by exclusive space, then DISABLE it again — quotas slow every snapshot deletion, so never leave them on: +#+begin_src bash +sudo btrfs quota enable /home && sudo btrfs quota rescan -w /home +sudo btrfs qgroup show -re --sort=-excl /home | head -20 # top exclusive (reclaimable) holders +sudo btrfs quota disable /home +#+end_src +Reality check: data deleted from the live fs but still in many old snapshots shows ~0 *exclusive* per snapshot (it's shared across them), and only frees once the whole chain holding it is pruned. So reclaiming a large deletion means pruning the old snapshots, not just the newest one. + +*** 7b. ZFS Snapshots + +*Applies when:* =snapshot = zfs-native= or =sanoid= + +#+begin_src bash +# All snapshots, newest first +sudo zfs list -t snapshot -o name,creation,used -s creation | tail -20 + +# Count per pool +sudo zfs list -t snapshot -H -o name | awk -F'@' '{print $1}' | sort | uniq -c + +# Oldest snapshot age per dataset (detect runaway retention) +sudo zfs list -t snapshot -H -o name,creation -s creation | head -20 + +# Auto-snapshot service status (zfs-auto-snapshot / sanoid / zrepl) +systemctl list-timers --all 2>/dev/null | grep -Ei "zfs|sanoid|zrepl" || echo "no auto-snapshot timer" +#+end_src + +Severity rules: +- Zero snapshots on a dataset that should have them → WARNING +- Snapshot count on any dataset > =snapshots.zfs_count_warn= (seeded 1000) → WARNING (runaway retention) +- Snapshot space used > =snapshots.zfs_space_warn_pct= (seeded 20%) of pool capacity → WARNING (heavy divergence) +- Auto-snapshot timer not running → WARNING +- Normal → INFO (counts per pool, oldest snapshot age) + +*** 7c. No snapshot system + +*Applies when:* =snapshot = none= + +Report =INFO: no snapshot system configured on this host= and skip. + +** 8. Memory and Swap + +#+begin_src bash +free -h +journalctl -b --no-pager | grep -i "out of memory\|oom-kill\|killed process" | tail -10 +#+end_src + +Ratio has 128GB RAM and no swap configured. No swap is expected — don't flag it. + +- OOM kills found → WARNING +- Normal → INFO + +** 9. CPU Temperatures + +#+begin_src bash +sensors 2>/dev/null || echo "lm_sensors not installed or not configured" +#+end_src + +Key sensors on ratio: +- =k10temp= → CPU (Tctl) +- =amdgpu= → GPU edge temp +- =nvme= → NVMe drives (2 drives) +- =cros_ec= → mainboard (power, memory, ambient, CPU) + +- CPU temp > =power.cpu_temp_warn_c= or GPU temp > =power.gpu_temp_warn_c= (TOML; seeded 90/95°C) → WARNING, CRITICAL if sustained +- Normal → INFO (report CPU and GPU temps) + +** 10. NTP Sync + +#+begin_src bash +chronyc tracking +#+end_src + +Ratio uses chrony for NTP. + +- Clock not synchronized → WARNING +- NTP service inactive → WARNING +- Last offset > =network.ntp_offset_warn_ms= (TOML; seeded 100 ms) → WARNING +- Synchronized → INFO + +** 11. rsyncshot Backups + +*Applies when:* =backup = source= + +#+begin_src bash +tail -30 /var/log/rsyncshot.log 2>/dev/null +#+end_src + +rsyncshot runs via root crontab: +- Hourly: =30 0-1,3-23 * * *= (every hour except 2:30) +- Daily: =30 2 * * *= (2:30 AM) + +Backups go to TrueNAS via rsync. Look for "rsyncshot completed successfully" and check the timestamp. + +- No daily backup in =backups.rsyncshot_daily_crit_hours= (seeded 48) → CRITICAL +- No hourly backup in =backups.rsyncshot_hourly_warn_hours= (seeded 3) → WARNING (only on hosts whose log shows hourly runs) +- Errors in log → WARNING +- Recent and healthy → INFO + +** 12. Tailscale + +*Applies when:* =mesh = tailscale= + +#+begin_src bash +tailscale status +#+end_src + +Expected peers: ratio, cjennings, truenas, velox, worker. pixel6 is a phone and is often offline — don't flag it. + +- Tailscale not running → WARNING +- Server peers (truenas, velox, worker) offline → WARNING +- All expected peers online → INFO + +** 13. fail2ban + +#+begin_src bash +sudo fail2ban-client status +sudo fail2ban-client status sshd 2>/dev/null +#+end_src + +- fail2ban not running → WARNING +- Running → INFO (note any recent bans for awareness) + +** 14. Package Maintenance + +Dispatched by package manager. + +*** 14a. pacman (Arch) + +*Applies when:* =pm = pacman= + +#+begin_src bash +# Orphaned packages +pacman -Qtdq 2>/dev/null + +# Pending updates +checkupdates 2>/dev/null + +# Package cache timer + current size +systemctl list-timers paccache.timer --no-pager +du -sh /var/cache/pacman/pkg 2>/dev/null + +# .pacnew files +find /etc -name "*.pacnew" 2>/dev/null +#+end_src + +Notes: +- =paccache.timer= runs weekly keeping 3 versions — fine for routine pruning, but it only trims *old versions*. A cache can still balloon from *breadth* (many packages, ≤3 versions each), where =paccache -r= finds nothing to prune. On 2026-05-26 the cache was 17 GB and =paccache -r= pruned 0; the space came from deeper levers: + - =sudo paccache -ruk0= — remove cache for *uninstalled* packages (safe, re-downloadable). + - =sudo paccache -rk1= — keep only 1 version of installed packages (frees the most; less downgrade headroom). + Together those took 17 GB → 5.8 GB. +- When removing orphans, pass package names as arguments (don't pipe through stdin — breaks snap-pac's snapper hook). +- Review orphans before removing — some (like =rust=) warrant discussion (Craig switched to =rustup=). + +Severity rules: +- Orphaned packages beyond the curated =curation.kept_orphans= set, in bulk (> ~20) → WARNING +- Pending updates > =updates.pending_warn= (TOML; seeded 50) → WARNING +- Unreviewed .pacnew files → WARNING +- Package cache > =packages.cache_warn_gb= (TOML; seeded 10 GB) → INFO: suggest =paccache -ruk0= (uninstalled) and/or =paccache -rk1= (keep 1) for a deeper reclaim beyond the weekly keep-3. +- Normal counts → INFO + +*** 14b. apt (Debian/Ubuntu/Mint) + +*Applies when:* =pm = apt= + +#+begin_src bash +# Pending updates +apt list --upgradable 2>/dev/null | tail -n +2 + +# Auto-removable packages +apt -s autoremove 2>&1 | grep -E "^Remv|packages will be REMOVED" + +# .dpkg-dist / .dpkg-new files (the apt equivalent of .pacnew) +find /etc -name "*.dpkg-dist" -o -name "*.dpkg-new" 2>/dev/null + +# unattended-upgrades timer +systemctl list-timers apt-daily.timer apt-daily-upgrade.timer --no-pager 2>/dev/null +#+end_src + +Severity rules: +- Pending security updates > 0 → WARNING +- Auto-removable packages > 30 → WARNING +- Unreviewed .dpkg-dist files → WARNING +- Normal → INFO + +*** 14c. none (TrueNAS SCALE, other appliances) + +*Applies when:* =pm = none= + +Report =N/A — package maintenance handled by middleware= and skip. On TrueNAS SCALE, OS updates go through the TrueNAS UI (System → Update); user-level =apt= exists but is unsupported and shouldn't be used. + +*** 14d. Orchestrator (topgrade) + +*Applies when:* =orch = topgrade= + +If topgrade is present, Phase 3 uses it rather than the raw package manager. Phase 1 still runs 14a/14b/14c above — topgrade is for the update run, not the status scan. + +** 15. App Logs (~/.local/var/log) + +#+begin_src bash +for log in ~/.local/var/log/*.log; do + if [ -f "$log" ]; then + errors=$(grep -ci "error\|critical\|fatal" "$log" 2>/dev/null) + if [ "$errors" -gt 0 ]; then + echo "$(basename $log): $errors error lines" + grep -i "error\|critical\|fatal" "$log" | tail -3 + echo "" + fi + fi +done +#+end_src + +Known noise patterns: +- *waybar*: LIBDBUSMENU-GLIB-WARNING "Unable to replace properties on 0" — caused by insync and zoom tray icons with broken dbusmenu implementations. Harmless. Filtered in waybar's log output via =grep -v= in hyprland.conf. +- *hyprland*: "_pixman_log_error" and "xkbcomp" — benign +- *gammastep*: "Wayland connection experienced a fatal error" — happens on session restart, self-recovers +- *dunst*: gdk_pixbuf assertion failures — usually a malformed notification icon, one-off + +Look for patterns that are NOT in the known noise list. + +- Coredumps within =logs.coredump_window_days= (TOML; seeded 14 — =coredumpctl list= and age-filter) → WARNING +- Recurring non-noise errors → WARNING +- Only known noise → INFO + +** 16. System Logs (/var/log) + +#+begin_src bash +# Recent pacman errors +grep -i "error\|failed" /var/log/pacman.log 2>/dev/null | tail -10 + +# fail2ban anomalies +tail -50 /var/log/fail2ban.log 2>/dev/null | grep -i "error\|warning" +#+end_src + +- Failed pacman transactions → WARNING +- fail2ban errors → WARNING +- Clean → INFO + +** 17. Docker/Podman + +*Applies when:* =virt= contains =docker= or =podman= + +#+begin_src bash +docker ps -a --filter "status=exited" --format "{{.Names}}: exited {{.Status}}" 2>/dev/null +docker system df 2>/dev/null +podman ps -a --filter "status=exited" --format "{{.Names}}: exited {{.Status}}" 2>/dev/null +podman system df 2>/dev/null +#+end_src + +The WinVM podman container is run on-demand and will often show as exited — this is expected. + +*Severity thresholds:* +- Stopped containers that should be running → WARNING +- Docker/Podman reclaimable > =services.docker_reclaim_warn_gb= *or* > =services.docker_reclaim_warn_pct= of total (TOML; seeded 5 GB / 50%) → WARNING (propose prune tiers in Phase 2) +- Docker/Podman reclaimable 1–5 GB → INFO (note for awareness) +- Expected stopped containers → INFO + +*Prune tiers to propose when reclaimable crosses the WARNING threshold:* +1. =docker container prune= — removes only stopped containers (~tens of MB, unlocks referenced images) +2. =docker image prune -a= — removes all images not used by any container (biggest bite; requires tier 1 first) +3. =docker system prune -a --volumes= — full nuke, includes unused volumes and networks. Present as the option when Craig wants to start fresh. + +Confirm with Craig before running any tier — especially if work-related containers (e.g. =deepsat-*=) are present. + +** 18. libvirt / VMs + +*Applies when:* =virt= contains =libvirt= + +#+begin_src bash +sudo virsh list --all 2>/dev/null || echo "libvirt not available" +#+end_src + +VMs (ultmos-15, etc.) are usually shut off unless Craig is actively using them. "shut off" is the normal state. + +- VMs in unexpected state → INFO +- libvirt not running → INFO + +** 19. Cron Jobs + +#+begin_src bash +systemctl is-active cronie +crontab -l 2>/dev/null | grep -v "^#" | grep -v "^$" +sudo crontab -l 2>/dev/null | grep -v "^#" | grep -v "^$" +#+end_src + +Expected user crontab entries: +- =0 12 * * *= log-cleanup + +Expected root crontab entries: +- =30 0-1,3-23 * * *= rsyncshot hourly +- =30 2 * * *= rsyncshot daily + +- cronie not running → WARNING +- Expected crontab entries missing → WARNING +- Running with expected entries → INFO + +** 20. Log Cleanup Cron + +#+begin_src bash +find ~/.local/var/log -type f -name "*.log" -mtime +7 2>/dev/null | head -10 +find ~/.local/var/log -type f 2>/dev/null | wc -l +#+end_src + +The user crontab runs =~/.local/bin/cron/log-cleanup= daily at noon, which should keep logs to ~7 days. + +- Log files older than =logs.app_log_warn_days= (TOML; seeded 7) present → WARNING (cleanup cron may not be running) +- Within the retention window → INFO + +** 21. Network + +#+begin_src bash +# DNS resolution (dig may not be available — use ping as fallback) +ping -c1 -W2 archlinux.org 2>/dev/null && echo "DNS OK" || echo "DNS FAILED" + +# NetworkManager status +nmcli general status 2>/dev/null +#+end_src + +- DNS resolution fails → CRITICAL +- NetworkManager not running or disconnected → CRITICAL +- All healthy → INFO + +* Report Format + +Present findings as a ranked org-mode table, sorted by severity (CRITICAL first, then WARNING, then INFO): + +#+begin_example +| # | Severity | Category | Finding | +|---+----------+-------------+--------------------------------| +| 1 | CRITICAL | rsyncshot | Daily backup failed 2 days ago | +|---+----------+-------------+--------------------------------| +| 2 | WARNING | disk usage | / at 85% capacity | +|---+----------+-------------+--------------------------------| +| 3 | WARNING | packages | 47 orphaned packages | +|---+----------+-------------+--------------------------------| +| 4 | INFO | uptime | 12 days | +|---+----------+-------------+--------------------------------| +| 5 | INFO | disk health | Both NVMe drives PASSED | +|---+----------+-------------+--------------------------------| +| 6 | INFO | tailscale | 3/3 peers online | +|---+----------+-------------+--------------------------------| +#+end_example + +After the table, announce the investigation plan: + +- If CRITICAL or WARNING issues exist: "Starting with #1 — investigating [category]: [finding]..." +- If only INFO items: "No issues found. System looks healthy. Here are the details..." + +* Known Issues Log + +Each entry is scoped to one host (or =any=). When Phase 1 cross-references findings against this log, it matches on both the issue signature AND the host — an entry tagged =:host: mybitch= won't suppress a finding on ratio. For pre-existing entries without an explicit host tag, the hostname is inferred from the entry title where possible (e.g., "mybitch —"). New entries should include =:host: <name>= for clarity. + +** 2026-03-24: mybitch — USB autosuspend causing keyboard/numpad disconnects +:host: mybitch +- Framework Laptop 16 internal Genesys Logic USB hubs (05e3:0610) had =control=auto= and =delay=0= +- Caused repeated USB disconnects of keyboard module (1-4.2) and numpad module (1-3.2) +- Error -71 (EPROTO) on reconnection attempts +- Fix: udev rule at =/etc/udev/rules.d/99-framework-usb-hub-no-autosuspend.rules= +- Sets =power/control=on= for matching hubs, disabling autosuspend +- Added check #4 (Kernel & Hardware Events) to this workflow to catch similar issues in future + +** 2026-02-27: Bluetooth journal spam (1,006 errors/boot) +:host: ratio +- bluetoothd polling "Craig's Pixel Buds" (B8:7B:D4:19:A6:01) every 60s while out of range +- Fix: removed stale pairing with =bluetoothctl remove B8:7B:D4:19:A6:01= +- Note: Craig has recurring Pixel Buds pairing issues with this machine + +** 2026-02-27: hyprlock deprecated config options (v0.9.2) +:host: ratio +- =general:grace= and =general:no_fade_in= moved to CLI flags (=--grace=, =--no-fade-in=) +- =input-field:fail_transition= replaced by =animations= block (=inputFieldColors= animation) +- Fix: removed deprecated options, added =animations= block to hyprlock.conf +- Config at: =~/code/archsetup/dotfiles/hyprland/.config/hypr/hyprlock.conf= + +** 2026-02-27: dunst coredump +:host: ratio +- Segfault in libglycin/gdk_pixbuf — malformed notification icon +- One-off, no action taken. Monitor for recurrence. + +** 2026-02-27: Missing udev script for Logitech Brio +:host: ratio +- Udev rule references =~/.local/bin/logitech-brio-settings.sh= which is a symlink to archsetup dotfiles +- Fails at boot because udev runs before home is fully available +- Script works fine for hotplug after login +- Decision: leave as-is + +** 2026-02-27: waybar LIBDBUSMENU-GLIB warnings (~8,000/session) +:host: ratio +- Caused by insync and zoom tray icons with broken dbusmenu implementations +- Both return empty arrays for menu root item (ID 0) +- No fix available (insync has no option to disable tray icon) +- Mitigation: added =grep -v "LIBDBUSMENU-GLIB-WARNING"= filter to waybar log line in hyprland.conf +- Takes effect on next Hyprland start + +** 2026-02-27: Package cleanup +:host: ratio +- Removed 51 orphaned packages (build deps, Python dev tools) +- Swapped =rust= pacman package for =rustup= (toolchain manager) +- Added note to =~/code/archsetup/inbox/rustup.txt= + +** 2026-02-27: System update via topgrade +:host: ratio +- 66 packages updated including Mesa 25.3→26.0, Firefox/Thunderbird 148, linux-firmware, Signal 8.0 +- No manual intervention required (checked Arch news) +- Reboot recommended for firmware and Mesa changes + +** 2026-02-27: Resolved .pacnew files +:host: ratio +- =/etc/pacman.d/mirrorlist.pacnew= — stock mirrorlist, safe to delete (Reflector manages the active one) +- =/etc/locale.gen.pacnew= — only added commented =en_SE.UTF-8=, current config correct, deleted + +** 2026-04-19: proton.VPN.service transient failure after topgrade +:host: ratio +- Appeared as a =failed= unit immediately after =topgrade= bumped iproute2 from 6.19.0 → 7.0.0 mid-session (9-day uptime) +- Self-healed on reboot; no config change required +- Pattern: VPN daemon holds routing/ip-rule state referencing old iproute2 ABI, can't reconcile after live package swap +- Classification: KNOWN-TRANSIENT on iproute2 upgrade → DEFER-TO-POST-REBOOT per Phase 2 rules + +** 2026-04-19: Tailscale DNS reachability warning on long uptime +:host: ratio +- =tailscale status= footer: "Tailscale can't reach the configured DNS servers. Internet connectivity may be affected." +- Appeared on 9-day uptime; all core functions (name resolution, netcheck, tailnet routing) tested working +- Cleared on reboot — footer warning gone, no config change +- Pattern: tailscaled's internal DNS health check accumulates stale state on long uptime, particularly after a mid-session iproute2 transition +- Classification: KNOWN-TRANSIENT on long uptime / iproute2 upgrade → DEFER-TO-POST-REBOOT per Phase 2 rules + +** 2026-04-19: wpa_supplicant bgscan error spam volume scales with uptime +:host: ratio +- Error: =bgscan simple: Failed to enable signal strength monitoring= +- Volume pre-reboot (9-day uptime): 1,200+ instances in journal +- Volume post-reboot (fresh boot): 1 instance total +- Root cause: RSSI monitoring ioctl not implemented in mt7925e driver (Framework Desktop WiFi 7 card) — each retry or roam event re-triggers the failure +- 1–3 instances per boot is baseline noise; only triage if volume > ~50 AND not explainable by long uptime +- Classification: KNOWN (mt7925e driver limitation, no upstream fix tracked); annotate low-volume occurrences as =KNOWN — mt7925e baseline= + +** 2026-04-19: Orphan package cleanup +:host: ratio +- Removed 11 packages (8 orphans + 3 cascading python build-tool deps): cli11, electron34, lua-lpeg, minizip-ng, python-build, python-hatchling, python-installer, yarn + python-editables, python-pyproject-hooks, python-trove-classifiers +- Freed 280 MiB +- Future note: electron34 and yarn were previously flagged "ask first" due to possible AUR build-dep relevance — post-removal, no AUR rebuild has failed. Can remove in future without asking unless an active AUR build references them. + +** 2026-04-21: velox ZFS — systemd-tmpfiles "Protocol driver not attached" +:host: velox +- Symptom: =systemd-tmpfiles-setup.service= (boot) and/or =systemd-tmpfiles-clean.service= (periodic) produce 10-30 =statx(...) failed: Protocol driver not attached= journal errors per run +- Root cause: on ZFS, statx against another service's =/var/tmp/systemd-private-*/tmp= mount returns errno 132 (ENOTNAM); ext4/btrfs don't surface this as an error. Bare systemd-tmpfiles unit has no =PrivateTmp= set, so it traverses sibling namespaces +- Fix: drop-in with =PrivateTmp=yes= at =/etc/systemd/system/systemd-tmpfiles-clean.service.d/zfs-private-tmp.conf= AND =.../systemd-tmpfiles-setup.service.d/zfs-private-tmp.conf= +- Applies to any ZFS-on-root Arch host. Not needed on btrfs hosts. + +** 2026-04-19: Docker image bloat cleanup +:host: ratio +- Freed 15.3 GB via =docker system prune -a --volumes= +- Removed 10 images (including nvidia/cuda 12.4 devel, postgis, nginx, python-slim, node-alpine, nerdfonts/patcher) and 4 exited deepsat-* work containers from 9 days prior +- 130 MB orphan volume survived prune (anonymous, not caught by =--volumes=); ignored +- Future: Check #17 now promotes reclaimable > 5 GB OR > 50% to WARNING — this will catch bloat earlier without Craig having to notice it + +** 2026-05-10: WiFi powersave default caused variable WiFi latency on mybitch + ratio +:host: mybitch, ratio (velox naturally clean — defensive config added anyway) +- Symptom: variable LAN/WiFi latency manifesting as "slow-feeling internet". On mybitch (Christine's laptop, the original report): gateway ping avg 67.9 ms, max 173.9 ms, mdev 55.7 ms at 1-second cadence vs avg 14.5 ms / mdev 16.4 ms at 0.2-second cadence (faster cadence kept the card awake, masking the issue). On ratio (Arch desktop): same pattern, mdev 46.7 ms, max 183.5 ms. +- Root cause: NetworkManager defaults to =wifi.powersave = 3= (enable) when no override is configured. On Mint/Ubuntu, this is shipped explicitly as =/etc/NetworkManager/conf.d/default-wifi-powersave-on.conf=. On Arch, it's the upstream NetworkManager default (no shipped file, but same effective behavior). On bursty traffic (typical browsing), the WiFi card sleeps between bursts and the first packet of each new burst takes 50-150 ms to wake it — pages feel like they "stall" before loading. +- Fix: drop =/etc/NetworkManager/conf.d/wifi-powersave-off.conf= with =wifi.powersave = 2= (disable). NetworkManager merges conf.d alphabetically; =wifi-...= sorts after =default-...= and =dns.conf= so the override wins. Restart NetworkManager applies it. +- Verification: post-fix gateway ping (both mybitch and ratio) avg 12 ms, max 21-24 ms, mdev 3 ms — 15-18× improvement in jitter, 8-9× improvement in max latency. Throughput unchanged at the ISP/router ceiling (~85 Mbit/s down, 25 Mbit/s up — same on both clients, so that's the connection limit, not a per-host issue). +- Velox edge: velox's WiFi card defaults to powersave-off at the driver/kernel level even without an explicit NM config (different chipset behavior). Latency was already healthy. Defensive =wifi-powersave-off.conf= added anyway so the configuration is uniform across the homelab and protected against future NM-default or driver-behavior changes. +- Tradeoff: ~0.5-1 W more power draw — irrelevant on AC, slight battery hit on battery. All three hosts are mostly on AC. +- Coverage status as of this entry: + - mybitch (Mint): explicit override applied, was active issue → resolved + - ratio (Arch): explicit override applied, was active issue → resolved + - velox (Arch laptop): explicit override applied defensively, no observable issue + - truenas: not on WiFi, N/A +- Pattern note: any new NM-using host added to the homelab should get this override at provisioning time. The 2026-04-30 mybitch upgrade soak picked up the issue indirectly; future host adds should set this proactively. + +** 2026-05-10: mybitch — keyboard soak verified closed (2026-03-24 USB hub fix held over 9-day uptime) +:host: mybitch +- Reminder from 2026-04-30 (overdue day 10) was to verify the 2026-03-24 USB-autosuspend fix holds in routine use. +- Verification (2026-05-10, mybitch uptime 1w 2d 2h): all three Framework 16 internal hubs (1-2, 1-3, 1-4) show =control=on, delay=0= — udev rule active. Total USB error count this boot: *0*. Soak passed. +- Closes the 2026-04-30 reminder. The 2026-03-27 mybitch BIOS-side fixes (which were gated on this soak result) are now unblocked. + +** 2026-05-10: dmesg -T displays future-dated timestamps after suspend cycles +:host: any (laptops affected; desktops rarely suspend) +- Symptom: =dmesg -T= shows wall-clock timestamps drifted forward by the cumulative suspend duration since boot. Example: mybitch on 2026-05-10 with 9d 2h monotonic uptime displayed messages dated May 15-17 (~7 days of suspend during the boot session pushed the displayed dates 7 days into the future). +- Root cause: kernel printk timestamps include suspend time (boottime-style clock). =dmesg -T= computes the boot epoch using =current_realtime − CLOCK_MONOTONIC=, but =CLOCK_MONOTONIC= excludes suspend. The two clocks diverge by however long the system was suspended during the current boot, and =dmesg -T= adds the (boottime-correct) message offset to a (monotonic-derived) boot epoch — producing future-dated displays. +- Verification: =journalctl -k -b= shows the same kernel messages with correct timestamps because journald stamps =CLOCK_REALTIME= at message-write time. =date=, =timedatectl=, and =/proc/stat btime= are all correct. +- Workflow fix (applied 2026-05-10): Phase 1 check #4 now uses =journalctl -k -b= instead of =dmesg=. Tradeoff is ~100 ms vs ~10 ms per query — negligible at this cadence. Side benefits: cross-boot queries (=-b -1=, =-b -2=) and longer history retention than the kernel ring buffer. +- Live cleanup (optional): a reboot resets both clocks and =dmesg -T= comes back accurate until the next long suspend session. Not urgent — the workflow change routes around the bug regardless. +- Out of scope: the dmesg tool itself isn't going to change behavior here; this is a long-standing util-linux design decision around how to translate ring-buffer timestamps. We don't fight it; we use the right tool. + +** 2026-05-10: aardvark-dns "empty response" spam during WinVM podman runs +:host: ratio +- Pattern: =aardvark-dns[N]: <port> dns request got empty response= logged at error priority while the podman-rootless aardvark-dns daemon forwards DNS for the WinVM container +- Volume: ~1,200 lines per WinVM session (Windows guest aggressively retries DNS lookups, every retry that gets back NOERROR/empty triggers one line) +- Trigger: only logs while a podman container with the rootless aardvark-dns network is running. Daemon stops with the container, so journal noise is bounded by WinVM uptime +- Active when looked at: aardvark-dns daemon is not running between WinVM sessions; these errors are retrospective journal entries, not a live issue +- Classification: KNOWN — annotate as =KNOWN — WinVM podman aardvark-dns DNS retry noise= and check the surrounding podman/WinVM lifecycle to confirm correlation. Volume scales with WinVM session length. +- No fix in scope. Suppression options if it becomes annoying: (a) journald filter rule for unit pattern, (b) switch WinVM to a different DNS path. Neither pursued today. + +** 2026-05-10: cameractrls cameraptzmidi.py SEGV during Python 3.14 exit +:host: ratio +- =python3 /usr/lib/python3.14/site-packages/CameraCtrls/cameraptzmidi.py -l= prints its output (e.g. "JDS Labs Element IV MIDI 1:32:0") and then SEGVs during interpreter shutdown +- Stack trace lives entirely inside =libpython3.14.so= on the =Py_Exit= path — no cameractrls frames, no asound frames at the crash point +- Pattern: ctypes-loaded =libasound= atexit handler vs Python 3.14 finalization order; the script's job completes before the crash so functional behavior is fine +- Reproduces every invocation. Three coredumps on 2026-05-08 11:49–11:50 were Craig running it three times in a row, not a regression burst +- Classification: KNOWN (upstream Python 3.14 / cameractrls issue, no local fix). Annotate future cameraptzmidi coredumps as =KNOWN — Py_Exit / libasound finalization= +- Real fix is upstream: Python 3.14 ctypes-finalization change or cameractrls explicit asound cleanup before exit. Not worth tracking locally. + +** 2026-05-11: mybitch — hard freeze caused by amdgpu iGPU MES hang; mitigated with =amdgpu.cwsr_enable=0= +:host: mybitch +- Symptom: full system freeze during active GUI use (Christine typing in a browser). Desktop frozen, keyboard + trackpad dead, off the network. Not the s2idle USB-keyboard bug — the machine was awake, not resuming from suspend, and the WiFi (M.2 PCIe, not USB) was dead too, so a USB-hub-only failure is excluded. Required hard power-cycle. =last= records the prior session as ending in "crash". +- Evidence in =journalctl -b -1=: the journal *stops dead* with no panic / oops / hung-task warning / OOM / GPU-reset trace — classic hard hang (kernel wedged, logging stopped). pstore empty. Preceding the freeze: =amdgpu 0000:c5:00.0: amdgpu: MES failed to respond to msg=MISC (WAIT_REG_MEM)= + =failed to reg_write_reg_wait= — *38 occurrences* over the 10-day boot, *zero* in every prior boot including the pre-upgrade 6.8.0-110 stretch. Last one ~4.7 min before the hang. =c5:00.0= is the *integrated* GPU (Radeon 780M / Phoenix1, GFX 11.0.x); =03:00.0= is the dGPU (RX 7700S) — the dGPU's =PSP/SMU is resuming= log lines are routine runtime-PM, not errors. +- Root cause class: amdgpu MES (Micro Engine Scheduler) firmware going unresponsive on AMD GFX11 APUs (Phoenix 780M, Strix Point, Kraken Point) — a known issue across a wide range of recent kernels (6.11 → 6.18+), no clean upstream fix. The MES errors appeared exactly when mybitch moved to kernel 6.17.0-23 (Mint 22.3 HWE-edge, adopted 2026-04-30 to fix the s2idle USB-keyboard bug). Tracked at: Framework Community "AMD GPU MES Timeouts Causing System Hangs", ROCm issues #3207 / #5590 / #5844, drm/amd GitLab #2986. +- Mitigation applied 2026-05-11: =amdgpu.cwsr_enable=0= added to =GRUB_CMDLINE_LINUX_DEFAULT= in =/etc/default/grub= on mybitch (backup: =/etc/default/grub.bak-2026-05-11-amdgpu-cwsr=), =update-grub= run, param verified in all 6 grub.cfg entries. Disables Compute Wave Store and Resume — the feature several reporters found triggers the MES firmware hang. Narrow, low-risk. Takes effect on next reboot. +- Watch: after the next reboot, check =journalctl -k -b | grep -c 'MES failed'= — should stay 0. If a freeze recurs even with cwsr disabled, escalate to =amdgpu.mes=0= (disables hardware MES scheduling entirely, falls back to KIQ; heavier hammer). Last resort = pin back to 6.8.0-111 (still installed), but that reintroduces the s2idle USB-keyboard bug, so not clean. +- *Update 2026-05-12:* =cwsr_enable=0= did NOT reduce the MES errors — 14 "MES failed to respond" hits in the first 14.5h boot after the fix (≈0.97/h) vs. 38 over the prior 10-day boot (≈0.16/h), i.e. *higher* rate; no freeze yet. Escalated proactively (Christine about to travel with the laptop for a week): added =amdgpu.mes=0= to =/etc/default/grub= alongside =cwsr_enable=0= (backup =/etc/default/grub.bak-2026-05-12-amdgpu-mes=, =update-grub= run, in all 6 grub.cfg entries). Takes effect on the next reboot (after the in-progress first rsyncshot backup). =mes=0= disables the GFX11 hardware MES scheduler → kernel falls back to the mature legacy KIQ path → sidesteps the hanging MES firmware entirely. Tradeoff: KIQ-on-GFX11 is a less-traveled config (small chance of cosmetic display/modeset quirks; irrelevant for a light desktop workload). Post-reboot check: =journalctl -k -b | grep -c 'MES failed'= should be 0; watch for any new amdgpu display oddities. (Also tracked in =homelab-inventory/mybitch-laptop.org= → Operational Changes Log.) +- *Update 2026-05-12b (logged retroactively 2026-05-25):* =mes=0= was swapped for =uni_mes=0= the same day (backup =/etc/default/grub.bak-2026-05-12b-uni-mes=). This refinement was applied but never written into this entry until the 2026-05-25 health check found the live cmdline disagreeing with the documented =mes=0= mitigation. Current cmdline: =amdgpu.cwsr_enable=0 amdgpu.uni_mes=0=. +- *Update 2026-05-25 (health check):* Kernel bumped 6.17.0-23 → 6.17.0-29-generic (Mint HWE). MES errors persist: 49 =MES failed to respond= / =reg_write_reg_wait= on =c5:00.0= over a 3d5h boot (≈0.63/h), *no hard freeze this uptime*. Key finding from =modinfo amdgpu= on 6.17.0-29: =mes= now defaults to 0 (disabled) and =uni_mes= defaults to 1 (enabled) — between -23 and -29 AMD moved GFX11 onto the *unified* MES path, so =uni_mes=0= is the current-kernel equivalent of the old =mes=0= mitigation. Setting =amdgpu.mes=0= explicitly on this kernel just restates the default and won't change behavior; the errors are on the unified-MES path, which is already disabled. Decision (Craig, 2026-05-25): leave the cmdline as-is — there is no stronger *documented* knob left to pull beyond =uni_mes=0=, and there's no freeze to chase. The newer kernel appears to recover from each MES timeout rather than wedging. Watch freeze behavior; escalation lever if a hard freeze recurs would be the experimental =amdgpu.mes=0 amdgpu.mes_kiq=0 amdgpu.uni_mes=0= full-legacy-KIQ path (not applied — risky on a traveling laptop) or pinning back to 6.8.0-111 (reintroduces the s2idle USB-keyboard bug). +- Classification: when a future health check on mybitch sees =MES failed to respond= / =reg_write_reg_wait= on =c5:00.0=, annotate as =KNOWN — amdgpu GFX11 iGPU MES hang= and check whether the count is climbing despite the mitigations; a single hard freeze with the dead-journal signature is the same issue recurring. On kernel 6.17.0-29+ the box runs =uni_mes=0= (the unified-MES disable), which is the heaviest documented knob in use — MES errors persisting on it without a freeze is the expected steady state, not an escalation signal. A *hard freeze* with the dead-journal signature is the real escalation trigger; remaining levers at that point are the experimental full-legacy-KIQ cmdline or pinning back to 6.8.0-111. + +** 2026-05-11: mybitch — power-profiles-daemon was stuck on power-saver (now balanced); benign dGPU power-limit error +:host: mybitch +- Symptom: =amdgpu 0000:03:00.0: amdgpu: New power limit (30) is out of range [100,120]= + =amdgpu: Failed to set power limit value= at error priority, once per boot (and on profile changes). +- Root cause: =power-profiles-daemon= in the =power-saver= profile tried to cap the discrete RX 7700S (=03:00.0=) at 30W, but the GPU's settable power-cap range is [100W, 120W] (current cap 100W). amdgpu rejected the out-of-range request → cosmetic error; the dGPU stayed at its 100W floor regardless. Underlying issue: PPD persists the last-set profile in =/var/lib/power-profiles-daemon/state.ini= and restores it on every boot — someone had set =power-saver= long ago (state.ini mtime Oct 2025) and it had been pinned there ever since. Christine runs mybitch on AC a lot, so this wasn't what she wanted. +- Resolution (2026-05-11): set the profile to =balanced=. =powerprofilesctl set= over SSH is polkit-denied (=switch-profile= needs an active local session), so done by editing =state.ini= directly under sudo (=Profile=power-saver= → =Profile=balanced=) with =power-profiles-daemon= stopped, then restarting it. Verified =powerprofilesctl get= → =balanced=; persists across reboots; no new power-limit errors. Christine can still flip the profile anytime via the Cinnamon power applet. +- Classification: if the =New power limit (30) out of range= error reappears, it means the profile drifted back to =power-saver= — annotate as =KNOWN — PPD power-saver dGPU cap rejection= and re-set to =balanced= the same way. Harmless either way; the only reason to fix it is that power-saver throttles the box on AC. + +** 2026-05-18: velox — NVMe Error Information Log entries from kernel optional-feature probing +:host: velox +- Symptom: =smartctl -A /dev/nvme0n1= shows =Error Information Log Entries: 9,530= and climbing slowly over time, despite =Media and Data Integrity Errors: 0= and =SMART overall-health PASSED=. +- Detail (=smartctl -l error /dev/nvme0n1=): all 9,530 entries are a single error class — =Status 0x4004 "Invalid Field in Command"= on the Admin Submission Queue (SQId 0). One row in the error info log, accumulated over 20,079 power-on hours (~2.3 years of drive uptime). +- Root cause: kernel or userspace polls for an optional NVMe feature/log page the drive doesn't implement; the controller rejects with =Status 0x4004=, the host sees ENOTSUP and moves on, but the drive faithfully logs every rejection. Common on NVMe drives where newer kernels probe for features added after the drive's firmware was written. +- Classification: KNOWN — annotate future findings as =KNOWN — NVMe Invalid-Field-in-Command from kernel optional-feature probing=. The counter going up is not a failure signal; only =Media and Data Integrity Errors=, =Critical Warning=, or =Available Spare= changes indicate actual drive health regression. +- Out of scope: no kernel-side fix worth chasing. The probe is harmless and would have to be tracked down to a specific kernel subsystem; not worth the time when the only effect is a benign counter. + +** 2026-05-25: mybitch — timeshift SIGSEGV on =--check --scripted= cron runs (GObject teardown race) +:host: mybitch +- Symptom: =coredumpctl= shows intermittent SIGSEGV of =/usr/bin/timeshift= during the hourly =timeshift --check --scripted= cron job (e.g. 2026-05-23 02:00, 2026-05-24 23:00 — 2 in a 3-day window). Crash is at address =0x30= inside =libgobject-2.0.so.0= =g_object_unref=, with a second thread in =g_file_get_contents= — a multi-threaded GObject teardown race. +- Timing: the process runs ~29 s (started :01, segfault :30) — it completes its work and dies during cleanup/exit, the same "job done, crash on teardown" shape as the cameractrls Py_Exit case. +- Backups unaffected: hourly =backup.log= files in =/var/log/timeshift/= are present every hour, =--list= works, 50 snapshots on disk. timeshift rsyncs to a temp dir then promotes, so a crashed run leaves at worst an incomplete dir the next run ignores — not a corrupt snapshot. +- Long-standing, not a regression: an older coredump from 2026-01-06 has the identical two-thread =g_object_unref= / =g_file_get_contents= signature. Version: =timeshift 25.12.4+zena= (Mint's maintained fork); apt shows 0 pending so no newer build to move to. +- Classification: KNOWN — annotate future findings as =KNOWN — timeshift zena teardown SIGSEGV on --check=. A real escalation signal would be missing hourly =backup.log= entries or a gap in the snapshot timeline; the coredumps alone are cosmetic. No fix in scope (upstream zena-fork bug); suppression isn't worth it. + +** 2026-06-13: ratio — dockerized telega-server SIGSEGVs in musl build +:host: ratio +- Symptom: =coredumpctl= shows repeated SIGSEGVs from =telega-server -O 31 -l /home/cjennings/.telega/telega-server.log -v 3= running inside the Telega Docker container. Example stack is entirely in the container's musl loader plus =/usr/bin/telega-server=, not host kernel, GPU, storage, or memory paths. +- Evidence: =~/.telega/telega-server.log= ends with =Unexpected char 'm' in plist value= followed by =Assertion failed: false (telega-dat.c: tdat_plist_value: 500)=. Surrounding TDLib traffic includes sticker/custom-emoji metadata such as =documentAttributeCustomEmoji= and =PhotoSizeSourceThumbnail[Thumbnail, type = m]=. +- Prior local triage: =~/.emacs.d/todo.org= recorded the same issue on 2026-06-11 as spontaneous memory-corruption crashes in =zevlg/telega-server:latest='s musl build, with several coredumps occurring without action-verb traffic. Telega package installed at the 2026-06-13 health check was =20260513.509=; MELPA had =20260604.2321= available. +- Functional status at 2026-06-13 check: no coredumps yet that day; Telegram scans still worked from cached chat state. Treat as an app/server-container crash, not a machine-health fault. +- Classification: KNOWN — annotate future =telega-server= coredumps on ratio as =KNOWN — dockerized telega-server musl SIGSEGV= if the signature matches =tdat_plist_value= / unexpected plist value or otherwise stays inside the Telega container. Escalate only if crashes become continuous, break Telegram workflows, or appear after moving off the Docker musl build. +- Deferred remediation options, in order of least disruption: update the Emacs =telega= package, rebuild/pull a newer =telega-server= image, pin a known-good pre-2026-06 image digest, build =telega-server= natively, or report upstream with =coredumpctl= and log evidence. |
