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