@morscherlab/mint-sdk 1.4.0-beta.12 → 1.4.0-beta.13

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 (43) hide show
  1. package/dist/{AppSidebar-DGK5i-hJ.js → AppSidebar-CIXJBzDv.js} +234 -67
  2. package/dist/{AppSidebar-DGK5i-hJ.js.map → AppSidebar-CIXJBzDv.js.map} +1 -1
  3. package/dist/{ExperimentSelectorModal-BoofZZxF.js → ExperimentSelectorModal-BbFtr1Fr.js} +109 -93
  4. package/dist/ExperimentSelectorModal-BbFtr1Fr.js.map +1 -0
  5. package/dist/{SettingsModal-BXsjIVnm.js → SettingsModal-CdhFMi8j.js} +2 -2
  6. package/dist/{SettingsModal-BXsjIVnm.js.map → SettingsModal-CdhFMi8j.js.map} +1 -1
  7. package/dist/__stories__/experiment-helpers.d.ts +10 -0
  8. package/dist/components/AppTopBar.vue.d.ts +13 -5
  9. package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
  10. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
  11. package/dist/{data-ihsBd9bs.js → data-DUt0TIp5.js} +2 -2
  12. package/dist/{data-ihsBd9bs.js.map → data-DUt0TIp5.js.map} +1 -1
  13. package/dist/data.js +2 -2
  14. package/dist/index.js +7 -7
  15. package/dist/{install-CFgfRO64.js → install-Czoe1rA8.js} +6 -6
  16. package/dist/{install-CFgfRO64.js.map → install-Czoe1rA8.js.map} +1 -1
  17. package/dist/install.js +1 -1
  18. package/dist/{runtime-pKfF_iql.js → runtime-fPb_FEZr.js} +2 -2
  19. package/dist/{runtime-pKfF_iql.js.map → runtime-fPb_FEZr.js.map} +1 -1
  20. package/dist/runtime.js +2 -2
  21. package/dist/{shell-BIH8Rc21.js → shell-lDf_EY-x.js} +2 -2
  22. package/dist/{shell-BIH8Rc21.js.map → shell-lDf_EY-x.js.map} +1 -1
  23. package/dist/shell.js +2 -2
  24. package/dist/styles.css +288 -45
  25. package/dist/types/index.d.ts +1 -1
  26. package/dist/types/jobs.d.ts +9 -0
  27. package/package.json +1 -1
  28. package/src/__stories__/experiment-helpers.ts +3 -3
  29. package/src/__tests__/components/AppTopBar.test.ts +175 -1
  30. package/src/__tests__/components/AppTopBarPageSelector.test.ts +125 -9
  31. package/src/__tests__/components/ExperimentSelectorModal.test.ts +34 -1
  32. package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
  33. package/src/components/AppTopBar.story.vue +59 -4
  34. package/src/components/AppTopBar.vue +107 -8
  35. package/src/components/ExperimentSelectorModal.story.vue +38 -0
  36. package/src/components/ExperimentSelectorModal.vue +20 -1
  37. package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
  38. package/src/styles/components/app-page-selector.css +120 -45
  39. package/src/styles/components/app-top-bar.css +155 -0
  40. package/src/styles/components/experiment-selector-modal.css +13 -0
  41. package/src/types/index.ts +1 -0
  42. package/src/types/jobs.ts +10 -0
  43. package/dist/ExperimentSelectorModal-BoofZZxF.js.map +0 -1
@@ -12,6 +12,7 @@ import { defineControlModel, defineControls } from '../../composables/useControl
12
12
  import ThemeToggle from '../../components/ThemeToggle.vue'
13
13
  import SettingsModal from '../../components/SettingsModal.vue'
14
14
  import type { TopBarSettingsConfig } from '../../types/components'
15
+ import type { TopBarJob } from '../../types/jobs'
15
16
 
16
17
  // Mock the usePlatform composable
