# Desktop Companion 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:** A chibi 2B in the bottom-right corner of the focused monitor who reacts to notifications, typing, deleting, the assistant, idling and clicks, as specified in `docs/superpowers/specs/2026-10-23-companion-design.md`. **Architecture:** A component of `desktop/` (`desktop/companion/`), created once in `desktop/shell.qml` and handed the assistant. The state decision is a pure JS library (`Mood.js`) checked with node; `Companion.qml` owns the window, inputs and timers; `Sprite.qml` draws and animates a frame. Typing and BackSpace arrive as Hyprland custom events from the live config in the `hypr-theme` repo. The art lives outside the repo, in `~/.local/share/keystone/companion/2b/`. **Tech Stack:** Quickshell 0.3.1 (QML, `Quickshell.Hyprland`, `Quickshell.Wayland`), node for the JS checks, bash + ImageMagick 7 for frame preparation, Hyprland 0.56.2 Lua config. --- ## Ground rules for whoever runs this - Read `AGENTS.md` first. Three of its traps apply directly: the process is `qs`, never `quickshell`; never `pkill -f`; a `qs` started from an agent's tool call dies with the call, so **never start or restart the desktop shell yourself**. The running `desktop` shell hot-reloads on save; check it with `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail`. - Hot reload does not follow the `shared/` symlinks. After Task 2 the user restarts with `./reload.sh desktop notifications` from their own terminal (Task 10). - A new directory under `desktop/` is only indexed when a file importing it changes; Task 8 changes `desktop/shell.qml`, which does that. - Every new source file carries the GPLv2 header used across the repo (copy it from `desktop/assistant/Md.js`, lines 1-10, or the `#` form from `desktop/modules/ai/ai-state.sh`, lines 1-12). - No home paths in committed files (a gitleaks hook blocks them): write `~` in docs, `Quickshell.env("HOME")` in QML. - No em dashes in any prose, comment or commit message. - Commits are GPG-signed (the default here; never disable signing) and end with: `Co-Authored-By: Claude Opus 5.5 ` ## File map | File | Status | Responsibility | | --- | --- | --- | | `~/Programming/GIT/hypr-theme/sections/companion.lua` | create | typing pulse and BackSpace event | | `~/Programming/GIT/hypr-theme/hyprland.lua` | modify | require the new section | | `~/Programming/GIT/hypr-theme/sections/keybindings.lua` | modify | SUPER+\ toggle | | `shared/NotifyConfig.js` | modify | `shown()`: is a balloon on screen | | `shared/test_notify_config.js` | modify | checks for `shown()` | | `notifications/NotificationBalloon.qml` | modify | use `shown()` | | `desktop/companion/Mood.js` | create | which state wins; timings; outfit; cooldowns | | `desktop/companion/test_mood.js` | create | node check of `Mood.js` | | `desktop/companion/Lines.js` | create | her English lines and the monologue | | `desktop/companion/prep-frames` | create | generated images to display frames | | `desktop/assistant/Assistant.qml` | modify | `signal replied()` | | `desktop/companion/Sprite.qml` | create | frame, crossfade, bob, drop, explosion | | `desktop/companion/Companion.qml` | create | window, inputs, timers, speech, IPC | | `desktop/shell.qml` | modify | create the companion | | `desktop/companion/README.md` | create | component notes | | `AGENTS.md` | modify | list the component | | `docs/superpowers/specs/2026-10-23-companion-design.md` | modify | `lines.js` became `Lines.js` | --- ### Task 1: Hyprland inputs (open checks 1 and 2) The spec's first two open checks, tested before any QML depends on them. This is the live config, the `hypr-theme` repo that `~/.config/hypr` links to. It has an unrelated uncommitted change in `sections/autostart.lua`: leave it alone and stage only the files below. **Files:** - Create: `~/Programming/GIT/hypr-theme/sections/companion.lua` - Modify: `~/Programming/GIT/hypr-theme/hyprland.lua` (after `require("sections.keybindings")`, line 23) - Modify: `~/Programming/GIT/hypr-theme/sections/keybindings.lua` (after the `mainMod .. " + Home"` bind, line 68) - [ ] **Step 1: Write `sections/companion.lua`** ```lua -- The desktop companion (quickshell/desktop/companion) reacts to typing and -- to deleting. Neither reaches a layer surface that never takes the -- keyboard, so the compositor tells it, as custom events on socket2. -- Typing: a pulse at most once a second, carrying no key data at all. if _G.keystoneTyping then _G.keystoneTyping:remove() end local lastPulse = 0 _G.keystoneTyping = hl.on("input.keyboard.key", function() local now = os.time() if now == lastPulse then return end lastPulse = now hl.dispatch(hl.dsp.event("keystone-typing")) end) -- Deleting: BackSpace only, and non_consuming so the key still reaches the -- focused window. It is the one key the companion learns about. hl.bind("BackSpace", hl.dsp.event("keystone-delete"), { non_consuming = true, description = "Desktop: Companion notices a delete" }) ``` - [ ] **Step 2: Require it** In `hyprland.lua`, after line 23 (`require("sections.keybindings")`), add: ```lua require("sections.companion") ``` - [ ] **Step 3: Add the toggle bind** In `sections/keybindings.lua`, after the `mainMod .. " + Home"` line, add: ```lua -- code:49, the physical key left of 1: `\` on the Italian layout, a backtick -- on the US one. Binding the keysym would move the shortcut with the layout. hl.bind(mainMod .. " + code:49", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call companion toggle"), { description = "Desktop: Companion" }) ``` - [ ] **Step 4: Reload Hyprland and read its errors** Run: `hyprctl reload && sleep 1 && hyprctl configerrors` Expected: `ok`, then no error line that mentions `companion`, `code:49`, `BackSpace` or `input.keyboard.key`. Run `hyprctl configerrors` once before Step 1 too, so errors that were already there are not blamed on this task. - [ ] **Step 5: Watch the events arrive** Run (it listens 15 s; ask the user to type a few words and press BackSpace in any window meanwhile): ```bash timeout 15 socat -u UNIX-CONNECT:"$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" - | grep '^custom>>' ``` Expected: lines `custom>>keystone-typing`, at most one a second while typing, and `custom>>keystone-delete` per BackSpace. If `socat` is missing, use `nc -U` with the same path. If no `custom>>` line arrives, stop: the QML in Task 8 depends on this exact form, so report what socket2 printed instead. - [ ] **Step 6: Ask the user to confirm BackSpace still deletes** In a terminal and in the browser. This is open check 1; it cannot be measured from here. If BackSpace stops deleting, remove the `hl.bind("BackSpace", ...)` line, reload, and report: the mock reaction then needs another source. - [ ] **Step 7: Commit in hypr-theme** ```bash cd ~/Programming/GIT/hypr-theme git add sections/companion.lua hyprland.lua sections/keybindings.lua git commit -F - <<'EOF' companion: typing and delete events, SUPER+\ toggle The quickshell desktop companion reacts to typing and to BackSpace, and a layer surface that never takes the keyboard sees neither, so the compositor sends them as custom events: a typing pulse at most once a second with no key data, and a non_consuming BackSpace bind so the key still deletes. The toggle binds code:49 rather than the backslash keysym, which sits on different keys in the it and us layouts. Co-Authored-By: Claude Opus 5.5 EOF ``` --- ### Task 2: One "balloon on screen" check for both shells **Files:** - Modify: `shared/NotifyConfig.js` (add after `withheld`, which ends at line 72) - Modify: `shared/test_notify_config.js` (the `new Function` line 21, and new asserts before `console.log("ok")`) - Modify: `notifications/NotificationBalloon.qml:35-41` - [ ] **Step 1: Write the failing test** In `shared/test_notify_config.js`, change line 21 to: ```js const C = new Function(src + "\nreturn { defaults, resolve, withheld, soundFor, shown };")(); ``` and insert before the final `console.log("ok");`: ```js // On screen: the drawer, snooze, mute, DND and expiry, in one check that // the balloons and the desktop companion share. const live = { app: "ok", urgency: "normal", expires: 0 }; assert.strictEqual(C.shown(cfg, live, false, false, 0, 5000), true); assert.strictEqual(C.shown(cfg, live, false, true, 0, 5000), false); // drawer open assert.strictEqual(C.shown(cfg, live, false, false, 6000, 5000), false); // snoozed assert.strictEqual(C.shown(cfg, { app: "spam", urgency: "normal", expires: 0 }, false, false, 0, 5000), false); assert.strictEqual(C.shown(cfg, live, true, false, 0, 5000), false); // DND const timed = { app: "ok", urgency: "normal", expires: 5000 }; assert.strictEqual(C.shown(cfg, timed, false, false, 0, 4999), true); assert.strictEqual(C.shown(cfg, timed, false, false, 0, 5000), false); // expired ``` - [ ] **Step 2: Run it to see it fail** Run: `node shared/test_notify_config.js` Expected: FAIL, `ReferenceError: shown is not defined`. - [ ] **Step 3: Implement `shown`** In `shared/NotifyConfig.js`, after the closing `}` of `withheld`: ```js // Whether a queued notification's balloon is on screen at `now`: the drawer // and snooze withhold everything, then mute and DND, then its own expiry. // The balloon shell and the desktop companion both ask this, so they cannot // disagree about what is showing. function shown(cfg, n, dnd, drawerOpen, snoozeUntil, now) { if (drawerOpen || snoozeUntil > now) return false; if (withheld(cfg, n, dnd)) return false; return n.expires === 0 || now < n.expires; } ``` - [ ] **Step 4: Run it to see it pass** Run: `node shared/test_notify_config.js` Expected: `ok` - [ ] **Step 5: Use it in the balloon** In `notifications/NotificationBalloon.qml`, replace the `visible: { ... }` block (lines 35-41) with: ```qml visible: NC.shown(Notify.config, b.notification, Notify.dnd, Notify.drawerOpen, Notify.snoozeUntil, b.now) ``` Keep the comment above it unchanged; it still describes the rule. - [ ] **Step 6: Commit** ```bash git add shared/NotifyConfig.js shared/test_notify_config.js notifications/NotificationBalloon.qml git commit -F - <<'EOF' notify: one "balloon on screen" check, NotifyConfig.shown The desktop companion needs to know when a balloon is showing in her corner, and the rule lived inline in NotificationBalloon's visible binding. It moves to the shared library so both shells ask the same function: drawer, snooze, mute, DND, then expiry. Shared files do not hot-reload through their links: restart the notifications and desktop shells after pulling this. Co-Authored-By: Claude Opus 5.5 EOF ``` --- ### Task 3: Mood.js, the state decision **Files:** - Create: `desktop/companion/Mood.js` - Test: `desktop/companion/test_mood.js` - [ ] **Step 1: Write the failing test** `desktop/companion/test_mood.js` (GPLv2 header first, as in `desktop/assistant/test_md.js`): ```js // Run: node desktop/companion/test_mood.js // Mood.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 + "/Mood.js", "utf8") .replace(/^\.pragma library$/m, ""); const M = new Function(src + "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue };")(); const NOW = 10000000; // A snapshot with nothing going on, overridden per case. const calm = over => Object.assign({ now: NOW, destructAt: 0, dodging: false, shoved: false, recording: false, busy: false, repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, monologue: false, idle: 0, }, over); const state = over => M.pick(calm(over)).state; const frame = over => M.pick(calm(over)).frame; // Nothing going on: the rest pose. assert.deepStrictEqual(M.pick(calm({})), { state: "base", frame: "base" }); // The priority table, top to bottom: with every trigger from a row down // active at once, that row wins. Merged so a higher row's values win. const ladder = [ ["destruct", { destructAt: NOW - 10 }], ["scorched", { destructAt: NOW - M.T.destruct - 10 }], ["dodge", { dodging: true }], ["shoved", { shoved: true }], ["listen", { recording: true }], ["think", { busy: true }], ["wave", { repliedAt: NOW - 10 }], ["clicks", { clicks: { count: 2, at: NOW - 10 } }], ["mock", { mockAt: NOW - 10 }], ["watch", { typingAt: NOW - 10 }], ["stretch", { wakeAt: NOW - 10 }], ["muse", { monologue: true }], ["lying", { idle: 3 }], ]; for (let i = 0; i < ladder.length; i++) { const all = Object.assign({}, ...ladder.slice(i).map(l => l[1]).reverse()); assert.strictEqual(state(all), ladder[i][0], "everything from " + ladder[i][0] + " down"); } // Idle levels and their frames. assert.deepStrictEqual(M.pick(calm({ idle: 1 })), { state: "bored", frame: "sleepy" }); assert.deepStrictEqual(M.pick(calm({ idle: 2 })), { state: "yawn", frame: "yawn" }); assert.deepStrictEqual(M.pick(calm({ idle: 3 })), { state: "lying", frame: "lying" }); // Holds end on time. assert.strictEqual(state({ repliedAt: NOW - M.T.waveHold }), "base"); assert.strictEqual(state({ typingAt: NOW - M.T.typingHold }), "base"); assert.strictEqual(state({ wakeAt: NOW - M.T.wakeHold }), "base"); assert.strictEqual(state({ clicks: { count: 3, at: NOW - M.T.clickHold } }), "base"); assert.strictEqual(state({ destructAt: NOW - M.T.destruct - M.T.scorched }), "base"); // The mock points first, then smirks. assert.strictEqual(frame({ mockAt: NOW - 10 }), "point"); assert.strictEqual(frame({ mockAt: NOW - M.T.mockHold / 2 - 10 }), "smirk"); assert.strictEqual(state({ mockAt: NOW - M.T.mockHold }), "base"); // A click burst: within burstGap it grows, past it it starts over. let c = { count: 0, at: 0 }; c = M.click(c, 1000); assert.deepStrictEqual(c, { count: 1, at: 1000 }); c = M.click(c, 1000 + M.T.burstGap); assert.strictEqual(c.count, 2); c = M.click(c, c.at + M.T.burstGap + 1); assert.strictEqual(c.count, 1); c = { count: 0, at: 0 }; for (let i = 0; i < 7; i++) c = M.click(c, 5000 + i * 300); assert.strictEqual(c.count, 7); // 1 smiles, 2-6 blush harder. assert.deepStrictEqual([1, 2, 3, 4, 5, 6].map(M.clickFrame), ["smile", "blush1", "blush1", "blush2", "blush2", "blush3"]); // Outfit: the dress until the flash is over, then the leotard for // scorched + skirtless, then the dress again. assert.strictEqual(M.outfit(0, NOW), "dress"); assert.strictEqual(M.outfit(NOW - 10, NOW), "dress"); assert.strictEqual(M.outfit(NOW - M.T.destruct, NOW), "leotard"); const bare = M.T.destruct + M.T.scorched + M.T.skirtless; assert.strictEqual(M.outfit(NOW - bare + 10, NOW), "leotard"); assert.strictEqual(M.outfit(NOW - bare, NOW), "dress"); // Cooldowns. assert.strictEqual(M.mockAllowed(0, NOW), true); assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown + 1, NOW), false); assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown, NOW), true); assert.strictEqual(M.canSpeak(0, NOW), true); assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown + 1, NOW), false); assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown, NOW), true); // The monologue: 1 in 4 on boredom, 1 in 3 on being shown, once an hour. assert.strictEqual(M.monologue("bored", 0, NOW, 0.24), true); assert.strictEqual(M.monologue("bored", 0, NOW, 0.25), false); assert.strictEqual(M.monologue("shown", 0, NOW, 0.33), true); assert.strictEqual(M.monologue("shown", 0, NOW, 0.34), false); assert.strictEqual(M.monologue("other", 0, NOW, 0), false); assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown + 1, NOW, 0), false); assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown, NOW, 0), true); console.log("ok"); ``` - [ ] **Step 2: Run it to see it fail** Run: `node desktop/companion/test_mood.js` Expected: FAIL, `ENOENT: no such file or directory, open '.../Mood.js'`. - [ ] **Step 3: Write `Mood.js`** `desktop/companion/Mood.js` (GPLv2 header first): ```js .pragma library // Which state the companion is in, from a snapshot of her inputs. Pure, so // test_mood.js drives it with plain objects and a fake clock. Companion.qml // owns the inputs and the timers; this only decides. Times in milliseconds. var T = { burstGap: 1500, // a click burst ends after this long without a click clickHold: 2000, // a click reaction lasts this long after the last click mockHold: 2500, mockCooldown: 20000, typingHold: 2000, waveHold: 3000, wakeHold: 1500, // the stretch after idling lineCooldown: 30000, monologueCooldown: 3600000, destruct: 1000, // the glow and the flash; the sprite is hidden under it scorched: 3000, skirtless: 60000, }; function st(state, frame) { return { state: state, frame: frame }; } // The burst after a click at now. function click(prev, now) { if (prev.count > 0 && now - prev.at <= T.burstGap) return { count: prev.count + 1, at: now }; return { count: 1, at: now }; } // Click 1 smiles; 2 to 6 blush harder, in three steps. The 7th is the // self-destruct, which Companion.qml starts rather than shows. function clickFrame(count) { if (count <= 1) return "smile"; if (count <= 3) return "blush1"; if (count <= 5) return "blush2"; return "blush3"; } // The spec's priority table, top to bottom: the first that applies wins. // idle is 0 awake, 1 bored, 2 yawning, 3 lying down. function pick(s) { const since = s.destructAt ? s.now - s.destructAt : Infinity; if (since < T.destruct) return st("destruct", "base"); if (since < T.destruct + T.scorched) return st("scorched", "scorched"); if (s.dodging) return st("dodge", "base"); if (s.shoved) return st("shoved", "mad"); if (s.recording) return st("listen", "listen"); if (s.busy) return st("think", "think"); if (s.now - s.repliedAt < T.waveHold) return st("wave", "wave"); if (s.clicks.count > 0 && s.now - s.clicks.at < T.clickHold) return st("clicks", clickFrame(s.clicks.count)); if (s.now - s.mockAt < T.mockHold) return st("mock", s.now - s.mockAt < T.mockHold / 2 ? "point" : "smirk"); if (s.now - s.typingAt < T.typingHold) return st("watch", "watch"); if (s.now - s.wakeAt < T.wakeHold) return st("stretch", "stretch"); if (s.monologue) return st("muse", "muse"); if (s.idle >= 3) return st("lying", "lying"); if (s.idle === 2) return st("yawn", "yawn"); if (s.idle === 1) return st("bored", "sleepy"); return st("base", "base"); } // The self-destruct takes the skirt off once the flash is over, for the // scorched moment and the skirtless minute after it. Showing her again // sets destructAt back to 0, which restores it early. function outfit(destructAt, now) { if (!destructAt) return "dress"; const since = now - destructAt; return since >= T.destruct && since < T.destruct + T.scorched + T.skirtless ? "leotard" : "dress"; } function mockAllowed(lastMockAt, now) { return now - lastMockAt >= T.mockCooldown; } function canSpeak(lastLineAt, now) { return now - lastLineAt >= T.lineCooldown; } // Whether the opening monologue plays: a chance per trigger, and never // twice within the hour. roll is Math.random(), passed in for the test. function monologue(trigger, lastAt, now, roll) { if (now - lastAt < T.monologueCooldown) return false; const chance = trigger === "bored" ? 1 / 4 : trigger === "shown" ? 1 / 3 : 0; return roll < chance; } ``` - [ ] **Step 4: Run it to see it pass** Run: `node desktop/companion/test_mood.js` Expected: `ok` - [ ] **Step 5: Commit** ```bash git add desktop/companion/Mood.js desktop/companion/test_mood.js git commit -F - <<'EOF' companion: Mood.js, which state wins The spec's priority table as a pure function over a snapshot of her inputs, with the click burst, the outfit after the self-destruct, the mock and line cooldowns and the monologue's odds beside it. Pure so the node check can drive it with a fake clock; Companion.qml will own the inputs and timers. Co-Authored-By: Claude Opus 5.5 EOF ``` --- ### Task 4: Lines.js, what she says **Files:** - Create: `desktop/companion/Lines.js` - [ ] **Step 1: Write `Lines.js`** GPLv2 header first, then: ```js .pragma library // What she says, in English, grouped by the reaction that says it. // Companion.qml picks one at random per reaction and keeps to one line in // 30 s, except for clicks and the self-destruct (Mood.canSpeak). var lines = { shown: ["Glory to mankind.", "2B, reporting.", "...Back on duty."], bored: ["...Operator, I'm bored.", "Nothing to report.", "Awaiting orders."], wake: ["I'm awake.", "Systems online."], mock: ["Typo again?", "Delete, delete, delete.", "Were you aiming for that key?"], shoved: ["Hey.", "Rude.", "I was standing here."], ready: ["Answer's ready.", "Your reply came in."], click: ["...Yes?", "Can I help you?"], blush: ["S-stop that.", "...Operator."], dodge: ["...Fine.", "I'll move."], destruct: ["Self-destruct sequence initiated."], aftermath: ["...That was unpleasant."], }; // The game's opening, one sentence per bubble. var monologue = [ "Everything that lives is designed to end.", "We are perpetually trapped in a never-ending spiral of life and death.", "Is this a curse?", "Or some kind of punishment?", "I often think about the god who blessed us with this cryptic puzzle...and wonder if we'll ever get the chance to kill him.", ]; // A line for kind; roll is Math.random(). "" for a kind with no lines. function pick(kind, roll) { const l = lines[kind] || []; return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : ""; } ``` - [ ] **Step 2: Check it evaluates** Run: ```bash node -e 'const s=require("fs").readFileSync("desktop/companion/Lines.js","utf8").replace(/^\.pragma library$/m,""); const L=new Function(s+"\nreturn {lines,monologue,pick};")(); console.log(L.pick("mock",0), "|", L.pick("mock",0.999), "|", JSON.stringify(L.pick("nope",0)), "|", L.monologue.length)' ``` Expected: `Typo again? | Were you aiming for that key? | "" | 5` - [ ] **Step 3: Commit** ```bash git add desktop/companion/Lines.js git commit -F - <<'EOF' companion: her lines and the opening monologue Fixed English lines per reaction, terse in the game's register, and the opening monologue split at its sentences so each fills one bubble. Co-Authored-By: Claude Opus 5.5 EOF ``` --- ### Task 5: prep-frames, and a placeholder base **Files:** - Create: `desktop/companion/prep-frames` (executable) - [ ] **Step 1: Write the script** ```bash #!/bin/bash # # (GPLv2 header, as in desktop/modules/ai/ai-state.sh lines 2-12) # # prep-frames: the companion's generated images to the frames she shows. # # prep-frames [ART_DIR] default: ~/.local/share/keystone/companion/2b # # Reads ART_DIR/dress/*.png and ART_DIR/leotard/*.png, 1024x1024 on white, # and writes ART_DIR/frames//.png: background removed, trimmed # to her, scaled by a quarter. One scale for every frame keeps her the same # size whatever the pose, which scaling each to a fixed height would not: # lying down would come out huge. Sprite.qml draws them at half size and # stands each on its bottom edge. # # The background goes by flood fill from the four corners: the dark line art # stops the fill, so the white hair and the white leotard inside it stay. set -euo pipefail art=${1:-$HOME/.local/share/keystone/companion/2b} n=0 for outfit in dress leotard; do [[ -d $art/$outfit ]] || continue mkdir -p "$art/frames/$outfit" for src in "$art/$outfit"/*.png; do [[ -e $src ]] || continue read -r w h < <(magick identify -format '%w %h\n' "$src") magick "$src" -alpha set -fuzz 6% -fill none \ -draw "color 0,0 floodfill" -draw "color $((w - 1)),0 floodfill" \ -draw "color 0,$((h - 1)) floodfill" -draw "color $((w - 1)),$((h - 1)) floodfill" \ -trim +repage -resize 25% "$art/frames/$outfit/$(basename "$src")" n=$((n + 1)) done done echo "prep-frames: $n frames in $art/frames" ``` Then `chmod +x desktop/companion/prep-frames`. - [ ] **Step 2: Check it on a copy of the sitting base** Run: ```bash T=$(mktemp -d) && mkdir "$T/dress" && cp ~/.local/share/keystone/companion/2b/base-sitting.png "$T/dress/base.png" \ && desktop/companion/prep-frames "$T" \ && magick "$T/frames/dress/base.png" -format '%w %h corner=%[fx:p{0,0}.a] hair=%[fx:p{w/2,h*0.15}.a]\n' info: ; rm -rf "$T" ``` Expected: `prep-frames: 1 frames in ...`, then about `173 255 corner=0 hair=1`: transparent corner, opaque hair. A `hair=0` means the fill ate her hair: stop and report. - [ ] **Step 3: Put a placeholder base in place** The standing base is still being generated (spec, "She stands"). Until it exists the sitting one stands in, so the component has a frame to show: ```bash A=~/.local/share/keystone/companion/2b mkdir -p "$A/dress" && [ -e "$A/dress/base.png" ] || cp "$A/base-sitting.png" "$A/dress/base.png" desktop/companion/prep-frames ls "$A/frames/dress" ``` Expected: `base.png` listed. If the user has already delivered the standing base, it is used instead: the `[ -e ]` keeps it. - [ ] **Step 4: Commit** ```bash git add desktop/companion/prep-frames git commit -F - <<'EOF' companion: prep-frames, generated images to display frames Background removed by flood fill from the corners, which the line art stops, so white hair survives; trimmed; scaled by one quarter for every frame, so she stays one size across poses. The art itself stays outside the repo. Co-Authored-By: Claude Opus 5.5 EOF ``` --- ### Task 6: The assistant says when a reply arrives **Files:** - Modify: `desktop/assistant/Assistant.qml` (after line 76, `readonly property bool busy: ...`; and the `case "done":` branch, line ~408) - [ ] **Step 1: Add the signal** After `readonly property bool busy: root.state !== "idle"`: ```qml // A reply finished (not stopped). The canvas notifies through notifyDone; // the desktop companion waves. signal replied() ``` - [ ] **Step 2: Emit it** In `case "done":`, replace ```qml if (!e.stopped && i >= 0) root.notifyDone(turns.get(i).reply); ``` with ```qml if (!e.stopped && i >= 0) { root.notifyDone(turns.get(i).reply); root.replied(); } ``` - [ ] **Step 3: Check the reload** Run: `sleep 2; qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -5` Expected: a `Reloading configuration...` after the edit's mtime and `Configuration Loaded`, no error. If no reload line follows, see AGENTS.md, Reloading: force one by changing a file that imports the directory. - [ ] **Step 4: Commit** ```bash git add desktop/assistant/Assistant.qml git commit -F - <<'EOF' assistant: replied() when a reply finishes The desktop companion waves when an answer is ready. The moment already existed as the done branch that calls notifyDone; it is now a signal too, so the companion connects to it instead of reading the turn list. Co-Authored-By: Claude Opus 5.5 EOF ``` --- ### Task 7: Sprite.qml **Files:** - Create: `desktop/companion/Sprite.qml` - [ ] **Step 1: Write it** GPLv2 header first, then: ```qml import QtQuick import ".." // One frame of the companion, drawn at half its pixel size and standing on // its bottom edge: a crossfade between frames, a slow bob, the drop out of // view and back, and the self-destruct flash. Which frame is Mood.js's // business; this only draws it. Item { id: s // The frame, and what to show when it is missing: the same outfit's // base, never the other outfit's, so a gap in the art cannot put the // skirt back mid-scene. property string source property string fallback // Dropped below the bottom edge and faded out. property bool out: false signal settled(bool out) // Two images: the front one stays until the back one has loaded, then // they swap, so a frame change never flashes empty. property bool flip: false readonly property Image front: s.flip ? b : a width: s.front.implicitWidth / 2 height: s.front.implicitHeight / 2 function load(url) { const front = s.flip ? b : a, back = s.flip ? a : b; if (String(front.source) === url) { back.source = ""; return; } if (String(back.source) === url && back.status === Image.Ready) { s.flip = !s.flip; return; } back.source = url; } onSourceChanged: s.load(s.source) Component.onCompleted: s.load(s.source) function ready(img) { if (img.status === Image.Error && String(img.source) !== s.fallback) { img.source = s.fallback; return; } if (img.status === Image.Ready && img !== s.front) s.flip = !s.flip; } property real drop: 0 property real bob: 0 NumberAnimation { id: dropAnim target: s; property: "drop"; duration: 320 onFinished: s.settled(s.out) } onOutChanged: { dropAnim.stop(); dropAnim.to = s.out ? s.height + 24 : 0; dropAnim.easing.type = s.out ? Easing.InCubic : Easing.OutBack; dropAnim.start(); } SequentialAnimation on bob { running: !s.out loops: Animation.Infinite NumberAnimation { to: 2; duration: 1900; easing.type: Easing.InOutSine } NumberAnimation { to: 0; duration: 2300; easing.type: Easing.InOutSine } } Item { anchors.fill: parent opacity: 1 - s.drop / (s.height + 24) transform: Translate { y: s.drop - s.bob } Image { id: a width: implicitWidth / 2; height: implicitHeight / 2 anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter } smooth: true; mipmap: true; asynchronous: true opacity: s.flip ? 0 : 1 Behavior on opacity { NumberAnimation { duration: 120 } } onStatusChanged: s.ready(a) } Image { id: b width: implicitWidth / 2; height: implicitHeight / 2 anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter } smooth: true; mipmap: true; asynchronous: true opacity: s.flip ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 120 } } onStatusChanged: s.ready(b) } } // --- self-destruct: the glow swells into a flash, then a ring and // sparks burst out. About 1.1 s, matching Mood.T.destruct, during which // the frame swaps to the leotard under the flash. function explode() { boom.restart(); } property real burst: 0 Rectangle { id: flash anchors.centerIn: parent width: s.height * 1.6; height: width; radius: width / 2 color: "white"; opacity: 0; scale: 0 } Rectangle { id: ring anchors.centerIn: parent width: s.height * 2; height: width; radius: width / 2 color: "transparent" border { width: 3; color: Theme.peach } opacity: 0; scale: 0 } Repeater { model: 8 Rectangle { required property int index readonly property real angle: index * Math.PI / 4 width: 6; height: 6; radius: 3 color: index % 2 ? Theme.peach : Theme.yellow x: s.width / 2 - 3 + Math.cos(angle) * s.burst * s.height y: s.height / 2 - 3 + Math.sin(angle) * s.burst * s.height opacity: s.burst > 0 && s.burst < 1 ? 1 - s.burst : 0 } } SequentialAnimation { id: boom ParallelAnimation { NumberAnimation { target: flash; property: "scale"; from: 0; to: 1; duration: 600; easing.type: Easing.InQuad } NumberAnimation { target: flash; property: "opacity"; from: 0; to: 0.95; duration: 600 } } ParallelAnimation { NumberAnimation { target: flash; property: "opacity"; to: 0; duration: 400 } NumberAnimation { target: ring; property: "scale"; from: 0.2; to: 1.4; duration: 400; easing.type: Easing.OutCubic } NumberAnimation { target: ring; property: "opacity"; from: 1; to: 0; duration: 400 } NumberAnimation { target: s; property: "burst"; from: 0; to: 1; duration: 500; easing.type: Easing.OutCubic } } PropertyAction { target: s; property: "burst"; value: 0 } } } ``` No commit yet: nothing loads it until Task 8, and they are committed together. --- ### Task 8: Companion.qml and the wiring **Files:** - Create: `desktop/companion/Companion.qml` - Modify: `desktop/shell.qml` (imports at line 42; the `Assistant { ... }` line ~124) - [ ] **Step 1: Write `Companion.qml`** GPLv2 header first, then: ```qml import Quickshell import Quickshell.Io import Quickshell.Wayland import Quickshell.Hyprland import QtQuick import ".." import "../NotifyConfig.js" as NC import "Mood.js" as Mood import "Lines.js" as Lines // The desktop companion: a chibi 2B in the bottom-right corner of the // focused monitor. Inputs and timers live here, the decision in Mood.js, // the drawing in Sprite.qml. See README.md here and // docs/superpowers/specs/2026-10-23-companion-design.md. PanelWindow { id: root required property var asst readonly property string art: Quickshell.env("HOME") + "/.local/share/keystone/companion/2b/frames" // SUPER+\, through the IPC below. property bool present: true // The monitor she is on trails the focused one: a change plays her exit // here first, and only then moves the window (settled, below). readonly property string focused: Hyprland.focusedMonitor?.name ?? "" property string here: "" property bool hopping: false onFocusedChanged: { if (root.here === "") root.here = root.focused; else if (root.focused !== root.here) root.hopping = true; } Component.onCompleted: root.here = root.focused screen: Quickshell.screens.find(s => s.name === root.here) ?? Quickshell.screens[0] anchors { right: true; bottom: true } margins { right: 24; bottom: 16 } implicitWidth: 760 implicitHeight: 260 color: "transparent" exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Top WlrLayershell.namespace: "keystone-companion" WlrLayershell.keyboardFocus: WlrKeyboardFocus.None // Her body only: clicks anywhere else, the bubble included, reach the // window below. Out of view the region is empty. mask: Region { item: hit } // --- inputs --------------------------------------------------------- property double now: Date.now() Timer { interval: 200; repeat: true; running: root.present; onTriggered: root.now = Date.now() } property var clicks: ({ count: 0, at: 0 }) property double destructAt: 0 property bool dodging: false property double repliedAt: 0 property double mockAt: 0 property double typingAt: 0 property double wakeAt: 0 property double lastLineAt: 0 property double lastMonologueAt: 0 property bool monologue: false // Bored, yawning, lying down at 1, 3 and 5 minutes. Inhibitors count, // so presentation mode keeps her awake. enabled is set explicitly: left // out, it read false on creation. IdleMonitor { id: idle1; enabled: true; respectInhibitors: true; timeout: 60 } IdleMonitor { id: idle2; enabled: true; respectInhibitors: true; timeout: 180 } IdleMonitor { id: idle3; enabled: true; respectInhibitors: true; timeout: 300 } readonly property int idle: idle3.isIdle ? 3 : idle2.isIdle ? 2 : idle1.isIdle ? 1 : 0 property int wasIdle: 0 onIdleChanged: { const t = Date.now(); if (root.idle === 0 && root.wasIdle > 0) { root.wakeAt = t; root.say("wake"); } else if (root.idle === 1 && root.wasIdle === 0) { if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue(); else root.say("bored"); } root.wasIdle = root.idle; } // 2 is real fullscreen; 1 is maximised, which is most windows here and // must not hide her. lastIpcObject is only current after a refresh, and // the island measured that it settles a moment after the event. property int fullscreen: 0 Timer { id: fsSettle interval: 60 onTriggered: root.fullscreen = Hyprland.activeToplevel?.lastIpcObject?.fullscreen ?? 0 } Connections { target: Hyprland function onRawEvent(event) { if (event.name === "custom") { // From hypr-theme's sections/companion.lua. const what = event.data.trim(); const t = Date.now(); if (what === "keystone-typing") { root.typingAt = t; } else if (what === "keystone-delete") { root.typingAt = t; if (Mood.mockAllowed(root.mockAt, t)) { root.mockAt = t; root.say("mock"); } } } else if (["fullscreen", "activewindowv2", "workspacev2"].includes(event.name)) { Hyprland.refreshToplevels(); fsSettle.restart(); } } } Connections { target: root.asst function onReplied() { root.repliedAt = Date.now(); root.say("ready"); } } // Balloons stack bottom-right of their screen. When that is her corner, // any balloon on screen shoves her left past the stack. readonly property bool shoved: { const place = Notify.config.balloons; if (place.screen !== root.here || place.corner !== "bottom-right") return false; return (Notify.queue || []).some(n => NC.shown(Notify.config, n, Notify.dnd, Notify.drawerOpen, Notify.snoozeUntil, root.now)); } onShovedChanged: if (root.shoved) root.say("shoved") // --- state ---------------------------------------------------------- readonly property var mood: Mood.pick({ now: root.now, destructAt: root.destructAt, dodging: root.dodging, shoved: root.shoved, recording: root.asst.recording, busy: root.asst.busy, repliedAt: root.repliedAt, clicks: root.clicks, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt, monologue: root.monologue, idle: root.idle, }) readonly property string outfit: Mood.outfit(root.destructAt, root.now) // Anything above the monologue cuts it short. onMoodChanged: if (root.monologue && root.mood.state !== "muse") root.stopMonologue() // Hidden: no sprite, no bubble. Away adds the dodge, which hides the // sprite but lets its line show. readonly property bool hidden: !root.present || root.fullscreen === 2 || root.hopping readonly property bool away: root.hidden || root.dodging // --- speech --------------------------------------------------------- function say(kind, force) { const t = Date.now(); if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return; const line = Lines.pick(kind, Math.random()); if (line === "") return; root.lastLineAt = t; bubble.show(line, 3000); } // The opening monologue: about 15 characters a second, one sentence // per bubble, 1.5 s between sentences, the last held 4 s. property int monoLine: 0 function startMonologue() { if (root.hidden) return; root.monologue = true; root.lastMonologueAt = Date.now(); root.lastLineAt = root.lastMonologueAt; root.monoLine = 0; typer.begin(Lines.monologue[0]); } function stopMonologue() { root.monologue = false; typer.stop(); typerHold.stop(); bubble.hide(); } Timer { id: typer property string full: "" interval: 66 repeat: true function begin(text) { typer.full = text; bubble.typed(""); typer.restart(); } onTriggered: { const n = bubble.text.length + 1; bubble.typed(typer.full.slice(0, n)); if (n < typer.full.length) return; typer.stop(); typerHold.interval = root.monoLine === Lines.monologue.length - 1 ? 4000 : 1500; typerHold.restart(); } } Timer { id: typerHold onTriggered: { root.monoLine++; if (root.monoLine >= Lines.monologue.length) root.stopMonologue(); else typer.begin(Lines.monologue[root.monoLine]); } } // --- clicks and the dodge ------------------------------------------- function clicked() { const t = Date.now(); root.now = t; root.clicks = Mood.click(root.clicks, t); if (root.clicks.count >= 7) { root.clicks = { count: 0, at: 0 }; root.stopMonologue(); root.destructAt = t; sprite.explode(); root.say("destruct", true); aftermath.restart(); } else if (root.clicks.count === 1) { root.say("click", true); } else if (root.clicks.count === 4) { root.say("blush", true); } } Timer { id: aftermath; interval: Mood.T.destruct + 400; onTriggered: root.say("aftermath", true) } // She cannot see the pointer coming, only feel it on her: Quickshell has // no global pointer and Hyprland sends no motion events. A click before // the 0.6 s still reaches her. Timer { id: hover interval: 600 onTriggered: { if (Math.random() < 0.5) root.say("dodge"); root.dodging = true; dodgeBack.restart(); } } Timer { id: dodgeBack; interval: 5000; onTriggered: root.dodging = false } // --- showing and hiding ---------------------------------------------- function appear() { if (root.present) return; root.present = true; root.destructAt = 0; // the skirt is back root.now = Date.now(); greet.restart(); } function dismiss() { if (!root.present) return; root.stopMonologue(); bubble.hide(); root.present = false; } // After the drop-in has landed. Timer { id: greet interval: 450 onTriggered: { if (Mood.monologue("shown", root.lastMonologueAt, Date.now(), Math.random())) root.startMonologue(); else root.say("shown", true); } } // qs -p ipc call companion toggle -> SUPER+\ IpcHandler { target: "companion" function toggle() { if (root.present) root.dismiss(); else root.appear(); } function show() { root.appear(); } function hide() { root.dismiss(); } } // The exit has played on the old monitor: move, and the entrance plays. function settled(out) { if (out && root.hopping) { root.here = root.focused; root.hopping = false; } } // --- drawing -------------------------------------------------------- // Where she stands: the bottom-right of the window, or left of the // balloon stack. 336 = the stack's 340 px plus its 12 px margin // (notifications/Balloons.qml), minus this window's 24 px right margin, // plus 8 px of air. Item { id: seat width: sprite.width height: sprite.height anchors.bottom: parent.bottom x: root.width - width - (root.shoved ? 336 : 0) Behavior on x { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } } } Sprite { id: sprite x: seat.x y: seat.y source: `file://${root.art}/${root.outfit}/${root.mood.frame}.png` fallback: `file://${root.art}/${root.outfit}/base.png` out: root.away onSettled: out => root.settled(out) } Item { id: hit x: sprite.x y: sprite.y width: root.away ? 0 : sprite.width height: root.away ? 0 : sprite.height MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onEntered: hover.restart() onExited: hover.stop() onClicked: { hover.stop(); root.clicked(); } } } // Up and to the left of her head, anchored to where she stands rather // than to the sprite, so a dodge line stays where she was. Rectangle { id: bubble property alias text: label.text function show(t, ms) { label.text = t; bubble.opacity = 1; hideTimer.interval = ms; hideTimer.restart(); } function typed(t) { label.text = t; bubble.opacity = 1; hideTimer.stop(); } function hide() { hideTimer.stop(); bubble.opacity = 0; } visible: !root.hidden && opacity > 0 && label.text !== "" anchors { right: seat.left; rightMargin: -14; bottom: seat.top; bottomMargin: -22 } width: Math.min(260, label.implicitWidth + 24) height: label.implicitHeight + 16 radius: 12 color: Qt.alpha(Theme.base, 0.92) border { width: 1; color: Qt.alpha(Theme.text, 0.25) } opacity: 0 Behavior on opacity { NumberAnimation { duration: 180 } } Timer { id: hideTimer; onTriggered: bubble.opacity = 0 } Text { id: label anchors.centerIn: parent width: Math.min(236, implicitWidth) wrapMode: Text.WordWrap color: Theme.text font { family: Theme.fontFamily; pixelSize: 13 } } Rectangle { width: 10; height: 10; rotation: 45 color: bubble.color anchors { right: parent.right; rightMargin: 18; bottom: parent.bottom; bottomMargin: -5 } } } } ``` - [ ] **Step 2: Wire it into the shell** In `desktop/shell.qml`, after `import "assistant"` (line 42): ```qml import "companion" ``` and after the `Assistant { id: assistant; ... }` line: ```qml // The desktop companion, bottom-right of the focused monitor, on SUPER+\. // She reads the assistant's state. See companion/README.md. Companion { asst: assistant } ``` - [ ] **Step 3: Check the reload and the layer** Run: ```bash sleep 2; qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -8 hyprctl layers -j | jq -r '.[] | .levels[]?[] | select(.namespace=="keystone-companion") | "x=\(.x) y=\(.y) w=\(.w) h=\(.h)"' ``` Expected: `Configuration Loaded` with no `companion` error or warning after it, and one layer line, `w=760 h=260`, at the bottom-right of the focused monitor. A `ReferenceError` naming `Companion` means the new directory was not indexed: see AGENTS.md, Reloading, and ask the user to run `./reload.sh desktop`. - [ ] **Step 4: Run both JS checks again** Run: `node desktop/companion/test_mood.js && node shared/test_notify_config.js` Expected: `ok` twice. - [ ] **Step 5: Commit** ```bash git add desktop/companion/Sprite.qml desktop/companion/Companion.qml desktop/shell.qml git commit -F - <<'EOF' companion: the window, her inputs and her drawing Companion.qml is a Top-layer PanelWindow bottom-right of the focused monitor, masked to her body so every other click passes through. It gathers the inputs (Hyprland's custom typing and delete events, the assistant's state and replied signal, the balloons on her screen, three IdleMonitors, clicks and a hover), hands a snapshot to Mood.js, and speaks from Lines.js. Sprite.qml crossfades frames, bobs, drops out of view and back, and plays the self-destruct. Maximised windows report fullscreen 1 and most windows here are, so only 2 hides her. She only feels the pointer once it is on her, so the dodge waits 0.6 s and a quick click still lands. Co-Authored-By: Claude Opus 5.5 EOF ``` --- ### Task 9: Notes **Files:** - Create: `desktop/companion/README.md` - Modify: `AGENTS.md` (the directory listing under "What this is") - Modify: `docs/superpowers/specs/2026-10-23-companion-design.md` (`lines.js` to `Lines.js`) - [ ] **Step 1: Write the README** ```markdown # companion A chibi 2B in the bottom-right corner of the focused monitor, on SUPER+\. The design, with what was measured on the way, is `docs/superpowers/specs/2026-10-23-companion-design.md`. ## Files | File | | | --- | --- | | `Companion.qml` | the window, inputs, timers, speech, IPC (`companion toggle/show/hide`) | | `Sprite.qml` | draws a frame: crossfade, bob, drop out and back, explosion | | `Mood.js` | which state wins, from a snapshot; all the timings are its `T` | | `Lines.js` | her lines and the opening monologue | | `prep-frames` | generated images to display frames | ## Art Not in the repo: the character is Square Enix's and this repo is GPLv2. It lives in `~/.local/share/keystone/companion/2b/`: dress/.png, leotard/.png generated, 1024x1024 on white frames//.png what she shows, from prep-frames recipe.txt how each was made, and what failed Run `prep-frames` after adding or replacing any generated image. A missing frame falls back to its outfit's `base`. ## Inputs - Typing and BackSpace come from `sections/companion.lua` in the hypr-theme repo, as `custom>>keystone-typing` and `custom>>keystone-delete` on socket2. Nothing else about the keyboard reaches her. - She only feels the pointer while it is over her: Quickshell has no global pointer and Hyprland sends no motion events. Hence the 0.6 s dodge rather than moving away as the pointer approaches. - `IdleMonitor.enabled` read false on creation when left out, so each of the three sets it. - Fullscreen is `lastIpcObject.fullscreen === 2`. Maximised is 1, and most windows here are maximised. - The lock screen covers every layer, so locking needs no code. ## Check node desktop/companion/test_mood.js ``` - [ ] **Step 2: List the component in AGENTS.md** In the directory listing under "What this is", after the `assistant/` line, add: ``` companion/ a chibi 2B, bottom-right of the focused monitor, on SUPER+\ ``` - [ ] **Step 3: Fix the spec's file name** In `docs/superpowers/specs/2026-10-23-companion-design.md`, replace `lines.js` with `Lines.js` (two places: the file tree and "Fixed English lines from `lines.js`"). - [ ] **Step 4: Commit** ```bash git add desktop/companion/README.md AGENTS.md docs/superpowers/specs/2026-10-23-companion-design.md git commit -F - <<'EOF' companion: README, AGENTS listing, Lines.js in the spec The README records the measurements the code depends on: where typing and BackSpace come from, why the dodge waits for the pointer, the IdleMonitor enabled default, and fullscreen 2 against maximised 1. Co-Authored-By: Claude Opus 5.5 EOF ``` --- ### Task 10: Restart and check by hand Visual, so the user does it. Ask them to run, from their own terminal: ``` ./reload.sh desktop notifications ``` (both link the edited `shared/NotifyConfig.js`; hot reload does not follow the link). Then go through, one at a time, and record the results in the README if anything differs: - [ ] She appears bottom-right of the focused monitor; SUPER+\ hides and shows her, with a greeting line. - [ ] Focus DP-3, then DP-1: she drops out on one and in on the other. - [ ] `notify-send test hello`: she slides left of the balloon, looks mad, says a line; slides back when it expires. - [ ] Type in a terminal: she watches. BackSpace: she points, then smirks; again within 20 s: no second mock. - [ ] Ask the assistant something with the canvas closed: think while it works, wave when it replies. - [ ] Rest the pointer on her: after about 0.6 s she drops away and returns 5 s later. A quick click instead: a smile. - [ ] Seven quick clicks: glow, flash, sparks, "Self-destruct sequence initiated.", then "...That was unpleasant." - [ ] A fullscreen video (F11 in the browser) hides her; a maximised window does not. This is the spec's open check 3. - [ ] Leave the machine 1 minute: bored (or the monologue); 3: yawn; 5: lying down; touch the mouse: stretch. - [ ] Clicks anywhere beside her and on the bubble reach the window underneath. Until the frames are made, every state shows the base: that is the fallback working, not a bug. --- ### Task 11: The frames (the user's, with sd-webui) Not code: a checklist for generating the art with the recipes in `recipe.txt`. Each image goes into `~/.local/share/keystone/companion/2b/dress/` or `leotard/` under the frame's name, then `desktop/companion/prep-frames`. - [x] `dress/base.png`: klein edit of `base-sitting.png`, the standing prompt (delivered 2026-10-23, before Task 5, so its placeholder step keeps it) - [ ] img2img from `dress/base.png`, strength 0.75: `smile`, `mad`, `smirk`, `blush1`, `blush2`, `blush3`, `sleepy` - [ ] klein edits of `dress/base.png`: `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch` - [ ] klein edit of each dress frame into `leotard/`: "her skirt is gone, revealing a plain white leotard; change nothing else" - [ ] klein edit of `leotard/base.png`: `leotard/scorched`