@morscherlab/mint-sdk 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/{EmptyState-DgBRoYAy.js → EmptyState-LMPS3keb.js} +3 -193
  2. package/dist/EmptyState-LMPS3keb.js.map +1 -0
  3. package/dist/{ExperimentPopover-BFw3QX8L.js → ExperimentPopover-D6_RGzTY.js} +113 -35
  4. package/dist/ExperimentPopover-D6_RGzTY.js.map +1 -0
  5. package/dist/{ExperimentSelectorModal-BBWXC7KW.js → ExperimentSelectorModal-CDNsNudn.js} +9 -11
  6. package/dist/ExperimentSelectorModal-CDNsNudn.js.map +1 -0
  7. package/dist/{SettingsModal-Dpg-X1OM.js → SettingsModal-BGBimSIA.js} +5 -5
  8. package/dist/{SettingsModal-Dpg-X1OM.js.map → SettingsModal-BGBimSIA.js.map} +1 -1
  9. package/dist/__stories__/experiment-helpers.d.ts +3 -0
  10. package/dist/__tests__/stores/experiment.test.d.ts +1 -0
  11. package/dist/{analysisArtifactTypes-ejqR8MX3.js → analysisArtifactTypes-DQ0MjwHZ.js} +1 -1
  12. package/dist/{analysisArtifactTypes-ejqR8MX3.js.map → analysisArtifactTypes-DQ0MjwHZ.js.map} +1 -1
  13. package/dist/components/ExperimentPopover.vue.d.ts +5 -0
  14. package/dist/components/ExperimentSelectorModal.vue.d.ts +1 -5
  15. package/dist/components/PluginWorkspaceView.props.d.ts +1 -4
  16. package/dist/components/PluginWorkspaceView.vue.d.ts +0 -1
  17. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  18. package/dist/components/index.js +7 -7
  19. package/dist/{components-Ci_BTx00.js → components-BbOyjE2T.js} +24 -29
  20. package/dist/components-BbOyjE2T.js.map +1 -0
  21. package/dist/composables/experiment-utils.d.ts +10 -0
  22. package/dist/composables/index.d.ts +2 -2
  23. package/dist/composables/index.js +11 -10
  24. package/dist/composables/useAppExperiment.d.ts +29 -17
  25. package/dist/composables/usePlatformContext.d.ts +3 -3
  26. package/dist/{composables-C0VEJ73O.js → composables-CW8NRXLB.js} +5 -5
  27. package/dist/{composables-C0VEJ73O.js.map → composables-CW8NRXLB.js.map} +1 -1
  28. package/dist/experiment-Y9Iz7KYi.js +95 -0
  29. package/dist/experiment-Y9Iz7KYi.js.map +1 -0
  30. package/dist/{experiment-utils-CxkruZ3G.js → experiment-utils-7Yw-h0tD.js} +16 -2
  31. package/dist/{experiment-utils-CxkruZ3G.js.map → experiment-utils-7Yw-h0tD.js.map} +1 -1
  32. package/dist/index.js +16 -15
  33. package/dist/index.js.map +1 -1
  34. package/dist/install.js +7 -7
  35. package/dist/{plotly.min-BR53hja4.js → plotly.min-c2Sez60c.js} +1 -1
  36. package/dist/{plotly.min-BR53hja4.js.map → plotly.min-c2Sez60c.js.map} +1 -1
  37. package/dist/stores/auth.d.ts +6 -6
  38. package/dist/stores/experiment.d.ts +166 -0
  39. package/dist/stores/index.d.ts +1 -0
  40. package/dist/stores/index.js +3 -2
  41. package/dist/styles.css +188 -0
  42. package/dist/templates/index.js +4 -4
  43. package/dist/{templates-Cdj93S9C.js → templates-CJRFiWUX.js} +2 -2
  44. package/dist/{templates-Cdj93S9C.js.map → templates-CJRFiWUX.js.map} +1 -1
  45. package/dist/types/auth.d.ts +1 -1
  46. package/dist/types/index.js +1 -1
  47. package/dist/types/platform.d.ts +1 -1
  48. package/dist/{auth-B87xM--s.js → useApi-CDBq6z23.js} +191 -2
  49. package/dist/useApi-CDBq6z23.js.map +1 -0
  50. package/dist/{useControlSchema-B_qgU4rW.js → useControlSchema-BEAgLyyx.js} +5 -4
  51. package/dist/{useControlSchema-B_qgU4rW.js.map → useControlSchema-BEAgLyyx.js.map} +1 -1
  52. package/dist/{useDropdownState-gVaMXtwW.js → useDropdownState-Be-ug_ci.js} +2 -2
  53. package/dist/{useDropdownState-gVaMXtwW.js.map → useDropdownState-Be-ug_ci.js.map} +1 -1
  54. package/dist/{useExperimentSelector-BuQZRgMt.js → useExperimentSelector-Dld4841S.js} +4 -3
  55. package/dist/{useExperimentSelector-BuQZRgMt.js.map → useExperimentSelector-Dld4841S.js.map} +1 -1
  56. package/dist/{useFormBuilder-D1G5J5Z5.js → useFormBuilder-CR9pBApW.js} +2 -2
  57. package/dist/{useFormBuilder-D1G5J5Z5.js.map → useFormBuilder-CR9pBApW.js.map} +1 -1
  58. package/dist/{useProtocolTemplates-DeaOmMaw.js → useProtocolTemplates-DXeIHMwV.js} +38 -42
  59. package/dist/useProtocolTemplates-DXeIHMwV.js.map +1 -0
  60. package/package.json +1 -1
  61. package/src/__stories__/experiment-helpers.ts +12 -9
  62. package/src/__tests__/components/AppTopBar.test.ts +10 -6
  63. package/src/__tests__/components/ExperimentPopover.test.ts +64 -0
  64. package/src/__tests__/components/ExperimentSelectorModal.test.ts +86 -41
  65. package/src/__tests__/components/PluginWorkspaceView.test.ts +103 -19
  66. package/src/__tests__/components/SettingsModal.test.ts +1 -1
  67. package/src/__tests__/composables/experiment-utils.test.ts +33 -0
  68. package/src/__tests__/composables/useAppExperiment.test.ts +331 -92
  69. package/src/__tests__/composables/useAuth.test.ts +2 -2
  70. package/src/__tests__/composables/usePlatformContext.test.ts +5 -1
  71. package/src/__tests__/stores/experiment.test.ts +300 -0
  72. package/src/components/AppTopBar.vue +0 -2
  73. package/src/components/ExperimentPopover.story.vue +59 -0
  74. package/src/components/ExperimentPopover.vue +96 -6
  75. package/src/components/ExperimentSelectorModal.story.vue +6 -16
  76. package/src/components/ExperimentSelectorModal.vue +8 -4
  77. package/src/components/PluginWorkspaceView.props.ts +1 -4
  78. package/src/components/PluginWorkspaceView.story.vue +12 -5
  79. package/src/components/PluginWorkspaceView.vue +0 -2
  80. package/src/composables/experiment-utils.ts +22 -0
  81. package/src/composables/index.ts +1 -2
  82. package/src/composables/useAppExperiment.ts +57 -67
  83. package/src/composables/useAuth.ts +1 -1
  84. package/src/stores/experiment.ts +120 -0
  85. package/src/stores/index.ts +1 -0
  86. package/src/styles/components/experiment-popover.css +105 -0
  87. package/src/types/auth.ts +1 -1
  88. package/src/types/platform.ts +1 -1
  89. package/dist/EmptyState-DgBRoYAy.js.map +0 -1
  90. package/dist/ExperimentPopover-BFw3QX8L.js.map +0 -1
  91. package/dist/ExperimentSelectorModal-BBWXC7KW.js.map +0 -1
  92. package/dist/auth-B87xM--s.js.map +0 -1
  93. package/dist/components-Ci_BTx00.js.map +0 -1
  94. package/dist/useProtocolTemplates-DeaOmMaw.js.map +0 -1
