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/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
- 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}`);
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, 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
+ | `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 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. |
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 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. |
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
- image: Blob | null; // marks painted in; null after "Send without screenshot"
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. It is a
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, image } = await parseFeedbackRequest(request, { maxImageBytes: 5_000_000 });
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. Fields that `FeedbackReport`
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
- - It checks that the image is a real WebP or PNG (magic bytes, not the declared type) and within
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 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).
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 stores nothing and sends nothing by itself.
6
+ package sends nothing by itself, and stores nothing but an unsent draft on the user's own device
7
+ (§7).
7
8
 
8
9
  ## 2. Flow
9
10
  ```
10
11
  idle --open--> capturing --image ready--> annotating --Send--> sending --ok--> sent --3 s--> idle
11
- | | |
12
- +--fails--> capture error +--Cancel/Esc--> idle +--fails--> send error
12
+ | | ^ |
13
+ +--fails--> capture error | | +--fails--> send error
14
+ | +--Open, or Capture this view (capturing first)
15
+ +--Esc / Keep as draft / Add screenshot--> draft
16
+ annotating, draft --Cancel (it may ask first, below) / Discard--> idle
13
17
  ```
14
18
  - **idle:** the trigger button shows (`trigger: "button"`), or nothing shows (`trigger: "none"`,
15
19
  where the host calls `open()`).
16
20
  - **capturing:** the trigger and any open panel are hidden, so they are not in the image. The
17
21
  screen is captured (SPEC §5), and a screen reader is told the `capturing` text.
18
22
  - **capture error:** the `captureError` text, with "Try again" (back to capturing), "Send without
19
- screenshot" (annotating with no image: no toolbar, `image: null`, no marks) and Cancel or Esc
20
- (idle).
21
- - **annotating:** a full-viewport overlay shows the image, a toolbar and the note field.
23
+ screenshot" (annotating with no image: no toolbar, no screenshots in the report) and Cancel or Esc
24
+ (idle). This is the first screenshot only; a later one that fails is the draft's (below).
25
+ - **annotating:** a full-viewport overlay shows the image with a tool rail beside it, a strip of
26
+ the report's screenshots under it, and the note field, with Send in a footer row under that.
27
+ Esc and the header's Keep as draft fold it into the draft; the header's close button (Cancel)
28
+ discards it. When that would throw away a note, a mark, a second screenshot or a chosen category,
29
+ Cancel first asks `discardConfirm`, with "Discard", `minimise` (before Send, with a screenshot) and
30
+ `keepEditing`, which has the focus; Keep editing, and Esc on the question, take the question back
31
+ and put the focus on Cancel. Without a screenshot ("Send without screenshot"), Esc cancels,
32
+ after the same question when it would throw work away.
33
+ - **draft:** a small bar in the trigger's corner, in place of the trigger: the `draft` label with
34
+ the number of screenshots, small copies of them, "Capture this view", "Open" and "Discard". The
35
+ page scrolls, and a single-page app can change route, while it shows. "Capture this view" takes
36
+ another screenshot of the page as it is now, the bar and the dialog left out, adds it, and opens
37
+ the dialog on it; a capture that fails opens the dialog on the kept draft with the
38
+ `captureError` text, where Esc keeps the draft again and Cancel discards it, after the same question when it would throw work away. "Open", and `open()`, open the dialog as it was. A full page load in the same tab, or a route
39
+ change that removes the widget and makes the next one, brings the draft back, folded in the bar, as it was when it was last folded: its screenshots, marks, note and
40
+ category. Undo starts after the marks it brought back. With `draft: "memory"`, a page load ends
41
+ it.
22
42
  - **sending:** the note, the category and the tools are disabled, and the Send button shows
23
- progress. Cancel and Esc still close the dialog.
43
+ progress. The Esc hint goes, as it is absent with no screenshot: from Send on, Esc keeps no
44
+ draft, and Cancel and Esc close the dialog.
24
45
  - **sent:** a short confirmation, then idle.
25
46
  - **send error:** the message comes from the host's error, or else the `error` text. The note and
