@mahmulp/feedback-sdk 0.1.0 → 0.1.1

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Machmul Pratama
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machmul Pratama
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,110 +1,111 @@
1
- # @mahmulp/feedback-sdk
2
-
3
- [![npm](https://img.shields.io/npm/v/@mahmulp/feedback-sdk.svg)](https://www.npmjs.com/package/@mahmulp/feedback-sdk)
4
- [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@mahmulp/feedback-sdk)](https://bundlephobia.com/package/@mahmulp/feedback-sdk)
5
-
6
- Framework-agnostic visual feedback SDK for prototype review. Reviewers pin comments to UI elements; you get back a stable DOM selector, percentage + pixel coordinates, viewport metadata, and an optional screenshot.
7
-
8
- Designed for **self-hosted** setups: pair this SDK with a backend (we ship one in the same repo) and own your data.
9
-
10
- - Floating launcher widget built in (toggle feedback mode, hide pins, hide launcher)
11
- - Stable DOM selectors that survive layout changes
12
- - Drag-to-move pins with optimistic update
13
- - Optional screenshot capture (via dynamically loaded `html2canvas`)
14
- - Svelte adapter for ergonomic SvelteKit integration
15
- - Works in React, Vue, and plain HTML via the same core API
16
-
17
- ## Install
18
-
19
- ```bash
20
- npm install @mahmulp/feedback-sdk
21
- bun add @mahmulp/feedback-sdk
22
- pnpm add @mahmulp/feedback-sdk
23
- yarn add @mahmulp/feedback-sdk
24
- ```
25
-
26
- ## Quick start
27
-
28
- You always need an `apiUrl` and an `apiKey`. The key tells the API which project this prototype belongs to — generate one in your dashboard's project settings.
29
-
30
- ### Vanilla / React / Vue
31
-
32
- ```ts
33
- import { initFeedback } from '@mahmulp/feedback-sdk'
34
-
35
- initFeedback({
36
- apiUrl: 'https://feedback.example.com',
37
- apiKey: 'mp_…',
38
- })
39
- ```
40
-
41
- That's it. A floating launcher appears in the bottom-right corner.
42
-
43
- ### Svelte / SvelteKit
44
-
45
- ```svelte
46
- <script lang="ts">
47
- import { feedback } from '@mahmulp/feedback-sdk/svelte'
48
- </script>
49
-
50
- <div use:feedback={{
51
- apiUrl: 'https://feedback.example.com',
52
- apiKey: 'mp_…',
53
- }}>
54
- <slot />
55
- </div>
56
- ```
57
-
58
- ### Local development without a backend
59
-
60
- For demos, e2e tests, or styling work — use the in-memory mock transport:
61
-
62
- ```ts
63
- import { initFeedback } from '@mahmulp/feedback-sdk'
64
- import { createMockTransport } from '@mahmulp/feedback-sdk/mock'
65
-
66
- initFeedback({ transport: createMockTransport() })
67
- ```
68
-
69
- ## What it does
70
-
71
- When the user enters feedback mode (via the launcher or `setEnabled(true)`):
72
-
73
- - **Hover** any element → outline highlight + tag/class HUD.
74
- - **Click** composer popover (name, email, comment, optional screenshot).
75
- - **Alt-click** → walks one parent up so you can pin a coarser element.
76
- - **Esc** → cancel.
77
-
78
- Existing pins are rendered as draggable markers. Click a pin to open its thread, reply, or change status. Drop a `data-feedback-id="some-key"` attribute on important elements to make their selectors human-readable and refactor-proof.
79
-
80
- ## Public API
81
-
82
- ```ts
83
- import {
84
- initFeedback,
85
- setFeedbackEnabled,
86
- destroyFeedback,
87
- resolveSelector,
88
- findElement,
89
- createHttpTransport,
90
- captureViewport,
91
- } from '@mahmulp/feedback-sdk'
92
- ```
93
-
94
- ```ts
95
- import { feedback, feedbackEnabled } from '@mahmulp/feedback-sdk/svelte'
96
- ```
97
-
98
- ```ts
99
- import { createMockTransport } from '@mahmulp/feedback-sdk/mock'
100
- ```
101
-
102
- The full set of options is documented in `InitFeedbackOptions` (TypeScript types ship with the package).
103
-
104
- ## Self-host the backend
105
-
106
- The SDK pairs with a small self-hostable backend (Hono on Bun + PostgreSQL or in-memory store) that lives in the same monorepo: <https://github.com/MahmulP/feedback-prototype>. The dashboard there manages users, projects, and per-project API keys.
107
-
108
- ## License
109
-
110
- MIT © Mahmul Pratama
1
+ # @mahmulp/feedback-sdk
2
+
3
+ [![npm](https://img.shields.io/npm/v/@mahmulp/feedback-sdk.svg)](https://www.npmjs.com/package/@mahmulp/feedback-sdk)
4
+ [![types](https://img.shields.io/npm/types/@mahmulp/feedback-sdk.svg)](https://www.npmjs.com/package/@mahmulp/feedback-sdk)
5
+ [![license](https://img.shields.io/npm/l/@mahmulp/feedback-sdk.svg)](./LICENSE)
6
+
7
+ Framework-agnostic visual feedback SDK for prototype review. Reviewers pin comments to UI elements; you get back a stable DOM selector, percentage + pixel coordinates, viewport metadata, and an optional screenshot.
8
+
9
+ Designed for **self-hosted** setups: pair this SDK with a backend (we ship one in the same repo) and own your data.
10
+
11
+ - Floating launcher widget built in (toggle feedback mode, hide pins, hide launcher)
12
+ - Stable DOM selectors that survive layout changes
13
+ - Drag-to-move pins with optimistic update
14
+ - Screenshot capture via dynamically loaded `html2canvas-pro` (bundled as a direct dependency)
15
+ - Svelte adapter for ergonomic SvelteKit integration
16
+ - Works in React, Vue, and plain HTML via the same core API
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install @mahmulp/feedback-sdk
22
+ bun add @mahmulp/feedback-sdk
23
+ pnpm add @mahmulp/feedback-sdk
24
+ yarn add @mahmulp/feedback-sdk
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ You always need an `apiUrl` and an `apiKey`. The key tells the API which project this prototype belongs to — generate one in your dashboard's project settings.
30
+
31
+ ### Vanilla / React / Vue
32
+
33
+ ```ts
34
+ import { initFeedback } from '@mahmulp/feedback-sdk'
35
+
36
+ initFeedback({
37
+ apiUrl: 'https://feedback.example.com',
38
+ apiKey: 'mp_…',
39
+ })
40
+ ```
41
+
42
+ That's it. A floating launcher appears in the bottom-right corner.
43
+
44
+ ### Svelte / SvelteKit
45
+
46
+ ```svelte
47
+ <script lang="ts">
48
+ import { feedback } from '@mahmulp/feedback-sdk/svelte'
49
+ </script>
50
+
51
+ <div use:feedback={{
52
+ apiUrl: 'https://feedback.example.com',
53
+ apiKey: 'mp_…',
54
+ }}>
55
+ <slot />
56
+ </div>
57
+ ```
58
+
59
+ ### Local development without a backend
60
+
61
+ For demos, e2e tests, or styling work — use the in-memory mock transport:
62
+
63
+ ```ts
64
+ import { initFeedback } from '@mahmulp/feedback-sdk'
65
+ import { createMockTransport } from '@mahmulp/feedback-sdk/mock'
66
+
67
+ initFeedback({ transport: createMockTransport() })
68
+ ```
69
+
70
+ ## What it does
71
+
72
+ When the user enters feedback mode (via the launcher or `setEnabled(true)`):
73
+
74
+ - **Hover** any element outline highlight + tag/class HUD.
75
+ - **Click** → composer popover (name, email, comment, optional screenshot).
76
+ - **Alt-click** → walks one parent up so you can pin a coarser element.
77
+ - **Esc** → cancel.
78
+
79
+ Existing pins are rendered as draggable markers. Click a pin to open its thread, reply, or change status. Drop a `data-feedback-id="some-key"` attribute on important elements to make their selectors human-readable and refactor-proof.
80
+
81
+ ## Public API
82
+
83
+ ```ts
84
+ import {
85
+ initFeedback,
86
+ setFeedbackEnabled,
87
+ destroyFeedback,
88
+ resolveSelector,
89
+ findElement,
90
+ createHttpTransport,
91
+ captureViewport,
92
+ } from '@mahmulp/feedback-sdk'
93
+ ```
94
+
95
+ ```ts
96
+ import { feedback, feedbackEnabled } from '@mahmulp/feedback-sdk/svelte'
97
+ ```
98
+
99
+ ```ts
100
+ import { createMockTransport } from '@mahmulp/feedback-sdk/mock'
101
+ ```
102
+
103
+ The full set of options is documented in `InitFeedbackOptions` (TypeScript types ship with the package).
104
+
105
+ ## Self-host the backend
106
+
107
+ The SDK pairs with a small self-hostable backend (Hono on Bun + PostgreSQL or in-memory store) that lives in the same monorepo: <https://github.com/MahmulP/feedback-prototype>. The dashboard there manages users, projects, and per-project API keys.
108
+
109
+ ## License
110
+
111
+ MIT © Mahmul Pratama
package/dist/index.cjs CHANGED
@@ -184,7 +184,10 @@ var LauncherManager = class {
184
184
  __publicField(this, "state", {
185
185
  enabled: false,
186
186
  pinsVisible: true,
187
- launcherVisible: true
187
+ // Start collapsed — only the small reveal bubble is visible until the
188
+ // user clicks it. Less visual weight on the prototype out of the box;
189
+ // user opts in by clicking the bubble icon in the bottom-right.
190
+ launcherVisible: false
188
191
  });
189
192
  this.launcherEl = document.createElement("div");
190
193
  this.launcherEl.className = "launcher";
@@ -236,6 +239,7 @@ var LauncherManager = class {
236
239
  /** True when an event originated from the launcher's own UI. */
237
240
  ownsNode(node) {
238
241
  if (!node) return false;
242
+ if (typeof Node === "undefined" || !(node instanceof Node)) return false;
239
243
  return this.launcherEl.contains(node) || this.revealEl.contains(node);
240
244
  }
241
245
  destroy() {
@@ -317,11 +321,34 @@ var PopoverManager = class {
317
321
  });
318
322
  return el;
319
323
  }
324
+ /** True when a thread for this feedback is already open. */
325
+ isThreadOpenFor(feedbackId) {
326
+ return this.current?.type === "thread" && this.current.feedbackId === feedbackId;
327
+ }
320
328
  showThread(feedback, anchor, cb) {
329
+ if (this.current?.type === "thread" && this.current.feedbackId === feedback.id) {
330
+ const fresh = this.buildThread(feedback, cb);
331
+ this.current.el.replaceWith(fresh);
332
+ this.current = {
333
+ type: "thread",
334
+ el: fresh,
335
+ pageX: anchor.pageX,
336
+ pageY: anchor.pageY,
337
+ feedbackId: feedback.id
338
+ };
339
+ this.repositionInternal();
340
+ return fresh;
341
+ }
321
342
  this.hide();
322
343
  const el = this.buildThread(feedback, cb);
323
344
  this.layer.appendChild(el);
324
- this.current = { type: "thread", el, pageX: anchor.pageX, pageY: anchor.pageY };
345
+ this.current = {
346
+ type: "thread",
347
+ el,
348
+ pageX: anchor.pageX,
349
+ pageY: anchor.pageY,
350
+ feedbackId: feedback.id
351
+ };
325
352
  this.repositionInternal();
326
353
  return el;
327
354
  }
@@ -1029,33 +1056,65 @@ var Overlay = class {
1029
1056
  });
1030
1057
  }
1031
1058
  layoutPins() {
1032
- const next = document.createDocumentFragment();
1059
+ const existing = /* @__PURE__ */ new Map();
1060
+ for (const node of Array.from(this.pinLayer.children)) {
1061
+ const id = node.dataset.feedbackId;
1062
+ if (id) existing.set(id, node);
1063
+ }
1064
+ const desiredOrder = [];
1065
+ const seen = /* @__PURE__ */ new Set();
1033
1066
  for (const fb of this.pendingFeedback) {
1067
+ seen.add(fb.id);
1034
1068
  const target = findElement(fb.selector);
1035
1069
  const projected = projectCoordinates(target, fb.coordinates);
1036
- const pin = document.createElement("button");
1037
- pin.type = "button";
1038
- const classes = ["pin"];
1039
- if (projected.orphaned) classes.push("orphaned");
1040
- if (fb.status === "resolved") classes.push("resolved");
1041
- if (fb.status === "archived") classes.push("archived");
1042
- pin.className = classes.join(" ");
1043
- pin.setAttribute("data-feedback-id", fb.id);
1044
- pin.setAttribute("aria-label", `Feedback ${fb.id}`);
1045
- pin.style.left = `${projected.x - window.scrollX}px`;
1046
- pin.style.top = `${projected.y - window.scrollY}px`;
1047
- const label = document.createElement("span");
1048
- label.textContent = String(fb.thread.length || 1);
1049
- pin.appendChild(label);
1050
- pin.addEventListener("click", (e) => {
1051
- e.stopPropagation();
1052
- if (this.draggingPin === fb.id) return;
1053
- this.onPinClick?.(fb);
1054
- });
1055
- this.attachDragHandlers(pin, fb);
1056
- next.appendChild(pin);
1070
+ const left = `${projected.x - window.scrollX}px`;
1071
+ const top = `${projected.y - window.scrollY}px`;
1072
+ let pin = existing.get(fb.id);
1073
+ if (pin) {
1074
+ const classes = ["pin"];
1075
+ if (projected.orphaned) classes.push("orphaned");
1076
+ if (fb.status === "resolved") classes.push("resolved");
1077
+ if (fb.status === "archived") classes.push("archived");
1078
+ if (pin.classList.contains("dragging")) classes.push("dragging");
1079
+ pin.className = classes.join(" ");
1080
+ if (pin.style.left !== left) pin.style.left = left;
1081
+ if (pin.style.top !== top) pin.style.top = top;
1082
+ const label = pin.firstElementChild;
1083
+ const nextLabel = String(fb.thread.length || 1);
1084
+ if (label && label.textContent !== nextLabel) label.textContent = nextLabel;
1085
+ } else {
1086
+ pin = document.createElement("button");
1087
+ pin.type = "button";
1088
+ const classes = ["pin"];
1089
+ if (projected.orphaned) classes.push("orphaned");
1090
+ if (fb.status === "resolved") classes.push("resolved");
1091
+ if (fb.status === "archived") classes.push("archived");
1092
+ pin.className = classes.join(" ");
1093
+ pin.dataset.feedbackId = fb.id;
1094
+ pin.setAttribute("aria-label", `Feedback ${fb.id}`);
1095
+ pin.style.left = left;
1096
+ pin.style.top = top;
1097
+ const label = document.createElement("span");
1098
+ label.textContent = String(fb.thread.length || 1);
1099
+ pin.appendChild(label);
1100
+ pin.addEventListener("click", (e) => {
1101
+ e.stopPropagation();
1102
+ if (this.draggingPin === fb.id) return;
1103
+ const fresh = this.pendingFeedback.find((f) => f.id === fb.id) ?? fb;
1104
+ this.onPinClick?.(fresh);
1105
+ });
1106
+ this.attachDragHandlers(pin, fb);
1107
+ }
1108
+ desiredOrder.push(pin);
1109
+ }
1110
+ for (const [id, node] of existing) {
1111
+ if (!seen.has(id)) node.remove();
1112
+ }
1113
+ for (let i = 0; i < desiredOrder.length; i++) {
1114
+ const want = desiredOrder[i];
1115
+ const have = this.pinLayer.children[i];
1116
+ if (have !== want) this.pinLayer.insertBefore(want, have ?? null);
1057
1117
  }
1058
- this.pinLayer.replaceChildren(next);
1059
1118
  }
1060
1119
  destroy() {
1061
1120
  if (this.rafHandle !== null) {
@@ -1174,8 +1233,6 @@ async function captureViewport(options = {}) {
1174
1233
  const html2canvas = await loadHtml2Canvas();
1175
1234
  if (!html2canvas) return null;
1176
1235
  const host = document.querySelector(`[${HOST_ATTR2}]`);
1177
- const previousVisibility = host?.style.visibility ?? "";
1178
- if (host) host.style.visibility = "hidden";
1179
1236
  try {
1180
1237
  const canvas = await html2canvas(document.documentElement, {
1181
1238
  backgroundColor: null,
@@ -1188,23 +1245,37 @@ async function captureViewport(options = {}) {
1188
1245
  windowHeight: window.innerHeight,
1189
1246
  logging: false,
1190
1247
  useCORS: true,
1191
- allowTaint: false
1248
+ allowTaint: false,
1249
+ ignoreElements: (el) => {
1250
+ if (host && (el === host || host.contains(el))) return true;
1251
+ if (el instanceof HTMLElement && el.hasAttribute(HOST_ATTR2)) return true;
1252
+ return false;
1253
+ }
1192
1254
  });
1193
1255
  return await canvasToBlob(canvas, opts.mimeType, opts.quality);
1194
- } catch {
1256
+ } catch (err) {
1257
+ if (typeof console !== "undefined") {
1258
+ console.warn("[feedback-sdk] screenshot capture failed:", err);
1259
+ }
1195
1260
  return null;
1196
- } finally {
1197
- if (host) host.style.visibility = previousVisibility;
1198
1261
  }
1199
1262
  }
1200
1263
  var html2canvasPromise = null;
1264
+ var html2canvasMissingWarned = false;
1201
1265
  async function loadHtml2Canvas() {
1202
1266
  if (html2canvasPromise) return html2canvasPromise;
1203
1267
  html2canvasPromise = (async () => {
1204
1268
  try {
1205
- const mod = await import('html2canvas');
1269
+ const mod = await import('html2canvas-pro');
1206
1270
  return mod.default ?? mod ?? null;
1207
- } catch {
1271
+ } catch (err) {
1272
+ if (!html2canvasMissingWarned && typeof console !== "undefined") {
1273
+ html2canvasMissingWarned = true;
1274
+ console.warn(
1275
+ "[feedback-sdk] screenshot capture disabled: failed to load `html2canvas-pro`. This shouldn't normally happen \u2014 html2canvas-pro is a direct dependency of the SDK. Pass `captureScreenshots: false` to silence this warning if intended.",
1276
+ err
1277
+ );
1278
+ }
1208
1279
  return null;
1209
1280
  }
1210
1281
  })();
@@ -1497,8 +1568,6 @@ function initFeedback(options) {
1497
1568
  const updated = await transport.reply(fb.id, { author, body });
1498
1569
  setAuthor(author);
1499
1570
  replaceFeedback(updated);
1500
- overlay.popoverManager().hide();
1501
- activeThreadId = null;
1502
1571
  openThread(updated);
1503
1572
  } catch (err) {
1504
1573
  reportError(err);
@@ -1508,8 +1577,6 @@ function initFeedback(options) {
1508
1577
  try {
1509
1578
  const updated = await transport.setStatus(fb.id, status);
1510
1579
  replaceFeedback(updated);
1511
- overlay.popoverManager().hide();
1512
- activeThreadId = null;
1513
1580
  openThread(updated);
1514
1581
  } catch (err) {
1515
1582
  reportError(err);
@@ -1557,7 +1624,8 @@ function initFeedback(options) {
1557
1624
  }
1558
1625
  async function refresh() {
1559
1626
  try {
1560
- const result = await transport.list({ projectId: "" });
1627
+ const pageUrl = getPageUrl();
1628
+ const result = await transport.list({ projectId: "", pageUrl });
1561
1629
  state.feedbacks = result.items;
1562
1630
  overlay.renderPins(state.feedbacks, openThread, onPinDragEnd);
1563
1631
  } catch (err) {
@@ -1587,12 +1655,35 @@ function initFeedback(options) {
1587
1655
  document.addEventListener("keydown", onKeyDown, true);
1588
1656
  window.addEventListener("scroll", onWindowReposition, true);
1589
1657
  window.addEventListener("resize", onWindowReposition);
1658
+ let lastSeenUrl = getPageUrl();
1659
+ function onMaybeNavigate() {
1660
+ const next = getPageUrl();
1661
+ if (next === lastSeenUrl) return;
1662
+ lastSeenUrl = next;
1663
+ void refresh();
1664
+ }
1665
+ const originalPushState = history.pushState.bind(history);
1666
+ const originalReplaceState = history.replaceState.bind(history);
1667
+ history.pushState = function patchedPushState(...args) {
1668
+ const result = originalPushState(...args);
1669
+ queueMicrotask(onMaybeNavigate);
1670
+ return result;
1671
+ };
1672
+ history.replaceState = function patchedReplaceState(...args) {
1673
+ const result = originalReplaceState(...args);
1674
+ queueMicrotask(onMaybeNavigate);
1675
+ return result;
1676
+ };
1677
+ window.addEventListener("popstate", onMaybeNavigate);
1590
1678
  void refresh();
1591
1679
  overlay.setEnabledStyles(state.enabled);
1592
1680
  const wantsLauncher = options.showLauncher !== false;
1593
1681
  const launcherState = {
1594
1682
  pinsVisible: options.pinsVisible !== false,
1595
- launcherVisible: true
1683
+ // Default to a collapsed launcher. The user reveals it by clicking the
1684
+ // small bubble button in the bottom-right corner. This keeps the SDK's
1685
+ // visual footprint near zero on first paint of the prototype.
1686
+ launcherVisible: false
1596
1687
  };
1597
1688
  overlay.setPinsVisible(launcherState.pinsVisible);
1598
1689
  function syncLauncher() {
@@ -1649,6 +1740,9 @@ function initFeedback(options) {
1649
1740
  document.removeEventListener("keydown", onKeyDown, true);
1650
1741
  window.removeEventListener("scroll", onWindowReposition, true);
1651
1742
  window.removeEventListener("resize", onWindowReposition);
1743
+ window.removeEventListener("popstate", onMaybeNavigate);
1744
+ history.pushState = originalPushState;
1745
+ history.replaceState = originalReplaceState;
1652
1746
  overlay.destroy();
1653
1747
  }
1654
1748
  };