@morscherlab/mint-sdk 1.0.0-beta.3 → 1.0.0-beta.4

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 (165) hide show
  1. package/README.md +9 -2
  2. package/dist/__tests__/composables/experiment-utils.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useApi.test.d.ts +1 -0
  4. package/dist/components/AppContainer.vue.d.ts +1 -1
  5. package/dist/components/AppLayout.vue.d.ts +20 -1
  6. package/dist/components/AppSidebar.vue.d.ts +56 -4
  7. package/dist/components/AppTopBar.vue.d.ts +7 -25
  8. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +3 -1
  9. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +1 -0
  10. package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +5 -0
  11. package/dist/components/ComponentBindingRenderer.vue.d.ts +44 -0
  12. package/dist/components/ControlWorkspaceView.vue.d.ts +24 -7
  13. package/dist/components/DoseDesignWorkspaceView.vue.d.ts +149 -0
  14. package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
  15. package/dist/components/FormBuilder.vue.d.ts +9 -9
  16. package/dist/components/PlateMapEditor.vue.d.ts +1 -1
  17. package/dist/components/PluginWorkspaceView.vue.d.ts +310 -0
  18. package/dist/components/SettingsModal.vue.d.ts +1 -1
  19. package/dist/components/WellPlate.vue.d.ts +2 -2
  20. package/dist/components/index.d.ts +3 -12
  21. package/dist/components/index.js +3 -3
  22. package/dist/components/{AppPageSelector.vue.d.ts → internal/AppPageSelectorInternal.vue.d.ts} +1 -1
  23. package/dist/components/{AppPillNav.vue.d.ts → internal/AppPillNavInternal.vue.d.ts} +3 -1
  24. package/dist/components/{CalendarGridPanel.vue.d.ts → internal/CalendarGridPanelInternal.vue.d.ts} +1 -1
  25. package/dist/components/internal/FormSectionRenderer.vue.d.ts +4 -4
  26. package/dist/components/{WellEditPopup.vue.d.ts → internal/WellEditPopupInternal.vue.d.ts} +1 -1
  27. package/dist/{components-D_Sr0adg.js → components-BkGF4B4y.js} +4484 -3967
  28. package/dist/components-BkGF4B4y.js.map +1 -0
  29. package/dist/composables/experiment-utils.d.ts +8 -0
  30. package/dist/composables/index.d.ts +5 -7
  31. package/dist/composables/index.js +4 -4
  32. package/dist/composables/useAppExperiment.d.ts +31 -2
  33. package/dist/composables/useBioTemplateComponents.d.ts +5 -3
  34. package/dist/composables/useBioTemplatePackWorkspace.d.ts +3 -2
  35. package/dist/composables/useBioTemplatePresetWorkspace.d.ts +6 -5
  36. package/dist/composables/useBioTemplateWorkspace.d.ts +5 -4
  37. package/dist/composables/useControlSchema.d.ts +43 -21
  38. package/dist/composables/usePluginClient.d.ts +5 -2
  39. package/dist/{composables-C3dpXQN5.js → composables-CHsME9H1.js} +40 -28
  40. package/dist/composables-CHsME9H1.js.map +1 -0
  41. package/dist/index.d.ts +5 -12
  42. package/dist/index.js +5 -5
  43. package/dist/install.js +2 -2
  44. package/dist/styles.css +3625 -3651
  45. package/dist/templates/componentBindings.d.ts +13 -0
  46. package/dist/templates/index.d.ts +3 -3
  47. package/dist/templates/index.js +2 -2
  48. package/dist/{templates-50NPjaxL.js → templates-B5jmTWuk.js} +111 -56
  49. package/dist/templates-B5jmTWuk.js.map +1 -0
  50. package/dist/types/components.d.ts +6 -25
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/{useScheduleDrag-D4oWdh41.js → useScheduleDrag-BgzpQT53.js} +160 -117
  53. package/dist/useScheduleDrag-BgzpQT53.js.map +1 -0
  54. package/package.json +1 -1
  55. package/src/__tests__/components/ActionItem.test.ts +6 -6
  56. package/src/__tests__/components/AppLayout.test.ts +44 -0
  57. package/src/__tests__/components/AppPageSelector.test.ts +8 -8
  58. package/src/__tests__/components/AppPillNav.test.ts +53 -6
  59. package/src/__tests__/components/AppSidebar.test.ts +126 -0
  60. package/src/__tests__/components/AppToastContainer.test.ts +0 -11
  61. package/src/__tests__/components/AppTopBar.test.ts +182 -119
  62. package/src/__tests__/components/BioTemplateExperimentWorkspaceView.test.ts +7 -1
  63. package/src/__tests__/components/BioTemplatePackWorkspaceView.test.ts +15 -1
  64. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +26 -1
  65. package/src/__tests__/components/CalendarGridPanel.test.ts +3 -3
  66. package/src/__tests__/components/ComponentBindingRenderer.test.ts +161 -0
  67. package/src/__tests__/components/ControlWorkspaceView.test.ts +134 -63
  68. package/src/__tests__/components/DateTimePicker.test.ts +2 -2
  69. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +185 -0
  70. package/src/__tests__/components/PluginWorkspaceView.test.ts +548 -0
  71. package/src/__tests__/composables/experiment-utils.test.ts +30 -0
  72. package/src/__tests__/composables/useApi.test.ts +30 -0
  73. package/src/__tests__/composables/useAppExperiment.test.ts +100 -1
  74. package/src/__tests__/composables/useBioTemplatePackWorkspace.test.ts +6 -3
  75. package/src/__tests__/composables/useBioTemplatePresetWorkspace.test.ts +6 -6
  76. package/src/__tests__/composables/useBioTemplateWorkspace.test.ts +6 -1
  77. package/src/__tests__/composables/useControlSchema.test.ts +150 -36
  78. package/src/__tests__/composables/usePluginClient.test.ts +99 -2
  79. package/src/__tests__/docs/frontendDocsCatalog.test.ts +120 -25
  80. package/src/__tests__/templates/templates.test.ts +12 -0
  81. package/src/components/AppAvatarMenu.vue +3 -3
  82. package/src/components/AppLayout.story.vue +39 -0
  83. package/src/components/AppLayout.vue +83 -2
  84. package/src/components/AppPluginSwitcher.vue +5 -5
  85. package/src/components/AppSidebar.story.vue +113 -5
  86. package/src/components/AppSidebar.vue +144 -24
  87. package/src/components/AppTopBar.story.vue +2 -5
  88. package/src/components/AppTopBar.vue +35 -425
  89. package/src/components/BioTemplateExperimentWorkspaceView.story.vue +2 -2
  90. package/src/components/BioTemplateExperimentWorkspaceView.vue +6 -0
  91. package/src/components/BioTemplatePackWorkspaceView.story.vue +4 -4
  92. package/src/components/BioTemplatePackWorkspaceView.vue +1 -0
  93. package/src/components/BioTemplatePresetWorkspaceView.story.vue +14 -2
  94. package/src/components/BioTemplatePresetWorkspaceView.vue +11 -2
  95. package/src/components/BioTemplateRenderer.vue +15 -227
  96. package/src/components/ComponentBindingRenderer.story.vue +57 -0
  97. package/src/components/ComponentBindingRenderer.vue +308 -0
  98. package/src/components/ControlWorkspaceView.story.vue +20 -9
  99. package/src/components/ControlWorkspaceView.vue +43 -12
  100. package/src/components/DatePicker.vue +2 -2
  101. package/src/components/DateTimePicker.vue +2 -2
  102. package/src/components/DoseDesignWorkspaceView.story.vue +77 -0
  103. package/src/components/DoseDesignWorkspaceView.vue +255 -0
  104. package/src/components/ExperimentPopover.vue +2 -6
  105. package/src/components/ExperimentSelectorModal.vue +6 -5
  106. package/src/components/FormBuilder.story.vue +190 -0
  107. package/src/components/PluginWorkspaceView.story.vue +334 -0
  108. package/src/components/PluginWorkspaceView.vue +708 -0
  109. package/src/components/SettingsModal.story.vue +87 -0
  110. package/src/components/WellPlate.vue +2 -2
  111. package/src/components/index.ts +3 -12
  112. package/src/components/{AppPageSelector.vue → internal/AppPageSelectorInternal.vue} +9 -9
  113. package/src/components/internal/AppPillNavInternal.vue +194 -0
  114. package/src/components/{CalendarGridPanel.vue → internal/CalendarGridPanelInternal.vue} +1 -1
  115. package/src/components/{WellEditPopup.vue → internal/WellEditPopupInternal.vue} +3 -3
  116. package/src/composables/experiment-utils.ts +26 -0
  117. package/src/composables/index.ts +21 -7
  118. package/src/composables/useApi.ts +9 -2
  119. package/src/composables/useAppExperiment.ts +85 -13
  120. package/src/composables/useBioTemplateComponents.ts +12 -0
  121. package/src/composables/useBioTemplatePackWorkspace.ts +6 -2
  122. package/src/composables/useBioTemplatePresetWorkspace.ts +10 -21
  123. package/src/composables/useBioTemplateWorkspace.ts +6 -4
  124. package/src/composables/useControlSchema.ts +157 -69
  125. package/src/composables/usePluginClient.ts +50 -9
  126. package/src/index.ts +6 -563
  127. package/src/styles/components/app-layout.css +82 -0
  128. package/src/styles/components/app-pill-nav.css +70 -0
  129. package/src/styles/components/app-sidebar.css +119 -0
  130. package/src/styles/components/app-top-bar.css +0 -235
  131. package/src/styles/index.css +0 -1
  132. package/src/templates/componentBindings.ts +38 -0
  133. package/src/templates/index.ts +4 -0
  134. package/src/types/components.ts +6 -31
  135. package/src/types/index.ts +2 -6
  136. package/dist/__tests__/composables/usePluginApi.test.d.ts +0 -13
  137. package/dist/components/FormFieldRenderer.vue.d.ts +0 -28
  138. package/dist/components/FormSection.vue.d.ts +0 -30
  139. package/dist/components/GroupingModal.vue.d.ts +0 -12
  140. package/dist/components/SettingsButton.vue.d.ts +0 -30
  141. package/dist/components/ToastNotification.vue.d.ts +0 -2
  142. package/dist/components-D_Sr0adg.js.map +0 -1
  143. package/dist/composables/usePluginApi.d.ts +0 -22
  144. package/dist/composables-C3dpXQN5.js.map +0 -1
  145. package/dist/templates-50NPjaxL.js.map +0 -1
  146. package/dist/useScheduleDrag-D4oWdh41.js.map +0 -1
  147. package/src/__tests__/components/FormCompatibility.test.ts +0 -94
  148. package/src/__tests__/components/GroupingModal.test.ts +0 -73
  149. package/src/__tests__/components/SettingsButton.test.ts +0 -44
  150. package/src/__tests__/composables/usePluginApi.test.ts +0 -81
  151. package/src/components/AppPillNav.vue +0 -71
  152. package/src/components/FormFieldRenderer.vue +0 -35
  153. package/src/components/FormSection.vue +0 -37
  154. package/src/components/GroupingModal.story.vue +0 -52
  155. package/src/components/GroupingModal.vue +0 -61
  156. package/src/components/SettingsButton.story.vue +0 -58
  157. package/src/components/SettingsButton.vue +0 -64
  158. package/src/components/ToastNotification.vue +0 -9
  159. package/src/composables/usePluginApi.ts +0 -32
  160. package/src/styles/components/settings-button.css +0 -31
  161. /package/dist/__tests__/components/{FormCompatibility.test.d.ts → ComponentBindingRenderer.test.d.ts} +0 -0
  162. /package/dist/__tests__/components/{GroupingModal.test.d.ts → DoseDesignWorkspaceView.test.d.ts} +0 -0
  163. /package/dist/__tests__/components/{SettingsButton.test.d.ts → PluginWorkspaceView.test.d.ts} +0 -0
  164. /package/dist/components/{ActionItem.vue.d.ts → internal/ActionItemInternal.vue.d.ts} +0 -0
  165. /package/src/components/{ActionItem.vue → internal/ActionItemInternal.vue} +0 -0
