aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-10-17-obs-pill.md225
1 files changed, 123 insertions, 102 deletions
diff --git a/docs/superpowers/plans/2026-10-17-obs-pill.md b/docs/superpowers/plans/2026-10-17-obs-pill.md
index 1c81d95..8bc3692 100644
--- a/docs/superpowers/plans/2026-10-17-obs-pill.md
+++ b/docs/superpowers/plans/2026-10-17-obs-pill.md
@@ -2,9 +2,9 @@
> **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:** An island pill that exists while OBS runs and controls recording, pause, scene and webcam visibility, with a pulsing sliver while recording.
+**Goal:** An island pill that exists while OBS runs; a click opens its panel (record, pause, webcam visibility, scene list), and the sliver pulses while recording.
-**Architecture:** `Obs.qml` singleton speaks obs-websocket v5 over QML `WebSocket` (QtWebSockets), reading the password from OBS's own config; the SHA256 auth string comes from `obs-auth.sh` (openssl). `ObsPill.qml` renders state and calls the singleton's actions. `Island.qml` places the pill right of the status pill and draws the pulse over the sliver.
+**Architecture:** `Obs.qml` singleton speaks obs-websocket v5 over QML `WebSocket` (QtWebSockets), reading the password from OBS's own config; the SHA256 auth string comes from `obs-auth.sh` (openssl). `ObsPill.qml` shows a dot and elapsed time at rest and morphs into a controls panel on click, as the status pill does for sound. `Island.qml` places the pill right of the status pill and draws the pulse over the sliver.
**Tech Stack:** Quickshell 0.3.1 QML, QtWebSockets, openssl, node (for `test_logic.js`).
@@ -18,15 +18,15 @@ Spec: `docs/superpowers/specs/2026-10-17-obs-pill-design.md`.
- Create `desktop/island/obs-auth.sh`: stdin `password+salt` line, `challenge` line; stdout the auth string.
- Create `desktop/island/Obs.qml`: singleton, protocol and state.
-- Create `desktop/island/ObsPill.qml`: the pill.
-- Modify `desktop/island/Logic.js`, `desktop/island/test_logic.js`: `obsElapsed`, `obsStep`.
+- Create `desktop/island/ObsPill.qml`: the pill and its panel.
+- Modify `desktop/island/Logic.js`, `desktop/island/test_logic.js`: `obsElapsed`.
- Modify `desktop/island/Island.qml`: hover, mask, placement, sliver pulse.
- Modify `desktop/island/README.md`, `AGENTS.md`: docs.
## Constraints every task must respect
- GPLv2 header on every new file (copy from `privacy.sh` / `Privacy.qml`).
-- Glyphs render with `Theme.iconFamily`. Checked present in Inconsolata Nerd Font: `\uf111` fa-circle, `\uf04c` fa-pause, `\uf04b` fa-play, `\u{F0567}` md-video, `\u{F0568}` md-video_off.
+- Glyphs render with `Theme.iconFamily`. Checked present in Inconsolata Nerd Font: `\uf111` fa-circle, the only glyph used.
- Never run `qs` detached or `reload.sh` from a tool call (AGENTS.md). New files in `island/` need `./reload.sh desktop` from the user's terminal to be indexed.
- Process name is `qs`; never `pkill -f`.
@@ -36,7 +36,7 @@ Spec: `docs/superpowers/specs/2026-10-17-obs-pill-design.md`.
**Files:** Modify `desktop/island/Logic.js`, `desktop/island/test_logic.js`
-- [ ] **Step 1: Failing tests.** In `test_logic.js`, extend the return list to `{ volumeIcon, vmRole, flagFor, privacyRoles, parseOpenWindow, obsElapsed, obsStep }` and add before `console.log("ok")`:
+- [ ] **Step 1: Failing tests.** In `test_logic.js`, extend the return list to `{ volumeIcon, vmRole, flagFor, privacyRoles, parseOpenWindow, obsElapsed }` and add before `console.log("ok")`:
```js
// OBS elapsed: mm:ss under an hour, h:mm:ss past it.
@@ -46,12 +46,6 @@ assert.strictEqual(L.obsElapsed(3599999), "59:59");
assert.strictEqual(L.obsElapsed(3661000), "1:01:01");
assert.strictEqual(L.obsElapsed(undefined), "00:00");
-// OBS scene step: wraps both ways; an unknown current scene starts at the first.
-assert.strictEqual(L.obsStep(["a", "b", "c"], "a", 1), "b");
-assert.strictEqual(L.obsStep(["a", "b", "c"], "c", 1), "a");
-assert.strictEqual(L.obsStep(["a", "b", "c"], "a", -1), "c");
-assert.strictEqual(L.obsStep(["a", "b", "c"], "x", 1), "a");
-assert.strictEqual(L.obsStep([], "x", 1), "");
```
- [ ] **Step 2:** `node desktop/island/test_logic.js` → FAIL, `obsElapsed is not defined`.
@@ -68,19 +62,11 @@ function obsElapsed(ms) {
return h ? `${h}:${mmss}` : mmss;
}
-// The scene d steps (+1 next, -1 previous) from cur, wrapping. A current
-// scene missing from the list starts at the first.
-function obsStep(list, cur, d) {
- if (!list.length) return "";
- const i = list.indexOf(cur);
- if (i < 0) return list[0];
- return list[(i + d + list.length) % list.length];
-}
```
- [ ] **Step 4:** `node desktop/island/test_logic.js` → `ok`.
-- [ ] **Step 5: Commit** `island: elapsed and scene-step helpers for the OBS pill`.
+- [ ] **Step 5: Commit** `island: elapsed-time helper for the OBS pill`.
### Task 2: Auth helper
@@ -338,102 +324,121 @@ import QtQuick
import ".."
import "Logic.js" as Logic
-// Record and elapsed, pause, scene, webcam. Each part hides when it has
-// nothing to do: pause while not recording, the scene with fewer than two,
-// the webcam when the scene has none.
+// At rest a record dot, with the elapsed time while recording. A click
+// morphs the capsule into the controls panel, as the status pill does for
+// sound: record, pause, webcam, and the scene list.
Capsule {
id: pill
- restWidth: row.implicitWidth + 24
+ property string panel: ""
+ signal openPanel()
+
+ expanded: panel === "obs"
+ restWidth: rest.implicitWidth + 24
+ panelWidth: 260
+ panelHeight: controls.implicitHeight + 28
Row {
- id: row
+ id: rest
anchors.centerIn: parent
- spacing: 12
+ spacing: 6
+ opacity: pill.expanded ? 0 : 1
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: 120 } }
- // Dot and time share one click target.
- Item {
+ Text {
anchors.verticalCenter: parent.verticalCenter
- implicitWidth: rec.implicitWidth
- implicitHeight: rec.implicitHeight
- Row {
- id: rec
- spacing: 6
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: "\uf111"
- color: Obs.recording ? Theme.red : Theme.subtext
- opacity: Obs.paused ? 0.5 : 1
- font { family: Theme.iconFamily; pixelSize: 14 }
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- visible: Obs.recording
- text: Logic.obsElapsed(Obs.duration)
- color: Theme.text
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- }
- }
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: Obs.toggleRecord()
- }
+ text: "\uf111"
+ color: Obs.recording ? Theme.red : Theme.subtext
+ opacity: Obs.paused ? 0.5 : 1
+ font { family: Theme.iconFamily; pixelSize: 14 }
}
-
Text {
- visible: Obs.recording
anchors.verticalCenter: parent.verticalCenter
- text: Obs.paused ? "\uf04b" : "\uf04c"
+ visible: Obs.recording
+ text: Logic.obsElapsed(Obs.duration)
color: Theme.text
- font { family: Theme.iconFamily; pixelSize: 14 }
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: Obs.togglePause()
- }
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
}
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: !pill.expanded
+ cursorShape: Qt.PointingHandCursor
+ onClicked: pill.openPanel()
+ }
+
+ Column {
+ id: controls
+ x: 14
+ y: 14
+ width: pill.panelWidth - 28
+ spacing: 10
+ opacity: pill.expanded ? 1 : 0
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: 160 } }
Text {
- visible: Obs.scenes.length > 1
- anchors.verticalCenter: parent.verticalCenter
- text: Obs.scene
- color: Theme.subtext
+ text: Obs.recording ? (Obs.paused ? "Paused " : "Recording ") + Logic.obsElapsed(Obs.duration)
+ : "Not recording"
+ color: Obs.recording ? Theme.red : Theme.subtext
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- // One step per wheel notch, as the status pill's layout flag.
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- property real acc: 0
- onClicked: Obs.setScene(Logic.obsStep(Obs.scenes, Obs.scene, 1))
- onWheel: wheel => {
- acc += wheel.angleDelta.y;
- if (Math.abs(acc) >= 120) {
- Obs.setScene(Logic.obsStep(Obs.scenes, Obs.scene, acc > 0 ? -1 : 1));
- acc = 0;
- }
- wheel.accepted = true;
- }
- }
}
- Text {
- visible: Obs.camId >= 0
- anchors.verticalCenter: parent.verticalCenter
- text: Obs.camOn ? "\u{F0567}" : "\u{F0568}"
- color: Obs.camOn ? Theme.text : Theme.overlay
- font { family: Theme.iconFamily; pixelSize: 16 }
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
+ Row {
+ spacing: 8
+ Button {
+ text: Obs.recording ? "Stop" : "Record"
+ danger: Obs.recording
+ onClicked: Obs.toggleRecord()
+ }
+ Button {
+ visible: Obs.recording
+ text: Obs.paused ? "Resume" : "Pause"
+ onClicked: Obs.togglePause()
+ }
+ Button {
+ visible: Obs.camId >= 0
+ text: Obs.camOn ? "Hide webcam" : "Show webcam"
onClicked: Obs.toggleCam()
}
}
+
+ // ponytail: a plain Column, no scrolling; about ten scenes fit the
+ // island's 480px window. A ListView if a scene collection outgrows it.
+ Repeater {
+ model: Obs.scenes
+ delegate: Rectangle {
+ required property string modelData
+ readonly property bool current: modelData === Obs.scene
+ width: controls.width
+ height: 30
+ radius: 7
+ color: current ? Qt.alpha(Theme.accent, 0.25)
+ : area.containsMouse ? Qt.alpha(Theme.surface, 0.8) : "transparent"
+ Text {
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: modelData
+ color: current ? Theme.text : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ MouseArea {
+ id: area
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Obs.setScene(modelData)
+ }
+ }
+ }
}
}
```
-- [ ] **Step 2: Commit** `island: OBS pill`.
+`Button` is `desktop/Button.qml`, reached by `import ".."`.
+
+- [ ] **Step 2: Commit** `island: OBS pill and its controls panel`.
### Task 5: Wire into the island
@@ -452,12 +457,25 @@ Capsule {
ObsPill {
id: obs
visible: Obs.connected
+ panel: win.panel
+ onOpenPanel: win.panel = "obs"
x: win.cx - width / 2 + (win.gap * 1.5 + status.width + width / 2) * win.reveal
y: -height + (win.top + height) * win.reveal
opacity: Math.min(1, win.reveal * 2)
}
```
+- [ ] **Step 3b: Panel bookkeeping.** The `panel` comment becomes `// "", "sound", "calendar" or "obs".` OBS quitting with its panel open must close it, else the island stays held open by an invisible pill. Add next to the dismissal section:
+
+```qml
+ Connections {
+ target: Obs
+ function onConnectedChanged() { if (!Obs.connected && win.panel === "obs") win.panel = ""; }
+ }
+```
+
+Escape and outside clicks already close any panel (`keys`, `HyprlandFocusGrab`); nothing else to add.
+
- [ ] **Step 4: Sliver pulse**, right after the `sliver` Row:
```qml
@@ -487,11 +505,13 @@ Capsule {
- [ ] **Step 6: Live check with OBS** (user starts OBS; the server is enabled). Ask the user to confirm by eye:
1. Pill appears within ~3s of OBS starting, disappears on quit.
- 2. Click record: dot turns red, timer counts, sliver pulses.
- 3. Pause: timer freezes, sliver steady dim. Resume continues.
- 4. Scene: click steps, wheel steps both ways, OBS follows; changing scene in OBS updates the pill.
- 5. Webcam icon toggles the source in OBS; toggling the eye in OBS updates the icon.
- 6. Stop: pill back to idle, pulse gone.
+ 2. Click the pill: panel opens; Escape and an outside click close it.
+ 3. Record: rest dot turns red, timer counts in the panel and on the pill, sliver pulses.
+ 4. Pause: timer freezes, sliver steady dim. Resume continues.
+ 5. Picking a scene switches OBS; changing scene in OBS moves the highlight.
+ 6. Webcam button toggles the source in OBS; toggling the eye in OBS updates the button. Absent in a scene without a webcam.
+ 7. Stop: pill back to idle, pulse gone.
+ 8. Quit OBS with the panel open: panel and pill go, island hides.
Agent side: `qs log -p desktop | grep obs:` shows no warnings during the above.
@@ -512,11 +532,12 @@ listens on every interface). `Obs.qml` speaks obs-websocket v5 directly
and reads the password from OBS's own `obs-websocket/config.json`, so no
copy of it exists; `obs-auth.sh` computes the SHA256 handshake QML cannot.
-Record dot and elapsed time (click starts or stops), pause (while
-recording), the current scene (click or wheel steps, wrapping; only with
-two or more scenes), and the webcam (the first `v4l2_input` item in the
-current scene; click shows or hides it). While recording the sliver
-pulses red, and holds dim while paused.
+At rest it shows a record dot, with the elapsed time while recording. A
+click opens its panel in the capsule: Record or Stop, Pause or Resume
+(while recording), Show or Hide webcam (the first `v4l2_input` item in the
+current scene; absent without one), and the scene list, current one
+highlighted, click to switch. While recording the sliver pulses red, and
+holds dim while paused.
QML's `WebSocket` does not expose the close code, so a wrong password
reads the same as OBS quitting; it is logged once as "closed before