@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.
- package/README.md +74 -16
- package/dist/admin/PreviewLinks.js +45 -24
- package/dist/admin/TestsNavLink.d.ts +3 -1
- package/dist/admin/TestsNavLink.js +2 -2
- package/dist/admin/armSelect/ArmSelectField.js +14 -22
- package/dist/admin/arms/arms.model.d.ts +1 -21
- package/dist/admin/arms/arms.model.js +1 -153
- package/dist/admin/copy/arms.d.ts +1 -6
- package/dist/admin/copy/arms.js +1 -16
- package/dist/admin/copy/readiness.d.ts +4 -1
- package/dist/admin/copy/readiness.js +3 -1
- package/dist/admin/copy/results.d.ts +2 -0
- package/dist/admin/copy/results.js +3 -1
- package/dist/admin/copy/review.d.ts +1 -1
- package/dist/admin/copy/review.js +1 -1
- package/dist/admin/copy/surface.d.ts +0 -1
- package/dist/admin/copy/surface.js +0 -1
- package/dist/admin/paths.d.ts +2 -1
- package/dist/admin/paths.js +2 -1
- package/dist/admin/previewLinks.model.d.ts +2 -0
- package/dist/admin/previewLinks.model.js +3 -0
- package/dist/admin/results/legacy.d.ts +16 -0
- package/dist/admin/results/legacy.js +27 -0
- package/dist/admin/results/results.model.d.ts +6 -6
- package/dist/admin/results/results.model.js +14 -5
- package/dist/admin/results/snapshot.d.ts +14 -0
- package/dist/admin/results/snapshot.js +3 -0
- package/dist/admin/surface/TestsHome.js +6 -9
- package/dist/admin/surface/goals/data.js +16 -16
- package/dist/admin/surface/home.model.d.ts +1 -0
- package/dist/admin/surface/newTest/NewTest.js +4 -4
- package/dist/admin/surface/readiness/ActionDialogs.js +4 -5
- package/dist/admin/surface/readiness/readiness.model.js +8 -1
- package/dist/admin/surface/results/ResultsBody.js +2 -1
- package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
- package/dist/admin/surface/results/recommendation.model.js +10 -3
- package/dist/admin/surface/surface.css +0 -1
- package/dist/admin/surface/test/GoalSection.js +7 -15
- package/dist/admin/surface/test/testPage.model.d.ts +2 -3
- package/dist/admin/surface/test/testPage.model.js +1 -21
- package/dist/admin/surface/useTestDocument.js +2 -1
- package/dist/admin/useLayerUsage.js +14 -23
- package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
- package/dist/admin/views/TestsNavLinkServer.js +43 -0
- package/dist/arms/copy.d.ts +6 -0
- package/dist/arms/copy.js +16 -0
- package/dist/arms/model.d.ts +23 -0
- package/dist/arms/model.js +173 -0
- package/dist/db/appends.d.ts +6 -2
- package/dist/db/appends.js +10 -4
- package/dist/db/locks.d.ts +6 -0
- package/dist/db/locks.js +13 -1
- package/dist/db/pageOccupancy.d.ts +12 -0
- package/dist/db/pageOccupancy.js +45 -0
- package/dist/db/snapshots.js +4 -3
- package/dist/exports/adminRsc.d.ts +1 -0
- package/dist/exports/adminRsc.js +1 -0
- package/dist/exports/index.d.ts +1 -1
- package/dist/exports/puck.d.ts +2 -1
- package/dist/exports/puck.js +1 -1
- package/dist/exports/puckEditor.d.ts +3 -1
- package/dist/exports/puckEditor.js +2 -1
- package/dist/exports/rsc.d.ts +1 -0
- package/dist/hooks/pageGuard.js +21 -3
- package/dist/lib/fetchAllDocs.d.ts +16 -0
- package/dist/lib/fetchAllDocs.js +56 -0
- package/dist/options.js +2 -9
- package/dist/plugin.js +4 -0
- package/dist/puck/ExperimentPicker.js +47 -16
- package/dist/puck/ExperimentSlot.d.ts +51 -1
- package/dist/puck/ExperimentSlot.js +23 -12
- package/dist/puck/copy.d.ts +5 -4
- package/dist/puck/copy.js +5 -4
- package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
- package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
- package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
- package/dist/puck/editor/ResultsReadout.d.ts +6 -0
- package/dist/puck/editor/ResultsReadout.js +99 -0
- package/dist/puck/editor/VersionBuilder.d.ts +34 -0
- package/dist/puck/editor/VersionBuilder.js +317 -0
- package/dist/puck/editor/armWrites.d.ts +7 -0
- package/dist/puck/editor/armWrites.js +71 -0
- package/dist/puck/editor/armsCache.d.ts +14 -0
- package/dist/puck/editor/armsCache.js +78 -0
- package/dist/puck/editor/copy.d.ts +33 -1
- package/dist/puck/editor/copy.js +45 -8
- package/dist/puck/editor/createTest.d.ts +1 -1
- package/dist/puck/editor/createTest.js +25 -14
- package/dist/puck/editor/editor.chrome.css +47 -161
- package/dist/puck/editor/editor.css +47 -161
- package/dist/puck/editor/editor.model.d.ts +4 -1
- package/dist/puck/editor/editor.model.js +10 -7
- package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
- package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
- package/dist/puck/experimentSlotField.d.ts +2 -0
- package/dist/puck/experimentSlotField.js +11 -3
- package/dist/puck/hostContext.d.ts +10 -0
- package/dist/puck/hostContext.js +12 -0
- package/dist/puck/routes.d.ts +12 -0
- package/dist/puck/routes.js +11 -0
- package/dist/puck/slotConfig.d.ts +1 -1
- package/dist/puck/slotConfig.js +2 -2
- package/dist/sanitizeReadinessDetail.js +10 -1
- package/dist/server/metadata.js +9 -2
- package/dist/server/normaliseContentRelationships.d.ts +7 -0
- package/dist/server/normaliseContentRelationships.js +62 -0
- package/dist/server/recordContentChange.d.ts +1 -1
- package/dist/server/recordContentChange.js +17 -8
- package/dist/server/runResults.js +36 -4
- package/dist/transitions/checkStart.d.ts +5 -1
- package/dist/transitions/checkStart.js +10 -0
- package/dist/transitions/decide.js +16 -1
- package/dist/transitions/placements.d.ts +2 -6
- package/dist/transitions/placements.js +8 -1
- package/dist/transitions/preflightData.d.ts +8 -0
- package/dist/transitions/preflightData.js +116 -36
- package/dist/transitions/start.js +33 -3
- package/dist/types/contracts.d.ts +19 -13
- package/dist/types/endpoints.d.ts +1 -1
- package/dist/ui/Tabs.d.ts +4 -2
- package/dist/ui/Tabs.js +33 -26
- package/dist/ui/accent.d.ts +22 -3
- package/dist/ui/accent.js +74 -16
- 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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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?: (
|
|
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:
|
|
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__*/
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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",
|
package/dist/ui/accent.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
active: mix(accent, target, amount),
|
|
95
|
+
...states,
|
|
58
96
|
subtle: mix(surface, accent, 0.08),
|
|
59
97
|
fg,
|
|
60
|
-
focus: againstSurface(accent, surface,
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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,
|
|
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,
|
|
73
|
-
dark: set(dark,
|
|
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])
|
|
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.
|
|
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",
|