aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-09-15-notification-image-renderers.md218
-rw-r--r--docs/superpowers/plans/2026-09-15-notification-renderers.md1380
-rw-r--r--docs/superpowers/plans/2026-09-15-notifyd-images.md1000
-rw-r--r--docs/superpowers/specs/2026-09-15-notification-images-design.md201
4 files changed, 2799 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-15-notification-image-renderers.md b/docs/superpowers/plans/2026-09-15-notification-image-renderers.md
new file mode 100644
index 0000000..660949e
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-15-notification-image-renderers.md
@@ -0,0 +1,218 @@
+# Notification Image 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 balloon draws a notification's content image as a large preview below the text, and the RichText body never fetches a remote image.
+
+**Architecture:** `NotificationBalloon` gains one `Image` bound to the new `notifyd` contract field `image`, sized to the balloon width and capped in height. Inline `<img>` already renders through the existing RichText body; a small sanitizer in the `Notify` singleton strips remote sources before display, so the shell cannot be made to fetch a URL.
+
+**Tech Stack:** Quickshell 0.3.1, Qt6 QML, `Quickshell.Io.FileView`, `Quickshell.Io.Process`.
+
+**Spec:** `docs/superpowers/specs/2026-09-15-notification-images-design.md` (read it before starting). The daemon half is a separate plan and must ship for the image path to be exercised; this plan tolerates a daemon without the field.
+
+## Global Constraints
+
+- Quickshell 0.3.1, Qt6 QML. Run a config with `qs -p <dir>`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f`.
+- GPLv2 only. Existing headers stay; no new source file in this plan needs one.
+- `image` may be absent on an older daemon, so every read guards `!== undefined`.
+- Inline images are local only: a `<img>` whose `src` is `http:` or `https:` is stripped before display. Local paths and `file://` are left alone.
+- No em dashes. No home paths in committed files.
+- Smoke check, harness owns the process:
+
+```bash
+timeout 8 qs -p <dir> 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean"
+```
+
+---
+
+## File Structure
+
+ notifications/NotificationBalloon.qml the large preview image (modify)
+ shared/Notify.qml the sanitizer for inline sources (modify)
+ desktop/NotificationRow.qml use the sanitizer for the row body (modify)
+
+---
+
+### Task 1: The balloon image preview
+
+**Files:**
+- Modify: `notifications/NotificationBalloon.qml`
+
+**Interfaces:**
+- Consumes: the daemon's `image` field (`notification.image`, a path string or undefined).
+- Produces: nothing consumed by later tasks.
+
+- [ ] **Step 1: Make the balloon height account for the image**
+
+In `notifications/NotificationBalloon.qml`, change:
+
+```qml
+ implicitHeight: texts.implicitHeight + 20
+```
+
+to:
+
+```qml
+ implicitHeight: texts.implicitHeight + 20 + (preview.visible ? preview.height + 8 : 0)
+```
+
+- [ ] **Step 2: Add the preview image**
+
+Insert this block immediately after the closing `}` of the `Column { id: texts ... }`
+and before the `Text { id: close ... }`:
+
+```qml
+ // The content image (a screenshot or an app-provided image), below the
+ // text. The daemon writes the path; an older daemon without the field
+ // leaves this hidden. The height matches the scaled width so
+ // PreserveAspectFit does not letterbox, and a tall screenshot is capped at
+ // 240px. Asynchronous so a large screenshot does not stall the shell.
+ Image {
+ id: preview
+ visible: b.notification.image !== "" && b.notification.image !== undefined
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: texts.bottom
+ leftMargin: 10
+ rightMargin: 10
+ topMargin: 8
+ }
+ height: visible && implicitWidth > 0
+ ? Math.min(width * implicitHeight / implicitWidth, 240)
+ : 0
+ source: visible ? "file://" + b.notification.image : ""
+ fillMode: Image.PreserveAspectFit
+ asynchronous: true
+ }
+```
+
+- [ ] **Step 3: 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`. The running daemon currently publishes no `image` field, so
+this also proves the `undefined` guard holds: nothing new is drawn.
+
+- [ ] **Step 4: Confirm by hand, once the daemon plan has shipped**
+
+Ask the user to send:
+
+```bash
+notify-send -u critical -t 30000 -i ~/.cache/opencode/packages/@mohak34/opencode-notifier@latest/node_modules/@mohak34/opencode-notifier/logos/opencode-logo-dark.png "preview" "the logo should fill the balloon width"
+```
+
+Expected: a balloon with the logo as a large image below the text, undistorted and capped in height. A grimblast screenshot (`notify-send -i <screenshot>`) behaves the same.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add notifications/NotificationBalloon.qml
+git commit -m "feat(notifications): draw the content image in the balloon
+
+The daemon now publishes an image path; the balloon shows it below the
+text, scaled to the balloon width with a 240px cap. A daemon without the
+field leaves it hidden, so the renderer and the daemon can ship in
+either order."
+```
+
+---
+
+### Task 2: Strip remote inline image sources
+
+**Files:**
+- Modify: `shared/Notify.qml`
+- Modify: `notifications/NotificationBalloon.qml`
+- Modify: `desktop/NotificationRow.qml`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `Notify.sanitize(body)` returning the body with remote `<img>` tags removed.
+
+- [ ] **Step 1: Add the sanitizer**
+
+In `shared/Notify.qml`, add this function beside `run`/`close`:
+
+```qml
+ // Inline images are local only. A notification is untrusted input, and a
+ // remote <img src> would otherwise make the shell fetch a URL, which leaks
+ // that the notification was shown. This removes such tags before the
+ // RichText body renders; a local path or file:// source is left alone.
+ function sanitize(body) {
+ return (body || "").replace(/<img\b[^>]*\bsrc\s*=\s*["']?\s*https?:\/\/[^>]*>/gi, "");
+ }
+```
+
+- [ ] **Step 2: Use it in both renderers**
+
+In `notifications/NotificationBalloon.qml`, change the body text:
+
+```qml
+ text: b.notification.body || ""
+```
+
+to:
+
+```qml
+ text: Notify.sanitize(b.notification.body)
+```
+
+In `desktop/NotificationRow.qml`, change:
+
+```qml
+ text: row.notification.body || ""
+```
+
+to:
+
+```qml
+ text: Notify.sanitize(row.notification.body)
+```
+
+The balloon already resolves `Notify`; the row does too, through the
+`desktop/Notify.qml` symlink.
+
+- [ ] **Step 3: Smoke check both configs**
+
+```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"
+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: both `clean`.
+
+- [ ] **Step 4: Confirm by hand**
+
+Ask the user to send both, with the logo path from Task 1:
+
+```bash
+notify-send -u critical -t 30000 "inline local" "above<br><img src='file://<logo-path>' width='200'><br>below"
+notify-send -u critical -t 30000 "inline remote" "above<br><img src='https://example.org/does-not-exist.png' width='200'><br>below"
+```
+
+Expected: the first shows the image inline between the lines of text. The
+second shows only the text, with no image and no network request.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add shared/Notify.qml notifications/NotificationBalloon.qml desktop/NotificationRow.qml
+git commit -m "feat(notifications): strip remote inline image sources
+
+A notification is untrusted input. Inline <img> now renders only for
+local sources; an http(s) source is removed before the RichText body is
+shown, so a remote sender cannot make the shell fetch a URL. The row and
+the balloon share the one sanitizer in the Notify singleton."
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** the balloon large preview (Task 1) and the local-only inline policy (Task 2) are the renderer half of the spec. The drawer row correctly gets no image. App-icon theme names are resolved daemon-side, so the renderer is unchanged there.
+
+**Placeholder scan:** none; every step carries its code.
+
+**Type consistency:** `Notify.sanitize(body)` is defined in Task 2 and used by both renderers; `notification.image` is read as a string path in Task 1.
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 <dir>`. 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. <danix@danix.xyz>
+//
+// 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 <dir> 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. <danix@danix.xyz>
+//
+// 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. <danix@danix.xyz>
+//
+// 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. <danix@danix.xyz>
+//
+// 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. <danix@danix.xyz>
+//
+// 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 <id> <label> <key> [label key ...]`, which reads a selection from rofi and runs `notifyctl action`.
+
+The click handlers were written in Task 2; this task supplies the picker they call.
+
+- [ ] **Step 1: Write `notifications/notify-actions.sh`**
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# 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.
+#
+# Pick one of a notification's actions with rofi and invoke it. The renderer
+# passes the labels and keys as arguments, so nothing here parses JSON.
+#
+# notify-actions.sh <id> <label> <key> [label key ...]
+
+set -u
+
+[[ $# -ge 3 ]] || { echo "usage: ${0##*/} <id> <label> <key> ..." >&2; exit 2; }
+
+id="$1"; shift
+labels=()
+keys=()
+while [[ $# -ge 2 ]]; do
+ labels+=("$1")
+ keys+=("$2")
+ shift 2
+done
+
+choice="$(printf '%s\n' "${labels[@]}" | rofi -dmenu -i -p "Notification")"
+[[ -n "$choice" ]] || exit 0
+
+for i in "${!labels[@]}"; do
+ if [[ "${labels[$i]}" == "$choice" ]]; then
+ notifyctl action "$id" "${keys[$i]}"
+ exit $?
+ fi
+done
+```
+
+- [ ] **Step 2: Make it runnable, install it, and smoke check**
+
+The `Notify.actions` helper runs it from PATH, so it is installed beside `notifyctl`:
+
+```bash
+chmod +x notifications/notify-actions.sh
+install -m 755 notifications/notify-actions.sh ~/bin/notify-actions.sh
+command -v notify-actions.sh
+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: the resolved path, and `clean`.
+
+- [ ] **Step 3: Confirm by hand**
+
+Ask the user to run the component (`qs -p notifications`) and send a mail-shaped notification with an action, then click its balloon body and confirm rofi lists the action and choosing it opens the target:
+
+```bash
+notify-send -a test -u normal -A default=open "Action test" "click the body"
+```
+
+Expected: rofi appears with `open`; choosing it invokes the action. An X closes the notification, a right click closes all.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add notifications/notify-actions.sh
+git commit -m "feat(notifications): add the rofi action picker
+
+The renderer passes the action labels and keys as arguments, so the picker
+parses no JSON; a chosen label maps to its key and runs notifyctl action."
+```
+
+---
+
+### Task 4: The drawer's reserved space
+
+**Files:**
+- Modify: `desktop/Drawer.qml` (the reserved `Item`, and the drawer flag write)
+- Create: `desktop/NotificationList.qml`
+- Create: `desktop/NotificationRow.qml`
+
+**Interfaces:**
+- Consumes: `Notify`, `Theme`.
+- Produces: `NotificationList` with a `history` signal; `NotificationRow` with a `required property var notification` and a `live` boolean. Task 5 uses the signal.
+
+- [ ] **Step 1: Write `desktop/NotificationRow.qml`**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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 notification in the drawer: app, summary and body. Live rows close with
+// the X and click through to their actions; a history row is inert.
+Item {
+ id: row
+
+ required property var notification
+ // A history row has no live client: no X, and a click does nothing.
+ property bool live: true
+
+ readonly property bool inert: row.notification.expires !== 0 && Date.now() >= row.notification.expires
+
+ implicitHeight: texts.implicitHeight + 16
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (!row.live) return;
+ const acts = row.notification.actions || [];
+ if (acts.length > 0 && !row.inert) Notify.actions(row.notification.id, acts);
+ else Notify.close(row.notification.id);
+ }
+ }
+
+ Column {
+ id: texts
+ anchors {
+ left: parent.left
+ right: closeBtn.visible ? closeBtn.left : parent.right
+ rightMargin: 10
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 2
+
+ Text {
+ width: parent.width
+ text: (row.notification.app || "") + (row.notification.summary ? " " + row.notification.summary : "")
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.text
+ }
+
+ Text {
+ width: parent.width
+ visible: text !== ""
+ text: row.notification.body || ""
+ textFormat: Text.RichText
+ wrapMode: Text.WordWrap
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ color: Theme.subtext
+ }
+ }
+
+ Text {
+ id: closeBtn
+ visible: row.live
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ 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(row.notification.id)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Write `desktop/NotificationList.qml`**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+
+// The drawer's reserved notification space: a header with a History button,
+// then the live queue, scrollable because the queue can hold 20 and the grid
+// below is fixed. The grid owns its own position, so this space only fills
+// what the grid leaves.
+Item {
+ id: list
+
+ signal history
+
+ Column {
+ anchors.fill: parent
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 28
+
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: "Notifications"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true }
+ color: Theme.subtext
+ }
+
+ Text {
+ id: historyBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "History"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: historyArea.containsMouse ? Theme.accent : Theme.subtext
+
+ MouseArea {
+ id: historyArea
+ anchors.fill: parent
+ anchors.margins: -6
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: list.history()
+ }
+ }
+ }
+
+ Flickable {
+ id: flick
+ width: parent.width
+ height: parent.height - 34
+ clip: true
+ contentWidth: width
+ contentHeight: col.implicitHeight
+ boundsBehavior: Flickable.StopAtBounds
+
+ Column {
+ id: col
+ width: flick.width
+ spacing: 2
+
+ Repeater {
+ model: Notify.queue
+ NotificationRow {
+ required property var modelData
+ width: col.width
+ notification: modelData
+ live: true
+ }
+ }
+
+ Text {
+ width: col.width
+ visible: Notify.queue.length === 0
+ text: "No notifications"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Fill the reserved space and write the drawer flag in `desktop/Drawer.qml`**
+
+Replace the reserved `Item` block:
+
+```qml
+ // Reserved for the notification engine. An empty Item that
+ // claims the space rather than a placeholder graphic: the
+ // grid has to sit where it will sit once notifications
+ // arrive, or the layout is tuned against a position that
+ // does not survive.
+ Item {
+ id: notifications
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ anchors.bottom: grid.top
+ anchors.bottomMargin: 16
+ }
+```
+
+with:
+
+```qml
+ // The notification engine's space. The live queue lists
+ // here while the drawer is open; the balloons stand down
+ // because the drawer flag below is set.
+ NotificationList {
+ id: notifications
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ anchors.bottom: grid.top
+ anchors.bottomMargin: 16
+ onHistory: root.history = true
+ }
+```
+
+Add the imports at the top (the file imports `Quickshell`, `Quickshell.Wayland`, `QtQuick`): add `import Quickshell.Io`.
+
+Add the drawer flag and the open/close writes. After the `Scope { id: root ... }` properties, add:
+
+```qml
+ // The balloons read this to stand down while the drawer holds the space.
+ FileView {
+ id: drawerFlag
+ path: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/notifyd/drawer"
+ atomicWrites: true
+ printErrors: false
+ }
+
+ onOpenChanged: drawerFlag.setText(root.open ? "1\n" : "0\n")
+```
+
+- [ ] **Step 4: Smoke check**
+
+```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`.
+
+- [ ] **Step 5: Confirm by hand**
+
+Ask the user to restart the drawer, open it, and confirm: the reserved space above the grid shows the live queue, its History button is present (inert until Task 5), a notification sent while the drawer is open appears there and **not** as a balloon, and closing it with the X removes it to history. Then with the drawer closed, confirm a balloon appears again.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/Drawer.qml desktop/NotificationList.qml desktop/NotificationRow.qml
+git commit -m "feat(desktop): fill the drawer's notification space
+
+The reserved Item becomes the live queue with a History button, scrollable
+because the queue can hold 20. The drawer writes notifyd/drawer so the
+balloon shell stands down while this space is showing, which is what stops a
+notification appearing twice."
+```
+
+---
+
+### Task 5: The history page
+
+**Files:**
+- Create: `desktop/NotificationHistory.qml`
+- Modify: `desktop/Drawer.qml` (a history view and the `history` property)
+
+**Interfaces:**
+- Consumes: `Notify.history`, `Notify.clearHistory`, `NotificationRow`.
+- Produces: a drawer view reachable from the reserved space's History button. This plan ships a clear-all only; see the deviation note in Task 6's README.
+
+- [ ] **Step 1: Write `desktop/NotificationHistory.qml`**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+
+// The history ring: the last 20 dismissed or evicted notifications, newest
+// first, with a clear all. Rows are inert; only clear-all removes them, since
+// the daemon has no per-id history deletion.
+Column {
+ id: page
+
+ spacing: 4
+
+ Item {
+ width: parent.width
+ height: 28
+
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: Notify.history.length + " in history"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+
+ Text {
+ id: clearBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ visible: Notify.history.length > 0
+ text: "Clear all"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: clearArea.containsMouse ? Theme.red : Theme.subtext
+
+ MouseArea {
+ id: clearArea
+ anchors.fill: parent
+ anchors.margins: -6
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Notify.clearHistory()
+ }
+ }
+ }
+
+ Repeater {
+ model: Notify.history
+
+ NotificationRow {
+ required property var modelData
+ width: page.width
+ notification: modelData
+ live: false
+ }
+ }
+
+ Text {
+ width: parent.width
+ visible: Notify.history.length === 0
+ text: "Nothing in history"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+}
+```
+
+- [ ] **Step 2: Add the history view to `desktop/Drawer.qml`**
+
+Add to the root, beside `property string page`:
+
+```qml
+ // The history page is reachable only from the reserved space, so it is not
+ // a module and has no tile: a boolean, reset when the drawer closes.
+ property bool history: false
+```
+
+In `close()`, add `root.history = false;` after `root.page = "";`.
+
+In the grid view's `visible:` condition, add the history guard so the grid does not show through the history page (the page has no opaque background):
+
+```qml
+ visible: root.page === "" && !root.history
+```
+
+Add the history loader after the page view Loader:
+
+```qml
+ // --- history view ---
+
+ Loader {
+ id: historyLoader
+ anchors.fill: parent
+ anchors.margins: 16
+ active: root.history
+ sourceComponent: Component {
+ Page {
+ title: "History"
+ NotificationHistory { width: parent.width }
+ }
+ }
+ onLoaded: if (item && item.back) item.back.connect(() => root.history = false)
+ }
+```
+
+The `Page` and `Theme` names resolve through the root directory's own types, the same way the module page loader uses `Page`.
+
+- [ ] **Step 3: Also let Escape leave history**
+
+In the panel's `Keys.onEscapePressed`, change:
+
+```qml
+ Keys.onEscapePressed: {
+ if (root.page) root.page = "";
+ else root.close();
+ }
+```
+
+to:
+
+```qml
+ Keys.onEscapePressed: {
+ if (root.history) root.history = false;
+ else if (root.page) root.page = "";
+ else root.close();
+ }
+```
+
+- [ ] **Step 4: Smoke check**
+
+```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`.
+
+- [ ] **Step 5: Confirm by hand**
+
+Ask the user to open the drawer, click History, and confirm the ring lists closed notifications newest-first with a clear all that empties it, that the back arrow and Escape return to the grid, and that a notification closed in the reserved space then appears in history.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/Drawer.qml desktop/NotificationHistory.qml
+git commit -m "feat(desktop): add the notification history page
+
+Reachable only from the reserved space's History button, so it is a drawer
+view, not a module and not a tile. Rows are inert; clear-all empties the
+ring, since the daemon has no per-id history deletion."
+```
+
+---
+
+### Task 6: The Status page snooze row
+
+**Files:**
+- Create: `desktop/modules/status/SnoozeRow.qml`
+- Modify: `desktop/modules/status/StatusPage.qml`
+
+**Interfaces:**
+- Consumes: `Notify.snoozeUntil`, `notify-snooze.sh`, the shared `Switch`, `Theme`, and the last-used file `~/.local/state/notify-snooze.minutes`.
+- Produces: a snooze control on the Status page. The balloon shell already reads `Notify.snoozeUntil`, so this only drives the file.
+
+- [ ] **Step 1: Write `desktop/modules/status/SnoozeRow.qml`**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import QtQuick.Controls
+import "../.."
+
+// A switch plus a minutes field. On snoozes for the typed minutes through
+// notify-snooze.sh; off clears. The last used value is persisted by the
+// script, so the field prefills from it.
+Item {
+ id: row
+
+ // A tick so the switch reflects the snooze ending on its own.
+ property double now: Date.now()
+ Timer {
+ interval: 1000
+ running: true
+ repeat: true
+ onTriggered: row.now = Date.now()
+ }
+
+ readonly property bool active: Notify.snoozeUntil > row.now
+
+ implicitHeight: Math.max(texts.implicitHeight, sw.implicitHeight) + 16
+
+ function apply(on) {
+ if (on) {
+ const n = parseInt(minutes.text, 10);
+ snooze.command = ["notify-snooze.sh", String(isNaN(n) || n < 1 ? 30 : n)];
+ } else {
+ snooze.command = ["notify-snooze.sh", "off"];
+ }
+ snooze.running = false;
+ snooze.running = true;
+ }
+
+ Process { id: snooze; printErrors: false }
+
+ FileView {
+ id: lastUsed
+ path: `${Quickshell.env("HOME")}/.local/state/notify-snooze.minutes`
+ printErrors: false
+ onLoaded: minutes.text = text().trim()
+ onLoadFailed: minutes.text = "30"
+ }
+
+ Column {
+ id: texts
+ anchors {
+ left: parent.left
+ right: controls.left
+ rightMargin: 12
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 2
+
+ Text {
+ text: "Snooze"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+
+ Text {
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text: row.active ? "All notification balloons are held until snooze ends."
+ : "Hold every notification balloon for a number of minutes."
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+ }
+
+ Row {
+ id: controls
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ TextField {
+ id: minutes
+ width: 48
+ height: 28
+ text: "30"
+ horizontalAlignment: TextInput.AlignHCenter
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ background: Rectangle {
+ radius: 6
+ color: Qt.alpha(Theme.surface, 0.9)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.15)
+ }
+ validator: IntValidator { bottom: 1; top: 1440 }
+ }
+
+ Switch {
+ id: sw
+ checked: row.active
+ onToggled: row.apply(!row.active)
+ }
+ }
+
+ onNowChanged: sw.checked = row.active
+}
+```
+
+- [ ] **Step 2: Add the row to `desktop/modules/status/StatusPage.qml`**
+
+Append after the presentation row:
+
+```qml
+ Rectangle {
+ width: page.width
+ height: 1
+ color: Qt.alpha(Theme.text, 0.08)
+ }
+
+ SnoozeRow {
+ width: page.width
+ }
+```
+
+- [ ] **Step 3: Smoke check**
+
+```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`.
+
+- [ ] **Step 4: Confirm by hand**
+
+Ask the user to open Status. Confirm: the Snooze row has a minutes field and a switch; typing `2` and toggling on sets the switch and writes `notifyd/snooze` about two minutes out; sending a notification draws no balloon while snoozed but still appears in the reserved space; `notify-snooze.sh off` or the switch off ends it; reopening the page after a shell restart shows the last used minutes.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/status/SnoozeRow.qml desktop/modules/status/StatusPage.qml
+git commit -m "feat(status): add the snooze row
+
+A switch and a free-text minutes field driving notify-snooze.sh. Snooze is a
+file the balloon shell reads, so the row only writes it; the switch follows
+the file, including a snooze that ends while the page is open."
+```
+
+---
+
+### Task 7: Wire it up and record the traps
+
+**Files:**
+- Modify: `notifications/README.md`
+- Modify: `AGENTS.md`
+- Modify outside the repo: `~/.config/hypr/sections/decorations.lua`, `~/.config/hypr/sections/autostart.lua` (user steps)
+
+**Interfaces:**
+- Consumes: the finished component.
+- Produces: nothing executable.
+
+- [ ] **Step 1: Ask the user to add the blur rule**
+
+Hyprland blurs a layer surface only when a rule names its namespace. Ask the user to append to `~/.config/hypr/sections/decorations.lua`:
+
+```lua
+-- Frosted glass for the quickshell notification balloons.
+hl.layer_rule({
+ name = "blur-notifications",
+ match = { namespace = "^(quickshell-notifications)$" },
+ blur = true,
+ xray = false,
+ ignore_alpha = 0.1,
+})
+```
+
+Then `hyprctl reload`.
+
+- [ ] **Step 2: Ask the user to add the component to autostart**
+
+In `~/.config/hypr/sections/autostart.lua`, beside the other quickshell lines, using the same absolute prefix those lines use:
+
+```lua
+ hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/notifications")
+```
+
+- [ ] **Step 3: Add the deviation note to `notifications/README.md`**
+
+Append:
+
+```markdown
+## History rows
+
+The spec calls history rows closable individually. `notifyctl` has no per-id
+history delete, only `clear-history`, so the history page ships a clear all
+and inert rows. The daemon verb and the renderer row it needs are tracked in
+the `notifyd` repo's `TODO.md`; when that ships, the row's X is wired to
+`notifyctl history-remove`.
+```
+
+- [ ] **Step 4: Add the traps to `AGENTS.md`**
+
+Append to the per-component notes list:
+
+```markdown
+- **The notification daemon is a separate process that owns the D-Bus name.**
+ The quickshell side only reads its published files under
+ `$XDG_RUNTIME_DIR/notifyd/`; the files are the interface, and `notifyctl` is
+ the one path back. `notifications/` is inert without it: balloons still
+ draw, but close and actions do nothing.
+- **Suppression is in the balloon shell, not the daemon.** `status.dnd`
+ withholds low and normal balloons and `notifyd/snooze` withholds all, but
+ the drawer's reserved space lists everything, because a list the user opened
+ is not an interruption.
+- **A notification appears as a balloon or in the drawer's reserved space,
+ never both.** The drawer writes `notifyd/drawer` and the balloon shell reads
+ it; `Drawer.qml`'s reserved `Item` is the space.
+- **The dead `waybar/modules/custom/notification.jsonc` and
+ `waybar/scripts/notifications.py` still shell out to `dunstctl`.** They are
+ not in the live waybar config; do not resurrect them.
+```
+
+- [ ] **Step 5: Run every check**
+
+```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"
+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"
+bash desktop/modules/status/test-statusctl.sh
+```
+
+Expected: both `clean`; statusctl `9 passed, 0 failed`.
+
+- [ ] **Step 6: Confirm the process count**
+
+```bash
+pgrep -cx qs
+```
+
+Expected: the shells the user runs, one more than before if `notifications/` is running (normally four: desktop, appearance, window-switcher, notifications).
+
+- [ ] **Step 7: Final visual pass**
+
+Ask the user to confirm the whole loop: a balloon bottom-right on DP-1 with an icon and markup body; opening the drawer replaces it with a row in the reserved space; the History button opens the ring; DND still pops critical only while listing both; snooze holds every balloon; and a mail notification's action opens through rofi.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add notifications/README.md AGENTS.md
+git commit -m "docs(notifications): document the renderers and record the traps
+
+The daemon is a separate process and the files are the interface; suppression
+lives in the balloon shell so the drawer can list what DND held back; and the
+drawer flag is what stops a notification appearing as both a balloon and a
+row."
+```
+
+---
+
+## Notes for the implementer
+
+**Suppression is a filter, not daemon state.** DND and snooze withhold a balloon; they never stop the notification reaching the queue or history. If you move the filter into the daemon, the drawer loses the items DND held back, which is the behaviour the spec rejected.
+
+**The drawer flag is the whole no-double-show mechanism.** `Drawer.qml` writes `notifyd/drawer`; `Balloons.qml` reads `Notify.drawerOpen`. Do not add a second source of truth.
+
+**`Notify.actions` passes pairs as flattened arguments.** A pair from the JSON is `[key, label]`; the picker takes `<label> <key>`. Getting that order backwards makes rofi display the key and invoke the label.
+
+**A missing published file is normal at first start.** Every `FileView` here has an `onLoadFailed` that means empty or off, never an error: the daemon writes `[]` on its own start, but a renderer may win the race.
diff --git a/docs/superpowers/plans/2026-09-15-notifyd-images.md b/docs/superpowers/plans/2026-09-15-notifyd-images.md
new file mode 100644
index 0000000..104e566
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-15-notifyd-images.md
@@ -0,0 +1,1000 @@
+# notifyd Image Support 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 daemon accepts the freedesktop image hints, publishes a content image path for every notification, resolves app_icon / image-path theme names to files, and cleans up what it owns.
+
+**Architecture:** Image hints are parsed as pure functions in `internal/notify`, decoded and encoded to PNG with the standard library, and written under `$XDG_RUNTIME_DIR/notifyd/img/`. `Popup` gains an `image` field that the renderers read. A theme-name resolver reads qt6ct (then GTK3, then hicolor) and searches the XDG icon directories. The store gains an injected cleanup callback so a daemon-owned image is unlinked when its notification leaves the live queue.
+
+**Tech Stack:** Go, `github.com/godbus/dbus/v5`, the standard library (`image`, `image/png`), `dbus-run-session` for the integration test.
+
+**Spec:** `docs/superpowers/specs/2026-09-15-notification-images-design.md` (in the quickshell repo; read it before starting). The renderer half is a separate plan.
+
+## Global Constraints
+
+- Go module path `danix.xyz/notifyd`. The only third-party dependency is `github.com/godbus/dbus/v5`; everything else is the standard library.
+- GPLv2 only. Every new `.go` file begins with the standard per-file header notice (copy it from `policy.go`).
+- The published contract is exact: `Popup` gains `"image"` (a path, empty when none); `created` and `expires` stay epoch milliseconds.
+- `GetCapabilities` becomes `actions`, `body-markup`, `body-images`, `icon-static`, `persistence`.
+- The spec's image priority is `image-data`, then `image-path`, then the deprecated `icon_data`; `app_icon` stays the icon, not a fallback image.
+- `image-data` / `icon_data` are a D-Bus `(iiibiiay)` struct: width, height, rowstride, has_alpha, bits_per_sample, channels, data (RGB byte order).
+- Theme source is qt6ct `icon_theme`, then GTK3 `gtk-icon-theme-name`, then `hicolor`. Search `$XDG_DATA_HOME/icons` then `$XDG_DATA_DIRS/icons`.
+- No home paths in committed files. `gofmt` clean. `go vet ./...` clean.
+- Test commands: `go test ./...` for pure logic; `dbus-run-session -- go test ./internal/notify` for the bus test; `bash test-notifyctl.sh` for the end to end check.
+- Work in the `notifyd` repo (`~/Programming/GIT/notifyd`), not the quickshell repo.
+
+---
+
+## File Structure
+
+ internal/notify/image.go image hint parsing and PNG encoding (create)
+ internal/notify/image_test.go
+ internal/notify/icons.go theme-name resolution to an icon file (create)
+ internal/notify/icons_test.go
+ internal/notify/policy.go add image hint entry points (modify)
+ internal/notify/store.go Popup.Image and the removal callback (modify)
+ internal/notify/store_test.go
+ internal/notify/files.go image directory and PNG write (modify)
+ internal/notify/files_test.go
+ internal/notify/service.go capabilities, materialisation, wiring (modify)
+ internal/notify/service_test.go
+ test-notifyctl.sh assert the image field survives publish (modify)
+
+---
+
+### Task 1: Image hint parsing and PNG encoding
+
+**Files:**
+- Create: `internal/notify/image.go`
+- Test: `internal/notify/image_test.go`
+
+**Interfaces:**
+- Consumes: `github.com/godbus/dbus/v5`.
+- Produces: `type RawImage struct { Width, Height, RowStride int; HasAlpha bool; BitsPerSample, Channels int; Data []byte }`; `ImageDataFromHints(hints map[string]dbus.Variant) (*RawImage, bool)`; `ImagePathFromHints(hints map[string]dbus.Variant) (string, bool)`; `(*RawImage) PNG() ([]byte, error)`. Tasks 3 and 4 use these.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/notify/image_test.go`:
+
+```go
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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.
+
+package notify
+
+import (
+ "bytes"
+ "image/png"
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// rawVariant builds the (iiibiiay) struct the bus delivers for image-data.
+func rawVariant(w, h, stride int, alpha bool, ch int, data []byte) dbus.Variant {
+ return dbus.MakeVariant([]interface{}{
+ int32(w), int32(h), int32(stride), alpha, int32(8), int32(ch), data,
+ })
+}
+
+func TestImageDataFromHints(t *testing.T) {
+ // 2x1 RGBA: red, green.
+ rgba := []byte{255, 0, 0, 255, 0, 255, 0, 255}
+ cases := []struct {
+ name string
+ hints map[string]dbus.Variant
+ wantW int
+ want bool
+ }{
+ {"image-data wins", map[string]dbus.Variant{
+ "image-data": rawVariant(2, 1, 8, true, 4, rgba),
+ "image-path": dbus.MakeVariant("/tmp/x.png"),
+ }, 2, true},
+ {"icon_data fallback", map[string]dbus.Variant{
+ "icon_data": rawVariant(2, 1, 8, true, 4, rgba),
+ }, 2, true},
+ {"image_data alias", map[string]dbus.Variant{
+ "image_data": rawVariant(2, 1, 8, true, 4, rgba),
+ }, 2, true},
+ {"absent", map[string]dbus.Variant{}, 0, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, ok := ImageDataFromHints(c.hints)
+ if ok != c.want {
+ t.Fatalf("ok = %v, want %v", ok, c.want)
+ }
+ if ok && got.Width != c.wantW {
+ t.Fatalf("width = %d, want %d", got.Width, c.wantW)
+ }
+ })
+ }
+}
+
+func TestImagePathFromHints(t *testing.T) {
+ got, ok := ImagePathFromHints(map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")})
+ if !ok || got != "/tmp/shot.png" {
+ t.Fatalf("got %q ok=%v", got, ok)
+ }
+ if _, ok := ImagePathFromHints(map[string]dbus.Variant{}); ok {
+ t.Fatal("empty hints must not report a path")
+ }
+}
+
+func TestRawImagePNG(t *testing.T) {
+ // 2x1 RGBA on a rowstride wider than the data, to prove stride is honoured.
+ r := &RawImage{Width: 2, Height: 1, RowStride: 12, HasAlpha: true, BitsPerSample: 8, Channels: 4,
+ Data: []byte{255, 0, 0, 255, 0, 255, 0, 255, 9, 9, 9, 9}}
+ data, err := r.PNG()
+ if err != nil {
+ t.Fatalf("PNG: %v", err)
+ }
+ img, err := png.Decode(bytes.NewReader(data))
+ if err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if img.Bounds().Dx() != 2 || img.Bounds().Dy() != 1 {
+ t.Fatalf("bounds = %v", img.Bounds())
+ }
+ r0, g0, b0, a0 := img.At(0, 0).RGBA()
+ if r0>>8 != 255 || g0>>8 != 0 || b0>>8 != 0 || a0>>8 != 255 {
+ t.Fatalf("pixel 0 = %d %d %d %d", r0>>8, g0>>8, b0>>8, a0>>8)
+ }
+}
+
+func TestRawImagePNGRejectsBadData(t *testing.T) {
+ if _, err := (&RawImage{Width: 0, Height: 1, Channels: 4, BitsPerSample: 8}).PNG(); err == nil {
+ t.Fatal("zero width must error")
+ }
+ if _, err := (&RawImage{Width: 1, Height: 1, Channels: 2, BitsPerSample: 8}).PNG(); err == nil {
+ t.Fatal("channels 2 must error")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/notify -run 'TestImage|TestRaw' -v`
+Expected: FAIL with `undefined: RawImage` and the hint functions.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `internal/notify/image.go`:
+
+```go
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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.
+
+package notify
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// RawImage is the spec's image-data structure (iiibiiay). Data is RGB byte
+// order: 4 bytes per pixel with alpha, 3 without, and rows are RowStride
+// bytes apart, which may exceed Width*Channels.
+type RawImage struct {
+ Width int
+ Height int
+ RowStride int
+ HasAlpha bool
+ BitsPerSample int
+ Channels int
+ Data []byte
+}
+
+// ImageDataFromHints reads the raw image struct, preferring the spec key then
+// the deprecated icon_data, then the underscore alias older libnotify sent.
+func ImageDataFromHints(hints map[string]dbus.Variant) (*RawImage, bool) {
+ for _, key := range []string{"image-data", "icon_data", "image_data"} {
+ v, ok := hints[key]
+ if !ok {
+ continue
+ }
+ if r, ok := rawImageFromVariant(v); ok {
+ return r, true
+ }
+ }
+ return nil, false
+}
+
+// ImagePathFromHints reads image-path, a URI, a path, or a theme icon name.
+func ImagePathFromHints(hints map[string]dbus.Variant) (string, bool) {
+ v, ok := hints["image-path"]
+ if !ok {
+ return "", false
+ }
+ s, ok := v.Value().(string)
+ if !ok || s == "" {
+ return "", false
+ }
+ return s, true
+}
+
+// rawImageFromVariant accepts the []interface{} godbus yields for a struct.
+// Each numeric field may arrive as int32 or int depending on the encoder.
+func rawImageFromVariant(v dbus.Variant) (*RawImage, bool) {
+ f, ok := v.Value().([]interface{})
+ if !ok || len(f) != 7 {
+ return nil, false
+ }
+ r := &RawImage{}
+ var okW, okH, okS, okC, okD bool
+ r.Width, okW = asInt(f[0])
+ r.Height, okH = asInt(f[1])
+ r.RowStride, okS = asInt(f[2])
+ r.HasAlpha, _ = f[3].(bool)
+ r.BitsPerSample, _ = asInt(f[4])
+ r.Channels, okC = asInt(f[5])
+ r.Data, okD = f[6].([]byte)
+ if !okW || !okH || !okS || !okC || !okD {
+ return nil, false
+ }
+ return r, true
+}
+
+func asInt(v any) (int, bool) {
+ switch n := v.(type) {
+ case int:
+ return n, true
+ case int32:
+ return int(n), true
+ case int64:
+ return int(n), true
+ case uint32:
+ return int(n), true
+ }
+ return 0, false
+}
+
+// PNG encodes the raw pixels as a PNG the renderer can load.
+func (r *RawImage) PNG() ([]byte, error) {
+ if r.Width <= 0 || r.Height <= 0 {
+ return nil, fmt.Errorf("notifyd: image %dx%d", r.Width, r.Height)
+ }
+ if r.BitsPerSample != 8 || (r.Channels != 3 && r.Channels != 4) {
+ return nil, fmt.Errorf("notifyd: image bits=%d channels=%d", r.BitsPerSample, r.Channels)
+ }
+ stride := r.RowStride
+ if stride < r.Width*r.Channels {
+ stride = r.Width * r.Channels
+ }
+ if len(r.Data) < stride*(r.Height-1)+r.Width*r.Channels {
+ return nil, fmt.Errorf("notifyd: image data short: %d bytes", len(r.Data))
+ }
+ img := image.NewRGBA(image.Rect(0, 0, r.Width, r.Height))
+ for y := 0; y < r.Height; y++ {
+ row := r.Data[y*stride:]
+ for x := 0; x < r.Width; x++ {
+ if r.Channels == 4 {
+ i := x * 4
+ img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], row[i+3]})
+ } else {
+ i := x * 3
+ img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], 255})
+ }
+ }
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/notify -run 'TestImage|TestRaw' -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/image.go internal/notify/image_test.go
+git commit -m "feat(notify): parse the image hints and encode them to PNG
+
+image-data (and the deprecated icon_data) is the (iiibiiay) struct; the
+PNG encoder honours rowstride and both 3- and 4-channel data. The path
+and data readers are pure, so the service can apply the spec's priority
+and the store stays free of image handling."
+```
+
+---
+
+### Task 2: Theme-name resolution
+
+**Files:**
+- Create: `internal/notify/icons.go`
+- Test: `internal/notify/icons_test.go`
+
+**Interfaces:**
+- Consumes: nothing but the standard library and `os`.
+- Produces: `IconThemeName() string`; `ResolveIcon(value string) string`; `XDGIconDirs() []string`. Task 4 uses both entry points.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/notify/icons_test.go`:
+
+```go
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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.
+
+package notify
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestResolveIconPathAndURI(t *testing.T) {
+ if got := ResolveIcon("/usr/share/icons/x/apps/48/firefox.png"); got != "/usr/share/icons/x/apps/48/firefox.png" {
+ t.Fatalf("path passthrough: %q", got)
+ }
+ if got := ResolveIcon("file:///tmp/shot.png"); got != "/tmp/shot.png" {
+ t.Fatalf("uri: %q", got)
+ }
+ if got := ResolveIcon(""); got != "" {
+ t.Fatalf("empty: %q", got)
+ }
+}
+
+func TestResolveIconThemeName(t *testing.T) {
+ root := t.TempDir()
+ // Theme "Plum" inherits "Base"; the icon is only in Base.
+ theme := filepath.Join(root, "icons", "Plum")
+ base := filepath.Join(root, "icons", "Base")
+ plumIndex := filepath.Join(theme, "index.theme")
+ baseApp := filepath.Join(base, "apps", "48")
+ if err := os.MkdirAll(filepath.Join(theme, "apps", "scalable"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(baseApp, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(plumIndex, []byte("[Icon Theme]\nInherits=Base\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(baseApp, "firefox.svg"), []byte("<svg/>"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("XDG_DATA_HOME", root)
+ t.Setenv("XDG_DATA_DIRS", "")
+ t.Setenv("HOME", filepath.Join(root, "home"))
+ if err := os.MkdirAll(filepath.Join(root, "home", ".config", "qt6ct"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "home", ".config", "qt6ct", "qt6ct.conf"), []byte("icon_theme=Plum\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got := ResolveIcon("firefox")
+ want := filepath.Join(root, "icons", "Base", "apps", "48", "firefox.svg")
+ if got != want {
+ t.Fatalf("resolved %q, want %q", got, want)
+ }
+ if got := ResolveIcon("no-such-icon-xyz"); got != "" {
+ t.Fatalf("missing name must be empty, got %q", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/notify -run TestResolveIcon -v`
+Expected: FAIL with `undefined: ResolveIcon`.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `internal/notify/icons.go`:
+
+```go
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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.
+
+package notify
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// IconThemeName reads the desktop's current icon theme: qt6ct is the truth on
+// this desktop, then the GTK3 setting, then hicolor. A missing or empty value
+// falls through.
+func IconThemeName() string {
+ if v := iniValue(filepath.Join(homeDir(), ".config", "qt6ct", "qt6ct.conf"), "icon_theme"); v != "" {
+ return v
+ }
+ if v := iniValue(filepath.Join(homeDir(), ".config", "gtk-3.0", "settings.ini"), "gtk-icon-theme-name"); v != "" {
+ return v
+ }
+ return "hicolor"
+}
+
+// ResolveIcon turns an app_icon or image-path value into a file path. A URI is
+// trimmed, a path is returned unchanged, and a bare name is looked up in the
+// icon theme. Nothing found is an empty string, which renders no image.
+func ResolveIcon(value string) string {
+ if value == "" {
+ return ""
+ }
+ if strings.HasPrefix(value, "file://") {
+ return strings.TrimPrefix(value, "file://")
+ }
+ if strings.Contains(value, "/") {
+ return value
+ }
+ return lookupThemeIcon(value, IconThemeName())
+}
+
+// XDGIconDirs is the icon search path: the user's dir then each data dir.
+func XDGIconDirs() []string {
+ home := os.Getenv("XDG_DATA_HOME")
+ if home == "" {
+ home = filepath.Join(homeDir(), ".local", "share")
+ }
+ dirs := []string{filepath.Join(home, "icons")}
+ for _, d := range filepath.SplitList(os.Getenv("XDG_DATA_DIRS")) {
+ if d != "" {
+ dirs = append(dirs, filepath.Join(d, "icons"))
+ }
+ }
+ if len(dirs) == 1 {
+ dirs = append(dirs, "/usr/local/share/icons", "/usr/share/icons")
+ }
+ return dirs
+}
+
+// lookupThemeIcon searches the theme, then its Inherits chain, then hicolor.
+func lookupThemeIcon(name, theme string) string {
+ seen := map[string]bool{}
+ for theme != "" && !seen[theme] {
+ seen[theme] = true
+ found, next := "", ""
+ for _, root := range XDGIconDirs() {
+ base := filepath.Join(root, theme)
+ if p := findInTheme(base, name); p != "" {
+ found = p
+ break
+ }
+ if next == "" {
+ next = inheritsOf(base)
+ }
+ }
+ if found != "" {
+ return found
+ }
+ theme = next
+ }
+ for _, root := range XDGIconDirs() {
+ if p := findInTheme(filepath.Join(root, "hicolor"), name); p != "" {
+ return p
+ }
+ }
+ return ""
+}
+
+// findInTheme prefers scalable then the largest raster under apps/.
+func findInTheme(base, name string) string {
+ sizes := []string{"scalable"}
+ matches, _ := filepath.Glob(filepath.Join(base, "apps", "[0-9]*"))
+ for _, m := range matches {
+ sizes = append(sizes, filepath.Base(m))
+ }
+ numeric := sizes[1:]
+ sort.Slice(numeric, func(i, j int) bool {
+ a, _ := strconv.Atoi(strings.TrimSuffix(numeric[i], "@2x"))
+ b, _ := strconv.Atoi(strings.TrimSuffix(numeric[j], "@2x"))
+ return a > b
+ })
+ for _, size := range sizes {
+ for _, ext := range []string{"svg", "png", "xpm"} {
+ p := filepath.Join(base, "apps", size, name+"."+ext)
+ if fileExists(p) {
+ return p
+ }
+ }
+ }
+ return ""
+}
+
+func inheritsOf(base string) string {
+ v := iniValue(filepath.Join(base, "index.theme"), "Inherits")
+ if i := strings.IndexByte(v, ','); i >= 0 {
+ v = v[:i]
+ }
+ return strings.TrimSpace(v)
+}
+
+func iniValue(path, key string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
+ continue
+ }
+ k, v, _ := strings.Cut(line, "=")
+ if strings.TrimSpace(k) == key {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+func homeDir() string {
+ if h := os.Getenv("HOME"); h != "" {
+ return h
+ }
+ return os.TempDir()
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/notify -run TestResolveIcon -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/icons.go internal/notify/icons_test.go
+git commit -m "feat(notify): resolve theme icon names to files
+
+qt6ct's icon_theme is authoritative on this desktop, with the GTK3
+setting and hicolor as fallbacks. The lookup prefers scalable, then the
+largest raster, and follows the theme's Inherits chain, so an app that
+passes a name instead of a path gets an icon."
+```
+
+---
+
+### Task 3: The image field and its lifecycle
+
+**Files:**
+- Modify: `internal/notify/store.go`
+- Modify: `internal/notify/files.go`
+- Test: `internal/notify/store_test.go`
+- Test: `internal/notify/files_test.go`
+
+**Interfaces:**
+- Consumes: `Popup` from `store.go`.
+- Produces: `Popup.Image string`; `NewStore(emit, publish, removeImage)` with a third parameter `removeImage func(string)`; `(*Store) SetImage(id uint32, path string)`; `WriteImage(dir string, id uint32, data []byte) (string, error)`; `ImagesDir(dir string) string`. Task 4 wires them.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/notify/store_test.go`:
+
+```go
+func TestStoreRemovesImageOnDismissAndExpire(t *testing.T) {
+ var removed []string
+ s := NewStore(func(uint32, uint32) {}, func(_, _ []Popup) {}, func(p string) { removed = append(removed, p) })
+ id, _ := s.Add(&Popup{Image: "/run/img/1.png"}, "", 0)
+ s.SetImage(id, "/run/img/other.png")
+ s.Dismiss(id, 2)
+ if len(removed) != 1 || removed[0] != "/run/img/other.png" {
+ t.Fatalf("dismiss removed %v", removed)
+ }
+}
+
+func TestStoreRemovesImageOnReplace(t *testing.T) {
+ var removed []string
+ s := NewStore(func(uint32, uint32) {}, func(_, _ []Popup) {}, func(p string) { removed = append(removed, p) })
+ s.Add(&Popup{Image: "/run/img/old.png"}, "tag", 0)
+ id, _ := s.Add(&Popup{Image: "/run/img/new.png"}, "tag", 0)
+ _ = id
+ if len(removed) != 1 || removed[0] != "/run/img/old.png" {
+ t.Fatalf("replace removed %v", removed)
+ }
+}
+```
+
+Create `internal/notify/files_test.go` if it does not exist, else append:
+
+```go
+func TestWriteImage(t *testing.T) {
+ dir := t.TempDir()
+ p, err := WriteImage(dir, 7, []byte("png-bytes"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if filepath.Base(p) != "7.png" {
+ t.Fatalf("path %q", p)
+ }
+ if b, _ := os.ReadFile(p); string(b) != "png-bytes" {
+ t.Fatalf("contents %q", b)
+ }
+ if got := ImagesDir(dir); got != filepath.Join(dir, "img") {
+ t.Fatalf("ImagesDir %q", got)
+ }
+}
+```
+
+Add the needed imports (`os`, `path/filepath`) to the test files.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/notify -run 'TestStoreRemovesImage|TestWriteImage' -v`
+Expected: FAIL with `undefined: SetImage` / `WriteImage` and a NewStore arity error.
+
+- [ ] **Step 3: Write the implementation**
+
+In `internal/notify/store.go`, add the field to `Popup`:
+
+```go
+ Image string `json:"image"`
+```
+
+Add the callback to `Store` and `NewStore`:
+
+```go
+type Store struct {
+ // ...existing fields...
+ removeImage func(string)
+}
+
+func NewStore(emit func(id, reason uint32), publish func(live, history []Popup), removeImage func(string)) *Store {
+ return &Store{
+ nextID: 1,
+ entries: map[uint32]*live{},
+ emit: emit,
+ publish: publish,
+ removeImage: removeImage,
+ }
+}
+```
+
+In `Add`, before overwriting a replaced entry, remove its daemon-owned image:
+
+```go
+ add := &live{Popup: *n, Stack: stack}
+ if old != nil {
+ if s.removeImage != nil {
+ s.removeImage(old.Image)
+ }
+ add.ID = old.ID
+ // ...unchanged...
+```
+
+Add `SetImage` and clean up in `removeLocked` and `Expire`:
+
+```go
+// SetImage attaches a materialised image path to a live entry and republishes.
+func (s *Store) SetImage(id uint32, path string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ n, ok := s.entries[id]
+ if !ok {
+ return
+ }
+ n.Image = path
+ s.publishLocked()
+}
+```
+
+In `Expire`, after `n.Closed = true`, remove the image (the balloon is gone):
+
+```go
+ if s.removeImage != nil {
+ s.removeImage(n.Image)
+ }
+```
+
+In `removeLocked`, remove the image before deleting:
+
+```go
+func (s *Store) removeLocked(id uint32) {
+ if n, ok := s.entries[id]; ok && s.removeImage != nil {
+ s.removeImage(n.Image)
+ }
+ delete(s.entries, id)
+ // ...unchanged...
+```
+
+Update the two existing `NewStore(...)` call sites in `store_test.go` to pass `nil` or a no-op as the third argument.
+
+In `internal/notify/files.go`, add:
+
+```go
+// ImagesDir is where the daemon writes decoded image-data.
+func ImagesDir(dir string) string {
+ return filepath.Join(dir, "img")
+}
+
+// WriteImage writes a decoded image as <id>.png under the image directory.
+func WriteImage(dir string, id uint32, data []byte) (string, error) {
+ imgDir := ImagesDir(dir)
+ if err := os.MkdirAll(imgDir, 0o700); err != nil {
+ return "", err
+ }
+ path := filepath.Join(imgDir, strconv.FormatUint(uint64(id), 10)+".png")
+ tmp, err := os.CreateTemp(imgDir, ".img-*")
+ if err != nil {
+ return "", err
+ }
+ tmpName := tmp.Name()
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ os.Remove(tmpName)
+ return "", err
+ }
+ if err := tmp.Close(); err != nil {
+ os.Remove(tmpName)
+ return "", err
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ os.Remove(tmpName)
+ return "", err
+ }
+ return path, nil
+}
+```
+
+Add `strconv` to the `files.go` imports.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/notify -run 'TestStoreRemovesImage|TestWriteImage' -v`
+Expected: PASS. Then run `go test ./...` and `go vet ./...`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/store.go internal/notify/store_test.go internal/notify/files.go internal/notify/files_test.go
+git commit -m "feat(notify): add the image field and its cleanup
+
+Popup gains image, and the store calls an injected removeImage when an
+entry is dismissed, evicted, replaced or expired, so a daemon-written
+PNG does not outlive its balloon. The service decides what is
+daemon-owned; the store only names the path."
+```
+
+---
+
+### Task 4: Service materialisation and capabilities
+
+**Files:**
+- Modify: `internal/notify/service.go`
+- Test: `internal/notify/service_test.go`
+- Modify: `test-notifyctl.sh`
+
+**Interfaces:**
+- Consumes: `ImageDataFromHints`, `ImagePathFromHints`, `(*RawImage).PNG`, `ResolveIcon`, `WriteImage`, `ImagesDir`, `Store.SetImage`.
+- Produces: a `Popup.Image` populated for every notification and `body-images` advertised.
+
+- [ ] **Step 1: Write the failing test**
+
+In `internal/notify/service_test.go`, add a capability assertion and an image-path assertion. The existing bus test builds a `NewService(...)`; keep its shape and add:
+
+```go
+func TestCapabilitiesIncludeBodyImages(t *testing.T) {
+ caps, err := NewService(nil, t.TempDir()).GetCapabilities()
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, c := range caps {
+ if c == "body-images" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("body-images missing from %v", caps)
+ }
+}
+```
+
+Add a pure test that an image-path hint becomes `Popup.Image`. The test file is
+`package notify`, so it builds a Service directly and captures the publish:
+
+```go
+func TestNotifyPublishesImagePath(t *testing.T) {
+ var live []Popup
+ s := &Service{dir: t.TempDir()}
+ s.store = NewStore(s.emitClosed, func(l, _ []Popup) { live = l }, s.removeImage)
+ hints := map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")}
+ if _, err := s.Notify("t", 0, "", "s", "b", nil, hints, -1); err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 || live[0].Image != "/tmp/shot.png" {
+ t.Fatalf("published %+v", live)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./...`
+Expected: FAIL with the missing capability and the missing `newServiceWithDir` if used.
+
+- [ ] **Step 3: Write the implementation**
+
+In `internal/notify/service.go`:
+
+```go
+func (s *Service) GetCapabilities() ([]string, *dbus.Error) {
+ return []string{"actions", "body-markup", "body-images", "icon-static", "persistence"}, nil
+}
+```
+
+Add the image removal hook and wire it in `NewService`:
+
+```go
+// removeImage unlinks only what the daemon wrote, so a client's own image-path
+// is never touched.
+func (s *Service) removeImage(path string) {
+ if path == "" {
+ return
+ }
+ if !strings.HasPrefix(path, ImagesDir(s.dir)+string(os.PathSeparator)) {
+ return
+ }
+ os.Remove(path)
+}
+```
+
+Update `NewService` to pass it:
+
+```go
+ s.store = NewStore(s.emitClosed, func(live, history []Popup) {
+ if err := Publish(s.dir, live, history); err != nil {
+ log.Printf("notifyd: publish: %v", err)
+ }
+ }, s.removeImage)
+```
+
+In `Start`, clear leftovers from a previous run:
+
+```go
+ if err := os.RemoveAll(ImagesDir(s.dir)); err != nil {
+ log.Printf("notifyd: clear images: %v", err)
+ }
+```
+
+In `Notify`, resolve the icon and the image:
+
+```go
+ n := &Popup{
+ App: appName,
+ Summary: summary,
+ Body: body,
+ Urgency: u,
+ Icon: ResolveIcon(appIcon),
+ Actions: ParseActions(actions),
+ Created: now,
+ }
+ if raw, ok := ImageDataFromHints(hints); ok {
+ if data, err := raw.PNG(); err == nil {
+ // The id is assigned by Add; remember the blob and write it after.
+ pendingImage = data
+ }
+ } else if path, ok := ImagePathFromHints(hints); ok {
+ n.Image = ResolveIcon(path)
+ }
+```
+
+Then after `Add`:
+
+```go
+ id, _ := s.store.Add(n, tag, replacesID)
+ if pendingImage != nil {
+ if path, err := WriteImage(s.dir, id, pendingImage); err == nil {
+ s.store.SetImage(id, path)
+ } else {
+ log.Printf("notifyd: write image: %v", err)
+ }
+ }
+ s.arm(id, ms)
+ return id, nil
+```
+
+Declare `var pendingImage []byte` before the `Popup` literal. Add `os` and `strings` to the imports.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./... && go vet ./...`
+Expected: PASS and clean. Then `dbus-run-session -- go test ./internal/notify` and `bash test-notifyctl.sh`.
+
+Extend `test-notifyctl.sh`. The file is structured around a `dbus-run-session`
+that prints one line per observation into `$tmp/out`, then a `check label want
+got` per line. Add the image-path notification and its emitted line inside the
+session, after the first `notifyctl list` echo, then add the matching check and
+shift the existing "list cleared" check to the third line:
+
+Inside the `dbus-run-session` script, change the block to:
+
+```bash
+ notify-send -a test -u normal "t1" "b1" || exit 3
+ sleep 0.3
+ echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")"
+ notify-send -a test -u normal --hint=string:image-path:/tmp/x.png "t2" "b2" || exit 3
+ sleep 0.3
+ echo "$(notifyctl list | grep -c "\"image\": \"/tmp/x.png\"")"
+ notifyctl close-all
+ sleep 0.3
+ echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")"
+ kill $daemon
+```
+
+Then add and adjust the checks at the bottom of the script:
+
+```bash
+check "list shows the notification" "1" "$(sed -n 1p "$tmp/out")"
+check "image-path is published" "1" "$(sed -n 2p "$tmp/out")"
+check "list clears" "0" "$(sed -n 3p "$tmp/out")"
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/service.go internal/notify/service_test.go test-notifyctl.sh
+git commit -m "feat(notify): materialise notification images
+
+Notify resolves app_icon and image-path theme names, decodes image-data to
+a PNG under the image directory, and re-publishes the entry with its
+image path. The daemon advertises body-images, and removeImage refuses to
+touch a path outside its own directory so a client's screenshot file is
+never deleted."
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** hints and priority (Task 1), theme resolution including qt6ct authority and Inherits (Task 2), the `image` contract field and cleanup lifecycle (Task 3), capabilities and materialisation (Task 4). The inline-image and renderer sections belong to the renderer plan.
+
+**Placeholder scan:** none; every code step carries the code.
+
+**Type consistency:** `RawImage`, `ImageDataFromHints`, `ImagePathFromHints`, `PNG`, `ResolveIcon`, `WriteImage`, `ImagesDir`, `SetImage`, and the `NewStore` third parameter are used with the same signatures across tasks.
diff --git a/docs/superpowers/specs/2026-09-15-notification-images-design.md b/docs/superpowers/specs/2026-09-15-notification-images-design.md
new file mode 100644
index 0000000..fd7eabf
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-15-notification-images-design.md
@@ -0,0 +1,201 @@
+# Notification Images
+
+The daemon and the renderers gain content images: a screenshot or an
+application image attached to a notification, shown large in the balloon, plus
+inline images inside the body markup, plus resolution of app icons given as
+theme names.
+
+This extends the shipped daemon design
+(`2026-09-15-notification-daemon-design.md`) and the renderers built from
+`../plans/2026-09-15-notification-renderers.md`.
+
+## Why
+
+The daemon currently reads only the `app_icon` parameter. It ignores every
+image hint, so content images never arrive. Two live consumers show the gap:
+
+ opencode its notifier passes its logo with notify-send --icon
+ grimblast it passes the screenshot with notify-send -i
+
+libnotify 0.8.8 splits two flags that were once one: `-i/--icon` is the content
+image (it lands in the `image-path` hint) and `-n/--app-icon` is the
+`app_icon` parameter. Both consumers use the content image. The daemon drops
+that hint, so opencode shows no logo and a screenshot shows nothing.
+
+Separately, `app_icon` is only usable when it is an absolute path. Apps that
+send a theme name (the spec's other allowed form) render nothing, because the
+renderer builds `file://<name>`.
+
+## What it is not
+
+It is not a change to the daemon's or renderer's suppression, timeout, replace
+or history behaviour. It is not animated images, multiple images, progress
+bars, or body hyperlink handling. It is not a resolver for `desktop-entry`.
+
+## The freedesktop contract
+
+The specification defines one image per notification. An implementation that
+can display both the app icon and the image shows `app_icon` as the icon and
+picks the image in this order:
+
+ 1. image-data (raw pixels, a (iiibiiay) struct)
+ 2. image-path (a URI or a theme icon name)
+ 3. icon_data (deprecated, the same struct as image-data)
+
+An implementation that can show only one image picks from image-data,
+image-path, app_icon, then icon_data. The daemon here shows both, so it uses
+the first order and keeps `app_icon` as the icon.
+
+`image-data` and `icon_data` are a D-Bus structure `(iiibiiay)`:
+
+ width (i) width in pixels
+ height (i) height in pixels
+ rowstride (i) bytes between row starts
+ has_alpha (b) whether there is an alpha channel
+ bits_per_sample (i) always 8
+ channels (i) 4 with alpha, 3 without
+ data (ay) pixels, RGB byte order
+
+`image-path`, and `app_icon`, are each either a `file://` URI or a name in a
+freedesktop icon theme. A name must be resolved against a theme.
+
+## The daemon
+
+### Hints
+
+`Notify` gains hint parsing beside the existing urgency and stack-tag reads:
+
+- `image-data` and the deprecated `icon_data` (struct): decode to an image.
+- `image-path` (string): a `file://` URI, an absolute path, or a theme name.
+
+`image-data` wins when both a data and a path hint are present, per the
+priority above. The underscore spelling `image_data`, which older libnotify
+sent, is accepted as an alias.
+
+### Materialisation and lifecycle
+
+A raw `image-data` is decoded in Go and written as a PNG to
+`$XDG_RUNTIME_DIR/notifyd/img/<id>.png`. A replaced notification reuses its id
+and overwrites the same file. The file is removed when the notification leaves
+the live queue: on dismiss, on eviction, and on expiry. Only the balloon shows
+an image, and the drawer row does not, so nothing needs it once the balloon is
+gone. This bounds the directory to the live balloons.
+
+An `image-path` is the client's file and is published as-is; the daemon never
+deletes it. A theme name is resolved to a file first. A `file://` URI is
+converted to a path.
+
+### Capabilities
+
+`GetCapabilities` adds `body-images`, which is the spec's token for inline
+image support. It keeps `actions`, `body-markup`, `icon-static` and
+`persistence`.
+
+### Icon and image theme-name resolution
+
+`app_icon` and `image-path` values without a `/` are theme names and are
+resolved to a file. A value that is already a path or a `file://` URI is used
+as-is.
+
+The theme is read from qt6ct, `~/.config/qt6ct/qt6ct.conf`, key `icon_theme`.
+On this machine that is `Material-Black-Plum-Suru`, and it is authoritative;
+GTK3, gsettings and qt6ct agree, and the one dissent (GTK4's `breeze-dark`)
+has been corrected to match. If qt6ct has no value, fall back to the GTK3
+setting `gtk-icon-theme-name` (or `gsettings`), then `hicolor`.
+
+Lookup, once a theme name is known:
+
+1. Search `$XDG_DATA_HOME/icons/<theme>`, then each `$XDG_DATA_DIRS/icons/<theme>`
+ in order.
+2. Within the theme, prefer `apps/scalable`, then the largest available size
+ under `apps/`.
+3. Follow the theme's `Inherits` chain from its `index.theme`.
+4. Fall back to `hicolor`.
+5. If nothing matches, leave the value empty, which renders no image. This is
+ today's behaviour and stays the honest outcome.
+
+## The published contract
+
+`Popup` gains one field:
+
+ {
+ "id": 57,
+ "app": "grimblast",
+ "summary": "Screenshot of Area",
+ "body": "...",
+ "urgency": "normal",
+ "icon": "", // app identity, a resolved path
+ "image": "/run/user/1000/notifyd/img/57.png", // content image, empty if none
+ "actions": [],
+ "created": 1758000000000,
+ "expires": 1758000010000
+ }
+
+`icon` keeps its meaning and its consumers. `image` is new and empty when the
+notification has none. History objects carry the same field; history rows do
+not render it, so a stale path there is inert.
+
+## The renderers
+
+### Balloon
+
+When `image` is non-empty, the balloon shows it below the text block,
+`Image.PreserveAspectFit`, the balloon's width, capped at 240px tall. The
+32px `icon` slot is unchanged, and both can appear together. The balloon grows
+to fit, exactly as it does for a long body.
+
+### Inline images in the body
+
+The body is already rendered as `Text.RichText`, and Qt's rich text engine
+loads `<img>` from a local path or a `file://` URI (verified: a 200px
+`<img>` raised the text's `contentHeight` from 52 to 307). No new rendering
+code is needed for the common case.
+
+Policy: only local sources render. Before display, the renderer strips any
+`<img>` whose `src` begins with `http:` or `https:`, so a notification from an
+untrusted sender cannot make the shell fetch a URL. Remote inline images are
+not shown. Sources that are absolute paths or `file://` URIs are left alone.
+
+The existing body line caps still apply: three lines in the balloon, two in a
+drawer row.
+
+### Drawer rows
+
+No image. The reserved space and the history page stay text only, as they are
+today.
+
+## Privacy
+
+A notification is untrusted input. The only new outward action the design can
+cause is a network fetch, and it is closed by the inline policy above: remote
+image sources are stripped, never fetched. The daemon writes a decoded image
+only under its own runtime directory.
+
+## Verification
+
+Daemon (Go, table tests, no bus or clock):
+
+- image hint parsing: `image-data` beats `image-path`; `icon_data` and
+ `image_data` aliases are read; a `file://` URI becomes a path.
+- `image-data` decode to PNG for 3- and 4-channel data with a rowstride.
+- theme-name resolution against a fake icon directory: a plain name, a name
+ found only through `Inherits`, a missing name, and the largest-size
+ preference.
+
+`test-notifyctl.sh` is extended if the contract change reaches it.
+
+End to end, by hand:
+
+- a grimblast screenshot and an opencode event each draw a large preview.
+- `notify-send -n firefox` draws the themed Firefox icon.
+- a `gdbus` call sending `image-data` draws the image.
+- a body containing `<img src="https://example.org/x.png">` draws no image and
+ makes no request.
+- the renderer smoke checks stay clean.
+
+## Out of scope
+
+- Icon caches and theme re-scan; a name is resolved per notification.
+- `desktop-entry` resolution.
+- Animated images, more than one image, an image in a drawer row.
+- Progress bars and body hyperlink handling, unchanged deferrals.