visual-feedback 0.3.3 → 0.4.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
@@ -36,7 +36,10 @@ comes before the first report in step 6.
36
36
  recipe adds. In a React or Next.js app, render `<Feedback endpoint="/api/feedback" />` from
37
37
  `visual-feedback/react` instead, once, in the root layout; its props are the same options, and
38
38
  a function prop such as `onSubmit` needs a `"use client"` file (`docs/API.md`). Every option is in `docs/API.md`; the look is in
39
- `docs/THEMING.md`.
39
+ `docs/THEMING.md`. Fit the button to the app's own design: read its layout and pick
40
+ `trigger` and `position`, and `--vf-offset` where the default spot would cover something the app shows, and the
41
+ `--vf-*` colours and corners where the widget does not take them from the page already. Tell the
42
+ developer where it sits and why.
40
43
  3. On the server: copy the recipe's route as it is written. Its test runs that code, with only the
41
44
  marked parts below filled in, so do not change how it stores a report. If the app is not a Next.js App Router app, keep the logic and
42
45
  change only how the route is mounted.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 (2026-10-14)
4
+
5
+ - The dialog fits the window, 16px from its edges, with nothing to scroll: on a large screen the
6
+ image grows to the height left, and the dialog is as wide as the image needs. On a short
7
+ screen the tool rail has two columns, and a phone in landscape gets the note beside the image
8
+ (SPEC §2).
9
+ - While a send is under way, Cancel, Keep as draft and Esc do nothing. A send that takes over 30
10
+ seconds fails with the new `timeout` text, and `error` fires with a `TimeoutError`; an
11
+ `endpoint` request is aborted.
12
+ - The sent confirmation shows for half a second, or until a click, in place of 3 seconds.
13
+ - Clear asks first (new `clearConfirm` text), and Undo brings the cleared marks back.
14
+ - Delete sits under Select in the tool rail. New parts: `confirm-clear` and `side`.
15
+
16
+ ## 0.4.0 (2026-10-13)
17
+
18
+ - Breaking: the trigger is a tab by default, in the corner `position` names, with a flag icon. It
19
+ has no border: the text colour fills it, and on hover the colours swap. `--vf-offset` lifts the
20
+ tab along its side edge, and no longer moves it off that edge. `trigger: "button"` keeps the
21
+ round button, 16px from both edges, filled the same way (SPEC §3).
22
+ - Breaking: the trigger has no `title`. Its tooltip is the widget's own: it shows at once on hover
23
+ or keyboard focus, beside the trigger with an arrow pointing at it, and has its own part,
24
+ `trigger-tooltip`. Screen readers still hear it as the description.
25
+ - The README starts with a prompt to give a coding agent, naming the package by its npm page, and AGENTS.md asks the agent to fit the
26
+ button to the app's design.
27
+ - The demo styles the tooltip as its "Click here" hint, and fades that hint while the tooltip
28
+ shows.
29
+
3
30
  ## 0.3.3 (2026-10-13)
4
31
 
5
32
  - The trigger stands out more: a swirl icon that fills it, in place of the speech bubble, ringed
package/README.md CHANGED
@@ -8,6 +8,14 @@ any design.
8
8
 
9
9
  ![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)
10
10
 
11
+ Ask your coding agent:
12
+
13
+ ```text
14
+ Install the visual-feedback npm package (https://www.npmjs.com/package/visual-feedback) and add it to this app by following its AGENTS.md. Place its button where it covers nothing, and match its position, colours and corners to our design.
15
+ ```
16
+
17
+ Or the old-school way, by hand:
18
+
11
19
  ```sh
12
20
  npm install visual-feedback
13
21
  ```
