@delmaredigital/payload-experiments 0.4.0-rc.1 → 0.4.0-rc.2

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 (88) hide show
  1. package/README.md +10 -8
  2. package/dist/admin/TestsDashboardCards.d.ts +1 -0
  3. package/dist/admin/TestsDashboardCards.js +4 -8
  4. package/dist/admin/TestsNavLink.d.ts +1 -0
  5. package/dist/admin/TestsNavLink.js +4 -10
  6. package/dist/admin/api.d.ts +7 -0
  7. package/dist/admin/api.js +27 -0
  8. package/dist/admin/blocks/BlockPanel.js +65 -19
  9. package/dist/admin/blocks/formApi.d.ts +3 -0
  10. package/dist/admin/blocks/formApi.js +9 -3
  11. package/dist/admin/blocks/statusCache.d.ts +2 -0
  12. package/dist/admin/blocks/statusCache.js +11 -2
  13. package/dist/admin/copy/blocks.d.ts +16 -0
  14. package/dist/admin/copy/blocks.js +27 -1
  15. package/dist/admin/copy/common.d.ts +1 -0
  16. package/dist/admin/copy/common.js +1 -1
  17. package/dist/admin/copy/pages.d.ts +2 -4
  18. package/dist/admin/copy/pages.js +3 -5
  19. package/dist/admin/copy/readiness.d.ts +1 -0
  20. package/dist/admin/copy/readiness.js +1 -0
  21. package/dist/admin/goals/XpGoalField.d.ts +2 -0
  22. package/dist/admin/goals/XpGoalField.js +51 -35
  23. package/dist/admin/goals/xpGoalField.css +48 -0
  24. package/dist/admin/paths.d.ts +3 -0
  25. package/dist/admin/paths.js +3 -0
  26. package/dist/admin/surface/TestsHome.js +2 -4
  27. package/dist/admin/surface/TestsSurface.js +2 -5
  28. package/dist/admin/surface/goals/GoalForm.js +4 -10
  29. package/dist/admin/surface/goals/GoalsList.js +2 -1
  30. package/dist/admin/surface/home.model.d.ts +2 -2
  31. package/dist/admin/surface/newTest/NewTest.js +2 -4
  32. package/dist/admin/surface/newTest/PageWizard.js +7 -2
  33. package/dist/admin/surface/pagePicker/CopyRoute.d.ts +1 -1
  34. package/dist/admin/surface/pagePicker/{PagePicker.d.ts → PageCombobox.d.ts} +3 -3
  35. package/dist/admin/surface/pagePicker/PageCombobox.js +395 -0
  36. package/dist/admin/surface/pagePicker/pagePicker.css +10 -0
  37. package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +2 -0
  38. package/dist/admin/surface/readiness/readiness.model.js +1 -0
  39. package/dist/admin/surface/results/ResultsHeader.js +3 -8
  40. package/dist/admin/surface/results/useResults.d.ts +10 -0
  41. package/dist/admin/surface/test/GoalSection.js +4 -7
  42. package/dist/admin/surface/test/TestPage.js +3 -8
  43. package/dist/admin/surface/test/pageParts.js +10 -39
  44. package/dist/admin/surface/test/testPage.css +0 -5
  45. package/dist/admin/surface/useTestDocument.d.ts +4 -0
  46. package/dist/admin/surfaceUrls.d.ts +7 -0
  47. package/dist/admin/surfaceUrls.js +19 -0
  48. package/dist/admin/theme.css +2 -0
  49. package/dist/admin/useApi.d.ts +10 -0
  50. package/dist/admin/views/CollectionListRedirect.d.ts +1 -0
  51. package/dist/admin/views/CollectionListRedirect.js +1 -1
  52. package/dist/admin/views/ExperimentsRootView.js +18 -165
  53. package/dist/admin/views/GoalEditRedirect.d.ts +4 -0
  54. package/dist/admin/views/GoalEditRedirect.js +14 -0
  55. package/dist/admin/views/GoalsCollectionListView.d.ts +2 -0
  56. package/dist/admin/views/GoalsCollectionListView.js +4 -0
  57. package/dist/admin/views/TestsCollectionListView.d.ts +2 -0
  58. package/dist/admin/views/TestsCollectionListView.js +4 -0
  59. package/dist/admin/views/TestsNavLinkServer.d.ts +1 -0
  60. package/dist/admin/views/TestsNavLinkServer.js +1 -1
  61. package/dist/admin/views/buildSurfaceProps.d.ts +8 -0
  62. package/dist/admin/views/buildSurfaceProps.js +143 -0
  63. package/dist/admin/views/renderCollectionList.d.ts +2 -0
  64. package/dist/admin/views/renderCollectionList.js +34 -0
  65. package/dist/admin/views/renderTestsSurface.d.ts +3 -0
  66. package/dist/admin/views/renderTestsSurface.js +34 -0
  67. package/dist/collections/experiments/config.js +9 -5
  68. package/dist/collections/metrics.js +16 -4
  69. package/dist/exports/adminRsc.d.ts +3 -0
  70. package/dist/exports/adminRsc.js +3 -0
  71. package/dist/fields/xpGoal.js +2 -14
  72. package/dist/goals/validate.d.ts +3 -0
  73. package/dist/goals/validate.js +14 -0
  74. package/dist/hooks/puckGoals.d.ts +3 -0
  75. package/dist/hooks/puckGoals.js +38 -0
  76. package/dist/plugin.js +12 -18
  77. package/dist/puck/editor/editor.chrome.css +13 -0
  78. package/dist/puck/editor/editor.css +105 -0
  79. package/dist/puck/editor/xpGoalPuckEditorField.js +28 -20
  80. package/dist/sanitizeReadinessDetail.js +2 -0
  81. package/dist/transitions/checkStart.d.ts +2 -1
  82. package/dist/transitions/checkStart.js +3 -1
  83. package/dist/transitions/decide.js +1 -0
  84. package/dist/transitions/start.js +2 -1
  85. package/dist/types/endpoints.d.ts +1 -1
  86. package/dist/ui/ui.css +4 -0
  87. package/package.json +1 -1
  88. package/dist/admin/surface/pagePicker/PagePicker.js +0 -172
