aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-17-obs-pill.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 12:13:00 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 12:13:00 +0200
commit65c664e716a17fa3e200cc86131f514f5a817d60 (patch)
tree2d2dd02fdfcb36c496146af4454873db7d9d8d5a /docs/superpowers/plans/2026-10-17-obs-pill.md
parent662adfe717203fbe5ea8218a190bc39aff897d6e (diff)
downloadquickshell-65c664e716a17fa3e200cc86131f514f5a817d60.tar.gz
quickshell-65c664e716a17fa3e200cc86131f514f5a817d60.zip
docs: the OBS pill opens a controls panel
At rest the pill is a record dot with the elapsed time; a click morphs it into a panel, as the status pill does for sound, with Record/Stop, Pause/Resume, Show/Hide webcam and the scene list. The user preferred it to every control inline: the pill stays small, and scenes are picked by name rather than stepped through, so obsStep is gone. OBS quitting with the panel open clears it, or the island would stay held open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-10-17-obs-pill.md')
-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