# 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. // // 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 " ``` --- ### 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 " ``` --- ### 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. // // 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 " ``` --- ### 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 " ``` --- ### 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 " ``` --- ### 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 " ``` --- ### 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 " ```