@delmaredigital/payload-experiments 0.3.0-rc.1 → 0.3.0-rc.4

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 (124) hide show
  1. package/README.md +74 -16
  2. package/dist/admin/PreviewLinks.js +45 -24
  3. package/dist/admin/TestsNavLink.d.ts +3 -1
  4. package/dist/admin/TestsNavLink.js +2 -2
  5. package/dist/admin/armSelect/ArmSelectField.js +14 -22
  6. package/dist/admin/arms/arms.model.d.ts +1 -21
  7. package/dist/admin/arms/arms.model.js +1 -153
  8. package/dist/admin/copy/arms.d.ts +1 -6
  9. package/dist/admin/copy/arms.js +1 -16
  10. package/dist/admin/copy/readiness.d.ts +4 -1
  11. package/dist/admin/copy/readiness.js +3 -1
  12. package/dist/admin/copy/results.d.ts +2 -0
  13. package/dist/admin/copy/results.js +3 -1
  14. package/dist/admin/copy/review.d.ts +1 -1
  15. package/dist/admin/copy/review.js +1 -1
  16. package/dist/admin/copy/surface.d.ts +0 -1
  17. package/dist/admin/copy/surface.js +0 -1
  18. package/dist/admin/paths.d.ts +2 -1
  19. package/dist/admin/paths.js +2 -1
  20. package/dist/admin/previewLinks.model.d.ts +2 -0
  21. package/dist/admin/previewLinks.model.js +3 -0
  22. package/dist/admin/results/legacy.d.ts +16 -0
  23. package/dist/admin/results/legacy.js +27 -0
  24. package/dist/admin/results/results.model.d.ts +6 -6
  25. package/dist/admin/results/results.model.js +14 -5
  26. package/dist/admin/results/snapshot.d.ts +14 -0
  27. package/dist/admin/results/snapshot.js +3 -0
  28. package/dist/admin/surface/TestsHome.js +6 -9
  29. package/dist/admin/surface/goals/data.js +16 -16
  30. package/dist/admin/surface/home.model.d.ts +1 -0
  31. package/dist/admin/surface/newTest/NewTest.js +4 -4
  32. package/dist/admin/surface/readiness/ActionDialogs.js +4 -5
  33. package/dist/admin/surface/readiness/readiness.model.js +8 -1
  34. package/dist/admin/surface/results/ResultsBody.js +2 -1
  35. package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
  36. package/dist/admin/surface/results/recommendation.model.js +10 -3
  37. package/dist/admin/surface/surface.css +0 -1
  38. package/dist/admin/surface/test/GoalSection.js +7 -15
  39. package/dist/admin/surface/test/testPage.model.d.ts +2 -3
  40. package/dist/admin/surface/test/testPage.model.js +1 -21
  41. package/dist/admin/surface/useTestDocument.js +2 -1
  42. package/dist/admin/useLayerUsage.js +14 -23
  43. package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
  44. package/dist/admin/views/TestsNavLinkServer.js +43 -0
  45. package/dist/arms/copy.d.ts +6 -0
  46. package/dist/arms/copy.js +16 -0
  47. package/dist/arms/model.d.ts +23 -0
  48. package/dist/arms/model.js +173 -0
  49. package/dist/db/appends.d.ts +6 -2
  50. package/dist/db/appends.js +10 -4
  51. package/dist/db/locks.d.ts +6 -0
  52. package/dist/db/locks.js +13 -1
  53. package/dist/db/pageOccupancy.d.ts +12 -0
  54. package/dist/db/pageOccupancy.js +45 -0
  55. package/dist/db/snapshots.js +4 -3
  56. package/dist/exports/adminRsc.d.ts +1 -0
  57. package/dist/exports/adminRsc.js +1 -0
  58. package/dist/exports/index.d.ts +1 -1
  59. package/dist/exports/puck.d.ts +2 -1
  60. package/dist/exports/puck.js +1 -1
  61. package/dist/exports/puckEditor.d.ts +3 -1
  62. package/dist/exports/puckEditor.js +2 -1
  63. package/dist/exports/rsc.d.ts +1 -0
  64. package/dist/hooks/pageGuard.js +21 -3
  65. package/dist/lib/fetchAllDocs.d.ts +16 -0
  66. package/dist/lib/fetchAllDocs.js +56 -0
  67. package/dist/options.js +2 -9
  68. package/dist/plugin.js +4 -0
  69. package/dist/puck/ExperimentPicker.js +47 -16
  70. package/dist/puck/ExperimentSlot.d.ts +51 -1
  71. package/dist/puck/ExperimentSlot.js +23 -12
  72. package/dist/puck/copy.d.ts +5 -4
  73. package/dist/puck/copy.js +5 -4
  74. package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
  75. package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
  76. package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
  77. package/dist/puck/editor/ResultsReadout.d.ts +6 -0
  78. package/dist/puck/editor/ResultsReadout.js +99 -0
  79. package/dist/puck/editor/VersionBuilder.d.ts +34 -0
  80. package/dist/puck/editor/VersionBuilder.js +317 -0
  81. package/dist/puck/editor/armWrites.d.ts +7 -0
  82. package/dist/puck/editor/armWrites.js +71 -0
  83. package/dist/puck/editor/armsCache.d.ts +14 -0
  84. package/dist/puck/editor/armsCache.js +78 -0
  85. package/dist/puck/editor/copy.d.ts +33 -1
  86. package/dist/puck/editor/copy.js +45 -8
  87. package/dist/puck/editor/createTest.d.ts +1 -1
  88. package/dist/puck/editor/createTest.js +25 -14
  89. package/dist/puck/editor/editor.chrome.css +47 -161
  90. package/dist/puck/editor/editor.css +47 -161
  91. package/dist/puck/editor/editor.model.d.ts +4 -1
  92. package/dist/puck/editor/editor.model.js +10 -7
  93. package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
  94. package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
  95. package/dist/puck/experimentSlotField.d.ts +2 -0
  96. package/dist/puck/experimentSlotField.js +11 -3
  97. package/dist/puck/hostContext.d.ts +10 -0
  98. package/dist/puck/hostContext.js +12 -0
  99. package/dist/puck/routes.d.ts +12 -0
  100. package/dist/puck/routes.js +11 -0
  101. package/dist/puck/slotConfig.d.ts +1 -1
  102. package/dist/puck/slotConfig.js +2 -2
  103. package/dist/sanitizeReadinessDetail.js +10 -1
  104. package/dist/server/metadata.js +9 -2
  105. package/dist/server/normaliseContentRelationships.d.ts +7 -0
  106. package/dist/server/normaliseContentRelationships.js +62 -0
  107. package/dist/server/recordContentChange.d.ts +1 -1
  108. package/dist/server/recordContentChange.js +17 -8
  109. package/dist/server/runResults.js +36 -4
  110. package/dist/transitions/checkStart.d.ts +5 -1
  111. package/dist/transitions/checkStart.js +10 -0
  112. package/dist/transitions/decide.js +16 -1
  113. package/dist/transitions/placements.d.ts +2 -6
  114. package/dist/transitions/placements.js +8 -1
  115. package/dist/transitions/preflightData.d.ts +8 -0
  116. package/dist/transitions/preflightData.js +116 -36
  117. package/dist/transitions/start.js +33 -3
  118. package/dist/types/contracts.d.ts +19 -13
  119. package/dist/types/endpoints.d.ts +1 -1
  120. package/dist/ui/Tabs.d.ts +4 -2
  121. package/dist/ui/Tabs.js +33 -26
  122. package/dist/ui/accent.d.ts +22 -3
  123. package/dist/ui/accent.js +74 -16
  124. package/package.json +5 -2
