@delmaredigital/payload-experiments 0.2.0-rc.7 → 0.3.0-rc.1

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 (267) hide show
  1. package/README.md +129 -11
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +24 -16
  4. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  5. package/dist/admin/actions/ConfirmDialog.js +37 -82
  6. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  7. package/dist/admin/actions/DecideDialog.js +30 -13
  8. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  9. package/dist/admin/actions/MakeContentResult.js +115 -16
  10. package/dist/admin/actions/actions.model.d.ts +2 -4
  11. package/dist/admin/actions/actions.model.js +2 -3
  12. package/dist/admin/allocation.model.js +3 -5
  13. package/dist/admin/api.d.ts +10 -4
  14. package/dist/admin/api.js +11 -7
  15. package/dist/admin/copy/actions.d.ts +39 -3
  16. package/dist/admin/copy/actions.js +40 -4
  17. package/dist/admin/copy/armSelect.js +5 -5
  18. package/dist/admin/copy/audience.d.ts +2 -1
  19. package/dist/admin/copy/audience.js +3 -2
  20. package/dist/admin/copy/common.d.ts +13 -6
  21. package/dist/admin/copy/common.js +14 -8
  22. package/dist/admin/copy/goals.d.ts +21 -6
  23. package/dist/admin/copy/goals.js +27 -6
  24. package/dist/admin/copy/readiness.d.ts +19 -0
  25. package/dist/admin/copy/readiness.js +19 -0
  26. package/dist/admin/copy/recommendation.d.ts +0 -3
  27. package/dist/admin/copy/recommendation.js +0 -3
  28. package/dist/admin/copy/results.d.ts +41 -2
  29. package/dist/admin/copy/results.js +42 -3
  30. package/dist/admin/copy/review.d.ts +1 -1
  31. package/dist/admin/copy/review.js +1 -1
  32. package/dist/admin/copy/setting.d.ts +2 -0
  33. package/dist/admin/copy/setting.js +2 -0
  34. package/dist/admin/copy/surface.d.ts +7 -4
  35. package/dist/admin/copy/surface.js +9 -4
  36. package/dist/admin/copy/testPage.d.ts +10 -0
  37. package/dist/admin/copy/testPage.js +10 -0
  38. package/dist/admin/format/date.d.ts +2 -0
  39. package/dist/admin/format/date.js +10 -0
  40. package/dist/admin/previewLinks.model.d.ts +2 -2
  41. package/dist/admin/previewLinks.model.js +5 -7
  42. package/dist/admin/results/CiChart.js +11 -21
  43. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  44. package/dist/admin/results/DataQualityPanel.js +30 -17
  45. package/dist/admin/results/DataTable.d.ts +16 -0
  46. package/dist/admin/results/DataTable.js +152 -0
  47. package/dist/admin/results/StatTile.d.ts +7 -0
  48. package/dist/admin/results/StatTile.js +22 -0
  49. package/dist/admin/results/VerdictHero.d.ts +13 -0
  50. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  51. package/dist/admin/results/releasePages.model.d.ts +17 -0
  52. package/dist/admin/results/releasePages.model.js +10 -0
  53. package/dist/admin/results/results.css +64 -0
  54. package/dist/admin/results/results.model.d.ts +5 -1
  55. package/dist/admin/results/results.model.js +10 -2
  56. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  57. package/dist/admin/surface/ResultsRoute.js +59 -34
  58. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  59. package/dist/admin/surface/SurfaceShell.js +49 -14
  60. package/dist/admin/surface/TestsHome.d.ts +6 -1
  61. package/dist/admin/surface/TestsHome.js +183 -112
  62. package/dist/admin/surface/TestsSurface.js +56 -16
  63. package/dist/admin/surface/goals/GoalForm.js +341 -308
  64. package/dist/admin/surface/goals/GoalsList.js +123 -89
  65. package/dist/admin/surface/goals/data.d.ts +8 -2
  66. package/dist/admin/surface/goals/data.js +11 -3
  67. package/dist/admin/surface/goals/goals.css +10 -13
  68. package/dist/admin/surface/home.model.d.ts +7 -8
  69. package/dist/admin/surface/home.model.js +23 -12
  70. package/dist/admin/surface/newTest/NewTest.js +5 -5
  71. package/dist/admin/surface/pageBlock/parts.js +31 -26
  72. package/dist/admin/surface/readiness/ActionDialogs.js +43 -14
  73. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  74. package/dist/admin/surface/readiness/Banner.js +58 -33
  75. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  76. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  77. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  78. package/dist/admin/surface/readiness/readiness.model.js +18 -9
  79. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  80. package/dist/admin/surface/readiness/useTransition.js +2 -1
  81. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  82. package/dist/admin/surface/results/ReleasePages.js +116 -0
  83. package/dist/admin/surface/results/ResultsBody.d.ts +11 -1
  84. package/dist/admin/surface/results/ResultsBody.js +276 -105
  85. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  86. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  87. package/dist/admin/surface/results/recommendation.model.js +2 -6
  88. package/dist/admin/surface/results/useResults.d.ts +21 -3
  89. package/dist/admin/surface/results/useResults.js +2 -1
  90. package/dist/admin/surface/setting/parts.js +75 -88
  91. package/dist/admin/surface/setting/setting.css +3 -15
  92. package/dist/admin/surface/surface.css +62 -47
  93. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  94. package/dist/admin/surface/test/GoalSection.js +39 -32
  95. package/dist/admin/surface/test/LengthSection.js +46 -34
  96. package/dist/admin/surface/test/Rail.d.ts +4 -0
  97. package/dist/admin/surface/test/Rail.js +83 -0
  98. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  99. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  100. package/dist/admin/surface/test/Section.d.ts +1 -1
  101. package/dist/admin/surface/test/Section.js +36 -16
  102. package/dist/admin/surface/test/TargetingSection.js +168 -142
  103. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  104. package/dist/admin/surface/test/TestPage.js +146 -68
  105. package/dist/admin/surface/test/VersionsSection.js +151 -120
  106. package/dist/admin/surface/test/fields.d.ts +11 -4
  107. package/dist/admin/surface/test/fields.js +84 -85
  108. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  109. package/dist/admin/surface/test/jumpToSection.js +10 -0
  110. package/dist/admin/surface/test/pageParts.js +9 -3
  111. package/dist/admin/surface/test/testPage.css +81 -14
  112. package/dist/admin/surface/useTestDocument.js +10 -4
  113. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  114. package/dist/admin/targeting/targeting.model.js +3 -8
  115. package/dist/admin/theme.css +115 -36
  116. package/dist/admin/theme.d.ts +38 -0
  117. package/dist/admin/theme.js +38 -1
  118. package/dist/admin/useApi.d.ts +17 -2
  119. package/dist/admin/views/ExperimentsRootView.js +12 -2
  120. package/dist/collections/experiments/config.js +25 -0
  121. package/dist/collections/experiments/lockHook.js +3 -1
  122. package/dist/collections/identities.js +6 -1
  123. package/dist/collections/metrics.js +7 -0
  124. package/dist/collections/metricsGuards.d.ts +9 -0
  125. package/dist/collections/metricsGuards.js +33 -0
  126. package/dist/contentDone.d.ts +4 -0
  127. package/dist/contentDone.js +5 -0
  128. package/dist/db/identities.d.ts +8 -7
  129. package/dist/db/identities.js +9 -6
  130. package/dist/db/metricUsage.d.ts +19 -0
  131. package/dist/db/metricUsage.js +54 -0
  132. package/dist/db/names.d.ts +5 -0
  133. package/dist/db/names.js +5 -0
  134. package/dist/db/publishedContent.d.ts +10 -0
  135. package/dist/db/publishedContent.js +43 -0
  136. package/dist/db/results.d.ts +2 -0
  137. package/dist/db/results.js +6 -2
  138. package/dist/db/snapshots.d.ts +18 -6
  139. package/dist/db/snapshots.js +23 -7
  140. package/dist/db/sweep.js +11 -6
  141. package/dist/endpoints/index.js +19 -1
  142. package/dist/endpoints/results.js +5 -0
  143. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  144. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  145. package/dist/endpoints/summary.js +20 -2
  146. package/dist/endpoints/transitions.d.ts +15 -0
  147. package/dist/endpoints/transitions.js +36 -0
  148. package/dist/errors.d.ts +8 -0
  149. package/dist/errors.js +28 -0
  150. package/dist/exports/index.d.ts +2 -1
  151. package/dist/exports/index.js +1 -0
  152. package/dist/exports/puckEditor.d.ts +1 -0
  153. package/dist/exports/puckEditor.js +1 -0
  154. package/dist/exports/rsc.d.ts +5 -1
  155. package/dist/exports/rsc.js +2 -0
  156. package/dist/hooks/pageGuard.d.ts +5 -0
  157. package/dist/hooks/pageGuard.js +121 -0
  158. package/dist/log.js +6 -3
  159. package/dist/manifest/compile.d.ts +5 -0
  160. package/dist/manifest/compile.js +5 -1
  161. package/dist/options.js +23 -0
  162. package/dist/plugin.js +26 -2
  163. package/dist/puck/copy.d.ts +1 -1
  164. package/dist/puck/copy.js +1 -1
  165. package/dist/puck/editor/editor.chrome.css +235 -0
  166. package/dist/puck/editor/editor.css +241 -43
  167. package/dist/results/aggregate.js +4 -3
  168. package/dist/results/analyse.d.ts +4 -1
  169. package/dist/results/analyse.js +27 -11
  170. package/dist/results/cuped.js +3 -2
  171. package/dist/results/identityCredit.d.ts +15 -7
  172. package/dist/results/identityCredit.js +32 -33
  173. package/dist/results/rolloutUnits.js +2 -1
  174. package/dist/results/snapshot.js +8 -2
  175. package/dist/results/srm.js +2 -1
  176. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  177. package/dist/sanitizeReadinessDetail.js +54 -0
  178. package/dist/server/linkIdentity.d.ts +10 -1
  179. package/dist/server/linkIdentity.js +17 -15
  180. package/dist/server/metadata.d.ts +5 -0
  181. package/dist/server/metadata.js +9 -1
  182. package/dist/server/runResults.d.ts +3 -2
  183. package/dist/server/runResults.js +121 -31
  184. package/dist/targeting/values.d.ts +4 -0
  185. package/dist/targeting/values.js +10 -0
  186. package/dist/transitions/addMetric.js +8 -0
  187. package/dist/transitions/archive.js +2 -3
  188. package/dist/transitions/checkStart.d.ts +6 -3
  189. package/dist/transitions/checkStart.js +15 -0
  190. package/dist/transitions/contentFields.d.ts +7 -0
  191. package/dist/transitions/contentFields.js +20 -0
  192. package/dist/transitions/decide.js +6 -1
  193. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  194. package/dist/transitions/dismissContentEntry.js +45 -0
  195. package/dist/transitions/makeContent.d.ts +5 -2
  196. package/dist/transitions/makeContent.js +217 -118
  197. package/dist/transitions/phaseRecord.d.ts +8 -2
  198. package/dist/transitions/placements.d.ts +1 -0
  199. package/dist/transitions/placements.js +4 -0
  200. package/dist/transitions/preflightData.d.ts +1 -1
  201. package/dist/transitions/preflightData.js +42 -4
  202. package/dist/transitions/releasePages.d.ts +8 -0
  203. package/dist/transitions/releasePages.js +26 -0
  204. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  205. package/dist/transitions/restoreContentEntry.js +47 -0
  206. package/dist/transitions/start.js +30 -5
  207. package/dist/types/contracts.d.ts +14 -0
  208. package/dist/types/endpoints.d.ts +34 -3
  209. package/dist/types/index.d.ts +1 -0
  210. package/dist/types/index.js +1 -0
  211. package/dist/types/reasons.d.ts +3 -0
  212. package/dist/types/reasons.js +1 -0
  213. package/dist/types/snapshot.d.ts +6 -1
  214. package/dist/ui/Banner.d.ts +2 -0
  215. package/dist/ui/Banner.js +2 -0
  216. package/dist/ui/Breadcrumb.d.ts +7 -0
  217. package/dist/ui/Breadcrumb.js +25 -0
  218. package/dist/ui/Button.d.ts +15 -0
  219. package/dist/ui/Button.js +32 -0
  220. package/dist/ui/Card.d.ts +12 -0
  221. package/dist/ui/Card.js +40 -0
  222. package/dist/ui/Checkbox.d.ts +2 -0
  223. package/dist/ui/Checkbox.js +2 -0
  224. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  225. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  226. package/dist/ui/Dialog.d.ts +13 -0
  227. package/dist/ui/Dialog.js +85 -0
  228. package/dist/ui/EmptyState.d.ts +2 -0
  229. package/dist/ui/EmptyState.js +2 -0
  230. package/dist/ui/Feedback.d.ts +24 -0
  231. package/dist/ui/Feedback.js +40 -0
  232. package/dist/ui/Inputs.d.ts +34 -0
  233. package/dist/ui/Inputs.js +225 -0
  234. package/dist/ui/NumberInput.d.ts +2 -0
  235. package/dist/ui/NumberInput.js +2 -0
  236. package/dist/ui/Range.d.ts +2 -0
  237. package/dist/ui/Range.js +2 -0
  238. package/dist/ui/SearchInput.d.ts +2 -0
  239. package/dist/ui/SearchInput.js +2 -0
  240. package/dist/ui/SectionCard.d.ts +2 -0
  241. package/dist/ui/SectionCard.js +2 -0
  242. package/dist/ui/Select.d.ts +2 -0
  243. package/dist/ui/Select.js +2 -0
  244. package/dist/ui/StatusChip.d.ts +2 -0
  245. package/dist/ui/StatusChip.js +2 -0
  246. package/dist/ui/Tabs.d.ts +14 -0
  247. package/dist/ui/Tabs.js +54 -0
  248. package/dist/ui/TextInput.d.ts +2 -0
  249. package/dist/ui/TextInput.js +2 -0
  250. package/dist/ui/Textarea.d.ts +2 -0
  251. package/dist/ui/Textarea.js +2 -0
  252. package/dist/ui/Toggle.d.ts +2 -0
  253. package/dist/ui/Toggle.js +2 -0
  254. package/dist/ui/accent.d.ts +21 -0
  255. package/dist/ui/accent.js +81 -0
  256. package/dist/ui/index.d.ts +9 -0
  257. package/dist/ui/index.js +9 -0
  258. package/dist/ui/ui.css +92 -0
  259. package/package.json +1 -1
  260. package/dist/admin/components/chipsInput.css +0 -11
  261. package/dist/admin/results/ArmTable.d.ts +0 -8
  262. package/dist/admin/results/ArmTable.js +0 -113
  263. package/dist/admin/results/VerdictCard.d.ts +0 -9
  264. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  265. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  266. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  267. package/dist/admin/surface/test/TestHeader.js +0 -70
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @delmaredigital/payload-experiments
2
2
 
