visual-feedback 0.0.0-stage → 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 ADDED
@@ -0,0 +1,214 @@
1
+ # visual-feedback: behaviour
2
+
3
+ ## 1. What it is
4
+ A user reports a problem or an idea from the page they are on. They capture the screen, mark it
5
+ up, write a note and send it. The host app receives one report and decides where it goes. The
6
+ package sends nothing by itself, and stores nothing but an unsent draft on the user's own device
7
+ (§7).
8
+
9
+ ## 2. Flow
10
+ ```
11
+ idle --open--> capturing --image ready--> annotating --Send--> sending --ok--> sent --3 s--> idle
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
17
+ ```
18
+ - **idle:** the trigger button shows (`trigger: "button"`), or nothing shows (`trigger: "none"`,
19
+ where the host calls `open()`).
20
+ - **capturing:** the trigger and any open panel are hidden, so they are not in the image. The
21
+ screen is captured (SPEC §5), and a screen reader is told the `capturing` text.
22
+ - **capture error:** the `captureError` text, with "Try again" (back to capturing), "Send without
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.
42
+ - **sending:** the note, the category and the tools are disabled, and the Send button shows
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.
45
+ - **sent:** a short confirmation, then idle.
46
+ - **send error:** the message comes from the host's error, or else the `error` text. The note and
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.
49
+
50
+ ## 3. The trigger
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.
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.
58
+ - It does not watch the page. The host moves it off its own content with `position` or
59
+ `--vf-offset`.
60
+
61
+ ## 4. Annotating
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.
69
+ - **One colour** for the marks, `--vf-mark`, which defaults to a red that contrasts with most pages.
70
+ - Pointer events, so mouse, pen and touch all work. While drawing, touch never scrolls the page.
71
+ - **Note:** a textarea of 2000 characters at most, with a visible label (`noteLabel`). It is
72
+ required unless `requireNote: false`.
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.
88
+ - Sending without any mark is allowed.
89
+
90
+ ## 5. Capture
91
+ - **mode "dom"** (the default) serialises the DOM to an image, and needs no permission.
92
+ - **mode "display"** uses `getDisplayMedia` for exact pixels, after the browser asks the user. The
93
+ image is what the user picks in the browser's prompt: this tab, another window or the whole
94
+ screen. If the user refuses, the browser does not support it, or the shared screen gives no
95
+ frame within 2 seconds, it falls back to "dom".
96
+ - **Size:** in "dom", the image is the visible viewport at the device pixel ratio; in "display",
97
+ the shared surface. Both are scaled down to `maxWidth` (1920 by default).
98
+ - **Format:** WebP by default, or PNG if the browser cannot encode WebP.
99
+ - **Fonts and images:** what cannot be drawn (for example a cross-origin image without CORS) shows
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.
104
+
105
+ ## 6. The report
106
+ Defined in `docs/API.md` (`FeedbackReport`). It holds:
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;
110
+ - the note and the category;
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";
113
+ - the user agent, the language, the time zone and the time of Send;
114
+ - whatever the host's `context()` returns.
115
+
116
+ ## 7. Privacy
117
+ - The image is what the user sees. Nothing on the page is masked, hidden or redacted, password
118
+ fields included. Only the widget itself is left out (SPEC §2, capturing).
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.
135
+ - The query string and the hash of the URL are kept unless `redactUrl: true` is set.
136
+
137
+ ## 8. Accessibility
138
+ - The overlay is a `role="dialog"` with `aria-modal="true"` and a label.
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).
148
+ - With the keyboard alone, the user can still write the note, choose a category and send without
149
+ marks.
150
+ - `prefers-reduced-motion` turns off every transition.
151
+ - `prefers-color-scheme` sets the default theme (`theme: "auto"`).
152
+
153
+ ## 9. Budgets
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
158
+ capture library (snapdom) keeps 12 listeners on `window` and `document` and up to 5 mutation observers
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 })`.
162
+ - CI fails when a budget is exceeded.
163
+
164
+ ## 10. Browsers
165
+ The browsers that support constructable stylesheets (`adoptedStyleSheets`) and `color-mix()`:
166
+ Chrome 111+, Firefox 113+ and Safari 16.4+. Older browsers get no trigger and no error.
167
+
168
+ ## 11. Content Security Policy
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
172
+ open, the scroll lock sets `overflow` on `<html>` through the CSSOM, which a CSP allows, and
173
+ puts back what was there on close.
174
+ - Capture (`@zumer/snapdom`) renders through `data:` image URLs, so a site with a CSP needs
175
+ `img-src 'self' data:`. With same-origin styles and images, that is all it needs: it
176
+ works under `style-src 'self'`, with no `'unsafe-inline'`, in Chromium, Firefox and WebKit
177
+ (`tests/e2e/capture-csp.spec.ts`).
178
+ - Cross-origin fonts, stylesheets and images are downloaded again with `fetch()`, which
179
+ `connect-src` governs. Where `connect-src` does not allow their origin, they are treated as §5
180
+ says for anything that cannot be drawn.
181
+
182
+ ## 12. Default copy
183
+ These are the default texts. Each one can be replaced through `strings` (`docs/API.md`).
184
+
185
+ | key | text |
186
+ |---|---|
187
+ | trigger | Send feedback |
188
+ | title | Send feedback |
189
+ | capturing | Taking a screenshot… |
190
+ | captureError | Couldn't take a screenshot. |
191
+ | sendWithout | Send without screenshot |
192
+ | noteLabel | Your note |
193
+ | notePlaceholder | What happened, or what would you change? |
194
+ | noteRequired | Write a few words first. |
195
+ | categoryLabel | Type |
196
+ | pen / box / arrow / text / select | Draw / Box / Arrow / Text / Select |
197
+ | remove | Delete |
198
+ | undo / clear | Undo / Clear |
199
+ | cancel / send | Cancel / Send |
200
+ | sending | Sending… |
201
+ | sent | Thanks, it's sent. |
202
+ | error | It didn't send. Your note is kept. |
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. |
@@ -0,0 +1,72 @@
1
+ # visual-feedback: theming
2
+
3
+ The widget lives in one shadow root, so the site's CSS cannot break it, and its CSS cannot leak
4
+ out. There are four ways to adapt it, from lightest to heaviest:
5
+
6
+ ## 1. It inherits
7
+ Inside the shadow root, the widget sets `font: inherit` and `color-scheme: light dark`, so a site
8
+ rule such as `* { all: unset }` cannot reset them. The defaults use the system colours `Canvas`,
9
+ `CanvasText` and `AccentColor`. With no theming at all, it takes the site's font and follows light
10
+ and dark mode.
11
+
12
+ ## 2. CSS custom properties
13
+ Set these on `visual-feedback` (or on any ancestor). Custom properties cross the shadow boundary.
14
+
15
+ | variable | default | used for |
16
+ |---|---|---|
17
+ | `--vf-font` | `inherit` | font family of all text, such as `Inter, sans-serif` |
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
+ | `--vf-text` | `CanvasText` | text |
21
+ | `--vf-muted` | text at 60% | hints, counters |
22
+ | `--vf-border` | text at 15% | hairlines |
23
+ | `--vf-accent` | `AccentColor`, or `#0b57d0` where the browser has no `AccentColor` | Send button, active tool |
24
+ | `--vf-accent-text` | black or white, whichever contrasts more with the accent | text on the accent |
25
+ | `--vf-focus` | `--vf-accent` | focus ring |
26
+ | `--vf-mark` | `#e5243b` | marks on the screenshot |
27
+ | `--vf-backdrop` | `rgb(0 0 0 / 45%)` | overlay behind the image |
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
+ | `--vf-z` | `2147483000` | stacking |
32
+
33
+ ```css
34
+ visual-feedback {
35
+ --vf-accent: #2563eb;
36
+ --vf-accent-text: #fff;
37
+ --vf-radius: 8px;
38
+ }
39
+ ```
40
+
41
+ ## 3. `::part()`
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".
51
+
52
+ ```css
53
+ visual-feedback::part(trigger) { opacity: .6; }
54
+ visual-feedback::part(trigger):hover { opacity: 1; }
55
+ ```
56
+
57
+ ## 4. The `css` option and `unstyled`
58
+ - `css` is a string. It is added as a constructable stylesheet inside the shadow root, after the
59
+ defaults, so it can reach anything, including states that `::part()` cannot select.
60
+ - `unstyled: true` drops every default except the layout-critical rules (the trigger's position and
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.
63
+
64
+ ```ts
65
+ createFeedback({ onSubmit, css: `.toolbar { gap: 2px } .send { text-transform: uppercase }` });
66
+ ```
67
+ Class names used inside the shadow root are part of the public API from 1.0. Before 1.0 they may
68
+ change.
69
+
70
+ ## Theme
71
+ `theme: "light" | "dark"` sets `color-scheme` inside the shadow root, which forces that scheme.
72
+ `"auto"` follows the system.
@@ -0,0 +1,209 @@
1
+ # Recipe: GitHub Issues (private repo only)
2
+
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
10
+ is private, and refuses otherwise. It uses `fetch` and GitHub's REST API, so it needs no package.
11
+
12
+ `report.context` comes from the browser (docs/API.md): the issue shows it as data, never as proof
13
+ of who sent the report. The images show the screen as the user saw it, with nothing masked
14
+ (SPEC §7). Only people with access to the repo see them. GitHub shows an image in an issue through
15
+ a signed link that works for anyone who holds it, for a few minutes: show it, never share it.
16
+
17
+ ## 1. Pick a layout
18
+
19
+ **A dedicated private repo (the default).** Create a private repo only for feedback, such as
20
+ `<app>-feedback`, never the code repo, with a README so it has a default branch. Images stay out of the code repo's clones, and making the
21
+ code repo public later exposes nothing. Images are committed to its default branch.
22
+
23
+ **The code repo, with an orphan branch.** Only when you are sure the code repo stays private
24
+ forever. Issues sit next to the code with one token, and images go to a branch `visual-feedback`
25
+ that shares no history with the code. A token cannot be limited to one branch, so the server's
26
+ token can also push to the code: a leak of it is a leak of write access to your source. But every clone and fetch downloads that branch unless it is
27
+ cloned with `--single-branch`, and if the repo is ever made public, every report is exposed. Create
28
+ the branch once, in a clean clone (git 2.23 or later):
29
+
30
+ ```sh
31
+ git switch --orphan visual-feedback
32
+ git commit --allow-empty -m "visual-feedback images"
33
+ git push origin visual-feedback
34
+ git switch -
35
+ ```
36
+
37
+ In both layouts, deleting a file or a branch does not remove its images or reports: commits stay reachable by
38
+ their SHA on GitHub until GitHub Support purges them. A later option, once it works with a
39
+ fine-grained token, is GitHub's own issue-attachment upload, which keeps images out of git history;
40
+ it is undocumented, so this recipe does not use it.
41
+
42
+ ## 2. Token and settings
43
+
44
+ Create a fine-grained personal access token for that one repo, with **Issues: read and write** and
45
+ **Contents: read and write**, and set on the server:
46
+
47
+ | variable | for example |
48
+ |---|---|
49
+ | `GITHUB_FEEDBACK_REPO` | `acme/app-feedback` |
50
+ | `GITHUB_FEEDBACK_TOKEN` | the token; never send it to the browser |
51
+ | `GITHUB_FEEDBACK_BRANCH` | `visual-feedback` for the code-repo layout; unset for the default branch |
52
+
53
+ Send PNG from the widget, the image type this recipe was checked with inside an issue:
54
+ `createFeedback({ endpoint: "/api/feedback", capture: { type: "image/png" } })`. Without it the
55
+ widget sends WebP (docs/API.md): the route commits and links `screenshot-<n>.webp` the same way, but whether
56
+ the issue shows a WebP image inline is not checked.
57
+
58
+ ## 3. The route
59
+
60
+ This is a Next.js App Router route on the Node.js runtime (it uses `Buffer`). As written, anyone who
61
+ can reach `POST` can open issues, with any category as a label: before production, put it behind
62
+ your own session check and a rate limit.
63
+
64
+ ```ts
65
+ // app/api/feedback/route.ts
66
+ import {
67
+ type FeedbackReport,
68
+ FeedbackRequestError,
69
+ parseFeedbackRequest,
70
+ } from "visual-feedback/server";
71
+
72
+ function github() {
73
+ const { GITHUB_FEEDBACK_REPO: repo, GITHUB_FEEDBACK_TOKEN: token } = process.env;
74
+ if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo) || !token) {
75
+ throw new Error("Set GITHUB_FEEDBACK_REPO (owner/name) and GITHUB_FEEDBACK_TOKEN.");
76
+ }
77
+ return async (method: string, path: string, body?: object, accept = "github+json") => {
78
+ const response = await fetch(`https://api.github.com/repos/${repo}${path}`, {
79
+ method,
80
+ headers: {
81
+ authorization: `Bearer ${token}`,
82
+ accept: `application/vnd.${accept}`,
83
+ "x-github-api-version": "2022-11-28",
84
+ },
85
+ ...(body ? { body: JSON.stringify(body) } : {}),
86
+ cache: "no-store",
87
+ });
88
+ return { status: response.status, json: response.ok ? await response.json() : null };
89
+ };
90
+ }
91
+
92
+ // GitHub refuses an issue body over 65,536 characters; report.json keeps every field in full.
93
+ function cut(text: string, max: number): string {
94
+ const chars = Array.from(text);
95
+ return chars.length > max ? `${chars.slice(0, max).join("")}…` : text;
96
+ }
97
+
98
+ // A fence longer than any run of backticks in the text, so the text shows as written.
99
+ function fenced(text: string, max: number): string {
100
+ const shown = cut(text, max);
101
+ const longest = Math.max(2, ...[...shown.matchAll(/`+/g)].map((run) => run[0].length));
102
+ const fence = "`".repeat(longest + 1);
103
+ return `${fence}text\n${shown}\n${fence}`;
104
+ }
105
+
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 {
129
+ const { page, viewport, userAgent, language, timeZone, createdAt, id } = report;
130
+ const screen = `${viewport.width}x${viewport.height} @${viewport.devicePixelRatio}x`;
131
+ // Each fence starts its own line: after other text on that line it is no block.
132
+ return [
133
+ fenced(report.note || "(no note)", 10_000 * share),
134
+ // The angle brackets keep a ( or ) in a branch name from ending the link.
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)}`,
138
+ `**Sent:** ${createdAt}, report \`${id}\``,
139
+ `**Context, from the browser:**\n${fenced(JSON.stringify(report.context, null, 2), 6_000 * share)}`,
140
+ ].join("\n\n");
141
+ }
142
+
143
+ export async function POST(request: Request): Promise<Response> {
144
+ try {
145
+ const { report } = await parseFeedbackRequest(request);
146
+ const api = github();
147
+ const repo = await api("GET", "");
148
+ // Checked on every send: a repo made public later must not receive reports.
149
+ if (repo.status !== 200) throw new Error(`GitHub answered ${repo.status} for the repo.`);
150
+ if (repo.json.private !== true) {
151
+ return new Response("The feedback repo must be private.", { status: 503 });
152
+ }
153
+ const branch = process.env.GITHUB_FEEDBACK_BRANCH || repo.json.default_branch;
154
+ const ref = `?ref=${encodeURIComponent(branch)}`;
155
+ const folder = `feedback/${report.id}`;
156
+ // The plain JSON answer refuses a file over 1 MB; the object one only leaves out its content.
157
+ const stored = (name: string) =>
158
+ api("GET", `/contents/${folder}/${name}${ref}`, undefined, "github.object");
159
+ // report.json is written last, so its presence means the issue exists: a resend stops here.
160
+ const marker = await stored("report.json");
161
+ if (marker.status === 200) return new Response(null, { status: 204 });
162
+ if (marker.status !== 404) throw new Error(`GitHub answered ${marker.status} for report.json.`);
163
+ const put = async (name: string, bytes: Uint8Array) => {
164
+ const content = Buffer.from(bytes).toString("base64");
165
+ const message = `Feedback ${report.id}: ${name}`;
166
+ // No sha, so GitHub only creates: an existing file is never replaced (422).
167
+ let commit = await api("PUT", `/contents/${folder}/${name}`, { message, content, branch });
168
+ // Two commits to one branch at once: GitHub refuses the second with 409.
169
+ for (let retry = 0; commit.status === 409 && retry < 2; retry++) {
170
+ commit = await api("PUT", `/contents/${folder}/${name}`, { message, content, branch });
171
+ }
172
+ if (commit.status === 201) return;
173
+ // GitHub also answers 422 for a missing branch, so only a file already there counts.
174
+ if (commit.status !== 422 || (await stored(name)).status !== 200) {
175
+ throw new Error(`GitHub answered ${commit.status} for ${name}.`);
176
+ }
177
+ };
178
+ const imageUrls: string[] = [];
179
+ for (const [i, { image }] of report.screenshots.entries()) {
180
+ const name = `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
181
+ await put(name, new Uint8Array(await image.arrayBuffer()));
182
+ const path = `${branch.split("/").map(encodeURIComponent).join("/")}/${folder}/${name}`;
183
+ imageUrls.push(`https://github.com/${repo.json.full_name}/blob/${path}?raw=true`);
184
+ }
185
+ // GitHub refuses a label name over 50 characters, and one that is only space.
186
+ const category = cut(report.category?.replace(/\s+/g, " ").trim() ?? "", 49);
187
+ const title = cut(report.note.split("\n")[0]?.trim() || "Feedback", 80);
188
+ const issue = await api("POST", "/issues", {
189
+ title,
190
+ body: issueBody(report, imageUrls),
191
+ labels: ["feedback", ...(category ? [category] : [])],
192
+ });
193
+ if (issue.status !== 201) throw new Error(`GitHub answered ${issue.status} for the issue.`);
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);
196
+ await put("report.json", new TextEncoder().encode(json));
197
+ return new Response(null, { status: 204 });
198
+ } catch (error) {
199
+ if (error instanceof FeedbackRequestError) {
200
+ return new Response(error.message, { status: error.status });
201
+ }
202
+ throw error;
203
+ }
204
+ }
205
+ ```
206
+
207
+ If the issue opens but `report.json` cannot be written, a "Try again" opens a second issue for the
208
+ same report. Two sends of one report at the same moment can also both open one, since the check
209
+ and the writes are separate. Each issue body names the report id, so the two are easy to match.
@@ -0,0 +1,75 @@
1
+ # Recipe: a local `.visual-feedback/` folder
2
+
3
+ For local development only: it writes only when `NODE_ENV` is `development`. `next dev` sets it;
4
+ start any other server with `NODE_ENV=development`.
5
+
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
+
10
+ It does not work on hosts whose disk is read-only or wiped on each deploy or restart, such as
11
+ Vercel, Netlify, Cloudflare and most containers. Use another recipe there.
12
+
13
+ The images show the screen as the user saw it, with nothing masked (SPEC §7), so treat the folder
14
+ as private.
15
+
16
+ ## 1. Keep the folder out of git
17
+
18
+ Add this line to `.gitignore`:
19
+
20
+ ```gitignore
21
+ .visual-feedback/
22
+ ```
23
+
24
+ ## 2. The route
25
+
26
+ The widget posts to it with `createFeedback({ endpoint: "/api/feedback" })`. This is a Next.js
27
+ App Router route. Other servers that hand you a Web `Request` on Node can call the same function;
28
+ one that hands you Node's `IncomingMessage`, such as Express, needs an adapter first. The route
29
+ checks no user, so keep the dev server on localhost: `next dev` listens on every network
30
+ interface unless it is started as `next dev -H 127.0.0.1`.
31
+
32
+ ```ts
33
+ // app/api/feedback/route.ts
34
+ import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
35
+ import { join } from "node:path";
36
+ import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
37
+
38
+ export async function POST(request: Request): Promise<Response> {
39
+ // Development only: a deployed disk can be read-only or wiped, and NODE_ENV may be unset.
40
+ if (process.env.NODE_ENV !== "development") {
41
+ return new Response("Local feedback storage works only in development.", { status: 503 });
42
+ }
43
+ try {
44
+ const { report } = await parseFeedbackRequest(request);
45
+ // report.id is a checked UUID, so it is safe as a folder name.
46
+ const folder = join(process.cwd(), ".visual-feedback", report.id);
47
+ const name = (image: Blob, i: number) =>
48
+ `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
49
+ await mkdir(folder, { recursive: true });
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) }));
55
+ await writeFile(
56
+ join(folder, "report.json"),
57
+ JSON.stringify({ ...report, screenshots }, null, 2),
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 });
63
+ }
64
+ return new Response(null, { status: 204 });
65
+ } catch (error) {
66
+ if (error instanceof FeedbackRequestError) {
67
+ return new Response(error.message, { status: error.status });
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ ```
73
+
74
+ A "Try again" sends the same report again, with the same `id`, so it writes over its own folder and
75
+ never makes a second one.