@@ -3,6 +3,8 @@ import { safeError } from '../log.js';
3
3
  import { powerMetricWeeks } from '@delmaredigital/experiments-stats';
4
4
  import { checkStart, relationshipId } from './checkStart.js';
5
5
  import { flagPlacements, scanBlockPlacements } from './placements.js';
6
+ import { routedPageTests } from '../db/pageOccupancy.js';
7
+ import { pooledExecutor, txExecutor } from '../db/executor.js';
6
8
  const failureDetail = (cause)=>{
7
9
  const name = cause instanceof Error ? cause.name : undefined;
8
10
  return cause instanceof Forbidden || name === 'Forbidden' ? 'forbidden' : cause instanceof NotFound || name === 'NotFound' ? 'not-found' : 'placements-failed';
@@ -28,6 +30,30 @@ const failureDetail = (cause)=>{
28
30
  experiment
29
31
  ])).get(String(experiment.id)) ?? null;
30
32
  }
33
+ /** Occupancy from the arms the runtime routes on (frozen rounds), shared by preflight and
34
+ * Start's locked recheck. Raw SQL on the request's transaction when there is one. */ export async function loadPageTestsInUse(req) {
35
+ return routedPageTests(await req.transactionID != null ? txExecutor(req) : pooledExecutor(req.payload));
36
+ }
37
+ /** Page targets no registered collection reports as published. A lookup that throws counts as
38
+ * unpublished (logged). Start calls it again under its page-path locks on a detached request. */ export async function unpublishedPageTargets(req, options, experiment) {
39
+ const targets = await Promise.all(experiment.arms.filter((arm)=>arm.kind !== 'holdout' && arm.pageTarget?.trim()).map(async (arm)=>{
40
+ for (const collection of options.pages?.collections ?? []){
41
+ try {
42
+ const page = await collection.findByPath(arm.pageTarget, req);
43
+ if (page?.published === true) return null;
44
+ } catch (cause) {
45
+ req.payload.logger.error({
46
+ err: safeError(cause),
47
+ experimentId: experiment.id,
48
+ collection: collection.slug,
49
+ id: arm.pageTarget
50
+ }, 'Experiment page lookup failed');
51
+ }
52
+ }
53
+ return arm.pageTarget;
54
+ }));
55
+ return targets.filter((target)=>target !== null);
56
+ }
31
57
  const metricIds = (experiment)=>[
32
58
  experiment.metrics?.primary,
33
59
  ...experiment.metrics?.secondary ?? [],
@@ -58,6 +84,7 @@ const metricIds = (experiment)=>[
58
84
  })();