@@ -1,5 +1,6 @@
1
+ import { SURFACE_COPY } from '../admin/copy/surface.js';
1
2
  import { CLICK_KEY, isClickSource, validClickSource } from '../goals/key.js';
2
- import { XP_METRIC_SOURCE_SELECT } from '../admin/paths.js';
3
+ import { XP_METRIC_SOURCE_SELECT, XP_GOALS_COLLECTION_LIST, XP_GOAL_EDIT_REDIRECT } from '../admin/paths.js';
3
4
  import { managedAccess } from './access.js';
4
5
  import { serializeMetricSources } from './serialize.js';
5
6
  import { validateCappingType, validateCappingValue } from './metricValidation.js';
@@ -11,14 +12,25 @@ export function metricsCollection(options) {
11
12
  graphQL: false,
12
13
  disableDuplicate: true,
13
14
  versions: false,
14
- // group:false hides navigation while keeping collection routes reachable (hidden causes 404s).
15
15
  labels: {
16
16
  singular: 'Goal',
17
17
  plural: 'Goals'
18
18
  },
19
19
  admin: {
20
- group: false,
21
- useAsTitle: 'name'
20
+ group: SURFACE_COPY.title,
21
+ useAsTitle: 'name',
22
+ components: {
23
+ views: {
24
+ list: {
25
+ Component: XP_GOALS_COLLECTION_LIST
26
+ },
27
+ edit: {
28
+ default: {
29
+ Component: XP_GOAL_EDIT_REDIRECT
30
+ }
31
+ }
32
+ }
33
+ }
22
34
  },
23
35
  access: managedAccess(options),
24
36
  hooks: {
@@ -2,3 +2,6 @@ export { ExperimentsRootView } from '../admin/views/ExperimentsRootView.js';
2
2
  export { CollectionListRedirect } from '../admin/views/CollectionListRedirect.js';
3
3
  export { CollectionEditRedirect } from '../admin/views/CollectionEditRedirect.js';
4
4
  export { TestsNavLinkServer } from '../admin/views/TestsNavLinkServer.js';
5
+ export { TestsCollectionListView } from '../admin/views/TestsCollectionListView.js';
6
+ export { GoalsCollectionListView } from '../admin/views/GoalsCollectionListView.js';
7
+ export { GoalEditRedirect } from '../admin/views/GoalEditRedirect.js';
@@ -2,3 +2,6 @@ export { ExperimentsRootView } from '../admin/views/ExperimentsRootView.js';
2
2
  export { CollectionListRedirect } from '../admin/views/CollectionListRedirect.js';
3
3
  export { CollectionEditRedirect } from '../admin/views/CollectionEditRedirect.js';
4
4
  export { TestsNavLinkServer } from '../admin/views/TestsNavLinkServer.js';
5
+ export { TestsCollectionListView } from '../admin/views/TestsCollectionListView.js';
6
+ export { GoalsCollectionListView } from '../admin/views/GoalsCollectionListView.js';
7
+ export { GoalEditRedirect } from '../admin/views/GoalEditRedirect.js';
@@ -1,8 +1,5 @@
1
1
  import { XP_GOAL_FIELD } from '../admin/paths.js';
2
- import { definedClickKeys } from '../db/clickGoals.js';
3
- import { pooledExecutor } from '../db/executor.js';
4
- import { clickKeys } from '../goals/cache.js';
5
- import { CLICK_KEY } from '../goals/key.js';
2
+ import { validateClickGoal } from '../goals/validate.js';
6
3
  /** The host renders this value as data-xp-goal on its interactive element. */ export function xpGoalField() {
7
4
  return {
8
5
  name: 'xpGoal',
@@ -14,15 +11,6 @@ import { CLICK_KEY } from '../goals/key.js';
14
11
  Field: XP_GOAL_FIELD
15
12
  }
16
13
  },
17
- validate: async (value, { req })=>{
18
- if (!value) return true;
19
- if (!CLICK_KEY.test(value)) return 'Use a click goal short name.';
20
- const options = req.payload.config.custom?.xp;
21
- if (!options || await options.access.manage({
22
- req
23
- }) !== true) return true;
24
- const keys = await clickKeys(req.payload);
25
- return !keys || keys.has(value) || (await definedClickKeys(pooledExecutor(req.payload))).has(value) || 'No click goal has this name yet. Create it with "+ New click goal".';
26
- }
14
+ validate: (value, { req })=>validateClickGoal(value, req)
27
15
  };
28
16
  }
