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-13-controls-panel.md1063
1 files changed, 1063 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-13-controls-panel.md b/docs/superpowers/plans/2026-10-13-controls-panel.md
new file mode 100644
index 0000000..6c2e152
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-13-controls-panel.md
@@ -0,0 +1,1063 @@
+# Controls Panel 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:** A grid of seven toggles in the island's status pill, with an edit mode, backed by a JSON preferences file, opened by a bare SUPER release.
+
+**Architecture:** Pure helpers in `desktop/island/Controls.js` (tile table, order operations, fill, drop slot) tested under node. `desktop/Prefs.qml` is a singleton over one JSON file. `ControlTile.qml` binds one tile's live state; `ControlsPanel.qml` lays them out and owns edit mode. The status pill hosts the panel in its capsule exactly as it hosts the sound panel, and a new `island` IPC target opens it.
+
+**Tech Stack:** Quickshell 0.3.1 QML (`Quickshell.Io` FileView, `Quickshell.Networking`, `Quickshell.Bluetooth`, PipeWire via the sound module's Service), node for the pure tests, Hyprland 0.56.2 Lua config.
+
+**Spec:** `docs/superpowers/specs/2026-10-13-controls-panel-design.md`
+
+---
+
+## Read first
+
+- `AGENTS.md`, sections "The rule that bites first", "Verifying, and how not to", "Reloading". In short: the process is `qs`, never `quickshell`; never `pkill -f`; never start a detached `qs` from a tool call; hot reload does not pick up a new file until a file importing that directory changes.
+- `desktop/island/Island.qml`, `StatusPill.qml`, `SoundPanel.qml`: the panel mechanism this copies.
+- Icons are freedesktop names through `Quickshell.iconPath(name, true)`, the island's rule (island spec, "Icons"). Every name used below was checked to resolve in the active theme (candy-icons, inheriting breeze-dark).
+
+Every new source file starts with the GPLv2 header used in the repo:
+
+```
+// 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.
+```
+
+The code blocks below write it as `// <GPLv2 header>`; paste the full header there.
+
+## Files
+
+| File | Action | Responsibility |
+| --- | --- | --- |
+| `docs/superpowers/specs/2026-10-13-controls-panel-design.md` | modify | two corrections found while planning |
+| `desktop/island/Controls.js` | create | tile table, icons, order helpers, fill, volume step, drop slot |
+| `desktop/island/test_controls.js` | create | node test for Controls.js |
+| `desktop/Prefs.qml` | create | singleton over `$XDG_STATE_HOME/quickshell/desktop.json` |
+| `desktop/island/ControlTile.qml` | create | one tile: live state, click, right-click, scroll, fill |
+| `desktop/island/ControlsPanel.qml` | create | header, grid, edit mode, hidden strip |
+| `desktop/island/StatusPill.qml` | modify | opener icon, hosts ControlsPanel |
+| `desktop/island/Island.qml` | modify | `"controls"` panel, `toggleControls()`, drawer page link |
+| `desktop/shell.qml` | modify | `island` IPC target |
+| `~/.config/hypr/sections/keybindings.lua` | modify (live config, not in repo) | SUPER release bind |
+| `desktop/README.md`, `AGENTS.md` | modify | document the panel and what the build learned |
+
+---
+
+### Task 1: Spec corrections
+
+Two things in the spec were settled differently while planning:
+
+1. Icons: the spec says Nerd Font glyphs; the island's rule is named icons through the icon theme.
+2. Drag: the spec says `DragHandler` + `DropArea`; one `MouseArea` per tile plus a pure `slotAt()` is less code and testable.
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-10-13-controls-panel-design.md`
+
+- [ ] **Step 1: Replace the glyph line**
+
+Replace:
+
+```
+Glyphs must exist in Inconsolata Nerd Font and be checked in its cmap.
+```
+
+with:
+
+```
+Icons are freedesktop names through `Quickshell.iconPath(name, true)`, the
+island's rule, so an icon theme switch reaches the panel. Every name was
+checked to resolve in candy-icons (which inherits breeze-dark): wifi
+`network-wireless` / `network-wireless-disconnected`, bluetooth
+`preferences-system-bluetooth`, DND `notifications` /
+`notifications-disabled`, presentation `video-display`, auto-lock
+`system-lock-screen`, output `Logic.volumeIcon`, mic
+`audio-input-microphone` / `microphone-sensitivity-muted`, the opener
+`preferences-system`, edit `document-edit`, hide `window-close`, show
+`list-add`.
+```
+
+- [ ] **Step 2: Replace the drag bullet**
+
+Replace:
+
+```
+- a tile dragged onto another moves to that position (`DragHandler` on the
+ tile, `DropArea` on each slot);
+```
+
+with:
+
+```
+- a tile dragged onto another moves to that position: one `MouseArea` per
+ tile offsets it while pressed, and on release the slot under its centre
+ (`slotAt()` in `Controls.js`) is where it lands;
+```
+
+and add `slotAt(x, y, pitch, cols, count)` and `step(volume, delta, max)` to the list of pure helpers in the same section.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/superpowers/specs/2026-10-13-controls-panel-design.md
+git commit -m "docs(spec): controls panel uses named icons and a plain drag
+
+Planning found two places where the spec diverged from the island it
+extends. Icons go through the icon theme like every other island icon,
+and drag is one MouseArea per tile with a pure drop-slot helper, which
+is less code than DragHandler and DropArea and can be tested under node.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Controls.js and its test
+
+**Files:**
+- Create: `desktop/island/Controls.js`
+- Create: `desktop/island/test_controls.js`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/island/test_controls.js`:
+
+```js
+// <GPLv2 header>
+
+// Run: node desktop/island/test_controls.js
+// Controls.js is a QML library, so its .pragma line is stripped before node
+// evaluates it, as test_logic.js does for Logic.js.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Controls.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const C = new Function(src +
+ "\nreturn { defaults, tile, icon, normalize, hidden, move, hide, show, fill, step, slotAt };")();
+
+const ALL = ["wifi", "bluetooth", "dnd", "presentation", "nolock", "output", "mic"];
+
+// Defaults: all seven, a fresh copy each time.
+assert.deepStrictEqual(C.defaults(), ALL);
+const d = C.defaults(); d.pop();
+assert.deepStrictEqual(C.defaults(), ALL);
+
+// Table: label and drawer page; unknown is null.
+assert.strictEqual(C.tile("wifi").page, "network");
+assert.strictEqual(C.tile("nolock").label, "Auto-lock");
+assert.strictEqual(C.tile("mic").page, "sound");
+assert.strictEqual(C.tile("nope"), null);
+
+// Icons by state. Output is Logic.volumeIcon's job, so "" here.
+assert.strictEqual(C.icon("wifi", true), "network-wireless");
+assert.strictEqual(C.icon("wifi", false), "network-wireless-disconnected");
+assert.strictEqual(C.icon("dnd", true), "notifications-disabled");
+assert.strictEqual(C.icon("dnd", false), "notifications");
+assert.strictEqual(C.icon("mic", true), "audio-input-microphone");
+assert.strictEqual(C.icon("mic", false), "microphone-sensitivity-muted");
+assert.strictEqual(C.icon("bluetooth", false), "preferences-system-bluetooth");
+assert.strictEqual(C.icon("output", true), "");
+
+// normalize: drops unknown ids and duplicates, keeps order; an empty list
+// is valid (everything hidden); a non-array is the default.
+assert.deepStrictEqual(C.normalize(["mic", "gone", "wifi", "mic"]), ["mic", "wifi"]);
+assert.deepStrictEqual(C.normalize([]), []);
+assert.deepStrictEqual(C.normalize("wifi"), ALL);
+assert.deepStrictEqual(C.normalize(undefined), ALL);
+
+// hidden: known ids not in the list, in table order.
+assert.deepStrictEqual(C.hidden(["wifi", "mic"]), ["bluetooth", "dnd", "presentation", "nolock", "output"]);
+assert.deepStrictEqual(C.hidden(ALL), []);
+
+// move: from index to index, input untouched.
+const src3 = ["a", "b", "c"];
+assert.deepStrictEqual(C.move(src3, 0, 2), ["b", "c", "a"]);
+assert.deepStrictEqual(C.move(src3, 2, 0), ["c", "a", "b"]);
+assert.deepStrictEqual(C.move(src3, 1, 1), ["a", "b", "c"]);
+assert.deepStrictEqual(src3, ["a", "b", "c"]);
+
+// hide / show.
+assert.deepStrictEqual(C.hide(["wifi", "mic"], "wifi"), ["mic"]);
+assert.deepStrictEqual(C.show(["mic"], "wifi"), ["mic", "wifi"]);
+assert.deepStrictEqual(C.show(["mic"], "mic"), ["mic"]);
+assert.deepStrictEqual(C.show(["mic"], "nope"), ["mic"]);
+
+// fill: volume clamped to 0..1; unpopulated reads as empty, not full.
+assert.strictEqual(C.fill(0.4), 0.4);
+assert.strictEqual(C.fill(1.5), 1);
+assert.strictEqual(C.fill(-1), 0);
+assert.strictEqual(C.fill(undefined), 0);
+assert.strictEqual(C.fill(NaN), 0);
+
+// step: 5% steps, rounded to the percent, clamped to 0..max.
+assert.strictEqual(C.step(0.5, 0.05, 1.5), 0.55);
+assert.strictEqual(C.step(0.02, -0.05, 1.5), 0);
+assert.strictEqual(C.step(1.48, 0.05, 1.5), 1.5);
+assert.strictEqual(C.step(0.98, 0.05, 1.0), 1);
+assert.strictEqual(C.step(0.1 + 0.2, 0.05, 1), 0.35);
+assert.strictEqual(C.step(undefined, 0.05, 1), 0.05);
+
+// slotAt: pitch 70, 4 columns, 7 tiles.
+assert.strictEqual(C.slotAt(35, 35, 70, 4, 7), 0);
+assert.strictEqual(C.slotAt(175, 35, 70, 4, 7), 2);
+assert.strictEqual(C.slotAt(105, 105, 70, 4, 7), 5);
+assert.strictEqual(C.slotAt(-50, -50, 70, 4, 7), 0); // off the top left
+assert.strictEqual(C.slotAt(500, 35, 70, 4, 7), 3); // past the right edge
+assert.strictEqual(C.slotAt(275, 105, 70, 4, 7), 6); // empty 8th slot -> last
+assert.strictEqual(C.slotAt(35, 900, 70, 4, 7), 6); // below the grid -> last
+
+console.log("test_controls: ok");
+```
+
+- [ ] **Step 2: Run it to see it fail**
+
+Run: `node desktop/island/test_controls.js`
+Expected: FAIL, `ENOENT: no such file or directory, open '.../Controls.js'`
+
+- [ ] **Step 3: Write Controls.js**
+
+`desktop/island/Controls.js`:
+
+```js
+.pragma library
+
+// <GPLv2 header>
+
+// The controls panel's tiles and the pure operations on their order. The
+// order itself lives in Prefs.qml; everything here takes a list and returns
+// a new one, so it runs under node (test_controls.js).
+
+// Table order is the default order and the order of the hidden strip.
+const TILES = {
+ wifi: { label: "Wi-Fi", page: "network" },
+ bluetooth: { label: "Bluetooth", page: "bluetooth" },
+ dnd: { label: "DND", page: "status" },
+ presentation: { label: "Presentation", page: "status" },
+ nolock: { label: "Auto-lock", page: "status" },
+ output: { label: "Output", page: "sound" },
+ mic: { label: "Mic", page: "sound" },
+};
+
+function defaults() {
+ return Object.keys(TILES);
+}
+
+function tile(id) {
+ return TILES[id] || null;
+}
+
+// Icon name for a tile's state. `on` is the tile's lit state: for the mic
+// that is "not muted". Output is "" because Logic.volumeIcon owns it.
+function icon(id, on) {
+ switch (id) {
+ case "wifi": return on ? "network-wireless" : "network-wireless-disconnected";
+ case "bluetooth": return "preferences-system-bluetooth";
+ case "dnd": return on ? "notifications-disabled" : "notifications";
+ case "presentation": return "video-display";
+ case "nolock": return "system-lock-screen";
+ case "mic": return on ? "audio-input-microphone" : "microphone-sensitivity-muted";
+ default: return "";
+ }
+}
+
+// A stored list made safe: unknown ids (a tile removed from the code) and
+// duplicates dropped. An empty list is a valid choice; a non-list is not.
+function normalize(list) {
+ if (!Array.isArray(list)) return defaults();
+ const out = [];
+ for (let i = 0; i < list.length; i++) {
+ const id = list[i];
+ if (TILES[id] && out.indexOf(id) < 0) out.push(id);
+ }
+ return out;
+}
+
+function hidden(list) {
+ return defaults().filter(id => list.indexOf(id) < 0);
+}
+
+function move(list, from, to) {
+ const out = list.slice();
+ const id = out.splice(from, 1)[0];
+ out.splice(to, 0, id);
+ return out;
+}
+
+function hide(list, id) {
+ return list.filter(x => x !== id);
+}
+
+function show(list, id) {
+ if (!TILES[id] || list.indexOf(id) >= 0) return list.slice();
+ return list.concat([id]);
+}
+
+// Audio tile bar width as a fraction. Output goes to 1.5, the bar stops at
+// 1. !(> 0) also catches a volume not populated yet.
+function fill(volume) {
+ if (!(volume > 0)) return 0;
+ return Math.min(1, volume);
+}
+
+// One scroll step, rounded to the percent so repeated steps do not drift.
+function step(volume, delta, max) {
+ const v = Math.round(((volume > 0 ? volume : 0) + delta) * 100) / 100;
+ return Math.max(0, Math.min(max, v));
+}
+
+// The slot a dragged tile lands in, from its centre in grid coordinates.
+// Off the grid clamps to the nearest edge; past the last tile is the last.
+function slotAt(x, y, pitch, cols, count) {
+ const col = Math.max(0, Math.min(cols - 1, Math.floor(x / pitch)));
+ const row = Math.max(0, Math.floor(y / pitch));
+ return Math.max(0, Math.min(count - 1, row * cols + col));
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `node desktop/island/test_controls.js`
+Expected: `test_controls: ok`
+
+Also run `node desktop/island/test_logic.js` to confirm nothing else moved.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/island/Controls.js desktop/island/test_controls.js
+git commit -m "feat(island): pure tile table and order helpers for controls
+
+The controls panel's tiles, their icons and drawer pages, and every
+operation on the visible list: normalize, hide, show, move, the drop
+slot under a dragged tile, the audio fill and the scroll step. All pure,
+so the edit mode's logic is tested under node rather than by dragging.
+
+normalize keeps an empty list: hiding every tile is a choice, and only
+a value that is not a list falls back to the default.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Prefs.qml
+
+**Files:**
+- Create: `desktop/Prefs.qml`
+
+Facts this relies on, checked while planning: `FileView.setText` creates missing parent directories; `Quickshell.stateDir` is a per-shell hashed directory, which is why the path is built from `XDG_STATE_HOME` instead. The FileView options mirror `shared/Status.qml`'s `ModeFile`.
+
+- [ ] **Step 1: Write the singleton**
+
+`desktop/Prefs.qml`:
+
+```qml
+// <GPLv2 header>
+
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import "island/Controls.js" as Controls
+
+// Preferences that must survive a reboot, in one JSON file under the state
+// home. Only the shell writes it, which is why it is not in ~/.config.
+//
+// Nothing is written until the user changes something. A file that fails to
+// parse reads as the defaults and stays on disk as it is, so a hand edit
+// with a typo is not replaced by a panel that was merely opened.
+Singleton {
+ id: root
+
+ readonly property string path:
+ (Quickshell.env("XDG_STATE_HOME") || Quickshell.env("HOME") + "/.local/state")
+ + "/quickshell/desktop.json"
+
+ // Visible control tiles, in display order.
+ property var controls: Controls.defaults()
+
+ // The whole parsed object, so a write keeps keys this version does not
+ // know about.
+ property var data: ({})
+
+ function setControls(list) {
+ root.controls = Controls.normalize(list);
+ const out = Object.assign({}, root.data, { controls: root.controls });
+ root.data = out;
+ file.setText(JSON.stringify(out, null, 2) + "\n");
+ }
+
+ function reparse() {
+ let parsed = {};
+ try {
+ const v = JSON.parse(file.text());
+ if (v && typeof v === "object" && !Array.isArray(v)) parsed = v;
+ } catch (e) {}
+ root.data = parsed;
+ const next = "controls" in parsed ? Controls.normalize(parsed.controls) : Controls.defaults();
+ // The watch also fires on our own write; compare so it is a no-op.
+ if (JSON.stringify(next) !== JSON.stringify(root.controls)) root.controls = next;
+ }
+
+ FileView {
+ id: file
+ path: root.path
+ atomicWrites: true
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.reparse()
+ // Missing file: the defaults, and nothing written.
+ onLoadFailed: { root.data = {}; root.controls = Controls.defaults(); }
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+Nothing imports it yet, so there is nothing to run; Task 6 exercises it.
+
+```bash
+git add desktop/Prefs.qml
+git commit -m "feat(desktop): preferences file under the state home
+
+The first preference is the controls panel's visible tiles. The file is
+written only on a change and keeps keys it does not know, so a broken or
+newer file is never clobbered by a shell that only read it. State home
+rather than the runtime dir because it must survive a reboot, rather
+than ~/.config because the shell writes it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: ControlTile.qml
+
+**Files:**
+- Create: `desktop/island/ControlTile.qml`
+
+- [ ] **Step 1: Write the tile**
+
+`desktop/island/ControlTile.qml`:
+
+```qml
+// <GPLv2 header>
+
+import Quickshell
+import Quickshell.Widgets
+import Quickshell.Networking
+import Quickshell.Bluetooth
+import QtQuick
+import ".."
+import "Controls.js" as Controls
+import "Logic.js" as Logic
+
+// One control: icon over label. Left-click toggles, right-click opens the
+// drawer page that holds the rest, scroll changes volume on the two audio
+// tiles. Toggle tiles fill when on; audio tiles fill to their volume.
+Rectangle {
+ id: tile
+
+ required property string tileId
+ // The sound module's Service.
+ required property QtObject sound
+
+ signal openPage(string page)
+
+ readonly property var info: Controls.tile(tileId)
+ readonly property bool isAudio: tileId === "output" || tileId === "mic"
+ readonly property var audio: tileId === "output" ? (sound.sink?.audio ?? null)
+ : tileId === "mic" ? (sound.source?.audio ?? null)
+ : null
+ readonly property var adapter: Bluetooth.defaultAdapter
+ // Networking starts empty for about two seconds (AGENTS.md).
+ readonly property bool wifiReady:
+ (Networking.devices?.values ?? []).some(d => d.type === DeviceType.Wifi)
+
+ readonly property bool ready: isAudio ? audio !== null
+ : tileId === "bluetooth" ? adapter !== null
+ : tileId === "wifi" ? wifiReady
+ : true
+
+ readonly property bool on: {
+ switch (tileId) {
+ case "wifi": return Networking.wifiEnabled;
+ case "bluetooth": return adapter?.enabled ?? false;
+ case "dnd": return Status.dnd;
+ // Lit while a game holds it too; a click then clears only the manual
+ // half (Status.qml), so the tile stays lit until the game ends.
+ case "presentation": return Status.presentation;
+ case "nolock": return !Status.nolock;
+ default: return audio ? !audio.muted : false;
+ }
+ }
+
+ function toggle() {
+ switch (tileId) {
+ case "wifi": Networking.wifiEnabled = !Networking.wifiEnabled; break;
+ case "bluetooth": adapter.enabled = !adapter.enabled; break;
+ case "dnd":
+ case "presentation":
+ case "nolock": Status.toggleMode(tileId); break;
+ default: audio.muted = !audio.muted;
+ }
+ }
+
+ radius: 12
+ color: Qt.alpha(Theme.surface, 0.5)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.08)
+ opacity: ready ? 1 : 0.4
+ clip: true
+
+ // Toggle tiles: lit when on.
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ visible: !tile.isAudio && tile.on
+ color: Qt.alpha(Theme.accent, 0.35)
+ }
+
+ // Audio tiles: a bar to the volume. Muted keeps the width in a neutral
+ // colour, so the level stays readable. Not the accent as the neutral:
+ // udt can snap it onto a palette colour (AGENTS.md).
+ Rectangle {
+ visible: tile.isAudio
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: parent.width * Controls.fill(tile.audio?.volume)
+ radius: parent.radius
+ color: tile.audio?.muted ? Qt.alpha(Theme.overlay, 0.6) : Qt.alpha(Theme.accent, 0.35)
+ }
+
+ Column {
+ anchors.centerIn: parent
+ width: parent.width - 8
+ spacing: 4
+
+ IconImage {
+ anchors.horizontalCenter: parent.horizontalCenter
+ implicitSize: 22
+ source: Quickshell.iconPath(tile.tileId === "output"
+ ? Logic.volumeIcon(tile.audio?.volume ?? 0, tile.audio?.muted ?? false)
+ : Controls.icon(tile.tileId, tile.on), true)
+ }
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ elide: Text.ElideRight
+ text: tile.info?.label ?? tile.tileId
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.text
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ cursorShape: Qt.PointingHandCursor
+ onClicked: mouse => {
+ if (mouse.button === Qt.RightButton) tile.openPage(tile.info.page);
+ else if (tile.ready) tile.toggle();
+ }
+ // One step per wheel notch (120), as the layout flag does. A
+ // MouseArea, not a WheelHandler: the handler never fired in the
+ // island (AGENTS.md).
+ property real acc: 0
+ onWheel: wheel => {
+ wheel.accepted = true;
+ if (!tile.isAudio || !tile.ready) return;
+ acc += wheel.angleDelta.y;
+ if (Math.abs(acc) < 120) return;
+ const max = tile.tileId === "output" ? 1.5 : 1.0;
+ tile.audio.volume = Controls.step(tile.audio.volume, acc > 0 ? 0.05 : -0.05, max);
+ acc = 0;
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/island/ControlTile.qml
+git commit -m "feat(island): a control tile bound to its live state
+
+Wifi and bluetooth through the Quickshell services, DND, presentation and
+auto-lock through the Status registry, output and mic through the sound
+module's tracked nodes. A tile whose backing object has not arrived yet
+is dimmed and ignores input, which covers the two seconds before
+Networking and Bluetooth populate.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: ControlsPanel.qml
+
+**Files:**
+- Create: `desktop/island/ControlsPanel.qml`
+
+- [ ] **Step 1: Write the panel**
+
+`desktop/island/ControlsPanel.qml`:
+
+```qml
+// <GPLv2 header>
+
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import ".."
+import "Controls.js" as Controls
+
+// The grid of controls and its edit mode. In edit mode a tile is dragged to
+// a new slot, a × hides it, and hidden tiles wait in a strip below with a +.
+// Every change is written to Prefs at once.
+Column {
+ id: panel
+
+ required property QtObject sound
+ property bool editing: false
+
+ signal openDrawer(string page)
+
+ spacing: 12
+
+ Item {
+ width: panel.width
+ height: 20
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: panel.editing ? "Edit controls" : "Controls"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+
+ IconImage {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ implicitSize: 16
+ opacity: panel.editing ? 1 : 0.6
+ source: Quickshell.iconPath("document-edit", true)
+
+ MouseArea {
+ anchors.fill: parent
+ anchors.margins: -4
+ cursorShape: Qt.PointingHandCursor
+ onClicked: panel.editing = !panel.editing
+ }
+ }
+ }
+
+ Item {
+ id: grid
+
+ readonly property int cols: 4
+ readonly property real gap: 8
+ readonly property real cell: (width - gap * (cols - 1)) / cols
+ readonly property real pitch: cell + gap
+ readonly property int rows: Math.ceil(Prefs.controls.length / cols)
+
+ width: panel.width
+ height: Math.max(0, rows * pitch - gap)
+
+ Repeater {
+ model: Prefs.controls
+
+ delegate: Item {
+ id: slot
+ required property string modelData
+ required property int index
+
+ width: grid.cell
+ height: grid.cell
+ x: (index % grid.cols) * grid.pitch
+ y: Math.floor(index / grid.cols) * grid.pitch
+ z: mover.pressed ? 1 : 0
+
+ ControlTile {
+ width: parent.width
+ height: parent.height
+ x: mover.pressed ? mover.dx : 0
+ y: mover.pressed ? mover.dy : 0
+ tileId: slot.modelData
+ sound: panel.sound
+ onOpenPage: page => panel.openDrawer(page)
+ }
+
+ // Edit mode only, and above the tile so it takes the press
+ // instead of the toggle. Offsets the tile while held; on
+ // release the tile lands in the slot under its centre.
+ MouseArea {
+ id: mover
+ anchors.fill: parent
+ enabled: panel.editing
+ preventStealing: true
+ cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor
+ property real sx: 0
+ property real sy: 0
+ property real dx: 0
+ property real dy: 0
+ onPressed: mouse => { sx = mouse.x; sy = mouse.y; dx = 0; dy = 0; }
+ onPositionChanged: mouse => { dx = mouse.x - sx; dy = mouse.y - sy; }
+ onReleased: {
+ const to = Controls.slotAt(slot.x + dx + width / 2, slot.y + dy + height / 2,
+ grid.pitch, grid.cols, Prefs.controls.length);
+ if (to !== slot.index)
+ Prefs.setControls(Controls.move(Prefs.controls, slot.index, to));
+ }
+ }
+
+ IconImage {
+ visible: panel.editing && !mover.pressed
+ anchors { top: parent.top; right: parent.right; margins: 3 }
+ implicitSize: 14
+ source: Quickshell.iconPath("window-close", true)
+
+ MouseArea {
+ anchors.fill: parent
+ anchors.margins: -4
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Prefs.setControls(Controls.hide(Prefs.controls, slot.modelData))
+ }
+ }
+ }
+ }
+ }
+
+ Flow {
+ width: panel.width
+ spacing: 6
+ visible: panel.editing && Controls.hidden(Prefs.controls).length > 0
+
+ Repeater {
+ model: Controls.hidden(Prefs.controls)
+
+ delegate: Rectangle {
+ id: chip
+ required property string modelData
+ width: chipRow.implicitWidth + 16
+ height: 28
+ radius: 14
+ color: Qt.alpha(Theme.surface, 0.5)
+
+ Row {
+ id: chipRow
+ anchors.centerIn: parent
+ spacing: 4
+
+ IconImage {
+ anchors.verticalCenter: parent.verticalCenter
+ implicitSize: 14
+ source: Quickshell.iconPath("list-add", true)
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: Controls.tile(chip.modelData).label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.text
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Prefs.setControls(Controls.show(Prefs.controls, chip.modelData))
+ }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/island/ControlsPanel.qml
+git commit -m "feat(island): controls grid with an edit mode
+
+Four columns, the visible list from Prefs. Edit mode puts a MouseArea
+over each tile that drags it and drops it into the slot under its
+centre, a × that hides it, and a strip of hidden tiles with a + each.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Wire it into the island and IPC
+
+**Files:**
+- Modify: `desktop/island/StatusPill.qml`
+- Modify: `desktop/island/Island.qml`
+- Modify: `desktop/shell.qml`
+
+- [ ] **Step 1: StatusPill**
+
+In `desktop/island/StatusPill.qml`:
+
+Update the top comment's first sentence to: `// Controls icon, volume icon, tray, layout flag. The controls and volume icons open their panels in this capsule; the tray is a plain tray; the flag switches layout on scroll and ignores clicks.` (keep the line wrapping of the file).
+
+Replace the signals and sizing block:
+
+```qml
+ signal openSound()
+ signal openDrawer()
+ // Emitted just before a tray menu opens; the menu cannot report its close.
+ signal menuShown()
+
+ expanded: panel === "sound"
+ restWidth: restRow.implicitWidth + 24
+ panelWidth: 320
+ panelHeight: soundPanel.implicitHeight + 28
+```
+
+with:
+
+```qml
+ signal openSound()
+ signal openControls()
+ signal openDrawer()
+ // A drawer page, from a control tile's right-click.
+ signal openPage(string page)
+ // Emitted just before a tray menu opens; the menu cannot report its close.
+ signal menuShown()
+
+ expanded: panel !== ""
+ restWidth: restRow.implicitWidth + 24
+ panelWidth: 320
+ panelHeight: (panel === "controls" ? controlsPanel.implicitHeight : soundPanel.implicitHeight) + 28
+
+ // Edit mode never outlives its panel.
+ onPanelChanged: controlsPanel.editing = false
+```
+
+As the first child of `restRow`, before the volume `IconImage`, add:
+
+```qml
+ IconImage {
+ anchors.verticalCenter: parent.verticalCenter
+ implicitSize: 18
+ source: Quickshell.iconPath("preferences-system", true)
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: pill.openControls()
+ }
+ }
+```
+
+In `SoundPanel { ... }` change `opacity: pill.expanded ? 1 : 0` to `opacity: pill.panel === "sound" ? 1 : 0`.
+
+After the `SoundPanel` block add:
+
+```qml
+ ControlsPanel {
+ id: controlsPanel
+ x: 14
+ y: 14
+ width: pill.panelWidth - 28
+ sound: pill.sound
+ opacity: pill.panel === "controls" ? 1 : 0
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: 160 } }
+ onOpenDrawer: page => pill.openPage(page)
+ }
+```
+
+- [ ] **Step 2: Island**
+
+In `desktop/island/Island.qml`:
+
+Change the comment `// "" or "sound".` to `// "", "sound" or "controls".`
+
+After the `onPanelChanged` block add:
+
+```qml
+ // The keybind's way in: reveal, and open or close the controls panel.
+ // Closing goes through onPanelChanged, which starts the hide timer when
+ // the pointer is elsewhere.
+ function toggleControls() {
+ hideTimer.stop();
+ shown = true;
+ panel = panel === "controls" ? "" : "controls";
+ }
+```
+
+In `StatusPill { ... }` add after `onOpenDrawer`:
+
+```qml
+ onOpenControls: win.toggleControls()
+ onOpenPage: page => { win.panel = ""; win.drawer.show(page); }
+```
+
+- [ ] **Step 3: shell.qml**
+
+In `desktop/shell.qml` give the island an id:
+
+```qml
+ Island {
+ id: island
+ sound: soundModule.service
+ drawer: drawer
+ }
+```
+
+and after the `drawer` `IpcHandler` add:
+
+```qml
+ // The bare SUPER release bind reaches this:
+ // qs -p <this dir> ipc call island controls
+ IpcHandler {
+ target: "island"
+ function controls() { island.toggleControls(); }
+ }
+```
+
+Editing `shell.qml` is also what makes the running shell re-index `island/` and `desktop/`, so the new files resolve (AGENTS.md, Reloading).
+
+- [ ] **Step 4: Check the running shell's log**
+
+The user's `desktop` shell hot-reloads on save. Do not start another `qs` from a tool call. Read its log:
+
+Run: `qs log -p ~/Programming/GIT/quickshell/desktop 2>&1 | tail -40`
+Expected: a reload line after the edit and no `ReferenceError`, `TypeError`, `is not a type` or `Cannot assign` warnings mentioning `Prefs`, `Controls`, `ControlTile`, `ControlsPanel`, `StatusPill` or `Island`.
+
+If `qs log` reports no running instance, ask the user to run `./reload.sh desktop` from their terminal, then read the log again.
+
+Then exercise the IPC path and the store without a screenshot:
+
+Run: `qs -p ~/Programming/GIT/quickshell/desktop ipc call island controls; sleep 0.5; qs -p ~/Programming/GIT/quickshell/desktop ipc call island controls; qs log -p ~/Programming/GIT/quickshell/desktop 2>&1 | tail -10`
+Expected: both calls return with no error, no new warnings in the log. (`sleep` in a single foreground command is fine; a separate tool call cannot hold it.)
+
+Run: `ls ~/.local/state/quickshell/desktop.json 2>&1`
+Expected: `No such file or directory`: opening the panel must not write the file.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/island/StatusPill.qml desktop/island/Island.qml desktop/shell.qml
+git commit -m "feat(island): controls panel in the status pill, island IPC
+
+A settings icon in the status pill opens the controls panel in the same
+capsule the sound panel uses. Right-click on a tile closes the panel and
+opens its drawer page. The island IPC target reveals the island and
+toggles the panel, for the SUPER release bind.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: The SUPER release bind
+
+**Files:**
+- Modify: `~/.config/hypr/sections/keybindings.lua` (live config, not in this repo; commit nothing for it here)
+
+- [ ] **Step 1: Add the bind**
+
+After the `Desktop: Drawer` bind (the `mainMod .. " + Return"` line) add:
+
+```lua
+-- Bare SUPER, pressed and released alone: the island's controls panel. A key
+-- pressed in between cancels a release bind, so SUPER+<key> is unaffected.
+hl.bind("SUPER + Super_L", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call island controls"), { release = true, description = "Desktop: Controls" })
+```
+
+- [ ] **Step 2: Reload and confirm it registered**
+
+Run: `hyprctl reload && hyprctl binds -j | python3 -c 'import json,sys; print([b for b in json.load(sys.stdin) if "island controls" in b.get("arg","")])'`
+Expected: one entry with `"release": true` and `"key": "Super_L"`. If the list is empty, check `hyprctl configerrors`.
+
+- [ ] **Step 3: User checks the bind (ask, do not screenshot)**
+
+Ask the user to try, in order, and report each:
+
+1. Press and release SUPER alone: panel opens; again: it closes.
+2. `SUPER+c` on a window, `SUPER+k`, `SUPER+Return`: none also opens the panel.
+3. `SUPER` + left drag to move a window, `SUPER` + right drag to resize.
+4. `SUPER` + scroll to change workspace.
+
+If 3 or 4 opens the panel, replace the bind with:
+
+```lua
+hl.bind(mainMod .. " + q", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call island controls"), { description = "Desktop: Controls" })
+```
+
+and `hyprctl reload`. Record which way it went for Task 9.
+
+---
+
+### Task 8: Visual check with the user
+
+No code. Ask the user to check, and fix anything reported before moving on:
+
+- The settings icon sits left of the volume icon; clicking it opens a 4×2 grid, seven tiles, labels readable.
+- Each toggle flips its state and its fill: wifi, bluetooth, DND, presentation, auto-lock (lit means auto-lock on).
+- Output and mic: the bar matches the volume, muted greys it, click mutes, scroll steps 5%.
+- Right-click on each tile opens the right drawer page and closes the panel.
+- Edit mode: drag a tile to another slot, hide one with ×, bring it back with +, hide all (only the strip remains), leave edit mode with the pencil and with Escape.
+- After an edit, `cat ~/.local/state/quickshell/desktop.json` shows the list; `./reload.sh desktop` from the user's terminal keeps the order.
+
+---
+
+### Task 9: Docs
+
+**Files:**
+- Modify: `desktop/README.md`
+- Modify: `AGENTS.md`
+
+- [ ] **Step 1: README**
+
+In `desktop/README.md`, in the island section, add a paragraph:
+
+```
+The status pill's settings icon, or SUPER pressed and released alone, opens
+the controls panel: wifi, bluetooth, DND, presentation, auto-lock, output and
+mic. Click toggles, right-click opens the tile's drawer page, scroll on the
+audio tiles changes volume. The pencil enters edit mode: drag to reorder, ×
+to hide, + to show. The visible tiles are kept in
+`$XDG_STATE_HOME/quickshell/desktop.json` (`Prefs.qml`), written only when
+they change. Tests: `node island/test_controls.js`.
+```
+
+Use the bind that Task 7 settled on; if it fell back, say `SUPER+q` instead of "SUPER pressed and released alone".
+
+- [ ] **Step 2: AGENTS.md**
+
+Add per-component notes for what the build established. At minimum:
+
+```
+- **`FileView.setText` creates missing parent directories.** Checked while
+ planning the preferences file, so `Prefs.qml` has no mkdir step.
+ `Quickshell.stateDir` is a per-shell hashed directory
+ (`by-shell/<hash>`), not `~/.local/state/quickshell`, which is why the
+ path is built from `XDG_STATE_HOME`.
+```
+
+and one line on the SUPER release bind's outcome from Task 7 (whether SUPER+drag and SUPER+scroll cancelled it on 0.56.2, or forced the fallback). Add any other trap Tasks 6 to 8 hit. Update the `desktop/` line in "What this is" to mention the controls panel.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/README.md AGENTS.md
+git commit -m "docs: the controls panel, its preferences file and bind
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```