# Wallpaper Engine 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:** Change both wallpapers on a timer from per-screen hearted lists, with a light accent run for timer changes and a home wallpaper restored on switch-off. **Architecture:** A `WallEngine` singleton in `desktop/modules/appearance/` owns the timer, the shuffle and the homes, and sets wallpapers through a refactored `Wallpapers.run(h, v, kind)`, the same path Apply uses, so the burn transition plays. Its settings and lists live in `Prefs` (`desktop.json`). `udt-accent` skips GRUB, homepage and SDDM when `UDT_ROTATION` is set, which `wallp` passes through untouched. **Tech Stack:** Quickshell 0.3.1 QML, QML JS libraries tested under node, Python 3 (`udt-accent`), Hyprland Lua config. **Spec:** `docs/superpowers/specs/2026-10-29-wallpaper-engine-design.md` **Repos:** `quickshell` (this one), `../unified-desktop-theme` (Task 1), `../conky-theme-udt` (Task 8, it owns `hypr/dashboard.lua`, which `~/.config/hypr/sections/dashboard.lua` links to). **Rules from AGENTS.md that apply:** every new source file carries the GPLv2 header; no home paths in committed files; never run `./reload.sh` or a detached `qs` from a tool call; `pkill -x qs`, never `-f`; QML JS has no `flatMap`, `Object.fromEntries` or `matchAll`; check `qs log -p desktop --log-times` against file mtimes after each QML edit; a new file in `modules/appearance/` needs a `desktop/shell.qml` change or a restart to be seen; changing `shell.qml` usually needs `./reload.sh desktop`, run by the user. --- ## File map | File | Change | | --- | --- | | `../unified-desktop-theme/bin/udt-accent` | `UDT_ROTATION` gate in `main`, selftest case | | `desktop/modules/appearance/Shuffle.js` | new: shuffle bag `draw()` | | `desktop/modules/appearance/test_shuffle.js` | new: node check | | `desktop/Prefs.qml` | `wallEngine` object and `setWallEngine()` | | `desktop/modules/appearance/Wallpapers.qml` | `run(h, v, kind)`, `finished`, `manualApplied` | | `desktop/modules/appearance/WallpaperSpread.qml` | hide on `finished` instead of `applied` | | `desktop/modules/appearance/WallEngine.qml` | new singleton: timer, homes, lists | | `desktop/modules/appearance/WallpaperTab.qml` | the heart on each thumbnail | | `desktop/modules/appearance/WallEngineModule.qml` | new settings page | | `desktop/modules/appearance/WallEngineTab.qml` | new page body | | `desktop/shell.qml` | register `WallEngineModule` | | `desktop/modules/appearance/README.md` | engine section | | `../conky-theme-udt/hypr/dashboard.lua` | `silent` on the workspace rule | --- ### Task 1: udt-accent light run **Files:** - Modify: `../unified-desktop-theme/bin/udt-accent` (`main`, around lines 629-665; `selftest`, before `print("selftest OK")`) - [ ] **Step 1: Write the failing selftest case** Insert before `print("selftest OK")` in `selftest()`: ```python # UDT_ROTATION is the wallpaper engine's light run: GRUB, the homepage and # SDDM and their helpers are skipped, everything else still runs. Every # writer and both process calls are swapped for recorders, so nothing on # disk is touched and no helper starts. every = ("write_accent", "write_border", "write_hyprlock_border", "write_qml", "write_sublime", "write_sddm", "write_colors_json", "write_firefox", "write_grub", "write_homepage", "write_conky") helpers = {"udt-homepage", "udt-grub", "udt-keyboard", "udt-ledstrip", "udt-bulb", "udt-tv"} calls = [] class FakeSubprocess: @staticmethod def run(*a, **k): pass @staticmethod def Popen(cmd, **k): calls.append(Path(cmd[0]).name) saved, saved_env = dict(globals()), os.environ.get("UDT_ROTATION") try: for fn in every: globals()[fn] = (lambda n: lambda *a: calls.append(n))(fn) globals()["signature_color"] = lambda image: None globals()["subprocess"] = FakeSubprocess skipped = {"write_sddm", "write_grub", "write_homepage", "udt-grub", "udt-homepage"} for env, want in (("", set(every) | helpers), ("1", (set(every) | helpers) - skipped)): calls.clear() os.environ["UDT_ROTATION"] = env main("unused.png") assert set(calls) == want, (env, sorted(set(calls) ^ want)) finally: globals().update(saved) if saved_env is None: os.environ.pop("UDT_ROTATION", None) else: os.environ["UDT_ROTATION"] = saved_env ``` - [ ] **Step 2: Run it, expect failure** Run: `cd ../unified-desktop-theme && bin/udt-accent --selftest` Expected: `AssertionError: ('1', ['udt-grub', 'udt-homepage', 'write_grub', 'write_homepage', 'write_sddm'])` - [ ] **Step 3: Gate main** Above `def main`, add: ```python # Set by keystone's wallpaper engine on its timer changes. They recolour the # live desktop and leave the boot and login surfaces alone: nobody sees GRUB, # the homepage or SDDM mid-session, and GRUB costs a rebuild and the homepage # an scp on every change. A manual change is a full run and brings them level. LIGHT_SKIPS_HELPERS = ("udt-homepage", "udt-grub") ``` In `main`, first line of the body: `light = bool(os.environ.get("UDT_ROTATION"))`. Replace `write_sddm(name, image)` with: ```python if not light: write_sddm(name, image) ``` Replace the two lines `write_grub(name)` / `write_homepage(name)` (keep the comment above them) with: ```python if not light: write_grub(name) write_homepage(name) ``` In the helper loop, as the loop body's first line: ```python if light and helper in LIGHT_SKIPS_HELPERS: continue ``` - [ ] **Step 4: Run, expect pass** Run: `bin/udt-accent --selftest` Expected: `selftest OK` - [ ] **Step 5: Document and commit (unified-desktop-theme)** Add `UDT_ROTATION=1` to the module docstring's usage notes: ``` With UDT_ROTATION set and non-empty (keystone's wallpaper engine), GRUB, the homepage and SDDM are left alone, and udt-grub and udt-homepage do not start. ``` ```bash git add bin/udt-accent git commit -m "accent: UDT_ROTATION, a light run for timed wallpaper changes" -m "" ``` --- ### Task 2: Shuffle bag **Files:** - Create: `desktop/modules/appearance/Shuffle.js` - Test: `desktop/modules/appearance/test_shuffle.js` - [ ] **Step 1: Write the test** (GPL header as in `desktop/modules/fonts/test_fonts.js`) ```js // Run: node desktop/modules/appearance/test_shuffle.js // Shuffle.js is a QML library, so its .pragma line is stripped before node // evaluates it. const fs = require("fs"); const assert = require("assert"); const src = fs.readFileSync(__dirname + "/Shuffle.js", "utf8").replace(/^\.pragma library$/m, ""); const S = new Function(src + "\nreturn { draw };")(); // Every round shows each path once, and no path twice running, across the // refill included. const list = ["a", "b", "c", "d"]; let bag = [], last = ""; for (let r = 0; r < 200; r++) { const round = []; for (let i = 0; i < list.length; i++) { const d = S.draw(list, bag, last); assert.notStrictEqual(d.pick, last, `round ${r}: ${d.pick} twice running`); round.push(d.pick); bag = d.bag; last = d.pick; } assert.deepStrictEqual(round.slice().sort(), list); } // A path removed from the list leaves the bag with it. assert.deepStrictEqual(S.draw(["a", "b"], ["c", "b"], "a"), { pick: "b", bag: [] }); // One path is all there is, every time. assert.strictEqual(S.draw(["a"], [], "a").pick, "a"); // Nothing listed, nothing picked. assert.deepStrictEqual(S.draw([], [], "a"), { pick: "", bag: [] }); console.log("test_shuffle OK"); ``` - [ ] **Step 2: Run, expect failure** Run: `node desktop/modules/appearance/test_shuffle.js` Expected: `ENOENT` for `Shuffle.js`. - [ ] **Step 3: Implement** (`.pragma library` first line, then the GPL header) ```js // The wallpaper engine's order, tested by test_shuffle.js under node: a // shuffle bag. Each round shows every path once in random order, and a new // round never opens with the path on screen, so a reshuffle cannot show the // same picture twice running. // The next path and what is left of the round. `list` is what may be shown // now, so a heart removed since the last draw drops out of the bag; `last` is // the path on screen. pick is "" for an empty list. function draw(list, bag, last) { let left = bag.filter(p => list.indexOf(p) >= 0); if (left.length === 0) left = shuffled(list, last); return { pick: left.length ? left[0] : "", bag: left.slice(1) }; } function shuffled(list, last) { const a = list.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = a[i]; a[i] = a[j]; a[j] = t; } if (a.length > 1 && a[0] === last) { const t = a[0]; a[0] = a[1]; a[1] = t; } return a; } ``` - [ ] **Step 4: Run, expect pass** Run: `node desktop/modules/appearance/test_shuffle.js` Expected: `test_shuffle OK` - [ ] **Step 5: Commit** ```bash git add desktop/modules/appearance/Shuffle.js desktop/modules/appearance/test_shuffle.js git commit -m "wallpaper: a shuffle bag for the engine's order" ``` --- ### Task 3: Prefs carries the engine **Files:** - Modify: `desktop/Prefs.qml` - [ ] **Step 1: Property and setter**, after `settingsSidebar`: ```qml // The wallpaper engine (modules/appearance/WallEngine.qml): on or off, // the interval, a list of paths per screen, and each screen's home, the // wallpaper switching off returns to. In here rather than in list files: // the lists can be empty, and an empty FileView write writes nothing. readonly property var wallEngineDefaults: ({ on: false, minutes: 30, h: [], v: [], homeH: "", homeV: "" }) property var wallEngine: wallEngineDefaults function setWallEngine(changes) { const w = Object.assign({}, root.wallEngine, changes); root.wallEngine = w; root.write(Object.assign({}, root.doc, { wallEngine: w })); } ``` - [ ] **Step 2: Parse it**, at the end of `reparse()`: ```qml const we = parsed.wallEngine && typeof parsed.wallEngine === "object" ? parsed.wallEngine : {}; const paths = a => Array.isArray(a) ? a.filter(s => typeof s === "string" && s !== "") : []; const str = s => typeof s === "string" ? s : ""; root.wallEngine = { on: we.on === true, minutes: Number.isInteger(we.minutes) && we.minutes >= 1 ? we.minutes : 30, h: paths(we.h), v: paths(we.v), homeH: str(we.homeH), homeV: str(we.homeV), }; ``` and in `onLoadFailed` add `root.wallEngine = root.wallEngineDefaults;`. - [ ] **Step 3: Check the reload** Run: `qs log -p desktop --log-times | tail -5` Expected: a `Configuration Loaded` after the file's mtime, no new warning naming `Prefs.qml`. - [ ] **Step 4: Commit** ```bash git add desktop/Prefs.qml git commit -m "prefs: the wallpaper engine's settings, lists and homes" ``` --- ### Task 4: Wallpapers.run, one path for every set **Files:** - Modify: `desktop/modules/appearance/Wallpapers.qml` (signals near `applied`; `apply()` and `applyProc` at the end) - Modify: `desktop/modules/appearance/WallpaperSpread.qml` (the `Connections`) - [ ] **Step 1: Signals**, replacing the `swapping` block added by the burn commit: ```qml signal applied(bool ok, string message) // Sent before wallp runs, while current still names the old picture, so // WallpaperSpread can cover the swap with it. signal swapping(string which, string from, string to) // Every run's end, Apply's or the engine's. applied is Apply's alone: it // reports to the save bar and closes the window. signal finished(bool ok) // A successful Apply, with what it set ("" for a screen it left). signal manualApplied(string h, string v) ``` - [ ] **Step 2: Replace `apply()` and `applyProc`** with: ```qml // wallp does the work, including running udt-accent, so the accent follows // the new wallpaper exactly as it does from the command line. Both screens // go in one call: wallp accepts H= and V= together. function apply() { if (dirty) run(pendingH, pendingV, "manual"); } // What started the run in flight: "manual" (Apply), "tick" (the engine's // timer) or "home" (the engine switched off). A tick is the light run, // UDT_ROTATION, which udt-accent reads; the others are full. property string kind: "" // false when nothing was started: a run in flight, or nothing to set. function run(h, v, kind) { if (applying || (h === "" && v === "")) return false; applying = true; root.kind = kind; if (h !== "" && currentH !== "" && h !== currentH) swapping("H", currentH, h); if (v !== "" && currentV !== "" && v !== currentV) swapping("V", currentV, v); const args = []; if (h !== "") args.push(`H=${JSON.stringify(h)}`); if (v !== "") args.push(`V=${JSON.stringify(v)}`); const env = kind === "tick" ? "UDT_ROTATION=1 " : ""; applyProc.command = ["sh", "-c", `${env}wallp --set ${args.join(" ")} >/dev/null 2>&1`]; applyProc.running = true; return true; } Process { id: applyProc stderr: StdioCollector { id: applyErr } onExited: code => { const ok = code === 0; root.applying = false; if (root.kind === "manual") { if (ok) { root.manualApplied(root.pendingH, root.pendingV); root.clearPending(); } root.applied(ok, ok ? "" : (applyErr.text.trim() || `wallp exited ${code}`)); } else if (!ok) { console.warn(`Wallpapers: the engine's ${root.kind} set failed, wallp exited ${code}`); } root.finished(ok); } } ``` - [ ] **Step 3: WallpaperSpread hides on any run's end.** In `WallpaperSpread.qml` replace `function onApplied(ok) {` with `function onFinished(ok) {`, and the comment above it with: ```qml // Any run's end, Apply's or the engine's. A failed set may // have left either picture underneath, so the cover goes at // once rather than claiming the new one. ``` - [ ] **Step 4: Check by hand** Check the reload in the log as in Task 3. Ask the user to Apply a wallpaper on DP-1: the burn plays, the window closes, the accent changes as before. - [ ] **Step 5: Commit** ```bash git add desktop/modules/appearance/Wallpapers.qml desktop/modules/appearance/WallpaperSpread.qml git commit -m "wallpaper: one run() for Apply and the engine, with a finished signal" ``` --- ### Task 5: The engine **Files:** - Create: `desktop/modules/appearance/WallEngine.qml` (GPL header). Not `Rotation`: QtQuick has that type and would shadow it (AGENTS.md, Sprite). - [ ] **Step 1: Write it** ```qml pragma Singleton import Quickshell import QtQuick import "../.." import "Shuffle.js" as Shuffle // The wallpaper engine: both wallpapers on a timer, from a list per screen // picked with the heart in the picker. Ticks go through Wallpapers.run like // Apply, so the burn plays, but as the light run: udt-accent leaves GRUB, the // homepage and SDDM alone. Each screen has a home, the wallpaper last chosen // for it by hand, and switching off goes back to it with a full run, so those // three match the desktop again. See the spec, // docs/superpowers/specs/2026-10-29-wallpaper-engine-design.md. Singleton { id: root readonly property var cfg: Prefs.wallEngine readonly property bool on: cfg.on function list(which) { return which === "V" ? cfg.v : cfg.h; } function listed(path, which) { return list(which).indexOf(path) >= 0; } // ponytail: present means in the gallery's last scan, so a file deleted // since stays drawable until a rescan, and wallp then skips it. function present(path) { return Wallpapers.files.indexOf(path) >= 0; } function toggle(path, which) { const l = list(which).slice(); const i = l.indexOf(path); if (i >= 0) l.splice(i, 1); else l.push(path); Prefs.setWallEngine(which === "V" ? { v: l } : { h: l }); } function setOn(v) { if (v === on) return; if (v) { Prefs.setWallEngine({ on: true, homeH: Wallpapers.currentH, homeV: Wallpapers.currentV }); } else { Prefs.setWallEngine({ on: false }); goHome(); } } function setMinutes(m) { if (Number.isInteger(m) && m >= 1 && m !== cfg.minutes) Prefs.setWallEngine({ minutes: m }); } // ponytail: in memory, so a restart starts a new round. property var bagH: [] property var bagV: [] // A screen whose list is empty, or whose pick is already up, is left out. function tick() { if (Wallpapers.applying) return; const h = Shuffle.draw(cfg.h.filter(present), bagH, Wallpapers.currentH); const v = Shuffle.draw(cfg.v.filter(present), bagV, Wallpapers.currentV); const ph = h.pick !== Wallpapers.currentH ? h.pick : ""; const pv = v.pick !== Wallpapers.currentV ? v.pick : ""; if (Wallpapers.run(ph, pv, "tick")) { bagH = h.bag; bagV = v.bag; } } function next() { tick(); timer.restart(); } // A switch-off during a run cannot start its own; it goes when that run // finishes. property bool homeWaiting: false function goHome() { const h = cfg.homeH !== Wallpapers.currentH ? cfg.homeH : ""; const v = cfg.homeV !== Wallpapers.currentV ? cfg.homeV : ""; if (h === "" && v === "") return; homeWaiting = !Wallpapers.run(h, v, "home"); } Timer { id: timer interval: root.cfg.minutes * 60000 repeat: true running: root.on onTriggered: root.tick() } Connections { target: Wallpapers // A pick by hand is the new home, and stays up a full interval. function onManualApplied(h, v) { if (!root.on) return; Prefs.setWallEngine({ homeH: h !== "" ? h : root.cfg.homeH, homeV: v !== "" ? v : root.cfg.homeV }); timer.restart(); } function onFinished() { if (!root.homeWaiting || root.on) return; root.homeWaiting = false; root.goHome(); } } } ``` - [ ] **Step 2: Commit** (exercised once the page references it, Task 7) ```bash git add desktop/modules/appearance/WallEngine.qml git commit -m "wallpaper: the engine, a timer over the hearted lists with a home per screen" ``` --- ### Task 6: The heart **Files:** - Modify: `desktop/modules/appearance/WallpaperTab.qml` (thumbnail delegate, after the `Column` holding the `Image` and label, inside the bordered `Rectangle`) - [ ] **Step 1: Add it**, as the Rectangle's last child: ```qml // In the engine's list for the screen the // picker targets (WallEngine.qml). Last, so it // is above the pick MouseArea and takes its own // click. Outlined, so it reads on a light // image too. Text { readonly property bool on: WallEngine.listed(modelData, Wallpapers.target) anchors { top: parent.top; right: parent.right; margins: 10 } visible: on || thumbHover.hovered // nf-md-heart, nf-md-heart_outline. text: on ? "\u{F02D1}" : "\u{F02D5}" color: on ? Theme.red : Theme.text style: Text.Outline styleColor: Qt.alpha(Theme.base, 0.6) font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 8 } MouseArea { anchors { fill: parent; margins: -4 } cursorShape: Qt.PointingHandCursor onClicked: WallEngine.toggle(modelData, Wallpapers.target) } } ``` - [ ] **Step 2: Check the reload, then by hand** Log as in Task 3. Ask the user: hovering a thumbnail shows an outlined heart; clicking fills it red; switching the picker to Vertical shows that screen's hearts instead. - [ ] **Step 3: Commit** ```bash git add desktop/modules/appearance/WallpaperTab.qml git commit -m "wallpaper: the heart is back, for the engine's per-screen lists" ``` --- ### Task 7: The engine's page **Files:** - Create: `desktop/modules/appearance/WallEngineModule.qml`, `desktop/modules/appearance/WallEngineTab.qml` (GPL headers) - Modify: `desktop/shell.qml` (modules list, after `WallpaperModule {},`) - [ ] **Step 1: The module** ```qml import QtQuick import "../.." // The wallpaper engine's page: on or off, the interval, and the two lists. Module { name: "wallengine" label: "Wallpaper engine" // nf-fa-shuffle, present in Inconsolata Nerd Font. icon: "" settings: true group: "Appearance" keywords: ["wallpaper", "timer", "rotate", "rotation", "shuffle", "slideshow", "heart"] // A singleton is built on first use, and the engine's timer has to run // from the start of the session, page opened or not. Modules exist for // the whole session, so this reference is what starts it. readonly property bool engineOn: WallEngine.on page: Component { Page { title: "Wallpaper engine" WallEngineTab { width: parent.width } } } } ``` - [ ] **Step 2: The page body** ```qml import QtQuick import "../.." Item { implicitHeight: col.implicitHeight Column { id: col width: parent.width spacing: 14 Row { spacing: 8 Toggle { anchors.verticalCenter: parent.verticalCenter checked: WallEngine.on label: "Change the wallpapers every" onToggled: WallEngine.setOn(checked) } Field { anchors.verticalCenter: parent.verticalCenter width: 50 value: String(WallEngine.cfg.minutes) onEdited: { WallEngine.setMinutes(Number(value)); value = String(WallEngine.cfg.minutes); } } Text { anchors.verticalCenter: parent.verticalCenter text: "minutes" color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Tab { text: "Next" enabled: WallEngine.on && !Wallpapers.applying onClicked: if (enabled) WallEngine.next() } } Text { width: col.width wrapMode: Text.Wrap text: "Timer changes recolour the desktop and leave GRUB, the homepage and the login screen alone. " + "Switching off puts back each screen's home wallpaper, the one last applied by hand, with a full run." color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Repeater { model: [{ which: "H", name: "Horizontal, DP-1" }, { which: "V", name: "Vertical, DP-3" }] Column { id: screen required property var modelData readonly property var paths: WallEngine.list(modelData.which) readonly property string home: modelData.which === "V" ? WallEngine.cfg.homeV : WallEngine.cfg.homeH width: col.width spacing: 8 Text { text: `${screen.modelData.name}: ${screen.paths.length} hearted` + (WallEngine.on && screen.home !== "" ? `, home ${Wallpapers.label(screen.home)}` : "") color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } Text { visible: screen.paths.length === 0 text: "Heart a wallpaper on the Wallpaper page, with this screen selected, to add it." color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Flow { width: col.width spacing: 8 Repeater { model: screen.paths // Dimmed when the gallery's scan did not find it: the // timer skips it. Rectangle { required property string modelData width: 192; height: 108 + 20 radius: 6 color: Qt.alpha(Theme.surface, 0.3) opacity: WallEngine.present(modelData) ? 1 : 0.4 clip: true Image { id: thumb anchors { top: parent.top; left: parent.left; right: parent.right; margins: 4 } height: 100 source: "file://" + parent.modelData asynchronous: true cache: false fillMode: Image.PreserveAspectCrop sourceSize.width: 192 } Text { anchors { top: thumb.bottom; left: parent.left; right: parent.right; margins: 4 } elide: Text.ElideMiddle text: Wallpapers.label(parent.modelData) color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 6 } } // nf-fa-xmark: out of the list. Text { anchors { top: parent.top; right: parent.right; margins: 8 } text: "" color: Theme.text style: Text.Outline styleColor: Qt.alpha(Theme.base, 0.6) font { family: Theme.iconFamily; pixelSize: Theme.fontSize } MouseArea { anchors { fill: parent; margins: -4 } cursorShape: Qt.PointingHandCursor onClicked: WallEngine.toggle(parent.parent.modelData, screen.modelData.which) } } } } } } } } } ``` - [ ] **Step 3: Register it** in `desktop/shell.qml`, after `WallpaperModule {},`: ```qml WallEngineModule {}, ``` - [ ] **Step 4: Load check** `shell.qml` changed, so expect a restart: ask the user to run `./reload.sh desktop`, then read `qs log -p desktop --log-times | tail -20` for errors naming the new files. - [ ] **Step 5: Check by hand, with the user** 1. Heart two wallpapers for H and one for V. The page lists them with counts. 2. Set the interval to 1, switch on. Within a minute both screens burn to a hearted image; `cat ~/.config/wallp/wall_h` names it; `git -C ../grub-theme-udt diff --stat` and `git -C ../unified-desktop-theme diff --stat templates/homepage` show no new change from the tick. 3. Next changes at once. 4. Apply a wallpaper by hand: the page shows it as home. 5. Switch off: the screens burn back to the home wallpapers, and GRUB and the homepage take its accent. 6. Restart with the engine on (`./reload.sh desktop`): it keeps ticking. - [ ] **Step 6: Commit** ```bash git add desktop/modules/appearance/WallEngineModule.qml desktop/modules/appearance/WallEngineTab.qml desktop/shell.qml git commit -m "wallpaper: the engine's settings page" ``` --- ### Task 8: Conky stays on its special workspace **Files:** - Modify: `../conky-theme-udt/hypr/dashboard.lua` (`dash-workspace` rule) - [ ] **Step 1: Make the rule silent** ```lua -- silent: conky reloads conky.conf in place on every accent change and maps -- a new window doing it, and without silent the rule then shows the special -- workspace over whatever was on screen. The exec rule in autostart only -- covers the first window. hl.window_rule({ name = "dash-workspace", match = { class = "conky-dash" }, workspace = WS .. " silent", }) ``` - [ ] **Step 2: Validate and apply** Run: `hyprctl eval 'hl.window_rule({ name = "dash-workspace-probe", match = { class = "conky-dash-probe" }, workspace = "special:dash silent" })'; echo $?` Expected: `0`. Then `hyprctl reload` and `hyprctl configerrors` (empty). - [ ] **Step 3: Check by hand** Ask the user to change the wallpaper (any way): the dashboard no longer comes up. - [ ] **Step 4: Commit (conky-theme-udt)** ```bash git add hypr/dashboard.lua git commit -m "hypr: dash-workspace rule silent, conky's reload no longer shows the dashboard" ``` --- ### Task 9: Docs **Files:** - Modify: `desktop/modules/appearance/README.md`, the spec - [ ] **Step 1: README section** "Wallpaper engine": what it does, where its state is (`wallEngine` in `$XDG_STATE_HOME/keystone/desktop.json`), the light run and `UDT_ROTATION`, homes and switch-off, `node desktop/modules/appearance/test_shuffle.js`. - [ ] **Step 2: Spec**: lists in `Prefs`, not list files, because an empty `FileView` write writes nothing; a page of its own in the Appearance group instead of a section, so the picker keeps its height. - [ ] **Step 3: Commit** ```bash git add desktop/modules/appearance/README.md docs/superpowers/specs/2026-10-29-wallpaper-engine-design.md git commit -m "docs: the wallpaper engine" ```