@goflowstate/widget-sdk 0.3.2 → 0.3.4

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,9 @@ 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; 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`.
165
166
 
166
167
  The icon and the sheet take the look of the page they sit on: its fonts, ground, ink, the fill
167
168
  and shape of its main button, and its corner radius. To pin a role, set a custom property on
@@ -174,6 +175,12 @@ and shape of its main button, and its corner radius. To pin a role, set a custom
174
175
  }
175
176
  ```
176
177
 
178
+ The sheet's type comes from the words the flow is drawing, not from `<body>`: the title takes
179
+ the face, weight and letter spacing of the flow's largest heading, the note and the field take
180
+ the face most of the flow's smaller text is set in, and the send button takes the face, weight,
181
+ letter spacing and case of the flow's main button. Pin `--fs-feedback-font` or
182
+ `--fs-feedback-font-display` when the reading is off.
183
+
177
184
  The button floats over the flow and belongs to the person using it. They can drag it anywhere,
178
185
  and it settles on the nearer side. A flick at an edge, or a drag into one, docks it there as a
179
186
  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.4
2
2
  // https://github.com/goflowstate/goflowstate-sdk
3
3
 
4
4
  "use strict";
@@ -159,6 +159,46 @@ var CanvasWidgetSDK = (() => {
159
159
  });
160
160
  return best;
161
161
  };
