# 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 .frag.qsb .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.