# 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//` 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. // // 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. // // 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 `. 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. // // 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. // // 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. // // 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 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` 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. // // 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 ipc call drawer open -> the grid // qs -p 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. // // 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. // // 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. // // 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. // // 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. // // 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. // // 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. // // 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. // // 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. // // 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. // // 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. // // 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. // // 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//` 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": "", "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.