@morscherlab/mint-sdk 1.4.0-beta.9 → 1.4.0

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 (198) hide show
  1. package/README.md +14 -3
  2. package/dist/{AlertBox-B2Bgr3ei.js → AlertBox-DwgNTEqf.js} +3 -3
  3. package/dist/{AlertBox-B2Bgr3ei.js.map → AlertBox-DwgNTEqf.js.map} +1 -1
  4. package/dist/{AppSidebar-Dq9W1jE2.js → AppSidebar-CZZaxni0.js} +1103 -144
  5. package/dist/AppSidebar-CZZaxni0.js.map +1 -0
  6. package/dist/{ConfirmDialog-nbJj51is.js → ConfirmDialog-CjWoxAD1.js} +2 -2
  7. package/dist/{ConfirmDialog-nbJj51is.js.map → ConfirmDialog-CjWoxAD1.js.map} +1 -1
  8. package/dist/{ExperimentPopover-5T1fAq2L.js → ExperimentPopover-DujiuQIO.js} +5 -5
  9. package/dist/{ExperimentPopover-5T1fAq2L.js.map → ExperimentPopover-DujiuQIO.js.map} +1 -1
  10. package/dist/{ExperimentSelectorModal-65peG4Up.js → ExperimentSelectorModal-B4j4eUOJ.js} +114 -98
  11. package/dist/ExperimentSelectorModal-B4j4eUOJ.js.map +1 -0
  12. package/dist/{SegmentedControl-BtnnjWzs.js → SegmentedControl-DyUPH3I5.js} +2 -2
  13. package/dist/{SegmentedControl-BtnnjWzs.js.map → SegmentedControl-DyUPH3I5.js.map} +1 -1
  14. package/dist/{SettingsModal-483nyI6b.js → SettingsModal-D2MOOtuO.js} +26 -25
  15. package/dist/SettingsModal-D2MOOtuO.js.map +1 -0
  16. package/dist/__stories__/experiment-helpers.d.ts +10 -0
  17. package/dist/__tests__/components/AppTopBarOnboarding.test.d.ts +1 -0
  18. package/dist/__tests__/components/FloatingPanelsInModal.test.d.ts +1 -0
  19. package/dist/__tests__/composables/useFloatingPanel.test.d.ts +1 -0
  20. package/dist/__tests__/composables/useOnboarding.test.d.ts +1 -0
  21. package/dist/__tests__/styles/paletteTokens.test.d.ts +1 -0
  22. package/dist/__tests__/styles/v2AuditRules.test.d.ts +1 -0
  23. package/dist/components/AppTopBar.vue.d.ts +20 -5
  24. package/dist/components/BaseButton.vue.d.ts +4 -2
  25. package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
  26. package/dist/components/JobsStatusTray.vue.d.ts +15 -4
  27. package/dist/components/PluginWorkspaceView.props.d.ts +3 -0
  28. package/dist/components/PluginWorkspaceView.vue.d.ts +3 -0
  29. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -3
  30. package/dist/components/internal/AppTopBarOnboardingInternal.vue.d.ts +7 -0
  31. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
  32. package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +2 -2
  33. package/dist/components/internal/OnboardingLayerInternal.vue.d.ts +7 -0
  34. package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +8 -0
  35. package/dist/composables/pluginWorkspaceModel.d.ts +2 -2
  36. package/dist/composables/useFileImport.d.ts +7 -0
  37. package/dist/composables/useFloatingPanel.d.ts +29 -0
  38. package/dist/composables/useJobCenter.d.ts +41 -3
  39. package/dist/composables/useOnboarding.d.ts +119 -0
  40. package/dist/{controlSchemaModel-B8CTXagP.js → controlSchemaModel-Bsg0t5Ab.js} +2 -2
  41. package/dist/{controlSchemaModel-B8CTXagP.js.map → controlSchemaModel-Bsg0t5Ab.js.map} +1 -1
  42. package/dist/{data-CgILxDLW.js → data-Boeh4uPI.js} +13 -12
  43. package/dist/{data-CgILxDLW.js.map → data-Boeh4uPI.js.map} +1 -1
  44. package/dist/data.d.ts +1 -1
  45. package/dist/data.js +9 -9
  46. package/dist/{experiment-43OJtH7R.js → experiment-DyO54FQB.js} +25 -61
  47. package/dist/experiment-DyO54FQB.js.map +1 -0
  48. package/dist/{experimentStatus-Cqp6qNrR.js → experimentStatus-DCn2QKG7.js} +2 -2
  49. package/dist/{experimentStatus-Cqp6qNrR.js.map → experimentStatus-DCn2QKG7.js.map} +1 -1
  50. package/dist/index.js +20 -20
  51. package/dist/{install-BtLWTk6H.js → install-BEHp7Mo-.js} +15 -15
  52. package/dist/{install-BtLWTk6H.js.map → install-BEHp7Mo-.js.map} +1 -1
  53. package/dist/install.js +1 -1
  54. package/dist/{lab-MHzp-_J8.js → lab-D61eaX6f.js} +68 -61
  55. package/dist/{lab-MHzp-_J8.js.map → lab-D61eaX6f.js.map} +1 -1
  56. package/dist/lab.js +4 -4
  57. package/dist/{records-B6gpM_3e.js → records-D4aQXyZu.js} +193 -133
  58. package/dist/records-D4aQXyZu.js.map +1 -0
  59. package/dist/{runtime-7vfl_bI3.js → runtime-q-R0CNLh.js} +400 -33
  60. package/dist/runtime-q-R0CNLh.js.map +1 -0
  61. package/dist/runtime.d.ts +1 -1
  62. package/dist/runtime.js +7 -7
  63. package/dist/{shell-Cs9OJMnm.js → shell-6CGFvUUw.js} +10 -5
  64. package/dist/{shell-Cs9OJMnm.js.map → shell-6CGFvUUw.js.map} +1 -1
  65. package/dist/shell.d.ts +1 -0
  66. package/dist/shell.js +6 -6
  67. package/dist/stores/index.d.ts +1 -1
  68. package/dist/stores/index.js +2 -2
  69. package/dist/stores/settings.d.ts +4 -12
  70. package/dist/styles.css +1191 -479
  71. package/dist/types/components.d.ts +2 -0
  72. package/dist/types/index.d.ts +1 -1
  73. package/dist/types/jobs.d.ts +9 -0
  74. package/dist/types/platform.d.ts +1 -1
  75. package/dist/{useDropdownState-8yWMu-7_.js → useDropdownState-uFIz6Yv8.js} +16 -4
  76. package/dist/useDropdownState-uFIz6Yv8.js.map +1 -0
  77. package/dist/{useExperimentSamples-DHnxKFAF.js → useExperimentSamples-QEFoq4tf.js} +48 -6
  78. package/dist/useExperimentSamples-QEFoq4tf.js.map +1 -0
  79. package/dist/{useListReorder-BitdUhxn.js → useListReorder-DsuzwWGS.js} +122 -172
  80. package/dist/useListReorder-DsuzwWGS.js.map +1 -0
  81. package/dist/{useRackEditor-DiwCt77M.js → useRackEditor-KpZ4iJ6o.js} +22 -32
  82. package/dist/useRackEditor-KpZ4iJ6o.js.map +1 -0
  83. package/dist/useRealtime-B58Lo-Tm.js +508 -0
  84. package/dist/useRealtime-B58Lo-Tm.js.map +1 -0
  85. package/dist/{useRequest-D506eHi8.js → useRequest-BuDmW8Jc.js} +8 -37
  86. package/dist/useRequest-BuDmW8Jc.js.map +1 -0
  87. package/package.json +3 -2
  88. package/src/__stories__/experiment-helpers.ts +3 -3
  89. package/src/__tests__/components/ActionMenu.test.ts +2 -2
  90. package/src/__tests__/components/AppTopBar.test.ts +206 -1
  91. package/src/__tests__/components/AppTopBarOnboarding.test.ts +160 -0
  92. package/src/__tests__/components/AppTopBarPageSelector.test.ts +125 -9
  93. package/src/__tests__/components/BaseButton.test.ts +19 -0
  94. package/src/__tests__/components/BaseModal.test.ts +13 -0
  95. package/src/__tests__/components/ExperimentSelectorModal.test.ts +34 -1
  96. package/src/__tests__/components/FloatingPanelsInModal.test.ts +94 -0
  97. package/src/__tests__/components/GeneratedPluginView.test.ts +1 -1
  98. package/src/__tests__/components/JobsStatusTray.test.ts +373 -71
  99. package/src/__tests__/components/PluginWorkspaceView.test.ts +63 -1
  100. package/src/__tests__/components/SampleSelector.test.ts +20 -0
  101. package/src/__tests__/components/SettingsModal.test.ts +9 -2
  102. package/src/__tests__/composables/useFileImport.test.ts +36 -0
  103. package/src/__tests__/composables/useFloatingPanel.test.ts +68 -0
  104. package/src/__tests__/composables/useJobCenter.test.ts +262 -3
  105. package/src/__tests__/composables/useOnboarding.test.ts +317 -0
  106. package/src/__tests__/docs/frontendDocsCatalog.test.ts +4 -3
  107. package/src/__tests__/fixtures/table-import/cells.xlsx +0 -0
  108. package/src/__tests__/fixtures/table-import/samples.xlsx +0 -0
  109. package/src/__tests__/stores/settings.test.ts +21 -21
  110. package/src/__tests__/styles/cssTokens.test.ts +4 -1
  111. package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
  112. package/src/__tests__/styles/paletteTokens.test.ts +44 -0
  113. package/src/__tests__/styles/v2AuditRules.test.ts +104 -0
  114. package/src/components/ActionMenu.vue +6 -37
  115. package/src/components/AppTopBar.onboarding.story.vue +69 -0
  116. package/src/components/AppTopBar.story.vue +59 -4
  117. package/src/components/AppTopBar.vue +132 -10
  118. package/src/components/BaseButton.vue +16 -2
  119. package/src/components/BaseModal.vue +2 -1
  120. package/src/components/ExperimentSelectorModal.story.vue +38 -0
  121. package/src/components/ExperimentSelectorModal.vue +20 -1
  122. package/src/components/GeneratedPluginView.vue +4 -9
  123. package/src/components/GeneratedResultRenderer.vue +3 -4
  124. package/src/components/JobsStatusTray.story.vue +97 -43
  125. package/src/components/JobsStatusTray.vue +287 -290
  126. package/src/components/LayoutResizeHandle.story.vue +65 -4
  127. package/src/components/PluginWorkspaceView.props.ts +3 -0
  128. package/src/components/PluginWorkspaceView.vue +17 -0
  129. package/src/components/SampleSelector.story.vue +21 -6
  130. package/src/components/SampleSelector.vue +10 -3
  131. package/src/components/SectionCard.story.vue +33 -0
  132. package/src/components/SettingsModal.vue +7 -12
  133. package/src/components/Tooltip.vue +9 -32
  134. package/src/components/internal/AppTopBarOnboardingInternal.vue +172 -0
  135. package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
  136. package/src/components/internal/DatePickerDateInternal.vue +7 -45
  137. package/src/components/internal/DatePickerDateTimeInternal.vue +7 -1
  138. package/src/components/internal/DatePickerTimeInternal.vue +8 -1
  139. package/src/components/internal/NumberUnitInputInternal.vue +4 -18
  140. package/src/components/internal/OnboardingLayerInternal.vue +173 -0
  141. package/src/components/internal/SelectMultipleInternal.vue +6 -1
  142. package/src/components/internal/SelectSearchableInternal.vue +6 -1
  143. package/src/components/internal/WellEditPopupInternal.vue +11 -22
  144. package/src/components/internal/WorkspaceShellInternal.vue +12 -0
  145. package/src/composables/pluginWorkspaceModel.ts +2 -2
  146. package/src/composables/useFileImport.ts +51 -1
  147. package/src/composables/useFloatingPanel.ts +107 -0
  148. package/src/composables/useJobCenter.ts +188 -6
  149. package/src/composables/useOnboarding.ts +465 -0
  150. package/src/composables/usePluginWorkspace.ts +1 -1
  151. package/src/data.ts +1 -0
  152. package/src/runtime.ts +2 -1
  153. package/src/shell.ts +17 -0
  154. package/src/stores/index.ts +0 -2
  155. package/src/stores/settings.ts +20 -50
  156. package/src/styles/components/action-menu.css +1 -1
  157. package/src/styles/components/app-layout.css +6 -3
  158. package/src/styles/components/app-page-selector.css +120 -45
  159. package/src/styles/components/app-top-bar.css +165 -3
  160. package/src/styles/components/button.css +8 -0
  161. package/src/styles/components/dataframe.css +1 -1
  162. package/src/styles/components/date-picker.css +4 -2
  163. package/src/styles/components/datetime-picker.css +4 -4
  164. package/src/styles/components/experiment-selector-modal.css +13 -0
  165. package/src/styles/components/jobs-status-tray.css +134 -296
  166. package/src/styles/components/layout-resize-handle.css +59 -16
  167. package/src/styles/components/modal.css +3 -3
  168. package/src/styles/components/number-input.css +1 -1
  169. package/src/styles/components/onboarding.css +368 -0
  170. package/src/styles/components/progress-bar.css +3 -3
  171. package/src/styles/components/sample-selector.css +43 -0
  172. package/src/styles/components/searchable-select.css +3 -7
  173. package/src/styles/components/section-card.css +30 -6
  174. package/src/styles/components/settings-modal.css +7 -2
  175. package/src/styles/components/time-picker.css +3 -6
  176. package/src/styles/components/toast.css +1 -1
  177. package/src/styles/components/tooltip.css +2 -2
  178. package/src/styles/components/unit-input.css +1 -1
  179. package/src/styles/components/well-edit-popup.css +2 -2
  180. package/src/styles/index.css +1 -0
  181. package/src/styles/variables.css +153 -15
  182. package/src/types/components.ts +2 -0
  183. package/src/types/index.ts +1 -0
  184. package/src/types/jobs.ts +10 -0
  185. package/src/types/platform.ts +1 -1
  186. package/dist/AppSidebar-Dq9W1jE2.js.map +0 -1
  187. package/dist/ExperimentSelectorModal-65peG4Up.js.map +0 -1
  188. package/dist/SettingsModal-483nyI6b.js.map +0 -1
  189. package/dist/experiment-43OJtH7R.js.map +0 -1
  190. package/dist/records-B6gpM_3e.js.map +0 -1
  191. package/dist/runtime-7vfl_bI3.js.map +0 -1
  192. package/dist/useDropdownState-8yWMu-7_.js.map +0 -1
  193. package/dist/useExperimentSamples-DHnxKFAF.js.map +0 -1
  194. package/dist/useListReorder-BitdUhxn.js.map +0 -1
  195. package/dist/useRackEditor-DiwCt77M.js.map +0 -1
  196. package/dist/useRealtime-C3iy9QkU.js +0 -699
  197. package/dist/useRealtime-C3iy9QkU.js.map +0 -1
  198. package/dist/useRequest-D506eHi8.js.map +0 -1
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  /** Compact overflow actions with an icon-only BaseButton trigger and a viewport-safe teleported menu. */
3
- import { computePosition, flip, offset, shift } from '@floating-ui/dom'
4
3
  import { Check, Ellipsis } from 'lucide-vue-next'
