visual-feedback 0.4.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
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
+
3
16
  ## 0.4.0 (2026-10-13)
4
17
 
5
18
  - Breaking: the trigger is a tab by default, in the corner `position` names, with a flag icon. It
@@ -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-Cxcm3h1Z.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-BoohIxqp.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-Cxcm3h1Z.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-BoohIxqp.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;
@@ -490,7 +490,7 @@ function createFeedback(options = {}) {
490
490
  returnFocus = deepActive();
491
491
  const stream = kept ? void 0 : requestDisplay();
492
492
  try {
493
- dialogs ??= import("./dialog-W6m1knfu.mjs").catch((error) => {
493
+ dialogs ??= import("./dialog-BL7k2Jir.mjs").catch((error) => {
494
494
  dialogs = void 0;
495
495
  throw error;
496
496
  });
package/docs/API.md CHANGED
@@ -34,8 +34,8 @@ 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
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. |
package/docs/SPEC.md CHANGED
@@ -8,7 +8,7 @@ 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)
@@ -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,9 +47,12 @@ 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
 
@@ -77,8 +86,9 @@ annotating, draft --Cancel (it may ask first, below) / Discard--> idle
77
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
78
87
  its line or a label by its text, and shows a dashed outline that never reaches the sent image: a
79
88
  drag moves it, kept whole inside the image; the arrow keys move it (Shift for ten); Delete,
80
- Backspace or the Delete button removes it; Escape lets go of it. Undo takes back the last add,
81
- 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.
82
92
  - **One colour** for the marks, `--vf-mark`, which defaults to a red that contrasts with most pages.
83
93
  - Pointer events, so mouse, pen and touch all work. While drawing, touch never scrolls the page.
84
94
  - **Note:** a textarea of 2000 characters at most, with a visible label (`noteLabel`). It is
@@ -153,7 +163,7 @@ Defined in `docs/API.md` (`FeedbackReport`). It holds:
153
163
  mark (§4), or on Cancel's question (§2),
154
164
  keeps an unsent draft that has a screenshot in the draft bar, and moves the focus to it ("Capture
155
165
  this view", or "Open" at 10 screenshots). With no screenshot, or after a send error, it cancels,
156
- 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.
157
167
  While the dialog is open, the page does not scroll. On close, focus goes back to the trigger,
158
168
  or to where it was before.
159
169
  - Every control has an accessible name and a visible focus ring (`--vf-focus`), `unstyled`
@@ -215,10 +225,12 @@ These are the default texts. Each one can be replaced through `strings` (`docs/A
215
225
  | pen / box / arrow / text / select | Draw / Box / Arrow / Text / Select |
216
226
  | remove | Delete |
217
227
  | undo / clear | Undo / Clear |
228
+ | clearConfirm | Clear all marks on this screenshot? |
218
229
  | cancel / send | Cancel / Send |
219
230
  | sending | Sending… |
220
231
  | sent | Thanks, it's sent. |
221
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. |
222
234
  | retry | Try again |
223
235
  | triggerText | none: the icon alone |
224
236
  | escHint | Esc keeps it as a draft |
package/docs/THEMING.md CHANGED
@@ -68,8 +68,8 @@ Every visible element has a part name: `trigger`, `trigger-icon`, `trigger-text`
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.4.0",
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",