17
18
  vi.mock('../../composables/usePlatform', () => ({
@@ -908,7 +909,7 @@ describe('AppTopBar', () => {
908
909
  const imageIcons = wrapper.findAll('.mint-plugin-icon__img')
909
910
  expect(imageIcons).toHaveLength(1)
910
911
  expect(imageIcons[0].attributes('src')).toBe(pngIcon)
911
- expect(wrapper.find('.mint-page-selector__item-hint').exists()).toBe(false)
912
+ expect(wrapper.get('.mint-page-selector__item-hint').text()).toBe('Overview')
912
913
  })
913
914
  })
914
915
 
@@ -1237,6 +1238,26 @@ describe('AppTopBar', () => {
1237
1238
  expect(wrapper.find('.mint-topbar__plugin-name').exists()).toBe(false)
1238
1239
  })
1239
1240
 
1241
+ it.each([
1242
+ { case: 'integrated with display_name', integrated: true, plugin: { ...integratedPlugin, display_name: 'LC-MS Peak Review' }, props: { title: 'Ignored' }, seed: false, expected: 'LC-MS Peak Review' },
1243
+ { case: 'integrated without display_name', integrated: true, plugin: integratedPlugin, props: {}, seed: false, expected: 'LC-MS Analysis' },
1244
+ { case: 'standalone with a title', integrated: false, plugin: undefined, props: { title: 'Planner Dev' }, seed: true, expected: 'Planner Dev' },
1245
+ { case: 'standalone seeded', integrated: false, plugin: undefined, props: {}, seed: true, expected: 'MS Planner' },
1246
+ { case: 'standalone without a name', integrated: false, plugin: undefined, props: {}, seed: false, expected: null },
1247
+ ])('should put the plugin name in the page selector crumb when $case', ({ integrated, plugin, props, seed, expected }) => {
1248
+ mockPlatformCtx({ isIntegrated: integrated, plugin })
1249
+ if (seed) providePluginIdentity({ plugin: { name: 'ms-planner', displayName: 'MS Planner' } })
1250
+
1251
+ try {
1252
+ const wrapper = createWrapper({ ...props, pageSelector: pages, currentPageSelectorId: 'peaks' })
1253
+
1254
+ const name = wrapper.find('.mint-page-selector__plugin')
1255
+ expect(name.exists() ? name.text() : null).toBe(expected)
1256
+ } finally {
1257
+ providePluginIdentity({ plugin: { name: '' } })
1258
+ }
1259
+ })
1260
+
1240
1261
  it('should hide the plugin name when a page selector is shown', () => {
1241
1262
  mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
1242
1263
 
@@ -1483,4 +1504,157 @@ describe('AppTopBar', () => {
1483
1504
  expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function), expect.objectContaining({ capture: true }))
1484
1505
  })
1485
1506
  })
