Files
Panama/docs/superpowers/plans/2026-08-18-phase2-wallpaper-modes.md
Gabriel Brown e1faaf7a76 Drop the extension, and give the test suite a front door
Phase 6, the last of the fresh-install spec.

159 scripts lose their .sh: 110 contracts, 47 Vicinae commands, 2 compositor
contracts. A shebang and the executable bit already select the interpreter. The
extension only ever added something that had to stay in sync, and the rename
proved the point twice over in the space of an hour.

The spec's stated risk was Vicinae's script discovery. One script was renamed and
reloaded on its own before the other 46 followed; it came back as
scripts:panama.capture and all 47 resolve. What the probe turned up instead is
that the extension was never only a filename: Vicinae's command IDs embed it, so
every ID changed. Nothing in this repository refers to them, so nothing breaks.
The only trace is Vicinae's metadata.json, whose visited map had two Panama
entries that are now orphaned -- two commands lost their usage ranking and will
earn it back. Worth knowing before anyone renames these again on a machine that
has a keybind pointing at one.

Rewriting the references by exact filename missed two things it structurally
could not see: a name built from a variable, settings-$page.sh, and a glob,
-name '*.sh'. Both were in the contract that counts the generated commands, which
promptly reported 47 expected and 0 found. The mechanical part of a rename is the
part that looks finished.

The three subcommands. panama doctor fronts a health check that already existed
and already ran at the end of every install but could not be reached from a
terminal. panama upgrade re-runs the installer from anywhere. panama test runs
the suite, which had no entry point at all -- 121 files that were the main safety
net in this repository and were invisible in it.

Writing that runner found three tests nothing was running.
calendar_agenda_bridge_test, home_assistant_bridge_test and kdeconnect_bridge_test
are unittest suites without the executable bit, so no contract invoked them and
the first draft of the runner skipped them silently. All three pass, and have
passed unobserved for weeks. The runner collects *_test.py as well now, because a
runner with a blind spot is worse than no runner for the same reason a dependency
checker with one is: it reports PASS.

Six worktrees pruned. Each was re-checked rather than trusted to the spec's list,
and two needed it: panama-commands is not on feat/panama-commands but on
feat/gnome-tweaks-parity, and fix/panama-displays-review reads [ahead 3] -- ahead
of its remote, not of main, with every commit patch-equivalent to landed work.
roadmap-completion stays; it has five commits that are genuinely unlanded. The
branches are left alone: pruning a worktree costs nothing, deleting a branch is a
decision.

121 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:55:55 -04:00

15 KiB

Phase 2 Wallpaper Modes 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: Extend Panama's thumbnail-first wallpaper picker with verified single, slideshow, and per-monitor policies.

Architecture: A pure policy module validates collections, calculates output maps, and owns deterministic shuffle bags. Wallpaper.qml remains the sole hyprpaper process boundary and persists manual changes only after listactive verifies every output.

Tech Stack: Quickshell 0.3, Qt 6 QML/JavaScript, hyprpaper 0.8.4 IPC, Bash fixture contracts

Spec: docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md

Global Constraints

  • Preserve wallpaperPath as the single-image and migration fallback.
  • Reject non-absolute paths, commas, newlines, and UI selections outside scanned candidates.
  • Automatic slideshow changes never rewrite durable policy or spin on failure.
  • No filesystem watcher, animated wallpaper, per-workspace mode, or continuous repaint.
  • A manual policy mutation persists only after full output-map readback succeeds.

Task 1: Schema and pure wallpaper policy

Files:

  • Modify: config/dot/quickshell/config/PreferenceSchema.qml
  • Create: config/dot/quickshell/services/WallpaperPolicy.js
  • Create: config/dot/quickshell/wallpaper-policy-harness.qml
  • Create: tests/quickshell/wallpaper-policy-contract

Interfaces:

  • Consumes: mode, global path, collection, per-monitor map, connected outputs, valid candidates, and shuffle bag.

  • Produces: validPath, validCollection, validAssignments, effectiveMap, orderedNext, shuffledBag, and shuffledNext.

  • Step 1: Write the failing policy contract

Use literal paths /images/a.jpg, /images/b.jpg, and /images/c.jpg with connected outputs DP-2 and HDMI-A-1. Assert:

