aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-09-14-desktop-shell.md2817
1 files changed, 2817 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-14-desktop-shell.md b/docs/superpowers/plans/2026-09-14-desktop-shell.md
new file mode 100644
index 0000000..b767919
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-14-desktop-shell.md
@@ -0,0 +1,2817 @@
+# Desktop shell 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:** One quickshell component, `desktop/`, presenting a left-side drawer that hosts sound, mail, vm and appearance as modules, absorbing three existing components.
+
+**Architecture:** A `ShellRoot` holds a keepalive window, an `IpcHandler` and a registry list of modules. Each module is a directory under `desktop/modules/` exposing a `Module.qml` that declares a tile, a page, both or neither, plus `alwaysActive` governing whether its background service runs while the drawer is closed. The drawer is a single `PanelWindow` whose content is either the grid or one full-height page.
+
+**Tech Stack:** Quickshell 0.3.1, Qt 6 QML. `QtQuick.Controls` for `ScrollView`, plain `QtQuick` `Flow` for the tile grid. `Quickshell.Services.Pipewire`, `Quickshell.Services.Mpris`, `Quickshell.Io` for `Process`/`FileView`/`IpcHandler`, `Quickshell.Wayland` for layershell properties.
+
+**Spec:** `docs/superpowers/specs/2026-09-14-desktop-shell-design.md`
+
+---
+
+## Before you start
+
+Read `AGENTS.md` at the repo root. Four things there will cost you hours if you
+skip them:
+
+- **A quickshell config with no visible window exits.** No error, no message, it
+ just quits. Every task that runs the shell depends on the keepalive
+ `PanelWindow` from Task 2 existing.
+- **A detached `qs` does not survive a tool call.** Starting one with `&`,
+ `nohup` or `setsid -f` and checking `pgrep` later always reports it dead,
+ whether or not the config is sound. Start it so the harness owns the process,
+ read the log, and do not conclude anything from a later `pgrep`.
+- **The process is `qs`, not `quickshell`.** `pkill -x quickshell` matches
+ nothing and exits successfully, so every "stopped" is a lie and restarts
+ stack. Use `pkill -x qs`, then `pgrep -cx qs` and check the number.
+- **`pkill -f` kills the caller**, because the agent's own working directory is
+ in its command line. Always `-x`.
+
+Verification in this plan is therefore: start the shell in the foreground with a
+timeout, read what it printed, and kill by exact name. Anything visual is for
+the user to look at, not for a screenshot.
+
+## File structure
+
+```
+desktop/
+ shell.qml ShellRoot: keepalive, IpcHandler, module registry
+ Drawer.qml the PanelWindow: notification area, grid, page stack
+ Module.qml the contract: name, icon, alwaysActive, tile, page, activate()
+ Tile.qml one grid tile: icon, label, state line, click
+ Page.qml page chrome: header, back arrow, content slot
+ Button.qml moved from mail-overview (byte-identical in vm-manager)
+ Theme.qml symlink -> ../shared/Theme.qml
+ README.md
+ modules/
+ sound/
+ SoundModule.qml
+ Service.qml PipeWire bindings, PwObjectTracker, show() logic
+ Player.qml moved from volume-osd, singleton, unchanged
+ Osd.qml the transient OSD window, keeps its own namespace
+ TransportButton.qml moved from volume-osd, unchanged
+ SoundTile.qml
+ SoundPage.qml
+ mail/
+ MailModule.qml
+ Accounts.qml moved from mail-overview, singleton, unchanged
+ MailTile.qml
+ MailPage.qml MailPanel's content, rehomed
+ mail-notify.sh moved
+ waybar-mail.sh moved
+ test-mail-notify.sh moved
+ vm/
+ VmModule.qml
+ Virsh.qml moved from vm-manager, singleton, unchanged
+ Stat.qml moved from vm-manager, unchanged
+ VmTile.qml
+ VmPage.qml VmPanel's content, rehomed
+ appearance/
+ AppearanceModule.qml tile only, activate() calls the external shell
+ qmldir singleton registrations
+```
+
+Deleted when their contents have moved: `volume-osd/`, `mail-overview/`,
+`vm-manager/`.
+
+### A note on singletons
+
+`Player.qml`, `Accounts.qml`, `Virsh.qml` and `Theme.qml` are all
+`pragma Singleton`. In the existing components they work because each is in the
+component's root directory, which quickshell scans. Moving them into
+`modules/<name>/` subdirectories means they are no longer in the root, so they
+need a `qmldir` registering them. Task 1 creates it, and every later task that
+moves a singleton adds its line.
+
+---
+
+## Task 1: Skeleton that runs
+
+**Files:**
+- Create: `desktop/shell.qml`
+- Create: `desktop/qmldir`
+- Create: `desktop/Theme.qml` (symlink)
+
+- [ ] **Step 1: Create the directory and the Theme symlink**
+
+The symlink, not a copy. `shared/Theme.qml` is the one real file and the other
+components link to it; a copy here would be the drift the shared file exists to
+prevent.
+
+```bash
+mkdir -p desktop/modules
+ln -s ../shared/Theme.qml desktop/Theme.qml
+ls -l desktop/Theme.qml
+```
+
+Expected: `desktop/Theme.qml -> ../shared/Theme.qml`
+
+- [ ] **Step 2: Write the qmldir**
+
+`desktop/qmldir`:
+
+```
+singleton Theme 1.0 Theme.qml
+```
+
+- [ ] **Step 3: Write a shell that only holds itself open**
+
+`desktop/shell.qml`:
+
+```qml
+// 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.
+
+import Quickshell
+import Quickshell.Wayland
+
+ShellRoot {
+ // Quickshell exits once no window is visible, and this shell's drawer is
+ // closed most of the time. A 1x1 transparent window with an empty mask
+ // holds the process open without drawing anything or catching a click.
+ // See AGENTS.md: without it the shell loads, reports no error, and quits.
+ PanelWindow {
+ visible: true
+ implicitWidth: 1
+ implicitHeight: 1
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ mask: Region {}
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ }
+}
+```
+
+- [ ] **Step 4: Verify it loads and stays up**
+
+Run it in the foreground under a timeout, so the harness owns the process:
+
+```bash
+timeout 5 qs -p desktop 2>&1 | head -20
+```
+
+Expected: a line containing `Configuration Loaded`, no `QML` errors, and the
+command ending only when the timeout fires (exit 124). If it returns
+immediately with no error, the keepalive window is missing or malformed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/
+git commit -m "feat(desktop): skeleton shell with the keepalive window
+
+The window draws nothing and catches nothing; it exists because a
+quickshell config with no visible window exits silently, and this
+shell's drawer is closed most of the time."
+```
+
+---
+
+## Task 2: The module contract
+
+**Files:**
+- Create: `desktop/Module.qml`
+
+- [ ] **Step 1: Write Module.qml**
+
+Deliberately thin. Its value is being the one file to read to learn what a
+module is, not enforcement.
+
+`desktop/Module.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+
+// What a module declares to the drawer.
+//
+// A module provides a tile, a page, both, or neither. A tile with no page
+// calls activate() when clicked. A module with neither is a pure background
+// service. A module may also own windows outside the drawer entirely, as
+// sound does with its OSD.
+//
+// Nothing here enforces anything: a module is free to do something unusual.
+// This file is documentation with defaults.
+QtObject {
+ // Identifies the module to IPC: `ipc call drawer open <name>`.
+ required property string name
+
+ // Shown on the tile. A Nerd Font glyph.
+ property string icon: ""
+
+ // Label under the icon. Defaults to the name, capitalised.
+ property string label: name.charAt(0).toUpperCase() + name.slice(1)
+
+ // Whether this module's background service runs while the drawer is
+ // closed. The drawer is closed most of the time, so this is what decides
+ // whether the shell is cheap to run all session. It governs the service
+ // only: pages are lazily loaded either way.
+ property bool alwaysActive: false
+
+ // Rendered inside the tile, below the icon: a short state line. Null for
+ // a tile that says nothing beyond its label.
+ property Component tileContent: null
+
+ // The full-height page behind the tile. Null means the tile is
+ // fire-and-forget and activate() is called instead.
+ property Component page: null
+
+ // What a tile with no page does when clicked.
+ function activate() {}
+}
+```
+
+- [ ] **Step 2: Verify it parses**
+
+`Module.qml` is not instantiated yet, so loading the shell will not touch it.
+Check it compiles on its own.
+
+Use the Qt 6 binary by its full path: bare `qmllint` on this machine resolves
+to `/usr/lib64/qt5/bin/qmllint`, which rejects Qt 6 syntax and reports errors
+that have nothing to do with the file.
+
+```bash
+/usr/lib64/qt6/bin/qmllint desktop/Module.qml 2>&1 | head -20
+```
+
+Expected: no output, or warnings only about the unresolved `Theme` import,
+which qmllint cannot see without the config's import path. Errors naming a
+syntax problem are real failures.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/Module.qml
+git commit -m "feat(desktop): the module contract
+
+A module provides a tile, a page, both or neither, plus alwaysActive,
+which governs the background service rather than the page: the drawer is
+closed most of the time and three of four modules have background work."
+```
+
+---
+
+## Task 3: Tile and Page chrome
+
+**Files:**
+- Create: `desktop/Tile.qml`
+- Create: `desktop/Page.qml`
+
+- [ ] **Step 1: Write Tile.qml**
+
+Sized by the `Flow` that holds it, so the width comes from outside. The minimum
+tile width of 180px lives in `Drawer.qml`, not here.
+
+`desktop/Tile.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+
+Rectangle {
+ id: tile
+
+ property string icon: ""
+ property string label: ""
+ property Component content: null
+ property bool active: false
+ signal clicked
+
+ implicitHeight: 96
+ radius: 12
+ color: area.containsMouse
+ ? Qt.alpha(Theme.accent, 0.22)
+ : Qt.alpha(Theme.surface, active ? 0.7 : 0.35)
+ border.width: 1
+ border.color: active ? Qt.alpha(Theme.accent, 0.5) : Qt.alpha(Theme.text, 0.08)
+
+ Behavior on color { ColorAnimation { duration: 120 } }
+
+ Column {
+ anchors {
+ left: parent.left; right: parent.right
+ verticalCenter: parent.verticalCenter
+ leftMargin: 14; rightMargin: 14
+ }
+ spacing: 6
+
+ Text {
+ text: tile.icon
+ font { family: Theme.fontFamily; pixelSize: 22 }
+ color: tile.active ? Theme.accent : Theme.text
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: tile.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+
+ // The state line. A module with nothing to say leaves this null and
+ // the tile is icon and label only.
+ Loader {
+ width: parent.width
+ active: tile.content !== null
+ sourceComponent: tile.content
+ }
+ }
+
+ MouseArea {
+ id: area
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: tile.clicked()
+ }
+}
+```
+
+- [ ] **Step 2: Write Page.qml**
+
+The header and back arrow, with the module's own content below. The content
+scrolls: mail with several accounts and vm with several VMs both exceed the
+drawer height.
+
+`desktop/Page.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+import QtQuick.Controls
+
+Item {
+ id: page
+
+ property string title: ""
+ default property alias content: holder.data
+ signal back
+
+ Item {
+ id: header
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ height: 44
+
+ Rectangle {
+ id: backBtn
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 32; height: 32; radius: 16
+ color: backArea.containsMouse ? Qt.alpha(Theme.accent, 0.22) : "transparent"
+
+ Text {
+ anchors.centerIn: parent
+ text: ""
+ font { family: Theme.fontFamily; pixelSize: 14 }
+ color: Theme.text
+ }
+
+ MouseArea {
+ id: backArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: page.back()
+ }
+ }
+
+ Text {
+ anchors { left: backBtn.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: page.title
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2; bold: true }
+ color: Theme.text
+ }
+ }
+
+ Rectangle {
+ id: rule
+ anchors { top: header.bottom; left: parent.left; right: parent.right }
+ height: 1
+ color: Qt.alpha(Theme.text, 0.12)
+ }
+
+ ScrollView {
+ anchors { top: rule.bottom; left: parent.left; right: parent.right; bottom: parent.bottom }
+ anchors.topMargin: 12
+ clip: true
+ contentWidth: availableWidth
+
+ Item {
+ id: holder
+ width: parent.width
+ implicitHeight: childrenRect.height
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Verify both parse by instantiating them**
+
+Temporarily add to `desktop/shell.qml`, inside `ShellRoot`, after the keepalive
+window:
+
+```qml
+ // scratch: remove before committing
+ property Component _t: Tile { icon: "x"; label: "Test" }
+ property Component _p: Page { title: "Test" }
+```
+
+Then:
+
+```bash
+timeout 5 qs -p desktop 2>&1 | head -20
+```
+
+Expected: `Configuration Loaded`, no errors naming `Tile.qml` or `Page.qml`.
+Remove the two scratch lines afterwards.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/Tile.qml desktop/Page.qml
+git commit -m "feat(desktop): tile and page chrome
+
+The page body scrolls because mail with several accounts and vm with
+several VMs both exceed the drawer height; the tile grid deliberately
+does not, being fixed at a 3x3 ceiling."
+```
+
+---
+
+## Task 4: The drawer
+
+**Files:**
+- Create: `desktop/Drawer.qml`
+- Modify: `desktop/shell.qml`
+
+- [ ] **Step 1: Write Drawer.qml**
+
+The layout decisions from the spec are all here: left-anchored on DP-1, 600px,
+`ExclusionMode.Normal`, reserved top, `Flow` grid with a 180px minimum tile
+width, page replacing the whole content.
+
+`desktop/Drawer.qml`:
+
+```qml
+// 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.
+
+import Quickshell
+import Quickshell.Wayland
+import QtQuick
+
+Scope {
+ id: root
+
+ // The modules the drawer hosts, in grid order. Set from shell.qml.
+ property list<QtObject> modules
+
+ // Waybar runs on this screen and the launcher sits at its left end, so
+ // the drawer belongs here. Falls back to the first screen when this
+ // monitor is not connected, so the drawer is never invisible.
+ property string monitor: "DP-1"
+
+ property bool open: false
+
+ // Which module's page is showing. Empty means the grid.
+ property string page: ""
+
+ readonly property var screenObj:
+ Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+
+ // A plain `list<QtObject>` is indexed directly; it is not an
+ // ObjectModel, so there is no `.values` to go through.
+ readonly property QtObject current: {
+ for (let i = 0; i < root.modules.length; i++)
+ if (root.modules[i].name === root.page) return root.modules[i];
+ return null;
+ }
+
+ function show(name) {
+ root.page = name ?? "";
+ root.open = true;
+ }
+
+ function close() {
+ root.open = false;
+ // Reset to the grid: a panel that reopens somewhere unexpected is
+ // worse than one extra click.
+ root.page = "";
+ }
+
+ function toggle(name) {
+ if (root.open && (name ?? "") === root.page) root.close();
+ else root.show(name);
+ }
+
+ // Clicking a tile: a module with a page opens it, one without acts.
+ function activate(mod) {
+ if (mod.page) root.page = mod.name;
+ else { mod.activate(); root.close(); }
+ }
+
+ LazyLoader {
+ active: root.open
+
+ PanelWindow {
+ id: win
+ screen: root.screenObj
+
+ anchors { top: true; left: true; right: true; bottom: true }
+ color: "transparent"
+
+ // Normal, not Ignore: waybar claims an exclusive zone at the top
+ // of this screen, so respecting it puts the drawer below the bar
+ // without this file knowing the bar's height. The drawer is
+ // reached from the bar, so the bar must stay visible and
+ // clickable while it is open.
+ exclusionMode: ExclusionMode.Normal
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "quickshell-desktop"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ // The click-outside catcher. It covers the whole surface, and the
+ // drawer sits on top of it swallowing its own clicks.
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root.close()
+ }
+
+ // Keys reach a focused item, never the window: setting
+ // keyboardFocus above is necessary but not sufficient, and
+ // Keys.onEscapePressed on a PanelWindow never fires. See AGENTS.md.
+ Item {
+ anchors.fill: parent
+ focus: true
+ Keys.onEscapePressed: {
+ if (root.page) root.page = "";
+ else root.close();
+ }
+ }
+
+ Rectangle {
+ id: panel
+ anchors { top: parent.top; left: parent.left; bottom: parent.bottom }
+ width: 600
+ color: Qt.alpha(Theme.base, 0.72)
+ topRightRadius: 14
+ bottomRightRadius: 14
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.12)
+
+ // Clicks on the panel must not reach the catcher behind it.
+ MouseArea { anchors.fill: parent }
+
+ // --- grid view ---
+
+ Item {
+ anchors.fill: parent
+ anchors.margins: 16
+ visible: root.page === ""
+
+ // Reserved for the notification engine. An empty Item that
+ // claims the space rather than a placeholder graphic: the
+ // grid has to sit where it will sit once notifications
+ // arrive, or the layout is tuned against a position that
+ // does not survive.
+ Item {
+ id: notifications
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ anchors.bottom: grid.top
+ anchors.bottomMargin: 16
+ }
+
+ // Fixed, never scrolled. Three columns at 600px with a
+ // 180px minimum; tiles wrap and add rows, ceiling 3x3.
+ Flow {
+ id: grid
+ anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
+ spacing: 10
+
+ Repeater {
+ model: root.modules
+
+ Tile {
+ required property QtObject modelData
+ // Three columns, or fewer if the panel is
+ // narrower than three 180px tiles allow.
+ width: (grid.width - 2 * grid.spacing) / 3
+ icon: modelData.icon
+ label: modelData.label
+ content: modelData.tileContent
+ onClicked: root.activate(modelData)
+ }
+ }
+ }
+ }
+
+ // --- page view ---
+
+ Loader {
+ anchors.fill: parent
+ anchors.margins: 16
+ active: root.current !== null
+ sourceComponent: root.current?.page ?? null
+
+ // The page enters from the right: the one piece of motion
+ // in the design, and what makes the drawer read as one
+ // surface rather than a window swapping contents.
+ opacity: active ? 1 : 0
+ x: active ? 16 : 60
+ Behavior on x { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
+ Behavior on opacity { NumberAnimation { duration: 160 } }
+ }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Wire it into the shell with no modules yet**
+
+Replace `desktop/shell.qml` with:
+
+```qml
+// 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.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+
+ShellRoot {
+ // Quickshell exits once no window is visible, and the drawer is closed
+ // most of the time. See AGENTS.md.
+ PanelWindow {
+ visible: true
+ implicitWidth: 1
+ implicitHeight: 1
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ mask: Region {}
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ }
+
+ Drawer {
+ id: drawer
+ modules: []
+ }
+
+ // The waybar launcher and the deep-link keybinds all reach this:
+ // qs -p <this dir> ipc call drawer open -> the grid
+ // qs -p <this dir> ipc call drawer open mail -> the mail page
+ IpcHandler {
+ target: "drawer"
+ function open(page: string) { drawer.show(page); }
+ function toggle(page: string) { drawer.toggle(page); }
+ function close() { drawer.close(); }
+ }
+}
+```
+
+- [ ] **Step 3: Verify the drawer opens**
+
+```bash
+timeout 8 qs -p desktop 2>&1 | head -20 &
+sleep 3
+qs -p desktop ipc call drawer open
+sleep 1
+qs -p desktop ipc call drawer close
+wait
+```
+
+Expected: `Configuration Loaded`, both `ipc call` commands exiting 0, and no
+QML errors. An empty 600px panel appearing on the left of DP-1 for one second
+is the visible result; ask the user to confirm it rather than screenshotting.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/Drawer.qml desktop/shell.qml
+git commit -m "feat(desktop): the drawer, with the top reserved
+
+Left of DP-1 because conky holds the right; ExclusionMode.Normal so the
+bar the drawer is reached from stays visible and clickable. The top is an
+empty Item claiming the space the notification engine will fill, so the
+grid already sits where it will sit once that lands."
+```
+
+---
+
+## Task 5: The appearance module
+
+The simplest module, and the one that proves a tile needs no page. Done first
+so the grid has something in it before the harder migrations.
+
+**Files:**
+- Create: `desktop/modules/appearance/AppearanceModule.qml`
+- Modify: `desktop/shell.qml`
+
+- [ ] **Step 1: Write the module**
+
+`desktop/modules/appearance/AppearanceModule.qml`:
+
+```qml
+// 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.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+// Fire and forget. The appearance shell stays a separate process: a wallpaper
+// picker needs more room than a 600px drawer, so this tile only opens it.
+Module {
+ id: mod
+
+ name: "appearance"
+ icon: ""
+ label: "Appearance"
+
+ function activate() {
+ proc.running = false;
+ proc.running = true;
+ }
+
+ property Process proc: Process {
+ command: ["qs", "-p", `${Quickshell.env("HOME")}/Programming/GIT/quickshell/appearance`,
+ "ipc", "call", "appearance", "wallpaper"]
+ }
+}
+```
+
+- [ ] **Step 2: Register it in the shell**
+
+A QML type is named by its file, so each module's file carries its own name
+rather than all four being `Module.qml`: four files of the same name in four
+directories would collide the moment two are imported together. Write the file
+from Step 1 as `desktop/modules/appearance/AppearanceModule.qml`, and the type
+is `AppearanceModule`.
+
+In `desktop/shell.qml`, add the directory import near the top, after the other
+imports:
+
+```qml
+import "modules/appearance"
+```
+
+and replace the `Drawer` block with:
+
+```qml
+ Drawer {
+ id: drawer
+ modules: [
+ AppearanceModule {},
+ ]
+ }
+```
+
+- [ ] **Step 3: Verify the tile appears and fires**
+
+```bash
+timeout 10 qs -p desktop 2>&1 | head -20 &
+sleep 3
+qs -p desktop ipc call drawer open
+sleep 5
+wait
+pgrep -cx qs
+```
+
+Expected: one tile labelled "Appearance" in the grid. Ask the user to click it
+and confirm the wallpaper picker opens and the drawer closes. `pgrep -cx qs`
+should report the number of shells actually running, which during development
+is the existing five plus this one.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/modules/appearance/ desktop/shell.qml
+git commit -m "feat(desktop): the appearance tile
+
+A tile with no page: appearance stays its own process because a wallpaper
+picker needs more room than a 600px drawer, so the tile only fires its
+existing IPC. This is the case the contract's activate() exists for."
+```
+
+---
+
+## Task 6: Move the sound module
+
+Three jobs currently live in `VolumeOsd.qml`: PipeWire tracking, the OSD
+surface, and the player transport. They split into `Service.qml`, `Osd.qml` and
+the page.
+
+**Files:**
+- Create: `desktop/modules/sound/SoundModule.qml`
+- Create: `desktop/modules/sound/Service.qml`
+- Create: `desktop/modules/sound/Osd.qml`
+- Create: `desktop/modules/sound/SoundTile.qml`
+- Create: `desktop/modules/sound/SoundPage.qml`
+- Move: `volume-osd/Player.qml` -> `desktop/modules/sound/Player.qml`
+- Move: `volume-osd/TransportButton.qml` -> `desktop/modules/sound/TransportButton.qml`
+- Modify: `desktop/qmldir`
+
+- [ ] **Step 1: Move the two files that need no change**
+
+```bash
+git mv volume-osd/Player.qml desktop/modules/sound/Player.qml
+git mv volume-osd/TransportButton.qml desktop/modules/sound/TransportButton.qml
+```
+
+- [ ] **Step 2: Register Player as a singleton**
+
+`Player.qml` is `pragma Singleton` and is no longer in the config root, so it
+needs a qmldir entry. Append to `desktop/qmldir`:
+
+```
+singleton Player 1.0 modules/sound/Player.qml
+```
+
+- [ ] **Step 3: Write the service**
+
+The PipeWire half of the old `VolumeOsd.qml`, with the two traps preserved
+verbatim in comment and code.
+
+`desktop/modules/sound/Service.qml`:
+
+```qml
+// 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.
+
+import Quickshell
+import Quickshell.Services.Pipewire
+import QtQuick
+
+// The PipeWire half of what used to be VolumeOsd.qml. Always active: the OSD
+// has to react to a volume keypress with no drawer open.
+Scope {
+ id: root
+
+ readonly property PwNode sink: Pipewire.defaultAudioSink
+ readonly property PwNode source: Pipewire.defaultAudioSource
+
+ readonly property real volume: sink?.audio?.volume ?? 0
+ readonly property bool muted: sink?.audio?.muted ?? false
+
+ // Which node changed last, and whether it was the input. The OSD draws
+ // this one; null means nothing to show.
+ property PwNode active: null
+ property bool isInput: false
+
+ // Keeping the nodes bound is what makes volume/muted actually update.
+ // Without the tracker the value reads once and goes stale.
+ PwObjectTracker { objects: [root.sink, root.source].filter(n => n !== null) }
+
+ signal changed()
+
+ // A node reports its initial volume while binding, before `ready` goes
+ // true, so the `ready` check alone suppresses the startup values. Nothing
+ // else may be swallowed: the next signal after that is the user's first
+ // keypress, and eating it costs the OSD its first appearance.
+ function show(node, input) {
+ if (!node?.ready || !node.audio) return;
+ root.active = node;
+ root.isInput = input;
+ root.changed();
+ }
+
+ function showTrack() {
+ if (!Player.active) return;
+ root.active = root.sink;
+ root.isInput = false;
+ root.changed();
+ }
+
+ Connections {
+ target: root.sink?.audio ?? null
+ function onVolumeChanged() { root.show(root.sink, false); }
+ function onMutedChanged() { root.show(root.sink, false); }
+ }
+
+ Connections {
+ target: root.source?.audio ?? null
+ function onVolumeChanged() { root.show(root.source, true); }
+ function onMutedChanged() { root.show(root.source, true); }
+ }
+
+ // A track change shows the OSD as well, so the row is not something you
+ // only see when you happen to touch the volume.
+ Connections {
+ target: Player.current ?? null
+ function onTrackTitleChanged() { if (Player.title) root.showTrack(); }
+ function onPlaybackStateChanged() { root.showTrack(); }
+ }
+}
+```
+
+- [ ] **Step 4: Write the OSD**
+
+The window half, keeping its namespace so the existing Hyprland blur rule needs
+no edit. The body is the old `VolumeOsd.qml` from its first `PanelWindow`
+onward, with `root.` reads redirected to the injected service.
+
+`desktop/modules/sound/Osd.qml`:
+
+```qml
+// 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.
+
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Services.Pipewire
+import QtQuick
+
+// The transient on-screen display. Unchanged in behaviour from volume-osd,
+// including its namespace, so the existing Hyprland blur rule still matches.
+Scope {
+ id: root
+
+ required property var service
+
+ // Milliseconds the OSD stays up after the last change.
+ property int timeout: 1500
+
+ property bool visibleNow: false
+
+ // Hovering freezes the countdown so the transport buttons can be clicked;
+ // leaving starts it again.
+ property bool hovered: false
+
+ Connections {
+ target: root.service
+ function onChanged() {
+ root.visibleNow = true;
+ hideTimer.restart();
+ }
+ }
+
+ Timer {
+ id: hideTimer
+ running: root.visibleNow && !root.hovered
+ interval: root.timeout
+ onTriggered: root.visibleNow = false
+ }
+
+ PanelWindow {
+ id: win
+
+ visible: root.visibleNow
+
+ readonly property PwNode node: root.service.active
+ readonly property real volume: node?.audio?.volume ?? 0
+ readonly property bool muted: node?.audio?.muted ?? false
+ readonly property bool isInput: root.service.isInput
+
+ // Bottom centre. Move the anchor to relocate.
+ anchors.bottom: true
+ margins.bottom: 120
+
+ // Grows to fit the track row; the volume-only size is unchanged.
+ implicitWidth: 360
+ implicitHeight: Player.active ? 150 : 72
+ color: "transparent"
+
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "quickshell-volume-osd"
+ // Still no keyboard focus: the transport buttons are pointer targets,
+ // and the OSD must never take keys from the window being typed in.
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 12
+ // Translucent so the compositor's blur shows through. The frosting
+ // itself is Hyprland's, applied by layerrule to this window's
+ // namespace: see the README.
+ color: Qt.alpha(Theme.base, 0.65)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.12)
+
+ HoverHandler {
+ onHoveredChanged: root.hovered = hovered
+ }
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: 16
+ spacing: 12
+
+ Loader {
+ active: Player.active
+ width: parent.width
+ sourceComponent: trackRow
+ }
+
+ Rectangle {
+ visible: Player.active
+ width: parent.width
+ height: 1
+ color: Qt.alpha(Theme.text, 0.1)
+ }
+
+ Row {
+ width: parent.width
+ spacing: 14
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 30
+ horizontalAlignment: Text.AlignHCenter
+ font.family: Theme.fontFamily
+ font.pixelSize: 24
+ color: win.muted ? Theme.red : Theme.accent
+ text: {
+ if (win.isInput) return win.muted ? "" : "";
+ if (win.muted || win.volume <= 0) return "";
+ return win.volume < 0.5 ? "" : "";
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - 30 - parent.spacing
+ spacing: 8
+
+ Item {
+ width: parent.width
+ height: label.implicitHeight
+
+ Text {
+ id: label
+ anchors.left: parent.left
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ color: Theme.subtext
+ text: win.isInput ? "Input" : "Output"
+ }
+
+ Text {
+ anchors.right: parent.right
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ color: Theme.text
+ text: win.muted ? "muted" : Math.round(win.volume * 100) + "%"
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 6
+ radius: 3
+ color: Theme.surface
+
+ Rectangle {
+ height: parent.height
+ radius: parent.radius
+ // Volume can exceed 1.0; the bar stops at full.
+ width: parent.width * Math.min(win.volume, 1)
+ color: win.muted ? Theme.red : Theme.accent
+ opacity: win.muted ? 0.5 : 1
+ Behavior on width { NumberAnimation { duration: 100 } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: trackRow
+
+ Row {
+ id: trackLine
+ // A Row sizes to its children, so the panel width has to be
+ // pushed in: the text column below subtracts from it.
+ width: parent ? parent.width : 0
+ spacing: 12
+
+ // Players that extract embedded art reuse one temp path, so the
+ // source carries a per-track suffix and caching is off.
+ Rectangle {
+ width: 46; height: 46; radius: 6
+ color: Qt.alpha(Theme.surface, 0.8)
+ clip: true
+
+ Image {
+ anchors.fill: parent
+ source: Player.artUrl
+ cache: false
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ visible: status === Image.Ready
+ }
+ Text {
+ anchors.centerIn: parent
+ visible: Player.artUrl === "" || parent.children[0].status !== Image.Ready
+ text: ""
+ font { family: Theme.fontFamily; pixelSize: 20 }
+ color: Theme.overlay
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ // Whatever the art and transport buttons leave: a fixed width
+ // here overflowed the panel and pushed `next` past its edge.
+ width: trackLine.width - 46 - transport.width - 2 * trackLine.spacing
+ spacing: 3
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: Player.title || "Nothing playing"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ color: Theme.text
+ }
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ visible: Player.artist !== ""
+ text: Player.artist
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+ }
+
+ Row {
+ id: transport
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+ TransportButton {
+ glyph: ""
+ enabled: Player.current?.canGoPrevious ?? false
+ onClicked: Player.current?.previous()
+ }
+ TransportButton {
+ glyph: Player.playing ? "" : ""
+ enabled: Player.current?.canTogglePlaying ?? false
+ onClicked: Player.current?.togglePlaying()
+ }
+ TransportButton {
+ glyph: ""
+ enabled: Player.current?.canGoNext ?? false
+ onClicked: Player.current?.next()
+ }
+ }
+ }
+ }
+}
+```
+
+Note: the glyphs above are written as escapes because the originals are Nerd
+Font private-use characters that do not survive copying through a plan
+document. When moving the file, take the glyph bytes from the original
+`volume-osd/VolumeOsd.qml` rather than retyping them, and check
+`git diff` shows no change to those literals.
+
+- [ ] **Step 5: Write the tile content and the page**
+
+`desktop/modules/sound/SoundTile.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+
+// The tile's state line: volume, or what is playing.
+Text {
+ required property var service
+
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ text: {
+ if (service.muted) return "muted";
+ const pct = Math.round(service.volume * 100) + "%";
+ return Player.active && Player.title ? `${pct} · ${Player.title}` : pct;
+ }
+}
+```
+
+`desktop/modules/sound/SoundPage.qml`:
+
+```qml
+// 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.
+
+import Quickshell
+import Quickshell.Services.Pipewire
+import QtQuick
+
+Column {
+ id: page
+
+ required property var service
+ signal back
+
+ spacing: 16
+
+ // Output and input, each with its own slider.
+ Repeater {
+ model: [
+ { label: "Output", node: page.service.sink },
+ { label: "Input", node: page.service.source },
+ ]
+
+ Column {
+ required property var modelData
+ readonly property var audio: modelData.node?.audio ?? null
+
+ width: page.width
+ spacing: 6
+
+ Item {
+ width: parent.width
+ implicitHeight: name.implicitHeight
+
+ Text {
+ id: name
+ anchors.left: parent.left
+ text: modelData.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ color: Theme.text
+ }
+
+ Text {
+ anchors.right: parent.right
+ text: !audio ? "—" : audio.muted ? "muted" : Math.round(audio.volume * 100) + "%"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: audio?.muted ? Theme.red : Theme.subtext
+ }
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: modelData.node?.description ?? modelData.node?.name ?? ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+
+ // Click or drag anywhere on the bar to set the level.
+ Rectangle {
+ width: parent.width
+ height: 8
+ radius: 4
+ color: Theme.surface
+
+ Rectangle {
+ height: parent.height
+ radius: parent.radius
+ width: parent.width * Math.min(audio?.volume ?? 0, 1)
+ color: audio?.muted ? Theme.red : Theme.accent
+ opacity: audio?.muted ? 0.5 : 1
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: audio !== null
+ onPositionChanged: mouse => set(mouse.x)
+ onPressed: mouse => set(mouse.x)
+ function set(x) {
+ if (audio) audio.volume = Math.max(0, Math.min(1, x / width));
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) }
+
+ // What is playing, with transport. Same Player singleton the OSD uses,
+ // so playerctld's duplicate is already filtered out by dbusName.
+ Column {
+ width: parent.width
+ spacing: 8
+ visible: Player.active
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: Player.title || "Nothing playing"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ color: Theme.text
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ visible: Player.artist !== ""
+ text: Player.artist
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+
+ Row {
+ spacing: 4
+ TransportButton {
+ glyph: ""
+ enabled: Player.current?.canGoPrevious ?? false
+ onClicked: Player.current?.previous()
+ }
+ TransportButton {
+ glyph: Player.playing ? "" : ""
+ enabled: Player.current?.canTogglePlaying ?? false
+ onClicked: Player.current?.togglePlaying()
+ }
+ TransportButton {
+ glyph: ""
+ enabled: Player.current?.canGoNext ?? false
+ onClicked: Player.current?.next()
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Write the module**
+
+`desktop/modules/sound/SoundModule.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+
+// Always active: the OSD must react to a volume keypress with no drawer open,
+// which is the whole reason this module's service cannot be lazy.
+Module {
+ id: mod
+
+ name: "sound"
+ icon: ""
+ label: "Sound"
+ alwaysActive: true
+
+ readonly property Service service: Service {}
+
+ // The OSD is this module's own window, outside the drawer entirely.
+ readonly property Osd osd: Osd { service: mod.service }
+
+ tileContent: Component {
+ SoundTile { service: mod.service }
+ }
+
+ page: Component {
+ Page {
+ title: "Sound"
+ SoundPage { width: parent.width; service: mod.service }
+ }
+ }
+}
+```
+
+- [ ] **Step 7: Register it and delete the old component**
+
+In `desktop/shell.qml`, add `import "modules/sound"` and put `SoundModule {}`
+first in the `modules` list, before `AppearanceModule {}`.
+
+Then remove what is now duplicated:
+
+```bash
+git rm volume-osd/VolumeOsd.qml volume-osd/shell.qml volume-osd/Theme.qml
+git mv volume-osd/README.md desktop/modules/sound/README.md
+rmdir volume-osd
+```
+
+- [ ] **Step 8: Verify the OSD still works and the page renders**
+
+```bash
+pkill -x qs
+pgrep -cx qs
+```
+
+Expected: `0`. If it is not zero, something is still running and later readings
+will be wrong.
+
+Then start only the new shell:
+
+```bash
+timeout 20 qs -p desktop 2>&1 | head -30
+```
+
+While it runs, ask the user to:
+1. Press a volume key and confirm the OSD appears bottom-centre as before.
+2. Run `qs -p desktop ipc call drawer open sound` and confirm the page shows
+ output and input with working sliders.
+
+Afterwards restart the other components the user still needs:
+
+```bash
+qs -p vm-manager &
+qs -p mail-overview &
+qs -p appearance &
+qs -p window-switcher &
+```
+
+Note these are detached and will not survive the tool call; they are for the
+user's session, so have the user start them, or leave them for the next login.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A desktop/modules/sound volume-osd desktop/shell.qml desktop/qmldir
+git commit -m "feat(desktop): move volume-osd in as the sound module
+
+VolumeOsd.qml did three jobs in one file: PipeWire tracking, the OSD
+surface and the player transport. They become Service, Osd and the page.
+The OSD keeps its namespace so the existing Hyprland blur rule still
+matches, and the service stays always-active because the OSD has to
+answer a keypress with no drawer open."
+```
+
+---
+
+## Task 7: Move the mail module
+
+The gentlest move: `Accounts.qml` is unchanged, `MailPanel.qml`'s body becomes
+the page, and the three scripts move with it.
+
+**Files:**
+- Move: `mail-overview/Accounts.qml` -> `desktop/modules/mail/Accounts.qml`
+- Move: `mail-overview/Button.qml` -> `desktop/Button.qml`
+- Move: the three scripts -> `desktop/modules/mail/`
+- Create: `desktop/modules/mail/MailModule.qml`
+- Create: `desktop/modules/mail/MailTile.qml`
+- Create: `desktop/modules/mail/MailPage.qml`
+- Modify: `desktop/qmldir`
+
+- [ ] **Step 1: Move the files that need no change**
+
+`Button.qml` is byte-identical in `mail-overview` and `vm-manager`; one copy
+moves to the shell root and the other is deleted in Task 8.
+
+```bash
+git mv mail-overview/Accounts.qml desktop/modules/mail/Accounts.qml
+git mv mail-overview/Button.qml desktop/Button.qml
+git mv mail-overview/mail-notify.sh desktop/modules/mail/mail-notify.sh
+git mv mail-overview/waybar-mail.sh desktop/modules/mail/waybar-mail.sh
+git mv mail-overview/test-mail-notify.sh desktop/modules/mail/test-mail-notify.sh
+```
+
+- [ ] **Step 2: Register Accounts as a singleton**
+
+Append to `desktop/qmldir`:
+
+```
+singleton Accounts 1.0 modules/mail/Accounts.qml
+```
+
+- [ ] **Step 3: Check the scripts for self-referential paths**
+
+The scripts may locate siblings relative to their own directory. Check before
+assuming the move is transparent:
+
+```bash
+grep -n 'dirname\|BASH_SOURCE\|\$0\|mail-overview' desktop/modules/mail/*.sh
+```
+
+If any line hardcodes `mail-overview`, update it to the new path. If they use
+`$(dirname "$0")` they are already correct.
+
+- [ ] **Step 4: Verify the test suite still passes**
+
+This is the only automated oracle in the whole project.
+
+```bash
+./desktop/modules/mail/test-mail-notify.sh
+```
+
+Expected: `16 passed, 0 failed`. If the count differs, the move broke
+something; fix before continuing.
+
+- [ ] **Step 5: Write the page**
+
+`MailPanel.qml`'s content, with the window chrome dropped and `root.` reads
+pointing at the page. The heartbeat `FileView` moves in unchanged, including
+its deliberate lack of `watchChanges`.
+
+`desktop/modules/mail/MailPage.qml`:
+
+```qml
+// 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.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Column {
+ id: page
+
+ signal close
+
+ // mail-watcher's heartbeat, written every 60s. Read once per open: the
+ // page is behind a Loader, so opening it rebuilds the FileView and reads
+ // the current file. Watch is deliberately not used, because the heartbeat
+ // is written by atomic replace (tmpfile + rename) and an inotify watch
+ // held on the old inode dies with it.
+ property bool watcherAlive: false
+ property int watcherDead: 0
+ property int watcherExpected: 0
+
+ // The same staleness rule as mail-watcher's heartbeat_is_healthy: dead
+ // threads or a heartbeat older than 300s mean the watcher needs a look.
+ // Backoff is healthy, so it never turns the dot.
+ function readHeartbeat(payload) {
+ page.watcherAlive = false;
+ page.watcherDead = 0;
+ page.watcherExpected = 0;
+
+ let data = null;
+ try { data = JSON.parse(payload); } catch (e) { return; }
+ if (!data || typeof data.ts !== "string") return;
+
+ const ts = Date.parse(data.ts);
+ if (isNaN(ts) || (Date.now() - ts) / 1000 > 300) return;
+
+ page.watcherAlive = true;
+ page.watcherDead = Number(data.dead) || 0;
+ page.watcherExpected = Number(data.expected) || 0;
+ }
+
+ readonly property color watcherColor:
+ !watcherAlive ? Theme.red
+ : watcherDead > 0 ? Theme.yellow
+ : Theme.green
+
+ readonly property string watcherText:
+ !watcherAlive ? "watcher not running"
+ : watcherDead > 0 ? `${watcherDead} folder(s) dead, check the log`
+ : `watcher ok · ${watcherExpected} folders`
+
+ spacing: 10
+
+ FileView {
+ id: heartbeat
+ path: `${Quickshell.env("HOME")}/.local/state/mail-watcher.heartbeat`
+ onLoaded: page.readHeartbeat(text())
+ onLoadFailed: page.readHeartbeat("")
+ }
+
+ Item {
+ width: parent.width
+ implicitHeight: totalText.implicitHeight
+
+ Text {
+ id: totalText
+ anchors.right: parent.right
+ // A dash rather than a possibly-wrong number while any account is
+ // still unknown.
+ text: Accounts.anyUnknown ? "—" : `${Accounts.total} unread`
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ }
+ }
+
+ Text {
+ visible: Accounts.error !== ""
+ width: parent.width
+ text: Accounts.error
+ color: Theme.red
+ wrapMode: Text.WordWrap
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 2
+ }
+
+ Repeater {
+ model: Accounts.accounts
+
+ Column {
+ required property var modelData
+ width: page.width
+ spacing: 4
+
+ Item {
+ width: parent.width
+ implicitHeight: 26
+
+ Rectangle {
+ id: dot
+ anchors.verticalCenter: parent.verticalCenter
+ width: 8; height: 8; radius: 4
+ // The account's own colour from the config. Per-account
+ // identity, not a palette.
+ color: modelData.color || Theme.accent
+ }
+
+ Text {
+ anchors { left: dot.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: modelData.label
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ }
+
+ Text {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: modelData.count < 0 ? "—" : String(modelData.count)
+ color: modelData.count > 0 ? Theme.text : Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ font.bold: modelData.count > 0
+ }
+ }
+
+ // The newest three unread threads. Read-only: qtmaildir takes no
+ // arguments, so there is no way to ask it for a particular thread.
+ Repeater {
+ model: modelData.threads
+
+ Column {
+ required property var modelData
+ width: page.width - 18
+ x: 18
+ spacing: 1
+ bottomPadding: 4
+
+ Item {
+ width: parent.width
+ implicitHeight: who.implicitHeight
+
+ Text {
+ id: who
+ anchors.left: parent.left
+ width: parent.width - when.implicitWidth - 10
+ text: modelData.authors
+ elide: Text.ElideRight
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 3
+ }
+
+ Text {
+ id: when
+ anchors.right: parent.right
+ text: modelData.date
+ color: Theme.overlay
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 3
+ }
+ }
+
+ Text {
+ width: parent.width
+ text: modelData.subject
+ elide: Text.ElideRight
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 2
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) }
+
+ // Watcher health. Green when idling, yellow when a folder gave up, red
+ // when there is no fresh heartbeat at all.
+ Item {
+ width: parent.width
+ implicitHeight: 22
+
+ Rectangle {
+ id: watcherDot
+ anchors.verticalCenter: parent.verticalCenter
+ width: 8; height: 8; radius: 4
+ color: page.watcherColor
+ }
+
+ Text {
+ anchors { left: watcherDot.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: page.watcherText
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 2
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) }
+
+ Row {
+ anchors.right: parent.right
+ spacing: 10
+
+ Button {
+ text: "Open qtmaildir"
+ onClicked: { Accounts.openClient(); page.close(); }
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Write the tile and the module**
+
+`desktop/modules/mail/MailTile.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+
+Text {
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Accounts.total > 0 ? Theme.text : Theme.subtext
+ // A dash rather than a possibly-wrong number while any account is unknown,
+ // the same rule the page header uses.
+ text: Accounts.anyUnknown ? "—"
+ : Accounts.total === 0 ? "no unread"
+ : `${Accounts.total} unread`
+}
+```
+
+`desktop/modules/mail/MailModule.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+
+// Always active: the unread count outlives the drawer, and Accounts watches
+// qtmaildir.conf so a new account appears without a restart.
+Module {
+ id: mod
+
+ name: "mail"
+ icon: ""
+ label: "Mail"
+ alwaysActive: true
+
+ tileContent: Component { MailTile {} }
+
+ page: Component {
+ Page {
+ title: "Mail"
+ // Mail has almost certainly arrived since this was last opened,
+ // and for an autostarted shell that is the whole session.
+ Component.onCompleted: Accounts.refresh()
+ MailPage { width: parent.width }
+ }
+ }
+}
+```
+
+- [ ] **Step 7: Register it and delete the old component**
+
+Add `import "modules/mail"` to `desktop/shell.qml` and put `MailModule {}` in
+the `modules` list, after sound.
+
+```bash
+git rm mail-overview/MailPanel.qml mail-overview/shell.qml mail-overview/Theme.qml
+git mv mail-overview/README.md desktop/modules/mail/README.md
+rmdir mail-overview
+```
+
+- [ ] **Step 8: Verify**
+
+```bash
+pkill -x qs
+pgrep -cx qs
+```
+
+Expected: `0`.
+
+```bash
+./desktop/modules/mail/test-mail-notify.sh
+timeout 15 qs -p desktop 2>&1 | head -30
+```
+
+Expected: the test reporting `16 passed, 0 failed`, then
+`Configuration Loaded` with no QML errors. Ask the user to run
+`qs -p desktop ipc call drawer open mail` and confirm the account rows, thread
+previews and watcher dot all render as they did in the old drawer.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A desktop mail-overview
+git commit -m "feat(desktop): move mail-overview in as the mail module
+
+Accounts.qml is unchanged and MailPanel's body becomes the page. The
+three scripts move with it, which changes the absolute paths in autostart
+and in the waybar module; both are outside this repo and listed in the
+plan's final task. Button.qml, byte-identical here and in vm-manager,
+lands at the shell root as the single copy."
+```
+
+---
+
+## Task 8: Move the vm module
+
+The largest move. `Virsh.qml` becomes the module's service unchanged;
+`VmPanel.qml`'s body becomes the page.
+
+**Files:**
+- Move: `vm-manager/Virsh.qml` -> `desktop/modules/vm/Virsh.qml`
+- Move: `vm-manager/Stat.qml` -> `desktop/modules/vm/Stat.qml`
+- Create: `desktop/modules/vm/VmModule.qml`
+- Create: `desktop/modules/vm/VmTile.qml`
+- Create: `desktop/modules/vm/VmPage.qml`
+- Modify: `desktop/qmldir`, `desktop/shell.qml`
+
+- [ ] **Step 1: Move the two files that need no change**
+
+```bash
+git mv vm-manager/Virsh.qml desktop/modules/vm/Virsh.qml
+git mv vm-manager/Stat.qml desktop/modules/vm/Stat.qml
+git rm vm-manager/Button.qml
+```
+
+`Button.qml` is deleted rather than moved: it was byte-identical to
+`mail-overview`'s, which became `desktop/Button.qml` in Task 7. Confirm before
+deleting:
+
+```bash
+git show HEAD~1:vm-manager/Button.qml | diff - desktop/Button.qml && echo IDENTICAL
+```
+
+- [ ] **Step 2: Register Virsh as a singleton**
+
+Append to `desktop/qmldir`:
+
+```
+singleton Virsh 1.0 modules/vm/Virsh.qml
+```
+
+- [ ] **Step 3: Write the page**
+
+`VmPanel.qml`'s content with the window chrome dropped. The confirm-step logic,
+the per-VM rows and the snapshot list all move unchanged; only the enclosing
+`Scope`/`PanelWindow` and the keyboard handling go away, the latter because the
+drawer owns Escape now.
+
+`desktop/modules/vm/VmPage.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+
+Column {
+ id: page
+
+ property string selected: ""
+
+ // A pending destructive action, shown as a confirm step instead of the
+ // action list: { kind, vm, snap }. Null when nothing is being confirmed.
+ property var confirming: null
+ property string typed: ""
+
+ spacing: 16
+
+ Component.onCompleted: {
+ // The stats timer only runs while this page is up, so start it here
+ // and stop it in Component.onDestruction. The lifecycle event stream
+ // in Virsh keeps running regardless, which is what makes the list
+ // correct the moment the page appears.
+ Virsh.sampling = true;
+ Virsh.refreshList();
+ if (!page.selected && Virsh.names.length) page.selected = Virsh.names[0];
+ if (page.selected) Virsh.loadSnapshots(page.selected);
+ }
+
+ Component.onDestruction: Virsh.sampling = false
+
+ onSelectedChanged: if (selected) Virsh.loadSnapshots(selected)
+
+ function fmtBytes(b) {
+ if (b < 0) return "—";
+ const g = b / (1024 * 1024 * 1024);
+ return g >= 10 ? g.toFixed(0) + " GB" : g.toFixed(1) + " GB";
+ }
+
+ function stateColor(s) {
+ if (s === "running") return Theme.green;
+ if (s === "paused" || s === "suspended" || s === "shutting down") return Theme.yellow;
+ if (s === "crashed") return Theme.red;
+ return Theme.overlay;
+ }
+
+ // Which verbs make sense in the current state, mirroring the states the
+ // old rofi script switched on.
+ function actionsFor(s, saved) {
+ if (s === "running")
+ return [["shutdown", "Shutdown"], ["reboot", "Reboot"], ["suspend", "Suspend"],
+ ["reset", "Reset"], ["destroy", "Force stop"]];
+ if (s === "paused" || s === "suspended")
+ return [["resume", "Resume"], ["shutdown", "Shutdown"], ["destroy", "Force stop"]];
+ // Only worth offering when a saved image actually exists: without one
+ // managedsave-remove fails, and the button would be noise on every
+ // other VM.
+ if (saved)
+ return [["start", "Start"], ["discardsave", "Discard saved state"]];
+ return [["start", "Start"]];
+ }
+
+ function isDestructive(a) { return a === "reset" || a === "destroy" || a === "discardsave"; }
+
+ function run(vm, action) {
+ if (isDestructive(action)) page.confirming = { kind: action, vm: vm, snap: "" };
+ else Virsh.act(vm, action);
+ }
+
+ // One row per VM, so several VMs stay readable at a glance.
+ Repeater {
+ model: Virsh.names
+
+ Rectangle {
+ required property string modelData
+ readonly property var vm: Virsh.vms[modelData] ?? ({})
+ readonly property bool isSel: page.selected === modelData
+
+ width: page.width
+ implicitHeight: vmCol.implicitHeight + 24
+ radius: 10
+ color: isSel ? Qt.alpha(Theme.surface, 0.7) : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: isSel ? Qt.alpha(Theme.accent, 0.5) : "transparent"
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: page.selected = modelData
+ }
+
+ Column {
+ id: vmCol
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 }
+ spacing: 10
+
+ Row {
+ spacing: 10
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 9; height: 9; radius: 5
+ color: page.stateColor(vm.state ?? "")
+ }
+ Text {
+ text: modelData
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: vm.state ?? ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.subtext
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: (vm.vcpus ?? 0) + " vCPU"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.overlay
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: vm.state === "running" && !(vm.agent ?? false)
+ text: "agent starting"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ }
+
+ // Stats only mean anything while the VM runs. The figures are
+ // guest-agent only: libvirt's balloon.current reads full
+ // forever and block.allocation is host-side qcow2 growth, so
+ // a dash is correct where the agent is silent.
+ Flow {
+ visible: vm.state === "running"
+ width: parent.width
+ spacing: 20
+
+ Stat {
+ label: "CPU"
+ value: (vm.cpu ?? -1) < 0 ? "—" : (vm.cpu).toFixed(0) + "%"
+ fraction: (vm.cpu ?? 0) / 100
+ }
+ Stat {
+ label: "RAM"
+ value: (vm.memUsed ?? -1) < 0 ? "—"
+ : page.fmtBytes(vm.memUsed) + " / " + page.fmtBytes(vm.memTotal)
+ fraction: (vm.memUsed ?? -1) < 0 ? -1 : vm.memUsed / vm.memTotal
+ }
+ Stat {
+ label: "Disk"
+ value: (vm.fsUsed ?? -1) < 0 ? "—"
+ : page.fmtBytes(vm.fsUsed) + " / " + page.fmtBytes(vm.fsTotal)
+ fraction: (vm.fsUsed ?? -1) < 0 ? -1 : vm.fsUsed / vm.fsTotal
+ }
+ Stat {
+ label: "Address"
+ value: (vm.ip ?? "") === "" ? "—" : vm.ip
+ fraction: -1
+ }
+ }
+
+ // Actions and snapshots, for the selected VM only.
+ Loader {
+ active: isSel
+ width: parent.width
+ sourceComponent: detail
+ property string vmName: modelData
+ property string vmState: vm.state ?? ""
+ property bool vmSaved: vm.saved ?? false
+ }
+ }
+ }
+ }
+
+ Component {
+ id: detail
+
+ Column {
+ spacing: 12
+
+ readonly property string vmName: parent.vmName
+ readonly property string vmState: parent.vmState
+ readonly property bool vmSaved: parent.vmSaved
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) }
+
+ // Confirm step replaces the buttons, so the action cannot be
+ // clicked again while it is being confirmed.
+ Loader {
+ active: page.confirming !== null && page.confirming.vm === vmName
+ width: parent.width
+ sourceComponent: confirmUi
+ }
+
+ Flow {
+ visible: !(page.confirming !== null && page.confirming.vm === vmName)
+ width: parent.width
+ spacing: 8
+
+ Repeater {
+ model: page.actionsFor(vmState, vmSaved)
+ Button {
+ required property var modelData
+ text: modelData[1]
+ danger: page.isDestructive(modelData[0])
+ onClicked: page.run(vmName, modelData[0])
+ }
+ }
+ Button {
+ text: "Snapshot"
+ onClicked: Virsh.snapshotCreate(vmName)
+ }
+ Button {
+ text: "Delete VM"
+ danger: true
+ onClicked: page.confirming = { kind: "delete", vm: vmName, snap: "" }
+ }
+ }
+
+ Text {
+ visible: (Virsh.snapshots[vmName] ?? []).length > 0
+ text: "Snapshots"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.subtext
+ }
+
+ Repeater {
+ model: Virsh.snapshots[vmName] ?? []
+
+ Column {
+ required property var modelData
+ width: parent.width
+ spacing: 4
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: modelData.name
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+
+ Row {
+ width: parent.width
+ spacing: 10
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.created
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.state
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ Button {
+ text: "Revert"
+ danger: true
+ onClicked: page.confirming = { kind: "revert", vm: vmName, snap: modelData.name }
+ }
+ Button {
+ text: "Delete"
+ danger: true
+ onClicked: page.confirming = { kind: "snapdelete", vm: vmName, snap: modelData.name }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: confirmUi
+
+ Column {
+ spacing: 10
+ readonly property var c: page.confirming
+ // Deleting a VM erases its disk image, so that one asks for the
+ // name to be typed. The rest are recoverable enough for a click.
+ readonly property bool needsTyping: c && c.kind === "delete"
+
+ Text {
+ width: parent.width
+ wrapMode: Text.Wrap
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: Theme.red
+ text: {
+ if (!c) return "";
+ if (c.kind === "delete") return `Delete ${c.vm}? This erases its disk image and cannot be undone.`;
+ if (c.kind === "revert") return `Revert ${c.vm} to "${c.snap}"? Changes since that snapshot are lost.`;
+ if (c.kind === "snapdelete") return `Delete snapshot "${c.snap}"?`;
+ if (c.kind === "destroy") return `Force stop ${c.vm}? This is a power cut, not a shutdown.`;
+ if (c.kind === "discardsave") return `Discard the saved state of ${c.vm}? Its memory image is deleted and the next start boots cold. The disk is untouched.`;
+ if (c.kind === "reset") return `Reset ${c.vm}? This is a hard reset, not a reboot.`;
+ return "";
+ }
+ }
+
+ TextInput {
+ id: nameField
+ visible: needsTyping
+ width: 260
+ text: page.typed
+ onTextChanged: page.typed = text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: Theme.text
+ focus: needsTyping
+ Component.onCompleted: if (needsTyping) forceActiveFocus()
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: -6
+ z: -1
+ radius: 6
+ color: Qt.alpha(Theme.surface, 0.8)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.15)
+ }
+ Text {
+ visible: !nameField.text
+ text: "type the VM name"
+ font: nameField.font
+ color: Theme.overlay
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button {
+ text: "Confirm"
+ danger: true
+ enabled: !needsTyping || page.typed === c.vm
+ onClicked: {
+ if (c.kind === "delete") Virsh.deleteVm(c.vm);
+ else if (c.kind === "revert") Virsh.snapshotRevert(c.vm, c.snap);
+ else if (c.kind === "snapdelete") Virsh.snapshotDelete(c.vm, c.snap);
+ else Virsh.act(c.vm, c.kind);
+ page.confirming = null;
+ page.typed = "";
+ }
+ }
+ Button {
+ text: "Cancel"
+ onClicked: { page.confirming = null; page.typed = ""; }
+ }
+ }
+ }
+ }
+}
+```
+
+Note the stat row changed from `Row` to `Flow`: four stats at 150px bars do not
+fit in a 600px drawer as one row, where they did in the old full-width panel.
+
+- [ ] **Step 4: Write the tile and the module**
+
+The tile shows one dot per VM. `Virsh` runs `virsh event --all --loop`
+unconditionally and refreshes the list on every lifecycle event, so
+`Virsh.names` and each VM's state are current even while `sampling` is false;
+what `sampling` gates is only the 2s stats poll.
+
+`desktop/modules/vm/VmTile.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+
+// One dot per VM, green for running. The state behind these comes from the
+// lifecycle event stream, which runs whether or not the page is open, so the
+// dots are current without the stats poll.
+Row {
+ spacing: 4
+
+ Repeater {
+ model: Virsh.names
+
+ Rectangle {
+ required property string modelData
+ readonly property string state: Virsh.vms[modelData]?.state ?? ""
+
+ anchors.verticalCenter: parent.verticalCenter
+ width: 7; height: 7; radius: 4
+ color: state === "running" ? Theme.green
+ : state === "paused" || state === "suspended" ? Theme.yellow
+ : Theme.overlay
+ }
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: Virsh.names.length === 0
+ text: "no VMs"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+}
+```
+
+`desktop/modules/vm/VmModule.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+
+// Not always active: the 2s stats poll exists only to paint a page nobody is
+// looking at, so the page starts and stops it. The lifecycle event stream in
+// Virsh is separate and always runs, which is what keeps the tile's dots and
+// the VM list correct without polling.
+Module {
+ id: mod
+
+ name: "vm"
+ icon: ""
+ label: "Machines"
+ alwaysActive: false
+
+ tileContent: Component { VmTile {} }
+
+ page: Component {
+ Page {
+ title: "Virtual machines"
+ VmPage { width: parent.width }
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Keep the failure notification**
+
+`vm-manager/shell.qml` turned `Virsh.actionFailed` into a `notify-send`. That
+belongs in the module now. Add to `VmModule.qml`, inside the `Module` block:
+
+```qml
+ // An action that fails (libvirt refusing, a disk in use) has to say so:
+ // the notification is the only feedback, since virsh output goes nowhere.
+ property Process notifyProc: Process {}
+
+ property Connections conn: Connections {
+ target: Virsh
+ function onActionFailed(vm, action, message) {
+ mod.notifyProc.command = ["notify-send", "--app-name=vm-manager",
+ "--urgency=critical", "--icon=error",
+ `${action} failed: ${vm}`, message];
+ mod.notifyProc.running = false;
+ mod.notifyProc.running = true;
+ }
+ }
+```
+
+and add `import Quickshell.Io` at the top of the file.
+
+- [ ] **Step 6: Register it and delete the old component**
+
+Add `import "modules/vm"` to `desktop/shell.qml` and put `VmModule {}` in the
+`modules` list, after mail.
+
+```bash
+git rm vm-manager/VmPanel.qml vm-manager/shell.qml vm-manager/Theme.qml
+git mv vm-manager/README.md desktop/modules/vm/README.md
+rmdir vm-manager
+```
+
+- [ ] **Step 7: Verify**
+
+```bash
+pkill -x qs
+pgrep -cx qs
+```
+
+Expected: `0`.
+
+```bash
+timeout 20 qs -p desktop 2>&1 | head -30
+```
+
+Expected: `Configuration Loaded`, no QML errors. Ask the user to:
+1. Confirm the grid shows four tiles, with the VM tile showing a dot per VM.
+2. Run `qs -p desktop ipc call drawer open vm` and confirm the VM rows, stats
+ and snapshot list render, and that the back arrow returns to the grid.
+3. Confirm the stats update while the page is open and that the tile dots are
+ still right after closing it.
+
+`Virsh.qml` moved unchanged, so the two libvirt findings it encodes should
+still hold. Confirm they survived the move rather than assuming it:
+
+```bash
+grep -c 'Managed save' desktop/modules/vm/Virsh.qml
+grep -c 'balloon\|block.allocation\|guest-get-fsinfo' desktop/modules/vm/Virsh.qml
+```
+
+Expected: `1` and at least `1`. The first is the managed-save detection, which
+`domstats` cannot report and which makes `virsh start` fail every time on an
+affected VM; the second is the guest-agent path that exists because libvirt's
+own memory and disk figures measure something else. If either reads `0`, the
+file was edited when it should have been moved verbatim.
+
+A VM carrying a managed save shows "Discard saved state" among its actions; if
+the user has one, that button appearing is the end-to-end check.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add -A desktop vm-manager
+git commit -m "feat(desktop): move vm-manager in as the vm module
+
+Virsh.qml is unchanged; VmPanel's body becomes the page and the stat row
+becomes a Flow, because four stats with 150px bars do not fit a 600px
+drawer as one row. The page starts and stops Virsh.sampling, so the 2s
+poll runs only while something is looking at it; the lifecycle event
+stream still runs always, which is what keeps the tile's dots current."
+```
+
+---
+
+## Task 9: Documentation
+
+**Files:**
+- Create: `desktop/README.md`
+- Modify: `README.md`
+- Modify: `AGENTS.md`
+
+- [ ] **Step 1: Write the component README**
+
+`desktop/README.md` covers what the drawer is, how a module is written, and the
+outside-repo configuration. The three moved READMEs stay where Tasks 6-8 put
+them, under their module directories, and this one links to them.
+
+Write it with these sections:
+
+```markdown
+# desktop
+
+One drawer, left of DP-1, hosting the things a desktop lets you adjust.
+Reached from a launcher at the left end of waybar.
+
+## Running it
+
+ qs -p ./desktop
+
+ qs -p ./desktop ipc call drawer open # the grid
+ qs -p ./desktop ipc call drawer open mail # straight to a page
+
+## The modules
+
+ modules/sound/ output and input volume, the OSD, the player
+ modules/mail/ unread per account, threads, the watcher dot
+ modules/vm/ libvirt state, live stats, snapshots
+ modules/appearance/ a tile that opens the separate appearance shell
+
+Each has its own README.
+
+## Writing a module
+
+[Describe Module.qml's properties: name, icon, label, alwaysActive,
+tileContent, page, activate(). Explain that a module provides a tile, a
+page, both or neither, and that alwaysActive governs the service rather
+than the page. Show the appearance module as the smallest complete
+example, since it is nine lines of substance.]
+
+## alwaysActive
+
+[Explain why sound and mail are true and vm is false: the OSD must answer
+a keypress with no drawer open, the unread count outlives the drawer, and
+vm's stats poll only paints a page nobody is looking at. Note that vm's
+lifecycle event stream still runs always, so the property governs the
+poll, not everything the module does.]
+
+## Geometry
+
+[600px, left of DP-1 because conky holds the right, ExclusionMode.Normal
+so waybar stays visible and clickable, the reserved notification area at
+the top, the fixed 3x3 tile grid at the bottom.]
+
+## Hyprland and waybar
+
+[The blur rule, the launcher module, the deep-link click targets. Refer
+to the config table in the plan's final task.]
+
+## Theme
+
+[One sentence: Theme.qml is a symlink to shared/Theme.qml, as in every
+component here.]
+```
+
+Fill each bracketed section with real prose; the brackets are instructions to
+you, not content to keep.
+
+- [ ] **Step 2: Update the top-level README**
+
+In `README.md`, replace the "Implementations" block with:
+
+```
+ desktop/ the drawer: sound, mail, VMs, appearance
+ appearance/ wallpaper picker and colour scheme switcher
+ window-switcher/ open windows as live previews in a grid, on ALT+TAB
+```
+
+and adjust the paragraph above it, which currently says the components are
+"separate shells, not modules of a single bar". That is still true of the three
+directories, but `desktop/` is itself a host for modules, so say so: three
+shells, one of which hosts modules.
+
+Update the `qs -p ./volume-osd` example to `qs -p ./desktop`.
+
+- [ ] **Step 3: Update AGENTS.md**
+
+Two sections are now wrong:
+
+- The component list at the top still names five directories.
+- The "Theme" section says there is one `Theme.qml` "symlinked five ways"; it
+ is now three.
+
+Add to the per-component notes, since both cost time to rediscover:
+
+```markdown
+- **A singleton outside the config root needs a `qmldir` entry.** The five
+ original components each kept their singletons beside `shell.qml`, where
+ quickshell finds them. Moving one into `modules/<name>/` makes it invisible
+ until `qmldir` names it.
+- **`Virsh.sampling` gates the 2s stats poll, not the whole service.** The
+ lifecycle event stream runs unconditionally, which is what keeps the VM list
+ and the tile's dots current while the page is closed.
+```
+
+- [ ] **Step 4: Verify the docs match the code**
+
+```bash
+ls desktop/modules/
+grep -c 'volume-osd\|mail-overview\|vm-manager' README.md AGENTS.md
+```
+
+Expected: the four module directories listed, and the grep reporting `0` for
+`README.md`. `AGENTS.md` legitimately still mentions the old names in its
+historical notes, so read its matches rather than requiring zero.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add README.md AGENTS.md desktop/README.md
+git commit -m "docs: the desktop shell and what the merge changed
+
+Five components become three, and the Theme symlink count with them. Two
+new notes: a singleton outside the config root is invisible without a
+qmldir entry, and Virsh.sampling gates only the stats poll, not the
+lifecycle stream that keeps the tile correct while the page is closed."
+```
+
+---
+
+## Task 10: The configuration outside this repo
+
+These six edits are in the user's live Hyprland and waybar configuration, not
+in this repository. **Do not apply them without asking.** Present the list, make
+the edits the user approves, and let the user restart the session.
+
+- [ ] **Step 1: Show the user what needs changing**
+
+| file | change |
+|---|---|
+| `~/.config/hypr/sections/autostart.lua` | five `qs` lines to three; `mail-notify.sh` path |
+| `~/.config/hypr/sections/keybindings.lua` | `SUPER+v` to the drawer deep-link |
+| `~/.config/hypr/sections/decorations.lua` | add `blur-desktop`; drop `blur-mail`, `blur-vm-manager`; keep `blur-volume-osd` |
+| `~/.config/waybar/config.jsonc` | add the launcher at the left end |
+| `~/.config/waybar/modules/custom/mail.jsonc` | `exec` path and `on-click` |
+| `~/.config/waybar/modules/custom/launcher.jsonc` | new file |
+
+- [ ] **Step 2: autostart.lua**
+
+Replace the five `qs` lines with:
+
+```lua
+ hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop")
+ hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/appearance")
+ hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/window-switcher")
+```
+
+and change the notifier line to its new path:
+
+```lua
+ hl.exec_cmd("~/Programming/GIT/quickshell/desktop/modules/mail/mail-notify.sh")
+```
+
+- [ ] **Step 3: keybindings.lua**
+
+Replace the `SUPER + v` bind:
+
+```lua
+hl.bind(mainMod .. " + v", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer open vm"))
+```
+
+ALT+TAB and `SUPER+Return` are unchanged: both still point at shells that still
+exist.
+
+- [ ] **Step 4: decorations.lua**
+
+Delete the `blur-mail` and `blur-vm-manager` rules, whose namespaces no longer
+exist. Keep `blur-volume-osd`, which the OSD still uses. Add:
+
+```lua
+-- Frosted glass for the quickshell desktop drawer.
+hl.layer_rule({
+ name = "blur-desktop",
+ match = { namespace = "^(quickshell-desktop)$" },
+ blur = true,
+ xray = false,
+ ignore_alpha = 0.1,
+})
+```
+
+- [ ] **Step 5: The waybar launcher**
+
+Create `~/.config/waybar/modules/custom/launcher.jsonc`:
+
+```jsonc
+{
+ // Opens the quickshell desktop drawer. A static button: no "exec", so it
+ // cannot show whether the drawer is open, which would need the shell to
+ // feed waybar.
+ "custom/launcher": {
+ "format": "<span font='18px'></span>",
+ "tooltip": false,
+ "on-click": "qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer toggle"
+ }
+}
+```
+
+The glyph between the `span` tags must be the Slackware icon from the user's
+Nerd Font; ask the user which codepoint they want rather than guessing.
+
+In `~/.config/waybar/config.jsonc`, add the include and put the module first in
+`modules-left`:
+
+```jsonc
+ "~/.config/waybar/modules/custom/launcher.jsonc",
+```
+
+```jsonc
+ "modules-left": [
+ "custom/launcher",
+ "clock#date",
+```
+
+- [ ] **Step 6: The mail module's paths**
+
+In `~/.config/waybar/modules/custom/mail.jsonc`:
+
+```jsonc
+ "exec": "~/Programming/GIT/quickshell/desktop/modules/mail/waybar-mail.sh",
+ "on-click": "qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer open mail",
+```
+
+- [ ] **Step 7: Apply and restart**
+
+```bash
+hyprctl reload
+```
+
+That picks up the binds and the layer rules. It does not start processes:
+`hl.exec_cmd` is exec-once, so the three-shell autostart takes effect at the
+next login. Have the user either log out and back in, or start the shells
+manually for this session.
+
+Restart waybar for the launcher and the mail module's new paths:
+
+```bash
+pkill -x waybar && waybar &
+```
+
+- [ ] **Step 8: Confirm the end state**
+
+After the user has logged back in:
+
+```bash
+pgrep -ax qs
+```
+
+Expected: exactly three, running `desktop`, `appearance` and `window-switcher`.
+
+Ask the user to confirm: the launcher opens the drawer, `SUPER+v` lands on the
+VM page, the waybar mail count still updates and its click opens the mail page,
+the volume OSD still appears on a keypress with the drawer closed, and the
+drawer is frosted rather than flat.
+
+---
+
+## Notes for whoever executes this
+
+**Commit after every task.** Each task leaves the repo working; several delete
+a component, and a half-finished deletion is painful to unpick.
+
+**The three deleted components stay in git history.** If a page turns out to
+have lost something, `git show HEAD~n:vm-manager/VmPanel.qml` has the original.
+
+**Glyphs.** Several files carry Nerd Font private-use characters. This plan
+writes them as `\uXXXX` escapes because they do not survive copying through a
+document. When moving a file, take the real bytes from the original with
+`git mv` or `git show`, and check `git diff` reports no change to them. Where a
+new file needs a glyph, the escape form is correct and renders identically.
+
+**What the spec deliberately leaves out.** Notifications, DND, breaktimer,
+wifi, bluetooth and kdeconnect are later projects. The reserved area at the top
+of the drawer is the only accommodation this project makes for the first of
+them. Resist filling it.