aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-10-29-wallpaper-engine.md803
1 files changed, 803 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-29-wallpaper-engine.md b/docs/superpowers/plans/2026-10-29-wallpaper-engine.md
new file mode 100644
index 0000000..b5e03ad
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-29-wallpaper-engine.md
@@ -0,0 +1,803 @@
+# 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 "<why, as in the comment above LIGHT_SKIPS_HELPERS>"
+```
+
+---
+
+### 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"
+```