visual-feedback 0.1.0 → 0.3.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 +12 -7
- package/CHANGELOG.md +110 -0
- package/README.md +5 -3
- package/dist/dialog-W6m1knfu.mjs +1382 -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-DvGTo0B4.mjs +565 -0
- package/dist/{widget-Ca1heF8n.d.mts → widget-Z1KOUKjx.d.mts} +14 -4
- package/docs/API.md +59 -31
- package/docs/SPEC.md +122 -30
- package/docs/THEMING.md +50 -19
- 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/dist/widget-CVnusNlY.mjs +0 -278
package/docs/API.md
CHANGED
|
@@ -5,11 +5,13 @@ needs a bundler, or an import map that points that name to the file. There are t
|
|
|
5
5
|
|
|
6
6
|
| import | for | runs in |
|
|
7
7
|
|---|---|---|
|
|
8
|
-
| `visual-feedback` | `createFeedback()` and the types | browser |
|
|
8
|
+
| `visual-feedback` | `createFeedback()`, `discardDraft()` and the types | browser |
|
|
9
9
|
| `visual-feedback/server` | `parseFeedbackRequest()`, `FeedbackRequestError` and the report types | any runtime with Web `Request` (Node 20+, Bun, Deno, edge) |
|
|
10
10
|
| `visual-feedback/react` | `<Feedback />` and the type `FeedbackProps` | browser, React 18+ |
|
|
11
11
|
|
|
12
12
|
## createFeedback(options: FeedbackOptions): FeedbackWidget
|
|
13
|
+
Without a document (a server render) or in a browser below SPEC §10, it returns a widget whose
|
|
14
|
+
methods do nothing: it draws nothing and fires no event.
|
|
13
15
|
|
|
14
16
|
```ts
|
|
15
17
|
import { createFeedback } from "visual-feedback";
|
|
@@ -17,10 +19,11 @@ import { createFeedback } from "visual-feedback";
|
|
|
17
19
|
const feedback = createFeedback({
|
|
18
20
|
onSubmit: async (report) => {
|
|
19
21
|
const body = new FormData();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
const screenshots = report.screenshots.map(({ image: _, ...shot }) => shot);
|
|
23
|
+
body.append("report", JSON.stringify({ ...report, screenshots }));
|
|
24
|
+
for (const [i, { image }] of report.screenshots.entries()) {
|
|
25
|
+
const extension = image.type === "image/png" ? "png" : "webp";
|
|
26
|
+
body.append(`image${i}`, image, `${report.id}-${i}.${extension}`);
|
|
24
27
|
}
|
|
25
28
|
const res = await fetch("/api/feedback", { method: "POST", body });
|
|
26
29
|
if (!res.ok) throw new Error("It didn't send. Your note is kept.");
|
|
@@ -32,45 +35,55 @@ const feedback = createFeedback({
|
|
|
32
35
|
| option | type | default | meaning |
|
|
33
36
|
|---|---|---|---|
|
|
34
37
|
| `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,
|
|
36
|
-
| `headers` | `Record<string, string>` or `() => Record<string, string>` | – | Extra headers for `endpoint`. |
|
|
37
|
-
| `trigger` | `"button" \| "none"` | `"button"` | `"none"` draws
|
|
38
|
+
| `endpoint` | `string` | – | POSTs `multipart/form-data` with a `report` part (JSON, each screenshot without its image) and one part per screenshot, `image0`, `image1` and on, named `<id>-<n>.webp` or `<id>-<n>.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; another origin needs CORS on its server and a `connect-src` that allows it. |
|
|
39
|
+
| `headers` | `Record<string, string>` or `() => Record<string, string>` | – | Extra headers for `endpoint`. A function is called at Send; a throw shows the send error state, using the error's message. |
|
|
40
|
+
| `trigger` | `"button" \| "none"` | `"button"` | `"none"` draws no trigger; the host calls `open()`. A kept draft's bar still shows. |
|
|
38
41
|
| `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
|
|
40
|
-
| `tools` | `("pen" \| "box" \| "arrow")[]` | all
|
|
42
|
+
| `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 form field (`input`, `textarea`, `select`) or editable content. It does nothing while the dialog is open or a draft is kept in the draft bar. Opt-in; it is the widget's only listener on `window` or `document`, one `keydown` on `window` (SPEC §9 names what else the widget watches, and what the capture library keeps). |
|
|
43
|
+
| `tools` | `("pen" \| "box" \| "arrow" \| "text" \| "select")[]` | all five | Order is the toolbar order, and the first drawing tool is the one selected at open. Repeats and unknown names are ignored; an empty list, or Select alone, shows no toolbar. The Delete button is there only with Select. |
|
|
41
44
|
| `categories` | `{ id: string; label: string }[]` | none | Shows a category picker. |
|
|
42
45
|
| `requireNote` | `boolean` | `true` | |
|
|
43
46
|
| `capture.mode` | `"dom" \| "display"` | `"dom"` | SPEC §5. The type of `capture` is exported as `CaptureOptions`. |
|
|
44
47
|
| `capture.maxWidth` | `number` | `1920` | |
|
|
45
48
|
| `capture.type` | `"image/webp" \| "image/png"` | `"image/webp"` | |
|
|
46
49
|
| `capture.quality` | `number` | `0.85` | For WebP. |
|
|
47
|
-
| `redactUrl` | `boolean` | `false` | Drops the query string and the hash from `page.url
|
|
50
|
+
| `redactUrl` | `boolean` | `false` | Drops the query string and the hash from `page.url`, the report's and each screenshot's. |
|
|
48
51
|
| `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
52
|
| `strings` | `Partial<FeedbackStrings>` | English | SPEC §12 keys. Keys left out, or given as `undefined`, keep their English text. |
|
|
50
53
|
| `theme` | `"auto" \| "light" \| "dark"` | `"auto"` | THEMING.md. |
|
|
51
54
|
| `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. |
|
|
55
|
+
| `unstyled` | `boolean` | `false` | Keeps only the layout-critical CSS and the focus ring. THEMING.md. |
|
|
53
56
|
| `container` | `HTMLElement` | `document.body` | Where the host element is appended. |
|
|
54
57
|
| `tagName` | `string` | `"visual-feedback"` | The custom element name, if it clashes. |
|
|
58
|
+
| `draft` | `"session" \| "memory"` | `"session"` | Where a draft folded in the draft bar is kept. `"session"` keeps it for this tab across page loads and route changes, as it was last folded, until it ends or a day after it was last folded or brought back (SPEC §7); `"memory"` keeps nothing on the device, so a page load ends it. One widget of a tag keeps a draft, the first whose element is in the page; when that element leaves, the next widget of the tag to need it takes over. Another one with the same `tagName` keeps its own in memory only. |
|
|
55
59
|
|
|
56
60
|
### FeedbackWidget
|
|
57
61
|
| member | meaning |
|
|
58
62
|
|---|---|
|
|
59
|
-
| `open(): Promise<void>` | Starts a capture. When it is
|
|
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).
|
|
62
|
-
| `on(event, fn): () => void` | Events: `open
|
|
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
|
|
63
|
+
| `open(): Promise<void>` | Starts a capture. When a draft is kept in the draft bar, or this tab still keeps one, it opens the dialog on that draft instead, once it is back; when the dialog is open already, it does nothing. When the dialog cannot load or be shown, it rejects and `error` fires. |
|
|
64
|
+
| `close(): void` | Cancels and discards the draft, also when it is kept in the draft bar, and never asks first as the Cancel button can (SPEC §2); one a page load is still bringing back is only hidden, kept, with no event (SPEC §7). Before `submit` fires, nothing is sent; after it, the delivery is not stopped, and `sent` or `error` still fires when it ends. |
|
|
65
|
+
| `destroy(): void` | Removes the element, its listeners, its watch of the page's theme and its stylesheet, but not what the capture library keeps (SPEC §9). A delivery still under way fires neither `sent` nor `error` after it. The draft, open or folded, stays kept, so `close` does not fire: the next widget of the same tag made in this tab brings it back folded, as after a page load, so a route change keeps it. A host that removes the element gets the same for the draft, but its listeners and its watch of the page's theme stay until `destroy()`. A draft it cannot keep ends, and `close` fires as the last event: with `draft: "memory"`, a Send already handed to the host (`submit` fired), a widget that does not own its tag's draft, no screenshot (the capture error, or after "Send without screenshot"), a screenshot taken while it did not keep drafts, or sessionStorage that refuses it. To end the draft too, as at a logout, call `discardDraft()`. A draft whose IndexedDB write then fails (storage full) is lost, with no `close`: the widget is gone by then. |
|
|
66
|
+
| `on(event, fn): () => void` | Events: `open` (when a draft starts, and when a kept draft comes back), `close` (only when the draft ends: sent and closed, Cancel, Discard, `close()`, or a `destroy()` or removed element that does not keep it; folding into the draft bar, opening it again, and a `destroy()` or removal that keeps it fire none), `submit` (with the report, before each attempt), `sent` (with the report), `error` (with the error), `saved` (after Save as PDF downloads the PDF; nothing is sent). The type `FeedbackEvents` maps each event to what it passes. Returns an unsubscribe function. |
|
|
67
|
+
| `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` (a draft bar already shown moves at its next fold), `theme`, `css`, `unstyled`, `strings.trigger`, `strings.triggerText` and `shortcut` change at once, and a new `theme` or `css` reads the page's look again (THEMING.md). The dialog's texts, `tools`, `categories` and `requireNote` apply from the next open, and the rest at the next capture or send. |
|
|
68
|
+
|
|
69
|
+
## discardDraft(tagName?: string): Promise<void>
|
|
70
|
+
Deletes the draft this tab keeps for a tag (default `"visual-feedback"`), as at a logout, so no
|
|
71
|
+
later widget of that tag brings it back: the id in sessionStorage and the record in IndexedDB. A
|
|
72
|
+
widget that holds it, open or folded, ends it, and `close` fires; one still bringing it back at a
|
|
73
|
+
page load stops, with no event, since it never showed it. It waits for a write still under
|
|
74
|
+
way, so none lands after it. It needs no widget on the page; with `<Feedback />`, import it from
|
|
75
|
+
`visual-feedback`. A widget made with a `tagName` needs that same name here. It rejects when the
|
|
76
|
+
device's storage cannot be reached; the id is already gone by then, so no widget brings the draft
|
|
77
|
+
back.
|
|
64
78
|
|
|
65
79
|
## FeedbackReport
|
|
66
80
|
```ts
|
|
67
81
|
type FeedbackReport = {
|
|
68
82
|
id: string; // UUID v4 from crypto.getRandomValues, so plain http works
|
|
69
|
-
createdAt: string; // ISO 8601
|
|
83
|
+
createdAt: string; // ISO 8601, as Date.prototype.toISOString() writes it
|
|
70
84
|
note: string;
|
|
71
|
-
category?: string;
|
|
72
|
-
|
|
73
|
-
marks: Mark[]; // vectors, in image pixels
|
|
85
|
+
category?: string; // the chosen category's id; absent without `categories`
|
|
86
|
+
screenshots: Screenshot[]; // at most 10; empty after "Send without screenshot"
|
|
74
87
|
page: { url: string; title: string };
|
|
75
88
|
viewport: { width: number; height: number; devicePixelRatio: number };
|
|
76
89
|
scroll: { x: number; y: number };
|
|
@@ -79,16 +92,26 @@ type FeedbackReport = {
|
|
|
79
92
|
timeZone: string;
|
|
80
93
|
context: Record<string, unknown>;
|
|
81
94
|
};
|
|
95
|
+
type Screenshot = {
|
|
96
|
+
image: Blob; // marks painted in
|
|
97
|
+
marks: Mark[]; // shapes and text labels, in image pixels
|
|
98
|
+
page: { url: string; title: string }; // the page as it was when this screenshot was taken
|
|
99
|
+
viewport: { width: number; height: number; devicePixelRatio: number };
|
|
100
|
+
scroll: { x: number; y: number };
|
|
101
|
+
};
|
|
82
102
|
type Mark =
|
|
83
103
|
| { tool: "pen"; points: [number, number][] }
|
|
84
104
|
| { tool: "box"; x: number; y: number; width: number; height: number }
|
|
85
|
-
| { tool: "arrow"; from: [number, number]; to: [number, number] }
|
|
105
|
+
| { tool: "arrow"; from: [number, number]; to: [number, number] }
|
|
106
|
+
// A label, its text at most 280 characters, centred on y and starting at x.
|
|
107
|
+
| { tool: "text"; x: number; y: number; text: string };
|
|
86
108
|
```
|
|
87
109
|
|
|
88
110
|
## `<Feedback {...options} />` (`visual-feedback/react`)
|
|
89
111
|
Its props are the options of `createFeedback`. It renders nothing where it stands: after it mounts
|
|
90
112
|
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
|
|
113
|
+
back to its default; `tagName` and `container` are read once), and unmounting destroys it, which
|
|
114
|
+
keeps a draft: call `discardDraft()` at a logout. It is a
|
|
92
115
|
client component, so a server component can render it with props that reach the browser as data,
|
|
93
116
|
such as `endpoint`; `onSubmit`, `headers` as a function, `context` and `container` need a client
|
|
94
117
|
component.
|
|
@@ -101,19 +124,24 @@ import { Feedback } from "visual-feedback/react";
|
|
|
101
124
|
|
|
102
125
|
## parseFeedbackRequest(request, limits?) (`visual-feedback/server`)
|
|
103
126
|
```ts
|
|
104
|
-
const { report
|
|
127
|
+
const { report } = await parseFeedbackRequest(request, { maxImageBytes: 5_000_000 });
|
|
105
128
|
```
|
|
106
129
|
- 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
|
|
130
|
+
lower-case UUID v4, and `createdAt` an ISO time from 1970 to 9999, in exactly the form
|
|
131
|
+
`Date.prototype.toISOString()` writes (UTC, with milliseconds). Fields that `FeedbackReport`
|
|
108
132
|
does not define, and `__proto__` keys at any depth, are dropped.
|
|
109
133
|
- In the report's text, `context` included, NUL characters are removed and half surrogate pairs (a
|
|
110
134
|
cut emoji) become U+FFFD, so any store, Postgres `jsonb` included, can keep it.
|
|
111
|
-
-
|
|
135
|
+
- A report holds at most 10 screenshots, and screenshot `n` needs exactly one image part,
|
|
136
|
+
`image<n>`. An image part with no screenshot is refused, and so is a part named `image`, which
|
|
137
|
+
visual-feedback 0.1 sent.
|
|
138
|
+
- It checks that each image is a real WebP or PNG (magic bytes, not the declared type) and within
|
|
112
139
|
`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
|
|
114
|
-
larger body is refused while it streams in.
|
|
115
|
-
- `image` is the checked image
|
|
116
|
-
|
|
117
|
-
|
|
140
|
+
the whole body at most 10 times `maxImageBytes` plus 1 MiB plus 64 KiB for the multipart
|
|
141
|
+
framing; a larger body is refused while it streams in.
|
|
142
|
+
- `report.screenshots[n].image` is the checked image of part `image<n>`, typed from its bytes.
|
|
143
|
+
- If any check fails, it throws a `FeedbackRequestError` that carries a `status` (400 or 413). A
|
|
144
|
+
`maxImageBytes` that is not a number of bytes is a mistake in the host's code: it throws a
|
|
145
|
+
`RangeError`.
|
|
118
146
|
- Storing the report, and checking who sent it, is the host's job. `report.context` comes from the
|
|
119
147
|
browser, so it is never proof of who sent it: take the user from your own session.
|
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-*)`.
|
|
32
|
-
- It
|
|
33
|
-
|
|
57
|
+
- It is hidden while there is a draft, whether the dialog is open or kept in the draft bar.
|
|
58
|
+
- It does not move to keep clear of the page's content. The host moves it off its own content
|
|
59
|
+
with `position` or `--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,73 @@ 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
|
+
- With `theme: "auto"`, the page's own background sets light or dark, and `prefers-color-scheme`
|
|
152
|
+
where the page shows none (THEMING.md).
|
|
82
153
|
|
|
83
154
|
## 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
|
|
155
|
+
- **Loader** (the trigger and the `open()` wiring): 15 KB or less, minified and gzipped.
|
|
156
|
+
- **Annotator and dialog:** 135 KB or less, gzipped. This does not include the capture library.
|
|
157
|
+
- **Idle cost:** no work after the first paint of the trigger, past reading one sessionStorage
|
|
158
|
+
key and the page's colours and button corners at load, and reading them again when the page
|
|
159
|
+
switches its theme (THEMING.md section 1). To see a switch, it keeps one mutation observer on
|
|
160
|
+
the attributes of `<html>` and `<body>` and on the children of `<html>` (to find a new
|
|
161
|
+
`<body>`), and one `change` listener on `prefers-color-scheme`, both gone at `destroy()`. A
|
|
162
|
+
change the observer sees that leaves the page's colours alone reads the colours of `<html>` and
|
|
163
|
+
`<body>` and scans nothing else; a system switch always reads the page. Only when this tab kept a draft does the dialog load, to bring it back. After the first screenshot, the
|
|
87
164
|
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
|
|
165
|
+
until the page unloads, and offers no way to remove them; `destroy()` removes everything else
|
|
166
|
+
but a draft it keeps (§7). The opt-in `shortcut` adds one `keydown` listener on `window`, gone at
|
|
167
|
+
`destroy()` or `update({ shortcut: false })`.
|
|
89
168
|
- CI fails when a budget is exceeded.
|
|
90
169
|
|
|
91
170
|
## 10. Browsers
|
|
@@ -93,8 +172,9 @@ The browsers that support constructable stylesheets (`adoptedStyleSheets`) and `
|
|
|
93
172
|
Chrome 111+, Firefox 113+ and Safari 16.4+. Older browsers get no trigger and no error.
|
|
94
173
|
|
|
95
174
|
## 11. Content Security Policy
|
|
96
|
-
- The widget's own CSS goes in through constructable stylesheets. It uses no `<style>` tags and
|
|
97
|
-
|
|
175
|
+
- The widget's own CSS goes in through constructable stylesheets. It uses no `<style>` tags, and
|
|
176
|
+
sets the few inline styles it needs (the selection frame, a label being typed, the theme's
|
|
177
|
+
`color-scheme` and the colours and corners it found on the page) through the CSSOM, so a strict `style-src` does not block it. While the dialog is
|
|
98
178
|
open, the scroll lock sets `overflow` on `<html>` through the CSSOM, which a CSP allows, and
|
|
99
179
|
puts back what was there on close.
|
|
100
180
|
- Capture (`@zumer/snapdom`) renders through `data:` image URLs, so a site with a CSP needs
|
|
@@ -119,10 +199,22 @@ These are the default texts. Each one can be replaced through `strings` (`docs/A
|
|
|
119
199
|
| notePlaceholder | What happened, or what would you change? |
|
|
120
200
|
| noteRequired | Write a few words first. |
|
|
121
201
|
| categoryLabel | Type |
|
|
122
|
-
| pen / box / arrow | Draw / Box / Arrow |
|
|
202
|
+
| pen / box / arrow / text / select | Draw / Box / Arrow / Text / Select |
|
|
203
|
+
| remove | Delete |
|
|
123
204
|
| undo / clear | Undo / Clear |
|
|
124
205
|
| cancel / send | Cancel / Send |
|
|
125
206
|
| sending | Sending… |
|
|
126
207
|
| sent | Thanks, it's sent. |
|
|
127
208
|
| error | It didn't send. Your note is kept. |
|
|
128
209
|
| retry | Try again |
|
|
210
|
+
| triggerText | none: the icon alone |
|
|
211
|
+
| escHint | Esc keeps it as a draft |
|
|
212
|
+
| minimise | Keep as draft |
|
|
213
|
+
| screenshot | Screenshot (each one is named with its number, as "Screenshot 2") |
|
|
214
|
+
| addScreenshot | Add screenshot |
|
|
215
|
+
| removeScreenshot | Remove screenshot (with the number, as "Remove screenshot 2") |
|
|
216
|
+
| draft | Feedback draft (the bar shows the number of screenshots after it) |
|
|
217
|
+
| captureView / reopen / discard | Capture this view / Open / Discard |
|
|
218
|
+
| discardConfirm / keepEditing | Discard this feedback? / Keep editing |
|
|
219
|
+
| moreSend / savePdf | More send options / Save as PDF |
|
|
220
|
+
| pdfError | Couldn't save the PDF. |
|
package/docs/THEMING.md
CHANGED
|
@@ -3,11 +3,34 @@
|
|
|
3
3
|
The widget lives in one shadow root, so the site's CSS cannot break it, and its CSS cannot leak
|
|
4
4
|
out. There are four ways to adapt it, from lightest to heaviest:
|
|
5
5
|
|
|
6
|
-
## 1. It
|
|
7
|
-
Inside the shadow root, the widget sets `font: inherit
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
and
|
|
6
|
+
## 1. It matches the site
|
|
7
|
+
Inside the shadow root, the widget sets `font: inherit`, so a site rule such as
|
|
8
|
+
`* { all: unset }` cannot reset it. With no theming at all, it takes the site's font, and it
|
|
9
|
+
reads the page when it loads, at each open (a draft opened again from its bar too), when
|
|
10
|
+
`theme` or `css` changes, and when the page switches its theme: a change to the attributes of `<html>` or
|
|
11
|
+
`<body>`, such as a `dark` class, that changes the page's background, text colour or
|
|
12
|
+
`color-scheme`, or a change of the system's light or dark. The trigger, the dialog and the draft
|
|
13
|
+
bar then take the new look at once, or, where the site fades its colours, when the fade ends. A
|
|
14
|
+
widget whose element was off the page reads the page again when the element is put back. The rest
|
|
15
|
+
of the page is not watched, so a look changed only by a new stylesheet shows at the next open.
|
|
16
|
+
What it reads:
|
|
17
|
+
|
|
18
|
+
- **accent:** the page's `<meta name="theme-color">` (one with a `media` attribute counts only
|
|
19
|
+
where its media matches), else the most common coloured background
|
|
20
|
+
of its buttons, else the colour of its links. Greys, muted colours, near black, near white and
|
|
21
|
+
the browser's own unstyled buttons do not count.
|
|
22
|
+
- **corners:** the middle radius of the page's filled buttons, so the widget's buttons get the
|
|
23
|
+
same, up to 15px. A site where at least half of the filled buttons are pills keeps the
|
|
24
|
+
default.
|
|
25
|
+
- **background and text**, with `theme: "auto"`: the page's background and text colour, and light
|
|
26
|
+
or dark from that background.
|
|
27
|
+
|
|
28
|
+
A guess is used only when it contrasts: the accent 3:1 against the widget's background, the text
|
|
29
|
+
4.5:1 against it. Otherwise the default stays: the system colours `Canvas`, `CanvasText` and
|
|
30
|
+
`AccentColor`, light and dark from the system, and `16px` corners. With the page's text, the
|
|
31
|
+
widget's hints take more of it than 60% where 60% would fall under 4.5:1. Each `--vf-*` variable
|
|
32
|
+
the site sets wins over the guess for that variable; a site that sets only one of `--vf-surface`
|
|
33
|
+
and `--vf-text` gets the other from the defaults.
|
|
11
34
|
|
|
12
35
|
## 2. CSS custom properties
|
|
13
36
|
Set these on `visual-feedback` (or on any ancestor). Custom properties cross the shadow boundary.
|
|
@@ -15,19 +38,19 @@ Set these on `visual-feedback` (or on any ancestor). Custom properties cross the
|
|
|
15
38
|
| variable | default | used for |
|
|
16
39
|
|---|---|---|
|
|
17
40
|
| `--vf-font` | `inherit` | font family of all text, such as `Inter, sans-serif` |
|
|
18
|
-
| `--vf-radius` | `
|
|
19
|
-
| `--vf-surface` | `Canvas` | panel and
|
|
20
|
-
| `--vf-text` | `CanvasText` | text |
|
|
21
|
-
| `--vf-muted` | text at 60
|
|
41
|
+
| `--vf-radius` | from the page's button corners, so the widget's buttons match them up to 15px (this is 1.6 times theirs, up to `24px`), else `16px` | panel and composer, and a share of it for buttons and the image |
|
|
42
|
+
| `--vf-surface` | with `theme: "auto"` the page's background, else `Canvas` | panel, tool rail and composer background |
|
|
43
|
+
| `--vf-text` | with `theme: "auto"` the page's text colour, else `CanvasText` | text |
|
|
44
|
+
| `--vf-muted` | text at 60%, more on a page whose text would make that too faint | hints, counters |
|
|
22
45
|
| `--vf-border` | text at 15% | hairlines |
|
|
23
|
-
| `--vf-accent` | `AccentColor`, or `#0b57d0` where the browser has no `AccentColor` | Send button, active tool |
|
|
46
|
+
| `--vf-accent` | the page's accent, else `AccentColor`, or `#0b57d0` where the browser has no `AccentColor` | Send button and its menu button, "Capture this view", active tool, chosen screenshot |
|
|
24
47
|
| `--vf-accent-text` | black or white, whichever contrasts more with the accent | text on the accent |
|
|
25
48
|
| `--vf-focus` | `--vf-accent` | focus ring |
|
|
26
49
|
| `--vf-mark` | `#e5243b` | marks on the screenshot |
|
|
27
50
|
| `--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 |
|
|
51
|
+
| `--vf-shadow` | `0 12px 32px -12px rgb(0 0 0 / 35%)` | trigger, panel and draft bar |
|
|
52
|
+
| `--vf-trigger-size` | `2.5em`, 40px on a 16px site | trigger height, and its width when it shows the icon alone |
|
|
53
|
+
| `--vf-offset` | `16px` | trigger and draft bar distance from the edges |
|
|
31
54
|
| `--vf-z` | `2147483000` | stacking |
|
|
32
55
|
|
|
33
56
|
```css
|
|
@@ -39,9 +62,15 @@ visual-feedback {
|
|
|
39
62
|
```
|
|
40
63
|
|
|
41
64
|
## 3. `::part()`
|
|
42
|
-
Every visible element has a part name: `trigger`, `trigger-icon`, `
|
|
43
|
-
`
|
|
44
|
-
`
|
|
65
|
+
Every visible element has a part name: `trigger`, `trigger-icon`, `trigger-text`, `backdrop`,
|
|
66
|
+
`dialog`, `header`, `title`, `minimise`, `body`, `stage`, `image`, `toolbar`, `tool`, `tool-active`,
|
|
67
|
+
`divider`, `undo`, `clear`, `remove`, `icon`, `text-input`, `selection`, `strip`, `thumb`,
|
|
68
|
+
`thumb-pick`, `thumb-image`, `thumb-number`, `thumb-remove`, `add`, `label`, `composer`, `note`,
|
|
69
|
+
`composer-bar`, `category`, `footer`, `split`, `send-more`, `menu`, `save-pdf`, `actions`, `cancel`,
|
|
70
|
+
`send`, `hint`, `status`, `error`, `retry`, `send-without`, `draft`, `draft-label`, `draft-thumbs`,
|
|
71
|
+
`capture-view`, `reopen`, `discard`, `confirm`, `confirm-text`, `keep-draft`, `keep-editing`. Cancel's
|
|
72
|
+
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
|
|
73
|
+
sends again, the same part as the capture error's "Try again".
|
|
45
74
|
|
|
46
75
|
```css
|
|
47
76
|
visual-feedback::part(trigger) { opacity: .6; }
|
|
@@ -52,7 +81,8 @@ visual-feedback::part(trigger):hover { opacity: 1; }
|
|
|
52
81
|
- `css` is a string. It is added as a constructable stylesheet inside the shadow root, after the
|
|
53
82
|
defaults, so it can reach anything, including states that `::part()` cannot select.
|
|
54
83
|
- `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)
|
|
84
|
+
size, the overlay's position, the panel's layout, canvas size, hidden states) and the focus ring,
|
|
85
|
+
in `--vf-focus`, which `css` can restyle. Then the `css` option styles everything else.
|
|
56
86
|
|
|
57
87
|
```ts
|
|
58
88
|
createFeedback({ onSubmit, css: `.toolbar { gap: 2px } .send { text-transform: uppercase }` });
|
|
@@ -61,5 +91,6 @@ Class names used inside the shadow root are part of the public API from 1.0. Bef
|
|
|
61
91
|
change.
|
|
62
92
|
|
|
63
93
|
## Theme
|
|
64
|
-
`theme: "light" | "dark"` sets `color-scheme` inside the shadow root, which forces that scheme
|
|
65
|
-
`"auto"` follows the system
|
|
94
|
+
`theme: "light" | "dark"` sets `color-scheme` inside the shadow root, which forces that scheme
|
|
95
|
+
with the system colours. `"auto"` follows the page's background (section 1), and the system
|
|
96
|
+
where the page shows none.
|