@c15t/scripts 3.0.0-alpha.2 → 3.0.0-alpha.3

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 (300) hide show
  1. package/AGENTS.md +129 -63
  2. package/README.md +8 -29
  3. package/SKILL.md +33 -0
  4. package/dist/adobe-analytics.js +2 -0
  5. package/dist/ahrefs-analytics.js +2 -0
  6. package/dist/amplitude.js +2 -0
  7. package/dist/clearbit.js +2 -0
  8. package/dist/cloudflare-web-analytics.js +2 -0
  9. package/dist/cloudflare-zaraz.js +2 -0
  10. package/dist/crisp.js +2 -0
  11. package/dist/databuddy.js +2 -0
  12. package/dist/e2e-test-utils.js +2 -139
  13. package/dist/engine/compile.js +2 -89
  14. package/dist/engine/runtime.js +2 -448
  15. package/dist/events.js +2 -218
  16. package/dist/fathom-analytics.js +2 -0
  17. package/dist/front-chat.js +2 -0
  18. package/dist/google-tag-manager.js +2 -0
  19. package/dist/google-tag.js +2 -0
  20. package/dist/heap.js +2 -0
  21. package/dist/hightouch.js +2 -0
  22. package/dist/hotjar.js +2 -0
  23. package/dist/intercom.js +2 -0
  24. package/dist/klaviyo.js +2 -0
  25. package/dist/linkedin-insights.js +2 -0
  26. package/dist/logrocket.js +2 -0
  27. package/dist/matomo-analytics.js +2 -0
  28. package/dist/meta-pixel.js +2 -0
  29. package/dist/microsoft-clarity.js +2 -0
  30. package/dist/microsoft-uet.js +2 -0
  31. package/dist/mixpanel-analytics.js +2 -0
  32. package/dist/one-dollar-stats.js +2 -0
  33. package/dist/openai-pixel.js +2 -0
  34. package/dist/pinterest-tag.js +2 -0
  35. package/dist/pirsch.js +2 -0
  36. package/dist/plausible-analytics.js +2 -0
  37. package/dist/posthog.js +2 -0
  38. package/dist/promptwatch.js +2 -0
  39. package/dist/reddit-pixel.js +2 -0
  40. package/dist/registry.js +2 -422
  41. package/dist/resolve.js +2 -33
  42. package/dist/rudderstack.js +2 -0
  43. package/dist/rybbit-analytics.js +2 -0
  44. package/dist/segment.js +2 -0
  45. package/dist/snapchat-pixel.js +2 -0
  46. package/dist/tiktok-pixel.js +2 -0
  47. package/dist/types.js +2 -16
  48. package/dist/umami-analytics.js +2 -0
  49. package/dist/vendors/_shared/attributes.js +2 -14
  50. package/dist/vendors/_shared/google-consent.js +2 -27
  51. package/dist/vendors/_shared/install-builders.js +2 -21
  52. package/dist/vendors/_shared/required-id.js +2 -0
  53. package/dist/vendors/_shared/script-url.js +2 -28
  54. package/dist/vendors/ads-and-pixels/linkedin-insights.js +2 -48
  55. package/dist/vendors/ads-and-pixels/meta-pixel.js +2 -153
  56. package/dist/vendors/ads-and-pixels/microsoft-uet.js +2 -110
  57. package/dist/vendors/ads-and-pixels/openai-pixel.js +2 -88
  58. package/dist/vendors/ads-and-pixels/pinterest-tag.js +2 -123
  59. package/dist/vendors/ads-and-pixels/reddit-pixel.js +2 -107
  60. package/dist/vendors/ads-and-pixels/snapchat-pixel.js +2 -87
  61. package/dist/vendors/ads-and-pixels/tiktok-pixel.js +2 -89
  62. package/dist/vendors/ads-and-pixels/x-pixel.js +2 -48
  63. package/dist/vendors/analytics/adobe-analytics.js +2 -49
  64. package/dist/vendors/analytics/ahrefs-analytics.js +2 -27
  65. package/dist/vendors/analytics/amplitude.js +2 -134
  66. package/dist/vendors/analytics/clearbit.js +2 -28
  67. package/dist/vendors/analytics/cloudflare-web-analytics.js +2 -32
  68. package/dist/vendors/analytics/databuddy.js +2 -103
  69. package/dist/vendors/analytics/fathom-analytics.js +2 -35
  70. package/dist/vendors/analytics/google-tag.js +2 -78
  71. package/dist/vendors/analytics/heap.js +2 -134
  72. package/dist/vendors/analytics/hightouch.js +2 -109
  73. package/dist/vendors/analytics/hotjar.js +2 -44
  74. package/dist/vendors/analytics/logrocket.js +2 -58
  75. package/dist/vendors/analytics/matomo-analytics.js +2 -191
  76. package/dist/vendors/analytics/microsoft-clarity.js +2 -100
  77. package/dist/vendors/analytics/mixpanel-analytics.js +2 -93
  78. package/dist/vendors/analytics/one-dollar-stats.js +2 -30
  79. package/dist/vendors/analytics/pirsch.js +2 -67
  80. package/dist/vendors/analytics/plausible-analytics.js +2 -81
  81. package/dist/vendors/analytics/posthog.js +2 -200
  82. package/dist/vendors/analytics/promptwatch.js +2 -29
  83. package/dist/vendors/analytics/rudderstack.js +2 -183
  84. package/dist/vendors/analytics/rybbit-analytics.js +2 -63
  85. package/dist/vendors/analytics/segment.js +2 -65
  86. package/dist/vendors/analytics/umami-analytics.js +2 -39
  87. package/dist/vendors/analytics/vercel-analytics.js +2 -53
  88. package/dist/vendors/email-and-sms/klaviyo.js +2 -0
  89. package/dist/vendors/functional/crisp.js +2 -100
  90. package/dist/vendors/functional/front-chat.js +2 -64
  91. package/dist/vendors/functional/intercom.js +2 -45
  92. package/dist/vendors/tag-managers/cloudflare-zaraz.js +2 -98
  93. package/dist/vendors/tag-managers/google-tag-manager.js +2 -73
  94. package/dist/vercel-analytics.js +2 -0
  95. package/dist/x-pixel.js +2 -0
  96. package/dist-types/adobe-analytics.d.ts +2 -0
  97. package/dist-types/ahrefs-analytics.d.ts +2 -0
  98. package/dist-types/amplitude.d.ts +2 -0
  99. package/dist-types/clearbit.d.ts +2 -0
  100. package/dist-types/cloudflare-web-analytics.d.ts +2 -0
  101. package/dist-types/cloudflare-zaraz.d.ts +2 -0
  102. package/dist-types/crisp.d.ts +2 -0
  103. package/dist-types/databuddy.d.ts +2 -0
  104. package/dist-types/e2e-test-utils.d.ts +2 -0
  105. package/dist-types/engine/compile.d.ts +2 -3
  106. package/dist-types/engine/runtime.d.ts +2 -3
  107. package/dist-types/events.d.ts +2 -46
  108. package/dist-types/fathom-analytics.d.ts +2 -0
  109. package/dist-types/front-chat.d.ts +2 -0
  110. package/dist-types/google-tag-manager.d.ts +2 -0
  111. package/dist-types/google-tag.d.ts +2 -0
  112. package/dist-types/heap.d.ts +2 -0
  113. package/dist-types/hightouch.d.ts +2 -0
  114. package/dist-types/hotjar.d.ts +2 -0
  115. package/dist-types/intercom.d.ts +2 -0
  116. package/dist-types/klaviyo.d.ts +2 -0
  117. package/dist-types/linkedin-insights.d.ts +2 -0
  118. package/dist-types/logrocket.d.ts +2 -0
  119. package/dist-types/matomo-analytics.d.ts +2 -0
  120. package/dist-types/meta-pixel.d.ts +2 -0
  121. package/dist-types/microsoft-clarity.d.ts +2 -0
  122. package/dist-types/microsoft-uet.d.ts +2 -0
  123. package/dist-types/mixpanel-analytics.d.ts +2 -0
  124. package/dist-types/one-dollar-stats.d.ts +2 -0
  125. package/dist-types/openai-pixel.d.ts +2 -0
  126. package/dist-types/pinterest-tag.d.ts +2 -0
  127. package/dist-types/pirsch.d.ts +2 -0
  128. package/dist-types/plausible-analytics.d.ts +2 -0
  129. package/dist-types/posthog.d.ts +2 -0
  130. package/dist-types/promptwatch.d.ts +2 -0
  131. package/dist-types/reddit-pixel.d.ts +2 -0
  132. package/dist-types/registry.d.ts +2 -485
  133. package/dist-types/resolve.d.ts +2 -9
  134. package/dist-types/rudderstack.d.ts +2 -0
  135. package/dist-types/rybbit-analytics.d.ts +2 -0
  136. package/dist-types/segment.d.ts +2 -0
  137. package/dist-types/snapchat-pixel.d.ts +2 -0
  138. package/dist-types/tiktok-pixel.d.ts +2 -0
  139. package/dist-types/types.d.ts +2 -314
  140. package/dist-types/umami-analytics.d.ts +2 -0
  141. package/dist-types/vendors/_shared/attributes.d.ts +2 -35
  142. package/dist-types/vendors/_shared/google-consent.d.ts +2 -47
  143. package/dist-types/vendors/_shared/install-builders.d.ts +2 -30
  144. package/dist-types/vendors/_shared/required-id.d.ts +2 -0
  145. package/dist-types/vendors/_shared/script-url.d.ts +2 -75
  146. package/dist-types/vendors/ads-and-pixels/linkedin-insights.d.ts +2 -92
  147. package/dist-types/vendors/ads-and-pixels/meta-pixel.d.ts +2 -289
  148. package/dist-types/vendors/ads-and-pixels/microsoft-uet.d.ts +2 -105
  149. package/dist-types/vendors/ads-and-pixels/openai-pixel.d.ts +2 -211
  150. package/dist-types/vendors/ads-and-pixels/pinterest-tag.d.ts +2 -295
  151. package/dist-types/vendors/ads-and-pixels/reddit-pixel.d.ts +2 -210
  152. package/dist-types/vendors/ads-and-pixels/snapchat-pixel.d.ts +2 -171
  153. package/dist-types/vendors/ads-and-pixels/tiktok-pixel.d.ts +2 -106
  154. package/dist-types/vendors/ads-and-pixels/x-pixel.d.ts +2 -183
  155. package/dist-types/vendors/analytics/adobe-analytics.d.ts +2 -75
  156. package/dist-types/vendors/analytics/ahrefs-analytics.d.ts +2 -62
  157. package/dist-types/vendors/analytics/amplitude.d.ts +2 -234
  158. package/dist-types/vendors/analytics/clearbit.d.ts +2 -60
  159. package/dist-types/vendors/analytics/cloudflare-web-analytics.d.ts +2 -67
  160. package/dist-types/vendors/analytics/databuddy.d.ts +2 -147
  161. package/dist-types/vendors/analytics/fathom-analytics.d.ts +2 -90
  162. package/dist-types/vendors/analytics/google-tag.d.ts +2 -95
  163. package/dist-types/vendors/analytics/heap.d.ts +2 -316
  164. package/dist-types/vendors/analytics/hightouch.d.ts +2 -285
  165. package/dist-types/vendors/analytics/hotjar.d.ts +2 -73
  166. package/dist-types/vendors/analytics/logrocket.d.ts +2 -101
  167. package/dist-types/vendors/analytics/matomo-analytics.d.ts +2 -41
  168. package/dist-types/vendors/analytics/microsoft-clarity.d.ts +2 -97
  169. package/dist-types/vendors/analytics/mixpanel-analytics.d.ts +2 -113
  170. package/dist-types/vendors/analytics/one-dollar-stats.d.ts +2 -39
  171. package/dist-types/vendors/analytics/pirsch.d.ts +2 -96
  172. package/dist-types/vendors/analytics/plausible-analytics.d.ts +2 -122
  173. package/dist-types/vendors/analytics/posthog.d.ts +2 -175
  174. package/dist-types/vendors/analytics/promptwatch.d.ts +2 -36
  175. package/dist-types/vendors/analytics/rudderstack.d.ts +2 -330
  176. package/dist-types/vendors/analytics/rybbit-analytics.d.ts +2 -82
  177. package/dist-types/vendors/analytics/segment.d.ts +2 -164
  178. package/dist-types/vendors/analytics/umami-analytics.d.ts +2 -93
  179. package/dist-types/vendors/analytics/vercel-analytics.d.ts +2 -66
  180. package/dist-types/vendors/email-and-sms/klaviyo.d.ts +2 -0
  181. package/dist-types/vendors/functional/crisp.d.ts +2 -78
  182. package/dist-types/vendors/functional/front-chat.d.ts +2 -62
  183. package/dist-types/vendors/functional/intercom.d.ts +2 -135
  184. package/dist-types/vendors/tag-managers/cloudflare-zaraz.d.ts +2 -39
  185. package/dist-types/vendors/tag-managers/google-tag-manager.d.ts +2 -96
  186. package/dist-types/vercel-analytics.d.ts +2 -0
  187. package/dist-types/x-pixel.d.ts +2 -0
  188. package/docs/README.md +129 -63
  189. package/docs/assets/v3/bottom-bar.png +0 -0
  190. package/docs/assets/v3/brand-card.png +0 -0
  191. package/docs/assets/v3/brand-preferences.png +0 -0
  192. package/docs/assets/v3/choice-wall.png +0 -0
  193. package/docs/assets/v3/headless-bar-html.png +0 -0
  194. package/docs/assets/v3/headless-bar-mobile.png +0 -0
  195. package/docs/assets/v3/headless-bar.png +0 -0
  196. package/docs/assets/v3/slim-bar.png +0 -0
  197. package/docs/concepts/choose-your-setup.md +87 -0
  198. package/docs/concepts/consent-categories.md +84 -0
  199. package/docs/{guides → concepts}/consent-state.md +71 -101
  200. package/docs/{guides → concepts}/data-fetching.md +31 -27
  201. package/docs/concepts/how-consent-works.md +123 -0
  202. package/docs/concepts/policies.md +71 -0
  203. package/docs/customization/class-names.md +202 -0
  204. package/docs/customization/dark-mode.md +157 -0
  205. package/docs/customization/motion.md +119 -0
  206. package/docs/customization/overview.md +67 -34
  207. package/docs/customization/recipes.md +839 -49
  208. package/docs/customization/slots.md +216 -35
  209. package/docs/customization/stylesheets.md +147 -0
  210. package/docs/customization/tailwind.md +842 -0
  211. package/docs/customization/tokens.md +163 -96
  212. package/docs/customization/translations.md +61 -3
  213. package/docs/frameworks/astro/embeds.md +160 -0
  214. package/docs/frameworks/astro/network-blocker.md +86 -0
  215. package/docs/frameworks/astro/scripts.md +146 -0
  216. package/docs/frameworks/html/embeds.md +142 -0
  217. package/docs/frameworks/html/network-blocker.md +105 -0
  218. package/docs/frameworks/html/scripts.md +164 -0
  219. package/docs/frameworks/javascript/scripts.md +119 -0
  220. package/docs/frameworks/next/embeds.md +90 -0
  221. package/docs/frameworks/next/network-blocker.md +153 -0
  222. package/docs/frameworks/next/scripts.md +196 -0
  223. package/docs/frameworks/nuxt/embeds.md +81 -0
  224. package/docs/frameworks/nuxt/network-blocker.md +97 -0
  225. package/docs/frameworks/nuxt/scripts.md +89 -0
  226. package/docs/frameworks/react/embeds.md +89 -0
  227. package/docs/frameworks/react/network-blocker.md +140 -0
  228. package/docs/frameworks/react/scripts.md +115 -0
  229. package/docs/frameworks/svelte/embeds.md +96 -0
  230. package/docs/frameworks/svelte/network-blocker.md +141 -0
  231. package/docs/frameworks/svelte/scripts.md +137 -0
  232. package/docs/frameworks/sveltekit/embeds.md +103 -0
  233. package/docs/frameworks/sveltekit/network-blocker.md +149 -0
  234. package/docs/frameworks/sveltekit/scripts.md +141 -0
  235. package/docs/frameworks/tanstack-start/embeds.md +96 -0
  236. package/docs/frameworks/tanstack-start/network-blocker.md +145 -0
  237. package/docs/frameworks/tanstack-start/scripts.md +103 -0
  238. package/docs/frameworks/vue/embeds.md +84 -0
  239. package/docs/frameworks/vue/network-blocker.md +99 -0
  240. package/docs/frameworks/vue/scripts.md +93 -0
  241. package/docs/guides/banner-experiments.md +654 -0
  242. package/docs/guides/troubleshooting.md +120 -47
  243. package/docs/guides/verify-consent.md +81 -49
  244. package/docs/integrations/adobe-analytics.md +167 -159
  245. package/docs/integrations/ahrefs-analytics.md +152 -154
  246. package/docs/integrations/amplitude.md +162 -156
  247. package/docs/integrations/building-integrations.md +136 -37
  248. package/docs/integrations/clearbit.md +154 -154
  249. package/docs/integrations/cloudflare-web-analytics.md +156 -156
  250. package/docs/integrations/cloudflare-zaraz.md +209 -261
  251. package/docs/integrations/crisp.md +164 -158
  252. package/docs/integrations/databuddy.md +157 -173
  253. package/docs/integrations/fathom-analytics.md +158 -156
  254. package/docs/integrations/front-chat.md +167 -167
  255. package/docs/integrations/google-maps.md +118 -83
  256. package/docs/integrations/google-tag-manager.md +178 -163
  257. package/docs/integrations/google-tag.md +163 -160
  258. package/docs/integrations/heap.md +163 -155
  259. package/docs/integrations/hightouch.md +161 -157
  260. package/docs/integrations/hotjar.md +159 -155
  261. package/docs/integrations/intercom.md +183 -153
  262. package/docs/integrations/klaviyo.md +486 -0
  263. package/docs/integrations/linkedin-insights.md +174 -150
  264. package/docs/integrations/logrocket.md +160 -156
  265. package/docs/integrations/matomo-analytics.md +188 -178
  266. package/docs/integrations/meta-pixel.md +188 -150
  267. package/docs/integrations/microsoft-clarity.md +163 -155
  268. package/docs/integrations/microsoft-uet.md +148 -154
  269. package/docs/integrations/mixpanel-analytics.md +155 -160
  270. package/docs/integrations/one-dollar-stats.md +166 -165
  271. package/docs/integrations/openai-pixel.md +204 -301
  272. package/docs/integrations/overview.md +137 -80
  273. package/docs/integrations/pinterest-tag.md +191 -183
  274. package/docs/integrations/pirsch.md +169 -159
  275. package/docs/integrations/plausible-analytics.md +172 -158
  276. package/docs/integrations/posthog.md +226 -241
  277. package/docs/integrations/promptwatch.md +154 -154
  278. package/docs/integrations/reddit-pixel.md +185 -157
  279. package/docs/integrations/rudderstack.md +201 -186
  280. package/docs/integrations/rybbit-analytics.md +171 -160
  281. package/docs/integrations/segment.md +182 -154
  282. package/docs/integrations/snapchat-pixel.md +186 -156
  283. package/docs/integrations/tiktok-pixel.md +171 -150
  284. package/docs/integrations/umami-analytics.md +163 -158
  285. package/docs/integrations/vercel-analytics.md +166 -157
  286. package/docs/integrations/x-pixel.md +176 -150
  287. package/docs/integrations/youtube.md +121 -86
  288. package/docs/upgrade-v3.md +475 -467
  289. package/package.json +10 -257
  290. package/dist-types/__tests__/helpers.d.ts +0 -141
  291. package/docs/assets/v3/brand-bar.png +0 -0
  292. package/docs/assets/v3/mobile-card.png +0 -0
  293. package/docs/assets/v3/preferences.png +0 -0
  294. package/docs/frameworks/javascript/script-loader.md +0 -100
  295. package/docs/frameworks/next/script-loader.md +0 -216
  296. package/docs/frameworks/react/script-loader.md +0 -69
  297. package/docs/guides/deployment-modes.md +0 -75
  298. package/docs/guides/shared-consent-controls.md +0 -158
  299. package/docs/integrations/clear-on-revocation.md +0 -167
  300. package/docs/integrations/granular-consent.md +0 -210
