@delmaredigital/payload-experiments 0.2.0-rc.3 → 0.2.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 (65) hide show
  1. package/README.md +15 -0
  2. package/dist/admin/TestsNavLink.d.ts +1 -0
  3. package/dist/admin/TestsNavLink.js +1 -0
  4. package/dist/admin/actions/ConfirmDialog.d.ts +1 -0
  5. package/dist/admin/actions/ConfirmDialog.js +7 -5
  6. package/dist/admin/components/ChipsInput.d.ts +12 -0
  7. package/dist/admin/components/ChipsInput.js +96 -0
  8. package/dist/admin/components/RootViewMessage.d.ts +5 -0
  9. package/dist/admin/components/RootViewMessage.js +15 -0
  10. package/dist/admin/components/chipsInput.css +11 -0
  11. package/dist/admin/copy/audience.d.ts +4 -0
  12. package/dist/admin/copy/audience.js +4 -0
  13. package/dist/admin/copy/goals.d.ts +44 -0
  14. package/dist/admin/copy/goals.js +46 -0
  15. package/dist/admin/results/ArmTable.js +2 -1
  16. package/dist/admin/results/CappedCell.js +2 -1
  17. package/dist/admin/results/CiChart.js +8 -7
  18. package/dist/admin/results/DataQualityPanel.js +3 -2
  19. package/dist/admin/results/VerdictCard.js +3 -2
  20. package/dist/admin/styles.css +8 -8
  21. package/dist/admin/styles.d.ts +1 -0
  22. package/dist/admin/styles.js +1 -0
  23. package/dist/admin/surface/SurfaceShell.d.ts +1 -0
  24. package/dist/admin/surface/SurfaceShell.js +1 -0
  25. package/dist/admin/surface/TestsHome.js +1 -1
  26. package/dist/admin/surface/TestsSurface.js +10 -1
  27. package/dist/admin/surface/goals/GoalForm.d.ts +7 -0
  28. package/dist/admin/surface/goals/GoalForm.js +470 -0
  29. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +5 -0
  30. package/dist/admin/surface/goals/GoalsBreadcrumb.js +47 -0
  31. package/dist/admin/surface/goals/GoalsList.d.ts +6 -0
  32. package/dist/admin/surface/goals/GoalsList.js +135 -0
  33. package/dist/admin/surface/goals/data.d.ts +3 -0
  34. package/dist/admin/surface/goals/data.js +24 -0
  35. package/dist/admin/surface/goals/goals.css +15 -0
  36. package/dist/admin/surface/goals/goals.model.d.ts +30 -0
  37. package/dist/admin/surface/goals/goals.model.js +26 -0
  38. package/dist/admin/surface/newTest/newTest.css +6 -6
  39. package/dist/admin/surface/readiness/Banner.js +5 -4
  40. package/dist/admin/surface/results/ResultsBody.js +3 -2
  41. package/dist/admin/surface/setting/setting.css +11 -11
  42. package/dist/admin/surface/surface.css +29 -29
  43. package/dist/admin/surface/test/GoalSection.js +32 -1
  44. package/dist/admin/surface/test/TargetingSection.js +27 -10
  45. package/dist/admin/surface/test/VersionsSection.js +6 -5
  46. package/dist/admin/surface/test/testPage.css +12 -12
  47. package/dist/admin/targeting/targeting.model.d.ts +6 -1
  48. package/dist/admin/targeting/targeting.model.js +18 -2
  49. package/dist/admin/theme.css +42 -0
  50. package/dist/admin/theme.d.ts +50 -0
  51. package/dist/admin/theme.js +49 -0
  52. package/dist/admin/views/ExperimentsRootView.js +18 -18
  53. package/dist/collections/metricValidation.d.ts +3 -0
  54. package/dist/collections/metricValidation.js +9 -0
  55. package/dist/collections/metrics.js +3 -5
  56. package/dist/plugin.js +2 -2
  57. package/dist/puck/editor/CreateExperimentDrawer.d.ts +1 -0
  58. package/dist/puck/editor/CreateExperimentDrawer.js +34 -25
  59. package/dist/puck/editor/ExperimentSlotEditor.d.ts +1 -0
  60. package/dist/puck/editor/ExperimentSlotEditor.js +137 -78
  61. package/dist/puck/editor/copy.d.ts +3 -1
  62. package/dist/puck/editor/copy.js +3 -1
  63. package/dist/puck/editor/editor.css +302 -0
  64. package/dist/types/surface.d.ts +2 -1
  65. package/package.json +2 -1