3
+ > ⚠️ Upgrading to 0.3? For 0.3.0-rc.1, every `linkIdentity` call must supply `verified`; it now returns an outcome. Run `payload migrate:create` for the nullable `xp_identities.verified_at` column and `user_id` index, plus the release-pages columns listed in the CHANGELOG (`decision_pages_released_at` / `decision_pages_released_by` on `xp_experiments` and their `version_` counterparts on `_xp_experiments_v`). Existing identity links become unverified; see the CHANGELOG for optional backfill.
4
+
3
5
  > ⚠️ Upgrading to 0.2? After upgrading, run `payload generate:importmap`: the Tests surface is registered through admin.dependencies, and without it the Tests routes show a 'not installed' message instead of the Tests screen. In 0.2.0-rc.2, the Tests server views moved from `/rsc` to `/admin/rsc`, so the import map must be regenerated when upgrading from rc.1 too. The old experiments edit-form components are gone from `/admin/client`, and the `xp-experiments` admin list and edit views now redirect to the Tests surface.
4
6
 
5
7
  A/B testing for Payload CMS and Next.js, with results inside the admin and data
@@ -87,7 +89,7 @@ permission is granted only for `true`. Optional `access.admin` and
87
89
  `access.readResults` default to `manage`; `access.readIdentities` defaults to
