@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.
package/README.md CHANGED
@@ -174,6 +174,21 @@ and shape of its main button, and its corner radius. To pin a role, set a custom
174
174
  }
175
175
  ```
176
176
 
177
+ The button floats over the flow and belongs to the person using it. They can drag it anywhere,
178
+ and it settles on the nearer side. A flick at an edge, or a drag into one, docks it there as a
179
+ slim tab with the bug still showing; a tap on the tab opens the sheet, and pulling the tab
180
+ inward floats it again. The sheet also has a "Tuck the button to the side" link. Where a person
181
+ leaves the button is remembered.
182
+
183
+ Until a person moves it, the button keeps clear of the page's own controls. It starts in the bottom corner and, whenever
184
+ the page changes, resizes or stops scrolling, looks up the right edge for the lowest spot that is empty. It sits over a line of text only
185
+ when empty space is far above it, and on a control only when the whole edge is controls. Pinning
186
+ `--fs-feedback-bottom` turns that off and holds the icon where the flow puts it.
187
+
188
+ To take the icon off one screen of a flow (a film, a full-screen picture), set
189
+ `--fs-feedback-display: none` from the flow's stylesheet while that screen is up, for example
190
+ `:root:has(.film) { --fs-feedback-display: none; }`.
191
+
177
192
  Name each screen so a report says where it came from: `document.body.dataset.pageKey = 'question-2'`.
178
193
  A page that must not show the icon (a projected wall) sets `<html data-fs-feedback="off">`.
179
194
 
@@ -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
  "use strict";
@@ -265,21 +265,168 @@ var CanvasWidgetSDK = (() => {
265
265
  }
266
266
  );
267
267
  });
268
+ var PLACEMENT_BASE = 16;
269
+ var PLACEMENT_STEP = 8;
270
+ var PLACEMENT_PAD = 8;
271
+ var PLACEMENT_MAX_SHARE = 0.6;
272
+ var PLACEMENT_READ_COST = 240;
273
+ var EMPTY = 0;
274
+ var READ = 1;
275
+ var TAP = 2;
276
+ var SAMPLES = 5;
277
+ function choosePlacement(box, viewportHeight, at) {
278
+ const max = Math.max(PLACEMENT_BASE, viewportHeight * PLACEMENT_MAX_SHARE);
279
+ const span = box.size + PLACEMENT_PAD * 2;
280
+ const offsets = Array.from({ length: SAMPLES }, (_, i) => span * i / (SAMPLES - 1));
281
+ const x0 = box.left - PLACEMENT_PAD;
282
+ let best = PLACEMENT_BASE;
283
+ let bestCost = Infinity;
284
+ for (let bottom = PLACEMENT_BASE; bottom <= max; bottom += PLACEMENT_STEP) {
285
+ if (bottom >= bestCost) break;
286
+ const y0 = box.top - (bottom - box.bottom) - PLACEMENT_PAD;
287
+ let worst = EMPTY;
288
+ for (const dy of offsets) {
289
+ for (const dx of offsets) {
290
+ const found = at(x0 + dx, y0 + dy);
291
+ if (found > worst) worst = found;
292
+ if (worst === TAP) break;
293
+ }
294
+ if (worst === TAP) break;
295
+ }
296
+ if (worst === TAP) continue;
297
+ const cost = bottom + (worst === READ ? PLACEMENT_READ_COST : 0);
298
+ if (cost < bestCost) {
299
+ bestCost = cost;
300
+ best = bottom;
301
+ }
302
+ }
303
+ return best;
304
+ }
305
+ 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]';
306
+ var PICTURE = "img,svg,canvas,video,picture";
307
+ var SURFACE_SHARE = 0.8;
308
+ function occupancyReader(host, doc = document) {
309
+ if (typeof doc.elementsFromPoint !== "function") return () => EMPTY;
310
+ const view = doc.defaultView;
311
+ const width = view?.innerWidth ?? 1;
312
+ const height = view?.innerHeight ?? 1;
313
+ const seen = /* @__PURE__ */ new Map();
314
+ const read = (el) => {
315
+ const known = seen.get(el);
316
+ if (known) return known;
317
+ const style = view ? view.getComputedStyle(el) : null;
318
+ let tap = el.matches(TAPPABLE) || style?.cursor === "pointer";
319
+ if (tap) {
320
+ const r = el.getBoundingClientRect();
321
+ if (r.width * r.height >= width * height * SURFACE_SHARE) tap = false;
322
+ }
323
+ const text = [];
324
+ if (typeof doc.createRange === "function") {
325
+ for (const node of Array.from(el.childNodes)) {
326
+ if (node.nodeType !== 3 || !node.nodeValue || !node.nodeValue.trim()) continue;
327
+ const range = doc.createRange();
328
+ range.selectNodeContents(node);
329
+ if (typeof range.getClientRects === "function")
330
+ text.push(...Array.from(range.getClientRects()));
331
+ }
332
+ }
333
+ const ground = parseColor(style?.backgroundColor);
334
+ const image = style?.backgroundImage ?? "";
335
+ const reading = {
336
+ tap,
337
+ picture: el.matches(PICTURE) || el.closest("svg") !== null,
338
+ text,
339
+ opaque: ground !== null && ground[3] >= 0.5 || image !== "" && image !== "none"
340
+ };
341
+ seen.set(el, reading);
342
+ return reading;
343
+ };
344
+ return (x, y) => {
345
+ const cx = Math.min(Math.max(x, 0), width - 1);
346
+ const cy = Math.min(Math.max(y, 0), height - 1);
347
+ const stack = doc.elementsFromPoint(cx, cy).filter((el) => el !== host && el !== doc.documentElement && el !== doc.body);
348
+ for (let el = stack[0] ?? null; el && el !== doc.body; el = el.parentElement) {
349
+ if (read(el).tap) return TAP;
350
+ }
351
+ for (const el of stack) {
352
+ const r = read(el);
353
+ if (r.picture || r.text.some((t) => cx >= t.left && cx <= t.right && cy >= t.top && cy <= t.bottom))
354
+ return READ;
355
+ if (r.opaque) break;
356
+ }
357
+ return EMPTY;
358
+ };
359
+ }
360
+ var BUTTON_SIZE = 44;
361
+ var TAB_WIDTH = 26;
362
+ var TAB_HEIGHT = 54;
363
+ var EDGE_MARGIN = 12;
364
+ var DOCK_PAST = 14;
365
+ var SPOT_KEY = "fs-feedback-spot";
366
+ var projectMomentum = (v, deceleration = 0.992) => v / 1e3 * deceleration / (1 - deceleration);
367
+ var rubberband = (overshoot, dimension, constant = 0.55) => overshoot * dimension * constant / (dimension + constant * Math.abs(overshoot));
368
+ var clampY = (y, height, view) => Math.min(
369
+ Math.max(y, view.top + EDGE_MARGIN),
370
+ Math.max(view.top + EDGE_MARGIN, view.height - view.bottom - EDGE_MARGIN - height)
371
+ );
372
+ function restingSpot(state, left, y, view) {
373
+ if (state === "float") {
374
+ return {
375
+ state,
376
+ x: left ? EDGE_MARGIN : view.width - BUTTON_SIZE - EDGE_MARGIN,
377
+ y: clampY(y, BUTTON_SIZE, view)
378
+ };
379
+ }
380
+ return { state, x: left ? 0 : view.width - TAB_WIDTH, y: clampY(y, TAB_HEIGHT, view) };
381
+ }
382
+ function settleRelease(from, velocity, view) {
383
+ const py = from.y + projectMomentum(velocity.y);
384
+ if (from.state !== "float") return restingSpot(from.state, from.state === "dock-left", py, view);
385
+ const px = from.x + projectMomentum(velocity.x);
386
+ const maxX = view.width - BUTTON_SIZE;
387
+ const tabY = py + (BUTTON_SIZE - TAB_HEIGHT) / 2;
388
+ if (px < -DOCK_PAST || from.x < 3) return restingSpot("dock-left", true, tabY, view);
389
+ if (px > maxX + DOCK_PAST || from.x > maxX - 3)
390
+ return restingSpot("dock-right", false, tabY, view);
391
+ return restingSpot("float", px + BUTTON_SIZE / 2 < view.width / 2, py, view);
392
+ }
268
393
  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
394
  var STYLE = `