@@ -0,0 +1,50 @@
1
+ /** every Payload token reference lives in theme.css; porting to Payload 4 edits that file only */
2
+ export declare const theme: {
3
+ readonly color: {
4
+ readonly bg: "var(--xp-bg)";
5
+ readonly text: "var(--xp-text)";
6
+ readonly surface: "var(--xp-surface)";
7
+ readonly surfaceRaised: "var(--xp-surface-raised)";
8
+ readonly border: "var(--xp-border)";
9
+ readonly borderStrong: "var(--xp-border-strong)";
10
+ readonly borderControl: "var(--xp-border-control)";
11
+ readonly inputBorder: "var(--xp-input-border)";
12
+ readonly borderAccent: "var(--xp-border-accent)";
13
+ readonly accent: "var(--xp-accent)";
14
+ readonly textMuted: "var(--xp-text-muted)";
15
+ readonly textStrong: "var(--xp-text-strong)";
16
+ readonly textEmphasis: "var(--xp-text-emphasis)";
17
+ readonly inputBg: "var(--xp-input-bg)";
18
+ readonly overlay: "var(--xp-overlay)";
19
+ readonly successSurface: "var(--xp-success-surface)";
20
+ readonly successBg: "var(--xp-success-bg)";
21
+ readonly success: "var(--xp-success)";
22
+ readonly successText: "var(--xp-success-text)";
23
+ readonly successTextStrong: "var(--xp-success-text-strong)";
24
+ readonly warningSurface: "var(--xp-warning-surface)";
25
+ readonly warningBg: "var(--xp-warning-bg)";
26
+ readonly warning: "var(--xp-warning)";
27
+ readonly warningText: "var(--xp-warning-text)";
28
+ readonly warningTextStrong: "var(--xp-warning-text-strong)";
29
+ readonly errorSurface: "var(--xp-error-surface)";
30
+ readonly error: "var(--xp-error)";
31
+ readonly errorText: "var(--xp-error-text)";
32
+ };
33
+ readonly radius: {
34
+ readonly small: "var(--xp-radius)";
35
+ readonly large: "var(--xp-radius-large)";
36
+ };
37
+ readonly space: {
38
+ readonly 1: "var(--xp-space-1)";
39
+ readonly 2: "var(--xp-space-2)";
40
+ readonly 3: "var(--xp-space-3)";
41
+ readonly 4: "var(--xp-space-4)";
42
+ readonly 5: "var(--xp-space-5)";
43
+ readonly 6: "var(--xp-space-6)";
44
+ };
45
+ readonly font: {
46
+ readonly body: "var(--xp-font-body)";
47
+ readonly small: "var(--xp-font-size-small)";
48
+ readonly size: "var(--xp-font-size-body)";
49
+ };
50
+ };
@@ -0,0 +1,49 @@
1
+ /** every Payload token reference lives in theme.css; porting to Payload 4 edits that file only */ export const theme = {
2
+ color: {
3
+ bg: 'var(--xp-bg)',
4
+ text: 'var(--xp-text)',
5
+ surface: 'var(--xp-surface)',
6
+ surfaceRaised: 'var(--xp-surface-raised)',
7
+ border: 'var(--xp-border)',
8
+ borderStrong: 'var(--xp-border-strong)',
9
+ borderControl: 'var(--xp-border-control)',
10
+ inputBorder: 'var(--xp-input-border)',
11
+ borderAccent: 'var(--xp-border-accent)',
12
+ accent: 'var(--xp-accent)',
13
+ textMuted: 'var(--xp-text-muted)',
14
+ textStrong: 'var(--xp-text-strong)',
15
+ textEmphasis: 'var(--xp-text-emphasis)',
16
+ inputBg: 'var(--xp-input-bg)',
17
+ overlay: 'var(--xp-overlay)',
18
+ successSurface: 'var(--xp-success-surface)',
19
+ successBg: 'var(--xp-success-bg)',
20
+ success: 'var(--xp-success)',
21
+ successText: 'var(--xp-success-text)',
22
+ successTextStrong: 'var(--xp-success-text-strong)',
23
+ warningSurface: 'var(--xp-warning-surface)',
24
+ warningBg: 'var(--xp-warning-bg)',
25
+ warning: 'var(--xp-warning)',
26
+ warningText: 'var(--xp-warning-text)',
27
+ warningTextStrong: 'var(--xp-warning-text-strong)',
28
+ errorSurface: 'var(--xp-error-surface)',
29
+ error: 'var(--xp-error)',
30
+ errorText: 'var(--xp-error-text)'
31
+ },
32
+ radius: {
33
+ small: 'var(--xp-radius)',
34
+ large: 'var(--xp-radius-large)'
35
+ },
36
+ space: {
37
+ 1: 'var(--xp-space-1)',
38
+ 2: 'var(--xp-space-2)',
39
+ 3: 'var(--xp-space-3)',
40
+ 4: 'var(--xp-space-4)',
41
+ 5: 'var(--xp-space-5)',
42
+ 6: 'var(--xp-space-6)'
43
+ },
44
+ font: {
45
+ body: 'var(--xp-font-body)',
46
+ small: 'var(--xp-font-size-small)',
47
+ size: 'var(--xp-font-size-body)'
48
+ }
49
+ };
@@ -18,7 +18,11 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
18
18
  let id;