88
90
  `admin`. Use `admin` for code experiments and changing the analysis method.
89
91
  Other plugin options are `flagPoints`, `metricSources`, `eventSources`,
90
- `attributeKeys`, `puck` and `events`:
92
+ `attributeKeys`, `pages`, `puck` and `events`:
91
93
 
92
94
  - `attributeKeys` declares targeting attributes as `{ type, values? }`, with
93
95
  `type` one of `string`, `number`, `boolean`; optional string `values` supplies
@@ -261,11 +263,8 @@ Other server integrations:
261
263
  outside a Next request; a user-only server conversion can supply `userId`.
262
264
  Record revenue through trusted server code or a metric source, never a client
263
265
  beacon.
264
- - `linkIdentity(serverRuntime, { headers, userId })` belongs on **every**
265
- user-creation and sign-in path. It links the signed anonymous cookie to the
266
- opaque user id using insert-ignore on its own connection and never throws into
267
- sign-in. For visitor experiments using user-keyed outcomes, credit goes to the
268
- earliest-exposed linked visitor; cross-group links are reported separately.
266
+ - `linkIdentity(serverRuntime, { headers, userId, verified })` belongs on every
267
+ user-creation and proving sign-in path; see Identity linking below.
269
268
  - `eraseUser(payload, { userId })` deletes the user's identities and associated
