aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xarchsetup99
-rw-r--r--tests/zig-pin/test_zig_pin.py203
-rw-r--r--todo.org5
-rw-r--r--working/collapsible-waybar-sides/collapsible-waybar-sides-spec.org120
-rw-r--r--working/collapsible-waybar-sides/spike-findings.org42
5 files changed, 466 insertions, 3 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/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 a71fd3c..196b882 100644
--- a/todo.org
+++ b/todo.org
@@ -105,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:
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.