19
19
  if (tail.length === 0) route = 'home';
20
20
  else if (tail.length === 1 && tail[0] === 'new') route = 'new';
21
- else if (tail.length === 1) {
21
+ else if (tail.length === 1 && tail[0] === 'goals') route = 'goals';
22
+ else if (tail.length === 2 && tail[0] === 'goals') {
23
+ route = tail[1] === 'new' ? 'goalNew' : 'goal';
24
+ if (route === 'goal') id = tail[1];
25
+ } else if (tail.length === 1) {
22
26
  route = 'test';
23
27
  id = tail[0];
24
28
  } else if (tail.length === 2 && tail[1] === 'results') {
@@ -41,6 +45,8 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
41
45
  path: `${login}?redirect=${encodeURIComponent(currentPath)}`
42
46
  }));
43
47
  }
48
+ // Keep the server export importable in plain Node; Next loads the CSS-bearing client boundary when this view renders.
49
+ const { RootViewMessage } = await import('../components/RootViewMessage.js');
44
50
  const options = xpOptionsFromReq(req);
45
51
  async function permits(access) {
46
52
  try {
@@ -60,12 +66,7 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
60
66
  canManage,
61
67
  canReadResults
62
68
  }) !== 'ok') {
63
- return /*#__PURE__*/ _jsx("div", {
64
- style: {
65
- padding: 'var(--base)',
66
- color: 'var(--theme-text)',
67
- background: 'var(--theme-bg)'
68
- },
69
+ return /*#__PURE__*/ _jsx(RootViewMessage, {
69
70
  children: SURFACE_COPY.noAccess
70
71
  });
71
72
  }
@@ -131,7 +132,11 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
131
132
  }
132
133
  }))).flat() : undefined;
133
134
  const descriptions = options.admin?.descriptions;
135
+ const returnToTest = (await searchParams)?.return;
134
136
  const clientProps = {
137
+ ...typeof returnToTest === 'string' && /^[\w-]+$/.test(returnToTest) ? {
138
+ returnToTest
139
+ } : {},
135
140
  route,
136
141
  ...id === undefined ? {} : {
137
142
  id
@@ -155,6 +160,12 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
155
160
  }
156
161
  } : {}
157
162
  };
