@goflowstate/widget-sdk 0.3.2 → 0.3.5

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/README.md CHANGED
@@ -160,8 +160,12 @@ before the first state arrives.
160
160
 
161
161
  A flow that calls `CanvasWidget.initSession()` carries the feedback icon with no code of its own.
162
162
  While the ring has feedback switched on, the session state says so and the SDK puts a round
163
- button in the corner of the page. A tap takes a screenshot of the page, then opens a sheet with
164
- one field. The note is sent through the flow's session to `POST /api/w/:slug/feedback`.
163
+ button in the corner of the page. A tap opens a sheet with one field at once and takes a
164
+ screenshot of the page behind it, and neither the icon nor the sheet is in it. The sheet says each
165
+ step: corner marks frame the page and the slot reads "Taking a screenshot" (up to 15 seconds),
166
+ then "Screenshot attached" with the picture and a check, or "No screenshot" with "Take
167
+ screenshot" when it could not be taken or was removed. The button reads "Sending" while the note
168
+ goes out, and the sheet ends on "Feedback sent" before it closes. The note is sent through the flow's session to `POST /api/w/:slug/feedback`.
165
169
 
166
170
  The icon and the sheet take the look of the page they sit on: its fonts, ground, ink, the fill
167
171
  and shape of its main button, and its corner radius. To pin a role, set a custom property on
@@ -174,6 +178,12 @@ and shape of its main button, and its corner radius. To pin a role, set a custom
174
178
  }
