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

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 (113) hide show
  1. package/README.md +23 -140
  2. package/dist/{ResourcePickerListInternal-c3IT8PIu.js → ResourcePickerListInternal-BEXnyBNz.js} +7 -3
  3. package/dist/ResourcePickerListInternal-BEXnyBNz.js.map +1 -0
  4. package/dist/components/FilePicker.vue.d.ts +4 -1
  5. package/dist/components/WellPlate.colors.d.ts +1 -5
  6. package/dist/components/WellPlate.rendering.d.ts +5 -2
  7. package/dist/components/index.d.ts +32 -17
  8. package/dist/components/index.js +3 -3
  9. package/dist/components/internal/FilePickerListInternal.vue.d.ts +2 -0
  10. package/dist/{components-CvY105kJ.js → components-BTzcqrJp.js} +400 -358
  11. package/dist/components-BTzcqrJp.js.map +1 -0
  12. package/dist/composables/createFilePickerAdapter.d.ts +5 -1
  13. package/dist/composables/filePicker/usePickerNavigation.d.ts +3 -2
  14. package/dist/composables/index.d.ts +0 -3
  15. package/dist/composables/index.js +4 -4
  16. package/dist/composables/useAutoGroup.d.ts +1 -1
  17. package/dist/composables/useBioTemplateComponents.d.ts +15 -3
  18. package/dist/composables/useBioTemplateControls.d.ts +5 -1
  19. package/dist/composables/useBioTemplatePackWorkspace.d.ts +2 -0
  20. package/dist/composables/useBioTemplatePresetWorkspace.d.ts +5 -1
  21. package/dist/composables/useBioTemplateWorkspace.d.ts +5 -1
  22. package/dist/composables/useExperimentSave.d.ts +5 -1
  23. package/dist/composables/useFilePicker.d.ts +4 -1
  24. package/dist/composables/useGroupAssignment.d.ts +5 -1
  25. package/dist/composables/useTemplateCollection.d.ts +5 -1
  26. package/dist/composables-Doqb7g1z.js +591 -0
  27. package/dist/composables-Doqb7g1z.js.map +1 -0
  28. package/dist/index.js +24 -24
  29. package/dist/index.js.map +1 -1
  30. package/dist/install.js +1 -1
  31. package/dist/lcms.d.ts +11 -11
  32. package/dist/styles.css +60 -89
  33. package/dist/templates/index.js +1 -1
  34. package/dist/{templates-Bj9hZ7iV.js → templates-DgAG5kMq.js} +2 -2
  35. package/dist/{templates-Bj9hZ7iV.js.map → templates-DgAG5kMq.js.map} +1 -1
  36. package/dist/types/auth.d.ts +0 -27
  37. package/dist/types/components.d.ts +4 -0
  38. package/dist/types/fileBrowserTypes.d.ts +13 -0
  39. package/dist/types/filePicker.d.ts +8 -1
  40. package/dist/types/index.d.ts +2 -2
  41. package/dist/utils/rack.d.ts +16 -16
  42. package/dist/{validation-B4Y8_eEe.js → validation-DILksa0c.js} +50 -12
  43. package/dist/validation-DILksa0c.js.map +1 -0
  44. package/package.json +1 -6
  45. package/src/__tests__/components/ActionMenu.test.ts +10 -0
  46. package/src/__tests__/components/AppTopBar.test.ts +16 -8
  47. package/src/__tests__/components/EmptyState.test.ts +15 -0
  48. package/src/__tests__/components/FilePicker.test.ts +65 -1
  49. package/src/__tests__/components/WellPlate.decoration.test.ts +4 -28
  50. package/src/__tests__/components/WellPlate.rendering.test.ts +20 -33
  51. package/src/__tests__/components/WellPlate.sampleTypeIndicator.test.ts +7 -7
  52. package/src/__tests__/composables/createFilePickerAdapter.test.ts +92 -1
  53. package/src/__tests__/composables/usePickerNavigation.test.ts +61 -1
  54. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +18 -6
  55. package/src/__tests__/docs/frontendDocsCatalog.test.ts +2 -2
  56. package/src/__tests__/styles/controlRefreshRules.test.ts +0 -6
  57. package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
  58. package/src/__tests__/templates/templatesDeprecation.test.ts +51 -11
  59. package/src/components/ActionMenu.vue +10 -2
  60. package/src/components/AppTopBar.vue +2 -4
  61. package/src/components/EmptyState.vue +3 -1
  62. package/src/components/FilePicker.vue +42 -0
  63. package/src/components/WellPlate.colors.ts +6 -10
  64. package/src/components/WellPlate.legend.ts +4 -4
  65. package/src/components/WellPlate.rendering.ts +9 -8
  66. package/src/components/WellPlate.vue +5 -22
  67. package/src/components/index.ts +32 -17
  68. package/src/components/internal/FilePickerListInternal.vue +11 -2
  69. package/src/composables/autoGroup/index.ts +1 -1
  70. package/src/composables/createFilePickerAdapter.ts +82 -22
  71. package/src/composables/filePicker/usePickerNavigation.ts +29 -2
  72. package/src/composables/index.ts +0 -7
  73. package/src/composables/useAutoGroup.ts +1 -1
  74. package/src/composables/useBioTemplateComponents.ts +15 -3
  75. package/src/composables/useBioTemplateControls.ts +5 -1
  76. package/src/composables/useBioTemplatePackWorkspace.ts +2 -0
  77. package/src/composables/useBioTemplatePresetWorkspace.ts +5 -1
  78. package/src/composables/useBioTemplateWorkspace.ts +5 -1
  79. package/src/composables/useExperimentSave.ts +5 -1
  80. package/src/composables/useFilePicker.ts +8 -1
  81. package/src/composables/useGroupAssignment.ts +5 -1
  82. package/src/composables/usePlatformFilePickerAdapter.ts +26 -4
  83. package/src/composables/useTemplateCollection.ts +5 -1
  84. package/src/index.ts +1 -1
  85. package/src/lcms.ts +11 -11
  86. package/src/styles/components/action-menu.css +10 -0
  87. package/src/styles/components/app-top-bar.css +7 -31
  88. package/src/styles/components/empty-state.css +3 -2
  89. package/src/styles/components/file-picker.css +23 -1
  90. package/src/styles/components/well-plate.css +18 -55
  91. package/src/types/auth.ts +0 -37
  92. package/src/types/components.ts +4 -0
  93. package/src/types/fileBrowserTypes.ts +15 -0
  94. package/src/types/filePicker.ts +8 -1
  95. package/src/types/index.ts +2 -5
  96. package/src/utils/rack.ts +16 -16
  97. package/dist/ResourcePickerListInternal-c3IT8PIu.js.map +0 -1
  98. package/dist/__tests__/composables/useAuth.test.d.ts +0 -1
  99. package/dist/__tests__/composables/usePasskey.test.d.ts +0 -1
  100. package/dist/components-CvY105kJ.js.map +0 -1
  101. package/dist/composables/useAuth.d.ts +0 -47
  102. package/dist/composables/usePasskey.d.ts +0 -10
  103. package/dist/composables/useWellPlateEditor.d.ts +0 -33
  104. package/dist/composables-DL4Vca7L.js +0 -1193
  105. package/dist/composables-DL4Vca7L.js.map +0 -1
  106. package/dist/plotly.min-D6Ge6qgX.js +0 -168582
  107. package/dist/plotly.min-D6Ge6qgX.js.map +0 -1
  108. package/dist/validation-B4Y8_eEe.js.map +0 -1
  109. package/src/__tests__/composables/useAuth.test.ts +0 -290
  110. package/src/__tests__/composables/usePasskey.test.ts +0 -38
  111. package/src/composables/useAuth.ts +0 -470
  112. package/src/composables/usePasskey.ts +0 -216
  113. package/src/composables/useWellPlateEditor.ts +0 -415
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.3.0-beta.3",
3
+ "version": "1.3.0-beta.5",
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",
@@ -67,16 +67,12 @@
67
67
  "story:preview": "histoire preview"