5
- import { nextTick, onMounted, ref, watch } from 'vue'
4
+ import { nextTick, onMounted, ref } from 'vue'
6
5
  import { useDropdownState } from '../composables/useDropdownState'
6
+ import { useFloatingPanel } from '../composables/useFloatingPanel'
7
7
  import { useMenuKeyboard } from '../composables/useMenuKeyboard'
8
8
  import type { ActionMenuItem } from '../types/components'
9
9
  import BaseButton from './BaseButton.vue'
@@ -30,19 +30,15 @@ defineSlots<{
30
30
  }>()
31
31
 
32
32
  const panelRef = ref<HTMLElement | null>(null)
33
- const panelStyle = ref<Record<string, string>>({ visibility: 'hidden' })
34
33
 
35
- // Disclosure state (outside-click, Escape, scroll/resize closing, focus return)
36
- // comes from the shared composable; only floating-ui placement stays local.
34
+ // Disclosure state (outside-click, Escape, focus return) and placement come from
35
+ // the shared composables; the open menu follows its trigger on scroll.
37
36
  const { isOpen, rootRef, triggerRef, open, close } = useDropdownState({
38
- closeOnScroll: true,
39
37
  insideRefs: [panelRef],
40
- onOpen: () => {
41
- panelStyle.value = { visibility: 'hidden' }
42
- emit('open')
43
- },
38
+ onOpen: () => emit('open'),
44
39
  onClose: () => emit('close'),
45
40
  })