270
269
  events transactionally and returns `{ identities, events }` counts. Call it
271
270
  from the host's authorized user-erasure flow.
@@ -279,11 +278,72 @@ Other server integrations:
279
278
  failure is logged and the content save proceeds.
280
279
  - `experimentMetadata(serverRuntime, path)` returns metadata to merge into the
281
280
  page's Next metadata: live variant targets get noindex and an Original
282
- canonical; archived losing paths point canonically to the winner.
281
+ canonical; archived losing paths point canonically to the winner. It reads
282
+ the signed request context via `headers()`, so it decides from the URL the
283
+ visitor requested, not the rewritten route, and the Original's URL is never
284
+ noindex. Call it only from a dynamic `generateMetadata`, never inside
285
+ `'use cache'`; `path` is the fallback when the proxy did not run.
283
286
  `experimentSitemapFilter(path, manifest)` is a synchronous build-time filter:
284
287
  pass a privately server-loaded manifest and keep paths for which it returns
285
288
  `true`, excluding live variants and archived losing paths.
286
289
 
290
+ ## Identity linking
291
+
292
+ Call `linkIdentity(serverRuntime, { headers, userId, verified })` on every
293
+ user-creation and proving sign-in path. It links the signed anonymous cookie
294
+ to an opaque user id on its own connection and never throws into sign-in.
295
+ `verified` is required. Pass `true` only when **this request proves, in this
296
+ browser, that the person owns the account**: a magic-link or one-time-code
297
+ callback, password sign-in, or OAuth callback. It does not mean "a session
298
+ exists" or "the account's email is verified". Registration alone uses `false`.
299
+
300
+ ```ts
301
+ // Registration, before proof of account ownership:
302
+ await linkIdentity(serverRuntime, { headers, userId, verified: false })
303
+ // After successful magic-link/code verification in this browser:
304
+ await linkIdentity(serverRuntime, { headers, userId, verified: true })
305
+ // After a successful password sign-in (or OAuth callback):
306
+ await linkIdentity(serverRuntime, { headers, userId, verified: true })
307
+ ```
308
+
309
+ An auth "after" hook that fires whenever a new session cookie is set also
310
+ fires on routine session refreshes; pass `verified: true` only from proving
311
+ sign-in paths, never from a refresh.
312
+
313
+ The result is `'linked'` for an inserted link, `'verified'` for an upgraded
314
+ unverified link, `'skipped'` for no consent, no valid unit cookie or an existing
315
+ link with nothing to change, and `'failed'` for an error (safely logged).
316
+ First link per browser wins; a different account never replaces it.
317
+
318
+ For visitor experiments with user-keyed events, verified links are always
319
+ eligible. Unverified links are eligible only before the first verification
320
+ across all of that user's browsers, or indefinitely if nobody ever verifies.
321
+ Each event goes to the earliest exposed eligible visitor. Eligible links
322
+ spanning groups are reported as a diagnostic. User-keyed CUPED covariates use
323
+ the same eligibility rule at each visitor's first exposure and credit one
324
+ aggregate value to the earliest eligible visitor per user.
325
+
326
+ Accepted limits:
327
+
328
+ - Cross-device: register in A, verify on B, keep using A. A's events after B's
329
+ proof are not credited until a proving sign-in on A upgrades A. Events before
330
+ B's proof, including the registration funnel, remain credited. Once A is
331
+ upgraded, its link is verified and eligible for all its events, so the next
332
+ results run also credits A's events from between B's proof and the upgrade.
333
+ - Password set before verification: a later password sign-in is proof the
334
+ plugin accepts. Whether unverified registration may set a password is a host
335
+ authentication concern.
336
+ - Shared device: a different user's verified sign-in never relinks an already
337
+ linked browser and returns `'skipped'`; that user's events get no credit
338
+ through that browser.
339
+ - If someone registers another person's email from their own browser and that
340
+ person never signs in, a later user-keyed event the host records against that
341
+ account (for example an offline gift) is credited to the registering browser.
342
+ It needs a deliberate registration of someone else's email plus an event
343
+ matched to that account, and it moves one unit's value, never assignment.
344
+ There is no time window, so recurring gifts from never-signed-in donors keep
345
+ counting.
346
+
287
347
  ## Results
288
348
 
289
349
  The host owns scheduling and authentication. Call
@@ -368,7 +428,8 @@ a user-keyed source. Visitor experiments can use a user-keyed source through
368
428
  linked identities.
369
429
 
370
430
  Optional `covariate({ units: [{ unitId, firstExposureTs }], lookbackDays })`
371
- returns `[{ unitId, value }]` for pre-exposure CUPED adjustment (the plugin asks for a 14-day lookback). Percentile
431
+ returns `[{ unitId, value }]`, one aggregate value per unit for pre-exposure
432
+ CUPED adjustment (the plugin asks for a 14-day lookback). Percentile
372
433
  revenue capping reuses `query` over the 90 days before Start; the resolved cap
373
434
  is frozen for the round. Capping affects decision statistics, while results
374
435
  also show uncapped totals. Count and binomial metrics are never capped.
