@goflowstate/widget-sdk 0.3.1 → 0.3.2

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.
@@ -71,6 +71,79 @@ export declare const luminance: (c: Rgba) => number;
71
71
  export declare function readFlowTheme(doc?: Document): FlowFeedbackTheme;
72
72
  /** Step the JPEG quality, then the size, until the picture fits the service's cap. */
73
73
  export declare function encodeScreenshot(canvas: HTMLCanvasElement): string | null;
74
+ export declare const PLACEMENT_BASE = 16;
75
+ export declare const PLACEMENT_STEP = 8;
76
+ /** Clear space kept around the icon, so it does not crowd what it sits beside. */
77
+ export declare const PLACEMENT_PAD = 8;
78
+ /** The icon never climbs past this share of the viewport. */
79
+ export declare const PLACEMENT_MAX_SHARE = 0.6;
80
+ /** How far up empty space is still worth more than sitting on a line of text lower down. */
81
+ export declare const PLACEMENT_READ_COST = 240;
82
+ /** What is at a point: nothing, something to read or look at, or something to tap. */
83
+ export declare const EMPTY = 0;
84
+ export declare const READ = 1;
85
+ export declare const TAP = 2;
86
+ export type Occupancy = typeof EMPTY | typeof READ | typeof TAP;
87
+ export interface PlacementBox {
88
+ /** The icon's box as measured now, in viewport pixels. */
89
+ left: number;
90
+ top: number;
91
+ size: number;
92
+ /** The bottom offset that box was measured at. */
93
+ bottom: number;
94
+ }
95
+ /**
96
+ * The bottom offset to sit at. `at(x, y)` says what is at a viewport point. An empty spot costs
97
+ * its height; a spot over text or a picture costs its height plus PLACEMENT_READ_COST; a spot
98
+ * over a control is taken only when there is no other, and then it is the base.
99
+ */
100
+ export declare function choosePlacement(box: PlacementBox, viewportHeight: number, at: (x: number, y: number) => Occupancy): number;
101
+ /**
102
+ * Reads what is at viewport points, the feedback host aside. One reader serves one placement
103
+ * pass: what it learns about an element is kept for the pass and dropped with it.
104
+ *
105
+ * A tap lands on the topmost element and travels up its ancestors, so only those can make a
106
+ * point a control: a button under the flow's own bottom sheet is not one. What a person reads
107
+ * is whatever shows through, down to the first element that paints its own ground.
108
+ */
109
+ export declare function occupancyReader(host: Element, doc?: Document): (x: number, y: number) => Occupancy;
110
+ export type DockState = 'float' | 'dock-left' | 'dock-right';
111
+ export declare const BUTTON_SIZE = 44;
112
+ export declare const TAB_WIDTH = 26;
113
+ export declare const TAB_HEIGHT = 54;
114
+ export declare const EDGE_MARGIN = 12;
115
+ /** How far past an edge a release has to be heading before the button docks there. */
116
+ export declare const DOCK_PAST = 14;
117
+ /** Storage key for where the person left the button. One origin serves every flow, so the
118
+ * choice follows them from flow to flow. */
119
+ export declare const SPOT_KEY = "fs-feedback-spot";
120
+ /** The part of the viewport the button may rest in: its size, less the safe-area insets. */
121
+ export interface FeedbackViewport {
122
+ width: number;
123
+ height: number;
124
+ top: number;
125
+ bottom: number;
126
+ }
127
+ export interface FeedbackSpot {
128
+ state: DockState;
129
+ x: number;
130
+ y: number;
131
+ }
132
+ /** Where a release at velocity `v` (px per second) comes to rest: scroll deceleration. */
133
+ export declare const projectMomentum: (v: number, deceleration?: number) => number;
134
+ /** Past a boundary the button follows less and less, so the edge reads as soft, not stuck. */
135
+ export declare const rubberband: (overshoot: number, dimension: number, constant?: number) => number;
136
+ /** The resting spot for a state on one side, at a height. */
137
+ export declare function restingSpot(state: DockState, left: boolean, y: number, view: FeedbackViewport): FeedbackSpot;
138
+ /**
139
+ * Where a drag that ends at (x, y) with velocity (vx, vy) settles. A floating button heading
140
+ * past an edge, or let go against one, docks on that side; otherwise it floats on the side its
141
+ * momentum carries it to. A docked tab stays docked and only slides along its edge.
142
+ */
143
+ export declare function settleRelease(from: FeedbackSpot, velocity: {
144
+ x: number;
145
+ y: number;
146
+ }, view: FeedbackViewport): FeedbackSpot;
74
147
  /** Whether the page asked for no feedback icon (`<html data-fs-feedback="off">`). */
