aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-23-companion-blush.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-10-23-companion-blush.md')
-rw-r--r--docs/superpowers/plans/2026-10-23-companion-blush.md655
1 files changed, 655 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-23-companion-blush.md b/docs/superpowers/plans/2026-10-23-companion-blush.md
new file mode 100644
index 0000000..4ae1842
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-23-companion-blush.md
@@ -0,0 +1,655 @@
+# Companion Blush 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:** The companion turns to a listed wallpaper (`watch` frame, 1.5 s) and blushes (`blush3`, 3 s) when the workspace in front of her is empty, at most once every 3 minutes.
+
+**Architecture:** `Mood.js` gains an `admire` state stamped by `admireAt`. A new singleton `desktop/companion/Admired.qml` owns the plain-text wallpaper list (pure helpers in `ListFile.js`) and what wallp has on each monitor. `Companion.qml` checks the trigger on workspace events and whenever she stops being away. The Companion settings page takes the list path (`Prefs.companionBlushFile`), and the wallpaper picker draws a heart toggle on each thumbnail.
+
+**Tech Stack:** Quickshell 0.3.1 QML, QML JS libraries tested under node, Hyprland raw events.
+
+Spec: `docs/superpowers/specs/2026-10-23-companion-blush-design.md`.
+
+Read AGENTS.md "Verifying, and how not to" and "Reloading" before running anything: never start `qs` from a tool call, `pkill -x qs` only, and a new component file needs `./reload.sh desktop` from the user's terminal.
+
+---
+
+## File map
+
+| File | Change |
+| --- | --- |
+| `desktop/companion/ListFile.js` | create: `parse`, `toggle`, `outputs`, pure |
+| `desktop/companion/Mood.js` | `T.admireLook`, `T.admireBlush`, `T.admireCooldown`, the `admire` row, `admireAllowed` |
+| `desktop/companion/test_mood.js` | tests for both |
+| `desktop/Prefs.qml` | `companionBlushFile` + setter |
+| `desktop/companion/Admired.qml` | create: the singleton |
+| `desktop/companion/Companion.qml` | trigger, `admireAt` in the snapshot, the line |
+| `desktop/companion/Lines.js` | `wallpaper` lines |
+| `desktop/modules/companion/CompanionModule.qml` | the path field |
+| `desktop/modules/appearance/WallpaperTab.qml` | the heart |
+| `desktop/companion/README.md`, spec | docs |
+
+---
+
+### Task 1: ListFile.js
+
+**Files:**
+- Create: `desktop/companion/ListFile.js`
+- Test: `desktop/companion/test_mood.js`
+
+- [ ] **Step 1: Write the failing test**
+
+In `test_mood.js`, after the `const M = ...` block, add:
+
+```js
+const lsrc = fs.readFileSync(__dirname + "/ListFile.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const L = new Function(lsrc + "\nreturn { parse, toggle, outputs };")();
+```
+
+Before `console.log("ok");`, add:
+
+```js
+// The wallpaper list: paths, blank lines and # comments skipped.
+assert.deepStrictEqual(L.parse(""), []);
+assert.deepStrictEqual(L.parse("# mine\n/a.png\n\n /b.png \n#/c.png\n"), ["/a.png", "/b.png"]);
+// Toggling adds a missing path and removes a listed one, leaving
+// comments and every other line as they were.
+assert.strictEqual(L.toggle("", "/a.png"), "/a.png\n");
+assert.strictEqual(L.toggle("# mine\n/b.png", "/a.png"), "# mine\n/b.png\n/a.png\n");
+assert.strictEqual(L.toggle("# mine\n/a.png\n/b.png\n", "/a.png"), "# mine\n/b.png\n");
+assert.strictEqual(L.toggle("/a.png\n", "/a.png"), "");
+// A commented-out path is not listed, so toggling it adds a live line.
+assert.strictEqual(L.toggle("#/a.png\n", "/a.png"), "#/a.png\n/a.png\n");
+// wallp.conf's outputs, by connector.
+assert.deepStrictEqual(L.outputs("# x\nOUTPUT_H=DP-1\nOUTPUT_V=DP-3\nDEFAULT_H=~/h.png\n"),
+ { "DP-1": "H", "DP-3": "V" });
+assert.deepStrictEqual(L.outputs(""), {});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: FAIL, `ENOENT: no such file or directory` for `ListFile.js`.
+
+- [ ] **Step 3: Write ListFile.js**
+
+```js
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+.pragma library
+
+// The wallpapers the companion blushes at, as a plain text file: one
+// absolute path per line, blank lines and lines starting with # skipped.
+// Pure, so test_mood.js drives it; Admired.qml reads and writes the file.
+
+function parse(text) {
+ const out = [];
+ for (const l of text.split("\n")) {
+ const p = l.trim();
+ if (p !== "" && p[0] !== "#") out.push(p);
+ }
+ return out;
+}
+
+// text with path's line removed if it is listed, else appended. Comments
+// and every other line stay as they are.
+function toggle(text, path) {
+ const lines = text === "" ? [] : text.replace(/\n$/, "").split("\n");
+ const kept = lines.filter(l => l.trim() !== path);
+ if (kept.length === lines.length) kept.push(path);
+ return kept.length ? kept.join("\n") + "\n" : "";
+}
+
+// wallp.conf's OUTPUT_H and OUTPUT_V, as { connector: "H" | "V" }. An exec
+// loop, because QML's engine has no String.matchAll.
+function outputs(conf) {
+ const out = {};
+ const re = /^\s*OUTPUT_([HV])\s*=\s*(\S+)/gm;
+ let m;
+ while ((m = re.exec(conf)) !== null) out[m[2]] = m[1];
+ return out;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/ListFile.js desktop/companion/test_mood.js
+git commit -m "companion: ListFile.js, the wallpaper list as plain text
+
+Toggling keeps comments and every other line, so a hand-edited list
+survives the picker's heart. wallp.conf's outputs map a monitor to the
+H or V wallpaper.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: the admire state in Mood.js
+
+**Files:**
+- Modify: `desktop/companion/Mood.js` (`T`, `pick`, a new function)
+- Test: `desktop/companion/test_mood.js`
+
+- [ ] **Step 1: Write the failing test**
+
+In `test_mood.js`:
+
+Add `admireAllowed` to the `M` export list:
+```js
+ "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue, admireAllowed };")();
+```
+
+Add `admireAt: 0,` to `calm`, after `wakeAt: 0,`.
+
+In `ladder`, between the `clicks` and `mock` rows:
+```js
+ ["admire", { admireAt: NOW - 10 }],
+```
+
+Before the `ListFile` tests, add:
+```js
+// Admiring a wallpaper: turned to it, then blushing, then done.
+assert.deepStrictEqual(M.pick(calm({ admireAt: NOW - 10 })), { state: "admire", frame: "watch" });
+assert.strictEqual(frame({ admireAt: NOW - M.T.admireLook }), "blush3");
+assert.strictEqual(state({ admireAt: NOW - M.T.admireLook - M.T.admireBlush }), "base");
+assert.strictEqual(M.admireAllowed(0, NOW), true);
+assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown + 1, NOW), false);
+assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown, NOW), true);
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: FAIL, `ReferenceError: admireAllowed is not defined`.
+
+- [ ] **Step 3: Implement**
+
+In `Mood.js` `T`, after `scorched: 3000,`:
+```js
+ admireLook: 1500, // turned to the wallpaper, before the blush
+ admireBlush: 3000,
+ admireCooldown: 180000,
+```
+
+In `pick`, right after the `clicks` return:
+```js
+ if (s.now - s.admireAt < T.admireLook + T.admireBlush)
+ return st("admire", s.now - s.admireAt < T.admireLook ? "watch" : "blush3");
+```
+
+After `mockAllowed`:
+```js
+function admireAllowed(lastAt, now) { return now - lastAt >= T.admireCooldown; }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+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 -m "companion: admire, turned to a wallpaper and then blushing
+
+The watch back view for 1.5 s, then blush3 for 3 s, at most every 3
+minutes. Below clicks, so a click during it wins; above the mock and
+typing.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Prefs and the Admired singleton
+
+**Files:**
+- Modify: `desktop/Prefs.qml`
+- Create: `desktop/companion/Admired.qml`
+
+- [ ] **Step 1: Prefs.companionBlushFile**
+
+In `Prefs.qml`, after `setCompanion`:
+```qml
+ // The companion's wallpaper list, "" for the default in
+ // companion/Admired.qml.
+ property string companionBlushFile: ""
+ function setCompanionBlushFile(p) {
+ p = p.trim();
+ if (p === root.companionBlushFile) return;
+ root.companionBlushFile = p;
+ root.write(Object.assign({}, root.doc, { companionBlushFile: p }));
+ }
+```
+
+In `reparse()`, after `root.companion = ...`:
+```qml
+ root.companionBlushFile = typeof parsed.companionBlushFile === "string" ? parsed.companionBlushFile : "";
+```
+
+In `onLoadFailed`, add `root.companionBlushFile = "";` before `root.ready = true;`.
+
+- [ ] **Step 2: Admired.qml**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import ".."
+import "ListFile.js" as LF
+
+// The wallpapers the companion blushes at, and what wallp has on each
+// monitor. The companion reads both; the wallpaper picker toggles entries.
+// What is on each monitor is read from wallp's own files here rather than
+// through the appearance module's Wallpapers, which scans every wallpaper
+// on creation, and the companion runs all session.
+Singleton {
+ id: root
+
+ readonly property string home: Quickshell.env("HOME")
+ readonly property string defaultPath:
+ (Quickshell.env("XDG_CONFIG_HOME") || root.home + "/.config") + "/keystone/companion-blush.txt"
+ readonly property string path:
+ Prefs.companionBlushFile !== "" ? Prefs.companionBlushFile.replace(/^~(?=\/|$)/, root.home) : root.defaultPath
+
+ property string listText: ""
+ readonly property var paths: LF.parse(root.listText)
+ function listed(p) { return p !== "" && root.paths.indexOf(p) >= 0; }
+
+ // The wallpaper on a monitor, by connector name; "" for one wallp does
+ // not set.
+ property var outputs: ({})
+ property string wallH: ""
+ property string wallV: ""
+ function on(monitor) {
+ const w = root.outputs[monitor];
+ return w === "H" ? root.wallH : w === "V" ? root.wallV : "";
+ }
+
+ // ponytail: a list file created by hand after start may not be watched
+ // until a restart (the watch is on a path that did not exist); the
+ // heart writes through this process, which sets listText itself.
+ FileView {
+ path: root.path
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.listText = text()
+ onLoadFailed: root.listText = ""
+ }
+ FileView {
+ path: root.home + "/.config/wallp/wallp.conf"
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.outputs = LF.outputs(text())
+ }
+ FileView {
+ path: root.home + "/.config/wallp/wall_h"
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.wallH = text().trim()
+ }
+ FileView {
+ path: root.home + "/.config/wallp/wall_v"
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.wallV = text().trim()
+ }
+
+ // Through cat rather than FileView.setText, because the list can become
+ // empty and setText("") on a path the view has not loaded writes no
+ // file (AGENTS.md).
+ function toggle(p) {
+ writer.data = LF.toggle(root.listText, p);
+ root.listText = writer.data;
+ writer.command = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", root.path];
+ writer.stdinEnabled = true;
+ writer.running = false;
+ writer.running = true;
+ }
+ Process {
+ id: writer
+ property string data: ""
+ onStarted: {
+ writer.write(writer.data);
+ writer.stdinEnabled = false;
+ }
+ onExited: code => { if (code !== 0) console.warn("Admired: writing", root.path, "failed"); }
+ }
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/Prefs.qml desktop/companion/Admired.qml
+git commit -m "companion: Admired, the wallpaper list and what each monitor shows
+
+The list is a plain text file whose path is a preference, the default
+under ~/.config/keystone. Written through cat because an emptied list
+is an empty file, which FileView.setText does not write.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: the trigger in Companion.qml and her lines
+
+**Files:**
+- Modify: `desktop/companion/Companion.qml`
+- Modify: `desktop/companion/Lines.js`
+
+- [ ] **Step 1: Lines**
+
+In `Lines.js`, after `blush:`:
+```js
+ wallpaper: ["...!", "W-what is this?", "I wasn't staring."],
+```
+
+- [ ] **Step 2: Input and snapshot**
+
+In `Companion.qml`, after `property double wakeAt: 0`:
+```qml
+ property double admireAt: 0
+```
+
+In the `Mood.pick({...})` snapshot, change
+`clicks: root.clicks, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,` to
+`clicks: root.clicks, admireAt: root.admireAt, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,`.
+
+- [ ] **Step 3: Events**
+
+Replace the last branch of `onRawEvent`:
+```qml
+ } else if (["fullscreen", "activewindowv2", "workspacev2"].includes(event.name)) {
+ Hyprland.refreshToplevels();
+ fsSettle.restart();
+ }
+```
+with:
+```qml
+ } else if (["fullscreen", "activewindowv2", "workspacev2", "closewindow"].includes(event.name)) {
+ Hyprland.refreshToplevels();
+ fsSettle.restart();
+ admireSettle.restart();
+ }
+```
+
+- [ ] **Step 4: The check**
+
+After the `Connections { target: root.asst ... }` block:
+```qml
+ // A listed wallpaper on an empty workspace: she turns to it and blushes.
+ // Checked after the events above (switching to an empty workspace,
+ // closing its last window) and whenever she stops being away: shown,
+ // back from a remap, a hop, a dodge or fullscreen. Toplevels settle a
+ // moment after the event, as fullscreen does.
+ onAwayChanged: if (!root.away) admireSettle.restart()
+ Timer {
+ id: admireSettle
+ interval: 100
+ onTriggered: {
+ const t = Date.now();
+ if (root.away || root.here !== root.focused || !Mood.admireAllowed(root.admireAt, t)) return;
+ if ((Hyprland.focusedWorkspace?.toplevels.values.length ?? 1) !== 0) return;
+ if (!Admired.listed(Admired.on(root.here))) return;
+ root.admireAt = t;
+ root.now = t;
+ admireLine.restart();
+ }
+ }
+ // The line comes with the blush, not the turn.
+ Timer { id: admireLine; interval: Mood.T.admireLook; onTriggered: root.say("wallpaper") }
+```
+
+- [ ] **Step 5: Ask the user to restart, then check the log**
+
+`Admired.qml` is a new file, which hot reload does not index. Ask the user to run `./reload.sh desktop` in their terminal. Then:
+
+Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -30`
+Expected: `Configuration Loaded`, no `ReferenceError`, no `TypeError` mentioning `toplevels` or `Admired`.
+
+If `toplevels` is undefined on `HyprlandWorkspace`, replace the window count with
+`Hyprland.toplevels.values.filter(w => w.workspace === Hyprland.focusedWorkspace).length` and re-check.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/companion/Companion.qml desktop/companion/Lines.js
+git commit -m "companion: blushes at a listed wallpaper on an empty workspace
+
+Checked on switching workspace or closing a window, and whenever she
+stops being away, which covers being shown, a remap, a hop to another
+monitor, a dodge and fullscreen. The 3 minute cooldown keeps workspace
+flicking from repeating it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: the path on the Companion settings page
+
+**Files:**
+- Modify: `desktop/modules/companion/CompanionModule.qml`
+
+- [ ] **Step 1: Imports and comment**
+
+Add after `import "../.."`:
+```qml
+import "../appearance"
+import "../../companion"
+```
+Change the comment `// The desktop companion's one setting: whether she is shown. The same` / `// preference SUPER+\ toggles, so the switch follows the key.` to:
+```qml
+// The desktop companion's settings: whether she is shown, the same
+// preference SUPER+\ toggles, so the switch follows the key; and where her
+// wallpaper list is, marked with the heart in the wallpaper picker.
+```
+Add `"wallpaper", "blush"` to `keywords`.
+
+- [ ] **Step 2: Rows**
+
+`Page`'s content holder is a plain `Item`, so wrap the existing `Item { ... }` row in
+```qml
+ Column {
+ width: parent.width
+ spacing: 6
+
+ // (the existing Shown row, unchanged)
+```
+and after that row, inside the `Column`, add:
+```qml
+ Item {
+ width: parent.width
+ height: 34
+ Text {
+ id: lbl2
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ text: "Wallpaper list"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Field {
+ anchors { left: lbl2.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter }
+ value: Prefs.companionBlushFile
+ placeholder: Admired.defaultPath.replace(Admired.home, "~")
+ onEdited: Prefs.setCompanionBlushFile(value)
+ }
+ }
+ Text {
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text: "One path per line. She blushes at these on an empty workspace; the heart in Wallpaper adds and removes them."
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
+ }
+```
+
+- [ ] **Step 3: Check the log**
+
+Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -15`
+Expected: a `Reloading configuration` line after the edit, no errors. If none followed, ask for `./reload.sh desktop`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/modules/companion/CompanionModule.qml
+git commit -m "companion: the wallpaper list path on her settings page
+
+Empty means the default under ~/.config/keystone, shown as the
+placeholder.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: the heart in the wallpaper picker
+
+**Files:**
+- Modify: `desktop/modules/appearance/WallpaperTab.qml`
+
+- [ ] **Step 1: Glyphs**
+
+Run: `fc-list ':charset=f02d1 f02d5' family | grep -c "Inconsolata Nerd Font"`
+Expected: non-zero (`nf-md-heart` U+F02D1, `nf-md-heart_outline` U+F02D5; checked while planning: Propo and Mono both carry them).
+
+- [ ] **Step 2: Import**
+
+After `import "../.."`:
+```qml
+import "../../companion"
+```
+
+- [ ] **Step 3: The heart**
+
+In the thumbnail `Rectangle`, after its `Column { ... }` (the image and label), add:
+```qml
+ // The companion blushes at the hearted ones
+ // (companion/Admired.qml). After the pick
+ // MouseArea, so it takes the click.
+ Text {
+ readonly property bool on: Admired.listed(modelData)
+ anchors { top: parent.top; right: parent.right; margins: 10 }
+ visible: on || thumbHover.hovered
+ text: on ? "\u{F02D1}" : "\u{F02D5}"
+ color: on ? Theme.red : Theme.text
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize }
+ MouseArea {
+ anchors { fill: parent; margins: -4 }
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Admired.toggle(modelData)
+ }
+ }
+```
+
+- [ ] **Step 4: Check the log**
+
+Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -15`
+Expected: a reload after the edit and no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/appearance/WallpaperTab.qml
+git commit -m "wallpaper: a heart on each thumbnail, for the companion's list
+
+Shown on hover, and always on a listed one. It toggles the path in the
+list file and leaves the rest of the file as it was.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: docs and the hand check
+
+**Files:**
+- Modify: `desktop/companion/README.md`
+- Modify: `docs/superpowers/specs/2026-10-23-companion-blush-design.md`
+
+- [ ] **Step 1: README**
+
+Add rows to the Files table:
+```
+| `Admired.qml` | the wallpaper list she blushes at, and what wallp has on each monitor |
+| `ListFile.js` | that list as plain text: parse and toggle |
+```
+Under Inputs, add:
+```
+- A listed wallpaper (Settings > Desktop > Companion for the file, the
+ heart in the wallpaper picker for entries) on an empty workspace: she
+ turns to it with `watch` and blushes with `blush3`, at most every 3
+ minutes. Checked on `workspacev2` and `closewindow`, and whenever she
+ stops being away. Her monitor's wallpaper comes from wallp's
+ `wallp.conf` outputs and `wall_h`/`wall_v`.
+```
+
+- [ ] **Step 2: Spec**
+
+In the spec's Trigger section, change `she appears: shown (SUPER+\ or the settings switch), back from a remap after another layer opened, or moved to another monitor with the focus.` to `she stops being away: shown (SUPER+\ or the settings switch), back from a remap after another layer opened, moved to another monitor with the focus, back from a dodge, or out of fullscreen.`
+
+- [ ] **Step 3: Test**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: `ok`
+
+- [ ] **Step 4: Hand check, by the user**
+
+Ask the user to check, with a wallpaper hearted on DP-1:
+1. switch to an empty workspace on DP-1: back view, then blush, then a line;
+2. again within 3 minutes: nothing;
+3. a workspace with a window: nothing;
+4. an unhearted wallpaper: nothing;
+5. DP-3 with its wallpaper hearted: same as 1;
+6. unheart from the picker, and the list file keeps any comments.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/README.md docs/superpowers/specs/2026-10-23-companion-blush-design.md
+git commit -m "companion: README for the wallpaper blush, spec matches the trigger
+
+The trigger fires whenever she stops being away, which is a dodge and
+fullscreen as well as the three appearances the spec named.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```