@morscherlab/mint-sdk 1.0.0-beta.3 → 1.0.0-beta.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 (165) hide show
  1. package/README.md +9 -2
  2. package/dist/__tests__/composables/experiment-utils.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useApi.test.d.ts +1 -0
  4. package/dist/components/AppContainer.vue.d.ts +1 -1
  5. package/dist/components/AppLayout.vue.d.ts +20 -1
  6. package/dist/components/AppSidebar.vue.d.ts +56 -4
  7. package/dist/components/AppTopBar.vue.d.ts +7 -25
  8. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +3 -1
  9. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +1 -0
  10. package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +5 -0
  11. package/dist/components/ComponentBindingRenderer.vue.d.ts +44 -0
  12. package/dist/components/ControlWorkspaceView.vue.d.ts +24 -7
  13. package/dist/components/DoseDesignWorkspaceView.vue.d.ts +149 -0
  14. package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
  15. package/dist/components/FormBuilder.vue.d.ts +9 -9
  16. package/dist/components/PlateMapEditor.vue.d.ts +1 -1
  17. package/dist/components/PluginWorkspaceView.vue.d.ts +310 -0
  18. package/dist/components/SettingsModal.vue.d.ts +1 -1
  19. package/dist/components/WellPlate.vue.d.ts +2 -2
  20. package/dist/components/index.d.ts +3 -12
  21. package/dist/components/index.js +3 -3
  22. package/dist/components/{AppPageSelector.vue.d.ts → internal/AppPageSelectorInternal.vue.d.ts} +1 -1
  23. package/dist/components/{AppPillNav.vue.d.ts → internal/AppPillNavInternal.vue.d.ts} +3 -1
  24. package/dist/components/{CalendarGridPanel.vue.d.ts → internal/CalendarGridPanelInternal.vue.d.ts} +1 -1
  25. package/dist/components/internal/FormSectionRenderer.vue.d.ts +4 -4
  26. package/dist/components/{WellEditPopup.vue.d.ts → internal/WellEditPopupInternal.vue.d.ts} +1 -1
  27. package/dist/{components-D_Sr0adg.js → components-BkGF4B4y.js} +4484 -3967
  28. package/dist/components-BkGF4B4y.js.map +1 -0
  29. package/dist/composables/experiment-utils.d.ts +8 -0
  30. package/dist/composables/index.d.ts +5 -7
  31. package/dist/composables/index.js +4 -4
  32. package/dist/composables/useAppExperiment.d.ts +31 -2
  33. package/dist/composables/useBioTemplateComponents.d.ts +5 -3
  34. package/dist/composables/useBioTemplatePackWorkspace.d.ts +3 -2
  35. package/dist/composables/useBioTemplatePresetWorkspace.d.ts +6 -5
  36. package/dist/composables/useBioTemplateWorkspace.d.ts +5 -4
  37. package/dist/composables/useControlSchema.d.ts +43 -21
  38. package/dist/composables/usePluginClient.d.ts +5 -2
  39. package/dist/{composables-C3dpXQN5.js → composables-CHsME9H1.js} +40 -28
  40. package/dist/composables-CHsME9H1.js.map +1 -0
  41. package/dist/index.d.ts +5 -12
  42. package/dist/index.js +5 -5
  43. package/dist/install.js +2 -2
  44. package/dist/styles.css +3625 -3651
  45. package/dist/templates/componentBindings.d.ts +13 -0
  46. package/dist/templates/index.d.ts +3 -3
  47. package/dist/templates/index.js +2 -2
  48. package/dist/{templates-50NPjaxL.js → templates-B5jmTWuk.js} +111 -56
  49. package/dist/templates-B5jmTWuk.js.map +1 -0
  50. package/dist/types/components.d.ts +6 -25
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/{useScheduleDrag-D4oWdh41.js → useScheduleDrag-BgzpQT53.js} +160 -117
  53. package/dist/useScheduleDrag-BgzpQT53.js.map +1 -0
  54. package/package.json +1 -1
  55. package/src/__tests__/components/ActionItem.test.ts +6 -6
  56. package/src/__tests__/components/AppLayout.test.ts +44 -0
  57. package/src/__tests__/components/AppPageSelector.test.ts +8 -8
  58. package/src/__tests__/components/AppPillNav.test.ts +53 -6
  59. package/src/__tests__/components/AppSidebar.test.ts +126 -0
  60. package/src/__tests__/components/AppToastContainer.test.ts +0 -11
  61. package/src/__tests__/components/AppTopBar.test.ts +182 -119
  62. package/src/__tests__/components/BioTemplateExperimentWorkspaceView.test.ts +7 -1
  63. package/src/__tests__/components/BioTemplatePackWorkspaceView.test.ts +15 -1
  64. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +26 -1
  65. package/src/__tests__/components/CalendarGridPanel.test.ts +3 -3
  66. package/src/__tests__/components/ComponentBindingRenderer.test.ts +161 -0
  67. package/src/__tests__/components/ControlWorkspaceView.test.ts +134 -63
  68. package/src/__tests__/components/DateTimePicker.test.ts +2 -2
  69. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +185 -0
  70. package/src/__tests__/components/PluginWorkspaceView.test.ts +548 -0
  71. package/src/__tests__/composables/experiment-utils.test.ts +30 -0
  72. package/src/__tests__/composables/useApi.test.ts +30 -0
  73. package/src/__tests__/composables/useAppExperiment.test.ts +100 -1
  74. package/src/__tests__/composables/useBioTemplatePackWorkspace.test.ts +6 -3
  75. package/src/__tests__/composables/useBioTemplatePresetWorkspace.test.ts +6 -6
  76. package/src/__tests__/composables/useBioTemplateWorkspace.test.ts +6 -1
  77. package/src/__tests__/composables/useControlSchema.test.ts +150 -36
  78. package/src/__tests__/composables/usePluginClient.test.ts +99 -2
  79. package/src/__tests__/docs/frontendDocsCatalog.test.ts +120 -25
  80. package/src/__tests__/templates/templates.test.ts +12 -0
  81. package/src/components/AppAvatarMenu.vue +3 -3
  82. package/src/components/AppLayout.story.vue +39 -0
  83. package/src/components/AppLayout.vue +83 -2
  84. package/src/components/AppPluginSwitcher.vue +5 -5
  85. package/src/components/AppSidebar.story.vue +113 -5
  86. package/src/components/AppSidebar.vue +144 -24
  87. package/src/components/AppTopBar.story.vue +2 -5
  88. package/src/components/AppTopBar.vue +35 -425
  89. package/src/components/BioTemplateExperimentWorkspaceView.story.vue +2 -2
  90. package/src/components/BioTemplateExperimentWorkspaceView.vue +6 -0
  91. package/src/components/BioTemplatePackWorkspaceView.story.vue +4 -4
  92. package/src/components/BioTemplatePackWorkspaceView.vue +1 -0
  93. package/src/components/BioTemplatePresetWorkspaceView.story.vue +14 -2
  94. package/src/components/BioTemplatePresetWorkspaceView.vue +11 -2
  95. package/src/components/BioTemplateRenderer.vue +15 -227
  96. package/src/components/ComponentBindingRenderer.story.vue +57 -0
  97. package/src/components/ComponentBindingRenderer.vue +308 -0
  98. package/src/components/ControlWorkspaceView.story.vue +20 -9
  99. package/src/components/ControlWorkspaceView.vue +43 -12
  100. package/src/components/DatePicker.vue +2 -2
  101. package/src/components/DateTimePicker.vue +2 -2
  102. package/src/components/DoseDesignWorkspaceView.story.vue +77 -0
  103. package/src/components/DoseDesignWorkspaceView.vue +255 -0
  104. package/src/components/ExperimentPopover.vue +2 -6
  105. package/src/components/ExperimentSelectorModal.vue +6 -5
  106. package/src/components/FormBuilder.story.vue +190 -0
  107. package/src/components/PluginWorkspaceView.story.vue +334 -0
  108. package/src/components/PluginWorkspaceView.vue +708 -0
  109. package/src/components/SettingsModal.story.vue +87 -0
  110. package/src/components/WellPlate.vue +2 -2
  111. package/src/components/index.ts +3 -12
  112. package/src/components/{AppPageSelector.vue → internal/AppPageSelectorInternal.vue} +9 -9
  113. package/src/components/internal/AppPillNavInternal.vue +194 -0
  114. package/src/components/{CalendarGridPanel.vue → internal/CalendarGridPanelInternal.vue} +1 -1
  115. package/src/components/{WellEditPopup.vue → internal/WellEditPopupInternal.vue} +3 -3
  116. package/src/composables/experiment-utils.ts +26 -0
  117. package/src/composables/index.ts +21 -7
  118. package/src/composables/useApi.ts +9 -2
  119. package/src/composables/useAppExperiment.ts +85 -13
  120. package/src/composables/useBioTemplateComponents.ts +12 -0
  121. package/src/composables/useBioTemplatePackWorkspace.ts +6 -2
  122. package/src/composables/useBioTemplatePresetWorkspace.ts +10 -21
  123. package/src/composables/useBioTemplateWorkspace.ts +6 -4
  124. package/src/composables/useControlSchema.ts +157 -69
  125. package/src/composables/usePluginClient.ts +50 -9
  126. package/src/index.ts +6 -563
  127. package/src/styles/components/app-layout.css +82 -0
  128. package/src/styles/components/app-pill-nav.css +70 -0
  129. package/src/styles/components/app-sidebar.css +119 -0
  130. package/src/styles/components/app-top-bar.css +0 -235
  131. package/src/styles/index.css +0 -1
  132. package/src/templates/componentBindings.ts +38 -0
  133. package/src/templates/index.ts +4 -0
  134. package/src/types/components.ts +6 -31
  135. package/src/types/index.ts +2 -6
  136. package/dist/__tests__/composables/usePluginApi.test.d.ts +0 -13
  137. package/dist/components/FormFieldRenderer.vue.d.ts +0 -28
  138. package/dist/components/FormSection.vue.d.ts +0 -30
  139. package/dist/components/GroupingModal.vue.d.ts +0 -12
  140. package/dist/components/SettingsButton.vue.d.ts +0 -30
  141. package/dist/components/ToastNotification.vue.d.ts +0 -2
  142. package/dist/components-D_Sr0adg.js.map +0 -1
  143. package/dist/composables/usePluginApi.d.ts +0 -22
  144. package/dist/composables-C3dpXQN5.js.map +0 -1
  145. package/dist/templates-50NPjaxL.js.map +0 -1
  146. package/dist/useScheduleDrag-D4oWdh41.js.map +0 -1
  147. package/src/__tests__/components/FormCompatibility.test.ts +0 -94
  148. package/src/__tests__/components/GroupingModal.test.ts +0 -73
  149. package/src/__tests__/components/SettingsButton.test.ts +0 -44
  150. package/src/__tests__/composables/usePluginApi.test.ts +0 -81
  151. package/src/components/AppPillNav.vue +0 -71
  152. package/src/components/FormFieldRenderer.vue +0 -35
  153. package/src/components/FormSection.vue +0 -37
  154. package/src/components/GroupingModal.story.vue +0 -52
  155. package/src/components/GroupingModal.vue +0 -61
  156. package/src/components/SettingsButton.story.vue +0 -58
  157. package/src/components/SettingsButton.vue +0 -64
  158. package/src/components/ToastNotification.vue +0 -9
  159. package/src/composables/usePluginApi.ts +0 -32
  160. package/src/styles/components/settings-button.css +0 -31
  161. /package/dist/__tests__/components/{FormCompatibility.test.d.ts → ComponentBindingRenderer.test.d.ts} +0 -0
  162. /package/dist/__tests__/components/{GroupingModal.test.d.ts → DoseDesignWorkspaceView.test.d.ts} +0 -0
  163. /package/dist/__tests__/components/{SettingsButton.test.d.ts → PluginWorkspaceView.test.d.ts} +0 -0
  164. /package/dist/components/{ActionItem.vue.d.ts → internal/ActionItemInternal.vue.d.ts} +0 -0
  165. /package/src/components/{ActionItem.vue → internal/ActionItemInternal.vue} +0 -0
