visual-feedback 0.0.0-stage → 0.1.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 +96 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +67 -2
- package/dist/capture-CufRosj3.mjs +83 -0
- package/dist/dialog-BTY6FKLo.mjs +485 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +2 -0
- package/dist/react.d.mts +6 -0
- package/dist/react.mjs +29 -0
- package/dist/report-BRZX6sWL.mjs +83 -0
- package/dist/report-BxonlIdW.mjs +97 -0
- package/dist/report-CIgy7uzP.d.mts +43 -0
- package/dist/seen-BpIWldOP.mjs +21 -0
- package/dist/server.d.mts +15 -0
- package/dist/server.mjs +95 -0
- package/dist/widget-CVnusNlY.mjs +278 -0
- package/dist/widget-Ca1heF8n.d.mts +56 -0
- package/docs/API.md +119 -0
- package/docs/SPEC.md +128 -0
- package/docs/THEMING.md +65 -0
- package/docs/recipes/github-issues.md +185 -0
- package/docs/recipes/local.md +71 -0
- package/docs/recipes/postgres.md +92 -0
- package/docs/recipes/s3.md +124 -0
- package/docs/recipes/supabase.md +124 -0
- package/docs/recipes/vercel-blob.md +123 -0
- package/package.json +98 -4
package/docs/API.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# visual-feedback: API
|
|
2
|
+
|
|
3
|
+
ESM only, with TypeScript types. On first open it imports `@zumer/snapdom` by name, so a page
|
|
4
|
+
needs a bundler, or an import map that points that name to the file. There are three entry points:
|
|
5
|
+
|
|
6
|
+
| import | for | runs in |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `visual-feedback` | `createFeedback()` and the types | browser |
|
|
9
|
+
| `visual-feedback/server` | `parseFeedbackRequest()`, `FeedbackRequestError` and the report types | any runtime with Web `Request` (Node 20+, Bun, Deno, edge) |
|
|
10
|
+
| `visual-feedback/react` | `<Feedback />` and the type `FeedbackProps` | browser, React 18+ |
|
|
11
|
+
|
|
12
|
+
## createFeedback(options: FeedbackOptions): FeedbackWidget
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { createFeedback } from "visual-feedback";
|
|
16
|
+
|
|
17
|
+
const feedback = createFeedback({
|
|
18
|
+
onSubmit: async (report) => {
|
|
19
|
+
const body = new FormData();
|
|
20
|
+
body.append("report", JSON.stringify({ ...report, image: undefined }));
|
|
21
|
+
if (report.image) {
|
|
22
|
+
const extension = report.image.type === "image/png" ? "png" : "webp";
|
|
23
|
+
body.append("image", report.image, `${report.id}.${extension}`);
|
|
24
|
+
}
|
|
25
|
+
const res = await fetch("/api/feedback", { method: "POST", body });
|
|
26
|
+
if (!res.ok) throw new Error("It didn't send. Your note is kept.");
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Options
|
|
32
|
+
| option | type | default | meaning |
|
|
33
|
+
|---|---|---|---|
|
|
34
|
+
| `onSubmit` | `(report) => Promise<void>` | – | Sends the report. A throw shows the send error state, using the error's message. Give this or `endpoint`; with both, `onSubmit` is used. |
|
|
35
|
+
| `endpoint` | `string` | – | POSTs `multipart/form-data` with a `report` part (JSON, no image) and an `image` part named `<id>.webp` or `<id>.png`. An answer that is not 2xx, a redirect included, shows the send error state with the default `error` text. It is a same-origin `fetch`, so it sends cookies only to the page's own origin. |
|
|
36
|
+
| `headers` | `Record<string, string>` or `() => Record<string, string>` | – | Extra headers for `endpoint`. |
|
|
37
|
+
| `trigger` | `"button" \| "none"` | `"button"` | `"none"` draws nothing; the host calls `open()`. |
|
|
38
|
+
| `position` | `"bottom-right" \| "bottom-left" \| "top-right" \| "top-left"` | `"bottom-right"` | Corner of the trigger. |
|
|
39
|
+
| `shortcut` | `string \| false` | `false` | For example `"Alt+Shift+F"`: any of `Alt`, `Shift`, `Control` and `Meta`, then one key, a letter, a digit or a key name such as `F2`, joined by `+`. The modifiers must match exactly, AltGr never matches, and any other modifier name makes it match nothing. Without `Control` or `Meta` it does not fire while focus is in a text field. It does nothing while the widget is open. Opt-in; it is the widget's only global listener, one `keydown` on `window` (SPEC §9 names what the capture library keeps). |
|
|
40
|
+
| `tools` | `("pen" \| "box" \| "arrow")[]` | all three | Order is the toolbar order. Repeats and unknown names are ignored; an empty list shows no toolbar. |
|
|
41
|
+
| `categories` | `{ id: string; label: string }[]` | none | Shows a category picker. |
|
|
42
|
+
| `requireNote` | `boolean` | `true` | |
|
|
43
|
+
| `capture.mode` | `"dom" \| "display"` | `"dom"` | SPEC §5. The type of `capture` is exported as `CaptureOptions`. |
|
|
44
|
+
| `capture.maxWidth` | `number` | `1920` | |
|
|
45
|
+
| `capture.type` | `"image/webp" \| "image/png"` | `"image/webp"` | |
|
|
46
|
+
| `capture.quality` | `number` | `0.85` | For WebP. |
|
|
47
|
+
| `redactUrl` | `boolean` | `false` | Drops the query string and the hash from `page.url`. |
|
|
48
|
+
| `context` | `() => Record<string, unknown> \| Promise<…>` | – | Host data added to the report, such as a user id or the app version. Called at Send; a throw shows the send error state, using the error's message. |
|
|
49
|
+
| `strings` | `Partial<FeedbackStrings>` | English | SPEC §12 keys. Keys left out, or given as `undefined`, keep their English text. |
|
|
50
|
+
| `theme` | `"auto" \| "light" \| "dark"` | `"auto"` | THEMING.md. |
|
|
51
|
+
| `css` | `string` | – | Extra CSS added inside the shadow root, after the defaults. THEMING.md. |
|
|
52
|
+
| `unstyled` | `boolean` | `false` | Keeps only the layout-critical CSS. THEMING.md. |
|
|
53
|
+
| `container` | `HTMLElement` | `document.body` | Where the host element is appended. |
|
|
54
|
+
| `tagName` | `string` | `"visual-feedback"` | The custom element name, if it clashes. |
|
|
55
|
+
|
|
56
|
+
### FeedbackWidget
|
|
57
|
+
| member | meaning |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `open(): Promise<void>` | Starts a capture. When it is already open, it does nothing. When the dialog cannot load or be shown, it rejects and `error` fires. |
|
|
60
|
+
| `close(): void` | Cancels and discards the draft. Before `submit` fires, nothing is sent; after it, the delivery is not stopped, and `sent` or `error` still fires when it ends. |
|
|
61
|
+
| `destroy(): void` | Removes the element, its listeners and its stylesheet, but not what the capture library keeps (SPEC §9). If the dialog is open, `close` fires, and it is the last event: a delivery still under way fires neither `sent` nor `error`. |
|
|
62
|
+
| `on(event, fn): () => void` | Events: `open`, `close`, `submit` (with the report, before each attempt), `sent` (with the report), `error` (with the error). The type `FeedbackEvents` maps each event to what it passes. Returns an unsubscribe function. |
|
|
63
|
+
| `update(options): void` | Merges these options into the current ones, key by key: a key given as `undefined` goes back to its default, and `strings` or `capture` replaces the whole object. `tagName` and `container` are ignored. The trigger, `position`, `theme`, `css`, `unstyled`, `strings.trigger` and `shortcut` change at once. The dialog's texts, `tools`, `categories` and `requireNote` apply from the next open, and the rest at the next capture or send. |
|
|
64
|
+
|
|
65
|
+
## FeedbackReport
|
|
66
|
+
```ts
|
|
67
|
+
type FeedbackReport = {
|
|
68
|
+
id: string; // UUID v4 from crypto.getRandomValues, so plain http works
|
|
69
|
+
createdAt: string; // ISO 8601
|
|
70
|
+
note: string;
|
|
71
|
+
category?: string;
|
|
72
|
+
image: Blob | null; // marks painted in; null after "Send without screenshot"
|
|
73
|
+
marks: Mark[]; // vectors, in image pixels
|
|
74
|
+
page: { url: string; title: string };
|
|
75
|
+
viewport: { width: number; height: number; devicePixelRatio: number };
|
|
76
|
+
scroll: { x: number; y: number };
|
|
77
|
+
userAgent: string;
|
|
78
|
+
language: string;
|
|
79
|
+
timeZone: string;
|
|
80
|
+
context: Record<string, unknown>;
|
|
81
|
+
};
|
|
82
|
+
type Mark =
|
|
83
|
+
| { tool: "pen"; points: [number, number][] }
|
|
84
|
+
| { tool: "box"; x: number; y: number; width: number; height: number }
|
|
85
|
+
| { tool: "arrow"; from: [number, number]; to: [number, number] };
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## `<Feedback {...options} />` (`visual-feedback/react`)
|
|
89
|
+
Its props are the options of `createFeedback`. It renders nothing where it stands: after it mounts
|
|
90
|
+
in the browser it creates the widget, a changed prop goes to `update()` (a prop that is gone goes
|
|
91
|
+
back to its default; `tagName` and `container` are read once), and unmounting destroys it. It is a
|
|
92
|
+
client component, so a server component can render it with props that reach the browser as data,
|
|
93
|
+
such as `endpoint`; `onSubmit`, `headers` as a function, `context` and `container` need a client
|
|
94
|
+
component.
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
import { Feedback } from "visual-feedback/react";
|
|
98
|
+
|
|
99
|
+
<Feedback endpoint="/api/feedback" />;
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## parseFeedbackRequest(request, limits?) (`visual-feedback/server`)
|
|
103
|
+
```ts
|
|
104
|
+
const { report, image } = await parseFeedbackRequest(request, { maxImageBytes: 5_000_000 });
|
|
105
|
+
```
|
|
106
|
+
- It reads the multipart body that `endpoint` sends and checks the report's shape. `id` must be a
|
|
107
|
+
lower-case UUID v4, and `createdAt` an ISO time from 1970 to 9999. Fields that `FeedbackReport`
|
|
108
|
+
does not define, and `__proto__` keys at any depth, are dropped.
|
|
109
|
+
- In the report's text, `context` included, NUL characters are removed and half surrogate pairs (a
|
|
110
|
+
cut emoji) become U+FFFD, so any store, Postgres `jsonb` included, can keep it.
|
|
111
|
+
- It checks that the image is a real WebP or PNG (magic bytes, not the declared type) and within
|
|
112
|
+
`maxImageBytes`, which is `10_000_000` by default. The report part may be at most 1 MiB, and
|
|
113
|
+
the whole body at most `maxImageBytes` plus 1 MiB plus 64 KiB for the multipart framing; a
|
|
114
|
+
larger body is refused while it streams in.
|
|
115
|
+
- `image` is the checked image, typed from its bytes, or `null` when there is no image part.
|
|
116
|
+
`report.image` is the same value.
|
|
117
|
+
- If any check fails, it throws a `FeedbackRequestError` that carries a `status` (400 or 413).
|
|
118
|
+
- Storing the report, and checking who sent it, is the host's job. `report.context` comes from the
|
|
119
|
+
browser, so it is never proof of who sent it: take the user from your own session.
|
package/docs/SPEC.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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 stores nothing and sends nothing by itself.
|
|
7
|
+
|
|
8
|
+
## 2. Flow
|
|
9
|
+
```
|
|
10
|
+
idle --open--> capturing --image ready--> annotating --Send--> sending --ok--> sent --3 s--> idle
|
|
11
|
+
| | |
|
|
12
|
+
+--fails--> capture error +--Cancel/Esc--> idle +--fails--> send error
|
|
13
|
+
```
|
|
14
|
+
- **idle:** the trigger button shows (`trigger: "button"`), or nothing shows (`trigger: "none"`,
|
|
15
|
+
where the host calls `open()`).
|
|
16
|
+
- **capturing:** the trigger and any open panel are hidden, so they are not in the image. The
|
|
17
|
+
screen is captured (SPEC §5), and a screen reader is told the `capturing` text.
|
|
18
|
+
- **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.
|
|
22
|
+
- **sending:** the note, the category and the tools are disabled, and the Send button shows
|
|
23
|
+
progress. Cancel and Esc still close the dialog.
|
|
24
|
+
- **sent:** a short confirmation, then idle.
|
|
25
|
+
- **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.
|
|
27
|
+
|
|
28
|
+
## 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.
|
|
31
|
+
- It sits 16 px from the edges by default, and it respects `env(safe-area-inset-*)`.
|
|
32
|
+
- It does not watch the page. The host moves it off its own content with `position` or
|
|
33
|
+
`--vf-offset`.
|
|
34
|
+
|
|
35
|
+
## 4. Annotating
|
|
36
|
+
- **Tools:** Pen (freehand), Box (rectangle outline) and Arrow. Undo removes the last mark, and
|
|
37
|
+
Clear removes all.
|
|
38
|
+
- **One colour** for the marks, `--vf-mark`, which defaults to a red that contrasts with most pages.
|
|
39
|
+
- Pointer events, so mouse, pen and touch all work. While drawing, touch never scrolls the page.
|
|
40
|
+
- **Note:** a textarea of 2000 characters at most, with a visible label (`noteLabel`). It is
|
|
41
|
+
required unless `requireNote: false`.
|
|
42
|
+
- **Category:** shown only when `categories` is given, with a visible label (`categoryLabel`).
|
|
43
|
+
- Sending without any mark is allowed.
|
|
44
|
+
|
|
45
|
+
## 5. Capture
|
|
46
|
+
- **mode "dom"** (the default) serialises the DOM to an image, and needs no permission.
|
|
47
|
+
- **mode "display"** uses `getDisplayMedia` for exact pixels, after the browser asks the user. The
|
|
48
|
+
image is what the user picks in the browser's prompt: this tab, another window or the whole
|
|
49
|
+
screen. If the user refuses, the browser does not support it, or the shared screen gives no
|
|
50
|
+
frame within 2 seconds, it falls back to "dom".
|
|
51
|
+
- **Size:** in "dom", the image is the visible viewport at the device pixel ratio; in "display",
|
|
52
|
+
the shared surface. Both are scaled down to `maxWidth` (1920 by default).
|
|
53
|
+
- **Format:** WebP by default, or PNG if the browser cannot encode WebP.
|
|
54
|
+
- **Fonts and images:** what cannot be drawn (for example a cross-origin image without CORS) shows
|
|
55
|
+
as a grey box. It never makes the capture fail.
|
|
56
|
+
|
|
57
|
+
## 6. The report
|
|
58
|
+
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;
|
|
61
|
+
- the note and the category;
|
|
62
|
+
- the page URL and title, the viewport and the scroll position, as they were at the screenshot;
|
|
63
|
+
- the user agent, the language, the time zone and the time of Send;
|
|
64
|
+
- whatever the host's `context()` returns.
|
|
65
|
+
|
|
66
|
+
## 7. Privacy
|
|
67
|
+
- The image is what the user sees. Nothing on the page is masked, hidden or redacted, password
|
|
68
|
+
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.
|
|
71
|
+
- The query string and the hash of the URL are kept unless `redactUrl: true` is set.
|
|
72
|
+
|
|
73
|
+
## 8. Accessibility
|
|
74
|
+
- 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`).
|
|
78
|
+
- With the keyboard alone, the user can still write the note, choose a category and send without
|
|
79
|
+
marks.
|
|
80
|
+
- `prefers-reduced-motion` turns off every transition.
|
|
81
|
+
- `prefers-color-scheme` sets the default theme (`theme: "auto"`).
|
|
82
|
+
|
|
83
|
+
## 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
|
|
87
|
+
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.
|
|
89
|
+
- CI fails when a budget is exceeded.
|
|
90
|
+
|
|
91
|
+
## 10. Browsers
|
|
92
|
+
The browsers that support constructable stylesheets (`adoptedStyleSheets`) and `color-mix()`:
|
|
93
|
+
Chrome 111+, Firefox 113+ and Safari 16.4+. Older browsers get no trigger and no error.
|
|
94
|
+
|
|
95
|
+
## 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
|
|
98
|
+
open, the scroll lock sets `overflow` on `<html>` through the CSSOM, which a CSP allows, and
|
|
99
|
+
puts back what was there on close.
|
|
100
|
+
- Capture (`@zumer/snapdom`) renders through `data:` image URLs, so a site with a CSP needs
|
|
101
|
+
`img-src 'self' data:`. With same-origin styles and images, that is all it needs: it
|
|
102
|
+
works under `style-src 'self'`, with no `'unsafe-inline'`, in Chromium, Firefox and WebKit
|
|
103
|
+
(`tests/e2e/capture-csp.spec.ts`).
|
|
104
|
+
- Cross-origin fonts, stylesheets and images are downloaded again with `fetch()`, which
|
|
105
|
+
`connect-src` governs. Where `connect-src` does not allow their origin, they are treated as §5
|
|
106
|
+
says for anything that cannot be drawn.
|
|
107
|
+
|
|
108
|
+
## 12. Default copy
|
|
109
|
+
These are the default texts. Each one can be replaced through `strings` (`docs/API.md`).
|
|
110
|
+
|
|
111
|
+
| key | text |
|
|
112
|
+
|---|---|
|
|
113
|
+
| trigger | Send feedback |
|
|
114
|
+
| title | Send feedback |
|
|
115
|
+
| capturing | Taking a screenshot… |
|
|
116
|
+
| captureError | Couldn't take a screenshot. |
|
|
117
|
+
| sendWithout | Send without screenshot |
|
|
118
|
+
| noteLabel | Your note |
|
|
119
|
+
| notePlaceholder | What happened, or what would you change? |
|
|
120
|
+
| noteRequired | Write a few words first. |
|
|
121
|
+
| categoryLabel | Type |
|
|
122
|
+
| pen / box / arrow | Draw / Box / Arrow |
|
|
123
|
+
| undo / clear | Undo / Clear |
|
|
124
|
+
| cancel / send | Cancel / Send |
|
|
125
|
+
| sending | Sending… |
|
|
126
|
+
| sent | Thanks, it's sent. |
|
|
127
|
+
| error | It didn't send. Your note is kept. |
|
|
128
|
+
| retry | Try again |
|
package/docs/THEMING.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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` | `12px` | panel, inputs, buttons |
|
|
19
|
+
| `--vf-surface` | `Canvas` | panel and toolbar 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 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 |
|
|
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`, `backdrop`, `dialog`, `title`,
|
|
43
|
+
`image`, `toolbar`, `tool`, `tool-active`, `undo`, `clear`, `label`, `note`, `category`, `actions`, `cancel`, `send`,
|
|
44
|
+
`status`, `error`, `retry`, `send-without`.
|
|
45
|
+
|
|
46
|
+
```css
|
|
47
|
+
visual-feedback::part(trigger) { opacity: .6; }
|
|
48
|
+
visual-feedback::part(trigger):hover { opacity: 1; }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 4. The `css` option and `unstyled`
|
|
52
|
+
- `css` is a string. It is added as a constructable stylesheet inside the shadow root, after the
|
|
53
|
+
defaults, so it can reach anything, including states that `::part()` cannot select.
|
|
54
|
+
- `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.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
createFeedback({ onSubmit, css: `.toolbar { gap: 2px } .send { text-transform: uppercase }` });
|
|
59
|
+
```
|
|
60
|
+
Class names used inside the shadow root are part of the public API from 1.0. Before 1.0 they may
|
|
61
|
+
change.
|
|
62
|
+
|
|
63
|
+
## Theme
|
|
64
|
+
`theme: "light" | "dark"` sets `color-scheme` inside the shadow root, which forces that scheme.
|
|
65
|
+
`"auto"` follows the system.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Recipe: GitHub Issues (private repo only)
|
|
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
|
|
8
|
+
is private, and refuses otherwise. It uses `fetch` and GitHub's REST API, so it needs no package.
|
|
9
|
+
|
|
10
|
+
`report.context` comes from the browser (docs/API.md): the issue shows it as data, never as proof
|
|
11
|
+
of who sent the report. The images show the screen as the user saw it, with nothing masked
|
|
12
|
+
(SPEC §7). Only people with access to the repo see them. GitHub shows an image in an issue through
|
|
13
|
+
a signed link that works for anyone who holds it, for a few minutes: show it, never share it.
|
|
14
|
+
|
|
15
|
+
## 1. Pick a layout
|
|
16
|
+
|
|
17
|
+
**A dedicated private repo (the default).** Create a private repo only for feedback, such as
|
|
18
|
+
`<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
|
|
19
|
+
code repo public later exposes nothing. Images are committed to its default branch.
|
|
20
|
+
|
|
21
|
+
**The code repo, with an orphan branch.** Only when you are sure the code repo stays private
|
|
22
|
+
forever. Issues sit next to the code with one token, and images go to a branch `visual-feedback`
|
|
23
|
+
that shares no history with the code. A token cannot be limited to one branch, so the server's
|
|
24
|
+
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
|
|
25
|
+
cloned with `--single-branch`, and if the repo is ever made public, every report is exposed. Create
|
|
26
|
+
the branch once, in a clean clone (git 2.23 or later):
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
git switch --orphan visual-feedback
|
|
30
|
+
git commit --allow-empty -m "visual-feedback images"
|
|
31
|
+
git push origin visual-feedback
|
|
32
|
+
git switch -
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
In both layouts, deleting a file or a branch does not remove its images or reports: commits stay reachable by
|
|
36
|
+
their SHA on GitHub until GitHub Support purges them. A later option, once it works with a
|
|
37
|
+
fine-grained token, is GitHub's own issue-attachment upload, which keeps images out of git history;
|
|
38
|
+
it is undocumented, so this recipe does not use it.
|
|
39
|
+
|
|
40
|
+
## 2. Token and settings
|
|
41
|
+
|
|
42
|
+
Create a fine-grained personal access token for that one repo, with **Issues: read and write** and
|
|
43
|
+
**Contents: read and write**, and set on the server:
|
|
44
|
+
|
|
45
|
+
| variable | for example |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `GITHUB_FEEDBACK_REPO` | `acme/app-feedback` |
|
|
48
|
+
| `GITHUB_FEEDBACK_TOKEN` | the token; never send it to the browser |
|
|
49
|
+
| `GITHUB_FEEDBACK_BRANCH` | `visual-feedback` for the code-repo layout; unset for the default branch |
|
|
50
|
+
|
|
51
|
+
Send PNG from the widget, the image type this recipe was checked with inside an issue:
|
|
52
|
+
`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
|
|
54
|
+
the issue shows a WebP image inline is not checked.
|
|
55
|
+
|
|
56
|
+
## 3. The route
|
|
57
|
+
|
|
58
|
+
This is a Next.js App Router route on the Node.js runtime (it uses `Buffer`). As written, anyone who
|
|
59
|
+
can reach `POST` can open issues, with any category as a label: before production, put it behind
|
|
60
|
+
your own session check and a rate limit.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// app/api/feedback/route.ts
|
|
64
|
+
import {
|
|
65
|
+
type FeedbackReport,
|
|
66
|
+
FeedbackRequestError,
|
|
67
|
+
parseFeedbackRequest,
|
|
68
|
+
} from "visual-feedback/server";
|
|
69
|
+
|
|
70
|
+
function github() {
|
|
71
|
+
const { GITHUB_FEEDBACK_REPO: repo, GITHUB_FEEDBACK_TOKEN: token } = process.env;
|
|
72
|
+
if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo) || !token) {
|
|
73
|
+
throw new Error("Set GITHUB_FEEDBACK_REPO (owner/name) and GITHUB_FEEDBACK_TOKEN.");
|
|
74
|
+
}
|
|
75
|
+
return async (method: string, path: string, body?: object, accept = "github+json") => {
|
|
76
|
+
const response = await fetch(`https://api.github.com/repos/${repo}${path}`, {
|
|
77
|
+
method,
|
|
78
|
+
headers: {
|
|
79
|
+
authorization: `Bearer ${token}`,
|
|
80
|
+
accept: `application/vnd.${accept}`,
|
|
81
|
+
"x-github-api-version": "2022-11-28",
|
|
82
|
+
},
|
|
83
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
84
|
+
cache: "no-store",
|
|
85
|
+
});
|
|
86
|
+
return { status: response.status, json: response.ok ? await response.json() : null };
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// GitHub refuses an issue body over 65,536 characters; report.json keeps every field in full.
|
|
91
|
+
function cut(text: string, max: number): string {
|
|
92
|
+
const chars = Array.from(text);
|
|
93
|
+
return chars.length > max ? `${chars.slice(0, max).join("")}…` : text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A fence longer than any run of backticks in the text, so the text shows as written.
|
|
97
|
+
function fenced(text: string, max: number): string {
|
|
98
|
+
const shown = cut(text, max);
|
|
99
|
+
const longest = Math.max(2, ...[...shown.matchAll(/`+/g)].map((run) => run[0].length));
|
|
100
|
+
const fence = "`".repeat(longest + 1);
|
|
101
|
+
return `${fence}text\n${shown}\n${fence}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function issueBody(report: FeedbackReport, imageUrl: string | null): string {
|
|
105
|
+
const { page, viewport, userAgent, language, timeZone, createdAt, id } = report;
|
|
106
|
+
const screen = `${viewport.width}x${viewport.height} @${viewport.devicePixelRatio}x`;
|
|
107
|
+
// Each fence starts its own line: after other text on that line it is no block.
|
|
108
|
+
return [
|
|
109
|
+
fenced(report.note || "(no note)", 10_000),
|
|
110
|
+
// The angle brackets keep a ( or ) in a branch name from ending the link.
|
|
111
|
+
imageUrl ? `` : "No screenshot.",
|
|
112
|
+
`**Page:**\n${fenced(page.url, 2_000)}`,
|
|
113
|
+
`**Browser:**\n${fenced(`${userAgent}\n${screen}, ${language}, ${timeZone}`, 1_000)}`,
|
|
114
|
+
`**Sent:** ${createdAt}, report \`${id}\``,
|
|
115
|
+
`**Context, from the browser:**\n${fenced(JSON.stringify(report.context, null, 2), 6_000)}`,
|
|
116
|
+
].join("\n\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function POST(request: Request): Promise<Response> {
|
|
120
|
+
try {
|
|
121
|
+
const { report, image } = await parseFeedbackRequest(request);
|
|
122
|
+
const api = github();
|
|
123
|
+
const repo = await api("GET", "");
|
|
124
|
+
// Checked on every send: a repo made public later must not receive reports.
|
|
125
|
+
if (repo.status !== 200) throw new Error(`GitHub answered ${repo.status} for the repo.`);
|
|
126
|
+
if (repo.json.private !== true) {
|
|
127
|
+
return new Response("The feedback repo must be private.", { status: 503 });
|
|
128
|
+
}
|
|
129
|
+
const branch = process.env.GITHUB_FEEDBACK_BRANCH || repo.json.default_branch;
|
|
130
|
+
const ref = `?ref=${encodeURIComponent(branch)}`;
|
|
131
|
+
const folder = `feedback/${report.id}`;
|
|
132
|
+
// The plain JSON answer refuses a file over 1 MB; the object one only leaves out its content.
|
|
133
|
+
const stored = (name: string) =>
|
|
134
|
+
api("GET", `/contents/${folder}/${name}${ref}`, undefined, "github.object");
|
|
135
|
+
// report.json is written last, so its presence means the issue exists: a resend stops here.
|
|
136
|
+
const marker = await stored("report.json");
|
|
137
|
+
if (marker.status === 200) return new Response(null, { status: 204 });
|
|
138
|
+
if (marker.status !== 404) throw new Error(`GitHub answered ${marker.status} for report.json.`);
|
|
139
|
+
const put = async (name: string, bytes: Uint8Array) => {
|
|
140
|
+
const content = Buffer.from(bytes).toString("base64");
|
|
141
|
+
const message = `Feedback ${report.id}: ${name}`;
|
|
142
|
+
// No sha, so GitHub only creates: an existing file is never replaced (422).
|
|
143
|
+
let commit = await api("PUT", `/contents/${folder}/${name}`, { message, content, branch });
|
|
144
|
+
// Two commits to one branch at once: GitHub refuses the second with 409.
|
|
145
|
+
for (let retry = 0; commit.status === 409 && retry < 2; retry++) {
|
|
146
|
+
commit = await api("PUT", `/contents/${folder}/${name}`, { message, content, branch });
|
|
147
|
+
}
|
|
148
|
+
if (commit.status === 201) return;
|
|
149
|
+
// GitHub also answers 422 for a missing branch, so only a file already there counts.
|
|
150
|
+
if (commit.status !== 422 || (await stored(name)).status !== 200) {
|
|
151
|
+
throw new Error(`GitHub answered ${commit.status} for ${name}.`);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
let imageUrl: string | null = null;
|
|
155
|
+
if (image) {
|
|
156
|
+
const name = `image.${image.type === "image/png" ? "png" : "webp"}`;
|
|
157
|
+
await put(name, new Uint8Array(await image.arrayBuffer()));
|
|
158
|
+
const path = `${branch.split("/").map(encodeURIComponent).join("/")}/${folder}/${name}`;
|
|
159
|
+
imageUrl = `https://github.com/${repo.json.full_name}/blob/${path}?raw=true`;
|
|
160
|
+
}
|
|
161
|
+
// GitHub refuses a label name over 50 characters, and one that is only space.
|
|
162
|
+
const category = cut(report.category?.replace(/\s+/g, " ").trim() ?? "", 49);
|
|
163
|
+
const title = cut(report.note.split("\n")[0]?.trim() || "Feedback", 80);
|
|
164
|
+
const issue = await api("POST", "/issues", {
|
|
165
|
+
title,
|
|
166
|
+
body: issueBody(report, imageUrl),
|
|
167
|
+
labels: ["feedback", ...(category ? [category] : [])],
|
|
168
|
+
});
|
|
169
|
+
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);
|
|
172
|
+
await put("report.json", new TextEncoder().encode(json));
|
|
173
|
+
return new Response(null, { status: 204 });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof FeedbackRequestError) {
|
|
176
|
+
return new Response(error.message, { status: error.status });
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
If the issue opens but `report.json` cannot be written, a "Try again" opens a second issue for the
|
|
184
|
+
same report. Two sends of one report at the same moment can also both open one, since the check
|
|
185
|
+
and the writes are separate. Each issue body names the report id, so the two are easy to match.
|
|
@@ -0,0 +1,71 @@
|
|
|
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 `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.
|
|
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, 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, image } = 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 file = image ? `image.${image.type === "image/png" ? "png" : "webp"}` : null;
|
|
48
|
+
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()));
|
|
52
|
+
await writeFile(
|
|
53
|
+
join(folder, "report.json"),
|
|
54
|
+
JSON.stringify({ ...report, image: file }, null, 2),
|
|
55
|
+
);
|
|
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
|
+
}
|
|
60
|
+
return new Response(null, { status: 204 });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof FeedbackRequestError) {
|
|
63
|
+
return new Response(error.message, { status: error.status });
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A "Try again" sends the same report again, with the same `id`, so it writes over its own folder and
|
|
71
|
+
never makes a second one.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Recipe: Postgres
|
|
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).
|
|
6
|
+
|
|
7
|
+
To keep images in a bucket instead (docs/recipes/s3.md):
|
|
8
|
+
|
|
9
|
+
1. Install `aws4fetch` and set the `S3_*` variables as that recipe's step 1 says.
|
|
10
|
+
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`.
|
|
16
|
+
5. Read images back with that recipe's `GET`, its `canRead` replaced by the same admin check your
|
|
17
|
+
screens use.
|
|
18
|
+
|
|
19
|
+
The images show the screen as the user saw it, with nothing masked (SPEC §7), so read the table only
|
|
20
|
+
from your own admin screens.
|
|
21
|
+
|
|
22
|
+
## 1. Install and create the table
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install postgres
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```sql
|
|
29
|
+
create table feedback_reports (
|
|
30
|
+
id uuid primary key,
|
|
31
|
+
user_id text not null,
|
|
32
|
+
created_at timestamptz not null,
|
|
33
|
+
report jsonb not null,
|
|
34
|
+
image bytea,
|
|
35
|
+
image_type text
|
|
36
|
+
);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Set `DATABASE_URL` on the server. On a serverless host, use your provider's pooled URL, since each
|
|
40
|
+
instance opens its own connections; a pooler in transaction mode also needs `prepare: false` beside
|
|
41
|
+
`idle_timeout` below.
|
|
42
|
+
|
|
43
|
+
## 2. The route
|
|
44
|
+
|
|
45
|
+
The widget posts to it with `createFeedback({ endpoint: "/api/feedback" })`. This is a Next.js
|
|
46
|
+
App Router route.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// app/api/feedback/route.ts
|
|
50
|
+
import postgres from "postgres";
|
|
51
|
+
import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
|
|
52
|
+
|
|
53
|
+
let sql: postgres.Sql | undefined;
|
|
54
|
+
|
|
55
|
+
// Connects on the first send, so a build or an import never opens a connection.
|
|
56
|
+
function db(): postgres.Sql {
|
|
57
|
+
if (!process.env.DATABASE_URL) throw new Error("Set DATABASE_URL.");
|
|
58
|
+
// idle_timeout closes a pool a hot reload left behind, instead of holding its connections.
|
|
59
|
+
sql ??= postgres(process.env.DATABASE_URL, { idle_timeout: 20 });
|
|
60
|
+
return sql;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Replace with your own session: the user who is signed in. Until then, nobody can send.
|
|
64
|
+
// Never take the user from report.context, which the browser controls.
|
|
65
|
+
async function currentUser(_request: Request): Promise<string | null> {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function POST(request: Request): Promise<Response> {
|
|
70
|
+
const user = await currentUser(request);
|
|
71
|
+
if (!user) return new Response(null, { status: 401 });
|
|
72
|
+
try {
|
|
73
|
+
const { report, image } = await parseFeedbackRequest(request);
|
|
74
|
+
const { image: _, ...stored } = report;
|
|
75
|
+
const bytes = image ? new Uint8Array(await image.arrayBuffer()) : null;
|
|
76
|
+
// 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`;
|
|
84
|
+
return new Response(null, { status: 204 });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof FeedbackRequestError) {
|
|
87
|
+
return new Response(error.message, { status: error.status });
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|