270
395
  :host { all: initial; }
271
396
  * { box-sizing: border-box; }
272
397
  .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;
398
+ position: fixed; z-index: 2147483000; left: 0; top: 0;
399
+ width: 44px; height: 44px; border-radius: 22px; padding: 0; margin: 0;
400
+ display: var(--fs-feedback-display, grid); place-items: center; cursor: grab;
278
401
  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;
402
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2), 0 2px 5px rgba(0, 0, 0, 0.12);
403
+ -webkit-tap-highlight-color: transparent; touch-action: none; user-select: none; -webkit-user-select: none;
404
+ will-change: transform;
405
+ }
406
+ @supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
407
+ .bug {
408
+ background: color-mix(in srgb, var(--surface) 74%, transparent);
409
+ -webkit-backdrop-filter: blur(14px) saturate(160%); backdrop-filter: blur(14px) saturate(160%);
410
+ }
411
+ }
412
+ .bug svg { width: 20px; height: 20px; pointer-events: none; }
413
+ .bug.down svg { transform: scale(0.9); }
414
+ .bug.dragging { cursor: grabbing; box-shadow: 0 18px 36px rgba(0, 0, 0, 0.26), 0 3px 8px rgba(0, 0, 0, 0.14); }
415
+ .bug[data-state^="dock"] { width: 26px; height: 54px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14); opacity: 0.92; }
416
+ .bug[data-state="dock-right"] { border-radius: 16px 0 0 16px; border-right-width: 0; }
417
+ .bug[data-state="dock-left"] { border-radius: 0 16px 16px 0; border-left-width: 0; }
418
+ .bug[data-state^="dock"] svg { width: 16px; height: 16px; }
419
+ .probe {
420
+ position: fixed; visibility: hidden; pointer-events: none; width: 0; height: 0;
421
+ padding: env(safe-area-inset-top, 0px) 0 env(safe-area-inset-bottom, 0px) 0;
422
+ }
423
+ .tuck { align-self: center; font-size: 13px; color: var(--ink-dim); }
424
+ @media (prefers-reduced-transparency: reduce) {
425
+ .bug { background: var(--surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
426
+ }
427
+ @media (prefers-contrast: more) {
428
+ .bug { background: var(--surface); border-color: var(--ink); -webkit-backdrop-filter: none; backdrop-filter: none; }
281
429
  }
282
- .bug svg { width: 20px; height: 20px; }
283
430
  .bug:focus-visible, .send:focus-visible, .link:focus-visible, textarea:focus-visible {
284
431
  outline: 2px solid var(--ink); outline-offset: 2px;
285
432
  }
@@ -311,12 +458,18 @@ textarea::placeholder { color: var(--ink-dim); }
311
458
  .status { margin: 0; font-size: 13px; color: var(--ink-dim); min-height: 1.4em; }
312
459
  [hidden] { display: none !important; }
313
460
  @media (prefers-reduced-motion: no-preference) {
461
+ .bug {
462
+ 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),
463
+ border-radius 0.22s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.22s ease;
464
+ }
465
+ .bug svg { transition: transform 0.1s ease-out, width 0.22s ease, height 0.22s ease; }
314
466
  .sheet { animation: rise 0.22s ease-out; }
315
467
  @keyframes rise { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } }
316
468
  }
