1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
|
# OBS pill enhancements 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:** Add desktop-audio mute and webcam corner placement to the OBS pill, replace the text scene list with a mapped icon grid, and add a settings page that edits the map in a watched `~/.config` file.
**Architecture:** `Obs.qml` grows the audio and canvas state plus two requests. A pure `SceneIcons.js` parses the map, `SceneIcons.qml` owns the watched file, `ObsPill.qml` renders the grid and the new buttons, and a new `modules/obs` settings page edits the same map through `SceneIcons`'s draft API.
**Tech Stack:** Quickshell 0.3.1 QML, obs-websocket v5, node (tests).
**Spec:** `docs/superpowers/specs/2026-10-17-obs-pill-enhancements-design.md`
## Global Constraints
- GPLv2 header on every new file (copy from `desktop/island/privacy.sh` / `Privacy.qml`).
- Never start `qs` detached or run `./reload.sh` from a tool call; the user runs the reload in their terminal (AGENTS.md).
- Process name is `qs`; never `pkill -f`.
- Glyphs render with `Theme.iconFamily`. The spec's table gives the JS/JSON escape for each; the `md-*` ones are surrogate pairs, never a five-digit `\u` escape.
- Config path is `$XDG_CONFIG_HOME/quickshell/obs-scene-icons.json`, defaulting to `~/.config/quickshell/obs-scene-icons.json`.
- A glyph field accepts exactly one code point; a supplementary-plane glyph is two UTF-16 units.
- No home paths or personal data in committed files; `~` in docs.
## Review Focus
1. A pasted supplementary-plane glyph (U+F02DC) is one code point but two UTF-16 units; it must validate, survive the JSON round-trip, and render.
2. A hand-edited config with a multi-character or non-string value must read as `generic`, never crash or render junk.
3. A missing or unparseable config must yield the starter defaults, not an empty map.
4. An OBS scene with no mapping must show the `generic` tile, never an empty tile.
5. The shared `Button.qml` gains an optional icon; every existing text-only button in the drawer and settings must look identical.
---
### Task 1: Scene icon map logic
**Files:**
- Create: `desktop/island/SceneIcons.js`
- Create: `desktop/island/test_scene_icons.js`
**Interfaces:**
- Produces: `SceneIcons.defaults` (`{ generic: string, scenes: { name: string } }`), `validGlyph(s) -> bool`, `validMap(map) -> bool`, `normalize(map) -> map`, `parse(text) -> map`, `serialize(map) -> string`, `iconFor(map, name) -> string`.
- [ ] **Step 1: Write the failing test**
Create `desktop/island/test_scene_icons.js`. Strip `.pragma library` the way `test_logic.js` does, then:
```js
const fs = require("fs");
const assert = require("assert");
const src = fs.readFileSync(__dirname + "/SceneIcons.js", "utf8")
.replace(/^\.pragma library$/m, "");
const I = new Function(src +
"\nreturn { defaults, validGlyph, validMap, normalize, parse, serialize, iconFor };")();
// One code point, even when it is a surrogate pair (U+F02DC, md-home).
assert.strictEqual(I.validGlyph("\u{f02dc}"), true);
assert.strictEqual(I.validGlyph("a"), true);
assert.strictEqual(I.validGlyph(""), false);
assert.strictEqual(I.validGlyph("ab"), false);
// A missing or unparseable file is the starter map, not an empty one.
assert.deepStrictEqual(I.parse(""), I.defaults);
assert.deepStrictEqual(I.parse("not json"), I.defaults);
// A valid file is used as-is: no merge with the defaults.
const one = I.parse('{"generic":"\\udb80\\uded7","scenes":{"Desktop":"\\udb80\\udf79"}}');
assert.deepStrictEqual(one, { generic: "\udb80\uded7", scenes: { Desktop: "\udb80\udf79" } });
assert.strictEqual(I.iconFor(one, "Desktop"), "\udb80\udf79");
assert.strictEqual(I.iconFor(one, "Webcam Only"), "\udb80\uded7");
// Invalid values are dropped; the generic falls back.
const bad = I.parse('{"generic":"","scenes":{"A":"ab","B":"\\udb80\\udf79"}}');
assert.deepStrictEqual(bad, { generic: I.defaults.generic, scenes: { B: "\udb80\udf79" } });
// A non-string value is dropped, never passed to charCodeAt.
assert.deepStrictEqual(I.parse('{"scenes":{"A":42}}'),
{ generic: I.defaults.generic, scenes: {} });
// normalize is what Save writes: empty generic -> default, invalid scene dropped.
assert.deepStrictEqual(
I.normalize({ generic: "", scenes: { A: I.defaults.scenes.Desktop, B: "ab" } }),
{ generic: I.defaults.generic, scenes: { A: I.defaults.scenes.Desktop } });
// Whole-draft validity: an empty generic is allowed (Save substitutes the default).
assert.strictEqual(I.validMap({ generic: "", scenes: { A: "\u{f02dc}" } }), true);
assert.strictEqual(I.validMap({ generic: I.defaults.generic, scenes: { A: "ab" } }), false);
// Round-trip.
assert.deepStrictEqual(I.parse(I.serialize(one)), one);
assert.ok(I.serialize(one).endsWith("\n"));
console.log("ok");
```
- [ ] **Step 2: Run it to verify it fails**
Run: `node desktop/island/test_scene_icons.js`
Expected: FAIL, `ReferenceError: validGlyph is not defined`.
- [ ] **Step 3: Implement `SceneIcons.js`**
`pragma library` after the header. Implementation:
- `defaults`: `generic` = `"\udb80\uded7"` (md-help_circle), `scenes` = Desktop `"\udb80\udf79"`, Webcam Only `"\udb81\udd67"`, Window Only `"\uf2d0"`, Intervallo `"\udb80\udd76"`.
- `validGlyph(s)`: count Unicode code points by walking `charCodeAt`, skipping a low surrogate that follows a high one; true when the count is 1. Empty or non-string is false.
- `normalize(map)`: `{ generic: validGlyph(map.generic) ? map.generic : defaults.generic, scenes: {} }`, keeping scene entries whose value passes `validGlyph`.
- `parse(text)`: `JSON.parse`, and if the result is not a plain object return a deep copy of `defaults`; otherwise return `normalize(v)`.
- `serialize(map)`: `JSON.stringify({ generic, scenes }, null, 2) + "\n"`.
- `iconFor(map, name)`: `map.scenes[name] || map.generic || defaults.generic`.
- `validMap(map)`: false unless every scene value passes `validGlyph`; the generic may be empty, else it must pass.
- [ ] **Step 4: Run it to verify it passes**
Run: `node desktop/island/test_scene_icons.js`
Expected: `ok`.
- [ ] **Step 5: Commit**
```bash
git add desktop/island/SceneIcons.js desktop/island/test_scene_icons.js
git commit -m "island: scene icon map logic and its tests"
```
### Task 2: `SceneIcons.qml` singleton
**Files:**
- Create: `desktop/island/SceneIcons.qml`
**Interfaces:**
- Consumes: `SceneIcons.js` (`parse`, `serialize`, `iconFor`, `defaults`).
- Produces: singleton `SceneIcons` with `readonly property string path`, `property var map` (default `Icons.defaults`), `iconFor(name) -> string`, `save(map)`.
- [ ] **Step 1: Write it**
`pragma Singleton`, then `import Quickshell`, `import Quickshell.Io`, `import QtQuick`, `import "SceneIcons.js" as Icons`. Contents:
- `path`: `(Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/quickshell/obs-scene-icons.json"`.
- `map`: `Icons.defaults` until the file loads.
- `iconFor(name)`: `Icons.iconFor(root.map, name)`.
- `save(map)`: `root.map = map; fv.setText(Icons.serialize(map));`.
- `FileView { id: fv; path: root.path; atomicWrites: true; watchChanges: true; onFileChanged: reload(); onLoaded: root.map = Icons.parse(text()); onLoadFailed: root.map = Icons.defaults }`.
- No draft/dirty state yet; Task 6 adds it.
QML has no unit-test rig here; the logic it calls is covered in Task 1 and the singleton is exercised by the live check.
- [ ] **Step 2: Check syntax**
Run: `qmllint desktop/island/SceneIcons.qml`
Expected: exit 0; only unresolved-import warnings (the Quickshell modules are not on qmllint's path) and no syntax errors.
- [ ] **Step 3: Commit**
```bash
git add desktop/island/SceneIcons.qml
git commit -m "island: watched scene icon map singleton"
```
### Task 3: Corner position helper
**Files:**
- Modify: `desktop/island/Logic.js`
- Modify: `desktop/island/test_logic.js`
**Interfaces:**
- Produces: `cornerPos(corner, W, H, w, h, m) -> { x: number, y: number }`.
- [ ] **Step 1: Write the failing test**
In `test_logic.js`, extend the return list to `{ volumeIcon, vmRole, flagFor, privacyRoles, parseOpenWindow, obsElapsed, cornerPos }` and add before `console.log("ok")`:
```js
// Corner positions: 1920x1080 canvas, 298x168 webcam, 16px margin.
for (const [c, x, y] of [["tl", 16, 16], ["tr", 1606, 16], ["bl", 16, 896], ["br", 1606, 896]])
assert.deepStrictEqual(L.cornerPos(c, 1920, 1080, 298, 168, 16), { x, y });
// An unknown corner reads as top-left, never undefined.
assert.deepStrictEqual(L.cornerPos("xx", 1920, 1080, 298, 168, 16), { x: 16, y: 16 });
```
- [ ] **Step 2: Run it to verify it fails**
Run: `node desktop/island/test_logic.js`
Expected: FAIL, `TypeError: L.cornerPos is not a function`.
- [ ] **Step 3: Implement `cornerPos` in `Logic.js`**
Top-left position for a `w x h` source on a `W x H` canvas with margin `m`: `x` is `m` unless the corner is `tr` or `br` (then `W - w - m`); `y` is `m` unless it is `bl` or `br` (then `H - h - m`). Anything unrecognised stays top-left.
- [ ] **Step 4: Run it to verify it passes**
Run: `node desktop/island/test_logic.js`
Expected: `ok`.
- [ ] **Step 5: Commit**
```bash
git add desktop/island/Logic.js desktop/island/test_logic.js
git commit -m "island: corner position helper for the OBS webcam"
```
### Task 4: `Obs.qml` audio and corner state
**Files:**
- Modify: `desktop/island/Obs.qml`
**Interfaces:**
- Consumes: `Logic.cornerPos`.
- Produces: `desktopAudio: string`, `desktopMuted: bool`, `baseWidth: real`, `baseHeight: real`, `camW: real`, `camH: real`, `toggleAudio()`, `moveCam(corner)` where corner is `"tl" | "tr" | "bl" | "br"`.
- [ ] **Step 1: Add the state and import**
`import "Logic.js" as Logic`; after `camOn` add the six properties initialised to `""` / `false` / `0`.
- [ ] **Step 2: Subscribe to Inputs and request the new state**
In `identify()`, `eventSubscriptions` becomes `4 | 8 | 64 | 128` (Scenes | Inputs | Outputs | SceneItems = **204**). On `Identified` (op 2), in addition to the existing two requests, send `GetSpecialInputs` and `GetVideoSettings`.
- [ ] **Step 3: Handle the responses**
In `response()`, before the existing cases:
- `GetSpecialInputs`: `desktopAudio = r.desktop1 || ""`; if non-empty, request `GetInputMute { inputName: desktopAudio }`.
- `GetVideoSettings`: `baseWidth = r.baseWidth; baseHeight = r.baseHeight`.
- `GetInputMute`: `desktopMuted = r.inputMuted`.
- `GetSceneItemList`: also set `camW` / `camH` from the webcam item's `sceneItemTransform.width` / `.height`, and `0` when no webcam item.
- [ ] **Step 4: Handle the mute event and add the actions**
Event: `InputMuteStateChanged` sets `desktopMuted = d.inputMuted` when `d.inputName === desktopAudio`.
`toggleAudio()`: if `desktopAudio`, `SetInputMute { inputName: desktopAudio, inputMuted: !desktopMuted }`.
`moveCam(corner)`: return unless `camId >= 0 && baseWidth > 0 && camW > 0 && camH > 0`; then `Logic.cornerPos(corner, baseWidth, baseHeight, camW, camH, 16)` and `SetSceneItemTransform { sceneName: scene, sceneItemId: camId, sceneItemTransform: { positionX, positionY } }`. Position only: a full echoed transform fails on `boundsWidth: 0`. Add a comment that the math assumes `alignment: 5` (top-left).
- [ ] **Step 5: Clear the new state on disconnect**
Extend `reset()` with `desktopAudio = ""; desktopMuted = false; baseWidth = 0; baseHeight = 0; camW = 0; camH = 0;`.
- [ ] **Step 6: Check syntax and commit**
Run: `qmllint desktop/island/Obs.qml` (exit 0, no syntax errors), then:
```bash
git add desktop/island/Obs.qml
git commit -m "island: OBS desktop audio and webcam corner control"
```
### Task 5: Icon buttons and the scene grid
**Files:**
- Modify: `desktop/Button.qml`
- Modify: `desktop/island/ObsPill.qml`
- Modify: `desktop/island/README.md`
**Interfaces:**
- Consumes: `Obs.desktopAudio`, `Obs.desktopMuted`, `Obs.camId`, `Obs.camOn`, `Obs.toggleAudio()`, `Obs.moveCam(corner)`, `SceneIcons.iconFor(name)`.
- Produces: `Button.icon: string` (optional, default `""`).
- [ ] **Step 1: Give `Button` an optional icon**
Add `property string icon: ""`. Replace the centred label `Text` with a `Row` (id `content`, `spacing: 6`) holding an icon `Text` (id `iconText`, `visible: btn.icon !== ""`, `font.family: Theme.iconFamily`, `font.pixelSize: label.font.pixelSize`) and the existing label `Text`. Set `iconText.color: label.color`. `implicitWidth: content.implicitWidth + 22`, `implicitHeight: content.implicitHeight + 12`. Keep the `text` and `font` aliases pointing at `label`. With no icon, `Row` lays out only the label, so existing buttons are unchanged.
- [ ] **Step 2: Convert the control buttons to icon + label**
In `ObsPill.qml`'s button `Row`:
- Record/Stop: icon `Obs.recording ? "\udb81\ude66" : "\udb83\udec2"`.
- Pause/Resume: icon `Obs.paused ? "\udb81\udc0a" : "\udb80\udfe4"`.
- Webcam: icon `Obs.camOn ? "\udb81\udd67" : "\udb81\udd68"`, label `"Webcam"`.
- Audio: visible when `Obs.desktopAudio !== ""`, icon `Obs.desktopMuted ? "\udb81\udd81" : "\udb81\udd7e"`, label `"Audio"`, `onClicked: Obs.toggleAudio()`.
Below that row add a `Row` of four icon-only `Button`s, `visible: Obs.camId >= 0`, icons `"\udb80\udc5b"`, `"\udb80\udc5c"`, `"\udb80\udc42"`, `"\udb80\udc43"`, calling `Obs.moveCam("tl" | "tr" | "bl" | "br")`.
- [ ] **Step 3: Replace the scene list with the grid**
Replace the scene `Repeater`/`Column` with a `Flickable` (`width: controls.width`, `height: Math.min(80, grid.childrenRect.height)`, `contentHeight: grid.childrenRect.height`, `clip: true`) holding a `Grid { id: grid; columns: 4; spacing: 8 }` of `Rectangles` (36x36, radius 8), each with a `Text` whose text is `SceneIcons.iconFor(modelData)` in `Theme.iconFamily` at pixelSize 18, and a `MouseArea` calling `Obs.setScene(modelData)`. Highlight the current scene with `Qt.alpha(Theme.accent, 0.25)` as the old list did. Click-only selection; the `Flickable` scrolls with the wheel when there are more than eight scenes.
- [ ] **Step 4: Update the island README**
In both the "OBS pill" section and, if needed, the intro: the panel now has Record/Stop, Pause/Resume, Webcam, Audio (desktop audio mute) and four corner buttons; the scene list is a four-column icon grid from `~/.config/quickshell/obs-scene-icons.json` (paste-only glyphs, `?` for unmapped); recording still pulses the sliver.
- [ ] **Step 5: Check syntax and commit**
Run: `qmllint desktop/Button.qml desktop/island/ObsPill.qml` (exit 0, no syntax errors), then:
```bash
git add desktop/Button.qml desktop/island/ObsPill.qml desktop/island/README.md
git commit -m "island: icon buttons, audio and corner controls, scene grid"
```
### Task 6: OBS scenes settings page
**Files:**
- Create: `desktop/modules/obs/ObsScenesModule.qml`
- Create: `desktop/modules/obs/ObsScenesPage.qml`
- Create: `desktop/modules/obs/README.md`
- Modify: `desktop/island/SceneIcons.qml`
- Modify: `desktop/shell.qml`
**Interfaces:**
- Consumes: `SceneIcons.map`, `SceneIcons.iconFor`, `SceneIcons.save(map)`; `Obs.scenes`.
- Produces: `SceneIcons.draft` (`map` or `null`), `SceneIcons.dirty` (bool), `SceneIcons.valid` (readonly bool), `SceneIcons.validGlyph(s)`, `SceneIcons.begin()`, `SceneIcons.editScene(name, text)`, `SceneIcons.editGeneric(text)`, `SceneIcons.discard()`; `save(map)` also clears the draft.
- [ ] **Step 1: Add the draft API to `SceneIcons.qml`**
- `property var draft: null`, `property bool dirty: false`, `readonly property bool valid: draft !== null && Icons.validMap(draft)`.
- `validGlyph(s)`: forward to `Icons.validGlyph`.
- `begin()`: if not dirty, `draft = Icons.parse(Icons.serialize(map))`.
- `editScene(name, text)`: ensure the draft, delete the key when `text === ""` else set it, reassign `draft` with a fresh `scenes` object so bindings fire, set `dirty = true`.
- `editGeneric(text)`: ensure the draft, reassign with the new generic, `dirty = true`.
- `discard()`: `draft = null; dirty = false`.
- `save(map)`: `Icons.normalize(map)`, set `root.map`, write with `Icons.serialize`, then `draft = null; dirty = false`.
- [ ] **Step 2: Write the module**
`ObsScenesModule.qml`: `import QtQuick`, `import "../.."`, `import "../../island"`. A `Module` with `name: "obs"`, `label: "OBS scenes"`, `icon: "\udb81\ude66"`, `settings: true`, `group: "Desktop"`, `dirty: SceneIcons.dirty`, keywords (`obs`, `scene`, `icon`, `glyph`, `recording`, `webcam`, `stream`). `refresh()` calls `SceneIcons.begin()` when not dirty and sets `searchItems` to `{ text: name, key: name }` for the union of `Object.keys(SceneIcons.map.scenes)` and `Obs.scenes`. The `page` is a `Page` titled "OBS scenes", with `actions: Component { SaveBar { dirty: SceneIcons.dirty; valid: SceneIcons.valid; onSave: SceneIcons.save(SceneIcons.draft); onDiscard: SceneIcons.discard() } }` and `ObsScenesPage { width: parent.width }`.
- [ ] **Step 3: Write the page**
`ObsScenesPage.qml`: a `Column` with `import "../.."` and `import "../../island"`. A hint line ("Paste an icon from the Nerd Fonts cheat sheet."). A `Repeater` over the union of the draft's scene keys and `Obs.scenes`, each row showing the name and a `TextInput` styled like the settings fields (`Theme.surface` background, radius 8), `font.family: Theme.iconFamily`, text `SceneIcons.draft?.scenes?.[name] ?? ""`, `onTextEdited: SceneIcons.editScene(name, text)`, red border when the text is non-empty and `!SceneIcons.validGlyph(text)`. A final row "Default for unmapped scenes" bound to `SceneIcons.draft?.generic ?? SceneIcons.map.generic` with `onTextEdited: SceneIcons.editGeneric(text)` and the same red-border rule.
- [ ] **Step 4: Register the module and document it**
In `desktop/shell.qml`, add `import "modules/obs"` and `ObsScenesModule {}` after `AutostartModule {}` in the modules list (keeps the "Desktop" group order). Create `desktop/modules/obs/README.md`: the page edits the same config file the pill watches; glyphs are pasted from the Nerd Fonts cheat sheet; empty generic falls back to `?`; the JSON may also be hand-edited.
- [ ] **Step 5: Check syntax and commit**
Run: `qmllint desktop/modules/obs/*.qml desktop/modules/obs/../island/SceneIcons.qml desktop/shell.qml` (exit 0, no syntax errors), then:
```bash
git add desktop/modules/obs desktop/island/SceneIcons.qml desktop/shell.qml
git commit -m "settings: OBS scenes icon page"
```
- [ ] **Step 6: Live check (user-run)**
Ask the user to run `./reload.sh desktop` from their terminal (new files in `island/` and `modules/obs/` need the reindex). Then confirm by eye:
1. Existing buttons in the drawer/settings look unchanged (no blank icon gap).
2. Pill: Record/Stop, Pause/Resume, Webcam, Audio show icon + label; corner buttons move the webcam to each corner.
3. Audio button mutes/unmutes `Desktop Audio`; a mute from OBS's own mixer moves it too.
4. Scene grid: four columns, icon tiles, current highlighted, click switches; the dropdown's old text list is gone.
5. Settings → OBS scenes: rows for every scene, paste a glyph, Save, pill tile updates without restart; an invalid (multi-character) glyph shows red and blocks Save.
6. Remove the config file, reload, and the starter icons return; create a scene in OBS and its tile is `?` until mapped.
Agent side: `qs log -p desktop --log-times | tail -40` shows no `ReferenceError`, no import error, and no `obs:` warnings.
|