68
68
  },
69
69
  "peerDependencies": {
70
- "@simplewebauthn/browser": "^14.0.0",
71
70
  "pinia": "^4.0.3",
72
71
  "tailwindcss": "^4.3.3",
73
72
  "vue": "^3.5.43",
74
73
  "vue-router": "^5.3.1"
75
74
  },
76
75
  "peerDependenciesMeta": {
77
- "@simplewebauthn/browser": {
78
- "optional": true
79
- },
80
76
  "vue-router": {
81
77
  "optional": true
82
78
  }
@@ -89,7 +85,6 @@
89
85
  },
90
86
  "devDependencies": {
91
87
  "@histoire/plugin-vue": "1.0.0-beta.1",
92
- "@simplewebauthn/browser": "14.0.0",
93
88
  "@types/node": "22.20.4",
94
89
  "@vitejs/plugin-vue": "6.0.9",
95
90
  "@vue/language-core": "3.3.11",
@@ -267,4 +267,14 @@ describe('ActionMenu', () => {
267
267
 
268
268
  expect(document.activeElement).toBe(trigger.element)
269
269
  })
270
+
271
+ it('should render a checked item as a checked menu radio when checked is set', async () => {
272
+ const wrapper = mount(ActionMenu, {
273
+ attachTo: document.body,
274
+ props: { label: 'Sort', items: [{ id: 'a', label: 'A', checked: true }, { id: 'b', label: 'B', checked: false }] },
275
+ })
276
+ await wrapper.get('button').trigger('click')
277
+ const radios = [...document.body.querySelectorAll('[role="menuitemradio"]')]
278
+ expect(radios.map((item) => item.getAttribute('aria-checked'))).toEqual(['true', 'false'])
279
+ })
270
280
  })
@@ -1211,12 +1211,12 @@ 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 plugin version in mono after the name when integrated', () => {
1214
+ it('should not render the plugin version when integrated', () => {
1215
1215
  mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
1216
1216
 
1217
1217
  const wrapper = createWrapper()
1218
1218
 
1219
- expect(wrapper.get('.mint-topbar__plugin-version').text()).toBe('v2.4.0')
1219
+ expect(wrapper.text()).not.toContain('v2.4.0')
1220
1220
  })
1221
1221
 