26
- the marks are kept, and "Try again" sends the same report.
47
+ the marks are kept, and "Try again" sends the same report. Cancel and Esc, after
48
+ the same question when it would throw work away, close the dialog, and the draft goes with it.
27
49
 
28
50
  ## 3. The trigger
29
- - It is a small round button, 36 px by default, in a corner (`position`), with an icon and an
30
- accessible name. It has no text label and no badge, and it does not move or pulse.
51
+ - It is a small round button, 2.5em across by default (40 px on a 16 px site), in a corner
52
+ (`position`), with an icon and the `trigger` accessible name. A host that gives `triggerText`
53
+ gets that short label beside the icon, as a pill; with only `triggerText` given, that text is
54
+ the name too. A host that sets both keeps `triggerText` inside `trigger`, so
55
+ speech control can say what it sees. It has no badge, and it does not move or pulse.
31
56
  - It sits 16 px from the edges by default, and it respects `env(safe-area-inset-*)`.
32
- - It does not watch the page. The host moves it off its own content with `position` or
33
- `--vf-offset`.
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 Arrow. Undo removes the last mark, and
37
- Clear removes all.
62
+ - **Tools:** Pen (freehand), Box (rectangle outline), Arrow and Text. Text places a label where the
63
+ image is clicked: Enter or clicking away keeps it, Escape drops that label only, and an empty one
64
+ is not kept; a label holds up to 280 characters. Labels are one size, in the mark colour with a white outline. Select picks a mark by
65
+ its line or a label by its text, and shows a dashed outline that never reaches the sent image: a
66
+ drag moves it, kept whole inside the image; the arrow keys move it (Shift for ten); Delete,
67
+ Backspace or the Delete button removes it; Escape lets go of it. Undo takes back the last add,
68
+ move or removal, one at a time, and Clear removes all.
38
69
  - **One colour** for the marks, `--vf-mark`, which defaults to a red that contrasts with most pages.
39
70
  - Pointer events, so mouse, pen and touch all work. While drawing, touch never scrolls the page.
40
71
  - **Note:** a textarea of 2000 characters at most, with a visible label (`noteLabel`). It is
41
72
  required unless `requireNote: false`.
42
73
  - **Category:** shown only when `categories` is given, with a visible label (`categoryLabel`).
74
+ - **Save as PDF:** Send is a split button; its caret ("More send options") opens a menu with "Save
75
+ as PDF". It downloads `feedback-<date>.pdf`, dated by the user's clock and made in the browser:
76
+ the category and the note,
77
+ then the first screenshot, on page 1, and each other screenshot on a page of its own, marks
78
+ included. Nothing is sent, the dialog and the draft stay as they are, and the `saved` event
79
+ fires. While the menu is open, Esc closes the menu only, from Send and the menu button too, and
80
+ puts the focus on the menu button; Tab closes it too. A PDF that cannot be made, a page the browser has no memory to draw
81
+ included (as on iOS), shows the `pdfError` text, never a blank page, and
82
+ neither `saved` nor `error` fires, since nothing failed to send; one asked for while another is being made is ignored.
83
+ - **Screenshots:** a report holds up to 10. Each keeps its own marks and its own Undo; the tool
84
+ chosen stays the same across them. The strip numbers them in the order they are sent: a click
85
+ shows one, and its remove button, there only when there are two or more, takes one out. "Add
86
+ screenshot" folds the dialog into the draft, and is gone at 10. The note and the category are one
87
+ for the whole report.
43
88
  - Sending without any mark is allowed.
44
89
 
45
90
  ## 5. Capture
@@ -53,39 +98,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 flattened image, with the marks painted in, or no image after "Send without screenshot";
60
- - the marks as vectors;
107
+ - the screenshots, at most 10, or none after "Send without screenshot". Each has its flattened
108
+ image with the marks painted in, the marks as vectors, and the page URL and title, the viewport
109
+ and the scroll position as they were when it was taken;
61
110
  - the note and the category;