@@ -0,0 +1,3 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ /** Shared publish validation for Payload fields and Puck JSON props. */
3
+ export declare function validateClickGoal(value: string | null | undefined, req: PayloadRequest): Promise<true | string>;
@@ -0,0 +1,14 @@
1
+ import { definedClickKeys } from '../db/clickGoals.js';
2
+ import { pooledExecutor } from '../db/executor.js';
3
+ import { clickKeys } from './cache.js';
4
+ import { CLICK_KEY } from './key.js';
5
+ /** Shared publish validation for Payload fields and Puck JSON props. */ export async function validateClickGoal(value, req) {
6
+ if (!value) return true;
7
+ if (!CLICK_KEY.test(value)) return 'Use a click goal short name.';
8
+ const options = req.payload.config.custom?.xp;
9
+ if (!options || await options.access.manage({
10
+ req
11
+ }) !== true) return true;
12
+ const keys = await clickKeys(req.payload);
13
+ return !keys || keys.has(value) || (await definedClickKeys(pooledExecutor(req.payload))).has(value) || 'No click goal has this name yet. Create it with "+ New click goal".';
14
+ }
@@ -0,0 +1,3 @@
1
+ import { type CollectionBeforeChangeHook } from 'payload';
2
+ /** Validate only the Puck JSON included in a publishing write. */
3
+ export declare function puckGoalsBeforeChange(field: string): CollectionBeforeChangeHook;
@@ -0,0 +1,38 @@
1
+ import { ValidationError } from 'payload';
2
+ import { validateClickGoal } from '../goals/validate.js';
3
+ /** Validate only the Puck JSON included in a publishing write. */ export function puckGoalsBeforeChange(field) {
4
+ return async function validatePuckGoals({ collection, data, originalDoc, req }) {
5
+ const drafts = Boolean(collection.versions && collection.versions.drafts);
6
+ if (drafts && data._status !== 'published') return data;
7
+ // Publishing a stored draft without resending its JSON publishes the draft's JSON.
8
+ let value = data[field] !== undefined ? data[field] : drafts ? originalDoc?.[field] : undefined;
9
+ if (value === undefined) return data;
10
+ if (typeof value === 'string') {
11
+ try {
12
+ value = JSON.parse(value);
13
+ } catch {
14
+ return data;
15
+ }
16
+ }
17
+ const errors = [];
18
+ async function walk(value, path) {
19
+ if (!value || typeof value !== 'object') return;
20
+ for (const [key, prop] of Object.entries(value)){
21
+ const propPath = `${path}.${key}`;
22
+ if (key === 'xpGoal' && typeof prop === 'string') {
23
+ const result = await validateClickGoal(prop, req);
24
+ if (result !== true) errors.push({
25
+ path: propPath,
26
+ message: result
27
+ });
28
+ } else await walk(prop, propPath);
29
+ }
30
+ }
31
+ await walk(value, field);
32
+ if (errors.length) throw new ValidationError({
33
+ collection: collection.slug,
34
+ errors
35
+ });
36
+ return data;
37
+ };
38
+ }
package/dist/plugin.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { buildCollections } from './collections/index.js';
2
2
  import { buildEndpoints } from './endpoints/index.js';
3
3
  import { contentChangesBeforeHook, contentChangesHook } from './hooks/contentChanges.js';
4
+ import { puckGoalsBeforeChange } from './hooks/puckGoals.js';
4
5
  import { pageGuardAfterChange, pageGuardAfterOperation, pageGuardBeforeChange, pageGuardBeforeDelete, pageGuardBeforeOperation } from './hooks/pageGuard.js';
