aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
authordanix <danix@danix.xyz>2026-06-11 09:34:20 +0200
committerdanix <danix@danix.xyz>2026-06-11 09:34:20 +0200
commitbb61e5cc1cd36a161d1382735bbf6dacab6b3206 (patch)
tree9132cf4e1dff48f52aaaa6874b1a2d28630858b2 /docs/superpowers
parent7603c19a42b93035f1c17c5a854ce5b7ee92af93 (diff)
downloadwallp-bb61e5cc1cd36a161d1382735bbf6dacab6b3206.tar.gz
wallp-bb61e5cc1cd36a161d1382735bbf6dacab6b3206.zip
docs: wallp merge implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-06-11-wallp-merge.md1166
1 files changed, 1166 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-06-11-wallp-merge.md b/docs/superpowers/plans/2026-06-11-wallp-merge.md
new file mode 100644
index 0000000..3b17c88
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-11-wallp-merge.md
@@ -0,0 +1,1166 @@
+# wallp Merge Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Merge `multiwal.sh` (interactive setter) and `qar-lastwall.sh` (restorer) into one flag-driven bash script `wallp` with per-output partial updates, theme persistence, and a config file.
+
+**Architecture:** Single bash script `wallp`. Pure logic (conf parse, arg parse, `~` expansion, output mapping, theme resolution) lives in sourceable functions. Side-effecting actions (swaybg, wal, qarma, notify-send, filesystem writes) are isolated in thin functions. The script runs `main "$@"` only when executed directly, so a plain-bash test harness can source it and unit-test the pure functions with PATH stubs and a temp `$HOME`. Persistence is wallp-owned under `~/.config/wallp/`; a `~/.cache/wal/wpaper` symlink is maintained for rofi.
+
+**Tech Stack:** bash, swaybg, pywal (`wal`), qarma, notify-send. Tests: plain bash harness (no bats/shellcheck — not installed on this Slackware host), PATH stubs, temp `$HOME`.
+
+---
+
+## Conventions
+
+- All tests live in `tests/`. The harness `tests/run.sh` sources `wallp`, sets `HOME` to a temp dir, prepends a stub dir to `PATH`, and runs `test_*` functions, printing PASS/FAIL and exiting non-zero on any failure.
+- `wallp` ends with:
+ ```bash
+ if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi
+ ```
+ so sourcing it for tests does not run `main`.
+- Every function reads paths via variables seeded from `$HOME` at call time (no hardcoded `/home/danix`), so tests relocating `$HOME` work.
+- Commit after each task.
+
+## File Structure
+
+- Create: `wallp` — the merged script (all logic + `main`).
+- Create: `wallp.conf.example` — shipped example config.
+- Create: `tests/run.sh` — bash test harness + assertions, sources `wallp`.
+- Create: `tests/stubs/` — stub executables (`swaybg`, `wal`, `qarma`, `notify-send`) created by the harness at runtime (not committed; harness writes them to a temp dir).
+- Modify: `/home/danix/bin/wal.sh` — remove wal/wal2 copy lines + wpaper symlink (done late, after wallp owns it). Out of repo; handled in final task with explicit diff.
+- Delete (after wallp verified): `multiwal.sh`, `qar-lastwall.sh` — final task.
+
+---
+
+## Task 1: Test harness skeleton
+
+**Files:**
+- Create: `tests/run.sh`
+- Create: `wallp` (stub: just a shebang + sourcing guard so harness can source it)
+
+- [ ] **Step 1: Create minimal sourceable `wallp`**
+
+Create `wallp`:
+
+```bash
+#!/bin/bash
+# wallp — unified wallpaper manager. See docs/superpowers/specs/.
+
+set -u
+
+main() {
+ return 0
+}
+
+if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi
+```
+
+- [ ] **Step 2: Write the harness with one trivial test**
+
+Create `tests/run.sh`:
+
+```bash
+#!/bin/bash
+# Plain-bash test harness for wallp. No bats dependency.
+set -u
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PASS=0
+FAIL=0
+
+setup() {
+ TMP_HOME="$(mktemp -d)"
+ STUB_DIR="$TMP_HOME/stubs"
+ mkdir -p "$STUB_DIR"
+ export HOME="$TMP_HOME"
+ export PATH="$STUB_DIR:$PATH"
+ # default stubs: record calls to $TMP_HOME/calls.log, succeed
+ for bin in swaybg wal qarma notify-send pkill kill; do
+ cat > "$STUB_DIR/$bin" <<EOF
+#!/bin/bash
+echo "$bin \$*" >> "$TMP_HOME/calls.log"
+exit 0
+EOF
+ chmod +x "$STUB_DIR/$bin"
+ done
+}
+
+teardown() {
+ rm -rf "$TMP_HOME"
+}
+
+assert_eq() { # expected actual msg
+ if [ "$1" = "$2" ]; then PASS=$((PASS+1));
+ else FAIL=$((FAIL+1)); echo "FAIL: $3 (expected '$1' got '$2')"; fi
+}
+assert_file() { # path msg
+ if [ -f "$1" ]; then PASS=$((PASS+1));
+ else FAIL=$((FAIL+1)); echo "FAIL: $2 (missing file $1)"; fi
+}
+assert_no_file() { # path msg
+ if [ ! -e "$1" ]; then PASS=$((PASS+1));
+ else FAIL=$((FAIL+1)); echo "FAIL: $2 (file exists $1)"; fi
+}
+assert_contains() { # haystack needle msg
+ case "$1" in *"$2"*) PASS=$((PASS+1));; *) FAIL=$((FAIL+1)); echo "FAIL: $3 (no '$2' in '$1')";; esac
+}
+
+run_all() {
+ for t in $(declare -F | awk '{print $3}' | grep '^test_'); do
+ setup
+ source "$SCRIPT_DIR/wallp"
+ "$t"
+ teardown
+ done
+ echo "PASS=$PASS FAIL=$FAIL"
+ [ "$FAIL" -eq 0 ]
+}
+
+test_harness_self() {
+ assert_eq "x" "x" "harness sanity"
+}
+
+run_all
+```
+
+- [ ] **Step 3: Run harness, verify it passes**
+
+Run: `bash tests/run.sh`
+Expected: `PASS=1 FAIL=0`, exit 0.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "test: bash harness skeleton + sourceable wallp stub"
+```
+
+---
+
+## Task 2: `~` expansion helper
+
+**Files:**
+- Modify: `wallp` (add `expand_tilde`)
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing test**
+
+Add to `tests/run.sh` before `run_all`:
+
+```bash
+test_expand_tilde_home() {
+ assert_eq "$HOME/pics/a.png" "$(expand_tilde '~/pics/a.png')" "leading tilde expands"
+}
+test_expand_tilde_noop() {
+ assert_eq "/abs/b.png" "$(expand_tilde '/abs/b.png')" "absolute path untouched"
+}
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — `expand_tilde: command not found` / failed asserts.
+
+- [ ] **Step 3: Implement**
+
+Add to `wallp` above `main`:
+
+```bash
+# Expand a leading ~ or ~/ to $HOME. Leaves other paths unchanged.
+expand_tilde() {
+ case "$1" in
+ "~") printf '%s\n' "$HOME" ;;
+ "~/"*) printf '%s\n' "$HOME/${1#\~/}" ;;
+ *) printf '%s\n' "$1" ;;
+ esac
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: PASS count up, `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: expand_tilde helper"
+```
+
+---
+
+## Task 3: Config parsing
+
+Parses `key=value` lines (ignore blanks + `#`), expands `~` in path values, sets globals `CONF_THEME CONF_OUTPUT_H CONF_OUTPUT_V CONF_DEFAULT_H CONF_DEFAULT_V`.
+
+**Files:**
+- Modify: `wallp` (add `parse_conf`)
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing test**
+
+Add to `tests/run.sh`:
+
+```bash
+test_parse_conf_reads_keys() {
+ conf="$HOME/c.conf"
+ printf '%s\n' \
+ '# comment' '' \
+ 'THEME=mytheme' \
+ 'OUTPUT_H=DP-1' \
+ 'OUTPUT_V=DP-3' \
+ 'DEFAULT_H=~/p/h.png' \
+ 'DEFAULT_V=/abs/v.png' > "$conf"
+ parse_conf "$conf"
+ assert_eq "mytheme" "$CONF_THEME" "theme"
+ assert_eq "DP-1" "$CONF_OUTPUT_H" "output_h"
+ assert_eq "DP-3" "$CONF_OUTPUT_V" "output_v"
+ assert_eq "$HOME/p/h.png" "$CONF_DEFAULT_H" "default_h tilde expanded"
+ assert_eq "/abs/v.png" "$CONF_DEFAULT_V" "default_v abs"
+}
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — `parse_conf: command not found`.
+
+- [ ] **Step 3: Implement**
+
+Add to `wallp`:
+
+```bash
+# Parse key=value conf into CONF_* globals. Ignores blanks and #-comments.
+# Expands ~ in DEFAULT_* path values. Does not validate (see require_conf_keys).
+CONF_THEME="" CONF_OUTPUT_H="" CONF_OUTPUT_V="" CONF_DEFAULT_H="" CONF_DEFAULT_V=""
+parse_conf() {
+ local file="$1" line key val
+ while IFS= read -r line || [ -n "$line" ]; do
+ case "$line" in ''|'#'*) continue ;; esac
+ key="${line%%=*}"
+ val="${line#*=}"
+ case "$key" in
+ THEME) CONF_THEME="$val" ;;
+ OUTPUT_H) CONF_OUTPUT_H="$val" ;;
+ OUTPUT_V) CONF_OUTPUT_V="$val" ;;
+ DEFAULT_H) CONF_DEFAULT_H="$(expand_tilde "$val")" ;;
+ DEFAULT_V) CONF_DEFAULT_V="$(expand_tilde "$val")" ;;
+ esac
+ done < "$file"
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: parse_conf key=value config loader"
+```
+
+---
+
+## Task 4: Conf bootstrap + required-key validation
+
+`load_conf` orchestrates: if conf missing → write template, message, signal "bootstrapped" (caller exits 0); else parse + validate required path/output keys (hard error). THEME falls back to `sexy-splurge` if empty.
+
+**Files:**
+- Modify: `wallp` (add `conf_path`, `write_conf_template`, `require_conf_keys`, `load_conf`)
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing tests**
+
+Add to `tests/run.sh`:
+
+```bash
+test_load_conf_missing_bootstraps() {
+ out="$(load_conf 2>&1)"; rc=$?
+ assert_eq "10" "$rc" "missing conf returns rc 10 (bootstrapped)"
+ assert_file "$HOME/.config/wallp/wallp.conf" "template written"
+ assert_contains "$out" "fill it in" "user told to fill conf"
+}
+test_load_conf_missing_key_hard_errors() {
+ mkdir -p "$HOME/.config/wallp"
+ printf '%s\n' 'OUTPUT_H=DP-1' 'OUTPUT_V=DP-3' 'DEFAULT_H=/a.png' \
+ > "$HOME/.config/wallp/wallp.conf" # DEFAULT_V missing
+ out="$(load_conf 2>&1)"; rc=$?
+ assert_eq "1" "$rc" "missing key rc 1"
+ assert_contains "$out" "DEFAULT_V" "names missing key"
+}
+test_load_conf_ok_theme_fallback() {
+ mkdir -p "$HOME/.config/wallp"
+ printf '%s\n' 'OUTPUT_H=DP-1' 'OUTPUT_V=DP-3' \
+ 'DEFAULT_H=/a.png' 'DEFAULT_V=/b.png' \
+ > "$HOME/.config/wallp/wallp.conf" # no THEME
+ load_conf; rc=$?
+ assert_eq "0" "$rc" "valid conf rc 0"
+ assert_eq "sexy-splurge" "$CONF_THEME" "theme fallback"
+}
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — `load_conf: command not found`.
+
+- [ ] **Step 3: Implement**
+
+Add to `wallp`:
+
+```bash
+conf_path() { printf '%s\n' "$HOME/.config/wallp/wallp.conf"; }
+
+write_conf_template() {
+ local path; path="$(conf_path)"
+ mkdir -p "$(dirname "$path")"
+ cat > "$path" <<'EOF'
+# wallp config. Fill in real values, then re-run wallp.
+# THEME is optional (defaults to sexy-splurge).
+THEME=sexy-splurge
+# Physical output names (see: swaymsg -t get_outputs / wlr-randr)
+OUTPUT_H=DP-1
+OUTPUT_V=DP-3
+# Default wallpapers used by --restore when no saved state exists.
+DEFAULT_H=~/Pictures/wallpapers/SFW/horizontal.png
+DEFAULT_V=~/Pictures/wallpapers/SFW/vertical.png
+EOF
+}
+
+# Hard-error if any required path/output key is empty. Returns 1 on first miss.
+require_conf_keys() {
+ local k
+ for k in OUTPUT_H OUTPUT_V DEFAULT_H DEFAULT_V; do
+ local var="CONF_$k"
+ if [ -z "${!var}" ]; then
+ echo "wallp: required config key '$k' is missing or empty in $(conf_path)" >&2
+ return 1
+ fi
+ done
+ return 0
+}
+
+# Returns: 0 ok, 1 invalid conf, 10 bootstrapped (caller should exit 0).
+load_conf() {
+ local path; path="$(conf_path)"
+ if [ ! -f "$path" ]; then
+ write_conf_template
+ echo "wallp: generated config at $path — fill it in and re-run." >&2
+ command -v notify-send >/dev/null 2>&1 && \
+ notify-send -u normal "wallp" "Config generated at $path. Fill it in and re-run."
+ return 10
+ fi
+ parse_conf "$path"
+ [ -z "$CONF_THEME" ] && CONF_THEME="sexy-splurge"
+ require_conf_keys || return 1
+ return 0
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: conf bootstrap + required-key validation"
+```
+
+---
+
+## Task 5: Theme resolution + persistence
+
+`resolve_theme <flag_theme>` precedence: flag > persisted (`~/.config/wallp/theme`) > `CONF_THEME` > `sexy-splurge`. `persist_theme <name>` writes the file.
+
+**Files:**
+- Modify: `wallp` (add `theme_file`, `persist_theme`, `resolve_theme`)
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing tests**
+
+```bash
+test_resolve_theme_flag_wins() {
+ CONF_THEME="conf"; mkdir -p "$HOME/.config/wallp"
+ echo persisted > "$HOME/.config/wallp/theme"
+ assert_eq "flagged" "$(resolve_theme flagged)" "flag beats all"
+}
+test_resolve_theme_persisted_beats_conf() {
+ CONF_THEME="conf"; mkdir -p "$HOME/.config/wallp"
+ echo persisted > "$HOME/.config/wallp/theme"
+ assert_eq "persisted" "$(resolve_theme '')" "persisted beats conf"
+}
+test_resolve_theme_conf_when_no_persist() {
+ CONF_THEME="conf"
+ assert_eq "conf" "$(resolve_theme '')" "conf used"
+}
+test_persist_theme_writes() {
+ persist_theme "abc"
+ assert_eq "abc" "$(cat "$HOME/.config/wallp/theme")" "theme persisted"
+}
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — functions not defined.
+
+- [ ] **Step 3: Implement**
+
+```bash
+theme_file() { printf '%s\n' "$HOME/.config/wallp/theme"; }
+
+persist_theme() {
+ local f; f="$(theme_file)"
+ mkdir -p "$(dirname "$f")"
+ printf '%s\n' "$1" > "$f"
+}
+
+# Precedence: flag arg > persisted file > CONF_THEME > sexy-splurge.
+resolve_theme() {
+ local flag="$1" f; f="$(theme_file)"
+ if [ -n "$flag" ]; then printf '%s\n' "$flag"; return; fi
+ if [ -s "$f" ]; then head -n1 "$f"; return; fi
+ if [ -n "${CONF_THEME:-}" ]; then printf '%s\n' "$CONF_THEME"; return; fi
+ printf '%s\n' "sexy-splurge"
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: theme resolution + persistence"
+```
+
+---
+
+## Task 6: Logical→physical output mapping + persistence paths
+
+Map `H`/`V` to `CONF_OUTPUT_H`/`CONF_OUTPUT_V`; provide persistence-path and pid-path helpers.
+
+**Files:**
+- Modify: `wallp`
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing tests**
+
+```bash
+test_output_for_logical() {
+ CONF_OUTPUT_H="DP-1"; CONF_OUTPUT_V="DP-3"
+ assert_eq "DP-1" "$(output_for H)" "H maps"
+ assert_eq "DP-3" "$(output_for V)" "V maps"
+}
+test_wall_file_for() {
+ assert_eq "$HOME/.config/wallp/wall_h" "$(wall_file_for H)" "wall_h path"
+ assert_eq "$HOME/.config/wallp/wall_v" "$(wall_file_for V)" "wall_v path"
+}
+test_pid_file_for() {
+ assert_eq "$HOME/.cache/wallp/H.pid" "$(pid_file_for H)" "H pid path"
+ assert_eq "$HOME/.cache/wallp/V.pid" "$(pid_file_for V)" "V pid path"
+}
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — functions not defined.
+
+- [ ] **Step 3: Implement**
+
+```bash
+output_for() {
+ case "$1" in
+ H) printf '%s\n' "$CONF_OUTPUT_H" ;;
+ V) printf '%s\n' "$CONF_OUTPUT_V" ;;
+ *) echo "wallp: unknown logical output '$1'" >&2; return 1 ;;
+ esac
+}
+wall_file_for() {
+ case "$1" in
+ H) printf '%s\n' "$HOME/.config/wallp/wall_h" ;;
+ V) printf '%s\n' "$HOME/.config/wallp/wall_v" ;;
+ esac
+}
+pid_file_for() {
+ case "$1" in
+ H) printf '%s\n' "$HOME/.cache/wallp/H.pid" ;;
+ V) printf '%s\n' "$HOME/.cache/wallp/V.pid" ;;
+ esac
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: output mapping + state path helpers"
+```
+
+---
+
+## Task 7: `--set H=/V=` argument parsing
+
+`parse_set_args` consumes `H=<file>` / `V=<file>` tokens into associative-style globals `SET_H`/`SET_V`; rejects unknown tokens.
+
+**Files:**
+- Modify: `wallp`
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing tests**
+
+```bash
+test_parse_set_args_both() {
+ SET_H="" SET_V=""
+ parse_set_args "H=/a.png" "V=/b.png"
+ assert_eq "/a.png" "$SET_H" "H captured"
+ assert_eq "/b.png" "$SET_V" "V captured"
+}
+test_parse_set_args_partial() {
+ SET_H="" SET_V=""
+ parse_set_args "V=/only.png"
+ assert_eq "" "$SET_H" "H empty"
+ assert_eq "/only.png" "$SET_V" "V captured"
+}
+test_parse_set_args_tilde() {
+ SET_H="" SET_V=""
+ parse_set_args "H=~/x.png"
+ assert_eq "$HOME/x.png" "$SET_H" "H tilde expanded"
+}
+test_parse_set_args_unknown_rejected() {
+ SET_H="" SET_V=""
+ out="$(parse_set_args "Z=/bad.png" 2>&1)"; rc=$?
+ assert_eq "1" "$rc" "unknown token rc1"
+ assert_contains "$out" "Z=" "names bad token"
+}
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — `parse_set_args: command not found`.
+
+- [ ] **Step 3: Implement**
+
+```bash
+SET_H="" SET_V=""
+parse_set_args() {
+ local tok
+ for tok in "$@"; do
+ case "$tok" in
+ H=*) SET_H="$(expand_tilde "${tok#H=}")" ;;
+ V=*) SET_V="$(expand_tilde "${tok#V=}")" ;;
+ *) echo "wallp: unknown --set argument '$tok' (expected H=<file> or V=<file>)" >&2; return 1 ;;
+ esac
+ done
+ return 0
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: parse --set H=/V= arguments"
+```
+
+---
+
+## Task 8: Apply a single wallpaper (selective kill + swaybg + persist)
+
+`apply_output <H|V> <file>`: validate file exists; selective-kill prior PID; launch swaybg; save new PID; persist path to `wall_h`/`wall_v`. Returns 1 (skip) on missing file without blanking.
+
+**Files:**
+- Modify: `wallp`
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing tests**
+
+```bash
+test_apply_output_missing_file_skips() {
+ CONF_OUTPUT_H="DP-1"
+ out="$(apply_output H "$HOME/nope.png" 2>&1)"; rc=$?
+ assert_eq "1" "$rc" "missing file rc1"
+ assert_no_file "$(pid_file_for H)" "no pid written on skip"
+}
+test_apply_output_sets_and_persists() {
+ CONF_OUTPUT_H="DP-1"
+ : > "$HOME/real.png"
+ apply_output H "$HOME/real.png"; rc=$?
+ assert_eq "0" "$rc" "apply ok"
+ assert_eq "$HOME/real.png" "$(cat "$(wall_file_for H)")" "path persisted"
+ assert_file "$(pid_file_for H)" "pid written"
+ assert_contains "$(cat "$HOME/calls.log")" "swaybg -o DP-1 -i $HOME/real.png" "swaybg launched"
+}
+test_apply_output_v_does_not_touch_h_pid() {
+ CONF_OUTPUT_H="DP-1" CONF_OUTPUT_V="DP-3"
+ mkdir -p "$HOME/.cache/wallp"; echo 99999 > "$(pid_file_for H)"
+ : > "$HOME/v.png"
+ apply_output V "$HOME/v.png"
+ assert_eq "99999" "$(cat "$(pid_file_for H)")" "H pid untouched on V update"
+}
+```
+
+Note: the harness `swaybg` stub exits 0 immediately, so `$!` is a real (already-exited) PID — fine for assertions. The `kill` stub records and exits 0.
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — `apply_output: command not found`.
+
+- [ ] **Step 3: Implement**
+
+```bash
+# Kill the swaybg recorded for a logical output, if its PID is still alive.
+kill_output() {
+ local logical="$1" pf pid; pf="$(pid_file_for "$logical")"
+ [ -f "$pf" ] || return 0
+ pid="$(cat "$pf")"
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
+ kill "$pid" 2>/dev/null
+ fi
+ rm -f "$pf"
+}
+
+# apply_output <H|V> <file>: set wallpaper for one output. rc1 = skipped.
+apply_output() {
+ local logical="$1" file="$2" output pf wf
+ if [ ! -f "$file" ]; then
+ echo "wallp: file not found, skipping $logical: $file" >&2
+ return 1
+ fi
+ output="$(output_for "$logical")" || return 1
+ kill_output "$logical"
+ swaybg -o "$output" -i "$file" -m fill &
+ pf="$(pid_file_for "$logical")"; mkdir -p "$(dirname "$pf")"
+ printf '%s\n' "$!" > "$pf"
+ wf="$(wall_file_for "$logical")"; mkdir -p "$(dirname "$wf")"
+ printf '%s\n' "$file" > "$wf"
+ return 0
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: apply_output with selective kill + persistence"
+```
+
+---
+
+## Task 9: wpaper symlink + theme application
+
+`update_wpaper`: symlink `~/.cache/wal/wpaper` → current H image (read from `wall_h`). `apply_theme <theme>`: persist theme, run wal, notify. `finalize <theme>`: wpaper + apply_theme.
+
+**Files:**
+- Modify: `wallp`
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing tests**
+
+```bash
+test_update_wpaper_symlinks_h() {
+ : > "$HOME/h.png"; mkdir -p "$(dirname "$(wall_file_for H)")"
+ echo "$HOME/h.png" > "$(wall_file_for H)"
+ update_wpaper
+ assert_eq "$HOME/h.png" "$(readlink "$HOME/.cache/wal/wpaper")" "wpaper -> H image"
+}
+test_apply_theme_runs_wal_and_persists() {
+ apply_theme "mytheme"
+ assert_eq "mytheme" "$(cat "$(theme_file)")" "theme persisted"
+ assert_contains "$(cat "$HOME/calls.log")" "wal --backend colorz -nq --theme mytheme -o $HOME/bin/wal.sh" "wal invoked"
+ assert_contains "$(cat "$HOME/calls.log")" "notify-send" "notify sent"
+}
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — functions not defined.
+
+- [ ] **Step 3: Implement**
+
+```bash
+update_wpaper() {
+ local wf img link; wf="$(wall_file_for H)"
+ [ -s "$wf" ] || return 0
+ img="$(cat "$wf")"
+ link="$HOME/.cache/wal/wpaper"
+ mkdir -p "$(dirname "$link")"
+ ln -sf "$img" "$link"
+}
+
+apply_theme() {
+ local theme="$1"
+ persist_theme "$theme"
+ wal --backend colorz -nq --theme "$theme" -o "$HOME/bin/wal.sh"
+ command -v notify-send >/dev/null 2>&1 && \
+ notify-send -u normal -t 5000 "color theme update" "setting color theme to: $theme"
+}
+
+finalize() {
+ update_wpaper
+ apply_theme "$1"
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: wpaper symlink + theme application"
+```
+
+---
+
+## Task 10: Restore action
+
+`do_restore <flag_theme>`: for H and V, use persisted `wall_h`/`wall_v` if present else `CONF_DEFAULT_*` (persisting the default); apply_output each; finalize with resolved theme.
+
+**Files:**
+- Modify: `wallp`
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing tests**
+
+```bash
+test_restore_uses_persisted() {
+ CONF_OUTPUT_H="DP-1" CONF_OUTPUT_V="DP-3" CONF_THEME="conf"
+ CONF_DEFAULT_H="$HOME/dh.png" CONF_DEFAULT_V="$HOME/dv.png"
+ : > "$HOME/saved_h.png"; : > "$HOME/saved_v.png"
+ mkdir -p "$HOME/.config/wallp"
+ echo "$HOME/saved_h.png" > "$(wall_file_for H)"
+ echo "$HOME/saved_v.png" > "$(wall_file_for V)"
+ do_restore ""
+ assert_contains "$(cat "$HOME/calls.log")" "swaybg -o DP-1 -i $HOME/saved_h.png" "H restored from saved"
+ assert_contains "$(cat "$HOME/calls.log")" "swaybg -o DP-3 -i $HOME/saved_v.png" "V restored from saved"
+}
+test_restore_falls_back_to_defaults() {
+ CONF_OUTPUT_H="DP-1" CONF_OUTPUT_V="DP-3" CONF_THEME="conf"
+ : > "$HOME/dh.png"; : > "$HOME/dv.png"
+ CONF_DEFAULT_H="$HOME/dh.png" CONF_DEFAULT_V="$HOME/dv.png"
+ do_restore ""
+ assert_contains "$(cat "$HOME/calls.log")" "swaybg -o DP-1 -i $HOME/dh.png" "H default used"
+ assert_eq "$HOME/dh.png" "$(cat "$(wall_file_for H)")" "default persisted"
+}
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — `do_restore: command not found`.
+
+- [ ] **Step 3: Implement**
+
+```bash
+# Pick saved path for a logical output, else its conf default.
+restore_path_for() {
+ local logical="$1" wf; wf="$(wall_file_for "$logical")"
+ if [ -s "$wf" ]; then cat "$wf"; return; fi
+ case "$logical" in
+ H) printf '%s\n' "$CONF_DEFAULT_H" ;;
+ V) printf '%s\n' "$CONF_DEFAULT_V" ;;
+ esac
+}
+
+do_restore() {
+ local flag_theme="$1" logical file
+ for logical in H V; do
+ file="$(restore_path_for "$logical")"
+ apply_output "$logical" "$file" || true
+ done
+ finalize "$(resolve_theme "$flag_theme")"
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: restore action (saved -> default fallback)"
+```
+
+---
+
+## Task 11: Set action (CLI args path; qarma path isolated)
+
+`do_set <flag_theme> [H=.. V=..]`: parse args; if neither given and display present → `qarma_select` (separate fn, stubbed in tests); apply each provided; finalize. `qarma_select` populates `SET_H`/`SET_V` via the qarma menu+pickers — isolated so tests drive only the CLI path.
+
+**Files:**
+- Modify: `wallp`
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing tests**
+
+```bash
+test_set_cli_partial_only_v() {
+ CONF_OUTPUT_H="DP-1" CONF_OUTPUT_V="DP-3" CONF_THEME="conf"
+ : > "$HOME/v.png"
+ do_set "" "V=$HOME/v.png"
+ assert_contains "$(cat "$HOME/calls.log")" "swaybg -o DP-3 -i $HOME/v.png" "V set"
+ case "$(cat "$HOME/calls.log")" in *"swaybg -o DP-1"*) echo "FAIL: H should not be set"; FAIL=$((FAIL+1));; *) PASS=$((PASS+1));; esac
+}
+test_set_cli_theme_flag_persisted() {
+ CONF_OUTPUT_H="DP-1" CONF_OUTPUT_V="DP-3" CONF_THEME="conf"
+ : > "$HOME/h.png"
+ do_set "flagtheme" "H=$HOME/h.png"
+ assert_eq "flagtheme" "$(cat "$(theme_file)")" "flag theme persisted"
+}
+test_set_no_args_no_display_errors() {
+ CONF_OUTPUT_H="DP-1" CONF_OUTPUT_V="DP-3" CONF_THEME="conf"
+ unset WAYLAND_DISPLAY
+ out="$(do_set "" 2>&1)"; rc=$?
+ assert_eq "1" "$rc" "no display no args rc1"
+ assert_contains "$out" "no display" "error mentions display"
+}
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — `do_set: command not found`.
+
+- [ ] **Step 3: Implement**
+
+```bash
+# Interactive selection via qarma. Populates SET_H/SET_V. Isolated for testing.
+qarma_select() {
+ local choice file
+ choice="$(qarma --list --title="wallp" --text="Which screen?" \
+ --column="Screen" H V Both 2>/dev/null)" || return 1
+ case "$choice" in
+ H|Both)
+ file="$(qarma --file-selection --preview-images 500 --width 1300 --height 600 \
+ --title='Choose Horizontal Wallpaper')" && SET_H="$file" ;;
+ esac
+ case "$choice" in
+ V|Both)
+ file="$(qarma --file-selection --preview-images 500 --width 1300 --height 600 \
+ --title='Choose Vertical Wallpaper')" && SET_V="$file" ;;
+ esac
+ return 0
+}
+
+do_set() {
+ local flag_theme="$1"; shift
+ SET_H="" SET_V=""
+ if [ "$#" -gt 0 ]; then
+ parse_set_args "$@" || return 1
+ fi
+ if [ -z "$SET_H" ] && [ -z "$SET_V" ]; then
+ if [ -n "${WAYLAND_DISPLAY:-}" ]; then
+ qarma_select || return 1
+ else
+ echo "wallp: no display: pass H=<file> and/or V=<file>" >&2
+ return 1
+ fi
+ fi
+ [ -n "$SET_H" ] && apply_output H "$SET_H" || true
+ [ -n "$SET_V" ] && apply_output V "$SET_V" || true
+ finalize "$(resolve_theme "$flag_theme")"
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: set action (CLI + qarma selection)"
+```
+
+---
+
+## Task 12: Help screen + argument dispatch (`main`)
+
+`show_help` (qarma window if display, else stdout). `main` parses top-level flags `--set --restore --theme <name> --help/-h`, calls `load_conf` (honoring bootstrap rc 10 → exit 0), dispatches.
+
+**Files:**
+- Modify: `wallp` (replace stub `main`, add `show_help`)
+- Test: `tests/run.sh`
+
+- [ ] **Step 1: Write failing tests**
+
+```bash
+test_main_bare_shows_help() {
+ unset WAYLAND_DISPLAY
+ out="$(main 2>&1)"; rc=$?
+ assert_eq "0" "$rc" "help rc0"
+ assert_contains "$out" "Usage" "help text shown"
+}
+test_main_unknown_flag_errors() {
+ out="$(main --bogus 2>&1)"; rc=$?
+ assert_eq "1" "$rc" "unknown flag rc1"
+ assert_contains "$out" "Usage" "help shown on error"
+}
+test_main_bootstrap_exits_zero() {
+ # no conf present -> bootstrap, no wallpaper change
+ out="$(main --restore 2>&1)"; rc=$?
+ assert_eq "0" "$rc" "bootstrap rc0"
+ assert_no_file "$(pid_file_for H)" "no wallpaper set during bootstrap"
+}
+test_main_theme_flag_parsed_with_restore() {
+ mkdir -p "$HOME/.config/wallp"
+ printf '%s\n' 'OUTPUT_H=DP-1' 'OUTPUT_V=DP-3' \
+ 'DEFAULT_H=/dh.png' 'DEFAULT_V=/dv.png' > "$HOME/.config/wallp/wallp.conf"
+ : > /dh.png 2>/dev/null || true # may be unwritable; rely on default-skip
+ main --restore --theme tflag >/dev/null 2>&1
+ assert_eq "tflag" "$(cat "$(theme_file)")" "theme flag honored via main"
+}
+```
+
+Note: `test_main_theme_flag_parsed_with_restore` uses `/dh.png` which likely
+does not exist; `apply_output` will skip (rc1, `|| true`) but `finalize` still
+runs and persists the theme — which is what the assertion checks.
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `bash tests/run.sh`
+Expected: FAIL — `main` is still the stub returning 0 with no help.
+
+- [ ] **Step 3: Implement** (replace the stub `main`)
+
+```bash
+show_help() {
+ local text="wallp — unified wallpaper manager
+
+Usage:
+ wallp Show this help
+ wallp --help | -h Show this help
+ wallp --set Interactive (qarma) wallpaper selection
+ wallp --set H=<file> Set horizontal screen only
+ wallp --set V=<file> Set vertical screen only
+ wallp --set H=<f> V=<f> Set both screens
+ wallp --restore Restore last session (defaults if none)
+ wallp --theme <name> Override theme (with --set or --restore)
+
+Config: ~/.config/wallp/wallp.conf"
+ if [ -n "${WAYLAND_DISPLAY:-}" ] && command -v qarma >/dev/null 2>&1; then
+ qarma --info --title="wallp help" --text="$text" 2>/dev/null
+ else
+ printf '%s\n' "$text"
+ fi
+}
+
+main() {
+ local action="" flag_theme="" set_args=()
+ while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --help|-h) show_help; return 0 ;;
+ --set) action="set" ;;
+ --restore) action="restore" ;;
+ --theme) shift; flag_theme="${1:-}";
+ [ -z "$flag_theme" ] && { echo "wallp: --theme needs a name" >&2; return 1; } ;;
+ H=*|V=*) set_args+=("$1") ;;
+ *) echo "wallp: unknown argument '$1'" >&2; show_help; return 1 ;;
+ esac
+ shift
+ done
+
+ if [ -z "$action" ]; then show_help; return 0; fi
+
+ load_conf; local rc=$?
+ if [ "$rc" -eq 10 ]; then return 0; fi # bootstrapped, no change
+ if [ "$rc" -ne 0 ]; then return 1; fi
+
+ case "$action" in
+ set) do_set "$flag_theme" "${set_args[@]}" ;;
+ restore) do_restore "$flag_theme" ;;
+ esac
+}
+```
+
+- [ ] **Step 4: Run, verify pass**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add wallp tests/run.sh
+git commit -m "feat: help screen + main dispatch"
+```
+
+---
+
+## Task 13: Ship example config + make executable
+
+**Files:**
+- Create: `wallp.conf.example`
+- Modify: `wallp` (ensure executable bit)
+
+- [ ] **Step 1: Create `wallp.conf.example`**
+
+```
+# wallp config. Copy to ~/.config/wallp/wallp.conf and edit.
+# THEME is optional (defaults to sexy-splurge).
+THEME=sexy-splurge
+# Physical output names (swaymsg -t get_outputs / wlr-randr)
+OUTPUT_H=DP-1
+OUTPUT_V=DP-3
+# Defaults used by --restore when no saved state exists.
+DEFAULT_H=~/Pictures/wallpapers/SFW/horizontal.png
+DEFAULT_V=~/Pictures/wallpapers/SFW/vertical.png
+```
+
+- [ ] **Step 2: Make wallp executable**
+
+Run: `chmod +x wallp tests/run.sh`
+
+- [ ] **Step 3: Verify full suite green**
+
+Run: `bash tests/run.sh`
+Expected: `FAIL=0`, exit 0.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add wallp wallp.conf.example tests/run.sh
+git commit -m "chore: ship example config, make wallp executable"
+```
+
+---
+
+## Task 14: Manual smoke test on real two-screen setup
+
+No automated test — real hardware. Document the run; do not commit code unless a bug is found (then add a regression test first).
+
+- [ ] **Step 1: Backup current state**
+
+Run:
+```bash
+cp -a ~/.config/wallp ~/.config/wallp.bak 2>/dev/null || true
+```
+
+- [ ] **Step 2: First run bootstraps conf**
+
+Run: `./wallp --restore`
+Expected: message "generated config at …"; conf file created; **no wallpaper change**. Edit the conf with real output names + default wallpaper paths.
+
+- [ ] **Step 3: Restore with defaults**
+
+Run: `./wallp --restore`
+Expected: both screens show the configured defaults; theme applied; notification appears.
+
+- [ ] **Step 4: Partial CLI set (V only) does not blank H**
+
+Run: `./wallp --set V=/path/to/some/vertical.png`
+Expected: vertical screen changes; **horizontal stays as-is** (not black).
+
+- [ ] **Step 5: qarma flow**
+
+Run: `./wallp --set`
+Expected: qarma menu H/V/Both → file picker(s) → chosen screen(s) update. Cancel skips.
+
+- [ ] **Step 6: Theme persistence across restore**
+
+Run:
+```bash
+./wallp --set H=/some/h.png --theme some-other-theme
+./wallp --restore
+```
+Expected: after restore, `some-other-theme` is reapplied (persisted theme wins), not the conf default.
+
+- [ ] **Step 7: Help**
+
+Run: `./wallp` and `./wallp --help`
+Expected: usage shown (qarma window under wayland, else stdout).
+
+---
+
+## Task 15: Cut over wal.sh and remove old scripts
+
+Only after Task 14 passes. `wal.sh` is outside the repo (`/home/danix/bin/wal.sh`).
+
+- [ ] **Step 1: Trim `wal.sh`**
+
+Edit `/home/danix/bin/wal.sh` — remove the wallpaper-pointer + wpaper logic (old lines 15-27), keeping only dunst + kitty glue. Resulting file:
+
+```sh
+#!/bin/sh
+
+# uncomment for debug
+#set -ex
+
+# Symlink dunst config
+ln -sf ~/.cache/wal/dunstrc ~/.config/dunst/dunstrc
+# Restart dunst with the new color scheme
+pkill dunst
+dunst &
+
+# symlink kitty colors
+ln -sf ~/.cache/wal/colors-kitty.conf ~/.config/kitty/current-theme.conf
+```
+
+- [ ] **Step 2: Verify wpaper still maintained**
+
+Run: `./wallp --restore && readlink ~/.cache/wal/wpaper`
+Expected: symlink points at current H image (wallp now owns it). Confirm rofi menus still themed.
+
+- [ ] **Step 3: Remove superseded scripts**
+
+Run:
+```bash
+git rm multiwal.sh qar-lastwall.sh
+```
+
+- [ ] **Step 4: Update any autostart references**
+
+Manually check the wayland/hyprland autostart (e.g. hyprland config `exec-once`) that called `qar-lastwall.sh` — repoint to `wallp --restore`. Document the file edited in the commit body.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "chore: cut over to wallp, remove multiwal.sh + qar-lastwall.sh"
+```
+
+---
+
+## Self-Review Notes
+
+- Spec coverage: CLI forms (T7,T11,T12), conf bootstrap + hard-error (T4), logical H/V mapping (T6), theme persistence + precedence (T5, asserted via T11/T12), selective per-output kill / no-blank partial (T8, asserted T11), wpaper kept for rofi (T9,T15), drop wal/wal2 (no writes anywhere in plan — verified), restore saved→default (T10), help (T12), error handling (T4,T7,T8,T11,T12). All covered.
+- Naming consistency: `apply_output`, `output_for`, `wall_file_for`, `pid_file_for`, `resolve_theme`, `persist_theme`, `do_set`, `do_restore`, `finalize`, `update_wpaper`, `apply_theme`, `parse_conf`, `load_conf`, `parse_set_args` — used consistently across tasks.
+- No placeholders: all steps carry real code/commands/expected output.