diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-23 16:23:28 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-23 16:23:28 +0200 |
| commit | 783340342e79417b7bc120d278c993252a247bd2 (patch) | |
| tree | 216809edff8c965886cdf92156f6fca756ff0f54 /docs/superpowers | |
| parent | 338ff88ff19740a7bb27a1f76fd1074417900b22 (diff) | |
| download | quickshell-783340342e79417b7bc120d278c993252a247bd2.tar.gz quickshell-783340342e79417b7bc120d278c993252a247bd2.zip | |
companion: plan for blushing at a listed wallpaper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-10-23-companion-blush.md | 655 |
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>" +``` |
