@morscherlab/mint-sdk 1.4.0-beta.11 → 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 (84) hide show
  1. package/dist/{AlertBox-B2Bgr3ei.js → AlertBox-BdoZqANv.js} +2 -2
  2. package/dist/{AlertBox-B2Bgr3ei.js.map → AlertBox-BdoZqANv.js.map} +1 -1
  3. package/dist/{AppSidebar-mvm--GxN.js → AppSidebar-CIXJBzDv.js} +237 -70
  4. package/dist/{AppSidebar-mvm--GxN.js.map → AppSidebar-CIXJBzDv.js.map} +1 -1
  5. package/dist/{ExperimentPopover-5T1fAq2L.js → ExperimentPopover-CbD7BR_s.js} +2 -2
  6. package/dist/{ExperimentPopover-5T1fAq2L.js.map → ExperimentPopover-CbD7BR_s.js.map} +1 -1
  7. package/dist/{ExperimentSelectorModal-65peG4Up.js → ExperimentSelectorModal-BbFtr1Fr.js} +112 -96
  8. package/dist/ExperimentSelectorModal-BbFtr1Fr.js.map +1 -0
  9. package/dist/{SegmentedControl-BtnnjWzs.js → SegmentedControl-BkwYOKpG.js} +2 -2
  10. package/dist/{SegmentedControl-BtnnjWzs.js.map → SegmentedControl-BkwYOKpG.js.map} +1 -1
  11. package/dist/{SettingsModal-DO3IZ7qz.js → SettingsModal-CdhFMi8j.js} +25 -24
  12. package/dist/SettingsModal-CdhFMi8j.js.map +1 -0
  13. package/dist/__stories__/experiment-helpers.d.ts +10 -0
  14. package/dist/__tests__/styles/paletteTokens.test.d.ts +1 -0
  15. package/dist/components/AppTopBar.vue.d.ts +13 -5
  16. package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
  17. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
  18. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
  19. package/dist/{controlSchemaModel-B8CTXagP.js → controlSchemaModel-BF5g4wRC.js} +2 -2
  20. package/dist/{controlSchemaModel-B8CTXagP.js.map → controlSchemaModel-BF5g4wRC.js.map} +1 -1
  21. package/dist/{data-CgILxDLW.js → data-DUt0TIp5.js} +10 -10
  22. package/dist/{data-CgILxDLW.js.map → data-DUt0TIp5.js.map} +1 -1
  23. package/dist/data.js +7 -7
  24. package/dist/{experiment-43OJtH7R.js → experiment-DyO54FQB.js} +25 -61
  25. package/dist/experiment-DyO54FQB.js.map +1 -0
  26. package/dist/index.js +18 -18
  27. package/dist/{install-mB8-IyvO.js → install-Czoe1rA8.js} +13 -13
  28. package/dist/{install-mB8-IyvO.js.map → install-Czoe1rA8.js.map} +1 -1
  29. package/dist/install.js +1 -1
  30. package/dist/{lab-D5bIX426.js → lab-B-pLwc5U.js} +10 -10
  31. package/dist/{lab-D5bIX426.js.map → lab-B-pLwc5U.js.map} +1 -1
  32. package/dist/lab.js +4 -4
  33. package/dist/{records-B6gpM_3e.js → records-DnK_1Q_S.js} +2 -2
  34. package/dist/{records-B6gpM_3e.js.map → records-DnK_1Q_S.js.map} +1 -1
  35. package/dist/{runtime-D7vKpfbF.js → runtime-fPb_FEZr.js} +9 -9
  36. package/dist/{runtime-D7vKpfbF.js.map → runtime-fPb_FEZr.js.map} +1 -1
  37. package/dist/runtime.js +7 -7
  38. package/dist/{shell-CywENwBT.js → shell-lDf_EY-x.js} +4 -4
  39. package/dist/{shell-CywENwBT.js.map → shell-lDf_EY-x.js.map} +1 -1
  40. package/dist/shell.js +4 -4
  41. package/dist/stores/index.d.ts +1 -1
  42. package/dist/stores/index.js +2 -2
  43. package/dist/stores/settings.d.ts +4 -12
  44. package/dist/styles.css +409 -60
  45. package/dist/types/index.d.ts +1 -1
  46. package/dist/types/jobs.d.ts +9 -0
  47. package/dist/types/platform.d.ts +1 -1
  48. package/dist/{useExperimentSamples-DHnxKFAF.js → useExperimentSamples-BGN1dmcR.js} +4 -4
  49. package/dist/{useExperimentSamples-DHnxKFAF.js.map → useExperimentSamples-BGN1dmcR.js.map} +1 -1
  50. package/dist/{useListReorder-BitdUhxn.js → useListReorder-DPynGrCj.js} +3 -3
  51. package/dist/{useListReorder-BitdUhxn.js.map → useListReorder-DPynGrCj.js.map} +1 -1
  52. package/dist/{useRackEditor-Q8xO-cCy.js → useRackEditor-B2FrQA0p.js} +7 -7
  53. package/dist/{useRackEditor-Q8xO-cCy.js.map → useRackEditor-B2FrQA0p.js.map} +1 -1
  54. package/dist/{useRealtime-C3iy9QkU.js → useRealtime-yWjTTnc9.js} +2 -2
  55. package/dist/{useRealtime-C3iy9QkU.js.map → useRealtime-yWjTTnc9.js.map} +1 -1
  56. package/dist/{useRequest-D506eHi8.js → useRequest-Dqw12k2s.js} +3 -3
  57. package/dist/{useRequest-D506eHi8.js.map → useRequest-Dqw12k2s.js.map} +1 -1
  58. package/package.json +1 -1
  59. package/src/__stories__/experiment-helpers.ts +3 -3
  60. package/src/__tests__/components/AppTopBar.test.ts +175 -1
  61. package/src/__tests__/components/AppTopBarPageSelector.test.ts +125 -9
  62. package/src/__tests__/components/ExperimentSelectorModal.test.ts +34 -1
  63. package/src/__tests__/components/SettingsModal.test.ts +9 -2
  64. package/src/__tests__/stores/settings.test.ts +21 -21
  65. package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
  66. package/src/__tests__/styles/paletteTokens.test.ts +44 -0
  67. package/src/components/AppTopBar.story.vue +59 -4
  68. package/src/components/AppTopBar.vue +107 -8
  69. package/src/components/ExperimentSelectorModal.story.vue +38 -0
  70. package/src/components/ExperimentSelectorModal.vue +20 -1
  71. package/src/components/SettingsModal.vue +7 -12
  72. package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
  73. package/src/stores/index.ts +0 -2
  74. package/src/stores/settings.ts +20 -50
  75. package/src/styles/components/app-page-selector.css +120 -45
  76. package/src/styles/components/app-top-bar.css +155 -0
  77. package/src/styles/components/experiment-selector-modal.css +13 -0
  78. package/src/styles/variables.css +121 -15
  79. package/src/types/index.ts +1 -0
  80. package/src/types/jobs.ts +10 -0
  81. package/src/types/platform.ts +1 -1
  82. package/dist/ExperimentSelectorModal-65peG4Up.js.map +0 -1
  83. package/dist/SettingsModal-DO3IZ7qz.js.map +0 -1
  84. package/dist/experiment-43OJtH7R.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'