317
469
  `;
318
470
  var mounted = null;
319
471
  var feedbackOptedOut = (doc = document) => doc.documentElement.getAttribute("data-fs-feedback") === "off";
472
+ var SCROLL_OPTIONS = { capture: true, passive: true };
320
473
  function mountFlowFeedback(options) {
321
474
  if (typeof document === "undefined" || !document.body) return null;
322
475
  if (mounted) return mounted;
@@ -325,7 +478,8 @@ textarea::placeholder { color: var(--ink-dim); }
325
478
  host.setAttribute(FEEDBACK_HOST_ATTR, "");
326
479
  const root = host.attachShadow({ mode: "open" });
327
480
  root.innerHTML = `<style>${STYLE}</style>
328
- <button type="button" class="bug" aria-label="Send feedback on this page">${BUG_ICON}</button>
481
+ <button type="button" class="bug" data-state="float" aria-label="Send feedback on this page">${BUG_ICON}</button>
482
+ <div class="probe" aria-hidden="true"></div>
329
483
  <div class="scrim" hidden></div>
330
484
  <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="fsf-title" hidden>
331
485
  <div class="grab" aria-hidden="true"></div>
@@ -334,6 +488,7 @@ textarea::placeholder { color: var(--ink-dim); }
334
488
  <textarea aria-labelledby="fsf-title" placeholder="What should change?" maxlength="2000"></textarea>