175
179
  ```
176
180
 
181
+ The sheet's type comes from the words the flow is drawing, not from `<body>`: the title takes
182
+ the face, weight and letter spacing of the flow's largest heading, the note and the field take
183
+ the face most of the flow's smaller text is set in, and the send button takes the face, weight,
184
+ letter spacing and case of the flow's main button. Pin `--fs-feedback-font` or
185
+ `--fs-feedback-font-display` when the reading is off.
186
+
177
187
  The button floats over the flow and belongs to the person using it. They can drag it anywhere,
178
188
  and it settles on the nearer side. A flick at an edge, or a drag into one, docks it there as a
179
189
  slim tab with the bug still showing; a tap on the tab opens the sheet, and pulling the tab
@@ -1,4 +1,4 @@
1
- // @goflowstate/widget-sdk v0.3.2
1
+ // @goflowstate/widget-sdk v0.3.5
2
2
  // https://github.com/goflowstate/goflowstate-sdk
3
3
 
4
4
  "use strict";
@@ -81,7 +81,9 @@ var CanvasWidgetSDK = (() => {
81
81
  var FEEDBACK_HOST_ATTR = "data-fs-feedback-host";
82
82
  var SCREENSHOT_MAX_BYTES = 15e4;
83
83
  var SCREENSHOT_MAX_SIDE = 1280;
84
- var CAPTURE_TIMEOUT_MS = 4e3;
84
+ var CAPTURE_TIMEOUT_MS = 15e3;
85
+ var SEND_WAIT_MS = 4e3;
86
+ var SENT_HOLD_MS = 1600;
85
87
  function parseColor(raw) {
86
88
  const value = (raw ?? "").trim().toLowerCase();
87
89
  if (!value || value === "transparent") return null;
@@ -159,6 +161,46 @@ var CanvasWidgetSDK = (() => {
159
161
  });
160
162
  return best;
161
163
  };
164
+ var TEXT_SAMPLE_LIMIT = 600;
165
+ var DISPLAY_MIN_PX = 22;
166
+ var sizeOf = (style) => {
167
+ const n = parseFloat(style.fontSize);
168
+ return Number.isFinite(n) && n > 0 ? n : 16;
169
+ };
170
+ var textSamples = (doc) => {
171
+ const out = [];
172
+ const all = doc.body.querySelectorAll("*");
173
+ for (let i = 0; i < all.length && out.length < TEXT_SAMPLE_LIMIT; i += 1) {
174
+ const el = all[i];
175
+ if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || inOurs(el)) continue;
176
+ let chars = 0;
177
+ for (const node of Array.from(el.childNodes)) {
178
+ if (node.nodeType === 3) chars += (node.textContent ?? "").trim().length;
179
+ }
180
+ if (chars === 0 || !visible(el)) continue;
181
+ const style = styleOf(el);
182
+ if (!style.fontFamily) continue;
183
+ out.push({
184
+ family: style.fontFamily,
185
+ size: sizeOf(style),
186
+ chars,
187
+ style,
188
+ heading: /^H[1-3]$/.test(el.tagName)
189
+ });
190
+ }
191
+ return out;
192
+ };
193
+ var trackingEm = (style, min, max, fallback) => {
194
+ const px = style ? parseFloat(style.letterSpacing) : NaN;
195
+ const em = style && Number.isFinite(px) ? px / sizeOf(style) : fallback;
196
+ const held = Math.min(max, Math.max(min, em));
197
+ return held === 0 ? "0em" : `${Math.round(held * 1e3) / 1e3}em`;
198
+ };
199
+ var weightIn = (style, min, max, fallback) => {
200
+ const raw = style?.fontWeight ?? "";
201
+ const n = raw === "bold" ? 700 : raw === "normal" ? 400 : parseFloat(raw);
202
+ return Number.isFinite(n) ? String(Math.min(max, Math.max(min, n))) : fallback;
203
+ };
162
204
  function readFlowTheme(doc = document) {
163
205
  const body = doc.body;
164
206
  const stage = body.querySelector(":scope > :not(script):not(style):not([data-fs-feedback-host])");
@@ -181,12 +223,32 @@ var CanvasWidgetSDK = (() => {
181
223
  const pinnedCtaInk = parseColor(rootVar(doc, ["--fs-feedback-cta-ink"]));
182
224
  const sampledCtaInk = !pinnedCta && sampledCta && buttonStyle ? parseColor(buttonStyle.color) : null;
183
225
  const ctaInk = pinnedCtaInk ?? (sampledCtaInk && contrast(sampledCtaInk, cta) >= 3 ? sampledCtaInk : null) ?? (luminance(cta) > 0.4 ? BLACK : WHITE);
184
- const heading = Array.from(doc.querySelectorAll('h1, h2, [class*="title"]')).find(
226
+ const samples = textSamples(doc);
227
+ const headings = Array.from(doc.querySelectorAll('h1, h2, [class*="title"]')).filter(
185
228
  (el) => !inOurs(el) && visible(el)
186
229
  );
187
- const headingStyle = heading ? styleOf(heading) : null;
188
- const fontBody = rootVar(doc, ["--fs-feedback-font"]) || (stage ? styleOf(stage).fontFamily : "") || bodyStyle.fontFamily || rootVar(doc, ["--sb-font-body", "--font-body"]) || "system-ui, sans-serif";
230
+ const largest = (items, size) => items.reduce(
231
+ (best, item) => best === null || size(item) > size(best) ? item : best,
232
+ null
233
+ );
234
+ const bigHeading = largest(
235
+ headings.filter((el) => sizeOf(styleOf(el)) >= DISPLAY_MIN_PX),
236
+ (el) => sizeOf(styleOf(el))
237
+ );
238
+ const bigLine = largest(
239
+ samples.filter((t) => t.size >= DISPLAY_MIN_PX && t.chars >= 3),
240
+ (t) => t.size
241
+ );
242
+ const headingStyle = bigHeading ? styleOf(bigHeading) : bigLine?.style ?? (headings[0] ? styleOf(headings[0]) : null);
243
+ const byFamily = /* @__PURE__ */ new Map();
244
+ for (const t of samples) {
245
+ if (t.size < DISPLAY_MIN_PX && !t.heading)
246
+ byFamily.set(t.family, (byFamily.get(t.family) ?? 0) + t.chars);
247
+ }
248
+ const readBody = largest(Array.from(byFamily), ([, chars]) => chars)?.[0] ?? "";
249
+ const fontBody = rootVar(doc, ["--fs-feedback-font"]) || readBody || (stage ? styleOf(stage).fontFamily : "") || bodyStyle.fontFamily || rootVar(doc, ["--sb-font-body", "--font-body"]) || "system-ui, sans-serif";
189
250
  const fontDisplay = rootVar(doc, ["--fs-feedback-font-display"]) || headingStyle?.fontFamily || rootVar(doc, ["--sb-font-display", "--sb-font-title", "--font-display"]) || fontBody;
251
+ const buttonType = buttonStyle?.fontFamily ? buttonStyle : null;
190
252
  const field = Array.from(
191
253
  doc.querySelectorAll('textarea, input[type="text"], input:not([type])')
192
254
  ).find((el) => !inOurs(el) && visible(el));
@@ -210,6 +272,11 @@ var CanvasWidgetSDK = (() => {
210
272
  fontBody,
211
273
  fontDisplay,
212
274
  displayWeight: headingStyle?.fontWeight || "500",
275
+ displayTracking: trackingEm(headingStyle, -0.04, 0.02, -0.01),
276
+ fontButton: buttonType?.fontFamily || fontBody,
277
+ buttonWeight: weightIn(buttonType, 500, 800, "600"),
278
+ buttonTracking: trackingEm(buttonType, -0.02, 0.08, 0),
279
+ buttonTransform: buttonType?.textTransform === "uppercase" ? "uppercase" : "none",
213
280
  radius,
214
281
  buttonRadius: buttonRadius === null ? "999px" : `${Math.min(buttonRadius, 999)}px`,
215
282
  dark
@@ -235,9 +302,10 @@ var CanvasWidgetSDK = (() => {
235
302
  return null;
236
303
  }
237
304
  async function captureViewport(ground) {
238
- const { domToCanvas: domToCanvas2 } = await Promise.resolve().then(() => (init_modern_screenshot(), modern_screenshot_exports));
239
305
  const width = window.innerWidth;
240
306
  const height = window.innerHeight;
307
+ const { scrollX, scrollY } = window;
308
+ const { domToCanvas: domToCanvas2 } = await Promise.resolve().then(() => (init_modern_screenshot(), modern_screenshot_exports));
241
309
  const scale = Math.min(
242
310
  window.devicePixelRatio || 1,
243
311
  SCREENSHOT_MAX_SIDE / Math.max(width, height)
@@ -247,7 +315,7 @@ var CanvasWidgetSDK = (() => {
247
315
  height,
248
316
  scale,
249
317
  backgroundColor: ground,
250
- style: { transform: `translate(${-window.scrollX}px, ${-window.scrollY}px)` },
318
+ style: { transform: `translate(${-scrollX}px, ${-scrollY}px)` },
251
319
  filter: (node) => !(node instanceof Element && node.hasAttribute(FEEDBACK_HOST_ATTR))
252
320
  });
253
321
  return encodeScreenshot(canvas);
@@ -441,9 +509,45 @@ var CanvasWidgetSDK = (() => {
441
509
  font-family: var(--font-body); font-size: 15px; line-height: 1.4;
442
510
  }
443
511
  .grab { width: 36px; height: 4px; border-radius: 2px; background: var(--line); align-self: center; }
444
- h2 { margin: 0; font-family: var(--font-display); font-weight: var(--display-weight); font-size: 19px; line-height: 1.2; }
445
- .shot { display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--ink-dim); }
446
- .shot img { width: 50px; max-height: 108px; object-fit: cover; object-position: top; border-radius: 8px; border: 1px solid var(--line); }
512
+ h2 {
513
+ margin: 2px 0 0; font-family: var(--font-display); font-weight: var(--display-weight);
514
+ font-size: 22px; line-height: 1.15; letter-spacing: var(--display-tracking); text-wrap: balance;
515
+ }
516
+ .form { display: contents; }
517
+ .shot { display: flex; gap: 14px; align-items: center; font-size: 14px; }
518
+ .thumb {
519
+ position: relative; flex: none; width: 60px; aspect-ratio: var(--shot-ratio, 0.46); max-height: 124px;
520
+ border-radius: 9px; border: 1px solid var(--line); background: var(--fill);
521
+ }
522
+ .thumb img { display: none; width: 100%; height: 100%; object-fit: cover; object-position: top; border-radius: 8px; }
523
+ .scan, .empty, .badge { display: none; }
524
+ .shot[data-shot="taking"] .thumb { overflow: hidden; }
525
+ .shot[data-shot="taking"] .scan {
526
+ display: block; position: absolute; left: 0; right: 0; top: 0; height: 34%;
527
+ background: linear-gradient(to bottom, transparent, var(--line)); border-bottom: 2px solid var(--ink);
528
+ }
529
+ .shot[data-shot="attached"] .thumb { background: var(--surface); }
530
+ .shot[data-shot="attached"] img { display: block; }
531
+ .shot[data-shot="attached"] .badge {
532
+ display: grid; place-items: center; position: absolute; right: -8px; top: -8px; width: 22px; height: 22px;
533
+ border-radius: 50%; background: #1f7a4d; color: #fff; border: 2px solid var(--surface);
534
+ }
535
+ .badge svg { width: 12px; height: 12px; }
536
+ .shot[data-shot="none"] .thumb { border-style: dashed; background: none; }
537
+ .shot[data-shot="none"] .empty { display: grid; place-items: center; position: absolute; inset: 0; color: var(--ink-dim); }
538
+ .empty svg { width: 22px; height: 22px; }
539
+ .shot-text { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 0; }
540
+ .shot-label { font-weight: 600; color: var(--ink); }
541
+ .shot[data-shot="none"] .shot-label { color: var(--ink-dim); font-weight: inherit; }
542
+ .finder {
543
+ position: fixed; z-index: 2147483002; pointer-events: none;
544
+ top: calc(10px + env(safe-area-inset-top, 0px)); left: 10px; right: 10px; bottom: var(--finder-bottom, 55%);
545
+ }
546
+ .finder i { position: absolute; width: 22px; height: 22px; border: 3px solid #fff; filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.5)); }
547
+ .finder i:nth-child(1) { left: 0; top: 0; border-right: 0; border-bottom: 0; border-top-left-radius: 9px; }
548
+ .finder i:nth-child(2) { right: 0; top: 0; border-left: 0; border-bottom: 0; border-top-right-radius: 9px; }
549
+ .finder i:nth-child(3) { left: 0; bottom: 0; border-right: 0; border-top: 0; border-bottom-left-radius: 9px; }
550
+ .finder i:nth-child(4) { right: 0; bottom: 0; border-left: 0; border-top: 0; border-bottom-right-radius: 9px; }
447
551
  .link { background: none; border: 0; padding: 0; font: inherit; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
448
552
  textarea {
449
553
  width: 100%; min-height: 84px; resize: none; font: inherit; font-size: 16px; color: var(--ink);
@@ -451,10 +555,24 @@ textarea {
451
555
  }
452
556
  textarea::placeholder { color: var(--ink-dim); }
453
557
  .send {
454
- font: inherit; font-weight: 500; cursor: pointer; border: 0; padding: 14px;
558
+ font-family: var(--font-button); font-weight: var(--button-weight); font-size: 17px; line-height: 1.2;
559
+ letter-spacing: var(--button-tracking); text-transform: var(--button-transform);
560
+ cursor: pointer; border: 0; padding: 15px 14px;
455
561
  background: var(--cta); color: var(--cta-ink); border-radius: var(--button-radius);
456
562
  }
563
+ .send { display: flex; align-items: center; justify-content: center; gap: 9px; }
457
564
  .send[disabled] { opacity: 0.5; cursor: default; }
565
+ .sheet[data-busy] .send { opacity: 1; }
566
+ .dots { display: inline-flex; gap: 4px; }
567
+ .dots i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0.4; }
568
+ .done { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 14px 0 10px; }
569
+ .ring { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 50%; background: #1f7a4d; color: #fff; }
570
+ .ring svg { width: 26px; height: 26px; }
571
+ .done-title {
572
+ margin: 0; font-family: var(--font-display); font-weight: var(--display-weight);
573
+ font-size: 22px; line-height: 1.15; letter-spacing: var(--display-tracking);
574
+ }
575
+ .said { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
458
576
  .status { margin: 0; font-size: 13px; color: var(--ink-dim); min-height: 1.4em; }
459
577
  [hidden] { display: none !important; }
460
578
  @media (prefers-reduced-motion: no-preference) {
@@ -464,11 +582,31 @@ textarea::placeholder { color: var(--ink-dim); }
464
582
  }
465
583
  .bug svg { transition: transform 0.1s ease-out, width 0.22s ease, height 0.22s ease; }
466
584
  .sheet { animation: rise 0.22s ease-out; }
585
+ .shot[data-shot="taking"] .scan { animation: fsf-scan 1.3s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
586
+ @keyframes fsf-scan { from { transform: translateY(-100%); } to { transform: translateY(300%); } }
587
+ .finder i { animation: fsf-frame 0.9s ease-in-out infinite alternate; }
588
+ @keyframes fsf-frame { from { opacity: 0.55; } to { opacity: 1; } }
589
+ .shot[data-shot="attached"] img { animation: fsf-land 0.28s cubic-bezier(0.2, 0.8, 0.2, 1); }
590
+ @keyframes fsf-land { from { transform: scale(1.14); opacity: 0; } to { transform: none; opacity: 1; } }
591
+ .shot[data-shot="attached"] .badge { animation: fsf-pop 0.24s cubic-bezier(0.2, 0.8, 0.2, 1) 0.14s backwards; }
592
+ .ring { animation: fsf-pop 0.26s cubic-bezier(0.2, 0.8, 0.2, 1); }
593
+ @keyframes fsf-pop { from { transform: scale(0.4); opacity: 0; } to { transform: none; opacity: 1; } }
594
+ .dots i { animation: fsf-dot 1s ease-in-out infinite; }
595
+ .dots i:nth-child(2) { animation-delay: 0.15s; }
596
+ .dots i:nth-child(3) { animation-delay: 0.3s; }
597
+ @keyframes fsf-dot { 30% { opacity: 1; transform: translateY(-2px); } 60% { opacity: 0.4; transform: none; } }
467
598
  @keyframes rise { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } }
468
599
  }
469
600
  `;
