@c15t/react 3.0.0-alpha.0 → 3.0.0-alpha.10

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