diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-07 18:42:17 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-07 18:42:17 +0200 |
| commit | d9783e91a4b98f14e02a3d7f947ab6617cb96047 (patch) | |
| tree | 3e858480f56b576ed27319322b21a08412fb9d69 /docs/superpowers/plans/2026-10-07-island-core.md | |
| parent | d4996abb0f75657f877a858a93afdd1eb9763bd9 (diff) | |
| download | quickshell-d9783e91a4b98f14e02a3d7f947ab6617cb96047.tar.gz quickshell-d9783e91a4b98f14e02a3d7f947ab6617cb96047.zip | |
docs(plan): island core implementation plan
Twelve tasks from pure logic to waybar removal. The spec is corrected on
two points found while reading the code: Quickshell's PwNode has no running
state, so mic and screen detection poll pw-dump like the waybar script did,
and the VM dot rule now agrees with its own table on saved VMs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-10-07-island-core.md')
| -rw-r--r-- | docs/superpowers/plans/2026-10-07-island-core.md | 1462 |
1 files changed, 1462 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-07-island-core.md b/docs/superpowers/plans/2026-10-07-island-core.md new file mode 100644 index 0000000..fb5beab --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-island-core.md @@ -0,0 +1,1462 @@ +# Island Core 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 top-centre island on DP-1, hidden behind a sliver, with a clock pill and a status pill that slide out of it, a sound panel, and waybar removed. + +**Architecture:** One fixed-size overlay `PanelWindow` inside the `desktop/` process; pills are animated rectangles inside it and the input mask follows them. Pure logic lives in `desktop/island/Logic.js`, tested with node. Data comes from existing services (`Service.qml` for sound, `Virsh.qml` for VMs) plus two small new singletons (`Privacy`, `Keyboard`). + +**Tech Stack:** Quickshell 0.3.1 (QML, `Quickshell.Hyprland`, `Quickshell.Services.SystemTray`, `Quickshell.Widgets`), sh + jq + pw-dump + fuser, node for the JS check. + +**Spec:** `docs/superpowers/specs/2026-10-07-island-core-design.md` + +--- + +## Rules for whoever executes this + +Read `AGENTS.md` first. The ones that bite here: + +- **Never start `qs` from a tool call** and never run `reload.sh`. The user + reloads (`./reload.sh desktop` or `./reload.sh desktop window-switcher`) + from their terminal; then read the log with + `qs log -p ~/Programming/GIT/quickshell/desktop 2>&1 | grep -v dbus.properties | tail -30` + and check `pgrep -cx qs` did not grow. +- `pkill -x`, never `pkill -f`. +- New files in a directory are invisible to a running shell until a file that + imports that directory changes. Editing `desktop/shell.qml` in the same task + covers that; the user reload covers the rest. +- Hot reload does not follow symlinks: after touching `shared/`, the user + reloads every component that links it. +- Every new source file starts with the GPLv2 header used across 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. +``` + + (`#` comment form for shell scripts, after the shebang.) The code blocks + below omit it for brevity; add it to every file. +- Lint every new or edited QML file with + `/usr/lib64/qt6/bin/qmllint <file> 2>&1 | grep -iE "error|syntax" | grep -v import` + and expect no output. Import warnings are normal (qmllint does not know + Quickshell's modules). +- Commits are GPG-signed (default config), messages explain why, and end with + `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. +- Modesty (`~/Programming/GIT/GITHUB/Modesty`) is GPLv3: read for ideas only. + +## File map + +| File | Responsibility | +| --- | --- | +| `desktop/island/Logic.js` | pure functions: icon names, colour roles, event parsing | +| `desktop/island/test_logic.js` | node check for Logic.js | +| `shared/Workspaces.qml` | workspace number -> icon name, shared | +| `desktop/Workspaces.qml`, `window-switcher/Workspaces.qml` | symlinks to it | +| `desktop/island/privacy.sh` | one line `mic cam loc scr` of 0/1 | +| `desktop/island/Privacy.qml` | singleton polling privacy.sh | +| `desktop/island/Keyboard.qml` | singleton: main keyboard's layout, switching | +| `desktop/island/assets/flag-it.svg`, `burger.svg` | layout flags | +| `desktop/island/Capsule.qml` | one animated pill shell | +| `desktop/island/ClockPill.qml` | date, time, VM dots, flick content | +| `desktop/island/StatusPill.qml` | volume icon, tray, flag, sound panel host | +| `desktop/island/SoundPanel.qml` | output/input sliders and mutes, drawer link | +| `desktop/island/Island.qml` | the window, sliver, reveal/hide, flick, panel state | +| `desktop/shell.qml` | instantiates Island | + +--- + +### Task 1: Logic.js and its check + +**Files:** +- Create: `desktop/island/Logic.js` +- Create: `desktop/island/test_logic.js` + +- [ ] **Step 1: Write the failing check** + +`desktop/island/test_logic.js`: + +```js +// Run: node desktop/island/test_logic.js +// Logic.js is a QML library, so its .pragma line is stripped before node +// evaluates it. + +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/Logic.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const L = new Function(src + + "\nreturn { volumeIcon, vmRole, flagFor, privacyRoles, parseOpenWindow };")(); + +// Volume: muted or zero is muted, then thirds. +assert.strictEqual(L.volumeIcon(0.5, true), "audio-volume-muted"); +assert.strictEqual(L.volumeIcon(0, false), "audio-volume-muted"); +assert.strictEqual(L.volumeIcon(0.2, false), "audio-volume-low"); +assert.strictEqual(L.volumeIcon(0.5, false), "audio-volume-medium"); +assert.strictEqual(L.volumeIcon(0.66, false), "audio-volume-high"); +assert.strictEqual(L.volumeIcon(1.2, false), "audio-volume-high"); + +// VM dots: Theme property names, "" for no dot. +assert.strictEqual(L.vmRole({ state: "running" }), "green"); +assert.strictEqual(L.vmRole({ state: "paused" }), "yellow"); +assert.strictEqual(L.vmRole({ state: "blocked" }), "yellow"); +assert.strictEqual(L.vmRole({ state: "shutting down" }), "yellow"); +assert.strictEqual(L.vmRole({ state: "suspended" }), "accent"); +assert.strictEqual(L.vmRole({ state: "crashed" }), "red"); +assert.strictEqual(L.vmRole({ state: "shut off", saved: true }), "blue"); +assert.strictEqual(L.vmRole({ state: "shut off", saved: false }), ""); +assert.strictEqual(L.vmRole({ state: "unknown" }), ""); +assert.strictEqual(L.vmRole({}), ""); + +// Flags: wb-lang's mapping, an unknown layout is the English one, an empty +// read is Italian (the default keymap here). +assert.strictEqual(L.flagFor("Italian"), "flag-it.svg"); +assert.strictEqual(L.flagFor("English (US)"), "burger.svg"); +assert.strictEqual(L.flagFor("German"), "burger.svg"); +assert.strictEqual(L.flagFor(""), "flag-it.svg"); + +// Privacy: fixed order mic, cam, loc, scr. +assert.deepStrictEqual(L.privacyRoles(false, false, false, false), []); +assert.deepStrictEqual(L.privacyRoles(true, false, false, true), ["green", "red"]); +assert.deepStrictEqual(L.privacyRoles(true, true, true, true), + ["green", "yellow", "blue", "red"]); + +// openwindow: "address,workspace,class,title"; the title may hold commas. +assert.deepStrictEqual(L.parseOpenWindow("55d1a0,3,kitty,a, b, c"), + { address: "55d1a0", workspace: "3", appClass: "kitty" }); +assert.deepStrictEqual(L.parseOpenWindow(""), + { address: "", workspace: "", appClass: "" }); + +console.log("ok"); +``` + +- [ ] **Step 2: Run it, expect failure** + +Run: `node desktop/island/test_logic.js` +Expected: `ENOENT ... Logic.js` + +- [ ] **Step 3: Write Logic.js** + +`desktop/island/Logic.js` (`.pragma library` is the first line, header after it, as in `assistant/Place.js`): + +```js +.pragma library + +// The default sink's icon name. Thresholds in thirds, as waybar's wb-icon did. +function volumeIcon(volume, muted) { + if (muted || volume <= 0) return "audio-volume-muted"; + if (volume < 0.33) return "audio-volume-low"; + if (volume < 0.66) return "audio-volume-medium"; + return "audio-volume-high"; +} + +// A VM's dot colour as a Theme property name, or "" for no dot. States are +// the names Virsh._stateName gives libvirt's enum: "blocked" is what virsh +// list prints as idle, "suspended" is pmsuspended. A shut-off VM only gets a +// dot when it carries a managed save, because starting it is then a restore. +function vmRole(vm) { + if (!vm || !vm.state) return ""; + if (vm.state === "shut off") return vm.saved ? "blue" : ""; + return ({ + "running": "green", + "paused": "yellow", + "blocked": "yellow", + "shutting down": "yellow", + "suspended": "accent", + "crashed": "red", + })[vm.state] || ""; +} + +// Flag image for a Hyprland active_keymap string, wb-lang's mapping. +function flagFor(keymap) { + if (!keymap || keymap.startsWith("Italian")) return "flag-it.svg"; + return "burger.svg"; +} + +// Active capture kinds as Theme property names, in a fixed order so the +// sliver's segments do not shuffle. +function privacyRoles(mic, cam, loc, scr) { + const out = []; + if (mic) out.push("green"); + if (cam) out.push("yellow"); + if (loc) out.push("blue"); + if (scr) out.push("red"); + return out; +} + +// Hyprland's openwindow event data: "address,workspace,class,title". The +// title is last and may contain commas, so only the first three fields are +// read. +function parseOpenWindow(data) { + const p = String(data || "").split(","); + return { address: p[0] || "", workspace: p[1] || "", appClass: p[2] || "" }; +} +``` + +- [ ] **Step 4: Run it, expect pass** + +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 "feat(island): pure logic for icons, dot colours and events + +The island's decisions that do not need a running shell: which volume icon, +which colour a VM dot takes, which flag a keymap shows, the order of the +privacy segments, and how an openwindow event splits. Kept out of QML so +node can check them. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 2: Shared workspace icons + +**Files:** +- Create: `shared/Workspaces.qml` +- Create symlinks: `desktop/Workspaces.qml`, `window-switcher/Workspaces.qml` +- Modify: `window-switcher/Windows.qml` (the `workspaceIcons` block and `workspaceIcon` function at the end of the file) + +- [ ] **Step 1: Create the singleton** + +`shared/Workspaces.qml`: + +```qml +pragma Singleton + +import Quickshell +import QtQuick + +// The desktop icon for each workspace, the eight waybar drew and the window +// switcher and the island show. Names, not paths: they are resolved through +// the active icon theme, so an icon theme switch changes them everywhere. +// A named workspace, or one past eight, has none, and callers fall back to +// its number. +Singleton { + readonly property var icons: ({ + 1: "web-browser", + 2: "text-editor", + 3: "utilities-terminal", + 4: "network-server", + 5: "document-edit", + 6: "applications-graphics", + 7: "dialog-messages", + 8: "input-gaming", + }) + + function icon(name) { + const n = parseInt(String(name), 10); + if (isNaN(n)) return ""; + return icons[n] ?? ""; + } +} +``` + +- [ ] **Step 2: Link it into both components** + +```bash +ln -s ../shared/Workspaces.qml desktop/Workspaces.qml +ln -s ../shared/Workspaces.qml window-switcher/Workspaces.qml +ls -l desktop/Workspaces.qml window-switcher/Workspaces.qml +``` + +Expected: both shown as `-> ../shared/Workspaces.qml`. + +- [ ] **Step 3: Make Windows.qml use it** + +In `window-switcher/Windows.qml`, replace the comment block starting +`// The desktop marker on a card is the same icon waybar's bar draws`, the +`readonly property var workspaceIcons: ({ ... })` block and the +`function workspaceIcon(workspace) { ... }` function with: + +```qml + // The desktop marker on a card is the same icon the island and the old + // waybar bar show for that workspace; the map lives in Workspaces.qml. + function workspaceIcon(workspace) { + return workspace ? Workspaces.icon(workspace.name) : ""; + } +``` + +- [ ] **Step 4: Lint** + +Run: `/usr/lib64/qt6/bin/qmllint shared/Workspaces.qml window-switcher/Windows.qml 2>&1 | grep -iE "error|syntax" | grep -v import` +Expected: no output. + +- [ ] **Step 5: User reload and check** + +Ask the user to run `./reload.sh window-switcher` and press Alt+Tab: the +desktop icons under each card are unchanged. Read +`qs log -p ~/Programming/GIT/quickshell/window-switcher 2>&1 | tail -20`, +expect no `ReferenceError`. + +- [ ] **Step 6: Commit** + +```bash +git add shared/Workspaces.qml desktop/Workspaces.qml window-switcher/Workspaces.qml window-switcher/Windows.qml +git commit -m "refactor: move the workspace icon map to a shared singleton + +The island's clock pill shows the same desktop icon the window switcher +does. One map, symlinked into both components like Theme.qml, rather than +a second copy that would drift. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 3: Privacy detection + +**Files:** +- Create: `desktop/island/privacy.sh` (executable) +- Create: `desktop/island/Privacy.qml` + +- [ ] **Step 1: Write the script** + +`desktop/island/privacy.sh`: + +```sh +#!/bin/sh +# (GPLv2 header here) +# +# One line, "mic cam loc scr", each 0 or 1: what is capturing right now. +# The rules are waybar-theme-udt's privacy_dots.sh. Mic and screen need +# pw-dump because a capturing source is told from an idle one by its node +# state, which Quickshell's PwNode does not expose. + +dump=$(pw-dump 2>/dev/null) + +mic=$(printf '%s' "$dump" | jq '[.[] + | select(.type == "PipeWire:Interface:Node") + | select(.info.props["media.class"] == "Audio/Source" + or .info.props["media.class"] == "Audio/Source/Virtual") + | select(.info.state == "running")] | if length > 0 then 1 else 0 end' 2>/dev/null) + +scr=$(printf '%s' "$dump" | jq '[.[] + | select((.info?.props?["media.name"]? // "") | test("^(xdph-streaming|gsr-default)"))] + | if length > 0 then 1 else 0 end' 2>/dev/null) + +cam=0 +for dev in /dev/video*; do + [ -e "$dev" ] && fuser "$dev" >/dev/null 2>&1 && { cam=1; break; } +done + +loc=0 +pgrep -x geoclue >/dev/null 2>&1 && loc=1 + +echo "${mic:-0} $cam $loc ${scr:-0}" +``` + +```bash +chmod +x desktop/island/privacy.sh +``` + +- [ ] **Step 2: Check it, idle then with the mic open** + +Run: `desktop/island/privacy.sh` +Expected: `0 0 0 0` (or `0 0 1 0` if geoclue runs). + +Run: `timeout 4 pw-record /dev/null & sleep 2; desktop/island/privacy.sh; wait` +Expected: first field `1`. + +- [ ] **Step 3: Write the singleton** + +`desktop/island/Privacy.qml`: + +```qml +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import "Logic.js" as Logic + +// What is capturing: microphone, camera, location, screen. Polled every 2s +// from privacy.sh, the same cadence class as the waybar module it replaces. +Singleton { + id: root + + property bool mic: false + property bool cam: false + property bool loc: false + property bool scr: false + + // Theme property names of the active kinds, fixed order. + readonly property var roles: Logic.privacyRoles(mic, cam, loc, scr) + + Process { + id: proc + command: ["sh", `${Quickshell.shellDir}/island/privacy.sh`] + stdout: StdioCollector { + onStreamFinished: { + const b = text.trim().split(" "); + // Anything but four fields is a broken run, not "nothing is + // capturing": keep the last good reading. + if (b.length !== 4) return; + root.mic = b[0] === "1"; + root.cam = b[1] === "1"; + root.loc = b[2] === "1"; + root.scr = b[3] === "1"; + } + } + } + + Timer { + interval: 2000 + running: true + repeat: true + triggeredOnStart: true + // A Process already running ignores running = true (AGENTS.md). + onTriggered: { proc.running = false; proc.running = true; } + } +} +``` + +- [ ] **Step 4: Lint** + +Run: `/usr/lib64/qt6/bin/qmllint desktop/island/Privacy.qml 2>&1 | grep -iE "error|syntax" | grep -v import` +Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/island/privacy.sh desktop/island/Privacy.qml +git commit -m "feat(island): privacy detection for mic, camera, location, screen + +Ported from waybar-theme-udt's privacy_dots.sh, because the island's sliver +takes over that indicator when waybar goes. pw-dump stays for mic and +screen: PwNode has no running state to tell capture from an idle source. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 4: Keyboard layout + +**Files:** +- Create: `desktop/island/Keyboard.qml` +- Create: `desktop/island/assets/flag-it.svg`, `desktop/island/assets/burger.svg` (copies) + +- [ ] **Step 1: Copy the flags** + +```bash +mkdir -p desktop/island/assets +cp ~/Programming/GIT/waybar-theme-udt/icons/flag-it.svg ~/Programming/GIT/waybar-theme-udt/icons/burger.svg desktop/island/assets/ +ls desktop/island/assets +``` + +Expected: `burger.svg flag-it.svg` + +- [ ] **Step 2: Write the singleton** + +`desktop/island/Keyboard.qml`: + +```qml +pragma Singleton + +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick +import "Logic.js" as Logic + +// The layout of whichever keyboard Hyprland marks main. Resolved per event, +// never cached: the same keyboard is a different device over USB and over +// its dongle, which is what broke wb-lang's hardcoded device name. +Singleton { + id: root + + property string keymap: "" + readonly property string flag: Logic.flagFor(keymap) + + readonly property string mainKeyboard: + "hyprctl devices -j | jq -r '.keyboards[] | select(.main) | .name'" + + Process { + id: query + running: true + command: ["sh", "-c", + "hyprctl devices -j | jq -r '.keyboards[] | select(.main) | .active_keymap'"] + stdout: StdioCollector { onStreamFinished: root.keymap = text.trim() } + } + + Process { id: switcher } + + function refresh() { + query.running = false; + query.running = true; + } + + // dir is "next" or "prev", from the caller's own literal, never user text. + function cycle(dir) { + switcher.command = ["sh", "-c", + `hyprctl switchxkblayout "$(${root.mainKeyboard})" ${dir}`]; + switcher.running = false; + switcher.running = true; + } + + Connections { + target: Hyprland + function onRawEvent(event) { + if (event.name === "activelayout") root.refresh(); + } + } +} +``` + +- [ ] **Step 3: Lint** + +Run: `/usr/lib64/qt6/bin/qmllint desktop/island/Keyboard.qml 2>&1 | grep -iE "error|syntax" | grep -v import` +Expected: no output. + +- [ ] **Step 4: Check the shell commands it runs** + +Run: `hyprctl devices -j | jq -r '.keyboards[] | select(.main) | .active_keymap'` +Expected: `Italian` (or the current layout). + +- [ ] **Step 5: Commit** + +```bash +git add desktop/island/Keyboard.qml desktop/island/assets +git commit -m "feat(island): keyboard layout of the main keyboard + +The flag follows whichever keyboard Hyprland marks main, looked up on every +activelayout event and every switch. wb-lang read a fixed device name and +went blank when the keyboard moved from its dongle to USB. Flags copied +from waybar-theme-udt, which goes away with waybar. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 5: Capsule + +**Files:** +- Create: `desktop/island/Capsule.qml` + +- [ ] **Step 1: Write it** + +```qml +import QtQuick +import ".." + +// One pill. Its size animates between the rest size and a panel size; the +// radius is clamped so it is a full capsule at rest and a rounded panel when +// open, and stays a valid shape mid-animation. +Rectangle { + id: cap + + property real restWidth: 0 + property real restHeight: 34 + property bool expanded: false + property real panelWidth: 0 + property real panelHeight: 0 + readonly property bool hovered: hover.hovered + + default property alias content: inner.data + + width: expanded ? panelWidth : restWidth + height: expanded ? panelHeight : restHeight + + // Duration-driven (velocity -1) and Immediate reversing, so a panel + // closed mid-open turns around from where it is instead of restarting. + Behavior on width { + SmoothedAnimation { velocity: -1; duration: 220; reversingMode: SmoothedAnimation.Immediate } + } + Behavior on height { + SmoothedAnimation { velocity: -1; duration: 220; reversingMode: SmoothedAnimation.Immediate } + } + + radius: Math.min(14, width / 2, height / 2) + color: Qt.alpha(Theme.base, 0.92) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + clip: true + + HoverHandler { id: hover } + + Item { id: inner; anchors.fill: parent } +} +``` + +- [ ] **Step 2: Lint** + +Run: `/usr/lib64/qt6/bin/qmllint desktop/island/Capsule.qml 2>&1 | grep -iE "error|syntax" | grep -v import` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/island/Capsule.qml +git commit -m "feat(island): the animated capsule both pills are made of + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 6: Clock pill + +**Files:** +- Create: `desktop/island/ClockPill.qml` + +- [ ] **Step 1: Write it** + +```qml +import Quickshell +import Quickshell.Widgets +import QtQuick +import ".." +import "../modules/vm" +import "Logic.js" as Logic + +// Date and time at rest, with one dot per VM that is not plainly shut off. +// During a flick: the workspace's desktop icon, the app's icon and its name. +Capsule { + id: pill + + property bool flicking: false + property string flickWs: "" + property string flickApp: "" + + readonly property var entry: flickApp ? DesktopEntries.heuristicLookup(flickApp) : null + readonly property string wsIcon: Workspaces.icon(flickWs) + + restWidth: (flicking ? flickRow.implicitWidth : restRow.implicitWidth) + 28 + + SystemClock { id: clock; precision: SystemClock.Minutes } + + Row { + id: restRow + anchors.centerIn: parent + spacing: 10 + opacity: pill.flicking ? 0 : 1 + Behavior on opacity { NumberAnimation { duration: 150 } } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDateTime(clock.date, "ddd dd/MM") + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDateTime(clock.date, "HH:mm") + font { family: Theme.fontFamily; pixelSize: Theme.fontSize; weight: Font.DemiBold } + color: Theme.text + } + Repeater { + model: Virsh.names.filter(n => Logic.vmRole(Virsh.vms[n]) !== "") + delegate: Rectangle { + required property string modelData + anchors.verticalCenter: parent.verticalCenter + width: 8 + height: 8 + radius: 4 + color: Theme[Logic.vmRole(Virsh.vms[modelData])] + } + } + } + + Row { + id: flickRow + anchors.centerIn: parent + spacing: 8 + opacity: pill.flicking ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 150 } } + + IconImage { + anchors.verticalCenter: parent.verticalCenter + visible: pill.wsIcon !== "" + implicitSize: 18 + source: pill.wsIcon ? Quickshell.iconPath(pill.wsIcon, true) : "" + } + // A named workspace, or one past eight, has no icon: its number. + Text { + anchors.verticalCenter: parent.verticalCenter + visible: pill.wsIcon === "" + text: pill.flickWs + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; weight: Font.DemiBold } + color: Theme.accent + } + IconImage { + anchors.verticalCenter: parent.verticalCenter + visible: pill.flickApp !== "" + implicitSize: 18 + source: pill.flickApp + ? Quickshell.iconPath(pill.entry?.icon || pill.flickApp, true) : "" + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: pill.flickApp !== "" + text: pill.entry?.name || pill.flickApp + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: Theme.text + } + } +} +``` + +- [ ] **Step 2: Lint** + +Run: `/usr/lib64/qt6/bin/qmllint desktop/island/ClockPill.qml 2>&1 | grep -iE "error|syntax" | grep -v import` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/island/ClockPill.qml +git commit -m "feat(island): clock pill with VM dots and flick content + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 7: Sound panel + +**Files:** +- Create: `desktop/island/SoundPanel.qml` + +- [ ] **Step 1: Write it** + +```qml +import Quickshell +import Quickshell.Widgets +import QtQuick +import ".." +import "Logic.js" as Logic + +// Output and input level with mute, and the way into the drawer's sound +// page. Device pickers and per-app volume stay on that page. +Column { + id: panel + + // The sound module's Service: sink, source, tracked by PwObjectTracker. + required property QtObject sound + signal openDrawer() + + spacing: 12 + + Repeater { + model: [{ label: "Output", input: false }, { label: "Input", input: true }] + + delegate: Row { + id: row + required property var modelData + readonly property var audio: + (modelData.input ? panel.sound.source : panel.sound.sink)?.audio ?? null + + width: panel.width + spacing: 10 + + IconImage { + id: muteIcon + anchors.verticalCenter: parent.verticalCenter + implicitSize: 18 + source: Quickshell.iconPath(row.modelData.input + ? (row.audio?.muted ? "microphone-sensitivity-muted" : "audio-input-microphone") + : Logic.volumeIcon(row.audio?.volume ?? 0, row.audio?.muted ?? false), true) + + MouseArea { + anchors.fill: parent + enabled: row.audio !== null + cursorShape: Qt.PointingHandCursor + onClicked: row.audio.muted = !row.audio.muted + } + } + + Rectangle { + id: track + anchors.verticalCenter: parent.verticalCenter + width: row.width - muteIcon.width - pct.width - row.spacing * 2 + height: 8 + radius: 4 + color: Theme.surface + + Rectangle { + height: parent.height + radius: parent.radius + width: parent.width * Math.min(1, row.audio?.volume ?? 0) + color: row.audio?.muted ? Theme.red : Theme.accent + opacity: row.audio?.muted ? 0.5 : 1 + } + + MouseArea { + anchors.fill: parent + enabled: row.audio !== null + preventStealing: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => set(mouse.x) + onPositionChanged: mouse => { if (pressed) set(mouse.x); } + function set(x) { + row.audio.volume = Math.max(0, Math.min(1, x / width)); + } + } + } + + Text { + id: pct + anchors.verticalCenter: parent.verticalCenter + width: 44 + horizontalAlignment: Text.AlignRight + text: !row.audio ? "—" : row.audio.muted ? "muted" + : Math.round(row.audio.volume * 100) + "%" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: row.audio?.muted ? Theme.red : Theme.subtext + } + } + } + + Text { + anchors.right: parent.right + text: "Sound settings ›" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: drawerLink.containsMouse ? Theme.accent : Theme.subtext + + MouseArea { + id: drawerLink + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: panel.openDrawer() + } + } +} +``` + +- [ ] **Step 2: Lint** + +Run: `/usr/lib64/qt6/bin/qmllint desktop/island/SoundPanel.qml 2>&1 | grep -iE "error|syntax" | grep -v import` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/island/SoundPanel.qml +git commit -m "feat(island): sound panel with output and input levels + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 8: Status pill + +**Files:** +- Create: `desktop/island/StatusPill.qml` + +- [ ] **Step 1: Write it** + +```qml +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.SystemTray +import QtQuick +import ".." +import "Logic.js" as Logic + +// Volume icon, tray, layout flag. The volume icon is the only way into the +// sound panel and has no other gesture; the tray is a plain tray; the flag +// switches layout on scroll and ignores clicks. +Capsule { + id: pill + + required property QtObject sound + // The island window, which tray menus are positioned against. + required property QtObject window + property string panel: "" + + signal openSound() + signal openDrawer() + + expanded: panel === "sound" + restWidth: restRow.implicitWidth + 24 + panelWidth: 320 + panelHeight: soundPanel.implicitHeight + 28 + + Row { + id: restRow + anchors.centerIn: parent + spacing: 12 + opacity: pill.expanded ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 120 } } + + IconImage { + anchors.verticalCenter: parent.verticalCenter + implicitSize: 18 + source: Quickshell.iconPath(Logic.volumeIcon(pill.sound.volume, pill.sound.muted), true) + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: pill.openSound() + } + } + + Repeater { + model: SystemTray.items.values + + delegate: IconImage { + id: trayIcon + required property SystemTrayItem modelData + anchors.verticalCenter: parent.verticalCenter + implicitSize: 16 + source: modelData.icon + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: mouse => { + const item = trayIcon.modelData; + const wantsMenu = mouse.button === Qt.RightButton || item.onlyMenu; + if (!wantsMenu) { item.activate(); return; } + if (!item.hasMenu) return; + const p = trayIcon.mapToItem(null, 0, trayIcon.height + 6); + item.display(pill.window, p.x, p.y); + } + } + } + } + + Image { + anchors.verticalCenter: parent.verticalCenter + width: 18 + height: 18 + sourceSize: Qt.size(18, 18) + source: Qt.resolvedUrl("assets/" + Keyboard.flag) + + WheelHandler { + onWheel: event => Keyboard.cycle(event.angleDelta.y > 0 ? "prev" : "next") + } + } + } + + SoundPanel { + id: soundPanel + x: 14 + y: 14 + width: pill.panelWidth - 28 + sound: pill.sound + opacity: pill.expanded ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 160 } } + onOpenDrawer: pill.openDrawer() + } +} +``` + +- [ ] **Step 2: Lint** + +Run: `/usr/lib64/qt6/bin/qmllint desktop/island/StatusPill.qml 2>&1 | grep -iE "error|syntax" | grep -v import` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/island/StatusPill.qml +git commit -m "feat(island): status pill with volume, tray and layout flag + +The tray makes the island the session's StatusNotifier host, which waybar +was until now. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 9: Island window, wired into the shell + +**Files:** +- Create: `desktop/island/Island.qml` +- Modify: `desktop/shell.qml` (imports; `SoundModule {}` in the modules list; add `Island`) + +- [ ] **Step 1: Write the window** + +`desktop/island/Island.qml`: + +```qml +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick +import ".." +import "Logic.js" as Logic + +// The island: a sliver at the top centre of DP-1 that the two pills slide +// out of. The window is a fixed overlay strip that never resizes; the pills +// move inside it and the input mask follows them, so the rest of the strip +// is click-through. +PanelWindow { + id: win + + required property QtObject sound // the sound module's Service + required property QtObject drawer // Drawer, for the sound page link + + screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] + anchors { top: true; left: true; right: true } + implicitHeight: 360 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-island" + WlrLayershell.keyboardFocus: panel !== "" ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + + // "" or "sound". + property string panel: "" + + // --- reveal and hide ------------------------------------------------- + + readonly property bool hovered: sliverHit.containsMouse || clock.hovered || status.hovered + property bool shown: false + + onHoveredChanged: { + if (hovered) { hideTimer.stop(); shown = true; } + else if (panel === "") hideTimer.restart(); + } + onPanelChanged: { + if (panel !== "") keys.forceActiveFocus(); + else if (!hovered) hideTimer.restart(); + } + + Timer { + id: hideTimer + interval: 400 + onTriggered: if (!win.hovered && win.panel === "") win.shown = false + } + + // 0 hidden, 1 revealed. One amount drives the split, so an interrupted + // reveal turns around from wherever it is. + property real reveal: shown ? 1 : 0 + Behavior on reveal { NumberAnimation { duration: 280; easing.type: Easing.OutCubic } } + + // The clock pill also drops alone for a flick while the island is hidden. + property real clockDrop: (shown || flicking) ? 1 : 0 + Behavior on clockDrop { NumberAnimation { duration: 280; easing.type: Easing.OutCubic } } + + // --- flick ----------------------------------------------------------- + + // Hyprland reports the initial state as events at startup. + property bool armed: false + Timer { interval: 1500; running: true; onTriggered: win.armed = true } + + property bool flicking: false + property string flickWs: "" + property string flickApp: "" + + function flick(ws, app) { + flickWs = ws; + flickApp = app; + flicking = true; + flickTimer.restart(); + } + + Timer { id: flickTimer; interval: 1500; onTriggered: win.flicking = false } + + // activeToplevel settles a moment after workspacev2 arrives. + Timer { + id: settle + interval: 60 + onTriggered: { + const ws = Hyprland.focusedWorkspace; + const top = Hyprland.activeToplevel; + const app = top && top.workspace === ws ? (top.wayland?.appId ?? "") : ""; + win.flick(ws?.name ?? "", app); + } + } + + Connections { + target: Hyprland + function onRawEvent(event) { + if (!win.armed) return; + if (event.name === "workspacev2") { + settle.restart(); + } else if (event.name === "openwindow") { + const w = Logic.parseOpenWindow(event.data); + if (!w.workspace.startsWith("special:")) win.flick(w.workspace, w.appClass); + } + } + } + + // --- dismissal ------------------------------------------------------- + + HyprlandFocusGrab { + windows: [win] + active: win.panel !== "" + onCleared: win.panel = "" + } + + // Key events reach a focused item, not the window (AGENTS.md). + Item { + id: keys + Keys.onEscapePressed: win.panel = "" + } + + // --- geometry -------------------------------------------------------- + + readonly property real gap: 10 + readonly property real top: 8 + readonly property real cx: width / 2 + + mask: Region { + item: sliverHit + Region { item: clock; intersection: Intersection.Combine } + Region { item: status; intersection: Intersection.Combine } + } + + // The visible sliver: accent at rest, the capture colours while + // something captures, one equal segment per active kind. + Row { + id: sliver + x: win.cx - width / 2 + y: 0 + width: 120 + height: 4 + + Repeater { + model: Privacy.roles.length ? Privacy.roles : ["accent"] + delegate: Rectangle { + required property string modelData + width: sliver.width / Math.max(1, Privacy.roles.length) + height: sliver.height + radius: 2 + color: Theme[modelData] + } + } + } + + MouseArea { + id: sliverHit + x: win.cx - width / 2 + y: 0 + width: 220 + height: 10 + hoverEnabled: true + } + + // Both pills start overlapped at the sliver, above the screen edge, and + // travel down and apart. At reveal 1 the clock ends gap/2 left of the + // centre and the status gap/2 right of it; the status pill's left edge + // stays put while its panel widens it. + ClockPill { + id: clock + x: win.cx - width / 2 - (win.gap / 2 + width / 2) * win.reveal + y: -height + (win.top + height) * win.clockDrop + opacity: Math.min(1, win.clockDrop * 2) + flicking: win.flicking + flickWs: win.flickWs + flickApp: win.flickApp + } + + StatusPill { + id: status + x: win.cx - width / 2 + (win.gap / 2 + width / 2) * win.reveal + y: -height + (win.top + height) * win.reveal + opacity: Math.min(1, win.reveal * 2) + sound: win.sound + window: win + panel: win.panel + onOpenSound: win.panel = win.panel === "sound" ? "" : "sound" + onOpenDrawer: { win.panel = ""; win.drawer.show("sound"); } + } +} +``` + +- [ ] **Step 2: Wire it into shell.qml** + +In `desktop/shell.qml`: + +Add after `import "modules/vm"`: + +```qml +import "island" +``` + +In the `modules: [ ... ]` list, change `SoundModule {},` to: + +```qml + SoundModule { id: soundModule }, +``` + +Add after the closing `}` of `Drawer { ... }`: + +```qml + // The island on DP-1. It reads the sound module's service rather than + // binding PipeWire a second time. + Island { + sound: soundModule.service + drawer: drawer + } +``` + +- [ ] **Step 3: Lint** + +Run: `/usr/lib64/qt6/bin/qmllint desktop/island/Island.qml desktop/shell.qml 2>&1 | grep -iE "error|syntax" | grep -v import` +Expected: no output. + +- [ ] **Step 4: User reload, log check** + +Ask the user to run `./reload.sh desktop`. Then: + +Run: `qs log -p ~/Programming/GIT/quickshell/desktop 2>&1 | grep -v dbus.properties | grep -iE "error|warn|island" | tail -20` +Expected: `Configuration Loaded`, no `ReferenceError`, `TypeError` or `is not a type` mentioning island files. + +Run: `pgrep -cx qs` +Expected: the same count as before the reload. + +Run: `hyprctl layers | grep -A1 quickshell-island` +Expected: one layer on DP-1. + +- [ ] **Step 5: User visual pass** + +Ask the user to check, on DP-1, and report: + +1. Sliver visible at top centre; pointer on it reveals both pills with the + split; leaving hides after ~0.4s; brushing past does not flicker. +2. Switching workspace with the island hidden drops the clock pill with + desktop icon, app icon, app name, then it retracts. Opening an app: same. +3. `pw-record /dev/null` in a terminal turns the sliver green within 2s; + Ctrl-C returns it to accent. +4. Volume icon follows mute and level; click opens the sound panel; both + sliders and mutes work; "Sound settings ›" opens the drawer sound page; + Escape and an outside click close the panel. +5. Tray icons present; right click opens menus. +6. Scrolling the flag switches layout and the flag follows; the layout + keybind also updates it. +7. `udt-accent <wallpaper>` recolours the island live. + +Fix what fails before committing. Motion feel (durations, easing) is tuned +here with the user, not guessed. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/island/Island.qml desktop/shell.qml +git commit -m "feat(island): the island window on DP-1 + +A fixed overlay strip that never resizes: the pills animate inside it and +the input mask follows their rectangles, so resizing a layer surface during +a morph never happens. Hidden behind a sliver that also carries the privacy +colours; the clock pill drops alone for a workspace or app-open flick. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 10: Blur rule (live config, confirm first) + +**Files:** +- Modify: `~/.config/hypr/sections/decorations.lua` (outside the repo) + +- [ ] **Step 1: Ask the user before editing the live Hyprland config.** + +- [ ] **Step 2: Add after the `blur-window-switcher` rule** + +```lua +-- Frosted glass for the quickshell island. +hl.layer_rule({ + name = "blur-island", + match = { namespace = "^(quickshell-island)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, +}) +``` + +- [ ] **Step 3: Apply and check** + +Run: `hyprctl reload && hyprctl layers | grep -A1 quickshell-island` +Expected: reload ok, layer still listed. The user confirms the pills look +frosted. Nothing to commit in this repo. + +--- + +### Task 11: Remove waybar + +**Files:** +- Modify: `~/.config/hypr/sections/autostart.lua` (live, outside the repo) +- Modify: `appearance/Icons.qml:293-306` +- Modify: `~/Programming/GIT/unified-desktop-theme/install.sh:137-161` and `:315` +- Delete: `desktop/modules/mail/waybar-mail.sh` +- Modify: `desktop/Drawer.qml` (exclusion comment and mode) + +- [ ] **Step 1: Ask the user to confirm waybar removal before touching the live config.** + +- [ ] **Step 2: autostart.lua** + +Delete these three lines: + +```lua + -- waybar-theme-udt: status on DP-3, desktop on DP-1. One process, two + -- bars. See ~/Programming/GIT/waybar-theme-udt + hl.exec_cmd("waybar -c ~/.config/waybar-udt/config.jsonc -s ~/.config/waybar-udt/styles/style.css") +``` + +The comment block above it (lines 7-13, about launching scratchpads before +waybar for wlr/taskbar) loses its reason: delete it too, after reading it to +confirm it is only about waybar. + +Then stop the running bar: `pkill -x waybar; pgrep -cx waybar` (expect `0`). + +- [ ] **Step 3: Icons.qml** + +Replace lines 293-306 (the comment, `udtRepo`, `waybarRepo`, and the +`execDetached` call) with: + +```qml + // The SDDM power icons resolve icon names from gsettings when udt-accent + // generates them, not when they run, so they are regenerated here, + // chained after the gsettings write they read. + readonly property string udtRepo: `${root.home}/Programming/GIT/unified-desktop-theme` + + function applyIcon(name) { + Quickshell.execDetached(["sh", "-c", + `gsettings set org.gnome.desktop.interface icon-theme "$1" || exit; ` + + `[ -e ~/.cache/udt/wpaper ] && ${JSON.stringify(root.udtRepo)}/bin/udt-accent ~/.cache/udt/wpaper`, + "sh", name]); +``` + +(The rest of `applyIcon`, from `const files = [qt6File, qt5File];`, is +unchanged.) Lint `appearance/Icons.qml`. + +- [ ] **Step 4: unified-desktop-theme install.sh (separate repo, separate commit)** + +Delete the waybar block, from `# waybar. The bar is waybar-theme-udt` through +the closing `fi` of `if [ -x "$waybar_repo/install.sh" ]`, and the line: + +```sh +pgrep -x waybar >/dev/null 2>&1 && killall -SIGUSR2 waybar 2>/dev/null && reloaded="$reloaded waybar" +``` + +Update the comment above the reload lines to drop "waybar" from +"Writing the configs is not enough: waybar, kitty and Hyprland reread on a +signal". Check README.md in that repo for waybar mentions and update them. +Run `bash -n install.sh` (expect no output). Commit in that repo: + +```bash +git -C ~/Programming/GIT/unified-desktop-theme commit -am "chore: waybar is gone, stop theming and reloading it + +The quickshell island replaced both waybar bars and reads the palette +itself. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +- [ ] **Step 5: Delete the mail script** + +```bash +git rm desktop/modules/mail/waybar-mail.sh +grep -rn "waybar-mail" . --include=*.qml --include=*.md --include=*.sh +``` + +Expected: only README/doc mentions, handled in Task 12. + +- [ ] **Step 6: Drawer exclusion** + +In `desktop/Drawer.qml`, the window says `exclusionMode: ExclusionMode.Normal` +with a comment about waybar's zone. With no exclusive zone on DP-1, Normal +and Ignore now place it identically. Replace the comment and keep the mode: + +```qml + // Normal, so a bar with an exclusive zone, if one ever returns, + // pushes the drawer below it. Today nothing reserves space on + // DP-1 and the drawer starts at the top edge; the island floats + // over it. + exclusionMode: ExclusionMode.Normal +``` + +Ask the user to reload and open the drawer: it starts at the top edge and +the island sliver stays on top of it. + +- [ ] **Step 7: Commit** + +```bash +git add -A appearance/Icons.qml desktop/Drawer.qml desktop/modules/mail +git commit -m "chore: remove waybar + +The island replaces both bars: clock and VM dots in the clock pill, volume, +tray, layout flag and privacy in the status pill and its sliver, mail count +in the drawer. The icon switch no longer regenerates or reloads waybar, and +the mail pill's watcher script goes with it. Workspaces and the taskbar have +no persistent view by decision: the flick and the expose cover them. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 12: Documentation + +**Files:** +- Modify: `AGENTS.md`, `README.md`, `desktop/README.md`, `desktop/modules/mail/README.md`, `window-switcher/README.md`, `desktop/modules/status/README.md`, `appearance/README.md` +- Create: `desktop/island/README.md` + +- [ ] **Step 1: AGENTS.md** + +Rewrite "What this is": the components are no longer all independent. +`desktop/` is the integrated process hosting the drawer and the island (and +later the control panel); window-switcher, keybinds, notifications, +assistant and appearance remain separate shells. Add `island` under +`desktop/` in the component list. In the Blur section replace the waybar +measurement paragraph's present tense with history (waybar is gone) and add +the `quickshell-island` namespace and `blur-island` rule. Add a gotcha for +anything found while building that cost time (for example a Quickshell API +that behaved unlike its docs). Mention `shared/Workspaces.qml` next to +`Theme.qml` in the Theme section's symlink paragraph. + +- [ ] **Step 2: desktop/island/README.md** + +Describe what the island shows and does (the Clock pill, Status pill, +States sections of the spec, condensed), how to run its check +(`node desktop/island/test_logic.js`), where privacy detection comes from and +why it polls, and the Theming rule. End with nothing else; no Development +Approach section here, that is the top-level README's. + +- [ ] **Step 3: The other READMEs** + +`grep -n -i waybar` each file listed above and rewrite every present-tense +waybar reference: the mail README's waybar wiring section goes; the status +README's `statusctl presentation watch` waybar consumer is noted as gone +(the command stays, the drawer's status page and later the control panel +toggle presentation); window-switcher's README says its desktop icons come +from `Workspaces.qml`; the top-level README lists `island` under `desktop/`. + +- [ ] **Step 4: Commit** + +```bash +git add AGENTS.md README.md desktop/README.md desktop/island/README.md desktop/modules/mail/README.md window-switcher/README.md desktop/modules/status/README.md appearance/README.md +git commit -m "docs: the island, and waybar gone + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +## Known ceilings (deliberate, not bugs) + +- A tray menu is its own popup; moving the pointer onto it leaves the pills, + so the island may hide while the menu stays open. Fix only if it bothers + the user (hold the island while `hasMenu` popups are open needs a menu-close + signal Quickshell does not give `display()`). +- Privacy is a 2s poll, as waybar's was 3s. +- A workspace switch on DP-3 flicks the island on DP-1; the island is one + instance on one screen by design. +- Presentation mode has no at-rest indicator and no one-click toggle until + the control panel (sub-project 2); the drawer's status page has it. |
