aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md25
-rw-r--r--desktop/Prefs.qml36
-rw-r--r--desktop/assistant/Assistant.qml8
-rw-r--r--desktop/assistant/Card.qml3
-rw-r--r--desktop/companion/Companion.qml166
-rw-r--r--desktop/companion/Figure.qml22
-rw-r--r--desktop/companion/Lines.js123
-rw-r--r--desktop/companion/Mood.js22
-rw-r--r--desktop/companion/README.md42
-rw-r--r--desktop/companion/frames.txt63
-rw-r--r--desktop/companion/test_lines.js79
-rw-r--r--desktop/companion/test_mood.js41
-rw-r--r--desktop/island/Island.qml11
-rw-r--r--desktop/island/README.md4
-rw-r--r--desktop/island/StatusPill.qml17
-rw-r--r--desktop/modules/companion/ArtistsModule.qml67
-rw-r--r--desktop/modules/companion/CompanionModule.qml2
-rw-r--r--desktop/modules/companion/PhrasesModule.qml64
-rw-r--r--desktop/modules/companion/PhrasesPage.qml195
-rw-r--r--desktop/modules/perf/Perf.js19
-rw-r--r--desktop/modules/perf/PerfPanel.qml93
-rw-r--r--desktop/modules/perf/README.md7
-rw-r--r--desktop/modules/perf/test_perf.js17
-rw-r--r--desktop/shell.qml2
-rw-r--r--docs/superpowers/plans/2026-10-23-companion-music.md589
-rw-r--r--docs/superpowers/plans/2026-10-24-companion-seat.md383
-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
-rw-r--r--docs/superpowers/specs/2026-10-24-companion-seat-design.md103
-rw-r--r--keybinds/shell.qml2
-rw-r--r--window-switcher/README.md3
-rw-r--r--window-switcher/Switcher.qml2
-rw-r--r--window-switcher/shell.qml2
33 files changed, 2267 insertions, 83 deletions
diff --git a/AGENTS.md b/AGENTS.md
index faf6b18..27af75d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -143,14 +143,24 @@ changing that component. The ones that generalise:
dominfo <vm>` grepped for `Managed save: yes`, polled per VM the way the
agent rows are. It measured 6ms.
- **Tray menus need `//@ pragma UseQApplication`** at the top of the root QML
- file. Without it `SystemTrayItem.display()` only logs a qCritical ("not
- started in QApplication mode") and nothing opens. Pragmas are read at launch,
+ file. Without it `SystemTrayItem.display()` and `QsMenuAnchor.open()` only
+ log a qCritical ("not started in QApplication mode") and nothing opens. Pragmas are read at launch,
before the first import, so adding or changing one needs a restart, not a hot
reload. `desktop/shell.qml` carries it.
- **A tray menu is a separate popup, and hovering it un-hovers the island.**
- The pills lose the pointer, so the hide timer would close the island under
- the open menu. `display()` reports no close, so the island holds itself shown
- after it until the pointer returns (a ponytail ceiling in `Island.qml`).
+ The pills lose the pointer and the hide timer would close the island under
+ the open menu, so the island holds itself shown while one is open.
+ `display()` reports no close, and holding until the pointer returned left
+ the island stuck open after a menu's Quit, with the pointer on the menu.
+ Menus open through a `QsMenuAnchor` instead, whose `closed` signal fires on
+ any dismissal and ends the hold.
+- **`SplitParser` can hand a line over with the previous blank line still
+ attached.** Streaming `top -b`, every frame header after the first arrived
+ as `"\ntop - ..."`, so `startsWith("top - ")` matched once and the
+ process table never filled, with nothing in the log. Match with
+ `/^\s*.../`. A shell pipe shows no such thing, so test the parse inside
+ `qs`, and capture with a live reader rather than a killed `top > file`,
+ which flushes on exit and hides buffering too.
- **A mouse button nothing accepts does not reliably fall through to a
catcher underneath.** The drawer's back button (`BTN_SIDE`, `Qt.BackButton`)
never reached a full-surface `MouseArea` below the panel while a page was
@@ -546,6 +556,11 @@ back.
`qs -p <dir>` runs one directly. `qs -p <dir> ipc call <target> <fn>` reaches a
running one, which is how `SUPER+v` opens the VM drawer.
+An `IpcHandler` function named after a `qs ipc` subcommand (`show`, `call`,
+`wait`, `listen`, `prop`) cannot be called from the CLI: `ipc call companion
+show` runs `ipc show` instead, prints the target list and exits 0. Three
+handlers had a `show()` nobody could reach; they are `open()` now.
+
## Conventions
- GPLv2 only, with the header notice in every source file.
diff --git a/desktop/Prefs.qml b/desktop/Prefs.qml
index 80515a4..610e004 100644
--- a/desktop/Prefs.qml
+++ b/desktop/Prefs.qml
@@ -75,6 +75,36 @@ Singleton {
root.write(Object.assign({}, root.doc, { companionBlushFile: p }));
}
+ // The companion's app lines as saved: a map of lowercased window class
+ // to phrases, or null for the defaults in companion/Lines.js. The
+ // Companion phrases page edits it.
+ property var companionApps: null
+ function setCompanionApps(map) {
+ root.companionApps = map;
+ root.write(Object.assign({}, root.doc, { companionApps: map }));
+ }
+ function resetCompanionApps() {
+ root.companionApps = null;
+ const d = Object.assign({}, root.doc);
+ delete d.companionApps;
+ root.write(d);
+ }
+
+ // 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);
+ }
+
// The settings window's sidebar width, dragged by its edge.
readonly property int sidebarMin: 160
readonly property int sidebarMax: 420
@@ -137,6 +167,10 @@ Singleton {
const sk = parsed.companionSkirtless;
root.companionSkirtless = Number.isInteger(sk) && sk >= 0 ? sk : 60;
root.companionBlushFile = typeof parsed.companionBlushFile === "string" ? parsed.companionBlushFile : "";
+ const ca = parsed.companionApps;
+ root.companionApps = ca && typeof ca === "object" && !Array.isArray(ca) ? ca : null;
+ const cr = parsed.companionArtists;
+ root.companionArtists = cr && typeof cr === "object" && !Array.isArray(cr) ? cr : null;
root.ready = true;
}
@@ -152,6 +186,6 @@ Singleton {
onFileChanged: reload()
onLoaded: root.reparse()
// Missing file: the defaults, and nothing written.
- onLoadFailed: { root.doc = {}; root.pinnedIp = ""; root.presets = null; root.settingsSidebar = 200; root.assistantModel = ""; root.companion = true; root.companionBlushFile = ""; root.companionSkirtless = 60; root.ready = true; }
+ onLoadFailed: { root.doc = {}; root.pinnedIp = ""; root.presets = null; root.settingsSidebar = 200; root.assistantModel = ""; root.companion = true; root.companionBlushFile = ""; root.companionSkirtless = 60; root.companionApps = null; root.companionArtists = null; root.ready = true; }
}
}
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index 0b7026d..4144910 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -112,6 +112,12 @@ Scope {
readonly property var screenObj:
Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+ // The tall card (a chat, or a panel) is fullHeight and centred, so its
+ // top only moves on entering or leaving tall. The companion sits there.
+ readonly property bool tall: root.mode === "chat" || root.historyOpen || root.pickerOpen || root.modelsOpen
+ readonly property int fullHeight: Math.round(root.screenObj.height * 0.7)
+ readonly property int cardTop: Math.round((root.screenObj.height - root.fullHeight) / 2)
+
function toggle() { if (root.open) root.close(); else root.show(); }
// A conversation idle for 10 minutes is over: opening starts a new one,
@@ -552,7 +558,7 @@ Scope {
Card {
anchors.centerIn: parent
width: Math.min(760, parent.width - 48)
- fullHeight: Math.round(parent.height * 0.7)
+ fullHeight: root.fullHeight
canvas: root
turnModel: turns
}
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index 810920f..437f0ad 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -28,8 +28,7 @@ Rectangle {
property real fullHeight: 600 // the chat state's height, set by the window
readonly property string mode: card.canvas.mode
- readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen || card.canvas.modelsOpen
- readonly property bool tall: card.mode === "chat" || card.panel
+ readonly property bool tall: card.canvas.tall
// Bottom-up: the input, then the actions (file state), then the chips.
height: card.tall ? card.fullHeight
diff --git a/desktop/companion/Companion.qml b/desktop/companion/Companion.qml
index 3102c59..dfdc474 100644
--- a/desktop/companion/Companion.qml
+++ b/desktop/companion/Companion.qml
@@ -12,9 +12,11 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
+import Quickshell.Bluetooth
import Quickshell.Hyprland
import QtQuick
import ".."
+import "../modules/sound"
import "../NotifyConfig.js" as NC
import "Mood.js" as Mood
import "Lines.js" as Lines
@@ -33,30 +35,72 @@ PanelWindow {
// SUPER+\ (the IPC below) and the Companion settings page, through Prefs,
// so it survives a restart.
readonly property bool present: Prefs.ready && Prefs.companion
+ // A hide plays a goodbye first: the wave frames, then the drop.
+ // Re-showing her cancels it and greets as usual.
+ property bool leaving: false
+ property int waveIndex: 0
+ readonly property var waveCycle: ["wave", "wave-l", "wave", "wave-r"]
onPresentChanged: {
if (root.present) {
+ root.leaving = false;
root.destructAt = 0; // the skirt is back
root.now = Date.now();
greet.restart();
} else {
root.stopMonologue();
+ // Nothing to wave at when she is already out of view.
+ if (root.fullscreen !== 2 && !root.hopping) {
+ root.leaving = true;
+ root.waveIndex = 0;
+ root.now = Date.now();
+ // A line one hide in four, like the monologue's roll, and not
+ // forced, so the usual 30 s line cooldown still applies. A
+ // silent wave is only as long as it takes to read nothing.
+ const spoke = Math.random() < 0.25 && root.say("bye");
+ goodbyeTimer.interval = spoke ? Mood.T.goodbye : Mood.T.goodbyeSilent;
+ goodbyeTimer.start();
+ }
}
}
- // The monitor she is on trails the focused one: a change plays her exit
- // here first, and only then moves the window (settled, below).
+ // Where she goes: the focused monitor's corner, or seated on the
+ // assistant's tall card. here and sitting are where the window is now
+ // and trail the goal: a change plays her exit first, and only then
+ // moves the window (settled, below).
readonly property string focused: Hyprland.focusedMonitor?.name ?? ""
+ readonly property bool seated: root.asst.open && root.asst.tall
+ readonly property string goal: root.seated ? root.asst.monitor : root.focused
property string here: ""
+ property bool sitting: false
property bool hopping: false
- onFocusedChanged: {
- if (root.here === "") root.here = root.focused;
- else if (root.focused !== root.here) root.hopping = true;
+ // No focused monitor (outputs gone over a suspend) is not a destination.
+ // Already out of view, there is no exit to wait for: settled would never
+ // fire, hopping would stick and hide her for good, so move at once.
+ // Mid-hop, settled takes the latest goal; moving now would jump the
+ // window before the exit has played (hopping counts as away).
+ function move() {
+ if (root.hopping || root.goal === "" || (root.goal === root.here && root.seated === root.sitting)) return;
+ if (root.here === "" || root.away) {
+ root.here = root.goal;
+ root.sitting = root.seated;
+ } else {
+ root.hopping = true;
+ }
}
- Component.onCompleted: root.here = root.focused
+ onGoalChanged: root.move()
+ onSeatedChanged: root.move()
+ Component.onCompleted: root.move()
screen: Quickshell.screens.find(s => s.name === root.here) ?? Quickshell.screens[0]
anchors { right: true; bottom: true }
- margins { right: 24; bottom: 16 }
+ // Seated, her right edge is 90 px right of the screen's centre, which
+ // puts her in the gap in the card's header, and her feet 35 px below the
+ // card's top. The right edge rather than her centre, because frames
+ // differ in width and the window would shift with every frame.
+ margins {
+ right: root.sitting ? Math.round(root.asst.screenObj.width / 2 - 90) : 24
+ bottom: root.sitting ? root.asst.screenObj.height - root.asst.cardTop - 35 : 16
+ }
implicitWidth: 760
implicitHeight: 260
color: "transparent"
@@ -83,6 +127,9 @@ PanelWindow {
property double pulseAt: 0
property double wakeAt: 0
property double admireAt: 0
+ property double singAt: 0
+ property double djAt: 0
+ property string groove: ""
property double lastLineAt: 0
property double lastMonologueAt: 0
property bool monologue: false
@@ -122,7 +169,9 @@ PanelWindow {
root.wakeAt = t;
root.say("wake");
} else if (root.idle === 1 && root.wasIdle === 0) {
- if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue();
+ root.pickGroove();
+ if (Player.playing) root.say("groove");
+ else if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue();
else root.say("bored");
}
root.wasIdle = root.idle;
@@ -188,6 +237,46 @@ PanelWindow {
}
}
+ // Sony headphones connecting, by the island's rule (island/StatusPill.qml):
+ // recognised by their model prefix, since BlueZ gives no class that says
+ // "headphones". A connection makes her sing for Mood.T.singHold. Bluetooth
+ // reads empty for about 2 s after launch, so a shell start with them
+ // already on is not a connection: btReady waits past that.
+ readonly property bool phonesConnected: (Bluetooth.devices?.values ?? [])
+ .some(d => d.connected && /^(WH|WF)-|^LinkBuds/.test(d.deviceName))
+ property bool btReady: false
+ // shortcut: a genuine connection in the first 4 s after a shell start is
+ // missed along with the startup populate; tighten if that bites.
+ Timer { interval: 4000; running: true; onTriggered: root.btReady = true }
+ onPhonesConnectedChanged: {
+ if (!root.phonesConnected || !root.btReady) return;
+ root.singAt = Date.now();
+ root.now = root.singAt;
+ root.say("headphones");
+ }
+
+ // 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)];
+ }
+
// 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,
@@ -199,7 +288,7 @@ PanelWindow {
interval: 100
onTriggered: {
const t = Date.now();
- if (root.away || root.here !== root.focused || !Mood.admireAllowed(root.admireAt, t)) return;
+ if (root.away || root.sitting || 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;
@@ -222,8 +311,10 @@ PanelWindow {
Timer { id: remap; interval: 400; onTriggered: root.remapping = false }
// Balloons stack bottom-right of their screen. When that is her corner,
- // any balloon on screen shoves her left past the stack.
+ // any balloon on screen shoves her left past the stack. Seated on the
+ // assistant's card she is nowhere near them.
readonly property bool shoved: {
+ if (root.sitting) return false;
const place = Notify.config.balloons;
if (place.screen !== root.here || place.corner !== "bottom-right") return false;
return (Notify.queue || []).some(n => NC.shown(Notify.config, n, Notify.dnd,
@@ -238,10 +329,12 @@ PanelWindow {
// --- state ----------------------------------------------------------
readonly property var mood: Mood.pick({
+ leaving: root.leaving,
now: root.now, destructAt: root.destructAt, dodging: root.dodging, shoved: root.shoved,
shovedAt: root.shovedAt,
recording: root.asst.recording, busy: root.asst.busy, repliedAt: root.repliedAt,
clicks: root.clicks, admireAt: root.admireAt, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,
+ singAt: root.singAt, djAt: root.djAt, music: root.music, groove: root.groove,
monologue: root.monologue, idle: root.idle,
})
readonly property string outfit: Mood.outfit(root.destructAt, root.now, Prefs.companionSkirtless * 1000)
@@ -249,19 +342,21 @@ PanelWindow {
onMoodChanged: if (root.monologue && root.mood.state !== "muse") root.stopMonologue()
// Hidden: no sprite, no bubble. Away adds the dodge, which hides the
- // sprite but lets its line show.
- readonly property bool hidden: !root.present || root.fullscreen === 2 || root.hopping
+ // sprite but lets its line show. While waving goodbye she is still on
+ // screen, so a hidden present does not hide her until the wave is done.
+ readonly property bool hidden: (!root.present && !root.leaving) || root.fullscreen === 2 || root.hopping
readonly property bool away: root.hidden || root.dodging || root.remapping
// --- speech ---------------------------------------------------------
function say(kind, force) {
const t = Date.now();
- if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return;
- const line = Lines.pick(kind, Math.random());
- if (line === "") return;
+ if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return false;
+ const line = Lines.pick(kind, Math.random(), Prefs.companionApps, Prefs.companionArtists);
+ if (line === "") return false;
root.lastLineAt = t;
bubble.show(line, 3000);
+ return true;
}
// The opening monologue: about 15 characters a second, one sentence
@@ -353,18 +448,51 @@ PanelWindow {
}
}
+ // The goodbye: she waves, then leaves. With a line she holds long enough
+ // to read it. The hand sways side to side while it lasts.
+ Timer { id: goodbyeTimer; onTriggered: root.leaving = false }
+ Timer {
+ id: waveFlip
+ interval: 250
+ repeat: true
+ running: root.leaving
+ onTriggered: root.waveIndex = (root.waveIndex + 1) % root.waveCycle.length
+ }
+
+ // 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: 300 },
+ 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 standing: root.leaving ? root.waveCycle[root.waveIndex]
+ : root.cycle ? root.cycle.frames[root.cycleIndex % root.cycle.frames.length] : root.mood.frame
+ // Seated, the sitting version of it (Mood.seatFrame).
+ readonly property string frame: root.sitting ? Mood.seatFrame(root.standing) : root.standing
+
// qs -p <desktop> ipc call companion toggle -> SUPER+\
IpcHandler {
target: "companion"
function toggle() { Prefs.setCompanion(!Prefs.companion); }
- function show() { Prefs.setCompanion(true); }
+ function open() { Prefs.setCompanion(true); }
function hide() { Prefs.setCompanion(false); }
}
- // The exit has played on the old monitor: move, and the entrance plays.
+ // The exit has played: move, and the entrance plays. The goal may have
+ // changed during the exit; the latest one wins.
function settled(out) {
if (out && root.hopping) {
- root.here = root.focused;
+ root.here = root.goal;
+ root.sitting = root.seated;
root.hopping = false;
}
}
@@ -388,7 +516,7 @@ PanelWindow {
id: sprite
x: seat.x
y: seat.y
- source: `file://${root.art}/${root.outfit}/${root.mood.frame}.png`
+ source: `file://${root.art}/${root.outfit}/${root.frame}.png`
fallback: `file://${root.art}/${root.outfit}/base.png`
out: root.away
trembling: root.mood.state === "admire" && root.mood.frame === "blush3"
diff --git a/desktop/companion/Figure.qml b/desktop/companion/Figure.qml
index 3401168..285e2cb 100644
--- a/desktop/companion/Figure.qml
+++ b/desktop/companion/Figure.qml
@@ -44,6 +44,13 @@ Item {
onSourceChanged: s.load(s.source)
Component.onCompleted: s.load(s.source)
+ // The incoming frame fades in on top while the outgoing one stays
+ // opaque under it. Fading both at once left each near half opacity
+ // mid-swap, and she blinked see-through on every turn of the dj loop.
+ property real fade: 1
+ NumberAnimation { id: fadeIn; target: s; property: "fade"; from: 0; to: 1; duration: 120 }
+ onFlipChanged: fadeIn.restart()
+
function ready(img) {
if (img.status === Image.Error && String(img.source) !== s.fallback) {
img.source = s.fallback;
@@ -59,8 +66,14 @@ Item {
target: s; property: "drop"; duration: 320
onFinished: s.settled(s.out)
}
+ // Out from creation (a shell started with her hidden) never ran the drop,
+ // since a starting value fires no onOutChanged, and she was drawn in place
+ // with her clock stopped, frozen on whatever frame came next. The body
+ // below hides her until she is shown, and the first entrance starts from
+ // below like any other.
onOutChanged: {
dropAnim.stop();
+ if (!s.out && s.drop === 0) s.drop = s.height + 24;
dropAnim.to = s.out ? s.height + 24 : 0;
dropAnim.easing.type = s.out ? Easing.InCubic : Easing.OutBack;
dropAnim.start();
@@ -86,6 +99,7 @@ Item {
Item {
anchors.fill: parent
+ visible: !s.out || dropAnim.running
opacity: 1 - s.drop / (s.height + 24)
transform: Translate { x: s.shake; y: s.drop - s.bob }
@@ -94,8 +108,8 @@ Item {
width: implicitWidth / 2; height: implicitHeight / 2
anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter }
smooth: true; mipmap: true; asynchronous: true
- opacity: s.flip ? 0 : 1
- Behavior on opacity { NumberAnimation { duration: 120 } }
+ z: s.front === a ? 1 : 0
+ opacity: s.front === a ? s.fade : s.fade < 1 ? 1 : 0
onStatusChanged: s.ready(a)
}
Image {
@@ -103,8 +117,8 @@ Item {
width: implicitWidth / 2; height: implicitHeight / 2
anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter }
smooth: true; mipmap: true; asynchronous: true
- opacity: s.flip ? 1 : 0
- Behavior on opacity { NumberAnimation { duration: 120 } }
+ z: s.front === b ? 1 : 0
+ opacity: s.front === b ? s.fade : s.fade < 1 ? 1 : 0
onStatusChanged: s.ready(b)
}
}
diff --git a/desktop/companion/Lines.js b/desktop/companion/Lines.js
index d6450df..77e7a83 100644
--- a/desktop/companion/Lines.js
+++ b/desktop/companion/Lines.js
@@ -17,34 +17,52 @@
var lines = {
shown: ["Glory to mankind.", "2B, reporting.", "...Back on duty."],
+ bye: ["2B, signing off.", "Rest while you can.", "Duty complete."],
bored: ["...Operator, I'm bored.", "Nothing to report.", "Awaiting orders."],
wake: ["I'm awake.", "Systems online."],
mock: ["Typo again?", "Delete, delete, delete.", "Were you aiming for that key?"],
shoved: ["Hey.", "Rude.", "I was standing here."],
ready: ["Answer's ready.", "Your reply came in."],
+ headphones: ["...Good song.", "Music. Finally.", "I'll allow this one.", "Louder."],
+ groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."],
click: ["...Yes?", "Can I help you?"],
blush: ["S-stop that.", "...Operator."],
wallpaper: ["...!", "W-what is this?", "I wasn't staring."],
dodge: ["...Fine.", "I'll move."],
destruct: ["Self-destruct sequence initiated."],
aftermath: ["...That was unpleasant."],
+};
+
+// The lines for a program she recognizes, by lowercased window class: one is
+// said at random when focus moves to it. The settings window's Companion
+// phrases page edits them into Prefs.companionApps, which then replaces this
+// whole set; a class with no entry says nothing.
+var apps = {
+ firefox: ["Researching again?", "Don't open too many tabs.", "The Bunker's network is faster."],
+ kitty: ["A terminal. Serious work.", "Careful with that rm.", "I'll watch your commands."],
+ discord: ["Talking to the Resistance?", "Don't tell them where we are."],
+ signal: ["Encrypted. Good."],
+ steam: ["Training simulation?", "Don't play all night."],
+ gimp: ["Making art?"],
+ "com.obsproject.studio": ["Are we recording?", "I'll stay out of frame."],
+ "md.obsidian.obsidian": ["Writing a report?", "Taking notes. Good habit."],
+ sublime_text: ["Writing code?", "Mind the typos."],
+ qtmaildir: ["Any orders from Command?", "Mail. Anything urgent?"],
+ "virt-manager": ["More machines?", "Androids running androids."],
+ audacious: ["Music. I like this."],
+ llamachat: ["Talking to Pod?"],
+ qtpass: ["I'm not looking."],
+};
- // The focused window's class, lowercased, as "app:<class>". A class
- // with no entry says nothing.
- "app:firefox": ["Researching again?", "Don't open too many tabs.", "The Bunker's network is faster."],
- "app:kitty": ["A terminal. Serious work.", "Careful with that rm.", "I'll watch your commands."],
- "app:discord": ["Talking to the Resistance?", "Don't tell them where we are."],
- "app:signal": ["Encrypted. Good."],
- "app:steam": ["Training simulation?", "Don't play all night."],
- "app:gimp": ["Making art?"],
- "app:com.obsproject.studio": ["Are we recording?", "I'll stay out of frame."],
- "app:md.obsidian.obsidian": ["Writing a report?", "Taking notes. Good habit."],
- "app:sublime_text": ["Writing code?", "Mind the typos."],
- "app:qtmaildir": ["Any orders from Command?", "Mail. Anything urgent?"],
- "app:virt-manager": ["More machines?", "Androids running androids."],
- "app:audacious": ["Music. I like this."],
- "app:llamachat": ["Talking to Pod?"],
- "app:qtpass": ["I'm not looking."],
+// 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."],
};
// The game's opening, one sentence per bubble.
@@ -56,8 +74,73 @@ var monologue = [
"I often think about the god who blessed us with this cryptic puzzle...and wonder if we'll ever get the chance to kill him.",
];
-// A line for kind; roll is Math.random(). "" for a kind with no lines.
-function pick(kind, roll) {
- const l = lines[kind] || [];
- return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : "";
+// 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;
+}
+
+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);
+}
+
+// The page's list back to a map for Prefs: empty classes and phrases dropped,
+// the class lowercased (Hyprland reports it that way), a repeated class
+// keeping its last entry.
+function toMap(list) {
+ const m = {};
+ for (let i = 0; i < list.length; i++) {
+ const cls = String(list[i].cls || "").trim().toLowerCase();
+ if (cls === "") continue;
+ const src = list[i].lines || [];
+ const ls = [];
+ for (let j = 0; j < src.length; j++) {
+ const s = String(src[j] || "").trim();
+ if (s !== "") ls.push(s);
+ }
+ if (ls.length) m[cls] = ls;
+ }
+ return m;
}
diff --git a/desktop/companion/Mood.js b/desktop/companion/Mood.js
index 409a958..be514d7 100644
--- a/desktop/companion/Mood.js
+++ b/desktop/companion/Mood.js
@@ -23,11 +23,15 @@ var T = {
mockCooldown: 20000,
typingHold: 2000,
waveHold: 3000,
+ singHold: 4000, // her singing reaction to the headphones connecting
+ djHold: 6000, // behind the decks when a track starts or changes
wakeHold: 1500, // the stretch after idling
lineCooldown: 30000,
monologueCooldown: 3600000,
destruct: 1000, // the glow and the flash; the sprite is hidden under it
scorched: 3000,
+ goodbye: 2000, // waving goodbye with a line, enough to read it
+ goodbyeSilent: 1000, // waving goodbye with nothing to read
admireLook: 1500, // turned to the wallpaper, before the blush
admireBlush: 3000,
admireCooldown: 180000,
@@ -53,6 +57,9 @@ function clickFrame(count) {
// The spec's priority table, top to bottom: the first that applies wins.
// idle is 0 awake, 1 bored, 2 yawning, 3 lying down.
function pick(s) {
+ // The user hid her: the goodbye outranks whatever she was doing. The
+ // frame here is a placeholder; Companion.qml cycles the wave frames.
+ if (s.leaving) return st("bye", "wave");
const since = s.destructAt ? s.now - s.destructAt : Infinity;
if (since < T.destruct) return st("destruct", "base");
if (since < T.destruct + T.scorched) return st("scorched", "scorched");
@@ -62,6 +69,8 @@ function pick(s) {
if (s.recording) return st("listen", "listen");
if (s.busy) return st("think", "think");
if (s.now - s.repliedAt < T.waveHold) return st("wave", "wave");
+ if (s.now - s.singAt < T.singHold) return st("sing", "sing");
+ if (s.now - s.djAt < T.djHold) return st("dj", "dj");
if (s.clicks.count > 0 && s.now - s.clicks.at < T.clickHold)
return st("clicks", clickFrame(s.clicks.count));
if (s.now - s.admireAt < T.admireLook + T.admireBlush)
@@ -73,7 +82,9 @@ function pick(s) {
if (s.monologue) return st("muse", "muse");
if (s.idle >= 3) return st("lying", "lying");
if (s.idle === 2) return st("yawn", "yawn");
- if (s.idle === 1) return st("bored", "sleepy");
+ // 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");
return st("base", "base");
}
@@ -94,6 +105,15 @@ function admireAllowed(lastAt, now) { return now - lastAt >= T.admireCooldown; }
function canSpeak(lastLineAt, now) { return now - lastLineAt >= T.lineCooldown; }
+// Seated on the assistant's tall card (Companion.qml, sitting): the frames
+// with a sitting version show it, everything else the plain sit. Typing's
+// watch becomes the lean over the card, reading.
+var SEAT = {
+ watch: "sit-read", point: "sit-point", smirk: "sit-smirk", think: "sit-think",
+ listen: "sit-listen", wave: "sit-wave", "wave-l": "sit-wave", "wave-r": "sit-wave",
+};
+function seatFrame(frame) { return SEAT[frame] || "sit"; }
+
// Whether the opening monologue plays: a chance per trigger, and never
// twice within the hour. roll is Math.random(), passed in for the test.
function monologue(trigger, lastAt, now, roll) {
diff --git a/desktop/companion/README.md b/desktop/companion/README.md
index 4bb87f2..2bd6c4e 100644
--- a/desktop/companion/README.md
+++ b/desktop/companion/README.md
@@ -11,7 +11,7 @@ The design, with what was measured on the way, is
| `Companion.qml` | the window, inputs, timers, speech, IPC (`companion toggle/show/hide`) |
| `Figure.qml` | draws a frame: crossfade, bob, drop out and back, explosion |
| `Mood.js` | which state wins, from a snapshot; all the timings are its `T` |
-| `Lines.js` | her lines and the opening monologue |
+| `Lines.js` | her reaction, per-app and per-artist lines, and the opening monologue |
| `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 |
| `prep-frames` | generated images to display frames |
@@ -21,9 +21,16 @@ The design, with what was measured on the way, is
it wins over the directory's own file (see AGENTS.md).
Whether she is shown is `companion` in `Prefs.qml`, so it survives a
-restart: SUPER+\ and the switch on Settings > Desktop > Companion
+restart: SUPER+\ and the switch on Settings > Companion
(`desktop/modules/companion/`) both set it.
+A hide by SUPER+\ or that switch plays a goodbye first: she waves (`wave`,
+`wave-l` and `wave-r` cycling) and then drops out. One hide in four she also
+says a `bye` line, under the usual 30 s line cooldown; with a line she holds
+`Mood.T.goodbye` (2 s, enough to read it), silently only
+`Mood.T.goodbyeSilent` (0.5 s). Fullscreen, the lock screen and the monitor
+hop keep their own exits and do not wave.
+
## Art
Not in the repo: the character is Square Enix's and this repo is GPLv2.
@@ -53,17 +60,41 @@ frame falls back to its outfit's `base`.
stacks a later surface above an earlier one on a layer, and 0.56.2 has
no layer rule to order them (no `order` key in the binary; an unknown
key raises no config error).
+- The assistant's tall card (a chat, or its history, picker or models
+ panel) seats her on its top edge, right of centre, through the monitor
+ hop. `Assistant.qml` gives `tall` and `cardTop`; seated she ignores
+ balloons and shows `Mood.seatFrame` of her frame, the `sit-*` set. The
+ bar and the file card leave her in the corner: standing frames face
+ out, not down at the text. Typing and BackSpace still reach her under
+ the canvas's exclusive keyboard (checked on socket2).
- The program in front of her is Hyprland's `activewindow` event,
`class,title`. It also fires on every title change, so she reacts
only when the class changes, a third of the time, under the 30 s line
- cooldown. Lines are `app:<class>` in `Lines.js`, class lowercased; a
- class with none says nothing.
-- A listed wallpaper (Settings > Desktop > Companion for the file, the
+ cooldown. Lines are by lowercased class in `Lines.js`; a class with
+ none says nothing. Settings > Companion > Companion phrases edits them
+ into `Prefs.companionApps`, which then replaces the defaults.
+- A listed wallpaper (Settings > 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`.
+- Sony headphones connecting are the island's rule (`island/StatusPill.qml`:
+ `WH-`/`WF-`/`LinkBuds` by name, since BlueZ has no headphones class): she
+ sings once for `Mood.T.singHold`, 4 s, and says a line if the usual 30 s
+ cooldown allows it. A shell start with them already on is not a connection:
+ Bluetooth reads empty for ~2 s, then populates.
+- 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; a shell start with music on DJs once. 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.
- She only feels the pointer while it is over her: Quickshell has no
global pointer and Hyprland sends no motion events. Hence the 0.6 s
dodge rather than moving away as the pointer approaches.
@@ -83,3 +114,4 @@ frame falls back to its outfit's `base`.
## Check
node desktop/companion/test_mood.js
+ node desktop/companion/test_lines.js
diff --git a/desktop/companion/frames.txt b/desktop/companion/frames.txt
index 0019db1..0553eb5 100644
--- a/desktop/companion/frames.txt
+++ b/desktop/companion/frames.txt
@@ -19,9 +19,11 @@
# after it. ${NAME} expands a NAME = ... line defined above it. Blank lines
# and lines starting with # are skipped.
#
-# dress/base is the one frame not made here: a klein edit of base-sitting.png,
-# recorded in recipe.txt. Every other dress frame is made from it, and every
-# leotard frame from its own dress frame, so the pose carries over.
+# Two frames are not made here, both recorded in recipe.txt: dress/base, a
+# klein edit of base-sitting.png, and dress/sit-read, a masked inpaint of
+# dress/sit. The other standing dress frames are made from base, the seated
+# ones from sit, and every leotard frame from its own dress frame, so the
+# pose carries over.
KEEP = She keeps her black gloves on both hands, her puffy black juliet sleeves, her black dress with its skirt, her black thighhigh boots, her black blindfold and hairband, her lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
POSE = keep her pose exactly, standing, arms relaxed at her sides.
@@ -34,6 +36,14 @@ EYES = Her black blindfold stays on as one solid band of cloth fully covering bo
# After the self-destruct the blindfold is gone with the skirt: the whole
# leotard set shows her eyes.
LEOTARD = Her skirt is gone, revealing a plain white leotard underneath, and her blindfold is gone too: both eyes uncovered and clearly visible, pale blue-grey, matching her expression. Everything else stays exactly the same: pose, expression, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips, the small mole under her mouth, chibi proportions, art style and white background.
+# Singing along to whatever is playing: headphones on, eyes shut. The
+# blindfold is gone, but BARE does not fit because it says the eyes show.
+SING = Her black blindfold is gone: nothing over her eyes, no blindfold, no mask; her black hairband stays. Both her eyes are closed, softly shut. Her mouth is open wide as if she is singing loudly. She wears a pair of black over-ear headphones over her head and ears. A couple of small black musical notes float in the air beside her head.
+# Seated on the assistant's card: the original sitting rest pose, legs
+# dangling over the edge, from base-sitting (the image dress/base came from).
+# Naming a ledge drew one, a grey bar across the whole image: she sits on
+# nothing, and the card's edge in the shell is the ledge.
+SIT = keep her sitting pose exactly: sitting, front view, legs dangling down, as if perched on an invisible edge. Nothing is drawn under or behind her: no ledge, no bar, no bench, no wall, no floor, no shadow; the background is plain white all around her.
# --- expressions
dress/smile <- dress/base : Change only her expression to a small warm closed-mouth smile; ${POSE} ${EYES} ${KEEP}
@@ -46,6 +56,10 @@ dress/sleepy <- dress/base : Change only her expression to sleepy and drowsy, h
# --- poses
dress/wave <- dress/base : Raise only the hand on the left side of the image beside her head, waving, with a small smile; her other arm stays at her side. ${KEEP}
+# The goodbye wave sways side to side. Edited from wave, the centre of the
+# sway, so klein keeps the arm up and moves only the hand.
+dress/wave-l <- dress/wave : Change only the hand she holds up: bend it at the wrist toward the left side of the image, fingers pointing left, as if in the middle of a goodbye wave; keep the arm raised beside her head, her small smile, and everything else exactly the same. ${KEEP}
+dress/wave-r <- dress/wave : Change only the hand she holds up: bend it at the wrist toward the right side of the image, fingers pointing right, as if in the middle of a goodbye wave; keep the arm raised beside her head, her small smile, and everything else exactly the same. ${KEEP}
# Shown while typing: she faces into the screen, away from the viewer, as if
# reading over the user's shoulder. A profile read as posing, not watching.
dress/watch <- dress/base : Turn her completely around into a back view: we see her from behind, the back of her head with her white bob, crossed by her black hairband and the small black cloth knot of her black blindfold (black, not white, no bow), her back, the back of her dress and the backs of her legs, as if she is facing a screen and watching it; arms relaxed at her sides. Same character, black gloves, puffy black juliet sleeves, black dress with its skirt, black thighhigh boots, black hairband, chibi proportions, art style and white background.
@@ -67,6 +81,31 @@ dress/stretch <- dress/base : She stretches both arms up above her head, waking
# and face were right: refs/shoved-7.png, made from dress/base with
# --seed 7 by the earlier standing prompt (see generated.log), kept in refs/.
dress/shoved <- refs/shoved-7 : Keep her face, her scared expression, her uncovered eyes and both raised hands exactly as they are. Change her body and legs: she is in the air, lifted completely off the ground, knocked flying toward the left side of the image by a strong blast from the right; both feet off the ground, legs trailing behind her toward the right side, her body tilted, her hair and skirt blown toward the left. No ground, no blast effect drawn. ${KEEP_BARE}
+# Headphones connecting: a few seconds of her singing along.
+dress/sing <- dress/base : ${SING} ${POSE} ${KEEP_BARE}
+# Music: behind the decks on a track start or change, and the grooves
+# that replace boredom while music plays.
+DECK = A small black DJ booth stands in front of her: two turntables and a mixer on top at her waist height, and a solid black front panel reaching down to the ground, so her legs and boots are hidden behind it and only her upper body shows above it. No table legs.
+dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. Both her eyes are closed, softly shut, enjoying the music. She wears a pair of black over-ear headphones over her head and ears. ${KEEP_BARE}
+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. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. Both her eyes are closed, softly shut, relaxed. ${POSE} ${KEEP_BARE}
+dress/headbang-up <- dress/base : She rocks out: her upper body leans far back, her face tipped up to the sky, mouth wide open shouting, eyes squeezed shut, one fist raised high above her head making the horns hand sign with index and little finger up, the other fist clenched at her side, her hair flying back. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. ${KEEP_BARE}
+# headbang-down: the kept result is --seed 7.
+dress/headbang-down <- dress/base : She headbangs hard: seen from the front, her whole upper body is bent forward at the waist by about 45 degrees, her head thrown down so the top of her head faces the viewer, her white hair flung forward and hanging down over her face, both fists clenched in front of her chest, knees slightly bent. Her face is hidden by her hair. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. ${KEEP_BARE}
+dress/airguitar <- dress/base : She plays an electric guitar with a glowing neon blue body and a black neck, held across her hips: one hand strumming at the body, the other fretting the neck to the side, knees bent, eyes shut, mouth open in a big grin. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. ${KEEP_BARE}
+
+# --- seated, on the assistant's chat card (Mood.seatFrame)
+dress/sit <- base-sitting : ${SIT} Both gloved hands resting palms down beside her hips, a calm closed mouth. ${EYES} ${KEEP}
+# dress/sit-read is hand made, like dress/base: no edit would bend her
+# forward, so it is a masked inpaint of dress/sit, seed 13, in recipe.txt.
+# No ${SIT} on the posed frames: its "keep her sitting pose exactly" kept
+# her arms down, and sit-point came out as sit with a smirk. Seed 42 then
+# left her old hand on her hip as well; the kept result is --seed 7.
+dress/sit-point <- dress/sit : She raises the hand on the left side of the image and points down past her knees with her index finger, at something below her feet, with a teasing smirk; her other hand stays palms down beside her hip. She keeps sitting with her legs dangling; nothing is drawn under her. ${KEEP}
+dress/sit-smirk <- dress/sit : Change only her expression to a teasing one-sided smirk. ${SIT} Both eyes fully visible and uncovered; the blindfold is nowhere on her head or face. ${BARE} ${KEEP_BARE}
+dress/sit-think <- dress/sit : One gloved hand at her chin, thinking, head tilted slightly; her other hand palms down beside her hip. She keeps sitting with her legs dangling; nothing is drawn under her. ${KEEP}
+dress/sit-listen <- dress/sit : She cups the hand on the left side of the image behind her ear, listening attentively; her other hand palms down beside her hip. She keeps sitting with her legs dangling; nothing is drawn under her. ${KEEP}
+dress/sit-wave <- dress/sit : Raise only the hand on the left side of the image beside her head, waving, with a small smile; her other hand stays palms down beside her hip. She keeps sitting with her legs dangling; nothing is drawn under her. ${KEEP}
# --- the leotard set: the same frames, after the self-destruct
leotard/base <- dress/base : ${LEOTARD}
@@ -80,6 +119,8 @@ leotard/blush2 <- dress/blush2 : ${LEOTARD}
leotard/blush3 <- dress/blush3 : ${LEOTARD}
leotard/sleepy <- dress/sleepy : ${LEOTARD}
leotard/wave <- dress/wave : ${LEOTARD}
+leotard/wave-l <- dress/wave-l : ${LEOTARD}
+leotard/wave-r <- dress/wave-r : ${LEOTARD}
# From dress/watch, klein kept the blindfold's band at the back of her head
# through three prompts, by name and by position; from leotard/base, which
# has no blindfold, the back view is the usual pose edit.
@@ -96,4 +137,20 @@ leotard/think <- dress/think : ${LEOTARD}
leotard/muse <- dress/muse : ${LEOTARD}
leotard/stretch <- dress/stretch : ${LEOTARD}
leotard/shoved <- dress/shoved : ${LEOTARD}
+leotard/sing <- dress/sing : Her skirt is gone, revealing a plain white leotard underneath. Change nothing else: she keeps her closed eyes, her black over-ear headphones, the musical notes beside her head, her pose, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
leotard/scorched <- leotard/base : Right after an explosion: her clothes lightly scorched and smudged with soot, a few wisps of smoke rising from her, hair slightly messy, a dazed flat expression; standing, arms at her sides. Her eyes stay uncovered, no blindfold. Her top stays black: the black bodice with its chest cutout and the puffy black juliet sleeves, exactly as in the image; the plain white leotard shows only below her waist, where the skirt was. She keeps her black gloves, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
+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 : Her skirt is gone, revealing a plain white leotard underneath. Change nothing else: her head stays lowered with her white hair hanging down over her whole face, hiding it completely, no eyes showing, no blindfold; her fists stay clenched in front of her chest; she keeps her black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband and pose. Same character, chibi proportions, art style and white background.
+leotard/airguitar <- dress/airguitar : ${LEOTARD}
+leotard/sit <- dress/sit : ${LEOTARD}
+# ${LEOTARD} uncovers her eyes and they looked at the viewer, losing the
+# read; standalone, as leotard/headbang-down, with the gaze named.
+leotard/sit-read <- dress/sit-read : Her skirt is gone, revealing a plain white leotard underneath, and her blindfold is gone: both eyes uncovered, pale blue-grey, lowered and looking down at something just below her feet, as if reading it; her eyelids half lowered, not looking at the viewer. Change nothing else: her hunched pose with her head sunk low, black gloves palms down beside her hips, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips, the small mole under her mouth, chibi proportions, art style and white background.
+leotard/sit-point <- dress/sit-point : ${LEOTARD}
+leotard/sit-smirk <- dress/sit-smirk : ${LEOTARD}
+leotard/sit-think <- dress/sit-think : ${LEOTARD}
+leotard/sit-listen <- dress/sit-listen : ${LEOTARD}
+leotard/sit-wave <- dress/sit-wave : ${LEOTARD}
diff --git a/desktop/companion/test_lines.js b/desktop/companion/test_lines.js
new file mode 100644
index 0000000..72addf2
--- /dev/null
+++ b/desktop/companion/test_lines.js
@@ -0,0 +1,79 @@
+// 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.
+
+// Run: node desktop/companion/test_lines.js
+// Lines.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Lines.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const L = new Function(src +
+ "\nreturn { lines, apps, artists, monologue, appMap, appList, pickApp, pickArtist, pick, toMap };")();
+
+// The defaults are what she says until a set is saved.
+assert.strictEqual(L.pickApp("firefox", null, 0), L.apps.firefox[0]);
+assert.strictEqual(L.pickApp("firefox", null, 0.99), L.apps.firefox[L.apps.firefox.length - 1]);
+assert.strictEqual(L.pickApp("nope", null, 0), "");
+assert.strictEqual(L.appMap(null), L.apps);
+// A hand edit with a typo reads as the defaults, not as silence.
+assert.strictEqual(L.appMap("typo"), L.apps);
+assert.strictEqual(L.appMap(["not", "a", "map"]), L.apps);
+
+// A saved set replaces the defaults, class by class.
+assert.strictEqual(L.pickApp("firefox", { firefox: ["hi"] }, 0), "hi");
+assert.strictEqual(L.pickApp("kitty", { firefox: ["hi"] }, 0), "");
+assert.strictEqual(L.pick("app:firefox", 0, { firefox: ["custom"] }), "custom");
+assert.strictEqual(L.pick("app:firefox", 0), L.apps.firefox[0]);
+
+// Empty phrases are skipped when picking; a class with only empty ones says
+// nothing.
+assert.strictEqual(L.pickApp("firefox", { firefox: ["", " ", "only"] }, 0), "only");
+assert.strictEqual(L.pickApp("firefox", { firefox: ["", " "] }, 0), "");
+
+// Reactions still come from lines.
+assert.strictEqual(L.pick("shown", 0), L.lines.shown[0]);
+assert.strictEqual(L.pick("nope", 0), "");
+
+// The saved set as an ordered list, for the settings page.
+const list = L.appList(null);
+assert.strictEqual(list.length, Object.keys(L.apps).length);
+assert.deepStrictEqual(list[0], { cls: "firefox", lines: L.apps.firefox.slice() });
+assert.deepStrictEqual(L.appList({ kitty: ["a"] }), [{ cls: "kitty", lines: ["a"] }]);
+
+// Back to a map: trim, lowercase the class, drop empty classes and empty
+// phrases, and a repeated class keeps its last entry.
+assert.deepStrictEqual(L.toMap([
+ { cls: " Firefox ", lines: [" one ", "", " ", "two"] },
+ { cls: "", lines: ["dropped"] },
+ { cls: "kitty", lines: ["", " "] },
+ { cls: "firefox", lines: ["last"] },
+]), { firefox: ["last"] });
+
+// 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);
+console.log("ok");
diff --git a/desktop/companion/test_mood.js b/desktop/companion/test_mood.js
index 37af2ea..3dd3dba 100644
--- a/desktop/companion/test_mood.js
+++ b/desktop/companion/test_mood.js
@@ -19,7 +19,7 @@ const assert = require("assert");
const src = fs.readFileSync(__dirname + "/Mood.js", "utf8")
.replace(/^\.pragma library$/m, "");
const M = new Function(src +
- "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue, admireAllowed };")();
+ "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue, admireAllowed, seatFrame };")();
const lsrc = fs.readFileSync(__dirname + "/ListFile.js", "utf8")
.replace(/^\.pragma library$/m, "");
const L = new Function(lsrc + "\nreturn { parse, toggle, outputs };")();
@@ -27,8 +27,9 @@ const L = new Function(lsrc + "\nreturn { parse, toggle, outputs };")();
const NOW = 10000000;
// A snapshot with nothing going on, overridden per case.
const calm = over => Object.assign({
- now: NOW, destructAt: 0, dodging: false, shoved: false, shovedAt: 0, recording: false, busy: false,
- repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0,
+ leaving: false, now: NOW, destructAt: 0, dodging: false, shoved: false, shovedAt: 0, recording: false, busy: false,
+ repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0, singAt: 0,
+ djAt: 0, music: false, groove: "",
monologue: false, idle: 0,
}, over);
const state = over => M.pick(calm(over)).state;
@@ -40,6 +41,7 @@ assert.deepStrictEqual(M.pick(calm({})), { state: "base", frame: "base" });
// The priority table, top to bottom: with every trigger from a row down
// active at once, that row wins. Merged so a higher row's values win.
const ladder = [
+ ["bye", { leaving: true }],
["destruct", { destructAt: NOW - 10 }],
["scorched", { destructAt: NOW - M.T.destruct - 10 }],
["dodge", { dodging: true }],
@@ -47,6 +49,8 @@ const ladder = [
["listen", { recording: true }],
["think", { busy: true }],
["wave", { repliedAt: NOW - 10 }],
+ ["sing", { singAt: NOW - 10 }],
+ ["dj", { djAt: NOW - 10 }],
["clicks", { clicks: { count: 2, at: NOW - 10 } }],
["admire", { admireAt: NOW - 10 }],
["mock", { mockAt: NOW - 10 }],
@@ -60,6 +64,11 @@ for (let i = 0; i < ladder.length; i++) {
assert.strictEqual(state(all), ladder[i][0], "everything from " + ladder[i][0] + " down");
}
+// A hide: she waves goodbye, above everything else, whatever she was doing.
+assert.deepStrictEqual(
+ M.pick(calm({ leaving: true, idle: 3, clicks: { count: 3, at: NOW - 10 } })),
+ { state: "bye", frame: "wave" });
+
// Shoved: knocked off balance for a moment, then mad beside the balloon
// for as long as it stays.
assert.deepStrictEqual(M.pick(calm({ shoved: true, shovedAt: NOW - 10 })), { state: "shoved", frame: "shoved" });
@@ -71,8 +80,21 @@ assert.deepStrictEqual(M.pick(calm({ idle: 1 })), { state: "bored", frame: "slee
assert.deepStrictEqual(M.pick(calm({ idle: 2 })), { state: "yawn", frame: "yawn" });
assert.deepStrictEqual(M.pick(calm({ idle: 3 })), { state: "lying", frame: "lying" });
+// 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" });
+
// Holds end on time.
assert.strictEqual(state({ repliedAt: NOW - M.T.waveHold }), "base");
+assert.strictEqual(state({ singAt: NOW - M.T.singHold }), "base");
+assert.strictEqual(state({ djAt: NOW - M.T.djHold }), "base");
assert.strictEqual(state({ typingAt: NOW - M.T.typingHold }), "base");
assert.strictEqual(state({ wakeAt: NOW - M.T.wakeHold }), "base");
assert.strictEqual(state({ clicks: { count: 3, at: NOW - M.T.clickHold } }), "base");
@@ -153,4 +175,17 @@ assert.deepStrictEqual(L.outputs("# x\nOUTPUT_H=DP-1\nOUTPUT_V=DP-3\nDEFAULT_H=~
{ "DP-1": "H", "DP-3": "V" });
assert.deepStrictEqual(L.outputs(""), {});
+// Seated on the assistant's card: each standing frame to its sitting one,
+// the goodbye's sway to the one wave, and anything else to the plain sit.
+assert.strictEqual(M.seatFrame("watch"), "sit-read");
+assert.strictEqual(M.seatFrame("point"), "sit-point");
+assert.strictEqual(M.seatFrame("smirk"), "sit-smirk");
+assert.strictEqual(M.seatFrame("think"), "sit-think");
+assert.strictEqual(M.seatFrame("listen"), "sit-listen");
+assert.strictEqual(M.seatFrame("wave"), "sit-wave");
+assert.strictEqual(M.seatFrame("wave-l"), "sit-wave");
+assert.strictEqual(M.seatFrame("wave-r"), "sit-wave");
+assert.strictEqual(M.seatFrame("dj-scratch"), "sit");
+assert.strictEqual(M.seatFrame("base"), "sit");
+
console.log("ok");
diff --git a/desktop/island/Island.qml b/desktop/island/Island.qml
index 12f49ea..ee03ca1 100644
--- a/desktop/island/Island.qml
+++ b/desktop/island/Island.qml
@@ -52,10 +52,9 @@ PanelWindow {
// A tray menu is a separate popup: moving onto it un-hovers the pills,
// and the hide timer would then hide the island under the open menu. The
- // menu cannot report its own close through display(), so the hold ends
- // the next time the pointer comes back to the island.
- // ponytail: if the menu is dismissed elsewhere, the island may stay shown
- // until the next hover.
+ // hold ends when the menu closes, or the next time the pointer comes back.
+ // It used to wait for the pointer alone, which left the island stuck open
+ // after a menu's Quit, since the pointer was on the menu, not the pills.
property bool menuHeld: false
onHoveredChanged: {
@@ -319,6 +318,10 @@ PanelWindow {
window: win
panel: win.panel
onMenuShown: win.menuHeld = true
+ onMenuClosed: {
+ win.menuHeld = false;
+ if (!win.hovered && win.panel === "") hideTimer.restart();
+ }
onOpenSound: win.panel = win.panel === "sound" ? "" : "sound"
onOpenHeadphones: win.panel = win.panel === "headphones" ? "" : "headphones"
onClosePanel: win.panel = ""
diff --git a/desktop/island/README.md b/desktop/island/README.md
index 9353f7e..01751c1 100644
--- a/desktop/island/README.md
+++ b/desktop/island/README.md
@@ -67,8 +67,8 @@ page now (`desktop/modules/perf/`).
opens the sound panel.
- **Tray** shows `SystemTray` items. Left click activates, right click opens
the item's menu. The island is the session's StatusNotifier host. A tray
- menu is a separate popup, so the island holds itself shown after `display()`
- until the pointer returns.
+ menu is a separate popup, opened through a `QsMenuAnchor`, so the island
+ holds itself shown until the anchor reports the menu closed.
- **Layout flag** shows the current keyboard layout, driven by Hyprland's
`activelayout` event. Scroll switches layout with
`hyprctl switchxkblayout current next|prev`.
diff --git a/desktop/island/StatusPill.qml b/desktop/island/StatusPill.qml
index 915807b..99f1512 100644
--- a/desktop/island/StatusPill.qml
+++ b/desktop/island/StatusPill.qml
@@ -38,8 +38,17 @@ Capsule {
signal openHeadphones()
signal closePanel()
signal openDrawer()
- // Emitted just before a tray menu opens; the menu cannot report its close.
+ // A tray menu opening and closing. display() reports no close, so menus
+ // open through a QsMenuAnchor, whose closed fires on any dismissal: an
+ // entry picked (Quit included), Escape or a click elsewhere.
signal menuShown()
+ signal menuClosed()
+
+ QsMenuAnchor {
+ id: menuAnchor
+ anchor.window: pill.window
+ onClosed: pill.menuClosed()
+ }
expanded: panel === "sound" || panel === "headphones"
restWidth: restRow.implicitWidth + 24
@@ -100,8 +109,12 @@ Capsule {
if (!wantsMenu) { item.activate(); return; }
if (!item.hasMenu) return;
const p = trayIcon.mapToItem(null, 0, trayIcon.height + 6);
+ if (menuAnchor.visible) menuAnchor.close();
+ menuAnchor.menu = item.menu;
+ menuAnchor.anchor.rect.x = p.x;
+ menuAnchor.anchor.rect.y = p.y;
pill.menuShown();
- item.display(pill.window, p.x, p.y);
+ menuAnchor.open();
}
}
}
diff --git a/desktop/modules/companion/ArtistsModule.qml b/desktop/modules/companion/ArtistsModule.qml
new file mode 100644
index 0000000..a0d23f4
--- /dev/null
+++ b/desktop/modules/companion/ArtistsModule.qml
@@ -0,0 +1,67 @@
+// 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.
+
+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: "\uf001"
+ 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
+ }
+ }
+ }
+}
diff --git a/desktop/modules/companion/CompanionModule.qml b/desktop/modules/companion/CompanionModule.qml
index 6ed36ec..8d6a68d 100644
--- a/desktop/modules/companion/CompanionModule.qml
+++ b/desktop/modules/companion/CompanionModule.qml
@@ -23,7 +23,7 @@ Module {
// nf-md-account_heart, present in Inconsolata Nerd Font.
icon: "\u{F0899}"
settings: true
- group: "Desktop"
+ group: "Companion"
keywords: ["companion", "2b", "mascot", "character", "show", "hide", "wallpaper", "blush", "leotard", "timeout"]
page: Component {
diff --git a/desktop/modules/companion/PhrasesModule.qml b/desktop/modules/companion/PhrasesModule.qml
new file mode 100644
index 0000000..7e4895a
--- /dev/null
+++ b/desktop/modules/companion/PhrasesModule.qml
@@ -0,0 +1,64 @@
+// 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.
+
+import QtQuick
+import "../.."
+import "../../companion/Lines.js" as Lines
+
+// The lines the companion says when focus moves to a program she
+// recognizes, by its lowercased window class. They live in Prefs and she
+// reads them live, so a save needs no restart. The page edits a draft here;
+// Save hands it to Prefs, Reset drops the saved set so Lines.js' defaults
+// show again.
+Module {
+ id: pm
+ name: "phrases"
+ label: "Companion phrases"
+ // nf-fa-comment, present in Inconsolata Nerd Font.
+ icon: "\uf075"
+ settings: true
+ group: "Companion"
+ keywords: ["companion", "phrase", "line", "say", "speak", "app", "program",
+ "window", "class", "firefox", "2b"]
+ searchItems: pm.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i }))
+
+ // null while there are no edits: the page then shows the saved set, or
+ // Lines.js' defaults when nothing is saved.
+ property var draft: null
+ // Reset was pressed: Save drops the saved set rather than saving a copy
+ // of the defaults, so later default changes still arrive.
+ property bool resetting: false
+ readonly property var shown: pm.draft ?? Lines.appList(Prefs.companionApps)
+ // Every app needs a class and at least one non-empty phrase; Save waits.
+ readonly property bool valid: pm.shown.every(a => String(a.cls).trim() !== ""
+ && (a.lines || []).some(l => String(l).trim() !== ""))
+ dirty: pm.draft !== null
+
+ function edit(list) { pm.draft = list; pm.resetting = false; }
+ function reset() { pm.draft = Lines.appList(null); pm.resetting = true; }
+ function save() {
+ if (pm.resetting) Prefs.resetCompanionApps();
+ else Prefs.setCompanionApps(Lines.toMap(pm.draft));
+ pm.discard();
+ }
+ function discard() { pm.draft = null; pm.resetting = false; }
+
+ page: Component {
+ Page {
+ title: "Companion phrases"
+ actions: Component {
+ SaveBar { dirty: pm.dirty; valid: pm.valid; note: "every app needs a class and a phrase"
+ onSave: pm.save(); onDiscard: pm.discard() }
+ }
+ PhrasesPage { width: parent.width; owner: pm }
+ }
+ }
+}
diff --git a/desktop/modules/companion/PhrasesPage.qml b/desktop/modules/companion/PhrasesPage.qml
new file mode 100644
index 0000000..1971171
--- /dev/null
+++ b/desktop/modules/companion/PhrasesPage.qml
@@ -0,0 +1,195 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// One card per recognized program: its window class and the lines she says
+// when focus moves to it. Every change edits the module's draft; the
+// header's Save hands the set to Prefs. Reset fills the draft with the
+// built-in set from Lines.js.
+Column {
+ id: page
+ spacing: 10
+
+ // The module, holding the draft. Not named pm, its id (it would bind to
+ // itself, AGENTS.md).
+ required property QtObject owner
+ // 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
+ readonly property var list: owner.shown
+ // The app card awaiting a second click on Remove.
+ property int confirming: -1
+
+ // Saves a copy of the list with app i replaced by fn(app), or removed
+ // when fn returns null.
+ function edit(i, fn) {
+ const out = [];
+ for (let j = 0; j < page.list.length; j++) {
+ const a = page.list[j];
+ const c = { cls: a.cls, lines: a.lines.slice() };
+ if (j !== i) { out.push(c); continue; }
+ const r = fn(c);
+ if (r) out.push(r);
+ }
+ page.confirming = -1;
+ page.owner.edit(out);
+ }
+
+ function addApp() { page.owner.edit(page.list.concat([{ cls: "", lines: [""] }])); }
+ function removeApp(i) { page.edit(i, () => null); }
+ function setCls(i, t) { page.edit(i, a => Object.assign(a, { cls: t })); }
+ function addLine(i) { page.edit(i, a => Object.assign(a, { lines: a.lines.concat([""]) })); }
+ function setLine(i, j, t) {
+ page.edit(i, a => {
+ const ls = a.lines.slice();
+ ls[j] = t;
+ return Object.assign(a, { lines: ls });
+ });
+ }
+ function removeLine(i, j) { page.edit(i, a => Object.assign(a, { lines: a.lines.filter((_, k) => k !== j) })); }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ // Reports every keystroke into the draft. Not bound to value, which
+ // would reset the cursor; it follows the value only when the draft moves
+ // under it (Discard, Reset). Empty is outlined, since Save refuses it.
+ component Field: Rectangle {
+ id: field
+ property string value: ""
+ property string placeholder: ""
+ signal edited(string text)
+ height: 32
+ radius: 8
+ color: Qt.alpha(Theme.base, 0.6)
+ border.width: ti.text.trim() === "" ? 1 : 0
+ border.color: Theme.red
+ onValueChanged: if (ti.text !== field.value) ti.text = field.value
+ TextInput {
+ activeFocusOnTab: true
+ id: ti
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ Component.onCompleted: text = field.value
+ onTextEdited: field.edited(text)
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: field.placeholder
+ color: Theme.overlay
+ font: parent.font
+ }
+ }
+ }
+
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: page.intro
+ }
+
+ Repeater {
+ // A count, not the list: a new list on every keystroke would rebuild
+ // every card and drop the focus of the field being typed in.
+ model: page.list.length
+
+ delegate: Rectangle {
+ id: card
+ required property int index
+ readonly property var modelData: page.list[index] ?? { cls: "", lines: [] }
+ objectName: "app:" + index
+ width: page.width
+ height: body.implicitHeight + 20
+ radius: 10
+ color: Qt.alpha(Theme.surface, 0.45)
+
+ Column {
+ id: body
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 }
+ spacing: 8
+
+ Item {
+ width: parent.width
+ height: 32
+ Field {
+ anchors { left: parent.left; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ value: card.modelData.cls
+ placeholder: page.keyHint
+ onEdited: t => page.setCls(card.index, t)
+ }
+ Button {
+ id: del
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ danger: true
+ text: page.confirming === card.index ? "Confirm" : "Remove"
+ onClicked: {
+ if (page.confirming === card.index) page.removeApp(card.index);
+ else page.confirming = card.index;
+ }
+ }
+ }
+
+ Repeater {
+ model: card.modelData.lines.length
+ delegate: Row {
+ id: phraseRow
+ required property int index
+ width: body.width
+ spacing: 6
+ Field {
+ width: phraseRow.width - removeBtn.width - phraseRow.spacing
+ value: card.modelData.lines[phraseRow.index] ?? ""
+ placeholder: "a line she says"
+ onEdited: t => page.setLine(card.index, phraseRow.index, t)
+ }
+ Button {
+ id: removeBtn
+ // nf-fa-xmark, present in Inconsolata Nerd Font.
+ icon: "\uf00d"
+ danger: true
+ onClicked: page.removeLine(card.index, phraseRow.index)
+ }
+ }
+ }
+
+ Button {
+ text: "Add phrase"
+ onClicked: page.addLine(card.index)
+ }
+ }
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button { text: page.addText; onClicked: page.addApp() }
+ Button {
+ text: "Reset to defaults"
+ danger: true
+ enabled: !page.owner.resetting && (page.saved !== null || page.owner.dirty)
+ onClicked: page.owner.reset()
+ }
+ }
+}
diff --git a/desktop/modules/perf/Perf.js b/desktop/modules/perf/Perf.js
index d84c694..8294e94 100644
--- a/desktop/modules/perf/Perf.js
+++ b/desktop/modules/perf/Perf.js
@@ -188,6 +188,25 @@ function push(list, v, n) {
return out.length > n ? out.slice(out.length - n) : out;
}
+// The process rows of one `top -b` frame, run under LC_ALL=C: [{pid, name,
+// cpu, mem}], cpu in percent of one core as top shows it, mem the resident
+// size in bytes. RES is KiB unless top scaled it to a suffixed unit (1.0g),
+// and COMMAND is the last column and may hold spaces ("Isolated Web Co").
+function procs(text) {
+ const out = [];
+ let rows = false;
+ for (const line of text.split("\n")) {
+ if (/^\s*PID\s+USER\b/.test(line)) { rows = true; continue; }
+ if (!rows) continue;
+ const f = line.trim().split(/\s+/);
+ const res = /^([\d.]+)([kmgtp]?)$/.exec(f[5] || "");
+ if (f.length < 12 || !res) continue;
+ const scale = 1024 ** (1 + Math.max(0, "kmgtp".indexOf(res[2] || "k")));
+ out.push({ pid: Number(f[0]), name: f.slice(11).join(" "), cpu: Number(f[8]), mem: Number(res[1]) * scale });
+ }
+ return out;
+}
+
function bytes(n) {
if (n === null || n === undefined) return "--";
const u = ["B", "K", "M", "G", "T"];
diff --git a/desktop/modules/perf/PerfPanel.qml b/desktop/modules/perf/PerfPanel.qml
index aa2e49f..ff9f74b 100644
--- a/desktop/modules/perf/PerfPanel.qml
+++ b/desktop/modules/perf/PerfPanel.qml
@@ -131,6 +131,39 @@ Column {
// --- CPU ----------------------------------------------------------------
+ // The five heaviest processes, from one `top -b` streaming a frame every
+ // 2s for as long as the page is open; it dies with the panel. top marks
+ // no frame's end but writes each in one burst, so a frame is published
+ // once its output goes quiet. The first is skipped, since top computes
+ // it over each process's whole life rather than the last interval, so
+ // rows load about 2s after the page opens.
+ property var procs: []
+ property string procBy: "cpu"
+ property string frame: ""
+ property int frames: 0
+
+ Process {
+ running: true
+ command: ["env", "LC_ALL=C", "top", "-b", "-d2", "-w512"]
+ stdout: SplitParser {
+ onRead: line => {
+ // Every header after the first reaches here with the blank
+ // line before it still attached, as "\ntop - ...".
+ if (/^\s*top - /.test(line)) {
+ panel.frames++;
+ panel.frame = "";
+ }
+ panel.frame += line + "\n";
+ frameEnd.restart();
+ }
+ }
+ }
+ Timer {
+ id: frameEnd
+ interval: 150
+ onTriggered: if (panel.frames >= 2) panel.procs = P.procs(panel.frame)
+ }
+
Card {
title: "CPU"
headline: Perf.load === null ? "--" : Math.round(Perf.load) + "%"
@@ -174,6 +207,66 @@ Column {
frac: Perf.mem ? Perf.mem.used / Perf.mem.total : 0
fill: panel.tone(panel.pct(Perf.mem ? Perf.mem.used : null, Perf.mem ? Perf.mem.total : 0), 25, 75)
}
+
+ // Sorted by CPU or RAM, the active one lit; switching re-sorts the
+ // frame already held rather than waiting for the next.
+ Item {
+ width: parent.width
+ height: byRow.implicitHeight
+ Label { text: "Processes"; color: Theme.subtext }
+ Row {
+ id: byRow
+ anchors.right: parent.right
+ spacing: 10
+ Repeater {
+ model: [{ key: "cpu", text: "CPU" }, { key: "mem", text: "RAM" }]
+ delegate: Label {
+ required property var modelData
+ text: modelData.text
+ font.bold: panel.procBy === modelData.key
+ color: panel.procBy === modelData.key ? Theme.text : Theme.overlay
+ MouseArea {
+ anchors.fill: parent
+ anchors.margins: -4
+ cursorShape: Qt.PointingHandCursor
+ onClicked: panel.procBy = modelData.key
+ }
+ }
+ }
+ }
+ }
+ Repeater {
+ model: panel.procs.slice().sort((a, b) => b[panel.procBy] - a[panel.procBy]).slice(0, 5)
+ // Two right-aligned columns of fixed width, so the numbers line
+ // up down the table whatever their length.
+ delegate: Item {
+ required property var modelData
+ width: parent.width
+ implicitHeight: nameText.implicitHeight
+ Label {
+ id: nameText
+ anchors { left: parent.left; right: cpuText.left; rightMargin: 8 }
+ text: modelData.name
+ elide: Text.ElideRight
+ }
+ Label {
+ id: cpuText
+ anchors.right: memText.left
+ width: panel.fs * 4
+ horizontalAlignment: Text.AlignRight
+ text: modelData.cpu.toFixed(1) + "%"
+ font.features: { "tnum": 1 }
+ }
+ Label {
+ id: memText
+ anchors.right: parent.right
+ width: panel.fs * 4.5
+ horizontalAlignment: Text.AlignRight
+ text: P.bytes(modelData.mem)
+ font.features: { "tnum": 1 }
+ }
+ }
+ }
}
// --- temperatures -------------------------------------------------------
diff --git a/desktop/modules/perf/README.md b/desktop/modules/perf/README.md
index 236f5b4..8282a50 100644
--- a/desktop/modules/perf/README.md
+++ b/desktop/modules/perf/README.md
@@ -31,6 +31,13 @@ VRAM (a scan of every fdinfo, ~14ms every 2s) and the fan, so the page opens
on the last minute of network history instead of an empty graph. The mount list, `br0`, the GPU name and the 12 GiB VRAM
size are constants in `Perf.qml`.
+The CPU card ends with the five heaviest processes, by CPU or by RAM (a
+toggle in the card, not saved). They come from one `top -b -d2` that runs only
+while the page is open. A frame is published once top's output goes quiet,
+and the first is skipped because top computes it over each process's whole
+life, so rows appear about 2s after opening. CPU is per core, as top shows it, so a
+busy process can exceed 100%.
+
The cache card runs `du -B1 -d1` once each time the page opens, 28ms over
2 GiB, instead of reading conky's 15-minute `cachesize.txt`. The largest
child is yellow, red when it holds over half the total, as conky had it.
diff --git a/desktop/modules/perf/test_perf.js b/desktop/modules/perf/test_perf.js
index ac5859d..f2caa94 100644
--- a/desktop/modules/perf/test_perf.js
+++ b/desktop/modules/perf/test_perf.js
@@ -20,7 +20,7 @@ const src = fs.readFileSync(__dirname + "/Perf.js", "utf8")
.replace(/^\.pragma library$/m, "");
const P = new Function(src + "\nreturn { cpuTimes, load, meminfo, df, vram, rate, " +
"hwmonMap, lanAddr, cpuModel, du, band, worst, isIPv4, parsePubip, formatPubip, " +
- "ipCheck, due, push, bytes, speed };")();
+ "ipCheck, due, push, bytes, speed, procs };")();
// cpuTimes: aggregate first, then cores; idle is idle + iowait.
const stat = [
@@ -156,4 +156,19 @@ assert.strictEqual(cache.total, 20);
assert.deepStrictEqual(cache.items, [{ name: "a b", bytes: 9 }, { name: "b", bytes: 5 }]);
assert.strictEqual(P.du(""), null);
+// procs: rows after the header only, RES suffixes scaled, spaced names kept.
+const frame = [
+ "top - 09:50:01 up 34 min, 1 user, load average: 1.02, 0.80, 0.60",
+ "Tasks: 412 total, 1 running",
+ "",
+ " PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND",
+ " 8733 you 20 0 3824328 1.0g 140312 S 26.9 3.2 2:23.27 Isolated Web Co",
+ " 3772 you 20 0 1311072 191708 126420 S 5.0 0.6 2:07.86 Hyprland",
+].join("\n");
+assert.deepStrictEqual(P.procs(frame), [
+ { pid: 8733, name: "Isolated Web Co", cpu: 26.9, mem: 1024 ** 3 },
+ { pid: 3772, name: "Hyprland", cpu: 5, mem: 191708 * 1024 },
+]);
+assert.deepStrictEqual(P.procs(""), []);
+
console.log("test_perf: ok");
diff --git a/desktop/shell.qml b/desktop/shell.qml
index 8aaead3..35dbe3f 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -88,6 +88,8 @@ ShellRoot {
SunsetModule {},
IdleModule {},
CompanionModule {},
+ PhrasesModule {},
+ ArtistsModule {},
NotificationsModule {},
AutostartModule {},
ObsScenesModule {},
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/plans/2026-10-24-companion-seat.md b/docs/superpowers/plans/2026-10-24-companion-seat.md
new file mode 100644
index 0000000..e9c902a
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-24-companion-seat.md
@@ -0,0 +1,383 @@
+# Companion seat 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:** While the assistant's tall card is up, the companion sits on its top edge and plays sitting versions of her reactions.
+
+**Architecture:** `Assistant.qml` exposes `tall` and `cardTop`. `Companion.qml` derives `seated`, hops through the existing monitor-hop path to a margin-computed seat, and maps frames through a pure `Mood.seatFrame`. The art is seven new `frames.txt` stanzas per outfit, generated by the user.
+
+**Tech Stack:** Quickshell 0.3.1 QML, node for `test_mood.js`, `gen-frames`/`prep-frames` for art.
+
+Spec: `docs/superpowers/specs/2026-10-24-companion-seat-design.md`.
+
+Rules from AGENTS.md that apply: never start `qs` from a tool call and trust a later `pgrep`; `pkill -x qs` only, never `-f`; check `qs log -p desktop --log-times` against file mtime after each edit, since hot reload sometimes does not fire; visual checks are the user's.
+
+---
+
+### Task 1: Do the typing binds reach Hyprland while the canvas holds the keyboard?
+
+**Files:** none changed.
+
+- [ ] **Step 1: Listen on socket2 and type into the canvas**
+
+Run (agent, foreground, 30 s cap):
+
+```bash
+timeout 30 socat -u UNIX-CONNECT:"$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" - | grep --line-buffered keystone-
+```
+
+Ask the user to open the canvas (SUPER+Home), type a few words into it and press BackSpace twice within those 30 s.
+
+Expected: `custom>>keystone-typing` and `custom>>keystone-delete` lines. If they appear, Task 5 is skipped. If none appear, Task 5 is required.
+
+### Task 2: `Mood.seatFrame`
+
+**Files:**
+- Modify: `desktop/companion/Mood.js` (append after `canSpeak`)
+- Test: `desktop/companion/test_mood.js`
+
+- [ ] **Step 1: Write the failing test**
+
+In `test_mood.js`, add `seatFrame` to the returned names:
+
+```js
+ "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue, admireAllowed, seatFrame };")();
+```
+
+and before `console.log("ok");`:
+
+```js
+// Seated on the assistant's card: each standing frame to its sitting one,
+// the goodbye's sway to the one wave, and anything else to the plain sit.
+assert.strictEqual(M.seatFrame("watch"), "sit-read");
+assert.strictEqual(M.seatFrame("point"), "sit-point");
+assert.strictEqual(M.seatFrame("smirk"), "sit-smirk");
+assert.strictEqual(M.seatFrame("think"), "sit-think");
+assert.strictEqual(M.seatFrame("listen"), "sit-listen");
+assert.strictEqual(M.seatFrame("wave"), "sit-wave");
+assert.strictEqual(M.seatFrame("wave-l"), "sit-wave");
+assert.strictEqual(M.seatFrame("wave-r"), "sit-wave");
+assert.strictEqual(M.seatFrame("dj-scratch"), "sit");
+assert.strictEqual(M.seatFrame("base"), "sit");
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: `ReferenceError: seatFrame is not defined`.
+
+- [ ] **Step 3: Implement**
+
+Append to `Mood.js`:
+
+```js
+// Seated on the assistant's tall card (Companion.qml, sitting): the frames
+// with a sitting version show it, everything else the plain sit. Typing's
+// watch becomes the lean over the card, reading.
+var SEAT = {
+ watch: "sit-read", point: "sit-point", smirk: "sit-smirk", think: "sit-think",
+ listen: "sit-listen", wave: "sit-wave", "wave-l": "sit-wave", "wave-r": "sit-wave",
+};
+function seatFrame(frame) { return SEAT[frame] || "sit"; }
+```
+
+- [ ] **Step 4: Run, expect pass**
+
+Run: `node desktop/companion/test_mood.js && node desktop/companion/test_lines.js`
+Expected: `ok` twice.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/Mood.js desktop/companion/test_mood.js
+git commit -m "companion: seatFrame, the sitting version of a frame
+
+Pure, so the seat's frame choice is tested with the rest of Mood rather
+than by eye. Frames without a sitting version fall to the plain sit: a
+standing frame on the card's edge would float above it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+### Task 3: The canvas says when it is tall and where the card's top is
+
+**Files:**
+- Modify: `desktop/assistant/Assistant.qml` (near `screenObj`, line ~112, and the `Card {}` at ~552)
+- Modify: `desktop/assistant/Card.qml:31-32`
+
+- [ ] **Step 1: Add the properties to `Assistant.qml`**, after `screenObj`:
+
+```qml
+ // The tall card (a chat, or a panel) is fullHeight and centred, so its
+ // top only moves on entering or leaving tall. The companion sits there.
+ readonly property bool tall: root.mode === "chat" || root.historyOpen || root.pickerOpen || root.modelsOpen
+ readonly property int fullHeight: Math.round(root.screenObj.height * 0.7)
+ readonly property int cardTop: Math.round((root.screenObj.height - root.fullHeight) / 2)
+```
+
+and in the `Card {}` replace `fullHeight: Math.round(parent.height * 0.7)` with:
+
+```qml
+ fullHeight: root.fullHeight
+```
+
+- [ ] **Step 2: `Card.qml` reads `tall` from the canvas**, replacing line 32:
+
+```qml
+ readonly property bool tall: card.canvas.tall
+```
+
+(`panel` on line 31 stays; other code in the card uses it.)
+
+- [ ] **Step 3: Check the reload**
+
+Run: `qs log -p desktop --log-times | tail -5` and `stat -c %y desktop/assistant/Assistant.qml`
+Expected: a `Reloading configuration` line after the mtime, no `TypeError` or `ReferenceError`. Ask the user to open a chat and confirm the card is the same size as before.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/assistant/Assistant.qml desktop/assistant/Card.qml
+git commit -m "assistant: the canvas owns tall and the card's top
+
+The companion needs both to sit on the chat card, and should not reach
+into the card for them. Card.tall now reads the canvas's, so there is one
+definition of when the card is tall.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+### Task 4: She sits
+
+**Files:**
+- Modify: `desktop/companion/Companion.qml` (lines 66-83 the monitor block and margins; 295-300 `shoved`; 455-456 `frame`; 466-472 `settled`)
+
+- [ ] **Step 1: Replace the monitor block (lines 66-79)** with:
+
+```qml
+ // Where she goes: the focused monitor's corner, or seated on the
+ // assistant's tall card. here and sitting are where the window is now
+ // and trail the goal: a change plays her exit first, and only then
+ // moves the window (settled, below).
+ readonly property string focused: Hyprland.focusedMonitor?.name ?? ""
+ readonly property bool seated: root.asst.open && root.asst.tall
+ readonly property string goal: root.seated ? root.asst.monitor : root.focused
+ property string here: ""
+ property bool sitting: false
+ property bool hopping: false
+ // No focused monitor (outputs gone over a suspend) is not a destination.
+ // Already out of view, there is no exit to wait for: settled would never
+ // fire, hopping would stick and hide her for good, so move at once.
+ function move() {
+ if (root.goal === "" || (root.goal === root.here && root.seated === root.sitting)) return;
+ if (root.here === "" || root.away) {
+ root.here = root.goal;
+ root.sitting = root.seated;
+ } else {
+ root.hopping = true;
+ }
+ }
+ onGoalChanged: root.move()
+ onSeatedChanged: root.move()
+ Component.onCompleted: root.move()
+```
+
+- [ ] **Step 2: Margins (line 83)** become:
+
+```qml
+ // Seated, her right edge is 90 px right of the screen's centre, which
+ // puts her in the gap in the card's header, and her feet 35 px below the
+ // card's top. The right edge rather than her centre, because frames
+ // differ in width and the window would shift with every frame.
+ margins {
+ right: root.sitting ? Math.round(root.asst.screenObj.width / 2 - 90) : 24
+ bottom: root.sitting ? root.asst.screenObj.height - root.asst.cardTop - 35 : 16
+ }
+```
+
+- [ ] **Step 3: No shove while seated.** First line of the `shoved` binding:
+
+```qml
+ if (root.sitting) return false;
+```
+
+- [ ] **Step 4: Sitting frames.** Replace the `frame` property:
+
+```qml
+ readonly property string standing: root.leaving ? root.waveCycle[root.waveIndex]
+ : root.cycle ? root.cycle.frames[root.cycleIndex % root.cycle.frames.length] : root.mood.frame
+ readonly property string frame: root.sitting ? Mood.seatFrame(root.standing) : root.standing
+```
+
+- [ ] **Step 5: `settled` applies the seat too:**
+
+```qml
+ // The exit has played: move, and the entrance plays. The goal may have
+ // changed during the exit; the latest one wins.
+ function settled(out) {
+ if (out && root.hopping) {
+ root.here = root.goal;
+ root.sitting = root.seated;
+ root.hopping = false;
+ }
+ }
+```
+
+- [ ] **Step 6: Check the reload** as in Task 3 Step 3 (file `desktop/companion/Companion.qml`).
+
+- [ ] **Step 7: Hand check, by the user.** No sit art exists yet, so she shows `base` standing on the ledge (the fallback). Ask the user:
+ 1. Open the canvas: she stays in the corner over the dim.
+ 2. Start a chat: she drops out and lands on the card's top edge, right of centre, feet a little below the edge.
+ 3. Close the canvas: she hops back to the corner of the focused monitor.
+ 4. With a chat open, focus another monitor: she stays on the card.
+ 5. With a chat open, a notification balloon: she does not move.
+
+Adjust the 90 and 35 to what the user reports, then commit.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add desktop/companion/Companion.qml
+git commit -m "companion: sit on the assistant's chat card
+
+While the canvas shows its tall card she hops onto its top edge, through
+the monitor hop: the exit plays, the window's margins move, the entrance
+plays. The tall card's top is fixed, so nothing follows a moving edge.
+Seated she ignores focus changes and balloons, and shows Mood.seatFrame
+of whatever she would show standing.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+### Task 5 (only if Task 1 saw no events): typing from the card's input
+
+**Files:**
+- Modify: `desktop/assistant/Assistant.qml` (signal next to `signal replied()`, line 91)
+- Modify: `desktop/assistant/Card.qml` (the `input` TextEdit, near line 501)
+- Modify: `desktop/companion/Companion.qml` (the `Connections { target: root.asst }` block)
+
+- [ ] **Step 1:** In `Assistant.qml` add `signal typed(bool deleted)` after `signal replied()`.
+
+- [ ] **Step 2:** On the card's `input`, add:
+
+```qml
+ property int lastLength: 0
+ onTextChanged: {
+ card.canvas.typed(text.length < lastLength);
+ lastLength = text.length;
+ }
+```
+
+- [ ] **Step 3:** In the companion's `Connections { target: root.asst }`, add:
+
+```qml
+ // Hyprland's binds do not fire while the canvas holds the keyboard,
+ // so the card's input reports typing itself.
+ function onTyped(deleted) {
+ const t = Date.now();
+ root.typingAt = t;
+ if (deleted && Mood.mockAllowed(root.mockAt, t)) {
+ root.mockAt = t;
+ root.say("mock");
+ }
+ }
+```
+
+- [ ] **Step 4:** Check the reload, ask the user to type and delete in a chat, then commit:
+
+```bash
+git add desktop/assistant/Assistant.qml desktop/assistant/Card.qml desktop/companion/Companion.qml
+git commit -m "companion: typing in the canvas comes from its input
+
+The canvas holds the keyboard exclusively and Hyprland's typing binds do
+not fire under it (checked on socket2), so the card's input reports
+typing and deletes itself.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+### Task 6: The sitting frames
+
+**Files:**
+- Modify: `desktop/companion/frames.txt` (a `SIT` macro after `SING`; a `# --- seated` group before the leotard set; leotard lines at the end)
+
+- [ ] **Step 1: Add the macro** after the `SING = ...` line:
+
+```
+# Seated on the assistant's card: the original sitting rest pose, legs
+# dangling over the edge, from base-sitting (the image dress/base came from).
+SIT = keep her sitting pose exactly: sitting on a ledge, front view, legs dangling down over its edge.
+```
+
+- [ ] **Step 2: The dress frames**, before `# --- the leotard set`:
+
+```
+# --- seated, on the assistant's chat card (Mood.seatFrame)
+dress/sit <- base-sitting : ${SIT} Hands resting on the ledge beside her, a calm closed mouth. ${EYES} ${KEEP}
+dress/sit-read <- dress/sit : She leans forward from the waist and tilts her head down, looking at something just below her feet, as if reading over the edge; hands still on the ledge. ${SIT} ${EYES} ${KEEP}
+dress/sit-point <- dress/sit : She points down past her knees with the hand on the left side of the image, a teasing smirk; her other hand stays on the ledge. ${SIT} ${KEEP}
+dress/sit-smirk <- dress/sit : Change only her expression to a teasing one-sided smirk. ${SIT} Both eyes fully visible and uncovered; the blindfold is nowhere on her head or face. ${BARE} ${KEEP_BARE}
+dress/sit-think <- dress/sit : One gloved hand at her chin, thinking, head tilted slightly; her other hand on the ledge. ${SIT} ${KEEP}
+dress/sit-listen <- dress/sit : She cups the hand on the left side of the image behind her ear, listening attentively; her other hand on the ledge. ${SIT} ${KEEP}
+dress/sit-wave <- dress/sit : Raise only the hand on the left side of the image beside her head, waving, with a small smile; her other hand stays on the ledge. ${SIT} ${KEEP}
+```
+
+- [ ] **Step 3: The leotard frames**, at the end of the leotard set:
+
+```
+leotard/sit <- dress/sit : ${LEOTARD}
+leotard/sit-read <- dress/sit-read : ${LEOTARD}
+leotard/sit-point <- dress/sit-point : ${LEOTARD}
+leotard/sit-smirk <- dress/sit-smirk : ${LEOTARD}
+leotard/sit-think <- dress/sit-think : ${LEOTARD}
+leotard/sit-listen <- dress/sit-listen : ${LEOTARD}
+leotard/sit-wave <- dress/sit-wave : ${LEOTARD}
+```
+
+- [ ] **Step 4: Dry run**
+
+Run: `desktop/companion/gen-frames --list`
+Expected: exactly the 14 new outputs listed, no existing frame.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/frames.txt
+git commit -m "companion: the seated frames' recipes
+
+Edited from base-sitting, the original sitting rest pose dress/base came
+from, so the legs already dangle and klein only changes the arms and
+expression. The leotard set follows its dress frames as before.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 6: Generation, by the user**: start sd-webui with FLUX.2 klein, run `desktop/companion/gen-frames`, and review the frames. Rerolls go by `--seed N dress/sit-x leotard/sit-x`, recorded in `frames.txt` comments as for headbang-down.
+
+### Task 7: Docs
+
+**Files:**
+- Modify: `desktop/companion/README.md` (a bullet in Inputs, after the openlayer one)
+- Modify: `docs/superpowers/specs/2026-10-24-companion-seat-design.md` (status line; the seat's x is the right edge, per Task 4)
+
+- [ ] **Step 1: README bullet:**
+
+```markdown
+- The assistant's tall card (a chat, or its history, picker or models
+ panel) seats her on its top edge, right of centre, through the monitor
+ hop. `Assistant.qml` gives `tall` and `cardTop`; seated she ignores
+ focus changes and balloons and shows `Mood.seatFrame` of her frame, the
+ `sit-*` set. The bar and the file card leave her in the corner:
+ standing frames face out, not down at the text.
+```
+
+- [ ] **Step 2: Spec:** status to `implemented`, and in Seat replace "with her centre 60 px right of the screen's centre ... `seatWidth / 2`" with "with her right edge 90 px right of the screen's centre (`margins.right = screenW / 2 - 90`), since frames differ in width", using the final numbers from Task 4 Step 7. Add Task 1's result to "Verified before designing".
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/companion/README.md docs/superpowers/specs/2026-10-24-companion-seat-design.md
+git commit -m "docs: the companion's seat on the assistant card
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
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.
diff --git a/docs/superpowers/specs/2026-10-24-companion-seat-design.md b/docs/superpowers/specs/2026-10-24-companion-seat-design.md
new file mode 100644
index 0000000..3e7fb0f
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-24-companion-seat-design.md
@@ -0,0 +1,103 @@
+# Companion seat: design
+
+Date: 2026-10-24. Status: implemented; the sitting art is generated by the user.
+
+## Goal
+
+While the assistant canvas shows its tall card (a chat, or the history,
+picker or models panel), the companion sits on the card's top edge, right
+of centre, legs dangling over the empty middle of the header. Typing makes
+her lean over and read; BackSpace makes her point and smirk from the ledge;
+thinking, listening and the reply wave happen there too. The bar and the
+file card do not seat her: she stays in her corner, since standing on a
+short card would need its own art to read anything below her feet.
+
+## Verified before designing
+
+- **Hyprland's typing binds fire under the canvas.** With the canvas open
+ and holding the keyboard `Exclusive`, socket2 carried
+ `custom>>keystone-typing` and `custom>>keystone-delete` for every burst
+ typed into it, so the companion needs no input of the canvas's own.
+- **The tall card has a fixed top.** `Card.tall` is `mode === "chat" ||
+ panel`, and while tall its height is `fullHeight`, set by the window to
+ 70% of the screen height. The card is centred, so its top is
+ `(screenH - fullHeight) / 2` and only changes on entering or leaving the
+ tall state. Nothing needs to follow a moving edge.
+- **The header's middle is empty.** The model name is on the left, the
+ context meter and buttons on the right, both in a 32 px row inside a
+ 14 px margin. Her legs fall in the gap between them.
+- **Sitting art exists.** `base-sitting.png` in the art directory is the
+ original rest pose: front view, sitting, legs dangling. `dress/base` was
+ made from it, so the new sitting frames edit it directly.
+- **Opening the canvas already remaps her.** `openlayer` for
+ `keystone-assistant` unmaps and maps her again above the dim, with a drop
+ out and a drop in. A seat change made while she is unmapped needs no
+ animation of its own.
+
+## Behaviour
+
+### Seat
+
+`Assistant.qml` gains `readonly property bool tall`, the same expression
+as `Card.tall`, and `readonly property real cardTop`, the card's top on
+its screen. The canvas owns both so the companion does not reach into the
+card.
+
+The companion's `seated` is `asst.open && asst.tall`. Seated, her window
+is on `asst.screenObj` and its margins put her seat's bottom 35 px below
+the card's top, with her right edge 90 px right of the screen's centre:
+the window stays anchored right and bottom, `margins.bottom = screenH -
+cardTop - 35`, `margins.right = screenW / 2 - 90`. Her right edge rather
+than her centre, because frames differ in width and the window would move
+with every frame. Both numbers were checked by eye. Not seated, the
+margins are today's 24 and 16.
+
+`seated` changing runs the monitor hop's path: she drops out, the window
+moves when the exit has settled, and the entrance plays. The first seat on
+opening a chat coincides with the remap and is hidden by it. While seated,
+a focused monitor change does not hop her; on unseating she goes to the
+focused monitor.
+
+Balloons do not shove her while seated: their stack is in a screen corner,
+not on the card.
+
+### Frames
+
+Seated, the frame Mood picks goes through `Mood.seatFrame(frame)`:
+
+| Standing | Sitting |
+| --- | --- |
+| `watch` | `sit-read`: leaning forward, looking down at the card |
+| `point` | `sit-point` |
+| `smirk` | `sit-smirk` |
+| `think` | `sit-think` |
+| `listen` | `sit-listen` |
+| `wave`, `wave-l`, `wave-r` | `sit-wave` |
+| anything else | `sit` |
+
+Cycles (the goodbye wave, dj, headbang) map frame by frame, so the
+goodbye shows `sit-wave` throughout and dj shows `sit`. `Mood.pick` is
+unchanged: typing still gives `watch`, delete still gives the mock.
+
+Seven new frames in each outfit, from `base-sitting` in `frames.txt`, with
+a sitting counterpart of `POSE` and the leotard stanzas like the others.
+`gen-frames` then `prep-frames`, by the user, as with every other frame.
+A missing frame falls back to `base`, standing, which is how an
+ungenerated set shows.
+
+### Inputs
+
+Typing and BackSpace keep arriving as `keystone-typing` and
+`keystone-delete` from Hyprland, which fire under the canvas (see above).
+
+## Check
+
+`test_mood.js` gains `seatFrame` cases: each row of the table, a cycle
+frame, and an unknown frame to `sit`. The seat position and the bind check
+are by hand.
+
+## Not doing
+
+- Standing on the bar or the file card: needs new art.
+- Following the card while it animates: the tall card does not move.
+- Sitting on ordinary windows.
diff --git a/keybinds/shell.qml b/keybinds/shell.qml
index 7c34bfd..3a24384 100644
--- a/keybinds/shell.qml
+++ b/keybinds/shell.qml
@@ -20,7 +20,7 @@ ShellRoot {
IpcHandler {
target: "keybinds"
function toggle() { keybinds.toggle(); }
- function show() { keybinds.open = true; }
+ function open() { keybinds.open = true; }
function close() { keybinds.close(); }
}
}
diff --git a/window-switcher/README.md b/window-switcher/README.md
index fb3dc8d..3a32bc5 100644
--- a/window-switcher/README.md
+++ b/window-switcher/README.md
@@ -46,7 +46,7 @@ running for the keybind to do anything:
qs -p ~/Programming/GIT/quickshell/window-switcher ipc call switcher toggle
Write that path out in full in the real config: Hyprland's Lua strings have no
-shell to expand `~`. The bind lives in `keybindings.lua` on ALT+TAB. `show` and
+shell to expand `~`. The bind lives in `keybindings.lua` on ALT+TAB. `open` and
`close` exist on the same IPC target for anything that wants one direction only.
## Interaction
@@ -57,6 +57,7 @@ shell to expand `~`. The bind lives in `keybindings.lua` on ALT+TAB. `show` and
| top-right corner of DP-1 | open, after the glow (see below) |
| click a preview | focus that window and dismiss |
| the `x` on a hovered preview | close that window, overlay stays open |
+| Del | close the selected window, overlay stays open |
| arrow keys | select a preview; the first press picks the top-left one |
| Enter | focus the selected window and dismiss |
| Escape, or a click on the backdrop | dismiss, focus unchanged |
diff --git a/window-switcher/Switcher.qml b/window-switcher/Switcher.qml
index 1eebfa8..3bc6a86 100644
--- a/window-switcher/Switcher.qml
+++ b/window-switcher/Switcher.qml
@@ -87,6 +87,8 @@ Scope {
Keys.onEscapePressed: root.close()
Keys.onReturnPressed: if (scatter.selected) root.focusAfterClose(scatter.selected)
Keys.onEnterPressed: if (scatter.selected) root.focusAfterClose(scatter.selected)
+ // Like the card's x: the overlay stays open for the next one.
+ Keys.onDeletePressed: if (scatter.selected) Windows.closeWindow(scatter.selected)
Keys.onLeftPressed: scatter.move(-1, 0)
Keys.onRightPressed: scatter.move(1, 0)
Keys.onUpPressed: scatter.move(0, -1)
diff --git a/window-switcher/shell.qml b/window-switcher/shell.qml
index 0ebe553..fbd7a79 100644
--- a/window-switcher/shell.qml
+++ b/window-switcher/shell.qml
@@ -25,7 +25,7 @@ ShellRoot {
IpcHandler {
target: "switcher"
function toggle() { switcher.toggle(); }
- function show() { switcher.open = true; }
+ function open() { switcher.open = true; }
function close() { switcher.close(); }
}
}