163
+ function TestsSurfaceFallback() {
164
+ return /*#__PURE__*/ _jsx(RootViewMessage, {
165
+ alert: true,
166
+ children: SURFACE_COPY.notInstalled
167
+ });
168
+ }
158
169
  return RenderServerComponent({
159
170
  Component: XP_TESTS_SURFACE,
160
171
  Fallback: TestsSurfaceFallback,
@@ -162,14 +173,3 @@ export async function ExperimentsRootView({ initPageResult, params, searchParams
162
173
  clientProps
163
174
  });
164
175
  }
165
- function TestsSurfaceFallback() {
166
- return /*#__PURE__*/ _jsx("div", {
167
- role: "alert",
168
- style: {
169
- padding: 'var(--base)',
170
- color: 'var(--theme-text)',
171
- background: 'var(--theme-bg)'
172
- },
173
- children: SURFACE_COPY.notInstalled
174
- });
175
- }
@@ -0,0 +1,3 @@
1
+ /** Shared with the goals form; preserve Payload's field validation messages. */
2
+ export declare function validateCappingType(value: unknown, capValue: unknown, sourceKind?: string): true | string;
3
+ export declare function validateCappingValue(value: unknown, type: unknown): true | string;
@@ -0,0 +1,9 @@
1
+ /** Shared with the goals form; preserve Payload's field validation messages. */ export function validateCappingType(value, capValue, sourceKind) {
2
+ const hasCapping = value != null || capValue != null;
3
+ if (hasCapping && sourceKind && sourceKind !== 'revenue') return 'Count and yes-or-no goals cannot use a limit.';
4
+ return !hasCapping || value === 'percentile' || value === 'absolute' || 'Choose how to set the limit.';
5
+ }
6
+ export function validateCappingValue(value, type) {
7
+ if (value == null && !type) return true;
8
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && (type === 'absolute' || type === 'percentile' && value > 0 && value <= 1) || 'Enter a valid limit (for past amounts, use a number greater than 0 and at most 1).';
9
+ }
@@ -1,6 +1,7 @@
1
1
  import { XP_METRIC_SOURCE_SELECT } from '../admin/paths.js';
2
2
  import { managedAccess } from './access.js';
3
3
  import { serializeMetricSources } from './serialize.js';
4
+ import { validateCappingType, validateCappingValue } from './metricValidation.js';
4
5
  export function metricsCollection(options) {
5
6
  const sources = serializeMetricSources(options);
6
7
  return {
@@ -125,10 +126,8 @@ export function metricsCollection(options) {
125
126
  description: 'Choose whether the limit comes from past amounts or a fixed amount.'
126
127
  },
127
128
  validate: (value, { data, siblingData })=>{
128
- const hasCapping = value != null || siblingData?.value != null;
129
129
  const source = sources.find((source)=>source.id === data?.source);
130
- if (hasCapping && source && source.kind !== 'revenue') return 'Count and yes-or-no goals cannot use a limit.';
131
- return !hasCapping || value === 'percentile' || value === 'absolute' || 'Choose how to set the limit.';
130
+ return validateCappingType(value, siblingData?.value, source?.kind);
132
131
  }
133
132
  },
134
133
  {
@@ -140,8 +139,7 @@ export function metricsCollection(options) {
140
139
  description: 'For past amounts, enter how much of the usual range to keep, such as 0.99 to limit only the largest 1%; or enter a fixed amount.'
141
140
  },
142
141
  validate: (value, { siblingData })=>{
143
- if (value == null && !siblingData?.type) return true;
144
- return typeof value === 'number' && Number.isFinite(value) && value >= 0 && (siblingData?.type === 'absolute' || siblingData?.type === 'percentile' && value > 0 && value <= 1) || 'Enter a valid limit (for past amounts, use a number greater than 0 and at most 1).';
142
+ return validateCappingValue(value, siblingData?.type);
145
143
  }
146
144
  }
147
145
  ]
package/dist/plugin.js CHANGED
@@ -35,8 +35,8 @@ export function createExperimentsPlugin(input) {
35
35
  path: '/experiments/:segments*'
36
36
  }
37
37
  },
38
- afterNavLinks: [
39
- ...config.admin?.components?.afterNavLinks ?? [],
38
+ beforeNavLinks: [
39
+ ...config.admin?.components?.beforeNavLinks ?? [],
40
40
  paths.XP_TESTS_NAV_LINK
41
41
  ]
42
42
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExperimentRef } from '../../types/puck.js';
2
+ import './editor.css';
2
3
  export declare function CreateExperimentDrawer({ onCreate, onClose, initialName }: {
3
4
  initialName?: string;
4
5
  onCreate: (experiment: ExperimentRef) => void;
@@ -3,8 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useRef, useState } from 'react';
4
4
  import { CreateRefused, createTest } from './createTest.js';
5
5
  import { editorCopy } from './copy.js';
6
+ import './editor.css';
6
7
  export function CreateExperimentDrawer({ onCreate, onClose, initialName = '' }) {
7
8
  const titleId = useId();
9
+ const nameId = useId();
8
10
  const [name, setName] = useState(initialName);
9
11
  const inFlight = useRef(false);
10
12
  const [pending, setPending] = useState(false);
@@ -30,27 +32,26 @@ export function CreateExperimentDrawer({ onCreate, onClose, initialName = '' })
30
32
  }
31
33
  }
