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/AGENTS.md +11 -7
- package/CHANGELOG.md +91 -0
- package/README.md +5 -3
- package/dist/dialog-B57CJ4I_.mjs +1374 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +1 -1
- package/dist/keep-BG5WxZPU.mjs +74 -0
- package/dist/pdf-Gj-ZO9W5.mjs +85 -0
- package/dist/react.d.mts +1 -1
- package/dist/react.mjs +1 -1
- package/dist/{report-BxonlIdW.mjs → report-BwLr1sYt.mjs} +45 -21
- package/dist/{report-CIgy7uzP.d.mts → report-CaJegYyg.d.mts} +30 -16
- package/dist/{report-BRZX6sWL.mjs → report-CnspEsHf.mjs} +27 -15
- package/dist/server.d.mts +2 -3
- package/dist/server.mjs +22 -13
- package/dist/{widget-CVnusNlY.mjs → widget-B1JDOVI1.mjs} +102 -25
- package/dist/{widget-Ca1heF8n.d.mts → widget-Z1KOUKjx.d.mts} +14 -4
- package/docs/API.md +59 -31
- package/docs/SPEC.md +113 -27
- package/docs/THEMING.md +16 -9
- package/docs/recipes/github-issues.md +44 -20
- package/docs/recipes/local.md +17 -13
- package/docs/recipes/postgres.md +42 -20
- package/docs/recipes/s3.md +15 -10
- package/docs/recipes/supabase.md +18 -18
- package/docs/recipes/vercel-blob.md +18 -12
- package/package.json +6 -3
- package/dist/dialog-BTY6FKLo.mjs +0 -485
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
|
|
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 +--
|
|
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,
|
|
20
|
-
(idle).
|
|
21
|
-
- **annotating:** a full-viewport overlay shows the image
|
|
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.
|
|
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,
|
|
30
|
-
|
|
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
|
|
37
|
-
|
|
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
|
|
60
|
-
|
|
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
|
|
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
|
-
-
|
|
70
|
-
- No console logs, network logs, cookies
|
|
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,
|
|
76
|
-
|
|
77
|
-
|
|
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):
|
|
85
|
-
- **Annotator and dialog:**
|
|
86
|
-
- **Idle cost:** no work after the first paint of the trigger
|
|
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
|
-
|
|
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` | `
|
|
19
|
-
| `--vf-surface` | `Canvas` | panel and
|
|
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
|
|
29
|
-
| `--vf-trigger-size` | `
|
|
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`, `
|
|
43
|
-
`
|
|
44
|
-
`
|
|
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)
|
|
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
|
|
4
|
-
category as a label.
|
|
5
|
-
`feedback/<id>/
|
|
6
|
-
it
|
|
7
|
-
|
|
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 `
|
|
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
|
-
|
|
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 `${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
|
-
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
const name = `
|
|
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
|
-
|
|
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,
|
|
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
|
|
171
|
-
const json = JSON.stringify({ ...
|
|
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) {
|
package/docs/recipes/local.md
CHANGED
|
@@ -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 `
|
|
7
|
-
`
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
|
50
|
-
|
|
51
|
-
await writeFile(join(folder,
|
|
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,
|
|
57
|
+
JSON.stringify({ ...report, screenshots }, null, 2),
|
|
55
58
|
);
|
|
56
|
-
// After report.json, so a resend with
|
|
57
|
-
|
|
58
|
-
|
|
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) {
|
package/docs/recipes/postgres.md
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
# Recipe: Postgres
|
|
2
2
|
|
|
3
|
-
Each report becomes one row
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
12
|
-
recipe's `POST` does:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
74
|
-
const { image: _, ...
|
|
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(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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) {
|
package/docs/recipes/s3.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Recipe: an S3-compatible bucket (S3, R2, Railway Buckets)
|
|
2
2
|
|
|
3
|
-
Each report becomes
|
|
4
|
-
(docs/recipes/local.md): `feedback/<id>/report.json`,
|
|
5
|
-
`feedback/<id>/
|
|
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
|
|
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
|
|
77
|
-
|
|
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
|
-
...(
|
|
80
|
-
|
|
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 |
|
|
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
|
-
|
|
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();
|