75
148
  export declare const feedbackOptedOut: (doc?: Document) => boolean;
76
149
  export declare function mountFlowFeedback(options: FlowFeedbackOptions): FlowFeedbackHandle | null;
@@ -1 +1 @@
1
- {"version":3,"file":"feedback.d.ts","sourceRoot":"","sources":["../src/feedback.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7C;AAED,MAAM,WAAW,mBAAmB;IAClC,yFAAyF;IACzF,MAAM,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACzD;8DAC0D;IAC1D,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvC,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,8CAA8C;IAC9C,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,0EAA0E;IAC1E,OAAO,IAAI,IAAI,CAAC;IAChB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,KAAK,IAAI,IAAI,CAAC;IACd,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,eAAO,MAAM,kBAAkB,0BAA0B,CAAC;AAC1D,gGAAgG;AAChG,eAAO,MAAM,oBAAoB,SAAU,CAAC;AAC5C,eAAO,MAAM,mBAAmB,OAAO,CAAC;AAKxC,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AAE7C,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,GAAG,IAAI,CA8BtE;AAaD,+BAA+B;AAC/B,eAAO,MAAM,SAAS,GAAI,GAAG,IAAI,KAAG,MAMnC,CAAC;AAmDF;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,GAAE,QAAmB,GAAG,iBAAiB,CAyFzE;AAOD,sFAAsF;AACtF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,CAiBzE;AA8FD,qFAAqF;AACrF,eAAO,MAAM,gBAAgB,GAAI,MAAK,QAAmB,KAAG,OACI,CAAC;AAEjE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,GAAG,kBAAkB,GAAG,IAAI,CAmJzF;AAED,eAAO,MAAM,mBAAmB,QAAO,IAA0B,CAAC"}
1
+ {"version":3,"file":"feedback.d.ts","sourceRoot":"","sources":["../src/feedback.ts"],"names":[],"mappings":"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"}
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // @goflowstate/widget-sdk v0.3.1
1
+ // @goflowstate/widget-sdk v0.3.2
2
2
  // https://github.com/goflowstate/goflowstate-sdk
3
3
 
4
4
  var __defProp = Object.defineProperty;
@@ -227,21 +227,168 @@ var withTimeout = (work, ms) => new Promise((resolve) => {
227
227
  }
228
228
  );
229
229
  });
