@goflowstate/widget-sdk 0.3.4 → 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
@@ -161,8 +161,11 @@ before the first state arrives.
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
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; the sheet shows "Taking a screenshot" until the picture is
165
- attached, and neither the icon nor the sheet is in it. The note is sent through the flow's session to `POST /api/w/:slug/feedback`.
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`.
166
169
 
167
170
  The icon and the sheet take the look of the page they sit on: its fonts, ground, ink, the fill
168
171
  and shape of its main button, and its corner radius. To pin a role, set a custom property on
@@ -1,4 +1,4 @@
1
- // @goflowstate/widget-sdk v0.3.4
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;
@@ -511,11 +513,41 @@ h2 {
511
513
  margin: 2px 0 0; font-family: var(--font-display); font-weight: var(--display-weight);
512
514
  font-size: 22px; line-height: 1.15; letter-spacing: var(--display-tracking); text-wrap: balance;
513
515
  }
514
- .shot { display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--ink-dim); }
515
- .shot img { width: 50px; max-height: 108px; object-fit: cover; object-position: top; border-radius: 8px; border: 1px solid var(--line); }
516
- .ghost { display: none; width: 50px; height: 108px; border-radius: 8px; border: 1px solid var(--line); background: var(--fill); }
517
- .shot[data-pending] .ghost { display: block; }
518
- .shot[data-pending] img { display: none; }
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; }
519
551
  .link { background: none; border: 0; padding: 0; font: inherit; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
520
552
  textarea {
521
553
  width: 100%; min-height: 84px; resize: none; font: inherit; font-size: 16px; color: var(--ink);
@@ -528,7 +560,19 @@ textarea::placeholder { color: var(--ink-dim); }
528
560
  cursor: pointer; border: 0; padding: 15px 14px;
529
561
  background: var(--cta); color: var(--cta-ink); border-radius: var(--button-radius);
530
562
  }
563
+ .send { display: flex; align-items: center; justify-content: center; gap: 9px; }
531
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; }
532
576
  .status { margin: 0; font-size: 13px; color: var(--ink-dim); min-height: 1.4em; }
533
577
  [hidden] { display: none !important; }
534
578
  @media (prefers-reduced-motion: no-preference) {
@@ -538,13 +582,31 @@ textarea::placeholder { color: var(--ink-dim); }
538
582
  }
539
583
  .bug svg { transition: transform 0.1s ease-out, width 0.22s ease, height 0.22s ease; }
540
584
  .sheet { animation: rise 0.22s ease-out; }
541
- .shot[data-pending] .ghost { animation: wait 1.1s ease-in-out infinite alternate; }
542
- @keyframes wait { from { opacity: 0.45; } to { opacity: 1; } }
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; } }
543
598
  @keyframes rise { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } }
544
599
  }
545
600
  `;
546
601
  var mounted = null;
547
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
+ };
548
610
  var SCROLL_OPTIONS = { capture: true, passive: true };