single     -> {"DP-2":"/images/a.jpg","HDMI-A-1":"/images/a.jpg"}
per-monitor -> {"DP-2":"/images/b.jpg","HDMI-A-1":"/images/a.jpg"}
slideshow item c -> {"DP-2":"/images/c.jpg","HDMI-A-1":"/images/c.jpg"}

Assert missing assignments fall back to the global path, invalid collection entries are skipped, ordered rotation wraps a -> b -> c -> a, and a seeded shuffle emits all three literal paths exactly once before any repeat.

  • Step 2: Run and verify RED

Run: tests/quickshell/wallpaper-policy-contract

Expected: FAIL because the policy module and harness are missing.

  • Step 3: Add schema entries

Add exact keys and defaults:

{
    key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
    label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
    options: [
        { value: "single", label: "Single" },
        { value: "slideshow", label: "Slideshow" },
        { value: "per-monitor", label: "Per display" }
    ]
},
{
    key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
    label: "Slideshow collection", detail: "Backgrounds selected for rotation"
},
{
    key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
    unit: "min", group: "wallpaper", label: "Change background every", detail: "Time between slideshow images"
},
{
    key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
    label: "Shuffle", detail: "Show every selected image before repeating"
},
{
    key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
    label: "Per-display backgrounds", detail: "Background assigned to each connected display"
}

Use group: "wallpaper" for every key. Keep the existing wallpaperPath pattern unchanged.

  • Step 4: Implement the pure policy API

validPath accepts only strings matching ^/[^,\n]+$ that occur in the supplied candidate set. validCollection de-duplicates while preserving first appearance. validAssignments keeps only connector keys matching ^[A-Za-z0-9_.-]+$ and valid paths. effectiveMap returns a plain output-keyed object. Shuffle accepts an injected random() function so tests use the literal sequence 0.8, 0.1, 0.6 without mocking global state.

  • Step 5: Run and verify GREEN

Run: tests/quickshell/wallpaper-policy-contract

Expected: PASS for validation, fallback maps, ordered wrap, and shuffle-without-repeat.

  • Step 6: Commit policy model
git add config/dot/quickshell/config/PreferenceSchema.qml config/dot/quickshell/services/WallpaperPolicy.js config/dot/quickshell/wallpaper-policy-harness.qml tests/quickshell/wallpaper-policy-contract
git commit -m "Model wallpaper display policies"

Task 2: Verified multi-output hyprpaper transaction

Files:

  • Modify: config/dot/quickshell/services/Wallpaper.qml
  • Create: config/dot/quickshell/wallpaper-service-harness.qml
  • Create: tests/quickshell/wallpaper-service-contract
  • Modify: tests/quickshell/settings-pages-contract

Interfaces:

  • Consumes: WallpaperPolicy.effectiveMap(...) and hyprpaper listactive output.

  • Produces: activeByOutput, applyPolicy(candidatePolicy, persist), applyCurrentPolicy(persist), setSingle(path), setAssignment(output, path), toggleSlideshowPath(path), and setMode(mode).

  • Step 1: Write the failing IPC transaction contract

Put fake hyprctl first on PATH. Make it record each argv and return fixture listactive maps. Assert a two-output request calls exactly:

hyprctl hyprpaper wallpaper DP-2,/images/a.jpg
hyprctl hyprpaper wallpaper HDMI-A-1,/images/b.jpg
hyprctl hyprpaper listactive

Assert preferences are written only after exact readback, wrong readback leaves the old policy untouched, and a second output failure stops the transaction and reports Hyprpaper did not apply that background.

  • Step 2: Run and verify RED

Run: tests/quickshell/wallpaper-service-contract

Expected: FAIL because Wallpaper.qml only tracks one active path and persists after exit status.

  • Step 3: Parse complete active state

Replace active as the primary state with property var activeByOutput: ({}); keep a compatibility active property bound to the first current screen. Parse every OUTPUT: /absolute/path line. Reject malformed lines instead of accepting partial state as verification.

  • Step 4: Implement the transaction queue

The operation record contains:

{
    expected: { "DP-2": "/images/a.jpg", "HDMI-A-1": "/images/b.jpg" },
    remaining: ["DP-2", "HDMI-A-1"],
    candidatePolicy: { mode: "per-monitor", assignments: { "HDMI-A-1": "/images/b.jpg" } },
    persist: true,
    automatic: false
}