32
34
  return /*#__PURE__*/ _jsxs("aside", {
35
+ className: "xp-slot-root xp-slot-drawer",
33
36
  role: "dialog",
34
37
  "aria-labelledby": titleId,
35
- style: {
36
- padding: 16,
37
- border: '1px solid #aaa',
38
- background: 'Canvas',
39
- color: 'CanvasText'
40
- },
41
38
  children: [
42
39
  /*#__PURE__*/ _jsx("h3", {
40
+ className: "xp-slot-title",
43
41
  id: titleId,
44
42
  children: editorCopy.createLabel
45
43
  }),
46
- /*#__PURE__*/ _jsxs("label", {
47
- style: {
48
- display: 'block',
49
- marginBottom: 8
50
- },
44
+ /*#__PURE__*/ _jsxs("div", {
45
+ className: "xp-slot-field",
51
46
  children: [
52
- editorCopy.name,
47
+ /*#__PURE__*/ _jsx("label", {
48
+ className: "xp-slot-label",
49
+ htmlFor: nameId,
50
+ children: editorCopy.name
51
+ }),
53
52
  /*#__PURE__*/ _jsx("input", {
53
+ className: "xp-slot-input",
54
+ id: nameId,
54
55
  value: name,
55
56
  disabled: pending,
56
57
  onChange: (event)=>setName(event.currentTarget.value)
@@ -58,22 +59,30 @@ export function CreateExperimentDrawer({ onCreate, onClose, initialName = '' })
58
59
  ]
59
60
  }),
60
61
  error ? /*#__PURE__*/ _jsx("p", {
62
+ className: "xp-slot-error",
61
63
  role: "alert",
62
64
  children: error
63
65
  }) : null,
64
- /*#__PURE__*/ _jsx("button", {
65
- type: "button",
66
- disabled: pending || !name.trim(),
67
- onClick: ()=>{
68
- void submitNew();
69
- },
70
- children: pending ? editorCopy.creating : editorCopy.createLabel
71
- }),
72
- /*#__PURE__*/ _jsx("button", {
73
- type: "button",
74
- disabled: pending,
75
- onClick: onClose,
76
- children: editorCopy.cancel
66
+ /*#__PURE__*/ _jsxs("div", {
67
+ className: "xp-slot-actions",
68
+ children: [
69
+ /*#__PURE__*/ _jsx("button", {
70
+ className: "xp-slot-button xp-slot-button-primary",
71
+ type: "button",
72
+ disabled: pending || !name.trim(),
73
+ onClick: ()=>{
74
+ void submitNew();
75
+ },
76
+ children: pending ? editorCopy.creating : editorCopy.createLabel
77
+ }),
78
+ /*#__PURE__*/ _jsx("button", {
79
+ className: "xp-slot-button",
80
+ type: "button",
81
+ disabled: pending,
82
+ onClick: onClose,
83
+ children: editorCopy.cancel
84
+ })
85
+ ]
77
86
  })
78
87
  ]
79
88
  });
@@ -1,3 +1,4 @@
1
1
  import { type ComponentConfig } from '@puckeditor/core';
2
2
  import type { ExperimentSlotProps } from '../../types/puck.js';
3
+ import './editor.css';
3
4
  export declare const ExperimentSlotEditor: ComponentConfig<ExperimentSlotProps>;
@@ -8,6 +8,7 @@ import { bannerFor, tabsFor } from './editor.model.js';
8
8
  import { cloneSlot } from './cloneSlot.js';
9
9
  import { CreateExperimentDrawer } from './CreateExperimentDrawer.js';
10
10
  import { editorCopy } from './copy.js';
11
+ import './editor.css';
11
12
  const usePuckSelector = createUsePuck();
12
13
  // Public Puck registration enables native controls inside the editor canvas.
13
14
  const registerControls = (element)=>registerOverlayPortal(element, {
@@ -34,8 +35,8 @@ function EditorRender(props) {
34
35
  const pageTitle = usePuckSelector((state)=>typeof state.appState.data.root.props?.title === 'string' ? state.appState.data.root.props.title : '');
35
36
  const selected = usePuckSelector((state)=>state.selectedItem?.props.id === props.id);
36
37
  const originalEmpty = usePuckSelector((state)=>{
37
- const content = state.selectedItem?.props.control;
38
- return state.selectedItem?.props.id !== props.id || !Array.isArray(content) || content.length === 0;
38
+ const content = state.getItemById(props.id)?.props.control;
39
+ return !Array.isArray(content) || content.length === 0;
39
40
  });
40
41
  useEffect(()=>{
41
42
  if (experimentId === undefined) return;
@@ -74,6 +75,10 @@ function EditorRender(props) {
74
75
  const tabs = tabsFor(experiment);
75
76
  const activeSlot = active && active.id === experimentId ? active.slot : undefined;
76
77
  const slot = tabs.find((tab)=>tab.slot === activeSlot)?.slot ?? tabs[0]?.slot ?? 'control';
78
+ const slotEmpty = usePuckSelector((state)=>{
79
+ const content = state.getItemById(props.id)?.props[slot];
80
+ return !Array.isArray(content) || content.length === 0;
81
+ });
77
82
  const Slot = props[slot];
78
83
  const winnerKey = experiment?.decision?.winnerArm;
79
84
  const winner = tabs.find((tab)=>tab.slot === winnerKey)?.label;
@@ -120,105 +125,159 @@ function EditorRender(props) {
120
125
  });
121
126
  }
122
127
  return /*#__PURE__*/ _jsxs("section", {
123
- style: {
124
- border: '1px solid #aaa',
125
- padding: 12
126
- },
128
+ className: "xp-slot-root xp-slot-editor",
127
129
  children: [
128
130
  /*#__PURE__*/ _jsxs("div", {
131
+ className: "xp-slot-chrome",
129
132
  ref: registerControls,
130
133
  children: [
131
- /*#__PURE__*/ _jsx("p", {
132
- role: "status",
133
- children: experimentId === undefined ? bannerFor() : result?.error ? editorCopy.loadError : experiment ? bannerFor(experiment.status, winner) : editorCopy.loading
134
+ /*#__PURE__*/ _jsxs("header", {
135
+ className: "xp-slot-header",
136
+ children: [
137
+ /*#__PURE__*/ _jsx("h3", {
138
+ className: "xp-slot-title",
139
+ children: experiment?.name ?? pickerCopy.label
140
+ }),
141
+ experiment ? /*#__PURE__*/ _jsx("span", {
142
+ className: "xp-slot-status",
143
+ "data-status": experiment.status,
144
+ children: pickerCopy.statuses[experiment.status]
145
+ }) : null,
146
+ /*#__PURE__*/ _jsxs("div", {
147
+ className: "xp-slot-actions",
148
+ children: [
149
+ experimentId === undefined ? /*#__PURE__*/ _jsx("button", {
150
+ className: "xp-slot-button xp-slot-button-primary",
151
+ type: "button",
152
+ disabled: !selected,
153
+ onClick: ()=>setCreating(true),
154
+ children: editorCopy.createLabel
155
+ }) : null,
156
+ experiment ? /*#__PURE__*/ _jsx("a", {
157
+ className: "xp-slot-button xp-slot-button-primary",
158
+ href: `/admin/experiments/${encodeURIComponent(String(experimentId))}${experiment.status === 'draft' ? '' : '/results'}`,
159
+ children: experiment.status === 'draft' ? editorCopy.setup : editorCopy.viewResults
160
+ }) : null
161
+ ]
162
+ })
163
+ ]
134
164
  }),
135
- /*#__PURE__*/ _jsx("button", {
136
- type: "button",
137
- disabled: !selected,
138
- onClick: ()=>setCreating(true),
139
- children: editorCopy.createLabel
165
+ /*#__PURE__*/ _jsxs("p", {
166
+ className: experiment?.status === 'running' ? 'xp-slot-notice xp-slot-warning' : 'xp-slot-notice',
167
+ role: "status",
168
+ children: [
169
+ experiment?.status === 'running' ? /*#__PURE__*/ _jsxs("svg", {
170
+ className: "xp-slot-warning-icon",
171
+ "aria-hidden": "true",
172
+ viewBox: "0 0 24 24",
173
+ fill: "none",
174
+ stroke: "currentColor",
175
+ strokeWidth: "2",
176
+ children: [
177
+ /*#__PURE__*/ _jsx("path", {
178
+ d: "M12 3 2 21h20L12 3Z"
179
+ }),
180
+ /*#__PURE__*/ _jsx("path", {
181
+ d: "M12 9v5m0 3v1"
182
+ })
183
+ ]
184
+ }) : null,
185
+ experimentId === undefined ? bannerFor() : result?.error ? editorCopy.loadError : experiment ? bannerFor(experiment.status, winner) : editorCopy.loading
186
+ ]
140
187
  }),
141
188
  creating ? /*#__PURE__*/ _jsx(CreateExperimentDrawer, {
142
189
  initialName: pageTitle,
143
190
  onCreate: bind,
144
191
  onClose: ()=>setCreating(false)
145
192
  }) : null,
146
- experiment?.status === 'draft' ? /*#__PURE__*/ _jsx("a", {
147
- href: `/admin/experiments/${encodeURIComponent(String(experimentId))}`,
148
- children: editorCopy.setup
149
- }) : null,
150
- /*#__PURE__*/ _jsx("div", {
151
- style: {
152
- display: 'flex',
153
- flexWrap: 'wrap',
154
- gap: 8,
155
- marginBlock: 8
156
- },
157
- children: tabs.filter((tab)=>tab.slot !== 'control').map((tab)=>/*#__PURE__*/ _jsx("button", {
158
- type: "button",
159
- disabled: originalEmpty,
160
- onClick: ()=>requestCopy(tab.slot),
161
- children: editorCopy.copy(tab.label)
162
- }, tab.slot))
163
- }),
164
- confirming ? /*#__PURE__*/ _jsxs("p", {
193
+ confirming ? /*#__PURE__*/ _jsxs("div", {
194
+ className: "xp-slot-confirm",
165
195
  role: "alert",
166
196
  children: [
167
- editorCopy.replaceConfirm(tabs.find((tab)=>tab.slot === confirming)?.label ?? confirming),
168
- ' ',
169
- /*#__PURE__*/ _jsx("button", {
170
- type: "button",
171
- onClick: ()=>copyTo(confirming),
172
- children: editorCopy.replace
197
+ /*#__PURE__*/ _jsx("p", {
198
+ className: "xp-slot-notice",
199
+ children: editorCopy.replaceConfirm(tabs.find((tab)=>tab.slot === confirming)?.label ?? confirming)
173
200
  }),
174
- ' ',
175
- /*#__PURE__*/ _jsx("button", {
176
- type: "button",
177
- onClick: ()=>setConfirming(null),
178
- children: editorCopy.cancel
201
+ /*#__PURE__*/ _jsxs("div", {
202
+ className: "xp-slot-actions",
203
+ children: [
204
+ /*#__PURE__*/ _jsx("button", {
205
+ className: "xp-slot-button xp-slot-button-primary",
206
+ type: "button",
207
+ onClick: ()=>copyTo(confirming),
208
+ children: editorCopy.replace
209
+ }),
210
+ /*#__PURE__*/ _jsx("button", {
211
+ className: "xp-slot-button",
212
+ type: "button",
213
+ onClick: ()=>setConfirming(null),
214
+ children: editorCopy.cancel
215
+ })
216
+ ]
179
217
  })
180
218
  ]
181
219
  }) : null,
182
- /*#__PURE__*/ _jsx("div", {
183
- role: "tablist",
184
- "aria-label": editorCopy.tabs,
185
- style: {
186
- display: 'flex',
187
- gap: 8,
188
- marginBottom: 8
189
- },
190
- children: tabs.map((tab, index)=>/*#__PURE__*/ _jsx("button", {
191
- type: "button",
192
- role: "tab",
193
- id: `${tabId}-${tab.slot}`,
194
- "aria-selected": tab.slot === slot,
195
- tabIndex: tab.slot === slot ? 0 : -1,
196
- "aria-controls": `${tabId}-panel`,
197
- onClick: ()=>setActive({
198
- id: experimentId,
199
- slot: tab.slot
200
- }),
201
- onKeyDown: (event)=>{
202
- const next = event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : undefined;
203
- if (next === undefined) return;
204
- event.preventDefault();
205
- setActive({
206
- id: experimentId,
207
- slot: tabs[next].slot
208
- });
209
- const buttons = event.currentTarget.parentElement?.querySelectorAll('[role="tab"]');
210
- buttons?.[next]?.focus();
211
- },
212
- children: tab.label
213
- }, tab.slot))
220
+ /*#__PURE__*/ _jsxs("div", {
221
+ className: "xp-slot-toolbar",
222
+ children: [
223
+ /*#__PURE__*/ _jsx("div", {
224
+ className: "xp-slot-tabs",
225
+ role: "tablist",
226
+ "aria-label": editorCopy.tabs,
227
+ children: tabs.map((tab, index)=>/*#__PURE__*/ _jsx("button", {
228
+ className: "xp-slot-tab",
229
+ type: "button",
230
+ role: "tab",
231
+ id: `${tabId}-${tab.slot}`,
232
+ "aria-selected": tab.slot === slot,
233
+ tabIndex: tab.slot === slot ? 0 : -1,
234
+ "aria-controls": `${tabId}-panel`,
235
+ onClick: ()=>setActive({
236
+ id: experimentId,
237
+ slot: tab.slot
238
+ }),
239
+ onKeyDown: (event)=>{
240
+ const next = event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : undefined;
241
+ if (next === undefined) return;
242
+ event.preventDefault();
243
+ setActive({
244
+ id: experimentId,
245
+ slot: tabs[next].slot
246
+ });
247
+ const buttons = event.currentTarget.parentElement?.querySelectorAll('[role="tab"]');
248
+ buttons?.[next]?.focus();
249
+ },
250
+ children: tab.label
251
+ }, tab.slot))
252
+ }),
253
+ slot !== 'control' ? /*#__PURE__*/ _jsx("div", {
254
+ className: "xp-slot-actions",
255
+ children: /*#__PURE__*/ _jsx("button", {
256
+ className: "xp-slot-button",
257
+ type: "button",
258
+ disabled: !selected || originalEmpty,
259
+ onClick: ()=>requestCopy(slot),
260
+ children: editorCopy.copy
261
+ })
262
+ }) : null
263
+ ]
214
264
  })
215
265
  ]
216
266
  }),
217
- /*#__PURE__*/ _jsx("div", {
267
+ /*#__PURE__*/ _jsxs("div", {
268
+ className: slotEmpty ? 'xp-slot-panel xp-slot-panel-empty' : 'xp-slot-panel',
218
269
  role: "tabpanel",
219
270
  id: `${tabId}-panel`,
220
271
  "aria-labelledby": `${tabId}-${slot}`,
221
- children: /*#__PURE__*/ _jsx(Slot, {})
272
+ children: [
273
+ /*#__PURE__*/ _jsx(Slot, {
274
+ className: "xp-slot-drop-target"
275
+ }),
276
+ slotEmpty ? /*#__PURE__*/ _jsx("span", {
277
+ className: "xp-slot-drop-label",
278
+ children: editorCopy.dropZone(slot)
279
+ }) : null
280
+ ]
222
281
  })
223
282
  ]
224
283
  });
@@ -10,7 +10,8 @@ export declare const editorCopy: {
10
10
  readonly running: "Changing this content mid-test mixes two versions in one result";
11
11
  readonly decided: (winner: string) => string;
12
12
  readonly archived: "This test is archived; the winning content is shown to everyone.";
13
- readonly copy: (label: string) => string;
13
+ readonly copy: "Copy Original to this version";
14
+ readonly dropZone: (slot: "control" | "variantA" | "variantB" | "variantC") => string;
14
15
  readonly replaceConfirm: (label: string) => string;
15
16
  readonly replace: "Replace";
16
17
  readonly createLabel: "Test this block";
@@ -18,6 +19,7 @@ export declare const editorCopy: {
18
19
  readonly cancel: "Cancel";
19
20
  readonly name: "Name";
20
21
  readonly setup: "Set up the test";
22
+ readonly viewResults: "View results";
21
23
  readonly nameExists: "This name is too close to an existing test. Choose a different name.";
22
24
  readonly createError: "Couldn't create the test. Try again.";
23
25
  readonly loading: "Loading test…";
@@ -10,7 +10,8 @@ export const editorCopy = {
10
10
  running: 'Changing this content mid-test mixes two versions in one result',
11
11
  decided: (winner)=>`Decided: ${winner}. Use 'Make it the content' on the test to finish.`,
12
12
  archived: 'This test is archived; the winning content is shown to everyone.',
13
- copy: (label)=>`Copy Original → ${label}`,
13
+ copy: 'Copy Original to this version',
14
+ dropZone: (slot)=>slot === 'control' ? 'Drop the original content here' : `Drop ${editorCopy.labels[slot]} content here`,
14
15
  replaceConfirm: (label)=>`${label} already has content. Replace it with a copy of the Original?`,
15
16
  replace: 'Replace',
16
17
  createLabel: 'Test this block',
@@ -18,6 +19,7 @@ export const editorCopy = {
18
19
  cancel: 'Cancel',
19
20
  name: 'Name',
20
21
  setup: 'Set up the test',
22
+ viewResults: 'View results',
21
23
  nameExists: "This name is too close to an existing test. Choose a different name.",
22
24
  createError: "Couldn't create the test. Try again.",
23
25
  loading: 'Loading test…',