5
6
  import { createOnInit } from './onInit.js';
6
7
  import { puckCollections, resolveOptions } from './options.js';
@@ -23,8 +24,6 @@ export function createExperimentsPlugin(input) {
23
24
  // Erase the user subtype only at this non-generic factory boundary.
24
25
  const { collections, globals } = buildCollections(options);
25
26
  const onInit = createOnInit(options);
26
- const beforeDashboard = config.admin?.components?.beforeDashboard ?? [];
27
- const beforeNavLinks = config.admin?.components?.beforeNavLinks ?? [];
28
27
  return {
29
28
  ...config,
30
29
  admin: {
@@ -34,10 +33,6 @@ export function createExperimentsPlugin(input) {
34
33
  xpTestsSurface: {
35
34
  path: paths.XP_TESTS_SURFACE,
36
35
  type: 'component'
37
- },
38
- xpTestsNav: {
39
- path: paths.XP_TESTS_NAV_CLIENT,
40
- type: 'component'
41
36
  }
42
37
  },
43
38
  components: {
@@ -48,20 +43,16 @@ export function createExperimentsPlugin(input) {
48
43
  Component: paths.XP_TESTS_VIEW,
49
44
  path: '/experiments/:segments*'
50
45
  }
51
- },
52
- beforeDashboard: beforeDashboard.includes(paths.XP_TESTS_DASHBOARD_CARDS) ? beforeDashboard : [
53
- ...beforeDashboard,
54
- paths.XP_TESTS_DASHBOARD_CARDS
55
- ],
56
- beforeNavLinks: beforeNavLinks.includes(paths.XP_TESTS_NAV_LINK) ? beforeNavLinks : [
57
- ...beforeNavLinks,
58
- paths.XP_TESTS_NAV_LINK
59
- ]
46
+ }
60
47
  }
61
48
  },
