@morscherlab/mint-sdk 1.2.0-beta.9 → 1.2.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 (215) hide show
  1. package/README.md +30 -0
  2. package/dist/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-CJdNlCZb.js} +21 -14
  3. package/dist/ExperimentPopover-CJdNlCZb.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-DHE9k8TP.js} +5 -5
  5. package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DHE9k8TP.js.map} +1 -1
  6. package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-DOcCf0Vo.js} +41 -18
  7. package/dist/SettingsModal-DOcCf0Vo.js.map +1 -0
  8. package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-jhF9wUkU.js} +19 -9
  9. package/dist/Skeleton-jhF9wUkU.js.map +1 -0
  10. package/dist/__tests__/apiErrors.test.d.ts +1 -0
  11. package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
  12. package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
  13. package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
  14. package/dist/__tests__/components/FormField.test.d.ts +1 -0
  15. package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
  16. package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
  17. package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
  18. package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
  19. package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
  20. package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
  21. package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
  22. package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
  23. package/dist/apiErrors.d.ts +26 -0
  24. package/dist/components/AppSidebar.vue.d.ts +7 -7
  25. package/dist/components/BaseInput.vue.d.ts +1 -1
  26. package/dist/components/BasePill.vue.d.ts +3 -0
  27. package/dist/components/BaseSelect.vue.d.ts +1 -1
  28. package/dist/components/BaseTabs.vue.d.ts +1 -4
  29. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  30. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  31. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
  32. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  33. package/dist/components/DatePicker.vue.d.ts +1 -1
  34. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  35. package/dist/components/FileBrowserModal.vue.d.ts +99 -0
  36. package/dist/components/FilePicker.vue.d.ts +32 -0
  37. package/dist/components/FileUploader.vue.d.ts +8 -2
  38. package/dist/components/FormField.vue.d.ts +8 -1
  39. package/dist/components/FormulaInput.vue.d.ts +1 -1
  40. package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
  41. package/dist/components/MobileSupportGate.vue.d.ts +1 -1
  42. package/dist/components/MoleculeInput.vue.d.ts +1 -1
  43. package/dist/components/NumberInput.vue.d.ts +1 -1
  44. package/dist/components/PlotlyChart.vue.d.ts +59 -0
  45. package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
  46. package/dist/components/ResourceCard.vue.d.ts +1 -1
  47. package/dist/components/SecretInput.vue.d.ts +1 -1
  48. package/dist/components/SequenceInput.vue.d.ts +1 -1
  49. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  50. package/dist/components/TagsInput.vue.d.ts +1 -1
  51. package/dist/components/TimePicker.vue.d.ts +1 -1
  52. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  53. package/dist/components/UnitInput.vue.d.ts +1 -1
  54. package/dist/components/WellPlate.vue.d.ts +1 -1
  55. package/dist/components/fileBrowserPresentation.d.ts +10 -0
  56. package/dist/components/index.d.ts +4 -0
  57. package/dist/components/index.js +8 -8
  58. package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
  59. package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
  60. package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
  61. package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
  62. package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
  63. package/dist/{components-CgAUzF8F.js → components-Cp8DcNrU.js} +4363 -1730
  64. package/dist/components-Cp8DcNrU.js.map +1 -0
  65. package/dist/composables/filePicker/localFiles.d.ts +3 -0
  66. package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -0
  67. package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
  68. package/dist/composables/filePicker/validation.d.ts +5 -0
  69. package/dist/composables/index.d.ts +2 -0
  70. package/dist/composables/index.js +10 -10
  71. package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
  72. package/dist/composables/useApi.d.ts +14 -7
  73. package/dist/composables/useFileBrowser.d.ts +55 -0
  74. package/dist/composables/useFilePicker.d.ts +189 -0
  75. package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
  76. package/dist/composables/useToast.d.ts +22 -1
  77. package/dist/{composables-B_pia3hO.js → composables-D_4qqOlp.js} +300 -11
  78. package/dist/composables-D_4qqOlp.js.map +1 -0
  79. package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
  80. package/dist/errors-BLuqMee5.js.map +1 -0
  81. package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
  82. package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
  83. package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
  84. package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
  85. package/dist/index.d.ts +1 -0
  86. package/dist/index.js +15 -15
  87. package/dist/install.js +2 -2
  88. package/dist/stores/index.js +2 -2
  89. package/dist/styles.css +2957 -1708
  90. package/dist/templates/index.js +2 -2
  91. package/dist/{templates-Qq113pyZ.js → templates-Dd9bBSs6.js} +2 -2
  92. package/dist/{templates-Qq113pyZ.js.map → templates-Dd9bBSs6.js.map} +1 -1
  93. package/dist/types/components.d.ts +21 -0
  94. package/dist/types/fileBrowserTypes.d.ts +77 -0
  95. package/dist/types/filePicker.d.ts +77 -0
  96. package/dist/types/form-builder.d.ts +2 -0
  97. package/dist/types/generated-ui.d.ts +2 -0
  98. package/dist/types/index.d.ts +3 -1
  99. package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-BB2GRPhW.js} +190 -105
  100. package/dist/useControlSchema-BB2GRPhW.js.map +1 -0
  101. package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
  102. package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
  103. package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-G_CAOFTX.js} +4 -4
  104. package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
  105. package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-BnGSdSRG.js} +2 -2
  106. package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-BnGSdSRG.js.map} +1 -1
  107. package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-CTxuzLIX.js} +70 -13
  108. package/dist/validation-CTxuzLIX.js.map +1 -0
  109. package/package.json +1 -1
  110. package/src/__tests__/apiErrors.test.ts +81 -0
  111. package/src/__tests__/components/AppSidebar.test.ts +84 -94
  112. package/src/__tests__/components/AppToastContainer.test.ts +55 -1
  113. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
  114. package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
  115. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
  116. package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
  117. package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
  118. package/src/__tests__/components/FilePicker.state.test.ts +287 -0
  119. package/src/__tests__/components/FilePicker.test.ts +487 -0
  120. package/src/__tests__/components/FormBuilder.test.ts +26 -0
  121. package/src/__tests__/components/FormField.test.ts +26 -0
  122. package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
  123. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
  124. package/src/__tests__/components/NumberInput.test.ts +105 -0
  125. package/src/__tests__/components/PlotlyChart.test.ts +305 -0
  126. package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
  127. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
  128. package/src/__tests__/components/SegmentedControl.test.ts +19 -0
  129. package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
  130. package/src/__tests__/composables/localFiles.test.ts +57 -0
  131. package/src/__tests__/composables/useApi.test.ts +92 -9
  132. package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
  133. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
  134. package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
  135. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
  136. package/src/__tests__/composables/usePluginClient.test.ts +3 -0
  137. package/src/__tests__/generatedUi.test.ts +111 -0
  138. package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
  139. package/src/apiErrors.ts +85 -0
  140. package/src/components/AppLayout.story.vue +1 -1
  141. package/src/components/AppSidebar.story.vue +7 -10
  142. package/src/components/AppSidebar.vue +178 -58
  143. package/src/components/AppToastContainer.vue +65 -23
  144. package/src/components/BasePill.story.vue +2 -0
  145. package/src/components/BasePill.vue +4 -0
  146. package/src/components/BaseTabs.story.vue +0 -42
  147. package/src/components/BaseTabs.vue +3 -6
  148. package/src/components/ExperimentPopover.vue +10 -2
  149. package/src/components/FileBrowserModal.story.vue +203 -0
  150. package/src/components/FileBrowserModal.vue +384 -0
  151. package/src/components/FilePicker.story.vue +44 -0
  152. package/src/components/FilePicker.vue +284 -0
  153. package/src/components/FileUploader.vue +42 -15
  154. package/src/components/FormField.vue +10 -3
  155. package/src/components/GeneratedPathInput.vue +11 -7
  156. package/src/components/GeneratedPlotlyResult.vue +34 -69
  157. package/src/components/GeneratedResultRenderer.vue +0 -5
  158. package/src/components/NumberInput.story.vue +32 -13
  159. package/src/components/NumberInput.vue +116 -55
  160. package/src/components/PlotlyChart.story.vue +61 -0
  161. package/src/components/PlotlyChart.vue +233 -0
  162. package/src/components/PluginWorkspaceView.props.ts +3 -3
  163. package/src/components/SegmentedControl.vue +2 -2
  164. package/src/components/fileBrowserPresentation.ts +39 -0
  165. package/src/components/index.ts +5 -0
  166. package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
  167. package/src/components/internal/FileBrowserListingInternal.vue +197 -0
  168. package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
  169. package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
  170. package/src/components/internal/FilePickerTreeInternal.vue +238 -0
  171. package/src/components/internal/FormFieldRendererInternal.vue +17 -0
  172. package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
  173. package/src/composables/filePicker/localFiles.ts +65 -0
  174. package/src/composables/filePicker/usePickerNavigation.ts +220 -0
  175. package/src/composables/filePicker/usePickerSelection.ts +270 -0
  176. package/src/composables/filePicker/validation.ts +41 -0
  177. package/src/composables/index.ts +7 -0
  178. package/src/composables/pluginWorkspaceModel.ts +0 -3
  179. package/src/composables/useApi.ts +57 -13
  180. package/src/composables/useFileBrowser.ts +314 -0
  181. package/src/composables/useFilePicker.ts +366 -0
  182. package/src/composables/useGeneratedAnalysis.ts +10 -17
  183. package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
  184. package/src/composables/usePluginClient.ts +3 -0
  185. package/src/composables/usePluginWorkspace.ts +1 -2
  186. package/src/composables/useToast.ts +21 -5
  187. package/src/generated/schema.ts +58 -18
  188. package/src/index.ts +3 -0
  189. package/src/styles/components/app-sidebar.css +282 -121
  190. package/src/styles/components/file-browser-modal.css +489 -0
  191. package/src/styles/components/file-picker.css +502 -0
  192. package/src/styles/components/form-builder.css +1 -1
  193. package/src/styles/components/form-field.css +28 -0
  194. package/src/styles/components/icon-button.css +9 -0
  195. package/src/styles/components/number-input.css +73 -91
  196. package/src/styles/components/pill.css +49 -51
  197. package/src/styles/components/segmented-control.css +16 -11
  198. package/src/styles/components/tabs.css +18 -34
  199. package/src/styles/components/toast.css +136 -27
  200. package/src/styles/index.css +3 -0
  201. package/src/styles/variables.css +25 -5
  202. package/src/types/components.ts +23 -0
  203. package/src/types/fileBrowserTypes.ts +85 -0
  204. package/src/types/filePicker.ts +72 -0
  205. package/src/types/form-builder.ts +2 -0
  206. package/src/types/generated-ui.ts +2 -0
  207. package/src/types/index.ts +16 -0
  208. package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
  209. package/dist/SettingsModal-Dphh3RrI.js.map +0 -1
  210. package/dist/Skeleton-DxtwJ5gt.js.map +0 -1
  211. package/dist/components-CgAUzF8F.js.map +0 -1
  212. package/dist/composables-B_pia3hO.js.map +0 -1
  213. package/dist/errors-_W0-LbA6.js.map +0 -1
  214. package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
  215. package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
