aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-09-15-notification-image-renderers.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-09-15-notification-image-renderers.md')
-rw-r--r--docs/superpowers/plans/2026-09-15-notification-image-renderers.md218
1 files changed, 218 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.