335
489
  <p class="status" role="status" aria-live="polite"></p>
336
490
  <button type="button" class="send" disabled>Send feedback</button>
491
+ <button type="button" class="link tuck">Tuck the button to the side</button>
337
492
  </div>`;
338
493
  const q = (selector) => root.querySelector(selector);
339
494
  const bug = q(".bug");
@@ -345,10 +500,199 @@ textarea::placeholder { color: var(--ink-dim); }
345
500
  const text = q("textarea");
346
501
  const status = q(".status");
347
502
  const send = q(".send");
503
+ const tuck = q(".tuck");
504
+ const probe = q(".probe");
348
505
  let theme = readFlowTheme();
349
506
  let screenshot = null;
350
507
  let busy = false;
351
508
  let closeTimer = null;
509
+ let placeTimer = null;
510
+ let autoBottom = PLACEMENT_BASE;
511
+ let spot = { state: "float", x: 0, y: 0 };
512
+ let placedByPerson = false;
513
+ let velocity = { x: 0, y: 0 };
514
+ let frame = 0;
515
+ const px = (name) => {
516
+ const raw = getComputedStyle(host).getPropertyValue(name).trim();
517
+ const n = raw ? parseFloat(raw) : NaN;
518
+ return Number.isFinite(n) ? n : null;
519
+ };
520
+ const viewport = () => {
521
+ const style = getComputedStyle(probe);
522
+ return {
523
+ width: window.innerWidth,
524
+ height: window.innerHeight,
525
+ top: parseFloat(style.paddingTop) || 0,
526
+ bottom: parseFloat(style.paddingBottom) || 0
527
+ };
528
+ };
529
+ const draw = () => {
530
+ bug.style.transform = `translate3d(${spot.x}px, ${spot.y}px, 0)`;
531
+ };
532
+ const setState = (next) => {
533
+ spot.state = next;
534
+ bug.dataset.state = next;
535
+ };
536
+ const remember = (spot2) => {
537
+ if (!placedByPerson) return;
538
+ try {
539
+ const view = viewport();
540
+ const width = spot2.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
541
+ localStorage.setItem(
542
+ SPOT_KEY,
543
+ JSON.stringify({
544
+ state: spot2.state,
545
+ left: spot2.x + width / 2 < view.width / 2,
546
+ y: view.height > 0 ? spot2.y / view.height : 0
547
+ })
548
+ );
549
+ } catch {
550
+ }
551
+ };
552
+ const still = () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
553
+ const springTo = (to, flicked) => {
554
+ cancelAnimationFrame(frame);
555
+ frame = 0;
556
+ setState(to.state);
557
+ remember(to);
558
+ if (still() || typeof requestAnimationFrame !== "function") {
559
+ spot = { ...to };
560
+ velocity = { x: 0, y: 0 };
561
+ draw();
562
+ return;
563
+ }
564
+ const omega = 2 * Math.PI / 0.4;
565
+ const k = omega * omega;
566
+ const c = 2 * (flicked ? 0.8 : 1) * omega;
567
+ let last = performance.now();
568
+ const tick = (now) => {
569
+ const dt = Math.min(0.032, (now - last) / 1e3);
570
+ last = now;
571
+ velocity.x += (-k * (spot.x - to.x) - c * velocity.x) * dt;
572
+ velocity.y += (-k * (spot.y - to.y) - c * velocity.y) * dt;
573
+ spot.x += velocity.x * dt;
574
+ spot.y += velocity.y * dt;
575
+ 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;
576
+ if (close2) {
577
+ spot = { ...to };
578
+ velocity = { x: 0, y: 0 };
579
+ frame = 0;
580
+ draw();
581
+ return;
582
+ }
583
+ draw();
584
+ frame = requestAnimationFrame(tick);
585
+ };
586
+ frame = requestAnimationFrame(tick);
587
+ };
588
+ const place = () => {
589
+ placeTimer = null;
590
+ if (!sheet.hidden || bug.hidden || drag || frame) return;
591
+ const view = viewport();
592
+ if (placedByPerson || spot.state !== "float") {
593
+ const width = spot.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
594
+ spot = restingSpot(spot.state, spot.x + width / 2 < view.width / 2, spot.y, view);
595
+ draw();
596
+ return;
597
+ }
598
+ const right = px("--fs-feedback-right") ?? 14;
599
+ const left = view.width - right - BUTTON_SIZE;
600
+ const pinned = px("--fs-feedback-bottom");
601
+ if (pinned === null && typeof document.elementsFromPoint === "function") {
602
+ autoBottom = choosePlacement(
603
+ {
604
+ left,
605
+ top: view.height - view.bottom - autoBottom - BUTTON_SIZE,
606
+ size: BUTTON_SIZE,
607
+ bottom: autoBottom
608
+ },
609
+ view.height,
610
+ occupancyReader(host)
611
+ );
612
+ }
613
+ const bottom = pinned ?? autoBottom;
614
+ spot = { state: "float", x: left, y: view.height - view.bottom - bottom - BUTTON_SIZE };
615
+ draw();
616
+ };
617
+ const schedulePlace = () => {
618
+ if (placeTimer) clearTimeout(placeTimer);
619
+ placeTimer = setTimeout(place, 150);
620
+ };
621
+ let drag = null;
622
+ let suppressClick = false;
623
+ const onDown = (event) => {
624
+ if (!sheet.hidden || busy) return;
625
+ bug.setPointerCapture?.(event.pointerId);
626
+ cancelAnimationFrame(frame);
627
+ frame = 0;
628
+ velocity = { x: 0, y: 0 };
629
+ drag = {
630
+ id: event.pointerId,
631
+ ox: event.clientX - spot.x,
632
+ oy: event.clientY - spot.y,
633
+ sx: event.clientX,
634
+ sy: event.clientY,
635
+ moved: false,
636
+ history: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }]
637
+ };
638
+ bug.classList.add("down");
639
+ };
640
+ const onMove = (event) => {
641
+ if (!drag || event.pointerId !== drag.id) return;
642
+ const dx = event.clientX - drag.sx;
643
+ const dy = event.clientY - drag.sy;
644
+ if (!drag.moved && Math.hypot(dx, dy) < 8) return;
645
+ if (!drag.moved) {
646
+ drag.moved = true;
647
+ bug.classList.add("dragging");
648
+ }
649
+ drag.history.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });
650
+ if (drag.history.length > 6) drag.history.shift();
651
+ const view = viewport();
652
+ if (spot.state !== "float") {
653
+ const inward = spot.state === "dock-left" ? dx : -dx;
654
+ if (inward <= 18) {
655
+ spot = restingSpot(spot.state, spot.state === "dock-left", event.clientY - drag.oy, view);
656
+ draw();
657
+ return;
658
+ }
659
+ setState("float");
660
+ drag.ox = BUTTON_SIZE / 2;
661
+ drag.oy = BUTTON_SIZE / 2;
662
+ }
663
+ let x = event.clientX - drag.ox;
664
+ let y = event.clientY - drag.oy;
665
+ const maxX = view.width - BUTTON_SIZE;
666
+ const maxY = view.height - BUTTON_SIZE;
667
+ if (x < 0) x = rubberband(x, 60);
668
+ else if (x > maxX) x = maxX + rubberband(x - maxX, 60);
669
+ if (y < 0) y = rubberband(y, 80);
670
+ else if (y > maxY) y = maxY + rubberband(y - maxY, 80);
671
+ spot.x = x;
672
+ spot.y = y;
673
+ draw();
674
+ };
675
+ const onUp = (event) => {
676
+ if (!drag || event.pointerId !== drag.id) return;
677
+ const ended = drag;
678
+ drag = null;
679
+ bug.classList.remove("down", "dragging");
680
+ if (!ended.moved) return;
681
+ suppressClick = true;
682
+ setTimeout(() => {
683
+ suppressClick = false;
684
+ }, 0);
685
+ const first = ended.history[0];
686
+ const last = ended.history[ended.history.length - 1];
687
+ const dt = Math.max(1, last.t - first.t);
688
+ const paused = event.timeStamp - last.t > 90;
689
+ velocity = paused ? { x: 0, y: 0 } : { x: (last.x - first.x) / dt * 1e3, y: (last.y - first.y) / dt * 1e3 };
690
+ placedByPerson = true;
691
+ springTo(settleRelease(spot, velocity, viewport()), Math.hypot(velocity.x, velocity.y) > 350);
692
+ };
693
+ const observer = typeof MutationObserver === "function" ? new MutationObserver((records) => {
694
+ if (records.some((r) => r.target !== host)) schedulePlace();
695
+ }) : null;
352
696
  const paint = () => {
353
697
  theme = readFlowTheme();
354
698
  const vars = {
@@ -388,6 +732,7 @@ textarea::placeholder { color: var(--ink-dim); }
388
732
  status.textContent = "";
389
733
  busy = false;
390
734
  sync();
735
+ schedulePlace();
391
736
  };
392
737
  const open = async () => {
393
738
  if (!sheet.hidden || busy) return;
@@ -398,6 +743,7 @@ textarea::placeholder { color: var(--ink-dim); }
398
743
  const url = await withTimeout(Promise.resolve().then(capture), CAPTURE_TIMEOUT_MS);
399
744
  busy = false;
400
745
  setShot(url);
746
+ tuck.hidden = spot.state !== "float";
401
747
  scrim.hidden = false;
402
748
  sheet.hidden = false;
403
749
  sync();
@@ -428,7 +774,28 @@ textarea::placeholder { color: var(--ink-dim); }
428
774
  sync();
429
775
  }
430
776
  };
431
- bug.addEventListener("click", () => void open());
777
+ bug.addEventListener("click", () => {
778
+ if (!suppressClick) void open();
779
+ });
780
+ bug.addEventListener("pointerdown", onDown);
781
+ bug.addEventListener("pointermove", onMove);
782
+ bug.addEventListener("pointerup", onUp);
783
+ bug.addEventListener("pointercancel", onUp);
784
+ tuck.addEventListener("click", () => {
785
+ const view = viewport();
786
+ const left = spot.x + BUTTON_SIZE / 2 < view.width / 2;
787
+ close();
788
+ placedByPerson = true;
789
+ springTo(
790
+ restingSpot(
791
+ left ? "dock-left" : "dock-right",
792
+ left,
793
+ spot.y + (BUTTON_SIZE - TAB_HEIGHT) / 2,
794
+ view
795
+ ),
796
+ false
797
+ );
798
+ });
432
799
  scrim.addEventListener("click", close);
433
800
  remove.addEventListener("click", () => setShot(null));
434
801
  text.addEventListener("input", sync);
@@ -442,6 +809,29 @@ textarea::placeholder { color: var(--ink-dim); }
442
809
  });
443
810
  document.body.appendChild(host);
444
811
  paint();
812
+ try {
813
+ const saved = JSON.parse(localStorage.getItem(SPOT_KEY) ?? "null");
814
+ if (saved && typeof saved.y === "number" && (saved.state === "float" || saved.state === "dock-left" || saved.state === "dock-right")) {
815
+ const view = viewport();
816
+ placedByPerson = true;
817
+ setState(saved.state);
818
+ spot = restingSpot(saved.state, saved.left === true, saved.y * view.height, view);
819
+ }
820
+ } catch {
821
+ }
822
+ place();
823
+ schedulePlace();
824
+ observer?.observe(document.body, {
825
+ childList: true,
826
+ subtree: true,
827
+ attributes: true,
828
+ attributeFilter: ["class", "style", "hidden"]
829
+ });
830
+ window.addEventListener("resize", schedulePlace);
831
+ window.addEventListener("load", schedulePlace);
832
+ window.addEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
833
+ document.addEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
834
+ document.addEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
445
835
  options.onReady?.();
446
836
  mounted = {
447
837
  host,
@@ -452,6 +842,14 @@ textarea::placeholder { color: var(--ink-dim); }
452
842
  close,
453
843
  unmount: () => {
454
844
  if (closeTimer) clearTimeout(closeTimer);
845
+ if (placeTimer) clearTimeout(placeTimer);
846
+ cancelAnimationFrame(frame);
847
+ observer?.disconnect();
848
+ window.removeEventListener("resize", schedulePlace);
849
+ window.removeEventListener("load", schedulePlace);
850
+ window.removeEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
851
+ document.removeEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
852
+ document.removeEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
455
853
  host.remove();
456
854
  mounted = null;
457
855
  }