@@ -0,0 +1,654 @@
1
+ ---
2
+ title: Banner experiments
3
+ description: Run A/B tests on consent banner presentation with any feature-flag
4
+ provider or built-in weighted assignment, and attribute every impression and
5
+ choice to its arm.
6
+ group: guides
7
+ ---
8
+
9
+ ## Vary presentation, not policy
10
+
11
+ An experiment changes only how the prompt and the preference center look:
12
+ variant, position, layout, primary actions, blocking. The policy rule, its
13
+ categories and its copy stay the same for every arm, so every arm records a
14
+ choice under the same policy fingerprint.
15
+
16
+ Your normal `presentation` is the `control` arm; without one, `control` is
17
+ the stock banner. Every other arm lists only what it changes. Add the
18
+ experiment to the provider you already have and pass the arm your feature flag
19
+ resolved. In the React example, the `Consent` wrapper takes the arm as a prop:
20
+
21
+ ```tsx title="src/consent.tsx"
22
+ import { defineExperiment } from 'c15t';
23
+ import {
24
+ ConsentBanner,
25
+ ConsentDialog,
26
+ ConsentDialogLink,
27
+ ConsentProvider,
28
+ hosted,
29
+ } from 'c15t/react';
30
+ import type { ConsentProviderCallbacks } from 'c15t/react';
31
+ import type { ReactNode } from 'react';
32
+
33
+ import { scripts } from './scripts';
34
+
35
+ import 'c15t/react/styles.css';
36
+
37
+ const mode = hosted({ url: 'https://your-project.inth.app' });
38
+
39
+ // `control` is the stock banner. `wall` blocks the page until the visitor
40
+ // chooses.
41
+ const bannerExperiment = defineExperiment({
42
+ arms: { wall: { prompt: { variant: 'wall' } } },
43
+ id: 'banner-shape',
44
+ });
45
+
46
+ const pushToDataLayer = (event: Record<string, unknown>) => {
47
+ const page = window as Window & { dataLayer?: unknown[] };
48
+ page.dataLayer ??= [];
49
+ page.dataLayer.push(event);
50
+ };
51
+
52
+ // Forward each impression and choice made under an arm to your analytics.
53
+ const callbacks = {
54
+ onChoiceRecorded: ({ consentAction, experiment }) => {
55
+ if (experiment) {
56
+ pushToDataLayer({
57
+ arm: experiment.arm,
58
+ consent_action: consentAction,
59
+ event: 'c15t_choice_recorded',
60
+ experiment_id: experiment.id,
61
+ });
62
+ }
63
+ },
64
+ onSurfaceShown: ({ experiment, surface }) => {
65
+ if (experiment) {
66
+ pushToDataLayer({
67
+ arm: experiment.arm,
68
+ event: 'c15t_surface_shown',
69
+ experiment_id: experiment.id,
70
+ surface,
71
+ });
72
+ }
73
+ },
74
+ } satisfies ConsentProviderCallbacks;
75
+
76
+ export const Consent = ({
77
+ arm,
78
+ children,
79
+ }: {
80
+ /**
81
+ * The arm your flag provider resolved. Omit it to let c15t pick, or pass
82
+ * `off` to leave this visitor out of the experiment.
83
+ */
84
+ arm?: 'control' | 'wall' | 'off';
85
+ children: ReactNode;
86
+ }) => (
87
+ <ConsentProvider
88
+ options={{
89
+ callbacks,
90
+ experiment: arm === 'off' ? undefined : { ...bannerExperiment, arm },
91
+ mode,
92
+ scripts,
93
+ }}
94
+ >
95
+ {children}
96
+ <ConsentBanner />
97
+ <ConsentDialog />
98
+ <footer>
99
+ <ConsentDialogLink>Privacy settings</ConsentDialogLink>
100
+ </footer>
101
+ </ConsentProvider>
102
+ );
103
+ ```
104
+
105
+ `defineExperiment()` from `c15t` returns its argument and has TypeScript check
106
+ the arm names in `arm` and `split`. The callbacks forward each impression and
107
+ choice to `window.dataLayer`; see
108
+ [send the events to your own analytics](#send-the-events-to-your-own-analytics-too).
109
+
110
+ Omit `arm` to let c15t pick, and set `split` to weight the arms:
111
+
112
+ ```ts
113
+ experiment: { ...bannerExperiment, split: { control: 60, wall: 40 } }
114
+ ```
115
+
116
+ | Field | Purpose |
117
+ | ------------------------ | ----------------------------------------------------------------------------------------------------- |
118
+ | `id` | Stable experiment name, recorded with every choice |
119
+ | `arms` | What each arm changes, merged over `presentation`. `control` is your `presentation` and is not listed |
120
+ | `arm` | The arm your flag resolved: `control` or a key of `arms` |
121
+ | `split` | Relative weights when c15t picks the arm; default equal. Keys are `control` and the arms |
122
+ | `acknowledgeDiagnostics` | Run an arm that trips a presentation diagnostic and record that you reviewed it |
123
+
124
+ c15t merges the arm over `presentation`, exposes it as `snapshot.experiment`,
125
+ sends it with `/init`, and records it on the choices of visitors the banner
126
+ showed it to, so you can compare opt-in rate and time to decision per arm.
127
+
128
+ The same option exists in `c15t/next` (`ConsentRoot` `options`), `c15t/vue`,
129
+ `@c15t/svelte`, the `c15t()` Astro integration from `c15t/astro`, and
130
+ `@c15t/browser`. On a server-rendered page in Next.js, TanStack Start or
131
+ SvelteKit, pass it to `resolveConsent` instead, which counts the arm and hands
132
+ it to the client; see [Vercel Flags SDK](#vercel-flags-sdk).
133
+
134
+ `experiment` is read once, when the provider mounts. Changing it later has
135
+ no effect; remount the provider (a `key` in React) to switch experiments.
136
+
137
+ Assignment and arm validation load as a separate chunk, only on pages that
138
+ set `experiment`. A site without an experiment does not download them.
139
+
140
+ ### Astro
141
+
142
+ The Astro banner is server-rendered HTML that the browser only shows or
143
+ hides, so the arm is resolved on the server. To pick it per request, set
144
+ `middleware: false` in `c15t()` and compose the consent middleware yourself
145
+ with `experimentArm`, where `bannerExperimentFlag` stands for your flag lookup:
146
+
147
+ ```ts title="src/middleware.ts"
148
+ import { consentMiddleware } from 'c15t/astro/middleware';
149
+
150
+ export const onRequest = consentMiddleware({
151
+ experimentArm: async (context) => await bannerExperimentFlag(context),
152
+ });
153
+ ```
154
+
155
+ Return `undefined` to run no experiment for that request. A fixed
156
+ `experiment.arm` in `c15t()` puts every visitor in one arm, which only
157
+ suits a staged rollout. Prerendered routes render once for every visitor, so
158
+ they get no per-request arm. `c15t()` throws at config time when
159
+ `experiment` has neither.
160
+
161
+ ### Vary the theme
162
+
163
+ An arm can carry `theme` overrides next to its presentation fragment. They
164
+ merge over the host `theme` one token group deep, so an arm can change one
165
+ colour or radius and keep the rest of your palette. Arrays and scalars are
166
+ replaced.
167
+
168
+ ```tsx
169
+ experiment: {
170
+ id: 'button-style',
171
+ arms: {
172
+ bold: {
173
+ theme: {
174
+ colors: { primary: '#0a0a0a' },
175
+ radius: { lg: '4px' },
176
+ },
177
+ },
178
+ },
179
+ }
180
+ ```
181
+
182
+ Read the merged theme with `useResolvedTheme()` in React,
183
+ `useResolvedTheme(theme)` in Vue, `getConsentManager().theme` in Svelte and
184
+ `client.theme` in `@c15t/browser`. Astro renders the arm's tokens with the
185
+ banner. In React, `consentActions` and slot overrides apply through the
186
+ provider, but colour, radius and other tokens reach the page through
187
+ `ConsentTheme`. Render it from the resolved theme in a client component inside
188
+ the provider, with both imported from `c15t/react`:
189
+
190
+ ```tsx
191
+ <ConsentTheme theme={useResolvedTheme()} />;
192
+ ```
193
+
194
+ Rendered from a client component, `ConsentTheme` ships the theme generator.
195
+ With an arm from your flag, you can instead render
196
+ `<ConsentTheme theme={resolveExperimentTheme(theme, experiment, { arm })} />`
197
+ from a Server Component. Per-action styling through
198
+ `theme.consentActions` runs through the same prominence check as
199
+ presentation: an arm that fills accept and outlines reject trips
200
+ `equivalent-prominence-overridden` and needs `acknowledgeDiagnostics: true`.
201
+
202
+ ## Resolve the arm with a flag provider
203
+
204
+ Resolve the arm wherever your flags live and pass its name as `arm`. c15t
205
+ records `assignedBy: 'host'` and never re-assigns a visitor you assigned.
206
+
207
+ ### Vercel Flags SDK
208
+
209
+ Give the flag three values. `off` keeps a visitor out of the test; `control`
210
+ and `wall` split the rest. Set the weights in the Vercel dashboard, so
211
+ you can roll out and widen the test without a deploy: start at 90% `off`,
212
+ check that both arms arrive, then move to 0% `off` for the real run.
213
+
214
+ ```ts title="src/flags.ts"
215
+ import { vercelAdapter } from '@flags-sdk/vercel';
216
+ import { flag } from 'flags/next';
217
+
218
+ export const bannerExperimentFlag = flag<'off' | 'control' | 'wall'>({
219
+ key: 'banner-shape',
220
+ adapter: vercelAdapter(),
221
+ identify, // your existing identify
222
+ defaultValue: 'off',
223
+ });
224
+ ```
225
+
226
+ Keep `identify` returning a stable id. Vercel splits on it, so a visitor
227
+ keeps their arm when you change the weights.
228
+
229
+ Resolve the flag where consent resolves and pass the experiment to
230
+ `resolveConsent`. The server reports the arm with `/init`, and the returned
231
+ state carries the experiment to `ConsentRoot`, so the client needs no
232
+ `experiment` option of its own. The Next.js example does this in the root
233
+ layout of its `/experiment` route:
234
+
235
+ ```tsx title="app/layout.tsx"
236
+ import { resolveConsent } from 'c15t/next/server';
237
+ import { Suspense } from 'react';
238
+ import type { ReactNode } from 'react';
239
+
240
+ import { consentConfig } from '@/c15t.config';
241
+ import { ExperimentConsent } from '@/components/experiment-consent';
242
+ import { bannerExperiment } from '@/lib/experiment';
243
+ import { bannerExperimentFlag } from '@/lib/flags';
244
+
245
+ import '@/styles/globals.css';
246
+
247
+ const ResolvedConsent = async ({ children }: { children: ReactNode }) => {
248
+ const arm = await bannerExperimentFlag();
249
+ const state = await resolveConsent({
250
+ config: consentConfig,
251
+ // `off` keeps this visitor out of the experiment.
252
+ experiment: arm === 'off' ? undefined : { ...bannerExperiment, arm },
253
+ });
254
+
255
+ return <ExperimentConsent state={state}>{children}</ExperimentConsent>;
256
+ };
257
+
258
+ const RootLayout = ({ children }: { children: ReactNode }) => (
259
+ <html lang="en">
260
+ <body>
261
+ <Suspense fallback={null}>
262
+ <ResolvedConsent>{children}</ResolvedConsent>
263
+ </Suspense>
264
+ </body>
265
+ </html>
266
+ );
267
+
268
+ export default RootLayout;
269
+ ```
270
+
271
+ In the example, `lib/flags.ts` stands in for the flag above and reads the arm
272
+ from the URL. `ExperimentConsent` is the `Consent` wrapper from the
273
+ [App Router guide](https://c15t.com/docs/frameworks/next/app-router) with the event callbacks
274
+ from [send the events to your own analytics](#send-the-events-to-your-own-analytics-too)
275
+ in `options`. The layout awaits consent inside `<Suspense>`, as in
276
+ [render the banner in the server HTML](https://c15t.com/docs/frameworks/next/app-router#render-the-banner-in-the-server-html),
277
+ so the banner is in the server HTML already showing the visitor's arm.
278
+ `resolveConsent` in `c15t/tanstack-start/server` and `@c15t/svelte/server`
279
+ takes the same `experiment`.
280
+
281
+ If you pass `resolveConsent()` to `ConsentRoot` without awaiting it (the
282
+ streaming layout), the client mounts before the state arrives. Pass the
283
+ same experiment to the client options as well; c15t warns in development
284
+ when the streamed state carries an experiment the client did not get.
285
+
286
+ ### PostHog
287
+
288
+ PostHog resolves flags asynchronously in the browser. Because `experiment`
289
+ is read once at mount, wait for the flags, then mount the provider with the
290
+ resolved arm. Do not wait forever: a blocked or slow flags request would
291
+ leave the visitor with no banner and you with no consent. After a second,
292
+ mount without the experiment. Those visitors see `presentation` and are not
293
+ counted in either arm. This component wraps the `Consent` component from
294
+ [the first example](#vary-presentation-not-policy):
295
+
296
+ ```tsx title="src/flagged-consent.tsx"
297
+ import posthog from 'posthog-js';
298
+ import { useEffect, useState } from 'react';
299
+ import type { ReactNode } from 'react';
300
+
301
+ import { Consent } from './consent';
302
+
303
+ export const FlaggedConsent = ({ children }: { children: ReactNode }) => {
304
+ const [arm, setArm] = useState<'control' | 'wall' | 'off' | null>(null);
305
+ useEffect(() => {
306
+ // The first answer wins: the provider reads the arm once.
307
+ const fallback = setTimeout(
308
+ () => setArm((current) => current ?? 'off'),
309
+ 1000
310
+ );
311
+ const unsubscribe = posthog.onFeatureFlags(() => {
312
+ const flag = posthog.getFeatureFlag('banner-shape');
313
+ setArm((current) => current ?? (flag === 'wall' ? 'wall' : 'control'));
314
+ });
315
+ return () => {
316
+ clearTimeout(fallback);
317
+ unsubscribe();
318
+ };
319
+ }, []);
320
+ if (arm === null) {
321
+ // Flags not loaded yet: no provider, no banner.
322
+ return children;
323
+ }
324
+ return <Consent arm={arm}>{children}</Consent>;
325
+ };
326
+ ```
327
+
328
+ ### LaunchDarkly, GrowthBook, Statsig
329
+
330
+ ```ts
331
+ const arm = ldClient.stringVariation('banner-shape', 'control');
332
+ const arm = growthbook.getFeatureValue('banner-shape', 'control');
333
+ const arm = StatsigClient.instance()
334
+ .getExperiment('banner-shape')
335
+ .get('arm', 'control');
336
+ ```
337
+
338
+ An `arm` that is not `control` or a key of `arms` logs an error and runs no
339
+ experiment for that visitor; the page still renders. Treat that as a flag
340
+ misconfiguration.
341
+
342
+ ## Let c15t assign the arm
343
+
344
+ Omit `arm` and c15t picks one by `split` (equal by default) when the page
345
+ starts, before `/init`, and records `assignedBy: 'c15t'`. A visitor who
346
+ already saw an arm keeps it.
347
+
348
+ The banner waits until the arm is picked, so the visitor never sees the base
349
+ banner swap for their arm. On a server-rendered page that means the banner is
350
+ not in the server HTML; it appears once the browser has loaded the
351
+ assignment chunk. If the chunk fails to load, the base banner shows and no
352
+ experiment runs. To keep the banner in the server HTML, resolve the arm on
353
+ the server and pass it as `arm`.
354
+
355
+ Once the banner has shown the arm, c15t stores `{ id, arm }` under
356
+ `c15t-experiment-v1` in localStorage (a cookie when localStorage is
357
+ unavailable), so the visitor keeps seeing the banner they saw. Nothing is
358
+ stored for a visitor who is never prompted, and nothing is stored for a
359
+ host-resolved arm: your flag provider decides that one on every visit. The
360
+ record holds no identifier. It exists only to keep the consent banner
361
+ consistent, so treat it like the consent record itself when you describe
362
+ your storage.
363
+
364
+ A `split` that gives no arm a positive weight, or that names an arm that does
365
+ not exist, logs an error and runs no experiment. An arm missing from the
366
+ split gets no visitors.
367
+
368
+ ```ts
369
+ experiment: {
370
+ id: 'banner-shape',
371
+ arms: { wall: { prompt: { variant: 'wall' } } },
372
+ split: { control: 60, wall: 40 },
373
+ }
374
+ ```
375
+
376
+ Changing `id` starts a new experiment and re-assigns everyone. Removing an
377
+ arm re-assigns only the visitors who were in it. For a clean analysis, change
378
+ `id` rather than editing the arms of a running experiment.
379
+
380
+ Built-in assignment is not available in Astro; see
381
+ [Astro](#astro).
382
+
383
+ ## Acknowledge presentation diagnostics
384
+
385
+ Each arm is resolved under the visitor's policy the same way `presentation`
386
+ is. An arm that trips a diagnostic, for example
387
+ `equivalent-prominence-overridden` because it makes accept primary while
388
+ reject stays neutral, is not shown under that policy: those visitors see the
389
+ base presentation, are not counted in the experiment, and c15t logs the
390
+ diagnostics. Set `acknowledgeDiagnostics: true` to run the arm anyway. c15t
391
+ then logs the diagnostics as a warning once per policy and records
392
+ `acknowledgedDiagnostics: true` with the arm on every choice. You own the
393
+ legal review of that arm; c15t records that you made it.
394
+
395
+ The check runs in the browser once the policy is known, so catch a rejected
396
+ arm before you deploy with `validateExperiment` in a test. `euPolicy` and
397
+ `presentation` stand for the policy and presentation your site uses:
398
+
399
+ ```ts
400
+ import { validateExperiment } from 'c15t/experiment';
401
+
402
+ test('banner-shape arms pass under the EU policy', () => {
403
+ validateExperiment(bannerExperiment, euPolicy, { presentation });
404
+ });
405
+ ```
406
+
407
+ It throws for an invalid definition and for arms with unacknowledged
408
+ diagnostics.
409
+
410
+ ## Read the assignment
411
+
412
+ ```tsx
413
+ import {
414
+ useExperiment,
415
+ useResolvedPresentation,
416
+ useResolvedTheme,
417
+ } from 'c15t/react';
418
+
419
+ const { id, arm, assignedBy } = useExperiment() ?? {};
420
+ const presentation = useResolvedPresentation();
421
+ const theme = useResolvedTheme();
422
+ ```
423
+
424
+ React exposes three hooks:
425
+
426
+ * `useExperiment()` returns the assigned arm (`id`, `arm`, `assignedBy`,
427
+ `acknowledgedDiagnostics`), or `null` while no experiment is configured
428
+ or no arm is assigned yet.
429
+ * `useResolvedPresentation()` returns `presentation` with the assigned
430
+ arm merged over it per surface. While no arm is assigned it returns
431
+ `presentation` itself. The stock banner, dialog and widget render from it,
432
+ as do `usePromptPresentation()` and `usePreferencesPresentation()`.
433
+ * `useResolvedTheme()` returns `theme` with the arm's `theme` merged one
434
+ token group deep over it. While no arm is assigned, or the arm has no
435
+ `theme`, it returns `theme` itself. The provider injects this merged theme.
436
+
437
+ Built-in assignment lands after mount, so `useExperiment()` is `null` on the
438
+ server render and during hydration and the resolved hooks return the base
439
+ values. The banner is held until then, and a clean preferences draft reseeds
440
+ from the arm's `preferences.defaults` when it lands. An
441
+ arm from your flag is known from the first render, on the server too.
442
+
443
+ Vue exposes `useExperiment()`, `useResolvedPresentation()` and
444
+ `useResolvedTheme(theme)`, Svelte `getConsentManager().experiment`,
445
+ `.presentation` and `.theme`, and `@c15t/browser` `client.presentation` and
446
+ `client.theme`. Every adapter also reports the arm on `snapshot.experiment`.
447
+
448
+ ## What is recorded
449
+
450
+ An impression or a choice carries the arm only once the banner has shown it
451
+ in the current page. A returning visitor who reopens the preference center
452
+ from a footer link, or saves from an inline widget, never saw the arm's
453
+ banner, so their choice is recorded without it and does not count toward any
454
+ arm.
455
+
456
+ Each choice saved through `POST /subjects` carries in `metadata`:
457
+
458
+ | Key | Value |
459
+ | ------------------ | -------------------------------------------------------------- |
460
+ | `experiment` | `{ id, arm, assignedBy, acknowledgedDiagnostics }` |
461
+ | `timeToDecisionMs` | Milliseconds from the surface's first impression to the action |
462
+
463
+ `uiSource` on the same record names the surface (`banner`, `dialog`,
464
+ `widget`). The `surface:shown` and `choice:recorded` kernel events and the
465
+ `onSurfaceShown` and `onChoiceRecorded` callbacks carry the same
466
+ `experiment` object, so impressions and decisions can be joined per arm in
467
+ your analytics without a backend query.
468
+
469
+ ## Measure the results
470
+
471
+ An opt-in rate needs two counts per arm: how many visitors the banner was
472
+ owed to, and how many of them accepted. c15t sends both to the backend on
473
+ its own; you do not wire anything up.
474
+
475
+ * **Visitors owed the banner.** While a visitor has no stored choice, every
476
+ `/init` carries their arm in an `x-c15t-experiment: <id>=<arm>` header,
477
+ and the backend puts it on that request's session report as
478
+ `experiment: { id, arm }`. In manifest mode, the server render or the
479
+ init route puts it on the report it sends to `POST /sessions`. A visitor
480
+ who already chose is not shown the banner, so is not counted.
481
+ * **Choices.** Every choice saved through `POST /subjects` carries the arm
482
+ in `metadata.experiment`, as above.
483
+
484
+ On a server-rendered page the server calls `/init`, not the browser, so the
485
+ server has to know the arm. Pass the experiment to `resolveConsent`, as in
486
+ [Vercel Flags SDK](#vercel-flags-sdk): it sends only `{ id, arm }` to the
487
+ backend and hands the full experiment to the client in its state.
488
+ `resolveConsent` takes `experiment` in `c15t/next/server`,
489
+ `c15t/tanstack-start/server` and `@c15t/svelte/server`; Astro and Nuxt pass
490
+ the arm they rendered on their own. When c15t picks the arm in the browser,
491
+ the browser's own `/init` carries it, so a server-rendered page that skips
492
+ the client `/init` has no count for built-in assignment. Resolve the arm with
493
+ a flag on those pages.
494
+
495
+ ### Where the counts go
496
+
497
+ On Inth, the dashboard reads both counts for you. A self-hosted backend
498
+ hands each session report to `sessions.onReport`, which is where you log or
499
+ count it; `experiment` is on the report. Choices are in the `consent` table,
500
+ and [`GET /experiments/:id/summary`](#read-the-summary-from-your-backend)
501
+ groups them per arm.
502
+
503
+ The opt-in rate of an arm is visitors with an `accept_all` choice under that
504
+ arm, divided by visitors whose session reports carry the arm. Count
505
+ visitors, not requests: an undecided visitor sends a report on every page
506
+ until they choose. Deduplicate the reports per visitor the way you count
507
+ sessions.
508
+
509
+ ### Send the events to your own analytics too
510
+
511
+ The `onSurfaceShown` and `onChoiceRecorded` callbacks carry the same
512
+ `experiment` object, so a few lines forward them to any tool. The
513
+ [React example](#vary-presentation-not-policy) pushes both to
514
+ `window.dataLayer` for Google Tag Manager, as `c15t_surface_shown` with the
515
+ surface and `c15t_choice_recorded` with the consent action. To send them to
516
+ PostHog instead, call `posthog.capture()` with the same fields in the
517
+ callbacks. The callbacks run for every impression and choice; check
518
+ `experiment` first, because it is `undefined` for visitors outside the test.
519
+
520
+ An impression fires before any consent exists. If your analytics tool loads
521
+ only after consent, it never sees a decliner's impression, and its opt-in
522
+ rate trends towards 100%. The backend counts above do not have that gap.
523
+
524
+ ### Read the results
525
+
526
+ Consent rates move by a few points, not by half. At a 30% base rate you need
527
+ roughly 2,500 visitors per arm to detect a 5-point change with 80% power,
528
+ and about 10,000 per arm to detect 2 points. Run a sample-size calculator
529
+ against your own base rate before you start, decide the stop date up front,
530
+ and do not stop early on a good-looking day.
531
+
532
+ Under an `opt-out` policy with `prompt: 'notice'`, dismissing the notice
533
+ records no choice, so compare arms on opt-outs instead: `opt_out` choices
534
+ per visitor owed the banner.
535
+
536
+ Time to decision is the median `timeToDecisionMs` per arm over the choices.
537
+ Use the median, not the mean: a visitor who leaves the tab open skews the
538
+ mean without saying anything about the arm.
539
+
540
+ ## Read the summary from your backend
541
+
542
+ The self-hosted backend copies `experiment.id`, `experiment.arm` and
543
+ `timeToDecisionMs` out of `metadata` onto their own columns (migration
544
+ `6-experiment-attribution`), so `GET /experiments/:id/summary` can group
545
+ choices per arm without a JSON query. It needs an API key.
546
+
547
+ ```bash
548
+ curl 'https://app.example.com/api/c15t/experiments/banner-shape/summary?from=2026-09-01&to=2026-09-30' \
549
+ -H "Authorization: Bearer $C15T_API_KEY"
550
+ ```
551
+
552
+ ```json
553
+ {
554
+ "experimentId": "banner-shape",
555
+ "from": "2026-09-01T00:00:00.000Z",
556
+ "to": "2026-09-30T23:59:59.999Z",
557
+ "arms": [
558
+ {
559
+ "arm": "wall",
560
+ "choices": 120,
561
+ "byAction": {
562
+ "accept_all": 80,
563
+ "custom": 10,
564
+ "opt_out": 0,
565
+ "reject_all": 30,
566
+ "unknown": 0
567
+ },
568
+ "bySurface": { "banner": 100, "dialog": 20 },
569
+ "medianTimeToDecisionMs": 4200
570
+ },
571
+ {
572
+ "arm": "control",
573
+ "choices": 95,
574
+ "byAction": {
575
+ "accept_all": 50,
576
+ "custom": 5,
577
+ "opt_out": 0,
578
+ "reject_all": 40,
579
+ "unknown": 0
580
+ },
581
+ "bySurface": { "banner": 90, "dialog": 5 },
582
+ "medianTimeToDecisionMs": 5100
583
+ }
584
+ ]
585
+ }
586
+ ```
587
+
588
+ `byAction` always has all five keys. They are the action the backend
589
+ stored, not the client's `consent_action`: `accept_all` for accept,
590
+ `reject_all` for reject, `opt_out` for a reject under an opt-out policy,
591
+ `custom` for a saved selection, and `unknown` for a record without one.
592
+ `choices` counts consent records, so a visitor who changes their mind under
593
+ the same arm counts twice.
594
+
595
+ `from` and `to` accept an ISO 8601 date or timestamp and filter on the
596
+ consent's `givenAt`, both ends inclusive. A date without a time is the whole
597
+ of that day in UTC: `from=2026-09-01` starts at midnight and `to=2026-09-30`
598
+ runs to the end of the 30th, so the example above covers all of September. A
599
+ `from` later than `to` is a 400. `domain` narrows to one domain name. An
600
+ experiment id no consent carries returns `arms: []`.
601
+
602
+ From server code, the [Node.js SDK](https://c15t.com/docs/self-host/api/node-sdk#read-an-experiment-summary)
603
+ makes the same call. Create the client with an API key; `from` and `to` also
604
+ take a `Date`:
605
+
606
+ ```ts
607
+ const c15t = createC15tClient({
608
+ baseUrl: 'https://app.example.com/api/c15t',
609
+ apiKey,
610
+ });
611
+
612
+ const result = await c15t.experiments.summary('banner-shape', {
613
+ from: '2026-09-01',
614
+ to: '2026-09-30',
615
+ });
616
+ ```
617
+
618
+ The summary counts choices. The other half of an opt-in rate, the visitors
619
+ each arm's banner was owed to, is on the session reports `/init` produces
620
+ (see [Measure the results](#measure-the-results)). Divide `accept_all` per
621
+ arm by the visitors whose reports carry that arm.
622
+
623
+ ## Try it
624
+
625
+ Every example app under `examples/` runs this experiment outside the pages
626
+ the docs publish. Each page shows the assigned arm and the events the
627
+ callbacks sent. Adding `arm=wall` sets the arm the way a flag would, so the
628
+ page shows `assignedBy: host`. Run each command in the example's directory.
629
+
630
+ | Example | Start | Open |
631
+ | --------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
632
+ | Next.js | `bun run dev` | `/experiment`, with the arm from a stand-in flag: `?arm=wall`, `?arm=off` to leave the test, `control` otherwise |
633
+ | TanStack Start | `C15T_EXPERIMENT=1 bun run dev` | `/consent-example?experiment=1`, then add `&arm=wall` |
634
+ | React | `bun run dev` | `/experiment.html`, then add `?arm=wall` |
635
+ | Nuxt | `C15T_NUXT_EXPERIMENT=1 bun run dev`; add `C15T_NUXT_EXPERIMENT_ARM=wall` for the wall arm | `/consent-example` |
636
+ | Vue | `bun run dev` | `/?experiment=1`, then add `&arm=wall` |
637
+ | Astro | `C15T_EXPERIMENT=1 bun run dev` | `/consent-example?experiment=1` runs `control`, as Astro has no built-in assignment; add `&arm=wall` |
638
+ | Svelte | `bun run dev` | `/?experiment=1`, then add `&arm=wall` |
639
+ | SvelteKit | `bun run dev` | `/experiment-example?experiment=1`, then add `&arm=wall` |
640
+ | HTML script tag | `bun run dev` | `/?experiment=1`, then add `&arm=wall` |
641
+ | JavaScript | `bun run dev` | `/experiment/`, then add `?arm=wall` |
642
+
643
+ ## End an experiment
644
+
645
+ Move the winning arm's fragment into `presentation` (and its `theme` into
646
+ `theme`), then remove `experiment`. Visitors keep their consent; the stored
647
+ arm is ignored once no experiment reads it.
648
+
649
+ ## Copy is out of scope
650
+
651
+ Arms change presentation and theme tokens only. Copy is not a variant dimension because
652
+ `copyRevision` is hashed into the prompt fingerprint: a copy change re-prompts
653
+ every returning visitor, so a copy experiment would re-prompt them on each
654
+ arm switch. Vary layout, shape, position and action prominence instead.