aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-07 18:42:17 +0200
committerDanilo M. <danix@danix.xyz>2026-10-07 18:42:17 +0200
commitd9783e91a4b98f14e02a3d7f947ab6617cb96047 (patch)
tree3e858480f56b576ed27319322b21a08412fb9d69 /docs
parentd4996abb0f75657f877a858a93afdd1eb9763bd9 (diff)
downloadquickshell-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')
-rw-r--r--docs/superpowers/plans/2026-10-07-island-core.md1462
-rw-r--r--docs/superpowers/specs/2026-10-07-island-core-design.md15
2 files changed, 1471 insertions, 6 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.
diff --git a/docs/superpowers/specs/2026-10-07-island-core-design.md b/docs/superpowers/specs/2026-10-07-island-core-design.md
index 93af743..0110432 100644
--- a/docs/superpowers/specs/2026-10-07-island-core-design.md
+++ b/docs/superpowers/specs/2026-10-07-island-core-design.md
@@ -83,9 +83,11 @@ instead, split into equal segments, one per active kind:
| location | `Theme.blue` | geoclue running |
| screen | `Theme.red` | a PipeWire node whose `media.name` matches `xdph-streaming` or `gsr-default` |
-The detection rules are those of `waybar-theme-udt/bin/privacy_dots.sh`. Mic
-and screen use `Quickshell.Services.Pipewire` instead of `pw-dump`; camera and
-location have no binding and stay as a short poll.
+The detection rules are those of `waybar-theme-udt/bin/privacy_dots.sh`,
+ported to `island/privacy.sh` and polled every 2s. Quickshell 0.3.1's `PwNode`
+exposes no running state, which is what tells a capturing source from an idle
+one, so mic and screen go through `pw-dump` like camera and location go
+through `fuser` and `pgrep`.
**Revealed.** The pointer enters the sliver's strip. The two pills slide down
and split apart from it in one continuous move: they start overlapping at the
@@ -126,12 +128,13 @@ time, coloured by state as waybar's `vms_dots.sh` did:
| State | Colour |
| --- | --- |
| running | `Theme.green` |
-| paused, idle | `Theme.yellow` |
-| pmsuspended | `Theme.accent` |
+| paused, blocked (virsh's idle), shutting down | `Theme.yellow` |
+| pmsuspended (`Virsh` calls it suspended) | `Theme.accent` |
| saved (managed save) | `Theme.blue` |
| crashed | `Theme.red` |
-Shut-off VMs get no dot, and with no VM running there are no dots at all.
+A shut-off VM gets no dot unless it carries a managed save; with every VM
+plainly shut off there are no dots at all.
States come from `desktop/modules/vm/Virsh.qml`, whose lifecycle event stream
already runs whether the drawer is open or not.