Apply outputs sequentially, then call listactive. Compare every expected key and path. Only then write the candidate schema values through DesktopPreferences.set. Keep the last verified map visible during work.

  • Step 5: Preserve the legacy API safely

Keep set(path) as return root.setSingle(path) so SettingsBackup and existing IPC remain compatible until their dedicated integration task changes them. Keep title formatting and scan behavior unchanged.

  • Step 6: Run and verify GREEN

Run: tests/quickshell/wallpaper-service-contract && tests/quickshell/settings-pages-contract

Expected: PASS for exact argv, readback gating, old API compatibility, and failure copy.

  • Step 7: Commit verified application
git add config/dot/quickshell/services/Wallpaper.qml config/dot/quickshell/wallpaper-service-harness.qml tests/quickshell/wallpaper-service-contract tests/quickshell/settings-pages-contract
git commit -m "Verify wallpaper policy application"

Task 3: Event-driven slideshow and hotplug settle

Files:

  • Modify: config/dot/quickshell/services/Wallpaper.qml
  • Modify: config/dot/quickshell/services/WallpaperPolicy.js
  • Modify: config/dot/quickshell/wallpaper-service-harness.qml
  • Modify: tests/quickshell/wallpaper-policy-contract
  • Modify: tests/quickshell/wallpaper-service-contract

Interfaces:

  • Consumes: validated slideshow collection, interval, shuffle flag, and Quickshell.screens.

  • Produces: advanceSlideshow(), runtime slideshowIndex, shuffleBag, and one interval Timer active only for a collection of at least two valid images.

  • Step 1: Write failing timer and failure tests

Use a harness-adjustable interval measured in milliseconds while production derives minutes * 60000. Assert: empty and one-item collections do not repeat; two items advance once per trigger; automatic application uses persist: false; failure keeps the same policy and schedules only the next normal interval; three rapid screen-set changes coalesce into one reapply.

  • Step 2: Run and verify RED

Run: tests/quickshell/wallpaper-policy-contract && tests/quickshell/wallpaper-service-contract

Expected: FAIL because no slideshow state or screen-set coalescing exists.

  • Step 3: Implement runtime rotation

Add one repeating Timer whose running expression requires slideshow mode, at least two valid paths, and no active transaction. advanceSlideshow() chooses ordered or shuffled next state through WallpaperPolicy, updates runtime state only after verified apply, and never sets a preference.

  • Step 4: Implement connected-screen coalescing

Bind a sorted screen-name signature and restart a 350 ms single-shot Timer when it changes. Reapply the current policy with persist: false. If a transaction is active, set one boolean follow-up flag rather than creating another queue.

  • Step 5: Run and verify GREEN

Run: tests/quickshell/wallpaper-policy-contract && tests/quickshell/wallpaper-service-contract

Expected: PASS without rapid retry or durable writes during rotation.

  • Step 6: Commit runtime policy
git add config/dot/quickshell/services/Wallpaper.qml config/dot/quickshell/services/WallpaperPolicy.js config/dot/quickshell/wallpaper-service-harness.qml tests/quickshell/wallpaper-policy-contract tests/quickshell/wallpaper-service-contract
git commit -m "Add event-driven wallpaper rotation"

Task 4: Continuity wallpaper controls

Files:

  • Create: config/dot/quickshell/modules/settings/WallpaperControls.qml
  • Modify: config/dot/quickshell/modules/settings/WallpaperPicker.qml
  • Modify: config/dot/quickshell/modules/settings/AppearancePage.qml
  • Modify: config/dot/quickshell/services/SettingsSearch.qml
  • Create: tests/quickshell/wallpaper-settings-contract
  • Modify: tests/quickshell/settings-search-contract

Interfaces:

  • Consumes: Wallpaper modes, connected displays, collection membership, assignments, and transaction state.

  • Produces: compact mode controls above the existing grid and mode-aware tile actions/badges.

  • Step 1: Write the failing UI contract

Render Appearance in all three modes. Assert Single tile activation calls setSingle; Slideshow activation toggles membership and exposes interval/shuffle; Per monitor exposes a display selector and calls setAssignment for the selected output. Assert the active image Prism and collection-member check are separate states.

  • Step 2: Run and verify RED