470
601
  var mounted = null;
471
602
  var feedbackOptedOut = (doc = document) => doc.documentElement.getAttribute("data-fs-feedback") === "off";
603
+ var CHECK_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
604
+ var CAMERA_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="6.5" width="17" height="12.5" rx="3"/><circle cx="12" cy="12.8" r="3.2"/><path d="M9 6.5l1-2h4l1 2"/></svg>';
605
+ var SHOT_LABEL = {
606
+ taking: "Taking a screenshot",
607
+ attached: "Screenshot attached",
608
+ none: "No screenshot"
609
+ };
472
610
  var SCROLL_OPTIONS = { capture: true, passive: true };
473
611
  function mountFlowFeedback(options) {
474
612
  if (typeof document === "undefined" || !document.body) return null;
@@ -481,14 +619,22 @@ textarea::placeholder { color: var(--ink-dim); }
481
619
  <button type="button" class="bug" data-state="float" aria-label="Send feedback on this page">${BUG_ICON}</button>
482
620
  <div class="probe" aria-hidden="true"></div>
483
621
  <div class="scrim" hidden></div>
484
- <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="fsf-title" hidden>
622
+ <div class="finder" aria-hidden="true" hidden><i></i><i></i><i></i><i></i></div>
623
+ <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="fsf-title" data-view="form" hidden>
485
624
  <div class="grab" aria-hidden="true"></div>
486
- <h2 id="fsf-title">Feedback on this page</h2>
487
- <div class="shot" hidden><img alt="Screenshot of this page" /><span><span>Screenshot attached</span><br /><button type="button" class="link remove">Remove screenshot</button></span></div>
488
- <textarea aria-labelledby="fsf-title" placeholder="What should change?" maxlength="2000"></textarea>
489
- <p class="status" role="status" aria-live="polite"></p>
490
- <button type="button" class="send" disabled>Send feedback</button>
491
- <button type="button" class="link tuck">Tuck the button to the side</button>
625
+ <div class="form">
626
+ <h2 id="fsf-title">Feedback on this page</h2>
627
+ <div class="shot" data-shot="none">
628
+ <span class="thumb"><span class="scan" aria-hidden="true"></span><img alt="Screenshot of this page" /><span class="empty" aria-hidden="true">${CAMERA_ICON}</span><span class="badge" aria-hidden="true">${CHECK_ICON}</span></span>
629
+ <span class="shot-text"><span class="shot-label" role="status" aria-live="polite">No screenshot</span><button type="button" class="link remove" hidden>Remove</button><button type="button" class="link retake" hidden>Take screenshot</button></span>
630
+ </div>
631
+ <textarea aria-labelledby="fsf-title" placeholder="What should change?" maxlength="2000"></textarea>
632
+ <p class="status" role="status" aria-live="polite"></p>
633
+ <button type="button" class="send" disabled><span class="send-label">Send feedback</span><span class="dots" aria-hidden="true" hidden><i></i><i></i><i></i></span></button>
634
+ <button type="button" class="link tuck">Tuck the button to the side</button>
635
+ </div>
636
+ <div class="done" hidden><span class="ring">${CHECK_ICON}</span><p class="done-title" aria-hidden="true">Feedback sent</p></div>
637
+ <p class="said" role="status" aria-live="polite"></p>
492
638
  </div>`;
493
639
  const q = (selector) => root.querySelector(selector);
494
640
  const bug = q(".bug");
@@ -496,7 +642,15 @@ textarea::placeholder { color: var(--ink-dim); }
496
642
  const sheet = q(".sheet");
497
643
  const shot = q(".shot");
498
644
  const img = q(".shot img");
645
+ const shotLabel = q(".shot-label");
499
646
  const remove = q(".remove");
647
+ const retake = q(".retake");
648
+ const finder = q(".finder");
649
+ const form = q(".form");
650
+ const done = q(".done");
651
+ const said = q(".said");
652
+ const sendLabel = q(".send-label");
653
+ const dots = q(".dots");
500
654
  const text = q("textarea");
501
655
  const status = q(".status");
502
656
  const send = q(".send");
@@ -706,6 +860,11 @@ textarea::placeholder { color: var(--ink-dim); }
706
860
  "--font-body": theme.fontBody,
707
861
  "--font-display": theme.fontDisplay,
708
862
  "--display-weight": theme.displayWeight,
863
+ "--display-tracking": theme.displayTracking,
864
+ "--font-button": theme.fontButton,
865
+ "--button-weight": theme.buttonWeight,
866
+ "--button-tracking": theme.buttonTracking,
867
+ "--button-transform": theme.buttonTransform,
709
868
  "--radius": theme.radius,
710
869
  "--button-radius": theme.buttonRadius
711
870
  };
@@ -715,46 +874,104 @@ textarea::placeholder { color: var(--ink-dim); }
715
874
  const sync = () => {
716
875
  send.disabled = busy || text.value.trim().length === 0;
717
876
  };
718
- const setShot = (url) => {
719
- screenshot = url;
720
- shot.hidden = url === null;
721
- if (url) img.src = url;
877
+ let round = 0;
878
+ let pending = null;
879
+ const setShot = (state, url = null) => {
880
+ screenshot = state === "attached" ? url : null;
881
+ shot.dataset.shot = state;
882
+ shotLabel.textContent = SHOT_LABEL[state];
883
+ remove.hidden = state !== "attached" || busy;
884
+ retake.hidden = state !== "none" || busy;
885
+ finder.hidden = state !== "taking";
886
+ if (screenshot) img.src = screenshot;
722
887
  else img.removeAttribute("src");
723
888
  };
889
+ const setBusy = (next) => {
890
+ busy = next;
891
+ if (busy) sheet.setAttribute("data-busy", "");
892
+ else sheet.removeAttribute("data-busy");
893
+ text.readOnly = busy;
894
+ sendLabel.textContent = busy ? "Sending" : "Send feedback";
895
+ dots.hidden = !busy;
896
+ const state = shot.dataset.shot;
897
+ remove.hidden = state !== "attached" || busy;
898
+ retake.hidden = state !== "none" || busy;
899
+ sync();
900
+ };
901
+ const setView = (view) => {
902
+ sheet.dataset.view = view;
903
+ form.hidden = view !== "form";
904
+ done.hidden = view !== "sent";
905
+ said.textContent = view === "sent" ? "Feedback sent" : "";
906
+ };
907
+ const take = () => {
908
+ const mine = round += 1;
909
+ setShot("taking");
910
+ host.style.setProperty("--finder-bottom", `${sheet.offsetHeight + 10}px`);
911
+ host.style.setProperty(
912
+ "--shot-ratio",
913
+ String(Math.max(0.3, Math.min(1, window.innerWidth / Math.max(1, window.innerHeight))))
914
+ );
915
+ const capture = options.capture ?? (() => captureViewport(theme.ground));
916
+ const work = withTimeout(Promise.resolve().then(capture), CAPTURE_TIMEOUT_MS).then((url) => {
917
+ if (mine !== round) return;
918
+ pending = null;
919
+ setShot(url ? "attached" : "none", url);
920
+ const active = root.activeElement;
921
+ if (!busy && (!active || active === bug || active === retake || active === remove))
922
+ text.focus();
923
+ });
924
+ pending = work;
925
+ return work;
926
+ };
724
927
  const close = () => {
725
928
  if (closeTimer) clearTimeout(closeTimer);
726
929
  closeTimer = null;
930
+ round += 1;
931
+ pending = null;
727
932
  sheet.hidden = true;
728
933
  scrim.hidden = true;
729
934
  bug.hidden = false;
730
- setShot(null);
935
+ setBusy(false);
936
+ setShot("none");
937
+ setView("form");
731
938
  text.value = "";
732
939
  status.textContent = "";
733
- busy = false;
734
940
  sync();
735
941
  schedulePlace();
736
942
  };
737
- const open = async () => {
738
- if (!sheet.hidden || busy) return;
739
- busy = true;
943
+ const open = () => {
944
+ if (!sheet.hidden) return pending ?? Promise.resolve();
945
+ if (busy) return Promise.resolve();
740
946
  paint();
741
947
  bug.hidden = true;
742
- const capture = options.capture ?? (() => captureViewport(theme.ground));
743
- const url = await withTimeout(Promise.resolve().then(capture), CAPTURE_TIMEOUT_MS);
744
- busy = false;
745
- setShot(url);
948
+ setView("form");
746
949
  tuck.hidden = spot.state !== "float";
747
950
  scrim.hidden = false;
748
951
  sheet.hidden = false;
749
952
  sync();
750
- text.focus();
953
+ return take();
751
954
  };
752
955
  const submit = async () => {
753
956
  const message = text.value.trim();
754
957
  if (!message || busy) return;
755
- busy = true;
756
- sync();
958
+ setBusy(true);
757
959
  status.textContent = "";
960
+ let mine = round;
961
+ if (pending) {
962
+ let timer;
963
+ await Promise.race([
964
+ pending,
965
+ new Promise((resolve) => timer = setTimeout(resolve, SEND_WAIT_MS))
966
+ ]);
967
+ clearTimeout(timer);
968
+ if (mine !== round) return;
969
+ if (pending) {
970
+ mine = round += 1;
971
+ pending = null;
972
+ setShot("none");
973
+ }
974
+ }
758
975
  try {
759
976
  await options.submit({
760
977
  message,
@@ -763,21 +980,27 @@ textarea::placeholder { color: var(--ink-dim); }
763
980
  pagePath: window.location.pathname.slice(-200),
764
981
  viewport: { width: window.innerWidth, height: window.innerHeight }
765
982
  });
766
- status.textContent = "Sent.";
983
+ if (mine !== round) return;
984
+ setView("sent");
767
985
  closeTimer = setTimeout(() => {
768
986
  close();
769
987
  bug.focus();
770
- }, 1200);
988
+ }, SENT_HOLD_MS);
771
989
  } catch {
772
- busy = false;
990
+ if (mine !== round) return;
991
+ setBusy(false);
773
992
  status.textContent = "That did not send. Try again.";
774
- sync();
775
993
  }
776
994
  };
777
995
  bug.addEventListener("click", () => {
778
996
  if (!suppressClick) void open();
779
997
  });
780
998
  bug.addEventListener("pointerdown", onDown);
999
+ if (!options.capture)
1000
+ bug.addEventListener("pointerdown", () => void Promise.resolve().then(() => (init_modern_screenshot(), modern_screenshot_exports)).catch(() => {
1001
+ }), {
1002
+ once: true
1003
+ });
781
1004
  bug.addEventListener("pointermove", onMove);
782
1005
  bug.addEventListener("pointerup", onUp);
783
1006
  bug.addEventListener("pointercancel", onUp);
@@ -797,7 +1020,19 @@ textarea::placeholder { color: var(--ink-dim); }
797
1020
  );
798
1021
  });
799
1022
  scrim.addEventListener("click", close);
800
- remove.addEventListener("click", () => setShot(null));
1023
+ remove.addEventListener("click", () => {
1024
+ round += 1;
1025
+ pending = null;
1026
+ setShot("none");
1027
+ retake.focus();
1028
+ });
1029
+ retake.addEventListener("click", () => void take());
1030
+ sheet.addEventListener("click", () => {
1031
+ if (sheet.dataset.view === "sent") {
1032
+ close();
1033
+ bug.focus();
1034
+ }
1035
+ });
801
1036
  text.addEventListener("input", sync);
802
1037
  send.addEventListener("click", () => void submit());
803
1038
  sheet.addEventListener("keydown", (event) => {