@morscherlab/mint-sdk 1.3.0-beta.5 → 1.3.0-beta.7

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 (49) hide show
  1. package/dist/{ResourcePickerListInternal-BEXnyBNz.js → ResourcePickerListInternal-CFAw4vTZ.js} +20 -17
  2. package/dist/ResourcePickerListInternal-CFAw4vTZ.js.map +1 -0
  3. package/dist/components/SettingsModal.schema.d.ts +1 -1
  4. package/dist/components/SettingsModal.vue.d.ts +2 -0
  5. package/dist/components/index.d.ts +3 -3
  6. package/dist/components/index.js +3 -3
  7. package/dist/components/internal/ResourcePickerListInternal.vue.d.ts +9 -3
  8. package/dist/{components-BTzcqrJp.js → components-GDDvof8D.js} +19 -15
  9. package/dist/components-GDDvof8D.js.map +1 -0
  10. package/dist/composables/index.js +3 -3
  11. package/dist/composables/usePlatformContext.d.ts +3 -0
  12. package/dist/{composables-Doqb7g1z.js → composables-D6uqp0UF.js} +3 -3
  13. package/dist/{composables-Doqb7g1z.js.map → composables-D6uqp0UF.js.map} +1 -1
  14. package/dist/index.js +5 -5
  15. package/dist/install.js +1 -1
  16. package/dist/styles.css +114 -115
  17. package/dist/templates/index.js +1 -1
  18. package/dist/{templates-DgAG5kMq.js → templates-BU1pet28.js} +31 -44
  19. package/dist/templates-BU1pet28.js.map +1 -0
  20. package/dist/types/components.d.ts +4 -0
  21. package/dist/types/platform.d.ts +2 -0
  22. package/dist/{validation-DILksa0c.js → validation-Dmp9Q5W0.js} +2 -2
  23. package/dist/{validation-DILksa0c.js.map → validation-Dmp9Q5W0.js.map} +1 -1
  24. package/package.json +1 -1
  25. package/src/__tests__/components/AppTopBar.test.ts +8 -0
  26. package/src/__tests__/components/ExperimentSelectorModal.test.ts +12 -5
  27. package/src/__tests__/components/FileUploader.test.ts +6 -9
  28. package/src/__tests__/components/ResourcePickerListInternal.test.ts +23 -4
  29. package/src/__tests__/components/SettingsModal.test.ts +22 -0
  30. package/src/__tests__/styles/controlRefreshRules.test.ts +22 -3
  31. package/src/__tests__/styles/menusShellRefresh.test.ts +9 -5
  32. package/src/__tests__/templates/templatesDeprecation.test.ts +8 -1
  33. package/src/components/AppTopBar.vue +5 -1
  34. package/src/components/ExperimentSelectorModal.vue +4 -1
  35. package/src/components/FileUploader.vue +30 -46
  36. package/src/components/SettingsModal.schema.ts +4 -2
  37. package/src/components/SettingsModal.vue +4 -2
  38. package/src/components/index.ts +3 -3
  39. package/src/components/internal/ResourcePickerListInternal.vue +21 -27
  40. package/src/styles/components/app-page-selector.css +3 -3
  41. package/src/styles/components/app-top-bar.css +16 -12
  42. package/src/styles/components/experiment-selector-modal.css +3 -9
  43. package/src/styles/components/file-uploader.css +22 -55
  44. package/src/styles/components/resource-picker-list.css +70 -36
  45. package/src/types/components.ts +4 -0
  46. package/src/types/platform.ts +2 -0
  47. package/dist/ResourcePickerListInternal-BEXnyBNz.js.map +0 -1
  48. package/dist/components-BTzcqrJp.js.map +0 -1
  49. package/dist/templates-DgAG5kMq.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.3.0-beta.5",
3
+ "version": "1.3.0-beta.7",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1211,6 +1211,14 @@ describe('AppTopBar', () => {
1211
1211
  expect(wrapper.get('.mint-topbar__plugin-name').text()).toBe('LC-MS Analysis')
1212
1212
  })
1213
1213
 
