aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-23-companion-music.md
blob: 534d02972758b5afab250c3f806eed054e92b80f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
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`.