# 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 `
` 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
`. 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 `
` 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 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 `) 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 `
` 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
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(/
]*\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

below"
notify-send -u critical -t 30000 "inline remote" "above

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
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.