162
+ var TEXT_SAMPLE_LIMIT = 600;
163
+ var DISPLAY_MIN_PX = 22;
164
+ var sizeOf = (style) => {
165
+ const n = parseFloat(style.fontSize);
166
+ return Number.isFinite(n) && n > 0 ? n : 16;
167
+ };
168
+ var textSamples = (doc) => {
169
+ const out = [];
170
+ const all = doc.body.querySelectorAll("*");
171
+ for (let i = 0; i < all.length && out.length < TEXT_SAMPLE_LIMIT; i += 1) {
172
+ const el = all[i];
173
+ if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || inOurs(el)) continue;
174
+ let chars = 0;
175
+ for (const node of Array.from(el.childNodes)) {
176
+ if (node.nodeType === 3) chars += (node.textContent ?? "").trim().length;
177
+ }
178
+ if (chars === 0 || !visible(el)) continue;
179
+ const style = styleOf(el);
180
+ if (!style.fontFamily) continue;
181
+ out.push({
182
+ family: style.fontFamily,
183
+ size: sizeOf(style),
184
+ chars,
185
+ style,
186
+ heading: /^H[1-3]$/.test(el.tagName)
187
+ });
188
+ }
189
+ return out;
190
+ };
191
+ var trackingEm = (style, min, max, fallback) => {
192
+ const px = style ? parseFloat(style.letterSpacing) : NaN;
193
+ const em = style && Number.isFinite(px) ? px / sizeOf(style) : fallback;
194
+ const held = Math.min(max, Math.max(min, em));
195
+ return held === 0 ? "0em" : `${Math.round(held * 1e3) / 1e3}em`;
196
+ };
197
+ var weightIn = (style, min, max, fallback) => {
198
+ const raw = style?.fontWeight ?? "";
199
+ const n = raw === "bold" ? 700 : raw === "normal" ? 400 : parseFloat(raw);
200
+ return Number.isFinite(n) ? String(Math.min(max, Math.max(min, n))) : fallback;
201
+ };
162
202
  function readFlowTheme(doc = document) {
163
203
  const body = doc.body;
164
204
  const stage = body.querySelector(":scope > :not(script):not(style):not([data-fs-feedback-host])");
@@ -181,12 +221,32 @@ var CanvasWidgetSDK = (() => {
181
221
  const pinnedCtaInk = parseColor(rootVar(doc, ["--fs-feedback-cta-ink"]));
182
222
  const sampledCtaInk = !pinnedCta && sampledCta && buttonStyle ? parseColor(buttonStyle.color) : null;
183
223
  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(
224
+ const samples = textSamples(doc);
225
+ const headings = Array.from(doc.querySelectorAll('h1, h2, [class*="title"]')).filter(
185
226
  (el) => !inOurs(el) && visible(el)
186
227
  );
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";
228
+ const largest = (items, size) => items.reduce(
229
+ (best, item) => best === null || size(item) > size(best) ? item : best,
230
+ null
231
+ );
232
+ const bigHeading = largest(
233
+ headings.filter((el) => sizeOf(styleOf(el)) >= DISPLAY_MIN_PX),
234
+ (el) => sizeOf(styleOf(el))
235
+ );
236
+ const bigLine = largest(
237
+ samples.filter((t) => t.size >= DISPLAY_MIN_PX && t.chars >= 3),
238
+ (t) => t.size
239
+ );
240
+ const headingStyle = bigHeading ? styleOf(bigHeading) : bigLine?.style ?? (headings[0] ? styleOf(headings[0]) : null);
241
+ const byFamily = /* @__PURE__ */ new Map();
242
+ for (const t of samples) {
243
+ if (t.size < DISPLAY_MIN_PX && !t.heading)
244
+ byFamily.set(t.family, (byFamily.get(t.family) ?? 0) + t.chars);
245
+ }
246
+ const readBody = largest(Array.from(byFamily), ([, chars]) => chars)?.[0] ?? "";
247
+ 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
248
  const fontDisplay = rootVar(doc, ["--fs-feedback-font-display"]) || headingStyle?.fontFamily || rootVar(doc, ["--sb-font-display", "--sb-font-title", "--font-display"]) || fontBody;
249
+ const buttonType = buttonStyle?.fontFamily ? buttonStyle : null;
190
250
  const field = Array.from(
191
251
  doc.querySelectorAll('textarea, input[type="text"], input:not([type])')
192
252
  ).find((el) => !inOurs(el) && visible(el));
@@ -210,6 +270,11 @@ var CanvasWidgetSDK = (() => {
210
270
  fontBody,
211
271
  fontDisplay,
212
272
  displayWeight: headingStyle?.fontWeight || "500",
273
+ displayTracking: trackingEm(headingStyle, -0.04, 0.02, -0.01),
274
+ fontButton: buttonType?.fontFamily || fontBody,
275
+ buttonWeight: weightIn(buttonType, 500, 800, "600"),
276
+ buttonTracking: trackingEm(buttonType, -0.02, 0.08, 0),
277
+ buttonTransform: buttonType?.textTransform === "uppercase" ? "uppercase" : "none",
213
278
  radius,
214
279
  buttonRadius: buttonRadius === null ? "999px" : `${Math.min(buttonRadius, 999)}px`,
215
280
  dark
@@ -235,9 +300,10 @@ var CanvasWidgetSDK = (() => {
235
300
  return null;
236
301
  }
237
302
  async function captureViewport(ground) {
238
- const { domToCanvas: domToCanvas2 } = await Promise.resolve().then(() => (init_modern_screenshot(), modern_screenshot_exports));
239
303
  const width = window.innerWidth;
240
304
  const height = window.innerHeight;
305
+ const { scrollX, scrollY } = window;
306
+ const { domToCanvas: domToCanvas2 } = await Promise.resolve().then(() => (init_modern_screenshot(), modern_screenshot_exports));
241
307
  const scale = Math.min(
242
308
  window.devicePixelRatio || 1,
243
309
  SCREENSHOT_MAX_SIDE / Math.max(width, height)
@@ -247,7 +313,7 @@ var CanvasWidgetSDK = (() => {
247
313
  height,
248
314
  scale,
249
315
  backgroundColor: ground,
250
- style: { transform: `translate(${-window.scrollX}px, ${-window.scrollY}px)` },
316
+ style: { transform: `translate(${-scrollX}px, ${-scrollY}px)` },
251
317
  filter: (node) => !(node instanceof Element && node.hasAttribute(FEEDBACK_HOST_ATTR))
252
318
  });
253
319
  return encodeScreenshot(canvas);
@@ -441,9 +507,15 @@ var CanvasWidgetSDK = (() => {
441
507
  font-family: var(--font-body); font-size: 15px; line-height: 1.4;
442
508
  }
443
509
  .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; }
510
+ h2 {
511
+ margin: 2px 0 0; font-family: var(--font-display); font-weight: var(--display-weight);
512
+ font-size: 22px; line-height: 1.15; letter-spacing: var(--display-tracking); text-wrap: balance;
513
+ }
445
514
  .shot { display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--ink-dim); }
446
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; }
447
519
  .link { background: none; border: 0; padding: 0; font: inherit; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
448
520
  textarea {
449
521
  width: 100%; min-height: 84px; resize: none; font: inherit; font-size: 16px; color: var(--ink);
@@ -451,7 +523,9 @@ textarea {
451
523
  }
452
524
  textarea::placeholder { color: var(--ink-dim); }
453
525
  .send {
454
- font: inherit; font-weight: 500; cursor: pointer; border: 0; padding: 14px;
526
+ font-family: var(--font-button); font-weight: var(--button-weight); font-size: 17px; line-height: 1.2;
527
+ letter-spacing: var(--button-tracking); text-transform: var(--button-transform);
528
+ cursor: pointer; border: 0; padding: 15px 14px;
455
529
  background: var(--cta); color: var(--cta-ink); border-radius: var(--button-radius);
456
530
  }
457
531
  .send[disabled] { opacity: 0.5; cursor: default; }
@@ -464,6 +538,8 @@ textarea::placeholder { color: var(--ink-dim); }
464
538
  }
465
539
  .bug svg { transition: transform 0.1s ease-out, width 0.22s ease, height 0.22s ease; }
466
540
  .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; } }
467
543
  @keyframes rise { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } }
468
544
  }
469
545
  `;
@@ -484,7 +560,7 @@ textarea::placeholder { color: var(--ink-dim); }
484
560
  <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="fsf-title" hidden>
485
561
  <div class="grab" aria-hidden="true"></div>
486
562
  <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>
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>
488
564
  <textarea aria-labelledby="fsf-title" placeholder="What should change?" maxlength="2000"></textarea>
489
565
  <p class="status" role="status" aria-live="polite"></p>
490
566
  <button type="button" class="send" disabled>Send feedback</button>
@@ -496,6 +572,7 @@ textarea::placeholder { color: var(--ink-dim); }
496
572
  const sheet = q(".sheet");
497
573
  const shot = q(".shot");
498
574
  const img = q(".shot img");
575
+ const shotLabel = q(".shot-label");
499
576
  const remove = q(".remove");
500
577
  const text = q("textarea");
501
578
  const status = q(".status");
@@ -706,6 +783,11 @@ textarea::placeholder { color: var(--ink-dim); }
706
783
  "--font-body": theme.fontBody,
707
784
  "--font-display": theme.fontDisplay,
708
785
  "--display-weight": theme.displayWeight,
786
+ "--display-tracking": theme.displayTracking,
787
+ "--font-button": theme.fontButton,
788
+ "--button-weight": theme.buttonWeight,
789
+ "--button-tracking": theme.buttonTracking,
790
+ "--button-transform": theme.buttonTransform,
709
791
  "--radius": theme.radius,
710
792
  "--button-radius": theme.buttonRadius
711
793
  };
@@ -715,15 +797,21 @@ textarea::placeholder { color: var(--ink-dim); }
715
797
  const sync = () => {
716
798
  send.disabled = busy || text.value.trim().length === 0;
717
799
  };
800
+ let round = 0;
801
+ let pending = null;
718
802
  const setShot = (url) => {
719
803
  screenshot = url;
720
804
  shot.hidden = url === null;
805
+ shot.removeAttribute("data-pending");
806
+ shotLabel.textContent = "Screenshot attached";
721
807
  if (url) img.src = url;
722
808
  else img.removeAttribute("src");
723
809
  };
724
810
  const close = () => {
725
811
  if (closeTimer) clearTimeout(closeTimer);
726
812
  closeTimer = null;
813
+ round += 1;
814
+ pending = null;
727
815
  sheet.hidden = true;
728
816
  scrim.hidden = true;
729
817
  bug.hidden = false;
@@ -734,20 +822,28 @@ textarea::placeholder { color: var(--ink-dim); }
734
822
  sync();
735
823
  schedulePlace();
736
824
  };
737
- const open = async () => {
738
- if (!sheet.hidden || busy) return;
739
- busy = true;
825
+ const open = () => {
826
+ if (!sheet.hidden) return pending ?? Promise.resolve();
827
+ if (busy) return Promise.resolve();
740
828
  paint();
829
+ const mine = round += 1;
741
830
  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);
831
+ setShot(null);
832
+ shot.hidden = false;
833
+ shot.setAttribute("data-pending", "");
834
+ shotLabel.textContent = "Taking a screenshot";
746
835
  tuck.hidden = spot.state !== "float";
747
836
  scrim.hidden = false;
748
837
  sheet.hidden = false;
749
838
  sync();
750
- text.focus();
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;
751
847
  };
752
848
  const submit = async () => {
753
849
  const message = text.value.trim();
@@ -755,6 +851,9 @@ textarea::placeholder { color: var(--ink-dim); }
755
851
  busy = true;
756
852
  sync();
757
853
  status.textContent = "";
854
+ const mine = round;
855
+ if (pending) await pending;
856
+ if (mine !== round) return;
758
857
  try {
759
858
  await options.submit({
760
859
  message,
@@ -778,6 +877,11 @@ textarea::placeholder { color: var(--ink-dim); }
778
877
  if (!suppressClick) void open();
779
878
  });
780
879
  bug.addEventListener("pointerdown", onDown);
880
+ if (!options.capture)
881
+ bug.addEventListener("pointerdown", () => void Promise.resolve().then(() => (init_modern_screenshot(), modern_screenshot_exports)).catch(() => {
882
+ }), {
883
+ once: true
884
+ });
781
885
  bug.addEventListener("pointermove", onMove);
782
886
  bug.addEventListener("pointerup", onUp);
783
887
  bug.addEventListener("pointercancel", onUp);
@@ -797,7 +901,11 @@ textarea::placeholder { color: var(--ink-dim); }
797
901
  );
798
902
  });
799
903
  scrim.addEventListener("click", close);
800
- remove.addEventListener("click", () => setShot(null));
904
+ remove.addEventListener("click", () => {
905
+ round += 1;
906
+ pending = null;
907
+ setShot(null);
908
+ });
801
909
  text.addEventListener("input", sync);
802
910
  send.addEventListener("click", () => void submit());
803
911
  sheet.addEventListener("keydown", (event) => {