@@ -184,6 +184,7 @@ describe('usePluginClient', () => {
184
184
 
185
185
  expect(requestConfigs[0]!.baseURL).toBe('/api/drp')
186
186
  expect(requestConfigs[0]!.url).toBe('/health')
187
+ expect(requestConfigs[0]).toMatchObject({ _mintTypedErrors: true })
187
188
  })
188
189
 
189
190
  it('omits MINT auth transport for a public generated endpoint', async () => {
@@ -396,6 +397,7 @@ describe('usePluginClient', () => {
396
397
  expect(requestConfigs[0]!.url).toBe('/schedule/import')
397
398
  expect(requestConfigs[0]!.params).toEqual({ instrument_id: 'orbitrap', mode: 'replace' })
398
399
  expect(requestConfigs[0]!.headers).toEqual({ 'Content-Type': undefined })
400
+ expect(requestConfigs[0]).toMatchObject({ _mintTypedErrors: true })
399
401
  expect(requestBodies[0]).toBeInstanceOf(FormData)
400
402
  const form = requestBodies[0] as FormData
401
403
  expect(form.get('file')).toBeInstanceOf(Blob)
@@ -462,6 +464,7 @@ describe('usePluginClient', () => {
462
464
  expect(requestConfigs[0]!.url).toBe('/schedule/export')
463
465
  expect(requestConfigs[0]!.params).toEqual({ instrument_id: 'orbitrap', start: '2026-05-27' })
464
466
  expect(requestConfigs[0]!.responseType).toBe('blob')
467
+ expect(requestConfigs[0]).toMatchObject({ _mintTypedErrors: true })
465
468
  })
466
469
 
467
470
  it('downloads generated POST endpoint blobs with the contract body and request options', async () => {
@@ -6,6 +6,8 @@ import {
6
6
  normalizeGeneratedJobInput,
7
7
  } from '../generated/schema'
8
8
  import type { GeneratedJobDefinition } from '../types/generated-ui'
9
+ import { validateFieldValue } from '../composables/useFormValidation'
10
+ import { buildRules } from '../composables/formBuilderSchema'
9
11
 
10
12
  const definition: GeneratedJobDefinition = {
11
13
  id: 'summarize',
@@ -30,6 +32,115 @@ const definition: GeneratedJobDefinition = {
30
32
  }
31
33
 
32
34
  describe('generated UI schema adapter', () => {
35
+ it('allows present empty strings, arrays, nullable values and false', () => {
36
+ const requiredDefinition: GeneratedJobDefinition = {
37
+ ...definition,
38
+ input_schema: {
39
+ properties: {
40
+ nullable: { anyOf: [{ type: 'string' }, { type: 'null' }] },
41
+ text: { type: 'string' },
42
+ labels: { type: 'array', items: { type: 'string' } },
43
+ enabled: { type: 'boolean' },
44
+ nonempty: { type: 'string', minLength: 1 },
45
+ },
46
+ required: ['nullable', 'text', 'labels', 'enabled', 'nonempty'],
47
+ },
48
+ }
49
+ const fields = generatedJobFormSchema(requiredDefinition).sections[0]!.fields
50
+ const rules = buildRules(fields)
51
+ const values = { nullable: null, text: '', labels: [], enabled: false, nonempty: 'valid' }
52
+ expect(fields.map(field => validateFieldValue(values[field.name as keyof typeof values], rules[field.name], values)))
53
+ .toEqual([null, null, null, null, null])
54
+ expect(generatedJobDefaults(requiredDefinition)).toEqual({ nullable: null })
55
+ expect(validateFieldValue('', rules.nonempty, {})).not.toBeNull()
56
+ expect(normalizeGeneratedJobInput(requiredDefinition, values)).toEqual(values)
57
+ })
58
+
59
+ it('supplies fixed literal values and resolves array enums and numeric items', () => {
60
+ const arrayDefinition: GeneratedJobDefinition = {
61
+ ...definition,
62
+ input_schema: {
63
+ $defs: { Choice: { type: 'integer', enum: [1, 2] } },
64
+ properties: {
65
+ kind: { type: 'string', const: 'fixed' },
66
+ choices: { type: 'array', items: { $ref: '#/$defs/Choice' } },
67
+ counts: { type: 'array', items: { type: 'integer' } },
68
+ scales: { type: 'array', items: { type: 'number' } },
69
+ labels: { type: 'array', items: { type: 'string' } },
70
+ flags: { type: 'array', items: { type: 'boolean' } },
71
+ },
72
+ },
73
+ }
74
+ expect(generatedJobDefaults(arrayDefinition)).toEqual({ kind: 'fixed' })
75
+ expect(generatedJobFormSchema(arrayDefinition).sections[0]!.fields).toMatchObject([
76
+ { name: 'kind', defaultValue: 'fixed', disabled: true },
77
+ { name: 'choices', type: 'multiselect', props: { options: [1, 2] } },
78
+ { name: 'counts', type: 'tags' },
79
+ { name: 'scales', type: 'tags' },
80
+ { name: 'labels', type: 'tags', props: { allowDuplicates: true } },
81
+ { name: 'flags', type: 'tags' },
82
+ ])
83
+ expect(normalizeGeneratedJobInput(arrayDefinition, {
84
+ counts: ['1', '2'], scales: ['0.5', '1.5'], choices: [1, 2], labels: ['1', '1'],
85
+ })).toEqual({ counts: [1, 2], scales: [0.5, 1.5], choices: [1, 2], labels: ['1', '1'] })
86
+ expect(normalizeGeneratedJobInput(arrayDefinition, { counts: ['bad', '1.5'] }))
87
+ .toEqual({ counts: ['bad', 1.5] })
88
+ expect(normalizeGeneratedJobInput(arrayDefinition, { flags: ['true', 'false', true, 'bad'] }))
89
+ .toEqual({ flags: [true, false, true, 'bad'] })
90
+ })
91
+
92
+ it('applies integer bounds and numeric steps to the existing controls', () => {
93
+ const boundedDefinition: GeneratedJobDefinition = {
94
+ ...definition,
95
+ input_schema: { properties: {
96
+ count: { type: 'integer', exclusiveMinimum: 0, exclusiveMaximum: 10, multipleOf: 2 },
97
+ fraction: { type: 'number', minimum: 0, maximum: 1, multipleOf: 0.1 },
98
+ } },
99
+ }
100
+ const fields = generatedJobFormSchema(boundedDefinition).sections[0]!.fields
101
+ const rules = buildRules(fields)
102
+ expect(fields).toMatchObject([
103
+ { validation: { min: 2, max: 8 }, props: { min: 2, max: 8, step: 2 } },
104
+ { validation: { min: 0, max: 1 }, props: { min: 0, max: 1, step: 0.1 } },
105
+ ])
106
+ expect(validateFieldValue(0, rules.count, {})).not.toBeNull()
107
+ expect(validateFieldValue(10, rules.count, {})).not.toBeNull()
108
+ })
109
+
110
+ it('preserves field labels and hints outside nullable schemas and enum references', () => {
111
+ const annotatedDefinition: GeneratedJobDefinition = {
112
+ ...definition,
113
+ input_schema: {
114
+ type: 'object',
115
+ $defs: { Method: { type: 'string', enum: ['fast', 'robust'], title: 'Method' } },
116
+ properties: {
117
+ count: {
118
+ anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }],
119
+ title: 'Optional sample count',
120
+ description: 'Leave empty to use all samples',
121
+ default: null,
122
+ },
123
+ method: {
124
+ $ref: '#/$defs/Method',
125
+ title: 'Analysis method',
126
+ description: 'Choose the analysis method',
127
+ },
128
+ },
129
+ },
130
+ }
131
+
132
+ expect(generatedJobFormSchema(annotatedDefinition).sections[0]?.fields).toMatchObject([
133
+ {
134
+ name: 'count', type: 'number', label: 'Optional sample count',
135
+ hint: 'Leave empty to use all samples', defaultValue: null, validation: { min: 1 },
136
+ },
137
+ {
138
+ name: 'method', type: 'select', label: 'Analysis method',
139
+ hint: 'Choose the analysis method', props: { options: ['fast', 'robust'] },
140
+ },
141
+ ])
142
+ })
143
+
33
144
  it('maps the supported JSON Schema subset to standard FormBuilder fields', () => {
34
145
  expect(generatedJobFormSchema(definition).sections[0]?.fields).toMatchObject([
35
146
  { name: 'threshold', type: 'number', validation: { required: true, min: 0, max: 10 } },
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url))
7
+ const componentsDir = resolve(here, '../../styles/components')
8
+
9
+ // Every color in these sheets must resolve from a token so dark mode remaps it.
10
+ const TOKEN_ONLY = ['pill.css', 'tabs.css', 'segmented-control.css', 'toast.css']
11
+
12
+ describe('token-only component stylesheets', () => {
13
+ it.each(TOKEN_ONLY)('%s has no rgba() literal', (file) => {
14
+ expect(readFileSync(join(componentsDir, file), 'utf-8')).not.toMatch(/rgba?\(/)
15
+ })
16
+ })
@@ -0,0 +1,85 @@
1
+ export interface MintApiErrorEnvelope {
2
+ code: string
3
+ message: string
4
+ status: number
5
+ request_id: string | null
6
+ details: unknown
7
+ detail?: unknown
8
+ }
9
+
10
+ const STATUS_MESSAGES: Readonly<Record<number, string>> = {
11
+ 400: 'Bad request',
12
+ 401: 'Authentication required',
13
+ 403: 'Permission denied',
14
+ 404: 'Resource not found',
15
+ 405: 'Method not allowed',
16
+ 409: 'Conflict',
17
+ 413: 'Payload too large',
18
+ 415: 'Unsupported media type',
19
+ 422: 'Validation error',
20
+ 429: 'Rate limit exceeded',
21
+ 500: 'Internal server error',
22
+ 502: 'Bad gateway',
23
+ 503: 'Service unavailable',
24
+ }
25
+
26
+ function asRecord(value: unknown): Record<string, unknown> | null {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
28
+ return value as Record<string, unknown>
29
+ }
30
+
31
+ /** Public error thrown by typed MINT SDK requests. */
32
+ export class MintApiError extends Error {
33
+ override readonly name: string = 'MintApiError'
34
+
35
+ constructor(
36
+ message: string,
37
+ readonly code: string,
38
+ readonly status: number,
39
+ readonly requestId: string | null,
40
+ readonly details: unknown,
41
+ readonly detail: unknown,
42
+ readonly body: unknown,
43
+ readonly cause?: unknown,
44
+ ) {
45
+ super(message)
46
+ }
47
+ }
48
+
49
+ export interface MintApiErrorOptions {
50
+ requestId?: string | null
51
+ cause?: unknown
52
+ }
53
+
54
+ /** Normalize current typed envelopes and the previous FastAPI detail shape. */
55
+ export function mintApiErrorFromResponse(
56
+ status: number,
57
+ payload: unknown,
58
+ options: MintApiErrorOptions = {},
59
+ ): MintApiError {
60
+ const body = asRecord(payload)
61
+ const legacyDetail = body?.detail
62
+ const inner = asRecord(legacyDetail) ?? body
63
+ const text = (value: unknown): string | undefined =>
64
+ typeof value === 'string' && value ? value : undefined
65
+ // Complete envelopes own the public code; older wrappers defer to the plugin payload.
66
+ const canonical = typeof body?.status === 'number' && text(body.code) && text(body.message)
67
+ const code = (canonical ? text(body.code) : text(inner?.error) ?? text(inner?.code))
68
+ ?? text(body?.code) ?? 'unknown'
69
+ const message = (canonical ? text(body.message) : text(inner?.message))
70
+ ?? text(body?.message) ?? text(legacyDetail)
71
+ ?? STATUS_MESSAGES[status] ?? `HTTP ${status}`
72
+ const details = body?.details ?? inner?.details ?? null
73
+ const requestId = text(body?.request_id) ?? text(inner?.request_id) ?? options.requestId ?? null
74
+
75
+ return new MintApiError(
76
+ message,
77
+ code,
78
+ status,
79
+ requestId,
80
+ details,
81
+ legacyDetail,
82
+ payload,
83
+ options.cause,
84
+ )
85
+ }
@@ -229,7 +229,7 @@ const logScale = ref(false)
229
229
  </template>
230
230
  <template #sidebar>
231
231
  <AppSidebar
232
- variant="analysis"
232
+ :floating="false"
233
233
  title="Feature Filters"
234
234
  subtitle="12 active samples"
235
235
  :badge="2"
@@ -16,7 +16,7 @@ import { useBioTemplateWorkspace } from '../composables/useBioTemplateWorkspace'
16
16
  import { createWellPlateScreenCollection } from '../templates'
17
17
  import type { SidebarToolSection } from '../types'
18
18
 
19
- type SidebarVariant = 'analysis' | 'default'
19
+ type SidebarDensity = 'compact' | 'comfortable'
20
20
 
21
21
  /* --- Icon paths (Lucide-style) --- */
22
22
  const icons = {
@@ -256,7 +256,7 @@ function handleToggle(id: string, value: boolean) {
256
256
  function initSimpleToolkit() {
257
257
  return {
258
258
  activeView: 'analysis',
259
- variant: 'analysis' as SidebarVariant,
259
+ density: 'compact' as SidebarDensity,
260
260
  floating: false,
261
261
  width: '20rem',
262
262
  side: 'left' as const,
@@ -351,7 +351,6 @@ function initSimpleToolkit() {
351
351
  <Variant title="Schema Driven Controls">
352
352
  <div style="padding: 2rem; height: 520px; position: relative; background: var(--bg-primary, #f1f5f9);">
353
353
  <AppSidebar
354
- variant="analysis"
355
354
  title="Peak Picking"
356
355
  subtitle="Current experiment"
357
356
  :controls="schemaControls"
@@ -365,7 +364,6 @@ function initSimpleToolkit() {
365
364
  <div style="padding: 2rem; height: 560px; position: relative; background: var(--bg-primary, #f1f5f9);">
366
365
  <AppSidebar
367
366
  v-model="modelDrivenValues"
368
- variant="analysis"
369
367
  title="Peak Picking"
370
368
  subtitle="Current experiment"
371
369
  :model="modelDrivenSidebarModel"
@@ -376,7 +374,6 @@ function initSimpleToolkit() {
376
374
  <Variant title="Route Owned Shell">
377
375
  <div style="padding: 2rem; height: 560px; display: flex; gap: 1rem; background: var(--bg-primary, #f1f5f9);">
378
376
  <AppSidebar
379
- variant="analysis"
380
377
  title="Sequence"
381
378
  subtitle="Acquisition run"
382
379
  content-id="seqgen-sidebar"
@@ -466,7 +463,7 @@ function initSimpleToolkit() {
466
463
  </div>
467
464
  <AppSidebar
468
465
  :panels="simplePanels"
469
- :variant="state.variant"
466
+ :density="state.density"
470
467
  :active-view="state.activeView"
471
468
  :floating="state.floating"
472
469
  :width="state.width"
@@ -531,11 +528,11 @@ function initSimpleToolkit() {
531
528
  :options="['analysis', 'results', 'settings'].map(v => ({ label: v, value: v }))"
532
529
  />
533
530
  <HstSelect
534
- v-model="state.variant"
535
- title="Variant"
531
+ v-model="state.density"
532
+ title="Density"
536
533
  :options="[
537
- { label: 'Analysis', value: 'analysis' },
538
- { label: 'Default', value: 'default' },
534
+ { label: 'Compact', value: 'compact' },
535
+ { label: 'Comfortable', value: 'comfortable' },
539
536
  ]"
540
537
  />
541
538
  <HstCheckbox v-model="state.floating" title="Floating" />
@@ -3,8 +3,9 @@
3
3
  * AppSidebar - Context-sensitive toolkit panel
4
4
  *
5
5
  * Shows tool sections relevant to the active view. Sections are defined via
6
- * the `panels` config and rendered as CollapsibleCards. Controls can be
7
- * provided through named slots or auto-rendered from FormBuilder schemas.
6
+ * the `panels` config and rendered as flat groups (header row + hairline,
7
+ * no nested card). Controls can be provided through named slots or
8
+ * auto-rendered from FormBuilder schemas.
8
9
  *
9
10
  * When activeView is omitted, the first non-empty panel view is selected.
10
11
  * When no view has matching panels, the sidebar hides entirely.
@@ -15,7 +16,7 @@
15
16
  * ```
16
17
  */
17
18
  import { computed, ref, useSlots, type Slots } from 'vue'
18
- import type { PillNavItem, SidebarToolSection } from '../types'
19
+ import type { PillNavItem, SidebarToolSection, SidebarToolSectionAction } from '../types'
19
20
  import type { FormEnhancements, FormSchema } from '../types/form-builder'
20
21
  import {
21
22
  controlsToSectionFormSchemas,
@@ -28,7 +29,6 @@ import {
28
29
  type ControlSchema,
29
30
  type ControlWorkspaceOptions,
30
31
  } from '../composables/useControlSchema'
31
- import CollapsibleCard from './CollapsibleCard.vue'
32
32
  import FormBuilder from './FormBuilder.vue'
33
33
 
34
34
  interface Props {
@@ -38,17 +38,17 @@ interface Props {
38
38
  subtitle?: string
39
39
  /** Optional compact badge/count rendered in the chrome header. */
40
40
  badge?: string | number
41
- /** Visual preset for common plugin sidebars. `analysis` preserves the LEAF-style MINT analysis sidebar design language. */
42
- variant?: 'default' | 'analysis'
43
41
  /** Map of view IDs to their tool sections */
44
42
  panels?: Record<string, SidebarToolSection[]>
45
43
  /** Which view's panels to display. Defaults to the first non-empty panel view. */
46
44
  activeView?: string
47
- /** Floating variant with absolute positioning. Defaults to false for analysis variant. */
45
+ /** Floating shell with absolute positioning. Prefer letting `AppLayout` own this; it stays for standalone use. */
48
46
  floating?: boolean
49
- /** Compact layout: smaller headers, tighter spacing, no icon backgrounds */
47
+ /** Section density. `compact` (default) is the 36px header row; `comfortable` restores the roomier spacing. */
48
+ density?: 'compact' | 'comfortable'
49
+ /** @deprecated Compact is now the default. Pass `density="comfortable"` for the roomier layout. */
50
50
  dense?: boolean
51
- /** Width when visible. Defaults to 20rem for analysis variant, otherwise 280px. */
51
+ /** Width when visible. */
52
52
  width?: string
53
53
  /** Position sidebar on left or right side */
54
54
  side?: 'left' | 'right'
@@ -88,7 +88,7 @@ interface Props {
88
88
  formReadonly?: boolean
89
89
  /** Size passed to auto-rendered section forms */
90
90
  formSize?: 'sm' | 'md' | 'lg'
91
- /** Show a built-in collapse/expand button in the sidebar chrome. Defaults to true for analysis variant. */
91
+ /** Show a built-in collapse/expand button in the sidebar chrome. */
92
92
  collapsible?: boolean
93
93
  /** Controlled collapsed state. */
94
94
  collapsed?: boolean
@@ -106,12 +106,12 @@ const props = withDefaults(defineProps<Props>(), {
106
106
  title: undefined,
107
107
  subtitle: undefined,
108
108
  badge: undefined,
109
- variant: 'default',
110
109
  panels: () => ({}),
111
110
  activeView: '',
112
- floating: undefined,
113
- dense: false,
114
- width: undefined,
111
+ floating: true,
112
+ density: undefined,
113
+ dense: undefined,
114
+ width: '280px',
115
115
  side: 'left',
116
116
  toggleState: () => ({}),
117
117
  forms: () => ({}),
@@ -129,7 +129,7 @@ const props = withDefaults(defineProps<Props>(), {
129
129
  formDisabled: false,
130
130
  formReadonly: false,
131
131
  formSize: 'sm',
132
- collapsible: undefined,
132
+ collapsible: false,
133
133
  collapsed: undefined,
134
134
  defaultCollapsed: false,
135
135
  collapsedWidth: '3rem',
@@ -157,10 +157,20 @@ const collapsedModel = computed({
157
157
  },
158
158
  })
159
159
 
160
- const isAnalysisVariant = computed(() => props.variant === 'analysis')
161
- const resolvedFloating = computed(() => props.floating ?? !isAnalysisVariant.value)
162
- const resolvedWidth = computed(() => props.width ?? (isAnalysisVariant.value ? '20rem' : '280px'))
163
- const resolvedCollapsible = computed(() => props.collapsible ?? isAnalysisVariant.value)
160
+ const resolvedDensity = computed(() =>
161
+ props.density ?? (props.dense === false ? 'comfortable' : 'compact'),
162
+ )
163
+
164
+ // Per-section open state; falls back to `defaultOpen` until the user toggles.
165
+ const openState = ref<Record<string, boolean>>({})
166
+
167
+ function isSectionOpen(section: SidebarToolSection): boolean {
168
+ return openState.value[section.id] ?? section.defaultOpen !== false
169
+ }
170
+
171
+ function toggleSection(section: SidebarToolSection) {
172
+ openState.value[section.id] = !isSectionOpen(section)
173
+ }
164
174
 
165
175
  const resolvedModel = computed<ControlModelBinding | undefined>(() => {
166
176
  return resolveControlModel(props.model)
@@ -224,16 +234,15 @@ const isVisible = computed<boolean>(() =>
224
234
  const sidebarClasses = computed<string[]>(() => [
225
235
  'mint-sidebar',
226
236
  `mint-sidebar--${props.side}`,
227
- `mint-sidebar--${props.variant}`,
228
- resolvedFloating.value ? 'mint-sidebar--floating' : 'mint-sidebar--static',
229
- props.dense ? 'mint-sidebar--dense' : '',
230
- resolvedCollapsible.value ? 'mint-sidebar--collapsible' : '',
237
+ props.floating ? 'mint-sidebar--floating' : 'mint-sidebar--static',
238
+ resolvedDensity.value === 'comfortable' ? 'mint-sidebar--comfortable' : '',
239
+ props.collapsible ? 'mint-sidebar--collapsible' : '',
231
240
  collapsedModel.value ? 'mint-sidebar--collapsed' : '',
232
241
  !isVisible.value ? 'mint-sidebar--hidden' : '',
233
242
  ])
234
243
 
235
244
  const sidebarStyle = computed<Record<string, string>>(() => ({
236
- width: collapsedModel.value ? props.collapsedWidth : resolvedWidth.value,
245
+ width: collapsedModel.value ? props.collapsedWidth : props.width,
237
246
  }))
238
247
 
239
248
  function handleFormUpdate(values: Record<string, unknown>) {
@@ -282,6 +291,17 @@ function badgeToneClass(section: SidebarToolSection): string {
282
291
  return `mint-sidebar__rail-badge--${section.badgeTone ?? 'cta'}`
283
292
  }
284
293
 
294
+ function tagToneClass(section: SidebarToolSection): string {
295
+ return `mint-sidebar__tag--${section.badgeTone ?? 'cta'}`
296
+ }
297
+
298
+ function actionClasses(action: SidebarToolSectionAction): string[] {
299
+ return [
300
+ 'mint-sidebar__group-action',
301
+ action.tone ? `mint-sidebar__group-action--${action.tone}` : '',
302
+ ]
303
+ }
304
+
285
305
  function mergeSidebarPanels(
286
306
  generated: Record<string, SidebarToolSection[]>,
287
307
  explicit: Record<string, SidebarToolSection[]>,
@@ -319,7 +339,7 @@ function firstVisibleViewId(panels: Record<string, SidebarToolSection[]>): strin
319
339
  >
320
340
  <!-- Header slot / built-in chrome -->
321
341
  <div
322
- v-if="$slots.header || title || subtitle || badge !== undefined || resolvedCollapsible"
342
+ v-if="$slots.header || title || subtitle || badge !== undefined || collapsible"
323
343
  class="mint-sidebar__header"
324
344
  >
325
345
  <slot
@@ -335,7 +355,7 @@ function firstVisibleViewId(panels: Record<string, SidebarToolSection[]>): strin
335
355
  <span v-if="badge !== undefined" class="mint-sidebar__badge">{{ badge }}</span>
336
356
  </div>
337
357
  <button
338
- v-if="resolvedCollapsible"
358
+ v-if="collapsible"
339
359
  type="button"
340
360
  class="mint-sidebar__collapse-button"
341
361
  :aria-label="collapsedModel ? expandButtonLabel : collapseButtonLabel"
@@ -365,41 +385,141 @@ function firstVisibleViewId(panels: Record<string, SidebarToolSection[]>): strin
365
385
  :id="contentId"
366
386
  class="mint-sidebar__sections"
367
387
  >
368
- <CollapsibleCard
388
+ <div
369
389
  v-for="section in activeSections"
370
390
  :key="section.id"
371
- :title="section.label"
372
- :subtitle="section.subtitle"
373
- :icon="section.icon"
374
- :icon-color="section.iconColor"
375
- :icon-bg="section.iconBg"
376
- :badge="section.badge"
377
- :badge-tone="section.badgeTone"
378
- :actions="section.actions"
379
- :dense="dense"
380
- :default-open="section.defaultOpen !== false"
381
- :show-toggle="section.showToggle"
382
- :toggle-value="toggleState[section.id] ?? false"
383
- @action="handleSectionAction(section.id, $event)"
384
- @update:toggle-value="emit('update:toggle', section.id, $event)"
391
+ :class="['mint-sidebar__group', isSectionOpen(section) ? 'mint-sidebar__group--open' : 'mint-sidebar__group--closed']"
385
392
  >
386
- <slot :name="`section-${section.id}`">
387
- <FormBuilder
388
- v-if="resolvedForms[section.id]"
389
- :model-value="resolvedValues"
390
- :schema="resolvedForms[section.id]"
391
- :enhancements="formEnhancements"
392
- :loading="formLoading"
393
- :disabled="formDisabled"
394
- :readonly="formReadonly"
395
- :size="formSize"
396
- :show-actions="showFormActions"
397
- @update:model-value="handleFormUpdate"
398
- @submit="handleFormSubmit(section.id, $event)"
399
- @cancel="handleFormCancel(section.id)"
400
- />
401
- </slot>
402
- </CollapsibleCard>
393
+ <div class="mint-sidebar__group-head">
394
+ <button
395
+ type="button"
396
+ class="mint-sidebar__group-toggle"
397
+ :aria-expanded="isSectionOpen(section)"
398
+ @click="toggleSection(section)"
399
+ >
400
+ <span
401
+ v-if="section.icon"
402
+ class="mint-sidebar__group-icon"
403
+ :style="railIconStyle(section)"
404
+ >
405
+ <svg
406
+ v-if="isSvgIcon(section.icon)"
407
+ class="mint-sidebar__group-svg"
408
+ viewBox="0 0 24 24"
409
+ fill="none"
410
+ stroke="currentColor"
411
+ stroke-width="2"
412
+ stroke-linecap="round"
413
+ stroke-linejoin="round"
414
+ aria-hidden="true"
415
+ >
416
+ <template v-if="Array.isArray(section.icon)">
417
+ <path v-for="(d, i) in section.icon" :key="i" :d="d" />
418
+ </template>
419
+ <path v-else :d="section.icon" />
420
+ </svg>
421
+ <span v-else class="mint-sidebar__group-text-icon">{{ section.icon }}</span>
422
+ </span>
423
+ <span class="mint-sidebar__group-copy">
424
+ <span class="mint-sidebar__group-label">{{ section.label }}</span>
425
+ <span v-if="section.subtitle" class="mint-sidebar__group-subtitle">{{ section.subtitle }}</span>
426
+ </span>
427
+ </button>
428
+
429
+ <div class="mint-sidebar__group-tail">
430
+ <span
431
+ v-if="section.badge !== undefined"
432
+ :class="['mint-sidebar__tag', tagToneClass(section)]"
433
+ >
434
+ {{ section.badge }}
435
+ </span>
436
+
437
+ <button
438
+ v-for="action in section.actions ?? []"
439
+ :key="action.id"
440
+ type="button"
441
+ :class="actionClasses(action)"
442
+ :style="action.iconColor ? { color: action.iconColor } : undefined"
443
+ :disabled="action.disabled"
444
+ :aria-label="action.label"
445
+ :title="action.label"
446
+ @click="handleSectionAction(section.id, action.id)"
447
+ >
448
+ <svg
449
+ v-if="action.icon && isSvgIcon(action.icon)"
450
+ class="mint-sidebar__group-action-icon"
451
+ viewBox="0 0 24 24"
452
+ fill="none"
453
+ stroke="currentColor"
454
+ stroke-width="2"
455
+ stroke-linecap="round"
456
+ stroke-linejoin="round"
457
+ aria-hidden="true"
458
+ >
459
+ <template v-if="Array.isArray(action.icon)">
460
+ <path v-for="(d, i) in action.icon" :key="i" :d="d" />
461
+ </template>
462
+ <path v-else :d="action.icon" />
463
+ </svg>
464
+ <span v-else-if="action.icon">{{ action.icon }}</span>
465
+ </button>
466
+
467
+ <button
468
+ v-if="section.showToggle"
469
+ type="button"
470
+ role="switch"
471
+ :aria-checked="toggleState[section.id] ?? false"
472
+ :aria-label="`Enable ${section.label}`"
473
+ :class="['mint-sidebar__switch', (toggleState[section.id] ?? false) ? 'mint-sidebar__switch--on' : '']"
474
+ @click="emit('update:toggle', section.id, !(toggleState[section.id] ?? false))"
475
+ >
476
+ <span class="mint-sidebar__switch-knob" />
477
+ </button>
478
+
479
+ <button
480
+ type="button"
481
+ class="mint-sidebar__chevron-button"
482
+ :aria-label="isSectionOpen(section) ? 'Collapse section' : 'Expand section'"
483
+ :aria-expanded="isSectionOpen(section)"
484
+ @click="toggleSection(section)"
485
+ >
486
+ <svg
487
+ :class="['mint-sidebar__chevron', isSectionOpen(section) ? 'mint-sidebar__chevron--open' : '']"
488
+ viewBox="0 0 24 24"
489
+ fill="none"
490
+ stroke="currentColor"
491
+ stroke-width="2"
492
+ stroke-linecap="round"
493
+ stroke-linejoin="round"
494
+ aria-hidden="true"
495
+ >
496
+ <path d="m6 9 6 6 6-6" />
497
+ </svg>
498
+ </button>
499
+ </div>
500
+ </div>
501
+
502
+ <Transition name="collapse">
503
+ <div v-show="isSectionOpen(section)" class="mint-sidebar__group-body">
504
+ <slot :name="`section-${section.id}`">
505
+ <FormBuilder
506
+ v-if="resolvedForms[section.id]"
507
+ :model-value="resolvedValues"
508
+ :schema="resolvedForms[section.id]"
509
+ :enhancements="formEnhancements"
510
+ :loading="formLoading"
511
+ :disabled="formDisabled"
512
+ :readonly="formReadonly"
513
+ :size="formSize"
514
+ :show-actions="showFormActions"
515
+ @update:model-value="handleFormUpdate"
516
+ @submit="handleFormSubmit(section.id, $event)"
517
+ @cancel="handleFormCancel(section.id)"
518
+ />
519
+ </slot>
520
+ </div>
521
+ </Transition>
522
+ </div>
403
523
 
404
524
  <slot
405
525
  :sections="activeSections"