aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-06 17:42:18 +0100
committerDanilo M. <danix@danix.xyz>2026-11-06 17:42:18 +0100
commit187682aa4bd212632204d23706c5cb317028b5d3 (patch)
treeab30ae7394eedbfa007a2f7e9a2fb3ed1a55266b /docs/superpowers
parent2ae4deaf7ac420ad5871e3957fb027a856ce7edf (diff)
downloadquickshell-187682aa4bd212632204d23706c5cb317028b5d3.tar.gz
quickshell-187682aa4bd212632204d23706c5cb317028b5d3.zip
wallpaper: choose a transition, by name or at random
A named pick plays as it is; random draws from the ticked pool and skips what played last while anything else is there. Saved values are checked here too, so a stale or hand-edited name lands on something playable. A JS library rather than QML, to test it under node as Shuffle.js is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-11-06-wallpaper-transitions.md947
-rw-r--r--docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md122
2 files changed, 1069 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-11-06-wallpaper-transitions.md b/docs/superpowers/plans/2026-11-06-wallpaper-transitions.md
new file mode 100644
index 0000000..f4de6c2
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-06-wallpaper-transitions.md
@@ -0,0 +1,947 @@
+# Wallpaper Transitions 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:** Six new wallpaper transitions beside the burn, chosen on a settings page with a preview per tile, plus a Random option drawing from ticked transitions.
+
+**Architecture:** One `.frag` per transition, run through a `WallShader` wrapper shared by the cover and the page's thumbnails. A `Transitions` singleton holds the choice and its draft, saved in `Prefs`, and `Wallpapers.run()` asks it for one name per run and passes it through `swapping`.
+
+**Tech Stack:** Quickshell 0.3.1 QML, GLSL 440 compiled with `qsb`, QML JS library tested under node.
+
+**Spec:** `docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md`
+
+**Rules from AGENTS.md that apply:** every new source file starts with the GPLv2 header, copied from lines 1-10 of `desktop/modules/appearance/burn.frag` (code blocks below omit it, `.js` and `.qml` and `.frag` all use the same `//` form); 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`; a new file in `modules/appearance/` needs a `desktop/shell.qml` change or a restart to be seen; a `shell.qml` edit and a recompiled `burn.frag.qsb` both need `./reload.sh desktop`, run by the user. Commits are GPG-signed.
+
+All paths below are relative to the repo root. `A` means `desktop/modules/appearance`.
+
+---
+
+## File map
+
+| File | Change |
+| --- | --- |
+| `A/TransitionPick.js` | new: `choose()`, `clean()` |
+| `A/test_transitions.js` | new: node check |
+| `desktop/Prefs.qml` | `wallTransition`, `setWallTransition()` |
+| `A/Transitions.qml` | new singleton: list, choice, draft, `next()` |
+| `A/WallShader.qml` | new: the shared `ShaderEffect` |
+| `A/burn.frag`, `A/burn.frag.qsb` | `progress` remapped to a plain 0 to 1 |
+| `A/Wallpapers.qml` | `swapping` gains the effect, `run()` asks for it |
+| `A/WallpaperSpread.qml` | plays the named effect through `WallShader` |
+| `A/{ink,shatter,ripple,melt,blinds,crosswarp}.frag` and `.qsb` | new shaders |
+| `A/TransitionTile.qml`, `A/TransitionsTab.qml`, `A/TransitionsModule.qml` | new: the page |
+| `desktop/shell.qml` | lists the module |
+| `A/README.md`, `AGENTS.md` | documentation |
+
+---
+
+### Task 1: The draw and the validation
+
+**Files:**
+- Create: `A/TransitionPick.js`
+- Test: `A/test_transitions.js`
+
+- [ ] **Step 1: Write the failing test**
+
+`A/test_transitions.js`:
+
+```js
+// Run: node desktop/modules/appearance/test_transitions.js
+// TransitionPick.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 + "/TransitionPick.js", "utf8").replace(/^\.pragma library$/m, "");
+const T = new Function(src + "\nreturn { choose, clean };")();
+
+// A named pick is returned as it is, whatever the pool holds.
+assert.strictEqual(T.choose("melt", [], "melt"), "melt");
+
+// Random stays in the pool and never plays the same one twice running.
+const pool = ["ink", "melt", "blinds"];
+let last = "";
+const seen = {};
+for (let i = 0; i < 500; i++) {
+ const n = T.choose("random", pool, last);
+ assert.ok(pool.indexOf(n) >= 0, `${n} is not in the pool`);
+ assert.notStrictEqual(n, last, `${n} twice running`);
+ seen[n] = true;
+ last = n;
+}
+assert.strictEqual(Object.keys(seen).length, 3);
+
+// A pool of one has to repeat, and an empty one falls back.
+assert.strictEqual(T.choose("random", ["ink"], "ink"), "ink");
+assert.strictEqual(T.choose("random", [], "ink"), "burn");
+
+// Saved values: missing, wrong and unknown all land on something playable,
+// and the pool comes back in the list's order.
+const names = ["burn", "ink", "melt"];
+assert.deepStrictEqual(T.clean(null, names), { pick: "burn", pool: names });
+assert.deepStrictEqual(T.clean({ pick: "gone", pool: "x" }, names), { pick: "burn", pool: names });
+assert.deepStrictEqual(T.clean({ pick: "random", pool: ["melt", "gone", "burn"] }, names),
+ { pick: "random", pool: ["burn", "melt"] });
+assert.deepStrictEqual(T.clean({ pick: "ink", pool: [] }, names), { pick: "ink", pool: [] });
+
+console.log("ok");
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `node desktop/modules/appearance/test_transitions.js`
+Expected: FAIL with `ENOENT` on `TransitionPick.js`
+
+- [ ] **Step 3: Write the implementation**
+
+`A/TransitionPick.js`:
+
+```js
+.pragma library
+
+// Which transition plays, tested by test_transitions.js under node.
+
+// The name for a run. `pick` is a name or "random"; `pool` is what random may
+// draw; `last` is what played last, skipped while there is anything else.
+function choose(pick, pool, last) {
+ if (pick !== "random") return pick;
+ const from = pool.length > 1 ? pool.filter(n => n !== last) : pool;
+ return from.length ? from[Math.floor(Math.random() * from.length)] : "burn";
+}
+
+// What was saved, made safe against `names`. Nothing saved ticks everything;
+// a pool is kept in the order of `names`, so two can be compared as strings.
+function clean(saved, names) {
+ const s = saved && typeof saved === "object" ? saved : {};
+ return {
+ pick: s.pick === "random" || names.indexOf(s.pick) >= 0 ? s.pick : "burn",
+ pool: Array.isArray(s.pool) ? names.filter(n => s.pool.indexOf(n) >= 0) : names,
+ };
+}
+```
+
+Note `.pragma library` stays on line 1, with the GPL header after it, as `Shuffle.js` does.
+
+- [ ] **Step 4: Run it to verify it passes**
+
+Run: `node desktop/modules/appearance/test_transitions.js`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md docs/superpowers/plans/2026-11-06-wallpaper-transitions.md desktop/modules/appearance/TransitionPick.js desktop/modules/appearance/test_transitions.js
+git commit -m "wallpaper: choose a transition, by name or at random"
+```
+
+---
+
+### Task 2: Saved choice
+
+**Files:**
+- Modify: `desktop/Prefs.qml`
+
+- [ ] **Step 1: Add the property and setter**
+
+After `setWallEngine()`:
+
+```qml
+ // The wallpaper transition as saved, or null for the defaults
+ // (modules/appearance/TransitionPick.js, which also validates what is
+ // read here).
+ property var wallTransition: null
+ function setWallTransition(t) {
+ root.wallTransition = t;
+ root.write(Object.assign({}, root.doc, { wallTransition: t }));
+ }
+```
+
+- [ ] **Step 2: Read it in `reparse()`**
+
+At the end of `reparse()`:
+
+```qml
+ // Only on a real change: any page's save rereads this file, and a new
+ // object here would throw away the transitions page's draft.
+ const wt = parsed.wallTransition !== undefined ? parsed.wallTransition : null;
+ if (JSON.stringify(wt) !== JSON.stringify(root.wallTransition)) root.wallTransition = wt;
+```
+
+- [ ] **Step 3: Reset it in `onLoadFailed`**
+
+Append `root.wallTransition = null;` inside the braces of `onLoadFailed`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/Prefs.qml
+git commit -m "prefs: keep the wallpaper transition"
+```
+
+---
+
+### Task 3: The singleton
+
+**Files:**
+- Create: `A/Transitions.qml`
+
+- [ ] **Step 1: Write it**
+
+```qml
+pragma Singleton
+
+import Quickshell
+import QtQuick
+import "../.."
+import "TransitionPick.js" as TransitionPick
+
+// Which transition covers a wallpaper change. The page edits the draft, Apply
+// saves it, and each run asks next() once, so both screens play the same one.
+// See the spec, docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md.
+Singleton {
+ id: root
+
+ readonly property var all: [
+ { name: "burn", label: "Burn" },
+ { name: "ink", label: "Ink" },
+ { name: "shatter", label: "Shatter" },
+ { name: "ripple", label: "Ripple" },
+ { name: "melt", label: "Melt" },
+ { name: "blinds", label: "Blinds" },
+ { name: "crosswarp", label: "Cross-warp" },
+ ]
+ readonly property var names: all.map(t => t.name)
+ readonly property int duration: 3500
+
+ readonly property var saved: TransitionPick.clean(Prefs.wallTransition, names)
+ property string draftPick: saved.pick
+ property var draftPool: saved.pool
+ onSavedChanged: reset()
+
+ readonly property bool dirty: draftPick !== saved.pick
+ || JSON.stringify(draftPool) !== JSON.stringify(saved.pool)
+ readonly property bool valid: draftPick !== "random" || draftPool.length > 0
+
+ function toggle(name) {
+ const on = draftPool.indexOf(name) >= 0;
+ draftPool = names.filter(n => n === name ? !on : draftPool.indexOf(n) >= 0);
+ }
+ function reset() {
+ draftPick = saved.pick;
+ draftPool = saved.pool;
+ }
+ function apply() {
+ if (valid) Prefs.setWallTransition({ pick: draftPick, pool: draftPool });
+ }
+
+ property string last: ""
+ function next() {
+ last = TransitionPick.choose(saved.pick, saved.pool, last);
+ return last;
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/modules/appearance/Transitions.qml
+git commit -m "wallpaper: a singleton for the chosen transition"
+```
+
+---
+
+### Task 4: The cover plays a named effect
+
+Burn is still the only shader that exists after this task, and it is what `next()` returns by default, so behaviour does not change.
+
+**Files:**
+- Create: `A/WallShader.qml`
+- Modify: `A/burn.frag`, `A/burn.frag.qsb`, `A/Wallpapers.qml`, `A/WallpaperSpread.qml`
+
+- [ ] **Step 1: Write `WallShader.qml`**
+
+```qml
+import QtQuick
+
+// One transition, by name, for the cover and the settings page's thumbnails.
+// Goes in the old picture's layer.effect, which fills in source. Every shader
+// draws the old picture at progress 0 and the new one at 1, and picks what it
+// needs from these properties. target must be an item with a layer: an Image
+// sampled directly gives the whole picture, not its PreserveAspectCrop crop.
+ShaderEffect {
+ property string name: "burn"
+ property var source
+ property var target
+ property real progress: 0
+ property real aspect: 1
+ property point origin: Qt.point(0.5, 0.5)
+ fragmentShader: Qt.resolvedUrl(name + ".frag.qsb")
+}
+```
+
+- [ ] **Step 2: Remap `burn.frag`**
+
+Replace the two lines:
+
+```glsl
+ float t = mix(d, fbm(qt_TexCoord0 * asp * 5.0), 0.5);
+ float e = t - progress;
+```
+
+with:
+
+```glsl
+ float t = mix(d, fbm(qt_TexCoord0 * asp * 5.0), 0.5);
+ // Starts behind the char band, so no pixel starts out scorched, and ends
+ // past 1, since fbm can push a threshold a little over it.
+ float e = t - (progress * 1.1 - 0.05);
+```
+
+Recompile: `cd desktop/modules/appearance && /usr/lib64/qt6/bin/qsb --qt6 -o burn.frag.qsb burn.frag`
+Expected: exit 0, no output.
+
+- [ ] **Step 3: Pass the effect through `Wallpapers.qml`**
+
+Change the signal at line 62:
+
+```qml
+ signal swapping(string which, string from, string to, string effect)
+```
+
+In `run()`, replace the `let covers` line and the two `swapping` lines:
+
+```qml
+ let covers = 0;
+ const effect = Transitions.next();
+ if (h !== "" && currentH !== "" && h !== currentH) { covers++; swapping("H", currentH, h, effect); }
+ if (v !== "" && currentV !== "" && v !== currentV) { covers++; swapping("V", currentV, v, effect); }
+```
+
+- [ ] **Step 4: Update `WallpaperSpread.qml`**
+
+Header comment: replace `the old picture burns away over the new one` with `the old picture gives way to the new one, through the transition Transitions picked`.
+
+Add beside `property real progress: 0`:
+
+```qml
+ property string effect: "burn"
+```
+
+Replace `onSwapping`:
+
+```qml
+ function onSwapping(which, from, to, effect) {
+ if (which !== win.modelData.which || !win.screen) return;
+ anim.stop();
+ win.effect = effect;
+ win.progress = 0;
+ // A different place each time it starts.
+ win.origin = Qt.point(Math.random(), Math.random());
+ win.done = false;
+ oldImg.source = from;
+ newImg.source = to;
+ win.running = true;
+ }
+```
+
+On `newImg`, add after `cache: false`:
+
+```qml
+ // A layer, so the shaders that read it get the crop.
+ layer.enabled: win.running
+```
+
+On `oldImg`, replace the `layer.effect` block:
+
+```qml
+ layer.effect: WallShader {
+ name: win.effect
+ target: newImg
+ progress: win.progress
+ aspect: win.width / Math.max(1, win.height)
+ origin: win.origin
+ }
+```
+
+Replace the animation's comment and bounds:
+
+```qml
+ // Linear, since an ease spends its slow ends where nothing
+ // happens.
+ to: 1
+ duration: Transitions.duration
+```
+
+- [ ] **Step 5: Smoke check**
+
+Start a scratch shell so the harness owns it (`run_in_background`, `timeout 8 qs -p desktop/`, not detached) only if no `desktop` shell is running; otherwise rely on the running shell's reload. Then:
+
+Run: `qs log -p desktop --log-times | tail -30`
+Expected: no `WallShader`, `Transitions` or `ShaderEffect` warning.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd desktop/modules/appearance
+git add WallShader.qml burn.frag burn.frag.qsb Wallpapers.qml WallpaperSpread.qml
+git commit -m "wallpaper: play the transition by name"
+```
+
+---
+
+### Task 5: Six shaders
+
+**Files:**
+- Create: `A/ink.frag`, `A/shatter.frag`, `A/ripple.frag`, `A/melt.frag`, `A/blinds.frag`, `A/crosswarp.frag` and their `.qsb`
+
+Each must draw the old picture exactly at `progress` 0 and the new picture exactly at 1. Each block below goes after the GPL header.
+
+- [ ] **Step 1: `ink.frag`**
+
+```glsl
+// The old wallpaper dissolving like ink in water: a soft edge through noise
+// warped by more noise, the picture smearing as it goes. Compiled with:
+// qsb --qt6 -o ink.frag.qsb ink.frag
+#version 440
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+ float aspect;
+};
+layout(binding = 1) uniform sampler2D source;
+
+float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
+
+float noise(vec2 p) {
+ vec2 i = floor(p), f = fract(p);
+ vec2 u = f * f * (3.0 - 2.0 * f);
+ return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
+ mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
+}
+
+float fbm(vec2 p) {
+ float v = 0.0, a = 0.5;
+ for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
+ return v;
+}
+
+void main() {
+ vec2 p = qt_TexCoord0 * vec2(aspect, 1.0);
+ vec2 warp = vec2(fbm(p * 3.0 + progress), fbm(p * 3.0 + 7.3 - progress));
+ // fbm sits around 0.2 to 0.8; stretched and clamped, so the edge below
+ // sweeps past every pixel.
+ float t = clamp((fbm(p * 2.0 + warp * 1.5) - 0.2) / 0.6, 0.0, 1.0);
+
+ const float w = 0.15;
+ float a = smoothstep(0.0, w, t - mix(-w, 1.0, progress));
+ vec2 uv = qt_TexCoord0 + (warp - 0.5) * 0.04 * (1.0 - a);
+ fragColor = vec4(texture(source, uv).rgb * a, a) * qt_Opacity;
+}
+```
+
+- [ ] **Step 2: `shatter.frag`**
+
+```glsl
+// The old wallpaper breaking into Voronoi shards that shrink, slide away from
+// where the blow landed and fade, each on its own delay. A shard only moves
+// inside the room its shrinking leaves, so a pixel needs one cell lookup to
+// find it, rather than a search of the neighbours. Compiled with:
+// qsb --qt6 -o shatter.frag.qsb shatter.frag
+#version 440
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+ float aspect;
+ vec2 origin;
+};
+layout(binding = 1) uniform sampler2D source;
+
+vec2 hash2(vec2 p) {
+ return fract(sin(vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)))) * 43758.5453);
+}
+
+// The nearest seed to p: its cell in xy, its position in zw.
+vec4 cell(vec2 p) {
+ vec2 i = floor(p), f = fract(p);
+ float best = 9.0;
+ vec4 r = vec4(0.0);
+ for (int y = -1; y <= 1; y++) {
+ for (int x = -1; x <= 1; x++) {
+ vec2 g = vec2(float(x), float(y));
+ vec2 s = g + hash2(i + g);
+ float d = dot(s - f, s - f);
+ if (d < best) { best = d; r = vec4(i + g, i + s); }
+ }
+ }
+ return r;
+}
+
+void main() {
+ const float n = 6.0;
+ vec2 asp = vec2(aspect, 1.0);
+ vec2 p = qt_TexCoord0 * asp * n;
+ vec4 c = cell(p);
+ vec2 seed = c.zw;
+
+ vec2 away = seed / n - origin * asp;
+ float d = clamp(length(away) / length(max(origin, 1.0 - origin) * asp), 0.0, 1.0);
+ float l = clamp((progress - 0.6 * mix(d, hash2(c.xy).x, 0.3)) / 0.4, 0.0, 1.0);
+
+ float s = 1.0 - 0.85 * l;
+ vec2 q = seed + (p - seed - normalize(away + 1e-4) * (1.0 - s) * 0.4) / s;
+ if (cell(q).xy != c.xy) { fragColor = vec4(0.0); return; }
+
+ float a = 1.0 - smoothstep(0.4, 1.0, l);
+ fragColor = vec4(texture(source, q / (asp * n)).rgb * a, a) * qt_Opacity;
+}
+```
+
+- [ ] **Step 3: `ripple.frag`**
+
+```glsl
+// A ring spreading from a random point, bending both pictures as it passes,
+// the new one behind it. Opaque throughout. Compiled with:
+// qsb --qt6 -o ripple.frag.qsb ripple.frag
+#version 440
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+ float aspect;
+ vec2 origin;
+};
+layout(binding = 1) uniform sampler2D source;
+layout(binding = 2) uniform sampler2D target;
+
+void main() {
+ vec2 asp = vec2(aspect, 1.0);
+ vec2 v = (qt_TexCoord0 - origin) * asp;
+ float d = length(v) / length(max(origin, 1.0 - origin) * asp);
+
+ // e runs -1 to 1 across the ring. It starts wholly outside the picture
+ // and ends wholly past it, so neither end is bent.
+ const float w = 0.12;
+ float e = (d - mix(-w, 1.0 + w, progress)) / w;
+ float ring = 1.0 - smoothstep(0.0, 1.0, abs(e));
+ vec2 off = v / max(length(v), 1e-4) / asp * sin(e * 3.14159) * ring * 0.03;
+
+ vec2 uv = qt_TexCoord0 + off;
+ vec3 col = mix(texture(target, uv).rgb, texture(source, uv).rgb, smoothstep(-0.6, 0.6, e));
+ fragColor = vec4(col, 1.0) * qt_Opacity;
+}
+```
+
+- [ ] **Step 4: `melt.frag`**
+
+```glsl
+// The old wallpaper running down the screen like wet paint, each column
+// starting late by its noise and accelerating. Compiled with:
+// qsb --qt6 -o melt.frag.qsb melt.frag
+#version 440
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+ float aspect;
+};
+layout(binding = 1) uniform sampler2D source;
+
+float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
+
+float noise(vec2 p) {
+ vec2 i = floor(p), f = fract(p);
+ vec2 u = f * f * (3.0 - 2.0 * f);
+ return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
+ mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
+}
+
+void main() {
+ float x = qt_TexCoord0.x * aspect;
+ float delay = 0.25 * (noise(vec2(x * 40.0, 0.0)) + noise(vec2(x * 9.0, 3.0)));
+ float l = clamp((progress - delay) / 0.5, 0.0, 1.0);
+
+ // Past 1, so the bottom row clears the screen.
+ vec2 uv = vec2(qt_TexCoord0.x, qt_TexCoord0.y - l * l * 1.02);
+ float a = smoothstep(-0.004, 0.0, uv.y);
+ fragColor = vec4(texture(source, uv).rgb * a, a) * qt_Opacity;
+}
+```
+
+- [ ] **Step 5: `blinds.frag`**
+
+```glsl
+// Vertical slats turning edge-on in a wave from a random column, darkening as
+// they turn, with the new wallpaper between them. Compiled with:
+// qsb --qt6 -o blinds.frag.qsb blinds.frag
+#version 440
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+ vec2 origin;
+};
+layout(binding = 1) uniform sampler2D source;
+
+void main() {
+ const float n = 16.0;
+ float slat = floor(qt_TexCoord0.x * n);
+ float f = fract(qt_TexCoord0.x * n) - 0.5;
+
+ float wave = abs((slat + 0.5) / n - origin.x) / max(origin.x, 1.0 - origin.x);
+ float l = clamp((progress - 0.6 * min(wave, 1.0)) / 0.4, 0.0, 1.0);
+
+ float half_ = 0.5 * cos(l * 1.5708);
+ if (l >= 1.0 || abs(f) > half_) { fragColor = vec4(0.0); return; }
+
+ vec2 uv = vec2((slat + 0.5 + f * 0.5 / half_) / n, qt_TexCoord0.y);
+ fragColor = vec4(texture(source, uv).rgb * mix(1.0, 0.55, l), 1.0) * qt_Opacity;
+}
+```
+
+- [ ] **Step 6: `crosswarp.frag`**
+
+```glsl
+// Both pictures pulled away from a random point by their own brightness,
+// flowing into each other: the old one stretches as it leaves, the new one
+// settles as it arrives. Opaque throughout. Compiled with:
+// qsb --qt6 -o crosswarp.frag.qsb crosswarp.frag
+#version 440
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+ vec2 origin;
+};
+layout(binding = 1) uniform sampler2D source;
+layout(binding = 2) uniform sampler2D target;
+
+float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+ vec2 dir = uv - origin;
+ float x = smoothstep(0.0, 1.0, progress);
+ vec2 ua = uv + dir * luma(texture(source, uv).rgb) * x * 0.3;
+ vec2 ub = uv - dir * luma(texture(target, uv).rgb) * (1.0 - x) * 0.3;
+ fragColor = vec4(mix(texture(source, ua).rgb, texture(target, ub).rgb, x), 1.0) * qt_Opacity;
+}
+```
+
+- [ ] **Step 7: Compile all six**
+
+Run:
+
+```bash
+cd desktop/modules/appearance
+for f in ink shatter ripple melt blinds crosswarp; do /usr/lib64/qt6/bin/qsb --qt6 -o $f.frag.qsb $f.frag || echo "FAILED $f"; done
+```
+
+Expected: no `FAILED` line.
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd desktop/modules/appearance
+git add ink.frag* shatter.frag* ripple.frag* melt.frag* blinds.frag* crosswarp.frag*
+git commit -m "wallpaper: six more transitions"
+```
+
+---
+
+### Task 6: The page
+
+**Files:**
+- Create: `A/TransitionTile.qml`, `A/TransitionsTab.qml`, `A/TransitionsModule.qml`
+- Modify: `desktop/shell.qml`
+
+- [ ] **Step 1: `TransitionTile.qml`**
+
+```qml
+import QtQuick
+import "../.."
+
+// One choice on the transitions page. A click selects it in the draft and
+// plays it once between the two current wallpapers. The tick puts it in
+// Random's pool. The tile named "random" has no shader and no tick.
+Item {
+ id: tile
+ required property string name
+ required property string label
+ readonly property bool random: name === "random"
+ readonly property bool selected: Transitions.draftPick === name
+ readonly property bool shows: !random && Wallpapers.currentH !== "" && Wallpapers.currentV !== ""
+ property real progress: 0
+ property point origin: Qt.point(0.5, 0.5)
+
+ implicitHeight: thumb.height + 30
+
+ Glow {
+ anchors.fill: thumb
+ on: tile.selected
+ }
+
+ Rectangle {
+ id: thumb
+ width: tile.width
+ height: Math.round(width * 9 / 16)
+ color: Theme.surface
+ clip: true
+
+ // Same layering as the cover, WallpaperSpread.qml. ponytail: rests on
+ // the old picture again after playing, with a cut.
+ Image {
+ id: newImg
+ visible: tile.shows
+ anchors.fill: parent
+ source: tile.shows ? Wallpapers.currentV : ""
+ fillMode: Image.PreserveAspectCrop
+ sourceSize: Qt.size(thumb.width, thumb.height)
+ asynchronous: true
+ layer.enabled: tile.shows
+ }
+ Image {
+ id: oldImg
+ visible: tile.shows
+ anchors.fill: parent
+ source: tile.shows ? Wallpapers.currentH : ""
+ fillMode: Image.PreserveAspectCrop
+ sourceSize: Qt.size(thumb.width, thumb.height)
+ asynchronous: true
+ layer.enabled: tile.shows
+ layer.effect: WallShader {
+ name: tile.name
+ target: newImg
+ progress: tile.progress
+ aspect: thumb.width / Math.max(1, thumb.height)
+ origin: tile.origin
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: tile.random
+ // nf-fa-shuffle, present in Inconsolata Nerd Font.
+ text: ""
+ color: Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize * 2 }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ Transitions.draftPick = tile.name;
+ if (!tile.shows) return;
+ tile.origin = Qt.point(Math.random(), Math.random());
+ play.restart();
+ }
+ }
+ }
+
+ NumberAnimation {
+ id: play
+ target: tile
+ property: "progress"
+ from: 0
+ to: 1
+ duration: Transitions.duration
+ onFinished: tile.progress = 0
+ }
+
+ Toggle {
+ anchors { top: thumb.bottom; topMargin: 6; left: parent.left }
+ visible: !tile.random
+ label: tile.label
+ // Toggle assigns checked itself, which would break a plain binding
+ // and leave the tick stale after Reset.
+ Binding on checked { value: Transitions.draftPool.indexOf(tile.name) >= 0 }
+ onToggled: Transitions.toggle(tile.name)
+ }
+
+ Text {
+ anchors { top: thumb.bottom; topMargin: 8; left: parent.left }
+ visible: tile.random
+ text: tile.label
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+}
+```
+
+- [ ] **Step 2: `TransitionsTab.qml`**
+
+```qml
+import QtQuick
+import "../.."
+
+Item {
+ id: tab
+ implicitHeight: col.implicitHeight
+
+ readonly property int gap: 14
+ readonly property int columns: Math.max(1, Math.floor((width + gap) / 260))
+ readonly property real tileWidth: (width - (columns - 1) * gap) / columns
+
+ Column {
+ id: col
+ width: parent.width
+ spacing: 14
+
+ Text {
+ width: col.width
+ wrapMode: Text.Wrap
+ text: "Click a transition to see it between your two current wallpapers. "
+ + "Random draws from the ticked ones, and never the same one twice running."
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ Grid {
+ columns: tab.columns
+ spacing: tab.gap
+
+ Repeater {
+ model: Transitions.all
+ TransitionTile {
+ required property var modelData
+ width: tab.tileWidth
+ name: modelData.name
+ label: modelData.label
+ }
+ }
+ TransitionTile {
+ width: tab.tileWidth
+ name: "random"
+ label: "Random"
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 3: `TransitionsModule.qml`**
+
+```qml
+import QtQuick
+import "../.."
+
+// The transitions page: which effect covers a wallpaper change.
+Module {
+ name: "transitions"
+ label: "Transitions"
+ // nf-fa-magic, present in Inconsolata Nerd Font.
+ icon: ""
+ settings: true
+ dirty: Transitions.dirty
+ group: "Appearance"
+ keywords: ["wallpaper", "transition", "effect", "burn", "ink", "shatter", "ripple", "melt", "blinds", "random"]
+
+ page: Component {
+ Page {
+ title: "Transitions"
+ actions: Component {
+ SaveBar {
+ dirty: Transitions.dirty
+ valid: Transitions.valid
+ note: "tick at least one transition for Random"
+ saveText: "Apply"
+ discardText: "Reset"
+ onSave: Transitions.apply()
+ onDiscard: Transitions.reset()
+ }
+ }
+ TransitionsTab { width: parent.width }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: List it in `desktop/shell.qml`**
+
+After `WallEngineModule {},` add:
+
+```qml
+ TransitionsModule {},
+```
+
+- [ ] **Step 5: Smoke check with a fresh process**
+
+`pgrep -cx qs` before; if the desktop shell runs, a second `qs -p desktop` would double its windows, so load from the running shell's log after the reload instead:
+
+Run: `qs log -p desktop --log-times | tail -30`
+Expected: a `Reloading configuration` line after the `shell.qml` mtime with no `Transition` warning. AGENTS.md warns a `shell.qml` edit may report a type as missing until restart: if so, this check waits for the user's `./reload.sh desktop`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/shell.qml desktop/modules/appearance/TransitionTile.qml desktop/modules/appearance/TransitionsTab.qml desktop/modules/appearance/TransitionsModule.qml
+git commit -m "wallpaper: a page to preview and pick the transition"
+```
+
+---
+
+### Task 7: Documentation
+
+**Files:**
+- Modify: `A/README.md:159-172`, `AGENTS.md:16`
+
+- [ ] **Step 1: README**
+
+Replace the `### The burn` heading with `### Transitions`, and its first sentence with:
+
+```markdown
+Apply, and every engine change, plays a transition over the swap, the old
+picture giving way to the new one (`WallpaperSpread.qml`, `WallShader.qml`,
+one `.frag` each: burn, ink, shatter, ripple, melt, blinds, crosswarp). The
+Transitions page picks one, or Random, which draws from the ticked ones and
+never repeats the last. Clicking a tile plays it between the two current
+wallpapers through the same shader. Both screens play the same one in a run.
+Each shader must draw the old picture exactly at `progress` 0 and the new one
+at 1, or the cover pops as it comes and goes. Pictures reach a shader through
+a layer, since an `Image` sampled directly gives the whole picture and not its
+crop.
+```
+
+In the rest of the section change `before the burn` to `before the transition`, `gets no burn` to `gets no transition`, and the closing sentence to: `After editing a `.frag`, recompile with `qsb --qt6 -o <name>.frag.qsb <name>.frag` under a new name or restart the shell: a running shell keeps a shader by URL.`
+
+- [ ] **Step 2: AGENTS.md**
+
+In the layout block, change `window for pages set once: wallpaper, theme, theme status, icons,` to `window for pages set once: wallpaper, transitions, theme, theme status, icons,`, rewrapping if it passes the block's width. In the note on the shader cache, leave `burn.frag.qsb` as the example.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add AGENTS.md desktop/modules/appearance/README.md
+git commit -m "docs: wallpaper transitions"
+```
+
+---
+
+### Task 8: User check
+
+The motion cannot be judged from here. Ask the user to run `./reload.sh desktop`, then:
+
+1. Settings > Transitions shows eight tiles, Burn glowing, all ticks on, Apply disabled.
+2. Clicking each tile plays it, and the header reads unsaved changes.
+3. Reset restores the selection and ticks.
+4. Select Random, untick all: Apply is held back with the note. Tick two, Apply, then change wallpaper a few times: only those two play, alternating.
+5. Pick each transition in turn, Apply, change wallpaper: no pop at the start or the end, on either screen, on DP-3 in portrait too.
+6. Saving another page with unsaved transitions keeps the draft.
+
+Tune `w`, `n` and the delays in the `.frag` files from what is reported. Recompiling under the same name needs a restart.
diff --git a/docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md b/docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md
new file mode 100644
index 0000000..7a9d4b7
--- /dev/null
+++ b/docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md
@@ -0,0 +1,122 @@
+# Wallpaper transitions
+
+Apply and the wallpaper engine play one effect today, the burn. This adds six
+more, a settings page to preview and choose between them, and a Random option
+that draws from a chosen subset.
+
+## Transitions
+
+`burn`, `ink`, `shatter`, `ripple`, `melt`, `blinds`, `crosswarp`.
+
+| Name | Effect |
+| --- | --- |
+| burn | the existing effect, unchanged to the eye |
+| ink | old picture dissolves through warped noise, soft edge |
+| shatter | Voronoi cells crack outward from `origin`, each sliding and fading on its own delay |
+| ripple | expanding ring refracts the picture, new one revealed behind it |
+| melt | columns drip down at noise-driven speeds |
+| blinds | slats rotate shut in a wave across the screen |
+| crosswarp | both pictures stretch along their brightness and flow into each other |
+
+## Shaders
+
+One `.frag` and one compiled `.qsb` per transition, in
+`desktop/modules/appearance/`, following `burn.frag`. An uber-shader with a
+`mode` uniform was rejected: every effect would pay for every branch, and one
+error would take out all seven.
+
+Each shader declares what it uses out of `progress`, `aspect`, `origin`,
+`source` (old picture) and `target` (new picture). The wrapper's properties
+are a superset; a property with no matching uniform is harmless, the reverse
+is not.
+
+Every shader must meet two conditions, because the cover appears over and
+leaves over swaybg:
+
+- at `progress` 0 it draws the old picture exactly;
+- at `progress` 1 it draws the new picture exactly, by full transparency over
+ `newImg` or by sampling `target`.
+
+`progress` runs 0 to 1 for all of them. `burn.frag` remaps it internally, with
+`progress * 1.1 - 0.05`, to keep the range it was tuned for, and the cover's
+`-0.05` start and `1.05` end go away.
+
+## WallShader.qml
+
+One `ShaderEffect` wrapper with `name`, `source`, `target`, `progress`,
+`aspect` and `origin`, resolving `name` to its `.qsb`. Both the cover and the
+thumbnails use it, so a preview runs the real shader. It is not called
+`Transition`: QtQuick has that type and it would shadow the file.
+
+In `WallpaperSpread.qml` it replaces the `ShaderEffect` in `oldImg`'s
+`layer.effect`. `newImg` gains `layer.enabled` so it can be passed as `target`.
+Both pictures must reach the shader through a layer: an `Image` used directly
+as a texture gives the whole picture, not the `PreserveAspectCrop` crop, and
+the cover would no longer line up with swaybg.
+
+## Transitions.qml
+
+A singleton beside `WallEngine.qml`, holding:
+
+- `all`, the seven names with their labels;
+- `duration`, 3500ms, used by the cover and the thumbnails;
+- `pick`, a transition name or `"random"`, and `pool`, the ticked names, both
+ saved;
+- `draftPick`, `draftPool`, `dirty`, `valid`, `apply()`, `reset()`;
+- `next()`, returning the name to play for a run.
+
+`next()` returns `pick` unless it is `"random"`, in which case it draws from
+`pool`, excluding the last name played when the pool holds two or more. The
+draw and the validation of saved values live in `TransitionPick.js` beside
+it, as `Shuffle.js` does, so they can be tested outside `qs`.
+
+Saved through a new `Prefs.setWallTransition()`, under `wallTransition` in
+`desktop.json`, default `{ pick: "burn", pool: <all seven> }`, so nothing
+changes until the user chooses. An unknown name read from the file is dropped
+from `pool`, and an unknown `pick` falls back to `burn`. `next()` falls back
+to `burn` if the pool ends up empty.
+
+## Settings page
+
+`TransitionsModule.qml`, `TransitionsTab.qml` and `TransitionTile.qml`, in the Appearance group,
+listed in `shell.qml` after `WallEngineModule`. A grid of seven tiles plus a
+Random tile.
+
+- Clicking a tile selects it in the draft and plays it once in its thumbnail,
+ then it rests. Clicking it again replays it. Random selects without playing.
+- Each of the seven tiles carries a tick, which edits the draft's pool. Ticks
+ stay available whatever is selected.
+- `SaveBar` sits in the header through `Page.actions`, with `saveText` Apply
+ and `discardText` Reset.
+- `valid` is false when Random is selected with nothing ticked, and the note
+ says so.
+- A thumbnail plays `Wallpapers.currentH` into `Wallpapers.currentV`, so no
+ gallery scan is needed. It uses a random `origin` per play. With either path
+ empty the tile is flat and still selectable.
+
+## Runtime
+
+`Wallpapers.run()` calls `Transitions.next()` once per run and passes the name
+as a fourth argument of `swapping`, so both screens play the same effect.
+`WallpaperSpread` sets it on its `WallShader`. Apply and the engine share
+`run()`, so both follow the choice. Sets from a terminal still get no
+transition.
+
+## Left out
+
+A duration setting, a choice per screen, and a full-screen try button.
+
+## Checks
+
+- All seven `.frag` compile with `qsb --qt6`.
+- `test_transitions.js` covers the draw: the pool is respected, no immediate
+ repeat with two or more, a pool of one repeats, an empty pool gives `burn`.
+- A fresh `qs -p desktop` loads with no new warnings.
+- The motion itself is judged by eye by the user. A `shell.qml` edit needs
+ `./reload.sh desktop` from the user's terminal.
+
+## Documentation
+
+`desktop/modules/appearance/README.md`: "The burn" becomes a section on
+transitions, and the page is described. `AGENTS.md`: the settings window's
+page list gains transitions.