1214
+ it('should render the platform display name when integrated', () => {
1215
+ mockPlatformCtx({ isIntegrated: true, plugin: { ...integratedPlugin, display_name: 'LC-MS Peak Review' } })
1216
+
1217
+ const wrapper = createWrapper()
1218
+
1219
+ expect(wrapper.get('.mint-topbar__plugin-name').text()).toBe('LC-MS Peak Review')
1220
+ })
1221
+
1214
1222
  it('should not render the plugin version when integrated', () => {
1215
1223
  mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
1216
1224
 
@@ -129,25 +129,32 @@ describe('ExperimentSelectorModal', () => {
129
129
  expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
130
130
  })
131
131
 
132
- it('should lead the row title with the experiment code in mono', () => {
132
+ it('should show the experiment code as a pill on the second line', () => {
133
133
  experiments.value = [experiment()]
134
134
  const wrapper = mountModal({ modelValue: true })
135
135
 
136
136
  expect(wrapper.get('.mint-resource-picker__code').text()).toBe('EXP-007')
137
137
  })
138
138
 
139
- it('should state the status as text on the second row line', () => {
139
+ it('should state the status as text in the right-hand column', () => {
140
140
  experiments.value = [experiment()]
141
141
  const wrapper = mountModal({ modelValue: true })
142
142
 
143
- expect(wrapper.get('.mint-resource-picker__meta').text()).toContain('Ongoing')
143
+ expect(wrapper.get('.mint-resource-picker__aside').text()).toContain('Ongoing')
144
144
  })
145
145
 
146
- it('should check the current experiment row', () => {
146
+ it('should tint the status with the experiment status variant', () => {
147
+ experiments.value = [experiment()]
148
+ const wrapper = mountModal({ modelValue: true })
149
+
150
+ expect(wrapper.get('.mint-resource-picker__status').classes()).toContain('mint-resource-picker__status--primary')
151
+ })
152
+
153
+ it('should fill the current experiment row', () => {
147
154
  experiments.value = [experiment()]
148
155
  const wrapper = mountModal({ modelValue: true, currentExperimentId: 7 })
149
156
 
150
- expect(wrapper.find('.mint-resource-picker__check').exists()).toBe(true)
157
+ expect(wrapper.find('.mint-resource-picker__row--selected').exists()).toBe(true)
151
158
  })
152
159
 
153
160
  it('clears the shared store from the clear-selection footer', async () => {
@@ -3,16 +3,13 @@ import { describe, expect, it } from 'vitest'
3
3
  import FileUploader from '../../components/FileUploader.vue'
4
4
 
5
5
  describe('FileUploader', () => {
6
- it('should prompt to release with the dragged file count when files are dragged over', async () => {
7
- const wrapper = mount(FileUploader, { props: { multiple: true } })
8
- await wrapper.get('.mint-file-uploader__dropzone').trigger('dragover', {
9
- dataTransfer: { items: { length: 3 } },
10
- })
11
- expect(wrapper.get('.mint-file-uploader__text').text()).toBe('Release to add 3 files')
6
+ it('should name the accepted formats in the prompt when accept is set', () => {
7
+ const wrapper = mount(FileUploader, { props: { accept: '.raw, .mzML' } })
8
+ expect(wrapper.get('.mint-file-uploader__text').text()).toBe('Drop RAW, MZML or click')
12
9
  })
13
10
 
14
- it('should list accepted formats as written when accept and maxSize are set', () => {
15
- const wrapper = mount(FileUploader, { props: { accept: '.raw, .mzML', maxSize: 2 * 1024 ** 3 } })
16
- expect(wrapper.get('.mint-file-uploader__formats').text()).toBe('.raw .mzML · max 2.0 GB')
11
+ it('should prompt for any file when accept is not set', () => {
12
+ const wrapper = mount(FileUploader)
13
+ expect(wrapper.get('.mint-file-uploader__text').text()).toBe('Drop any file or click')
17
14
  })
18
15
  })
@@ -40,8 +40,27 @@ describe('ResourcePickerListInternal', () => {
40
40
  expect(setup().get('.mint-resource-picker__code').text()).toBe('EXP-042')
41
41
  })
42
42
 
43
- it('should join the meta and date on the second line', () => {
44
- expect(setup().get('.mint-resource-picker__meta').text()).toBe('Zhang · 09-24')
43
+ it('should render the code as the shared gray code pill', () => {
44
+ expect(setup().get('.mint-resource-picker__code').classes()).toContain('mint-exp-code')
45
+ })
46
+
47
+ it('should tint the status by its variant', () => {
48
+ const wrapper = setup({
49
+ describe: (item: unknown) => ({ title: (item as Item).name, status: { label: 'Completed', variant: 'success' } }),
50
+ })
51
+ expect(wrapper.get('.mint-resource-picker__status').classes()).toContain('mint-resource-picker__status--success')
52
+ })
53
+
54
+ it('should keep the title line to the title alone', () => {
55
+ expect(setup().get('.mint-resource-picker__title').text()).toBe('Liver microsome stability')
56
+ })
57
+
58
+ it('should lead the second line with the code and meta', () => {
59
+ expect(setup().get('.mint-resource-picker__meta').text()).toBe('EXP-042Zhang')
60
+ })
61
+
62
+ it('should put the date in the right-hand column', () => {
63
+ expect(setup().get('.mint-resource-picker__aside .mint-resource-picker__date').text()).toBe('09-24')
45
64
  })
46
65
 
47
66
  it('should mark the selected row', () => {
@@ -49,8 +68,8 @@ describe('ResourcePickerListInternal', () => {
49
68
  expect(wrapper.findAll('[role="option"]')[1]!.attributes('aria-selected')).toBe('true')
50
69
  })
51
70
 
52
- it('should draw a trailing check on the selected row only', () => {
53
- expect(setup({ selectedKey: 2 }).findAll('.mint-resource-picker__check')).toHaveLength(1)
71
+ it('should fill the selected row only', () => {
72
+ expect(setup({ selectedKey: 2 }).findAll('.mint-resource-picker__row--selected')).toHaveLength(1)
54
73
  })
55
74
 
56
75
  it('should emit select when a row is clicked', async () => {
@@ -540,6 +540,28 @@ describe('SettingsModal', () => {
540
540
  expect(wrapper.findAll('.mint-modal__rail-section').map(heading => heading.text())).toEqual(['Plugin', 'Personal'])
541
541
  })
542
542
 
543
+ it('should list and open Appearance first when appearanceFirst is set', () => {
544
+ const wrapper = mountSettingsModal({
545
+ tabs: [{ id: 'plugins', label: 'Plugins' }],
546
+ showAppearance: true,
547
+ appearanceFirst: true,
548
+ layout: 'vertical',
549
+ })
550
+
551
+ expect(wrapper.get('.mint-modal__rail-item--active').text()).toContain('Appearance')
552
+ })
553
+
554
+ it('should label a manual tab rail section from its section', () => {
555
+ const wrapper = mountSettingsModal({
556
+ tabs: [{ id: 'plugins', label: 'Plugins', section: 'Platform' }],
557
+ showAppearance: true,
558
+ appearanceFirst: true,
559
+ layout: 'vertical',
560
+ })
561
+
562
+ expect(wrapper.findAll('.mint-modal__rail-section').map(heading => heading.text())).toEqual(['Personal', 'Platform'])
563
+ })
564
+
543
565
  it('should not render rail section headings when appearance is hidden', () => {
544
566
  const wrapper = mountSettingsModal({ schema: railSchema })
545
567
 
@@ -184,11 +184,30 @@ describe('SDK control refresh style rules', () => {
184
184
  expect(rule).toMatch(/flex-wrap:\s*wrap/)
185
185
  })
186
186
 
187
- it('should pad resource picker rows so two text lines fit the 44px row when scanning resource-picker-list.css (8b)', () => {
188
- // 16.25px title + 1px gap + 15px meta + 10px padding + 1px border = 43.25px, under min-height.
187
+ it('should pad resource picker rows to the roomier two-column layout when scanning resource-picker-list.css', () => {
189
188
  const css = stripComments(readFileSync(component('resource-picker-list.css'), 'utf-8'))
190
189
  const rule = /\.mint-resource-picker__row\s*\{([^}]*)\}/.exec(css)?.[1] ?? ''
191
- expect(rule).toMatch(/padding:\s*5px 12px/)
190
+ expect(rule).toMatch(/padding:\s*10px 12px/)
191
+ })
192
+
193
+ // Group bars and the list frame were inset by the modal body padding; the list now runs edge to edge.
194
+ it('should drop the modal body padding around the resource picker when scanning resource-picker-list.css', () => {
195
+ const css = stripComments(readFileSync(component('resource-picker-list.css'), 'utf-8'))
196
+ const rule = /\.mint-modal__body:has\(> \.mint-resource-picker\)\s*\{([^}]*)\}/.exec(css)?.[1] ?? ''
197
+ expect(rule).toMatch(/padding:\s*0/)
198
+ })
199
+
200
+ it('should pad resource picker group bars to the modal gutter when scanning resource-picker-list.css', () => {
201
+ const css = stripComments(readFileSync(component('resource-picker-list.css'), 'utf-8'))
202
+ const rule = /\.mint-resource-picker__group\s*\{([^}]*)\}/.exec(css)?.[1] ?? ''
203
+ expect(rule).toMatch(/padding:\s*0 1\.25rem/)
204
+ })
205
+
206
+ // The list sat in a bordered card inside the modal card.
207
+ it('should draw no frame around the resource picker list when scanning resource-picker-list.css', () => {
208
+ const css = stripComments(readFileSync(component('resource-picker-list.css'), 'utf-8'))
209
+ const rule = /\.mint-resource-picker__frame\s*\{([^}]*)\}/.exec(css)?.[1] ?? ''
210
+ expect(rule).not.toMatch(/(^|;)\s*border:/)
192
211
  })
193
212
 
194
213
  it('should paint the clear-selection link with primary ink when scanning experiment-selector-modal.css (8b)', () => {
@@ -21,22 +21,26 @@ describe('menus and app shell styles', () => {
21
21
  expect(css('step-wizard.css')).not.toMatch(/\.mint-wizard__nav-btn/)
22
22
  })
23
23
 
24
- it('should pad the top bar to 52px around 32px controls (5a)', () => {
25
- expect(css('app-top-bar.css')).toMatch(/\.mint-topbar\s*\{[^}]*padding:\s*0\.625rem 0\.75rem;/)
24
+ it('should pad the top bar to the 1.2 height (62px)', () => {
25
+ expect(css('app-top-bar.css')).toMatch(/\.mint-topbar\s*\{[^}]*padding:\s*0\.75rem 1rem;/)
26
+ })
27
+
28
+ it('should size the page selector trigger to the 36px identity tile', () => {
29
+ expect(css('app-page-selector.css')).toMatch(/\.mint-page-selector__trigger\s*\{[^}]*height:\s*2\.25rem;/)
26
30
  })
27
31
 
28
32
  // Shrinking the tile beside the identity also shrank structured icons inside their solid chip, drawing a ring.
29
- it('should keep the 32px plugin tile beside the static plugin identity (5a)', () => {
33
+ it('should keep the plugin tile beside the static plugin identity (5a)', () => {
30
34
  expect(css('app-top-bar.css')).not.toMatch(/:has\(\+ \.mint-topbar__plugin-identity\)/)
31
35
  })
32
36
 
33
37
  // A slotted 36px logo shrank to 32px wide but kept its height, drawing a 32x36 tile.
34
- it('should fit slotted identity content inside the 32px tile (5a)', () => {
38
+ it('should fit slotted identity content inside the identity tile (5a)', () => {
35
39
  expect(css('app-top-bar.css')).toMatch(/\.mint-topbar__identity > \*\s*\{[^}]*max-width:\s*100%;[^}]*max-height:\s*100%;/)
36
40
  })
37
41
 
38
42
  // 15px x 1.15 + 12px x 1.2 = 31.7px, so the title group fits the 32px control row.
39
- it('should fit the top-bar title and subtitle inside the 32px control row (5a)', () => {
43
+ it('should fit the top-bar title and subtitle inside the control row (5a)', () => {
40
44
  expect(css('app-top-bar.css')).toMatch(/\.mint-topbar-title\s*\{[^}]*line-height:\s*1\.15;/)
41
45
  })
42
46
 
@@ -53,12 +53,19 @@ function undeprecatedValueExports(file: string): string[] {
53
53
 
54
54
  describe('templates deprecation', () => {
55
55
  it('should expose deprecated component tags to TypeScript consumers', () => {
56
- const names = ['BioTemplateRenderer', 'SmartGroupModal', 'SampleSelector', 'DoseDesignWorkspaceView']
56
+ const names = ['BioTemplateRenderer', 'SmartGroupModal', 'DoseDesignWorkspaceView']
57
57
  const tagged = exportsOf(COMPONENTS_INDEX).filter(isDeprecated).map(symbol => symbol.getName())
58
58
 
59
59
  expect(names.filter(name => !tagged.includes(name))).toEqual([])
60
60
  })
61
61
 
62
+ it('should not tag components that stay public in MINT 1.4', () => {
63
+ const names = ['SampleSelector', 'SequenceProgressBar', 'InstrumentStateBadge']
64
+ const tagged = exportsOf(COMPONENTS_INDEX).filter(isDeprecated).map(symbol => symbol.getName())
65
+
66
+ expect(names.filter(name => tagged.includes(name))).toEqual([])
67
+ })
68
+
62
69
  it('should tag every template value export when the templates move out in MINT 1.4', () => {
63
70
  expect(undeprecatedValueExports(TEMPLATES_INDEX)).toEqual([])
64
71
  })
@@ -145,7 +145,10 @@ const hasPlatformPageSelector = computed(() =>
145
145
  )
146
146
  const hasPageSelector = computed(() => (props.pageSelector?.length ?? 0) > 1 || hasPlatformPageSelector.value)
147
147
  /** Integrated plugins show their name as a static identity in place of the title; the page selector replaces it. */
148
- const identityName = computed(() => (isIntegrated.value && !hasPageSelector.value ? plugin.value?.name : undefined))
148
+ // Platforms before 1.3 send no display_name.
149
+ const identityName = computed(() =>
150
+ isIntegrated.value && !hasPageSelector.value ? plugin.value?.display_name || plugin.value?.name : undefined,
151
+ )
149
152
  const hasPillNav = computed(() => !!props.pillNav?.length)
150
153
  const hasAccountMenu = computed(() => !!props.accountMenu?.length)
151
154
  const hasTitleGroup = computed(
@@ -441,6 +444,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
441
444
  :title="settingsConfig?.title"
442
445
  :tabs="normalizedSettingsTabs"
443
446
  :show-appearance="settingsConfig?.showAppearance ?? true"
447
+ :appearance-first="settingsConfig?.appearanceFirst"
444
448
  :size="settingsConfig?.size"
445
449
  :layout="settingsConfig?.layout"
446
450
  :schema="settingsConfig?.schema"
@@ -14,6 +14,7 @@ import { useExperimentStore } from '../stores/experiment'
14
14
  import {
15
15
  formatExperimentDate,
16
16
  formatExperimentStatus,
17
+ getExperimentStatusVariant,
17
18
  EXPERIMENT_STATUS_OPTIONS,
18
19
  DATE_PRESET_OPTIONS,
19
20
  SORT_OPTIONS,
@@ -105,8 +106,10 @@ function describe(experiment: ExperimentSummary) {
105
106
  meta: [
106
107
  ...(groupToggle.value ? [] : [experiment.project_name || experiment.project || '']),
107
108
  owner ? [owner.first_name, owner.last_name].filter(Boolean).join(' ') || owner.username : '',
108
- formatExperimentStatus(experiment.status),
109
109
  ].filter(Boolean),
110
+ status: experiment.status
111
+ ? { label: formatExperimentStatus(experiment.status), variant: getExperimentStatusVariant(experiment.status) }
112
+ : undefined,
110
113
  date: formatExperimentDate(experiment.created_at),
111
114
  }
112
115
  }
@@ -31,7 +31,6 @@ const emit = defineEmits<{
31
31
  }>()
32
32
 
33
33
  const isDragOver = ref(false)
34
- const dragCount = ref(0)
35
34
  const inputRef = ref<HTMLInputElement>()
36
35
  const selectedFiles = ref<File[]>([])
37
36
  const folderName = ref<string | null>(null)
@@ -45,14 +44,9 @@ watch(() => [props.files, props.rootName] as const, ([files, rootName]) => {
45
44
  const isFolder = computed(() => props.mode === 'folder')
46
45
  const isMixed = computed(() => props.mode === 'file+folder')
47
46
 
48
- const promptText = computed(() => {
49
- if (isDragOver.value) {
50
- if (isFolder.value || dragCount.value === 0) return 'Release to add'
51
- return `Release to add ${dragCount.value} file${dragCount.value === 1 ? '' : 's'}`
52
- }
53
- if (isMixed.value) return 'Drop files or a folder, or click to choose files'
54
- if (isFolder.value) return 'Drop a folder, or click to choose'
55
- return props.multiple ? 'Drop files, or click to choose' : 'Drop a file, or click to choose'
47
+ const acceptLabel = computed(() => {
48
+ if (!props.accept) return isFolder.value ? 'a folder' : 'any file'
49
+ return props.accept.split(',').map(a => a.trim().replace('.', '').toUpperCase()).join(', ')
56
50
  })
57
51
 
58
52
  const maxSizeLabel = computed(() => {
@@ -69,12 +63,6 @@ const maxSizeLabel = computed(() => {
69
63
  return `${props.maxSize} bytes`
70
64
  })
71
65
 
72
- /** Accepted formats exactly as the caller wrote them, plus the size cap. */
73
- const formatsLabel = computed(() => [
74
- props.accept?.split(',').map(a => a.trim()).filter(Boolean).join(' '),
75
- maxSizeLabel.value && `max ${maxSizeLabel.value}`,
76
- ].filter(Boolean).join(' · '))
77
-
78
66
  const fileTypeMap: Record<string, { color: string; label: string }> = {
79
67
  csv: { color: '#10B981', label: 'CSV' },
80
68
  tsv: { color: '#10B981', label: 'TSV' },
@@ -272,7 +260,6 @@ function handleDragOver(event: DragEvent) {
272
260
  event.preventDefault()
273
261
  if (!props.disabled) {
274
262
  isDragOver.value = true
275
- dragCount.value = event.dataTransfer?.items?.length ?? 0
276
263
  }
277
264
  }
278
265
 
@@ -359,37 +346,34 @@ function formatFileSize(bytes: number): string {
359
346
  />
360
347
 
361
348
  <div class="mint-file-uploader__content">
362
- <span class="mint-file-uploader__icon-tile">
363
- <svg
364
- v-if="isFolder"
365
- class="mint-file-uploader__icon"
366
- fill="none"
367
- stroke="currentColor"
368
- stroke-width="1.5"
369
- stroke-linecap="round"
370
- stroke-linejoin="round"
371
- viewBox="0 0 24 24"
372
- >
373
- <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
374
- </svg>
375
- <svg
376
- v-else
377
- class="mint-file-uploader__icon"
378
- fill="none"
379
- stroke="currentColor"
380
- stroke-width="1.5"
381
- stroke-linecap="round"
382
- stroke-linejoin="round"
383
- viewBox="0 0 24 24"
384
- >
385
- <path d="M12 16V4M7 9l5-5 5 5M4 20h16" />
386
- </svg>
387
- </span>
349
+ <svg
350
+ v-if="isFolder"
351
+ class="mint-file-uploader__icon"
352
+ fill="none"
353
+ stroke="currentColor"
354
+ stroke-width="1.5"
355
+ stroke-linecap="round"
356
+ stroke-linejoin="round"
357
+ viewBox="0 0 24 24"
358
+ >
359
+ <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
360
+ </svg>
361
+ <svg
362
+ v-else
363
+ class="mint-file-uploader__icon"
364
+ fill="none"
365
+ stroke="currentColor"
366
+ stroke-width="1.5"
367
+ stroke-linecap="round"
368
+ stroke-linejoin="round"
369
+ viewBox="0 0 24 24"
370
+ >
371
+ <path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3" /><path d="m16 16-4-4-4 4" /><path d="M12 12v9" />
372
+ </svg>
388
373
 
389
- <div class="mint-file-uploader__copy">
390
- <p class="mint-file-uploader__text">{{ promptText }}</p>
391
- <p v-if="formatsLabel" class="mint-file-uploader__formats">{{ formatsLabel }}</p>
392
- </div>
374
+ <p class="mint-file-uploader__text">
375
+ {{ isMixed ? 'Drop files or a folder, or click to choose files' : `Drop ${acceptLabel} or click` }}
376
+ </p>
393
377
  </div>
394
378
  </div>
395
379
 
@@ -68,7 +68,9 @@ export function normalizeVisibleSettingsTabs(
68
68
 
69
69
  export function buildSettingsTabs(
70
70
  baseTabs: SettingsTab[],
71
- showAppearance: boolean
71
+ showAppearance: boolean,
72
+ appearanceFirst = false
72
73
  ): SettingsTab[] {
73
- return showAppearance ? [...baseTabs, APPEARANCE_TAB] : baseTabs
74
+ if (!showAppearance) return baseTabs
75
+ return appearanceFirst ? [APPEARANCE_TAB, ...baseTabs] : [...baseTabs, APPEARANCE_TAB]
74
76
  }
@@ -90,6 +90,8 @@ interface Props {
90
90
  /** Manual tab descriptors. Ignored when `schema` is set (groups become tabs). */
91
91
  tabs?: SettingsTabInput[]
92
92
  showAppearance?: boolean
93
+ /** List Appearance before the other tabs and open on it. */
94
+ appearanceFirst?: boolean
93
95
  size?: 'md' | 'lg' | 'xl'
94
96
  layout?: SettingsModalLayout
95
97
  /** Declarative schema — fields auto-render via SDK form components. */
@@ -238,7 +240,7 @@ const allTabs = computed<SettingsTab[]>(() => {
238
240
  const base: SettingsTab[] = settingsSchema.value
239
241
  ? visibleSchemaGroups.value.map(settingsGroupToTab)
240
242
  : manualTabs.value
241
- return buildSettingsTabs(base, props.showAppearance)
243
+ return buildSettingsTabs(base, props.showAppearance, props.appearanceFirst)
242
244
  })
243
245
 
244
246
  const activeTab = ref(allTabs.value[0]?.id || APPEARANCE_TAB_ID)
@@ -272,7 +274,7 @@ const modalTabs = computed<ModalTab[]>(() =>
272
274
  description: tab.description,
273
275
  meta: group ? (modified ? String(modified) : undefined) : tab.meta,
274
276
  // Sections only split plugin groups from Appearance; without it there is nothing to split.
275
- section: props.showAppearance ? (tab.id === APPEARANCE_TAB_ID ? 'Personal' : 'Plugin') : undefined,
277
+ section: props.showAppearance ? (tab.id === APPEARANCE_TAB_ID ? 'Personal' : tab.section ?? 'Plugin') : undefined,
276
278
  }
277
279
  }),
278
280
  )
@@ -86,8 +86,8 @@ export { /** @deprecated Removed in MINT 1.4. */ default as SampleLegend } from
86
86
  export { /** @deprecated Removed in MINT 1.4. */ default as ExperimentTimeline } from './ExperimentTimeline.vue'
87
87
 
88
88
  // Sample management components
89
- /** Interactive sample list with drag-and-drop group assignment, color labeling, and smart-group support. @deprecated Scheduled for removal in MINT 1.4; no SDK replacement. */
90
- export { /** @deprecated Removed in MINT 1.4. */ default as SampleSelector } from './SampleSelector.vue'
89
+ /** Interactive sample list with drag-and-drop group assignment, color labeling, and smart-group support. */
90
+ export { default as SampleSelector } from './SampleSelector.vue'
91
91
  /** Smart Group modal wired to the `useAutoGroup` engine. @deprecated Scheduled for removal in MINT 1.4; no SDK replacement. */
92
92
  export { /** @deprecated Removed in MINT 1.4. */ default as AutoGroupModal } from './AutoGroupModal.vue'
93
93
  /** Drag-and-drop two-zone assigner for splitting sample groups into control and treatment arms. @deprecated Scheduled for removal in MINT 1.4; no SDK replacement. */
@@ -114,7 +114,7 @@ export { default as ProtocolStepEditor } from './ProtocolStepEditor.vue'
114
114
  export { default as PlotlyChart } from './PlotlyChart.vue'
115
115
  export { default as ScientificNumber } from './ScientificNumber.vue'
116
116
  export { default as ChemicalFormula } from './ChemicalFormula.vue'
117
- /** Displays LC/MS or GC/MS acquisition sequence progress with ETA and remaining-time labels. @deprecated Scheduled for removal in MINT 1.4; instrument UI moves to the mld-ms plugins. */
117
+ /** Displays LC/MS or GC/MS acquisition sequence progress with ETA and remaining-time labels. */
118
118
  export { default as SequenceProgressBar } from './SequenceProgressBar.vue'
119
119
  /** Status badge for instrument states: a tone dot (or dash / hollow shape for offline, never-reported and inactive) plus a label. */
120
120
  export { default as InstrumentStateBadge } from './InstrumentStateBadge.vue'
@@ -1,21 +1,25 @@
1
1
  <script setup lang="ts" generic="T">
2
2
  /**
3
3
  * Searchable, optionally grouped single-select list shared by the experiment and
4
- * artifact selector modals (8b): 44px two-line rows, trailing check, skeleton,
4
+ * artifact selector modals: two-line rows (title, then code + meta) with status and date
5
+ * in a right-hand column, a filled selected row, skeleton,
5
6
  * error and empty states, and the combobox keyboard model (↑ ↓ Enter).
6
7
  */
7
8
  import { computed, nextTick, reactive, ref, useId, watch } from 'vue'
8
9
  import BaseInput from '../BaseInput.vue'
9
10
  import Skeleton from '../Skeleton.vue'
10
11
  import EmptyState from '../EmptyState.vue'
12
+ import type { PillVariant } from '../../types'
11
13
 
12
14
  export interface ResourcePickerRow {
13
- /** Identifier shown in mono before the title, e.g. an experiment code. */
15
+ /** Identifier shown as a gray pill at the start of the second line, e.g. an experiment code. */
14
16
  code?: string
15
17
  title: string
16
- /** Second-line facts, joined with " · ". */
18
+ /** Second-line facts after the code, joined with " · ". */
17
19
  meta?: string[]
18
- /** Rendered last on the second line, in mono. */
20
+ /** Right column, above the date, tinted by its variant. */
21
+ status?: { label: string; variant: PillVariant }
22
+ /** Right column, in mono. */
19
23
  date?: string
20
24
  }
21
25
 
@@ -206,36 +210,26 @@ watch(
206
210
  @mouseenter="activeIndex = indexOf(item)"
207
211
  >
208
212
  <span class="mint-resource-picker__text">
209
- <span class="mint-resource-picker__title"
210
- ><template v-if="describe(item).code"
211
- ><span class="mint-resource-picker__code">{{ describe(item).code }}</span> · </template
212
- >{{ describe(item).title }}</span
213
+ <span class="mint-resource-picker__title">{{ describe(item).title }}</span>
214
+ <span v-if="describe(item).code || describe(item).meta?.length" class="mint-resource-picker__meta"
215
+ ><span v-if="describe(item).code" class="mint-resource-picker__code mint-exp-code mint-exp-code--sm">{{
216
+ describe(item).code
217
+ }}</span
218
+ ><span v-if="describe(item).meta?.length">{{ describe(item).meta!.join(' · ') }}</span></span
213
219
  >
220
+ </span>
221
+ <span v-if="describe(item).status || describe(item).date" class="mint-resource-picker__aside">
214
222
  <span
215
- v-if="describe(item).meta?.length || describe(item).date"
216
- class="mint-resource-picker__meta"
217
- >{{ [...(describe(item).meta ?? [])].join(' · ')
218
- }}<template v-if="describe(item).date"
219
- >{{ describe(item).meta?.length ? ' · ' : ''
220
- }}<span class="mint-resource-picker__date">{{ describe(item).date }}</span></template
221
- ></span
223
+ v-if="describe(item).status"
224
+ class="mint-resource-picker__status"
225
+ :class="`mint-resource-picker__status--${describe(item).status!.variant}`"
226
+ >{{ describe(item).status!.label }}</span
222
227
  >
228
+ <span v-if="describe(item).date" class="mint-resource-picker__date">{{ describe(item).date }}</span>
223
229
  </span>
224
230
  <span v-if="$slots.actions" class="mint-resource-picker__actions" @click.stop>
225
231
  <slot name="actions" :item="item" />
226
232
  </span>
227
- <span class="mint-resource-picker__check-slot" aria-hidden="true">
228
- <svg
229
- v-if="itemKey(item) === selectedKey"
230
- class="mint-resource-picker__check"
231
- viewBox="0 0 16 16"
232
- fill="none"
233
- stroke="currentColor"
234
- stroke-width="2"
235
- >
236
- <path d="M3.5 8.5l3 3 6-7" />
237
- </svg>
238
- </span>
239
233
  </div>
240
234
  </template>
241
235
  </div>
@@ -1,5 +1,5 @@
1
1
  /* AppTopBar page selector — left-side route/page dropdown.
2
- Trigger speaks the field language (5a): 32px, hover changes the background
2
+ Trigger speaks the field language (5a): 36px (the identity tile height), hover changes the background
3
3
  only, open = field focus, inline 12px chevron. Menu = .mint-popover +
4
4
  .mint-menu-item (2d); __menu / __item stay as aliases. */
5
5
 
@@ -13,8 +13,8 @@
13
13
  align-items: center;
14
14
  gap: 0.5rem;
15
15
  width: 100%;
16
- height: 2rem;
17
- padding: 0 0.5rem 0 0.3125rem;
16
+ height: 2.25rem;
17
+ padding: 0 0.5rem 0 0.4375rem;
18
18
  border: 1px solid var(--border-color);
19
19
  background: var(--bg-card);
20
20
  color: var(--text-primary);
@@ -9,7 +9,7 @@
9
9
  .mint-topbar {
10
10
  display: block;
11
11
  width: 100%;
12
- padding: 0.625rem 0.75rem;
12
+ padding: 0.75rem 1rem;
13
13
  background-color: var(--bg-secondary);
14
14
  box-sizing: border-box;
15
15
  }
@@ -19,7 +19,7 @@
19
19
  display: flex;
20
20
  align-items: center;
21
21
  gap: 0.5rem;
22
- min-height: 2rem;
22
+ min-height: 2.375rem;
23
23
  position: relative;
24
24
  }
25
25
 
@@ -31,24 +31,28 @@
31
31
  }
32
32
 
33
33
  .mint-topbar__identity {
34
- width: 2rem;
35
- height: 2rem;
34
+ width: 2.25rem;
35
+ height: 2.25rem;
36
36
  display: inline-flex;
37
37
  align-items: center;
38
38
  justify-content: center;
39
39
  flex-shrink: 0;
40
40
  }
41
41
 
42
- /* Slotted logos sized above 32px shrink to fit instead of turning into a 32x36 tile. */
42
+ /* Slotted logos sized above 36px shrink to fit instead of stretching the tile. */
43
43
  .mint-topbar__identity > * {
44
44
  max-width: 100%;
45
45
  max-height: 100%;
46
46
  }
47
47
 
48
- /* The 36px plugin tile would push the bar past 52px; the bar uses the 32px control height. */
49
- .mint-topbar__identity .mint-plugin-icon--topbar {
50
- width: 2rem;
51
- height: 2rem;
48
+ /* Left and right fields share the 36px identity tile height. */
49
+ .mint-topbar .mint-experiment-popover__trigger,
50
+ .mint-topbar .mint-experiment-popover__save-trigger {
51
+ height: 2.25rem;
52
+ }
53
+
54
+ .mint-topbar .mint-experiment-popover__save-trigger {
55
+ width: 2.25rem;
52
56
  }
53
57
 
54
58
  /* Centered section — absolute so asymmetric left/right weight never shifts the
@@ -87,8 +91,8 @@
87
91
  }
88
92
 
89
93
  .mint-topbar__logo-icon {
90
- width: 2rem;
91
- height: 2rem;
94
+ width: 2.25rem;
95
+ height: 2.25rem;
92
96
  background-color: var(--color-primary);
93
97
  border-radius: var(--radius-md);
94
98
  display: flex;
@@ -151,7 +155,7 @@
151
155
  opacity: 0.8;
152
156
  }
153
157
 
154
- /* Title + subtitle stay inside the 32px control row (5a: 52px bar). */
158
+ /* Title + subtitle stay inside the 36px control row. */
155
159
  .mint-topbar-title-group {
156
160
  display: flex;
157
161
  flex-direction: column;