@@ -5,7 +5,6 @@ import type {
5
5
  SelectOption,
6
6
  SettingsModalSchema,
7
7
  TopBarSettingsConfig,
8
- TopBarTab,
9
8
  } from '../types'
10
9
  import type {
11
10
  FieldCondition,
@@ -45,7 +44,7 @@ export interface ControlViewConfig {
45
44
  to?: string
46
45
  href?: string
47
46
  disabled?: boolean
48
- children?: TopBarTab['children']
47
+ children?: PillNavItem['children']
49
48
  }
50
49
 
51
50
  export interface ControlSidebarConfig extends Omit<ControlSectionConfig, 'id' | 'title' | 'description' | 'columns'> {
@@ -120,6 +119,10 @@ export interface ControlModel extends Omit<ControlWorkspaceOptions, 'sections' |
120
119
  controls?: ControlSchema
121
120
  sections?: Record<string, ControlModelSectionConfig>
122
121
  views?: Record<string, ControlModelViewConfig>
122
+ /** Optional SDK component bindings returned with generated component props for custom workspace slots. */
123
+ componentBindings?: ControlComponentBindingsConfig
124
+ /** Alias for componentBindings in raw control models. */
125
+ components?: ControlComponentBindingsConfig
123
126
  /** Optional ControlWorkspaceView componentProps mapping returned with the generated controls/options. */
124
127
  componentProps?: ControlComponentPropsMap
125
128
  /** Optional named componentProps mappings returned for multiple SDK components. */
@@ -129,6 +132,7 @@ export interface ControlModel extends Omit<ControlWorkspaceOptions, 'sections' |
129
132
  export interface ControlModelBinding {
130
133
  controls: ControlSchema
131
134
  controlOptions: ControlWorkspaceOptions
135
+ componentBindings?: ControlComponentBindingsConfig
132
136
  componentProps?: ControlComponentPropsMap
133
137
  componentPropsById?: ControlComponentPropsByIdMap
134
138
  }
@@ -171,37 +175,28 @@ export interface ControlWorkspaceTopBarSettingsBinding extends ControlTopBarSett
171
175
  onSettingsValuesChange: (values: Record<string, unknown>) => void
172
176
  }
173
177
 
174
- export interface ControlWorkspaceTopBarBinding {
175
- tabs: TopBarTab[]
176
- currentTabId: string
177
- onTabSelect: (tab: TopBarTab) => void
178
- }
179
-
180
178
  export interface ControlWorkspacePillNavBinding {
181
179
  items: PillNavItem[]
182
180
  currentItemId: string
183
181
  onSelect: (item: PillNavItem) => void
184
182
  }
185
183
 
186
- export interface ControlWorkspaceAppTopBarPillBinding extends ControlWorkspaceTopBarSettingsBinding {
184
+ export interface ControlWorkspaceTopBarBinding extends ControlWorkspaceTopBarSettingsBinding {
187
185
  pillNav: PillNavItem[]
188
186
  currentPillId: string
189
187
  onPillSelect: (item: PillNavItem) => void
190
188
  }
191
189
 
192
- export interface ControlWorkspaceAppTopBarTabsBinding extends ControlWorkspaceTopBarSettingsBinding {
193
- tabs: TopBarTab[]
194
- currentTabId: string
195
- onTabSelect: (tab: TopBarTab) => void
196
- }
190
+ export type ControlWorkspaceAppTopBarPillBinding = ControlWorkspaceTopBarBinding
197
191
 
198
192
  export interface ControlWorkspaceComponentBindings {
199
193
  form: ControlWorkspaceFormBinding
200
194
  sidebar: ControlWorkspaceSidebarBinding
201
- topBar: ComputedRef<ControlWorkspaceAppTopBarPillBinding>
202
- topBarTabs: ComputedRef<ControlWorkspaceAppTopBarTabsBinding>
195
+ topBar: ComputedRef<ControlWorkspaceTopBarBinding>
203
196
  topBarSettings: ControlWorkspaceTopBarSettingsBinding
204
197
  pillNav: ControlWorkspacePillNavBinding
198
+ componentBindings: ComputedRef<ControlComponentBinding[]>
199
+ componentBindingsById: ComputedRef<ControlComponentBindingsById>
205
200
  componentProps: ComputedRef<Record<string, unknown>>
206
201
  componentPropsById: ComputedRef<Record<string, Record<string, unknown>>>
207
202
  }
@@ -217,6 +212,29 @@ export type ControlComponentPropsMap<TValues extends Record<string, unknown> = R
217
212
  export type ControlComponentPropsByIdMap<TValues extends Record<string, unknown> = Record<string, unknown>> =
218
213
  Record<string, ControlComponentPropsMap<TValues>>
219
214
 
215
+ export interface ControlComponentBindingConfig<TValues extends Record<string, unknown> = Record<string, unknown>> {
216
+ id?: string
217
+ component: string
218
+ props?: ControlComponentPropsMap<TValues>
219
+ }
220
+
221
+ export interface ControlComponentBindingRecordConfig<TValues extends Record<string, unknown> = Record<string, unknown>> {
222
+ component: string
223
+ props?: ControlComponentPropsMap<TValues>
224
+ }
225
+
226
+ export type ControlComponentBindingsConfig<TValues extends Record<string, unknown> = Record<string, unknown>> =
227
+ | readonly ControlComponentBindingConfig<TValues>[]
228
+ | Record<string, ControlComponentBindingRecordConfig<TValues>>
229
+
230
+ export interface ControlComponentBinding {
231
+ id: string
232
+ component: string
233
+ props: Record<string, unknown>
234
+ }
235
+
236
+ export type ControlComponentBindingsById = Record<string, ControlComponentBinding>
237
+
220
238
  export interface WellPlateControlPropsOptions<TValues extends Record<string, unknown> = Record<string, unknown>> {
221
239
  selectedWells?: ControlComponentPropSource<TValues>
222
240
  format?: ControlComponentPropSource<TValues>
@@ -276,7 +294,6 @@ export interface UseControlSchemaReturn<TControls extends ControlSchema> {
276
294
  sidebarPanels: Record<string, SidebarToolSection[]>
277
295
  viewIds: string[]
278
296
  viewItems: PillNavItem[]
279
- topBarTabs: TopBarTab[]
280
297
  defaultView: string
281
298
  sectionSchemas: Record<string, ControlFormSchema>
282
299
  sidebar: ControlSidebarBinding
@@ -291,10 +308,12 @@ export interface UseControlWorkspaceReturn<TControls extends ControlSchema>
291
308
  activeView: Ref<string>
292
309
  form: ControlWorkspaceFormBinding
293
310
  sidebar: ControlWorkspaceSidebarBinding
294
- topBar: ControlWorkspaceTopBarBinding
311
+ topBar: ComputedRef<ControlWorkspaceTopBarBinding>
295
312
  pillNav: ControlWorkspacePillNavBinding
296
313
  topBarSettings: ControlWorkspaceTopBarSettingsBinding
297
314
  bindings: ControlWorkspaceComponentBindings
315
+ componentBindings: ComputedRef<ControlComponentBinding[]>
316
+ componentBindingsById: ComputedRef<ControlComponentBindingsById>
298
317
  componentProps: ComputedRef<Record<string, unknown>>
299
318
  componentPropsById: ComputedRef<Record<string, Record<string, unknown>>>
300
319
  setActiveView: (viewId: string) => void
@@ -306,6 +325,12 @@ export interface UseControlWorkspaceReturn<TControls extends ControlSchema>
306
325
  getComponentPropsById: (
307
326
  mappings?: ControlComponentPropsByIdMap<ControlValues<TControls> & Record<string, unknown>>
308
327
  ) => Record<string, Record<string, unknown>>
328
+ getComponentBindings: (
329
+ bindings?: ControlComponentBindingsConfig<ControlValues<TControls> & Record<string, unknown>>
330
+ ) => ControlComponentBinding[]
331
+ getComponentBindingsById: (
332
+ bindings?: ControlComponentBindingsConfig<ControlValues<TControls> & Record<string, unknown>>
333
+ ) => ControlComponentBindingsById
309
334
  }
310
335
 
311
336
  export type ControlValues<TControls extends ControlSchema> = {
@@ -339,16 +364,26 @@ export function defineControls<TControls extends ControlSchema>(controls: TContr
339
364
  return controls
340
365
  }
341
366
 
342
- /** Flatten a simple view/section control data model into ControlWorkspaceView props for generated forms and sidebars. */
367
+ /** Preserve literal SDK component binding ids while marking an object as generated workspace component bindings. */
368
+ export function defineControlComponentBindings<TBindings extends ControlComponentBindingsConfig>(
369
+ bindings: TBindings,
370
+ ): TBindings {
371
+ return bindings
372
+ }
373
+
374
+ /** Create a complete workspace component binding from a simple controls data model for ControlWorkspaceView, generated forms, and sidebars. */
343
375
  export function defineControlModel(model: ControlModel): ControlModelBinding {
344
376
  const {
345
377
  controls: rootControls,
346
378
  sections: rootSections,
347
379
  views: modelViews,
380
+ components,
381
+ componentBindings,
348
382
  componentProps,
349
383
  componentPropsById,
350
384
  ...baseOptions
351
385
  } = model
386
+ const resolvedComponentBindings = componentBindings ?? components
352
387
  const controls: ControlSchema = {}
353
388
  const views: Record<string, ControlViewConfig> = {}
354
389
  const sections: Record<string, ControlSectionConfig> = {}
@@ -418,6 +453,7 @@ export function defineControlModel(model: ControlModel): ControlModelBinding {
418
453
  return {
419
454
  controls,
420
455
  controlOptions,
456
+ ...(resolvedComponentBindings === undefined ? {} : { componentBindings: resolvedComponentBindings }),
421
457
  ...(componentProps === undefined ? {} : { componentProps }),
422
458
  ...(componentPropsById === undefined ? {} : { componentPropsById }),
423
459
  }
@@ -456,6 +492,30 @@ export function controlValuesToComponentProps<TValues extends Record<string, unk
456
492
  return props
457
493
  }
458
494
 
495
+ /** Map control workspace values into named SDK component bindings for direct slot rendering. */
496
+ export function controlValuesToComponentBindings<TValues extends Record<string, unknown>>(
497
+ values: TValues,
498
+ bindings?: ControlComponentBindingsConfig<TValues>,
499
+ ): ControlComponentBinding[] {
500
+ if (bindings === undefined) return []
501
+
502
+ return normalizeControlComponentBindingConfigs(bindings).map(binding => ({
503
+ id: binding.id,
504
+ component: binding.component,
505
+ props: controlValuesToComponentProps(values, binding.props),
506
+ }))
507
+ }
508
+
509
+ /** Map control workspace values into SDK component bindings keyed by binding id. */
510
+ export function controlValuesToComponentBindingsById<TValues extends Record<string, unknown>>(
511
+ values: TValues,
512
+ bindings?: ControlComponentBindingsConfig<TValues>,
513
+ ): ControlComponentBindingsById {
514
+ return Object.fromEntries(
515
+ controlValuesToComponentBindings(values, bindings).map(binding => [binding.id, binding]),
516
+ )
517
+ }
518
+
459
519
  /** Return a default WellPlate prop mapping for generated control workspaces. */
460
520
  export function defineWellPlateControlProps<TValues extends Record<string, unknown> = Record<string, unknown>>(
461
521
  options: WellPlateControlPropsOptions<TValues> = {},
@@ -506,6 +566,24 @@ export function defineWellPlateDoseControlProps<TValues extends Record<string, u
506
566
  }
507
567
  }
508
568
 
569
+ /** Return named WellPlate + DoseCalculator component bindings for one dose-design control model. */
570
+ export function defineWellPlateDoseComponentBindings<TValues extends Record<string, unknown> = Record<string, unknown>>(
571
+ options: WellPlateDoseControlPropsOptions<TValues> = {},
572
+ ): ControlComponentBindingsConfig<TValues> {
573
+ return [
574
+ {
575
+ id: options.plateId ?? 'plate',
576
+ component: 'WellPlate',
577
+ props: defineWellPlateControlProps(options.plate),
578
+ },
579
+ {
580
+ id: options.doseId ?? 'dose',
581
+ component: 'DoseCalculator',
582
+ props: defineDoseCalculatorControlProps(options.dose),
583
+ },
584
+ ]
585
+ }
586
+
509
587
  /** Return a complete ControlWorkspaceView model for WellPlate + DoseCalculator dose design. */
510
588
  export function defineDoseDesignControlModel(
511
589
  options: DoseDesignControlModelOptions = {},
@@ -543,16 +621,19 @@ export function defineDoseDesignControlModel(
543
621
  }
544
622
  }
545
623
 
624
+ const componentProps: WellPlateDoseControlPropsOptions = {
625
+ ...options.componentProps,
626
+ dose: {
627
+ ...(options.componentProps?.dose ?? {}),
628
+ ...(options.includeMolecularWeight && options.componentProps?.dose?.molecularWeight === undefined
629
+ ? { molecularWeight: 'molecularWeight' }
630
+ : {}),
631
+ },
632
+ }
633
+
546
634
  return defineControlModel({
547
- componentPropsById: defineWellPlateDoseControlProps({
548
- ...options.componentProps,
549
- dose: {
550
- ...(options.componentProps?.dose ?? {}),
551
- ...(options.includeMolecularWeight && options.componentProps?.dose?.molecularWeight === undefined
552
- ? { molecularWeight: 'molecularWeight' }
553
- : {}),
554
- },
555
- }),
635
+ componentBindings: defineWellPlateDoseComponentBindings(componentProps),
636
+ componentPropsById: defineWellPlateDoseControlProps(componentProps),
556
637
  views: {
557
638
  [viewId]: {
558
639
  label: options.viewLabel ?? 'Design',
@@ -705,7 +786,7 @@ export function controlsToViewIds(
705
786
  return controlsToViewItems(controls, options).map(item => item.id)
706
787
  }
707
788
 
708
- /** Return AppPillNav-compatible view items for switching generated control sidebars. */
789
+ /** Return AppTopBar pillNav-compatible view items for switching generated control sidebars. */
709
790
  export function controlsToViewItems(
710
791
  controls: ControlSchema,
711
792
  options: ControlSchemaOptions = {},
@@ -715,16 +796,6 @@ export function controlsToViewItems(
715
796
  .map(([id]) => controlViewItem(id, options))
716
797
  }
717
798
 
718
- /** Return AppTopBar-compatible tabs for the same views that drive generated AppSidebar panels. */
719
- export function controlsToTopBarTabs(
720
- controls: ControlSchema,
721
- options: ControlSchemaOptions = {},
722
- ): TopBarTab[] {
723
- return Object.entries(controlsToSidebarPanels(controls, options))
724
- .filter(([, sections]) => sections.length > 0)
725
- .map(([id]) => controlTopBarTab(id, options))
726
- }
727
-
728
799
  /** Return the first generated sidebar view ID, or an empty string when controls render no sidebar panels. */
729
800
  export function getDefaultControlView(
730
801
  controls: ControlSchema,
@@ -779,7 +850,6 @@ export function useControlSchema<TControls extends ControlSchema>(
779
850
  const sidebarPanels = controlsToSidebarPanels(controls, options)
780
851
  const viewItems = controlsToViewItems(controls, options)
781
852
  const viewIds = viewItems.map(item => item.id)
782
- const topBarTabs = controlsToTopBarTabs(controls, options)
783
853
  const defaultView = viewIds[0] ?? ''
784
854
  const sectionSchemas = controlsToSectionFormSchemas(controls, options)
785
855
 
@@ -802,7 +872,6 @@ export function useControlSchema<TControls extends ControlSchema>(
802
872
  sidebarPanels,
803
873
  viewIds,
804
874
  viewItems,
805
- topBarTabs,
806
875
  defaultView,
807
876
  sectionSchemas,
808
877
  sidebar: {
@@ -843,7 +912,6 @@ export function useControlWorkspace<TControls extends ControlSchema>(
843
912
  if (!schema.viewIds.includes(viewId)) return
844
913
  if (activeView.value !== viewId) activeView.value = viewId
845
914
  if (sidebar.activeView !== viewId) sidebar.activeView = viewId
846
- if (topBar.currentTabId !== viewId) topBar.currentTabId = viewId
847
915
  if (pillNav.currentItemId !== viewId) pillNav.currentItemId = viewId
848
916
  }
849
917
 
@@ -876,10 +944,24 @@ export function useControlWorkspace<TControls extends ControlSchema>(
876
944
  ]),
877
945
  )
878
946
  }
947
+
948
+ function getComponentBindings(
949
+ bindings?: ControlComponentBindingsConfig<ControlValues<TControls> & Record<string, unknown>>,
950
+ ): ControlComponentBinding[] {
951
+ return controlValuesToComponentBindings(values, bindings)
952
+ }
953
+
954
+ function getComponentBindingsById(
955
+ bindings?: ControlComponentBindingsConfig<ControlValues<TControls> & Record<string, unknown>>,
956
+ ): ControlComponentBindingsById {
957
+ return controlValuesToComponentBindingsById(values, bindings)
958
+ }
879
959
  const componentProps = computed(() => (
880
960
  model?.componentProps === undefined ? {} : getComponentProps(model.componentProps)
881
961
  ))
882
962
  const componentPropsById = computed(() => getComponentPropsById(model?.componentPropsById))
963
+ const componentBindings = computed(() => getComponentBindings(model?.componentBindings))
964
+ const componentBindingsById = computed(() => getComponentBindingsById(model?.componentBindings))
883
965
  const form = {
884
966
  ...schema.form,
885
967
  modelValue: values,
@@ -899,11 +981,6 @@ export function useControlWorkspace<TControls extends ControlSchema>(
899
981
  values,
900
982
  'onUpdate:values': setValues,
901
983
  }) as ControlWorkspaceSidebarBinding
902
- const topBar = reactive({
903
- tabs: schema.topBarTabs,
904
- currentTabId: activeView.value,
905
- onTabSelect: (tab: TopBarTab) => setActiveView(tab.id),
906
- }) as ControlWorkspaceTopBarBinding
907
984
  const pillNav = reactive({
908
985
  items: schema.viewItems,
909
986
  currentItemId: activeView.value,
@@ -914,32 +991,26 @@ export function useControlWorkspace<TControls extends ControlSchema>(
914
991
  settingsConfig: topBarSettingsConfig,
915
992
  onSettingsValuesChange: setValues,
916
993
  } as ControlWorkspaceTopBarSettingsBinding
917
- const topBarProps = computed<ControlWorkspaceAppTopBarPillBinding>(() => ({
994
+ const topBarProps = computed<ControlWorkspaceTopBarBinding>(() => ({
918
995
  pillNav: pillNav.items,
919
996
  currentPillId: pillNav.currentItemId,
920
997
  onPillSelect: pillNav.onSelect,
921
998
  ...topBarSettingsBinding,
922
999
  }))
923
- const topBarTabsProps = computed<ControlWorkspaceAppTopBarTabsBinding>(() => ({
924
- tabs: topBar.tabs,
925
- currentTabId: topBar.currentTabId,
926
- onTabSelect: topBar.onTabSelect,
927
- ...topBarSettingsBinding,
928
- }))
929
1000
  const bindings: ControlWorkspaceComponentBindings = {
930
1001
  form,
931
1002
  sidebar,
932
1003
  topBar: topBarProps,
933
- topBarTabs: topBarTabsProps,
934
1004
  topBarSettings: topBarSettingsBinding,
935
1005
  pillNav,
1006
+ componentBindings,
1007
+ componentBindingsById,
936
1008
  componentProps,
937
1009
  componentPropsById,
938
1010
  }
939
1011
 
940
1012
  watch(activeView, syncActiveView, { flush: 'sync' })
941
1013
  watch(() => sidebar.activeView, syncActiveView, { flush: 'sync' })
942
- watch(() => topBar.currentTabId, syncActiveView, { flush: 'sync' })
943
1014
  watch(() => pillNav.currentItemId, syncActiveView, { flush: 'sync' })
944
1015
 
945
1016
  return {
@@ -950,10 +1021,12 @@ export function useControlWorkspace<TControls extends ControlSchema>(
950
1021
  topBarSettingsConfig,
951
1022
  form,
952
1023
  sidebar,
953
- topBar,
1024
+ topBar: topBarProps,
954
1025
  pillNav,
955
1026
  topBarSettings: topBarSettingsBinding,
956
1027
  bindings,
1028
+ componentBindings,
1029
+ componentBindingsById,
957
1030
  componentProps,
958
1031
  componentPropsById,
959
1032
  setActiveView,
@@ -961,6 +1034,8 @@ export function useControlWorkspace<TControls extends ControlSchema>(
961
1034
  resetValues,
962
1035
  getComponentProps,
963
1036
  getComponentPropsById,
1037
+ getComponentBindings,
1038
+ getComponentBindingsById,
964
1039
  }
965
1040
  }
966
1041
 
@@ -1158,6 +1233,31 @@ function compactComponentPropsMap<TValues extends Record<string, unknown>>(
1158
1233
  )
1159
1234
  }
1160
1235
 
1236
+ function normalizeControlComponentBindingConfigs<TValues extends Record<string, unknown>>(
1237
+ bindings: ControlComponentBindingsConfig<TValues>,
1238
+ ): Array<Required<Pick<ControlComponentBindingConfig<TValues>, 'id' | 'component'>> & Pick<ControlComponentBindingConfig<TValues>, 'props'>> {
1239
+ if (Array.isArray(bindings)) {
1240
+ const usedIds = new Map<string, number>()
1241
+ return bindings.map(binding => ({
1242
+ id: uniqueComponentBindingId(binding.id ?? binding.component, usedIds),
1243
+ component: binding.component,
1244
+ props: binding.props,
1245
+ }))
1246
+ }
1247
+
1248
+ return Object.entries(bindings).map(([id, binding]) => ({
1249
+ id,
1250
+ component: binding.component,
1251
+ props: binding.props,
1252
+ }))
1253
+ }
1254
+
1255
+ function uniqueComponentBindingId(id: string, usedIds: Map<string, number>): string {
1256
+ const count = usedIds.get(id) ?? 0
1257
+ usedIds.set(id, count + 1)
1258
+ return count === 0 ? id : `${id}-${count + 1}`
1259
+ }
1260
+
1161
1261
  function sourceKey<TValues extends Record<string, unknown>>(key: string): keyof TValues & string {
1162
1262
  return key as keyof TValues & string
1163
1263
  }
@@ -1234,18 +1334,6 @@ function controlViewItem(viewId: string, options: ControlSchemaOptions): PillNav
1234
1334
  ...(config?.to !== undefined ? { to: config.to } : {}),
1235
1335
  ...(config?.href !== undefined ? { href: config.href } : {}),
1236
1336
  ...(config?.disabled !== undefined ? { disabled: config.disabled } : {}),
1237
- }
1238
- }
1239
-
1240
- function controlTopBarTab(viewId: string, options: ControlSchemaOptions): TopBarTab {
1241
- const config = options.views?.[viewId]
1242
- return {
1243
- id: viewId,
1244
- label: config?.label ?? humanize(viewId),
1245
- ...(config?.to !== undefined ? { to: config.to } : {}),
1246
- ...(config?.href !== undefined ? { href: config.href } : {}),
1247
- ...(config?.icon !== undefined ? { icon: config.icon } : {}),
1248
- ...(config?.disabled !== undefined ? { disabled: config.disabled } : {}),
1249
1337
  ...(config?.children !== undefined ? { children: config.children } : {}),
1250
1338
  }
1251
1339
  }
@@ -7,6 +7,7 @@ import {
7
7
  getInjectedPlatformContext,
8
8
  resolveCurrentExperimentId,
9
9
  } from './platformContextHelpers'
10
+ import type { ExperimentSummary, PageSelectorItem } from '../types'
10
11
 
11
12
  export type PluginHttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
12
13
 
@@ -90,7 +91,7 @@ export interface UseCurrentExperimentOptions {
90
91
  immediate?: boolean
91
92
  }
92
93
 
93
- export interface UseCurrentExperimentReturn<TExperiment = unknown> {
94
+ export interface UseCurrentExperimentReturn<TExperiment = ExperimentSummary> {
94
95
  /** Current experiment id resolved from platform injection or URL conventions. */
95
96
  experimentId: ComputedRef<number | undefined>
96
97
  /** Whether a current experiment id is available. */
@@ -114,27 +115,67 @@ export interface UseCurrentExperimentReturn<TExperiment = unknown> {
114
115
  export type PluginEndpointCaller = (payload?: unknown) => Promise<unknown>
115
116
  export type GeneratedPluginClient = Record<string, (...args: any[]) => Promise<any>>
116
117
 
118
+ function normalizeApiBaseUrl(baseUrl: string | undefined): string | undefined {
119
+ if (!baseUrl) return undefined
120
+ return baseUrl.length > 1 ? baseUrl.replace(/\/+$/, '') : baseUrl
121
+ }
122
+
117
123
  function normalizeApiPrefix(prefix: string | undefined): string | undefined {
118
124
  if (!prefix) return undefined
119
- if (prefix.startsWith('/api/')) return prefix
125
+ if (prefix.startsWith('/api/')) return normalizeApiBaseUrl(prefix)
120
126
  if (prefix === '/api') return prefix
121
- if (prefix.startsWith('/')) return `/api${prefix}`
122
- return `/api/${prefix}`
127
+ if (prefix.startsWith('/')) return normalizeApiBaseUrl(`/api${prefix}`)
128
+ return normalizeApiBaseUrl(`/api/${prefix}`)
129
+ }
130
+
131
+ function normalizePluginNavPath(path: string): string {
132
+ const raw = path.trim() || '/'
133
+ const prefixed = raw.startsWith('/') ? raw : `/${raw}`
134
+ return prefixed.replace(/\/+$/, '') || '/'
135
+ }
136
+
137
+ function pluginPageIdFromPath(path: string, fallbackIndex: number): string {
138
+ const normalizedPath = normalizePluginNavPath(path)
139
+ if (normalizedPath === '/') return 'dashboard'
140
+ return normalizedPath.replace(/^\/+/, '').replace(/\/+/g, '-') || `page-${fallbackIndex + 1}`
141
+ }
142
+
143
+ /** Convert PluginMetadata.nav_items into AppTopBar pageSelector items for topbar page switches, homepage PluginCards, and fallback views. */
144
+ export function getPluginPageSelectorItems(contract: PluginContract): PageSelectorItem[] {
145
+ const pluginIcon = contract.plugin.icon ?? ''
146
+ const navItems = contract.plugin.navItems ?? []
147
+ const source: PluginNavItemContract[] = navItems.length > 0
148
+ ? navItems
149
+ : [{
150
+ path: '/',
151
+ label: contract.plugin.name ?? 'Dashboard',
152
+ id: 'dashboard',
153
+ icon: pluginIcon || undefined,
154
+ description: contract.plugin.description,
155
+ }]
156
+
157
+ return source.map((item, index) => ({
158
+ id: item.id || pluginPageIdFromPath(item.path, index),
159
+ label: item.label,
160
+ to: normalizePluginNavPath(item.path),
161
+ icon: item.icon || pluginIcon || undefined,
162
+ hint: item.description || contract.plugin.name,
163
+ }))
123
164
  }
124
165
 
125
166
  /** Resolve the runtime plugin API base URL from env, explicit options, platform injection, or contract metadata. */
126
167
  export function resolvePluginBaseUrl(contract: PluginContract, explicitBaseUrl?: string): string {
127
168
  const envPrefix = (import.meta.env?.VITE_API_PREFIX as string | undefined) || undefined
128
- if (envPrefix) return envPrefix
129
- if (explicitBaseUrl) return explicitBaseUrl
169
+ if (envPrefix) return normalizeApiBaseUrl(envPrefix) ?? envPrefix
170
+ if (explicitBaseUrl) return normalizeApiBaseUrl(explicitBaseUrl) ?? explicitBaseUrl
130
171
 
131
172
  const injected = getInjectedPlatformContext()
132
173
  const platformPrefix =
133
- injected?.plugin?.api_prefix ||
174
+ normalizeApiBaseUrl(injected?.plugin?.api_prefix) ||
134
175
  normalizeApiPrefix(injected?.plugin?.route_prefix)
135
176
  if (platformPrefix) return platformPrefix
136
177
 
137
- return contract.plugin.apiPrefix || '/api'
178
+ return normalizeApiBaseUrl(contract.plugin.apiPrefix) || '/api'
138
179
  }
139
180
 
140
181
  function encodePath(path: string, payload: Record<string, unknown>, pathParams: string[]): string {
@@ -352,7 +393,7 @@ export function usePluginSettings<TSettings = Record<string, unknown>>() {
352
393
  }
353
394
 
354
395
  /** Read and optionally load the current platform experiment for integrated plugin views. */
355
- export function useCurrentExperiment<TExperiment = unknown>(
396
+ export function useCurrentExperiment<TExperiment = ExperimentSummary>(
356
397
  options: UseCurrentExperimentOptions = {},
357
398
  ): UseCurrentExperimentReturn<TExperiment> {
358
399
  const api = useApi({ baseUrl: options.apiBaseUrl ?? getInjectedPlatformContext()?.platformApiUrl })