@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.
@@ -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
  "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);
@@ -265,21 +331,168 @@ var CanvasWidgetSDK = (() => {
265
331
  }
266
332
  );
267
333
  });
334
+ var PLACEMENT_BASE = 16;
335
+ var PLACEMENT_STEP = 8;
336
+ var PLACEMENT_PAD = 8;
337
+ var PLACEMENT_MAX_SHARE = 0.6;
338
+ var PLACEMENT_READ_COST = 240;
339
+ var EMPTY = 0;
340
+ var READ = 1;
341
+ var TAP = 2;
342
+ var SAMPLES = 5;
343
+ function choosePlacement(box, viewportHeight, at) {
344
+ const max = Math.max(PLACEMENT_BASE, viewportHeight * PLACEMENT_MAX_SHARE);
345
+ const span = box.size + PLACEMENT_PAD * 2;
346
+ const offsets = Array.from({ length: SAMPLES }, (_, i) => span * i / (SAMPLES - 1));
347
+ const x0 = box.left - PLACEMENT_PAD;
348
+ let best = PLACEMENT_BASE;
349
+ let bestCost = Infinity;
350
+ for (let bottom = PLACEMENT_BASE; bottom <= max; bottom += PLACEMENT_STEP) {
351
+ if (bottom >= bestCost) break;
352
+ const y0 = box.top - (bottom - box.bottom) - PLACEMENT_PAD;
353
+ let worst = EMPTY;
354
+ for (const dy of offsets) {
355
+ for (const dx of offsets) {
356
+ const found = at(x0 + dx, y0 + dy);
357
+ if (found > worst) worst = found;
358
+ if (worst === TAP) break;
359
+ }
360
+ if (worst === TAP) break;
361
+ }
362
+ if (worst === TAP) continue;
363
+ const cost = bottom + (worst === READ ? PLACEMENT_READ_COST : 0);
364
+ if (cost < bestCost) {
365
+ bestCost = cost;
366
+ best = bottom;
367
+ }
368
+ }
369
+ return best;
370
+ }
371
+ 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]';
372
+ var PICTURE = "img,svg,canvas,video,picture";
373
+ var SURFACE_SHARE = 0.8;
374
+ function occupancyReader(host, doc = document) {
375
+ if (typeof doc.elementsFromPoint !== "function") return () => EMPTY;
376
+ const view = doc.defaultView;
377
+ const width = view?.innerWidth ?? 1;
378
+ const height = view?.innerHeight ?? 1;
379
+ const seen = /* @__PURE__ */ new Map();
380
+ const read = (el) => {
381
+ const known = seen.get(el);
382
+ if (known) return known;
383
+ const style = view ? view.getComputedStyle(el) : null;
384
+ let tap = el.matches(TAPPABLE) || style?.cursor === "pointer";
385
+ if (tap) {
386
+ const r = el.getBoundingClientRect();
387
+ if (r.width * r.height >= width * height * SURFACE_SHARE) tap = false;
388
+ }
389
+ const text = [];
390
+ if (typeof doc.createRange === "function") {
391
+ for (const node of Array.from(el.childNodes)) {
392
+ if (node.nodeType !== 3 || !node.nodeValue || !node.nodeValue.trim()) continue;
393
+ const range = doc.createRange();
394
+ range.selectNodeContents(node);
395
+ if (typeof range.getClientRects === "function")
396
+ text.push(...Array.from(range.getClientRects()));
397
+ }
398
+ }
399
+ const ground = parseColor(style?.backgroundColor);
400
+ const image = style?.backgroundImage ?? "";
401
+ const reading = {
402
+ tap,
403
+ picture: el.matches(PICTURE) || el.closest("svg") !== null,
404
+ text,
405
+ opaque: ground !== null && ground[3] >= 0.5 || image !== "" && image !== "none"
406
+ };
407
+ seen.set(el, reading);
408
+ return reading;
409
+ };
410
+ return (x, y) => {
411
+ const cx = Math.min(Math.max(x, 0), width - 1);
412
+ const cy = Math.min(Math.max(y, 0), height - 1);
413
+ const stack = doc.elementsFromPoint(cx, cy).filter((el) => el !== host && el !== doc.documentElement && el !== doc.body);
414
+ for (let el = stack[0] ?? null; el && el !== doc.body; el = el.parentElement) {
415
+ if (read(el).tap) return TAP;
416
+ }
417
+ for (const el of stack) {
418
+ const r = read(el);
419
+ if (r.picture || r.text.some((t) => cx >= t.left && cx <= t.right && cy >= t.top && cy <= t.bottom))
420
+ return READ;
421
+ if (r.opaque) break;
422
+ }
423
+ return EMPTY;
424
+ };
425
+ }
426
+ var BUTTON_SIZE = 44;
427
+ var TAB_WIDTH = 26;
428
+ var TAB_HEIGHT = 54;
429
+ var EDGE_MARGIN = 12;
430
+ var DOCK_PAST = 14;
431
+ var SPOT_KEY = "fs-feedback-spot";
432
+ var projectMomentum = (v, deceleration = 0.992) => v / 1e3 * deceleration / (1 - deceleration);
433
+ var rubberband = (overshoot, dimension, constant = 0.55) => overshoot * dimension * constant / (dimension + constant * Math.abs(overshoot));
434
+ var clampY = (y, height, view) => Math.min(
435
+ Math.max(y, view.top + EDGE_MARGIN),
436
+ Math.max(view.top + EDGE_MARGIN, view.height - view.bottom - EDGE_MARGIN - height)
437
+ );
438
+ function restingSpot(state, left, y, view) {
439
+ if (state === "float") {
440
+ return {
441
+ state,
442
+ x: left ? EDGE_MARGIN : view.width - BUTTON_SIZE - EDGE_MARGIN,
443
+ y: clampY(y, BUTTON_SIZE, view)
444
+ };
445
+ }
446
+ return { state, x: left ? 0 : view.width - TAB_WIDTH, y: clampY(y, TAB_HEIGHT, view) };
447
+ }
448
+ function settleRelease(from, velocity, view) {
449
+ const py = from.y + projectMomentum(velocity.y);
450
+ if (from.state !== "float") return restingSpot(from.state, from.state === "dock-left", py, view);
451
+ const px = from.x + projectMomentum(velocity.x);
452
+ const maxX = view.width - BUTTON_SIZE;
453
+ const tabY = py + (BUTTON_SIZE - TAB_HEIGHT) / 2;
454
+ if (px < -DOCK_PAST || from.x < 3) return restingSpot("dock-left", true, tabY, view);
455
+ if (px > maxX + DOCK_PAST || from.x > maxX - 3)
456
+ return restingSpot("dock-right", false, tabY, view);
457
+ return restingSpot("float", px + BUTTON_SIZE / 2 < view.width / 2, py, view);
458
+ }
268
459
  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>';
