visual-feedback 0.2.0 → 0.3.1

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 CHANGED
@@ -74,8 +74,9 @@ your own words.
74
74
  limit. The local folder works only in development: keep its dev server on localhost instead, since
75
75
  it checks no user.
76
76
  - **If the app has sign-in, call `discardDraft()` from `visual-feedback` at logout, with the same
77
- `tagName` if one was set.** Why: a folded or open draft, screenshots included, is kept in the tab
78
- across page loads and route changes, and would come back for the next person who signs in there.
77
+ `tagName` if one was set.** Why: a draft, screenshots included, is kept in the tab across route
78
+ changes, and across page loads as it was last folded, and would come back for the next person
79
+ who signs in there.
79
80
  - **For GitHub Issues, default to a separate private repo. Offer the code repo only when the
80
81
  developer confirms it stays private forever.** Why: images cannot be fully erased from GitHub,
81
82
  and if the code repo is made public later, every report in it is exposed.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 (2026-10-12)
4
+
5
+ - The trigger shows its name as a tooltip. With a `shortcut` set, the tooltip names it too, the way
6
+ the platform's keyboards do, such as `Send feedback (Option + Shift + F)` on a Mac and
7
+ `(Alt + Shift + F)` elsewhere (SPEC §3).
8
+ - A `shortcut` with no key, such as `"Alt+"`, now matches nothing: before, a key press whose code
9
+ was empty, as some virtual keyboards send, could open the widget.
10
+
11
+ ## 0.3.0 (2026-10-11)
12
+
13
+ - Breaking: with no `--vf-*` variables set, the widget now matches the site by itself. It takes
14
+ the page's accent (`theme-color`, else its coloured buttons, else its links), its button corners
15
+ (up to 15px) and, with `theme: "auto"`, its background and text colour, light or dark following
16
+ that background. A guess that does not contrast keeps the old default: the accent needs 3:1
17
+ against the widget's background and the text 4.5:1; the hints, the text at 60%, take more of it
18
+ where they would fall under 4.5:1. A site that wants the system colours back sets them, such as
19
+ `--vf-accent: AccentColor`; any `--vf-*` it sets still wins, and setting only one of
20
+ `--vf-surface` and `--vf-text` keeps the other at its default.
21
+ - The widget reads the page at load, at each open (a draft opened again from its bar too), when
22
+ `theme` or `css` changes, and when the page switches its theme: a change to the attributes of
23
+ `<html>` or `<body>` that changes the page's background, text colour or `color-scheme`, or a
24
+ change of the system's light or dark. The trigger, the dialog and the draft bar follow a site's
25
+ dark mode switch at once, or when the site's own colour fade ends. To see a switch it keeps one
26
+ mutation observer and one media query listener, both removed by `destroy()` (SPEC §9).
27
+ - The note shows 4 rows when the dialog opens, and the screenshot leaves room for them, so Send
28
+ stays in the window.
29
+
3
30
  ## 0.2.0 (2026-10-10)
4
31
 
5
32
  - Breaking: a report holds `screenshots`, a list of up to 10, in place of `image` and `marks`.
package/README.md CHANGED
@@ -4,7 +4,7 @@ A small button lets your users screenshot the page, mark it up and write a note.
4
4
  report and store it where you want. It has no service, no tracking and no global CSS, and it fits
5
5
  any design.
6
6
 
7
- ![The feedback dialog over a video site, with a box and an arrow drawn around a card that shows NaN views](docs/demo.png)
7
+ ![The feedback dialog over a video site, with a box and an arrow drawn around the topic buttons, seven of which read Undefined](docs/demo.png)
8
8
 