230
+ var PLACEMENT_BASE = 16;
231
+ var PLACEMENT_STEP = 8;
232
+ var PLACEMENT_PAD = 8;
233
+ var PLACEMENT_MAX_SHARE = 0.6;
234
+ var PLACEMENT_READ_COST = 240;
235
+ var EMPTY = 0;
236
+ var READ = 1;
237
+ var TAP = 2;
238
+ var SAMPLES = 5;
239
+ function choosePlacement(box, viewportHeight, at) {
240
+ const max = Math.max(PLACEMENT_BASE, viewportHeight * PLACEMENT_MAX_SHARE);
241
+ const span = box.size + PLACEMENT_PAD * 2;
242
+ const offsets = Array.from({ length: SAMPLES }, (_, i) => span * i / (SAMPLES - 1));
243
+ const x0 = box.left - PLACEMENT_PAD;
244
+ let best = PLACEMENT_BASE;
245
+ let bestCost = Infinity;
246
+ for (let bottom = PLACEMENT_BASE; bottom <= max; bottom += PLACEMENT_STEP) {
247
+ if (bottom >= bestCost) break;
248
+ const y0 = box.top - (bottom - box.bottom) - PLACEMENT_PAD;
249
+ let worst = EMPTY;
250
+ for (const dy of offsets) {
251
+ for (const dx of offsets) {
252
+ const found = at(x0 + dx, y0 + dy);
253
+ if (found > worst) worst = found;
254
+ if (worst === TAP) break;
255
+ }
256
+ if (worst === TAP) break;
257
+ }
258
+ if (worst === TAP) continue;
259
+ const cost = bottom + (worst === READ ? PLACEMENT_READ_COST : 0);
260
+ if (cost < bestCost) {
261
+ bestCost = cost;
262
+ best = bottom;
263
+ }
264
+ }
265
+ return best;
266
+ }
267
+ 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]';
268
+ var PICTURE = "img,svg,canvas,video,picture";
269
+ var SURFACE_SHARE = 0.8;
270
+ function occupancyReader(host, doc = document) {
271
+ if (typeof doc.elementsFromPoint !== "function") return () => EMPTY;
272
+ const view = doc.defaultView;
273
+ const width = view?.innerWidth ?? 1;
274
+ const height = view?.innerHeight ?? 1;
275
+ const seen = /* @__PURE__ */ new Map();
276
+ const read = (el) => {
277
+ const known = seen.get(el);
278
+ if (known) return known;
279
+ const style = view ? view.getComputedStyle(el) : null;
280
+ let tap = el.matches(TAPPABLE) || style?.cursor === "pointer";
281
+ if (tap) {
282
+ const r = el.getBoundingClientRect();
283
+ if (r.width * r.height >= width * height * SURFACE_SHARE) tap = false;
284
+ }
285
+ const text = [];
286
+ if (typeof doc.createRange === "function") {
287
+ for (const node of Array.from(el.childNodes)) {
288
+ if (node.nodeType !== 3 || !node.nodeValue || !node.nodeValue.trim()) continue;
289
+ const range = doc.createRange();
290
+ range.selectNodeContents(node);
291
+ if (typeof range.getClientRects === "function")
292
+ text.push(...Array.from(range.getClientRects()));
293
+ }
294
+ }
295
+ const ground = parseColor(style?.backgroundColor);
296
+ const image = style?.backgroundImage ?? "";
297
+ const reading = {
298
+ tap,
299
+ picture: el.matches(PICTURE) || el.closest("svg") !== null,
300
+ text,
301
+ opaque: ground !== null && ground[3] >= 0.5 || image !== "" && image !== "none"
302
+ };
303
+ seen.set(el, reading);
304
+ return reading;
305
+ };
306
+ return (x, y) => {
307
+ const cx = Math.min(Math.max(x, 0), width - 1);
308
+ const cy = Math.min(Math.max(y, 0), height - 1);
309
+ const stack = doc.elementsFromPoint(cx, cy).filter((el) => el !== host && el !== doc.documentElement && el !== doc.body);
310
+ for (let el = stack[0] ?? null; el && el !== doc.body; el = el.parentElement) {
311
+ if (read(el).tap) return TAP;
312
+ }
313
+ for (const el of stack) {
314
+ const r = read(el);
315
+ if (r.picture || r.text.some((t) => cx >= t.left && cx <= t.right && cy >= t.top && cy <= t.bottom))
316
+ return READ;
317
+ if (r.opaque) break;
318
+ }
319
+ return EMPTY;
320
+ };
321
+ }
322
+ var BUTTON_SIZE = 44;
323
+ var TAB_WIDTH = 26;
324
+ var TAB_HEIGHT = 54;
325
+ var EDGE_MARGIN = 12;
326
+ var DOCK_PAST = 14;
327
+ var SPOT_KEY = "fs-feedback-spot";
328
+ var projectMomentum = (v, deceleration = 0.992) => v / 1e3 * deceleration / (1 - deceleration);
329
+ var rubberband = (overshoot, dimension, constant = 0.55) => overshoot * dimension * constant / (dimension + constant * Math.abs(overshoot));
330
+ var clampY = (y, height, view) => Math.min(
331
+ Math.max(y, view.top + EDGE_MARGIN),
332
+ Math.max(view.top + EDGE_MARGIN, view.height - view.bottom - EDGE_MARGIN - height)
333
+ );
334
+ function restingSpot(state, left, y, view) {
335
+ if (state === "float") {
336
+ return {
337
+ state,
338
+ x: left ? EDGE_MARGIN : view.width - BUTTON_SIZE - EDGE_MARGIN,
339
+ y: clampY(y, BUTTON_SIZE, view)
340
+ };
341
+ }
342
+ return { state, x: left ? 0 : view.width - TAB_WIDTH, y: clampY(y, TAB_HEIGHT, view) };
343
+ }
344
+ function settleRelease(from, velocity, view) {
345
+ const py = from.y + projectMomentum(velocity.y);
346
+ if (from.state !== "float") return restingSpot(from.state, from.state === "dock-left", py, view);
347
+ const px = from.x + projectMomentum(velocity.x);
348
+ const maxX = view.width - BUTTON_SIZE;
349
+ const tabY = py + (BUTTON_SIZE - TAB_HEIGHT) / 2;
350
+ if (px < -DOCK_PAST || from.x < 3) return restingSpot("dock-left", true, tabY, view);
351
+ if (px > maxX + DOCK_PAST || from.x > maxX - 3)
352
+ return restingSpot("dock-right", false, tabY, view);
353
+ return restingSpot("float", px + BUTTON_SIZE / 2 < view.width / 2, py, view);
354
+ }
230
355
  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