@@ -3,8 +3,11 @@ import { mount } from '@vue/test-utils'
3
3
  import { computed, ref } from 'vue'
4
4
  import { createPinia } from 'pinia'
5
5
  import AppTopBar from '../../components/AppTopBar.vue'
6
+ import ExperimentPopover from '../../components/ExperimentPopover.vue'
7
+ import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vue'
6
8
  import { usePlatformContext } from '../../composables/usePlatformContext'
7
- import { controlsToTopBarTabs, defineControlModel, defineControls } from '../../composables/useControlSchema'
9
+ import { APP_EXPERIMENT_KEY, type AppExperimentState } from '../../composables/useAppExperiment'
10
+ import { defineControlModel, defineControls } from '../../composables/useControlSchema'
8
11
  import ThemeToggle from '../../components/ThemeToggle.vue'
9
12
  import SettingsModal from '../../components/SettingsModal.vue'
10
13
  import type { TopBarSettingsConfig } from '../../types/components'
@@ -43,7 +46,15 @@ function createWrapper(props = {}, slots = {}) {
43
46
  plugins: [pinia],
44
47
  stubs: {
45
48
  'router-link': {
46
- template: '<a><slot /></a>',
49
+ props: ['to', 'custom'],
50
+ template: `
51
+ <slot
52
+ v-if="custom"
53
+ :href="typeof to === 'string' ? to : '#'"
54
+ :navigate="() => {}"
55
+ />
56
+ <a v-else :href="typeof to === 'string' ? to : '#'"><slot /></a>
57
+ `,
47
58
  },
48
59
  BaseModal: {
49
60
  template: '<div><slot /></div>',
@@ -525,6 +536,86 @@ describe('AppTopBar', () => {
525
536
  })
526
537
 
527
538
  describe('combined features', () => {
539
+ it('passes injected app experiment bindings to the popover and selector modal', () => {
540
+ vi.mocked(usePlatformContext).mockReturnValueOnce({
541
+ isIntegrated: computed(() => true),
542
+ context: ref({ isIntegrated: true, theme: 'system' }),
543
+ plugin: computed(() => undefined),
544
+ user: computed(() => undefined),
545
+ theme: computed(() => 'system' as const),
546
+ features: computed(() => undefined),
547
+ navigate: vi.fn(),
548
+ notify: vi.fn(),
549
+ sendToPlatform: vi.fn(),
550
+ })
551
+ const appExperiment: AppExperimentState = {
552
+ experimentName: ref('Dose response'),
553
+ experimentCode: ref('EXP-012'),
554
+ experimentStatus: ref('ongoing'),
555
+ experimentId: ref(12),
556
+ showModal: ref(false),
557
+ saveLoading: ref(false),
558
+ saveSuccessMessage: ref('Saved'),
559
+ showSave: ref(true),
560
+ showDetach: computed(() => true),
561
+ saveDisabled: computed(() => true),
562
+ saveDisabledMessage: computed(() => 'Locked'),
563
+ popover: computed(() => ({
564
+ experimentName: 'Dose response',
565
+ experimentCode: 'EXP-012',
566
+ experimentStatus: 'ongoing',
567
+ showSave: true,
568
+ showDetach: true,
569
+ saveDisabled: true,
570
+ saveDisabledMessage: 'Locked',
571
+ saveLoading: false,
572
+ saveSuccessMessage: 'Saved',
573
+ })),
574
+ selectorModal: computed(() => ({
575
+ modelValue: false,
576
+ currentExperimentId: 12,
577
+ })),
578
+ openModal: vi.fn(),
579
+ closeModal: vi.fn(),
580
+ handleSelect: vi.fn(),
581
+ handleSave: vi.fn(),
582
+ handleDetach: vi.fn(),
583
+ }
584
+ const wrapper = mount(AppTopBar, {
585
+ props: { title: 'Test App' },
586
+ global: {
587
+ plugins: [createPinia()],
588
+ provide: {
589
+ [APP_EXPERIMENT_KEY as symbol]: appExperiment,
590
+ },
591
+ stubs: {
592
+ 'router-link': {
593
+ template: '<a><slot /></a>',
594
+ },
595
+ BaseModal: {
596
+ template: '<div><slot /></div>',
597
+ },
598
+ },
599
+ },
600
+ })
601
+
602
+ expect(wrapper.findComponent(ExperimentPopover).props()).toMatchObject({
603
+ experimentName: 'Dose response',
604
+ experimentCode: 'EXP-012',
605
+ experimentStatus: 'ongoing',
606
+ showSave: true,
607
+ showDetach: true,
608
+ saveDisabled: true,
609
+ saveDisabledMessage: 'Locked',
610
+ saveLoading: false,
611
+ saveSuccessMessage: 'Saved',
612
+ })
613
+ expect(wrapper.findComponent(ExperimentSelectorModal).props()).toMatchObject({
614
+ modelValue: false,
615
+ currentExperimentId: 12,
616
+ })
617
+ })
618
+
528
619
  it('should render ThemeToggle and settings button together', () => {
529
620
  const wrapper = createWrapper({
530
621
  title: 'Test App',
@@ -591,8 +682,8 @@ describe('AppTopBar', () => {
591
682
  })
592
683
  })
593
684
 
594
- describe('classic prop surface', () => {
595
- it('should not break existing usage without new props', () => {
685
+ describe('navigation prop surface', () => {
686
+ it('renders a title-only layout without navigation props', () => {
596
687
  const wrapper = createWrapper({
597
688
  title: 'Classic App',
598
689
  subtitle: 'Old API',
@@ -605,120 +696,113 @@ describe('AppTopBar', () => {
605
696
  expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(true)
606
697
  })
607
698
 
608
- it('should work with existing pages and tabs props', () => {
699
+ it('composes page selector, pill nav, theme, and settings controls', () => {
609
700
  const wrapper = createWrapper({
610
- pluginName: 'Test Plugin',
611
701
  title: 'Dashboard',
612
- pages: [
702
+ pageSelector: [
613
703
  { id: 'dashboard', label: 'Dashboard' },
614
704
  { id: 'settings', label: 'Settings' },
615
705
  ],
616
- tabs: [
706
+ currentPageSelectorId: 'dashboard',
707
+ pillNav: [
617
708
  { id: 'overview', label: 'Overview' },
618
709
  { id: 'details', label: 'Details' },
619
710
  ],
711
+ currentPillId: 'overview',
620
712
  showThemeToggle: true,
621
713
  showSettings: true,
622
714
  })
623
715
 
624
- expect(wrapper.text()).toContain('Test Plugin')
625
- expect(wrapper.text()).toContain('Dashboard')
716
+ expect(wrapper.find('.mint-page-selector__label').text()).toBe('Dashboard')
717
+ expect(wrapper.findAll('.mint-pill-nav__item').map(item => item.text())).toEqual([
718
+ 'Overview',
719
+ 'Details',
720
+ ])
721
+ expect(wrapper.findAll('.mint-pill-nav__item')[0].classes()).toContain('mint-pill-nav__item--active')
626
722
  expect(wrapper.findComponent(ThemeToggle).exists()).toBe(true)
627
723
  expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(true)
628
724
  })
629
725
 
630
- it('renders tabs generated from a compact control schema', async () => {
726
+ it('renders pill nav items with icon metadata', async () => {
631
727
  const runIcon = 'M8 5v14l11-7z'
632
- const controls = defineControls({
633
- threshold: {
634
- default: 0.05,
635
- section: 'parameters',
636
- view: 'analysis',
637
- },
638
- chartScale: {
639
- default: 'linear',
640
- section: 'display',
641
- view: 'results',
642
- },
643
- })
644
728
  const wrapper = createWrapper({
645
- tabs: controlsToTopBarTabs(controls, {
646
- views: {
647
- analysis: { label: 'Run', icon: runIcon },
648
- results: { label: 'Results' },
649
- },
650
- }),
651
- currentTabId: 'analysis',
729
+ pillNav: [
730
+ { id: 'analysis', label: 'Run', icon: runIcon },
731
+ { id: 'results', label: 'Results' },
732
+ ],
733
+ currentPillId: 'analysis',
652
734
  })
653
735
 
654
- const tabs = wrapper.findAll('.mint-topbar-tab')
655
- expect(tabs.map(tab => tab.text())).toEqual(['Run', 'Results'])
656
- expect(tabs[0].classes()).toContain('mint-topbar-tab--active')
657
- expect(tabs[0].get('.mint-topbar-tab-icon path').attributes('d')).toBe(runIcon)
658
- expect(tabs[1].find('.mint-topbar-tab-icon').exists()).toBe(false)
736
+ const pills = wrapper.findAll('.mint-pill-nav__item')
737
+ expect(pills.map(pill => pill.text())).toEqual(['Run', 'Results'])
738
+ expect(pills[0].classes()).toContain('mint-pill-nav__item--active')
739
+ expect(pills[0].get('.mint-pill-nav__icon path').attributes('d')).toBe(runIcon)
740
+ expect(pills[1].find('.mint-pill-nav__icon').exists()).toBe(false)
659
741
 
660
- await tabs[1].trigger('click')
661
- expect(wrapper.emitted('tab-select')?.[0][0]).toMatchObject({ id: 'results', label: 'Results' })
742
+ await pills[1].trigger('click')
743
+ expect(wrapper.emitted('pill-select')?.[0][0]).toMatchObject({ id: 'results', label: 'Results' })
662
744
  })
663
745
 
664
- it('uses shared dropdown state for the legacy page menu', async () => {
746
+ it('uses shared dropdown state for the page selector menu', async () => {
665
747
  const wrapper = createWrapper({
666
- pluginName: 'Test Plugin',
667
- pages: [
748
+ pageSelector: [
668
749
  { id: 'dashboard', label: 'Dashboard' },
669
750
  { id: 'settings', label: 'Settings' },
670
751
  ],
752
+ currentPageSelectorId: 'dashboard',
671
753
  })
672
- const dropdown = () => wrapper.get('.mint-topbar-dropdown')
754
+ const dropdown = () => wrapper.find('.mint-page-selector__menu')
673
755
 
674
- expect(dropdown().attributes('style')).toContain('display: none')
756
+ expect(dropdown().exists()).toBe(false)
675
757
 
676
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
677
- expect(dropdown().attributes('style') ?? '').not.toContain('display: none')
758
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
759
+ expect(dropdown().exists()).toBe(true)
678
760
 
679
761
  document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
680
762
  await wrapper.vm.$nextTick()
681
- expect(dropdown().attributes('style')).toContain('display: none')
763
+ expect(dropdown().exists()).toBe(false)
682
764
 
683
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
684
- expect(dropdown().attributes('style') ?? '').not.toContain('display: none')
765
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
766
+ expect(dropdown().exists()).toBe(true)
685
767
 
686
768
  document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
687
769
  await wrapper.vm.$nextTick()
688
- expect(dropdown().attributes('style')).toContain('display: none')
770
+ expect(dropdown().exists()).toBe(false)
689
771
 
690
772
  wrapper.unmount()
691
773
  })
692
774
 
693
- it('renders PluginIcon-compatible metadata icons in the legacy page menu', async () => {
775
+ it('renders PluginIcon-compatible metadata icons in the page selector menu', async () => {
694
776
  const iconPath = 'M4 19h16M7 16V8m5 8V4m5 12v-6'
695
777
  const pngIcon = 'data:image/png;base64,iVBORw0KGgo='
696
778
  const wrapper = createWrapper({
697
- pluginName: 'Test Plugin',
698
- pages: [
699
- { id: 'dashboard', label: 'Dashboard', to: '/', icon: iconPath, description: 'Overview' },
779
+ pageSelector: [
780
+ { id: 'dashboard', label: 'Dashboard', to: '/', icon: iconPath, hint: 'Overview' },
700
781
  { id: 'image', label: 'Image', to: '/image', icon: pngIcon },
701
- { id: 'legacy', label: 'Legacy', icon: 'home' },
782
+ { id: 'text', label: 'Text', icon: 'home' },
702
783
  ],
703
- currentPageId: 'dashboard',
784
+ currentPageSelectorId: 'dashboard',
704
785
  })
705
786
 
706
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
787
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
707
788
 
708
- const icons = wrapper.findAllComponents({ name: 'PluginIcon' })
789
+ const icons = wrapper
790
+ .findAllComponents({ name: 'PluginIcon' })
791
+ .filter(icon => icon.classes().includes('mint-page-selector__metadata-icon'))
709
792
  expect(icons).toHaveLength(2)
710
793
  expect(icons[0].props('icon')).toBe(iconPath)
711
794
  expect(icons[1].props('icon')).toBe(pngIcon)
712
795
  expect(icons.every((icon) => icon.props('variant') === 'tinted')).toBe(true)
713
796
 
714
797
  const pathIcons = wrapper.findAll('.mint-plugin-icon__svg')
715
- expect(pathIcons).toHaveLength(1)
716
- expect(pathIcons[0].find('path').attributes('d')).toBe(iconPath)
798
+ expect(pathIcons.map(icon => icon.find('path').attributes('d'))).toEqual(
799
+ expect.arrayContaining([iconPath]),
800
+ )
717
801
 
718
802
  const imageIcons = wrapper.findAll('.mint-plugin-icon__img')
719
803
  expect(imageIcons).toHaveLength(1)
720
804
  expect(imageIcons[0].attributes('src')).toBe(pngIcon)
721
- expect(wrapper.find('.mint-topbar-dropdown-item__description').text()).toBe('Overview')
805
+ expect(wrapper.find('.mint-page-selector__item-hint').text()).toBe('Overview')
722
806
  })
723
807
  })
724
808
 
@@ -926,7 +1010,7 @@ describe('AppTopBar', () => {
926
1010
  expect(wrapper.find('.mint-topbar__logo-text').text()).toBe('M')
927
1011
  })
928
1012
 
929
- it('uses platform plugin nav metadata when pluginName and pages are omitted', async () => {
1013
+ it('uses platform plugin nav metadata as the page selector when pageSelector is omitted', async () => {
930
1014
  const previousPath = window.location.pathname
931
1015
  window.history.pushState({}, '', '/test/analysis')
932
1016
  try {
@@ -949,25 +1033,28 @@ describe('AppTopBar', () => {
949
1033
  })
950
1034
 
951
1035
  const wrapper = createWrapper()
952
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
1036
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
953
1037
 
954
- expect(wrapper.get('.mint-topbar-plugin-name').text()).toContain('Dose Designer')
955
- expect(wrapper.get('.mint-topbar-current-page').text()).toBe('Analysis')
956
- expect(wrapper.findAll('.mint-topbar-dropdown-item__label').map(item => item.text())).toEqual([
1038
+ expect(wrapper.get('.mint-page-selector__label').text()).toBe('Analysis')
1039
+ expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual([
957
1040
  'Dashboard',
958
1041
  'Analysis',
959
1042
  ])
1043
+ expect(wrapper.findAll('.mint-page-selector__item-hint').map(item => item.text())).toEqual([
1044
+ 'Dose Designer',
1045
+ 'Dose Designer',
1046
+ ])
960
1047
  const dropdownIcons = wrapper
961
1048
  .findAllComponents({ name: 'PluginIcon' })
962
- .filter(icon => icon.classes().includes('mint-topbar-dropdown-item__icon'))
1049
+ .filter(icon => icon.classes().includes('mint-page-selector__metadata-icon'))
963
1050
  expect(dropdownIcons.map(icon => icon.props('icon'))).toEqual([pluginIcon, analysisIcon])
964
- expect(wrapper.findAll('.mint-topbar-dropdown-item')[1].classes()).toContain('mint-topbar-dropdown-item--active')
1051
+ expect(wrapper.findAll('.mint-page-selector__item')[1].classes()).toContain('mint-page-selector__item--active')
965
1052
  } finally {
966
1053
  window.history.pushState({}, '', previousPath)
967
1054
  }
968
1055
  })
969
1056
 
970
- it('keeps explicit legacy pages ahead of platform plugin nav metadata', async () => {
1057
+ it('keeps explicit pageSelector ahead of platform plugin nav metadata', async () => {
971
1058
  mockPlatformCtx({
972
1059
  isIntegrated: true,
973
1060
  plugin: {
@@ -981,13 +1068,13 @@ describe('AppTopBar', () => {
981
1068
  })
982
1069
 
983
1070
  const wrapper = createWrapper({
984
- pluginName: 'Explicit Plugin',
985
- pages: [{ id: 'overview', label: 'Overview', to: '/' }],
1071
+ pageSelector: [{ id: 'overview', label: 'Overview', to: '/' }],
1072
+ currentPageSelectorId: 'overview',
986
1073
  })
987
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
1074
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
988
1075
 
989
- expect(wrapper.get('.mint-topbar-plugin-name').text()).toContain('Explicit Plugin')
990
- expect(wrapper.findAll('.mint-topbar-dropdown-item__label').map(item => item.text())).toEqual(['Overview'])
1076
+ expect(wrapper.get('.mint-page-selector__label').text()).toBe('Overview')
1077
+ expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual(['Overview'])
991
1078
  })
992
1079
  })
993
1080
 
@@ -1026,59 +1113,35 @@ describe('AppTopBar', () => {
1026
1113
  ])
1027
1114
  })
1028
1115
 
1029
- it('accepts string shorthand for legacy pages', async () => {
1030
- const wrapper = createWrapper({
1031
- pluginName: 'Analysis',
1032
- currentPageId: 'Overview',
1033
- pages: ['Overview', 'Settings'],
1034
- })
1035
-
1036
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
1037
- await wrapper.findAll('.mint-topbar-dropdown-item')[1].trigger('click')
1038
-
1039
- expect(wrapper.emitted('page-select')).toEqual([
1040
- [{ id: 'Settings', label: 'Settings' }],
1041
- ])
1042
- })
1043
-
1044
- it('accepts string shorthand for legacy tabs', async () => {
1045
- const wrapper = createWrapper({
1046
- currentTabId: 'Run',
1047
- tabs: ['Run', 'Results'],
1048
- })
1049
-
1050
- expect(wrapper.findAll('.mint-topbar-tab').map(tab => tab.text())).toEqual([
1051
- 'Run',
1052
- 'Results',
1053
- ])
1054
-
1055
- await wrapper.findAll('.mint-topbar-tab')[1].trigger('click')
1056
-
1057
- expect(wrapper.emitted('tab-select')).toEqual([
1058
- [{ id: 'Results', label: 'Results' }],
1059
- ])
1060
- })
1061
-
1062
- it('accepts string shorthand for legacy tab dropdown options', async () => {
1116
+ it('emits pill-option-select for pillNav dropdown children', async () => {
1063
1117
  const wrapper = createWrapper({
1064
- currentTabId: 'Summary',
1065
- tabs: [
1066
- { id: 'analysis', label: 'Analysis', children: ['Summary', 'Details'] },
1118
+ currentPillId: 'pca',
1119
+ pillNav: [
1120
+ { id: 'charts', label: 'Charts' },
1121
+ {
1122
+ id: 'visualize',
1123
+ label: 'Visualize',
1124
+ children: [
1125
+ { id: 'pca', label: 'PCA', description: 'Principal component analysis' },
1126
+ { id: 'heatmap', label: 'Heatmap', description: 'Metabolite heatmap' },
1127
+ ],
1128
+ },
1067
1129
  ],
1068
1130
  })
1069
1131
 
1070
- await wrapper.get('.mint-topbar-tab').trigger('click')
1071
- expect(wrapper.findAll('.mint-topbar-dropdown-item').map(item => item.text())).toEqual([
1072
- 'Summary',
1073
- 'Details',
1074
- ])
1075
-
1076
- await wrapper.findAll('.mint-topbar-dropdown-item')[1].trigger('click')
1132
+ await wrapper.findAll('.mint-pill-nav__item')[1].trigger('click')
1133
+ await wrapper.findAll('.mint-pill-nav__dropdown-item')[1].trigger('click')
1077
1134
 
1078
- expect(wrapper.emitted('tab-option-select')?.[0]?.[0]).toEqual({
1079
- id: 'Details',
1080
- label: 'Details',
1135
+ expect(wrapper.emitted('pill-option-select')?.[0]?.[0]).toEqual({
1136
+ id: 'heatmap',
1137
+ label: 'Heatmap',
1138
+ description: 'Metabolite heatmap',
1139
+ })
1140
+ expect(wrapper.emitted('pill-option-select')?.[0]?.[1]).toMatchObject({
1141
+ id: 'visualize',
1142
+ label: 'Visualize',
1081
1143
  })
1082
1144
  })
1145
+
1083
1146
  })
1084
1147
  })
@@ -118,12 +118,15 @@ describe('BioTemplateExperimentWorkspaceView', () => {
118
118
  kind: 'collection',
119
119
  },
120
120
  slots: {
121
- default: ({ bindings, componentPropsById, getComponentProps }) => h(
121
+ default: ({ bindings, componentBindingsById, componentPropsById, getComponentProps }) => h(
122
122
  'pre',
123
123
  { class: 'component-props' },
124
124
  JSON.stringify({
125
125
  doseMode: componentPropsById['dose-response:DoseCalculator'].mode,
126
+ bindingComponent: componentBindingsById['dose-response:DoseCalculator'].component,
127
+ bindingMode: componentBindingsById['dose-response:DoseCalculator'].props.mode,
126
128
  bindingDoseMode: bindings.componentPropsById['dose-response:DoseCalculator'].mode,
129
+ nestedBindingMode: bindings.componentBindingsById['dose-response:DoseCalculator'].props.mode,
127
130
  hasPlateWells: Boolean(componentPropsById['plate-map:WellPlate'].wells),
128
131
  doseModeFromGetter: getComponentProps('DoseCalculator')?.mode,
129
132
  bindingDoseModeFromGetter: bindings.getComponentProps('DoseCalculator')?.mode,
@@ -143,7 +146,10 @@ describe('BioTemplateExperimentWorkspaceView', () => {
143
146
 
144
147
  expect(JSON.parse(wrapper.find('.component-props').text())).toEqual({
145
148
  doseMode: 'serial',
149
+ bindingComponent: 'DoseCalculator',
150
+ bindingMode: 'serial',
146
151
  bindingDoseMode: 'serial',
152
+ nestedBindingMode: 'serial',
147
153
  hasPlateWells: true,
148
154
  doseModeFromGetter: 'serial',
149
155
  bindingDoseModeFromGetter: 'serial',
@@ -124,14 +124,26 @@ describe('BioTemplatePackWorkspaceView', () => {
124
124
  pack: 'cell-culture-screen',
125
125
  },
126
126
  slots: {
127
- default: ({ bindings, pack, componentPropsById, form, getComponentProps, pillNav, sidebar, topBarSettings }) => h(
127
+ default: ({
128
+ bindings,
129
+ pack,
130
+ componentBindingsById,
131
+ componentPropsById,
132
+ form,
133
+ getComponentProps,
134
+ pillNav,
135
+ sidebar,
136
+ topBarSettings,
137
+ }) => h(
128
138
  'pre',
129
139
  { class: 'component-props' },
130
140
  JSON.stringify({
131
141
  pack: pack.name,
132
142
  activeView: sidebar.activeView,
133
143
  doseMode: componentPropsById['dose-response:DoseCalculator'].mode,
144
+ bindingMode: componentBindingsById['dose-response:DoseCalculator'].props.mode,
134
145
  bindingDoseMode: bindings.componentPropsById['dose-response:DoseCalculator'].mode,
146
+ nestedBindingMode: bindings.componentBindingsById['dose-response:DoseCalculator'].props.mode,
135
147
  hasPlateWells: Boolean(componentPropsById['plate-map:WellPlate'].wells),
136
148
  doseModeFromGetter: getComponentProps('DoseCalculator')?.mode,
137
149
  bindingDoseModeFromGetter: bindings.getComponentProps('DoseCalculator')?.mode,
@@ -148,7 +160,9 @@ describe('BioTemplatePackWorkspaceView', () => {
148
160
  pack: 'cell-culture-screen',
149
161
  activeView: 'design',
150
162
  doseMode: 'serial',
163
+ bindingMode: 'serial',
151
164
  bindingDoseMode: 'serial',
165
+ nestedBindingMode: 'serial',
152
166
  hasPlateWells: true,
153
167
  doseModeFromGetter: 'serial',
154
168
  bindingDoseModeFromGetter: 'serial',
@@ -32,6 +32,7 @@ describe('BioTemplatePresetWorkspaceView', () => {
32
32
  expect(wrapper.find('.mint-bio-template-preset-workspace').exists()).toBe(true)
33
33
  expect(wrapper.text()).toContain('Well-plate screen preset saves')
34
34
  expect(wrapper.find('.mint-sidebar').exists()).toBe(true)
35
+ expect(wrapper.findComponent(AppSidebar).props('variant')).toBe('analysis')
35
36
  expect(wrapper.find('.mint-bio-template-renderer').exists()).toBe(true)
36
37
  expect(wrapper.text()).toContain('plate-map')
37
38
  expect(wrapper.text()).toContain('dose-response')
@@ -68,6 +69,26 @@ describe('BioTemplatePresetWorkspaceView', () => {
68
69
  expect(wrapper.text()).toContain('dose-response')
69
70
  })
70
71
 
72
+ it('passes through sidebar variant overrides', () => {
73
+ const wrapper = mount(BioTemplatePresetWorkspaceView, {
74
+ props: {
75
+ preset: 'wellplate-screen',
76
+ sidebarVariant: 'default',
77
+ },
78
+ global: {
79
+ stubs: {
80
+ DataFrame: true,
81
+ DoseCalculator: true,
82
+ PlateMapEditor: true,
83
+ SampleSelector: true,
84
+ WellPlate: true,
85
+ },
86
+ },
87
+ })
88
+
89
+ expect(wrapper.findComponent(AppSidebar).props('variant')).toBe('default')
90
+ })
91
+
71
92
  it('updates the internally generated workspace when the preset changes after mount', async () => {
72
93
  const wrapper = mount(BioTemplatePresetWorkspaceView, {
73
94
  props: {
@@ -194,13 +215,15 @@ describe('BioTemplatePresetWorkspaceView', () => {
194
215
  preset: 'wellplate-screen',
195
216
  },
196
217
  slots: {
197
- default: ({ bindings, collection, componentPropsById, getComponentProps }) => h(
218
+ default: ({ bindings, collection, componentBindingsById, componentPropsById, getComponentProps }) => h(
198
219
  'pre',
199
220
  { class: 'component-props' },
200
221
  JSON.stringify({
201
222
  preset: collection.metadata?.preset,
202
223
  doseMode: componentPropsById['dose-response:DoseCalculator'].mode,
224
+ bindingMode: componentBindingsById['dose-response:DoseCalculator'].props.mode,
203
225
  bindingDoseMode: bindings.componentPropsById.value['dose-response:DoseCalculator'].mode,
226
+ nestedBindingMode: bindings.componentBindingsById.value['dose-response:DoseCalculator'].props.mode,
204
227
  hasPlateWells: Boolean(componentPropsById['plate-map:WellPlate'].wells),
205
228
  doseModeFromGetter: getComponentProps('DoseCalculator')?.mode,
206
229
  bindingDoseModeFromGetter: bindings.getComponentProps('DoseCalculator')?.mode,
@@ -221,7 +244,9 @@ describe('BioTemplatePresetWorkspaceView', () => {
221
244
  expect(JSON.parse(wrapper.find('.component-props').text())).toEqual({
222
245
  preset: 'wellplate-screen',
223
246
  doseMode: 'serial',
247
+ bindingMode: 'serial',
224
248
  bindingDoseMode: 'serial',
249
+ nestedBindingMode: 'serial',
225
250
  hasPlateWells: true,
226
251
  doseModeFromGetter: 'serial',
227
252
  bindingDoseModeFromGetter: 'serial',
@@ -1,6 +1,6 @@
1
1
  import { mount } from '@vue/test-utils'
2
2
  import { describe, expect, it } from 'vitest'
3
- import CalendarGridPanel from '../../components/CalendarGridPanel.vue'
3
+ import CalendarGridPanelInternal from '../../components/internal/CalendarGridPanelInternal.vue'
4
4
  import type { CalendarGridDay } from '../../composables/useCalendarGrid'
5
5
 
6
6
  const days: CalendarGridDay[] = [
@@ -9,9 +9,9 @@ const days: CalendarGridDay[] = [
9
9
  { date: new Date(2026, 3, 22), isCurrentMonth: true, isDisabled: true },
10
10
  ]
11
11
 
12
- describe('CalendarGridPanel', () => {
12
+ describe('CalendarGridPanelInternal', () => {
13
13
  it('renders calendar state and emits navigation and selection events', async () => {
14
- const wrapper = mount(CalendarGridPanel, {
14
+ const wrapper = mount(CalendarGridPanelInternal, {
15
15
  props: {
16
16
  weekDays: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
17
17
  monthYear: 'April 2026',