visual-feedback 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/SPEC.md CHANGED
@@ -3,43 +3,88 @@
3
3
  ## 1. What it is
4
4
  A user reports a problem or an idea from the page they are on. They capture the screen, mark it
5
5
  up, write a note and send it. The host app receives one report and decides where it goes. The
6
- package stores nothing and sends nothing by itself.
6
+ package sends nothing by itself, and stores nothing but an unsent draft on the user's own device
7
+ (§7).
7
8
 
8
9
  ## 2. Flow
9
10
  ```
10
11
  idle --open--> capturing --image ready--> annotating --Send--> sending --ok--> sent --3 s--> idle
11
- | | |
12
- +--fails--> capture error +--Cancel/Esc--> idle +--fails--> send error
12
+ | | ^ |
13
+ +--fails--> capture error | | +--fails--> send error
14
+ | +--Open, or Capture this view (capturing first)
15
+ +--Esc / Keep as draft / Add screenshot--> draft
16
+ annotating, draft --Cancel (it may ask first, below) / Discard--> idle
13
17
  ```
14
18
  - **idle:** the trigger button shows (`trigger: "button"`), or nothing shows (`trigger: "none"`,
15
19
  where the host calls `open()`).
16
20
  - **capturing:** the trigger and any open panel are hidden, so they are not in the image. The
17
21
  screen is captured (SPEC §5), and a screen reader is told the `capturing` text.
18
22
  - **capture error:** the `captureError` text, with "Try again" (back to capturing), "Send without
19
- screenshot" (annotating with no image: no toolbar, `image: null`, no marks) and Cancel or Esc
20
- (idle).
21
- - **annotating:** a full-viewport overlay shows the image, a toolbar and the note field.
23
+ screenshot" (annotating with no image: no toolbar, no screenshots in the report) and Cancel or Esc
24
+ (idle). This is the first screenshot only; a later one that fails is the draft's (below).
25
+ - **annotating:** a full-viewport overlay shows the image with a tool rail beside it, a strip of
26
+ the report's screenshots under it, and the note field, with Send in a footer row under that.
27
+ Esc and the header's Keep as draft fold it into the draft; the header's close button (Cancel)
28
+ discards it. When that would throw away a note, a mark, a second screenshot or a chosen category,
29
+ Cancel first asks `discardConfirm`, with "Discard", `minimise` (before Send, with a screenshot) and
30
+ `keepEditing`, which has the focus; Keep editing, and Esc on the question, take the question back
31
+ and put the focus on Cancel. Without a screenshot ("Send without screenshot"), Esc cancels,
32
+ after the same question when it would throw work away.
33
+ - **draft:** a small bar in the trigger's corner, in place of the trigger: the `draft` label with
34
+ the number of screenshots, small copies of them, "Capture this view", "Open" and "Discard". The
35
+ page scrolls, and a single-page app can change route, while it shows. "Capture this view" takes
36
+ another screenshot of the page as it is now, the bar and the dialog left out, adds it, and opens
37
+ the dialog on it; a capture that fails opens the dialog on the kept draft with the
38
+ `captureError` text, where Esc keeps the draft again and Cancel discards it, after the same question when it would throw work away. "Open", and `open()`, open the dialog as it was. A full page load in the same tab, or a route
39
+ change that removes the widget and makes the next one, brings the draft back, folded in the bar, as it was when it was last folded: its screenshots, marks, note and
40
+ category. Undo starts after the marks it brought back. With `draft: "memory"`, a page load ends
41
+ it.
22
42
  - **sending:** the note, the category and the tools are disabled, and the Send button shows
23
- progress. Cancel and Esc still close the dialog.
43
+ progress. The Esc hint goes, as it is absent with no screenshot: from Send on, Esc keeps no
44
+ draft, and Cancel and Esc close the dialog.
24
45
  - **sent:** a short confirmation, then idle.
25
46
  - **send error:** the message comes from the host's error, or else the `error` text. The note and
26
- the marks are kept, and "Try again" sends the same report.
47
+ the marks are kept, and "Try again" sends the same report. Cancel and Esc, after
48
+ the same question when it would throw work away, close the dialog, and the draft goes with it.
27
49
 
28
50
  ## 3. The trigger
29
- - It is a small round button, 36 px by default, in a corner (`position`), with an icon and an
30
- accessible name. It has no text label and no badge, and it does not move or pulse.
51
+ - It is a small round button, 2.5em across by default (40 px on a 16 px site), in a corner
52
+ (`position`), with an icon and the `trigger` accessible name. A host that gives `triggerText`
53
+ gets that short label beside the icon, as a pill; with only `triggerText` given, that text is
54
+ the name too. A host that sets both keeps `triggerText` inside `trigger`, so
55
+ speech control can say what it sees. It has no badge, and it does not move or pulse.
31
56
  - It sits 16 px from the edges by default, and it respects `env(safe-area-inset-*)`.
57
+ - It is hidden while there is a draft, whether the dialog is open or kept in the draft bar.
32
58
  - It does not watch the page. The host moves it off its own content with `position` or
