# 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:

```json
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:

```qml
{
    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**

```bash
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:

```text
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:

```qml
{
    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**

```bash
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**

```bash
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**

```bash
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**

```bash
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**

```bash
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.