1507
+ describe('run slot', () => {
1508
+ function mountRunSlot(job: TopBarJob | null | undefined, { experiment }: { experiment: boolean }) {
1509
+ vi.mocked(usePlatform).mockReturnValueOnce({
1510
+ integrated: computed(() => experiment),
1511
+ isAdmin: computed(() => true),
1512
+ can: () => true,
1513
+ plugin: computed(() => undefined),
1514
+ user: computed(() => undefined),
1515
+ theme: computed(() => 'system' as const),
1516
+ features: computed(() => undefined),
1517
+ })
1518
+ const appExperiment = {
1519
+ popover: computed(() => ({ experimentName: 'Serine tracing', experimentCode: 'EXP-0142', showSave: true })),
1520
+ selectorModal: computed(() => ({ open: false })),
1521
+ openModal: vi.fn(),
1522
+ closeModal: vi.fn(),
1523
+ handleSave: vi.fn(),
1524
+ handleDetach: vi.fn(),
1525
+ } as unknown as AppExperimentState
1526
+ return mount(AppTopBar, {
1527
+ props: job === undefined ? { title: 'LEAF' } : { title: 'LEAF', job },
1528
+ global: {
1529
+ plugins: [createPinia()],
1530
+ provide: { [APP_EXPERIMENT_KEY as symbol]: appExperiment },
1531
+ stubs: { 'router-link': { template: '<a><slot /></a>' }, BaseModal: { template: '<div><slot /></div>' }, transition: false },
1532
+ },
1533
+ })
1534
+ }
1535
+
1536
+ const running: TopBarJob = { status: 'running', label: 'Extracting', percent: 40 }
1537
+
1538
+ it.each([
1539
+ ['no job prop, experiment', undefined, true, { popover: true, chip: false, idle: false, joined: false }],
1540
+ ['idle, experiment', null, true, { popover: true, chip: true, idle: true, joined: false }],
1541
+ ['running, experiment', running, true, { popover: true, chip: true, idle: false, joined: true }],
1542
+ ['idle, standalone', null, false, { popover: false, chip: true, idle: true, joined: false }],
1543
+ ['running, standalone', running, false, { popover: false, chip: true, idle: false, joined: true }],
1544
+ ] as const)('should render the run slot for %s', async (_case, job, experiment, expected) => {
1545
+ const wrapper = mountRunSlot(job, { experiment })
1546
+ await waitForAsyncComponents(wrapper)
1547
+
1548
+ const chip = wrapper.find('.mint-topbar__job')
1549
+ expect({
1550
+ popover: wrapper.findComponent(ExperimentPopover).exists(),
1551
+ chip: chip.exists(),
1552
+ idle: chip.exists() && chip.classes('mint-topbar__job--idle'),
1553
+ joined: wrapper.find('.mint-topbar__run-slot--joined').exists(),
1554
+ }).toEqual(expected)
1555
+ })
1556
+
1557
+ it.each([
1558
+ [undefined, true],
1559
+ [null, true],
1560
+ [running, false],
1561
+ ])('should pass showSave to the experiment chip when the job is %s', async (job, showSave) => {
1562
+ const wrapper = mountRunSlot(job, { experiment: true })
1563
+ await waitForAsyncComponents(wrapper)
1564
+
1565
+ expect(wrapper.findComponent(ExperimentPopover).props('showSave')).toBe(showSave)
1566
+ })
1567
+
1568
+ it('should keep the slot joined until the job has left, then show the idle button', async () => {
1569
+ const wrapper = mountRunSlot(running, { experiment: true })
1570
+ await waitForAsyncComponents(wrapper)
1571
+ const joined = () => wrapper.find('.mint-topbar__run-slot--joined').exists()
1572
+
1573
+ await wrapper.setProps({ job: null })
1574
+ expect(joined()).toBe(true)
1575
+ await vi.waitFor(() => expect(wrapper.find('.mint-topbar__job--idle').exists()).toBe(true))
1576
+ expect(joined()).toBe(false)
1577
+
1578
+ await wrapper.setProps({ job: running })
1579
+ await vi.waitFor(() => expect(wrapper.find('.mint-topbar__job--primary').exists()).toBe(true))
1580
+ expect(joined()).toBe(true)
1581
+
1582
+ await wrapper.setProps({ job: undefined })
1583
+ await vi.waitFor(() => expect(wrapper.find('.mint-topbar__job').exists()).toBe(false))
1584
+ expect(joined()).toBe(false)
1585
+ })
1586
+
1587
+ it('should join the slot when the job and the slot mount together', async () => {
1588
+ const wrapper = mountRunSlot(undefined, { experiment: false })
1589
+ await waitForAsyncComponents(wrapper)
1590
+
1591
+ await wrapper.setProps({ job: running })
1592
+
1593
+ await vi.waitFor(() => expect(wrapper.find('.mint-topbar__run-slot--joined').exists()).toBe(true))
1594
+ })
1595
+
1596
+ it.each([
1597
+ [120, '100%', 'Extracting 100%'],
1598
+ [-5, '0%', 'Extracting 0%'],
1599
+ ])('should clamp percent %s to %s', async (percent, width, name) => {
1600
+ const wrapper = mountRunSlot({ ...running, percent }, { experiment: false })
1601
+ await waitForAsyncComponents(wrapper)
1602
+
1603
+ const chip = wrapper.get('.mint-topbar__job')
1604
+ expect(chip.get('.mint-topbar__job-bar > span').attributes('style')).toContain(`width: ${width}`)
1605
+ expect(chip.attributes('aria-label')).toBe(name)
1606
+ })
1607
+
1608
+ it.each([
1609
+ [running, 'Job running'],
1610
+ [{ status: 'awaiting_input', label: 'Decoding' } as TopBarJob, 'Job needs input'],
1611
+ [{ status: 'failed', label: '1 failed' } as TopBarJob, 'Job failed'],
1612
+ [null, ''],
1613
+ ])('should announce the status of %o as %s', async (job, text) => {
1614
+ const wrapper = mountRunSlot(job, { experiment: false })
1615
+ await waitForAsyncComponents(wrapper)
1616
+
1617
+ const live = wrapper.get('.mint-topbar__job-status')
1618
+ expect(live.attributes('aria-live')).toBe('polite')
1619
+ expect(live.text()).toBe(text)
1620
+ })
1621
+
1622
+ it.each([
1623
+ ['queued', 'primary'],
1624
+ ['running', 'primary'],
1625
+ ['awaiting_input', 'warning'],
1626
+ ['completed', 'success'],
1627
+ ['failed', 'error'],
1628
+ ['cancelled', 'neutral'],
1629
+ ] as const)('should tone a %s job as %s', async (status, tone) => {
1630
+ const wrapper = mountRunSlot({ status, label: 'Job' }, { experiment: false })
1631
+ await waitForAsyncComponents(wrapper)
1632
+
1633
+ expect(wrapper.get('.mint-topbar__job').classes()).toContain(`mint-topbar__job--${tone}`)
1634
+ })
1635
+
1636
+ it.each([
1637
+ [running, 'Extracting 40%', true],
1638
+ [{ status: 'completed', label: 'Saved to EXP-0142' } as TopBarJob, 'Saved to EXP-0142', false],
1639
+ ])('should show only the status for %o and name it %s', async (job, name, bar) => {
1640
+ const wrapper = mountRunSlot(job, { experiment: false })
1641
+ await waitForAsyncComponents(wrapper)
1642
+
1643
+ const chip = wrapper.get('.mint-topbar__job')
1644
+ expect(chip.text()).toBe('')
1645
+ expect(chip.attributes('aria-label')).toBe(name)
1646
+ expect(chip.attributes('title')).toBe(name)
1647
+ expect(chip.find('.mint-topbar__job-bar').exists()).toBe(bar)
1648
+ if (bar) expect(chip.get('.mint-topbar__job-bar > span').attributes('style')).toContain('width: 40%')
1649
+ })
1650
+
1651
+ it.each([null, running])('should emit job-click when the job control is %s', async (job) => {
1652
+ const wrapper = mountRunSlot(job, { experiment: false })
1653
+ await waitForAsyncComponents(wrapper)
1654
+
1655
+ await wrapper.get('.mint-topbar__job').trigger('click')
1656
+
1657
+ expect(wrapper.emitted('job-click')).toEqual([[]])
1658
+ })
1659
+ })
1486
1660
  })