41
+ const { style: panelStyle } = useFloatingPanel(triggerRef, panelRef, isOpen, { placement: 'bottom-end' })
46
42
 
47
43
  // BaseButton owns the actual <button>, so the trigger element is resolved from
48
44
  // the wrapper once mounted rather than bound with a template ref.
@@ -57,27 +53,6 @@ function menuItems(): HTMLElement[] {
57
53
  )
58
54
  }
59
55
 
60
- async function positionPanel() {
61
- const trigger = triggerRef.value
62
- const panel = panelRef.value
63
- if (!trigger || !panel) return
64
-
65
- const position = await computePosition(trigger, panel, {
66
- placement: 'bottom-end',
67
- strategy: 'fixed',
68
- middleware: [
69
- offset(4),
70
- flip(),
71
- shift({ padding: 8 }),
72
- ],
73
- })
74
- panelStyle.value = {
75
- left: `${position.x}px`,
76
- top: `${position.y}px`,
77
- visibility: 'visible',
78
- }
79
- }
80
-
81
56
  /** `focusFirst` is false when the keyboard composable owns the initial focus. */
82
57
  function openMenu(focusFirst = true) {
83
58
  if (props.disabled || isOpen.value) return
@@ -105,12 +80,6 @@ function handleSelect(item: ActionMenuItem) {
105
80
  emit('select', item)
106
81
  close()
107
82
  }
108
-
109
- watch(isOpen, async (opened) => {
110
- if (!opened) return
111
- await nextTick()
112
- await positionPanel()
113
- })
114
83
  </script>
115
84
 
116
85
  <template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { defineComponent, h, ref } from 'vue'
3
+ import AppTopBar from './AppTopBar.vue'
4
+ import BaseButton from './BaseButton.vue'
5
+ import { defineOnboarding, provideOnboarding, useOnboarding } from '../composables/useOnboarding'
6
+ import type { PillNavItem } from '../types'
7
+
8
+ const pills: PillNavItem[] = [
9
+ { id: 'home', label: 'Home' },
10
+ { id: 'experiments', label: 'Experiments' },
11
+ { id: 'instruments', label: 'Instruments' },
12
+ ]
13
+
14
+ const onboarding = defineOnboarding({
15
+ tours: [
16
+ {
17
+ id: 'intro',
18
+ title: 'The top bar',
19
+ steps: [
20
+ { id: 'pages', anchor: 'topbar-pills', title: 'Move between pages', body: 'Home, Experiments and Instruments are one click away on every page.' },
21
+ { id: 'new', anchor: 'story-new', title: 'Create an experiment', body: 'Click New experiment to go on.', placement: 'bottom-start', advanceOn: 'click' },
22
+ { id: 'help', anchor: 'topbar-help', title: 'Help', body: 'Open the checklist, play a tour again, or see what is new.', placement: 'bottom-end' },
23
+ ],
24
+ },
25
+ ],
26
+ checklist: [
27
+ { id: 'create', title: 'Create an experiment', action: { label: 'Open', run: () => {} } },
28
+ { id: 'project', title: 'Create a project', action: { label: 'Open', run: () => {} } },
29
+ { id: 'theme', title: 'Pick a theme' },
30
+ ],
31
+ whatsNew: [
32
+ { id: 'settings', anchor: 'topbar-settings', title: 'Palettes in Settings', body: 'Pick an accent color in Settings → Appearance.', placement: 'bottom-end' },
33
+ ],
34
+ })
35
+
36
+ // The stories are returning users with no state yet, so the news shows too.
37
+ const OnboardingProvider = defineComponent({
38
+ props: { appId: { type: String, required: true }, news: Boolean },
39
+ setup(props, { slots }) {
40
+ const key = `mint.onboarding.${props.appId}.local`
41
+ if (props.news && localStorage.getItem(key) === null) localStorage.setItem(key, '["task:theme"]')
42
+ provideOnboarding(onboarding, { appId: props.appId, userId: 'local' })
43
+ return () => slots.default?.()
44
+ },
45
+ })
46
+
47
+ const PageBody = defineComponent({
48
+ setup() {
49
+ const state = useOnboarding()
50
+ return () => h('div', { style: 'padding: 1.5rem; display: flex; gap: 0.5rem' }, [
51
+ h(BaseButton, { 'data-onboarding': 'story-new', onClick: () => state?.complete('create') }, () => 'New experiment'),
52
+ h(BaseButton, { variant: 'secondary', onClick: () => { localStorage.clear(); location.reload() } }, () => 'Reset onboarding'),
53
+ ])
54
+ },
55
+ })
56
+
57
+ const pill = ref('home')
58
+ </script>
59
+
60
+ <template>
61
+ <Story title="Layout/AppTopBar/Onboarding" :layout="{ type: 'single', iframe: true }">
62
+ <Variant title="Tour, checklist and news">
63
+ <OnboardingProvider app-id="story-onboarding" news>
64
+ <AppTopBar v-model:pill="pill" :spec="{ pills, settings: { tabs: [] }, standaloneLabel: false }" title="MINT" />
65
+ <PageBody />
66
+ </OnboardingProvider>
67
+ </Variant>
68
+ </Story>
69
+ </template>
@@ -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
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * The `#icon` slot applies in standalone mode only; integrated branding comes from `Metadata.icon`.
6
6
  */
7
- import { ref, computed, inject, defineAsyncComponent, onMounted, onBeforeUnmount, toValue, useSlots, watch } from 'vue'
7
+ import { ref, computed, inject, defineAsyncComponent, onMounted, onBeforeUnmount, toValue, useSlots, watch, type Component } from 'vue'
8
8
  import type {
9
9
  PillNavOption,
10
10
  PillNavItem,
@@ -19,12 +19,17 @@ import { applySpecDefaults } from '../composables/componentSpecs'
19
19
  import ThemeToggle from './ThemeToggle.vue'
20
20
  import AppTopBarPageSelectorInternal from './internal/AppTopBarPageSelectorInternal.vue'
21
21
  import AppTopBarPillNavInternal from './internal/AppTopBarPillNavInternal.vue'
22
+ import AppTopBarOnboardingInternal from './internal/AppTopBarOnboardingInternal.vue'
22
23
  import PluginIcon from './PluginIcon.vue'
23
24
  import { usePlatform } from '../composables/usePlatform'
24
25
  import { APP_EXPERIMENT_KEY } from '../composables/useAppExperiment'
26
+ import { useOnboarding } from '../composables/useOnboarding'
25
27
  import { pluginIdentity } from '../composables/pluginIdentity'
26
28
  import { integrationHost } from '../utils/integrationHost'
27
29
  import { trackTopBarForToasts } from './AppTopBar.toastOffset'
30
+ import { CircleAlert, CircleCheck, CircleX, ListChecks, LoaderCircle } from 'lucide-vue-next'
31
+ import type { JobStatus, TopBarJob } from '../types/jobs'
32
+ import type { Tone } from '../types/components'
28
33
 
29
34
  interface Props {
30
35
  /** App or plugin title shown in the left title group when no page selector is present. */
@@ -39,10 +44,23 @@ interface Props {
39
44
  pageSelector?: PageSelectorItemInput[]
40
45
  /** Active id of the platform page selector. */
41
46
  currentPageSelectorId?: string
47
+ /**
48
+ * The viewer's own job, joined to the experiment chip. Omit it for no jobs control;
49
+ * `null` shows the idle Jobs button. Both emit `job-click`.
50
+ */
51
+ job?: TopBarJob | null
52
+ /** Completed jobs, shown as a small count on the idle Jobs button. Hidden when 0. */
53
+ jobsDone?: number
54
+ /** Whether the jobs panel is open; `aria-expanded` of the job control. */
55
+ jobsOpen?: boolean
56
+ /** DOM id of the jobs panel; `aria-controls` of the job control. */
57
+ jobsPanelId?: string
42
58
  }
43
59
 
44
60
  const props = withDefaults(defineProps<Props>(), {
45
61
  spec: () => ({}),
62
+ // No boolean cast: without a jobs panel the control has no aria-expanded.
63
+ jobsOpen: undefined,
46
64
  })
47
65
 
48
66
  const config = computed(() => applySpecDefaults({
@@ -66,6 +84,7 @@ const emit = defineEmits<{
66
84
  'pill-select': [item: PillNavItem]
67
85
  'pill-option-select': [option: PillNavOption, item: PillNavItem]
68
86
  'settings-values-change': [data: Record<string, unknown>]
87
+ 'job-click': []
69
88
  }>()
70
89
 
71
90
  const settingsOpen = ref(false)
@@ -78,7 +97,46 @@ onBeforeUnmount(() => untrackToastOffset?.())
78
97
  const { integrated: isIntegrated, plugin } = usePlatform()
79
98
  const isStandalone = computed(() => !isIntegrated.value)
80
99
  const appExperiment = inject(APP_EXPERIMENT_KEY, null)
100
+ const onboarding = useOnboarding()
81
101
  const canBindExperiment = computed(() => plugin.value?.plugin_type !== 'static')
102
+ const showExperiment = computed(() => !!appExperiment && !isStandalone.value && canBindExperiment.value)
103
+ // The border joins only once the job is on screen and stays until it has left,
104
+ // so the name and the job never show at the same time.
105
+ const runSlotJoined = ref(!!props.job)
106
+ const JOB_STATUS_TEXT: Record<JobStatus, string> = {
107
+ queued: 'Job queued',
108
+ running: 'Job running',
109
+ awaiting_input: 'Job needs input',
110
+ completed: 'Job completed',
111
+ failed: 'Job failed',
112
+ cancelled: 'Job cancelled',
113
+ }
114
+ const jobPercent = computed(() =>
115
+ props.job?.percent == null ? null : Math.min(100, Math.max(0, Math.round(props.job.percent))),
116
+ )
117
+ const jobName = computed(() => props.job
118
+ ? (jobPercent.value != null ? `${props.job.label} ${jobPercent.value}%` : props.job.label)
119
+ : undefined)
120
+ function syncRunSlotJoined() {
121
+ runSlotJoined.value = !!props.job
122
+ }
123
+
124
+ const JOB_TONES: Record<JobStatus, Tone> = {
125
+ queued: 'primary',
126
+ running: 'primary',
127
+ awaiting_input: 'warning',
128
+ completed: 'success',
129
+ failed: 'error',
130
+ cancelled: 'neutral',
131
+ }
132
+ const JOB_ICONS: Record<JobStatus, Component> = {
133
+ queued: LoaderCircle,
134
+ running: LoaderCircle,
135
+ awaiting_input: CircleAlert,
136
+ completed: CircleCheck,
137
+ failed: CircleX,
138
+ cancelled: CircleX,
139
+ }
82
140
 
83
141
  const slots = useSlots()
84
142
  // Standalone: the seeded identity (providePluginIdentity) stands in for a missing icon and title.
@@ -103,6 +161,10 @@ watch(platformPageSelectorEl, (element, _previous, onCleanup) => {
103
161
  const identityName = computed(() =>
104
162
  isIntegrated.value && !hasPageSelector.value ? plugin.value?.display_name || plugin.value?.name : undefined,
105
163
  )
164
+ /** The page selector crumb reads "<plugin> / <page>". */
165
+ const pageSelectorPluginName = computed(() =>
166
+ isIntegrated.value ? plugin.value?.display_name || plugin.value?.name : props.title || pluginIdentity.name,
167
+ )
106
168
  // Platforms before 1.4 send no subtitle; the description fills line 2.
107
169
  const identitySubtitle = computed(() => plugin.value?.subtitle || plugin.value?.description)
108
170
  const hasPillNav = computed(() => !!config.value.pills?.length)
@@ -244,11 +306,13 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
244
306
  </span>
245
307
 
246
308
  <!-- Left: page selector (the platform integration script renders the nav_items one) -->
247
- <span v-if="hasPlatformPageSelector" ref="platformPageSelectorEl" class="mint-topbar__platform-page-selector" />
309
+ <span v-if="hasPlatformPageSelector" ref="platformPageSelectorEl" class="mint-topbar__platform-page-selector" data-onboarding="topbar-page-selector" />
248
310
  <AppTopBarPageSelectorInternal
249
311
  v-else-if="hasPropPageSelector"
312
+ data-onboarding="topbar-page-selector"
250
313
  :pages="normalizedPageSelector"
251
314
  :current-page-id="currentPageSelectorId"
315
+ :plugin-name="pageSelectorPluginName"
252
316
  @select="emit('page-selector-select', $event)"
253
317
  >
254
318
  <template v-if="$slots['page-selector-icon']" #icon="slotProps">
@@ -272,7 +336,7 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
272
336
  </div>
273
337
 
274
338
  <!-- Center: pill nav -->
275
- <div v-if="hasPillNav || $slots.center" class="mint-topbar__center">
339
+ <div v-if="hasPillNav || $slots.center" class="mint-topbar__center" data-onboarding="topbar-pills">
276
340
  <slot name="center">
277
341
  <AppTopBarPillNavInternal
278
342
  v-if="hasPillNav"
@@ -290,16 +354,73 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
290
354
  {{ config.standaloneLabel }}
291
355
  </span>
292
356
 
293
- <ExperimentPopover
294
- v-if="appExperiment && !isStandalone && canBindExperiment"
295
- v-bind="appExperiment.popover.value"
296
- @select="appExperiment.openModal()"
297
- @save="appExperiment.handleSave()"
298
- @detach="appExperiment.handleDetach()"
299
- />
357
+ <!-- Run slot: while a job shows, its progress takes the place of the experiment name and Save:
358
+ [ EXP-0142 ▾ | job ]. The idle Jobs button stands apart. -->
359
+ <div
360
+ v-if="showExperiment || job !== undefined || runSlotJoined"
361
+ :class="['mint-topbar__run-slot', { 'mint-topbar__run-slot--joined': runSlotJoined }]"
362
+ data-onboarding="topbar-run"
363
+ >
364
+ <ExperimentPopover
365
+ v-if="showExperiment && appExperiment"
366
+ v-bind="job ? { ...appExperiment.popover.value, showSave: false } : appExperiment.popover.value"
367
+ @select="appExperiment.openModal()"
368
+ @save="appExperiment.handleSave()"
369
+ @detach="appExperiment.handleDetach()"
370
+ />
371
+ <!-- Status words only, so a moving percent does not repeat. -->
372
+ <span class="mint-topbar__job-status" aria-live="polite">{{ job ? JOB_STATUS_TEXT[job.status] : '' }}</span>
373
+ <!-- appear: the slot can mount together with its first job; before-enter then joins it. -->
374
+ <Transition
375
+ name="mint-topbar-job"
376
+ mode="out-in"
377
+ appear
378
+ @before-enter="syncRunSlotJoined"
379
+ @after-leave="syncRunSlotJoined"
380
+ >
381
+ <button
382
+ v-if="job"
383
+ key="run"
384
+ type="button"
385
+ :class="['mint-topbar__job', `mint-topbar__job--${JOB_TONES[job.status]}`]"
386
+ :title="jobName"
387
+ :aria-label="jobName"
388
+ :aria-expanded="jobsOpen"
389
+ :aria-controls="jobsPanelId"
390
+ @click="emit('job-click')"
391
+ >
392
+ <component
393
+ :is="JOB_ICONS[job.status]"
394
+ :class="['mint-topbar__job-icon', { 'mint-topbar__job-icon--spin': job.status === 'running' || job.status === 'queued' }]"
395
+ :size="14"
396
+ aria-hidden="true"
397
+ />
398
+ <span v-if="jobPercent != null" class="mint-topbar__job-bar" aria-hidden="true">
399
+ <span :style="{ width: `${jobPercent}%` }" />
400
+ </span>
401
+ </button>
402
+ <button
403
+ v-else-if="job === null"
404
+ key="idle"
405
+ type="button"
406
+ class="mint-topbar__job mint-topbar__job--idle"
407
+ :aria-label="jobsDone ? `Jobs, ${jobsDone} completed` : undefined"
408
+ :aria-expanded="jobsOpen"
409
+ :aria-controls="jobsPanelId"
410
+ @click="emit('job-click')"
411
+ >
412
+ <ListChecks class="mint-topbar__job-icon" :size="14" aria-hidden="true" />
413
+ <span class="mint-topbar__job-label">Jobs</span>
414
+ <span v-if="jobsDone" class="mint-topbar__job-count" aria-hidden="true">{{ jobsDone }}</span>
415
+ </button>
416
+ </Transition>
417
+ </div>
300
418
 
301
419
  <slot name="actions" />
302
420
 
421
+ <!-- Onboarding: checklist chip, Help button and tour layer, when the app provides onboarding -->
422
+ <AppTopBarOnboardingInternal v-if="onboarding?.enabled.value" :state="onboarding" />
423
+
303
424
  <!-- Theme toggle -->
304
425
  <ThemeToggle v-if="config.themeToggle" size="sm" />
305
426
 
@@ -309,6 +430,7 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
309
430
  type="button"
310
431
  class="mint-topbar__settings-btn"
311
432
  aria-label="Open settings"
433
+ data-onboarding="topbar-settings"
312
434
  @click="settingsOpen = true"
313
435
  >
314
436
  <svg class="mint-topbar__settings-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -10,7 +10,7 @@
10
10
  * <BaseButton variant="ghost" :icon="Trash2" label="Delete" />
11
11
  * ```
12
12
  */
13
- import { computed, useSlots, type Component } from 'vue'
13
+ import { Comment, Fragment, Text, computed, useSlots, type Component, type VNode } from 'vue'
14
14
  import type { ButtonVariant, ButtonSize } from '../types'
15
15
 
16
16
  interface Props {
@@ -45,6 +45,19 @@ const slots = useSlots()
45
45
  const iconOnly = computed(() => Boolean(props.icon) && !slots.default)
46
46
  const block = computed(() => (iconOnly.value ? 'mint-icon-button' : 'mint-button'))
47
47
 
48
+ // A text-only label gets its own span so the stylesheet can centre the cap height.
49
+ // Element children are left as flex items, so their gap and alignment do not change.
50
+ function isTextNode(node: VNode): boolean {
51
+ if (node.type === Text || node.type === Comment) return true
52
+ return node.type === Fragment && Array.isArray(node.children)
53
+ && (node.children as VNode[]).every(isTextNode)
54
+ }
55
+
56
+ function hasTextLabel(): boolean {
57
+ const nodes = slots.default?.()
58
+ return !!nodes && nodes.length > 0 && nodes.every(isTextNode)
59
+ }
60
+
48
61
  function handleClick(event: MouseEvent) {
49
62
  if (!props.disabled && !props.loading) {
50
63
  emit('click', event)
@@ -93,7 +106,8 @@ function handleClick(event: MouseEvent) {
93
106
  <template v-if="!iconOnly">
94
107
  <component :is="icon" v-if="icon" class="mint-button__icon" aria-hidden="true" />
95
108
  <slot name="icon-left" />
96
- <slot />
109
+ <span v-if="hasTextLabel()" class="mint-button__label"><slot /></span>
110
+ <slot v-else />
97
111
  <slot name="icon-right" />
98
112
  </template>
99
113
  </button>
@@ -107,8 +107,9 @@ watch(() => props.open, (isOpen) => {
107
107
  if (!isOpen && index !== -1) openModals.splice(index, 1)
108
108
  }, { immediate: true })
109
109
 
110
+ // A control inside the modal that handles Escape (an open select) prevents the default first.
110
111
  function handleEscapeKeydown(event: KeyboardEvent) {
111
- if (event.key === 'Escape' && props.closeOnEscape && props.open && openModals[openModals.length - 1] === self) {
112
+ if (event.key === 'Escape' && !event.defaultPrevented && props.closeOnEscape && props.open && openModals[openModals.length - 1] === self) {
112
113
  close()
113
114
  }
114
115
  }
@@ -268,6 +268,7 @@ onMounted(async () => {
268
268
 
269
269
  // -- Story state --
270
270
  const isOpen = ref(false)
271
+ const isOpenRun = ref(false)
271
272
  const isOpenEmpty = ref(false)
272
273
  const isOpenWithSelection = ref(false)
273
274
  const isOpenFilters = ref(false)
@@ -363,6 +364,43 @@ const selectedExperiment = computed(() => experimentStore.current)
363
364
  </div>
364
365
  </Variant>
365
366
 
367
+ <Variant title="Run Destination (slots)">
368
+ <div style="padding: 2rem;">
369
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
370
+ A plugin asks where to save a run: the description slot explains why, and the footer slot adds Skip with close().
371
+ </p>
372
+ <button
373
+ style="padding: 0.5rem 1rem; background: var(--color-primary); color: var(--text-on-accent); border: none; border-radius: var(--radius-md); cursor: pointer; font-size: 0.875rem;"
374
+ @click="isOpenRun = true"
375
+ >
376
+ Run extraction
377
+ </button>
378
+
379
+ <ExperimentSelectorModal
380
+ v-model:open="isOpenRun"
381
+ title="Save the result to an experiment?"
382
+ >
383
+ <template #description>
384
+ Pick an experiment to save this run as a new artifact in it, next to the samples it belongs to.
385
+ </template>
386
+ <template #footer="{ close }">
387
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
388
+ <span style="flex: 1; font-size: 0.75rem; color: var(--text-secondary);">
389
+ Skip: the result stays in the plugin. You can save it to an experiment later.
390
+ </span>
391
+ <button
392
+ type="button"
393
+ style="height: 2rem; padding: 0 0.75rem; border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-card); color: var(--text-primary); cursor: pointer;"
394
+ @click="close"
395
+ >
396
+ Skip
397
+ </button>
398
+ </div>
399
+ </template>
400
+ </ExperimentSelectorModal>
401
+ </div>
402
+ </Variant>
403
+
366
404
  <Variant title="Empty State">
367
405
  <div style="padding: 2rem;">
368
406
  <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
@@ -40,6 +40,17 @@ const emit = defineEmits<{
40
40
  'update:open': [value: boolean]
41
41
  }>()
42
42
 
43
+ defineSlots<{
44
+ /** Text above the experiment list: why the caller asks for an experiment. */
45
+ description?: () => unknown
46
+ /** Extra footer content, before Clear selection. `close()` closes the modal without a selection. */
47
+ footer?: (props: { close: () => void }) => unknown
48
+ }>()
49
+
50
+ function close() {
51
+ emit('update:open', false)
52
+ }
53
+
43
54
  const experimentStore = useExperimentStore()
44
55
 
45
56
  const {
@@ -188,6 +199,10 @@ watch(
188
199
  :size="size"
189
200
  @update:open="emit('update:open', $event)"
190
201
  >
202
+ <div v-if="$slots.description" class="mint-experiment-selector__description">
203
+ <slot name="description" />
204
+ </div>
205
+
191
206
  <ResourcePickerListInternal
192
207
  :key="openCount"
193
208
  :search="filters.search ?? ''"
@@ -257,8 +272,12 @@ watch(
257
272
  </template>
258
273
  </ResourcePickerListInternal>
259
274
 
260
- <div v-if="currentExperimentId != null" class="mint-experiment-selector__footer">
275
+ <div v-if="$slots.footer || currentExperimentId != null" class="mint-experiment-selector__footer">
276
+ <div v-if="$slots.footer" class="mint-experiment-selector__footer-slot">
277
+ <slot name="footer" :close="close" />
278
+ </div>
261
279
  <button
280
+ v-if="currentExperimentId != null"
262
281
  type="button"
263
282
  class="mint-experiment-selector__clear-btn"
264
283
  @click="handleDeselect"
@@ -12,11 +12,11 @@ import { computed, onMounted, provide, reactive, ref, watch } from 'vue'
12
12
  import { generatedJobDefaults, generatedJobFormSchema, normalizeGeneratedJobInput } from '../generated/schema'
13
13
  import type { FormSchema } from '../types/form-builder'
14
14
  import type {
15
- GeneratedAnalysisJob,
16
15
  GeneratedAnalysisResult,
17
16
  GeneratedJobDefinition,
18
17
  } from '../types/generated-ui'
19
18
  import type { PillNavItem, SidebarToolSection } from '../types'
19
+ import type { JobState } from '../types/jobs'
20
20
  import {
21
21
  useJobsEndpoint,
22
22
  type GeneratedAnalysisTransport,
@@ -24,7 +24,6 @@ import {
24
24
  import AlertBox from './AlertBox.vue'
25
25
  import EmptyState from './EmptyState.vue'
26
26
  import GeneratedResultRenderer from './GeneratedResultRenderer.vue'
27
- import JobsStatusTray from './JobsStatusTray.vue'
28
27
  import LoadingSpinner from './LoadingSpinner.vue'
29
28
  import PluginWorkspaceView from './PluginWorkspaceView.vue'
30
29
  import { GENERATED_JOBS_API_KEY } from './internal/generatedJobsApi'
@@ -84,6 +83,7 @@ const workspace = computed(() => definePluginWorkspaceView({
84
83
  standaloneLabel: props.showStandaloneLabel ? 'Standalone' : false,
85
84
  },
86
85
  sidebar: { title: 'Parameters', panels: panels.value, forms: forms.value },
86
+ jobs: generated,
87
87
  }))
88
88
 
89
89
  // Seeds the standalone top bar icon; the title and subtitle above win over the seeded text.
@@ -137,7 +137,7 @@ async function submitJob(sectionId: string, values: Record<string, unknown>): Pr
137
137
  await generated.submit(definition.id, normalizeGeneratedJobInput(definition, values))
138
138
  }
139
139
 
140
- function selectJob(job: GeneratedAnalysisJob): void {
140
+ function selectJob(job: JobState): void {
141
141
  selectedJobId.value = job.job_id
142
142
  activeDefinitionId.value = String(job.metadata.definition_id ?? activeDefinitionId.value)
143
143
  }
@@ -166,6 +166,7 @@ onMounted(() => {
166
166
  :form-disabled="generated.isSubmitting.value"
167
167
  @update:values="updateValues"
168
168
  @form-submit="submitJob"
169
+ @job-select="selectJob"
169
170
  >
170
171
  <main class="mint-generated-workspace">
171
172
  <div v-if="generated.isLoading.value" class="mint-generated-workspace__loading">
@@ -200,12 +201,6 @@ onMounted(() => {
200
201
  />
201
202
  </section>
202
203
  </template>
203
-
204
- <JobsStatusTray
205
- :source="generated"
206
- :teleport-to="false"
207
- @select="selectJob"
208
- />
209
204
  </main>
210
205
  </PluginWorkspaceView>
211
206
  </template>