@@ -389,6 +450,41 @@ are recorded with `trackConversion`. Beacon conversions for a non-writable name
389
450
  are dropped silently. Only listed names become `events:<name>` sources. They
390
451
  support visitor and user units and do not represent counts or revenue in v1.
391
452
 
453
+ ### Whole-page options
454
+
455
+ | Option | Purpose |
456
+ | --- | --- |
457
+ | `pages.collections` | Optional array of `{ slug, pathOf, findByPath }`. `pathOf(doc)` derives a public path from the slug (used to protect live test paths); `findByPath(path, req)` resolves a published page to `{ id, published: true }`, or returns `null`. Start and readiness check every non-holdout version, including the Original. Registered collections must exist when this plugin runs. Localized publication status and field-level `beforeChange` hooks that rewrite paths after the collection guard are unsupported; derive paths from fields finalized before collection hooks. Without this option, readiness warns that pages are unchecked and Start keeps its existing behaviour. |
458
+
459
+ Do not swallow errors from nested updates of guarded page collections inside host hooks: a swallowed error leaves the guard's operation frame in place for the rest of the request because Payload's Local API does not call `afterError`.
460
+
461
+ For a collection with drafts enabled, use a published-only slug lookup with the
462
+ manager's request and `overrideAccess: false`:
463
+
464
+ ```ts
465
+ pages: {
466
+ collections: [{
467
+ slug: 'pages',
468
+ pathOf: doc => typeof doc.slug === 'string' ? `/${doc.slug}` : null,
469
+ findByPath: async (path, req) => {
470
+ const { docs } = await req.payload.find({
471
+ collection: 'pages', req, overrideAccess: false, draft: false, depth: 0, limit: 1,
472
+ where: { and: [
473
+ { slug: { equals: path.slice(1) } },
474
+ { _status: { equals: 'published' } },
475
+ ] },
476
+ })
477
+ return docs[0] ? { id: docs[0].id, published: true } : null
478
+ },
479
+ }],
480
+ },
481
+ ```
482
+
483
+ Adapt both callbacks for the site's homepage path. These checks run before Start;
484
+ they do not protect pages from later deletion, unpublishing or renaming. Keep an
485
+ archived winner published at the same address: archived test URLs still point to it.
486
+ No migration is needed.
487
+
392
488
  ### `admin` options
393
489
 
394
490
  Host links and kind descriptions are optional. For a host using payload-puck's
@@ -632,10 +728,32 @@ changes block actions**; save first so they use the saved experiment.
632
728
  - The Puck picker lists 100 experiments.
633
729
  - The Puck experiment picker always calls `/api`; hosts with a custom API route
634
730
  see an empty picker.
635
- - Making a winner permanent skips pages in collections that are localized,
636
- have hidden fields, or contain fields the manager cannot read or update. Those pages
637
- are listed with a note to update them by hand.
638
731
  - Content-change markers are recorded on publish only (on save for collections
639
732
  without drafts).
640
733
  - Results cover the whole round; breakdowns by segment or dimension are not
641
734
  available in v1.
735
+
736
+ ### Limits
737
+
738
+ | Capability | Limit |
739
+ |---|---|
740
+ | Make it the content: localized collections | not supported yet |
741
+ | Make it the content: field defaults | An update-denied field empty in both versions may publish its default because Payload applies defaults when reading. |
742
+
743
+ ## Theme
744
+
745
+ The admin inherits Payload's neutral palette by default. Set one host accent:
746
+
747
+ ```ts
748
+ theme: { accent: '#1f3a68' }
749
+ ```
750
+
751
+ `theme.accent` accepts `#rgb` or `#rrggbb`; empty or undefined uses the default.
752
+ The plugin derives darker hover/active fills (clamped to keep text contrast), a
753
+ contrasting focus ring, and an opaque 8% subtle tint.
754
+ In dark mode it raises HSL lightness when needed to reach 3:1 against the
755
+ reference dark surface, then selects white or near-black foreground text.
756
+ The WCAG 2.x gate requires at least 4.5:1 **foreground text on the accent fill**
757
+ in each theme (the accent is used as a fill, not as page text). A failing
758
+ colour is refused with its ratio. Status chips retain Payload semantic colours.
759
+ `deriveAccent` is also exported from `/puck/editor` for editor integrations.
@@ -1,6 +1,7 @@
1
1
  import type { DocId } from '../types/contracts.js';
2
2
  import type { PreflightResult } from '../types/endpoints.js';