549
611
  function mountFlowFeedback(options) {
550
612
  if (typeof document === "undefined" || !document.body) return null;
@@ -557,14 +619,22 @@ textarea::placeholder { color: var(--ink-dim); }
557
619
  <button type="button" class="bug" data-state="float" aria-label="Send feedback on this page">${BUG_ICON}</button>
558
620
  <div class="probe" aria-hidden="true"></div>
559
621
  <div class="scrim" hidden></div>
560
- <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>
561
624
  <div class="grab" aria-hidden="true"></div>
562
- <h2 id="fsf-title">Feedback on this page</h2>
563
- <div class="shot" hidden><span class="ghost" aria-hidden="true"></span><img alt="Screenshot of this page" /><span><span class="shot-label">Screenshot attached</span><br /><button type="button" class="link remove">Remove screenshot</button></span></div>
564
- <textarea aria-labelledby="fsf-title" placeholder="What should change?" maxlength="2000"></textarea>
565
- <p class="status" role="status" aria-live="polite"></p>
566
- <button type="button" class="send" disabled>Send feedback</button>
567
- <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>
568
638
  </div>`;
569
639
  const q = (selector) => root.querySelector(selector);
570
640
  const bug = q(".bug");
@@ -574,6 +644,13 @@ textarea::placeholder { color: var(--ink-dim); }
574
644
  const img = q(".shot img");
575
645
  const shotLabel = q(".shot-label");
576
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");
577
654
  const text = q("textarea");
578
655
  const status = q(".status");
579
656
  const send = q(".send");
@@ -799,14 +876,54 @@ textarea::placeholder { color: var(--ink-dim); }
799
876
  };
800
877
  let round = 0;
801
878
  let pending = null;
802
- const setShot = (url) => {
803
- screenshot = url;
804
- shot.hidden = url === null;
805
- shot.removeAttribute("data-pending");
806
- shotLabel.textContent = "Screenshot attached";
807
- if (url) img.src = url;
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;
808
887
  else img.removeAttribute("src");
809
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
+ };
810
927
  const close = () => {
811
928
  if (closeTimer) clearTimeout(closeTimer);
812
929
  closeTimer = null;
@@ -815,10 +932,11 @@ textarea::placeholder { color: var(--ink-dim); }
815
932
  sheet.hidden = true;
816
933
  scrim.hidden = true;
817
934
  bug.hidden = false;
818
- setShot(null);
935
+ setBusy(false);
936
+ setShot("none");
937
+ setView("form");
819
938
  text.value = "";
820
939
  status.textContent = "";
821
- busy = false;
822
940
  sync();
823
941
  schedulePlace();
824
942
  };
@@ -826,34 +944,34 @@ textarea::placeholder { color: var(--ink-dim); }
826
944
  if (!sheet.hidden) return pending ?? Promise.resolve();
827
945
  if (busy) return Promise.resolve();
828
946
  paint();
829
- const mine = round += 1;
830
947
  bug.hidden = true;
831
- setShot(null);
832
- shot.hidden = false;
833
- shot.setAttribute("data-pending", "");
834
- shotLabel.textContent = "Taking a screenshot";
948
+ setView("form");
835
949
  tuck.hidden = spot.state !== "float";
836
950
  scrim.hidden = false;
837
951
  sheet.hidden = false;
838
952
  sync();
839
- const capture = options.capture ?? (() => captureViewport(theme.ground));
840
- pending = withTimeout(Promise.resolve().then(capture), CAPTURE_TIMEOUT_MS).then((url) => {
841
- if (mine !== round) return;
842
- pending = null;
843
- setShot(url);
844
- if (!root.activeElement || root.activeElement === bug) text.focus();
845
- });
846
- return pending;
953
+ return take();
847
954
  };
848
955
  const submit = async () => {
849
956
  const message = text.value.trim();
850
957
  if (!message || busy) return;
851
- busy = true;
852
- sync();
958
+ setBusy(true);
853
959
  status.textContent = "";
854
- const mine = round;
855
- if (pending) await pending;
856
- if (mine !== round) return;
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
+ }
857
975
  try {
858
976
  await options.submit({
859
977
  message,
@@ -862,15 +980,16 @@ textarea::placeholder { color: var(--ink-dim); }
862
980
  pagePath: window.location.pathname.slice(-200),
863
981
  viewport: { width: window.innerWidth, height: window.innerHeight }
864
982
  });
865
- status.textContent = "Sent.";
983
+ if (mine !== round) return;
984
+ setView("sent");
866
985
  closeTimer = setTimeout(() => {
867
986
  close();
868
987
  bug.focus();
869
- }, 1200);
988
+ }, SENT_HOLD_MS);
870
989
  } catch {
871
- busy = false;
990
+ if (mine !== round) return;
991
+ setBusy(false);
872
992
  status.textContent = "That did not send. Try again.";
873
- sync();
874
993
  }
875
994
  };
876
995
  bug.addEventListener("click", () => {
@@ -904,7 +1023,15 @@ textarea::placeholder { color: var(--ink-dim); }
904
1023
  remove.addEventListener("click", () => {
905
1024
  round += 1;
906
1025
  pending = null;
907
- setShot(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
+ }
908
1035
  });
909
1036
  text.addEventListener("input", sync);
910
1037
  send.addEventListener("click", () => void submit());