diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-10-17-obs-pill.md | 225 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-10-17-obs-pill-design.md | 26 |
2 files changed, 137 insertions, 114 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 diff --git a/docs/superpowers/specs/2026-10-17-obs-pill-design.md b/docs/superpowers/specs/2026-10-17-obs-pill-design.md index 3331bba..9a3e3f3 100644 --- a/docs/superpowers/specs/2026-10-17-obs-pill-design.md +++ b/docs/superpowers/specs/2026-10-17-obs-pill-design.md @@ -88,19 +88,22 @@ password change in OBS fixes it without a restart. Right of the status pill, slides with the others, exists only while `Obs.connected`. -Left to right: +**At rest**: a record dot, grey when idle, red while recording, dimmed while +paused, followed by `mm:ss` (`h:mm:ss` past an hour) while recording. A +click opens the panel. -- **Record**: idle icon, or a red dot and `mm:ss` (`h:mm:ss` past an hour) - while recording. Click toggles recording. -- **Pause**: only while recording. Click toggles pause; the dot dims while - paused. -- **Scene**: current scene name. Click steps to the next, wheel steps - previous (up) and next (down), wrapping. Hidden with fewer than two scenes. -- **Webcam**: camera icon, struck while hidden. Click toggles. Absent when - `camId` is -1. +**Panel** (`win.panel === "obs"`), morphing the capsule as the status pill +does for sound; Escape and an outside click close it like the others: -The pill names its own panels (AGENTS.md): it opens none, so it never -expands. +- A status line: `Not recording`, `Recording 03:21` or `Paused 03:21`. +- Buttons (`desktop/Button.qml`): Record/Stop, Pause/Resume (while + recording), Show/Hide webcam (absent when `camId` is -1). +- The scene list in OBS's order, current one highlighted, click to switch. + A plain column; about ten scenes fit the island's 480px window. + +The pill names its own panel, `obs` (AGENTS.md). OBS quitting with the panel +open clears `win.panel`, or the island would stay held open by a pill that +no longer exists. ## Sliver @@ -115,7 +118,6 @@ colour, is what says "recording". Not recording: the sliver is unchanged. In `Logic.js`, covered by `test_logic.js`: - `obsElapsed(ms)`: `0` -> `00:00`, `61000` -> `01:01`, `3661000` -> `1:01:01` -- `obsStep(list, cur, d)`: next/previous scene with wrap, `cur` missing -> first ## Errors |
