# Notification Renderers 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:** The quickshell side of the notification daemon: balloon popups, the drawer's reserved notification space and history page, and the Status page snooze row. **Architecture:** `shared/Notify.qml` reads the daemon's published files (`queue.json`, `history.json`, `drawer`, `snooze`) and is the one place both renderers share; mutations go through `notifyctl` over a `Process`. A new `notifications/` component draws balloons bottom-right of `DP-1` over conky. The desktop drawer's existing reserved `Item` is filled with a scrollable live list plus a History button, and the Status page gains a snooze row. Nothing here talks D-Bus; the files are the interface, the same convention the status registry set. **Tech Stack:** Quickshell 0.3.1, Qt6 QML, `Quickshell.Io.FileView`, `Quickshell.Io.Process`, `notifyctl` and `notify-snooze.sh` in `~/bin`. **Spec:** `docs/superpowers/specs/2026-09-15-notification-daemon-design.md`. Plan 1 (the daemon, in the `notifyd` repo) is a prerequisite and is shipped. ## Global Constraints - Quickshell 0.3.1, Qt6 QML. Run a config with `qs -p `. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f`. - GPLv2 only. Every new `.qml` file begins with this exact header: ```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. ``` Shell scripts use the same notice with `#` markers after the shebang. - A component with no always-visible window exits. Every new component (`notifications/`) holds itself open with a 1x1 transparent `PanelWindow` with `mask: Region {}`. See AGENTS.md. - The daemon's published JSON is the contract. `created` and `expires` are epoch milliseconds, `0` meaning never; `actions` is an array of `[key, label]` pairs; `urgency` is `"low"`, `"normal"` or `"critical"`. - `$XDG_RUNTIME_DIR/notifyd/` holds `queue.json`, `history.json`, (written by the daemon), `drawer` (written by the drawer) and `snooze` (written by `notify-snooze.sh`). - Suppression lives here, not in the daemon: balloons are withheld by `dnd` (low and normal only) and by snooze (all). The drawer's reserved space lists everything. - Reusing a `Process` needs `running = false` immediately before `running = true`. - No em dashes. No home paths in committed files; `~` in documentation only. Nerd Font glyphs are written as `\uXXXX` and their bytes verified with `git diff`. - Smoke check, harness owns the process and reads the log, never a later `pgrep`: ```bash timeout 8 qs -p 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" ``` Expected: `clean`. ## Verified Facts (from Plan 1 and this repo) - The daemon is installed as `~/bin/notifyd` and is running; `notifyctl` and `notify-snooze.sh` are in `~/bin`. `notifyctl list` prints the live queue as JSON. - `expire_timeout` direction is the freedesktop one: `-1` takes the urgency default, `0` means never. - `Theme` carries `base surface text subtext red green yellow surfaceAlt overlay accent`, plus `fontFamily`, `fontSize` (16) and `iconFamily`. - `Drawer.qml:131` already has an empty `Item { id: notifications }` documented as "Reserved for the notification engine", anchored above the grid inside the grid view. - `Status.qml` (the registry singleton) is symlinked into `desktop/`; `Status.dnd` and `Status.presentation` are booleans. - The Hyprland blur rules live in `~/.config/hypr/sections/decorations.lua`; each quickshell layer needs its own rule matched on its namespace. - `custom/notification.jsonc` and `waybar/scripts/notifications.py` reference `dunstctl` but are not in the live waybar config; they are dead and not part of this plan. --- ## File Structure shared/Notify.qml the shared singleton (create) desktop/Notify.qml symlink to it (create) notifications/ the balloon component (create) shell.qml ShellRoot, keepalive, Balloons Balloons.qml the stack and the suppression filter NotificationBalloon.qml one balloon notify-actions.sh the rofi action picker Theme.qml, Status.qml, Notify.qml symlinks into ../shared (create) README.md component notes (create) desktop/Drawer.qml reserved space, history view, drawer flag (modify) desktop/NotificationList.qml the reserved space's list (create) desktop/NotificationRow.qml one row, live or history (create) desktop/NotificationHistory.qml the history page body (create) desktop/modules/status/SnoozeRow.qml the snooze control (create) desktop/modules/status/StatusPage.qml add the snooze row (modify) AGENTS.md traps (modify) --- ### Task 1: The shared Notify singleton **Files:** - Create: `shared/Notify.qml` - Create: `desktop/Notify.qml` (symlink) **Interfaces:** - Consumes: `Quickshell.Io.FileView`, `Quickshell.Io.Process`, `notifyctl` on PATH. - Produces: singleton `Notify` with `readonly property var queue`, `readonly property var history`, `readonly property bool drawerOpen`, `readonly property double snoozeUntil`, and the functions `close(id)`, `closeAll()`, `action(id, key)`, `actions(id, pairs)`, `clearHistory()`. Every later task uses these. - [ ] **Step 1: Write `shared/Notify.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. pragma Singleton import Quickshell import Quickshell.Io import QtQuick // The notification daemon's state, read from the files it publishes under // $XDG_RUNTIME_DIR/notifyd/. The files are the interface, the same convention // the status registry set: the daemon writes them, this reads them, and // notifyctl is the one path back for a mutation. Nothing here speaks D-Bus. // // A shell restart loses nothing: the daemon keeps running, the files stay, and // this singleton reads them back. Singleton { id: root readonly property string dir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/notifyd" // Parsed whole on every change. Malformed or missing JSON is treated as // empty rather than propagated: a renderer with a bad array is worse than // a renderer that briefly shows nothing. property var queue: [] property var history: [] // Written by the drawer, read here so the balloons know to stand down. property bool drawerOpen: false // Epoch milliseconds; 0 means not snoozing. property double snoozeUntil: 0 function parseQueue() { try { root.queue = JSON.parse(queueFile.text() || "[]"); } catch (e) { root.queue = []; } } function parseHistory() { try { root.history = JSON.parse(historyFile.text() || "[]"); } catch (e) { root.history = []; } } // Mutations go through notifyctl, the only thing that talks to the daemon. function run(args) { ctl.command = ["notifyctl"].concat(args); ctl.running = false; ctl.running = true; } function close(id) { root.run(["close", String(id)]); } function closeAll() { root.run(["close-all"]); } function action(id, key) { root.run(["action", String(id), key]); } function clearHistory() { root.run(["clear-history"]); } // The rofi picker lives in the notifications component; a caller passes // the notification id and its [key, label] pairs. Only that component // uses this, but the singleton owns the Process so there is one place a // notifyctl-adjacent command is built. function actions(id, pairs) { const cmd = ["notify-actions.sh", String(id)]; for (const pair of pairs) { cmd.push(pair[1]); cmd.push(pair[0]); } actProc.command = cmd; actProc.running = false; actProc.running = true; } Process { id: ctl; printErrors: false } Process { id: actProc; printErrors: false } FileView { id: queueFile path: root.dir + "/queue.json" watchChanges: true printErrors: false onFileChanged: reload() onLoaded: root.parseQueue() onLoadFailed: root.queue = [] } FileView { id: historyFile path: root.dir + "/history.json" watchChanges: true printErrors: false onFileChanged: reload() onLoaded: root.parseHistory() onLoadFailed: root.history = [] } FileView { id: drawerFile path: root.dir + "/drawer" watchChanges: true printErrors: false onFileChanged: reload() onLoaded: root.drawerOpen = drawerFile.text().trim() === "1" onLoadFailed: root.drawerOpen = false } FileView { id: snoozeFile path: root.dir + "/snooze" watchChanges: true printErrors: false onFileChanged: reload() onLoaded: { const v = parseInt(snoozeFile.text().trim(), 10); root.snoozeUntil = isNaN(v) ? 0 : v * 1000; } onLoadFailed: root.snoozeUntil = 0 } } ``` - [ ] **Step 2: Symlink it into the drawer** ```bash ln -s ../shared/Notify.qml desktop/Notify.qml ls -l desktop/Notify.qml ``` Expected: `desktop/Notify.qml -> ../shared/Notify.qml`. - [ ] **Step 3: Smoke check that the singleton parses** ```bash timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" ``` Expected: `clean`. The daemon is running, so the files exist; nothing references the singleton yet. - [ ] **Step 4: Commit** ```bash git add shared/Notify.qml desktop/Notify.qml git commit -m "feat(desktop): add the Notify singleton The daemon publishes its queue, history, drawer flag and snooze as files; this reads them for both renderers, the same files-are-the-interface convention the status registry set. Mutations and the rofi action picker run notifyctl through a Process, which is the one path back to the daemon." ``` --- ### Task 2: The balloon shell **Files:** - Create: `notifications/shell.qml` - Create: `notifications/Balloons.qml` - Create: `notifications/NotificationBalloon.qml` - Create: `notifications/Theme.qml`, `notifications/Status.qml`, `notifications/Notify.qml` (symlinks) - Create: `notifications/README.md` **Interfaces:** - Consumes: `Notify`, `Status`, `Theme`. - Produces: a running component that draws one balloon per unsuppressed live notification, bottom-right of `DP-1`. Task 3 adds interaction; this task draws. - [ ] **Step 1: Create the component directory and its symlinks** ```bash mkdir -p notifications ln -s ../shared/Theme.qml notifications/Theme.qml ln -s ../shared/Status.qml notifications/Status.qml ln -s ../shared/Notify.qml notifications/Notify.qml ls -l notifications/ ``` Expected: three symlinks into `../shared`. - [ ] **Step 2: Write `notifications/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 the balloons are // hidden whenever the queue is empty, so this keeps the shell alive. See // AGENTS.md. PanelWindow { visible: true implicitWidth: 1 implicitHeight: 1 color: "transparent" exclusionMode: ExclusionMode.Ignore mask: Region {} WlrLayershell.keyboardFocus: WlrKeyboardFocus.None } Balloons {} } ``` - [ ] **Step 3: Write `notifications/Balloons.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 // The balloon stack, bottom-right of DP-1, over conky. Scope { id: root // A tick drives both expiry and the end of a snooze without waiting for a // file change. 250ms keeps ronema's -t 1 near-instant. property double now: Date.now() Timer { interval: 250 running: true repeat: true onTriggered: root.now = Date.now() } // Which live notifications draw here. Suppression is deliberately here // and not in the daemon: the drawer lists a notification DND chose not to // pop, because a list the user opened is not an interruption. readonly property var visible: (Notify.queue || []).filter(p => { if (Notify.drawerOpen) return false; if (p.expires !== 0 && root.now >= p.expires) return false; if (Notify.snoozeUntil > root.now) return false; if (Status.dnd && p.urgency !== "critical") return false; return true; }) PanelWindow { id: win visible: root.visible.length > 0 screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] anchors { bottom: true; right: true } margins { bottom: 12; right: 12 } implicitWidth: 340 implicitHeight: column.implicitHeight color: "transparent" exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "quickshell-notifications" WlrLayershell.keyboardFocus: WlrKeyboardFocus.None Column { id: column width: parent.width anchors { bottom: parent.bottom; right: parent.right } spacing: 8 Repeater { model: root.visible NotificationBalloon { notification: modelData } } } } } ``` - [ ] **Step 4: Write `notifications/NotificationBalloon.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 balloon: icon at the left, app, summary and body, an X, and the click // targets. The body is markup, which is why the daemon advertises body-markup. Rectangle { id: b required property var notification width: parent ? parent.width : 340 implicitHeight: texts.implicitHeight + 20 radius: 10 color: Qt.alpha(Theme.base, 0.82) border.width: 1 border.color: Qt.alpha(Theme.text, 0.12) // The background click target is declared first so the X, declared later, // sits above it and wins its corner. MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: Qt.PointingHandCursor onClicked: mouse => { if (mouse.button === Qt.RightButton) { Notify.closeAll(); return; } const acts = b.notification.actions || []; const inert = b.notification.expires !== 0 && Date.now() >= b.notification.expires; if (acts.length > 0 && !inert) Notify.actions(b.notification.id, acts); else Notify.close(b.notification.id); } } Image { id: icon visible: b.notification.icon !== "" && b.notification.icon !== undefined anchors { left: parent.left; top: parent.top; margins: 10 } width: 32 height: 32 source: visible ? "file://" + b.notification.icon : "" sourceSize { width: 64; height: 64 } } Text { id: close anchors { right: parent.right; top: parent.top; margins: 6 } width: 20 height: 20 text: "\uf00d" horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter font { family: Theme.iconFamily; pixelSize: 11 } color: closeArea.containsMouse ? Theme.red : Theme.subtext MouseArea { id: closeArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: Notify.close(b.notification.id) } } Column { id: texts anchors { left: icon.visible ? icon.right : parent.left leftMargin: 10 right: parent.right rightMargin: 10 top: parent.top topMargin: 10 } spacing: 2 Text { width: parent.width text: b.notification.app || "" elide: Text.ElideRight font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } color: Theme.subtext } Text { width: parent.width text: b.notification.summary || "" elide: Text.ElideRight font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text } Text { width: parent.width visible: text !== "" text: b.notification.body || "" textFormat: Text.RichText wrapMode: Text.WordWrap maximumLineCount: 3 elide: Text.ElideRight font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.subtext } } } ``` - [ ] **Step 5: Write `notifications/README.md`** ```markdown # notifications The balloon renderer for the notification daemon (`notifyd`, a separate repo). It reads the daemon's published files through the `Notify` singleton and draws one balloon per live notification, bottom-right of `DP-1`. ## Suppression lives here The daemon does not know about DND or snooze. This component withholds balloons: `status.dnd` suppresses low and normal, `notifyd/snooze` suppresses everything. The drawer's reserved space lists every live notification anyway. ## The files are the interface $XDG_RUNTIME_DIR/notifyd/queue.json the live queue $XDG_RUNTIME_DIR/notifyd/history.json the ring of 20 $XDG_RUNTIME_DIR/notifyd/drawer "1" while the drawer holds the space $XDG_RUNTIME_DIR/notifyd/snooze an epoch second while snoozing `notifyctl` and `notify-snooze.sh` are in `~/bin`; without them the balloons draw but close and actions do nothing. ## Blur Hyprland blurs a layer surface only when a rule names its namespace. This component sets `quickshell-notifications`; the rule is in `~/.config/hypr/sections/decorations.lua`. ``` - [ ] **Step 6: Smoke check** ```bash timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" ``` Expected: `clean`. (This briefly starts a second copy; it dies with the timeout.) - [ ] **Step 7: Commit** ```bash git add notifications/ git commit -m "feat(notifications): add the balloon shell Reads the daemon's queue through the Notify singleton and draws a balloon per live notification, bottom-right of DP-1 over conky. Suppression is here, not in the daemon: dnd withholds low and normal, snooze withholds all, and the drawer still lists them." ``` --- ### Task 3: Balloon interactions **Files:** - Create: `notifications/notify-actions.sh` **Interfaces:** - Consumes: `Notify.actions`, `Notify.close`, `Notify.closeAll`. - Produces: `notify-actions.sh