59
85
  const blockSlugs = new Set(experiments.filter((e)=>e.kind === 'block').map((e)=>e.slug));
60
86
  // Lazy promises prevent unhandled rejections and share failed reads as well as successful ones.
87
+ let pageTestsInUse;
61
88
  let blocks;
62
89
  let state;
63
90
  const busyPoints = new Map();
@@ -84,27 +111,10 @@ const metricIds = (experiment)=>[
84
111
  return failureDetail(cause);
85
112
  };
86
113
  if (experiment.kind === 'page') {
114
+ pageTestsInUse ??= loadPageTestsInUse(req);
115
+ input.pageTestsInUse = await pageTestsInUse;
87
116
  input.pageTargetsUnchecked = options.pages === undefined;
88
- if (options.pages) {
89
- input.unpublishedPageTargets = [];
90
- for (const arm of experiment.arms.filter((arm)=>arm.kind !== 'holdout' && arm.pageTarget?.trim())){
91
- let published = false;
92
- for (const collection of options.pages.collections){
93
- try {
94
- const page = await collection.findByPath(arm.pageTarget, req);
95
- if (page?.published === true) published = true;
96
- } catch (cause) {
97
- req.payload.logger.error({
98
- err: safeError(cause),
99
- experimentId: experiment.id,
100
- collection: collection.slug,
101
- id: arm.pageTarget
102
- }, 'Experiment page lookup failed');
103
- }
104
- }
105
- if (!published) input.unpublishedPageTargets.push(arm.pageTarget);
106
- }
107
- }
117
+ if (options.pages) input.unpublishedPageTargets = await unpublishedPageTargets(req, options, experiment);
108
118
  }