9
9
  ```sh
10
10
  npm install visual-feedback
@@ -504,7 +504,7 @@ function icon(d, part) {
504
504
  }
505
505
  //#endregion
506
506
  //#region src/dialog.ts
507
- const LAYOUT = `.backdrop{position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;box-sizing:border-box;background:none;color:inherit;display:grid;place-items:center;overflow:auto}.backdrop:not([open]){display:none}.backdrop::backdrop{background:none}.dialog{box-sizing:border-box;width:min(560px,100% - 32px);display:grid;gap:12px}.dialog.wide{width:min(1100px,100% - 32px)}.header{display:flex;align-items:center;justify-content:space-between;gap:8px}.title{margin:0}.minimise{margin-left:auto}.body{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:start}.body>.stage:only-child{grid-column:1/-1}.stage{position:relative;display:grid;gap:8px;min-width:0}.image{display:block;max-width:100%;max-height:min(60vh,100vh - 23em);margin:0 auto;color:var(--_mark)}.toolbar+.stage>.image{touch-action:none}.selection{position:absolute;pointer-events:none;box-sizing:border-box}.text-input{position:absolute;transform:translateY(-50%);min-width:6em;margin:0;box-sizing:content-box}.toolbar{display:flex;flex-direction:column;gap:4px}.dialog svg,.draft svg{width:1.15em;height:1.15em;flex:none;stroke-linecap:round}.divider{display:block}.strip{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:8px}.thumb{position:relative;display:flex}.thumb-pick{position:relative;display:block;margin:0;padding:0;line-height:0}.thumb-image{display:block;height:3em;width:auto}.thumb-number{position:absolute;left:4px;top:4px;line-height:1.2}.thumb-remove{position:absolute;top:-.5em;right:-.5em}.add,.capture-view{display:inline-flex;align-items:center;gap:6px}.composer{display:grid;gap:8px}.note{box-sizing:border-box;width:100%;min-height:64px;resize:vertical}.footer{display:flex;align-items:center;justify-content:space-between;gap:8px}.composer-bar,.actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px}.send{display:inline-flex;align-items:center;gap:6px}.split{position:relative;display:flex;margin-left:auto}.send-more{display:inline-flex;align-items:center}.menu{position:absolute;right:0;bottom:calc(100% + 6px);z-index:1;display:grid;min-width:max-content}.menu[hidden]{display:none}.save-pdf{display:inline-flex;align-items:center;gap:8px}.draft{position:fixed;z-index:var(--_z);box-sizing:border-box;max-width:calc(100% - 2 * var(--_off));display:flex;flex-wrap:wrap;align-items:center;gap:8px}.draft-thumbs{display:flex;gap:4px}.draft-thumbs .thumb-image{height:1.75em}.drafting>.trigger{display:none}.live{position:fixed;width:1px;height:1px;margin:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}`;
507
+ const LAYOUT = `.backdrop{position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;box-sizing:border-box;background:none;color:inherit;display:grid;place-items:center;overflow:auto}.backdrop:not([open]){display:none}.backdrop::backdrop{background:none}.dialog{box-sizing:border-box;width:min(560px,100% - 32px);display:grid;gap:12px}.dialog.wide{width:min(1100px,100% - 32px)}.header{display:flex;align-items:center;justify-content:space-between;gap:8px}.title{margin:0}.minimise{margin-left:auto}.body{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:start}.body>.stage:only-child{grid-column:1/-1}.stage{position:relative;display:grid;gap:8px;min-width:0}.image{display:block;max-width:100%;max-height:min(60vh,100vh - 24.5em);margin:0 auto;color:var(--_mark)}.toolbar+.stage>.image{touch-action:none}.selection{position:absolute;pointer-events:none;box-sizing:border-box}.text-input{position:absolute;transform:translateY(-50%);min-width:6em;margin:0;box-sizing:content-box}.toolbar{display:flex;flex-direction:column;gap:4px}.dialog svg,.draft svg{width:1.15em;height:1.15em;flex:none;stroke-linecap:round}.divider{display:block}.strip{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:8px}.thumb{position:relative;display:flex}.thumb-pick{position:relative;display:block;margin:0;padding:0;line-height:0}.thumb-image{display:block;height:3em;width:auto}.thumb-number{position:absolute;left:4px;top:4px;line-height:1.2}.thumb-remove{position:absolute;top:-.5em;right:-.5em}.add,.capture-view{display:inline-flex;align-items:center;gap:6px}.composer{display:grid;gap:8px}.note{box-sizing:border-box;width:100%;min-height:64px;resize:vertical}.footer{display:flex;align-items:center;justify-content:space-between;gap:8px}.composer-bar,.actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px}.send{display:inline-flex;align-items:center;gap:6px}.split{position:relative;display:flex;margin-left:auto}.send-more{display:inline-flex;align-items:center}.menu{position:absolute;right:0;bottom:calc(100% + 6px);z-index:1;display:grid;min-width:max-content}.menu[hidden]{display:none}.save-pdf{display:inline-flex;align-items:center;gap:8px}.draft{position:fixed;z-index:var(--_z);box-sizing:border-box;max-width:calc(100% - 2 * var(--_off));display:flex;flex-wrap:wrap;align-items:center;gap:8px}.draft-thumbs{display:flex;gap:4px}.draft-thumbs .thumb-image{height:1.75em}.drafting>.trigger{display:none}.live{position:fixed;width:1px;height:1px;margin:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}`;
508
508
  const THEME = `.backdrop{background:var(--_bd)}.dialog,.draft{background:var(--_s);color:var(--_t);border:1px solid var(--_b);border-radius:var(--_r);box-shadow:var(--_sh);padding:14px}.draft{padding:8px}.title{margin-left:4px;font-size:1.1em;font-weight:600}.header button,.toolbar button,.thumb-remove{font:inherit;width:2.15em;height:2.15em;padding:0;display:grid;place-items:center;border:0;border-radius:calc(var(--_r) / 2);background:none;color:var(--_m);cursor:pointer}.header button:hover,.toolbar button:hover:not(:disabled){background:color-mix(in srgb,var(--_t) 8%,transparent);color:var(--_t)}.body{padding:12px;border-radius:calc(var(--_r) * .75);background:color-mix(in srgb,var(--_t) 5%,var(--_s))}.toolbar{padding:4px;border:1px solid var(--_b);border-radius:calc(var(--_r) * .75);background:var(--_s)}.toolbar [aria-pressed=true]{background:color-mix(in srgb,var(--_a) 16%,transparent);color:var(--_a)}.toolbar button:disabled{opacity:.35;cursor:default}.divider{width:20px;margin:4px auto;border-top:1px solid var(--_b)}.image{border-radius:calc(var(--_r) / 2);box-shadow:0 0 0 1px var(--_b),0 2px 8px rgb(0 0 0/10%)}.toolbar+.stage>.image{cursor:crosshair}.selection{border:2px dashed var(--_f);border-radius:4px}.text-input{font-family:inherit;font-weight:600;line-height:1.2;color:var(--_mark);background:color-mix(in srgb,var(--_s) 85%,transparent);border:1px dashed var(--_mark);border-radius:4px;padding:0 4px;outline:none}.thumb-pick{border:2px solid transparent;border-radius:calc(var(--_r) / 2.5);background:none;overflow:hidden;cursor:pointer}.thumb-pick[aria-current=true]{border-color:var(--_a)}.thumb-image{border-radius:calc(var(--_r) / 4);box-shadow:0 0 0 1px var(--_b)}.thumb-number{font-size:.75em;font-weight:600;padding:0 4px;border-radius:4px;background:var(--_s);color:var(--_t)}.thumb-remove{width:1.5em;height:1.5em;border:1px solid var(--_b);border-radius:50%;background:var(--_s)}.label{font-size:.9em;font-weight:600;color:var(--_m)}.composer{padding:8px;border:1px solid var(--_b);border-radius:var(--_r);background:var(--_s);transition:border-color .15s,box-shadow .15s}.composer:focus-within{border-color:var(--_f);box-shadow:0 0 0 3px color-mix(in srgb,var(--_f) 22%,transparent)}.note{font:inherit;color:inherit;background:none;border:0;border-radius:calc(var(--_r) / 2);padding:4px 6px}.note:focus-visible{outline:none}.note::placeholder{color:var(--_m)}.category{font:inherit;color:inherit;background:color-mix(in srgb,var(--_t) 5%,var(--_s));border:1px solid var(--_b);border-radius:calc(var(--_r) / 2);padding:6px 8px}.send,.actions button,.add,.draft button{font:inherit;font-weight:600;height:2.25em;padding:0 .9em;border:1px solid var(--_b);border-radius:calc(var(--_r) / 1.6);background:var(--_s);color:inherit;cursor:pointer}.add{border-style:dashed;color:var(--_m)}.send,.draft .capture-view{background:var(--_a);color:var(--_at);border-color:var(--_a)}.split .send{border-radius:calc(var(--_r) / 1.6) 0 0 calc(var(--_r) / 1.6)}.send-more{font:inherit;height:2.25em;padding:0 .55em;border:1px solid var(--_a);border-left-color:color-mix(in srgb,var(--_at) 40%,var(--_a));border-radius:0 calc(var(--_r) / 1.6) calc(var(--_r) / 1.6) 0;background:var(--_a);color:var(--_at);cursor:pointer}.send-more:disabled{opacity:.6;cursor:progress}.send:where([data-ink=dark]),.send-more:where([data-ink=dark]),.draft .capture-view:where([data-ink=dark]){color:#000}.menu{padding:4px;border:1px solid var(--_b);border-radius:calc(var(--_r) / 1.6);background:var(--_s);color:var(--_t);box-shadow:var(--_sh)}.save-pdf{font:inherit;padding:.5em .8em;border:0;border-radius:calc(var(--_r) / 2.5);background:none;color:inherit;cursor:pointer;white-space:nowrap}.save-pdf:hover,.save-pdf:focus-visible{background:color-mix(in srgb,var(--_t) 8%,transparent)}.draft button:disabled{opacity:.5;cursor:default}.draft-label{margin:0 4px;font-size:.9em;font-weight:600}[aria-disabled=true]{opacity:.6;cursor:progress}.note:disabled,.category:disabled{opacity:.6}.hint{margin:0 4px;font-size:.85em;color:var(--_m)}.error{margin:0 4px;font-weight:500}.status{margin:8px 4px}`;
509
509
  const ICONS = {
510
510
  pen: "M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4zm9.5-13.5 4 4",
@@ -619,6 +619,7 @@ function thumbnail(image) {
619
619
  }
620
620
  function ink(button) {
621
621
  const style = getComputedStyle(button);
622
+ delete button.dataset.ink;
622
623
  if (style.getPropertyValue("--vf-accent-text").trim()) return;
623
624
  let [r = 0, g = 0, b = 0] = /^rgba?\(([\d.]+), ([\d.]+), ([\d.]+)/.exec(style.backgroundColor)?.slice(1).map(Number) ?? [];
624
625
  if (!/^rgb/.test(style.backgroundColor)) {
@@ -974,14 +975,19 @@ function mount(parent, handlers, settings = {}) {
974
975
  function reopen() {
975
976
  if (!minimised) return;
976
977
  minimised = false;
978
+ handlers.restyle();
977
979
  bar?.remove();
978
980
  bar = void 0;
979
981
  parent.append(backdrop);
980
982
  backdrop.showModal();
981
983
  lockPage(true);
982
984
  renderStrip();
985
+ reink();
983
986
  note?.focus();
984
987
  }
988
+ function reink() {
989
+ for (const accent of parent.querySelectorAll(".send, .send-more, .capture-view")) ink(accent);
990
+ }
985
991
  let seen;
986
992
  let report;
987
993
  let mounted = true;
@@ -1091,7 +1097,8 @@ function mount(parent, handlers, settings = {}) {
1091
1097
  const field = element("textarea", "note", {
1092
1098
  id: "vf-note",
1093
1099
  placeholder: text.notePlaceholder,
1094
- maxlength: String(NOTE_LIMIT)
1100
+ maxlength: String(NOTE_LIMIT),
1101
+ rows: "4"
1095
1102
  });
1096
1103
  note = field;
1097
1104
  const required = element("p", "error", {
@@ -1312,6 +1319,7 @@ function mount(parent, handlers, settings = {}) {
1312
1319
  lockPage(true);
1313
1320
  },
1314
1321
  reopen,
1322
+ reink,
1315
1323
  async revive() {
1316
1324
  reviving = true;
1317
1325
  try {
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
1
 
2
- export { t as createFeedback, n as discardDraft } from "./widget-B1JDOVI1.mjs";
2
+ export { t as createFeedback, n as discardDraft } from "./widget-Ryu07XUU.mjs";
package/dist/react.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { t as createFeedback } from "./widget-B1JDOVI1.mjs";
2
+ import { t as createFeedback } from "./widget-Ryu07XUU.mjs";
3
3
  import { useEffect, useRef } from "react";
4
4
  //#region src/react/index.ts
5
5
  function Feedback(props) {
@@ -0,0 +1,600 @@
1
+
2
+ //#region src/match.ts
3
+ const BUTTONS = "button,[role=\"button\"],input[type=\"submit\"],input[type=\"button\"]";
4
+ const SAMPLE = 200;
5
+ let pixel;
6
+ /** A colour as sRGB bytes and alpha; syntax other than rgb() (oklch, hex, names) goes through a canvas. */
7
+ function rgb(color) {
8
+ const match = /^rgba?\(([\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)(?:[ ,/]+([\d.]+)(%?))?\)/.exec(color);
9
+ if (match) {
10
+ const alpha = match[4] === void 0 ? 1 : Number(match[4]) / (match[5] ? 100 : 1);
11
+ return [
12
+ Number(match[1]),
13
+ Number(match[2]),
14
+ Number(match[3]),
15
+ alpha
16
+ ];
17
+ }
18
+ if (pixel === void 0) pixel = Object.assign(document.createElement("canvas"), {
19
+ width: 1,
20
+ height: 1
21
+ }).getContext("2d", { willReadFrequently: true });
22
+ if (!pixel || !color) return void 0;
23
+ pixel.clearRect(0, 0, 1, 1);
24
+ pixel.fillStyle = "#0000";
25
+ pixel.fillStyle = color;
26
+ pixel.fillRect(0, 0, 1, 1);
27
+ const [r = 0, g = 0, b = 0, a = 0] = pixel.getImageData(0, 0, 1, 1).data;
28
+ return [
29
+ r,
30
+ g,
31
+ b,
32
+ a / 255
33
+ ];
34
+ }
35
+ function luminance([r, g, b]) {
36
+ const linear = (c) => c / 255 <= .04045 ? c / 255 / 12.92 : ((c / 255 + .055) / 1.055) ** 2.4;
37
+ return .2126 * linear(r) + .7152 * linear(g) + .0722 * linear(b);
38
+ }
39
+ function contrast(a, b) {
40
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
41
+ return ((light ?? 0) + .05) / ((dark ?? 0) + .05);
42
+ }
43
+ const css = ([r, g, b]) => `rgb(${r} ${g} ${b})`;
44
+ const opaque = (color) => (color?.[3] ?? 0) > .99;
45
+ function vivid(color) {
46
+ if (!opaque(color)) return false;
47
+ const max = Math.max(color[0], color[1], color[2]);
48
+ const min = Math.min(color[0], color[1], color[2]);
49
+ const light = (max + min) / 510;
50
+ return (max - min) / 255 >= .2 && light > .1 && light < .9;
51
+ }
52
+ function visible(element) {
53
+ return element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden";
54
+ }
55
+ function commonest(colors) {
56
+ const counts = /* @__PURE__ */ new Map();
57
+ for (const color of colors) {
58
+ const entry = counts.get(css(color)) ?? [color, 0];
59
+ counts.set(css(color), [entry[0], entry[1] + 1]);
60
+ }
61
+ return [...counts.values()].sort((a, b) => b[1] - a[1])[0]?.[0];
62
+ }
63
+ function themeColor() {
64
+ for (const meta of document.querySelectorAll("meta[name=\"theme-color\"]")) {
65
+ const media = meta.getAttribute("media");
66
+ if (!media || matchMedia(media).matches) return rgb(meta.content.trim());
67
+ }
68
+ }
69
+ /**
70
+ * Takes the page's own accent, corners and, with `theme: "auto"`, background and text, where the
71
+ * host set no --vf-* for them. Each guess that fails contrast falls back to the defaults.
72
+ */
73
+ function match(wrapper, theme) {
74
+ const style = wrapper.style;
75
+ for (const name of [
76
+ "--_pa",
77
+ "--_pm",
78
+ "--_pr",
79
+ "--_ps",
80
+ "--_pt"
81
+ ]) style.setProperty(name, "initial");
82
+ style.colorScheme = theme?.replace("auto", "") ?? "";
83
+ if (!document.body || !wrapper.isConnected) return;
84
+ try {
85
+ guess(wrapper, theme, document.body);
86
+ } catch {}
87
+ }
88
+ function guess(wrapper, theme, body) {
89
+ const style = wrapper.style;
90
+ const own = getComputedStyle(wrapper);
91
+ const free = (variable) => !own.getPropertyValue(variable).trim();
92
+ if ((theme ?? "auto") === "auto" && free("--vf-surface") && free("--vf-text")) {
93
+ const background = [body, document.documentElement].map((element) => rgb(getComputedStyle(element).backgroundColor)).find(opaque);
94
+ const text = rgb(getComputedStyle(body).color);
95
+ if (background && opaque(text) && contrast(background, text) >= 4.5) {
96
+ style.setProperty("--_ps", css(background));
97
+ style.setProperty("--_pt", css(text));
98
+ const mixed = (share) => [
99
+ share * text[0] + (1 - share) * background[0],
100
+ share * text[1] + (1 - share) * background[1],
101
+ share * text[2] + (1 - share) * background[2],
102
+ 1
103
+ ];
104
+ let share = .6;
105
+ while (share < 1 && contrast(background, mixed(share)) < 4.5) share += .05;
106
+ if (share > .6) style.setProperty("--_pm", css(mixed(Math.min(1, share))));
107
+ style.colorScheme = luminance(background) > .179 ? "light" : "dark";
108
+ }
109
+ }
110
+ const browser = /* @__PURE__ */ new Map();
111
+ const plain = (scheme) => {
112
+ if (!browser.has(scheme)) {
113
+ const native = wrapper.appendChild(document.createElement("button"));
114
+ native.style.all = "revert";
115
+ native.style.colorScheme = scheme;
116
+ const look = getComputedStyle(native);
117
+ browser.set(scheme, `${look.backgroundColor} ${look.borderTopLeftRadius}`);
118
+ native.remove();
119
+ }
120
+ return browser.get(scheme);
121
+ };
122
+ const filled = [...document.querySelectorAll(BUTTONS)].slice(0, SAMPLE).filter(visible).map((button) => [button, getComputedStyle(button)]).filter(([, look]) => {
123
+ const face = `${look.backgroundColor} ${look.borderTopLeftRadius}`;
124
+ return face !== plain("light") && face !== plain("dark");
125
+ });
126
+ if (free("--vf-radius")) {
127
+ const shapes = filled.filter(([, look]) => opaque(rgb(look.backgroundColor))).map(([button, look]) => {
128
+ const radius = Number.parseFloat(look.borderTopLeftRadius) || 0;
129
+ return {
130
+ radius,
131
+ pill: radius * 2 >= button.getBoundingClientRect().height - 1
132
+ };
133
+ });
134
+ const corners = shapes.filter((shape) => !shape.pill).map((shape) => shape.radius);
135
+ corners.sort((a, b) => a - b);
136
+ const middle = corners[Math.floor((corners.length - 1) / 2)];
137
+ if (middle !== void 0 && corners.length * 2 > shapes.length) style.setProperty("--_pr", `${Math.min(24, middle * 1.6)}px`);
138
+ }
139
+ if (free("--vf-accent")) {
140
+ const links = () => [...document.querySelectorAll("a[href]")].slice(0, SAMPLE).filter(visible).map((link) => rgb(getComputedStyle(link).color)).filter(vivid);
141
+ const accent = [
142
+ themeColor(),
143
+ commonest(filled.map(([, look]) => rgb(look.backgroundColor)).filter(vivid)),
144
+ commonest(links())
145
+ ].find(vivid);
146
+ const probe = wrapper.appendChild(document.createElement("i"));
147
+ probe.style.background = "var(--_s)";
148
+ const surface = rgb(getComputedStyle(probe).backgroundColor);
149
+ probe.remove();
150
+ if (accent && opaque(surface) && contrast(accent, surface) >= 3) style.setProperty("--_pa", css(accent));
151
+ }
152
+ }
153
+ //#endregion
154
+ //#region src/widget.ts
155
+ const SVG = "http://www.w3.org/2000/svg";
156
+ function supported() {
157
+ return typeof document !== "undefined" && "adoptedStyleSheets" in Document.prototype && "adoptedStyleSheets" in ShadowRoot.prototype && CSS.supports("color", "color-mix(in srgb, red, red)");
158
+ }
159
+ function sheet(text) {
160
+ const result = new CSSStyleSheet();
161
+ result.replaceSync(text);
162
+ return result;
163
+ }
164
+ function variables() {
165
+ return `.vf{--_r:var(--vf-radius,var(--_pr,16px));--_s:var(--vf-surface,var(--_ps,Canvas));--_t:var(--vf-text,var(--_pt,CanvasText));--_m:var(--vf-muted,var(--_pm,color-mix(in srgb,var(--_t) 60%,transparent)));--_b:var(--vf-border,color-mix(in srgb,var(--_t) 15%,transparent));--_a:var(--vf-accent,var(--_pa,${CSS.supports("color", "AccentColor") ? "AccentColor" : "#0b57d0"}));--_at:var(--vf-accent-text,#fff);--_f:var(--vf-focus,var(--_a));--_mark:var(--vf-mark,#e5243b);--_bd:var(--vf-backdrop,rgb(0 0 0/45%));--_sh:var(--vf-shadow,0 12px 32px -12px rgb(0 0 0/35%));--_size:var(--vf-trigger-size,2.5em);--_off:var(--vf-offset,16px);--_z:var(--vf-z,2147483000)}`;
166
+ }
167
+ const LAYOUT = `.vf{display:contents;font-family:var(--vf-font,inherit);color-scheme:light dark}.trigger{font:inherit;position:fixed;z-index:var(--_z);box-sizing:border-box;height:var(--_size);margin:0;padding:0 14px 0 12px;display:flex;align-items:center;gap:8px;white-space:nowrap}.trigger[data-icon]{width:var(--_size);padding:0;justify-content:center}[data-y=bottom]{bottom:calc(var(--_off) + env(safe-area-inset-bottom,0px))}[data-y=top]{top:calc(var(--_off) + env(safe-area-inset-top,0px))}[data-x=right]{right:calc(var(--_off) + env(safe-area-inset-right,0px))}[data-x=left]{left:calc(var(--_off) + env(safe-area-inset-left,0px))}.trigger svg{width:1.15em;height:1.15em}.capturing,.capturing .backdrop{visibility:hidden}.still .trigger{transition:none!important}:focus-visible{outline:2px solid var(--_f);outline-offset:2px}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}`;
168
+ const THEME = `.trigger{font-weight:600;border:1px solid var(--_b);border-radius:999px;background:var(--_s);color:var(--_t);box-shadow:var(--_sh);cursor:pointer;transition:background-color .15s}.trigger:hover{background:color-mix(in srgb,var(--_s) 90%,var(--_t))}`;
169
+ function icon() {
170
+ const svg = document.createElementNS(SVG, "svg");
171
+ const path = document.createElementNS(SVG, "path");
172
+ for (const [name, value] of Object.entries({
173
+ viewBox: "0 0 24 24",
174
+ "aria-hidden": "true",
175
+ fill: "none",
176
+ stroke: "currentColor",
177
+ "stroke-width": "2",
178
+ "stroke-linejoin": "round",
179
+ part: "trigger-icon"
180
+ })) svg.setAttribute(name, value);
181
+ path.setAttribute("d", "M21 12a8 8 0 0 1-11.6 7.1L4 20l1.1-4.4A8 8 0 1 1 21 12z");
182
+ svg.append(path);
183
+ return svg;
184
+ }
185
+ function field(element) {
186
+ return element.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName);
187
+ }
188
+ function deepActive() {
189
+ let active = document.activeElement;
190
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
191
+ return active;
192
+ }
193
+ const DISCONNECTED = Symbol.for("visual-feedback.disconnected");
194
+ const CONNECTED = Symbol.for("visual-feedback.connected");
195
+ const owners = /* @__PURE__ */ new Map();
196
+ const enders = /* @__PURE__ */ new WeakMap();
197
+ let dialogs;
198
+ let discarding = Promise.resolve();
199
+ /**
200
+ * Deletes the draft this tab keeps for a tag, as at a logout, so no later widget of the tag brings
201
+ * it back; a widget holding it, open or folded, ends it.
202
+ */
203
+ async function discardDraft(tagName = "visual-feedback") {
204
+ const key = `visual-feedback:${tagName}`;
205
+ let id = null;
206
+ try {
207
+ id = sessionStorage.getItem(key);
208
+ sessionStorage.removeItem(key);
209
+ } catch {}
210
+ const owner = owners.get(tagName);
211
+ if (owner) enders.get(owner)?.();
212
+ const mine = discarding.then(async () => {
213
+ await (await dialogs?.catch(() => void 0))?.settled();
214
+ if (id) await (await import("./keep-BG5WxZPU.mjs")).forget(id);
215
+ });
216
+ discarding = mine.catch(() => {});
217
+ await mine;
218
+ }
219
+ const noop = {
220
+ open: async () => {},
221
+ close() {},
222
+ destroy() {},
223
+ update() {},
224
+ on: () => () => {}
225
+ };
226
+ const MODIFIERS = [
227
+ "alt",
228
+ "shift",
229
+ "control",
230
+ "meta"
231
+ ];
232
+ /** A shortcut's modifiers in lower case and its key as written; undefined when it can match nothing. */
233
+ function parse(shortcut) {
234
+ const parts = shortcut.split("+");
235
+ const key = parts.pop() ?? "";
236
+ const mods = parts.map((part) => part.toLowerCase());
237
+ return key && mods.every((mod) => MODIFIERS.includes(mod)) ? {
238
+ mods,
239
+ key
240
+ } : void 0;
241
+ }
242
+ /** The shortcut as this platform's keyboards name it, such as "Option + Shift + F" on a Mac. */
243
+ function spoken(shortcut) {
244
+ const parsed = parse(shortcut);
245
+ if (!parsed) return void 0;
246
+ const platform = navigator.platform;
247
+ const names = /mac|iphone|ipad|ipod/i.test(platform) ? {
248
+ alt: "Option",
249
+ shift: "Shift",
250
+ control: "Control",
251
+ meta: "Command"
252
+ } : {
253
+ alt: "Alt",
254
+ shift: "Shift",
255
+ control: "Ctrl",
256
+ meta: /^win/i.test(platform) ? "Win" : "Super"
257
+ };
258
+ const key = parsed.key.charAt(0).toUpperCase() + parsed.key.slice(1);
259
+ return [...new Set(parsed.mods)].map((mod) => names[mod]).concat(key).join(" + ");
260
+ }
261
+ function createFeedback(options = {}) {
262
+ if (!supported()) return noop;
263
+ const tag = options.tagName ?? "visual-feedback";
264
+ if (!customElements.get(tag)) customElements.define(tag, class extends HTMLElement {
265
+ connectedCallback() {
266
+ this[CONNECTED]?.();
267
+ }
268
+ disconnectedCallback() {
269
+ this[DISCONNECTED]?.();
270
+ }
271
+ });
272
+ const host = document.createElement(tag);
273
+ const root = host.attachShadow({ mode: "open" });
274
+ const wrapper = document.createElement("div");
275
+ wrapper.className = "vf";
276
+ root.append(wrapper);
277
+ let view;
278
+ const handlers = /* @__PURE__ */ new Map();
279
+ const call = (fns, args) => {
280
+ for (const fn of [...fns]) try {
281
+ fn(...args);
282
+ } catch (error) {
283
+ reportError(error);
284
+ }
285
+ };
286
+ const emit = (event, ...args) => call(handlers.get(event) ?? [], args);
287
+ const start = () => void widget.open().catch(() => {});
288
+ let state = "idle";
289
+ let dialog;
290
+ let session = 0;
291
+ let returnFocus = null;
292
+ let pending;
293
+ let trigger;
294
+ let label;
295
+ let unlisten;
296
+ function listen(shortcut) {
297
+ const parsed = parse(shortcut);
298
+ const key = parsed?.key.toLowerCase();
299
+ const mods = MODIFIERS.map((name) => parsed?.mods.includes(name)).join();
300
+ const typing = !parsed?.mods.some((mod) => mod === "control" || mod === "meta");
301
+ const on = (event) => {
302
+ if (event instanceof KeyboardEvent && state === "idle" && !event.repeat && !event.isComposing && !event.getModifierState("AltGraph") && parsed && [
303
+ event.altKey,
304
+ event.shiftKey,
305
+ event.ctrlKey,
306
+ event.metaKey
307
+ ].join() === mods && [event.code.replace(/^(Key|Digit)/, ""), event.key].some((name) => name.toLowerCase() === key) && !(typing && field(event.composedPath()[0]))) {
308
+ event.preventDefault();
309
+ start();
310
+ }
311
+ };
312
+ addEventListener("keydown", on);
313
+ return () => removeEventListener("keydown", on);
314
+ }
315
+ let base = [];
316
+ let extra = [];
317
+ function sheets() {
318
+ const dialogCss = view?.css(Boolean(options.unstyled)).map(sheet) ?? [];
319
+ root.adoptedStyleSheets = [
320
+ ...base,
321
+ ...dialogCss,
322
+ ...extra
323
+ ];
324
+ }
325
+ let matched;
326
+ function apply() {
327
+ base = [sheet(variables() + LAYOUT), ...options.unstyled ? [] : [sheet(THEME)]];
328
+ extra = options.css ? [sheet(options.css)] : [];
329
+ sheets();
330
+ if (`${options.theme} ${options.css}` !== matched) {
331
+ matched = `${options.theme} ${options.css}`;
332
+ match(wrapper, options.theme);
333
+ dialog?.reink();
334
+ }
335
+ if (options.trigger === "none") {
336
+ trigger?.remove();
337
+ trigger = void 0;
338
+ } else {
339
+ if (!trigger) {
340
+ trigger = document.createElement("button");
341
+ trigger.type = "button";
342
+ trigger.className = "trigger";
343
+ trigger.setAttribute("part", "trigger");
344
+ label = document.createElement("span");
345
+ label.setAttribute("part", "trigger-text");
346
+ trigger.append(icon(), label);
347
+ trigger.addEventListener("click", start);
348
+ wrapper.prepend(trigger);
349
+ }
350
+ const [y, x] = (options.position ?? "bottom-right").split("-");
351
+ trigger.dataset.y = y;
352
+ trigger.dataset.x = x;
353
+ const { trigger: name, triggerText } = options.strings ?? {};
354
+ const accessible = name || triggerText || "Send feedback";
355
+ trigger.setAttribute("aria-label", accessible);
356
+ const keys = options.shortcut ? spoken(options.shortcut) : void 0;
357
+ trigger.title = keys ? `${accessible} (${keys})` : accessible;
358
+ trigger.toggleAttribute("data-icon", !triggerText);
359
+ if (label) {
360
+ label.textContent = triggerText ?? "";
361
+ label.hidden = !triggerText;
362
+ }
363
+ }
364
+ unlisten?.();
365
+ unlisten = options.shortcut ? listen(options.shortcut) : void 0;
366
+ }
367
+ apply();
368
+ (options.container ?? document.body ?? document.documentElement).append(host);
369
+ let seen = "";
370
+ const look = () => [document.documentElement, document.body].map((element) => {
371
+ const style = element && getComputedStyle(element);
372
+ return style ? `${style.backgroundColor} ${style.color} ${style.colorScheme}` : "";
373
+ }).join();
374
+ function restyle() {
375
+ wrapper.classList.add("still");
376
+ match(wrapper, options.theme);
377
+ if (trigger) getComputedStyle(trigger).backgroundColor;
378
+ wrapper.classList.remove("still");
379
+ dialog?.reink();
380
+ seen = look();
381
+ }
382
+ restyle();
383
+ let missed = false;
384
+ function follow(always = false) {
385
+ missed ||= always;
386
+ if (!host.isConnected) return;
387
+ const read = () => {
388
+ if (!host.isConnected || !(missed || look() !== seen)) return;
389
+ missed = false;
390
+ restyle();
391
+ };
392
+ const fading = document.getAnimations().filter((animation) => animation instanceof CSSTransition).filter((animation) => /color|background/.test(animation.transitionProperty));
393
+ if (fading.length) Promise.allSettled(fading.map((fade) => fade.finished)).then(read);
394
+ else read();
395
+ }
396
+ const system = matchMedia("(prefers-color-scheme: dark)");
397
+ const switched = () => follow(true);
398
+ system.addEventListener("change", switched);
399
+ let watched = null;
400
+ const observer = new MutationObserver(() => {
401
+ if (document.body !== watched) {
402
+ watched = document.body;
403
+ if (watched) observer.observe(watched, { attributes: true });
404
+ }
405
+ follow();
406
+ });
407
+ observer.observe(document.documentElement, {
408
+ attributes: true,
409
+ childList: true
410
+ });
411
+ watched = document.body;
412
+ if (watched) observer.observe(watched, { attributes: true });
413
+ const keeps = () => {
414
+ if (options.draft === "memory") return false;
415
+ if (!owners.get(tag)?.isConnected && host.isConnected) owners.set(tag, host);
416
+ return owners.get(tag) === host;
417
+ };
418
+ restore();
419
+ function restore() {
420
+ try {
421
+ if (!keeps() || !sessionStorage.getItem(`visual-feedback:${tag}`)) return false;
422
+ } catch {
423
+ return false;
424
+ }
425
+ state = "opening";
426
+ pending = show(++session, true);
427
+ pending.catch(() => {});
428
+ return true;
429
+ }
430
+ function hide(on) {
431
+ wrapper.classList.toggle("capturing", on);
432
+ }
433
+ function requestDisplay() {
434
+ const media = navigator.mediaDevices;
435
+ if (options.capture?.mode !== "display" || typeof media?.getDisplayMedia !== "function") return;
436
+ hide(true);
437
+ const stream = media.getDisplayMedia({ video: true });
438
+ stream.catch(() => {});
439
+ return stream;
440
+ }
441
+ function stop(stream) {
442
+ stream?.then((media) => {
443
+ for (const track of media.getTracks()) track.stop();
444
+ }, () => {});
445
+ }
446
+ async function grab(module, mine, stream) {
447
+ hide(true);
448
+ const quiet = view?.announce(root, options.strings);
449
+ try {
450
+ return await module.capture(options.capture, stream);
451
+ } catch {
452
+ return null;
453
+ } finally {
454
+ quiet?.();
455
+ if (mine === session) hide(false);
456
+ }
457
+ }
458
+ function cancel() {
459
+ session++;
460
+ hide(false);
461
+ }
462
+ /** Returns whether the draft stays kept; when not, it has ended. */
463
+ function teardown(keep) {
464
+ const stays = dialog?.unmount(keep) ?? false;
465
+ dialog = void 0;
466
+ const target = returnFocus?.isConnected && returnFocus !== document.body ? returnFocus : trigger;
467
+ returnFocus = null;
468
+ target?.focus?.();
469
+ return stays;
470
+ }
471
+ async function show(mine, kept) {
472
+ returnFocus = deepActive();
473
+ const stream = kept ? void 0 : requestDisplay();
474
+ try {
475
+ dialogs ??= import("./dialog-W6m1knfu.mjs").catch((error) => {
476
+ dialogs = void 0;
477
+ throw error;
478
+ });
479
+ const [loaded, capture] = await Promise.all([dialogs, import("./capture-CufRosj3.mjs")]);
480
+ if (mine !== session) return stop(stream);
481
+ view = loaded;
482
+ sheets();
483
+ const image = kept ? null : await grab(capture, mine, stream);
484
+ if (mine !== session) return;
485
+ if (!host.isConnected) {
486
+ state = "idle";
487
+ return;
488
+ }
489
+ match(wrapper, options.theme);
490
+ dialog = view.mount(wrapper, {
491
+ close: () => widget.close(),
492
+ retry: () => void retry(capture).catch((error) => {
493
+ widget.close();
494
+ emit("error", error);
495
+ }),
496
+ emit,
497
+ options: () => options,
498
+ keeps,
499
+ restyle: () => match(wrapper, options.theme)
500
+ }, options);
501
+ const mounted = dialog;
502
+ if (!kept) mounted.capture(image);
503
+ else {
504
+ const back = await mounted.revive();
505
+ if (mine !== session) {
506
+ mounted.unmount(true);
507
+ return;
508
+ }
509
+ if (!back) {
510
+ state = "idle";
511
+ mounted.unmount();
512
+ return;
513
+ }
514
+ }
515
+ } catch (error) {
516
+ stop(stream);
517
+ if (mine === session) {
518
+ state = "idle";
519
+ hide(false);
520
+ teardown();
521
+ }
522
+ emit("error", error);
523
+ throw error;
524
+ }
525
+ state = "open";
526
+ emit("open");
527
+ }
528
+ async function retry(capture) {
529
+ if (wrapper.classList.contains("capturing")) return;
530
+ const mine = session;
531
+ dialog?.lock(false);
532
+ const image = await grab(capture, mine, requestDisplay());
533
+ if (mine !== session) return;
534
+ dialog?.capture(image);
535
+ }
536
+ const widget = {
537
+ open() {
538
+ if (state === "opening" && pending) {
539
+ const mine = session;
540
+ return pending.then(() => mine === session ? widget.open() : void 0);
541
+ }
542
+ if (state !== "idle") {
543
+ dialog?.reopen();
544
+ return Promise.resolve();
545
+ }
546
+ if (restore()) return widget.open();
547
+ state = "opening";
548
+ pending = show(++session);
549
+ return pending;
550
+ },
551
+ close() {
552
+ end();
553
+ },
554
+ destroy() {
555
+ if (state === "destroyed") return;
556
+ const wasOpen = state === "open";
557
+ state = "destroyed";
558
+ cancel();
559
+ unlisten?.();
560
+ observer.disconnect();
561
+ system.removeEventListener("change", switched);
562
+ const closers = wasOpen && !teardown(true) ? [...handlers.get("close") ?? []] : [];
563
+ handlers.clear();
564
+ host.remove();
565
+ call(closers, []);
566
+ },
567
+ update(next) {
568
+ if (state === "destroyed") return;
569
+ options = {
570
+ ...options,
571
+ ...next
572
+ };
573
+ apply();
574
+ },
575
+ on(event, fn) {
576
+ if (state === "destroyed") return () => {};
577
+ const set = handlers.get(event) ?? /* @__PURE__ */ new Set();
578
+ handlers.set(event, set);
579
+ const handler = fn;
580
+ set.add(handler);
581
+ return () => set.delete(handler);
582
+ }
583
+ };
584
+ function end(keep) {
585
+ if (state !== "open" && state !== "opening") return;
586
+ const wasOpen = state === "open";
587
+ state = "idle";
588
+ cancel();
589
+ const stays = teardown(keep);
590
+ if (wasOpen && !stays) emit("close");
591
+ }
592
+ enders.set(host, () => end());
593
+ host[CONNECTED] = () => follow();
594
+ host[DISCONNECTED] = () => queueMicrotask(() => {
595
+ if (!host.isConnected) end(true);
596
+ });
597
+ return widget;
598
+ }
599
+ //#endregion
600
+ export { discardDraft as n, createFeedback as t };
package/docs/API.md CHANGED
@@ -39,7 +39,7 @@ const feedback = createFeedback({
39
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
40
  | `trigger` | `"button" \| "none"` | `"button"` | `"none"` draws no trigger; the host calls `open()`. A kept draft's bar still shows. |
41
41
  | `position` | `"bottom-right" \| "bottom-left" \| "top-right" \| "top-left"` | `"bottom-right"` | Corner of the trigger. |
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 text field. 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 global listener, one `keydown` on `window` (SPEC §9 names what the capture library keeps). |
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, or no key, 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. The trigger's tooltip names it (SPEC §3). 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
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. |
44
44
  | `categories` | `{ id: string; label: string }[]` | none | Shows a category picker. |
45
45
  | `requireNote` | `boolean` | `true` | |
@@ -62,9 +62,9 @@ const feedback = createFeedback({
62
62
  |---|---|
63
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
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 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. 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. |
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
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. The dialog's texts, `tools`, `categories` and `requireNote` apply from the next open, and the rest at the next capture or send. |
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
68
 
69
69
  ## discardDraft(tagName?: string): Promise<void>
70
70
  Deletes the draft this tab keeps for a tag (default `"visual-feedback"`), as at a logout, so no
package/docs/SPEC.md CHANGED
@@ -53,10 +53,16 @@ annotating, draft --Cancel (it may ask first, below) / Discard--> idle
53
53
  gets that short label beside the icon, as a pill; with only `triggerText` given, that text is
54
54
  the name too. A host that sets both keeps `triggerText` inside `trigger`, so
55
55
  speech control can say what it sees. It has no badge, and it does not move or pulse.
56
+ - Its tooltip (`title`) is its name, as for the dialog's icon buttons. With a `shortcut` that can
57
+ match, it is the name and the keys, each modifier once, joined by ` + `, as the platform's
58
+ keyboards name them: Option, Command, Control and Shift on a Mac, iPhone or iPad
59
+ (`navigator.platform`), as in `Send feedback (Option + Shift + F)`; Alt, Win, Ctrl and Shift on
60
+ Windows; Alt, Super, Ctrl and Shift elsewhere. A screen reader then reads the keys as the
61
+ description.
56
62
  - It sits 16 px from the edges by default, and it respects `env(safe-area-inset-*)`.
57
63
  - It is hidden while there is a draft, whether the dialog is open or kept in the draft bar.
58
- - It does not watch the page. The host moves it off its own content with `position` or
59
- `--vf-offset`.
64
+ - It does not move to keep clear of the page's content. The host moves it off its own content
65
+ with `position` or `--vf-offset`.
60
66
 
61
67
  ## 4. Annotating
62
68
  - **Tools:** Pen (freehand), Box (rectangle outline), Arrow and Text. Text places a label where the
@@ -148,13 +154,19 @@ Defined in `docs/API.md` (`FeedbackReport`). It holds:
148
154
  - With the keyboard alone, the user can still write the note, choose a category and send without
149
155
  marks.
150
156
  - `prefers-reduced-motion` turns off every transition.
151
- - `prefers-color-scheme` sets the default theme (`theme: "auto"`).
157
+ - With `theme: "auto"`, the page's own background sets light or dark, and `prefers-color-scheme`
158
+ where the page shows none (THEMING.md).
152
159
 
153
160
  ## 9. Budgets
154
161
  - **Loader** (the trigger and the `open()` wiring): 15 KB or less, minified and gzipped.
155
162
  - **Annotator and dialog:** 135 KB or less, gzipped. This does not include the capture library.
156
163
  - **Idle cost:** no work after the first paint of the trigger, past reading one sessionStorage
157
- key at load; only when this tab kept a draft does the dialog load, to bring it back. After the first screenshot, the
164
+ key and the page's colours and button corners at load, and reading them again when the page
165
+ switches its theme (THEMING.md section 1). To see a switch, it keeps one mutation observer on
166
+ the attributes of `<html>` and `<body>` and on the children of `<html>` (to find a new
167
+ `<body>`), and one `change` listener on `prefers-color-scheme`, both gone at `destroy()`. A
168
+ change the observer sees that leaves the page's colours alone reads the colours of `<html>` and
169
+ `<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
158
170
  capture library (snapdom) keeps 12 listeners on `window` and `document` and up to 5 mutation observers
159
171
  until the page unloads, and offers no way to remove them; `destroy()` removes everything else
160
172
  but a draft it keeps (§7). The opt-in `shortcut` adds one `keydown` listener on `window`, gone at
@@ -168,7 +180,7 @@ Chrome 111+, Firefox 113+ and Safari 16.4+. Older browsers get no trigger and no
168
180
  ## 11. Content Security Policy
169
181
  - The widget's own CSS goes in through constructable stylesheets. It uses no `<style>` tags, and
170
182
  sets the few inline styles it needs (the selection frame, a label being typed, the theme's
171
- `color-scheme`) through the CSSOM, so a strict `style-src` does not block it. While the dialog is
183
+ `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
172
184
  open, the scroll lock sets `overflow` on `<html>` through the CSSOM, which a CSP allows, and
173
185
  puts back what was there on close.
174
186
  - Capture (`@zumer/snapdom`) renders through `data:` image URLs, so a site with a CSP needs
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,12 +38,12 @@ 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` | `16px` | panel and composer, and a share of it for buttons and the image |
19
- | `--vf-surface` | `Canvas` | panel, tool rail and composer background |
20
- | `--vf-text` | `CanvasText` | text |
21
- | `--vf-muted` | text at 60% | hints, counters |
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 |
@@ -68,5 +91,6 @@ Class names used inside the shadow root are part of the public API from 1.0. Bef
68
91
  change.
69
92
 
70
93
  ## Theme
71
- `theme: "light" | "dark"` sets `color-scheme` inside the shadow root, which forces that scheme.
72
- `"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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "visual-feedback",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "A small button lets your users screenshot the page, mark it up and write a note. You receive one report and store it where you want.",
5
5
  "keywords": [
6
6
  "feedback",
@@ -60,7 +60,7 @@
60
60
  "test:pack": "playwright test -c tests/pack-smoke/playwright.config.ts",
61
61
  "size": "size-limit",
62
62
  "publint": "publint --strict",
63
- "check": "npm run check:static && npm run check:browser",
63
+ "check": "node tests/check.mjs",
64
64
  "check:static": "npm run lint && npm run typecheck && npm run build && npm run test && npm run test:edge && npm run size && npm run publint",
65
65
  "check:browser": "npm run test:e2e && npm run test:react && npm run test:pack",
66
66
  "prepack": "npm run --silent build -- --logLevel warn",
@@ -1,355 +0,0 @@
1
-
2
- //#region src/widget.ts
3
- const SVG = "http://www.w3.org/2000/svg";
4
- function supported() {
5
- return typeof document !== "undefined" && "adoptedStyleSheets" in Document.prototype && "adoptedStyleSheets" in ShadowRoot.prototype && CSS.supports("color", "color-mix(in srgb, red, red)");
6
- }
7
- function sheet(text) {
8
- const result = new CSSStyleSheet();
9
- result.replaceSync(text);
10
- return result;
11
- }
12
- function variables() {
13
- return `.vf{--_r:var(--vf-radius,16px);--_s:var(--vf-surface,Canvas);--_t:var(--vf-text,CanvasText);--_m:var(--vf-muted,color-mix(in srgb,var(--_t) 60%,transparent));--_b:var(--vf-border,color-mix(in srgb,var(--_t) 15%,transparent));--_a:var(--vf-accent,${CSS.supports("color", "AccentColor") ? "AccentColor" : "#0b57d0"});--_at:var(--vf-accent-text,#fff);--_f:var(--vf-focus,var(--_a));--_mark:var(--vf-mark,#e5243b);--_bd:var(--vf-backdrop,rgb(0 0 0/45%));--_sh:var(--vf-shadow,0 12px 32px -12px rgb(0 0 0/35%));--_size:var(--vf-trigger-size,2.5em);--_off:var(--vf-offset,16px);--_z:var(--vf-z,2147483000)}`;
14
- }
15
- const LAYOUT = `.vf{display:contents;font-family:var(--vf-font,inherit);color-scheme:light dark}.trigger{font:inherit;position:fixed;z-index:var(--_z);box-sizing:border-box;height:var(--_size);margin:0;padding:0 14px 0 12px;display:flex;align-items:center;gap:8px;white-space:nowrap}.trigger[data-icon]{width:var(--_size);padding:0;justify-content:center}[data-y=bottom]{bottom:calc(var(--_off) + env(safe-area-inset-bottom,0px))}[data-y=top]{top:calc(var(--_off) + env(safe-area-inset-top,0px))}[data-x=right]{right:calc(var(--_off) + env(safe-area-inset-right,0px))}[data-x=left]{left:calc(var(--_off) + env(safe-area-inset-left,0px))}.trigger svg{width:1.15em;height:1.15em}.capturing,.capturing .backdrop{visibility:hidden}:focus-visible{outline:2px solid var(--_f);outline-offset:2px}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}`;
16
- const THEME = `.trigger{font-weight:600;border:1px solid var(--_b);border-radius:999px;background:var(--_s);color:var(--_t);box-shadow:var(--_sh);cursor:pointer;transition:background-color .15s}.trigger:hover{background:color-mix(in srgb,var(--_s) 90%,var(--_t))}`;
17
- function icon() {
18
- const svg = document.createElementNS(SVG, "svg");
19
- const path = document.createElementNS(SVG, "path");
20
- for (const [name, value] of Object.entries({
21
- viewBox: "0 0 24 24",
22
- "aria-hidden": "true",
23
- fill: "none",
24
- stroke: "currentColor",
25
- "stroke-width": "2",
26
- "stroke-linejoin": "round",
27
- part: "trigger-icon"
28
- })) svg.setAttribute(name, value);
29
- path.setAttribute("d", "M21 12a8 8 0 0 1-11.6 7.1L4 20l1.1-4.4A8 8 0 1 1 21 12z");
30
- svg.append(path);
31
- return svg;
32
- }
33
- function field(element) {
34
- return element.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName);
35
- }
36
- function deepActive() {
37
- let active = document.activeElement;
38
- while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
39
- return active;
40
- }
41
- const DISCONNECTED = Symbol.for("visual-feedback.disconnected");
42
- const owners = /* @__PURE__ */ new Map();
43
- const enders = /* @__PURE__ */ new WeakMap();
44
- let dialogs;
45
- let discarding = Promise.resolve();
46
- /**
47
- * Deletes the draft this tab keeps for a tag, as at a logout, so no later widget of the tag brings
48
- * it back; a widget holding it, open or folded, ends it.
49
- */
50
- async function discardDraft(tagName = "visual-feedback") {
51
- const key = `visual-feedback:${tagName}`;
52
- let id = null;
53
- try {
54
- id = sessionStorage.getItem(key);
55
- sessionStorage.removeItem(key);
56
- } catch {}
57
- const owner = owners.get(tagName);
58
- if (owner) enders.get(owner)?.();
59
- const mine = discarding.then(async () => {
60
- await (await dialogs?.catch(() => void 0))?.settled();
61
- if (id) await (await import("./keep-BG5WxZPU.mjs")).forget(id);
62
- });
63
- discarding = mine.catch(() => {});
64
- await mine;
65
- }
66
- const noop = {
67
- open: async () => {},
68
- close() {},
69
- destroy() {},
70
- update() {},
71
- on: () => () => {}
72
- };
73
- function createFeedback(options = {}) {
74
- if (!supported()) return noop;
75
- const tag = options.tagName ?? "visual-feedback";
76
- if (!customElements.get(tag)) customElements.define(tag, class extends HTMLElement {
77
- disconnectedCallback() {
78
- this[DISCONNECTED]?.();
79
- }
80
- });
81
- const host = document.createElement(tag);
82
- const root = host.attachShadow({ mode: "open" });
83
- const wrapper = document.createElement("div");
84
- wrapper.className = "vf";
85
- root.append(wrapper);
86
- let view;
87
- const handlers = /* @__PURE__ */ new Map();
88
- const call = (fns, args) => {
89
- for (const fn of [...fns]) try {
90
- fn(...args);
91
- } catch (error) {
92
- reportError(error);
93
- }
94
- };
95
- const emit = (event, ...args) => call(handlers.get(event) ?? [], args);
96
- const start = () => void widget.open().catch(() => {});
97
- let state = "idle";
98
- let dialog;
99
- let session = 0;
100
- let returnFocus = null;
101
- let pending;
102
- let trigger;
103
- let label;
104
- let unlisten;
105
- function listen(shortcut) {
106
- const parts = shortcut.toLowerCase().split("+");
107
- const key = parts.pop();
108
- const names = [
109
- "alt",
110
- "shift",
111
- "control",
112
- "meta"
113
- ];
114
- const mods = names.map((name) => parts.includes(name)).join();
115
- const typing = !/control|meta/.test(shortcut.toLowerCase());
116
- const on = (event) => {
117
- if (event instanceof KeyboardEvent && state === "idle" && !event.repeat && !event.isComposing && !event.getModifierState("AltGraph") && parts.every((part) => names.includes(part)) && [
118
- event.altKey,
119
- event.shiftKey,
120
- event.ctrlKey,
121
- event.metaKey
122
- ].join() === mods && [event.code.replace(/^(Key|Digit)/, ""), event.key].some((name) => name.toLowerCase() === key) && !(typing && field(event.composedPath()[0]))) {
123
- event.preventDefault();
124
- start();
125
- }
126
- };
127
- addEventListener("keydown", on);
128
- return () => removeEventListener("keydown", on);
129
- }
130
- let base = [];
131
- let extra = [];
132
- function sheets() {
133
- const dialogCss = view?.css(Boolean(options.unstyled)).map(sheet) ?? [];
134
- root.adoptedStyleSheets = [
135
- ...base,
136
- ...dialogCss,
137
- ...extra
138
- ];
139
- }
140
- function apply() {
141
- wrapper.style.colorScheme = options.theme?.replace("auto", "") ?? "";
142
- base = [sheet(variables() + LAYOUT), ...options.unstyled ? [] : [sheet(THEME)]];
143
- extra = options.css ? [sheet(options.css)] : [];
144
- sheets();
145
- if (options.trigger === "none") {
146
- trigger?.remove();
147
- trigger = void 0;
148
- } else {
149
- if (!trigger) {
150
- trigger = document.createElement("button");
151
- trigger.type = "button";
152
- trigger.className = "trigger";
153
- trigger.setAttribute("part", "trigger");
154
- label = document.createElement("span");
155
- label.setAttribute("part", "trigger-text");
156
- trigger.append(icon(), label);
157
- trigger.addEventListener("click", start);
158
- wrapper.prepend(trigger);
159
- }
160
- const [y, x] = (options.position ?? "bottom-right").split("-");
161
- trigger.dataset.y = y;
162
- trigger.dataset.x = x;
163
- const { trigger: name, triggerText } = options.strings ?? {};
164
- trigger.setAttribute("aria-label", name || triggerText || "Send feedback");
165
- trigger.toggleAttribute("data-icon", !triggerText);
166
- if (label) {
167
- label.textContent = triggerText ?? "";
168
- label.hidden = !triggerText;
169
- }
170
- }
171
- unlisten?.();
172
- unlisten = options.shortcut ? listen(options.shortcut) : void 0;
173
- }
174
- apply();
175
- (options.container ?? document.body ?? document.documentElement).append(host);
176
- const keeps = () => {
177
- if (options.draft === "memory") return false;
178
- if (!owners.get(tag)?.isConnected && host.isConnected) owners.set(tag, host);
179
- return owners.get(tag) === host;
180
- };
181
- restore();
182
- function restore() {
183
- try {
184
- if (!keeps() || !sessionStorage.getItem(`visual-feedback:${tag}`)) return false;
185
- } catch {
186
- return false;
187
- }
188
- state = "opening";
189
- pending = show(++session, true);
190
- pending.catch(() => {});
191
- return true;
192
- }
193
- function hide(on) {
194
- wrapper.classList.toggle("capturing", on);
195
- }
196
- function requestDisplay() {
197
- const media = navigator.mediaDevices;
198
- if (options.capture?.mode !== "display" || typeof media?.getDisplayMedia !== "function") return;
199
- hide(true);
200
- const stream = media.getDisplayMedia({ video: true });
201
- stream.catch(() => {});
202
- return stream;
203
- }
204
- function stop(stream) {
205
- stream?.then((media) => {
206
- for (const track of media.getTracks()) track.stop();
207
- }, () => {});
208
- }
209
- async function grab(module, mine, stream) {
210
- hide(true);
211
- const quiet = view?.announce(root, options.strings);
212
- try {
213
- return await module.capture(options.capture, stream);
214
- } catch {
215
- return null;
216
- } finally {
217
- quiet?.();
218
- if (mine === session) hide(false);
219
- }
220
- }
221
- function cancel() {
222
- session++;
223
- hide(false);
224
- }
225
- /** Returns whether the draft stays kept; when not, it has ended. */
226
- function teardown(keep) {
227
- const stays = dialog?.unmount(keep) ?? false;
228
- dialog = void 0;
229
- const target = returnFocus?.isConnected && returnFocus !== document.body ? returnFocus : trigger;
230
- returnFocus = null;
231
- target?.focus?.();
232
- return stays;
233
- }
234
- async function show(mine, kept) {
235
- returnFocus = deepActive();
236
- const stream = kept ? void 0 : requestDisplay();
237
- try {
238
- dialogs ??= import("./dialog-B57CJ4I_.mjs").catch((error) => {
239
- dialogs = void 0;
240
- throw error;
241
- });
242
- const [loaded, capture] = await Promise.all([dialogs, import("./capture-CufRosj3.mjs")]);
243
- if (mine !== session) return stop(stream);
244
- view = loaded;
245
- sheets();
246
- const image = kept ? null : await grab(capture, mine, stream);
247
- if (mine !== session) return;
248
- if (!host.isConnected) {
249
- state = "idle";
250
- return;
251
- }
252
- dialog = view.mount(wrapper, {
253
- close: () => widget.close(),
254
- retry: () => void retry(capture).catch((error) => {
255
- widget.close();
256
- emit("error", error);
257
- }),
258
- emit,
259
- options: () => options,
260
- keeps
261
- }, options);
262
- const mounted = dialog;
263
- if (!kept) mounted.capture(image);
264
- else {
265
- const back = await mounted.revive();
266
- if (mine !== session) {
267
- mounted.unmount(true);
268
- return;
269
- }
270
- if (!back) {
271
- state = "idle";
272
- mounted.unmount();
273
- return;
274
- }
275
- }
276
- } catch (error) {
277
- stop(stream);
278
- if (mine === session) {
279
- state = "idle";
280
- hide(false);
281
- teardown();
282
- }
283
- emit("error", error);
284
- throw error;
285
- }
286
- state = "open";
287
- emit("open");
288
- }
289
- async function retry(capture) {
290
- if (wrapper.classList.contains("capturing")) return;
291
- const mine = session;
292
- dialog?.lock(false);
293
- const image = await grab(capture, mine, requestDisplay());
294
- if (mine !== session) return;
295
- dialog?.capture(image);
296
- }
297
- const widget = {
298
- open() {
299
- if (state === "opening" && pending) {
300
- const mine = session;
301
- return pending.then(() => mine === session ? widget.open() : void 0);
302
- }
303
- if (state !== "idle") return dialog?.reopen(), Promise.resolve();
304
- if (restore()) return widget.open();
305
- state = "opening";
306
- pending = show(++session);
307
- return pending;
308
- },
309
- close() {
310
- end();
311
- },
312
- destroy() {
313
- if (state === "destroyed") return;
314
- const wasOpen = state === "open";
315
- state = "destroyed";
316
- cancel();
317
- unlisten?.();
318
- const closers = wasOpen && !teardown(true) ? [...handlers.get("close") ?? []] : [];
319
- handlers.clear();
320
- host.remove();
321
- call(closers, []);
322
- },
323
- update(next) {
324
- if (state === "destroyed") return;
325
- options = {
326
- ...options,
327
- ...next
328
- };
329
- apply();
330
- },
331
- on(event, fn) {
332
- if (state === "destroyed") return () => {};
333
- const set = handlers.get(event) ?? /* @__PURE__ */ new Set();
334
- handlers.set(event, set);
335
- const handler = fn;
336
- set.add(handler);
337
- return () => set.delete(handler);
338
- }
339
- };
340
- function end(keep) {
341
- if (state !== "open" && state !== "opening") return;
342
- const wasOpen = state === "open";
343
- state = "idle";
344
- cancel();
345
- const stays = teardown(keep);
346
- if (wasOpen && !stays) emit("close");
347
- }
348
- enders.set(host, () => end());
349
- host[DISCONNECTED] = () => queueMicrotask(() => {
350
- if (!host.isConnected) end(true);
351
- });
352
- return widget;
353
- }
354
- //#endregion
355
- export { discardDraft as n, createFeedback as t };