1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
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.
|