269
460
  var STYLE = `
270
461
  :host { all: initial; }
271
462
  * { box-sizing: border-box; }
272
463
  .bug {
273
- position: fixed; z-index: 2147483000;
274
- right: calc(var(--fs-feedback-right, 14px) + env(safe-area-inset-right, 0px));
275
- bottom: calc(var(--fs-feedback-bottom, 88px) + env(safe-area-inset-bottom, 0px));
276
- width: 44px; height: 44px; border-radius: 50%; padding: 0;
277
- display: grid; place-items: center; cursor: pointer;
464
+ position: fixed; z-index: 2147483000; left: 0; top: 0;
465
+ width: 44px; height: 44px; border-radius: 22px; padding: 0; margin: 0;
466
+ display: var(--fs-feedback-display, grid); place-items: center; cursor: grab;
278
467
  background: var(--surface); color: var(--ink); border: 1px solid var(--line);
279
- box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
280
- -webkit-tap-highlight-color: transparent;
468
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2), 0 2px 5px rgba(0, 0, 0, 0.12);
469
+ -webkit-tap-highlight-color: transparent; touch-action: none; user-select: none; -webkit-user-select: none;
470
+ will-change: transform;
471
+ }
472
+ @supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
473
+ .bug {
474
+ background: color-mix(in srgb, var(--surface) 74%, transparent);
475
+ -webkit-backdrop-filter: blur(14px) saturate(160%); backdrop-filter: blur(14px) saturate(160%);
476
+ }
477
+ }
478
+ .bug svg { width: 20px; height: 20px; pointer-events: none; }
479
+ .bug.down svg { transform: scale(0.9); }
480
+ .bug.dragging { cursor: grabbing; box-shadow: 0 18px 36px rgba(0, 0, 0, 0.26), 0 3px 8px rgba(0, 0, 0, 0.14); }
481
+ .bug[data-state^="dock"] { width: 26px; height: 54px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14); opacity: 0.92; }
482
+ .bug[data-state="dock-right"] { border-radius: 16px 0 0 16px; border-right-width: 0; }
483
+ .bug[data-state="dock-left"] { border-radius: 0 16px 16px 0; border-left-width: 0; }
484
+ .bug[data-state^="dock"] svg { width: 16px; height: 16px; }
485
+ .probe {
486
+ position: fixed; visibility: hidden; pointer-events: none; width: 0; height: 0;
487
+ padding: env(safe-area-inset-top, 0px) 0 env(safe-area-inset-bottom, 0px) 0;
488
+ }
489
+ .tuck { align-self: center; font-size: 13px; color: var(--ink-dim); }
490
+ @media (prefers-reduced-transparency: reduce) {
491
+ .bug { background: var(--surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
492
+ }
493
+ @media (prefers-contrast: more) {
494
+ .bug { background: var(--surface); border-color: var(--ink); -webkit-backdrop-filter: none; backdrop-filter: none; }
281
495
  }
282
- .bug svg { width: 20px; height: 20px; }
283
496
  .bug:focus-visible, .send:focus-visible, .link:focus-visible, textarea:focus-visible {
284
497
  outline: 2px solid var(--ink); outline-offset: 2px;
285
498
  }
@@ -294,9 +507,15 @@ var CanvasWidgetSDK = (() => {
294
507
  font-family: var(--font-body); font-size: 15px; line-height: 1.4;
295
508
  }
296
509
  .grab { width: 36px; height: 4px; border-radius: 2px; background: var(--line); align-self: center; }
297
- 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
+ }
298
514
  .shot { display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--ink-dim); }
299
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; }
300
519
  .link { background: none; border: 0; padding: 0; font: inherit; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
301
520
  textarea {
302
521
  width: 100%; min-height: 84px; resize: none; font: inherit; font-size: 16px; color: var(--ink);
@@ -304,19 +523,29 @@ textarea {
304
523
  }
305
524
  textarea::placeholder { color: var(--ink-dim); }
306
525
  .send {
307
- 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;
308
529
  background: var(--cta); color: var(--cta-ink); border-radius: var(--button-radius);
309
530
  }
310
531
  .send[disabled] { opacity: 0.5; cursor: default; }
311
532
  .status { margin: 0; font-size: 13px; color: var(--ink-dim); min-height: 1.4em; }
312
533
  [hidden] { display: none !important; }
313
534
  @media (prefers-reduced-motion: no-preference) {
535
+ .bug {
536
+ 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),
537
+ border-radius 0.22s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.22s ease;
538
+ }
539
+ .bug svg { transition: transform 0.1s ease-out, width 0.22s ease, height 0.22s ease; }
314
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; } }
315
543
  @keyframes rise { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } }
316
544
  }
317
545
  `;