109
119
  if (experiment.kind === 'block') {
110
120
  try {
@@ -248,16 +258,23 @@ const metricIds = (experiment)=>[
248
258
  }
249
259
  return inputs;
250
260
  }
251
- function placementUrls(options, placement) {
261
+ // Bound each optional host preview lookup so it cannot indefinitely hold preflight open.
262
+ const PREVIEW_URL_TIMEOUT_MS = 2000;
263
+ // Resolve host preview lookups in a bounded pool instead of launching one per slot.
264
+ const PREVIEW_URL_CONCURRENCY = 8;
265
+ async function placementUrls(options, placement, pendingPreviews, onFailure, previews) {
252
266
  const result = {};
253
267
  const { collection, id, published } = placement;
254
- try {
255
- const url = options.admin?.previewUrl?.({
256
- collection,
257
- id
258
- });
259
- if (typeof url === 'string') result.url = url;
260
- } catch {}
268
+ // One host lookup per page version: several slots on a page share it.
269
+ const key = JSON.stringify([
270
+ collection,
271
+ String(id),
272
+ published
273
+ ]);
274
+ let preview = previews.get(key);
275
+ if (!preview) previews.set(key, preview = previewLookup(options, placement, pendingPreviews, onFailure));
276
+ const url = await preview;
277
+ if (url !== undefined) result.url = url;
261
278
  try {
262
279
  const url = options.admin?.editorUrl?.({
263
280
  collection,
@@ -268,6 +285,43 @@ function placementUrls(options, placement) {
268
285
  } catch {}
269
286
  return result;
270
287
  }
288
+ async function previewLookup(options, placement, pendingPreviews, onFailure) {
289
+ const { collection, id, title, published, slug } = placement;
290
+ const previewUrl = options.admin?.previewUrl;
291
+ let found;
292
+ if (previewUrl) {
293
+ let timer;
294
+ try {
295
+ if (pendingPreviews.count >= PREVIEW_URL_CONCURRENCY) throw new Error('Preview URL lookups at capacity');
296
+ pendingPreviews.count++;
297
+ // A timeout cannot cancel host work: retain its permit until that work settles.
298
+ const lookup = Promise.resolve().then(()=>previewUrl({
299
+ collection,
300
+ id,
301
+ title,
302
+ published,
303
+ ...slug !== undefined ? {
304
+ slug
305
+ } : {}
306
+ })).finally(()=>{
307
+ pendingPreviews.count--;
308
+ });
309
+ const url = await Promise.race([
310
+ lookup,
311
+ new Promise((_, reject)=>{
312
+ timer = setTimeout(()=>reject(new Error('Preview URL lookup timed out')), PREVIEW_URL_TIMEOUT_MS);
313
+ })
314
+ ]);
315
+ if (typeof url === 'string') found = url;
316
+ else if (url !== null && url !== undefined) throw new TypeError('Invalid preview URL result');
317
+ } catch (cause) {
318
+ onFailure(cause, placement);
319
+ } finally{
320
+ clearTimeout(timer);
321
+ }
322
+ }
323
+ return found;
324
+ }
271
325
  async function primaryHistory(source, now) {
272
326
  if (!source) return null;
273
327
  const from = new Date(now.getTime() - 28 * 86400000);
@@ -378,15 +432,41 @@ export async function preflightData(req, { experimentId, options }, { now = new
378
432
  }
379
433
  }
380
434
  const requiredSlots = e.arms.filter((arm)=>arm.kind !== 'holdout').map((arm)=>arm.slot);
381
- const placements = (input.placements ?? []).map((placement)=>({
435
+ const blocks = input.placements ?? [];
436
+ const placements = new Array(blocks.length);
437
+ let nextPlacement = 0;
438
+ const pendingPreviews = {
439
+ count: 0
440
+ };
441
+ const previews = new Map();
442
+ let loggedPreviewFailure = false;
443
+ const onPreviewFailure = (cause, placement)=>{
444
+ if (loggedPreviewFailure) return;
445
+ loggedPreviewFailure = true;
446
+ req.payload.logger.warn({
447
+ err: safeError(cause),
448
+ experimentId: e.id,
382
449
  collection: placement.collection,
383
- id: placement.id,
384
- title: placement.title,
385
- slots: placement.slots,
386
- ...placementUrls(options, placement),
387
- ok: requiredSlots.every((slot)=>slot && placement.slots[slot] > 0),
388
- detail: placement.published ? 'Published' : 'Draft'
389
- }));
450
+ id: placement.id
451
+ }, 'Experiment preview URL lookup failed');
452
+ };
453
+ await Promise.all(Array.from({
454
+ length: Math.min(PREVIEW_URL_CONCURRENCY, blocks.length)
455
+ }, async ()=>{
456
+ while(nextPlacement < blocks.length){
457
+ const index = nextPlacement++;
458
+ const placement = blocks[index];
459
+ placements[index] = {
460
+ collection: placement.collection,
461
+ id: placement.id,
462
+ title: placement.title,
463
+ slots: placement.slots,
464
+ ...await placementUrls(options, placement, pendingPreviews, onPreviewFailure, previews),
465
+ ok: requiredSlots.every((slot)=>slot && placement.slots[slot] > 0),
466
+ detail: placement.published ? 'Published' : 'Draft'
467
+ };
468
+ }
469
+ }));
390
470
  for (const placement of input.flagPlacements ?? [])placements.push({
391
471
  collection: e.flagPoint ?? '',
392
472
  id: placement.arm,
@@ -1,14 +1,15 @@
1
+ import { lockPagePaths } from '../db/locks.js';
1
2
  import { lockMetricsForFreeze } from '../db/metricUsage.js';
2
3
  import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
3
4
  import { claimSlices } from '../db/slices.js';
4
- import { unpublishedPageCopy } from '../admin/copy/readiness.js';
5
+ import { pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
5
6
  import { XpRefusal } from '../errors.js';
6
7
  import { isOpValid } from '../targeting/ops.js';
7
8
  import { newSeed } from '../runtime/seed.js';
8
- import { checkStart, relationshipId } from './checkStart.js';
9
+ import { checkStart, pageTargetPaths, relationshipId } from './checkStart.js';
9
10
  import { withTransition } from './kernel.js';
10
11
  import { writePhaseRecord } from './phaseRecord.js';
11
- import { loadStartInputs } from './preflightData.js';
12
+ import { loadPageTestsInUse, loadStartInputs, unpublishedPageTargets } from './preflightData.js';
12
13
  import { resolveCaps } from './resolveCaps.js';
13
14
  export async function readExperiment(req, id) {
14
15
  // §7.8: internal lookup serves the authorised transition write. Preserve
@@ -30,6 +31,7 @@ export async function readExperiment(req, id) {
30
31
  }
31
32
  export function refuse(code, detail) {
32
33
  const messages = {
34
+ 'page-in-use': pageInUseCopy(detail?.paths?.[0] ?? 'this page'),
33
35
  'page-target-unpublished': unpublishedPageCopy(typeof detail === 'string' ? detail : 'This page'),
34
36
  'invalid-status': 'This action is not available in the current status.',
35
37
  'stale-draft': 'The experiment changed. Review it again before starting.',
@@ -46,6 +48,7 @@ export function refuse(code, detail) {
46
48
  const conflict = [
47
49
  'invalid-status',
48
50
  'stale-draft',
51
+ 'page-in-use',
49
52
  'flag-point-busy',
50
53
  'layer-full',
51
54
  'allocation-lower',
@@ -107,6 +110,33 @@ export async function start(req, { experimentId, options }) {
107
110
  const current = await readExperiment(req, experimentId);
108
111
  if (current.updatedAt !== updatedAt) refuse('stale-draft');
109
112
  if (current.status !== 'draft' && current.status !== 'stopped') refuse('invalid-status');
113
+ if (current.kind === 'page') {
114
+ // Independent experiment/layer locks do not serialize overlapping pages.
115
+ // Re-read occupancy after acquiring sorted path locks; hold through commit.
116
+ // A waiting Start then sees the first Start's live test on this transaction.
117
+ await lockPagePaths(exec, pageTargetPaths(current.arms));
118
+ const recheck = checkStart({
119
+ ...input,
120
+ experiment: current,
121
+ pageTestsInUse: await loadPageTestsInUse(req)
122
+ }).find((check)=>check.code === 'page-in-use' && !check.ok);
123
+ if (recheck) refuse('page-in-use', recheck.detail);
124
+ if (options.pages) {
125
+ // The page guard takes the same path locks, so a delete, unpublish or rename that committed
126
+ // while Start waited is visible now. Read it on a detached request (committed state, outside
127
+ // this transaction): a refused host read must not kill the transition (CORE-21).
128
+ // (the make-content pattern: drop the transaction id for the reads, restore it after).
129
+ const transactionID = req.transactionID;
130
+ delete req.transactionID;
131
+ let unpublished;
132
+ try {
133
+ unpublished = await unpublishedPageTargets(req, options, current);
134
+ } finally{
135
+ req.transactionID = transactionID;
136
+ }
137
+ if (unpublished.length) refuse('page-target-unpublished', unpublished[0]);
138
+ }
139
+ }
110
140
  if (current.kind === 'flag' && current.flagPoint) {
111
141
  const busy = await req.payload.find({
112
142
  collection: 'xp-experiments',
@@ -3,6 +3,14 @@ import type { ExperimentsServerRuntime } from './runtime.js';
3
3
  import type { ArmSlot } from './puck.js';
4
4
  import type { TargetingOp } from '../targeting/ops.js';
5
5
  export type DocId = string | number;
6
+ export interface PreviewPlacement {
7
+ collection: string;
8
+ id: DocId;
9
+ title: string;
10
+ published: boolean;
11
+ /** The published main row's string slug, including on draft placements; absent for unpublished documents, never a draft/version row's slug. */
12
+ slug?: string;
13
+ }
6
14
  export type Attributes = Record<string, string | number | boolean>;
7
15
  export type ExposurePolicy = 'render' | 'viewport' | 'pageview' | 'manual';
8
16
  export type ExperimentKind = 'block' | 'page' | 'flag' | 'code';
@@ -93,6 +101,15 @@ export type RateLimiter = (args: {
93
101
  export type ExperimentsAccess = (args: {
94
102
  req: PayloadRequest;
95
103
  }) => boolean | Promise<boolean>;
104
+ export interface ExperimentsPageCollection {
105
+ slug: string;
106
+ pathOf: (doc: Record<string, unknown>) => string | null;
107
+ /** Lookup with the supplied req and overrideAccess: false. Under an anonymous req (results checks) only published pages are visible, so a hit is { published: true } or null. Under a req that can read drafts (Start), a draft-only page may be reported as { published: false }. */
108
+ findByPath: (path: string, req: PayloadRequest) => Promise<{
109
+ id: string | number;
110
+ published: boolean;
111
+ } | null>;
112
+ }
96
113
  export interface ExperimentsPluginOptions<TUser extends {
97
114
  id: string | number;
98
115
  } = {
@@ -114,10 +131,7 @@ export interface ExperimentsPluginOptions<TUser extends {
114
131
  id: DocId;
115
132
  draft?: boolean;
116
133
  }) => string;
117
- previewUrl?: (args: {
118
- collection: string;
119
- id: DocId;
120
- }) => string;
134
+ previewUrl?: (placement: PreviewPlacement) => string | null | undefined | Promise<string | null | undefined>;
121
135
  descriptions?: {
122
136
  block?: string;
123
137
  document?: string;
@@ -135,15 +149,7 @@ export interface ExperimentsPluginOptions<TUser extends {
135
149
  values?: string[];
136
150
  }>;
137
151
  pages?: {
138
- collections: Array<{
139
- slug: string;
140
- pathOf: (doc: Record<string, unknown>) => string | null;
141
- /** Published-only lookup using the supplied req and overrideAccess: false. Results checks supply an anonymous req. */
142
- findByPath: (path: string, req: PayloadRequest) => Promise<{
143
- id: string | number;
144
- published: boolean;
145
- } | null>;
146
- }>;
152
+ collections: ExperimentsPageCollection[];
147
153
  };
148
154
  puck?: {
149
155
  enabled: boolean;
@@ -7,7 +7,7 @@ import type { Snapshot, VerdictCode } from './snapshot.js';
7
7
  export type ArmIssueCode = 'weights-total' | 'control-count' | 'control-first' | 'holdout-last' | 'holdout-count' | 'variant-count' | 'duplicate-key' | 'duplicate-slot' | 'slot-required' | 'invalid-key';
8
8
  /** Content-report dismiss/restore refusals (CORE-44). */
9
9
  export type ContentEntryRefusal = 'not-decided' | 'entry-not-found' | 'entry-ok' | 'entry-dismissed' | 'entry-not-dismissed';
10
- export type RefusalCode = ArmIssueCode | 'not-found' | ContentEntryRefusal | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-no-targets' | 'page-target-unpublished' | 'page-user-unit' | 'version-page-unavailable' | 'version-page-unchecked' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'stale-edit' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'analysis-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'block-path-rule' | 'duplicate-metric';
10
+ export type RefusalCode = ArmIssueCode | 'not-found' | ContentEntryRefusal | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-in-use' | 'page-no-targets' | 'page-target-unpublished' | 'page-user-unit' | 'version-page-unavailable' | 'version-page-unchecked' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'stale-edit' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'analysis-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'block-path-rule' | 'duplicate-metric';
11
11
  export type ErrorEnvelope = {
12
12
  errors: Array<{
13
13
  code: RefusalCode;
package/dist/ui/Tabs.d.ts CHANGED
@@ -1,14 +1,16 @@
1
- import type { ReactNode } from 'react';
1
+ import type { ReactNode, Ref } from 'react';
2
2
  import './ui.css';
3
3
  export interface Tab {
4
4
  id: string;
5
5
  label: string;
6
6
  content: ReactNode;
7
+ actions?: ReactNode;
7
8
  disabled?: boolean;
8
9
  }
9
- export declare function Tabs({ items, label, value, onChange }: {
10
+ export declare function Tabs({ items, label, value, onChange, tabListRef }: {
10
11
  items: Tab[];
11
12
  label: string;
12
13
  value?: string;
13
14
  onChange?: (id: string) => void;
15
+ tabListRef?: Ref<HTMLDivElement>;
14
16
  }): import("react").JSX.Element;
package/dist/ui/Tabs.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useState } from 'react';
4
4
  import './ui.css';
5
- export function Tabs({ items, label, value, onChange }) {
5
+ export function Tabs({ items, label, value, onChange, tabListRef }) {
6
6
  const prefix = useId();
7
7
  const [local, setLocal] = useState(()=>items.find((item)=>!item.disabled)?.id);
8
8
  const requested = value ?? local;
@@ -14,31 +14,38 @@ export function Tabs({ items, label, value, onChange }) {
14
14
  return /*#__PURE__*/ _jsxs("div", {
15
15
  className: "xp-ui-tabs",
16
16
  children: [
17
- /*#__PURE__*/ _jsx("div", {
18
- role: "tablist",
19
- "aria-label": label,
20
- className: "xp-ui-tablist",
21
- children: items.map((item)=>/*#__PURE__*/ _jsx("button", {
22
- type: "button",
23
- role: "tab",
24
- id: `${prefix}-tab-${item.id}`,
25
- "aria-controls": `${prefix}-panel-${item.id}`,
26
- "aria-selected": selected === item.id,
27
- tabIndex: selected === item.id ? 0 : -1,
28
- disabled: item.disabled,
29
- className: "xp-ui-tab",
30
- onClick: ()=>select(item.id),
31
- onKeyDown: (event)=>{
32
- const enabled = items.filter((tab)=>!tab.disabled);
33
- const index = enabled.findIndex((tab)=>tab.id === item.id);
34
- const next = event.key === 'ArrowRight' ? enabled[(index + 1) % enabled.length] : event.key === 'ArrowLeft' ? enabled[(index - 1 + enabled.length) % enabled.length] : event.key === 'Home' ? enabled[0] : event.key === 'End' ? enabled.at(-1) : undefined;
35
- if (!next) return;
36
- event.preventDefault();
37
- select(next.id);
38
- event.currentTarget.ownerDocument.getElementById(`${prefix}-tab-${next.id}`)?.focus();
39
- },
40
- children: item.label
41
- }, item.id))
17
+ /*#__PURE__*/ _jsxs("div", {
18
+ ref: tabListRef,
19
+ className: "xp-ui-tabs-header",
20
+ children: [
21
+ /*#__PURE__*/ _jsx("div", {
22
+ role: "tablist",
23
+ "aria-label": label,
24
+ className: "xp-ui-tablist",
25
+ children: items.map((item)=>/*#__PURE__*/ _jsx("button", {
26
+ type: "button",
27
+ role: "tab",
28
+ id: `${prefix}-tab-${item.id}`,
29
+ "aria-controls": `${prefix}-panel-${item.id}`,
30
+ "aria-selected": selected === item.id,
31
+ tabIndex: selected === item.id ? 0 : -1,
32
+ disabled: item.disabled,
33
+ className: "xp-ui-tab",
34
+ onClick: ()=>select(item.id),
35
+ onKeyDown: (event)=>{
36
+ const enabled = items.filter((tab)=>!tab.disabled);
37
+ const index = enabled.findIndex((tab)=>tab.id === item.id);
38
+ const next = event.key === 'ArrowRight' ? enabled[(index + 1) % enabled.length] : event.key === 'ArrowLeft' ? enabled[(index - 1 + enabled.length) % enabled.length] : event.key === 'Home' ? enabled[0] : event.key === 'End' ? enabled.at(-1) : undefined;
39
+ if (!next) return;
40
+ event.preventDefault();
41
+ select(next.id);
42
+ event.currentTarget.ownerDocument.getElementById(`${prefix}-tab-${next.id}`)?.focus();
43
+ },
44
+ children: item.label
45
+ }, item.id))
46
+ }),
47
+ items.find((item)=>item.id === selected)?.actions
48
+ ]
42
49
  }),
43
50
  items.map((item)=>/*#__PURE__*/ _jsx("div", {
44
51
  role: "tabpanel",
@@ -10,12 +10,31 @@ export interface AccentSet {
10
10
  }
11
11
  /** WCAG 2.x relative luminance contrast. */
12
12
  export declare function contrast(a: string, b: string): number;
13
- /** Pure shared host-accent derivation. Reference surfaces match theme.css's Payload
14
- * 3.90.2 white / elevation-50 dark. Dark adjustment increases HSL lightness until
15
- * the quantised hex reaches 3:1. Hover/active move up to 12/24% in the interaction direction, clamped to preserve text contrast; subtle is 8% tint. */
13
+ /** Contrast floors for accent text and focus rings. Black or white always clears them on a real
14
+ * surface, so the fallbacks below are defensive; tests raise the floors to exercise them. */
15
+ type Floors = {
16
+ text: number;
17
+ focus: number;
18
+ };
19
+ /** Pure shared host-accent derivation using theme.css's reference surfaces. */
16
20
  export declare function deriveAccent(input: string): {
17
21
  light: AccentSet;
18
22
  dark: AccentSet;
19
23
  };
24
+ /** Module-only surface injection; not a package API. Dark adjustment raises HSL
25
+ * lightness to 3:1. Interaction steps preserve the foreground and prefer the
26
+ * original direction when both steps reach 1.1:1; subtle remains an 8% tint. */
27
+ export declare function deriveAccentFor(input: string, surfaces: {
28
+ light: string;
29
+ dark: string;
30
+ }, floors?: Floors): {
31
+ light: AccentSet;
32
+ dark: AccentSet;
33
+ };
20
34
  /** Validated hex values only; specificity exceeds theme.css regardless of load order. */
21
35
  export declare function accentStyles(input?: string): string | undefined;
36
+ /** The plugin and Puck factory accept exactly the same host accent. */
37
+ export declare function validateAccent(accent: unknown): void;
38
+ /** Inline variables use the same names as the admin accent stylesheet. */
39
+ export declare function accentVariables(set: AccentSet): Record<string, string>;
40
+ export {};
package/dist/ui/accent.js CHANGED
@@ -38,11 +38,8 @@ function mix(a, b, amount) {
38
38
  }
39
39
  return undefined;
40
40
  }
41
- function set(accent, surface, dark = false) {
42
- const fg = contrast(accent, '#ffffff') >= contrast(accent, '#141414') ? '#ffffff' : '#141414';
43
- // Light fills with dark text lighten on interaction. Dark-theme fills follow
44
- // the default dark tokens and also lighten. Clamp to preserve one foreground.
45
- const target = dark || fg === '#141414' ? '#ffffff' : '#000000';
41
+ const interactionStep = (accent, states)=>Math.min(contrast(accent, states.hover), contrast(states.hover, states.active));
42
+ /** Keep the old 12/24% steps when visible, otherwise search the full direction. */ function interaction(accent, fg, target) {
46
43
  let low = 0, high = 0.24;
47
44
  for(let i = 0; i < 24; i++){
48
45
  const amount = (low + high) / 2;
@@ -50,32 +47,93 @@ function set(accent, surface, dark = false) {
50
47
  else high = amount;
51
48
  }
52
49
  const amount = contrast(mix(accent, target, 0.24), fg) >= 4.5 ? 0.24 : low;
53
- const accentText = againstSurface(accent, surface, 4.5);
50
+ const original = {
51
+ hover: mix(accent, target, amount / 2),
52
+ active: mix(accent, target, amount)
53
+ };
54
+ if (interactionStep(accent, original) >= 1.1) return original;
55
+ const candidates = [
56
+ accent
57
+ ];
58
+ let hover;
59
+ for(let step = 1; step <= 1000; step++){
60
+ const candidate = mix(accent, target, step / 1000);
61
+ if (contrast(candidate, fg) < 4.5) break;
62
+ candidates.push(candidate);
63
+ if (!hover && contrast(accent, candidate) >= 1.1) hover = candidate;
64
+ else if (hover && contrast(hover, candidate) >= 1.1) return {
65
+ hover,
66
+ active: candidate
67
+ };
68
+ }
69
+ // If two full steps cannot fit, maximise the smaller step to the last safe fill.
70
+ const active = candidates[candidates.length - 1];
71
+ return candidates.reduce((best, candidate)=>{
72
+ const next = {
73
+ hover: candidate,
74
+ active
75
+ };
76
+ return interactionStep(accent, next) > interactionStep(accent, best) ? next : best;
77
+ }, {
78
+ hover: accent,
79
+ active
80
+ });
81
+ }
82
+ const FLOORS = {
83
+ text: 4.5,
84
+ focus: 3
85
+ };
86
+ function set(accent, surface, dark = false, floors = FLOORS) {
87
+ const fg = contrast(accent, '#ffffff') >= contrast(accent, '#141414') ? '#ffffff' : '#141414';
88
+ const target = dark || fg === '#141414' ? '#ffffff' : '#000000';
89
+ const preferred = interaction(accent, fg, target);
90
+ const other = interaction(accent, fg, target === '#ffffff' ? '#000000' : '#ffffff');
91
+ const states = interactionStep(accent, preferred) >= 1.1 || interactionStep(accent, preferred) >= interactionStep(accent, other) ? preferred : other;
92
+ const accentText = againstSurface(accent, surface, floors.text);
54
93
  return {
55
94
  accent,
56
- hover: mix(accent, target, amount / 2),
57
- active: mix(accent, target, amount),
95
+ ...states,
58
96
  subtle: mix(surface, accent, 0.08),
59
97
  fg,
60
- focus: againstSurface(accent, surface, 3) ?? 'var(--xp-text)',
98
+ focus: againstSurface(accent, surface, floors.focus) ?? 'var(--xp-text)',
61
99
  accentText: accentText ?? 'var(--xp-text)',
62
100
  accentTextDecoration: accentText ? 'none' : 'underline'
63
101
  };
64
102
  }
65
- /** Pure shared host-accent derivation. Reference surfaces match theme.css's Payload
66
- * 3.90.2 white / elevation-50 dark. Dark adjustment increases HSL lightness until
67
- * the quantised hex reaches 3:1. Hover/active move up to 12/24% in the interaction direction, clamped to preserve text contrast; subtle is 8% tint. */ export function deriveAccent(input) {
103
+ /** Pure shared host-accent derivation using theme.css's reference surfaces. */ export function deriveAccent(input) {
104
+ return deriveAccentFor(input, {
105
+ light: '#ffffff',
106
+ dark: '#222222'
107
+ });
108
+ }
109
+ /** Module-only surface injection; not a package API. Dark adjustment raises HSL
110
+ * lightness to 3:1. Interaction steps preserve the foreground and prefer the
111
+ * original direction when both steps reach 1.1:1; subtle remains an 8% tint. */ export function deriveAccentFor(input, surfaces, floors = FLOORS) {
68
112
  const accent = hexOf(rgb(input));
69
113
  let dark = accent;
70
- for(let step = 1; contrast(dark, '#222222') < 3 && step <= 1000; step++)dark = lighten(accent, step / 1000);
114
+ for(let step = 1; contrast(dark, surfaces.dark) < 3 && step <= 1000; step++)dark = lighten(accent, step / 1000);
71
115
  return {
72
- light: set(accent, '#ffffff'),
73
- dark: set(dark, '#222222', true)
116
+ light: set(accent, surfaces.light, false, floors),
117
+ dark: set(dark, surfaces.dark, true, floors)
74
118
  };
75
119
  }
76
120
  /** Validated hex values only; specificity exceeds theme.css regardless of load order. */ export function accentStyles(input) {
77
121
  if (!input) return undefined;
78
122
  const sets = deriveAccent(input);
79
- const declarations = (s)=>Object.entries(s).map(([name, value])=>`--xp-${name === 'accentText' ? 'accent-text' : name === 'accentTextDecoration' ? 'accent-text-decoration' : name === 'focus' || name === 'accent' ? name : `accent-${name}`}:${value};`).join('');
123
+ const declarations = (s)=>Object.entries(accentVariables(s)).map(([name, value])=>`${name}:${value};`).join('');
80
124
  return `:root:root, [data-theme="light"][data-theme="light"]{${declarations(sets.light)}}\n[data-theme="dark"][data-theme="dark"]{${declarations(sets.dark)}}`;
81
125
  }
126
+ /** The plugin and Puck factory accept exactly the same host accent. */ export function validateAccent(accent) {
127
+ if (accent === undefined || accent === '') return;
128
+ if (typeof accent !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) throw new Error('Experiments theme.accent must be a CSS hex colour (#rgb or #rrggbb)');
129
+ for (const [mode, set] of Object.entries(deriveAccent(accent))){
130
+ const ratio = contrast(set.accent, set.fg);
131
+ if (ratio < 4.5) throw new Error(`Experiments theme.accent ${accent}: ${mode} foreground contrast ${ratio.toFixed(2)}:1 is below 4.5:1`);
132
+ }
133
+ }
134
+ /** Inline variables use the same names as the admin accent stylesheet. */ export function accentVariables(set) {
135
+ return Object.fromEntries(Object.entries(set).map(([name, value])=>[
136
+ `--xp-${name === 'accentText' ? 'accent-text' : name === 'accentTextDecoration' ? 'accent-text-decoration' : name === 'focus' || name === 'accent' ? name : `accent-${name}`}`,
137
+ value
138
+ ]));
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-experiments",
3
- "version": "0.3.0-rc.1",
3
+ "version": "0.3.0-rc.4",
4
4
  "description": "No-code A/B testing for Payload CMS and Next.js: block, page and flag experiments with holdouts, layers, exposure logging and honest statistics inside the admin — no vendor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -68,6 +68,8 @@
68
68
  "default": "./dist/exports/puckEditor.js"
69
69
  },
70
70
  "./puck/editor.css": "./dist/puck/editor/editor.css",
71
+ "./admin/theme.css": "./dist/admin/theme.css",
72
+ "./admin/ui.css": "./dist/ui/ui.css",
71
73
  "./package.json": "./package.json"
72
74
  },
73
75
  "files": [
@@ -135,7 +137,8 @@
135
137
  "@electric-sql/pglite": "^0.5.13",
136
138
  "@puckeditor/core": "^0.24.0",
137
139
  "drizzle-orm": "0.45.2",
138
- "drizzle-kit": "0.31.7"
140
+ "drizzle-kit": "0.31.7",
141
+ "yaml": "2.9.1"
139
142
  },
140
143
  "scripts": {
141
144
  "build": "swc ./src -d ./dist --strip-leading-paths --config-file ../../.swcrc && tsc -p tsconfig.build.json && copyfiles -u 1 \"src/**/*.css\" dist",