356
  var STYLE = `
232
357
  :host { all: initial; }
233
358
  * { box-sizing: border-box; }
234
359
  .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;
360
+ position: fixed; z-index: 2147483000; left: 0; top: 0;
361
+ width: 44px; height: 44px; border-radius: 22px; padding: 0; margin: 0;
362
+ display: var(--fs-feedback-display, grid); place-items: center; cursor: grab;
240
363
  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;
364
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2), 0 2px 5px rgba(0, 0, 0, 0.12);
365
+ -webkit-tap-highlight-color: transparent; touch-action: none; user-select: none; -webkit-user-select: none;
366
+ will-change: transform;
367
+ }
368
+ @supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
369
+ .bug {
370
+ background: color-mix(in srgb, var(--surface) 74%, transparent);
371
+ -webkit-backdrop-filter: blur(14px) saturate(160%); backdrop-filter: blur(14px) saturate(160%);
372
+ }
373
+ }
374
+ .bug svg { width: 20px; height: 20px; pointer-events: none; }
375
+ .bug.down svg { transform: scale(0.9); }
376
+ .bug.dragging { cursor: grabbing; box-shadow: 0 18px 36px rgba(0, 0, 0, 0.26), 0 3px 8px rgba(0, 0, 0, 0.14); }
377
+ .bug[data-state^="dock"] { width: 26px; height: 54px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14); opacity: 0.92; }
378
+ .bug[data-state="dock-right"] { border-radius: 16px 0 0 16px; border-right-width: 0; }
379
+ .bug[data-state="dock-left"] { border-radius: 0 16px 16px 0; border-left-width: 0; }
380
+ .bug[data-state^="dock"] svg { width: 16px; height: 16px; }
381
+ .probe {
382
+ position: fixed; visibility: hidden; pointer-events: none; width: 0; height: 0;
383
+ padding: env(safe-area-inset-top, 0px) 0 env(safe-area-inset-bottom, 0px) 0;
384
+ }
385
+ .tuck { align-self: center; font-size: 13px; color: var(--ink-dim); }
386
+ @media (prefers-reduced-transparency: reduce) {
387
+ .bug { background: var(--surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
388
+ }
389
+ @media (prefers-contrast: more) {
390
+ .bug { background: var(--surface); border-color: var(--ink); -webkit-backdrop-filter: none; backdrop-filter: none; }
243
391
  }
244
- .bug svg { width: 20px; height: 20px; }
245
392
  .bug:focus-visible, .send:focus-visible, .link:focus-visible, textarea:focus-visible {
246
393
  outline: 2px solid var(--ink); outline-offset: 2px;
247
394
  }
@@ -273,12 +420,18 @@ textarea::placeholder { color: var(--ink-dim); }
273
420
  .status { margin: 0; font-size: 13px; color: var(--ink-dim); min-height: 1.4em; }
274
421
  [hidden] { display: none !important; }
275
422
  @media (prefers-reduced-motion: no-preference) {
423
+ .bug {
424
+ 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),
425
+ border-radius 0.22s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.22s ease;
426
+ }
427
+ .bug svg { transition: transform 0.1s ease-out, width 0.22s ease, height 0.22s ease; }
276
428
  .sheet { animation: rise 0.22s ease-out; }
277
429
  @keyframes rise { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } }
278
430
  }
279
431
  `;
280
432
  var mounted = null;
281
433
  var feedbackOptedOut = (doc = document) => doc.documentElement.getAttribute("data-fs-feedback") === "off";