33
59
  `--vf-offset`.
34
60
 
35
61
  ## 4. Annotating
36
- - **Tools:** Pen (freehand), Box (rectangle outline) and Arrow. Undo removes the last mark, and
37
- Clear removes all.
62
+ - **Tools:** Pen (freehand), Box (rectangle outline), Arrow and Text. Text places a label where the
63
+ image is clicked: Enter or clicking away keeps it, Escape drops that label only, and an empty one
64
+ is not kept; a label holds up to 280 characters. Labels are one size, in the mark colour with a white outline. Select picks a mark by
65
+ its line or a label by its text, and shows a dashed outline that never reaches the sent image: a
66
+ drag moves it, kept whole inside the image; the arrow keys move it (Shift for ten); Delete,
67
+ Backspace or the Delete button removes it; Escape lets go of it. Undo takes back the last add,
68
+ move or removal, one at a time, and Clear removes all.
38
69
  - **One colour** for the marks, `--vf-mark`, which defaults to a red that contrasts with most pages.
39
70
  - Pointer events, so mouse, pen and touch all work. While drawing, touch never scrolls the page.
40
71
  - **Note:** a textarea of 2000 characters at most, with a visible label (`noteLabel`). It is
41
72
  required unless `requireNote: false`.
42
73
  - **Category:** shown only when `categories` is given, with a visible label (`categoryLabel`).
74
+ - **Save as PDF:** Send is a split button; its caret ("More send options") opens a menu with "Save
75
+ as PDF". It downloads `feedback-<date>.pdf`, dated by the user's clock and made in the browser:
76
+ the category and the note,
77
+ then the first screenshot, on page 1, and each other screenshot on a page of its own, marks
78
+ included. Nothing is sent, the dialog and the draft stay as they are, and the `saved` event
79
+ fires. While the menu is open, Esc closes the menu only, from Send and the menu button too, and
80
+ puts the focus on the menu button; Tab closes it too. A PDF that cannot be made, a page the browser has no memory to draw
81
+ included (as on iOS), shows the `pdfError` text, never a blank page, and
82
+ neither `saved` nor `error` fires, since nothing failed to send; one asked for while another is being made is ignored.
83
+ - **Screenshots:** a report holds up to 10. Each keeps its own marks and its own Undo; the tool
84
+ chosen stays the same across them. The strip numbers them in the order they are sent: a click
85
+ shows one, and its remove button, there only when there are two or more, takes one out. "Add
86
+ screenshot" folds the dialog into the draft, and is gone at 10. The note and the category are one
87
+ for the whole report.
43
88
  - Sending without any mark is allowed.
44
89
 
45
90
  ## 5. Capture
@@ -53,39 +98,67 @@ idle --open--> capturing --image ready--> annotating --Send--> sending --ok--> s
53
98
  - **Format:** WebP by default, or PNG if the browser cannot encode WebP.
54
99
  - **Fonts and images:** what cannot be drawn (for example a cross-origin image without CORS) shows
55
100
  as a grey box. It never makes the capture fail.
101
+ - **Known limit:** in "dom", text cut by `-webkit-line-clamp` with a fractional line height (such as
102
+ 15px at 1.35) shows one line fewer than the page. The cause is in the capture library, snapdom
103
+ 3.3.0; this repository patches its own copy for its tests, which does not reach a host's install.
56
104
 
57
105
  ## 6. The report
58
106
  Defined in `docs/API.md` (`FeedbackReport`). It holds:
59
- - the flattened image, with the marks painted in, or no image after "Send without screenshot";
60
- - the marks as vectors;
107
+ - the screenshots, at most 10, or none after "Send without screenshot". Each has its flattened
108
+ image with the marks painted in, the marks as vectors, and the page URL and title, the viewport
109
+ and the scroll position as they were when it was taken;
61
110
  - the note and the category;
62
- - the page URL and title, the viewport and the scroll position, as they were at the screenshot;
111
+ - the page URL and title, the viewport and the scroll position, as they were at the first
112
+ screenshot, or at the capture error after "Send without screenshot";
63
113
  - the user agent, the language, the time zone and the time of Send;
64
114
  - whatever the host's `context()` returns.
65
115
 
66
116
  ## 7. Privacy
67
117
  - The image is what the user sees. Nothing on the page is masked, hidden or redacted, password
68
118
  fields included. Only the widget itself is left out (SPEC §2, capturing).
