@drip-apex/sdk 0.1.0

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.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
@@ -0,0 +1,305 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { startHeatmapCapture } from "./heatmap";
5
+ import type { Assignment, Experiment } from "./types";
6
+
7
+ const assignment: Assignment = {
8
+ experimentId: "exp_1",
9
+ variationId: "var_1",
10
+ index: 0,
11
+ mutations: [],
12
+ isControl: false,
13
+ hashValue: "hash_1",
14
+ };
15
+
16
+ function experiment(
17
+ capture: NonNullable<Experiment["behaviorCapture"]>,
18
+ ): Experiment {
19
+ return {
20
+ id: "exp_1",
21
+ behaviorCapture: {
22
+ enabled: true,
23
+ sampleRate: 1,
24
+ clicks: false,
25
+ scrolls: false,
26
+ moves: true,
27
+ ...capture,
28
+ },
29
+ variations: [],
30
+ };
31
+ }
32
+
33
+ function setVisibility(value: "visible" | "hidden") {
34
+ Object.defineProperty(document, "visibilityState", {
35
+ configurable: true,
36
+ value,
37
+ });
38
+ }
39
+
40
+ function dispatchPointer(clientX: number, pageY: number) {
41
+ const event = new MouseEvent("pointermove", {
42
+ bubbles: true,
43
+ clientX,
44
+ clientY: Math.min(pageY, window.innerHeight - 1),
45
+ });
46
+ Object.defineProperty(event, "pageY", { configurable: true, value: pageY });
47
+ document.dispatchEvent(event);
48
+ }
49
+
50
+ function dispatchClick(target: Element, pageX: number, pageY: number) {
51
+ const event = new MouseEvent("click", {
52
+ bubbles: true,
53
+ clientX: pageX,
54
+ clientY: pageY,
55
+ });
56
+ Object.defineProperty(event, "pageX", { configurable: true, value: pageX });
57
+ Object.defineProperty(event, "pageY", { configurable: true, value: pageY });
58
+ target.dispatchEvent(event);
59
+ }
60
+
61
+ describe("startHeatmapCapture", () => {
62
+ beforeEach(() => {
63
+ vi.useFakeTimers();
64
+ vi.setSystemTime(new Date("2026-07-10T12:00:00.000Z"));
65
+ sessionStorage.clear();
66
+ history.replaceState({}, "", "/products/example");
67
+ setVisibility("visible");
68
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1_000 });
69
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 800 });
70
+ Object.defineProperty(window, "scrollX", { configurable: true, value: 0 });
71
+ Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
72
+ Object.defineProperty(document.body, "scrollHeight", {
73
+ configurable: true,
74
+ value: 2_000,
75
+ });
76
+ Object.defineProperty(document.documentElement, "scrollHeight", {
77
+ configurable: true,
78
+ value: 2_000,
79
+ });
80
+ document.body.replaceChildren();
81
+ });
82
+
83
+ afterEach(() => {
84
+ vi.clearAllTimers();
85
+ vi.useRealTimers();
86
+ vi.restoreAllMocks();
87
+ });
88
+
89
+ it("accumulates dwell in five-percent document-relative buckets", () => {
90
+ const track = vi.fn();
91
+ const controller = startHeatmapCapture(
92
+ [assignment],
93
+ [experiment({})],
94
+ track,
95
+ );
96
+
97
+ dispatchPointer(250, 500);
98
+ vi.advanceTimersByTime(1_000);
99
+ controller.stop();
100
+
101
+ expect(track).toHaveBeenCalledWith(
102
+ "heatmap_move",
103
+ expect.objectContaining({
104
+ path: "/products/example",
105
+ page_url: "http://localhost:3000/products/example",
106
+ sample_ms: 1_000,
107
+ points: "25:25:1000",
108
+ }),
109
+ "exp_1",
110
+ "var_1",
111
+ );
112
+ });
113
+
114
+ it("encodes highest-dwell points first and stays under the worker string cap", () => {
115
+ const track = vi.fn();
116
+ const controller = startHeatmapCapture(
117
+ [assignment],
118
+ [experiment({})],
119
+ track,
120
+ );
121
+
122
+ dispatchPointer(1, 1);
123
+ vi.advanceTimersByTime(1_200);
124
+ for (let index = 1; index <= 180; index += 1) {
125
+ const xBucket = index % 20;
126
+ const yBucket = Math.floor(index / 20);
127
+ dispatchPointer(xBucket * 50 + 1, yBucket * 100 + 1);
128
+ vi.advanceTimersByTime(200);
129
+ }
130
+ controller.stop();
131
+
132
+ const moveCall = track.mock.calls.find(([type]) => type === "heatmap_move");
133
+ expect(moveCall).toBeDefined();
134
+ const points = moveCall?.[1].points as string;
135
+ expect(points.length).toBeLessThanOrEqual(900);
136
+ expect(points.split(";")[0]).toBe("0:0:1200");
137
+ });
138
+
139
+ it("flushes and resets buffers when the document becomes hidden", () => {
140
+ const track = vi.fn();
141
+ const controller = startHeatmapCapture(
142
+ [assignment],
143
+ [experiment({})],
144
+ track,
145
+ );
146
+
147
+ dispatchPointer(100, 200);
148
+ vi.advanceTimersByTime(1_000);
149
+ setVisibility("hidden");
150
+ document.dispatchEvent(new Event("visibilitychange"));
151
+
152
+ setVisibility("visible");
153
+ dispatchPointer(800, 1_600);
154
+ vi.advanceTimersByTime(1_000);
155
+ controller.stop();
156
+
157
+ const points = track.mock.calls
158
+ .filter(([type]) => type === "heatmap_move")
159
+ .map(([, payload]) => payload.points);
160
+ expect(points).toEqual(["10:10:1000", "80:80:1000"]);
161
+ });
162
+
163
+ it("flushes the old page on SPA navigation and starts a fresh page buffer", () => {
164
+ const track = vi.fn();
165
+ const controller = startHeatmapCapture(
166
+ [assignment],
167
+ [experiment({})],
168
+ track,
169
+ );
170
+
171
+ dispatchPointer(100, 200);
172
+ vi.advanceTimersByTime(1_000);
173
+ history.pushState({}, "", "/collections/new");
174
+ vi.advanceTimersByTime(200);
175
+ dispatchPointer(800, 1_600);
176
+ vi.advanceTimersByTime(1_000);
177
+ controller.stop();
178
+
179
+ const moves = track.mock.calls.filter(([type]) => type === "heatmap_move");
180
+ expect(moves).toHaveLength(2);
181
+ expect(moves[0][1]).toMatchObject({
182
+ path: "/products/example",
183
+ page_url: "http://localhost:3000/products/example",
184
+ points: "10:10:1000",
185
+ });
186
+ expect(moves[1][1]).toMatchObject({
187
+ path: "/collections/new",
188
+ page_url: "http://localhost:3000/collections/new",
189
+ points: "80:80:1000",
190
+ });
191
+ });
192
+
193
+ it("drops movement buffers below one second of dwell", () => {
194
+ const track = vi.fn();
195
+ const controller = startHeatmapCapture(
196
+ [assignment],
197
+ [experiment({})],
198
+ track,
199
+ );
200
+
201
+ dispatchPointer(250, 500);
202
+ vi.advanceTimersByTime(800);
203
+ controller.stop();
204
+
205
+ expect(track.mock.calls.some(([type]) => type === "heatmap_move")).toBe(false);
206
+ });
207
+
208
+ it("does not start movement capture when moves are disabled", () => {
209
+ const track = vi.fn();
210
+ const controller = startHeatmapCapture(
211
+ [assignment],
212
+ [experiment({ moves: false })],
213
+ track,
214
+ );
215
+
216
+ dispatchPointer(250, 500);
217
+ vi.advanceTimersByTime(2_000);
218
+ controller.stop();
219
+
220
+ expect(track.mock.calls.some(([type]) => type === "heatmap_move")).toBe(false);
221
+ });
222
+
223
+ it("recomputes movement capture when assignments are refreshed", () => {
224
+ const track = vi.fn();
225
+ const controller = startHeatmapCapture(
226
+ [assignment],
227
+ [experiment({ moves: false })],
228
+ track,
229
+ );
230
+
231
+ controller.update([assignment], [experiment({ moves: true })]);
232
+ dispatchPointer(250, 500);
233
+ vi.advanceTimersByTime(1_000);
234
+ controller.stop();
235
+
236
+ expect(track.mock.calls.filter(([type]) => type === "heatmap_move")).toHaveLength(1);
237
+ });
238
+
239
+ it("marks the third fast clustered click as rage", () => {
240
+ const track = vi.fn();
241
+ const controller = startHeatmapCapture(
242
+ [assignment],
243
+ [experiment({ clicks: true, moves: false })],
244
+ track,
245
+ );
246
+ const button = document.createElement("button");
247
+ button.textContent = "Buy";
248
+ document.body.append(button);
249
+
250
+ dispatchClick(button, 100, 100);
251
+ vi.advanceTimersByTime(150);
252
+ dispatchClick(button, 108, 104);
253
+ vi.advanceTimersByTime(150);
254
+ dispatchClick(button, 96, 102);
255
+ controller.stop();
256
+
257
+ const clicks = track.mock.calls.filter(([type]) => type === "heatmap_click");
258
+ expect(clicks).toHaveLength(3);
259
+ expect(clicks[0][1]).not.toHaveProperty("rage");
260
+ expect(clicks[1][1]).not.toHaveProperty("rage");
261
+ expect(clicks[2][1]).toHaveProperty("rage", 1);
262
+ });
263
+
264
+ it("does not flag clicks that are spatially spread out", () => {
265
+ const track = vi.fn();
266
+ const controller = startHeatmapCapture(
267
+ [assignment],
268
+ [experiment({ clicks: true, moves: false })],
269
+ track,
270
+ );
271
+ const button = document.createElement("button");
272
+ document.body.append(button);
273
+
274
+ dispatchClick(button, 100, 100);
275
+ vi.advanceTimersByTime(150);
276
+ dispatchClick(button, 200, 200);
277
+ vi.advanceTimersByTime(150);
278
+ dispatchClick(button, 100, 100);
279
+ controller.stop();
280
+
281
+ const clicks = track.mock.calls.filter(([type]) => type === "heatmap_click");
282
+ expect(clicks.every(([, payload]) => !("rage" in payload))).toBe(true);
283
+ });
284
+
285
+ it("does not flag clustered clicks outside the rage window", () => {
286
+ const track = vi.fn();
287
+ const controller = startHeatmapCapture(
288
+ [assignment],
289
+ [experiment({ clicks: true, moves: false })],
290
+ track,
291
+ );
292
+ const button = document.createElement("button");
293
+ document.body.append(button);
294
+
295
+ dispatchClick(button, 100, 100);
296
+ vi.advanceTimersByTime(1_201);
297
+ dispatchClick(button, 104, 102);
298
+ vi.advanceTimersByTime(1_201);
299
+ dispatchClick(button, 98, 99);
300
+ controller.stop();
301
+
302
+ const clicks = track.mock.calls.filter(([type]) => type === "heatmap_click");
303
+ expect(clicks.every(([, payload]) => !("rage" in payload))).toBe(true);
304
+ });
305
+ });
package/src/heatmap.ts ADDED
@@ -0,0 +1,373 @@
1
+ import type { Assignment, Experiment } from "./types";
2
+
3
+ type TrackFn = (
4
+ type: string,
5
+ data: Record<string, unknown>,
6
+ experimentId: string,
7
+ variationId: string,
8
+ ) => void;
9
+
10
+ type CaptureAssignment = Assignment & {
11
+ capture: NonNullable<Experiment["behaviorCapture"]>;
12
+ };
13
+
14
+ type HeatmapController = {
15
+ stop: () => void;
16
+ update: (assignments: Assignment[], experiments: Experiment[]) => void;
17
+ };
18
+
19
+ const SCROLL_BUCKETS = [25, 50, 75, 90, 100];
20
+ const MOVEMENT_SAMPLE_MS = 200;
21
+ const MOVEMENT_FLUSH_MS = 60_000;
22
+ const MOVEMENT_GRID_SIZE = 5;
23
+ const MOVEMENT_POINTS_MAX_LENGTH = 900;
24
+ const MOVEMENT_MIN_DWELL_MS = 1_000;
25
+ const RAGE_CLICK_WINDOW_MS = 1_200;
26
+ const RAGE_CLICK_RADIUS_PX = 32;
27
+ const RAGE_CLICK_BUFFER_SIZE = 4;
28
+
29
+ function clamp(value: number, min: number, max: number): number {
30
+ return Math.max(min, Math.min(max, value));
31
+ }
32
+
33
+ function cleanUrl(): string {
34
+ return location.origin + location.pathname;
35
+ }
36
+
37
+ function stableSampleKey(assignment: Assignment): string {
38
+ return `drip_hm:${assignment.experimentId}:${assignment.variationId}`;
39
+ }
40
+
41
+ function sampledIn(assignment: Assignment, sampleRate: number): boolean {
42
+ if (sampleRate >= 1) return true;
43
+ if (sampleRate <= 0) return false;
44
+ try {
45
+ const key = stableSampleKey(assignment);
46
+ const existing = sessionStorage.getItem(key);
47
+ if (existing === "1") return true;
48
+ if (existing === "0") return false;
49
+ const selected = Math.random() < sampleRate;
50
+ sessionStorage.setItem(key, selected ? "1" : "0");
51
+ return selected;
52
+ } catch {
53
+ return Math.random() < sampleRate;
54
+ }
55
+ }
56
+
57
+ function normalizeSampleRate(value: unknown): number {
58
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0.05;
59
+ return clamp(value, 0, 1);
60
+ }
61
+
62
+ function captureSelector(element: Element): string {
63
+ const parts: string[] = [];
64
+ let current: Element | null = element;
65
+ while (current && current !== document.documentElement && parts.length < 4) {
66
+ const tag = current.tagName.toLowerCase();
67
+ const id = current.id && /^[A-Za-z][\w-]{0,80}$/.test(current.id)
68
+ ? `#${current.id}`
69
+ : "";
70
+ if (id) {
71
+ parts.unshift(`${tag}${id}`);
72
+ break;
73
+ }
74
+
75
+ const testId =
76
+ current.getAttribute("data-testid") ||
77
+ current.getAttribute("data-test") ||
78
+ current.getAttribute("data-drip-id");
79
+ if (testId && /^[\w-]{1,80}$/.test(testId)) {
80
+ parts.unshift(`${tag}[data-testid="${testId}"]`);
81
+ break;
82
+ }
83
+
84
+ let nth = 1;
85
+ let sibling = current.previousElementSibling;
86
+ while (sibling) {
87
+ if (sibling.tagName === current.tagName) nth += 1;
88
+ sibling = sibling.previousElementSibling;
89
+ }
90
+ parts.unshift(`${tag}:nth-of-type(${nth})`);
91
+ current = current.parentElement;
92
+ }
93
+ return parts.join(" > ");
94
+ }
95
+
96
+ function safeElementText(element: Element): string {
97
+ const tag = element.tagName.toLowerCase();
98
+ if (tag === "input" || tag === "textarea" || tag === "select") return "";
99
+ return (element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80);
100
+ }
101
+
102
+ function scrollPercent(): number {
103
+ const docHeight = Math.max(
104
+ document.body.scrollHeight,
105
+ document.documentElement.scrollHeight,
106
+ );
107
+ const viewportHeight = window.innerHeight || 1;
108
+ if (docHeight <= viewportHeight) return 100;
109
+ return Math.round(((window.scrollY + viewportHeight) / docHeight) * 100);
110
+ }
111
+
112
+ function resolveCaptureAssignments(
113
+ assignments: Assignment[],
114
+ experiments: Experiment[],
115
+ ): CaptureAssignment[] {
116
+ const experimentsById = new Map(experiments.map((experiment) => [experiment.id, experiment]));
117
+ const out: CaptureAssignment[] = [];
118
+ for (const assignment of assignments) {
119
+ const capture = experimentsById.get(assignment.experimentId)?.behaviorCapture;
120
+ if (!capture?.enabled) continue;
121
+ if (!sampledIn(assignment, normalizeSampleRate(capture.sampleRate))) continue;
122
+ out.push({ ...assignment, capture });
123
+ }
124
+ return out;
125
+ }
126
+
127
+ export function startHeatmapCapture(
128
+ assignments: Assignment[],
129
+ experiments: Experiment[],
130
+ track: TrackFn,
131
+ ): HeatmapController {
132
+ let active = resolveCaptureAssignments(assignments, experiments);
133
+ const seenScrollBuckets = new Set<string>();
134
+ let lastClickAt = 0;
135
+ let recentClicks: Array<{ x: number; y: number; t: number }> = [];
136
+ let lastPosition: { clientX: number; pageY: number } | null = null;
137
+ let movementBuckets = new Map<string, number>();
138
+ let bucketPageUrl = cleanUrl();
139
+ let movementSampleTimer: number | null = null;
140
+ let movementFlushTimer: number | null = null;
141
+
142
+ const movementEnabled = () =>
143
+ active.some((assignment) => assignment.capture.moves !== false);
144
+
145
+ const encodeMovementPoints = (buckets: Map<string, number>): string => {
146
+ const sorted = [...buckets.entries()].sort((a, b) => {
147
+ const dwellDifference = b[1] - a[1];
148
+ return dwellDifference !== 0 ? dwellDifference : a[0].localeCompare(b[0]);
149
+ });
150
+ const encoded: string[] = [];
151
+ let length = 0;
152
+ for (const [bucket, dwell] of sorted) {
153
+ const point = `${bucket}:${Math.round(dwell)}`;
154
+ const nextLength = length + (encoded.length > 0 ? 1 : 0) + point.length;
155
+ if (nextLength > MOVEMENT_POINTS_MAX_LENGTH) break;
156
+ encoded.push(point);
157
+ length = nextLength;
158
+ }
159
+ return encoded.join(";");
160
+ };
161
+
162
+ const flushMovement = () => {
163
+ if (movementBuckets.size === 0) return;
164
+ const buckets = movementBuckets;
165
+ movementBuckets = new Map();
166
+ const totalDwell = Math.round(
167
+ [...buckets.values()].reduce((sum, dwell) => sum + dwell, 0),
168
+ );
169
+ if (totalDwell < MOVEMENT_MIN_DWELL_MS) return;
170
+
171
+ const points = encodeMovementPoints(buckets);
172
+ if (!points) return;
173
+ let path = location.pathname;
174
+ try {
175
+ path = new URL(bucketPageUrl).pathname;
176
+ } catch {
177
+ // Keep the current pathname if the page URL cannot be parsed.
178
+ }
179
+ const payload = {
180
+ path,
181
+ page_url: bucketPageUrl,
182
+ viewport_width: window.innerWidth,
183
+ viewport_height: window.innerHeight,
184
+ document_height: Math.max(
185
+ document.body.scrollHeight,
186
+ document.documentElement.scrollHeight,
187
+ ),
188
+ sample_ms: totalDwell,
189
+ points,
190
+ };
191
+
192
+ for (const assignment of active) {
193
+ if (assignment.capture.moves === false) continue;
194
+ track("heatmap_move", payload, assignment.experimentId, assignment.variationId);
195
+ }
196
+ };
197
+
198
+ const sampleMovement = () => {
199
+ if (document.visibilityState !== "visible" || !lastPosition) return;
200
+ const currentPageUrl = cleanUrl();
201
+ if (currentPageUrl !== bucketPageUrl) {
202
+ flushMovement();
203
+ bucketPageUrl = currentPageUrl;
204
+ lastPosition = null;
205
+ return;
206
+ }
207
+
208
+ const documentHeight = Math.max(
209
+ 1,
210
+ document.body.scrollHeight,
211
+ document.documentElement.scrollHeight,
212
+ );
213
+ const xPercent = clamp(
214
+ (lastPosition.clientX / Math.max(1, window.innerWidth)) * 100,
215
+ 0,
216
+ 99.999,
217
+ );
218
+ const yPercent = clamp(
219
+ (lastPosition.pageY / documentHeight) * 100,
220
+ 0,
221
+ 99.999,
222
+ );
223
+ const xBucket =
224
+ Math.floor(xPercent / MOVEMENT_GRID_SIZE) * MOVEMENT_GRID_SIZE;
225
+ const yBucket =
226
+ Math.floor(yPercent / MOVEMENT_GRID_SIZE) * MOVEMENT_GRID_SIZE;
227
+ const key = `${xBucket}:${yBucket}`;
228
+ movementBuckets.set(
229
+ key,
230
+ (movementBuckets.get(key) ?? 0) + MOVEMENT_SAMPLE_MS,
231
+ );
232
+ };
233
+
234
+ const onPointerMove = (event: PointerEvent) => {
235
+ lastPosition = { clientX: event.clientX, pageY: event.pageY };
236
+ };
237
+
238
+ const onVisibilityChange = () => {
239
+ if (document.visibilityState === "hidden") flushMovement();
240
+ };
241
+
242
+ const stopMovement = (flush: boolean) => {
243
+ if (flush) flushMovement();
244
+ if (movementSampleTimer !== null) {
245
+ window.clearInterval(movementSampleTimer);
246
+ movementSampleTimer = null;
247
+ }
248
+ if (movementFlushTimer !== null) {
249
+ window.clearInterval(movementFlushTimer);
250
+ movementFlushTimer = null;
251
+ }
252
+ document.removeEventListener("pointermove", onPointerMove);
253
+ document.removeEventListener("visibilitychange", onVisibilityChange);
254
+ window.removeEventListener("pagehide", flushMovement);
255
+ lastPosition = null;
256
+ };
257
+
258
+ const syncMovementCapture = () => {
259
+ if (!movementEnabled()) {
260
+ stopMovement(false);
261
+ movementBuckets.clear();
262
+ return;
263
+ }
264
+ if (movementSampleTimer !== null) return;
265
+ bucketPageUrl = cleanUrl();
266
+ document.addEventListener("pointermove", onPointerMove, { passive: true });
267
+ document.addEventListener("visibilitychange", onVisibilityChange);
268
+ window.addEventListener("pagehide", flushMovement, { passive: true });
269
+ movementSampleTimer = window.setInterval(sampleMovement, MOVEMENT_SAMPLE_MS);
270
+ movementFlushTimer = window.setInterval(flushMovement, MOVEMENT_FLUSH_MS);
271
+ };
272
+
273
+ const emitScroll = () => {
274
+ if (!active.some((assignment) => assignment.capture.scrolls !== false)) return;
275
+ const pct = scrollPercent();
276
+ const payload = {
277
+ path: location.pathname,
278
+ page_url: cleanUrl(),
279
+ scroll_percent: pct,
280
+ viewport_width: window.innerWidth,
281
+ viewport_height: window.innerHeight,
282
+ document_height: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight),
283
+ };
284
+
285
+ for (const assignment of active) {
286
+ if (assignment.capture.scrolls === false) continue;
287
+ for (const bucket of SCROLL_BUCKETS) {
288
+ if (pct < bucket) continue;
289
+ const key = `${assignment.experimentId}:${assignment.variationId}:${cleanUrl()}:${bucket}`;
290
+ if (seenScrollBuckets.has(key)) continue;
291
+ seenScrollBuckets.add(key);
292
+ track(
293
+ "heatmap_scroll",
294
+ { ...payload, scroll_bucket: bucket },
295
+ assignment.experimentId,
296
+ assignment.variationId,
297
+ );
298
+ }
299
+ }
300
+ };
301
+
302
+ const onClick = (event: MouseEvent) => {
303
+ if (!active.some((assignment) => assignment.capture.clicks !== false)) return;
304
+ const now = Date.now();
305
+ if (now - lastClickAt < 100) return;
306
+ lastClickAt = now;
307
+
308
+ const target = event.target instanceof Element ? event.target : null;
309
+ if (!target) return;
310
+ const rect = target.getBoundingClientRect();
311
+ const pageX = event.pageX;
312
+ const pageY = event.pageY;
313
+ recentClicks = recentClicks
314
+ .filter((click) => now - click.t <= RAGE_CLICK_WINDOW_MS)
315
+ .slice(-(RAGE_CLICK_BUFFER_SIZE - 1));
316
+ recentClicks.push({ x: pageX, y: pageY, t: now });
317
+ const clusteredClicks = recentClicks.filter((click) =>
318
+ Math.hypot(click.x - pageX, click.y - pageY) <= RAGE_CLICK_RADIUS_PX,
319
+ );
320
+ const rage = clusteredClicks.length >= 3;
321
+ const payload = {
322
+ path: location.pathname,
323
+ page_url: cleanUrl(),
324
+ x: Math.round(pageX),
325
+ y: Math.round(pageY),
326
+ viewport_x: Math.round(event.clientX),
327
+ viewport_y: Math.round(event.clientY),
328
+ x_pct: Math.round((event.clientX / Math.max(1, window.innerWidth)) * 1000) / 10,
329
+ y_pct: Math.round((event.clientY / Math.max(1, window.innerHeight)) * 1000) / 10,
330
+ viewport_width: window.innerWidth,
331
+ viewport_height: window.innerHeight,
332
+ document_height: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight),
333
+ selector: captureSelector(target),
334
+ element_tag: target.tagName.toLowerCase(),
335
+ element_text: safeElementText(target),
336
+ element_x: Math.round(rect.left + window.scrollX),
337
+ element_y: Math.round(rect.top + window.scrollY),
338
+ element_width: Math.round(rect.width),
339
+ element_height: Math.round(rect.height),
340
+ ...(rage ? { rage: 1 } : {}),
341
+ };
342
+
343
+ for (const assignment of active) {
344
+ if (assignment.capture.clicks === false) continue;
345
+ track("heatmap_click", payload, assignment.experimentId, assignment.variationId);
346
+ }
347
+ };
348
+
349
+ const onScroll = () => {
350
+ window.requestAnimationFrame(emitScroll);
351
+ };
352
+
353
+ document.addEventListener("click", onClick, { capture: true, passive: true });
354
+ window.addEventListener("scroll", onScroll, { passive: true });
355
+ window.addEventListener("resize", emitScroll, { passive: true });
356
+ window.setTimeout(emitScroll, 500);
357
+ syncMovementCapture();
358
+
359
+ return {
360
+ update(nextAssignments, nextExperiments) {
361
+ if (movementEnabled()) flushMovement();
362
+ active = resolveCaptureAssignments(nextAssignments, nextExperiments);
363
+ syncMovementCapture();
364
+ emitScroll();
365
+ },
366
+ stop() {
367
+ stopMovement(true);
368
+ document.removeEventListener("click", onClick, { capture: true });
369
+ window.removeEventListener("scroll", onScroll);
370
+ window.removeEventListener("resize", emitScroll);
371
+ },
372
+ };
373
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Anti-flicker reveal module.
3
+ *
4
+ * The Worker's /s/<shopId>.js snippet injects
5
+ * <style id="drip-hide">…</style> as early as possible.
6
+ * In optimized mode this style is selector-scoped and only falls back
7
+ * to full-page hiding when scoped selectors are unavailable.
8
+ *
9
+ * This module provides revealAll() which the SDK calls once all mutations
10
+ * have been applied, making the page visible with the correct variant.
11
+ * A snippet safety timeout guarantees the page is always revealed even if
12
+ * the SDK encounters an error.
13
+ */
14
+
15
+ const STYLE_ID = "drip-hide";
16
+
17
+ /** Remove the anti-flicker style injected by the snippet */
18
+ export function revealAll(): void {
19
+ const el = document.getElementById(STYLE_ID);
20
+ if (el) el.remove();
21
+ }