434
+ var SCROLL_OPTIONS = { capture: true, passive: true };
282
435
  function mountFlowFeedback(options) {
283
436
  if (typeof document === "undefined" || !document.body) return null;
284
437
  if (mounted) return mounted;
@@ -287,7 +440,8 @@ function mountFlowFeedback(options) {
287
440
  host.setAttribute(FEEDBACK_HOST_ATTR, "");
288
441
  const root = host.attachShadow({ mode: "open" });
289
442
  root.innerHTML = `<style>${STYLE}</style>
290
- <button type="button" class="bug" aria-label="Send feedback on this page">${BUG_ICON}</button>
443
+ <button type="button" class="bug" data-state="float" aria-label="Send feedback on this page">${BUG_ICON}</button>
444
+ <div class="probe" aria-hidden="true"></div>
291
445
  <div class="scrim" hidden></div>
292
446
  <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="fsf-title" hidden>
293
447
  <div class="grab" aria-hidden="true"></div>
@@ -296,6 +450,7 @@ function mountFlowFeedback(options) {
296
450
  <textarea aria-labelledby="fsf-title" placeholder="What should change?" maxlength="2000"></textarea>
297
451
  <p class="status" role="status" aria-live="polite"></p>
298
452
  <button type="button" class="send" disabled>Send feedback</button>
453
+ <button type="button" class="link tuck">Tuck the button to the side</button>
299
454
  </div>`;
300
455
  const q = (selector) => root.querySelector(selector);
301
456
  const bug = q(".bug");
@@ -307,10 +462,199 @@ function mountFlowFeedback(options) {
307
462
  const text = q("textarea");
308
463
  const status = q(".status");
309
464
  const send = q(".send");
465
+ const tuck = q(".tuck");
466
+ const probe = q(".probe");
310
467
  let theme = readFlowTheme();
311
468
  let screenshot = null;
312
469
  let busy = false;
313
470
  let closeTimer = null;
471
+ let placeTimer = null;
472
+ let autoBottom = PLACEMENT_BASE;
473
+ let spot = { state: "float", x: 0, y: 0 };
474
+ let placedByPerson = false;
475
+ let velocity = { x: 0, y: 0 };
476
+ let frame = 0;
477
+ const px = (name) => {
478
+ const raw = getComputedStyle(host).getPropertyValue(name).trim();
479
+ const n = raw ? parseFloat(raw) : NaN;
480
+ return Number.isFinite(n) ? n : null;
481
+ };
482
+ const viewport = () => {
483
+ const style = getComputedStyle(probe);
484
+ return {
485
+ width: window.innerWidth,
486
+ height: window.innerHeight,
487
+ top: parseFloat(style.paddingTop) || 0,
488
+ bottom: parseFloat(style.paddingBottom) || 0
489
+ };
490
+ };
491
+ const draw = () => {
492
+ bug.style.transform = `translate3d(${spot.x}px, ${spot.y}px, 0)`;
493
+ };
494
+ const setState = (next) => {
495
+ spot.state = next;
496
+ bug.dataset.state = next;
497
+ };
498
+ const remember = (spot2) => {
499
+ if (!placedByPerson) return;
500
+ try {
501
+ const view = viewport();
502
+ const width = spot2.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
503
+ localStorage.setItem(
504
+ SPOT_KEY,
505
+ JSON.stringify({
506
+ state: spot2.state,
507
+ left: spot2.x + width / 2 < view.width / 2,
508
+ y: view.height > 0 ? spot2.y / view.height : 0
509
+ })
510
+ );
511
+ } catch {
512
+ }
513
+ };
514
+ const still = () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
515
+ const springTo = (to, flicked) => {
516
+ cancelAnimationFrame(frame);
517
+ frame = 0;
518
+ setState(to.state);
519
+ remember(to);
520
+ if (still() || typeof requestAnimationFrame !== "function") {
521
+ spot = { ...to };
522
+ velocity = { x: 0, y: 0 };
523
+ draw();
524
+ return;
525
+ }
526
+ const omega = 2 * Math.PI / 0.4;
527
+ const k = omega * omega;
528
+ const c = 2 * (flicked ? 0.8 : 1) * omega;
529
+ let last = performance.now();
530
+ const tick = (now) => {
531
+ const dt = Math.min(0.032, (now - last) / 1e3);
532
+ last = now;
533
+ velocity.x += (-k * (spot.x - to.x) - c * velocity.x) * dt;
534
+ velocity.y += (-k * (spot.y - to.y) - c * velocity.y) * dt;
535
+ spot.x += velocity.x * dt;
536
+ spot.y += velocity.y * dt;
537
+ 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;
538
+ if (close2) {
539
+ spot = { ...to };
540
+ velocity = { x: 0, y: 0 };
541
+ frame = 0;
542
+ draw();
543
+ return;
544
+ }
545
+ draw();
546
+ frame = requestAnimationFrame(tick);
547
+ };
548
+ frame = requestAnimationFrame(tick);
549
+ };
550
+ const place = () => {
551
+ placeTimer = null;
552
+ if (!sheet.hidden || bug.hidden || drag || frame) return;
553
+ const view = viewport();
554
+ if (placedByPerson || spot.state !== "float") {
555
+ const width = spot.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
556
+ spot = restingSpot(spot.state, spot.x + width / 2 < view.width / 2, spot.y, view);
557
+ draw();
558
+ return;
559
+ }
560
+ const right = px("--fs-feedback-right") ?? 14;
561
+ const left = view.width - right - BUTTON_SIZE;
562
+ const pinned = px("--fs-feedback-bottom");
563
+ if (pinned === null && typeof document.elementsFromPoint === "function") {
564
+ autoBottom = choosePlacement(
565
+ {
566
+ left,
567
+ top: view.height - view.bottom - autoBottom - BUTTON_SIZE,
568
+ size: BUTTON_SIZE,
569
+ bottom: autoBottom
570
+ },
571
+ view.height,
572
+ occupancyReader(host)
573
+ );
574
+ }
575
+ const bottom = pinned ?? autoBottom;
576
+ spot = { state: "float", x: left, y: view.height - view.bottom - bottom - BUTTON_SIZE };
577
+ draw();
578
+ };
579
+ const schedulePlace = () => {
580
+ if (placeTimer) clearTimeout(placeTimer);
581
+ placeTimer = setTimeout(place, 150);
582
+ };
583
+ let drag = null;
584
+ let suppressClick = false;
585
+ const onDown = (event) => {
586
+ if (!sheet.hidden || busy) return;
587
+ bug.setPointerCapture?.(event.pointerId);
588
+ cancelAnimationFrame(frame);
589
+ frame = 0;
590
+ velocity = { x: 0, y: 0 };
591
+ drag = {
592
+ id: event.pointerId,
593
+ ox: event.clientX - spot.x,
594
+ oy: event.clientY - spot.y,
595
+ sx: event.clientX,
596
+ sy: event.clientY,
597
+ moved: false,
598
+ history: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }]
599
+ };
600
+ bug.classList.add("down");
601
+ };
602
+ const onMove = (event) => {
603
+ if (!drag || event.pointerId !== drag.id) return;
604
+ const dx = event.clientX - drag.sx;
605
+ const dy = event.clientY - drag.sy;
606
+ if (!drag.moved && Math.hypot(dx, dy) < 8) return;
607
+ if (!drag.moved) {
608
+ drag.moved = true;
609
+ bug.classList.add("dragging");
610
+ }
611
+ drag.history.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });
612
+ if (drag.history.length > 6) drag.history.shift();
613
+ const view = viewport();
614
+ if (spot.state !== "float") {
615
+ const inward = spot.state === "dock-left" ? dx : -dx;
616
+ if (inward <= 18) {
617
+ spot = restingSpot(spot.state, spot.state === "dock-left", event.clientY - drag.oy, view);
618
+ draw();
619
+ return;
620
+ }
621
+ setState("float");
622
+ drag.ox = BUTTON_SIZE / 2;
623
+ drag.oy = BUTTON_SIZE / 2;
624
+ }
625
+ let x = event.clientX - drag.ox;
626
+ let y = event.clientY - drag.oy;
627
+ const maxX = view.width - BUTTON_SIZE;
628
+ const maxY = view.height - BUTTON_SIZE;
629
+ if (x < 0) x = rubberband(x, 60);
630
+ else if (x > maxX) x = maxX + rubberband(x - maxX, 60);
631
+ if (y < 0) y = rubberband(y, 80);
632
+ else if (y > maxY) y = maxY + rubberband(y - maxY, 80);
633
+ spot.x = x;
634
+ spot.y = y;
635
+ draw();
636
+ };
637
+ const onUp = (event) => {
638
+ if (!drag || event.pointerId !== drag.id) return;
639
+ const ended = drag;
640
+ drag = null;
641
+ bug.classList.remove("down", "dragging");
642
+ if (!ended.moved) return;
643
+ suppressClick = true;
644
+ setTimeout(() => {
645
+ suppressClick = false;
646
+ }, 0);
647
+ const first = ended.history[0];
648
+ const last = ended.history[ended.history.length - 1];
649
+ const dt = Math.max(1, last.t - first.t);
650
+ const paused = event.timeStamp - last.t > 90;
651
+ velocity = paused ? { x: 0, y: 0 } : { x: (last.x - first.x) / dt * 1e3, y: (last.y - first.y) / dt * 1e3 };
652
+ placedByPerson = true;
653
+ springTo(settleRelease(spot, velocity, viewport()), Math.hypot(velocity.x, velocity.y) > 350);
654
+ };
655
+ const observer = typeof MutationObserver === "function" ? new MutationObserver((records) => {
656
+ if (records.some((r) => r.target !== host)) schedulePlace();
657
+ }) : null;
314
658
  const paint = () => {
315
659
  theme = readFlowTheme();
316
660
  const vars = {
@@ -350,6 +694,7 @@ function mountFlowFeedback(options) {
350
694
  status.textContent = "";
351
695
  busy = false;
352
696
  sync();
697
+ schedulePlace();
353
698
  };
354
699
  const open = async () => {
355
700
  if (!sheet.hidden || busy) return;
@@ -360,6 +705,7 @@ function mountFlowFeedback(options) {
360
705
  const url = await withTimeout(Promise.resolve().then(capture), CAPTURE_TIMEOUT_MS);
361
706
  busy = false;
362
707
  setShot(url);
708
+ tuck.hidden = spot.state !== "float";
363
709
  scrim.hidden = false;
364
710
  sheet.hidden = false;
365
711
  sync();
@@ -390,7 +736,28 @@ function mountFlowFeedback(options) {
390
736
  sync();
391
737
  }
392
738
  };
393
- bug.addEventListener("click", () => void open());
739
+ bug.addEventListener("click", () => {
740
+ if (!suppressClick) void open();
741
+ });
742
+ bug.addEventListener("pointerdown", onDown);
743
+ bug.addEventListener("pointermove", onMove);
744
+ bug.addEventListener("pointerup", onUp);
745
+ bug.addEventListener("pointercancel", onUp);
746
+ tuck.addEventListener("click", () => {
747
+ const view = viewport();
748
+ const left = spot.x + BUTTON_SIZE / 2 < view.width / 2;
749
+ close();
750
+ placedByPerson = true;
751
+ springTo(
752
+ restingSpot(
753
+ left ? "dock-left" : "dock-right",
754
+ left,
755
+ spot.y + (BUTTON_SIZE - TAB_HEIGHT) / 2,
756
+ view
757
+ ),
758
+ false
759
+ );
760
+ });
394
761
  scrim.addEventListener("click", close);
395
762
  remove.addEventListener("click", () => setShot(null));
396
763
  text.addEventListener("input", sync);
@@ -404,6 +771,29 @@ function mountFlowFeedback(options) {
404
771
  });
405
772
  document.body.appendChild(host);
406
773
  paint();
774
+ try {
775
+ const saved = JSON.parse(localStorage.getItem(SPOT_KEY) ?? "null");
776
+ if (saved && typeof saved.y === "number" && (saved.state === "float" || saved.state === "dock-left" || saved.state === "dock-right")) {
777
+ const view = viewport();
778
+ placedByPerson = true;
779
+ setState(saved.state);
780
+ spot = restingSpot(saved.state, saved.left === true, saved.y * view.height, view);
781
+ }
782
+ } catch {
783
+ }
784
+ place();
785
+ schedulePlace();
786
+ observer?.observe(document.body, {
787
+ childList: true,
788
+ subtree: true,
789
+ attributes: true,
790
+ attributeFilter: ["class", "style", "hidden"]
791
+ });
792
+ window.addEventListener("resize", schedulePlace);
793
+ window.addEventListener("load", schedulePlace);
794
+ window.addEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
795
+ document.addEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
796
+ document.addEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
407
797
  options.onReady?.();
408
798
  mounted = {
409
799
  host,
@@ -414,6 +804,14 @@ function mountFlowFeedback(options) {
414
804
  close,
415
805
  unmount: () => {
416
806
  if (closeTimer) clearTimeout(closeTimer);
807
+ if (placeTimer) clearTimeout(placeTimer);
808
+ cancelAnimationFrame(frame);
809
+ observer?.disconnect();
810
+ window.removeEventListener("resize", schedulePlace);
811
+ window.removeEventListener("load", schedulePlace);
812
+ window.removeEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
813
+ document.removeEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
814
+ document.removeEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
417
815
  host.remove();
418
816
  mounted = null;
419
817
  }