69
- - The image is never kept after `close()`.
70
- - No console logs, network logs, cookies, storage or keystrokes are collected.
119
+ - No screenshot is kept after the draft ends: Send, Cancel, Discard, `close()` or `discardDraft()`.
120
+ - No console logs, network logs, cookies or keystrokes are collected. Nothing is stored, with one
121
+ exception: a draft folded in the draft bar is kept on this device for this tab, so a page load
122
+ does not lose it. sessionStorage holds its id, and IndexedDB its screenshots with each one's
123
+ page facts (the whole URL: `redactUrl` applies only to the sent report), marks, note and category. It is deleted when the draft ends: the moment Send succeeds, or at Cancel, Discard,
124
+ `close()` or `discardDraft()`, which a host calls at a logout. `destroy()`, or the host removing the element, keeps it, open or folded, for the next
125
+ widget of that tag in this tab, unless its Send was already handed to the host; a click while
126
+ this tab keeps one opens it. One widget of a tag keeps a draft, the first whose element is in
127
+ the page; when that element leaves, the next widget of the tag to need it takes over, and never
128
+ writes over a draft still kept. Any other keeps its own in memory.
129
+ A `close()` made while a page load is still bringing it back hides it on
130
+ that page and keeps it. A day after it was last folded or brought back, it no longer comes back,
131
+ and it is deleted the next time this site opens the dialog or keeps a draft, in any tab: nothing
132
+ of the widget runs on the device to delete it sooner. A tab the browser duplicates copies
133
+ sessionStorage, so the copy shares that draft. `draft: "memory"` keeps
134
+ it in memory only, and does not delete one an earlier page kept.
71
135
  - The query string and the hash of the URL are kept unless `redactUrl: true` is set.
72
136
 
73
137
  ## 8. Accessibility
74
138
  - The overlay is a `role="dialog"` with `aria-modal="true"` and a label.
