@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,6 +5,7 @@ import axios, { type AxiosRequestConfig } from 'axios'
5
5
  import {
6
6
  buildPluginEndpointUrl,
7
7
  createPluginClient,
8
+ getPluginPageSelectorItems,
8
9
  resolvePluginBaseUrl,
9
10
  useCurrentExperiment,
10
11
  usePluginClient,
@@ -121,12 +122,44 @@ describe('usePluginClient', () => {
121
122
  it('resolves plugin base URLs without making requests', () => {
122
123
  expect(resolvePluginBaseUrl(contract)).toBe('/api/drp')
123
124
 
124
- expect(resolvePluginBaseUrl(contract, '/api/explicit')).toBe('/api/explicit')
125
+ expect(resolvePluginBaseUrl(
126
+ { ...contract, plugin: { ...contract.plugin, apiPrefix: '/api/drp/' } },
127
+ )).toBe('/api/drp')
128
+ expect(resolvePluginBaseUrl(contract, '/api/explicit/')).toBe('/api/explicit')
125
129
 
126
- vi.stubEnv('VITE_API_PREFIX', '/api/env')
130
+ vi.stubEnv('VITE_API_PREFIX', '/api/env/')
127
131
  expect(resolvePluginBaseUrl(contract, '/api/explicit')).toBe('/api/env')
128
132
  })
129
133
 
134
+ it('normalizes platform-injected plugin base URLs', () => {
135
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
136
+ isIntegrated: true,
137
+ theme: 'light',
138
+ plugin: {
139
+ id: 'drp',
140
+ name: 'DRP',
141
+ version: '1.0.0',
142
+ route_prefix: '/ignored/',
143
+ api_prefix: '/api/platform-drp/',
144
+ },
145
+ }
146
+
147
+ expect(resolvePluginBaseUrl(contract)).toBe('/api/platform-drp')
148
+
149
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
150
+ isIntegrated: true,
151
+ theme: 'light',
152
+ plugin: {
153
+ id: 'drp',
154
+ name: 'DRP',
155
+ version: '1.0.0',
156
+ route_prefix: '/platform-drp/',
157
+ },
158
+ }
159
+
160
+ expect(resolvePluginBaseUrl(contract)).toBe('/api/platform-drp')
161
+ })
162
+
130
163
  it('builds concrete endpoint URLs without making requests', () => {
131
164
  const url = buildPluginEndpointUrl(
132
165
  contract,
@@ -146,6 +179,70 @@ describe('usePluginClient', () => {
146
179
  expect(requestConfigs).toHaveLength(0)
147
180
  })
148
181
 
182
+ it('maps plugin contract nav items to AppTopBar page selector metadata', () => {
183
+ const pageSelectorItems = getPluginPageSelectorItems({
184
+ ...contract,
185
+ plugin: {
186
+ ...contract.plugin,
187
+ name: 'Dose Designer',
188
+ description: 'Build dose designs',
189
+ icon: 'PLUGIN_ICON',
190
+ navItems: [
191
+ { path: '/', label: 'Dashboard', icon: 'DASHBOARD_ICON', description: 'Overview' },
192
+ { id: 'dose-design', path: 'dose-design', label: 'Dose Design' },
193
+ { path: '/qc/review/', label: 'QC Review', icon: 'QC_ICON' },
194
+ ],
195
+ },
196
+ })
197
+
198
+ expect(pageSelectorItems).toEqual([
199
+ {
200
+ id: 'dashboard',
201
+ label: 'Dashboard',
202
+ to: '/',
203
+ icon: 'DASHBOARD_ICON',
204
+ hint: 'Overview',
205
+ },
206
+ {
207
+ id: 'dose-design',
208
+ label: 'Dose Design',
209
+ to: '/dose-design',
210
+ icon: 'PLUGIN_ICON',
211
+ hint: 'Dose Designer',
212
+ },
213
+ {
214
+ id: 'qc-review',
215
+ label: 'QC Review',
216
+ to: '/qc/review',
217
+ icon: 'QC_ICON',
218
+ hint: 'Dose Designer',
219
+ },
220
+ ])
221
+ expect(requestConfigs).toHaveLength(0)
222
+ })
223
+
224
+ it('provides a dashboard page selector fallback when contract nav items are absent', () => {
225
+ expect(getPluginPageSelectorItems({
226
+ ...contract,
227
+ plugin: {
228
+ ...contract.plugin,
229
+ name: 'Dose Designer',
230
+ description: 'Build dose designs',
231
+ icon: 'PLUGIN_ICON',
232
+ navItems: [],
233
+ },
234
+ })).toEqual([
235
+ {
236
+ id: 'dashboard',
237
+ label: 'Dose Designer',
238
+ to: '/',
239
+ icon: 'PLUGIN_ICON',
240
+ hint: 'Build dose designs',
241
+ },
242
+ ])
243
+ expect(requestConfigs).toHaveLength(0)
244
+ })
245
+
149
246
  it('can build path-only endpoint URLs with inferred experiment ids', () => {
150
247
  window.history.replaceState({}, '', '/experiments/42/plugins/drp')
151
248
 
@@ -18,6 +18,7 @@ interface FrontendManifest {
18
18
  templateExports: Array<{ name: string }>
19
19
  templatePacks: Array<{ name: string }>
20
20
  }
21
+ exports: Array<{ name: string }>
21
22
  }
22
23
 
23
24
  const TEST_DIR = dirname(fileURLToPath(import.meta.url))
@@ -42,6 +43,27 @@ function parseComponentExports(): string[] {
42
43
  .sort()
43
44
  }
44
45
 
46
+ function parseRootComponentExports(): string[] {
47
+ const content = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8')
48
+ if (/export\s+\*\s+from\s+['"]\.\/components['"]/.test(content)) {
49
+ return parseComponentExports()
50
+ }
51
+
52
+ const names = new Set<string>()
53
+
54
+ for (const match of content.matchAll(/export\s+\{([^}]*)\}\s+from\s+['"]\.\/components['"]/g)) {
55
+ const body = match[1].replace(/\/\/.*$/gm, '')
56
+ for (const item of body.split(',')) {
57
+ const spec = item.trim()
58
+ if (!spec || spec.startsWith('type ')) continue
59
+ const name = spec.split(/\s+as\s+/, 1)[0].trim()
60
+ if (/^[A-Z]\w*$/.test(name)) names.add(name)
61
+ }
62
+ }
63
+
64
+ return [...names].sort()
65
+ }
66
+
45
67
  function parseDeprecatedComponentExports(): Record<string, string> {
46
68
  const componentsDir = join(SDK_ROOT, 'src', 'components')
47
69
  const content = readFileSync(join(componentsDir, 'index.ts'), 'utf8')
@@ -115,6 +137,27 @@ function parseDocumentedComposableExports(): string[] {
115
137
  return [...new Set(names)].sort()
116
138
  }
117
139
 
140
+ function parseRootComposableExports(): string[] {
141
+ const rootContent = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8')
142
+ const composableExports = parseDocumentedComposableExports()
143
+
144
+ if (/export\s+\*\s+from\s+['"]\.\/composables['"]/.test(rootContent)) {
145
+ return composableExports
146
+ }
147
+
148
+ const names: string[] = []
149
+ for (const match of rootContent.matchAll(/export\s+\{([^}]*)\}\s+from\s+['"]\.\/composables['"]/g)) {
150
+ for (const item of match[1].split(',')) {
151
+ const spec = item.trim()
152
+ if (!spec || spec.startsWith('type ')) continue
153
+ const name = spec.split(/\s+as\s+/, 1)[0].trim()
154
+ if (name) names.push(name)
155
+ }
156
+ }
157
+
158
+ return [...new Set(names)].sort()
159
+ }
160
+
118
161
  function parseTemplateExports(): string[] {
119
162
  const indexContent = readFileSync(join(SDK_ROOT, 'src', 'templates', 'index.ts'), 'utf8')
120
163
  const names = new Set<string>()
@@ -148,6 +191,13 @@ describe('frontend docs catalog coverage', () => {
148
191
  expect(missingFromManifest(documentedComponents, publicComponents)).toEqual([])
149
192
  })
150
193
 
194
+ it('re-exports every public component from the root SDK entrypoint', () => {
195
+ const publicComponents = parseComponentExports()
196
+ const rootComponents = parseRootComponentExports()
197
+
198
+ expect(missingFromManifest(publicComponents, rootComponents)).toEqual([])
199
+ })
200
+
151
201
  it('marks every deprecated public component export with its replacement', () => {
152
202
  const manifest = extractFrontendManifest()
153
203
  const expected = parseDeprecatedComponentExports()
@@ -161,34 +211,28 @@ describe('frontend docs catalog coverage', () => {
161
211
  expect(documented).toEqual(expected)
162
212
  })
163
213
 
164
- it('keeps compatibility exports deprecated at public SDK entrypoints', () => {
214
+ it('keeps retired compatibility APIs out of public SDK entrypoints', () => {
165
215
  const rootIndex = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8')
216
+ const componentsIndex = readFileSync(join(SDK_ROOT, 'src', 'components', 'index.ts'), 'utf8')
166
217
  const composablesIndex = readFileSync(join(SDK_ROOT, 'src', 'composables', 'index.ts'), 'utf8')
167
218
 
168
- expect(rootIndex).toContain(
169
- "/** @deprecated Use AppToastContainer instead. */\nexport { ToastNotification } from './components'",
170
- )
171
- expect(rootIndex).toContain(
172
- "/** @deprecated Use AppTopBar settingsConfig or SettingsModal instead. */\nexport { SettingsButton } from './components'",
173
- )
174
- expect(rootIndex).toContain(
175
- "/** @deprecated Use AutoGroupModal instead. */\nexport { GroupingModal } from './components'",
176
- )
177
- expect(rootIndex).toContain(
178
- "/** @deprecated Use generated plugin clients from `mint sdk generate` instead. */\nexport {\n usePluginApi,\n type UsePluginApiOptions,\n} from './composables'",
179
- )
180
- expect(composablesIndex).toContain(
181
- "/** @deprecated Use generated plugin clients from `mint sdk generate` instead. */\nexport {\n usePluginApi,\n type UsePluginApiOptions,\n} from './usePluginApi'",
182
- )
183
- })
184
-
185
- it('keeps deprecated usePluginApi docs focused on migration', () => {
186
- const content = readFileSync(join(SDK_ROOT, 'src', 'composables', 'usePluginApi.ts'), 'utf8')
187
-
188
- expect(content).toContain('Migration shape for generated plugins')
189
- expect(content).toContain('useGeneratedPluginClient')
190
- expect(content).not.toContain('const api = usePluginApi')
191
- expect(content).not.toContain("fallbackPrefix: '/api/drp'")
219
+ const retiredNames = [
220
+ 'ToastNotification',
221
+ 'SettingsButton',
222
+ 'GroupingModal',
223
+ 'FormSection',
224
+ 'FormFieldRenderer',
225
+ 'AppPageSelector',
226
+ 'AppPillNav',
227
+ 'usePluginApi',
228
+ 'UsePluginApiOptions',
229
+ ]
230
+
231
+ for (const name of retiredNames) {
232
+ expect(rootIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
233
+ expect(componentsIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
234
+ expect(composablesIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
235
+ }
192
236
  })
193
237
 
194
238
  it('documents every public composable/helper export used by docs and doctor', () => {
@@ -200,6 +244,57 @@ describe('frontend docs catalog coverage', () => {
200
244
  expect(missingFromManifest(documentedComposables, publicComposables)).toEqual([])
201
245
  })
202
246
 
247
+ it('documents generated component binding helpers for control models', () => {
248
+ const manifest = extractFrontendManifest()
249
+ const documentedComposables = new Set(manifest.categories.composables.map(item => item.name))
250
+
251
+ expect(documentedComposables.has('defineControlComponentBindings')).toBe(true)
252
+ expect(documentedComposables.has('controlValuesToComponentBindings')).toBe(true)
253
+ expect(documentedComposables.has('controlValuesToComponentBindingsById')).toBe(true)
254
+ expect(documentedComposables.has('defineWellPlateDoseComponentBindings')).toBe(true)
255
+ })
256
+
257
+ it('re-exports every documented composable/helper from the root SDK entrypoint', () => {
258
+ const publicComposables = parseDocumentedComposableExports()
259
+ const rootComposables = parseRootComposableExports()
260
+
261
+ expect(missingFromManifest(publicComposables, rootComposables)).toEqual([])
262
+ })
263
+
264
+ it('re-exports the public types barrel from the root SDK entrypoint', () => {
265
+ const rootIndex = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8')
266
+
267
+ expect(rootIndex).toMatch(/export\s+type\s+\*\s+from\s+['"]\.\/types['"]/)
268
+ })
269
+
270
+ it('re-exports the public stores barrel from the root SDK entrypoint', () => {
271
+ const rootIndex = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8')
272
+
273
+ expect(rootIndex).toMatch(/export\s+\*\s+from\s+['"]\.\/stores['"]/)
274
+ })
275
+
276
+ it('re-exports the public templates barrel from the root SDK entrypoint', () => {
277
+ const rootIndex = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8')
278
+
279
+ expect(rootIndex).toMatch(/export\s+\*\s+from\s+['"]\.\/templates['"]/)
280
+ })
281
+
282
+ it('expands root barrel exports into the docs manifest', () => {
283
+ const manifest = extractFrontendManifest()
284
+ const rootExports = manifest.exports.map(item => item.name)
285
+
286
+ expect(rootExports).toEqual(expect.arrayContaining([
287
+ 'BaseButton',
288
+ 'DoseDesignWorkspaceView',
289
+ 'WellPlate',
290
+ 'useForm',
291
+ 'useReagentSeries',
292
+ 'ConversionResult',
293
+ 'FormSectionSchema',
294
+ 'createWellPlateScreenCollection',
295
+ ]))
296
+ })
297
+
203
298
  it('documents curated biology template packs', () => {
204
299
  const manifest = extractFrontendManifest()
205
300
 
@@ -43,6 +43,8 @@ import {
43
43
  searchBioTemplatePacks,
44
44
  searchBioTemplateCatalog,
45
45
  searchBioTemplatePresets,
46
+ toBioTemplateComponentBindings,
47
+ toBioTemplateComponentBindingsById,
46
48
  toBioTemplateComponentImports,
47
49
  toBioTemplateComponentProps,
48
50
  toBioTemplateComponentPropsByComponent,
@@ -330,14 +332,21 @@ describe('bio data templates', () => {
330
332
  const state = toPlateMapEditorState(plateMapFixture as PlateMapTemplate)
331
333
  const wells = toWellPlateWells(plateMapFixture as PlateMapTemplate)
332
334
  const componentProps = toBioTemplateComponentProps(plateMapFixture as PlateMapTemplate)
335
+ const componentBindings = toBioTemplateComponentBindings(plateMapFixture as PlateMapTemplate)
333
336
  const componentPropsById = toBioTemplateComponentPropsById(plateMapFixture as PlateMapTemplate)
337
+ const componentBindingsById = toBioTemplateComponentBindingsById(plateMapFixture as PlateMapTemplate)
334
338
  const wellPlate = componentProps.find(binding => binding.component === 'WellPlate')
339
+ const wellPlateBinding = componentBindings.find(binding => binding.component === 'WellPlate')
335
340
 
336
341
  expect(state.plates[0].name).toBe('Drug screen plate')
337
342
  expect(wells.A2.sampleType).toBe('drug-a')
338
343
  expect(wellPlate?.propsObject.format).toBe(96)
339
344
  expect((wellPlate?.propsObject.wells as Record<string, { sampleType?: string }>).A2.sampleType).toBe('drug-a')
345
+ expect(wellPlateBinding?.props.format).toBe(96)
346
+ expect(wellPlateBinding?.propNames).toContain('wells')
340
347
  expect(componentPropsById['plate-map:WellPlate']).toEqual(wellPlate?.propsObject)
348
+ expect(componentBindingsById['plate-map:WellPlate'].component).toBe('WellPlate')
349
+ expect(componentBindingsById['plate-map:WellPlate'].props).toEqual(wellPlate?.propsObject)
341
350
  expect((componentPropsById['plate-map:WellPlate'].wells as Record<string, { sampleType?: string }>).A2.sampleType).toBe('drug-a')
342
351
  })
343
352
 
@@ -347,6 +356,7 @@ describe('bio data templates', () => {
347
356
  compounds: { 'Drug A': [10, 1] },
348
357
  })
349
358
  const propsById = toBioTemplateComponentPropsById(collection)
359
+ const bindingsById = toBioTemplateComponentBindingsById(collection)
350
360
  const propsByComponent = toBioTemplateComponentPropsByComponent(collection)
351
361
  const doseCalculatorProps = getBioTemplateComponentProps(collection, 'DoseCalculator')
352
362
  const doseWellPlateProps = getBioTemplateComponentProps(collection, 'WellPlate', {
@@ -362,6 +372,8 @@ describe('bio data templates', () => {
362
372
  'dose-response:WellPlate',
363
373
  ])
364
374
  expect(propsById['dose-response:DoseCalculator'].mode).toBe('serial')
375
+ expect(bindingsById['dose-response:DoseCalculator'].props.mode).toBe('serial')
376
+ expect(bindingsById['dose-response:DoseCalculator'].propNames).toContain('mode')
365
377
  expect(propsById['plate-map:WellPlate'].wells).toBeDefined()
366
378
  expect(propsById['dose-response:WellPlate'].wells).toBeDefined()
367
379
  expect(propsByComponent.WellPlate).toHaveLength(2)
@@ -3,7 +3,7 @@
3
3
  import { computed } from 'vue'
4
4
  import { useDropdownState } from '../composables/useDropdownState'
5
5
  import type { AccountMenuItem } from '../types/components'
6
- import ActionItem from './ActionItem.vue'
6
+ import ActionItemInternal from './internal/ActionItemInternal.vue'
7
7
 
8
8
  interface Props {
9
9
  userName?: string
@@ -86,7 +86,7 @@ function handleSignOut() {
86
86
  <slot name="items" :close="closeSilently">
87
87
  <template v-for="item in items" :key="item.id">
88
88
  <div v-if="item.divider" class="mint-avatar-menu__divider" role="separator" />
89
- <ActionItem
89
+ <ActionItemInternal
90
90
  v-else
91
91
  :href="item.href"
92
92
  :to="item.to"
@@ -99,7 +99,7 @@ function handleSignOut() {
99
99
  </span>
100
100
  <span class="mint-avatar-menu__item-label">{{ item.label }}</span>
101
101
  <span v-if="item.rightLabel" class="mint-avatar-menu__item-right">{{ item.rightLabel }}</span>
102
- </ActionItem>
102
+ </ActionItemInternal>
103
103
  </template>
104
104
  </slot>
105
105
 
@@ -48,6 +48,7 @@ const logScale = ref(false)
48
48
  :sidebar-position="state.sidebarPosition"
49
49
  :floating="state.floating ?? true"
50
50
  :sidebar-width="state.sidebarWidth"
51
+ responsive-sidebar
51
52
  >
52
53
  <template #topbar>
53
54
  <AppTopBar
@@ -216,6 +217,44 @@ const logScale = ref(false)
216
217
  </div>
217
218
  </Variant>
218
219
 
220
+ <Variant title="Responsive Sidebar">
221
+ <div style="height: 500px; max-width: 760px; border: 1px solid var(--border-color, #e2e8f0);">
222
+ <AppLayout
223
+ floating
224
+ responsive-sidebar
225
+ sidebar-width="320px"
226
+ >
227
+ <template #topbar>
228
+ <AppTopBar title="Responsive Analysis" home-path="" />
229
+ </template>
230
+ <template #sidebar>
231
+ <AppSidebar
232
+ variant="analysis"
233
+ title="Feature Filters"
234
+ subtitle="12 active samples"
235
+ :badge="2"
236
+ :panels="toolPanels"
237
+ active-view="analysis"
238
+ >
239
+ <template #section-parameters>
240
+ <BaseSlider v-model="threshold" label="Threshold" :min="0" :max="100" />
241
+ <BaseSelect v-model="method" label="Method" :options="methods" />
242
+ </template>
243
+ <template #section-filters>
244
+ <BaseToggle v-model="showOutliers" label="Exclude outliers" />
245
+ </template>
246
+ </AppSidebar>
247
+ </template>
248
+ <div style="padding: 2rem;">
249
+ <p style="color: var(--text-muted, #94a3b8);">
250
+ At narrow widths, AppLayout owns the sidebar toggle and backdrop,
251
+ while AppSidebar owns the panel chrome and collapse state.
252
+ </p>
253
+ </div>
254
+ </AppLayout>
255
+ </div>
256
+ </Variant>
257
+
219
258
  <Variant title="With Experiment Selector">
220
259
  <ExperimentProvider>
221
260
  <div style="height: 600px;">
@@ -3,7 +3,8 @@
3
3
  * AppLayout - Page layout shell with topbar, sidebar, and main content slots
4
4
  *
5
5
  * Provides a responsive application layout structure with optional topbar and sidebar.
6
- * The sidebar slot is a simple pass-through; visibility is controlled by AppSidebar itself.
6
+ * The sidebar slot is a simple pass-through on desktop. When responsiveSidebar is
7
+ * enabled, AppLayout owns the mobile toggle/backdrop shell around the sidebar.
7
8
  *
8
9
  * @example
9
10
  * ```vue
@@ -20,7 +21,7 @@
20
21
  * </AppLayout>
21
22
  * ```
22
23
  */
23
- import { computed } from 'vue'
24
+ import { computed, ref } from 'vue'
24
25
 
25
26
  interface Props {
26
27
  /** Position of sidebar (left or right side of screen) */
@@ -29,23 +30,61 @@ interface Props {
29
30
  sidebarWidth?: string
30
31
  /** When true, topbar/sidebar/main render as floating cards with gaps */
31
32
  floating?: boolean
33
+ /** Convert the sidebar into a mobile overlay with built-in toggle and backdrop below 1024px. */
34
+ responsiveSidebar?: boolean
35
+ /** Controlled mobile sidebar open state. Desktop sidebar remains visible. */
36
+ sidebarOpen?: boolean
37
+ /** Initial mobile sidebar open state when sidebarOpen is uncontrolled. */
38
+ defaultSidebarOpen?: boolean
39
+ /** Accessible label for the mobile sidebar toggle. */
40
+ sidebarToggleLabel?: string
41
+ /** Accessible label used when the mobile sidebar is open. */
42
+ sidebarCloseLabel?: string
32
43
  }
33
44
 
34
45
  const props = withDefaults(defineProps<Props>(), {
35
46
  sidebarPosition: 'left',
36
47
  sidebarWidth: 'auto',
37
48
  floating: false,
49
+ responsiveSidebar: false,
50
+ sidebarOpen: undefined,
51
+ defaultSidebarOpen: false,
52
+ sidebarToggleLabel: 'Open sidebar',
53
+ sidebarCloseLabel: 'Close sidebar',
54
+ })
55
+
56
+ const emit = defineEmits<{
57
+ 'update:sidebarOpen': [value: boolean]
58
+ }>()
59
+
60
+ const internalSidebarOpen = ref(props.defaultSidebarOpen)
61
+ const sidebarOpenModel = computed({
62
+ get: () => props.sidebarOpen ?? internalSidebarOpen.value,
63
+ set: (value: boolean) => {
64
+ internalSidebarOpen.value = value
65
+ emit('update:sidebarOpen', value)
66
+ },
38
67
  })
39
68
 
40
69
  const layoutClasses = computed(() => [
41
70
  'mint-layout',
42
71
  props.sidebarPosition === 'right' ? 'mint-layout--sidebar-right' : '',
43
72
  props.floating ? 'mint-layout--floating' : '',
73
+ props.responsiveSidebar ? 'mint-layout--responsive-sidebar' : '',
74
+ sidebarOpenModel.value ? 'mint-layout--sidebar-open' : '',
44
75
  ])
45
76
 
46
77
  const sidebarStyle = computed(() => {
47
78
  return props.sidebarWidth !== 'auto' ? { width: props.sidebarWidth } : undefined
48
79
  })
80
+
81
+ function toggleSidebar() {
82
+ sidebarOpenModel.value = !sidebarOpenModel.value
83
+ }
84
+
85
+ function closeSidebar() {
86
+ sidebarOpenModel.value = false
87
+ }
49
88
  </script>
50
89
 
51
90
  <template>
@@ -55,6 +94,48 @@ const sidebarStyle = computed(() => {
55
94
  </div>
56
95
 
57
96
  <div class="mint-layout__body">
97
+ <button
98
+ v-if="responsiveSidebar && $slots.sidebar"
99
+ type="button"
100
+ class="mint-layout__sidebar-toggle"
101
+ :aria-label="sidebarOpenModel ? sidebarCloseLabel : sidebarToggleLabel"
102
+ :aria-expanded="sidebarOpenModel"
103
+ @click="toggleSidebar"
104
+ >
105
+ <svg
106
+ v-if="!sidebarOpenModel"
107
+ class="mint-layout__sidebar-toggle-icon"
108
+ viewBox="0 0 24 24"
109
+ fill="none"
110
+ stroke="currentColor"
111
+ stroke-width="2"
112
+ stroke-linecap="round"
113
+ stroke-linejoin="round"
114
+ aria-hidden="true"
115
+ >
116
+ <path d="M4 6h16M4 12h16M4 18h16" />
117
+ </svg>
118
+ <svg
119
+ v-else
120
+ class="mint-layout__sidebar-toggle-icon"
121
+ viewBox="0 0 24 24"
122
+ fill="none"
123
+ stroke="currentColor"
124
+ stroke-width="2"
125
+ stroke-linecap="round"
126
+ stroke-linejoin="round"
127
+ aria-hidden="true"
128
+ >
129
+ <path d="M6 18 18 6M6 6l12 12" />
130
+ </svg>
131
+ </button>
132
+
133
+ <div
134
+ v-if="responsiveSidebar && $slots.sidebar && sidebarOpenModel"
135
+ class="mint-layout__sidebar-backdrop"
136
+ @click="closeSidebar"
137
+ />
138
+
58
139
  <div
59
140
  v-if="$slots.sidebar"
60
141
  class="mint-layout__sidebar"
@@ -2,7 +2,7 @@
2
2
  /** Dropdown menu for switching between installed plugins with color swatches, version badges, and an install link. */
3
3
  import { useDropdownState } from '../composables/useDropdownState'
4
4
  import type { PluginSwitcherPlugin } from '../types/components'
5
- import ActionItem from './ActionItem.vue'
5
+ import ActionItemInternal from './internal/ActionItemInternal.vue'
6
6
 
7
7
  interface Props {
8
8
  current: PluginSwitcherPlugin
@@ -74,7 +74,7 @@ function handleInstall() {
74
74
 
75
75
  <div v-if="isOpen" class="mint-plugin-switcher__menu" role="menu">
76
76
  <div class="mint-plugin-switcher__menu-title">Switch plugin</div>
77
- <ActionItem
77
+ <ActionItemInternal
78
78
  v-for="plugin in plugins"
79
79
  :key="plugin.id"
80
80
  :href="plugin.href"
@@ -107,13 +107,13 @@ function handleInstall() {
107
107
  >
108
108
  <path d="M5 13l4 4L19 7" />
109
109
  </svg>
110
- </ActionItem>
110
+ </ActionItemInternal>
111
111
 
112
112
  <template v-if="plugins.length && (installHref || installTo || $slots.install)">
113
113
  <div class="mint-plugin-switcher__divider" role="separator" />
114
114
  </template>
115
115
  <slot name="install">
116
- <ActionItem
116
+ <ActionItemInternal
117
117
  v-if="installHref || installTo"
118
118
  :href="installHref"
119
119
  :to="installTo"
@@ -126,7 +126,7 @@ function handleInstall() {
126
126
  <line x1="5" y1="12" x2="19" y2="12" />
127
127
  </svg>
128
128
  <span>{{ installLabel }}</span>
129
- </ActionItem>
129
+ </ActionItemInternal>
130
130
  </slot>
131
131
  </div>
132
132
  </div>