@@ -728,10 +728,17 @@ describe('SettingsModal', () => {
728
728
  expect(useSettingsStore(pinia).theme).toBe('dark')
729
729
  })
730
730
 
731
- it('should render a swatch strip for each palette when appearance is shown', () => {
731
+ it('should preview each palette with its own tokens when appearance is shown', () => {
732
732
  const wrapper = mountSettingsModal({ showAppearance: true })
733
733
 
734
- expect(wrapper.findAll('.mint-settings-modal__palette')).toHaveLength(Object.keys(colorPalettes).length)
734
+ const strips = wrapper.findAll('.mint-settings-modal__swatches')
735
+ expect(strips.map((strip) => strip.attributes('data-palette'))).toEqual(Object.keys(colorPalettes))
736
+ expect(strips[0].findAll('.mint-settings-modal__swatch').map((swatch) => swatch.attributes('style'))).toEqual([
737
+ 'background-color: var(--color-primary);',
738
+ 'background-color: var(--color-primary-hover);',
739
+ 'background-color: var(--color-primary-soft);',
740
+ 'background-color: var(--color-primary-border);',
741
+ ])
735
742
  })
736
743
 
737
744
  it('should resize the density preview when the density changes', async () => {
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { createPinia, setActivePinia } from 'pinia'
3
3
  import { nextTick } from 'vue'
4
4
  import {
5
- paletteCssVariables,
6
5
  tableDensityToSize,
7
6
  tryUseSettingsStore,
8
7
  useSettingsStore,
@@ -54,6 +53,7 @@ describe('useSettingsStore', () => {
54
53
  document.documentElement.classList.remove('dark')
55
54
  document.documentElement.removeAttribute('style')
56
55
  delete document.documentElement.dataset.density
56
+ delete document.documentElement.dataset.palette
57
57
  mockMatchMedia(false)
58
58
  })
59
59
 
@@ -72,51 +72,51 @@ describe('useSettingsStore', () => {
72
72
  expect(settings.serverHost).toBe('127.0.0.1')
73
73
  expect(settings.serverPort).toBe(8001)
74
74
  expect(settings.theme).toBe('dark')
75
- expect(settings.colorPalette).toBe('viridis')
75
+ expect(settings.colorPalette).toBe('purple')
76
76
  expect(settings.tableDensity).toBe('compact')
77
77
  expect(localStorage.getItem('mld-settings')).toBeNull()
78
78
  expect(JSON.parse(localStorage.getItem('mint-settings') || '{}')).toEqual(expect.objectContaining({
79
79
  serverHost: '127.0.0.1',
80
80
  serverPort: 8001,
81
81
  theme: 'dark',
82
- colorPalette: 'viridis',
82
+ colorPalette: 'purple',
83
83
  tableDensity: 'compact',
84
84
  }))
85
85
  })
86
86
 
87
87
  describe('palette and density DOM sync', () => {
88
- it('writes no palette overrides for the default palette', () => {
88
+ it('mirrors the stored palette onto html[data-palette] without inline overrides', () => {
89
+ localStorage.setItem('mint-settings', JSON.stringify({ colorPalette: 'ocean' }))
90
+
89
91
  const settings = useSettingsStore()
90
92
  settings.initialize()
91
93
 
92
- expect(paletteCssVariables('default')).toEqual({})
94
+ expect(document.documentElement.dataset.palette).toBe('ocean')
93
95
  expect(document.documentElement.style.getPropertyValue('--color-primary')).toBe('')
94
96
  })
95
97
 
96
- it('applies the stored palette hues to html on initialize', () => {
97
- localStorage.setItem('mint-settings', JSON.stringify({ colorPalette: 'viridis' }))
98
-
98
+ it('updates html[data-palette] when the palette changes', async () => {
99
99
  const settings = useSettingsStore()
100
100
  settings.initialize()
101
+ expect(document.documentElement.dataset.palette).toBe('default')
101
102
 
102
- const style = document.documentElement.style
103
- expect(style.getPropertyValue('--color-primary')).toBe('hsl(280 84% 67%)')
104
- expect(style.getPropertyValue('--color-primary-hover')).toBe('hsl(280 75% 59%)')
105
- expect(style.getPropertyValue('--color-cta')).toBe('hsl(80 95% 53%)')
103
+ settings.colorPalette = 'forest'
104
+ await nextTick()
105
+ expect(document.documentElement.dataset.palette).toBe('forest')
106
106
  })
107
107
 
108
- it('re-applies the overrides when the palette changes and clears them on default', async () => {
108
+ it.each([
109
+ ['viridis', 'purple'],
110
+ ['pastel', 'default'],
111
+ ['not-a-palette', 'default'],
112
+ ])('maps the stored palette %s onto %s', (stored, expected) => {
113
+ localStorage.setItem('mint-settings', JSON.stringify({ colorPalette: stored }))
114
+
109
115
  const settings = useSettingsStore()
110
116
  settings.initialize()
111
117
 
112
- settings.colorPalette = 'pastel'
113
- await nextTick()
114
- expect(document.documentElement.style.getPropertyValue('--color-primary')).toBe('hsl(200 84% 67%)')
115
-
116
- settings.colorPalette = 'default'
117
- await nextTick()
118
- expect(document.documentElement.style.getPropertyValue('--color-primary')).toBe('')
119
- expect(document.documentElement.style.getPropertyValue('--color-primary-soft')).toBe('')
118
+ expect(settings.colorPalette).toBe(expected)
119
+ expect(document.documentElement.dataset.palette).toBe(expected)
120
120
  })
121
121
 
122
122
  it('mirrors the table density onto html[data-density]', async () => {
@@ -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.
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { colorPalettes } from '../../stores/settings'
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url))
8
+ const variablesCss = readFileSync(join(resolve(here, '../..'), 'styles/variables.css'), 'utf-8')
9
+
10
+ const ACCENT_TOKENS = [
11
+ '--color-primary',
12
+ '--color-primary-hover',
13
+ '--color-primary-pressed',
14
+ '--color-primary-soft',
15
+ '--color-primary-border',
16
+ ]
17
+ const STATUS_TOKENS = ['--mint-success', '--mint-error', '--mint-warning', '--mint-warning-text']
18
+
19
+ /** Declarations of every rule whose selector list has a selector that ends in `[data-palette="<key>"]`. */
20
+ function paletteDeclarations(key: string, dark: boolean): string {
21
+ const attr = `[data-palette="${key}"]`
22
+ return [...variablesCss.matchAll(/([^{}]+)\{([^}]*)\}/g)]
23
+ .filter(([, selector]) => selector.split(',').some((s) => {
24
+ const one = s.trim()
25
+ return one.endsWith(attr) && one.startsWith('html.dark') === dark
26
+ }))
27
+ .map(([, , body]) => body)
28
+ .join('\n')
29
+ }
30
+
31
+ describe('accent palettes', () => {
32
+ it.each(Object.keys(colorPalettes).flatMap((key) => [[key, 'light'], [key, 'dark']]))(
33
+ 'should define every accent token for the %s palette in %s mode',
34
+ (key, theme) => {
35
+ const body = paletteDeclarations(key, theme === 'dark')
36
+ for (const token of ACCENT_TOKENS) expect(body, `${key} ${theme} ${token}`).toMatch(new RegExp(`${token}\\s*:`))
37
+ },
38
+ )
39
+
40
+ it('should give the colour-blind palette Okabe-Ito status colours', () => {
41
+ const body = paletteDeclarations('colorblind', false)
42
+ for (const token of STATUS_TOKENS) expect(body, token).toMatch(new RegExp(`${token}\\s*:`))
43
+ })
44
+ })