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