aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xarchsetup99
-rw-r--r--assets/outbox/2026-06-15-1825-from-.emacs.d-org-protocol-popup-needs-to-be-larger.org5
-rw-r--r--tests/zig-pin/test_zig_pin.py203
-rw-r--r--todo.org110
-rw-r--r--working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org120
-rw-r--r--working/collapsible-waybar-sides/spike-findings.org42
6 files changed, 520 insertions, 59 deletions
diff --git a/archsetup b/archsetup
index 3f4821f..68ae5b8 100755
--- a/archsetup
+++ b/archsetup
@@ -2000,6 +2000,104 @@ gaming() {
sudo -u "$username" systemctl --user enable gamemoded.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
}
+### Zig Toolchain Pin
+# Arch's rolling repo ships zig 0.16+, but the Emacs ghostel terminal's native
+# module compile fallback needs exactly 0.15.2: ghostel pins ghostty 1.3.2-dev,
+# whose build does requireZig(0.15.2), and 0.16's build-API changes break the
+# dependency build scripts. So we do NOT `pacman -S zig` -- we pin the upstream
+# 0.15.2 toolchain under /opt and symlink it into /usr/local/bin (ahead of
+# /usr/bin on PATH), where `pacman -Syu` can't bump it out from under the build.
+#
+# The ghostel default install path downloads a prebuilt .so and needs no zig at
+# all; this pin only matters for the offline / no-release compile fallback.
+ZIG_VERSION=0.15.2
+ZIG_SHA256=02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239
+
+# zig_install_from_tarball <tarball> <sha256> <version> <opt_root> <bindir>
+# Verify <tarball>'s sha256 against <sha256>, extract it into
+# <opt_root>/zig-<version>/ (stripping the upstream wrapper dir so zig lands at
+# the root), and point <bindir>/zig at the extracted binary. Refuses -- without
+# extracting anything -- on a sha256 mismatch, a missing tarball, or a tree
+# carrying no zig binary; a tampered or truncated download must never reach
+# /opt. Idempotent: a correct existing install short-circuits. Self-contained
+# (no network, prints its own refusal reasons) so it can be unit-tested.
+zig_install_from_tarball() {
+ local tarball="$1" want_sha="$2" version="$3" opt_root="$4" bindir="$5"
+ local dest="$opt_root/zig-$version"
+ local link="$bindir/zig"
+
+ # Already extracted and linked at this version -> nothing to do.
+ if [ -x "$dest/zig" ] && [ "$(readlink -f "$link" 2>/dev/null)" = "$dest/zig" ]; then
+ return 0
+ fi
+
+ if [ ! -f "$tarball" ]; then
+ echo "zig pin: tarball '$tarball' not found" >&2
+ return 1
+ fi
+
+ local got_sha
+ got_sha="$(sha256sum "$tarball" | cut -d' ' -f1)"
+ if [ "$got_sha" != "$want_sha" ]; then
+ echo "zig pin: sha256 mismatch (want $want_sha, got $got_sha) -- refusing to install" >&2
+ return 1
+ fi
+
+ mkdir -p "$opt_root" "$bindir"
+ rm -rf "$dest"
+ mkdir -p "$dest"
+ # The upstream tarball unpacks to a single top-level dir
+ # zig-x86_64-linux-<version>/; --strip-components=1 drops its contents
+ # straight into $dest so $dest/zig is the binary.
+ if ! tar -xf "$tarball" -C "$dest" --strip-components=1; then
+ echo "zig pin: extraction failed" >&2
+ rm -rf "$dest"
+ return 1
+ fi
+ if [ ! -x "$dest/zig" ]; then
+ echo "zig pin: extracted tree has no zig binary at $dest/zig" >&2
+ rm -rf "$dest"
+ return 1
+ fi
+ ln -sfn "$dest/zig" "$link"
+}
+
+# install_zig_pin -- download zig $ZIG_VERSION from ziglang.org, verify it, and
+# pin it under /opt via zig_install_from_tarball. The opt root and bindir are
+# env-overridable (ZIG_OPT_ROOT / ZIG_BINDIR) so the verify-and-install core
+# stays testable; defaults are the real /opt and /usr/local/bin.
+install_zig_pin() {
+ local opt_root="${ZIG_OPT_ROOT:-/opt}"
+ local bindir="${ZIG_BINDIR:-/usr/local/bin}"
+ local dest="$opt_root/zig-$ZIG_VERSION"
+ local link="$bindir/zig"
+
+ # Idempotent short-circuit so a resume / re-run does no network I/O.
+ if [ -x "$dest/zig" ] && [ "$(readlink -f "$link" 2>/dev/null)" = "$dest/zig" ]; then
+ display "task" "zig $ZIG_VERSION already pinned at $dest"
+ return 0
+ fi
+
+ action="pinning zig $ZIG_VERSION upstream toolchain (ghostel build dep)" && display "task" "$action"
+
+ local url="https://ziglang.org/download/$ZIG_VERSION/zig-x86_64-linux-$ZIG_VERSION.tar.xz"
+ local tmp tarball
+ tmp="$(mktemp -d)" || { error_warn "$action (mktemp)" "$?"; return 1; }
+ tarball="$tmp/zig.tar.xz"
+
+ if ! curl -fsSL "$url" -o "$tarball" >> "$logfile" 2>&1; then
+ error_warn "$action (download)" "$?"
+ rm -rf "$tmp"
+ return 1
+ fi
+ if ! zig_install_from_tarball "$tarball" "$ZIG_SHA256" "$ZIG_VERSION" "$opt_root" "$bindir" >> "$logfile" 2>&1; then
+ error_warn "$action (verify/install)" "$?"
+ rm -rf "$tmp"
+ return 1
+ fi
+ rm -rf "$tmp"
+}
+
### Developer Workstation
developer_workstation() {
@@ -2091,6 +2189,7 @@ developer_workstation() {
pacman_install imagemagick # image previews for dired/dirvish
pacman_install libgccjit # native compilation for Emacs
pacman_install libvterm # vterm terminal emulator
+ install_zig_pin # zig 0.15.2 pin for ghostel's compile fallback (NOT pacman zig)
pacman_install mediainfo # generating media info in dired/dirvish
pacman_install 7zip # archive info for dirvish
pacman_install mpv # video viewer
diff --git a/assets/outbox/2026-06-15-1825-from-.emacs.d-org-protocol-popup-needs-to-be-larger.org b/assets/outbox/2026-06-15-1825-from-.emacs.d-org-protocol-popup-needs-to-be-larger.org
new file mode 100644
index 0000000..5e43ac3
--- /dev/null
+++ b/assets/outbox/2026-06-15-1825-from-.emacs.d-org-protocol-popup-needs-to-be-larger.org
@@ -0,0 +1,5 @@
+#+TITLE: org-protocol popup needs to be larger — it should be about t
+#+SOURCE: from .emacs.d
+#+DATE: 2026-06-15 18:25:39 -0500
+
+org-protocol popup needs to be larger — it should be about the size of one of the terminal scratchpads to be effective. (Captured via roam inbox, routed from .emacs.d inbox-zero.)
diff --git a/tests/zig-pin/test_zig_pin.py b/tests/zig-pin/test_zig_pin.py
new file mode 100644
index 0000000..f6d87e5
--- /dev/null
+++ b/tests/zig-pin/test_zig_pin.py
@@ -0,0 +1,203 @@
+"""Tests for the zig_install_from_tarball helper in the archsetup installer.
+
+zig_install_from_tarball is the verify-and-install core of the zig 0.15.2 pin:
+given a downloaded tarball it checks the sha256, extracts the tree to
+<opt_root>/zig-<version>/ (stripping the upstream wrapper dir), and symlinks
+<bindir>/zig at the extracted binary. It refuses — extracting nothing — on a
+sha256 mismatch, a missing tarball, or a tree with no zig binary, and it
+short-circuits when a correct install already exists. The network download is
+the thin outer install_zig_pin's job, not this function's, so this is unit
+testable.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), against real temp dirs and real tarballs the
+test builds. The helper is self-contained (prints its own refusal reasons to
+stderr, calls no installer logger), so no stub is needed.
+
+Run from repo root:
+ python3 -m unittest tests.zig-pin.test_zig_pin
+"""
+
+import hashlib
+import os
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+VERSION = "0.15.2"
+
+
+def sha256_of(path):
+ h = hashlib.sha256()
+ with open(path, "rb") as f:
+ for chunk in iter(lambda: f.read(8192), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+class ZigPinHarness(unittest.TestCase):
+ """Source zig_install_from_tarball out of the real archsetup script."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="zig-pin-test-")
+ # Wrapper that extracts just zig_install_from_tarball from the real
+ # installer and invokes it. Sourcing the sed-extracted function means
+ # we test the production code path, not a reimplementation.
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n "
+ "'/^zig_install_from_tarball() {/,/^}/p' \"$ARCHSETUP\")\n"
+ 'zig_install_from_tarball "$@"\n'
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def make_tarball(self, name="tarball.tar.xz", with_zig=True):
+ """Build an upstream-shaped tarball: a single top-level dir
+ zig-x86_64-linux-<version>/ optionally containing an executable `zig`.
+ Returns (tarball_path, sha256_hex)."""
+ builddir = os.path.join(self.tmp, "build")
+ topdir = "zig-x86_64-linux-%s" % VERSION
+ treedir = os.path.join(builddir, topdir)
+ os.makedirs(treedir, exist_ok=True)
+ # a sibling file so we can confirm the whole tree lands, not just zig
+ with open(os.path.join(treedir, "LICENSE"), "w") as f:
+ f.write("MIT")
+ if with_zig:
+ zigbin = os.path.join(treedir, "zig")
+ with open(zigbin, "w") as f:
+ f.write("#!/bin/sh\necho 0.15.2\n")
+ os.chmod(zigbin, 0o755)
+ tarball = os.path.join(self.tmp, name)
+ with tarfile.open(tarball, "w:xz") as tar:
+ tar.add(treedir, arcname=topdir)
+ shutil.rmtree(builddir)
+ return tarball, sha256_of(tarball)
+
+ def run_install(self, tarball, want_sha, opt_root, bindir, version=VERSION):
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP,
+ tarball, want_sha, version, opt_root, bindir],
+ capture_output=True, text=True, timeout=30,
+ )
+
+ def dirs(self):
+ opt_root = os.path.join(self.tmp, "opt")
+ bindir = os.path.join(self.tmp, "bin")
+ return opt_root, bindir
+
+
+# -----------------------------------------------------------------------------
+# Normal cases
+# -----------------------------------------------------------------------------
+
+class TestZigPinNormal(ZigPinHarness):
+
+ def test_valid_tarball_extracts_and_symlinks(self):
+ tarball, sha = self.make_tarball()
+ opt_root, bindir = self.dirs()
+ result = self.run_install(tarball, sha, opt_root, bindir)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ dest = os.path.join(opt_root, "zig-%s" % VERSION)
+ self.assertTrue(os.access(os.path.join(dest, "zig"), os.X_OK),
+ "zig binary should be extracted and executable")
+ self.assertTrue(os.path.isfile(os.path.join(dest, "LICENSE")),
+ "whole tree should land, not just the binary")
+ link = os.path.join(bindir, "zig")
+ self.assertTrue(os.path.islink(link), "bindir/zig should be a symlink")
+ self.assertEqual(os.path.realpath(link), os.path.join(dest, "zig"))
+
+ def test_idempotent_second_run_skips_without_tarball(self):
+ tarball, sha = self.make_tarball()
+ opt_root, bindir = self.dirs()
+ self.assertEqual(self.run_install(tarball, sha, opt_root, bindir).returncode, 0)
+ # Second run: install already correct, so it must short-circuit before
+ # touching the tarball — pass a nonexistent path to prove no re-extract.
+ again = self.run_install(os.path.join(self.tmp, "gone.tar.xz"),
+ sha, opt_root, bindir)
+ self.assertEqual(again.returncode, 0, msg=again.stderr)
+
+
+# -----------------------------------------------------------------------------
+# Boundary cases
+# -----------------------------------------------------------------------------
+
+class TestZigPinBoundary(ZigPinHarness):
+
+ def test_stale_dest_without_link_is_reinstalled(self):
+ # A prior half-install left a dest dir but no/wrong symlink. A fresh
+ # valid run should replace it and create the link.
+ opt_root, bindir = self.dirs()
+ dest = os.path.join(opt_root, "zig-%s" % VERSION)
+ os.makedirs(dest)
+ with open(os.path.join(dest, "stale.txt"), "w") as f:
+ f.write("junk")
+ tarball, sha = self.make_tarball()
+ result = self.run_install(tarball, sha, opt_root, bindir)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ self.assertFalse(os.path.exists(os.path.join(dest, "stale.txt")),
+ "stale contents should be cleared on reinstall")
+ self.assertTrue(os.path.islink(os.path.join(bindir, "zig")))
+
+ def test_existing_link_repointed_to_new_version(self):
+ # bindir/zig already points somewhere else; install should repoint it.
+ opt_root, bindir = self.dirs()
+ os.makedirs(bindir)
+ os.symlink("/usr/bin/zig", os.path.join(bindir, "zig"))
+ tarball, sha = self.make_tarball()
+ result = self.run_install(tarball, sha, opt_root, bindir)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ dest = os.path.join(opt_root, "zig-%s" % VERSION)
+ self.assertEqual(os.path.realpath(os.path.join(bindir, "zig")),
+ os.path.join(dest, "zig"))
+
+
+# -----------------------------------------------------------------------------
+# Error cases
+# -----------------------------------------------------------------------------
+
+class TestZigPinErrors(ZigPinHarness):
+
+ def test_sha256_mismatch_refuses_and_installs_nothing(self):
+ tarball, _ = self.make_tarball()
+ opt_root, bindir = self.dirs()
+ bad = "0" * 64
+ result = self.run_install(tarball, bad, opt_root, bindir)
+ self.assertNotEqual(result.returncode, 0,
+ "a sha256 mismatch must fail")
+ self.assertFalse(os.path.exists(os.path.join(opt_root, "zig-%s" % VERSION)),
+ "no tree may be extracted on a sha mismatch")
+ self.assertFalse(os.path.exists(os.path.join(bindir, "zig")),
+ "no symlink may be created on a sha mismatch")
+
+ def test_missing_tarball_refuses(self):
+ opt_root, bindir = self.dirs()
+ tarball, sha = self.make_tarball()
+ os.remove(tarball)
+ result = self.run_install(tarball, sha, opt_root, bindir)
+ self.assertNotEqual(result.returncode, 0)
+
+ def test_tarball_without_zig_binary_refuses_and_cleans_up(self):
+ tarball, sha = self.make_tarball(with_zig=False)
+ opt_root, bindir = self.dirs()
+ result = self.run_install(tarball, sha, opt_root, bindir)
+ self.assertNotEqual(result.returncode, 0,
+ "a tree with no zig binary must fail")
+ self.assertFalse(os.path.exists(os.path.join(opt_root, "zig-%s" % VERSION)),
+ "the bad extracted tree should be cleaned up")
+ self.assertFalse(os.path.exists(os.path.join(bindir, "zig")),
+ "no symlink should be left behind")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/todo.org b/todo.org
index b8efe35..196b882 100644
--- a/todo.org
+++ b/todo.org
@@ -12,6 +12,14 @@ Four levels, matching the Emacs config (=org-highest-priority ?A=, =org-lowest-p
- [#D] Default / unsorted. A bare TODO with no cookie is D. Stays out of the agenda — the inbox of priorities. Triage D's up to A/B/C or let them sit.
Rule of thumb: A = dated-and-must; B = the active backlog; C = parking lot; D = untriaged. Fixing the undated A/B tasks means either dating them or demoting to C.
+
+** Tags
+
+The vocabulary is open — topic tags are coined as needed — so these are conventions, not a closed set. A task carries at most one type tag, optionally the effort/autonomy tags, and any number of topic tags. Because the set is open, the task audit leaves topic tags alone (it doesn't strip "unknown" tags).
+
+- *Type* (one per task where the kind is clear): =:feature:= new capability, =:bug:= fix for broken behavior, =:test:= test coverage or test infra, =:refactor:= restructure with no behavior change, =:chore:= tooling / meta / housekeeping.
+- *Effort / autonomy*: =:quick:= a spare-moment fix (minutes, not a sitting); =:solo:= Claude can carry it end to end — there's a build path, a test path, and no upfront decision needed (a leftover manual spot-check doesn't disqualify it).
+- *Topic / area* (open): the subsystem a task touches — e.g. =:hyprland:= =:waybar:= =:mpd:= =:music:= =:network:= =:tooling:= =:llm:= =:eask:= =:pocketbook:= =:cmail:=. Coin a new one when it aids filtering.
* Archsetup Open Work
** TODO [#B] Scrolling layout: frame fit + wrap-around :hyprland:
:PROPERTIES:
@@ -97,10 +105,9 @@ Implementation notes (to flesh out when picked up): waybar =custom= module(s) wi
:END:
Let either side of the waybar collapse horizontally to a minimal base set, toggled by a click. Each collapsible side carries a small triangle / arrowhead pointing toward the screen edge it collapses into (away from center). Clicking it collapses that side to its base set and flips the arrow to point back toward center; clicking again restores the full side. Same shape-changes-with-state idea as the auto-dim indicator.
-- *Right-side base set* (proposed): the date/time, optionally plus the systray. Everything else on the right (sysmonitor group, netspeed, pulseaudio, the toggles) hides.
-- *Left-side base set*: TBD (workspaces only, or menu + workspaces).
+Spec ready (2026-06-19): [[file:working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org]]. Spike settled the mechanism: [[file:working/collapsible-waybar-sides/spike-findings.org]].
-Implementation notes: waybar has no native per-side collapse, so this is custom. Options to explore: (a) swap between a full and a collapsed waybar config on click via a signal/exec, (b) rewrite the modules array and reload (heavy), (c) a state file the modules read to hide/show a group via CSS. Likely a state file (=$XDG_RUNTIME_DIR=) + per-side toggle scripts + a targeted waybar refresh, mirroring the existing custom-module + signal pattern. Lives in the dotfiles repo (=hyprland/.config/waybar/= + =hyprland/.local/bin/=). TDD the toggle scripts per the dotfiles suite.
+Decisions locked: right base set = date + worldclock + tray; left base set = menu + workspaces; per-side independent; host-agnostic (base set constant, full set is each host's existing config). Mechanism = config-swap + SIGUSR2 reload via an active-config copy in =$XDG_RUNTIME_DIR= (the CSS/state-file approach was disproven — GTK3 can't reflow-hide native modules). Lives in =~/.dotfiles/hyprland/=. Next: implement per the spec (TDD the toggle + arrow scripts).
** TODO [#B] Network-manager dropdown, nmcli-backed with GPG-stored secrets :waybar:network:
:PROPERTIES:
@@ -197,6 +204,8 @@ Acceptance: fresh VM install of the ratio profile reaches an endpoint on =:8081=
:LAST_REVIEWED: 2026-06-09
:END:
Remove personal info, credentials, and code quality issues before publishing.
+*** 2026-06-16 Tue @ 00:55:39 -0500 Six dotfiles-scoped sub-tasks moved to the ~/.dotfiles project
+Per the 2026-06-16 task audit, the six sub-tasks targeting files now owned by the standalone =~/.dotfiles= repo were handed off to that project (newly bootstrapped as its own AI project) and removed from this epic: "Remove credentials and secrets from dotfiles", "Remove/template personal info from dotfiles", "Remove binary font files from repo", "Move battery out of waybar sysmonitor group", "Resolution-adaptive scratchpad sizing", and "Dynamic waybar/foot config based on screen resolution". Handoff: =~/.dotfiles/inbox/2026-06-16-0053-from-archsetup-dotfiles-release-prep-handoff.org=. This epic now covers archsetup-proper release work only (scripts personal-info, device-specific config, history scrub, shellcheck, SPDX headers, README/LICENSE). The 2026-06-09 reconciliation note below is the prior state.
*** 2026-06-09 Tue @ 19:21:36 -0500 Reconciliation: six sub-tasks now target the ~/.dotfiles repo, not archsetup
Phase 3.2 removed the in-repo =dotfiles/= tree, so six sub-tasks below no longer describe archsetup content — they target files now owned by the =~/.dotfiles= repo (=git.cjennings.net/dotfiles.git=): "Remove credentials and secrets from dotfiles", "Remove/template personal info from dotfiles", "Remove binary font files from repo", "Move battery out of waybar sysmonitor group", "Resolution-adaptive scratchpad sizing", and "Dynamic waybar/foot config based on screen resolution". Their paths are relative to that repo now. Kept here for tracking per Craig (2026-06-09); he'll re-scope the archsetup-vs-dotfiles split shortly. archsetup-proper release work (scripts personal-info, device-specific config, shellcheck, and scrubbing the pre-=b10cba5= dotfiles secrets from archsetup's own history) stays this task.
*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Open-source-prep source audit
@@ -220,14 +229,6 @@ Checked each subtask below against the source / git state. Bottom line: almost n
- *Standardize boolean comparison style* — NOT DONE. Mixed: =[ "$var" = "true" ]= at =archsetup:542,544,569= vs bare =if $var;= form ~7 places elsewhere.
- *Replace eval with safer alternatives* — NOT DONE. =archsetup:442= still =if eval "$cmd" >> "$logfile" 2>&1;= in =retry_install=.
-*** TODO [#A] Remove credentials and secrets from dotfiles :quick:
-- =.config/.tidal-dl.token.json= — active Tidal API token with userId
-- =.config/calibre/smtp.py.json= — hex-encoded relay password, personal email mappings (family Kindle accounts)
-- =.config/transmission/settings.json= — bcrypt-hashed RPC password
-- =.msmtprc= — mail server credentials (gpg password references)
-- =.mbsyncrc= — ProtonBridge IMAP credentials
-Add all to =.gitignore=, remove from git tracking, create =.example= templates where appropriate.
-
*** TODO [#A] Rotate exposed calendar feed URLs
Needs the ratio GUI (browser-based regeneration), so deferred until I'm in front of ratio. Three private ical URLs sat in git history (commit =500b1f5=, 2026-05-13) until the 2026-05-20 scrub. The scrub removed them from local + remote history, but anyone who pulled the repo between those dates still has the tokens, so regenerate all three:
- Google personal (=craigmartinjennings@gmail.com= private ical URL)
@@ -241,17 +242,9 @@ After regenerating, update the live =~/.emacs.d/calendar-sync.local.el= (now own
*** TODO [#A] Remove/template personal information from scripts
- =archsetup= lines 2-3: personal email and website in header
- =archsetup= lines 141-146: hardcoded =git.cjennings.net= repository URLs — make configurable via conf
-- =scripts/post-install.sh=: personal git repos (finances, documents, danneel-*, nextjob, etc.)
-- =scripts/gitrepos.sh=: personal server URLs
+- =scripts/post-install.sh=: personal git repos and server URLs (the old =scripts/gitrepos.sh= was consolidated into this script in =dae7659=, so its personal =git.cjennings.net= clone targets now live here)
- =init= line 8: hardcoded password =welcome=
-*** TODO [#A] Remove/template personal info from dotfiles
-- =.gitconfig=: hardcoded name, email, GitHub username
-- =.config/musicpd.conf=: hardcoded =~cjennings/= paths (use =~/= instead)
-- =.ssh/config=: personal host configuration
-- =.config/yt-dlp/config=: personal domain reference
-- =hyprland.conf= line 3: personal attribution
-
*** TODO [#A] Scrub git history of secrets (or start fresh)
Even after removing files, secrets remain in git history.
Options: =git filter-repo= to rewrite history, or start a fresh repo for the GitHub remote.
@@ -274,11 +267,6 @@ GPL-3 chosen. Canonical GPLv3 text landed at =LICENSE= on 2026-05-11 (commit =f8
*** TODO [#A] Add SPDX/license headers to source files :quick:
For a real GPL-3 release on GitHub, every source file should carry an SPDX-License-Identifier header (or the repo should ship a NOTICE file naming the license + contributors). Not blocking — the =LICENSE= file at root is what GitHub needs. Worth doing once the credentials-cleanup work for open-sourcing actually ships. Light lift: pick a header template, sweep the install script + scripts/ + dotfile scripts.
-*** TODO [#A] Remove binary font files from repo :quick:
-PragmataPro and Apple Color Emoji fonts in =dotfiles/common/.local/share/fonts/=.
-Add to =.gitignore=, document font installation separately.
-May have licensing issues for redistribution.
-
*** 2026-06-09 Tue @ 19:21:36 -0500 Made claude-code install optional
Shipped in =f2dad22= (feat: make the claude-code install optional). The =curl | sh= from claude.ai now sits behind a config flag instead of running unconditionally.
@@ -291,19 +279,6 @@ Added two pre-flight validators to =archsetup= (right after =load_config=, befor
- =validate_config()= — runs whenever =--config-file= is used: rejects unknown =DESKTOP_ENV= (must be dwm/hyprland/none) early instead of dying in step 7-9; rejects =AUTOLOGIN=/=NO_GPU_DRIVERS= values that aren't =yes=/=no= (currently silently ignored); basic shape check on =LOCALE=; and a scheme + no-whitespace/no-leading-dash check on the six =*_REPO= URLs that get passed to =git clone= (rejects e.g. =--upload-pack=…= injection). Plain =echo …>&2; exit 1= (the logging helpers aren't defined that early). =$source_dir= needs no separate check — it's =/home/$username/.local/src=, derived from the now-always-validated =$username=.
Not a security boundary (=load_config= sources the config as bash; a hostile config can already run anything) — it's typo-catching. Verified with =bash -n= and a smoke-test matrix of good/bad inputs through both functions. The next =make test= run confirms valid configs still install. Leaving as DOING for review.
-*** TODO [#A] Move battery out of waybar sysmonitor group :quick:
-Battery module is inside =group/sysmonitor= which bundles cpu, temp, memory, disk, and battery together. Battery should be a standalone module in =modules-right= so it's visible on laptops without the full sysmonitor group.
-
-*** TODO [#A] Resolution-adaptive scratchpad sizing
-Pyprland scratchpad percentages (50% wide, 70% tall) look good on 3440x1440 but tall/narrow on 2256x1504 laptops. Currently using local config overrides per machine. Options:
-- Hyprland windowrulev2 size/move rules in conf.d (cleanest — reuses existing per-machine pattern)
-- Launcher script that generates config.toml based on detected resolution
-- Hostname-based symlink swap at login
-- Fixed pixel sizes (pyprland clamps to screen bounds)
-
-*** TODO [#A] Dynamic waybar/foot config based on screen resolution
-Resolution-aware font sizes and conditional module inclusion. A startup script detects resolution and generates waybar CSS and foot config with appropriate values, so both machines use the same stowed templates.
-
*** 2026-05-20 Wed @ 06:50:25 -0500 Swept shellcheck across the shell scripts
Census across the 16 shell scripts (=archsetup=, =init=, =scripts/*.sh=, =scripts/testing/=): 124 findings, zero errors. Triaged against "what matters for public review" and confirmed the 2026-01-24 read — most are intentional or documented-acceptable:
- SC2024 (14, sudo redirects), SC2174 (16, =mkdir -p -m=), SC1091 (13, unfollowable sources), SC2329 (32, functions invoked indirectly via the =STEPS= dispatch array), SC2153 (1, =DISK_PATH= sourced from =vm-utils.sh=) — all false positives or accepted.
@@ -410,8 +385,10 @@ Core automation infrastructure - enables continuous validation
** TODO [#B] Fix install errors surfaced by the 2026-05-11 VM test run
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-13
+:LAST_REVIEWED: 2026-06-15
:END:
+*** 2026-06-15 Mon @ 23:53:21 -0500 Audit reconcile: latest VM run (2026-06-11) confirms the surviving error set
+The most recent VM run (=test-results/20260611-113904/=) carries four error-summary entries: =enabling firewall= + =verifying firewall is active= (the iptables/nf_tables "Could not fetch rule set generation id" pair, still unconfirmed on bare metal), =enabling gamemode for user= (non-critical), and =tidaler (AUR)=. The earlier fontconfig/dconf fixes held — none reappear. So the count is down from the 7→6 anchor below to four, all of them the known-residual items already itemized.
Errors logged during the VM install. Status as of the 2026-05-11 18:36 run (=test-results/20260511-183643/archsetup-output.log=) after the =48c9439= fontconfig/dconf fix: 7 → 6.
- refreshing font cache — RESOLVED in =48c9439= (now installs =fontconfig= before calling =fc-cache=).
- configuring GTK file chooser — RESOLVED in =ecab29f= (switched to a system-wide dconf db at =/etc/dconf/db/site.d/=; needs no session bus during install).
@@ -437,16 +414,6 @@ Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; t
:END:
Auto-create post-install fix scripts for failed packages - makes failures actionable
-** DONE [#B] Create package inventory system
-CLOSED: [2026-06-14 Sun]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-13
-:END:
-Satisfied by =scripts/package-inventory= (the same script that closes "Automate the inventory comparison" above). It lists archsetup's declared packages, lists the live system's packages, and prints the diff in both directions. Design note: it compares explicit-vs-explicit (=pacman -Qqe= against declared =pacman_install=/=aur_install=), which is the meaningful comparison — the original "including dependencies" framing was superseded, since transitive deps are pulled automatically and listing full closures would only add noise.
-*** 2026-06-14 Sun @ 22:13:48 -0500 Listed archsetup's declared packages — package-inventory extraction (pacman_install/aur_install + for-loop lists)
-*** 2026-06-14 Sun @ 22:13:48 -0500 Listed live-system packages — package-inventory via pacman -Qqe / -Qq / -Qqen / -Qqem
-*** 2026-06-14 Sun @ 22:13:48 -0500 Generated archsetup-vs-system diff — package-inventory, both directions, AUR/official split
-
** TODO [#B] Review undeclared ratio packages for installer inclusion
Triggered by the 2026-06-14 =make package-diff= run on ratio: 62 packages are installed but not declared in archsetup. Stripped of the structural buckets — pacstrap base/boot/kernel (base, linux*, grub, efibootmgr, sudo, btrfs-progs, fwupd, logrotate, ex-vi-compat, linux-lts-strix, zram-generator), the =make deps= VM set (qemu-full, virt-manager, virt-viewer, libguestfs, bridge-utils, dnsmasq, archiso), and the yay bootstrap — these 40 remain. Check the ones to add to the installer, then rerun =make package-diff= to confirm they clear.
@@ -502,15 +469,6 @@ The diff engine now exists (=scripts/package-inventory= / =make package-diff=),
*** TODO [#A] For packages on system but not in archsetup: decide add or remove
*** TODO [#A] Schedule monthly package diff review
-** DONE [#B] Automate the inventory comparison :test:solo:
-CLOSED: [2026-06-14 Sun]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-13
-:END:
-Make package diff a runnable script instead of manual process
-
-Resolved 2026-06-14: the runnable script already existed — =scripts/package-inventory= (built 2026-02-06) extracts archsetup's declared packages and diffs them against the live system (=--summary= / =--archsetup-only= / =--system-only= / full report). This pass added the missing coverage: 7 characterization tests in =tests/package-inventory/= pinning the extraction and both diff directions behind injectable =PKGINV_ARCHSETUP= / =PKGINV_PACMAN= seams, plus a =make package-diff= target for discoverability. Full unit suite green (26 tests, 3 suites).
-
** TODO [#B] Complete security education within 3 months
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
@@ -795,6 +753,23 @@ Expected: near-black frame (#151311), dark toolbar/omnibox (#252321), gold links
*** 2026-06-10 Wed @ 17:46:34 -0500 velox post-trim reboot verified; realtek firmware restored
Craig rebooted velox (passphrase at console); checks ran over SSH after boot. Wifi connected, TLP active, graphics fine. One dmesg hit: r8152 failed to load rtl_nic/rtl8156b-2.fw — the Framework Ethernet expansion card (RTL8156B) is Realtek, so the trim list wrongly dropped linux-firmware-realtek (a Realtek laptop camera is on USB too). Reinstalled the package on velox (its hook rebuilt the initramfs) and removed realtek from archsetup's trim list. The driver worked even without the blob (internal-defaults fallback), so this was correctness, not breakage.
+** TODO [#B] Enlarge org-capture popup to scratchpad size :hyprland:
+From a .emacs.d inbox handoff (2026-06-15, captured via roam): the quick-capture / org-protocol popup is too small to be effective — it should be about the size of a terminal scratchpad.
+
+*** 2026-06-15 Mon @ 19:19:55 -0500 AI Response: popup size is the frame's char-cell count, not the Hyprland rule
+Triaged under auto inbox-zero. The popup is the emacsclient frame named "org-capture", created by =~/.dotfiles/hyprland/.local/bin/quick-capture= with =(width . 90) (height . 22)= — 90 columns by 22 lines. Emacs sizes by character cells and overrides the Hyprland rule =windowrule = match:title ^(org-capture)$, size 900 500= (hyprland.conf:182). The live frame measured ~889x860 px; the width tracks the 90-column count, not the window rule. Setting the Hyprland rule to =size 55% 65%= (the scratchpad's pyprland spec) did not change the frame width, so I reverted it — dotfiles left clean.
+
+Real lever: the column/line count in the quick-capture script. Scratchpad reference on ratio (DP-4, 3440x1440) is 55% 65% ~= 1892x936 px ~= 190 cols by 24 lines. Why this isn't a solo auto-fix — it needs a tradeoff decision:
+- The script lives in the shared =hyprland/= stow tier, so a fixed ~190 columns overflows velox's 1920-wide laptop, and 24+ lines overflows velox's 1080 height (22 lines ~= 860 px is already near the safe max there).
+- Emacs char-cell sizing doesn't adapt to the monitor the way pyprland's percentage does, so "scratchpad-size on both machines" needs one of: a fixed compromise count, a per-host override via the ratio/velox tiers, or a script that computes columns from the active monitor.
+Options to weigh: (a) a safe-on-both compromise like width 120-130 / height 24; (b) per-host width through the ratio/velox tiers; (c) dynamic sizing in quick-capture from =hyprctl monitors=. Pick the tradeoff and I'll implement.
+
+** TODO [#C] archsetup Waybar Wi-Fi module should show no-internet state :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-13
+:END:
+From the roam inbox: the Waybar Wi-Fi module should distinguish "connected to an access point" from "connected and has internet." Add a no-internet state or indicator to the archsetup Waybar configuration. Not marked quick/solo because it needs the archsetup environment and live network-state verification.
+
* Archsetup Resolved
** DONE [#B] Full install logs should contain timestamps
@@ -1240,7 +1215,7 @@ CLOSED: [2026-06-13 Sat] SCHEDULED: <2026-06-12 Fri>
Resolved: .emacs.d fixed it config-side (single-window display + cj/quick-capture command); archsetup pointed the popup script at cj/quick-capture (8cc1be7). Verified end-to-end on ratio.
The quick-capture popup opens split in two windows — a top sliver of the daemon's last-visited buffer plus the =*Org Select*= menu below — so the two stacked modelines read like tmux status bars. Root cause: =org-mks= displays the template menu via =org-switch-to-buffer-other-window=, splitting the fresh popup frame instead of taking it over.
-Coordinating with the .emacs.d project: handoff sent 2026-06-12 18:59 requesting a config-side fix scoped to frames named =org-capture= ([[file:~/.emacs.d/inbox/2026-06-12-1859-from-archsetup-org-capture-popup-frame-split.org][handoff note]], [[file:~/.emacs.d/inbox/2026-06-12-1859-from-archsetup-popup-crop.png][screenshot evidence]]). Waiting on its reply in this project's inbox; then verify the popup end-to-end on ratio (Super+Shift+N → single-window menu → single-window capture buffer). Fallback if .emacs.d declines: carry the fix in the dotfiles =quick-capture= script's =-e= elisp.
+Coordinating with the .emacs.d project: handoff sent 2026-06-12 18:59 requesting a config-side fix scoped to frames named =org-capture= (handoff note + screenshot evidence delivered to .emacs.d's inbox, since processed and removed). Waiting on its reply in this project's inbox; then verify the popup end-to-end on ratio (Super+Shift+N → single-window menu → single-window capture buffer). Fallback if .emacs.d declines: carry the fix in the dotfiles =quick-capture= script's =-e= elisp.
Related finding, no change needed: whole-desktop screenshot already exists at CTRL+Super+S (=screenshot fullscreen=, grim fires before the fuzzel menu so popups survive). Possible follow-up decision: rebind Super+Shift+S (currently layout-switch to scrolling) if Craig wants fullscreen capture there.
@@ -1252,3 +1227,20 @@ CLOSED: [2026-06-11 Thu]
:LAST_REVIEWED: 2026-06-11
:END:
Shipped 2026-06-11 as dotfiles =a4ae4a4=, minutes after filing: =--silent= on all four of mic-toggle's notify calls (Muted/Live/unknown/fail), tests assert the flag on every path (5/5, full suite 15 suites green), and a live round-trip on ratio confirmed the toggle works with the toast and without the chime. velox picks it up on next pull.
+** DONE [#B] Create package inventory system
+CLOSED: [2026-06-14 Sun]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-13
+:END:
+Satisfied by =scripts/package-inventory= (the same script that closes "Automate the inventory comparison" above). It lists archsetup's declared packages, lists the live system's packages, and prints the diff in both directions. Design note: it compares explicit-vs-explicit (=pacman -Qqe= against declared =pacman_install=/=aur_install=), which is the meaningful comparison — the original "including dependencies" framing was superseded, since transitive deps are pulled automatically and listing full closures would only add noise.
+*** 2026-06-14 Sun @ 22:13:48 -0500 Listed archsetup's declared packages — package-inventory extraction (pacman_install/aur_install + for-loop lists)
+*** 2026-06-14 Sun @ 22:13:48 -0500 Listed live-system packages — package-inventory via pacman -Qqe / -Qq / -Qqen / -Qqem
+*** 2026-06-14 Sun @ 22:13:48 -0500 Generated archsetup-vs-system diff — package-inventory, both directions, AUR/official split
+** DONE [#B] Automate the inventory comparison :test:solo:
+CLOSED: [2026-06-14 Sun]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-13
+:END:
+Make package diff a runnable script instead of manual process
+
+Resolved 2026-06-14: the runnable script already existed — =scripts/package-inventory= (built 2026-02-06) extracts archsetup's declared packages and diffs them against the live system (=--summary= / =--archsetup-only= / =--system-only= / full report). This pass added the missing coverage: 7 characterization tests in =tests/package-inventory/= pinning the extraction and both diff directions behind injectable =PKGINV_ARCHSETUP= / =PKGINV_PACMAN= seams, plus a =make package-diff= target for discoverability. Full unit suite green (26 tests, 3 suites).
diff --git a/working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org b/working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org
new file mode 100644
index 0000000..b9ddc0d
--- /dev/null
+++ b/working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org
@@ -0,0 +1,120 @@
+#+TITLE: Collapsible waybar sides — implementation spec
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-19
+
+* Goal
+Let each side of the waybar collapse to a minimal base set with a click, and
+expand again with another click. Each side carries a small arrowhead that points
+toward the screen edge when expanded (click to collapse outward) and flips to
+point toward center when collapsed (click to expand). Left and right collapse
+independently.
+
+This is dotfiles work (=~/.dotfiles=, =hyprland/= tier). Tracked by the
+=Collapsible waybar sides= task in archsetup =todo.org=.
+
+* Decisions (locked 2026-06-19)
+- *Mechanism*: config-swap + =killall -SIGUSR2 waybar=. NOT state-file + CSS —
+ the spike proved CSS can't collapse native modules (they go invisible but hold
+ their space; GTK3 has no =display:none=). See [[file:spike-findings.org]].
+- *Right base set*: =custom/date=, =custom/worldclock=, =tray= (plus the right
+ arrow). Tray reflows cleanly and survives the reload (spike-confirmed).
+- *Left base set*: =custom/menu=, =hyprland/workspaces= (plus the left arrow).
+- *Per-side*: left and right toggle independently, each with its own arrow.
+- *Per-host*: host-agnostic. The base set is constant; the full set is whatever
+ each host's config already defines. ratio (no battery/touchpad/airplane) needs
+ no special-casing — collapse hides whatever modules that host has. Build once.
+
+* Architecture
+
+** The active-config indirection (the core piece)
+=~/.config/waybar/config= is a stow symlink into the dotfiles, so the toggle
+can't rewrite it in place (that would edit the repo). Instead:
+
+1. *Canonical* config: the committed dotfiles config. Always holds the FULL
+ module arrays. Read-only source of truth. Unchanged by this feature except
+ for adding the two arrow modules and their definitions.
+2. *Active* config: a generated copy at =$XDG_RUNTIME_DIR/waybar/config=. This is
+ what waybar loads (=waybar -c=). The toggle rewrites its =modules-left= /
+ =modules-right= between full and base.
+3. *Launch change* (hyprland.conf exec-once): before launching waybar, generate
+ the active config from the canonical (initial state = expanded/full), then
+ =waybar -c "$XDG_RUNTIME_DIR/waybar/config" -s <style>=.
+
+The style.css stays shared (canonical, stowed) — only the config (module arrays)
+needs the runtime copy.
+
+** Toggle scripts
+=waybar-collapse <side>= where side ∈ {left, right}:
+1. Read per-side state from =$XDG_RUNTIME_DIR/waybar/<side>.state= (expanded |
+ collapsed; absent = expanded).
+2. Flip it.
+3. Regenerate the active config's =modules-<side>= array:
+ - expanded → the canonical full array for that side.
+ - collapsed → the base set for that side (constant in the script) with the
+ arrow module included.
+4. Write the new state file.
+5. =killall -SIGUSR2 waybar=.
+
+The full array is read from the canonical config each time, so the script never
+loses it and stays correct as modules are added/removed upstream. The base set is
+the only constant the script hardcodes (or reads from a tiny sidecar).
+
+** Arrow modules
+Two custom modules, =custom/arrow-left= and =custom/arrow-right=, each an exec
+script (=waybar-arrow left= / =waybar-arrow right=) that:
+- Reads the side's state file.
+- Emits the glyph: expanded → points outward (left side ◀ toward left edge,
+ right side ▶ toward right edge); collapsed → points inward (left ▶, right ◀).
+- =on-click= runs =waybar-collapse <side>=.
+
+The arrow is always in the base set (it's the expand control), so it's present in
+both states. Place the left arrow as the LAST module in =modules-left= (innermost,
+nearest center) and the right arrow as the FIRST module in =modules-right=
+(innermost), so each arrow sits at the inner edge of its side and the collapse
+pulls outward away from it. (Confirm placement during implementation — the glyph
+direction and module order must agree so the arrow visually points the right way.)
+
+** State + reload
+- State dir: =$XDG_RUNTIME_DIR/waybar/= (per-boot, ephemeral — collapse state
+ resets on logout, which is fine).
+- Reload is =SIGUSR2= (full waybar reload). Cost: brief flicker, module state
+ resets, tray re-registers. Acceptable for a click action; spike confirmed tray
+ survives. This cost is per-toggle, never idle.
+
+* Files (all in =~/.dotfiles/hyprland/=)
+- =.local/bin/waybar-collapse= — the toggle (reads canonical, writes active,
+ signals). New.
+- =.local/bin/waybar-arrow= — the arrow module exec (state → glyph + class). New.
+- =.local/bin/waybar-active-config= — generates the active config from canonical
+ at login (used by exec-once and reused by waybar-collapse to resolve the full
+ arrays). New. (Or fold generation into waybar-collapse + a one-shot init call.)
+- =.config/waybar/config= — add =custom/arrow-left= / =custom/arrow-right= module
+ defs + place them in the arrays. Edit.
+- =.config/hypr/hyprland.conf= — exec-once: generate active config, then
+ =waybar -c "$XDG_RUNTIME_DIR/waybar/config"=. Edit.
+- Optional keybinds: =$mod+[= / =$mod+]= to collapse left/right without the mouse.
+
+* TDD plan (per the dotfiles suite)
+- =tests/waybar-collapse/=: full↔base array rewrite against a fixture canonical
+ config; expanded→collapsed→expanded round-trips to the original arrays; state
+ file flips; base set always includes the arrow; SIGUSR2 sent (fake killall).
+ Use a fake canonical config + temp =$XDG_RUNTIME_DIR=, fake killall on PATH
+ (same harness style as tests/waybar-toggle).
+- =tests/waybar-arrow/=: state → correct glyph + class for each side and state;
+ missing state file = expanded glyph (fail-safe).
+- JSON validity of the generated active config (parse it back).
+
+* Open / to-confirm during implementation
+- Exact arrow glyphs (nerd-font triangles) and that order-vs-direction agree.
+- Whether to keep =hyprland/window= (the title) out of the left base set — it's
+ long and variable-width; collapsing the left should drop it (it's not in the
+ base set, so it hides — correct).
+- Animation: none (waybar doesn't animate width; the collapse snaps). Accepted.
+
+* Risks
+- Reload flicker on every toggle. Mitigation: none needed unless it annoys in use.
+- If a future module is added to the canonical config, it lands in the full set
+ automatically (good) but the author should decide if it belongs in a base set.
+- $XDG_RUNTIME_DIR active config must exist before waybar starts; the exec-once
+ ordering must generate it first. waybar-toggle (the crash-relaunch path, mod+B)
+ must also point at the active config, not the canonical — update it to match.
diff --git a/working/collapsible-waybar-sides/spike-findings.org b/working/collapsible-waybar-sides/spike-findings.org
new file mode 100644
index 0000000..4d45ed1
--- /dev/null
+++ b/working/collapsible-waybar-sides/spike-findings.org
@@ -0,0 +1,42 @@
+#+TITLE: Collapsible waybar — spike findings (mechanism)
+#+DATE: 2026-06-18
+
+* Question
+Which mechanism actually collapses a waybar side to a base set, given the right
+side is a mix of native modules (group/sysmonitor, pulseaudio, pulseaudio#mic,
+idle_inhibitor, tray) and custom exec modules?
+
+* Method
+Two transient waybar instances against /tmp copies of the live config, captured
+with grim (live bar briefly down, restored after). Variants in this dir:
+- spike-style-csshide.css : option (c) — CSS-hide the native modules
+ (min-width:0; padding:0; margin:0; opacity:0) on #sysmonitor #pulseaudio
+ #idle_inhibitor.
+- spike-config-collapsed.json : option (b) — modules-right rewritten to the base
+ set [tray, custom/date, custom/worldclock].
+
+* Result
+- *CSS-hide (option c): FAILS.* sysmonitor and pulseaudio rendered invisible but
+ held their space — a gap remained where they were, no reflow. GTK3 has no
+ =display:none=, and opacity/zero-size leaves the label's intrinsic width. The
+ right side ends up ragged and half-collapsed, not narrowed. Not viable for the
+ native modules.
+- *Config-swap (option b): WORKS.* The collapsed config reflowed the right side
+ tight to tray + date, everything else fully gone, no gaps. Hides native and
+ custom modules alike. Tray icons survived the swap.
+
+* Decision
+Mechanism is config-swap + =killall -SIGUSR2 waybar= (reload), NOT the state-file
++ CSS approach the original task leaned toward. The original "heavy" label on
+option (b) is the cost of a full reload (brief flicker, module state resets, tray
+re-registers) — acceptable, and the only approach that actually collapses a mixed
+module set.
+
+* Implications for the spec
+- Don't maintain two static configs (drift-prone). A toggle script rewrites the
+ active config's modules-left / modules-right between the full set and the base
+ set, then SIGUSR2. Base sets defined once; collapsed set is the base set, full
+ set is restored from the canonical module list.
+- Per-side state in $XDG_RUNTIME_DIR; the arrow module reads it to pick its
+ direction. Arrow lives IN the base set (always visible, it's the expand control).
+- Reload cost is per-toggle, not idle — fine for a click action.