62
- - the page URL and title, the viewport and the scroll position, as they were at the screenshot;
111
+ - the page URL and title, the viewport and the scroll position, as they were at the first
112
+ screenshot, or at the capture error after "Send without screenshot";
63
113
  - the user agent, the language, the time zone and the time of Send;
64
114
  - whatever the host's `context()` returns.
65
115
 
66
116
  ## 7. Privacy
67
117
  - The image is what the user sees. Nothing on the page is masked, hidden or redacted, password
68
118
  fields included. Only the widget itself is left out (SPEC §2, capturing).
69
- - The image is never kept after `close()`.
70
- - No console logs, network logs, cookies, storage or keystrokes are collected.
119
+ - No screenshot is kept after the draft ends: Send, Cancel, Discard, `close()` or `discardDraft()`.
120
+ - No console logs, network logs, cookies or keystrokes are collected. Nothing is stored, with one
121
+ exception: a draft folded in the draft bar is kept on this device for this tab, so a page load
122
+ does not lose it. sessionStorage holds its id, and IndexedDB its screenshots with each one's
123
+ page facts (the whole URL: `redactUrl` applies only to the sent report), marks, note and category. It is deleted when the draft ends: the moment Send succeeds, or at Cancel, Discard,
124
+ `close()` or `discardDraft()`, which a host calls at a logout. `destroy()`, or the host removing the element, keeps it, open or folded, for the next
125
+ widget of that tag in this tab, unless its Send was already handed to the host; a click while
126
+ this tab keeps one opens it. One widget of a tag keeps a draft, the first whose element is in
127
+ the page; when that element leaves, the next widget of the tag to need it takes over, and never
128
+ writes over a draft still kept. Any other keeps its own in memory.
129
+ A `close()` made while a page load is still bringing it back hides it on
130
+ that page and keeps it. A day after it was last folded or brought back, it no longer comes back,
131
+ and it is deleted the next time this site opens the dialog or keeps a draft, in any tab: nothing
132
+ of the widget runs on the device to delete it sooner. A tab the browser duplicates copies
133
+ sessionStorage, so the copy shares that draft. `draft: "memory"` keeps
134
+ it in memory only, and does not delete one an earlier page kept.
71
135
  - The query string and the hash of the URL are kept unless `redactUrl: true` is set.
72
136
 
73
137
  ## 8. Accessibility
74
138
  - The overlay is a `role="dialog"` with `aria-modal="true"` and a label.
75
- - Focus stays inside the dialog, and Escape cancels it. While it is open, the page does not
76
- scroll. On close, focus goes back to the trigger, or to where it was before.
77
- - Every control has an accessible name and a visible focus ring (`--vf-focus`).
139
+ - Focus stays inside the dialog. Escape, except on the Send menu, a label being typed or a selected
140
+ mark (§4), or on Cancel's question (§2),
141
+ keeps an unsent draft that has a screenshot in the draft bar, and moves the focus to it ("Capture
142
+ this view", or "Open" at 10 screenshots). With no screenshot, or after a send error, it cancels,
143
+ asking first as Cancel does; while sending, and once sent, it closes at once.
144
+ While the dialog is open, the page does not scroll. On close, focus goes back to the trigger,
145
+ or to where it was before.
146
+ - Every control has an accessible name and a visible focus ring (`--vf-focus`), `unstyled`
147
+ included (THEMING.md).
78
148
  - With the keyboard alone, the user can still write the note, choose a category and send without
79
149
  marks.
80
150
  - `prefers-reduced-motion` turns off every transition.
81
- - `prefers-color-scheme` sets the default theme (`theme: "auto"`).
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): 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
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
- no inline `style` attributes, so a strict `style-src` does not block it. While the dialog is
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 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.
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` | `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 |
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 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 |
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`, `backdrop`, `dialog`, `title`,
43
- `image`, `toolbar`, `tool`, `tool-active`, `undo`, `clear`, `label`, `note`, `category`, `actions`, `cancel`, `send`,
44
- `status`, `error`, `retry`, `send-without`.
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). Then the `css` option styles everything.
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.