Run: tests/quickshell/wallpaper-settings-contract && tests/quickshell/settings-search-contract

Expected: FAIL because only single-image tile activation exists.

  • Step 3: Implement WallpaperControls.qml

Use ChoiceGrid for mode, ChoiceGrid for output only in per-monitor mode, SliderRow-equivalent layout for interval only in slideshow mode, and SettingsToggle for shuffle. Controls call Wallpaper transactional methods rather than writing preferences directly.

  • Step 4: Make the picker mode-aware

Add selectedOutput, mode, selected(path), and activate(path) properties/functions. Keep thumbnail decode bounds and event-driven fade. In slideshow mode, draw a quiet check in the upper-right for membership; preserve the Prism border exclusively for the image actually active on the selected/current output.

  • Step 5: Integrate and route search

Place WallpaperControls in the existing wallpaper card above WallpaperPicker. Add search terms for slideshow, shuffle interval, and per-monitor assignment without creating a new Settings page.

  • Step 6: Run and verify GREEN

Run: tests/quickshell/wallpaper-settings-contract && tests/quickshell/settings-search-contract && tests/quickshell/settings-ownership-contract

Expected: PASS with no new mirrors or QML warnings.

  • Step 7: Commit the wallpaper UI
git add config/dot/quickshell/modules/settings/WallpaperControls.qml config/dot/quickshell/modules/settings/WallpaperPicker.qml config/dot/quickshell/modules/settings/AppearancePage.qml config/dot/quickshell/services/SettingsSearch.qml tests/quickshell/wallpaper-settings-contract tests/quickshell/settings-search-contract
git commit -m "Add wallpaper mode controls"

Task 5: Restore and reset integration

Files:

  • Modify: config/dot/quickshell/services/SettingsBackup.qml
  • Modify: tests/quickshell/settings-backup-live-contract
  • Modify: tests/quickshell/settings-commit-reset-contract

Interfaces:

  • Consumes: Wallpaper.applyCurrentPolicy(false).

  • Produces: policy-aware restore and shipped single-wallpaper reset.

  • Step 1: Write failing restore assertions

Restore slideshow and per-monitor fixture snapshots. Assert the restored policy applies after preferences reload, uses persist: false, and shell reload waits for the bounded wallpaper transaction. Reset must yield mode single, empty collection/assignments, interval 30, shuffle true, and shipped wallpaperPath fallback.

  • Step 2: Run and verify RED

Run: tests/quickshell/settings-backup-live-contract && tests/quickshell/settings-commit-reset-contract

Expected: FAIL because restore calls Wallpaper.set(path) only.

  • Step 3: Replace path-only restore

Inject applyWallpaperPolicy: function() { return Wallpaper.applyCurrentPolicy(false); }, include Wallpaper.busy in the existing bounded settle condition, and remove the path parameter from the restore callback. Do not create a second wallpaper snapshot format; all policy keys are already in Settings JSON.

  • Step 4: Run and verify GREEN

Run: tests/quickshell/settings-backup-live-contract && tests/quickshell/settings-commit-reset-contract && tests/quickshell/wallpaper-service-contract

Expected: PASS for both policy modes and shipped reset.

  • Step 5: Commit restore integration
git add config/dot/quickshell/services/SettingsBackup.qml tests/quickshell/settings-backup-live-contract tests/quickshell/settings-commit-reset-contract
git commit -m "Restore complete wallpaper policies"

Task 6: Slice verification

  • Step 1: Run all wallpaper contracts once
tests/quickshell/wallpaper-policy-contract
tests/quickshell/wallpaper-service-contract
tests/quickshell/wallpaper-settings-contract
tests/quickshell/settings-backup-live-contract
tests/quickshell/settings-commit-reset-contract
tests/quickshell/settings-search-contract

Expected: all PASS. The live desktop wallpaper is never changed by fixture tests.

  • Step 2: Perform one read-only live comparison

Run: hyprctl hyprpaper listactive

Expected: every connected output reports an absolute path; compare it with Wallpaper.activeByOutput through the existing IPC/harness without applying an image.

  • Step 3: Review formatting and shell state

Run: git diff --check && git status --short

Expected: clean after the Task 5 commit.