75
- - Focus stays inside the dialog, and Escape cancels it. While it is open, the page does not
76
- scroll. On close, focus goes back to the trigger, or to where it was before.
77
- - Every control has an accessible name and a visible focus ring (`--vf-focus`).
139
+ - Focus stays inside the dialog. Escape, except on the Send menu, a label being typed or a selected
140
+ mark (§4), or on Cancel's question (§2),
141
+ keeps an unsent draft that has a screenshot in the draft bar, and moves the focus to it ("Capture
142
+ this view", or "Open" at 10 screenshots). With no screenshot, or after a send error, it cancels,
143
+ asking first as Cancel does; while sending, and once sent, it closes at once.
144
+ While the dialog is open, the page does not scroll. On close, focus goes back to the trigger,
145
+ or to where it was before.
146
+ - Every control has an accessible name and a visible focus ring (`--vf-focus`), `unstyled`
147
+ included (THEMING.md).
78
148
  - With the keyboard alone, the user can still write the note, choose a category and send without
79
149
  marks.
80
150
  - `prefers-reduced-motion` turns off every transition.
81
151
  - `prefers-color-scheme` sets the default theme (`theme: "auto"`).
82
152
 
83
153
  ## 9. Budgets
84
- - **Loader** (the trigger and the `open()` wiring): 3 KB or less, minified and gzipped.
85
- - **Annotator and dialog:** 15 KB or less, gzipped. This does not include the capture library.
86
- - **Idle cost:** no work after the first paint of the trigger. After the first screenshot, the
154
+ - **Loader** (the trigger and the `open()` wiring): 15 KB or less, minified and gzipped.
155
+ - **Annotator and dialog:** 135 KB or less, gzipped. This does not include the capture library.
156
+ - **Idle cost:** no work after the first paint of the trigger, past reading one sessionStorage
157
+ key at load; only when this tab kept a draft does the dialog load, to bring it back. After the first screenshot, the
87
158
  capture library (snapdom) keeps 12 listeners on `window` and `document` and up to 5 mutation observers
88
- until the page unloads, and offers no way to remove them; `destroy()` removes everything else.
159
+ until the page unloads, and offers no way to remove them; `destroy()` removes everything else
160
+ but a draft it keeps (§7). The opt-in `shortcut` adds one `keydown` listener on `window`, gone at
161
+ `destroy()` or `update({ shortcut: false })`.
89
162
  - CI fails when a budget is exceeded.
90
163
 
91
164
  ## 10. Browsers
@@ -93,8 +166,9 @@ The browsers that support constructable stylesheets (`adoptedStyleSheets`) and `
93
166
  Chrome 111+, Firefox 113+ and Safari 16.4+. Older browsers get no trigger and no error.
94
167
 
95
168
  ## 11. Content Security Policy
96
- - The widget's own CSS goes in through constructable stylesheets. It uses no `<style>` tags and
97
- no inline `style` attributes, so a strict `style-src` does not block it. While the dialog is
169
+ - The widget's own CSS goes in through constructable stylesheets. It uses no `<style>` tags, and
170
+ sets the few inline styles it needs (the selection frame, a label being typed, the theme's
171
+ `color-scheme`) through the CSSOM, so a strict `style-src` does not block it. While the dialog is
98
172
  open, the scroll lock sets `overflow` on `<html>` through the CSSOM, which a CSP allows, and
99
173
  puts back what was there on close.
100
174
  - Capture (`@zumer/snapdom`) renders through `data:` image URLs, so a site with a CSP needs
@@ -119,10 +193,22 @@ These are the default texts. Each one can be replaced through `strings` (`docs/A
119
193
  | notePlaceholder | What happened, or what would you change? |
120
194
  | noteRequired | Write a few words first. |
121
195
  | categoryLabel | Type |
122
- | pen / box / arrow | Draw / Box / Arrow |
196
+ | pen / box / arrow / text / select | Draw / Box / Arrow / Text / Select |
197
+ | remove | Delete |
123
198
  | undo / clear | Undo / Clear |
124
199
  | cancel / send | Cancel / Send |
125
200
  | sending | Sending… |
126
201
  | sent | Thanks, it's sent. |
127
202
  | error | It didn't send. Your note is kept. |
128
203
  | retry | Try again |
204
+ | triggerText | none: the icon alone |
205
+ | escHint | Esc keeps it as a draft |
206
+ | minimise | Keep as draft |
207
+ | screenshot | Screenshot (each one is named with its number, as "Screenshot 2") |
208
+ | addScreenshot | Add screenshot |
209
+ | removeScreenshot | Remove screenshot (with the number, as "Remove screenshot 2") |
210
+ | draft | Feedback draft (the bar shows the number of screenshots after it) |
211
+ | captureView / reopen / discard | Capture this view / Open / Discard |
212
+ | discardConfirm / keepEditing | Discard this feedback? / Keep editing |
213
+ | moreSend / savePdf | More send options / Save as PDF |
214
+ | pdfError | Couldn't save the PDF. |
package/docs/THEMING.md CHANGED
@@ -15,8 +15,8 @@ Set these on `visual-feedback` (or on any ancestor). Custom properties cross the
15
15
  | variable | default | used for |
16
16
  |---|---|---|
17
17
  | `--vf-font` | `inherit` | font family of all text, such as `Inter, sans-serif` |
18
- | `--vf-radius` | `12px` | panel, inputs, buttons |
19
- | `--vf-surface` | `Canvas` | panel and toolbar background |
18
+ | `--vf-radius` | `16px` | panel and composer, and a share of it for buttons and the image |
19
+ | `--vf-surface` | `Canvas` | panel, tool rail and composer background |
20
20
  | `--vf-text` | `CanvasText` | text |
21
21
  | `--vf-muted` | text at 60% | hints, counters |
22
22
  | `--vf-border` | text at 15% | hairlines |
@@ -25,9 +25,9 @@ Set these on `visual-feedback` (or on any ancestor). Custom properties cross the
25
25
  | `--vf-focus` | `--vf-accent` | focus ring |
26
26
  | `--vf-mark` | `#e5243b` | marks on the screenshot |
27
27
  | `--vf-backdrop` | `rgb(0 0 0 / 45%)` | overlay behind the image |
28
- | `--vf-shadow` | `0 4px 16px rgb(0 0 0 / 12%)` | trigger and panel |
29
- | `--vf-trigger-size` | `36px` | trigger diameter |
30
- | `--vf-offset` | `16px` | trigger distance from the edges |
28
+ | `--vf-shadow` | `0 12px 32px -12px rgb(0 0 0 / 35%)` | trigger, panel and draft bar |
29
+ | `--vf-trigger-size` | `2.5em`, 40px on a 16px site | trigger height, and its width when it shows the icon alone |
30
+ | `--vf-offset` | `16px` | trigger and draft bar distance from the edges |
31
31
  | `--vf-z` | `2147483000` | stacking |
32
32
 
33
33
  ```css
@@ -39,9 +39,15 @@ visual-feedback {
39
39
  ```
40
40
 
41
41
  ## 3. `::part()`
42
- Every visible element has a part name: `trigger`, `trigger-icon`, `backdrop`, `dialog`, `title`,
43
- `image`, `toolbar`, `tool`, `tool-active`, `undo`, `clear`, `label`, `note`, `category`, `actions`, `cancel`, `send`,
44
- `status`, `error`, `retry`, `send-without`.
42
+ Every visible element has a part name: `trigger`, `trigger-icon`, `trigger-text`, `backdrop`,
43
+ `dialog`, `header`, `title`, `minimise`, `body`, `stage`, `image`, `toolbar`, `tool`, `tool-active`,
44
+ `divider`, `undo`, `clear`, `remove`, `icon`, `text-input`, `selection`, `strip`, `thumb`,
45
+ `thumb-pick`, `thumb-image`, `thumb-number`, `thumb-remove`, `add`, `label`, `composer`, `note`,
46
+ `composer-bar`, `category`, `footer`, `split`, `send-more`, `menu`, `save-pdf`, `actions`, `cancel`,
47
+ `send`, `hint`, `status`, `error`, `retry`, `send-without`, `draft`, `draft-label`, `draft-thumbs`,
48
+ `capture-view`, `reopen`, `discard`, `confirm`, `confirm-text`, `keep-draft`, `keep-editing`. Cancel's
49
+ question is `confirm`, also an `actions` part, with its own `discard` button. After a send error, the Send button's part is `retry` until it
50
+ sends again, the same part as the capture error's "Try again".
45
51
 
46
52
  ```css
47
53
  visual-feedback::part(trigger) { opacity: .6; }
@@ -52,7 +58,8 @@ visual-feedback::part(trigger):hover { opacity: 1; }
52
58
  - `css` is a string. It is added as a constructable stylesheet inside the shadow root, after the
53
59
  defaults, so it can reach anything, including states that `::part()` cannot select.
54
60
  - `unstyled: true` drops every default except the layout-critical rules (the trigger's position and
55
- size, the overlay's position, the panel's layout, canvas size, hidden states). Then the `css` option styles everything.
61
+ size, the overlay's position, the panel's layout, canvas size, hidden states) and the focus ring,
62
+ in `--vf-focus`, which `css` can restyle. Then the `css` option styles everything else.
56
63
 
57
64
  ```ts
58
65
  createFeedback({ onSubmit, css: `.toolbar { gap: 2px } .send { text-transform: uppercase }` });
@@ -1,10 +1,12 @@
1
1
  # Recipe: GitHub Issues (private repo only)
2
2
 
3
- Each report becomes an issue: the note, the page URL, the browser data and the image, with the
4
- category as a label. The image is committed to the repo with GitHub's contents API, at
5
- `feedback/<id>/image.png`, and shown inside the issue. Then the whole report is committed beside
6
- it as `feedback/<id>/report.json`, with `image` set to the image's GitHub link (or `null`) and
7
- `issue` to the issue number: a resend that finds it opens no second issue. The route checks on every send that the repo
3
+ Each report becomes an issue: the note, the page URL, the browser data and the screenshots, with
4
+ the category as a label. Each screenshot's image is committed to the repo with GitHub's contents
5
+ API, at `feedback/<id>/screenshot-<n>.png`, numbered from 0, and shown inside the issue, with its
6
+ own page URL under it when it was taken on another page than the report's. Then the
7
+ whole report is committed beside them as `feedback/<id>/report.json`, with each screenshot's `image`
8
+ set to its GitHub link and `issue` to the issue number: a resend that finds it opens no second
9
+ issue. The route checks on every send that the repo
8
10
  is private, and refuses otherwise. It uses `fetch` and GitHub's REST API, so it needs no package.
9
11
 
10
12
  `report.context` comes from the browser (docs/API.md): the issue shows it as data, never as proof
@@ -50,7 +52,7 @@ Create a fine-grained personal access token for that one repo, with **Issues: re
50
52
 
51
53
  Send PNG from the widget, the image type this recipe was checked with inside an issue:
52
54
  `createFeedback({ endpoint: "/api/feedback", capture: { type: "image/png" } })`. Without it the
53
- widget sends WebP (docs/API.md): the route commits and links `image.webp` the same way, but whether
55
+ widget sends WebP (docs/API.md): the route commits and links `screenshot-<n>.webp` the same way, but whether
54
56
  the issue shows a WebP image inline is not checked.
55
57
 
56
58
  ## 3. The route
@@ -101,24 +103,46 @@ function fenced(text: string, max: number): string {
101
103
  return `${fence}text\n${shown}\n${fence}`;
102
104
  }
103
105
 
104
- function issueBody(report: FeedbackReport, imageUrl: string | null): string {
106
+ // A screenshot of another page than the report's names its own, as a draft can span routes.
107
+ function shots(report: FeedbackReport, imageUrls: string[], share: number): string {
108
+ return imageUrls
109
+ .map((url, i) => {
110
+ const page = report.screenshots[i]?.page.url ?? report.page.url;
111
+ const where = page === report.page.url ? "" : `\n${fenced(page, 2_000 * share)}`;
112
+ return `![Screenshot ${i + 1}](<${url}>)${where}`;
113
+ })
114
+ .join("\n\n");
115
+ }
116
+
117
+ function issueBody(report: FeedbackReport, imageUrls: string[]): string {
118
+ // A fence can be as long as its text, so a report of backticks can pass GitHub's limit at the
119
+ // usual cuts: they shrink until it fits. At a tenth, the longest report is far under it.
120
+ let body = "";
121
+ for (const share of [1, 0.5, 0.25, 0.1]) {
122
+ body = bodyAt(report, imageUrls, share);
123
+ if (body.length <= 65_536) break;
124
+ }
125
+ return body;
126
+ }
127
+
128
+ function bodyAt(report: FeedbackReport, imageUrls: string[], share: number): string {
105
129
  const { page, viewport, userAgent, language, timeZone, createdAt, id } = report;
106
130
  const screen = `${viewport.width}x${viewport.height} @${viewport.devicePixelRatio}x`;
107
131
  // Each fence starts its own line: after other text on that line it is no block.
108
132
  return [
109
- fenced(report.note || "(no note)", 10_000),
133
+ fenced(report.note || "(no note)", 10_000 * share),
110
134
  // The angle brackets keep a ( or ) in a branch name from ending the link.
111
- imageUrl ? `![Screenshot](<${imageUrl}>)` : "No screenshot.",
112
- `**Page:**\n${fenced(page.url, 2_000)}`,
113
- `**Browser:**\n${fenced(`${userAgent}\n${screen}, ${language}, ${timeZone}`, 1_000)}`,
135
+ shots(report, imageUrls, share) || "No screenshot.",
136
+ `**Page:**\n${fenced(page.url, 2_000 * share)}`,
137
+ `**Browser:**\n${fenced(`${userAgent}\n${screen}, ${language}, ${timeZone}`, 1_000 * share)}`,
114
138
  `**Sent:** ${createdAt}, report \`${id}\``,
115
- `**Context, from the browser:**\n${fenced(JSON.stringify(report.context, null, 2), 6_000)}`,
139
+ `**Context, from the browser:**\n${fenced(JSON.stringify(report.context, null, 2), 6_000 * share)}`,
116
140
  ].join("\n\n");
117
141
  }
118
142
 
119
143
  export async function POST(request: Request): Promise<Response> {
120
144
  try {
121
- const { report, image } = await parseFeedbackRequest(request);
145
+ const { report } = await parseFeedbackRequest(request);
122
146
  const api = github();
123
147
  const repo = await api("GET", "");
124
148
  // Checked on every send: a repo made public later must not receive reports.
@@ -151,24 +175,24 @@ export async function POST(request: Request): Promise<Response> {
151
175
  throw new Error(`GitHub answered ${commit.status} for ${name}.`);
152
176
  }
153
177
  };
154
- let imageUrl: string | null = null;
155
- if (image) {
156
- const name = `image.${image.type === "image/png" ? "png" : "webp"}`;
178
+ const imageUrls: string[] = [];
179
+ for (const [i, { image }] of report.screenshots.entries()) {
180
+ const name = `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
157
181
  await put(name, new Uint8Array(await image.arrayBuffer()));
158
182
  const path = `${branch.split("/").map(encodeURIComponent).join("/")}/${folder}/${name}`;
159
- imageUrl = `https://github.com/${repo.json.full_name}/blob/${path}?raw=true`;
183
+ imageUrls.push(`https://github.com/${repo.json.full_name}/blob/${path}?raw=true`);
160
184
  }
161
185
  // GitHub refuses a label name over 50 characters, and one that is only space.
162
186
  const category = cut(report.category?.replace(/\s+/g, " ").trim() ?? "", 49);
163
187
  const title = cut(report.note.split("\n")[0]?.trim() || "Feedback", 80);
164
188
  const issue = await api("POST", "/issues", {
165
189
  title,
166
- body: issueBody(report, imageUrl),
190
+ body: issueBody(report, imageUrls),
167
191
  labels: ["feedback", ...(category ? [category] : [])],
168
192
  });
169
193
  if (issue.status !== 201) throw new Error(`GitHub answered ${issue.status} for the issue.`);
170
- const { image: _, ...rest } = report;
171
- const json = JSON.stringify({ ...rest, image: imageUrl, issue: issue.json.number }, null, 2);
194
+ const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: imageUrls[i] }));
195
+ const json = JSON.stringify({ ...report, screenshots, issue: issue.json.number }, null, 2);
172
196
  await put("report.json", new TextEncoder().encode(json));
173
197
  return new Response(null, { status: 204 });
174
198
  } catch (error) {
@@ -3,9 +3,9 @@
3
3
  For local development only: it writes only when `NODE_ENV` is `development`. `next dev` sets it;
4
4
  start any other server with `NODE_ENV=development`.
5
5
 
6
- Each report becomes one folder, `.visual-feedback/<id>/`, holding `image.png` or `image.webp` and
7
- `report.json`. That file is the `FeedbackReport` (docs/API.md), with `image` set to the image's file
8
- name, or `null` when the report has none.
6
+ Each report becomes one folder, `.visual-feedback/<id>/`, holding `report.json` and one image per
7
+ screenshot: `screenshot-0.png` or `screenshot-0.webp`, then `screenshot-1`, and so on. That file is
8
+ the `FeedbackReport` (docs/API.md), with each screenshot's `image` set to its file name.
9
9
 
10
10
  It does not work on hosts whose disk is read-only or wiped on each deploy or restart, such as
11
11
  Vercel, Netlify, Cloudflare and most containers. Use another recipe there.
@@ -31,7 +31,7 @@ interface unless it is started as `next dev -H 127.0.0.1`.
31
31
 
32
32
  ```ts
33
33
  // app/api/feedback/route.ts
34
- import { mkdir, rm, writeFile } from "node:fs/promises";
34
+ import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
35
35
  import { join } from "node:path";
36
36
  import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
37
37
 
@@ -41,21 +41,25 @@ export async function POST(request: Request): Promise<Response> {
41
41
  return new Response("Local feedback storage works only in development.", { status: 503 });
42
42
  }
43
43
  try {
44
- const { report, image } = await parseFeedbackRequest(request);
44
+ const { report } = await parseFeedbackRequest(request);
45
45
  // report.id is a checked UUID, so it is safe as a folder name.
46
46
  const folder = join(process.cwd(), ".visual-feedback", report.id);
47
- const file = image ? `image.${image.type === "image/png" ? "png" : "webp"}` : null;
47
+ const name = (image: Blob, i: number) =>
48
+ `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
48
49
  await mkdir(folder, { recursive: true });
49
- // The image first: a report.json on disk then always has its image beside it.
50
- if (image && file)
51
- await writeFile(join(folder, file), new Uint8Array(await image.arrayBuffer()));
50
+ // The images first: a report.json on disk then always has its images beside it.
51
+ for (const [i, { image }] of report.screenshots.entries()) {
52
+ await writeFile(join(folder, name(image, i)), new Uint8Array(await image.arrayBuffer()));
53
+ }
54
+ const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
52
55
  await writeFile(
53
56
  join(folder, "report.json"),
54
- JSON.stringify({ ...report, image: file }, null, 2),
57
+ JSON.stringify({ ...report, screenshots }, null, 2),
55
58
  );
56
- // After report.json, so a resend with another image type, or none, leaves no stray image.
57
- for (const old of ["image.png", "image.webp"]) {
58
- if (old !== file) await rm(join(folder, old), { force: true });
59
+ // After report.json, so a resend with other images, or fewer, leaves no stray image.
60
+ const kept = new Set(["report.json", ...screenshots.map((shot) => shot.image)]);
61
+ for (const old of await readdir(folder)) {
62
+ if (!kept.has(old)) await rm(join(folder, old), { force: true });
59
63
  }
60
64
  return new Response(null, { status: 204 });
61
65
  } catch (error) {
@@ -1,18 +1,21 @@
1
1
  # Recipe: Postgres
2
2
 
3
- Each report becomes one row: the `FeedbackReport` (docs/API.md) without its `image` as `jsonb`, the
4
- image in `image` (`bytea`) with its type in `image_type`, and the user who sent it, taken from your
5
- own session. `user_id` is the proof of who sent a report; `report.context` is not (docs/API.md).
3
+ Each report becomes one row in `feedback_reports`: the `FeedbackReport` (docs/API.md), its
4
+ screenshots without their images, as `jsonb`, and the user who sent it, taken from your own session.
5
+ Each screenshot's image becomes one row in `feedback_screenshots`, by its `position` in
6
+ `report.screenshots`: the image in `image` (`bytea`) and its type in `image_type`. `user_id` is the
7
+ proof of who sent a report; `report.context` is not (docs/API.md).
6
8
 
7
9
  To keep images in a bucket instead (docs/recipes/s3.md):
8
10
 
9
11
  1. Install `aws4fetch` and set the `S3_*` variables as that recipe's step 1 says.
10
12
  2. Copy its `bucket()` function into this route.
11
- 3. Before the insert, PUT the image to `url(report.id, "image.png")` or `"image.webp"`, as that
12
- recipe's `POST` does: with `"if-none-match": "*"`, so a reused id never replaces a stored
13
- image, and throw on any refusal but 412, so a row never lacks its image.
14
- 4. Insert `null` for `image` and keep `image_type`: `image/png` is the file `image.png`, and
15
- `image/webp` is `image.webp`.
13
+ 3. Before the insert, PUT each image to `url(report.id, "screenshot-<position>.png")` or `.webp`,
14
+ as that recipe's `POST` does:
15
+ with `"if-none-match": "*"`, so a reused id never replaces a stored image, and throw on any
16
+ refusal but 412, so a row never lacks its image.
17
+ 4. Insert `null` for `image` and keep `image_type`: `image/png` is the file
18
+ `screenshot-<position>.png`, and `image/webp` is `screenshot-<position>.webp`.
16
19
  5. Read images back with that recipe's `GET`, its `canRead` replaced by the same admin check your
17
20
  screens use.
18
21
 
@@ -30,9 +33,15 @@ create table feedback_reports (
30
33
  id uuid primary key,
31
34
  user_id text not null,
32
35
  created_at timestamptz not null,
33
- report jsonb not null,
36
+ report jsonb not null
37
+ );
38
+
39
+ create table feedback_screenshots (
40
+ report_id uuid not null references feedback_reports (id) on delete cascade,
41
+ position integer not null,
34
42
  image bytea,
35
- image_type text
43
+ image_type text not null,
44
+ primary key (report_id, position)
36
45
  );
37
46
  ```
38
47
 
@@ -70,17 +79,30 @@ export async function POST(request: Request): Promise<Response> {
70
79
  const user = await currentUser(request);
71
80
  if (!user) return new Response(null, { status: 401 });
72
81
  try {
73
- const { report, image } = await parseFeedbackRequest(request);
74
- const { image: _, ...stored } = report;
75
- const bytes = image ? new Uint8Array(await image.arrayBuffer()) : null;
82
+ const { report } = await parseFeedbackRequest(request);
83
+ const screenshots = report.screenshots.map(({ image: _, ...shot }) => shot);
76
84
  // The report was parsed from JSON, so it is JSON; context is only typed as unknown values.
77
- const json = db().json(stored as postgres.JSONValue);
78
- // A "Try again" sends the same id, so the row it already wrote stays as it is.
79
- await db()`
80
- insert into feedback_reports (id, user_id, created_at, report, image, image_type)
81
- values (${report.id}, ${user}, ${report.createdAt}, ${json}, ${bytes},
82
- ${image?.type ?? null})
83
- on conflict (id) do nothing`;
85
+ const json = db().json({ ...report, screenshots } as postgres.JSONValue);
86
+ const images = await Promise.all(
87
+ report.screenshots.map(async ({ image }, position) => ({
88
+ report_id: report.id,
89
+ position,
90
+ image: new Uint8Array(await image.arrayBuffer()),
91
+ image_type: image.type,
92
+ })),
93
+ );
94
+ // One transaction, so a report row never lacks its screenshots.
95
+ await db().begin(async (sql) => {
96
+ // A "Try again" sends the same id, so the rows it already wrote stay as they are.
97
+ const inserted = await sql`
98
+ insert into feedback_reports (id, user_id, created_at, report)
99
+ values (${report.id}, ${user}, ${report.createdAt}, ${json})
100
+ on conflict (id) do nothing
101
+ returning id`;
102
+ if (inserted.length && images.length) {
103
+ await sql`insert into feedback_screenshots ${sql(images)}`;
104
+ }
105
+ });
84
106
  return new Response(null, { status: 204 });
85
107
  } catch (error) {
86
108
  if (error instanceof FeedbackRequestError) {
@@ -1,8 +1,8 @@
1
1
  # Recipe: an S3-compatible bucket (S3, R2, Railway Buckets)
2
2
 
3
- Each report becomes two objects in a private bucket, named and shaped as in the local recipe
4
- (docs/recipes/local.md): `feedback/<id>/report.json`, whose `image` is the image's name, and
5
- `feedback/<id>/image.png` or `image.webp`.
3
+ Each report becomes objects in a private bucket, its report and one per screenshot, named and shaped as in the local recipe
4
+ (docs/recipes/local.md): `feedback/<id>/report.json`, where each screenshot's `image` is its file
5
+ name, and `feedback/<id>/screenshot-<n>.png` or `.webp` for each screenshot, numbered from 0.
6
6
 
7
7
  The bucket stays private: nothing is public, and people read reports only through your own route,
8
8
  behind your own admin check. `report.context` comes from the browser (docs/API.md), so an admin view
@@ -64,7 +64,7 @@ function bucket() {
64
64
 
65
65
  export async function POST(request: Request): Promise<Response> {
66
66
  try {
67
- const { report, image } = await parseFeedbackRequest(request);
67
+ const { report } = await parseFeedbackRequest(request);
68
68
  const { client, url } = bucket();
69
69
  // A report already stored under this id stays: a retry, or a reused id, changes nothing.
70
70
  const stored = await client.fetch(url(report.id, "report.json"), { method: "HEAD" });
@@ -73,11 +73,15 @@ export async function POST(request: Request): Promise<Response> {
73
73
  if (stored.status !== 403 && stored.status !== 404) {
74
74
  throw new Error(`The bucket answered ${stored.status} for report.json.`);
75
75
  }
76
- const name = image ? `image.${image.type === "image/png" ? "png" : "webp"}` : null;
77
- // The image first: a report.json in the bucket then always has its image beside it.
76
+ const name = (image: Blob, i: number) =>
77
+ `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
78
+ const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
79
+ // The images first: a report.json in the bucket then always has its images beside it.
78
80
  const puts: [string, BodyInit, string][] = [
79
- ...(image && name ? [[name, image, image.type] as [string, BodyInit, string]] : []),
80
- ["report.json", JSON.stringify({ ...report, image: name }), "application/json"],
81
+ ...report.screenshots.map(
82
+ ({ image }, i): [string, BodyInit, string] => [name(image, i), image, image.type],
83
+ ),
84
+ ["report.json", JSON.stringify({ ...report, screenshots }), "application/json"],
81
85
  ];
82
86
  for (const [file, body, type] of puts) {
83
87
  const put = await client.fetch(url(report.id, file), {
@@ -99,14 +103,15 @@ export async function POST(request: Request): Promise<Response> {
99
103
  }
100
104
  }
101
105
 
102
- // GET /api/feedback?id=<report id>&file=report.json | image.png | image.webp
106
+ // GET /api/feedback?id=<report id>&file=report.json | screenshot-<n>.png | screenshot-<n>.webp
103
107
  export async function GET(request: Request): Promise<Response> {
104
108
  if (!(await canRead(request))) return new Response(null, { status: 403 });
105
109
  const query = new URL(request.url).searchParams;
106
110
  const id = query.get("id") ?? "";
107
111
  const file = query.get("file") ?? "";
108
112
  // Only a report id and a known file name, so the key cannot leave feedback/<id>/.
109
- if (!/^[0-9a-f-]{36}$/.test(id) || !["report.json", "image.png", "image.webp"].includes(file)) {
113
+ const known = file === "report.json" || /^screenshot-\d\.(png|webp)$/.test(file);
114
+ if (!/^[0-9a-f-]{36}$/.test(id) || !known) {
110
115
  return new Response(null, { status: 400 });
111
116
  }
112
117
  const { client, url } = bucket();