@@ -19,7 +19,6 @@ import type {
19
19
  ControlSchema,
20
20
  ControlWorkspaceOptions,
21
21
  } from '../composables/useControlSchema'
22
- import type { AppExperimentRecord } from '../composables/useAppExperiment'
23
22
 
24
23
  export type PluginWorkspaceSidebarVariant = 'default' | 'analysis'
25
24
 
@@ -72,10 +71,8 @@ export interface PluginWorkspaceViewProps {
72
71
  userInitial?: string
73
72
  /** Classic profile email. */
74
73
  userEmail?: string
75
- /** Enable the built-in AppTopBar experiment popover and selector flow. */
74
+ /** Enable the built-in AppTopBar experiment popover and selector flow. The current experiment comes from the shared experiment store, not from a prop. Keep this comment on one line — the docs extractor drops multi-line JSDoc. */
76
75
  experimentShell?: boolean
77
- /** Current experiment mirrored into the AppTopBar experiment chip when experimentShell is enabled. */
78
- experiment?: AppExperimentRecord | null
79
76
  /** Save handler used by the AppTopBar experiment chip. Return a message to flash success. */
80
77
  experimentSave?: () => string | null | Promise<string | null>
81
78
  /** Disable the experiment save action. */
@@ -2,7 +2,7 @@
2
2
  import { ref } from 'vue'
3
3
  import type { ExperimentSummary, PillNavItem, SidebarToolSection } from '../types'
4
4
  import { defineDoseDesignControlModel } from '../composables/useControlSchema'
5
- import { ExperimentProvider, mockExperiments } from '../__stories__/experiment-helpers'
5
+ import { ExperimentProvider } from '../__stories__/experiment-helpers'
6
6
  import PluginWorkspaceView from './PluginWorkspaceView.vue'
7
7
  import AlertBox from './AlertBox.vue'
8
8
  import BaseButton from './BaseButton.vue'
@@ -70,7 +70,9 @@ const doseDesignModel = defineDoseDesignControlModel({
70
70
  },
71
71
  })
