@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,582 @@
1
+ /**
2
+ * Drip SDK Devtools Overlay
3
+ *
4
+ * Enable via: `?drip_devtools=1`, `?drip_debug=1`, or `localStorage.drip_devtools=1`
5
+ *
6
+ * Renders a draggable panel (shadow DOM) showing:
7
+ * - Active experiments and assignments
8
+ * - Targeting evaluation results
9
+ * - Event log (assignments, mutations, goals, tracking)
10
+ * - Force variation controls
11
+ * - Performance timings
12
+ */
13
+
14
+ import type { ActivationGateDiagnostic, Assignment, Experiment, SdkDiagnostics } from "./types";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Event log
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface DevtoolsEvent {
21
+ ts: number;
22
+ type: string;
23
+ label: string;
24
+ detail?: Record<string, unknown>;
25
+ }
26
+
27
+ const eventLog: DevtoolsEvent[] = [];
28
+ const MAX_LOG_ENTRIES = 200;
29
+ const eventListeners = new Set<(event: DevtoolsEvent) => void>();
30
+
31
+ export function logDevtoolsEvent(
32
+ type: string,
33
+ label: string,
34
+ detail?: Record<string, unknown>,
35
+ ): void {
36
+ const event = { ts: Date.now(), type, label, detail };
37
+ eventLog.push(event);
38
+ if (eventLog.length > MAX_LOG_ENTRIES) eventLog.shift();
39
+ for (const listener of eventListeners) {
40
+ try {
41
+ listener(event);
42
+ } catch {
43
+ // Devtools listeners must not affect SDK runtime behavior.
44
+ }
45
+ }
46
+ // Notify mounted devtools
47
+ if (panelInstance) panelInstance.refresh();
48
+ }
49
+
50
+ export function subscribeDevtoolsEvents(listener: (event: DevtoolsEvent) => void): () => void {
51
+ eventListeners.add(listener);
52
+ return () => {
53
+ eventListeners.delete(listener);
54
+ };
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Detection
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export function isDevtoolsEnabled(): boolean {
62
+ try {
63
+ if (typeof URLSearchParams !== "undefined") {
64
+ const params = new URLSearchParams(location.search);
65
+ if (params.get("drip_devtools") === "1") return true;
66
+ if (params.get("drip_debug") === "1") return true;
67
+ if (params.get("apex_debug") === "1") return true;
68
+ }
69
+ if (typeof localStorage !== "undefined") {
70
+ return localStorage.getItem("drip_devtools") === "1";
71
+ }
72
+ } catch {
73
+ // ignore
74
+ }
75
+ return false;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Panel instance
80
+ // ---------------------------------------------------------------------------
81
+
82
+ let panelInstance: DevtoolsPanel | null = null;
83
+
84
+ interface MountOptions {
85
+ experiments: Experiment[];
86
+ assignments: Assignment[];
87
+ diagnostics?: SdkDiagnostics | null;
88
+ forceMap: Record<string, string> | null;
89
+ onForceVariation: (experimentId: string, variationId: string) => void;
90
+ }
91
+
92
+ export function mountDevtools(opts: MountOptions): void {
93
+ if (panelInstance) {
94
+ panelInstance.update(opts);
95
+ return;
96
+ }
97
+ panelInstance = new DevtoolsPanel(opts);
98
+ }
99
+
100
+ export function unmountDevtools(): void {
101
+ if (panelInstance) {
102
+ panelInstance.destroy();
103
+ panelInstance = null;
104
+ }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Panel implementation
109
+ // ---------------------------------------------------------------------------
110
+
111
+ class DevtoolsPanel {
112
+ private host: HTMLElement;
113
+ private shadow: ShadowRoot;
114
+ private opts: MountOptions;
115
+ private collapsed = false;
116
+ private activeTab: "sequence" | "assignments" | "log" | "force" = "sequence";
117
+ private dragState: { startX: number; startY: number; offsetX: number; offsetY: number } | null = null;
118
+
119
+ constructor(opts: MountOptions) {
120
+ this.opts = opts;
121
+ this.host = document.createElement("div");
122
+ this.host.id = "drip-devtools-host";
123
+ this.shadow = this.host.attachShadow({ mode: "closed" });
124
+ document.body.appendChild(this.host);
125
+ this.render();
126
+ this.attachDrag();
127
+ }
128
+
129
+ update(opts: MountOptions): void {
130
+ this.opts = opts;
131
+ this.render();
132
+ }
133
+
134
+ refresh(): void {
135
+ this.render();
136
+ }
137
+
138
+ destroy(): void {
139
+ this.host.remove();
140
+ }
141
+
142
+ private render(): void {
143
+ const { experiments, assignments, diagnostics, forceMap } = this.opts;
144
+
145
+ this.shadow.innerHTML = `
146
+ <style>${PANEL_CSS}</style>
147
+ <div class="dt-panel" id="dt-panel">
148
+ <div class="dt-header" id="dt-header">
149
+ <span class="dt-title">Drip Devtools</span>
150
+ <span class="dt-badge">${assignments.length} active</span>
151
+ <button class="dt-btn dt-toggle" id="dt-toggle">${this.collapsed ? "+" : "−"}</button>
152
+ </div>
153
+ ${this.collapsed ? "" : `
154
+ <div class="dt-tabs">
155
+ <button class="dt-tab ${this.activeTab === "sequence" ? "active" : ""}" data-tab="sequence">Sequence</button>
156
+ <button class="dt-tab ${this.activeTab === "assignments" ? "active" : ""}" data-tab="assignments">Assignments</button>
157
+ <button class="dt-tab ${this.activeTab === "log" ? "active" : ""}" data-tab="log">Log (${eventLog.length})</button>
158
+ <button class="dt-tab ${this.activeTab === "force" ? "active" : ""}" data-tab="force">Force</button>
159
+ </div>
160
+ <div class="dt-body">
161
+ ${this.renderTabContent(experiments, assignments, diagnostics ?? null, forceMap)}
162
+ </div>
163
+ `}
164
+ </div>
165
+ `;
166
+
167
+ // Bind events
168
+ this.shadow.getElementById("dt-toggle")?.addEventListener("click", () => {
169
+ this.collapsed = !this.collapsed;
170
+ this.render();
171
+ });
172
+
173
+ this.shadow.querySelectorAll(".dt-tab").forEach((tab) => {
174
+ tab.addEventListener("click", () => {
175
+ this.activeTab = (tab as HTMLElement).dataset.tab as typeof this.activeTab;
176
+ this.render();
177
+ });
178
+ });
179
+
180
+ // Force variation selects
181
+ this.shadow.querySelectorAll<HTMLSelectElement>(".dt-force-select").forEach((select) => {
182
+ select.addEventListener("change", () => {
183
+ const expId = select.dataset.expId!;
184
+ const varId = select.value;
185
+ if (varId) this.opts.onForceVariation(expId, varId);
186
+ });
187
+ });
188
+ }
189
+
190
+ private renderTabContent(
191
+ experiments: Experiment[],
192
+ assignments: Assignment[],
193
+ diagnostics: SdkDiagnostics | null,
194
+ forceMap: Record<string, string> | null,
195
+ ): string {
196
+ switch (this.activeTab) {
197
+ case "sequence":
198
+ return this.renderSequence(diagnostics);
199
+ case "assignments":
200
+ return this.renderAssignments(assignments);
201
+ case "log":
202
+ return this.renderLog();
203
+ case "force":
204
+ return this.renderForce(experiments, forceMap);
205
+ }
206
+ }
207
+
208
+ private renderSequence(diagnostics: SdkDiagnostics | null): string {
209
+ if (!diagnostics) return `<div class="dt-empty">No diagnostics available</div>`;
210
+ const project = diagnostics.activation.project.gates;
211
+ const experiments = diagnostics.activation.experiments;
212
+ return `
213
+ <div class="dt-section-title">Project</div>
214
+ <div class="dt-card">${project.map((gate) => this.renderGate(gate)).join("")}</div>
215
+ <div class="dt-section-title">Experiments</div>
216
+ ${experiments.length === 0
217
+ ? `<div class="dt-empty compact">No experiment sequence yet</div>`
218
+ : experiments.map((experiment) => `
219
+ <div class="dt-card">
220
+ <div class="dt-card-title">${esc(experiment.experimentName || experiment.experimentId)}</div>
221
+ <div class="dt-card-row"><span>Status:</span> <span class="dt-status dt-status-${experiment.status}">${esc(experiment.status)}</span></div>
222
+ ${experiment.assignedVariationId
223
+ ? `<div class="dt-card-row"><span>Variation:</span> <span>${esc(experiment.assignedVariationId)}</span></div>`
224
+ : `<div class="dt-card-row"><span>Reason:</span> <span>${esc(experiment.reason || "not_assigned")}</span></div>`}
225
+ <div class="dt-gates">${experiment.gates.map((gate) => this.renderGate(gate)).join("")}</div>
226
+ </div>
227
+ `).join("")}
228
+ `;
229
+ }
230
+
231
+ private renderGate(gate: ActivationGateDiagnostic): string {
232
+ return `
233
+ <div class="dt-gate">
234
+ <span class="dt-dot dt-dot-${gate.status}"></span>
235
+ <span class="dt-gate-label">${esc(gate.label)}</span>
236
+ <span class="dt-gate-reason">${esc(gate.reason || gate.status)}</span>
237
+ </div>
238
+ `;
239
+ }
240
+
241
+ private renderAssignments(assignments: Assignment[]): string {
242
+ if (assignments.length === 0) {
243
+ return `<div class="dt-empty">No active assignments</div>`;
244
+ }
245
+ return assignments
246
+ .map(
247
+ (a) => `
248
+ <div class="dt-card">
249
+ <div class="dt-card-title">${esc(a.experimentId)}</div>
250
+ <div class="dt-card-row"><span>Variation:</span> <span>${esc(a.variationId)}</span></div>
251
+ <div class="dt-card-row"><span>Index:</span> <span>${a.index}</span></div>
252
+ <div class="dt-card-row"><span>Control:</span> <span>${a.isControl ? "Yes" : "No"}</span></div>
253
+ <div class="dt-card-row"><span>Mutations:</span> <span>${a.mutations.length}</span></div>
254
+ <div class="dt-card-row"><span>CSS:</span> <span>${a.css ? "Yes" : "No"}</span></div>
255
+ <div class="dt-card-row"><span>JS:</span> <span>${a.js ? "Yes" : "No"}</span></div>
256
+ <div class="dt-card-row"><span>External CSS:</span> <span>${a.externalCssUrl ? "Yes" : "No"}</span></div>
257
+ <div class="dt-card-row"><span>External JS:</span> <span>${a.externalJsUrl ? "Yes" : "No"}</span></div>
258
+ <div class="dt-card-row"><span>Harness:</span> <span>${a.harnessSession ? `v${a.harnessSession.version ?? 0}` : "No"}</span></div>
259
+ </div>
260
+ `,
261
+ )
262
+ .join("");
263
+ }
264
+
265
+ private renderLog(): string {
266
+ if (eventLog.length === 0) {
267
+ return `<div class="dt-empty">No events logged yet</div>`;
268
+ }
269
+ const entries = [...eventLog].reverse().slice(0, 50);
270
+ return `<div class="dt-log">${entries
271
+ .map(
272
+ (e) => `<div class="dt-log-entry">
273
+ <span class="dt-log-time">${new Date(e.ts).toLocaleTimeString()}</span>
274
+ <span class="dt-log-type dt-type-${e.type}">${esc(e.type)}</span>
275
+ <span class="dt-log-label">${esc(e.label)}</span>
276
+ </div>`,
277
+ )
278
+ .join("")}</div>`;
279
+ }
280
+
281
+ private renderForce(
282
+ experiments: Experiment[],
283
+ forceMap: Record<string, string> | null,
284
+ ): string {
285
+ if (experiments.length === 0) {
286
+ return `<div class="dt-empty">No experiments available</div>`;
287
+ }
288
+ return experiments
289
+ .map(
290
+ (exp) => `
291
+ <div class="dt-card">
292
+ <div class="dt-card-title">${esc(exp.id)}</div>
293
+ <select class="dt-force-select" data-exp-id="${esc(exp.id)}">
294
+ <option value="">— Natural assignment —</option>
295
+ ${exp.variations
296
+ .map(
297
+ (v, i) =>
298
+ `<option value="${esc(v.id)}" ${forceMap?.[exp.id] === v.id ? "selected" : ""}>${esc(v.id)}${v.isControl ? " (control)" : ""} — ${(v.weight * 100).toFixed(0)}%</option>`,
299
+ )
300
+ .join("")}
301
+ </select>
302
+ </div>
303
+ `,
304
+ )
305
+ .join("");
306
+ }
307
+
308
+ private attachDrag(): void {
309
+ const header = () => this.shadow.getElementById("dt-header");
310
+ const panel = () => this.shadow.getElementById("dt-panel");
311
+
312
+ const onMouseDown = (e: MouseEvent) => {
313
+ const p = panel();
314
+ if (!p) return;
315
+ const rect = p.getBoundingClientRect();
316
+ this.dragState = {
317
+ startX: e.clientX,
318
+ startY: e.clientY,
319
+ offsetX: rect.left,
320
+ offsetY: rect.top,
321
+ };
322
+ e.preventDefault();
323
+ };
324
+
325
+ const onMouseMove = (e: MouseEvent) => {
326
+ if (!this.dragState) return;
327
+ const p = panel();
328
+ if (!p) return;
329
+ const dx = e.clientX - this.dragState.startX;
330
+ const dy = e.clientY - this.dragState.startY;
331
+ p.style.left = `${this.dragState.offsetX + dx}px`;
332
+ p.style.top = `${this.dragState.offsetY + dy}px`;
333
+ p.style.right = "auto";
334
+ p.style.bottom = "auto";
335
+ };
336
+
337
+ const onMouseUp = () => {
338
+ this.dragState = null;
339
+ };
340
+
341
+ // Use capturing on the host document to ensure drag works even outside shadow
342
+ const attachHeader = () => {
343
+ const h = header();
344
+ if (h) h.addEventListener("mousedown", onMouseDown);
345
+ };
346
+ attachHeader();
347
+
348
+ // Re-attach after renders
349
+ const origRender = this.render.bind(this);
350
+ this.render = () => {
351
+ origRender();
352
+ attachHeader();
353
+ };
354
+
355
+ document.addEventListener("mousemove", onMouseMove);
356
+ document.addEventListener("mouseup", onMouseUp);
357
+ }
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // HTML escaping
362
+ // ---------------------------------------------------------------------------
363
+
364
+ function esc(s: string): string {
365
+ return s
366
+ .replace(/&/g, "&amp;")
367
+ .replace(/</g, "&lt;")
368
+ .replace(/>/g, "&gt;")
369
+ .replace(/"/g, "&quot;");
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Panel CSS
374
+ // ---------------------------------------------------------------------------
375
+
376
+ const PANEL_CSS = `
377
+ * { box-sizing: border-box; margin: 0; padding: 0; }
378
+
379
+ .dt-panel {
380
+ position: fixed;
381
+ bottom: 16px;
382
+ right: 16px;
383
+ width: 340px;
384
+ max-height: 480px;
385
+ background: #1a1a2e;
386
+ border: 1px solid #333;
387
+ border-radius: 8px;
388
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
389
+ font-size: 12px;
390
+ color: #e0e0e0;
391
+ z-index: 2147483647;
392
+ box-shadow: 0 4px 24px rgba(0,0,0,0.5);
393
+ display: flex;
394
+ flex-direction: column;
395
+ overflow: hidden;
396
+ }
397
+
398
+ .dt-header {
399
+ display: flex;
400
+ align-items: center;
401
+ padding: 8px 12px;
402
+ background: #16213e;
403
+ cursor: move;
404
+ user-select: none;
405
+ border-bottom: 1px solid #333;
406
+ }
407
+
408
+ .dt-title {
409
+ font-weight: 600;
410
+ font-size: 13px;
411
+ color: #fff;
412
+ flex: 1;
413
+ }
414
+
415
+ .dt-badge {
416
+ background: #0f3460;
417
+ color: #e0e0e0;
418
+ padding: 2px 8px;
419
+ border-radius: 10px;
420
+ font-size: 10px;
421
+ margin-right: 8px;
422
+ }
423
+
424
+ .dt-btn {
425
+ background: none;
426
+ border: none;
427
+ color: #aaa;
428
+ cursor: pointer;
429
+ font-size: 16px;
430
+ line-height: 1;
431
+ padding: 2px 6px;
432
+ }
433
+ .dt-btn:hover { color: #fff; }
434
+
435
+ .dt-tabs {
436
+ display: flex;
437
+ border-bottom: 1px solid #333;
438
+ }
439
+
440
+ .dt-tab {
441
+ flex: 1;
442
+ padding: 6px 8px;
443
+ background: none;
444
+ border: none;
445
+ color: #888;
446
+ cursor: pointer;
447
+ font-size: 11px;
448
+ text-align: center;
449
+ border-bottom: 2px solid transparent;
450
+ }
451
+ .dt-tab:hover { color: #ccc; }
452
+ .dt-tab.active { color: #53c1de; border-bottom-color: #53c1de; }
453
+
454
+ .dt-body {
455
+ overflow-y: auto;
456
+ max-height: 360px;
457
+ padding: 8px;
458
+ }
459
+
460
+ .dt-empty {
461
+ text-align: center;
462
+ color: #666;
463
+ padding: 24px;
464
+ }
465
+ .dt-empty.compact { padding: 12px; }
466
+
467
+ .dt-section-title {
468
+ color: #888;
469
+ font-size: 10px;
470
+ font-weight: 700;
471
+ letter-spacing: 0.04em;
472
+ margin: 4px 2px 6px;
473
+ text-transform: uppercase;
474
+ }
475
+
476
+ .dt-card {
477
+ background: #16213e;
478
+ border-radius: 6px;
479
+ padding: 8px 10px;
480
+ margin-bottom: 6px;
481
+ }
482
+
483
+ .dt-card-title {
484
+ font-weight: 600;
485
+ color: #fff;
486
+ margin-bottom: 4px;
487
+ font-size: 12px;
488
+ }
489
+
490
+ .dt-card-row {
491
+ display: flex;
492
+ justify-content: space-between;
493
+ padding: 1px 0;
494
+ color: #aaa;
495
+ font-size: 11px;
496
+ }
497
+ .dt-card-row span:last-child { color: #e0e0e0; }
498
+
499
+ .dt-status {
500
+ border-radius: 3px;
501
+ font-size: 10px;
502
+ padding: 1px 5px;
503
+ text-transform: uppercase;
504
+ }
505
+ .dt-status-passed { background: #166534; color: #dcfce7 !important; }
506
+ .dt-status-failed { background: #7f1d1d; color: #fee2e2 !important; }
507
+ .dt-status-skipped { background: #334155; color: #e2e8f0 !important; }
508
+
509
+ .dt-gates {
510
+ margin-top: 6px;
511
+ border-top: 1px solid rgba(255,255,255,0.08);
512
+ padding-top: 5px;
513
+ }
514
+
515
+ .dt-gate {
516
+ display: grid;
517
+ grid-template-columns: 10px minmax(86px, 1fr) minmax(0, 1fr);
518
+ align-items: center;
519
+ gap: 6px;
520
+ padding: 3px 0;
521
+ color: #b8c1d1;
522
+ font-size: 11px;
523
+ }
524
+
525
+ .dt-dot {
526
+ width: 7px;
527
+ height: 7px;
528
+ border-radius: 999px;
529
+ display: inline-block;
530
+ }
531
+ .dt-dot-passed { background: #22c55e; }
532
+ .dt-dot-failed { background: #ef4444; }
533
+ .dt-dot-skipped { background: #64748b; }
534
+ .dt-gate-label { color: #e5e7eb; }
535
+ .dt-gate-reason {
536
+ color: #8b95a7;
537
+ overflow: hidden;
538
+ text-align: right;
539
+ text-overflow: ellipsis;
540
+ white-space: nowrap;
541
+ }
542
+
543
+ .dt-force-select {
544
+ width: 100%;
545
+ margin-top: 6px;
546
+ padding: 4px 8px;
547
+ background: #0f3460;
548
+ border: 1px solid #444;
549
+ color: #e0e0e0;
550
+ border-radius: 4px;
551
+ font-size: 11px;
552
+ }
553
+
554
+ .dt-log {
555
+ display: flex;
556
+ flex-direction: column;
557
+ gap: 2px;
558
+ }
559
+
560
+ .dt-log-entry {
561
+ display: flex;
562
+ gap: 6px;
563
+ padding: 3px 4px;
564
+ border-radius: 3px;
565
+ font-size: 11px;
566
+ line-height: 1.4;
567
+ }
568
+ .dt-log-entry:hover { background: rgba(255,255,255,0.03); }
569
+
570
+ .dt-log-time { color: #666; flex-shrink: 0; font-family: monospace; }
571
+ .dt-log-type { flex-shrink: 0; font-weight: 600; text-transform: uppercase; font-size: 9px; padding: 1px 4px; border-radius: 3px; }
572
+ .dt-log-label { color: #ccc; }
573
+
574
+ .dt-type-init { background: #1a6; color: #fff; }
575
+ .dt-type-eval { background: #36a; color: #fff; }
576
+ .dt-type-apply { background: #a63; color: #fff; }
577
+ .dt-type-track { background: #63a; color: #fff; }
578
+ .dt-type-goal { background: #a36; color: #fff; }
579
+ .dt-type-qa { background: #3aa; color: #fff; }
580
+ .dt-type-guard { background: #666; color: #fff; }
581
+ .dt-type-tracker { background: #636; color: #fff; }
582
+ `;
@@ -0,0 +1,70 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ class FakeElement {
4
+ constructor(
5
+ private readonly id: string,
6
+ private readonly inQaAssistantHost = false,
7
+ ) {}
8
+
9
+ getAttribute(name: string): string | null {
10
+ return name === "id" ? this.id : null;
11
+ }
12
+
13
+ closest(selector: string): FakeElement | null {
14
+ return this.inQaAssistantHost && selector.includes("#drip-qa-assistant-host") ? this : null;
15
+ }
16
+ }
17
+
18
+ describe("observeDomChanges", () => {
19
+ const originalDocument = (globalThis as any).document;
20
+ const originalElement = (globalThis as any).Element;
21
+ const originalMutationObserver = (globalThis as any).MutationObserver;
22
+ const originalWindow = (globalThis as any).window;
23
+ let mutationCallback: MutationCallback | null = null;
24
+
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ (globalThis as any).Element = FakeElement;
28
+ (globalThis as any).document = {
29
+ documentElement: new FakeElement("html"),
30
+ };
31
+ (globalThis as any).window = {
32
+ clearTimeout,
33
+ setTimeout,
34
+ };
35
+ (globalThis as any).MutationObserver = class MutationObserver {
36
+ constructor(callback: MutationCallback) {
37
+ mutationCallback = callback;
38
+ }
39
+
40
+ observe = vi.fn();
41
+ disconnect = vi.fn();
42
+ };
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.useRealTimers();
47
+ vi.restoreAllMocks();
48
+ (globalThis as any).document = originalDocument;
49
+ (globalThis as any).Element = originalElement;
50
+ (globalThis as any).MutationObserver = originalMutationObserver;
51
+ (globalThis as any).window = originalWindow;
52
+ mutationCallback = null;
53
+ });
54
+
55
+ it("ignores mutations inside the QA Assistant host", async () => {
56
+ const observer = vi.fn();
57
+ const { observeDomChanges } = await import("./dom-trigger");
58
+ observeDomChanges(observer, 25);
59
+
60
+ mutationCallback?.([
61
+ {
62
+ addedNodes: [new FakeElement("assistant-button", true)],
63
+ type: "childList",
64
+ } as unknown as MutationRecord,
65
+ ], {} as MutationObserver);
66
+ vi.advanceTimersByTime(25);
67
+
68
+ expect(observer).not.toHaveBeenCalled();
69
+ });
70
+ });
@@ -0,0 +1,55 @@
1
+ import { registerMutedObserver, unregisterMutedObserver } from "./observer-mute";
2
+
3
+ type DomChangeObserver = () => void;
4
+
5
+ const QA_ASSISTANT_HOST_SELECTOR = "#" + ["drip", "qa", "assistant", "host"].join("-");
6
+ const APEX_HOST_SELECTOR = ["#drip-qa-toolbar", QA_ASSISTANT_HOST_SELECTOR, "#drip-devtools-host"].join(",");
7
+
8
+ function isApexNode(node: Node): boolean {
9
+ if (!(node instanceof Element)) return false;
10
+ const id = node.getAttribute("id") || "";
11
+ return id.startsWith("drip-") || node.closest?.(APEX_HOST_SELECTOR) != null;
12
+ }
13
+
14
+ export function observeDomChanges(
15
+ observer: DomChangeObserver,
16
+ debounceMs = 250,
17
+ ): () => void {
18
+ if (typeof MutationObserver === "undefined" || !document.documentElement) {
19
+ return () => {};
20
+ }
21
+
22
+ let timer: number | null = null;
23
+ const schedule = () => {
24
+ if (timer !== null) window.clearTimeout(timer);
25
+ timer = window.setTimeout(() => {
26
+ timer = null;
27
+ observer();
28
+ }, debounceMs);
29
+ };
30
+
31
+ const onRecords: MutationCallback = (mutations) => {
32
+ const relevant = mutations.some((mutation) => {
33
+ if (mutation.type === "attributes") {
34
+ return mutation.target instanceof Element && !isApexNode(mutation.target);
35
+ }
36
+ return Array.from(mutation.addedNodes).some((node) => !isApexNode(node));
37
+ });
38
+ if (relevant) schedule();
39
+ };
40
+ const mutationObserver = new MutationObserver(onRecords);
41
+
42
+ registerMutedObserver(mutationObserver, onRecords);
43
+ mutationObserver.observe(document.documentElement, {
44
+ childList: true,
45
+ subtree: true,
46
+ attributes: true,
47
+ attributeFilter: ["class", "style", "hidden", "aria-hidden"],
48
+ });
49
+
50
+ return () => {
51
+ if (timer !== null) window.clearTimeout(timer);
52
+ mutationObserver.disconnect();
53
+ unregisterMutedObserver(mutationObserver);
54
+ };
55
+ }