3
- export declare function PreviewLinks({ experimentId, placements, arms }: {
3
+ export declare function PreviewLinks({ experimentId, placements, arms, flagPoint }: {
4
+ flagPoint?: string;
4
5
  experimentId: DocId;
5
6
  placements: PreflightResult['placements'];
6
7
  arms: readonly {
@@ -1,13 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useRef, useState } from 'react';
4
- import { Button } from '@payloadcms/ui';
4
+ import { Button } from '../ui/Button.js';
5
5
  import { armLabel } from './labels.js';
6
6
  import { useApi } from './useApi.js';
7
7
  import { REVIEW_COPY as C } from './copy/review.js';
8
8
  import { API_MESSAGES } from './copy/common.js';
9
9
  import { previewPages } from './previewLinks.model.js';
10
- export function PreviewLinks({ experimentId, placements, arms }) {
10
+ export function PreviewLinks({ experimentId, placements, arms, flagPoint }) {
11
11
  const api = useApi();
12
12
  const inFlight = useRef(false);
13
13
  const [busy, setBusy] = useState(false);
@@ -40,7 +40,8 @@ export function PreviewLinks({ experimentId, placements, arms }) {
40
40
  return /*#__PURE__*/ _jsxs("div", {
41
41
  className: "xp-preview-links",
42
42
  children: [
43
- previewPages(placements).map((placement)=>/*#__PURE__*/ _jsxs("div", {
43
+ previewPages(placements, flagPoint).map((placement)=>/*#__PURE__*/ _jsxs("div", {
44
+ className: "xp-preview-row",
44
45
  children: [
45
46
  /*#__PURE__*/ _jsxs("p", {
46
47
  children: [
@@ -48,19 +49,26 @@ export function PreviewLinks({ experimentId, placements, arms }) {
48
49
  placement.slotCount > 1 ? ` ${C.placedTimes(placement.slotCount)}` : null
49
50
  ]
50
51
  }),
51
- placement.url ? arms.filter((arm)=>arm.kind !== 'holdout' && arm.key !== 'holdout').map((arm)=>/*#__PURE__*/ _jsxs(Button, {
52
- disabled: busy,
53
- onClick: ()=>{
54
- void preview(placement.url, arm.key);
55
- },
56
- children: [
57
- C.preview,
58
- " ",
59
- arm.label?.trim() || armLabel(arm.key)
60
- ]
61
- }, arm.key)) : /*#__PURE__*/ _jsx(Button, {
62
- disabled: true,
63
- children: C.noPreview
52
+ /*#__PURE__*/ _jsx("div", {
53
+ className: "xp-ui-button-group",
54
+ children: placement.url ? arms.filter((arm)=>arm.kind !== 'holdout' && arm.key !== 'holdout').map((arm)=>/*#__PURE__*/ _jsxs(Button, {
55
+ variant: "secondary",
56
+ size: "sm",
57
+ disabled: busy,
58
+ onClick: ()=>{
59
+ void preview(placement.url, arm.key);
60
+ },
61
+ children: [
62
+ C.preview,
63
+ " ",
64
+ arm.label?.trim() || armLabel(arm.key)
65
+ ]
66
+ }, arm.key)) : /*#__PURE__*/ _jsx(Button, {
67
+ variant: "secondary",
68
+ size: "sm",
69
+ disabled: true,
70
+ children: C.noPreview
71
+ })
64
72
  })
65
73
  ]
66
74
  }, placement.key)),
@@ -1,13 +1,16 @@
1
- import '../theme.css';
2
1
  import type { ReactNode } from 'react';
3
2
  import type { DocId } from '../../types/contracts.js';
4
3
  import type { ExperimentAction } from './actions.model.js';
5
- export declare function DialogFrame({ title, onClose, children }: {
4
+ export declare function DialogFrame({ title, onClose, children, initialFocus, dismissible }: {
5
+ dismissible?: boolean;
6
+ initialFocus?: 'cancel';
6
7
  title: string;
7
8
  onClose: () => void;
8
9
  children: ReactNode;
9
- }): import("react").ReactPortal;
10
- export declare function ConfirmDialog({ id, action, winnerLabel, onClose }: {
10
+ }): import("react").JSX.Element;
11
+ export declare function ConfirmDialog({ id, action, winnerLabel, onClose, canManage, isDecided }: {
12
+ canManage?: boolean;
13
+ isDecided?: boolean;
11
14
  id: DocId;
12
15
  action: ExperimentAction;
13
16
  winnerLabel: string;
@@ -1,99 +1,45 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import '../theme.css';
4
- import { theme } from '../theme.js';
5
- import { useEffect, useId, useRef, useState } from 'react';
6
- import { createPortal } from 'react-dom';
7
- import { Button, TextInput } from '@payloadcms/ui';
3
+ import { useRef, useState } from 'react';
4
+ import { Button } from '../../ui/Button.js';
5
+ import { TextInput } from '../../ui/Inputs.js';
6
+ import { Dialog } from '../../ui/Dialog.js';
8
7
  import { expectedConfirm, matchesConfirm } from '../../transitions/confirmPhrases.js';
9
8
  import { useApi } from '../useApi.js';
10
9
  import { reloadPage } from '../reload.js';
11
10
  import { ACTIONS_COPY as C } from '../copy/actions.js';
12
11
  import { API_MESSAGES } from '../copy/common.js';
13
12
  import { MakeContentResult } from './MakeContentResult.js';
14
- export function DialogFrame({ title, onClose, children }) {
15
- const heading = useId();
16
- const ref = useRef(null);
17
- useEffect(()=>{
18
- const previous = document.activeElement;
19
- ref.current?.focus();
20
- return ()=>{
21
- if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
22
- };
23
- }, []);
24
- return /*#__PURE__*/ createPortal(/*#__PURE__*/ _jsx("div", {
25
- style: {
26
- position: 'fixed',
27
- inset: 0,
28
- zIndex: 1000,
29
- background: theme.color.overlay,
30
- display: 'grid',
31
- placeItems: 'center'
32
- },
33
- children: /*#__PURE__*/ _jsxs("div", {
34
- ref: ref,
35
- role: "dialog",
36
- "aria-modal": "true",
37
- "aria-labelledby": heading,
38
- tabIndex: -1,
39
- style: {
40
- background: theme.color.bg,
41
- color: theme.color.text,
42
- border: `1px solid ${theme.color.borderStrong}`,
43
- padding: '2rem',
44
- width: 'min(36rem, 90vw)',
45
- maxHeight: '90vh',
46
- overflow: 'auto',
47
- borderRadius: theme.radius.small
48
- },
49
- onKeyDown: (event)=>{
50
- if (event.defaultPrevented) return;
51
- if (event.key === 'Escape') {
52
- event.preventDefault();
53
- event.stopPropagation();
54
- onClose();
55
- }
56
- if (event.key === 'Tab') {
57
- const items = Array.from(ref.current?.querySelectorAll('button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex="0"]') ?? []);
58
- const first = items[0], last = items.at(-1);
59
- if (!first) {
60
- event.preventDefault();
61
- ref.current?.focus();
62
- } else if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) {
63
- event.preventDefault();
64
- last?.focus();
65
- } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === ref.current)) {
66
- event.preventDefault();
67
- first.focus();
68
- }
69
- }
70
- },
71
- children: [
72
- /*#__PURE__*/ _jsx("h2", {
73
- id: heading,
74
- children: title
75
- }),
76
- children
77
- ]
78
- })
79
- }), document.body);
13
+ export function DialogFrame({ title, onClose, children, initialFocus, dismissible = true }) {
14
+ return /*#__PURE__*/ _jsx(Dialog, {
15
+ open: true,
16
+ dismissible: dismissible,
17
+ initialFocus: initialFocus,
18
+ title: title,
19
+ onClose: onClose,
20
+ children: children
21
+ });
80
22
  }
81
- export function ConfirmDialog({ id, action, winnerLabel, onClose }) {
23
+ export function ConfirmDialog({ id, action, winnerLabel, onClose, canManage = false, isDecided = false }) {
82
24
  const api = useApi();
83
25
  const [typed, setTyped] = useState('');
84
26
  const [busy, setBusy] = useState(false);
27
+ const [reportBusy, setReportBusy] = useState(false);
85
28
  const inFlight = useRef(false);
86
29
  const [error, setError] = useState(null);
87
30
  const [content, setContent] = useState(null);
88
31
  const phrase = action.confirm.phrase ? expectedConfirm(action.confirm.phrase.action, winnerLabel) : null;
89
32
  const enabled = !busy && (!phrase || matchesConfirm(phrase, typed));
90
- async function submit() {
33
+ async function submit(finish = false) {
91
34
  if (inFlight.current || !enabled) return;
92
35
  inFlight.current = true;
93
36
  setBusy(true);
94
37
  setError(null);
95
38
  let result;
96
- if (action.archive) result = await api.archive(id, {
39
+ if (finish) result = await api.archive(id, {
40
+ mode: 'after-content'
41
+ });
42
+ else if (action.archive) result = await api.archive(id, {
97
43
  ...action.archive,
98
44
  ...phrase ? {
99
45
  confirmation: typed.trim()
@@ -112,7 +58,7 @@ export function ConfirmDialog({ id, action, winnerLabel, onClose }) {
112
58
  }
113
59
  result = response;
114
60
  }
115
- if (result.ok && action.id !== 'make-content') {
61
+ if (result.ok && (finish || action.id !== 'make-content')) {
116
62
  reloadPage();
117
63
  return;
118
64
  }
@@ -126,18 +72,27 @@ export function ConfirmDialog({ id, action, winnerLabel, onClose }) {
126
72
  });
127
73
  }
128
74
  function close() {
129
- if (busy) return;
75
+ if (busy || reportBusy) return;
130
76
  if (content) reloadPage();
131
77
  onClose();
132
78
  }
133
79
  return /*#__PURE__*/ _jsxs(DialogFrame, {
80
+ dismissible: !busy && !reportBusy,
81
+ initialFocus: action.id === 'start' ? undefined : 'cancel',
134
82
  title: action.confirm.title,
135
83
  onClose: close,
136
84
  children: [
137
85
  content ? /*#__PURE__*/ _jsx(MakeContentResult, {
138
86
  result: content,
139
- onRetry: submit,
140
- busy: busy
87
+ id: id,
88
+ api: api,
89
+ canManage: canManage,
90
+ isDecided: content.doc?.status ? content.doc.status === 'decided' : isDecided,
91
+ onRetry: ()=>submit(),
92
+ onFinish: ()=>submit(true),
93
+ busy: busy,
94
+ onBusyChange: setReportBusy,
95
+ onActionStart: ()=>setError(null)
141
96
  }) : /*#__PURE__*/ _jsxs(_Fragment, {
142
97
  children: [
143
98
  /*#__PURE__*/ _jsx("p", {
@@ -155,8 +110,7 @@ export function ConfirmDialog({ id, action, winnerLabel, onClose }) {
155
110
  ]
156
111
  }),
157
112
  /*#__PURE__*/ _jsx(TextInput, {
158
- path: "xp-confirmation",
159
- hasMany: false,
113
+ id: "xp-confirmation",
160
114
  label: C.confirmation,
161
115
  value: typed,
162
116
  readOnly: busy,
@@ -177,7 +131,7 @@ export function ConfirmDialog({ id, action, winnerLabel, onClose }) {
177
131
  },
178
132
  children: action.label
179
133
  }),
180
- error?.retryable ? /*#__PURE__*/ _jsx(Button, {
134
+ error?.retryable && !content ? /*#__PURE__*/ _jsx(Button, {
181
135
  disabled: !enabled,
182
136
  onClick: ()=>{
183
137
  void submit();
@@ -185,7 +139,8 @@ export function ConfirmDialog({ id, action, winnerLabel, onClose }) {
185
139
  children: C.retry
186
140
  }) : null,
187
141
  /*#__PURE__*/ _jsx(Button, {
188
- disabled: busy,
142
+ "data-dialog-cancel": true,
143
+ disabled: busy || reportBusy,
189
144
  onClick: close,
190
145
  children: content ? C.done : C.cancel
191
146
  })
@@ -1,7 +1,9 @@
1
1
  import type { DocId } from '../../types/contracts.js';
2
+ import type { PreflightResult } from '../../types/endpoints.js';
2
3
  import type { ActionGroup } from './actions.model.js';
3
- export declare function DecideDialog({ id, arms, onClose }: {
4
+ export declare function DecideDialog({ id, arms, checks, onClose }: {
4
5
  id: DocId;
5
6
  arms: readonly ActionGroup[];
7
+ checks?: PreflightResult['checks'];
6
8
  onClose: () => void;
7
9
  }): import("react").JSX.Element;
@@ -1,13 +1,16 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import { Button, SelectInput, TextareaInput, TextInput } from '@payloadcms/ui';
4
+ import { Button } from '../../ui/Button.js';
5
+ import { Select, Textarea, TextInput } from '../../ui/Inputs.js';
6
+ import { unsupportedContentCopy } from '../copy/readiness.js';
5
7
  import { useApi } from '../useApi.js';
6
8
  import { reloadPage } from '../reload.js';
9
+ import { RESULTS_COPY } from '../copy/results.js';
7
10
  import { ACTIONS_COPY as C, decideCopy } from '../copy/actions.js';
8
11
  import { decideState } from './decide.model.js';
9
12
  import { DialogFrame } from './ConfirmDialog.js';
10
- export function DecideDialog({ id, arms, onClose }) {
13
+ export function DecideDialog({ id, arms, checks = [], onClose }) {
11
14
  const api = useApi();
12
15
  const [snapshot, setSnapshot] = useState(null);
13
16
  const [chosenArm, setChosenArm] = useState(null);
@@ -85,6 +88,8 @@ export function DecideDialog({ id, arms, onClose }) {
85
88
  const enabled = !busy && !loading && !loadFailed && state.canSubmit;
86
89
  const label = state.options.find((option)=>option.value === chosenArm)?.label;
87
90
  return /*#__PURE__*/ _jsxs(DialogFrame, {
91
+ dismissible: !busy,
92
+ initialFocus: "cancel",
88
93
  title: C.decide,
89
94
  onClose: ()=>{
90
95
  if (!busy) onClose();
@@ -94,27 +99,39 @@ export function DecideDialog({ id, arms, onClose }) {
94
99
  role: "status",
95
100
  children: C.loading
96
101
  }) : null,
97
- /*#__PURE__*/ _jsx(SelectInput, {
98
- path: "xp-winner",
102
+ /*#__PURE__*/ _jsxs(Select, {
103
+ id: "xp-winner",
99
104
  name: "xp-winner",
100
105
  label: C.group,
101
- options: state.options,
102
106
  value: chosenArm ?? '',
103
- readOnly: busy || loading,
107
+ disabled: busy || loading,
104
108
  required: true,
105
- onChange: (option)=>{
106
- setChosenArm(option && !Array.isArray(option) ? String(option.value) : null);
109
+ onChange: (event)=>{
110
+ setChosenArm(event.target.value || null);
107
111
  setTyped('');
108
- }
112
+ },
113
+ children: [
114
+ /*#__PURE__*/ _jsx("option", {
115
+ value: "",
116
+ children: RESULTS_COPY.chooseGroup
117
+ }),
118
+ state.options.map((option)=>/*#__PURE__*/ _jsx("option", {
119
+ value: option.value,
120
+ children: option.label
121
+ }, option.value))
122
+ ]
109
123
  }),
110
124
  label ? /*#__PURE__*/ _jsx("p", {
111
125
  children: decideCopy(label, arms.some((group)=>group.key === 'holdout'), chosenArm === 'control')
112
126
  }) : null,
127
+ checks.filter((check)=>check.code === 'make-content-unsupported' && check.warning).map((check)=>/*#__PURE__*/ _jsx("p", {
128
+ children: unsupportedContentCopy(typeof check.detail === 'string' ? check.detail : 'This page')
129
+ }, check.code)),
113
130
  state.reason ? /*#__PURE__*/ _jsx("p", {
114
131
  children: state.reason
115
132
  }) : null,
116
- /*#__PURE__*/ _jsx(TextareaInput, {
117
- path: "xp-note",
133
+ /*#__PURE__*/ _jsx(Textarea, {
134
+ id: "xp-note",
118
135
  label: C.note,
119
136
  value: note,
120
137
  required: state.guarded,
@@ -133,8 +150,7 @@ export function DecideDialog({ id, arms, onClose }) {
133
150
  ]
134
151
  }),
135
152
  /*#__PURE__*/ _jsx(TextInput, {
136
- path: "xp-decide-confirmation",
137
- hasMany: false,
153
+ id: "xp-decide-confirmation",
138
154
  label: C.confirmation,
139
155
  value: typed,
140
156
  readOnly: busy,
@@ -164,6 +180,7 @@ export function DecideDialog({ id, arms, onClose }) {
164
180
  children: C.retry
165
181
  }) : null,
166
182
  /*#__PURE__*/ _jsx(Button, {
183
+ "data-dialog-cancel": true,
167
184
  disabled: busy,
168
185
  onClick: onClose,
169
186
  children: C.cancel
@@ -1,6 +1,17 @@
1
+ import type { DocId } from '../../types/contracts.js';
1
2
  import type { MakeContentResult as Result } from '../../types/endpoints.js';
2
- export declare function MakeContentResult({ result, onRetry, busy }: {
3
+ import type { createApi } from '../api.js';
4
+ type Props = {
3
5
  result: Result;
4
6
  onRetry: () => void | Promise<void>;
5
7
  busy?: boolean;
6
- }): import("react").JSX.Element;
8
+ id?: DocId;
9
+ api?: ReturnType<typeof createApi>;
10
+ canManage?: boolean;
11
+ isDecided?: boolean;
12
+ onFinish?: () => void | Promise<void>;
13
+ onBusyChange?: (busy: boolean) => void;
14
+ onActionStart?: () => void;
15
+ };
16
+ export declare function MakeContentResult({ result, onRetry, busy, id, api, canManage, isDecided, onFinish, onBusyChange, onActionStart }: Props): import("react").JSX.Element;
17
+ export {};