72
72
  const doseDesignValues = ref({})
73
- const shellExperiment = ref<ExperimentSummary | null>(mockExperiments[0] ?? null)
73
+ // Read-only echo of the shell's selection events. The workspace itself reads
74
+ // the current experiment from the shared store, not from a prop.
75
+ const lastExperimentEvent = ref<string>('')
74
76
 
75
77
  const state = ref({
76
78
  title: 'Plugin Workspace',
@@ -83,11 +85,11 @@ const state = ref({
83
85
  })
84
86
 
85
87
  function handleExperimentSelect(experiment: ExperimentSummary) {
86
- shellExperiment.value = experiment
88
+ lastExperimentEvent.value = `selected: ${experiment.name}`
87
89
  }
88
90
 
89
91
  function handleExperimentDetach() {
90
- shellExperiment.value = null
92
+ lastExperimentEvent.value = 'detached'
91
93
  }
92
94
 
93
95
  async function saveDoseDesign() {
@@ -242,7 +244,6 @@ async function saveDoseDesign() {
242
244
  sidebar-title="Design Controls"
243
245
  sidebar-subtitle="Plate and dose setup"
244
246
  experiment-shell
245
- :experiment="shellExperiment"
246
247
  :experiment-save="saveDoseDesign"
247
248
  show-settings
248
249
  show-theme-toggle
@@ -269,6 +270,12 @@ async function saveDoseDesign() {
269
270
  </div>
270
271
  </template>
271
272
  </PluginWorkspaceView>
273
+ <p
274
+ v-if="lastExperimentEvent"
275
+ style="padding: 0.75rem 1rem; margin: 0; font-family: 'Fira Code', monospace; font-size: 0.75rem; color: var(--text-muted);"
276
+ >
277
+ Last shell event: {{ lastExperimentEvent }}
278
+ </p>
272
279
  </div>
273
280
  </ExperimentProvider>
274
281
  </Variant>
@@ -52,7 +52,6 @@ const props = withDefaults(defineProps<PluginWorkspaceViewProps>(), {
52
52
  userInitial: undefined,
53
53
  userEmail: undefined,
54
54
  experimentShell: false,
55
- experiment: undefined,
56
55
  experimentSave: undefined,
57
56
  experimentSaveDisabled: false,
58
57
  experimentSaveDisabledMessage: undefined,
@@ -144,7 +143,6 @@ const slots = useSlots()
144
143
 
145
144
  if (props.experimentShell) {
146
145
  useAppExperiment({
147
- experiment: computed(() => props.experiment),
148
146
  onSelect: experiment => emit('experiment-select', experiment),
149
147
  onSave: props.experimentSave,
150
148
  onDetach: () => emit('experiment-detach'),
@@ -67,6 +67,28 @@ export function resolveExperimentCode(experiment: ExperimentCodeSource): string
67
67
  return undefined
68
68
  }
69
69
 
70
+ // Mirrors vue-router's LocationQuery, whose array members are `string | null`.
71
+ // Declaring `string[]` here made the helper reject the very type every caller
72
+ // passes it, so keep this assignable from LocationQuery without importing
73
+ // vue-router (the SDK does not depend on it).
74
+ type RouteQueryValue = string | null | undefined | readonly (string | null | undefined)[]
75
+ type RouteQueryLike = Record<string, RouteQueryValue>
76
+
77
+ /**
78
+ * Reads the experiment id a deep link was opened with.
79
+ *
80
+ * Returns an id and never a label: a display name belongs to the platform, so
81
+ * callers must resolve it (see `useExperimentStore().selectById`) rather than
82
+ * derive one from the query or the id itself.
83
+ */
84
+ export function experimentIdFromRoute(query: RouteQueryLike): number | null {
85
+ const raw = query.experiment_id ?? query.experimentId
86
+ const value = Array.isArray(raw) ? raw.find(item => typeof item === 'string') : raw
87
+ if (typeof value !== 'string' || !value) return null
88
+ const id = Number(value)
89
+ return Number.isInteger(id) && id > 0 ? id : null
90
+ }
91
+
70
92
  export const DATE_PRESET_OPTIONS: SelectOption<string>[] = [
71
93
  { value: '', label: 'Any time' },
72
94
  { value: 'last_7_days', label: 'Last 7 days' },
@@ -169,6 +169,7 @@ export {
169
169
  formatExperimentStatus,
170
170
  getExperimentStatusVariant,
171
171
  resolveExperimentCode,
172
+ experimentIdFromRoute,
172
173
  datePresetToISO,
173
174
  EXPERIMENT_STATUS_OPTIONS,
174
175
  EXPERIMENT_STATUS_VARIANT_MAP,
@@ -203,8 +204,6 @@ export {
203
204
  export {
204
205
  useAppExperiment,
205
206
  APP_EXPERIMENT_KEY,
206
- type AppExperimentRecord,
207
- type AppExperimentSource,
208
207
  type UseAppExperimentOptions,
209
208
  type UseAppExperimentReturn,
210
209
  type AppExperimentState,
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  ref,
3
3
  computed,
4
- readonly,
5
4
  provide,
6
5
  toValue,
7
6
  watch,
@@ -11,21 +10,14 @@ import {
11
10
  type InjectionKey,
12
11
  } from 'vue'
13
12
  import type { ExperimentSummary, ExperimentStatus } from '../types'
13
+ import { useExperimentStore } from '../stores/experiment'
14
14
  import { resolveExperimentCode } from './experiment-utils'
15
15
 
16
- export type AppExperimentRecord = Pick<ExperimentSummary, 'id' | 'name' | 'status' | 'experiment_code'>
17
-
18
- export type AppExperimentSource =
19
- | AppExperimentRecord
20
- | null
21
- | undefined
22
- | Ref<AppExperimentRecord | null | undefined>
23
- | ComputedRef<AppExperimentRecord | null | undefined>
24
- | (() => AppExperimentRecord | null | undefined)
25
-
26
16
  export interface UseAppExperimentOptions {
27
- /** Current experiment source to mirror into the app shell. Accepts a value, ref, computed, or getter. */
28
- experiment?: AppExperimentSource
17
+ /**
18
+ * Called when the shared store adopts an experiment — a modal selection or a
19
+ * resolved deep link. The record always comes from the platform.
20
+ */
29
21
  onSelect?: (experiment: ExperimentSummary) => void | Promise<void>
30
22
  onSave?: () => string | null | Promise<string | null>
31
23
  onDetach?: () => void
@@ -37,6 +29,10 @@ export interface AppExperimentPopoverBinding {
37
29
  experimentName?: string
38
30
  experimentCode?: string
39
31
  experimentStatus?: ExperimentStatus
32
+ /** An experiment is bound but its record has not arrived yet. */
33
+ isResolving: boolean
34
+ /** Resolution failed; the binding is kept so the user can retry or detach. */
35
+ error?: string
40
36
  showSave: boolean
41
37
  showDetach: boolean
42
38
  saveDisabled: boolean
@@ -51,26 +47,27 @@ export interface AppExperimentSelectorModalBinding {
51
47
  }
52
48
 
53
49
  export interface UseAppExperimentReturn {
54
- set: (experiment: AppExperimentRecord) => void
55
- clear: () => void
56
- experimentName: Readonly<Ref<string | undefined>>
57
- experimentCode: Readonly<Ref<string | undefined>>
58
- experimentStatus: Readonly<Ref<ExperimentStatus | undefined>>
59
- experimentId: Readonly<Ref<number | null>>
50
+ experimentName: ComputedRef<string | undefined>
51
+ experimentCode: ComputedRef<string | undefined>
52
+ experimentStatus: ComputedRef<ExperimentStatus | undefined>
53
+ experimentId: ComputedRef<number | null>
54
+ isResolving: ComputedRef<boolean>
55
+ error: ComputedRef<string | null>
60
56
  popover: ComputedRef<AppExperimentPopoverBinding>
61
57
  selectorModal: ComputedRef<AppExperimentSelectorModalBinding>
62
58
  openModal: () => void
63
59
  closeModal: () => void
64
- handleSelect: (experiment: ExperimentSummary) => void
65
60
  handleSave: () => Promise<void>
66
61
  handleDetach: () => void
67
62
  }
68
63
 
69
64
  export interface AppExperimentState {
70
- experimentName: Ref<string | undefined>
71
- experimentCode: Ref<string | undefined>
72
- experimentStatus: Ref<ExperimentStatus | undefined>
73
- experimentId: Ref<number | null>
65
+ experimentName: ComputedRef<string | undefined>
66
+ experimentCode: ComputedRef<string | undefined>
67
+ experimentStatus: ComputedRef<ExperimentStatus | undefined>
68
+ experimentId: ComputedRef<number | null>
69
+ isResolving: ComputedRef<boolean>
70
+ error: ComputedRef<string | null>
74
71
  showModal: Ref<boolean>
75
72
  saveLoading: Ref<boolean>
76
73
  saveSuccessMessage: Ref<string | undefined>
@@ -82,25 +79,41 @@ export interface AppExperimentState {
82
79
  popover: ComputedRef<AppExperimentPopoverBinding>
83
80
  selectorModal: ComputedRef<AppExperimentSelectorModalBinding>
84
81
  openModal: () => void
85
- handleSelect: (experiment: ExperimentSummary) => void
86
82
  handleSave: () => Promise<void>
87
83
  handleDetach: () => void
88
84
  }
89
85
 
90
86
  export const APP_EXPERIMENT_KEY: InjectionKey<AppExperimentState> = Symbol('app-experiment')
91
87
 
92
- /** Manages the current experiment selection, save flow, and detach action for a plugin's AppTopBar app shell. */
88
+ /**
89
+ * Projects the current experiment from the shared store into an AppTopBar app
90
+ * shell, and owns the shell-local concerns the store has no opinion on: the
91
+ * selector modal's open state and the save flow.
92
+ *
93
+ * The composable never writes a selection of its own. `ExperimentSelectorModal`
94
+ * commits to the store and this reads back, so a displayed name can only ever
95
+ * have come from the platform.
96
+ */
93
97
  export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppExperimentReturn {
94
- const experimentName = ref<string | undefined>()
95
- const experimentCode = ref<string | undefined>()
96
- const experimentStatus = ref<ExperimentStatus | undefined>()
97
- const experimentId = ref<number | null>(null)
98
+ const store = useExperimentStore()
99
+
98
100
  const showModal = ref(false)
99
101
  const saveLoading = ref(false)
100
102
  const saveSuccessMessage = ref<string | undefined>()
101
103
 
102
104
  let successTimer: ReturnType<typeof setTimeout> | null = null
103
105
 
106
+ const experimentName = computed(() => store.current?.name)
107
+ const experimentCode = computed(() =>
108
+ store.current ? resolveExperimentCode(store.current) : undefined,
109
+ )
110
+ const experimentStatus = computed(() => store.current?.status)
111
+ // Set while resolving and after a failed resolve, so the shell can tell
112
+ // "nothing selected" apart from "selected but not resolved".
113
+ const experimentId = computed(() => store.currentId)
114
+ const isResolving = computed(() => store.isResolving)
115
+ const error = computed(() => store.error)
116
+
104
117
  const showSave = ref(!!options.onSave)
105
118
  const showDetach = computed(() => experimentId.value !== null)
106
119
  const saveDisabled = computed(() => toValue(options.saveDisabled) ?? false)
@@ -109,6 +122,8 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
109
122
  experimentName: experimentName.value,
110
123
  experimentCode: experimentCode.value,
111
124
  experimentStatus: experimentStatus.value,
125
+ isResolving: isResolving.value,
126
+ error: error.value ?? undefined,
112
127
  showSave: showSave.value,
113
128
  showDetach: showDetach.value,
114
129
  saveDisabled: saveDisabled.value,
@@ -121,31 +136,12 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
121
136
  currentExperimentId: experimentId.value,
122
137
  }))
123
138
 
124
- function set(experiment: AppExperimentRecord) {
125
- experimentId.value = experiment.id
126
- experimentName.value = experiment.name
127
- experimentCode.value = resolveExperimentCode(experiment)
128
- experimentStatus.value = experiment.status
129
- }
130
-
131
- function clear() {
132
- experimentId.value = null
133
- experimentName.value = undefined
134
- experimentCode.value = undefined
135
- experimentStatus.value = undefined
136
- }
137
-
138
- if (options.experiment !== undefined) {
139
+ if (options.onSelect) {
139
140
  watch(
140
- () => toValue(options.experiment),
141
+ () => store.current,
141
142
  (experiment) => {
142
- if (experiment) {
143
- set(experiment)
144
- } else {
145
- clear()
146
- }
143
+ if (experiment) void options.onSelect?.(experiment)
147
144
  },
148
- { immediate: true },
149
145
  )
150
146
  }
151
147
 
@@ -157,12 +153,6 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
157
153
  showModal.value = false
158
154
  }
159
155
 
160
- function handleSelect(experiment: ExperimentSummary) {
161
- set(experiment)
162
- showModal.value = false
163
- options.onSelect?.(experiment)
164
- }
165
-
166
156
  async function handleSave() {
167
157
  if (!options.onSave || saveLoading.value) return
168
158
  saveLoading.value = true
@@ -182,7 +172,7 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
182
172
  }
183
173
 
184
174
  function handleDetach() {
185
- clear()
175
+ store.clear()
186
176
  options.onDetach?.()
187
177
  }
188
178
 
@@ -195,6 +185,8 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
195
185
  experimentCode,
196
186
  experimentStatus,
197
187
  experimentId,
188
+ isResolving,
189
+ error,
198
190
  showModal,
199
191
  saveLoading,
200
192
  saveSuccessMessage,
@@ -206,7 +198,6 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
206
198
  selectorModal,
207
199
  openModal,
208
200
  closeModal,
209
- handleSelect,
210
201
  handleSave,
211
202
  handleDetach,
212
203
  }
@@ -214,17 +205,16 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
214
205
  provide(APP_EXPERIMENT_KEY, state)
215
206
 
216
207
  return {
217
- set,
218
- clear,
219
- experimentName: readonly(experimentName),
220
- experimentCode: readonly(experimentCode),
221
- experimentStatus: readonly(experimentStatus),
222
- experimentId: readonly(experimentId),
208
+ experimentName,
209
+ experimentCode,
210
+ experimentStatus,
211
+ experimentId,
212
+ isResolving,
213
+ error,
223
214
  popover,
224
215
  selectorModal,
225
216
  openModal,
226
217
  closeModal,
227
- handleSelect,
228
218
  handleSave,
229
219
  handleDetach,
230
220
  }
@@ -6,7 +6,7 @@ import type { AuthConfig, UserInfo, LoginResponse, TokenVerifyResponse, UpdatePr
6
6
  import type { RoleInfo } from '../permissions'
7
7
 
8
8
  interface UserResponse {
9
- id: string
9
+ id: number
10
10
  username: string
11
11
  shortname: string | null
12
12
  email: string | null
@@ -0,0 +1,120 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { useApi } from '../composables/useApi'
4
+ import type { ExperimentSummary, PlatformContext } from '../types'
5
+
6
+ function getPlatformApiUrl(): string | undefined {
7
+ if (typeof window === 'undefined') return undefined
8
+ return (window as unknown as { __MINT_PLATFORM__?: PlatformContext }).__MINT_PLATFORM__
9
+ ?.platformApiUrl
10
+ }
11
+
12
+ function readErrorMessage(value: unknown): string {
13
+ if (value instanceof Error && value.message) return value.message
14
+ if (typeof value === 'string' && value.trim()) return value
15
+ return 'Failed to load experiment'
16
+ }
17
+
18
+ /**
19
+ * The single source of truth for the experiment a plugin is currently working on.
20
+ *
21
+ * Both entry points land here: `ExperimentSelectorModal` calls `select()` with a
22
+ * record the platform already returned, and deep links call `selectById()` with
23
+ * nothing but an id. Because a name can only ever arrive from the platform,
24
+ * there is no code path in which a caller can invent one.
25
+ */
26
+ export const useExperimentStore = defineStore('mint-experiment', () => {
27
+ const current = ref<ExperimentSummary | null>(null)
28
+ const currentId = ref<number | null>(null)
29
+ const isResolving = ref(false)
30
+ const error = ref<string | null>(null)
31
+
32
+ // In-flight requests only. Resolved records are deliberately not memoised, so
33
+ // a rename is picked up on the next resolve instead of persisting for the
34
+ // lifetime of the page.
35
+ const inFlight = new Map<number, Promise<ExperimentSummary>>()
36
+
37
+ // Monotonic token guarding every write to the shared selection. Responses can
38
+ // land in any order, and a late one must never overwrite a newer choice —
39
+ // that would display a name belonging to a different experiment, which is the
40
+ // failure this store exists to prevent.
41
+ let selectionToken = 0
42
+
43
+ function fetchExperiment(id: number): Promise<ExperimentSummary> {
44
+ const pending = inFlight.get(id)
45
+ if (pending) return pending
46
+
47
+ const base = getPlatformApiUrl() ?? ''
48
+ const request = useApi()
49
+ .get<ExperimentSummary>(`${base}/experiments/${id}`)
50
+ .finally(() => {
51
+ inFlight.delete(id)
52
+ })
53
+
54
+ inFlight.set(id, request)
55
+ return request
56
+ }
57
+
58
+ /** Adopt a record the platform already returned, without a further request. */
59
+ function select(experiment: ExperimentSummary): void {
60
+ selectionToken += 1
61
+ current.value = experiment
62
+ currentId.value = experiment.id
63
+ error.value = null
64
+ isResolving.value = false
65
+ }
66
+
67
+ /**
68
+ * Bind to an experiment known only by id, resolving its record from the
69
+ * platform. On failure the binding is remembered but `current` stays null, so
70
+ * callers can distinguish "not resolved yet" from "nothing selected".
71
+ */
72
+ async function selectById(id: number): Promise<ExperimentSummary | null> {
73
+ const joinsCurrentResolution = isResolving.value && currentId.value === id
74
+ const token = joinsCurrentResolution ? selectionToken : (selectionToken += 1)
75
+ if (!joinsCurrentResolution) {
76
+ current.value = null
77
+ currentId.value = id
78
+ error.value = null
79
+ isResolving.value = true
80
+ }
81
+ try {
82
+ const experiment = await fetchExperiment(id)
83
+ // A newer selection superseded this one while the request was in flight.
84
+ // Returning null tells the caller its result is no longer the selection,
85
+ // so it cannot be applied downstream either.
86
+ if (token !== selectionToken) return null
87
+ current.value = experiment
88
+ return experiment
89
+ } catch (err) {
90
+ if (token !== selectionToken) return null
91
+ current.value = null
92
+ error.value = readErrorMessage(err)
93
+ return null
94
+ } finally {
95
+ // Only the newest request owns the loading flag; otherwise a superseded
96
+ // response would clear it while the current one is still pending.
97
+ if (token === selectionToken) isResolving.value = false
98
+ }
99
+ }
100
+
101
+ function clear(): void {
102
+ // Supersede any in-flight resolve so a detached experiment cannot be
103
+ // repopulated by a response that was already on its way.
104
+ selectionToken += 1
105
+ current.value = null
106
+ currentId.value = null
107
+ error.value = null
108
+ isResolving.value = false
109
+ }
110
+
111
+ return {
112
+ current,
113
+ currentId,
114
+ isResolving,
115
+ error,
116
+ select,
117
+ selectById,
118
+ clear,
119
+ }
120
+ })
@@ -1,2 +1,3 @@
1
1
  export { useAuthStore } from './auth'
2
2
  export { useSettingsStore, colorPalettes, type SettingsState } from './settings'
3
+ export { useExperimentStore } from './experiment'
@@ -115,6 +115,47 @@
115
115
  color: var(--color-primary);
116
116
  }
117
117
 
118
+ /* ─── Unresolved states ───
119
+ An experiment is bound but has no name yet. The chip must not read "No
120
+ experiment", and it must never fall back to the numeric id — a name only
121
+ ever comes from the platform. */
122
+ .mint-experiment-popover__trigger--loading {
123
+ cursor: progress;
124
+ }
125
+
126
+ .mint-experiment-popover__trigger--loading .mint-experiment-popover__trigger-icon {
127
+ color: var(--text-muted);
128
+ }
129
+
130
+ .mint-experiment-popover__trigger--error {
131
+ border-color: var(--mint-error-border);
132
+ background: var(--mint-error-bg);
133
+ color: var(--mint-error);
134
+ }
135
+
136
+ .mint-experiment-popover__trigger--error .mint-experiment-popover__trigger-icon,
137
+ .mint-experiment-popover__trigger--error .mint-experiment-popover__trigger-chevron {
138
+ color: var(--mint-error);
139
+ }
140
+
141
+ /* Placeholder bar standing in for the name while it resolves. */
142
+ .mint-experiment-popover__trigger-skeleton {
143
+ flex: 1;
144
+ min-width: 4.5rem;
145
+ height: 0.625rem;
146
+ border-radius: var(--radius-sm);
147
+ background-color: var(--bg-tertiary);
148
+ background-image: linear-gradient(
149
+ 90deg,
150
+ var(--bg-tertiary) 0%,
151
+ var(--bg-hover) 40%,
152
+ var(--bg-tertiary) 60%
153
+ );
154
+ background-size: 200% 100%;
155
+ background-repeat: no-repeat;
156
+ animation: mint-experiment-popover-shimmer 1.6s ease-in-out infinite;
157
+ }
158
+
118
159
  .mint-experiment-popover__trigger-chevron {
119
160
  width: 0.8125rem;
120
161
  height: 0.8125rem;
@@ -386,6 +427,59 @@
386
427
  line-height: 1.3;
387
428
  }
388
429
 
430
+ /* ─── Card: unresolved variants ─── */
431
+ .mint-experiment-popover__card--error {
432
+ background: var(--mint-error-bg);
433
+ }
434
+
435
+ .mint-experiment-popover__card-icon--error {
436
+ background: transparent;
437
+ }
438
+
439
+ .mint-experiment-popover__card-icon--error svg {
440
+ color: var(--mint-error);
441
+ }
442
+
443
+ .mint-experiment-popover__card-message {
444
+ font-size: 0.6875rem;
445
+ color: var(--text-secondary);
446
+ line-height: 1.4;
447
+ overflow-wrap: anywhere;
448
+ }
449
+
450
+ /* Placeholder lines mirroring the code / name / status stack of a real card. */
451
+ .mint-experiment-popover__skeleton-line {
452
+ display: block;
453
+ height: 0.625rem;
454
+ border-radius: var(--radius-sm);
455
+ background-color: var(--bg-hover);
456
+ background-image: linear-gradient(
457
+ 90deg,
458
+ var(--bg-hover) 0%,
459
+ var(--bg-secondary) 40%,
460
+ var(--bg-hover) 60%
461
+ );
462
+ background-size: 200% 100%;
463
+ background-repeat: no-repeat;
464
+ animation: mint-experiment-popover-shimmer 1.6s ease-in-out infinite;
465
+ }
466
+
467
+ .mint-experiment-popover__skeleton-line--code {
468
+ width: 3.5rem;
469
+ height: 0.75rem;
470
+ border-radius: 9999px;
471
+ }
472
+
473
+ .mint-experiment-popover__skeleton-line--name {
474
+ width: 100%;
475
+ margin-top: 0.25rem;
476
+ }
477
+
478
+ .mint-experiment-popover__skeleton-line--status {
479
+ width: 45%;
480
+ margin-top: 0.25rem;
481
+ }
482
+
389
483
  /* Action row sits BELOW the card (was inside) so the buttons have
390
484
  room to breathe and don't compete with the info stack. */
391
485
  .mint-experiment-popover__card-actions {
@@ -532,6 +626,11 @@
532
626
  to { transform: rotate(360deg); }
533
627
  }
534
628
 
629
+ @keyframes mint-experiment-popover-shimmer {
630
+ 0% { background-position: 100% 0; }
631
+ 100% { background-position: -100% 0; }
632
+ }
633
+
535
634
  /* Respect motion preferences */
536
635
  @media (prefers-reduced-motion: reduce) {
537
636
  .mint-experiment-popover__trigger,
@@ -557,4 +656,10 @@
557
656
  .mint-experiment-popover__trigger-chevron {
558
657
  transition: none;
559
658
  }
659
+ /* Placeholders stay as flat blocks — still legible as "not loaded yet". */
660
+ .mint-experiment-popover__trigger-skeleton,
661
+ .mint-experiment-popover__skeleton-line {
662
+ animation: none;
663
+ background-image: none;
664
+ }
560
665
  }
package/src/types/auth.ts CHANGED
@@ -12,7 +12,7 @@ export interface AuthConfig {
12
12
 
13
13
  // User information
14
14
  export interface UserInfo {
15
- id: string
15
+ id: number
16
16
  username: string
17
17
  shortname: string | null
18
18
  email: string | null
@@ -76,7 +76,7 @@ export interface PlatformContext {
76
76
 
77
77
  // Current user info (if authenticated)
78
78
  user?: {
79
- id: string
79
+ id: number
80
80
  username: string
81
81
  role: string
82
82
  role_obj?: RoleInfo | null