318
546
  var mounted = null;
319
547
  var feedbackOptedOut = (doc = document) => doc.documentElement.getAttribute("data-fs-feedback") === "off";
548
+ var SCROLL_OPTIONS = { capture: true, passive: true };
320
549
  function mountFlowFeedback(options) {
321
550
  if (typeof document === "undefined" || !document.body) return null;
322
551
  if (mounted) return mounted;
@@ -325,15 +554,17 @@ textarea::placeholder { color: var(--ink-dim); }
325
554
  host.setAttribute(FEEDBACK_HOST_ATTR, "");
326
555
  const root = host.attachShadow({ mode: "open" });
327
556
  root.innerHTML = `<style>${STYLE}</style>
328
- <button type="button" class="bug" aria-label="Send feedback on this page">${BUG_ICON}</button>
557
+ <button type="button" class="bug" data-state="float" aria-label="Send feedback on this page">${BUG_ICON}</button>
558
+ <div class="probe" aria-hidden="true"></div>
329
559
  <div class="scrim" hidden></div>
330
560
  <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="fsf-title" hidden>
331
561
  <div class="grab" aria-hidden="true"></div>
332
562
  <h2 id="fsf-title">Feedback on this page</h2>
333
- <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>
334
564
  <textarea aria-labelledby="fsf-title" placeholder="What should change?" maxlength="2000"></textarea>
335
565
  <p class="status" role="status" aria-live="polite"></p>
336
566
  <button type="button" class="send" disabled>Send feedback</button>
567
+ <button type="button" class="link tuck">Tuck the button to the side</button>
337
568
  </div>`;
338
569
  const q = (selector) => root.querySelector(selector);
339
570
  const bug = q(".bug");
@@ -341,14 +572,204 @@ textarea::placeholder { color: var(--ink-dim); }
341
572
  const sheet = q(".sheet");
342
573
  const shot = q(".shot");
343
574
  const img = q(".shot img");
575
+ const shotLabel = q(".shot-label");
344
576
  const remove = q(".remove");
345
577
  const text = q("textarea");
346
578
  const status = q(".status");
347
579
  const send = q(".send");
580
+ const tuck = q(".tuck");
581
+ const probe = q(".probe");
348
582
  let theme = readFlowTheme();
349
583
  let screenshot = null;
350
584
  let busy = false;
351
585
  let closeTimer = null;
586
+ let placeTimer = null;
587
+ let autoBottom = PLACEMENT_BASE;
588
+ let spot = { state: "float", x: 0, y: 0 };
589
+ let placedByPerson = false;
590
+ let velocity = { x: 0, y: 0 };
591
+ let frame = 0;
592
+ const px = (name) => {
593
+ const raw = getComputedStyle(host).getPropertyValue(name).trim();
594
+ const n = raw ? parseFloat(raw) : NaN;
595
+ return Number.isFinite(n) ? n : null;
596
+ };
597
+ const viewport = () => {
598
+ const style = getComputedStyle(probe);
599
+ return {
600
+ width: window.innerWidth,
601
+ height: window.innerHeight,
602
+ top: parseFloat(style.paddingTop) || 0,
603
+ bottom: parseFloat(style.paddingBottom) || 0
604
+ };
605
+ };
606
+ const draw = () => {
607
+ bug.style.transform = `translate3d(${spot.x}px, ${spot.y}px, 0)`;
608
+ };
609
+ const setState = (next) => {
610
+ spot.state = next;
611
+ bug.dataset.state = next;
612
+ };
613
+ const remember = (spot2) => {
614
+ if (!placedByPerson) return;
615
+ try {
616
+ const view = viewport();
617
+ const width = spot2.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
618
+ localStorage.setItem(
619
+ SPOT_KEY,
620
+ JSON.stringify({
621
+ state: spot2.state,
622
+ left: spot2.x + width / 2 < view.width / 2,
623
+ y: view.height > 0 ? spot2.y / view.height : 0
624
+ })
625
+ );
626
+ } catch {
627
+ }
628
+ };
629
+ const still = () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
630
+ const springTo = (to, flicked) => {
631
+ cancelAnimationFrame(frame);
632
+ frame = 0;
633
+ setState(to.state);
634
+ remember(to);
635
+ if (still() || typeof requestAnimationFrame !== "function") {
636
+ spot = { ...to };
637
+ velocity = { x: 0, y: 0 };
638
+ draw();
639
+ return;
640
+ }
641
+ const omega = 2 * Math.PI / 0.4;
642
+ const k = omega * omega;
643
+ const c = 2 * (flicked ? 0.8 : 1) * omega;
644
+ let last = performance.now();
645
+ const tick = (now) => {
646
+ const dt = Math.min(0.032, (now - last) / 1e3);
647
+ last = now;
648
+ velocity.x += (-k * (spot.x - to.x) - c * velocity.x) * dt;
649
+ velocity.y += (-k * (spot.y - to.y) - c * velocity.y) * dt;
650
+ spot.x += velocity.x * dt;
651
+ spot.y += velocity.y * dt;
652
+ 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;
653
+ if (close2) {
654
+ spot = { ...to };
655
+ velocity = { x: 0, y: 0 };
656
+ frame = 0;
657
+ draw();
658
+ return;
659
+ }
660
+ draw();
661
+ frame = requestAnimationFrame(tick);
662
+ };
663
+ frame = requestAnimationFrame(tick);
664
+ };
665
+ const place = () => {
666
+ placeTimer = null;
667
+ if (!sheet.hidden || bug.hidden || drag || frame) return;
668
+ const view = viewport();
669
+ if (placedByPerson || spot.state !== "float") {
670
+ const width = spot.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
671
+ spot = restingSpot(spot.state, spot.x + width / 2 < view.width / 2, spot.y, view);
672
+ draw();
673
+ return;
674
+ }
675
+ const right = px("--fs-feedback-right") ?? 14;
676
+ const left = view.width - right - BUTTON_SIZE;
677
+ const pinned = px("--fs-feedback-bottom");
678
+ if (pinned === null && typeof document.elementsFromPoint === "function") {
679
+ autoBottom = choosePlacement(
680
+ {
681
+ left,
682
+ top: view.height - view.bottom - autoBottom - BUTTON_SIZE,
683
+ size: BUTTON_SIZE,
684
+ bottom: autoBottom
685
+ },
686
+ view.height,
687
+ occupancyReader(host)
688
+ );
689
+ }
690
+ const bottom = pinned ?? autoBottom;
691
+ spot = { state: "float", x: left, y: view.height - view.bottom - bottom - BUTTON_SIZE };
692
+ draw();
693
+ };
694
+ const schedulePlace = () => {
695
+ if (placeTimer) clearTimeout(placeTimer);
696
+ placeTimer = setTimeout(place, 150);
697
+ };
698
+ let drag = null;
699
+ let suppressClick = false;
700
+ const onDown = (event) => {
701
+ if (!sheet.hidden || busy) return;
702
+ bug.setPointerCapture?.(event.pointerId);
703
+ cancelAnimationFrame(frame);
704
+ frame = 0;
705
+ velocity = { x: 0, y: 0 };
706
+ drag = {
707
+ id: event.pointerId,
708
+ ox: event.clientX - spot.x,
709
+ oy: event.clientY - spot.y,
710
+ sx: event.clientX,
711
+ sy: event.clientY,
712
+ moved: false,
713
+ history: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }]
714
+ };
715
+ bug.classList.add("down");
716
+ };
717
+ const onMove = (event) => {
718
+ if (!drag || event.pointerId !== drag.id) return;
719
+ const dx = event.clientX - drag.sx;
720
+ const dy = event.clientY - drag.sy;
721
+ if (!drag.moved && Math.hypot(dx, dy) < 8) return;
722
+ if (!drag.moved) {
723
+ drag.moved = true;
724
+ bug.classList.add("dragging");
725
+ }
726
+ drag.history.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });
727
+ if (drag.history.length > 6) drag.history.shift();
728
+ const view = viewport();
729
+ if (spot.state !== "float") {
730
+ const inward = spot.state === "dock-left" ? dx : -dx;
731
+ if (inward <= 18) {
732
+ spot = restingSpot(spot.state, spot.state === "dock-left", event.clientY - drag.oy, view);
733
+ draw();
734
+ return;
735
+ }
736
+ setState("float");
737
+ drag.ox = BUTTON_SIZE / 2;
738
+ drag.oy = BUTTON_SIZE / 2;
739
+ }
740
+ let x = event.clientX - drag.ox;
741
+ let y = event.clientY - drag.oy;
742
+ const maxX = view.width - BUTTON_SIZE;
743
+ const maxY = view.height - BUTTON_SIZE;
744
+ if (x < 0) x = rubberband(x, 60);
745
+ else if (x > maxX) x = maxX + rubberband(x - maxX, 60);
746
+ if (y < 0) y = rubberband(y, 80);
747
+ else if (y > maxY) y = maxY + rubberband(y - maxY, 80);
748
+ spot.x = x;
749
+ spot.y = y;
750
+ draw();
751
+ };
752
+ const onUp = (event) => {
753
+ if (!drag || event.pointerId !== drag.id) return;
754
+ const ended = drag;
755
+ drag = null;
756
+ bug.classList.remove("down", "dragging");
757
+ if (!ended.moved) return;
758
+ suppressClick = true;
759
+ setTimeout(() => {
760
+ suppressClick = false;
761
+ }, 0);
762
+ const first = ended.history[0];
763
+ const last = ended.history[ended.history.length - 1];
764
+ const dt = Math.max(1, last.t - first.t);
765
+ const paused = event.timeStamp - last.t > 90;
766
+ velocity = paused ? { x: 0, y: 0 } : { x: (last.x - first.x) / dt * 1e3, y: (last.y - first.y) / dt * 1e3 };
767
+ placedByPerson = true;
768
+ springTo(settleRelease(spot, velocity, viewport()), Math.hypot(velocity.x, velocity.y) > 350);
769
+ };
770
+ const observer = typeof MutationObserver === "function" ? new MutationObserver((records) => {
771
+ if (records.some((r) => r.target !== host)) schedulePlace();
772
+ }) : null;
352
773
  const paint = () => {
353
774
  theme = readFlowTheme();
354
775
  const vars = {
@@ -362,6 +783,11 @@ textarea::placeholder { color: var(--ink-dim); }
362
783
  "--font-body": theme.fontBody,
363
784
  "--font-display": theme.fontDisplay,
364
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,
365
791
  "--radius": theme.radius,
366
792
  "--button-radius": theme.buttonRadius
367
793
  };
@@ -371,15 +797,21 @@ textarea::placeholder { color: var(--ink-dim); }
371
797
  const sync = () => {
372
798
  send.disabled = busy || text.value.trim().length === 0;
373
799
  };
800
+ let round = 0;
801
+ let pending = null;
374
802
  const setShot = (url) => {
375
803
  screenshot = url;
376
804
  shot.hidden = url === null;
805
+ shot.removeAttribute("data-pending");
806
+ shotLabel.textContent = "Screenshot attached";
377
807
  if (url) img.src = url;
378
808
  else img.removeAttribute("src");
379
809
  };
380
810
  const close = () => {
381
811
  if (closeTimer) clearTimeout(closeTimer);
382
812
  closeTimer = null;
813
+ round += 1;
814
+ pending = null;
383
815
  sheet.hidden = true;
384
816
  scrim.hidden = true;
385
817
  bug.hidden = false;
@@ -388,20 +820,30 @@ textarea::placeholder { color: var(--ink-dim); }
388
820
  status.textContent = "";
389
821
  busy = false;
390
822
  sync();
823
+ schedulePlace();
391
824
  };
392
- const open = async () => {
393
- if (!sheet.hidden || busy) return;
394
- busy = true;
825
+ const open = () => {
826
+ if (!sheet.hidden) return pending ?? Promise.resolve();
827
+ if (busy) return Promise.resolve();
395
828
  paint();
829
+ const mine = round += 1;
396
830
  bug.hidden = true;
397
- const capture = options.capture ?? (() => captureViewport(theme.ground));
398
- const url = await withTimeout(Promise.resolve().then(capture), CAPTURE_TIMEOUT_MS);
399
- busy = false;
400
- setShot(url);
831
+ setShot(null);
832
+ shot.hidden = false;
833
+ shot.setAttribute("data-pending", "");
834
+ shotLabel.textContent = "Taking a screenshot";
835
+ tuck.hidden = spot.state !== "float";
401
836
  scrim.hidden = false;
402
837
  sheet.hidden = false;
403
838
  sync();
404
- 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;
405
847
  };
406
848
  const submit = async () => {
407
849
  const message = text.value.trim();
@@ -409,6 +851,9 @@ textarea::placeholder { color: var(--ink-dim); }
409
851
  busy = true;
410
852
  sync();
411
853
  status.textContent = "";
854
+ const mine = round;
855
+ if (pending) await pending;
856
+ if (mine !== round) return;
412
857
  try {
413
858
  await options.submit({
414
859
  message,
@@ -428,9 +873,39 @@ textarea::placeholder { color: var(--ink-dim); }
428
873
  sync();
429
874
  }
430
875
  };
431
- bug.addEventListener("click", () => void open());
876
+ bug.addEventListener("click", () => {
877
+ if (!suppressClick) void open();
878
+ });
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
+ });
885
+ bug.addEventListener("pointermove", onMove);
886
+ bug.addEventListener("pointerup", onUp);
887
+ bug.addEventListener("pointercancel", onUp);
888
+ tuck.addEventListener("click", () => {
889
+ const view = viewport();
890
+ const left = spot.x + BUTTON_SIZE / 2 < view.width / 2;
891
+ close();
892
+ placedByPerson = true;
893
+ springTo(
894
+ restingSpot(
895
+ left ? "dock-left" : "dock-right",
896
+ left,
897
+ spot.y + (BUTTON_SIZE - TAB_HEIGHT) / 2,
898
+ view
899
+ ),
900
+ false
901
+ );
902
+ });
432
903
  scrim.addEventListener("click", close);
433
- remove.addEventListener("click", () => setShot(null));
904
+ remove.addEventListener("click", () => {
905
+ round += 1;
906
+ pending = null;
907
+ setShot(null);
908
+ });
434
909
  text.addEventListener("input", sync);
435
910
  send.addEventListener("click", () => void submit());
436
911
  sheet.addEventListener("keydown", (event) => {
@@ -442,6 +917,29 @@ textarea::placeholder { color: var(--ink-dim); }
442
917
  });
443
918
  document.body.appendChild(host);
444
919
  paint();
920
+ try {
921
+ const saved = JSON.parse(localStorage.getItem(SPOT_KEY) ?? "null");
922
+ if (saved && typeof saved.y === "number" && (saved.state === "float" || saved.state === "dock-left" || saved.state === "dock-right")) {
923
+ const view = viewport();
924
+ placedByPerson = true;
925
+ setState(saved.state);
926
+ spot = restingSpot(saved.state, saved.left === true, saved.y * view.height, view);
927
+ }
928
+ } catch {
929
+ }
930
+ place();
931
+ schedulePlace();
932
+ observer?.observe(document.body, {
933
+ childList: true,
934
+ subtree: true,
935
+ attributes: true,
936
+ attributeFilter: ["class", "style", "hidden"]
937
+ });
938
+ window.addEventListener("resize", schedulePlace);
939
+ window.addEventListener("load", schedulePlace);
940
+ window.addEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
941
+ document.addEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
942
+ document.addEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
445
943
  options.onReady?.();
446
944
  mounted = {
447
945
  host,
@@ -452,6 +950,14 @@ textarea::placeholder { color: var(--ink-dim); }
452
950
  close,
453
951
  unmount: () => {
454
952
  if (closeTimer) clearTimeout(closeTimer);
953
+ if (placeTimer) clearTimeout(placeTimer);
954
+ cancelAnimationFrame(frame);
955
+ observer?.disconnect();
956
+ window.removeEventListener("resize", schedulePlace);
957
+ window.removeEventListener("load", schedulePlace);
958
+ window.removeEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
959
+ document.removeEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
960
+ document.removeEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
455
961
  host.remove();
456
962
  mounted = null;
457
963
  }