From 3e67d1de753c131d2880661da5904f7c2398cc69 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 15 Sep 2026 14:10:25 +0200 Subject: docs: add the notification renderers implementation plan Plan two of two: the quickshell side of the notification daemon. The Notify singleton reads the daemon's published files, a new notifications/ component draws balloons and owns suppression, the drawer's reserved Item becomes the live queue with a history page, and the Status page gains a snooze row. Suppression lives in the balloon shell so the drawer can list what DND held back, and the drawer flag is the single no-double-show mechanism. Per-id history removal is a tracked TODO in the notifyd repo rather than an untracked spec gap. --- .../plans/2026-09-15-notification-renderers.md | 1380 ++++++++++++++++++++ 1 file changed, 1380 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-15-notification-renderers.md (limited to 'docs') diff --git a/docs/superpowers/plans/2026-09-15-notification-renderers.md b/docs/superpowers/plans/2026-09-15-notification-renderers.md new file mode 100644 index 0000000..fdcbe7a --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-notification-renderers.md @@ -0,0 +1,1380 @@ +# 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