aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-10-23-companion-music.md589
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-design.md13
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-music-design.md125
3 files changed, 721 insertions, 6 deletions
diff --git a/docs/superpowers/plans/2026-10-23-companion-music.md b/docs/superpowers/plans/2026-10-23-companion-music.md
new file mode 100644
index 0000000..534d029
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-23-companion-music.md
@@ -0,0 +1,589 @@
+# Companion music 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 DJs on a track start/change, grooves instead of looking bored while music plays, and says per-artist lines edited in a settings page.
+
+**Architecture:** `Mood.js` (pure) gains `djAt`, `music`, `groove` inputs. `Lines.js` gains artist defaults and a substring matcher, with its app-set helpers taking a defaults argument. `Companion.qml` reads the existing `Player` singleton (`modules/sound/Player.qml`) for track changes and drives a generic frame cycle. `PhrasesPage.qml` gets three text properties so a new `ArtistsModule.qml` reuses it, saving through new `Prefs.companionArtists`.
+
+**Tech Stack:** Quickshell 0.3.1 QML, QML JS libraries tested with node, FLUX.2 klein frames via `gen-frames`.
+
+Spec: `docs/superpowers/specs/2026-10-23-companion-music-design.md`.
+
+Rules from AGENTS.md that apply: no `flatMap`, `Object.fromEntries`, `matchAll` in QML JS; never name a property after the enclosing id; editing `shell.qml` needs `./reload.sh desktop` from the user's terminal, never from an agent; process is `qs` (`pgrep -cx qs`). Commits are GPG-signed, end with the Co-Authored-By line, and carry the reasoning.
+
+All commands run from the repo root `~/Programming/GIT/quickshell`.
+
+---
+
+### Task 1: Mood.js, dj and groove
+
+**Files:**
+- Modify: `desktop/companion/Mood.js` (T table, `pick`)
+- Test: `desktop/companion/test_mood.js`
+
+- [ ] **Step 1: Write the failing tests**
+
+In `test_mood.js`, extend the `calm` defaults (the object starting `leaving: false, now: NOW, ...`) with `djAt: 0, music: false, groove: ""` on the line that holds `singAt: 0,`:
+
+```js
+ repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0, singAt: 0,
+ djAt: 0, music: false, groove: "",
+```
+
+In `ladder`, insert directly after `["sing", { singAt: NOW - 10 }],`:
+
+```js
+ ["dj", { djAt: NOW - 10 }],
+```
+
+After the `// Idle levels and their frames.` block, add:
+
+```js
+// Music while bored: she grooves with the picked frame instead, whistling
+// when nothing was picked; yawning and lying down are unchanged.
+assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true, groove: "headbang" })), { state: "groove", frame: "headbang" });
+assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true })), { state: "groove", frame: "whistle" });
+assert.deepStrictEqual(M.pick(calm({ idle: 1, groove: "headbang" })), { state: "bored", frame: "sleepy" });
+assert.strictEqual(state({ idle: 2, music: true }), "yawn");
+assert.strictEqual(state({ idle: 3, music: true }), "lying");
+// Music while awake is the rest pose; the DJ is only the burst.
+assert.strictEqual(state({ music: true }), "base");
+assert.deepStrictEqual(M.pick(calm({ djAt: NOW - 10 })), { state: "dj", frame: "dj" });
+```
+
+After `assert.strictEqual(state({ singAt: NOW - M.T.singHold }), "base");` add:
+
+```js
+assert.strictEqual(state({ djAt: NOW - M.T.djHold }), "base");
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: AssertionError on the ladder (`everything from dj down`).
+
+- [ ] **Step 3: Implement**
+
+In `Mood.js`, in `T` after `singHold`:
+
+```js
+ djHold: 6000, // behind the decks when a track starts or changes
+```
+
+In `pick`, after the `sing` line:
+
+```js
+ if (s.now - s.djAt < T.djHold) return st("dj", "dj");
+```
+
+Replace `if (s.idle === 1) return st("bored", "sleepy");` with:
+
+```js
+ // Music turns boredom into a groove picked by Companion.qml; the
+ // deeper idle stages still win, so music does not keep her awake.
+ if (s.idle === 1) return s.music ? st("groove", s.groove || "whistle") : st("bored", "sleepy");
+```
+
+- [ ] **Step 4: Run, expect 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 -m "companion: dj and groove states in Mood
+
+A DJ burst ranks just below the headphones sing, so both arriving together
+sings first. Music turns only the first idle stage into a groove: yawning
+and lying down still win, so music does not keep her awake and the
+screensaver handoff is unchanged. The groove pick is an input, keeping
+pick() pure.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Lines.js, artist lines and groove lines
+
+**Files:**
+- Modify: `desktop/companion/Lines.js`
+- Test: `desktop/companion/test_lines.js`
+
+- [ ] **Step 1: Write the failing tests**
+
+In `test_lines.js`, change the return list to:
+
+```js
+ "\nreturn { lines, apps, artists, monologue, appMap, appList, pickApp, pickArtist, pick, toMap };")();
+```
+
+Before `console.log("ok");` add:
+
+```js
+// Artists: a case-insensitive substring of what MPRIS reports, first
+// matching key wins, empty phrases skipped.
+assert.strictEqual(L.pickArtist("Linkin Park", null, 0), L.artists["linkin park"][0]);
+assert.strictEqual(L.pickArtist("LINKIN PARK, Jay-Z", null, 0), L.artists["linkin park"][0]);
+assert.strictEqual(L.pickArtist("Nobody", null, 0), "");
+assert.strictEqual(L.pickArtist("", null, 0), "");
+assert.strictEqual(L.pickArtist("Air, Beck", { air: ["a"], beck: ["b"] }, 0), "a");
+assert.strictEqual(L.pickArtist("Airbourne", { air: ["", "a"] }, 0), "a");
+assert.strictEqual(L.pick("artist:Eminem", 0, null, null), L.artists.eminem[0]);
+assert.strictEqual(L.pick("artist:Eminem", 0, null, { eminem: ["mine"] }), "mine");
+assert.strictEqual(L.pick("groove", 0), L.lines.groove[0]);
+
+// The same list helpers serve the artist set.
+assert.strictEqual(L.appMap(null, L.artists), L.artists);
+assert.strictEqual(L.appList(null, L.artists).length, Object.keys(L.artists).length);
+assert.deepStrictEqual(L.toMap(L.appList(null, L.artists)), L.artists);
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `node desktop/companion/test_lines.js`
+Expected: `ReferenceError: artists is not defined`
+
+- [ ] **Step 3: Implement**
+
+In `Lines.js`, add to `lines` after `headphones`:
+
+```js
+ groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."],
+```
+
+After the `apps` object:
+
+```js
+// The lines for an artist she knows, keyed by a lowercased name matched as
+// a substring of the reported artist, so "daft punk" also catches
+// collaborations. The Companion artists page edits them into
+// Prefs.companionArtists, which then replaces this whole set.
+var artists = {
+ "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."],
+ "evanescence": ["Wake me up inside. ...Operator, don't laugh."],
+ "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."],
+ "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."],
+};
+```
+
+Replace `appMap` and `appList` with versions taking the defaults (apps when omitted, so existing callers keep working):
+
+```js
+// A set in effect: saved when it is a plain object, the defaults (apps
+// unless given) otherwise. A malformed saved value must not leave her mute.
+function appMap(saved, defaults) {
+ return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : (defaults || apps);
+}
+
+// A set as an ordered list of {cls, lines}, for the settings pages; cls is
+// the key, a window class or an artist.
+function appList(saved, defaults) {
+ const m = appMap(saved, defaults);
+ const keys = Object.keys(m);
+ const out = [];
+ for (let i = 0; i < keys.length; i++)
+ out.push({ cls: keys[i], lines: (m[keys[i]] || []).slice() });
+ return out;
+}
+```
+
+Replace `pickApp` and `pick` with a shared non-empty filter, plus `pickArtist`:
+
+```js
+function nonEmpty(all) {
+ const l = [];
+ for (let i = 0; i < (all || []).length; i++)
+ if (typeof all[i] === "string" && all[i].trim() !== "") l.push(all[i]);
+ return l;
+}
+
+function at(l, roll) { return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : ""; }
+
+// A line for an app class, empty strings skipped; "" when the class has none.
+function pickApp(cls, saved, roll) { return at(nonEmpty(appMap(saved)[cls]), roll); }
+
+// A line for the playing artist: the first key that is a case-insensitive
+// substring of it and has a non-empty line. ponytail: substring, so a
+// short key like "air" also matches "Airbourne"; whole words if that bites.
+function pickArtist(artist, saved, roll) {
+ const a = String(artist || "").toLowerCase();
+ if (a === "") return "";
+ const m = appMap(saved, artists);
+ const keys = Object.keys(m);
+ for (let i = 0; i < keys.length; i++) {
+ const l = nonEmpty(m[keys[i]]);
+ if (keys[i] !== "" && l.length && a.indexOf(keys[i].toLowerCase()) !== -1) return at(l, roll);
+ }
+ return "";
+}
+
+// A line for a reaction kind, or, with an "app:" or "artist:" prefix, for
+// that class or artist.
+function pick(kind, roll, saved, savedArtists) {
+ if (kind.slice(0, 4) === "app:") return pickApp(kind.slice(4), saved, roll);
+ if (kind.slice(0, 7) === "artist:") return pickArtist(kind.slice(7), savedArtists, roll);
+ return at(lines[kind] || [], roll);
+}
+```
+
+- [ ] **Step 4: Run, expect pass**
+
+Run: `node desktop/companion/test_lines.js && node desktop/companion/test_mood.js`
+Expected: `ok` twice.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/Lines.js desktop/companion/test_lines.js
+git commit -m "companion: artist and groove lines
+
+Artist keys match as a case-insensitive substring of the MPRIS artist, so
+collaborations and feat. credits still hit; the cost is a short key
+over-matching, marked as a ponytail ceiling. appMap/appList take their
+defaults so the settings page can serve artists with the same helpers.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Prefs.companionArtists
+
+**Files:**
+- Modify: `desktop/Prefs.qml` (after `resetCompanionApps`, parse block near line 155, `onLoadFailed` near line 172)
+
+- [ ] **Step 1: Add the property and setters** after `resetCompanionApps()`'s closing brace:
+
+```qml
+ // The companion's artist lines as saved, keyed by lowercased artist, or
+ // null for the defaults in companion/Lines.js. The Companion artists
+ // page edits it.
+ property var companionArtists: null
+ function setCompanionArtists(map) {
+ root.companionArtists = map;
+ root.write(Object.assign({}, root.doc, { companionArtists: map }));
+ }
+ function resetCompanionArtists() {
+ root.companionArtists = null;
+ const d = Object.assign({}, root.doc);
+ delete d.companionArtists;
+ root.write(d);
+ }
+```
+
+- [ ] **Step 2: Parse it** after the `root.companionApps = ca && ...` line:
+
+```qml
+ const cr = parsed.companionArtists;
+ root.companionArtists = cr && typeof cr === "object" && !Array.isArray(cr) ? cr : null;
+```
+
+- [ ] **Step 3: Reset on load failure**: in the `onLoadFailed` line, after `root.companionApps = null;` insert ` root.companionArtists = null;`.
+
+- [ ] **Step 4: Check the shell reloads cleanly**
+
+Run: `qs log -p desktop --log-times | tail -5`
+Expected: a `Reloading configuration` / `Configuration Loaded` after the edit's mtime, no errors. If no reload line, see AGENTS.md "Hot reload sometimes does not fire".
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/Prefs.qml
+git commit -m "prefs: companionArtists, saved like companionApps
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: PhrasesPage text properties and ArtistsModule
+
+**Files:**
+- Modify: `desktop/modules/companion/PhrasesPage.qml` (properties near line 27, intro at line 101, placeholder at line 132, button at line 180)
+- Create: `desktop/modules/companion/ArtistsModule.qml`
+- Modify: `desktop/shell.qml:91`
+
+- [ ] **Step 1: Parametrise the page.** After `required property QtObject owner`, add:
+
+```qml
+ // What the cards are keyed by; the defaults are the app set's, so
+ // PhrasesModule passes nothing.
+ property string intro: "When focus moves to a program she recognizes, she says one of its lines at random, a third "
+ + "of the time and under her 30 s line cooldown. The class is Hyprland's window class, "
+ + "lowercased (hyprctl activewindow shows it). Save applies at once."
+ property string keyHint: "window class, e.g. firefox"
+ property string addText: "Add app"
+ // The saved set, for Reset's enabled state; null means the defaults.
+ property var saved: Prefs.companionApps
+```
+
+Replace the intro `Small`'s `text:` (the three-line string) with `text: page.intro`, the card field's `placeholder: "window class, e.g. firefox"` with `placeholder: page.keyHint`, `Button { text: "Add app"; ...}` with `Button { text: page.addText; onClicked: page.addApp() }`, and in the Reset button's `enabled:` replace `Prefs.companionApps !== null` with `page.saved !== null`.
+
+- [ ] **Step 2: Create `ArtistsModule.qml`** (copy the GPLv2 header from `PhrasesModule.qml` lines 1-10 verbatim, then):
+
+```qml
+import QtQuick
+import "../.."
+import "../../companion/Lines.js" as Lines
+
+// The lines the companion says when a track by an artist she knows starts,
+// matched as a case-insensitive substring of the player's artist. Same
+// draft, Save and Reset as PhrasesModule, on Prefs.companionArtists and
+// Lines.artists; the page's cls field holds the artist.
+Module {
+ id: am
+ name: "artists"
+ label: "Companion artists"
+ // nf-fa-music, present in Inconsolata Nerd Font.
+ icon: ""
+ settings: true
+ group: "Companion"
+ keywords: ["companion", "artist", "music", "song", "track", "line", "say", "2b"]
+ searchItems: am.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i }))
+
+ property var draft: null
+ property bool resetting: false
+ readonly property var shown: am.draft ?? Lines.appList(Prefs.companionArtists, Lines.artists)
+ readonly property bool valid: am.shown.every(a => String(a.cls).trim() !== ""
+ && (a.lines || []).some(l => String(l).trim() !== ""))
+ dirty: am.draft !== null
+
+ function edit(list) { am.draft = list; am.resetting = false; }
+ function reset() { am.draft = Lines.appList(null, Lines.artists); am.resetting = true; }
+ function save() {
+ if (am.resetting) Prefs.resetCompanionArtists();
+ else Prefs.setCompanionArtists(Lines.toMap(am.draft));
+ am.discard();
+ }
+ function discard() { am.draft = null; am.resetting = false; }
+
+ page: Component {
+ Page {
+ title: "Companion artists"
+ actions: Component {
+ SaveBar { dirty: am.dirty; valid: am.valid; note: "every artist needs a name and a phrase"
+ onSave: am.save(); onDiscard: am.discard() }
+ }
+ PhrasesPage {
+ width: parent.width
+ owner: am
+ intro: "When a track by an artist she knows starts or changes, she says one of their lines at "
+ + "random, under her 30 s line cooldown. The name matches any part of the player's artist, "
+ + "ignoring case, so \"daft punk\" also catches \"Daft Punk, Pharrell Williams\". "
+ + "Save applies at once."
+ keyHint: "artist, e.g. linkin park"
+ addText: "Add artist"
+ saved: Prefs.companionArtists
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Register it** in `desktop/shell.qml`, after `PhrasesModule {},`:
+
+```qml
+ ArtistsModule {},
+```
+
+- [ ] **Step 4: Verify.** `shell.qml` changed, so ask the user to run `./reload.sh desktop` from their terminal (never from a tool call). Then `qs log -p desktop | tail -20` must show no errors, and the user checks by eye: Settings > Companion artists lists the four seeds; Companion phrases still reads as before.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/companion/PhrasesPage.qml desktop/modules/companion/ArtistsModule.qml desktop/shell.qml
+git commit -m "companion: artist lines page
+
+PhrasesPage takes its intro, key hint and add-button text, so the artist
+set reuses it rather than a copy that would drift. ArtistsModule mirrors
+PhrasesModule on Prefs.companionArtists and Lines.artists.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Companion.qml, track changes, groove pick, frame cycles
+
+**Files:**
+- Modify: `desktop/companion/Companion.qml`
+
+- [ ] **Step 1: Import the player.** After `import ".."` add `import "../modules/sound"`.
+
+- [ ] **Step 2: Inputs.** After `property double singAt: 0`:
+
+```qml
+ property double djAt: 0
+ property string groove: ""
+```
+
+- [ ] **Step 3: Groove pick and bored line.** In `onIdleChanged`, replace the `else if (root.idle === 1 && root.wasIdle === 0) { ... }` branch with:
+
+```qml
+ } else if (root.idle === 1 && root.wasIdle === 0) {
+ root.pickGroove();
+ if (Player.playing) root.say("groove");
+ else if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue();
+ else root.say("bored");
+ }
+```
+
+- [ ] **Step 4: Track changes.** After the headphones block (`onPhonesConnectedChanged`), add:
+
+```qml
+ // What is playing, from the sound module's player (it already drops the
+ // playerctld and browser-integration duplicates). A new artist+title
+ // while playing is a track start or change: she DJs for Mood.T.djHold
+ // and says the artist's line if she knows them. Paused reads "", and
+ // resuming the same track matches lastTrack, so it does not DJ again.
+ readonly property string track: Player.playing ? Player.artist + "\n" + Player.title : ""
+ property string lastTrack: ""
+ onTrackChanged: {
+ if (root.track === "" || root.track === root.lastTrack) return;
+ root.lastTrack = root.track;
+ root.djAt = Date.now();
+ root.now = root.djAt;
+ root.say("artist:" + Player.artist);
+ }
+ // Music starting while she is already bored needs a groove too.
+ readonly property bool music: Player.playing
+ onMusicChanged: if (root.music && root.idle === 1) root.pickGroove()
+ function pickGroove() {
+ const g = ["whistle", "headbang", "airguitar"];
+ root.groove = g[Math.floor(Math.random() * g.length)];
+ }
+```
+
+- [ ] **Step 5: Feed Mood.** In the `Mood.pick({...})` snapshot, change `singAt: root.singAt, monologue: root.monologue, idle: root.idle,` to:
+
+```qml
+ singAt: root.singAt, djAt: root.djAt, music: root.music, groove: root.groove,
+ monologue: root.monologue, idle: root.idle,
+```
+
+- [ ] **Step 6: Artist lines reach say.** In `say()`, change the pick to:
+
+```qml
+ const line = Lines.pick(kind, Math.random(), Prefs.companionApps, Prefs.companionArtists);
+```
+
+- [ ] **Step 7: Frame cycles.** After the `waveFlip` Timer, add:
+
+```qml
+ // Frames Mood names that are really a loop: the frame names its cycle
+ // and how fast it turns. Everything else shows as named.
+ readonly property var cycles: ({
+ dj: { frames: ["dj", "dj-scratch"], ms: 450 },
+ headbang: { frames: ["headbang-up", "headbang-down"], ms: 220 },
+ })
+ property int cycleIndex: 0
+ readonly property var cycle: root.cycles[root.mood.frame] ?? null
+ onCycleChanged: root.cycleIndex = 0
+ Timer {
+ interval: root.cycle?.ms ?? 1000
+ repeat: true
+ running: root.cycle !== null && !root.away
+ onTriggered: root.cycleIndex = (root.cycleIndex + 1) % root.cycle.frames.length
+ }
+ readonly property string frame: root.leaving ? root.waveCycle[root.waveIndex]
+ : root.cycle ? root.cycle.frames[root.cycleIndex % root.cycle.frames.length] : root.mood.frame
+```
+
+Change the `Figure`'s `source:` to:
+
+```qml
+ source: `file://${root.art}/${root.outfit}/${root.frame}.png`
+```
+
+- [ ] **Step 8: Verify the load.**
+
+Run: `node desktop/companion/test_mood.js && node desktop/companion/test_lines.js && qs log -p desktop --log-times | tail -10`
+Expected: `ok`, `ok`, a reload after the edit with no `TypeError`/`ReferenceError`. Then ask the user to play a track: she should show `base` (frames not generated yet, falls back) for 6s, and say an artist line for one of the seeds; pause and resume must not repeat it.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add desktop/companion/Companion.qml
+git commit -m "companion: DJ on track change, groove while bored with music
+
+Reads the sound module's Player, which already filters the MPRIS
+duplicates. The track key is artist+title while playing, so a pause and
+resume of the same track does not DJ again; a shell start with music on
+DJs once, which is harmless. Frame loops are a small table keyed by the
+frame Mood names, so dj and headbang need no special case in Mood.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Frames and docs
+
+**Files:**
+- Modify: `desktop/companion/frames.txt`
+- Modify: `desktop/companion/README.md`
+
+- [ ] **Step 1: Dress frames.** In `frames.txt`, after `dress/sing <- ...`, add:
+
+```
+# Music: behind the decks on a track start or change, and the grooves
+# that replace boredom while music plays.
+DECK = A small black DJ console with two turntables and a mixer stands in front of her at waist height, hiding her legs.
+dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile; she wears black over-ear headphones. ${EYES} ${KEEP}
+dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP}
+dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. ${POSE} ${EYES} ${KEEP}
+dress/headbang-up <- dress/base : She headbangs: head tipped back, hair flying up, eyes shut, fists clenched at her sides. ${BARE} ${KEEP_BARE}
+dress/headbang-down <- dress/headbang-up : Change only her head: tipped sharply forward and down, hair thrown forward over her face. Everything else exactly the same. ${KEEP_BARE}
+dress/airguitar <- dress/base : She plays an invisible air guitar: one hand strumming at her hip, the other fretting in the air to the side, knees bent, eyes shut, mouth open in a grin. Nothing in her hands. ${BARE} ${KEEP_BARE}
+```
+
+`KEEP`, `EYES`, `BARE`, `KEEP_BARE` and `LEOTARD` are defined at the top of the file; `DECK` only has to precede its uses, as `SING` does.
+
+- [ ] **Step 2: Leotard frames.** After the last `leotard/` line add:
+
+```
+leotard/dj <- dress/dj : ${LEOTARD}
+leotard/dj-scratch <- dress/dj-scratch : ${LEOTARD}
+leotard/whistle <- dress/whistle : ${LEOTARD}
+leotard/headbang-up <- dress/headbang-up : ${LEOTARD}
+leotard/headbang-down <- dress/headbang-down : ${LEOTARD}
+leotard/airguitar <- dress/airguitar : ${LEOTARD}
+```
+
+- [ ] **Step 3: Check the parse.**
+
+Run: `desktop/companion/gen-frames --list`
+Expected: the 12 new outputs listed (and nothing else new), no parse error.
+
+- [ ] **Step 4: README.** In `desktop/companion/README.md`, add to the Files table row for `Lines.js` "and per-artist lines", and add an Inputs bullet after the Sony headphones one:
+
+```
+- What is playing is `modules/sound/Player.qml`, which already drops the
+ playerctld and browser-integration duplicates. A new artist+title while
+ playing makes her DJ for `Mood.T.djHold`, 6 s (`dj` and `dj-scratch`
+ cycling), and say the artist's line if one matches: lowercased keys in
+ `Lines.artists`, matched as a substring, edited on Settings > Companion
+ artists into `Prefs.companionArtists`. Pause and resume of the same
+ track is not a change. While music plays, the first idle stage is a
+ groove instead of bored: `whistle`, `headbang` (`headbang-up`/`-down`
+ cycling) or `airguitar`, picked at random, with a `groove` line;
+ yawning and lying down are unchanged.
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/frames.txt desktop/companion/README.md
+git commit -m "companion: music frames to generate, and the README
+
+Twelve frames, dress and leotard. Prompts are a first draft for klein; the
+user generates them, and until then each falls back to base.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 6: Hand off art.** Tell the user: start sd-webui with FLUX.2 klein, run `desktop/companion/gen-frames`, reroll with `--seed N <output>` as needed, record failures in `recipe.txt`.
diff --git a/docs/superpowers/specs/2026-10-23-companion-design.md b/docs/superpowers/specs/2026-10-23-companion-design.md
index 03feaff..8a8c426 100644
--- a/docs/superpowers/specs/2026-10-23-companion-design.md
+++ b/docs/superpowers/specs/2026-10-23-companion-design.md
@@ -111,7 +111,7 @@ base, so every frame descends from one reference.
| --- | --- |
| klein edit of `base-sitting.png` | `base`: "make her stand upright, facing the viewer, arms relaxed at her sides" |
| img2img from `base`, strength 0.75 | `smile`, `mad`, `smirk`, `blush1`, `blush2`, `blush3`, `sleepy` |
-| klein edit of `base` | `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`, `shoved` |
+| klein edit of `base` | `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`, `shoved`, `sing` |
| klein edit of the dress frame | every `leotard/<name>`: "her skirt is gone, revealing a plain white leotard; change nothing else" |
| klein edit of `leotard/base` | `scorched`, leotard only: the moment after the explosion |
@@ -137,11 +137,12 @@ timers.
| 2 | Dodge | pointer rests on her 0.6 s with no click | slides out of the corner | returns after 5 s |
| 3 | Shoved | a balloon is on screen on her monitor | `shoved`, slid left by the stack's width | balloons gone |
| 4 | Assistant | recording / working / reply arrived | `listen` / `think` / `wave` | state ends; wave lasts 3 s |
-| 5 | Clicks | clicks 1-6 of a burst; a burst ends after 1.5 s without one | `smile`, `blush1` to `blush3` | 2 s after the last click |
-| 6 | Mock | BackSpace, at most once in 20 s | `smirk`, `point` | 2.5 s |
-| 7 | Watching | typing pulse in the last 2 s | `watch` | 2 s after typing stops |
-| 8 | Idle | no input for 1 / 3 / 5 min | `sleepy` / `yawn` / `lying` | any input: `stretch`, then base |
-| 9 | Base | nothing else | `base` | |
+| 5 | Singing | Sony headphones connected | `sing` | 4 s |
+| 6 | Clicks | clicks 1-6 of a burst; a burst ends after 1.5 s without one | `smile`, `blush1` to `blush3` | 2 s after the last click |
+| 7 | Mock | BackSpace, at most once in 20 s | `smirk`, `point` | 2.5 s |
+| 8 | Watching | typing pulse in the last 2 s | `watch` | 2 s after typing stops |
+| 9 | Idle | no input for 1 / 3 / 5 min | `sleepy` / `yawn` / `lying` | any input: `stretch`, then base |
+| 10 | Base | nothing else | `base` | |
- **Self-destruct, as in the game.** She glows, says "Self-destruct
sequence initiated.", a white flash covers the sprite for about a
diff --git a/docs/superpowers/specs/2026-10-23-companion-music-design.md b/docs/superpowers/specs/2026-10-23-companion-music-design.md
new file mode 100644
index 0000000..dc30257
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-23-companion-music-design.md
@@ -0,0 +1,125 @@
+# Companion music: design
+
+Date: 2026-10-23. Status: designed, not implemented.
+
+## Goal
+
+The companion reacts to what is playing, not only to headphones
+connecting. When a track starts or changes she DJs behind a console for a
+few seconds, and says a line if she knows the artist. While music plays
+and she is bored, she grooves instead of looking sleepy: whistling,
+headbanging or playing air guitar, with an occasional line. Her artist
+lines are edited in Settings > Companion artists, like the app lines.
+
+## Verified before designing
+
+- **`modules/sound/Player.qml` already picks the real player.** It is a
+ singleton the island reads, filters out the playerctld and
+ plasma-browser-integration duplicates, and exposes `playing`, `artist`
+ and `title`. The companion reads it; it opens no MPRIS of its own.
+- **The app lines page is one parameter away from serving artists.**
+ `PhrasesPage.qml` edits a list of `{cls, lines}` owned by
+ `PhrasesModule.qml`, which saves through `Prefs.setCompanionApps` and
+ falls back to `Lines.apps`. Only the key's label is app specific.
+
+## Behaviour
+
+### DJ burst
+
+A track key is `artist + "\n" + title`. While `Player.playing` is true, a
+key different from the last one seen sets `djAt` to now and becomes the
+last one seen. Resuming the same track after a pause is not a change, so
+it does not DJ. A shell start with music already playing does DJ once:
+unlike Bluetooth, MPRIS has no empty startup window to mistake for a
+connection, and a DJ on launch is harmless.
+
+`Mood.T.djHold` is 6000. In `Mood.pick` the DJ ranks directly below
+`sing`, so headphones connecting together with music starting sings
+first. Its frames are `dj` and `dj-scratch`, cycled by Companion.qml the
+way the wave frames are.
+
+On the burst she says an artist line if one matches, under the 30 s line
+cooldown. No match says nothing.
+
+### Artist matching
+
+`Lines.artistLine(map, artist)`: the first key of the map that is a
+case-insensitive substring of the reported artist wins, and one of its
+lines is picked at random. "daft punk" matches "Daft Punk, Pharrell
+Williams". Short keys can over-match ("air" in "Airbourne"); accepted.
+
+### Groove
+
+`Mood.pick` gains `s.music` (`Player.playing`). At `idle === 1` with
+music, the state is `groove` instead of `bored`; yawning and lying down
+are unchanged, so music does not stop her dozing off and the screensaver
+handoff stays the same.
+
+Reaching idle stage 1, or music starting while already there, picks one
+groove at random: `whistle`, `headbang` or `airguitar`; an unset pick
+reads as `whistle`. The pick is passed to `pick` as `s.groove`, so
+`Mood.js` stays pure. Headbang cycles `headbang-up` and `headbang-down`
+fast; the other two are single frames.
+
+The bored line becomes a `groove` line when music is playing, same
+chance, same cooldown. The opening monologue keeps its bored trigger
+only when there is no music.
+
+## Lines
+
+New reaction group in `Lines.lines`:
+
+ groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."],
+
+Default artists, `Lines.artists`:
+
+ "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."],
+ "evanescence": ["Wake me up inside. ...Operator, don't laugh."],
+ "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."],
+ "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."],
+
+`appList` and `toMap` take the default set as an argument, so the same
+functions serve apps and artists; `appMap` becomes `pickMap(saved,
+defaults)`.
+
+## Settings
+
+- `Prefs.companionArtists`, `setCompanionArtists`, `resetCompanionArtists`,
+ parsed and defaulted like `companionApps`.
+- `PhrasesPage.qml` gains `keyLabel` (default "Class") used for the key
+ field's placeholder and label.
+- `modules/companion/ArtistsModule.qml`: "Companion artists", group
+ Companion, a copy of `PhrasesModule` bound to the artist prefs and
+ defaults, `keyLabel: "Artist"`. Registered in `shell.qml`, which means a
+ restart (see AGENTS.md, Reloading).
+
+## Frames
+
+In `frames.txt`, each with its leotard line (`${LEOTARD}`), 12 in all:
+
+ DECK = A small black DJ console with two turntables and a mixer stands in front of her at waist height, hiding her legs.
+ dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile; she wears black over-ear headphones. ${EYES} ${KEEP}
+ dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP}
+ dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. ${POSE} ${EYES} ${KEEP}
+ dress/headbang-up <- dress/base : She headbangs: head tipped back, hair flying up, eyes shut, fists clenched at her sides. ${BARE} ${KEEP_BARE}
+ dress/headbang-down <- dress/headbang-up : Change only her head: tipped sharply forward and down, hair thrown forward over her face. Everything else exactly the same. ${KEEP_BARE}
+ dress/airguitar <- dress/base : She plays an invisible air guitar: one hand strumming at her hip, the other fretting in the air to the side, knees bent, eyes shut, mouth open in a grin. Nothing in her hands. ${BARE} ${KEEP_BARE}
+
+Prompts are a first draft: klein results get rerolled and reworded as
+before, and what failed goes into `recipe.txt`. The user runs
+`gen-frames`; a missing frame falls back to `base`, so the code can land
+before the art.
+
+## Tests
+
+- `test_mood.js`: dj wins over clicks and loses to sing; groove replaces
+ bored only with music; yawn and lying still win with music; groove
+ frame follows `s.groove`.
+- `test_lines.js`: substring match is case-insensitive and picks the
+ first matching key; no match returns nothing; `appList`/`toMap` round
+ trip with the artist defaults.
+
+## Not doing
+
+- Window title or URL reactions (separate idea, not this spec).
+- Per-artist frames, genre detection, beat sync.