@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,768 @@
1
+ // QA Assistant panel view — vanilla DOM inside a shadow root, rendered from
2
+ // QaAssistantState. Pixel spec: the "Operator QA Assistant" Claude Design
3
+ // canvas (opqa-*.jsx). Sentence case; monospace only inside real code blocks.
4
+
5
+ import type {
6
+ QaAssistantState,
7
+ QaAssistantStore,
8
+ QaEventModel,
9
+ QaExperimentModel,
10
+ QaTransactionsModel,
11
+ } from "./qa-assistant-state";
12
+
13
+ export const QA_ASSISTANT_HOST_ID = "drip-qa-assistant-host";
14
+
15
+ type TabId = "exp" | "ev" | "tx";
16
+
17
+ type ViewState = {
18
+ tab: TabId;
19
+ minimized: boolean;
20
+ expanded: Set<string>;
21
+ codeOpen: Set<string>;
22
+ pageFilter: "all" | "on" | "off";
23
+ statusFilter: "all" | "accepted" | "rejected" | "forced";
24
+ search: string;
25
+ firstMount: boolean;
26
+ };
27
+
28
+ const MINIMIZED_KEY = "drip_qa_assistant_min";
29
+
30
+ /* ── icons (Lucide-style paths from the design's icon set) ─────────────── */
31
+ const P = {
32
+ check: "M5 13l4 4L19 7",
33
+ x: "M18 6L6 18M6 6l12 12",
34
+ minus: "M5 12h14",
35
+ alert:
36
+ "M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0zM12 9v4M12 17h.01",
37
+ chevR: "M9 6l6 6-6 6",
38
+ chevD: "M6 9l6 6 6-6",
39
+ chevUp: "M18 15l-6-6-6 6",
40
+ bolt: "M13 2L3 14h7l-1 8 10-12h-7l1-8z",
41
+ eyeOff:
42
+ "M17.9 17.9A10 10 0 016.1 6.1M9.9 4.2A10.6 10.6 0 0122 12a17 17 0 01-3.2 3.7M3 3l18 18M14.1 14.1a3 3 0 01-4.2-4.2M2 12a17 17 0 013.8-4.3",
43
+ clock: "M12 22a10 10 0 100-20 10 10 0 000 20zM12 7v5l3 2",
44
+ store:
45
+ "M3 9l1.5-5h15L21 9M4 9v10a1 1 0 001 1h14a1 1 0 001-1V9M3 9h18M9 20v-6h6v6",
46
+ target:
47
+ "M12 2a10 10 0 100 20 10 10 0 000-20zM12 7a5 5 0 100 10 5 5 0 000-10zM12 11a1 1 0 100 2 1 1 0 000-2z",
48
+ checkSquare:
49
+ "M9 11l3 3L20 6M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11",
50
+ help:
51
+ "M12 22a10 10 0 100-20 10 10 0 000 20zM9.2 9.2a3 3 0 015.6 1.3c0 2-3 2.5-3 4M12 17.5h.01",
52
+ activity: "M22 12h-4l-3 8L9 4l-3 8H2",
53
+ code: "M16 18l6-6-6-6M8 6l-6 6 6 6",
54
+ };
55
+
56
+ function svg(d: string, size: number, sw = 1.6): string {
57
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="${d}"/></svg>`;
58
+ }
59
+
60
+ /* ── safety ─────────────────────────────────────────────────────────────── */
61
+ function esc(value: string | null | undefined): string {
62
+ return String(value ?? "")
63
+ .replace(/&/g, "&amp;")
64
+ .replace(/</g, "&lt;")
65
+ .replace(/>/g, "&gt;")
66
+ .replace(/"/g, "&quot;");
67
+ }
68
+
69
+ /* ── copy helpers ───────────────────────────────────────────────────────── */
70
+ const REASON_WORDS: Record<string, string> = {
71
+ url_mismatch: "page rules",
72
+ url_exclude_matched: "page rules",
73
+ page_rule_failed: "page rules",
74
+ segment_mismatch: "the audience rule",
75
+ segment_and_failed: "the audience rule",
76
+ attribute_mismatch: "the audience rule",
77
+ js_false: "the custom JS trigger",
78
+ js_error: "the custom JS trigger",
79
+ };
80
+
81
+ function humanizeSkipDetail(detail: string | null): string | null {
82
+ if (!detail) return detail;
83
+ return detail.replace(/Skipped — (\S+) failed first/, (whole, reason: string) => {
84
+ const words = REASON_WORDS[reason];
85
+ return words ? `Skipped — ${words} failed first` : "Skipped — an earlier rule failed first";
86
+ });
87
+ }
88
+
89
+ function formatTime(at: number): string {
90
+ const d = new Date(at);
91
+ const pad = (n: number) => String(n).padStart(2, "0");
92
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
93
+ }
94
+
95
+ /* ── stylesheet (exact TK/QAT values from the design) ───────────────────── */
96
+ const CSS = `
97
+ :host { all: initial; }
98
+ * { box-sizing: border-box; margin: 0; }
99
+ .root {
100
+ --bg1: #0A0A0C; --panel: #0C0C10; --raised: #141419; --inset: #0B0B0E;
101
+ --bd: rgba(255,255,255,0.06); --bd-hi: rgba(255,255,255,0.10); --bd-px: rgba(255,255,255,0.14);
102
+ --t1: #FAFAFA; --t2: rgba(255,255,255,0.66); --t3: rgba(255,255,255,0.42); --t4: rgba(255,255,255,0.26);
103
+ --p500: #5B29FF; --p400: #7C54FF; --p200: #C8B4FF;
104
+ --p-soft: rgba(91,41,255,0.14); --p-bd: rgba(124,84,255,0.34);
105
+ --pos: #3DD68C; --pos-soft: rgba(61,214,140,0.14); --pos-bd: rgba(61,214,140,0.26);
106
+ --warn: #F59E0B; --warn-soft: rgba(245,158,11,0.12); --warn-bd: rgba(245,158,11,0.26);
107
+ --neg: #EB5252; --neg-soft: rgba(235,82,82,0.10); --neg-bd: rgba(235,82,82,0.26);
108
+ --info: #1DDFEC; --info-soft: rgba(29,223,236,0.10); --info-bd: rgba(29,223,236,0.22);
109
+ font-family: Inter, system-ui, sans-serif;
110
+ position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
111
+ color: var(--t1); font-size: 12px; line-height: 1.45; letter-spacing: -0.01em;
112
+ -webkit-font-smoothing: antialiased;
113
+ }
114
+ button { font-family: inherit; background: none; border: 0; padding: 0; color: inherit; cursor: pointer; text-align: left; }
115
+ .panel {
116
+ width: 380px; max-height: min(72vh, 640px); display: flex; flex-direction: column;
117
+ border-radius: 14px; background: var(--panel); border: 1px solid var(--bd-px);
118
+ box-shadow: 0 32px 80px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4); overflow: hidden;
119
+ }
120
+ .hdr { padding: 11px 12px 10px 14px; border-bottom: 1px solid var(--bd); flex-shrink: 0; }
121
+ .hdr-row { display: flex; align-items: center; gap: 9px; }
122
+ .mark { width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0; background: linear-gradient(135deg,#1A1A21,#0E0E12);
123
+ border: 1px solid var(--bd-hi); display: flex; align-items: center; justify-content: center; }
124
+ .hdr-title { font-size: 13px; font-weight: 600; white-space: nowrap; }
125
+ .hdr-sub { font-size: 11px; color: var(--t3); margin-top: 6px; }
126
+ .hdr-actions { margin-left: auto; display: flex; align-items: center; gap: 4px; }
127
+ .chip { display: inline-flex; align-items: center; gap: 5px; height: 19px; padding: 0 7px; border-radius: 6, ; border-radius: 6px;
128
+ font-size: 10.5px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
129
+ .chip::before { content: ""; width: 4.5px; height: 4.5px; border-radius: 50%; background: currentColor; }
130
+ .chip-applied { background: var(--pos-soft); border: 1px solid var(--pos-bd); color: var(--pos); }
131
+ .chip-off { background: rgba(255,255,255,0.05); border: 1px solid var(--bd-hi); color: var(--t3); }
132
+ .chip-forced { background: var(--p-soft); border: 1px solid var(--p-bd); color: var(--p200); }
133
+ .btn-ghost { height: 24px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--bd-hi); color: var(--t2);
134
+ font-size: 11px; font-weight: 600; white-space: nowrap; display: inline-flex; align-items: center; }
135
+ .btn-icon { width: 24px; height: 24px; border-radius: 7px; display: inline-flex; align-items: center; justify-content: center; color: var(--t3); }
136
+ .btn-icon:hover, .btn-ghost:hover { background: rgba(255,255,255,0.05); }
137
+ .forced-bar { display: flex; align-items: center; gap: 8px; padding: 7px 14px; background: var(--p-soft);
138
+ border-bottom: 1px solid var(--p-bd); flex-shrink: 0; color: var(--p200); }
139
+ .forced-bar .txt { font-size: 11px; font-weight: 600; flex: 1; min-width: 0; }
140
+ .forced-bar .reset { font-size: 11px; font-weight: 600; color: var(--t1); white-space: nowrap; }
141
+ .tabs { display: flex; padding: 0 8px; border-bottom: 1px solid var(--bd); background: rgba(255,255,255,0.015); flex-shrink: 0; }
142
+ .tab { display: inline-flex; align-items: center; gap: 6px; padding: 10px 9px 9px; font-size: 12px; font-weight: 600;
143
+ color: var(--t3); white-space: nowrap; }
144
+ .tab[data-on="true"] { color: var(--t1); box-shadow: inset 0 -2px 0 var(--p400); }
145
+ .tab .n { font-size: 10px; font-weight: 600; color: var(--t4); }
146
+ .tab[data-on="true"] .n { color: var(--p200); }
147
+ .body { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
148
+ .ftr { display: flex; align-items: center; padding: 8px 14px; border-top: 1px solid var(--bd); background: var(--bg1); flex-shrink: 0; }
149
+ .ftr .hide { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--t3); font-weight: 600; }
150
+ .ftr .reset { margin-left: auto; font-size: 11px; color: var(--p200); font-weight: 600; white-space: nowrap; }
151
+ .grp { display: flex; align-items: baseline; gap: 6px; padding: 10px 14px 5px; }
152
+ .grp .l { font-size: 11px; font-weight: 600; color: var(--t3); }
153
+ .grp .n { font-size: 10.5px; color: var(--t4); }
154
+ .exp { border-bottom: 1px solid var(--bd); }
155
+ .exp:last-child { border-bottom: 0; }
156
+ .exp-head { display: flex; align-items: center; gap: 8px; padding: 9px 14px; width: 100%; }
157
+ .exp-head[data-open="true"] { background: rgba(255,255,255,0.02); }
158
+ .exp-head .cv { color: var(--t4); display: inline-flex; }
159
+ .exp-name { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
160
+ .exp-sub { font-size: 10.5px; color: var(--t3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
161
+ .pills { display:flex; flex-wrap:wrap; gap:4px; margin-top:5px; }
162
+ .rule-pill { font-size:9.5px; color:var(--t3); border:1px solid var(--bd); border-radius:999px; padding:2px 6px; }
163
+ .rule-pill[data-state="accepted"], .rule-pill[data-state="forced"] { color:var(--pos); border-color:var(--pos-bd); }
164
+ .rule-pill[data-state="rejected"], .rule-pill[data-state="outside traffic %"], .rule-pill[data-state="not running"], .rule-pill[data-state="excluded by overlap"] { color:var(--neg); border-color:var(--neg-bd); }
165
+ .filters { display:flex; gap:6px; padding:8px 12px; border-bottom:1px solid var(--bd); flex-wrap:wrap; }
166
+ .filters select,.filters input { background:var(--inset); color:var(--t2); border:1px solid var(--bd); border-radius:7px; height:28px; padding:0 8px; font-size:10.5px; }
167
+ .filters input { flex:1; min-width:110px; }
168
+ .cond-group { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--t4); margin-top:10px; }
169
+ .exp-main { flex: 1; min-width: 0; }
170
+ .exp-detail { padding: 0 14px 11px 33px; }
171
+ .cond { padding: 5px 0; }
172
+ .cond-row { display: flex; align-items: center; gap: 8px; }
173
+ .cond-dot { width: 16px; height: 16px; border-radius: 50%; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
174
+ .cond-text { font-size: 11.5px; flex: 1; min-width: 0; }
175
+ .cond-state { font-size: 10.5px; font-weight: 600; flex-shrink: 0; }
176
+ .cond-detail { font-size: 10.5px; color: var(--t3); margin: 3px 0 0 24px; }
177
+ .codeblk { margin: 6px 0 2px 24px; border-radius: 8px; background: var(--inset); border: 1px solid var(--bd); overflow: hidden; }
178
+ .codeblk-head { display: flex; align-items: center; gap: 6px; padding: 5px 9px; width: 100%; color: var(--t3); }
179
+ .codeblk-head .t { font-size: 10.5px; font-weight: 600; }
180
+ .codeblk-head .cv { margin-left: auto; color: var(--t4); display: inline-flex; }
181
+ .codeblk pre { padding: 8px 10px; border-top: 1px solid var(--bd);
182
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 10.5px; line-height: 1.6;
183
+ color: var(--t2); white-space: pre-wrap; word-break: break-word; }
184
+ .mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
185
+ .act-btn { margin-top: 8px; height: 26px; padding: 0 11px; border-radius: 7px; display: inline-flex; align-items: center;
186
+ background: var(--p-soft); border: 1px solid var(--p-bd); color: var(--p200); font-size: 11px; font-weight: 600; white-space: nowrap; }
187
+ .varpick { display: flex; flex-direction: column; gap: 4px; margin: 4px 0 2px; }
188
+ .varopt { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--bd); width: 100%; }
189
+ .varopt[data-on="true"] { background: var(--p-soft); border-color: var(--p-bd); }
190
+ .varopt .radio { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; border: 1.5px solid var(--t4); }
191
+ .varopt[data-on="true"] .radio { border: 4.5px solid var(--p400); background: #fff; }
192
+ .varopt .nm { font-size: 11.5px; font-weight: 600; color: var(--t2); flex: 1; }
193
+ .varopt[data-on="true"] .nm { color: var(--t1); }
194
+ .varopt .note { font-size: 10.5px; color: var(--t4); white-space: nowrap; }
195
+ .varopt[data-on="true"] .note { color: var(--p200); }
196
+ .note-line { font-size: 10.5px; color: var(--t3); margin-top: 7px; line-height: 1.5; }
197
+ .sumline { display: flex; align-items: center; gap: 7px; padding: 10px 14px 4px; font-size: 11.5px; color: var(--t2); }
198
+ .live { display: flex; align-items: center; gap: 7px; padding: 9px 14px; border-bottom: 1px solid var(--bd); }
199
+ .live .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--pos); box-shadow: 0 0 6px var(--pos); }
200
+ .live .t { font-size: 11px; color: var(--t3); }
201
+ .live .clear { margin-left: auto; font-size: 11px; color: var(--t4); font-weight: 600; }
202
+ .ev { padding: 8px 14px; border-bottom: 1px solid var(--bd); }
203
+ .ev:last-child { border-bottom: 0; }
204
+ .ev[data-dup="true"] { background: rgba(245,158,11,0.05); box-shadow: inset 2px 0 0 var(--warn); }
205
+ .ev-row { display: flex; align-items: center; gap: 8px; }
206
+ .ev-t { font-size: 10.5px; color: var(--t4); width: 48px; flex-shrink: 0; }
207
+ .ev-type { display: inline-flex; align-items: center; height: 18px; padding: 0 7px; border-radius: 5px; flex-shrink: 0;
208
+ font-size: 10px; font-weight: 600; white-space: nowrap; }
209
+ .ev-label { font-size: 11.5px; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
210
+ .ev-val { font-size: 11.5px; font-weight: 600; flex-shrink: 0; }
211
+ .qatag { height: 16px; padding: 0 5px; border-radius: 5px; background: var(--p-soft); border: 1px solid var(--p-bd);
212
+ color: var(--p200); font-size: 9.5px; font-weight: 600; display: inline-flex; align-items: center; flex-shrink: 0; }
213
+ .ev-dup { display: flex; align-items: center; gap: 6px; margin: 5px 0 0 56px; font-size: 10.5px; color: var(--warn); }
214
+ .empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 36px 32px 40px; }
215
+ .empty .icon { width: 36px; height: 36px; border-radius: 10px; background: var(--raised); border: 1px solid var(--bd);
216
+ display: flex; align-items: center; justify-content: center; color: var(--t3); }
217
+ .empty .h { font-size: 12.5px; font-weight: 600; margin-top: 12px; }
218
+ .empty .s { font-size: 11px; color: var(--t3); margin-top: 5px; line-height: 1.55; max-width: 250px; }
219
+ .tx { padding: 12px 14px; }
220
+ .tx-card { border-radius: 10px; background: var(--raised); border: 1px solid var(--bd); padding: 11px 12px; }
221
+ .tx-card .row { display: flex; align-items: center; gap: 8px; }
222
+ .tx-card .h { font-size: 12.5px; font-weight: 600; flex: 1; }
223
+ .tx-card .meta { font-size: 10.5px; color: var(--t3); margin-top: 5px; }
224
+ .tx-card .attr { display: flex; align-items: center; gap: 7px; margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--bd); font-size: 12px; }
225
+ .attr-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--bd); }
226
+ .attr-row:last-child { border-bottom: 0; }
227
+ .attr-row .nm { font-size: 11.5px; color: var(--t2); flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
228
+ .attr-row .vr { font-size: 11.5px; font-weight: 600; color: var(--p200); white-space: nowrap; }
229
+ .attr-row .vr[data-none="true"] { color: var(--t4); }
230
+ .receipt { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-radius: 9px; margin-top: 10px; }
231
+ .receipt.ok { background: var(--pos-soft); border: 1px solid var(--pos-bd); }
232
+ .receipt.pending { background: var(--warn-soft); border: 1px solid var(--warn-bd); }
233
+ .receipt .h { font-size: 11.5px; font-weight: 600; }
234
+ .receipt.ok .h { color: var(--pos); }
235
+ .receipt.pending .h { color: var(--warn); }
236
+ .receipt .s { font-size: 10.5px; color: var(--t3); margin-top: 2px; }
237
+ .infonote { display: flex; gap: 8px; padding: 9px 12px; border-radius: 9px; margin-top: 10px;
238
+ background: var(--p-soft); border: 1px solid var(--p-bd); }
239
+ .infonote .t { font-size: 10.5px; color: var(--t2); line-height: 1.55; }
240
+ .pill { display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 13px 0 9px; border-radius: 999px;
241
+ background: rgba(10,10,14,0.85); border: 1px solid var(--bd-px); backdrop-filter: blur(14px);
242
+ box-shadow: 0 16px 40px rgba(0,0,0,0.5); }
243
+ .pill .t { font-size: 12px; font-weight: 600; white-space: nowrap; }
244
+ @media (max-width: 560px) {
245
+ .root { right: 0; bottom: 0; left: 0; }
246
+ .panel { width: 100%; max-height: 78vh; border-radius: 18px 18px 0 0; border-bottom: 0; }
247
+ .exp-head { padding: 13px 16px; min-height: 44px; }
248
+ .tab { padding: 12px 10px 11px; font-size: 12.5px; }
249
+ .root .pill-dock { position: absolute; right: 16px; bottom: 16px; }
250
+ }
251
+ @media (prefers-reduced-motion: no-preference) {
252
+ .panel[data-enter="true"] { animation: qa-in 220ms cubic-bezier(0.22, 1, 0.36, 1); }
253
+ @keyframes qa-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
254
+ }
255
+ `;
256
+
257
+ /* ── renderers ──────────────────────────────────────────────────────────── */
258
+ function chip(label: string, tone: "applied" | "off" | "forced"): string {
259
+ return `<span class="chip chip-${tone}">${esc(label)}</span>`;
260
+ }
261
+
262
+ function condRow(
263
+ expId: string,
264
+ c: QaExperimentModel["conditions"][number],
265
+ codeOpen: Set<string>,
266
+ ): string {
267
+ const m = {
268
+ pass: { d: P.check, cls: "var(--pos)", bg: "var(--pos-soft)", label: "Passed" },
269
+ fail: { d: P.x, cls: "var(--neg)", bg: "var(--neg-soft)", label: "Failed" },
270
+ skip: { d: P.minus, cls: "var(--t4)", bg: "rgba(255,255,255,0.05)", label: "Skipped" },
271
+ error: { d: P.alert, cls: "var(--neg)", bg: "var(--neg-soft)", label: "Error" },
272
+ }[c.state];
273
+ const key = `${expId}:${c.id}`;
274
+ const detail = c.state === "skip" ? humanizeSkipDetail(c.detail) : c.detail;
275
+ let codeBlock = "";
276
+ if (c.rawCode) {
277
+ const open = codeOpen.has(key);
278
+ codeBlock = `
279
+ <div class="codeblk">
280
+ <button class="codeblk-head" data-action="toggle-code" data-key="${esc(key)}">
281
+ ${svg(P.code, 10.5)}<span class="t">Custom JS trigger — raw code</span>
282
+ <span class="cv">${svg(open ? P.chevUp : P.chevD, 10)}</span>
283
+ </button>
284
+ ${open ? `<pre>${esc(c.rawCode)}</pre>` : ""}
285
+ </div>`;
286
+ }
287
+ return `
288
+ <div class="cond">
289
+ <div class="cond-row">
290
+ <span class="cond-dot" style="background:${m.bg};color:${m.cls}">${svg(m.d, 9, 2.4)}</span>
291
+ <span class="cond-text">${esc(c.text)}</span>
292
+ <span class="cond-state" style="color:${m.cls}">${m.label}</span>
293
+ </div>
294
+ ${detail ? `<div class="cond-detail">${esc(detail)}</div>` : ""}
295
+ ${codeBlock}
296
+ </div>`;
297
+ }
298
+
299
+ function experimentRow(e: QaExperimentModel, view: ViewState): string {
300
+ const open = view.expanded.has(e.id);
301
+ const chipHtml = e.forced
302
+ ? chip("Forced", "forced")
303
+ : e.applied
304
+ ? chip("Applied", "applied")
305
+ : chip("Not applied", "off");
306
+ let detail = "";
307
+ if (open) {
308
+ let forceControls = "";
309
+ if (e.forced) {
310
+ const options = e.variations
311
+ .map(
312
+ (v) => `
313
+ <button class="varopt" data-on="${v.isCurrent}" data-action="pick-variation" data-exp="${esc(e.id)}" data-var="${esc(v.id)}">
314
+ <span class="radio"></span>
315
+ <span class="nm">${esc(v.name)}</span>
316
+ <span class="note">${v.isCurrent ? "Forced now" : v.isReal ? "Your real assignment" : ""}</span>
317
+ </button>`,
318
+ )
319
+ .join("");
320
+ forceControls = `
321
+ <div class="varpick">${options}</div>
322
+ <div class="note-line">Forcing only changes what you see — it never writes to results.</div>
323
+ <button class="act-btn" data-action="reset-force" data-exp="${esc(e.id)}">Reset to real assignment</button>`;
324
+ } else {
325
+ forceControls =
326
+ e.variations.length > 1
327
+ ? `<button class="act-btn" data-action="open-force" data-exp="${esc(e.id)}">Force this variation</button>`
328
+ : "";
329
+ }
330
+ const pages = e.conditions.filter((c) => c.group === "pages").map((c) => condRow(e.id, c, view.codeOpen)).join("");
331
+ const audience = e.conditions.filter((c) => c.group === "audience").map((c) => condRow(e.id, c, view.codeOpen)).join("");
332
+ detail = `<div class="exp-detail">
333
+ <div class="cond-group">Pages · Trigger</div>${pages || `<div class="cond-detail">${e.triggerPill === "not evaluated" ? "Not evaluated" : e.triggerPill === "accepted" ? "Matched on this page (rule detail unavailable)" : e.triggerPill === "rejected" ? "Did not match this page (rule detail unavailable)" : "All pages"}</div>`}
334
+ <div class="cond-group">Audience · Segment</div>${audience || `<div class="cond-detail">${e.segmentPill === "not evaluated" ? "Not evaluated" : e.segmentPill === "accepted" ? "Matched for this visitor (rule detail unavailable)" : e.segmentPill === "rejected" ? "Did not match this visitor (rule detail unavailable)" : "All visitors"}</div>`}
335
+ <div class="cond-group">Display frequency</div><div class="cond-detail">${esc(e.displayFrequencyPill)}</div>
336
+ ${forceControls}</div>`;
337
+ }
338
+ return `
339
+ <div class="exp">
340
+ <button class="exp-head" data-open="${open}" data-action="toggle-exp" data-exp="${esc(e.id)}">
341
+ <span class="cv">${svg(open ? P.chevD : P.chevR, 11)}</span>
342
+ <span class="exp-main">
343
+ <span class="exp-name" style="display:block">${esc(e.name)}</span>
344
+ <span class="exp-sub" style="display:block">${esc(e.subline)}</span>
345
+ <span class="pills">
346
+ <span class="rule-pill" data-state="${esc(e.triggerPill)}">Trigger: ${esc(e.triggerPill)}</span>
347
+ <span class="rule-pill" data-state="${esc(e.segmentPill)}">Segment: ${esc(e.segmentPill)}</span>
348
+ <span class="rule-pill" data-state="${esc(e.displayFrequencyPill)}">Display: ${esc(e.displayFrequencyPill)}</span>
349
+ </span>
350
+ </span>
351
+ ${chipHtml}
352
+ </button>
353
+ ${detail}
354
+ </div>`;
355
+ }
356
+
357
+ function experimentsBody(state: QaAssistantState, view: ViewState): string {
358
+ const query = view.search.trim().toLowerCase();
359
+ const visible = state.experiments.filter((e) => {
360
+ const pageOk = view.pageFilter === "all" || (view.pageFilter === "on" ? e.triggerPill === "accepted" || e.triggerPill === "all pages" : e.triggerPill === "rejected");
361
+ const statusOk = view.statusFilter === "all" || (view.statusFilter === "forced" ? e.forced : view.statusFilter === "accepted" ? e.applied && !e.forced : !e.applied && !e.forced);
362
+ return pageOk && statusOk && (!query || e.name.toLowerCase().includes(query) || e.id.toLowerCase().includes(query));
363
+ });
364
+ const applied = visible.filter((e) => e.applied || e.forced);
365
+ const rejected = visible.filter((e) => !e.applied && !e.forced);
366
+ const filters = `<div class="filters">
367
+ <select data-action="page-filter"><option value="all">On or off this page</option><option value="on" ${view.pageFilter === "on" ? "selected" : ""}>On this page</option><option value="off" ${view.pageFilter === "off" ? "selected" : ""}>Not on this page</option></select>
368
+ <select data-action="status-filter"><option value="all">All</option><option value="accepted" ${view.statusFilter === "accepted" ? "selected" : ""}>Applied</option><option value="rejected" ${view.statusFilter === "rejected" ? "selected" : ""}>Not applied</option><option value="forced" ${view.statusFilter === "forced" ? "selected" : ""}>Forced</option></select>
369
+ <input data-action="search" aria-label="Search campaigns" placeholder="Search name or ID" value="${esc(view.search)}">
370
+ </div>`;
371
+ // Summary lines describe the whole campaign set, never the filtered view —
372
+ // a filter that hides rejected rows must not make "all apply" claims.
373
+ const totalRejected = state.experiments.filter((e) => !e.applied && !e.forced).length;
374
+ let summary = "";
375
+ if (state.experiments.length > 0 && totalRejected === 0) {
376
+ summary = `<div class="sumline" style="color:var(--t2)"><span style="color:var(--pos);display:inline-flex">${svg(P.check, 12, 2.2)}</span>All ${state.experiments.length} experiment${state.experiments.length === 1 ? "" : "s"} apply on this page.</div>`;
377
+ } else if (state.experiments.length > 0 && totalRejected === state.experiments.length) {
378
+ summary = `<div class="sumline" style="color:var(--t2)"><span style="color:var(--warn);display:inline-flex">${svg(P.alert, 12)}</span>Nothing runs on this page — every experiment was rejected.</div>`;
379
+ } else if (state.experiments.length === 0) {
380
+ return filters + `<div class="empty">
381
+ <div class="icon">${svg(P.target, 16)}</div>
382
+ <div class="h">No experiments on this shop yet</div>
383
+ <div class="s">Running and draft experiments show up here when the QA link opens a page they could target.</div>
384
+ </div>`;
385
+ }
386
+ const appliedHtml = applied.length
387
+ ? `<div class="grp"><span class="l">Applied</span><span class="n">${applied.length}</span></div>` +
388
+ applied.map((e) => experimentRow(e, view)).join("")
389
+ : "";
390
+ const rejectedHtml = rejected.length
391
+ ? `<div class="grp"><span class="l">Not applied</span><span class="n">${rejected.length}</span></div>` +
392
+ rejected.map((e) => experimentRow(e, view)).join("")
393
+ : "";
394
+ return filters + summary + appliedHtml + rejectedHtml;
395
+ }
396
+
397
+ function eventRow(ev: QaEventModel): string {
398
+ const tones: Record<QaEventModel["type"], { fg: string; bg: string; bd: string; label: string }> = {
399
+ exposure: { fg: "var(--p200)", bg: "var(--p-soft)", bd: "var(--p-bd)", label: "Exposure" },
400
+ goal: { fg: "var(--info)", bg: "var(--info-soft)", bd: "var(--info-bd)", label: "Goal" },
401
+ revenue: { fg: "var(--pos)", bg: "var(--pos-soft)", bd: "var(--pos-bd)", label: "Revenue" },
402
+ pageview: { fg: "var(--t3)", bg: "rgba(255,255,255,0.05)", bd: "var(--bd-hi)", label: "Pageview" },
403
+ };
404
+ const t = tones[ev.type];
405
+ const dup = ev.duplicateOf != null;
406
+ return `
407
+ <div class="ev" data-dup="${dup}">
408
+ <div class="ev-row">
409
+ <span class="ev-t">${formatTime(ev.at)}</span>
410
+ <span class="ev-type" style="color:${t.fg};background:${t.bg};border:1px solid ${t.bd}">${t.label}</span>
411
+ <span class="ev-label">${esc(ev.label)}</span>
412
+ ${ev.value ? `<span class="ev-val">${esc(ev.value)}</span>` : ""}
413
+ <span class="qatag">QA</span>
414
+ </div>
415
+ ${dup ? `<div class="ev-dup">${svg(P.alert, 11)}Fired twice within a second — likely a double-bound click handler</div>` : ""}
416
+ </div>`;
417
+ }
418
+
419
+ function eventsBody(state: QaAssistantState): string {
420
+ const live = `
421
+ <div class="live">
422
+ <span class="dot"></span>
423
+ <span class="t">Live · this browser only · every event is QA-tagged</span>
424
+ <button class="clear" data-action="clear-events">Clear</button>
425
+ </div>`;
426
+ if (state.events.length === 0) {
427
+ return (
428
+ live +
429
+ `<div class="empty">
430
+ <div class="icon">${svg(P.activity, 16)}</div>
431
+ <div class="h">No events yet</div>
432
+ <div class="s">Click around the page — exposures, goals and revenue show up here the moment they fire.</div>
433
+ </div>`
434
+ );
435
+ }
436
+ const rows = [...state.events]
437
+ .sort((a, b) => b.at - a.at)
438
+ .slice(0, 80)
439
+ .map(eventRow)
440
+ .join("");
441
+ return live + rows;
442
+ }
443
+
444
+ function attributionRows(list: Array<{ experimentName: string; variationName: string | null }>): string {
445
+ if (!list.length) return "";
446
+ return `
447
+ <div class="grp"><span class="l">Attribution by experiment</span></div>
448
+ <div style="padding:0 2px">
449
+ ${list
450
+ .map(
451
+ (a) => `
452
+ <div class="attr-row">
453
+ <span class="nm">${esc(a.experimentName)}</span>
454
+ <span class="vr" data-none="${a.variationName == null}">${esc(a.variationName ?? "Not in experiment")}</span>
455
+ </div>`,
456
+ )
457
+ .join("")}
458
+ </div>`;
459
+ }
460
+
461
+ function transactionsBody(tx: QaTransactionsModel): string {
462
+ if (tx.kind === "unsupported") return "";
463
+ if (tx.kind === "shopify") {
464
+ const head = tx.cartStamped
465
+ ? `Your cart is stamped with <span style="color:var(--p200)">${esc(tx.variationName ?? "your variation")}</span>`
466
+ : "No cart stamp yet — add something to the cart first";
467
+ const meta = tx.stampedAt ? `Cart attribute written ${formatTime(tx.stampedAt)}` : "The stamp is written when the cart updates";
468
+ return `
469
+ <div class="tx">
470
+ <div class="tx-card">
471
+ <div class="row"><span style="color:var(--p200);display:inline-flex">${svg(P.checkSquare, 14)}</span><span class="h">${head}</span></div>
472
+ <div class="meta">${esc(meta)}</div>
473
+ </div>
474
+ ${attributionRows(tx.attribution)}
475
+ <div class="infonote">
476
+ <span style="color:var(--p200);display:inline-flex;margin-top:1px">${svg(P.help, 13)}</span>
477
+ <span class="t">Shopify’s checkout runs in a sandbox we can’t see into. Your final order confirmation appears in the dashboard once Shopify sends the order back.</span>
478
+ </div>
479
+ </div>`;
480
+ }
481
+ const receipt =
482
+ tx.serverReceipt === "confirmed"
483
+ ? `<div class="receipt ok">${svg(P.check, 13, 2.4)}<div><div class="h">Server receipt · Confirmed</div><div class="s">Our backend received this order.</div></div></div>`
484
+ : `<div class="receipt pending">${svg(P.clock, 13)}<div><div class="h">Server receipt · Pending…</div><div class="s">Waiting for our backend to receive this order.</div></div></div>`;
485
+ const orderHead = tx.orderNumber
486
+ ? `Order ${esc(tx.orderNumber)}`
487
+ : "Waiting for your test checkout";
488
+ return `
489
+ <div class="tx">
490
+ <div class="tx-card">
491
+ <div class="row"><span style="color:var(--t3);display:inline-flex">${svg(P.store, 14)}</span><span class="h">${orderHead}</span>${tx.orderNumber ? chip("Detected", "applied") : ""}</div>
492
+ <div class="meta">${tx.orderNumber ? "Your test checkout on this browser" : "Complete a checkout in this browser and it shows up here"}</div>
493
+ </div>
494
+ ${attributionRows(tx.attribution)}
495
+ ${tx.orderNumber ? receipt : ""}
496
+ </div>`;
497
+ }
498
+
499
+ /* ── shell ──────────────────────────────────────────────────────────────── */
500
+ function buildTabs(state: QaAssistantState): Array<{ id: TabId; label: string; count: number }> {
501
+ const hideTx = state.transactions.kind === "unsupported";
502
+ return [
503
+ { id: "exp", label: "Campaigns", count: state.experiments.length },
504
+ { id: "ev", label: "Events", count: state.events.length },
505
+ ...(hideTx ? [] : [{ id: "tx" as TabId, label: "Transactions", count: state.transactions.kind === "order" && state.transactions.orderNumber ? 1 : 0 }]),
506
+ ];
507
+ }
508
+
509
+ function tabsHtml(tabs: Array<{ id: TabId; label: string; count: number }>, activeTab: TabId): string {
510
+ return tabs
511
+ .map((t) => `<button class="tab" data-on="${t.id === activeTab}" data-action="tab" data-tab="${t.id}">${t.label}<span class="n">${t.count}</span></button>`)
512
+ .join("");
513
+ }
514
+
515
+ function panelHtml(state: QaAssistantState, view: ViewState): string {
516
+ const hideTx = state.transactions.kind === "unsupported";
517
+ const tabs = buildTabs(state);
518
+ const activeTab: TabId = hideTx && view.tab === "tx" ? "exp" : view.tab;
519
+ const body =
520
+ activeTab === "exp"
521
+ ? experimentsBody(state, view)
522
+ : activeTab === "ev"
523
+ ? eventsBody(state)
524
+ : transactionsBody(state.transactions);
525
+ return `
526
+ <div class="panel" data-enter="${view.firstMount}" role="dialog" aria-label="Apex QA assistant">
527
+ <div class="hdr">
528
+ <div class="hdr-row">
529
+ <span class="mark">${operatorMarkSvg(14)}</span>
530
+ <span class="hdr-title">Apex QA</span>
531
+ ${chip("QA mode", "forced")}
532
+ <span class="hdr-actions">
533
+ <button class="btn-ghost" data-action="exit-qa">Exit QA</button>
534
+ <button class="btn-icon" data-action="minimize" title="Minimize" aria-label="Minimize">${svg(P.minus, 13)}</button>
535
+ </span>
536
+ </div>
537
+ <div class="hdr-sub">You’re previewing as QA — nothing here counts toward results.</div>
538
+ </div>
539
+ <div data-region="forced">${forcedBarHtml(state)}</div>
540
+ <div class="tabs" data-region="tabs">
541
+ ${tabsHtml(tabs, activeTab)}
542
+ </div>
543
+ <div class="body">${body}</div>
544
+ <div class="ftr">
545
+ <button class="hide" data-action="minimize">${svg(P.eyeOff, 12)}Hide panel</button>
546
+ <button class="reset" data-action="reset-force">Reset to real assignment</button>
547
+ </div>
548
+ </div>`;
549
+ }
550
+
551
+ function forcedBarHtml(state: QaAssistantState): string {
552
+ return state.forcedSummary
553
+ ? `<div class="forced-bar">${svg(P.bolt, 12)}<span class="txt">${esc(state.forcedSummary)}</span><button class="reset" data-action="reset-force">Reset</button></div>`
554
+ : "";
555
+ }
556
+
557
+ function pillHtml(state: QaAssistantState): string {
558
+ const n = state.experiments.length;
559
+ return `
560
+ <button class="pill" data-action="restore" aria-label="Restore Apex QA panel">
561
+ <span class="mark" style="width:22px;height:22px">${operatorMarkSvg(13)}</span>
562
+ <span class="t">Apex QA · ${n} experiment${n === 1 ? "" : "s"}</span>
563
+ ${svg(P.chevUp, 12)}
564
+ </button>`;
565
+ }
566
+
567
+ function operatorMarkSvg(size: number): string {
568
+ return `<svg width="${size}" height="${size}" viewBox="0 0 120 120" fill="none" style="flex-shrink:0">
569
+ <defs><mask id="qa-op-cut"><rect width="120" height="120" fill="#fff"/><rect x="0" y="68" width="120" height="6" fill="#000"/></mask></defs>
570
+ <path d="M60 14 C92 14 106 36 106 60 C106 84 92 106 60 106 C28 106 14 84 14 60 C14 36 28 14 60 14 Z M60 38 C46 38 38 48 38 60 C38 72 46 82 60 82 C74 82 82 72 82 60 C82 48 74 38 60 38 Z" fill="#fff" fill-rule="evenodd" mask="url(#qa-op-cut)"/>
571
+ <rect x="92" y="92" width="14" height="14" rx="2" fill="#5B29FF"/>
572
+ </svg>`;
573
+ }
574
+
575
+ /* ── controller ─────────────────────────────────────────────────────────── */
576
+ export type QaAssistantView = { destroy(): void };
577
+
578
+ export function mountQaAssistantView(store: QaAssistantStore): QaAssistantView {
579
+ const existing = document.getElementById(QA_ASSISTANT_HOST_ID);
580
+ if (existing) existing.remove();
581
+
582
+ const host = document.createElement("div");
583
+ host.id = QA_ASSISTANT_HOST_ID;
584
+ const shadow = host.attachShadow({ mode: "open" });
585
+ const style = document.createElement("style");
586
+ style.textContent = CSS;
587
+ const root = document.createElement("div");
588
+ root.className = "root";
589
+ shadow.append(style, root);
590
+ document.body.appendChild(host);
591
+
592
+ let minimized = false;
593
+ try {
594
+ minimized = sessionStorage.getItem(MINIMIZED_KEY) === "1";
595
+ } catch {
596
+ // storage unavailable — start expanded
597
+ }
598
+ const view: ViewState = {
599
+ tab: "exp", minimized, expanded: new Set(), codeOpen: new Set(),
600
+ pageFilter: "all", statusFilter: "all", search: "", firstMount: true,
601
+ };
602
+ // Events cleared locally: hide everything at or before this timestamp.
603
+ let clearedBefore = 0;
604
+
605
+ let mounted = false;
606
+ let renderTimer: ReturnType<typeof setTimeout> | null = null;
607
+ let lastSignature = "";
608
+ let composingSearch = false;
609
+
610
+ const currentState = (): QaAssistantState => {
611
+ const raw = store.getState();
612
+ return clearedBefore
613
+ ? { ...raw, events: raw.events.filter((e) => e.at > clearedBefore) }
614
+ : raw;
615
+ };
616
+
617
+ const signature = (state: QaAssistantState): string => JSON.stringify({
618
+ experiments: state.experiments,
619
+ forcedSummary: state.forcedSummary,
620
+ transactions: state.transactions,
621
+ events: view.tab === "ev" ? state.events : undefined,
622
+ view: [view.tab, view.minimized, [...view.expanded], [...view.codeOpen], view.pageFilter, view.statusFilter, view.search],
623
+ });
624
+
625
+ const render = (immediate = false) => {
626
+ if (!immediate) {
627
+ if (renderTimer) clearTimeout(renderTimer);
628
+ renderTimer = setTimeout(() => render(true), 100);
629
+ return;
630
+ }
631
+ if (renderTimer) clearTimeout(renderTimer);
632
+ renderTimer = null;
633
+ if (composingSearch) {
634
+ renderTimer = setTimeout(() => render(true), 100);
635
+ return;
636
+ }
637
+ const state = currentState();
638
+ const nextSignature = signature(state);
639
+ if (mounted && nextSignature === lastSignature) {
640
+ const eventsBadge = root.querySelector<HTMLElement>('.tab[data-tab="ev"] .n');
641
+ if (eventsBadge) eventsBadge.textContent = String(state.events.length);
642
+ return;
643
+ }
644
+ lastSignature = nextSignature;
645
+ if (!mounted || view.minimized || !root.querySelector(".panel")) {
646
+ root.innerHTML = view.minimized ? pillHtml(state) : panelHtml(state, view);
647
+ mounted = true;
648
+ view.firstMount = false;
649
+ return;
650
+ }
651
+ const body = root.querySelector<HTMLElement>(".body");
652
+ const scrollTop = body?.scrollTop ?? 0;
653
+ const focused = shadow.activeElement as HTMLInputElement | null;
654
+ const focusedAction = focused?.dataset.action;
655
+ const selection = focusedAction === "search" && focused
656
+ ? [focused.selectionStart, focused.selectionEnd] as const
657
+ : null;
658
+ const activeTab = state.transactions.kind === "unsupported" && view.tab === "tx" ? "exp" : view.tab;
659
+ if (body) {
660
+ body.innerHTML = activeTab === "exp" ? experimentsBody(state, view) : activeTab === "ev" ? eventsBody(state) : transactionsBody(state.transactions);
661
+ body.scrollTop = scrollTop;
662
+ if (focusedAction) {
663
+ const replacement = body.querySelector<HTMLInputElement>(`[data-action="${focusedAction}"]`);
664
+ replacement?.focus();
665
+ if (replacement && selection?.[0] != null && selection[1] != null) replacement.setSelectionRange(selection[0], selection[1]);
666
+ }
667
+ }
668
+ const forced = root.querySelector<HTMLElement>('[data-region="forced"]');
669
+ if (forced) forced.innerHTML = forcedBarHtml(state);
670
+ const tabsRegion = root.querySelector<HTMLElement>('[data-region="tabs"]');
671
+ if (tabsRegion) {
672
+ tabsRegion.innerHTML = tabsHtml(buildTabs(state), activeTab);
673
+ }
674
+ };
675
+
676
+ root.addEventListener("click", (event) => {
677
+ const target = (event.target as HTMLElement | null)?.closest?.("[data-action]") as HTMLElement | null;
678
+ if (!target) return;
679
+ const action = target.dataset.action;
680
+ const expId = target.dataset.exp;
681
+ if (action === "minimize") {
682
+ view.minimized = true;
683
+ try {
684
+ sessionStorage.setItem(MINIMIZED_KEY, "1");
685
+ } catch { /* ignore */ }
686
+ } else if (action === "restore") {
687
+ view.minimized = false;
688
+ try {
689
+ sessionStorage.removeItem(MINIMIZED_KEY);
690
+ } catch { /* ignore */ }
691
+ } else if (action === "tab") {
692
+ view.tab = (target.dataset.tab as TabId) ?? "exp";
693
+ } else if (action === "toggle-exp" && expId) {
694
+ if (view.expanded.has(expId)) view.expanded.delete(expId);
695
+ else view.expanded.add(expId);
696
+ } else if (action === "toggle-code") {
697
+ const key = target.dataset.key ?? "";
698
+ if (view.codeOpen.has(key)) view.codeOpen.delete(key);
699
+ else view.codeOpen.add(key);
700
+ } else if (action === "open-force" && expId) {
701
+ // Entering force mode pins the current variation as forced, which flips
702
+ // the row into the picker so QA can switch or reset from there.
703
+ const experiment = store.getState().experiments.find((e) => e.id === expId);
704
+ const current = experiment?.variations.find((v) => v.isCurrent) ?? experiment?.variations[0];
705
+ if (experiment && current) store.forceVariation(experiment.id, current.id);
706
+ view.expanded.add(expId);
707
+ } else if (action === "pick-variation" && expId) {
708
+ const varId = target.dataset.var;
709
+ if (varId) store.forceVariation(expId, varId);
710
+ } else if (action === "reset-force") {
711
+ store.resetForce(expId || undefined);
712
+ } else if (action === "exit-qa") {
713
+ store.exitQa();
714
+ host.remove();
715
+ return;
716
+ } else if (action === "clear-events") {
717
+ clearedBefore = Date.now();
718
+ }
719
+ render(true);
720
+ });
721
+
722
+ root.addEventListener("change", (event) => {
723
+ const target = event.target as HTMLInputElement | HTMLSelectElement;
724
+ if (target.dataset.action === "page-filter") view.pageFilter = target.value as ViewState["pageFilter"];
725
+ else if (target.dataset.action === "status-filter") view.statusFilter = target.value as ViewState["statusFilter"];
726
+ else if (target.dataset.action === "search") view.search = target.value;
727
+ else return;
728
+ render(true);
729
+ });
730
+
731
+ // Keep in-progress search text in ViewState on every keystroke — a
732
+ // store-driven refresh between "input" and "change" would otherwise rebuild
733
+ // the field from a stale value and wipe the user's query.
734
+ root.addEventListener("input", (event) => {
735
+ const target = event.target as HTMLInputElement;
736
+ if (target.dataset?.action !== "search") return;
737
+ view.search = target.value;
738
+ if (composingSearch) return;
739
+ render(true);
740
+ });
741
+
742
+ root.addEventListener("compositionstart", (event) => {
743
+ const target = event.target as HTMLInputElement;
744
+ if (target.dataset?.action === "search") composingSearch = true;
745
+ });
746
+
747
+ root.addEventListener("compositionend", (event) => {
748
+ const target = event.target as HTMLInputElement;
749
+ if (target.dataset?.action !== "search") return;
750
+ composingSearch = false;
751
+ view.search = target.value;
752
+ render(true);
753
+ });
754
+
755
+ const unsubscribe = store.subscribe(() => {
756
+ render();
757
+ });
758
+
759
+ render(true);
760
+
761
+ return {
762
+ destroy() {
763
+ unsubscribe();
764
+ if (renderTimer) clearTimeout(renderTimer);
765
+ host.remove();
766
+ },
767
+ };
768
+ }