1222
1222
  it('should not render the plugin identity text when standalone', () => {
@@ -1225,21 +1225,29 @@ describe('AppTopBar', () => {
1225
1225
  expect(wrapper.find('.mint-topbar__plugin-name').exists()).toBe(false)
1226
1226
  })
1227
1227
 
1228
- it('should render a divider between the identity and the page selector', () => {
1228
+ it('should hide the plugin name when a page selector is shown', () => {
1229
1229
  mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
1230
1230
 
1231
1231
  const wrapper = createWrapper({ pageSelector: pages, currentPageSelectorId: 'peaks' })
1232
1232
 
1233
- expect(wrapper.get('.mint-topbar__divider').element.nextElementSibling?.classList)
1234
- .toContain('mint-page-selector')
1233
+ expect(wrapper.find('.mint-topbar__plugin-name').exists()).toBe(false)
1235
1234
  })
1236
1235
 
1237
- it('should not render the divider without a page selector', () => {
1238
- mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
1236
+ it('should hide the plugin name when the plugin declares several nav items', () => {
1237
+ mockPlatformCtx({
1238
+ isIntegrated: true,
1239
+ plugin: {
1240
+ ...integratedPlugin,
1241
+ nav_items: [
1242
+ { id: 'peaks', path: '/', label: 'Peaks' },
1243
+ { id: 'report', path: '/report', label: 'Report' },
1244
+ ],
1245
+ },
1246
+ })
1239
1247
 
1240
1248
  const wrapper = createWrapper()
1241
1249
 
1242
- expect(wrapper.find('.mint-topbar__divider').exists()).toBe(false)
1250
+ expect(wrapper.find('.mint-topbar__plugin-name').exists()).toBe(false)
1243
1251
  })
1244
1252
 
1245
1253
  it('should not repeat the title next to the integrated identity', () => {
@@ -20,4 +20,19 @@ describe('EmptyState', () => {
20
20
 
21
21
  expect(wrapper.emitted('action')).toHaveLength(1)
22
22
  })
23
+
24
+ it('should wrap default-slot content in the spaced content block', () => {
25
+ const wrapper = mount(EmptyState, {
26
+ props: { title: 'No instruments yet', description: 'Add the lab systems.' },
27
+ slots: { default: '<button class="slot-action">New instrument</button>' },
28
+ })
29
+
30
+ expect(wrapper.find('.mint-empty-state__content .slot-action').exists()).toBe(true)
31
+ })
32
+
33
+ it('should not render the content block without default-slot content', () => {
34
+ const wrapper = mount(EmptyState, { props: { title: 'Empty' } })
35
+
36
+ expect(wrapper.find('.mint-empty-state__content').exists()).toBe(false)
37
+ })
23
38
  })
@@ -548,9 +548,10 @@ describe('FilePicker list view', () => {
548
548
  await row(wrapper, folder.path).trigger('dblclick')
549
549
  await flushPromises()
550
550
  expect(wrapper.findAll('[role="option"]').map((item) => item.attributes('data-path'))).toEqual([
551
+ // Undated rows fall back to name order under the default newest-first sort.
552
+ unsupported.path,
551
553
  one.path,
552
554
  two.path,
553
- unsupported.path,
554
555
  ])
555
556
  })
556
557
 
@@ -767,4 +768,67 @@ describe('formatModified', () => {
767
768
  it('should show the full date outside the current year', () => {
768
769
  expect(formatModified('2025-01-02T08:00:00', now)).toBe('2025-01-02')
769
770
  })
771
+
772
+ it('should list the newest folder first when opened', async () => {
773
+ const wrapper = await setup({
774
+ adapter: {
775
+ ...adapter(),
776
+ listRoot: vi.fn(async () => [
777
+ { ...folder, modifiedAt: '2026-01-01T00:00:00Z' },
778
+ { ...other, modifiedAt: '2026-09-29T00:00:00Z' },
779
+ ]),
780
+ },
781
+ })
782
+ expect(wrapper.findAll('[role="treeitem"]').map((item) => item.attributes('data-path'))).toEqual([
783
+ other.path, folder.path,
784
+ ])
785
+ })
786
+
787
+ it('should reorder rows by name when Name A–Z is chosen from the sort menu', async () => {
788
+ const wrapper = await setup({ defaultSort: 'name-desc' })
789
+ await wrapper.get('.mint-picker__sort').trigger('click')
790
+ await wrapper.findAll('[role="menuitemradio"]').find((item) => item.text() === 'Name A–Z')!.trigger('click')
791
+ expect(wrapper.findAll('[role="treeitem"]').map((item) => item.attributes('data-path'))).toEqual([
792
+ other.path, folder.path,
793
+ ])
794
+ })
795
+
796
+ it('should label the sort trigger with the active order', async () => {
797
+ const wrapper = await setup()
798
+ expect(wrapper.get('.mint-picker__sort').text()).toContain('Newest first')
799
+ })
800
+
801
+ it('should tell the user when search results are partial', async () => {
802
+ vi.useFakeTimers()
803
+ const base = adapter()
804
+ const wrapper = await setup({
805
+ adapter: { ...base, search: vi.fn(async () => ({ nodes: [one], truncated: true })) },
806
+ })
807
+ await wrapper.get('[aria-label="Search folders and files"]').setValue('sample')
808
+ await vi.advanceTimersByTimeAsync(400)
809
+ await flushPromises()
810
+ expect(wrapper.get('.mint-picker__truncated').text()).toContain('first 1')
811
+ })
812
+
813
+ it('should still list the partial search results', async () => {
814
+ vi.useFakeTimers()
815
+ const wrapper = await setup({
816
+ adapter: { ...adapter(), search: vi.fn(async () => ({ nodes: [one], truncated: true })) },
817
+ })
818
+ await wrapper.get('[aria-label="Search folders and files"]').setValue('sample')
819
+ await vi.advanceTimersByTimeAsync(400)
820
+ await flushPromises()
821
+ expect(wrapper.findAll('[role="treeitem"]').map((item) => item.attributes('data-path'))).toEqual([one.path])
822
+ })
823
+
824
+ it('should say the search stopped early when a truncated search found nothing', async () => {
825
+ vi.useFakeTimers()
826
+ const wrapper = await setup({
827
+ adapter: { ...adapter(), search: vi.fn(async () => ({ nodes: [], truncated: true })) },
828
+ })
829
+ await wrapper.get('[aria-label="Search folders and files"]').setValue('sample')
830
+ await vi.advanceTimersByTimeAsync(400)
831
+ await flushPromises()
832
+ expect(wrapper.get('.mint-picker__empty').text()).toContain('stopped')
833
+ })
770
834
  })
@@ -26,7 +26,7 @@ describe('WellPlate per-well decoration', () => {
26
26
  .toEqual(['2px', 'solid', 'var(--mint-warning)'])
27
27
  })
28
28
 
29
- it('should render the QC marker glyph when a QC well has no label or id', () => {
29
+ it('should render no glyph when a QC well has no label or id', () => {
30
30
  const wrapper = mount(WellPlate, {
31
31
  props: {
32
32
  format: 6,
@@ -34,37 +34,13 @@ describe('WellPlate per-well decoration', () => {
34
34
  },
35
35
  })
36
36
 
37
- expect(wrapper.findAll('.mint-well-plate__well')[0].text()).toBe('Q')
37
+ expect(wrapper.findAll('.mint-well-plate__well')[0].text()).toBe('')
38
38
  })
39
39
 
40
- it('should keep the iQC marker glyph when well IDs are shown', () => {
41
- const wrapper = mount(WellPlate, {
42
- props: {
43
- format: 6,
44
- showWellIds: true,
45
- wells: { A1: { id: 'A1', row: 0, col: 0, state: 'filled', sampleType: 'iqc' } },
46
- },
47
- })
48
-
49
- expect(wrapper.findAll('.mint-well-plate__well')[0].find('.mint-well-plate__corner-glyph').text()).toBe('i')
50
- })
51
-
52
- it('should keep the iQC marker glyph when a custom label is shown', () => {
53
- const wrapper = mount(WellPlate, {
54
- props: {
55
- format: 6,
56
- showWellLabels: true,
57
- wells: { A1: { id: 'A1', row: 0, col: 0, state: 'filled', sampleType: 'iqc', metadata: { label: 'Std 1' } } },
58
- },
59
- })
60
-
61
- expect(wrapper.findAll('.mint-well-plate__well')[0].find('.mint-well-plate__corner-glyph').text()).toBe('i')
62
- })
63
-
64
- it('should draw legend swatches with the sample-type marker when the legend is shown', () => {
40
+ it('should draw plain legend swatches when the legend is shown', () => {
65
41
  const wrapper = mount(WellPlate, { props: { format: 6, showLegend: true } })
66
42
 
67
43
  const dots = wrapper.findAll('.mint-well-plate__sample-legend-dot')
68
- expect(dots.map(dot => dot.text())).toEqual(['', '', 'Q', 'i'])
44
+ expect(dots.map(dot => dot.text())).toEqual(['', '', '', ''])
69
45
  })
70
46
  })
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
- sampleTypeGlyph,
4
3
  wellBadge,
5
4
  wellClasses,
6
5
  wellLabel,
@@ -70,45 +69,45 @@ describe('WellPlate rendering helpers', () => {
70
69
  heatmap: { enabled: false },
71
70
  sampleColors: { treated: '#123456' },
72
71
  })).toEqual({
73
- backgroundColor: 'color-mix(in srgb, #123456 28%, var(--bg-card))',
74
- border: '1px solid #123456',
72
+ backgroundColor: 'color-mix(in srgb, #123456 15%, transparent)',
73
+ border: '1px solid color-mix(in srgb, #123456 40%, transparent)',
75
74
  })
76
75
 
77
76
  expect(wellStyle(well({ sampleType: 'qc' }), {
78
77
  heatmap: { enabled: false },
79
78
  sampleColors: {},
80
79
  })).toEqual({
81
- backgroundColor: 'var(--bg-card)',
82
- border: '1.5px solid var(--text-secondary-strong)',
80
+ backgroundColor: 'color-mix(in srgb, #8b5cf6 15%, transparent)',
81
+ border: '1px solid color-mix(in srgb, #8b5cf6 40%, transparent)',
83
82
  })
84
83
  })
85
84
 
86
- it('should fill a sample well with its group token when no custom color is set', () => {
85
+ it('should tint a sample well green when no custom color is set', () => {
87
86
  expect(wellStyle(well({ sampleType: 'sample' }), {
88
87
  heatmap: { enabled: false },
89
88
  sampleColors: {},
90
- }).border).toBe('1px solid var(--mint-sample-1)')
89
+ }).border).toBe('1px solid color-mix(in srgb, #10b981 40%, transparent)')
91
90
  })
92
91
 
93
- it('should color a well by its group before its sample type', () => {
94
- expect(wellStyle(well({ sampleType: 'iqc', group: 'trt' }), {
92
+ it('should tint a blank well orange when no custom color is set', () => {
93
+ expect(wellStyle(well({ sampleType: 'blank' }), {
95
94
  heatmap: { enabled: false },
96
- sampleColors: { trt: '#123456' },
97
- }).border).toBe('1px solid #123456')
95
+ sampleColors: {},
96
+ }).border).toBe('1px solid color-mix(in srgb, #f97316 40%, transparent)')
98
97
  })
99
98
 
100
- it('should keep a grouped blank well neutral', () => {
101
- expect(wellStyle(well({ sampleType: 'blank', group: 'trt' }), {
99
+ it('should color a well by its group before its sample type', () => {
100
+ expect(wellStyle(well({ sampleType: 'iqc', group: 'trt' }), {
102
101
  heatmap: { enabled: false },
103
102
  sampleColors: { trt: '#123456' },
104
- }).border).toBe('1.5px dashed var(--_control-border-strong)')
103
+ }).border).toBe('1px solid color-mix(in srgb, #123456 40%, transparent)')
105
104
  })
106
105
 
107
- it('should keep QC neutral when a custom QC color is set', () => {
108
- expect(wellStyle(well({ sampleType: 'qc' }), {
106
+ it('should tint a well with a group color token', () => {
107
+ expect(wellStyle(well({ sampleType: 'sample', group: 'trt' }), {
109
108
  heatmap: { enabled: false },
110
- sampleColors: { qc: '#8b5cf6' },
111
- }).border).toBe('1.5px solid var(--text-secondary-strong)')
109
+ sampleColors: { trt: 'var(--mint-sample-3)' },
110
+ }).backgroundColor).toBe('color-mix(in srgb, var(--mint-sample-3) 15%, transparent)')
112
111
  })
113
112
 
114
113
  it('should leave empty wells to the stylesheet when the well has no sample type', () => {
@@ -125,29 +124,17 @@ describe('WellPlate rendering helpers', () => {
125
124
  })).toEqual({})
126
125
  })
127
126
 
128
- it('should return the Q glyph when the well is QC', () => {
129
- expect(sampleTypeGlyph(well({ sampleType: 'qc' }))).toBe('Q')
130
- })
131
-
132
- it('should return the i glyph when the well is iQC', () => {
133
- expect(sampleTypeGlyph(well({ sampleType: 'iqc' }))).toBe('i')
134
- })
135
-
136
- it('should return no glyph when the well is a sample', () => {
137
- expect(sampleTypeGlyph(well({ sampleType: 'sample' }))).toBeNull()
138
- })
139
-
140
127
  it('formats labels and badges for optional overlays', () => {
141
128
  expect(wellLabel(well({ metadata: { label: 'Dose A' } }), true)).toBe('Dose A')
142
129
  expect(wellLabel(well({ metadata: { label: 'Dose A' } }), false)).toBeUndefined()
143
130
 
144
131
  expect(wellBadge(well({ metadata: { injectionCount: 3 } }), true)).toEqual({
145
132
  text: '3',
146
- color: 'var(--text-primary)',
133
+ color: '#6366f1',
147
134
  })
148
135
  expect(wellBadge(well({ metadata: { customMethod: 'LCMS-2' } }), true)).toEqual({
149
- text: '',
150
- color: 'var(--color-purple)',
136
+ text: '+',
137
+ color: '#ec4899',
151
138
  })
152
139
  expect(wellBadge(well({ metadata: { injectionCount: 1 } }), true)).toBeNull()
153
140
  })
@@ -4,9 +4,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
4
4
  import WellPlate from '../../components/WellPlate.vue'
5
5
  import type { Well } from '../../types'
6
6
 
7
- // `showSampleTypeIndicator` is deprecated (3a): shape encodes the type, only
8
- // QC `Q` and iQC `i` carry glyphs. The warning fires once per module, so the
9
- // order of the first two tests matters.
7
+ // `showSampleTypeIndicator` is deprecated (3a): wells carry no letters, color
8
+ // encodes the type. The warning fires once per module, so the order of the
9
+ // first two tests matters.
10
10
 
11
11
  function mountPlate(sampleType: string, showSampleTypeIndicator: boolean) {
12
12
  const wells: Record<string, Partial<Well>> = {
@@ -48,11 +48,11 @@ describe('WellPlate showSampleTypeIndicator (deprecated)', () => {
48
48
  expect(firstWellText('blank')).toBe('')
49
49
  })
50
50
 
51
- it('should keep the QC marker glyph when showSampleTypeIndicator is on', () => {
52
- expect(firstWellText('qc')).toBe('Q')
51
+ it('should render no letter for a QC well when showSampleTypeIndicator is on', () => {
52
+ expect(firstWellText('qc')).toBe('')
53
53
  })
54
54
 
55
- it('should keep the iQC marker glyph when showSampleTypeIndicator is on', () => {
56
- expect(firstWellText('iqc')).toBe('i')
55
+ it('should render no letter for an iQC well when showSampleTypeIndicator is on', () => {
56
+ expect(firstWellText('iqc')).toBe('')
57
57
  })
58
58
  })
@@ -110,7 +110,7 @@ it('bounds recursive traversal and rejects truncated listings', async () => {
110
110
  mount_id, path,
111
111
  entries: [{ kind: 'directory', name: 'next', path: path ? `${path}/next` : 'next' }],
112
112
  }))
113
- await expect(adapter.search('missing', { path: '', recursive: true })).rejects.toThrow('folder limit')
113
+ expect(await adapter.search('missing', { path: '', recursive: true })).toEqual({ nodes: [], truncated: true })
114
114
  expect(transport.browse).toHaveBeenCalledTimes(500)
115
115
  vi.mocked(transport.browse).mockResolvedValue({ mount_id: 'raw', path: '', entries: [], truncated: true })
116
116
  await expect(adapter.listRoot({ refresh: true })).rejects.toThrow('listing limit')
@@ -162,3 +162,94 @@ it('names the mount holding a path and nothing for a path it cannot decode', ()
162
162
  const adapter = createFilePickerAdapter(setup().transport)
163
163
  expect([adapter.mountOf!(identity('study/run')), adapter.mountOf!('not-json')]).toEqual([identity(), undefined])
164
164
  })
165
+
166
+ function treeSetup() {
167
+ const study = { kind: 'directory' as const, name: 'study', path: 'study' }
168
+ const run = { kind: 'file' as const, name: 'run.raw', path: 'study/run.raw' }
169
+ const transport: FilePickerTransport = {
170
+ listMounts: vi.fn(async () => [{ id: 'raw', label: 'Raw files' }]),
171
+ browse: vi.fn(async ({ mount_id, path }) => ({ mount_id, path, entries: [] })),
172
+ tree: vi.fn(async ({ mount_id }) => ({
173
+ listings: [
174
+ { mount_id, path: '', entries: [study] },
175
+ { mount_id, path: 'study', entries: [run] },
176
+ ],
177
+ truncated: false,
178
+ })),
179
+ search: vi.fn(async ({ path }) => ({
180
+ entries: [study, run].filter((entry) => !path || entry.path.startsWith(`${path}/`)),
181
+ searched_directories: 2,
182
+ truncated: false,
183
+ })),
184
+ }
185
+ return { transport, adapter: createFilePickerAdapter(transport, { rootLocation: { mount_id: 'raw', path: '' } }) }
186
+ }
187
+
188
+ it('should serve a subfolder from the tree response when the transport has tree', async () => {
189
+ const { adapter, transport } = treeSetup()
190
+ await adapter.listRoot()
191
+ await adapter.listChildren(identity('study'))
192
+ expect(transport.tree).toHaveBeenCalledTimes(1)
193
+ })
194
+
195
+ it('should not call browse when the transport has tree', async () => {
196
+ const { adapter, transport } = treeSetup()
197
+ await adapter.listRoot()
198
+ expect(transport.browse).not.toHaveBeenCalled()
199
+ })
200
+
201
+ it('should map tree listings to their own parent folders', async () => {
202
+ const { adapter } = treeSetup()
203
+ await adapter.listRoot()
204
+ expect(await adapter.listChildren(identity('study'))).toMatchObject([
205
+ { path: identity('study/run.raw'), parentPath: identity('study'), kind: 'file' },
206
+ ])
207
+ })
208
+
209
+ it('should search on the server when the transport has search', async () => {
210
+ const { adapter, transport } = treeSetup()
211
+ await adapter.search('ru', { path: identity('study'), recursive: true })
212
+ expect(transport.search).toHaveBeenCalledWith({ mount_id: 'raw', path: 'study' }, 'ru', true, undefined)
213
+ })
214
+
215
+ it('should give server search results their parent folder', async () => {
216
+ const { adapter } = treeSetup()
217
+ expect(await adapter.search('ru', { path: '', recursive: true })).toMatchObject([
218
+ { path: identity('study'), parentPath: null },
219
+ { path: identity('study/run.raw'), parentPath: identity('study') },
220
+ ])
221
+ })
222
+
223
+ it('should return partial server results marked truncated when the server stops early', async () => {
224
+ const { adapter, transport } = treeSetup()
225
+ vi.mocked(transport.search!).mockResolvedValueOnce({
226
+ entries: [{ kind: 'file', name: 'run.raw', path: 'study/run.raw' }], searched_directories: 5000, truncated: true,
227
+ })
228
+ expect(await adapter.search('ru', { path: '', recursive: true })).toMatchObject({
229
+ nodes: [{ path: identity('study/run.raw') }], truncated: true,
230
+ })
231
+ })
232
+
233
+ it('should reject a search entry whose name is only a suffix of its last path segment', async () => {
234
+ const { adapter, transport } = treeSetup()
235
+ vi.mocked(transport.search!).mockResolvedValueOnce({
236
+ entries: [{ kind: 'file', name: 'b', path: 'study/ab' }], searched_directories: 1, truncated: false,
237
+ })
238
+ await expect(adapter.search('b', { path: '', recursive: true })).rejects.toThrow(/Invalid/)
239
+ })
240
+
241
+ it('should reject a server search entry outside the searched folder', async () => {
242
+ const { adapter, transport } = treeSetup()
243
+ vi.mocked(transport.search!).mockResolvedValueOnce({
244
+ entries: [{ kind: 'file', name: 'x.raw', path: 'other/x.raw' }], searched_directories: 1, truncated: false,
245
+ })
246
+ await expect(adapter.search('x', { path: identity('study'), recursive: true })).rejects.toThrow(/Invalid/)
247
+ })
248
+
249
+ it('should reject a nested entry from a search limited to this folder', async () => {
250
+ const { adapter, transport } = treeSetup()
251
+ vi.mocked(transport.search!).mockResolvedValueOnce({
252
+ entries: [{ kind: 'file', name: 'x.raw', path: 'study/private/x.raw' }], searched_directories: 1, truncated: false,
253
+ })
254
+ await expect(adapter.search('x', { path: identity('study'), recursive: false })).rejects.toThrow(/Invalid/)
255
+ })
@@ -1,7 +1,7 @@
1
1
  import { afterEach, expect, it, vi } from 'vitest'
2
2
  import { effectScope, type EffectScope } from 'vue'
3
3
  import { usePickerNavigation } from '../../composables/filePicker/usePickerNavigation'
4
- import type { PickerAdapter, PickerNode } from '../../types/filePicker'
4
+ import type { PickerAdapter, PickerNode, PickerSort } from '../../types/filePicker'
5
5
 
6
6
  const scopes: EffectScope[] = []
7
7
  afterEach(() => {
@@ -147,3 +147,63 @@ it('restarts prefetch after reopening even if old requests ignore cancellation',
147
147
  finish[4]!()
148
148
  await vi.advanceTimersByTimeAsync(100)
149
149
  })
150
+
151
+ const dated = (kind: 'folder' | 'file', name: string, modifiedAt?: string): PickerNode =>
152
+ kind === 'folder'
153
+ ? { kind, path: name, name, modifiedAt }
154
+ : { kind, path: name, name, modifiedAt, format: 'raw' }
155
+ const mixed = () => [
156
+ dated('file', 'b.raw', '2026-09-01T00:00:00Z'),
157
+ dated('folder', 'old', '2026-01-01T00:00:00Z'),
158
+ dated('file', 'a.raw', '2026-09-29T00:00:00Z'),
159
+ dated('folder', 'new', '2026-09-28T00:00:00Z'),
160
+ dated('file', 'undated.raw'),
161
+ ]
162
+ async function sortedNames(sort: PickerSort | undefined) {
163
+ vi.useFakeTimers()
164
+ const adapter: PickerAdapter = {
165
+ listRoot: vi.fn(async () => mixed()),
166
+ listChildren: vi.fn(async () => []),
167
+ search: vi.fn(async () => []),
168
+ }
169
+ const scope = effectScope()
170
+ scopes.push(scope)
171
+ const nav = scope.run(() => usePickerNavigation(() => adapter, () => ({}), () => true, () => sort))!
172
+ await nav.load()
173
+ return nav.rows.value.map((row) => row.node.name)
174
+ }
175
+
176
+ it('should list folders first, newest first, when sorted by newest', async () => {
177
+ expect(await sortedNames('newest')).toEqual(['new', 'old', 'a.raw', 'b.raw', 'undated.raw'])
178
+ })
179
+
180
+ it('should list folders first, oldest first, when sorted by oldest', async () => {
181
+ expect(await sortedNames('oldest')).toEqual(['old', 'new', 'b.raw', 'a.raw', 'undated.raw'])
182
+ })
183
+
184
+ it('should list folders first by name when sorted by name', async () => {
185
+ expect(await sortedNames('name')).toEqual(['new', 'old', 'a.raw', 'b.raw', 'undated.raw'])
186
+ })
187
+
188
+ it('should list folders first by reverse name when sorted by name-desc', async () => {
189
+ expect(await sortedNames('name-desc')).toEqual(['old', 'new', 'undated.raw', 'b.raw', 'a.raw'])
190
+ })
191
+
192
+ it('should keep the adapter order when no sort is given', async () => {
193
+ expect(await sortedNames(undefined)).toEqual(['b.raw', 'old', 'a.raw', 'new', 'undated.raw'])
194
+ })
195
+
196
+ it('should show partial results and flag them when the adapter search is truncated', async () => {
197
+ const { nav, adapter } = setup([folder('a')])
198
+ vi.mocked(adapter.search).mockResolvedValueOnce({ nodes: [folder('a')], truncated: true })
199
+ await nav.load('a', true)
200
+ expect([nav.entries.value.length, nav.truncated.value]).toEqual([1, true])
201
+ })
202
+
203
+ it('should clear the truncated flag on the next plain listing', async () => {
204
+ const { nav, adapter } = setup([folder('a')])
205
+ vi.mocked(adapter.search).mockResolvedValueOnce({ nodes: [], truncated: true })
206
+ await nav.load('a', true)
207
+ await nav.load()
208
+ expect(nav.truncated.value).toBe(false)
209
+ })
@@ -26,7 +26,10 @@ it('passes cancellation, marks offline mounts, and maps compound extensions', as
26
26
  ],
27
27
  })
28
28
  .mockResolvedValueOnce({
29
- entries: [{ kind: 'file', path: 'run.mzML.gz', name: 'run.mzML.gz', size_bytes: 7 }],
29
+ listings: [{
30
+ mount_id: 'online', path: '',
31
+ entries: [{ kind: 'file', path: 'run.mzML.gz', name: 'run.mzML.gz', size_bytes: 7 }],
32
+ }],
30
33
  })
31
34
  const adapter = usePlatformFilePickerAdapter({ apiBaseUrl: '/api' })
32
35
  const signal = new AbortController().signal
@@ -42,7 +45,7 @@ it('passes cancellation, marks offline mounts, and maps compound extensions', as
42
45
  expect(get.mock.calls.every((call) => call[1].signal === signal)).toBe(true)
43
46
  })
44
47
  it('refuses truncated listings rather than returning a partial folder selection', async () => {
45
- get.mockResolvedValue({ truncated: true, entries: [] })
48
+ get.mockResolvedValue({ listings: [{ mount_id: 'a', path: '', truncated: true, entries: [] }] })
46
49
  await expect(
47
50
  usePlatformFilePickerAdapter({ apiBaseUrl: '/api' }).listChildren(
48
51
  encodePlatformPickerPath('a'),
@@ -58,22 +61,22 @@ it('scopes shallow search to one level and recursive search skips offline mounts
58
61
  { id: 'b', label: 'NAS', available: false },
59
62
  ],
60
63
  }
61
- : { entries: [{ kind: 'file', path: 'run.raw', name: 'run.raw', size_bytes: 1 }] },
64
+ : { listings: [{ mount_id: 'a', path: '', entries: [{ kind: 'file', path: 'run.raw', name: 'run.raw', size_bytes: 1 }] }] },
62
65
  )
63
66
  const adapter = usePlatformFilePickerAdapter({ apiBaseUrl: '/api' })
64
67
  expect(await adapter.search('run', { path: '', recursive: false })).toEqual([])
65
68
  expect(await adapter.search('run', { path: '', recursive: true })).toHaveLength(1)
66
- expect(get.mock.calls.filter((call) => call[0].includes('/browse'))).toHaveLength(1)
69
+ expect(get.mock.calls.filter((call) => call[0].includes('/tree'))).toHaveLength(1)
67
70
  })
68
71
  it('forwards explicit refresh to mount browsing and supports a scoped root', async () => {
69
- get.mockResolvedValue({ entries: [] })
72
+ get.mockResolvedValue({ listings: [{ mount_id: 'raw', path: 'study', entries: [] }] })
70
73
  const adapter = usePlatformFilePickerAdapter({
71
74
  apiBaseUrl: '/api', rootLocation: { mount_id: 'raw', path: 'study' },
72
75
  })
73
76
  const signal = new AbortController().signal
74
77
  await adapter.listRoot({ refresh: true, signal })
75
78
  expect(get).toHaveBeenCalledExactlyOnceWith(
76
- '/filesystem/browse?mount_id=raw&path=study&refresh=true', { signal },
79
+ '/filesystem/tree?mount_id=raw&path=study&refresh=true', { signal },
77
80
  )
78
81
  })
79
82
  it('forwards a mount-list refresh so server directory snapshots are invalidated too', async () => {
@@ -83,3 +86,12 @@ it('forwards a mount-list refresh so server directory snapshots are invalidated
83
86
  await adapter.listRoot({ refresh: true, signal })
84
87
  expect(get).toHaveBeenCalledExactlyOnceWith('/filesystem/mounts?refresh=true', { signal })
85
88
  })
89
+
90
+ it('should search a folder on the server with the query and scope', async () => {
91
+ get.mockResolvedValue({ entries: [], truncated: false })
92
+ await usePlatformFilePickerAdapter({ apiBaseUrl: '/api' })
93
+ .search('run', { path: encodePlatformPickerPath('raw', 'study'), recursive: false })
94
+ expect(get).toHaveBeenCalledExactlyOnceWith(
95
+ '/filesystem/search?mount_id=raw&path=study&q=run&recursive=false', { signal: undefined },
96
+ )
97
+ })
@@ -60,7 +60,7 @@ function parseComponentExports(): string[] {
60
60
  const componentsDir = join(SDK_ROOT, 'src', 'components')
61
61
  const content = readFileSync(join(componentsDir, 'index.ts'), 'utf8')
62
62
  return [...content.matchAll(
63
- /export\s+\{\s*default\s+as\s+(\w+)(?:\s*,\s*[^}]*)?\s*\}\s+from\s+['"]\.\/([\w-]+)\.vue['"]/g,
63
+ /export\s+\{\s*(?:\/\*\*.*?\*\/\s*)?default\s+as\s+(\w+)(?:\s*,\s*[^}]*)?\s*\}\s+from\s+['"]\.\/([\w-]+)\.vue['"]/g,
64
64
  )]
65
65
  .filter((match) => existsSync(join(componentsDir, `${match[2]}.vue`)))
66
66
  .map(match => match[1])
@@ -121,7 +121,7 @@ function parseDeprecatedComponentExports(): Record<string, string> {
121
121
  }
122
122
 
123
123
  const exportMatch = line.match(
124
- /export\s+\{\s*default\s+as\s+(\w+)(?:\s*,\s*[^}]*)?\s*\}\s+from\s+['"]\.\/([\w-]+)\.vue['"]/,
124
+ /export\s+\{\s*(?:\/\*\*.*?\*\/\s*)?default\s+as\s+(\w+)(?:\s*,\s*[^}]*)?\s*\}\s+from\s+['"]\.\/([\w-]+)\.vue['"]/,
125
125
  )
126
126
  if (!exportMatch) continue
127
127
 
@@ -184,12 +184,6 @@ describe('SDK control refresh style rules', () => {
184
184
  expect(rule).toMatch(/flex-wrap:\s*wrap/)
185
185
  })
186
186
 
187
- it('should keep the badge text off white when scanning well-plate.css (7a)', () => {
188
- const css = stripComments(readFileSync(component('well-plate.css'), 'utf-8'))
189
- const rule = /\.mint-well-plate__badge\s*\{([^}]*)\}/.exec(css)?.[1] ?? ''
190
- expect(rule).toMatch(/color:\s*var\(--bg-card\)/)
191
- })
192
-
193
187
  it('should pad resource picker rows so two text lines fit the 44px row when scanning resource-picker-list.css (8b)', () => {
194
188
  // 16.25px title + 1px gap + 15px meta + 10px padding + 1px border = 43.25px, under min-height.
195
189
  const css = stripComments(readFileSync(component('resource-picker-list.css'), 'utf-8'))