@@ -450,6 +450,7 @@ function annotate(canvas, onChange, textLabel, kept = []) {
450
450
  const step = history.pop();
451
451
  if (!step) return;
452
452
  if (step.kind === "add") marks.pop();
453
+ else if (step.kind === "clear") marks.push(...step.marks);
453
454
  else if (step.kind === "move") marks[step.index] = step.mark;
454
455
  else marks.splice(step.index, 0, step.mark);
455
456
  selected = null;
@@ -458,8 +459,10 @@ function annotate(canvas, onChange, textLabel, kept = []) {
458
459
  },
459
460
  clear() {
460
461
  finishDrag();
461
- marks.length = 0;
462
- history.length = 0;
462
+ if (marks.length) history.push({
463
+ kind: "clear",
464
+ marks: marks.splice(0)
465
+ });
463
466
  selected = null;
464
467
  paint();
465
468
  onChange();
@@ -504,8 +507,8 @@ function icon(d, part) {
504
507
  }
505
508
  //#endregion
506
509
  //#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 - 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
- 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}`;
510
+ const LAYOUT = `.backdrop{position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:max(16px,env(safe-area-inset-top,0px)) max(16px,env(safe-area-inset-right,0px)) max(16px,env(safe-area-inset-bottom,0px)) max(16px,env(safe-area-inset-left,0px));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%);display:grid;gap:12px}.dialog.wide{width:fit-content;min-width:min(560px,100%);max-width:100%}.side{display:contents}.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;width:auto;height:var(--_ih,auto);max-width:100%;max-height:var(--_ih,max(4em,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;white-space:nowrap}.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}@media (max-height:760px){.toolbar{display:grid;grid-template-columns:repeat(2,auto);align-content:start}.divider{grid-column:1/-1}}@media (max-height:700px){.dialog{position:relative}.dialog>.confirm{position:absolute;z-index:2;top:0;left:0;right:0}}@media (orientation:landscape) and (max-height:500px){.dialog.wide{width:100%;grid-template-columns:minmax(0,1fr) 12em;grid-template-rows:auto minmax(0,1fr) auto;row-gap:0}.wide>.header{grid-area:1/1/2/-1;margin-bottom:8px}.wide>.body{grid-area:2/1}.wide>.error{grid-area:3/1;margin-top:8px}.wide>.side{grid-area:2/2/4/3;display:flex;flex-direction:column;gap:12px;min-height:0}.wide>.side>.composer{flex:1;grid-template-rows:minmax(0,1fr)}.wide .note{height:100%;resize:none}.wide .footer{flex-wrap:wrap}.wide .hint{flex:1 1 100%}}`;
511
+ 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}@media (max-height:700px){.dialog>.confirm{min-height:2.15em;padding:14px;border-bottom:1px solid var(--_b);border-radius:calc(var(--_r) - 1px) calc(var(--_r) - 1px) 0 0;background:var(--_s);box-shadow:var(--_sh)}}`;
509
512
  const ICONS = {
510
513
  pen: "M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4zm9.5-13.5 4 4",
511
514
  box: "M6.5 5h11A2.5 2.5 0 0 1 20 7.5v9a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 16.5v-9A2.5 2.5 0 0 1 6.5 5z",
@@ -540,11 +543,13 @@ const ENGLISH = {
540
543
  remove: "Delete",
541
544
  undo: "Undo",
542
545
  clear: "Clear",
546
+ clearConfirm: "Clear all marks on this screenshot?",
543
547
  cancel: "Cancel",
544
548
  send: "Send",
545
549
  sending: "Sending…",
546
550
  sent: "Thanks, it's sent.",
547
551
  error: "It didn't send. Your note is kept.",
552
+ timeout: "It didn't send in 30 seconds. Check your internet connection, then try again.",
548
553
  retry: "Try again",
549
554
  escHint: "Esc keeps it as a draft",
550
555
  minimise: "Keep as draft",
@@ -695,7 +700,7 @@ function mount(parent, handlers, settings = {}) {
695
700
  const title = element("h2", "title", { id: "vf-title" });
696
701
  title.textContent = text.title;
697
702
  const header = element("div", "header");
698
- const cancel = iconButton("cancel", text.cancel, ICONS.cancel, () => lose() ? ask() : handlers.close());
703
+ const cancel = iconButton("cancel", text.cancel, ICONS.cancel, () => lose() ? askDiscard() : handlers.close());
699
704
  const keep = iconButton("minimise", text.minimise, ICONS.minimise, () => minimise());
700
705
  header.append(title, cancel);
701
706
  backdrop.append(panel);
@@ -729,26 +734,25 @@ function mount(parent, handlers, settings = {}) {
729
734
  let reviving = false;
730
735
  let picker;
731
736
  let asking;
737
+ const busy = () => !over && primary?.getAttribute("aria-disabled") === "true";
732
738
  function lose() {
733
- if (over || primary?.getAttribute("aria-disabled") === "true") return false;
739
+ if (over || busy()) return false;
734
740
  return Boolean(note?.value.trim() || picker?.selectedIndex || taken.length > 1 || taken.some((shot) => shot.annotator?.marks.length));
735
741
  }
736
- function ask() {
742
+ function ask(prompt, choices, from) {
737
743
  asking?.remove();
738
744
  const back = () => {
739
745
  asking?.remove();
740
- cancel.focus();
746
+ from.focus();
741
747
  };
742
748
  const question = element("span", "confirm-text");
743
- question.textContent = text.discardConfirm;
749
+ question.textContent = prompt;
744
750
  const stay = button("keep-editing", text.keepEditing, back);
745
751
  asking = element("div", "actions confirm", {
746
752
  role: "group",
747
- "aria-label": text.discardConfirm
753
+ "aria-label": prompt
748
754
  });
749
- asking.append(question, button("discard", text.discard, handlers.close));
750
- if (taken.length && !draft) asking.append(button("keep-draft", text.minimise, () => minimise()));
751
- asking.append(stay);
755
+ asking.append(question, ...choices, stay);
752
756
  asking.addEventListener("keydown", (event) => {
753
757
  if (event.key !== "Escape") return;
754
758
  event.preventDefault();
@@ -758,17 +762,25 @@ function mount(parent, handlers, settings = {}) {
758
762
  header.after(asking);
759
763
  stay.focus();
760
764
  }
765
+ function askDiscard() {
766
+ const discard = button("discard", text.discard, handlers.close);
767
+ const fold = taken.length && !draft ? [button("keep-draft", text.minimise, () => minimise())] : [];
768
+ ask(text.discardConfirm, [discard, ...fold], cancel);
769
+ }
761
770
  function toolbar() {
762
771
  if (!drawing) return [];
763
772
  const toolbar = element("div", "toolbar");
764
773
  const undo = iconButton("undo", text.undo, ICONS.undo, () => annotator?.undo());
765
- const clear = iconButton("clear", text.clear, ICONS.clear, () => annotator?.clear());
774
+ const clear = iconButton("clear", text.clear, ICONS.clear, () => {
775
+ const asked = annotator;
776
+ const yes = button("confirm-clear", text.clear, () => {
777
+ asking?.remove();
778
+ asked?.clear();
779
+ (undo.disabled ? buttons.find((b) => b.getAttribute("aria-pressed") === "true") : undo)?.focus();
780
+ });
781
+ ask(text.clearConfirm, [yes], clear);
782
+ });
766
783
  const remove = iconButton("remove", text.remove, ICONS.remove, () => annotator?.remove());
767
- const actions = choices.includes("select") ? [
768
- remove,
769
- undo,
770
- clear
771
- ] : [undo, clear];
772
784
  refresh = () => {
773
785
  const off = /* @__PURE__ */ new Map([
774
786
  [undo, !annotator?.canUndo],
@@ -789,8 +801,10 @@ function mount(parent, handlers, settings = {}) {
789
801
  };
790
802
  const buttons = choices.map((choice) => iconButton("tool", text[choice], ICONS[choice], () => pick(choice)));
791
803
  pick(choices.find((choice) => choice !== "select"));
792
- toolbar.append(...buttons, element("span", "divider", { "aria-hidden": "true" }), ...actions);
793
- locked.push(...buttons, ...actions);
804
+ const tools = buttons.flatMap((choice, i) => choices[i] === "select" ? [choice, remove] : [choice]);
805
+ const divider = element("span", "divider", { "aria-hidden": "true" });
806
+ toolbar.append(...tools, divider, undo, clear);
807
+ locked.push(...tools, undo, clear);
794
808
  return [toolbar];
795
809
  }
796
810
  let thumb;
@@ -832,6 +846,7 @@ function mount(parent, handlers, settings = {}) {
832
846
  stage?.prepend(shot.image);
833
847
  renderStrip();
834
848
  refresh();
849
+ measure();
835
850
  if (focused) strip?.querySelector("[aria-current=true]")?.focus();
836
851
  }
837
852
  function drop(index) {
@@ -983,6 +998,7 @@ function mount(parent, handlers, settings = {}) {
983
998
  lockPage(true);
984
999
  renderStrip();
985
1000
  reink();
1001
+ measure();
986
1002
  note?.focus();
987
1003
  }
988
1004
  function reink() {
@@ -996,7 +1012,7 @@ function mount(parent, handlers, settings = {}) {
996
1012
  const options = handlers.options();
997
1013
  let module;
998
1014
  try {
999
- module = await import("./report-CnspEsHf.mjs");
1015
+ module = await import("./report-BJADH9_W.mjs");
1000
1016
  if (!report) {
1001
1017
  const capture = await import("./capture-CufRosj3.mjs");
1002
1018
  const shots = [];
@@ -1016,7 +1032,7 @@ function mount(parent, handlers, settings = {}) {
1016
1032
  if (mounted) sent();
1017
1033
  } catch (error) {
1018
1034
  handlers.emit("error", error);
1019
- if (mounted) failed(module?.shown(error));
1035
+ if (mounted) failed(module?.late(error) ? text.timeout : module?.shown(error));
1020
1036
  }
1021
1037
  }
1022
1038
  let saving = false;
@@ -1132,6 +1148,7 @@ function mount(parent, handlers, settings = {}) {
1132
1148
  body = element("div", "body");
1133
1149
  if (image) {
1134
1150
  stage = element("div", "stage");
1151
+ fit.observe(stage);
1135
1152
  strip = element("div", "strip");
1136
1153
  stage.append(strip);
1137
1154
  body.append(...toolbar(), stage);
@@ -1223,7 +1240,9 @@ function mount(parent, handlers, settings = {}) {
1223
1240
  locked.push(more);
1224
1241
  footer.append(split);
1225
1242
  panel.classList.toggle("wide", Boolean(image));
1226
- panel.replaceChildren(header, ...image ? [body] : [], label, box, footer);
1243
+ const side = element("div", "side");
1244
+ side.append(label, box, footer);
1245
+ panel.replaceChildren(header, ...image ? [body] : [], side);
1227
1246
  if (image) add(image);
1228
1247
  ink(send);
1229
1248
  ink(more);
@@ -1236,6 +1255,7 @@ function mount(parent, handlers, settings = {}) {
1236
1255
  asking?.remove();
1237
1256
  for (const control of locked) control.disabled = true;
1238
1257
  for (const item of inert) item.inert = true;
1258
+ cancel.disabled = true;
1239
1259
  failure?.remove();
1240
1260
  primary.setAttribute("aria-disabled", "true");
1241
1261
  primary.textContent = text.sending;
@@ -1250,25 +1270,33 @@ function mount(parent, handlers, settings = {}) {
1250
1270
  status.textContent = text.sent;
1251
1271
  panel.classList.remove("wide");
1252
1272
  header.replaceChildren(title, cancel);
1273
+ cancel.disabled = false;
1253
1274
  panel.replaceChildren(header, status);
1254
1275
  status.focus();
1255
- done = setTimeout(handlers.close, 3e3);
1276
+ done = setTimeout(handlers.close, 500);
1277
+ const shown = performance.now();
1278
+ backdrop.addEventListener("click", (event) => {
1279
+ if (event.timeStamp >= shown) handlers.close();
1280
+ });
1256
1281
  }
1257
1282
  function failed(message = text.error) {
1258
1283
  if (!primary || !composer) return;
1259
1284
  failure = element("p", "error", { role: "alert" });
1260
1285
  failure.textContent = message;
1261
1286
  composer.after(failure);
1287
+ cancel.disabled = false;
1262
1288
  primary.removeAttribute("aria-disabled");
1263
1289
  primary.textContent = text.retry;
1264
1290
  primary.setAttribute("part", "retry");
1265
1291
  primary.focus();
1266
1292
  }
1267
1293
  const onEscape = () => {
1268
- if (taken.length && !draft) minimise();
1294
+ if (busy()) {
1295
+ if (!backdrop.open) backdrop.showModal();
1296
+ } else if (taken.length && !draft) minimise();
1269
1297
  else if (lose()) {
1270
1298
  if (!backdrop.open) backdrop.showModal();
1271
- ask();
1299
+ askDiscard();
1272
1300
  } else handlers.close();
1273
1301
  };
1274
1302
  backdrop.addEventListener("cancel", (event) => {
@@ -1294,6 +1322,29 @@ function mount(parent, handlers, settings = {}) {
1294
1322
  parent.classList.add("drafting");
1295
1323
  if (keeping()) import("./keep-BG5WxZPU.mjs").then(({ sweep }) => sweep()).catch(() => {});
1296
1324
  let done;
1325
+ let fitting = 0;
1326
+ const fit = new ResizeObserver(() => {
1327
+ cancelAnimationFrame(fitting);
1328
+ fitting = requestAnimationFrame(measure);
1329
+ });
1330
+ function measure() {
1331
+ const image = stage?.querySelector(":scope > .image");
1332
+ const shown = image?.getBoundingClientRect();
1333
+ const outer = panel.getBoundingClientRect();
1334
+ if (!image || !shown || !outer.width || !stage || !panel.classList.contains("wide")) return;
1335
+ const rail = panel.querySelector(".toolbar")?.getBoundingClientRect().height ?? 0;
1336
+ const column = stage.getBoundingClientRect();
1337
+ const below = Math.max(0, rail - column.height);
1338
+ const room = getComputedStyle(backdrop);
1339
+ const across = backdrop.clientWidth - Number.parseFloat(room.paddingLeft) - Number.parseFloat(room.paddingRight);
1340
+ const high = backdrop.clientHeight - Number.parseFloat(room.paddingTop) - Number.parseFloat(room.paddingBottom) - (outer.height - shown.height - below);
1341
+ const wide = across - (outer.width - column.width);
1342
+ const floor = 4 * Number.parseFloat(getComputedStyle(image).fontSize);
1343
+ const height = Math.min(wide * image.height / image.width, Math.max(floor, high));
1344
+ panel.style.setProperty("--_ih", `${Math.floor(height)}px`);
1345
+ }
1346
+ fit.observe(panel);
1347
+ fit.observe(backdrop);
1297
1348
  let scrollLocked = false;
1298
1349
  function lockPage(on) {
1299
1350
  if (on !== scrollLocked) (on ? lockScroll : unlockScroll)();
@@ -1366,6 +1417,8 @@ function mount(parent, handlers, settings = {}) {
1366
1417
  const stays = reviving || !!keep && !submitted && store();
1367
1418
  mounted = false;
1368
1419
  clearTimeout(done);
1420
+ fit.disconnect();
1421
+ cancelAnimationFrame(fitting);
1369
1422
  lockPage(false);
1370
1423
  backdrop.removeEventListener("close", closed);
1371
1424
  if (backdrop.open) backdrop.close();
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
1
 
2
- import { a as FeedbackWidget, c as CaptureOptions, i as FeedbackStrings, n as FeedbackEvents, o as createFeedback, r as FeedbackOptions, s as discardDraft, t as FeedbackEvent } from "./widget-Z1KOUKjx.mjs";
2
+ import { a as FeedbackWidget, c as CaptureOptions, i as FeedbackStrings, n as FeedbackEvents, o as createFeedback, r as FeedbackOptions, s as discardDraft, t as FeedbackEvent } from "./widget-CMjTbhGw.mjs";
3
3
  import { n as Mark, r as Screenshot, t as FeedbackReport } from "./report-CaJegYyg.mjs";
4
4
  export { type CaptureOptions, type FeedbackEvent, type FeedbackEvents, type FeedbackOptions, type FeedbackReport, type FeedbackStrings, type FeedbackWidget, type Mark, type Screenshot, createFeedback, discardDraft };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
1
 
2
- export { t as createFeedback, n as discardDraft } from "./widget-IwlLFJCu.mjs";
2
+ export { t as createFeedback, n as discardDraft } from "./widget-JBN1fbyx.mjs";
package/dist/react.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- import { r as FeedbackOptions } from "./widget-Z1KOUKjx.mjs";
2
+ import { r as FeedbackOptions } from "./widget-CMjTbhGw.mjs";
3
3
  //#region src/react/index.d.ts
4
4
  export type FeedbackProps = FeedbackOptions;
5
5
  export declare function Feedback(props: FeedbackProps): null;
package/dist/react.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { t as createFeedback } from "./widget-IwlLFJCu.mjs";
2
+ import { t as createFeedback } from "./widget-JBN1fbyx.mjs";
3
3
  import { useEffect, useRef } from "react";
4
4
  //#region src/react/index.ts
5
5
  function Feedback(props) {
@@ -66,8 +66,29 @@ async function build(draft, shots, seen, settings) {
66
66
  screenshots
67
67
  };
68
68
  }
69
+ /** How long a send may take before it fails as a TimeoutError. */
70
+ const SEND_TIMEOUT = 3e4;
71
+ const limits = /* @__PURE__ */ new WeakSet();
72
+ /** Whether the send failed on the widget's own 30 second limit. */
73
+ function late(error) {
74
+ return typeof error === "object" && error !== null && limits.has(error);
75
+ }
69
76
  /** Sends with onSubmit, or else POSTs what parseFeedbackRequest reads to the endpoint. */
70
77
  async function deliver(report, settings) {
78
+ const stop = new AbortController();
79
+ const timer = setTimeout(() => {
80
+ const error = new DOMException("The send took over 30 seconds.", "TimeoutError");
81
+ limits.add(error);
82
+ stop.abort(error);
83
+ }, SEND_TIMEOUT);
84
+ const late = new Promise((_, reject) => stop.signal.addEventListener("abort", () => reject(stop.signal.reason), { once: true }));
85
+ try {
86
+ return await Promise.race([send(report, settings, stop.signal), late]);
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+ async function send(report, settings, signal) {
71
92
  const { onSubmit, endpoint } = settings;
72
93
  if (onSubmit) return host(() => onSubmit(report));
73
94
  if (!endpoint) throw new Error("Neither onSubmit nor endpoint is set.");
@@ -87,9 +108,10 @@ async function deliver(report, settings) {
87
108
  method: "POST",
88
109
  body,
89
110
  headers,
90
- redirect: "manual"
111
+ redirect: "manual",
112
+ signal
91
113
  });
92
114
  if (!response.ok) throw new Error(`The endpoint answered ${response.status}.`);
93
115
  }
94
116
  //#endregion
95
- export { build, deliver, shown };
117
+ export { build, deliver, late, shown };
@@ -23,7 +23,7 @@ type FeedbackEvents = {
23
23
  };
24
24
  type FeedbackEvent = keyof FeedbackEvents;
25
25
  /** The texts the widget shows, by SPEC §12 key. */
26
- type FeedbackStrings = Record<"trigger" | "title" | "capturing" | "captureError" | "sendWithout" | "noteLabel" | "notePlaceholder" | "noteRequired" | "categoryLabel" | "pen" | "box" | "arrow" | "text" | "select" | "remove" | "undo" | "clear" | "cancel" | "send" | "sending" | "sent" | "error" | "retry" | "triggerText" | "escHint" | "minimise" | "screenshot" | "addScreenshot" | "removeScreenshot" | "draft" | "captureView" | "reopen" | "discard" | "discardConfirm" | "keepEditing" | "moreSend" | "savePdf" | "pdfError", string>;
26
+ type FeedbackStrings = Record<"trigger" | "title" | "capturing" | "captureError" | "sendWithout" | "noteLabel" | "notePlaceholder" | "noteRequired" | "categoryLabel" | "pen" | "box" | "arrow" | "text" | "select" | "remove" | "undo" | "clear" | "clearConfirm" | "cancel" | "send" | "sending" | "sent" | "error" | "timeout" | "retry" | "triggerText" | "escHint" | "minimise" | "screenshot" | "addScreenshot" | "removeScreenshot" | "draft" | "captureView" | "reopen" | "discard" | "discardConfirm" | "keepEditing" | "moreSend" | "savePdf" | "pdfError", string>;
27
27
  type FeedbackOptions = {
28
28
  onSubmit?: (report: FeedbackReport) => Promise<void>;
29
29
  endpoint?: string;
@@ -37,7 +37,7 @@ type FeedbackOptions = {
37
37
  requireNote?: boolean;
38
38
  redactUrl?: boolean;
39
39
  context?: () => Record<string, unknown> | Promise<Record<string, unknown>>;
40
- trigger?: "button" | "none";
40
+ trigger?: "tab" | "button" | "none";
41
41
  position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
42
42
  shortcut?: string | false;
43
43
  strings?: Partial<FeedbackStrings>;
@@ -162,23 +162,24 @@ function sheet(text) {
162
162
  return result;
163
163
  }
164
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)}`;
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);--_toff:var(--vf-offset,0px);--_z:var(--vf-z,2147483000)}`;
166
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}.trigger[data-icon] svg{width:100%;height:100%}[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(--_t);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() {
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}.trigger[data-icon]>svg{width:62.5%;height:62.5%}[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:where([data-tab][data-y=bottom]){bottom:calc(var(--_toff) + env(safe-area-inset-bottom,0px))}.trigger:where([data-tab][data-y=top]){top:calc(var(--_toff) + env(safe-area-inset-top,0px))}.trigger:where([data-tab][data-x=right]){right:env(safe-area-inset-right,0px)}.trigger:where([data-tab][data-x=left]){left:env(safe-area-inset-left,0px)}.trigger>svg{width:1.15em;height:1.15em}.tip{position:absolute;top:50%;translate:0 -50%;direction:ltr;box-sizing:border-box;width:max-content;max-width:calc(100vw - 100% - 2 * var(--_off) - 10px);white-space:normal;display:flex;align-items:center;gap:4px;visibility:hidden;opacity:0;pointer-events:none;transition:opacity .15s,visibility .15s}[data-x=right]>.tip{right:calc(100% + 10px)}[data-x=left]>.tip{left:calc(100% + 10px);flex-direction:row-reverse}[data-x=left]>.tip svg{rotate:180deg}.tip svg{width:1.15em;height:1.15em;flex:none}.trigger:focus-visible>.tip{visibility:inherit;opacity:1}@media (hover:hover){.trigger:hover>.tip{visibility:inherit;opacity:1}}.capturing,.capturing .backdrop{visibility:hidden}.capturing .tip{transition:none}.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:0;border-radius:999px;background:var(--_t);color:var(--_s);box-shadow:var(--_sh);cursor:pointer;transition:background-color .15s,color .15s}@media (hover:hover){.trigger:hover{background:var(--_s);color:var(--_t)}}.tip{padding:6px 8px 6px 14px;border-radius:999px;background:var(--_t);color:var(--_s);font-weight:700;box-shadow:var(--_sh)}[data-x=left]>.tip{padding:6px 14px 6px 8px}.trigger:where([data-tab]){--_tr:clamp(0px,var(--_toff) * 1000,var(--_r));border-radius:var(--_r)}.trigger:where([data-tab][data-y=bottom]){border-bottom-left-radius:var(--_tr);border-bottom-right-radius:var(--_tr)}.trigger:where([data-tab][data-y=top]){border-top-left-radius:var(--_tr);border-top-right-radius:var(--_tr)}.trigger:where([data-tab][data-x=right]){border-top-right-radius:0;border-bottom-right-radius:0}.trigger:where([data-tab][data-x=left]){border-top-left-radius:0;border-bottom-left-radius:0}`;
169
+ function icon(d, attributes) {
170
170
  const svg = document.createElementNS(SVG, "svg");
171
171
  const path = document.createElementNS(SVG, "path");
172
172
  for (const [name, value] of Object.entries({
173
173
  viewBox: "0 0 24 24",
174
174
  "aria-hidden": "true",
175
- fill: "currentColor",
176
- part: "trigger-icon"
175
+ ...attributes
177
176
  })) svg.setAttribute(name, value);
178
- path.setAttribute("d", "M12 12Q17.2 7.9 24 12L22.6 17.6Q16.2 11.6 12 12zM12 12Q18.1 14.5 18 22.4L12.4 24Q14.5 15.4 12 12zM12 12Q12.9 18.5 6 22.4L1.8 18.4Q10.3 15.8 12 12zM12 12Q6.8 16.1 0 12L1.4 6.4Q7.8 12.4 12 12zM12 12Q5.9 9.5 6 1.6L11.6 0Q9.5 8.6 12 12zM12 12Q11.1 5.5 18 1.6L22.2 5.6Q13.7 8.2 12 12z");
177
+ path.setAttribute("d", d);
179
178
  svg.append(path);
180
179
  return svg;
181
180
  }
181
+ const FLAG = "M4 3a1 1 0 0 1 2 0v18a1 1 0 0 1-2 0zM7 4c2.5-1.5 5-1.5 7 0s4 1.5 6 0v10c-2 1.5-4 1.5-6 0s-4.5-1.5-7 0z";
182
+ const ARROW = "M5 12h14m-6-6 6 6-6 6";
182
183
  function field(element) {
183
184
  return element.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName);
184
185
  }
@@ -289,6 +290,9 @@ function createFeedback(options = {}) {
289
290
  let pending;
290
291
  let trigger;
291
292
  let label;
293
+ const tip = document.createElement("span");
294
+ tip.dir = "auto";
295
+ tip.setAttribute("part", "trigger-tooltip-text");
292
296
  let unlisten;
293
297
  function listen(shortcut) {
294
298
  const parsed = parse(shortcut);
@@ -340,18 +344,35 @@ function createFeedback(options = {}) {
340
344
  trigger.setAttribute("part", "trigger");
341
345
  label = document.createElement("span");
342
346
  label.setAttribute("part", "trigger-text");
343
- trigger.append(icon(), label);
347
+ const tooltip = document.createElement("span");
348
+ tooltip.className = "tip";
349
+ tooltip.id = "vf-tip";
350
+ tooltip.setAttribute("part", "trigger-tooltip");
351
+ tooltip.setAttribute("aria-hidden", "true");
352
+ tooltip.append(tip, icon(ARROW, {
353
+ fill: "none",
354
+ stroke: "currentColor",
355
+ "stroke-width": "2.4",
356
+ "stroke-linecap": "round",
357
+ part: "icon"
358
+ }));
359
+ trigger.setAttribute("aria-describedby", tooltip.id);
360
+ trigger.append(icon(FLAG, {
361
+ fill: "currentColor",
362
+ part: "trigger-icon"
363
+ }), label, tooltip);
344
364
  trigger.addEventListener("click", start);
345
365
  wrapper.prepend(trigger);
346
366
  }
347
367
  const [y, x] = (options.position ?? "bottom-right").split("-");
348
368
  trigger.dataset.y = y;
349
369
  trigger.dataset.x = x;
370
+ trigger.toggleAttribute("data-tab", options.trigger !== "button");
350
371
  const { trigger: name, triggerText } = options.strings ?? {};
351
372
  const accessible = name || triggerText || "Send feedback";
352
373
  trigger.setAttribute("aria-label", accessible);
353
374
  const keys = options.shortcut ? spoken(options.shortcut) : void 0;
354
- trigger.title = keys ? `${accessible} (${keys})` : accessible;
375
+ tip.textContent = keys ? `${accessible} (${keys})` : accessible;
355
376
  trigger.toggleAttribute("data-icon", !triggerText);
356
377
  if (label) {
357
378
  label.textContent = triggerText ?? "";
@@ -469,7 +490,7 @@ function createFeedback(options = {}) {
469
490
  returnFocus = deepActive();
470
491
  const stream = kept ? void 0 : requestDisplay();
471
492
  try {
472
- dialogs ??= import("./dialog-W6m1knfu.mjs").catch((error) => {
493
+ dialogs ??= import("./dialog-BL7k2Jir.mjs").catch((error) => {
473
494
  dialogs = void 0;
474
495
  throw error;
475
496
  });
package/docs/API.md CHANGED
@@ -34,10 +34,10 @@ const feedback = createFeedback({
34
34
  ### Options
35
35
  | option | type | default | meaning |
36
36
  |---|---|---|---|
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. |
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. |
37
+ | `onSubmit` | `(report) => Promise<void>` | – | Sends the report. A throw shows the send error state, using the error's message. A promise still pending after 30 seconds shows it with the `timeout` text, and `error` fires with a `TimeoutError`; the widget stops waiting, but cannot stop the host's work. Give this or `endpoint`; with both, `onSubmit` is used. |
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. A request still under way after 30 seconds is aborted and shows the `timeout` 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
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. |
40
+ | `trigger` | `"tab" \| "button" \| "none"` | `"tab"` | `"tab"` touches the side edge, `"button"` is round, in the corner (SPEC §3). `"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
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. |
package/docs/SPEC.md CHANGED
@@ -8,15 +8,15 @@ package sends nothing by itself, and stores nothing but an unsent draft on the u
8
8
 
9
9
  ## 2. Flow
10
10
  ```
11
- idle --open--> capturing --image ready--> annotating --Send--> sending --ok--> sent --3 s--> idle
11
+ idle --open--> capturing --image ready--> annotating --Send--> sending --ok--> sent --0.5 s or a click--> idle
12
12
  | | ^ |
13
13
  +--fails--> capture error | | +--fails--> send error
14
14
  | +--Open, or Capture this view (capturing first)
15
15
  +--Esc / Keep as draft / Add screenshot--> draft
16
16
  annotating, draft --Cancel (it may ask first, below) / Discard--> idle
17
17
  ```
18
- - **idle:** the trigger button shows (`trigger: "button"`), or nothing shows (`trigger: "none"`,
19
- where the host calls `open()`).
18
+ - **idle:** the trigger shows, a tab (`trigger: "tab"`) or a round button (`trigger: "button"`), or
19
+ nothing shows (`trigger: "none"`, where the host calls `open()`).
20
20
  - **capturing:** the trigger and any open panel are hidden, so they are not in the image. The
21
21
  screen is captured (SPEC §5), and a screen reader is told the `capturing` text.
22
22
  - **capture error:** the `captureError` text, with "Try again" (back to capturing), "Send without
@@ -24,6 +24,12 @@ annotating, draft --Cancel (it may ask first, below) / Discard--> idle
24
24
  (idle). This is the first screenshot only; a later one that fails is the draft's (below).
25
25
  - **annotating:** a full-viewport overlay shows the image with a tool rail beside it, a strip of
26
26
  the report's screenshots under it, and the note field, with Send in a footer row under that.
27
+ The dialog fits the window, 16 px from its edges (or the safe area's), with nothing to scroll:
28
+ the image takes the height the rest leaves, growing past the screenshot's own size on a large
29
+ screen, and the dialog is as wide as the image needs. Below
30
+ 760 px of height the tool rail has two columns, and below 700 px a question lies over the
31
+ header in place of adding a row; on a landscape screen under 500 px high, the note and Send go
32
+ beside the image.
27
33
  Esc and the header's Keep as draft fold it into the draft; the header's close button (Cancel)
28
34
  discards it. When that would throw away a note, a mark, a second screenshot or a chosen category,
29
35
  Cancel first asks `discardConfirm`, with "Discard", `minimise` (before Send, with a screenshot) and
@@ -41,26 +47,35 @@ annotating, draft --Cancel (it may ask first, below) / Discard--> idle
41
47
  it.
42
48
  - **sending:** the note, the category and the tools are disabled, and the Send button shows
43
49
  progress. The Esc hint goes, as it is absent with no screenshot: from Send on, Esc keeps no
44
- draft, and Cancel and Esc close the dialog.
45
- - **sent:** a short confirmation, then idle.
46
- - **send error:** the message comes from the host's error, or else the `error` text. The note and
50
+ draft. Until the send ends, Cancel, Keep as draft and Esc do nothing. A send that has not ended
51
+ after 30 seconds fails, with the `timeout` text.
52
+ - **sent:** the `sent` confirmation for half a second, or until a click anywhere on the overlay,
53
+ then idle.
54
+ - **send error:** the message is the `timeout` text after 30 seconds, else the host's error, or
55
+ else the `error` text. The note and
47
56
  the marks are kept, and "Try again" sends the same report. Cancel and Esc, after
48
57
  the same question when it would throw work away, close the dialog, and the draft goes with it.
49
58
 
50
59
  ## 3. The trigger
51
- - It is a small round button, 2.5em across by default (40 px on a 16 px site), in a corner
52
- (`position`), filled by a swirl icon edged by a ring in the same colour, with the `trigger`
53
- accessible name. A host that gives `triggerText` gets that short label beside a small icon, as a pill; with
54
- only `triggerText` given, that text is the name too. A host that sets both keeps `triggerText`
55
- inside `trigger`, so speech control can say what it sees. It has no badge, and it does not move
56
- or pulse.
57
- - Its tooltip (`title`) is its name, as for the dialog's icon buttons. With a `shortcut` that can
58
- match, it is the name and the keys, each modifier once, joined by ` + `, as the platform's
59
- keyboards name them: Option, Command, Control and Shift on a Mac, iPhone or iPad
60
- (`navigator.platform`), as in `Send feedback (Option + Shift + F)`; Alt, Win, Ctrl and Shift on
61
- Windows; Alt, Super, Ctrl and Shift elsewhere. A screen reader then reads the keys as the
62
- description.
63
- - It sits 16 px from the edges by default, and it respects `env(safe-area-inset-*)`.
60
+ - By default it is a small tab, 2.5em square (40 px on a 16 px site), in the corner `position`
61
+ names, square on both edges there, with a flag icon and the `trigger` accessible name.
62
+ `--vf-offset` lifts it along its side edge, and then its end side is rounded too.
63
+ `trigger: "button"` makes it a round button in that corner instead, 16 px from both edges.
64
+ Either has no border: the text colour fills it, and the icon and label take the background
65
+ colour. On hover, on a device that can hover, the two swap back.
66
+ A host that gives `triggerText` gets that short label beside a small icon, in a wider tab, or a
67
+ pill as a button; with only `triggerText` given, that text is the name too. A host that sets
68
+ both keeps `triggerText` inside `trigger`, so speech control can say what it sees. It has no
69
+ badge, and it does not move or pulse.
70
+ - Its tooltip is its own, not the browser's `title`: a pill beside it, on the page's side, with an
71
+ arrow pointing at it. It fades in at once on keyboard focus, or on hover where the device can
72
+ hover, and out at the same speed.
73
+ It reads the name. With a `shortcut` that can match, it is the name and the keys, each modifier
74
+ once, joined by ` + `, as the platform's keyboards name them: Option, Command, Control and Shift
75
+ on a Mac, iPhone or iPad (`navigator.platform`), as in `Send feedback (Option + Shift + F)`;
76
+ Alt, Win, Ctrl and Shift on Windows; Alt, Super, Ctrl and Shift elsewhere. A screen reader reads
77
+ the tooltip as the description.
78
+ - It respects `env(safe-area-inset-*)`.
64
79
  - It is hidden while there is a draft, whether the dialog is open or kept in the draft bar.
65
80
  - It does not move to keep clear of the page's content. The host moves it off its own content
66
81
  with `position` or `--vf-offset`.
@@ -71,8 +86,9 @@ annotating, draft --Cancel (it may ask first, below) / Discard--> idle
71
86
  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
72
87
  its line or a label by its text, and shows a dashed outline that never reaches the sent image: a
73
88
  drag moves it, kept whole inside the image; the arrow keys move it (Shift for ten); Delete,
74
- Backspace or the Delete button removes it; Escape lets go of it. Undo takes back the last add,
75
- move or removal, one at a time, and Clear removes all.
89
+ Backspace or the Delete button, under Select in the tool rail, removes it; Escape lets go of
90
+ it. Clear asks `clearConfirm` first, with Clear and `keepEditing`, then removes every mark on the
91
+ shown screenshot. Undo takes back the last add, move, removal or Clear, one at a time.
76
92
  - **One colour** for the marks, `--vf-mark`, which defaults to a red that contrasts with most pages.
77
93
  - Pointer events, so mouse, pen and touch all work. While drawing, touch never scrolls the page.
78
94
  - **Note:** a textarea of 2000 characters at most, with a visible label (`noteLabel`). It is
@@ -147,7 +163,7 @@ Defined in `docs/API.md` (`FeedbackReport`). It holds:
147
163
  mark (§4), or on Cancel's question (§2),
148
164
  keeps an unsent draft that has a screenshot in the draft bar, and moves the focus to it ("Capture
149
165
  this view", or "Open" at 10 screenshots). With no screenshot, or after a send error, it cancels,
150
- asking first as Cancel does; while sending, and once sent, it closes at once.
166
+ asking first as Cancel does; while sending it does nothing, and once sent it closes at once.
151
167
  While the dialog is open, the page does not scroll. On close, focus goes back to the trigger,
152
168
  or to where it was before.
153
169
  - Every control has an accessible name and a visible focus ring (`--vf-focus`), `unstyled`
@@ -209,10 +225,12 @@ These are the default texts. Each one can be replaced through `strings` (`docs/A
209
225
  | pen / box / arrow / text / select | Draw / Box / Arrow / Text / Select |
210
226
  | remove | Delete |
211
227
  | undo / clear | Undo / Clear |
228
+ | clearConfirm | Clear all marks on this screenshot? |
212
229
  | cancel / send | Cancel / Send |
213
230
  | sending | Sending… |
214
231
  | sent | Thanks, it's sent. |
215
232
  | error | It didn't send. Your note is kept. |
233
+ | timeout | It didn't send in 30 seconds. Check your internet connection, then try again. |
216
234
  | retry | Try again |
217
235
  | triggerText | none: the icon alone |
218
236
  | escHint | Esc keeps it as a draft |
package/docs/THEMING.md CHANGED
@@ -39,8 +39,8 @@ Set these on `visual-feedback` (or on any ancestor). Custom properties cross the
39
39
  |---|---|---|
40
40
  | `--vf-font` | `inherit` | font family of all text, such as `Inter, sans-serif` |
41
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, and the trigger's icon and border |
42
+ | `--vf-surface` | with `theme: "auto"` the page's background, else `Canvas` | panel, tool rail and composer background, the trigger's icon and label, its fill on hover, and the text of its tooltip |
43
+ | `--vf-text` | with `theme: "auto"` the page's text colour, else `CanvasText` | text, the trigger's fill, its icon and label on hover, and its tooltip's background |
44
44
  | `--vf-muted` | text at 60%, more on a page whose text would make that too faint | hints, counters |
45
45
  | `--vf-border` | text at 15% | hairlines |
46
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 |
@@ -48,9 +48,9 @@ Set these on `visual-feedback` (or on any ancestor). Custom properties cross the
48
48
  | `--vf-focus` | `--vf-accent` | focus ring |
49
49
  | `--vf-mark` | `#e5243b` | marks on the screenshot |
50
50
  | `--vf-backdrop` | `rgb(0 0 0 / 45%)` | overlay behind the image |
51
- | `--vf-shadow` | `0 12px 32px -12px rgb(0 0 0 / 35%)` | trigger, panel and draft bar |
51
+ | `--vf-shadow` | `0 12px 32px -12px rgb(0 0 0 / 35%)` | trigger, its tooltip, panel and draft bar |
52
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 |
53
+ | `--vf-offset` | `16px`, and `0px` for the tab; a length, not a percentage | draft bar and round trigger distance from the edges; the tab keeps to its side edge, and this lifts it along it |
54
54
  | `--vf-z` | `2147483000` | stacking |
55
55
 
56
56
  ```css
@@ -62,14 +62,14 @@ visual-feedback {
62
62
  ```
63
63
 
64
64
  ## 3. `::part()`
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`,
65
+ Every visible element has a part name: `trigger`, `trigger-icon`, `trigger-text`, `trigger-tooltip`,
66
+ `trigger-tooltip-text`, `backdrop`, `dialog`, `header`, `title`, `minimise`, `body`, `stage`, `image`, `toolbar`, `tool`, `tool-active`,
67
67
  `divider`, `undo`, `clear`, `remove`, `icon`, `text-input`, `selection`, `strip`, `thumb`,
68
68
  `thumb-pick`, `thumb-image`, `thumb-number`, `thumb-remove`, `add`, `label`, `composer`, `note`,
69
69
  `composer-bar`, `category`, `footer`, `split`, `send-more`, `menu`, `save-pdf`, `actions`, `cancel`,
70
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
71
+ `capture-view`, `reopen`, `discard`, `confirm`, `confirm-text`, `confirm-clear`, `keep-draft`, `keep-editing`, `side`. Cancel's
72
+ question is `confirm`, also an `actions` part, with its own `discard` button; Clear's is `confirm` too, with `confirm-clear`. `side` holds the label, the composer and the footer; it lays nothing out except beside the image on a short landscape screen. After a send error, the Send button's part is `retry` until it
73
73
  sends again, the same part as the capture error's "Try again".
74
74
 
75
75
  ```css
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "visual-feedback",
3
- "version": "0.3.3",
3
+ "version": "0.4.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",