@goflowstate/widget-sdk 0.3.1 → 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/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // @goflowstate/widget-sdk v0.3.1
1
+ // @goflowstate/widget-sdk v0.3.4
2
2
  // https://github.com/goflowstate/goflowstate-sdk
3
3
 
4
4
  var __defProp = Object.defineProperty;
@@ -121,6 +121,46 @@ var primaryButton = (doc, ground) => {
121
121
  });
122
122
  return best;
123
123
  };
124
+ var TEXT_SAMPLE_LIMIT = 600;
125
+ var DISPLAY_MIN_PX = 22;
126
+ var sizeOf = (style) => {
127
+ const n = parseFloat(style.fontSize);
128
+ return Number.isFinite(n) && n > 0 ? n : 16;
129
+ };
130
+ var textSamples = (doc) => {
131
+ const out = [];
132
+ const all = doc.body.querySelectorAll("*");
133
+ for (let i = 0; i < all.length && out.length < TEXT_SAMPLE_LIMIT; i += 1) {
134
+ const el = all[i];
135
+ if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || inOurs(el)) continue;
136
+ let chars = 0;
137
+ for (const node of Array.from(el.childNodes)) {
138
+ if (node.nodeType === 3) chars += (node.textContent ?? "").trim().length;
139
+ }
140
+ if (chars === 0 || !visible(el)) continue;
141
+ const style = styleOf(el);
142
+ if (!style.fontFamily) continue;
143
+ out.push({
144
+ family: style.fontFamily,
145
+ size: sizeOf(style),
146
+ chars,
147
+ style,
148
+ heading: /^H[1-3]$/.test(el.tagName)
149
+ });
150
+ }
151
+ return out;
152
+ };
153
+ var trackingEm = (style, min, max, fallback) => {
154
+ const px = style ? parseFloat(style.letterSpacing) : NaN;
155
+ const em = style && Number.isFinite(px) ? px / sizeOf(style) : fallback;
156
+ const held = Math.min(max, Math.max(min, em));
157
+ return held === 0 ? "0em" : `${Math.round(held * 1e3) / 1e3}em`;
158
+ };
159
+ var weightIn = (style, min, max, fallback) => {
160
+ const raw = style?.fontWeight ?? "";
161
+ const n = raw === "bold" ? 700 : raw === "normal" ? 400 : parseFloat(raw);
162
+ return Number.isFinite(n) ? String(Math.min(max, Math.max(min, n))) : fallback;
163
+ };
124
164
  function readFlowTheme(doc = document) {
125
165
  const body = doc.body;
126
166
  const stage = body.querySelector(":scope > :not(script):not(style):not([data-fs-feedback-host])");
@@ -143,12 +183,32 @@ function readFlowTheme(doc = document) {
143
183
  const pinnedCtaInk = parseColor(rootVar(doc, ["--fs-feedback-cta-ink"]));
144
184
  const sampledCtaInk = !pinnedCta && sampledCta && buttonStyle ? parseColor(buttonStyle.color) : null;
145
185
  const ctaInk = pinnedCtaInk ?? (sampledCtaInk && contrast(sampledCtaInk, cta) >= 3 ? sampledCtaInk : null) ?? (luminance(cta) > 0.4 ? BLACK : WHITE);
146
- const heading = Array.from(doc.querySelectorAll('h1, h2, [class*="title"]')).find(
186
+ const samples = textSamples(doc);
187
+ const headings = Array.from(doc.querySelectorAll('h1, h2, [class*="title"]')).filter(
147
188
  (el) => !inOurs(el) && visible(el)
148
189
  );
149
- const headingStyle = heading ? styleOf(heading) : null;
150
- const fontBody = rootVar(doc, ["--fs-feedback-font"]) || (stage ? styleOf(stage).fontFamily : "") || bodyStyle.fontFamily || rootVar(doc, ["--sb-font-body", "--font-body"]) || "system-ui, sans-serif";
190
+ const largest = (items, size) => items.reduce(
191
+ (best, item) => best === null || size(item) > size(best) ? item : best,
192
+ null
193
+ );
194
+ const bigHeading = largest(
195
+ headings.filter((el) => sizeOf(styleOf(el)) >= DISPLAY_MIN_PX),
196
+ (el) => sizeOf(styleOf(el))
197
+ );
198
+ const bigLine = largest(
199
+ samples.filter((t) => t.size >= DISPLAY_MIN_PX && t.chars >= 3),
200
+ (t) => t.size
201
+ );
202
+ const headingStyle = bigHeading ? styleOf(bigHeading) : bigLine?.style ?? (headings[0] ? styleOf(headings[0]) : null);
203
+ const byFamily = /* @__PURE__ */ new Map();
204
+ for (const t of samples) {
205
+ if (t.size < DISPLAY_MIN_PX && !t.heading)
206
+ byFamily.set(t.family, (byFamily.get(t.family) ?? 0) + t.chars);
207
+ }
208
+ const readBody = largest(Array.from(byFamily), ([, chars]) => chars)?.[0] ?? "";
209
+ 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";
151
210
  const fontDisplay = rootVar(doc, ["--fs-feedback-font-display"]) || headingStyle?.fontFamily || rootVar(doc, ["--sb-font-display", "--sb-font-title", "--font-display"]) || fontBody;
211
+ const buttonType = buttonStyle?.fontFamily ? buttonStyle : null;
152
212
  const field = Array.from(
153
213
  doc.querySelectorAll('textarea, input[type="text"], input:not([type])')
154
214
  ).find((el) => !inOurs(el) && visible(el));
@@ -172,6 +232,11 @@ function readFlowTheme(doc = document) {
172
232
  fontBody,
173
233
  fontDisplay,
174
234
  displayWeight: headingStyle?.fontWeight || "500",
235
+ displayTracking: trackingEm(headingStyle, -0.04, 0.02, -0.01),
236
+ fontButton: buttonType?.fontFamily || fontBody,
237
+ buttonWeight: weightIn(buttonType, 500, 800, "600"),
238
+ buttonTracking: trackingEm(buttonType, -0.02, 0.08, 0),
239
+ buttonTransform: buttonType?.textTransform === "uppercase" ? "uppercase" : "none",
175
240
  radius,
176
241
  buttonRadius: buttonRadius === null ? "999px" : `${Math.min(buttonRadius, 999)}px`,
177
242
  dark
@@ -197,9 +262,10 @@ function encodeScreenshot(canvas) {
197
262
  return null;
198
263
  }
199
264
  async function captureViewport(ground) {
200
- const { domToCanvas } = await import("modern-screenshot");
201
265
  const width = window.innerWidth;
202
266
  const height = window.innerHeight;
267
+ const { scrollX, scrollY } = window;
268
+ const { domToCanvas } = await import("modern-screenshot");
203
269
  const scale = Math.min(
204
270
  window.devicePixelRatio || 1,
205
271
  SCREENSHOT_MAX_SIDE / Math.max(width, height)
@@ -209,7 +275,7 @@ async function captureViewport(ground) {
209
275
  height,
210
276
  scale,
211
277
  backgroundColor: ground,
212
- style: { transform: `translate(${-window.scrollX}px, ${-window.scrollY}px)` },
278
+ style: { transform: `translate(${-scrollX}px, ${-scrollY}px)` },
213
279
  filter: (node) => !(node instanceof Element && node.hasAttribute(FEEDBACK_HOST_ATTR))
214
280
  });
215
281
  return encodeScreenshot(canvas);
@@ -227,21 +293,168 @@ var withTimeout = (work, ms) => new Promise((resolve) => {
227
293
  }
228
294
  );
229
295
  });
296
+ var PLACEMENT_BASE = 16;
297
+ var PLACEMENT_STEP = 8;
298
+ var PLACEMENT_PAD = 8;
299
+ var PLACEMENT_MAX_SHARE = 0.6;
300
+ var PLACEMENT_READ_COST = 240;
301
+ var EMPTY = 0;
302
+ var READ = 1;
303
+ var TAP = 2;
304
+ var SAMPLES = 5;
305
+ function choosePlacement(box, viewportHeight, at) {
306
+ const max = Math.max(PLACEMENT_BASE, viewportHeight * PLACEMENT_MAX_SHARE);
307
+ const span = box.size + PLACEMENT_PAD * 2;
308
+ const offsets = Array.from({ length: SAMPLES }, (_, i) => span * i / (SAMPLES - 1));
309
+ const x0 = box.left - PLACEMENT_PAD;
310
+ let best = PLACEMENT_BASE;
311
+ let bestCost = Infinity;
312
+ for (let bottom = PLACEMENT_BASE; bottom <= max; bottom += PLACEMENT_STEP) {
313
+ if (bottom >= bestCost) break;
314
+ const y0 = box.top - (bottom - box.bottom) - PLACEMENT_PAD;
315
+ let worst = EMPTY;
316
+ for (const dy of offsets) {
317
+ for (const dx of offsets) {
318
+ const found = at(x0 + dx, y0 + dy);
319
+ if (found > worst) worst = found;
320
+ if (worst === TAP) break;
321
+ }
322
+ if (worst === TAP) break;
323
+ }
324
+ if (worst === TAP) continue;
325
+ const cost = bottom + (worst === READ ? PLACEMENT_READ_COST : 0);
326
+ if (cost < bestCost) {
327
+ bestCost = cost;
328
+ best = bottom;
329
+ }
330
+ }
331
+ return best;
332
+ }
333
+ var TAPPABLE = 'a[href],button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],[role="slider"],[role="option"],[tabindex]:not([tabindex="-1"]),[contenteditable=""],[contenteditable="true"],[onclick]';
334
+ var PICTURE = "img,svg,canvas,video,picture";
335
+ var SURFACE_SHARE = 0.8;
336
+ function occupancyReader(host, doc = document) {
337
+ if (typeof doc.elementsFromPoint !== "function") return () => EMPTY;
338
+ const view = doc.defaultView;
339
+ const width = view?.innerWidth ?? 1;
340
+ const height = view?.innerHeight ?? 1;
341
+ const seen = /* @__PURE__ */ new Map();
342
+ const read = (el) => {
343
+ const known = seen.get(el);
344
+ if (known) return known;
345
+ const style = view ? view.getComputedStyle(el) : null;
346
+ let tap = el.matches(TAPPABLE) || style?.cursor === "pointer";
347
+ if (tap) {
348
+ const r = el.getBoundingClientRect();
349
+ if (r.width * r.height >= width * height * SURFACE_SHARE) tap = false;
350
+ }
351
+ const text = [];
352
+ if (typeof doc.createRange === "function") {
353
+ for (const node of Array.from(el.childNodes)) {
354
+ if (node.nodeType !== 3 || !node.nodeValue || !node.nodeValue.trim()) continue;
355
+ const range = doc.createRange();
356
+ range.selectNodeContents(node);
357
+ if (typeof range.getClientRects === "function")
358
+ text.push(...Array.from(range.getClientRects()));
359
+ }
360
+ }
361
+ const ground = parseColor(style?.backgroundColor);
362
+ const image = style?.backgroundImage ?? "";
363
+ const reading = {
364
+ tap,
365
+ picture: el.matches(PICTURE) || el.closest("svg") !== null,
366
+ text,
367
+ opaque: ground !== null && ground[3] >= 0.5 || image !== "" && image !== "none"
368
+ };
369
+ seen.set(el, reading);
370
+ return reading;
371
+ };
372
+ return (x, y) => {
373
+ const cx = Math.min(Math.max(x, 0), width - 1);
374
+ const cy = Math.min(Math.max(y, 0), height - 1);
375
+ const stack = doc.elementsFromPoint(cx, cy).filter((el) => el !== host && el !== doc.documentElement && el !== doc.body);
376
+ for (let el = stack[0] ?? null; el && el !== doc.body; el = el.parentElement) {
377
+ if (read(el).tap) return TAP;
378
+ }
379
+ for (const el of stack) {
380
+ const r = read(el);
381
+ if (r.picture || r.text.some((t) => cx >= t.left && cx <= t.right && cy >= t.top && cy <= t.bottom))
382
+ return READ;
383
+ if (r.opaque) break;
384
+ }
385
+ return EMPTY;
386
+ };
387
+ }
388
+ var BUTTON_SIZE = 44;
389
+ var TAB_WIDTH = 26;
390
+ var TAB_HEIGHT = 54;
391
+ var EDGE_MARGIN = 12;
392
+ var DOCK_PAST = 14;
393
+ var SPOT_KEY = "fs-feedback-spot";
394
+ var projectMomentum = (v, deceleration = 0.992) => v / 1e3 * deceleration / (1 - deceleration);
395
+ var rubberband = (overshoot, dimension, constant = 0.55) => overshoot * dimension * constant / (dimension + constant * Math.abs(overshoot));
396
+ var clampY = (y, height, view) => Math.min(
397
+ Math.max(y, view.top + EDGE_MARGIN),
398
+ Math.max(view.top + EDGE_MARGIN, view.height - view.bottom - EDGE_MARGIN - height)
399
+ );
400
+ function restingSpot(state, left, y, view) {
401
+ if (state === "float") {
402
+ return {
403
+ state,
404
+ x: left ? EDGE_MARGIN : view.width - BUTTON_SIZE - EDGE_MARGIN,
405
+ y: clampY(y, BUTTON_SIZE, view)
406
+ };
407
+ }
408
+ return { state, x: left ? 0 : view.width - TAB_WIDTH, y: clampY(y, TAB_HEIGHT, view) };
409
+ }
410
+ function settleRelease(from, velocity, view) {
411
+ const py = from.y + projectMomentum(velocity.y);
412
+ if (from.state !== "float") return restingSpot(from.state, from.state === "dock-left", py, view);
413
+ const px = from.x + projectMomentum(velocity.x);
414
+ const maxX = view.width - BUTTON_SIZE;
415
+ const tabY = py + (BUTTON_SIZE - TAB_HEIGHT) / 2;
416
+ if (px < -DOCK_PAST || from.x < 3) return restingSpot("dock-left", true, tabY, view);
417
+ if (px > maxX + DOCK_PAST || from.x > maxX - 3)
418
+ return restingSpot("dock-right", false, tabY, view);
419
+ return restingSpot("float", px + BUTTON_SIZE / 2 < view.width / 2, py, view);
420
+ }
230
421
  var BUG_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="7" width="8" height="12" rx="4"/><path d="M9.5 7a2.5 2.5 0 0 1 5 0M12 11v8M4 13h4M16 13h4M5 7l3.2 2.4M19 7l-3.2 2.4M5 20l3.2-2.6M19 20l-3.2-2.6"/></svg>';
231
422
  var STYLE = `
232
423
  :host { all: initial; }
233
424
  * { box-sizing: border-box; }
234
425
  .bug {
235
- position: fixed; z-index: 2147483000;
236
- right: calc(var(--fs-feedback-right, 14px) + env(safe-area-inset-right, 0px));
237
- bottom: calc(var(--fs-feedback-bottom, 88px) + env(safe-area-inset-bottom, 0px));
238
- width: 44px; height: 44px; border-radius: 50%; padding: 0;
239
- display: grid; place-items: center; cursor: pointer;
426
+ position: fixed; z-index: 2147483000; left: 0; top: 0;
427
+ width: 44px; height: 44px; border-radius: 22px; padding: 0; margin: 0;
428
+ display: var(--fs-feedback-display, grid); place-items: center; cursor: grab;
240
429
  background: var(--surface); color: var(--ink); border: 1px solid var(--line);
241
- box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
242
- -webkit-tap-highlight-color: transparent;
430
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2), 0 2px 5px rgba(0, 0, 0, 0.12);
431
+ -webkit-tap-highlight-color: transparent; touch-action: none; user-select: none; -webkit-user-select: none;
432
+ will-change: transform;
433
+ }
434
+ @supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
435
+ .bug {
436
+ background: color-mix(in srgb, var(--surface) 74%, transparent);
437
+ -webkit-backdrop-filter: blur(14px) saturate(160%); backdrop-filter: blur(14px) saturate(160%);
438
+ }
439
+ }
440
+ .bug svg { width: 20px; height: 20px; pointer-events: none; }
441
+ .bug.down svg { transform: scale(0.9); }
442
+ .bug.dragging { cursor: grabbing; box-shadow: 0 18px 36px rgba(0, 0, 0, 0.26), 0 3px 8px rgba(0, 0, 0, 0.14); }
443
+ .bug[data-state^="dock"] { width: 26px; height: 54px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14); opacity: 0.92; }
444
+ .bug[data-state="dock-right"] { border-radius: 16px 0 0 16px; border-right-width: 0; }
445
+ .bug[data-state="dock-left"] { border-radius: 0 16px 16px 0; border-left-width: 0; }
446
+ .bug[data-state^="dock"] svg { width: 16px; height: 16px; }
447
+ .probe {
448
+ position: fixed; visibility: hidden; pointer-events: none; width: 0; height: 0;
449
+ padding: env(safe-area-inset-top, 0px) 0 env(safe-area-inset-bottom, 0px) 0;
450
+ }
451
+ .tuck { align-self: center; font-size: 13px; color: var(--ink-dim); }
452
+ @media (prefers-reduced-transparency: reduce) {
453
+ .bug { background: var(--surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
454
+ }
455
+ @media (prefers-contrast: more) {
456
+ .bug { background: var(--surface); border-color: var(--ink); -webkit-backdrop-filter: none; backdrop-filter: none; }
243
457
  }
244
- .bug svg { width: 20px; height: 20px; }
245
458
  .bug:focus-visible, .send:focus-visible, .link:focus-visible, textarea:focus-visible {
246
459
  outline: 2px solid var(--ink); outline-offset: 2px;
247
460
  }
@@ -256,9 +469,15 @@ var STYLE = `
256
469
  font-family: var(--font-body); font-size: 15px; line-height: 1.4;
257
470
  }
258
471
  .grab { width: 36px; height: 4px; border-radius: 2px; background: var(--line); align-self: center; }
259
- h2 { margin: 0; font-family: var(--font-display); font-weight: var(--display-weight); font-size: 19px; line-height: 1.2; }
472
+ h2 {
473
+ margin: 2px 0 0; font-family: var(--font-display); font-weight: var(--display-weight);
474
+ font-size: 22px; line-height: 1.15; letter-spacing: var(--display-tracking); text-wrap: balance;
475
+ }
260
476
  .shot { display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--ink-dim); }
261
477
  .shot img { width: 50px; max-height: 108px; object-fit: cover; object-position: top; border-radius: 8px; border: 1px solid var(--line); }
478
+ .ghost { display: none; width: 50px; height: 108px; border-radius: 8px; border: 1px solid var(--line); background: var(--fill); }
479
+ .shot[data-pending] .ghost { display: block; }
480
+ .shot[data-pending] img { display: none; }
262
481
  .link { background: none; border: 0; padding: 0; font: inherit; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
263
482
  textarea {
264
483
  width: 100%; min-height: 84px; resize: none; font: inherit; font-size: 16px; color: var(--ink);
@@ -266,19 +485,29 @@ textarea {
266
485
  }
267
486
  textarea::placeholder { color: var(--ink-dim); }
268
487
  .send {
269
- font: inherit; font-weight: 500; cursor: pointer; border: 0; padding: 14px;
488
+ font-family: var(--font-button); font-weight: var(--button-weight); font-size: 17px; line-height: 1.2;
489
+ letter-spacing: var(--button-tracking); text-transform: var(--button-transform);
490
+ cursor: pointer; border: 0; padding: 15px 14px;
270
491
  background: var(--cta); color: var(--cta-ink); border-radius: var(--button-radius);
271
492
  }
272
493
  .send[disabled] { opacity: 0.5; cursor: default; }
273
494
  .status { margin: 0; font-size: 13px; color: var(--ink-dim); min-height: 1.4em; }
274
495
  [hidden] { display: none !important; }
275
496
  @media (prefers-reduced-motion: no-preference) {
497
+ .bug {
498
+ transition: width 0.22s cubic-bezier(0.2, 0.8, 0.2, 1), height 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
499
+ border-radius 0.22s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.22s ease;
500
+ }
501
+ .bug svg { transition: transform 0.1s ease-out, width 0.22s ease, height 0.22s ease; }
276
502
  .sheet { animation: rise 0.22s ease-out; }
503
+ .shot[data-pending] .ghost { animation: wait 1.1s ease-in-out infinite alternate; }
504
+ @keyframes wait { from { opacity: 0.45; } to { opacity: 1; } }
277
505
  @keyframes rise { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } }
278
506
  }
279
507
  `;
280
508
  var mounted = null;
281
509
  var feedbackOptedOut = (doc = document) => doc.documentElement.getAttribute("data-fs-feedback") === "off";
510
+ var SCROLL_OPTIONS = { capture: true, passive: true };
282
511
  function mountFlowFeedback(options) {
283
512
  if (typeof document === "undefined" || !document.body) return null;
284
513
  if (mounted) return mounted;
@@ -287,15 +516,17 @@ function mountFlowFeedback(options) {
287
516
  host.setAttribute(FEEDBACK_HOST_ATTR, "");
288
517
  const root = host.attachShadow({ mode: "open" });
289
518
  root.innerHTML = `<style>${STYLE}</style>
290
- <button type="button" class="bug" aria-label="Send feedback on this page">${BUG_ICON}</button>
519
+ <button type="button" class="bug" data-state="float" aria-label="Send feedback on this page">${BUG_ICON}</button>
520
+ <div class="probe" aria-hidden="true"></div>
291
521
  <div class="scrim" hidden></div>
292
522
  <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="fsf-title" hidden>
293
523
  <div class="grab" aria-hidden="true"></div>
294
524
  <h2 id="fsf-title">Feedback on this page</h2>
295
- <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>
525
+ <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>
296
526
  <textarea aria-labelledby="fsf-title" placeholder="What should change?" maxlength="2000"></textarea>
297
527
  <p class="status" role="status" aria-live="polite"></p>
298
528
  <button type="button" class="send" disabled>Send feedback</button>
529
+ <button type="button" class="link tuck">Tuck the button to the side</button>
299
530
  </div>`;
300
531
  const q = (selector) => root.querySelector(selector);
301
532
  const bug = q(".bug");
@@ -303,14 +534,204 @@ function mountFlowFeedback(options) {
303
534
  const sheet = q(".sheet");
304
535
  const shot = q(".shot");
305
536
  const img = q(".shot img");
537
+ const shotLabel = q(".shot-label");
306
538
  const remove = q(".remove");
307
539
  const text = q("textarea");
308
540
  const status = q(".status");
309
541
  const send = q(".send");
542
+ const tuck = q(".tuck");
543
+ const probe = q(".probe");
310
544
  let theme = readFlowTheme();
311
545
  let screenshot = null;
312
546
  let busy = false;
313
547
  let closeTimer = null;
548
+ let placeTimer = null;
549
+ let autoBottom = PLACEMENT_BASE;
550
+ let spot = { state: "float", x: 0, y: 0 };
551
+ let placedByPerson = false;
552
+ let velocity = { x: 0, y: 0 };
553
+ let frame = 0;
554
+ const px = (name) => {
555
+ const raw = getComputedStyle(host).getPropertyValue(name).trim();
556
+ const n = raw ? parseFloat(raw) : NaN;
557
+ return Number.isFinite(n) ? n : null;
558
+ };
559
+ const viewport = () => {
560
+ const style = getComputedStyle(probe);
561
+ return {
562
+ width: window.innerWidth,
563
+ height: window.innerHeight,
564
+ top: parseFloat(style.paddingTop) || 0,
565
+ bottom: parseFloat(style.paddingBottom) || 0
566
+ };
567
+ };
568
+ const draw = () => {
569
+ bug.style.transform = `translate3d(${spot.x}px, ${spot.y}px, 0)`;
570
+ };
571
+ const setState = (next) => {
572
+ spot.state = next;
573
+ bug.dataset.state = next;
574
+ };
575
+ const remember = (spot2) => {
576
+ if (!placedByPerson) return;
577
+ try {
578
+ const view = viewport();
579
+ const width = spot2.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
580
+ localStorage.setItem(
581
+ SPOT_KEY,
582
+ JSON.stringify({
583
+ state: spot2.state,
584
+ left: spot2.x + width / 2 < view.width / 2,
585
+ y: view.height > 0 ? spot2.y / view.height : 0
586
+ })
587
+ );
588
+ } catch {
589
+ }
590
+ };
591
+ const still = () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
592
+ const springTo = (to, flicked) => {
593
+ cancelAnimationFrame(frame);
594
+ frame = 0;
595
+ setState(to.state);
596
+ remember(to);
597
+ if (still() || typeof requestAnimationFrame !== "function") {
598
+ spot = { ...to };
599
+ velocity = { x: 0, y: 0 };
600
+ draw();
601
+ return;
602
+ }
603
+ const omega = 2 * Math.PI / 0.4;
604
+ const k = omega * omega;
605
+ const c = 2 * (flicked ? 0.8 : 1) * omega;
606
+ let last = performance.now();
607
+ const tick = (now) => {
608
+ const dt = Math.min(0.032, (now - last) / 1e3);
609
+ last = now;
610
+ velocity.x += (-k * (spot.x - to.x) - c * velocity.x) * dt;
611
+ velocity.y += (-k * (spot.y - to.y) - c * velocity.y) * dt;
612
+ spot.x += velocity.x * dt;
613
+ spot.y += velocity.y * dt;
614
+ const close2 = Math.abs(spot.x - to.x) < 0.3 && Math.abs(spot.y - to.y) < 0.3 && Math.hypot(velocity.x, velocity.y) < 6;
615
+ if (close2) {
616
+ spot = { ...to };
617
+ velocity = { x: 0, y: 0 };
618
+ frame = 0;
619
+ draw();
620
+ return;
621
+ }
622
+ draw();
623
+ frame = requestAnimationFrame(tick);
624
+ };
625
+ frame = requestAnimationFrame(tick);
626
+ };
627
+ const place = () => {
628
+ placeTimer = null;
629
+ if (!sheet.hidden || bug.hidden || drag || frame) return;
630
+ const view = viewport();
631
+ if (placedByPerson || spot.state !== "float") {
632
+ const width = spot.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
633
+ spot = restingSpot(spot.state, spot.x + width / 2 < view.width / 2, spot.y, view);
634
+ draw();
635
+ return;
636
+ }
637
+ const right = px("--fs-feedback-right") ?? 14;
638
+ const left = view.width - right - BUTTON_SIZE;
639
+ const pinned = px("--fs-feedback-bottom");
640
+ if (pinned === null && typeof document.elementsFromPoint === "function") {
641
+ autoBottom = choosePlacement(
642
+ {
643
+ left,
644
+ top: view.height - view.bottom - autoBottom - BUTTON_SIZE,
645
+ size: BUTTON_SIZE,
646
+ bottom: autoBottom
647
+ },
648
+ view.height,
649
+ occupancyReader(host)
650
+ );
651
+ }
652
+ const bottom = pinned ?? autoBottom;
653
+ spot = { state: "float", x: left, y: view.height - view.bottom - bottom - BUTTON_SIZE };
654
+ draw();
655
+ };
656
+ const schedulePlace = () => {
657
+ if (placeTimer) clearTimeout(placeTimer);
658
+ placeTimer = setTimeout(place, 150);
659
+ };
660
+ let drag = null;
661
+ let suppressClick = false;
662
+ const onDown = (event) => {
663
+ if (!sheet.hidden || busy) return;
664
+ bug.setPointerCapture?.(event.pointerId);
665
+ cancelAnimationFrame(frame);
666
+ frame = 0;
667
+ velocity = { x: 0, y: 0 };
668
+ drag = {
669
+ id: event.pointerId,
670
+ ox: event.clientX - spot.x,
671
+ oy: event.clientY - spot.y,
672
+ sx: event.clientX,
673
+ sy: event.clientY,
674
+ moved: false,
675
+ history: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }]
676
+ };
677
+ bug.classList.add("down");
678
+ };
679
+ const onMove = (event) => {
680
+ if (!drag || event.pointerId !== drag.id) return;
681
+ const dx = event.clientX - drag.sx;
682
+ const dy = event.clientY - drag.sy;
683
+ if (!drag.moved && Math.hypot(dx, dy) < 8) return;
684
+ if (!drag.moved) {
685
+ drag.moved = true;
686
+ bug.classList.add("dragging");
687
+ }
688
+ drag.history.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });
689
+ if (drag.history.length > 6) drag.history.shift();
690
+ const view = viewport();
691
+ if (spot.state !== "float") {
692
+ const inward = spot.state === "dock-left" ? dx : -dx;
693
+ if (inward <= 18) {
694
+ spot = restingSpot(spot.state, spot.state === "dock-left", event.clientY - drag.oy, view);
695
+ draw();
696
+ return;
697
+ }
698
+ setState("float");
699
+ drag.ox = BUTTON_SIZE / 2;
700
+ drag.oy = BUTTON_SIZE / 2;
701
+ }
702
+ let x = event.clientX - drag.ox;
703
+ let y = event.clientY - drag.oy;
704
+ const maxX = view.width - BUTTON_SIZE;
705
+ const maxY = view.height - BUTTON_SIZE;
706
+ if (x < 0) x = rubberband(x, 60);
707
+ else if (x > maxX) x = maxX + rubberband(x - maxX, 60);
708
+ if (y < 0) y = rubberband(y, 80);
709
+ else if (y > maxY) y = maxY + rubberband(y - maxY, 80);
710
+ spot.x = x;
711
+ spot.y = y;
712
+ draw();
713
+ };
714
+ const onUp = (event) => {
715
+ if (!drag || event.pointerId !== drag.id) return;
716
+ const ended = drag;
717
+ drag = null;
718
+ bug.classList.remove("down", "dragging");
719
+ if (!ended.moved) return;
720
+ suppressClick = true;
721
+ setTimeout(() => {
722
+ suppressClick = false;
723
+ }, 0);
724
+ const first = ended.history[0];
725
+ const last = ended.history[ended.history.length - 1];
726
+ const dt = Math.max(1, last.t - first.t);
727
+ const paused = event.timeStamp - last.t > 90;
728
+ velocity = paused ? { x: 0, y: 0 } : { x: (last.x - first.x) / dt * 1e3, y: (last.y - first.y) / dt * 1e3 };
729
+ placedByPerson = true;
730
+ springTo(settleRelease(spot, velocity, viewport()), Math.hypot(velocity.x, velocity.y) > 350);
731
+ };
732
+ const observer = typeof MutationObserver === "function" ? new MutationObserver((records) => {
733
+ if (records.some((r) => r.target !== host)) schedulePlace();
734
+ }) : null;
314
735
  const paint = () => {
315
736
  theme = readFlowTheme();
316
737
  const vars = {
@@ -324,6 +745,11 @@ function mountFlowFeedback(options) {
324
745
  "--font-body": theme.fontBody,
325
746
  "--font-display": theme.fontDisplay,
326
747
  "--display-weight": theme.displayWeight,
748
+ "--display-tracking": theme.displayTracking,
749
+ "--font-button": theme.fontButton,
750
+ "--button-weight": theme.buttonWeight,
751
+ "--button-tracking": theme.buttonTracking,
752
+ "--button-transform": theme.buttonTransform,
327
753
  "--radius": theme.radius,
328
754
  "--button-radius": theme.buttonRadius
329
755
  };
@@ -333,15 +759,21 @@ function mountFlowFeedback(options) {
333
759
  const sync = () => {
334
760
  send.disabled = busy || text.value.trim().length === 0;
335
761
  };
762
+ let round = 0;
763
+ let pending = null;
336
764
  const setShot = (url) => {
337
765
  screenshot = url;
338
766
  shot.hidden = url === null;
767
+ shot.removeAttribute("data-pending");
768
+ shotLabel.textContent = "Screenshot attached";
339
769
  if (url) img.src = url;
340
770
  else img.removeAttribute("src");
341
771
  };
342
772
  const close = () => {
343
773
  if (closeTimer) clearTimeout(closeTimer);
344
774
  closeTimer = null;
775
+ round += 1;
776
+ pending = null;
345
777
  sheet.hidden = true;
346
778
  scrim.hidden = true;
347
779
  bug.hidden = false;
@@ -350,20 +782,30 @@ function mountFlowFeedback(options) {
350
782
  status.textContent = "";
351
783
  busy = false;
352
784
  sync();
785
+ schedulePlace();
353
786
  };
354
- const open = async () => {
355
- if (!sheet.hidden || busy) return;
356
- busy = true;
787
+ const open = () => {
788
+ if (!sheet.hidden) return pending ?? Promise.resolve();
789
+ if (busy) return Promise.resolve();
357
790
  paint();
791
+ const mine = round += 1;
358
792
  bug.hidden = true;
359
- const capture = options.capture ?? (() => captureViewport(theme.ground));
360
- const url = await withTimeout(Promise.resolve().then(capture), CAPTURE_TIMEOUT_MS);
361
- busy = false;
362
- setShot(url);
793
+ setShot(null);
794
+ shot.hidden = false;
795
+ shot.setAttribute("data-pending", "");
796
+ shotLabel.textContent = "Taking a screenshot";
797
+ tuck.hidden = spot.state !== "float";
363
798
  scrim.hidden = false;
364
799
  sheet.hidden = false;
365
800
  sync();
366
- text.focus();
801
+ const capture = options.capture ?? (() => captureViewport(theme.ground));
802
+ pending = withTimeout(Promise.resolve().then(capture), CAPTURE_TIMEOUT_MS).then((url) => {
803
+ if (mine !== round) return;
804
+ pending = null;
805
+ setShot(url);
806
+ if (!root.activeElement || root.activeElement === bug) text.focus();
807
+ });
808
+ return pending;
367
809
  };
368
810
  const submit = async () => {
369
811
  const message = text.value.trim();
@@ -371,6 +813,9 @@ function mountFlowFeedback(options) {
371
813
  busy = true;
372
814
  sync();
373
815
  status.textContent = "";
816
+ const mine = round;
817
+ if (pending) await pending;
818
+ if (mine !== round) return;
374
819
  try {
375
820
  await options.submit({
376
821
  message,
@@ -390,9 +835,39 @@ function mountFlowFeedback(options) {
390
835
  sync();
391
836
  }
392
837
  };
393
- bug.addEventListener("click", () => void open());
838
+ bug.addEventListener("click", () => {
839
+ if (!suppressClick) void open();
840
+ });
841
+ bug.addEventListener("pointerdown", onDown);
842
+ if (!options.capture)
843
+ bug.addEventListener("pointerdown", () => void import("modern-screenshot").catch(() => {
844
+ }), {
845
+ once: true
846
+ });
847
+ bug.addEventListener("pointermove", onMove);
848
+ bug.addEventListener("pointerup", onUp);
849
+ bug.addEventListener("pointercancel", onUp);
850
+ tuck.addEventListener("click", () => {
851
+ const view = viewport();
852
+ const left = spot.x + BUTTON_SIZE / 2 < view.width / 2;
853
+ close();
854
+ placedByPerson = true;
855
+ springTo(
856
+ restingSpot(
857
+ left ? "dock-left" : "dock-right",
858
+ left,
859
+ spot.y + (BUTTON_SIZE - TAB_HEIGHT) / 2,
860
+ view
861
+ ),
862
+ false
863
+ );
864
+ });
394
865
  scrim.addEventListener("click", close);
395
- remove.addEventListener("click", () => setShot(null));
866
+ remove.addEventListener("click", () => {
867
+ round += 1;
868
+ pending = null;
869
+ setShot(null);
870
+ });
396
871
  text.addEventListener("input", sync);
397
872
  send.addEventListener("click", () => void submit());
398
873
  sheet.addEventListener("keydown", (event) => {
@@ -404,6 +879,29 @@ function mountFlowFeedback(options) {
404
879
  });
405
880
  document.body.appendChild(host);
406
881
  paint();
882
+ try {
883
+ const saved = JSON.parse(localStorage.getItem(SPOT_KEY) ?? "null");
884
+ if (saved && typeof saved.y === "number" && (saved.state === "float" || saved.state === "dock-left" || saved.state === "dock-right")) {
885
+ const view = viewport();
886
+ placedByPerson = true;
887
+ setState(saved.state);
888
+ spot = restingSpot(saved.state, saved.left === true, saved.y * view.height, view);
889
+ }
890
+ } catch {
891
+ }
892
+ place();
893
+ schedulePlace();
894
+ observer?.observe(document.body, {
895
+ childList: true,
896
+ subtree: true,
897
+ attributes: true,
898
+ attributeFilter: ["class", "style", "hidden"]
899
+ });
900
+ window.addEventListener("resize", schedulePlace);
901
+ window.addEventListener("load", schedulePlace);
902
+ window.addEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
903
+ document.addEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
904
+ document.addEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
407
905
  options.onReady?.();
408
906
  mounted = {
409
907
  host,
@@ -414,6 +912,14 @@ function mountFlowFeedback(options) {
414
912
  close,
415
913
  unmount: () => {
416
914
  if (closeTimer) clearTimeout(closeTimer);
915
+ if (placeTimer) clearTimeout(placeTimer);
916
+ cancelAnimationFrame(frame);
917
+ observer?.disconnect();
918
+ window.removeEventListener("resize", schedulePlace);
919
+ window.removeEventListener("load", schedulePlace);
920
+ window.removeEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
921
+ document.removeEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
922
+ document.removeEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
417
923
  host.remove();
418
924
  mounted = null;
419
925
  }