@c15t/react 3.0.0-alpha.1 → 3.0.0-alpha.11

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