@@ -1,4 +1,5 @@
1
1
  import { mount } from '@vue/test-utils'
2
+ import { nextTick } from 'vue'
2
3
  import { describe, expect, it } from 'vitest'
3
4
  import AppTopBarPageSelectorInternal from '../../components/internal/AppTopBarPageSelectorInternal.vue'
4
5
 
@@ -24,26 +25,139 @@ describe('AppTopBarPageSelectorInternal', () => {
24
25
  expect(wrapper.emitted('select')).toEqual([[{ id: 'Results', label: 'Results' }]])
25
26
  })
26
27
 
27
- it('omits page hints from the menu for compact scanning', async () => {
28
+ it('should show each page hint under its label when the page has one', async () => {
28
29
  const wrapper = mount(AppTopBarPageSelectorInternal, {
29
30
  props: {
30
31
  currentPageId: 'downloads',
31
32
  pages: [
32
33
  { id: 'downloads', label: 'Downloads', hint: 'Paste links and queue downloads' },
33
- { id: 'settings', label: 'Settings', hint: 'Credentials and destination' },
34
+ { id: 'settings', label: 'Settings' },
34
35
  ],
35
36
  },
36
37
  })
37
38
 
38
39
  await wrapper.get('.mint-page-selector__trigger').trigger('click')
39
40
 
40
- expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual([
41
- 'Downloads',
42
- 'Settings',
41
+ const rows = wrapper.findAll('.mint-page-selector__item')
42
+ expect(rows.map(row => row.find('.mint-page-selector__item-hint').exists() ? row.get('.mint-page-selector__item-hint').text() : null)).toEqual([
43
+ 'Paste links and queue downloads',
44
+ null,
43
45
  ])
44
- expect(wrapper.find('.mint-page-selector__item-hint').exists()).toBe(false)
45
- expect(wrapper.text()).not.toContain('Paste links')
46
- expect(wrapper.text()).not.toContain('Credentials')
46
+ })
47
+
48
+ it.each([
49
+ { pluginName: 'LEAF', crumb: ['LEAF', '/'], title: 'LEAF pages' },
50
+ { pluginName: undefined, crumb: [], title: 'Pages' },
51
+ ])('should name the menu after the plugin when pluginName is $pluginName', async ({ pluginName, crumb, title }) => {
52
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
53
+ props: { pluginName, currentPageId: 'Extract', pages: ['Extract', 'Analysis'] },
54
+ })
55
+
56
+ expect(wrapper.findAll('.mint-page-selector__plugin, .mint-page-selector__slash').map(el => el.text())).toEqual(crumb)
57
+
58
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
59
+
60
+ expect(wrapper.get('.mint-page-selector__menu-title').text()).toBe(title)
61
+ expect(wrapper.get('[role="menu"]').attributes('aria-label')).toBe(title)
62
+ })
63
+
64
+ it('should show number keys for the first nine pages only', async () => {
65
+ const pages = Array.from({ length: 10 }, (_, i) => `Page ${i + 1}`)
66
+ const wrapper = mount(AppTopBarPageSelectorInternal, { props: { pages } })
67
+
68
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
69
+
70
+ expect(wrapper.findAll('.mint-page-selector__item').map(row => row.find('.mint-page-selector__item-key').exists() ? row.get('.mint-page-selector__item-key').text() : null)).toEqual(
71
+ ['1', '2', '3', '4', '5', '6', '7', '8', '9', null],
72
+ )
73
+ })
74
+
75
+ describe('keyboard', () => {
76
+ const pages = [
77
+ { id: 'check', label: 'Quick check' },
78
+ { id: 'extract', label: 'Extract' },
79
+ { id: 'locked', label: 'Locked', disabled: true },
80
+ { id: 'analysis', label: 'Analysis' },
81
+ ]
82
+ const focusedLabel = () => document.activeElement?.querySelector('.mint-page-selector__item-label')?.textContent
83
+
84
+ function mountAttached() {
85
+ return mount(AppTopBarPageSelectorInternal, {
86
+ props: { pages, currentPageId: 'extract' },
87
+ attachTo: document.body,
88
+ })
89
+ }
90
+
91
+ it.each(['ArrowDown', 'Enter', ' '])('should open the menu on the current row when %j is pressed on the trigger', async (key) => {
92
+ const wrapper = mountAttached()
93
+
94
+ await wrapper.get('.mint-page-selector__trigger').trigger('keydown', { key })
95
+ await nextTick()
96
+
97
+ expect(wrapper.find('.mint-page-selector__menu').exists()).toBe(true)
98
+ expect(focusedLabel()).toBe('Extract')
99
+ wrapper.unmount()
100
+ })
101
+
102
+ it('should move between enabled rows with arrows, Home and End', async () => {
103
+ const wrapper = mountAttached()
104
+ await wrapper.get('.mint-page-selector__trigger').trigger('keydown', { key: 'ArrowDown' })
105
+ await nextTick()
106
+ const menu = wrapper.get('.mint-page-selector__menu')
107
+
108
+ const visited: Array<string | null | undefined> = []
109
+ for (const key of ['ArrowDown', 'ArrowDown', 'ArrowUp', 'Home', 'ArrowUp', 'End']) {
110
+ await menu.trigger('keydown', { key })
111
+ visited.push(focusedLabel())
112
+ }
113
+
114
+ expect(visited).toEqual(['Analysis', 'Quick check', 'Analysis', 'Quick check', 'Analysis', 'Analysis'])
115
+ wrapper.unmount()
116
+ })
117
+
118
+ it.each(['Enter', ' '])('should activate the focused row and refocus the trigger when %j is pressed', async (key) => {
119
+ const wrapper = mountAttached()
120
+ await wrapper.get('.mint-page-selector__trigger').trigger('keydown', { key: 'ArrowDown' })
121
+ await nextTick()
122
+ const menu = wrapper.get('.mint-page-selector__menu')
123
+
124
+ await menu.trigger('keydown', { key: 'ArrowDown' })
125
+ await menu.trigger('keydown', { key })
126
+
127
+ expect(wrapper.emitted('select')).toEqual([[pages[3]]])
128
+ expect(wrapper.find('.mint-page-selector__menu').exists()).toBe(false)
129
+ expect(document.activeElement).toBe(wrapper.get('.mint-page-selector__trigger').element)
130
+ wrapper.unmount()
131
+ })
132
+
133
+ it.each([
134
+ { key: '4', emitted: [[pages[3]]], open: false },
135
+ { key: '3', emitted: undefined, open: true },
136
+ { key: '9', emitted: undefined, open: true },
137
+ ])('should handle digit $key as a jump to that page', async ({ key, emitted, open }) => {
138
+ const wrapper = mountAttached()
139
+ await wrapper.get('.mint-page-selector__trigger').trigger('keydown', { key: 'ArrowDown' })
140
+ await nextTick()
141
+
142
+ await wrapper.get('.mint-page-selector__menu').trigger('keydown', { key })
143
+
144
+ expect(wrapper.emitted('select')).toEqual(emitted)
145
+ expect(wrapper.find('.mint-page-selector__menu').exists()).toBe(open)
146
+ wrapper.unmount()
147
+ })
148
+
149
+ it('should close on Escape and return focus to the trigger', async () => {
150
+ const wrapper = mountAttached()
151
+ await wrapper.get('.mint-page-selector__trigger').trigger('keydown', { key: 'ArrowDown' })
152
+ await nextTick()
153
+
154
+ await wrapper.get('.mint-page-selector__menu').trigger('keydown', { key: 'Escape' })
155
+ await nextTick()
156
+
157
+ expect(wrapper.find('.mint-page-selector__menu').exists()).toBe(false)
158
+ expect(document.activeElement).toBe(wrapper.get('.mint-page-selector__trigger').element)
159
+ wrapper.unmount()
160
+ })
47
161
  })
48
162
 
49
163
  it('closes linked page actions without emitting select', async () => {
@@ -171,6 +285,8 @@ describe('AppTopBarPageSelectorInternal', () => {
171
285
 
172
286
  await wrapper.get('.mint-page-selector__trigger').trigger('click')
173
287
 
174
- expect(wrapper.findAll('.mint-page-selector__item')[0].classes()).toContain('mint-page-selector__item--active')
288
+ const rows = wrapper.findAll('.mint-page-selector__item')
289
+ expect(rows[0].classes()).toContain('mint-page-selector__item--active')
290
+ expect(rows.map(row => row.attributes('aria-current'))).toEqual(['page', undefined])
175
291
  })
176
292
  })
@@ -64,9 +64,10 @@ interface ModalProps {
64
64
  currentExperimentId?: number | null
65
65
  }
66
66
 
67
- function mountModal(props: ModalProps) {
67
+ function mountModal(props: ModalProps, slots: Record<string, string> = {}) {
68
68
  return mount(ExperimentSelectorModal, {
69
69
  props,
70
+ slots,
70
71
  global: {
71
72
  stubs: {
72
73
  BaseModal: {
@@ -202,6 +203,38 @@ describe('ExperimentSelectorModal', () => {
202
203
  expect(wrapper.emitted('update:open')).toEqual([[false]])
203
204
  })
204
205
 
206
+ it('should show the description slot above the experiment list', () => {
207
+ const wrapper = mountModal({ open: true }, { description: '<p class="why">Keep the result with its samples.</p>' })
208
+
209
+ const description = wrapper.get('.mint-experiment-selector__description')
210
+ expect(description.find('.why').text()).toBe('Keep the result with its samples.')
211
+ expect(description.element.compareDocumentPosition(wrapper.get('.mint-experiment-selector').element))
212
+ .toBe(Node.DOCUMENT_POSITION_FOLLOWING)
213
+ })
214
+
215
+ it.each([
216
+ [null, false],
217
+ [7, true],
218
+ ])('should render the footer slot when the current experiment is %s, with the clear button %s', (currentExperimentId, hasClear) => {
219
+ const wrapper = mountModal({ open: true, currentExperimentId }, { footer: '<span class="extra">Skip</span>' })
220
+
221
+ const footer = wrapper.get('.mint-experiment-selector__footer')
222
+ expect(footer.find('.extra').exists()).toBe(true)
223
+ expect(footer.find('.mint-experiment-selector__clear-btn').exists()).toBe(hasClear)
224
+ })
225
+
226
+ it('should close without touching the store from the footer slot close()', async () => {
227
+ const store = useExperimentStore()
228
+ const wrapper = mountModal({ open: true }, {
229
+ footer: '<template #footer="{ close }"><button class="skip" @click="close">Skip</button></template>',
230
+ })
231
+
232
+ await wrapper.get('.skip').trigger('click')
233
+
234
+ expect(wrapper.emitted('update:open')).toEqual([[false]])
235
+ expect(store.current).toBeNull()
236
+ })
237
+
205
238
  it('should list each status with its count and check the current one', async () => {
206
239
  statusCounts.value = { ongoing: 3, planned: 1, completed: 0, cancelled: 2 }
207
240
  filters.status = 'ongoing'
@@ -30,8 +30,14 @@ describe('menus and app shell styles', () => {
30
30
  expect(css('app-pill-nav.css')).toMatch(/\.mint-pill-nav__item\s*\{[^}]*height:\s*1\.625rem;/)
31
31
  })
32
32
 
33
- it('should size the page selector trigger to the 36px identity tile', () => {
34
- expect(css('app-page-selector.css')).toMatch(/\.mint-page-selector__trigger\s*\{[^}]*height:\s*2\.25rem;/)
33
+ // Design v2 option C: the page switcher sits in the crumb at the 32px top-bar control height.
34
+ it('should size the page selector trigger to the 32px top-bar control height', () => {
35
+ expect(css('app-page-selector.css')).toMatch(/\.mint-page-selector__trigger\s*\{[^}]*height:\s*2rem;/)
36
+ })
37
+
38
+ // A content-box menu measured 310px, and the platform's <a> rows (100% + padding) overflowed it.
39
+ it.each(['menu', 'item'])('should size the page selector %s as border-box', (part) => {
40
+ expect(css('app-page-selector.css')).toMatch(new RegExp(`\\.mint-page-selector__${part}\\s*\\{[^}]*box-sizing:\\s*border-box;`))
35
41
  })
36
42
 
37
43
  // Shrinking the tile beside the identity also shrank structured icons inside their solid chip, drawing a ring.
@@ -4,14 +4,26 @@ import Avatar from './Avatar.vue'
4
4
  import PluginIcon from './PluginIcon.vue'
5
5
  import { ExperimentProvider } from '../__stories__/experiment-helpers'
6
6
  import { providePluginIdentity } from '../composables/pluginIdentity'
7
- import type { App } from 'vue'
7
+ import { ref, type App } from 'vue'
8
8
  import type {
9
9
  TopBarVariant,
10
10
  PillNavItem,
11
11
  PageSelectorItem,
12
12
  PluginInfo,
13
+ TopBarJob,
13
14
  } from '../types'
14
15
 
16
+ const runSlotJobs: { label: string; job: TopBarJob | null }[] = [
17
+ { label: 'Idle', job: null },
18
+ { label: 'Running', job: { status: 'running', label: 'Extracting', percent: 40 } },
19
+ { label: 'Needs input', job: { status: 'awaiting_input', label: 'Decoding', percent: 62 } },
20
+ { label: 'Saved', job: { status: 'completed', label: 'Saved to EXP-0142' } },
21
+ { label: 'Failed', job: { status: 'failed', label: '1 failed' } },
22
+ ]
23
+
24
+ // An index, not the job object: Histoire merges object state across updates.
25
+ const liveRow = ref(0)
26
+
15
27
  const variants: TopBarVariant[] = ['card', 'default']
16
28
 
17
29
  const samplePillNav: PillNavItem[] = [
@@ -43,10 +55,12 @@ const samplePageSelector: PageSelectorItem[] = [
43
55
  { id: 'admin', label: 'Admin' },
44
56
  ]
45
57
 
58
+ // The menu shows each hint as a second line; a page without one stays a single line.
46
59
  const samplePluginPages: PageSelectorItem[] = [
47
- { id: 'peaks', label: 'Peak integration' },
48
- { id: 'calibration', label: 'Calibration' },
49
- { id: 'report', label: 'Report' },
60
+ { id: 'peaks', label: 'Peak integration', hint: 'Integrate peaks and review the fits per sample' },
61
+ { id: 'calibration', label: 'Calibration', hint: 'Fit standard curves and check their residuals' },
62
+ { id: 'report', label: 'Report', hint: 'Export concentrations for the experiment' },
63
+ { id: 'settings', label: 'Settings' },
50
64
  ]
51
65
 
52
66
  const samplePlugin: PluginInfo = {
@@ -147,6 +161,47 @@ function seedIdentity({ app }: { app: App }) {
147
161
  </div>
148
162
  </Variant>
149
163
 
164
+ <Variant title="Run slot · Experiment and job">
165
+ <!-- The plugin picks the viewer's own job; the bar joins it to the experiment chip. Like LEAF Extract: no Save. -->
166
+ <div style="padding: 2rem; background: var(--bg-primary); display: grid; gap: 0.75rem;">
167
+ <ExperimentProvider :plugin="samplePlugin" :save="false">
168
+ <AppTopBar
169
+ v-for="row in runSlotJobs"
170
+ :key="row.label"
171
+ :spec="{ themeToggle: true, homePath: '' }"
172
+ :page-selector="samplePluginPages"
173
+ current-page-selector-id="peaks"
174
+ :job="row.job"
175
+ />
176
+ </ExperimentProvider>
177
+ </div>
178
+ </Variant>
179
+
180
+ <Variant title="Run slot · Animation">
181
+ <div style="padding: 2rem; background: var(--bg-primary); display: grid; gap: 0.75rem;">
182
+ <div style="display: flex; gap: 0.5rem;">
183
+ <button
184
+ v-for="(row, index) in runSlotJobs"
185
+ :key="row.label"
186
+ type="button"
187
+ data-run-slot-state
188
+ style="padding: 0.25rem 0.75rem; border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-card); color: var(--text-primary); cursor: pointer;"
189
+ @click="liveRow = index"
190
+ >
191
+ {{ row.label }}
192
+ </button>
193
+ </div>
194
+ <ExperimentProvider :plugin="samplePlugin" :save="false">
195
+ <AppTopBar
196
+ :spec="{ themeToggle: true, homePath: '' }"
197
+ :page-selector="samplePluginPages"
198
+ current-page-selector-id="peaks"
199
+ :job="runSlotJobs[liveRow].job"
200
+ />
201
+ </ExperimentProvider>
202
+ </div>
203
+ </Variant>
204
+
150
205
  <Variant title="Refresh · Admin">
151
206
  <div style="padding: 2rem; background: var(--bg-primary); min-height: 12rem;">
152
207
  <AppTopBar