# 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