62
49
  collections: [
50
+ ...[
51
+ 'xp-experiments',
52
+ 'xp-metrics'
53
+ ].map((slug)=>collections.find((collection)=>collection.slug === slug)),
63
54
  ...(config.collections ?? []).map((collection)=>{
64
- const puck = options.puck?.enabled && registrations.some((entry)=>entry.slug === collection.slug);
55
+ const puck = options.puck?.enabled && registrations.find((entry)=>entry.slug === collection.slug);
65
56
  const page = options.pages?.collections.some((entry)=>entry.slug === collection.slug);
66
57
  const blocks = options.blocks?.collections.find((entry)=>entry.slug === collection.slug);
67
58
  if (!puck && !page && !blocks) return collection;
@@ -87,7 +78,10 @@ export function createExperimentsPlugin(input) {
87
78
  beforeChange: [
88
79
  ...collection.hooks?.beforeChange ?? [],
89
80
  pageGuardBeforeChange,
90
- contentChangesBeforeHook
81
+ contentChangesBeforeHook,
82
+ ...puck ? [
83
+ puckGoalsBeforeChange(puck.field)
84
+ ] : []
91
85
  ],
92
86
  beforeOperation: [
93
87
  ...collection.hooks?.beforeOperation ?? [],
@@ -109,7 +103,7 @@ export function createExperimentsPlugin(input) {
109
103
  }
110
104
  };
111
105
  }),
112
- ...collections
106
+ ...collections.filter((collection)=>collection.slug !== 'xp-experiments' && collection.slug !== 'xp-metrics')
113
107
  ],
114
108
  globals: [
115
109
  ...config.globals ?? [],
@@ -120,3 +120,16 @@
120
120
  .xp-slot-readout > .xp-ui-button { justify-self: start; }
121
121
  .xp-slot-editor .xp-ui-tablist { flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
122
122
  .xp-slot-root.xp-puck-field { display: grid; gap: var(--xp-space-3); min-width: 0; }
123
+
124
+ /* Puck's inline field sidebar stays light, including under Payload's dark-mode text overrides. */
125
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-button--secondary { color: var(--xp-text); }
126
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-button--primary { color: var(--xp-accent-fg); }
127
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-error { color: var(--xp-error); }
128
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-help { color: var(--xp-text-2); }
129
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-field label { color: var(--xp-text); }
130
+ [class*="_PuckFields"] .xp-puck-field .xp-ui-button:disabled {
131
+ opacity: 1;
132
+ background: var(--xp-surface-3);
133
+ border-color: var(--xp-border-strong);
134
+ color: var(--xp-text-2);
135
+ }
@@ -55,6 +55,7 @@
55
55
  --xp-space-3: 12px;
56
56
  --xp-space-4: 16px;
57
57
  --xp-space-5: 20px;
58
+ --xp-field-gap: calc((20 / 13) * 1rem);
58
59
  --xp-space-6: 24px;
59
60
  --xp-space-7: 28px;
60
61
  --xp-space-8: 32px;
@@ -141,6 +142,7 @@
141
142
  --xp-space-3: 12px;
142
143
  --xp-space-4: 16px;
143
144
  --xp-space-5: 20px;
145
+ --xp-field-gap: calc((20 / 13) * 1rem);
144
146
  --xp-space-6: 24px;
145
147
  --xp-space-7: 28px;
146
148
  --xp-space-8: 32px;
@@ -170,6 +172,92 @@
170
172
  --xp-font-size-body: 15px;
171
173
  }
172
174
 
175
+ [class*="_PuckFields"] .xp-slot-root {
176
+ --xp-bg: rgb(245, 245, 245);
177
+ --xp-surface: rgb(255, 255, 255);
178
+ --xp-surface-2: rgb(235, 235, 235);
179
+ --xp-surface-3: rgb(221, 221, 221);
180
+ --xp-border: rgb(235, 235, 235);
181
+ --xp-border-strong: rgb(195, 195, 195);
182
+ --xp-text: rgb(34, 34, 34);
183
+ --xp-text-2: rgb(87, 87, 87);
184
+ --xp-text-3: rgb(141, 141, 141);
185
+ --xp-accent: rgb(47, 47, 47);
186
+ --xp-accent-hover: rgb(20, 20, 20);
187
+ --xp-accent-active: rgb(7, 7, 7);
188
+ --xp-accent-subtle: rgb(208, 208, 208);
189
+ --xp-accent-fg: rgb(255, 255, 255);
190
+ --xp-accent-text: rgb(34, 34, 34);
191
+ --xp-accent-text-decoration: none;
192
+ --xp-focus: rgb(47, 47, 47);
193
+ --xp-series-muted: rgb(181, 181, 181);
194
+ --xp-success: #1a6a3a;
195
+ --xp-success-bg: #e8f4ec;
196
+ --xp-success-border: #bddfc9;
197
+ --xp-warning: #8a5a0b;
198
+ --xp-warning-bg: #fbf3e1;
199
+ --xp-warning-border: #ecd6a2;
200
+ --xp-error: #a63030;
201
+ --xp-error-bg: #fbeaea;
202
+ --xp-error-border: #efc3c3;
203
+ --xp-series-a: #2a78d6;
204
+ --xp-series-b: #eb6834;
205
+ --xp-series-c: #8056b3;
206
+ --xp-radius-control: 8px;
207
+ --xp-radius-card: 12px;
208
+ --xp-radius-chip: 6px;
209
+ --xp-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
210
+ --xp-mono: 'SF Mono', Menlo, Consolas, Monaco, monospace;
211
+ --xp-text-display: 600 28px/32px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
212
+ --xp-text-title: 600 20px/28px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
213
+ --xp-text-body: 400 15px/22px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
214
+ --xp-text-small: 400 13px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
215
+ --xp-motion: 120ms;
216
+ --xp-space-half: 2px;
217
+ --xp-space-one-half: 6px;
218
+ --xp-version-share-width: 96px;
219
+ --xp-version-share-width-mobile: 84px;
220
+ --xp-version-state-width: 192px;
221
+ --xp-version-remove-width: 128px;
222
+ --xp-targeting-attribute-width: 150px;
223
+ --xp-targeting-comparison-width: 140px;
224
+ --xp-legend-key-size: 10px;
225
+ --xp-legend-key-radius: 3px;
226
+ --xp-space-1: 4px;
227
+ --xp-space-2: 8px;
228
+ --xp-space-3: 12px;
229
+ --xp-space-4: 16px;
230
+ --xp-space-5: 20px;
231
+ --xp-field-gap: calc((20 / 13) * 1rem);
232
+ --xp-space-6: 24px;
233
+ --xp-space-7: 28px;
234
+ --xp-space-8: 32px;
235
+ --xp-surface-raised: rgb(235, 235, 235);
236
+ --xp-border-control: rgb(195, 195, 195);
237
+ --xp-input-border: rgb(181, 181, 181);
238
+ --xp-border-accent: rgb(154, 154, 154);
239
+ --xp-text-muted: rgb(87, 87, 87);
240
+ --xp-text-strong: rgb(34, 34, 34);
241
+ --xp-text-emphasis: rgb(34, 34, 34);
242
+ --xp-input-bg: rgb(255, 255, 255);
243
+ --xp-overlay: rgba(5, 5, 5, 0.5);
244
+ --xp-success-surface: #e8f4ec;
245
+ --xp-success-text: #1a6a3a;
246
+ --xp-success-text-strong: #1a6a3a;
247
+ --xp-warning-surface: #fbf3e1;
248
+ --xp-warning-text: #8a5a0b;
249
+ --xp-warning-text-strong: #8a5a0b;
250
+ --xp-error-surface: #fbeaea;
251
+ --xp-error-text: #a63030;
252
+ --xp-radius: 8px;
253
+ --xp-radius-large: 12px;
254
+ --xp-dash-card-gap: 12px;
255
+ --xp-dash-card-gap-small: 8px;
256
+ --xp-font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
257
+ --xp-font-size-small: 13px;
258
+ --xp-font-size-body: 15px;
259
+ }
260
+
173
261
  /* Shared primitives: no host palette references, and no global element resets. */
174
262
  .xp-ui-chips, .xp-ui-chips-control, .xp-ui-chips-item, .xp-ui-button, .xp-ui-input, .xp-ui-toggle, .xp-ui-tab, .xp-ui-dialog, .xp-ui-card { box-sizing: border-box; }
175
263
  .xp-ui-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 36px; padding: 7px 14px; border-radius: var(--xp-radius-control); border: 1px solid transparent; font: 500 14px/20px var(--xp-font); color: var(--xp-text); background: transparent; cursor: pointer; white-space: normal; overflow-wrap: anywhere; max-width: 100%; min-width: 0; text-decoration: none; transition: background-color var(--xp-motion), border-color var(--xp-motion), color var(--xp-motion); }
@@ -246,6 +334,10 @@
246
334
  .xp-ui-chips p:not(.xp-ui-error) { margin: 0; color: var(--xp-text-muted); font-size: var(--xp-font-size-small); }
247
335
  /* Quiet actions and breadcrumb links stay neutral; only the selected tab takes the text accent. */
248
336
  .xp-ui-button-group { display: flex; flex-wrap: wrap; gap: 8px; min-width: 0; max-width: 100%; }
337
+ .xp-ui-inline-row { display: flex; flex-wrap: wrap; align-items: end; gap: var(--xp-space-3); min-width: 0; }
338
+ .xp-ui-inline-row > .xp-ui-field { flex: 1 1 auto; min-width: 0; }
339
+ .xp-ui-inline-row > .xp-ui-button { flex: 0 0 auto; }
340
+ .xp-ui-subpanel { display: grid; gap: var(--xp-space-3); border: 1px solid var(--xp-border); border-radius: var(--xp-radius-card); padding: var(--xp-space-4); }
249
341
  @media (prefers-reduced-motion: reduce) {
250
342
  .xp-ui-button, .xp-ui-input, .xp-ui-toggle, .xp-ui-toggle::before, .xp-ui-chips-control { transition: none; }
251
343
  }
@@ -385,3 +477,16 @@
385
477
  .xp-slot-readout > .xp-ui-button { justify-self: start; }
386
478
  .xp-slot-editor .xp-ui-tablist { flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
387
479
  .xp-slot-root.xp-puck-field { display: grid; gap: var(--xp-space-3); min-width: 0; }
480
+
481
+ /* Puck's inline field sidebar stays light, including under Payload's dark-mode text overrides. */
482
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-button--secondary { color: var(--xp-text); }
483
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-button--primary { color: var(--xp-accent-fg); }
484
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-error { color: var(--xp-error); }
485
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-help { color: var(--xp-text-2); }
486
+ [class*="_PuckFields"] .xp-slot-root .xp-ui-field label { color: var(--xp-text); }
487
+ [class*="_PuckFields"] .xp-puck-field .xp-ui-button:disabled {
488
+ opacity: 1;
489
+ background: var(--xp-surface-3);
490
+ border-color: var(--xp-border-strong);
491
+ color: var(--xp-text-2);
492
+ }
@@ -30,22 +30,32 @@ function XpGoalPuckEditor({ value, onChange, api, readOnly }) {
30
30
  }),
31
31
  picker.canManage && !readOnly ? /*#__PURE__*/ _jsxs(_Fragment, {
32
32
  children: [
33
- /*#__PURE__*/ _jsxs(Select, {
34
- id: `${id}-picker`,
35
- label: C.choose,
36
- value: picker.goals.some((goal)=>goal.slug === value) ? value : '',
37
- onChange: (event)=>{
38
- if (event.currentTarget.value) onChange(event.currentTarget.value);
39
- },
33
+ /*#__PURE__*/ _jsxs("div", {
34
+ className: "xp-ui-inline-row",
40
35
  children: [
41
- /*#__PURE__*/ _jsx("option", {
42
- value: "",
43
- children: C.choose
36
+ /*#__PURE__*/ _jsxs(Select, {
37
+ id: `${id}-picker`,
38
+ label: C.choose,
39
+ value: picker.goals.some((goal)=>goal.slug === value) ? value : '',
40
+ onChange: (event)=>{
41
+ if (event.currentTarget.value) onChange(event.currentTarget.value);
42
+ },
43
+ children: [
44
+ /*#__PURE__*/ _jsx("option", {
45
+ value: "",
46
+ children: C.choose
47
+ }),
48
+ picker.goals.map((goal)=>/*#__PURE__*/ _jsx("option", {
49
+ value: goal.slug,
50
+ children: goal.name
51
+ }, goal.id))
52
+ ]
44
53
  }),
45
- picker.goals.map((goal)=>/*#__PURE__*/ _jsx("option", {
46
- value: goal.slug,
47
- children: goal.name
48
- }, goal.id))
54
+ !picker.panel.open ? /*#__PURE__*/ _jsx(Button, {
55
+ type: "button",
56
+ onClick: picker.open,
57
+ children: C.newGoal
58
+ }) : null
49
59
  ]
50
60
  }),
51
61
  picker.undefinedKey(value ?? '') ? /*#__PURE__*/ _jsx("p", {
@@ -53,11 +63,8 @@ function XpGoalPuckEditor({ value, onChange, api, readOnly }) {
53
63
  role: "status",
54
64
  children: C.undefinedKey
55
65
  }) : null,
56
- !picker.panel.open ? /*#__PURE__*/ _jsx(Button, {
57
- type: "button",
58
- onClick: picker.open,
59
- children: C.newGoal
60
- }) : /*#__PURE__*/ _jsxs("section", {
66
+ picker.panel.open ? /*#__PURE__*/ _jsxs("section", {
67
+ className: "xp-ui-subpanel",
61
68
  "aria-labelledby": `${id}-title`,
62
69
  children: [
63
70
  /*#__PURE__*/ _jsx("h3", {
@@ -100,6 +107,7 @@ function XpGoalPuckEditor({ value, onChange, api, readOnly }) {
100
107
  children: [
101
108
  /*#__PURE__*/ _jsx(Button, {
102
109
  type: "button",
110
+ variant: "primary",
103
111
  disabled: picker.panel.pending || !picker.panel.name.trim(),
104
112
  onClick: ()=>void picker.create(),
105
113
  children: C.createGoal
@@ -113,7 +121,7 @@ function XpGoalPuckEditor({ value, onChange, api, readOnly }) {
113
121
  ]
114
122
  })
115
123
  ]
116
- })
124
+ }) : null
117
125
  ]
118
126
  }) : null
119
127
  ]
@@ -63,6 +63,8 @@ const failure = (value)=>value === 'placements-failed' || value === 'forbidden'
63
63
  } : undefined;
64
64
  case 'no-placements':
65
65
  return failure(detail) ? detail : undefined;
66
+ case 'placement-removed':
67
+ return undefined;
66
68
  case 'page-in-use':
67
69
  {
68
70
  if (!object(detail) || !Array.isArray(detail.paths)) return undefined;
@@ -2,13 +2,14 @@ import { type GoalContent } from '../goals/fairness.js';
2
2
  import type { Arm, DocId, ExperimentKind, ExposurePolicy, FlagPoint, MetricSource, ResolvedOptions, Status, TargetingRule, UnitType } from '../types/contracts.js';
3
3
  import type { ArmIssueCode, PreflightResult } from '../types/endpoints.js';
4
4
  import type { BlockPlacement, FlagPlacement } from './placements.js';
5
- export type ReadinessCode = ArmIssueCode | 'goal-in-some-versions' | 'placement-unchecked' | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'placement-unpublished' | 'duplicate-version-row' | 'interacting-tests' | 'version-identical' | 'version-same-address' | 'unknown-collection' | 'page-no-targets' | 'page-in-use' | 'page-target-unpublished' | 'page-targets-unchecked' | 'version-page-unavailable' | 'version-page-unchecked' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'make-content-unsupported' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'block-path-rule' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
5
+ export type ReadinessCode = ArmIssueCode | 'goal-in-some-versions' | 'placement-unchecked' | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'placement-unpublished' | 'duplicate-version-row' | 'interacting-tests' | 'version-identical' | 'version-same-address' | 'unknown-collection' | 'page-no-targets' | 'page-in-use' | 'page-target-unpublished' | 'page-targets-unchecked' | 'version-page-unavailable' | 'version-page-unchecked' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'placement-removed' | 'empty-slot' | 'make-content-unsupported' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'block-path-rule' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
6
6
  export interface StartExperiment {
7
7
  id: DocId;
8
8
  slug: string;
9
9
  status: Status;
10
10
  kind: ExperimentKind;
11
11
  unitType: UnitType;
12
+ phase?: number;
12
13
  pageCollection?: string | null;
13
14
  arms: Arm[];
14
15
  allocation: number;
@@ -114,7 +114,9 @@ function invalidParams(arms, point) {
114
114
  }))
115
115
  });
116
116
  const placements = input.placements ?? [];
117
- add('no-placements', e.kind !== 'block' || !input.placementError && placements.length > 0, input.placementError);
117
+ const placementRemoved = e.kind === 'block' && e.status === 'stopped' && (e.phase ?? 0) >= 1 && placements.length === 0 && input.placementError === undefined;
118
+ add('no-placements', placementRemoved || e.kind !== 'block' || !input.placementError && placements.length > 0, input.placementError);
119
+ add('placement-removed', !placementRemoved);
118
120
  const unpublished = placements.filter((p)=>!p.published && !placements.some((other)=>other.published && other.collection === p.collection && String(other.id) === String(p.id)));
119
121
  add('placement-unpublished', unpublished.length === 0, unpublished.map(placementDetail));
120
122
  const duplicates = placements.filter((p)=>p.published && p.duplicateRows);
@@ -57,6 +57,7 @@ export async function decide(req, { experimentId, winnerArm, note, confirmation
57
57
  ...current,
58
58
  arms: test.arms
59
59
  }, placements);
60
+ if (rescan.placements.length === 0) refuse('placement-removed');
60
61
  await refreshPlacementDocs(exec, experimentId, test.phase, rescan.docs);
61
62
  }
62
63
  if (current.kind === 'page' && current.status === 'stopped') {
@@ -5,7 +5,7 @@ import { lockPagePaths } from '../db/locks.js';
5
5
  import { lockMetricsForFreeze } from '../db/metricUsage.js';
6
6
  import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
7
7
  import { claimSlices } from '../db/slices.js';
8
- import { asymmetricGoalsCopy, sameAddressCopy, interactingTestsCopy, pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
8
+ import { READINESS_COPY, asymmetricGoalsCopy, sameAddressCopy, interactingTestsCopy, pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
9
9
  import { GROUP_LABELS } from '../admin/copy/arms.js';
10
10
  import { XpRefusal } from '../errors.js';
11
11
  import { isOpValid } from '../targeting/ops.js';
@@ -43,6 +43,7 @@ export function refuse(code, detail) {
43
43
  return d ? interactingTestsCopy(d.path, d.status, d.name, d.version) : 'Finish one of the tests first.';
44
44
  })(),
45
45
  'unknown-collection': "That page type isn't set up for tests.",
46
+ 'placement-removed': READINESS_COPY.placementRemoved,
46
47
  'page-in-use': pageInUseCopy(detail?.paths?.[0] ?? 'this page'),
47
48
  'version-same-address': (()=>{
48
49
  const d = detail;
@@ -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 | 'goal-in-some-versions' | 'placement-unchecked' | 'placement-unpublished' | 'duplicate-version-row' | 'interacting-tests' | 'placements-changed' | 'page-busy' | 'wrap-target-missing' | 'version-identical' | 'version-same-address' | 'unknown-collection' | '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';
10
+ export type RefusalCode = ArmIssueCode | 'goal-in-some-versions' | 'placement-unchecked' | 'placement-unpublished' | 'duplicate-version-row' | 'interacting-tests' | 'placements-changed' | 'page-busy' | 'wrap-target-missing' | 'version-identical' | 'version-same-address' | 'unknown-collection' | 'not-found' | ContentEntryRefusal | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'placement-removed' | '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/ui.css CHANGED
@@ -74,6 +74,10 @@
74
74
  .xp-ui-chips p:not(.xp-ui-error) { margin: 0; color: var(--xp-text-muted); font-size: var(--xp-font-size-small); }
75
75
  /* Quiet actions and breadcrumb links stay neutral; only the selected tab takes the text accent. */
76
76
  .xp-ui-button-group { display: flex; flex-wrap: wrap; gap: 8px; min-width: 0; max-width: 100%; }
77
+ .xp-ui-inline-row { display: flex; flex-wrap: wrap; align-items: end; gap: var(--xp-space-3); min-width: 0; }
78
+ .xp-ui-inline-row > .xp-ui-field { flex: 1 1 auto; min-width: 0; }
79
+ .xp-ui-inline-row > .xp-ui-button { flex: 0 0 auto; }
80
+ .xp-ui-subpanel { display: grid; gap: var(--xp-space-3); border: 1px solid var(--xp-border); border-radius: var(--xp-radius-card); padding: var(--xp-space-4); }
77
81
  @media (prefers-reduced-motion: reduce) {
78
82
  .xp-ui-button, .xp-ui-input, .xp-ui-toggle, .xp-ui-toggle::before, .xp-ui-chips-control { transition: none; }
79
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-experiments",
3
- "version": "0.4.0-rc.1",
3
+ "version": "0.4.0-rc.2",
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",