@morscherlab/mint-sdk 1.5.0-beta.1 → 1.5.0-beta.11

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 (174) hide show
  1. package/dist/{AlertBox-CIhqsWbN.js → AlertBox-Cai0w7gF.js} +3 -3
  2. package/dist/{AlertBox-CIhqsWbN.js.map → AlertBox-Cai0w7gF.js.map} +1 -1
  3. package/dist/{AppSidebar-BDrd7Kwn.js → AppSidebar-C-Vq5x4I.js} +410 -127
  4. package/dist/AppSidebar-C-Vq5x4I.js.map +1 -0
  5. package/dist/{ConfirmDialog-CjWoxAD1.js → ConfirmDialog-BjFsOpBe.js} +3 -3
  6. package/dist/ConfirmDialog-BjFsOpBe.js.map +1 -0
  7. package/dist/{ExperimentPopover-BM92LlrQ.js → ExperimentPopover-DYy_s0kI.js} +25 -37
  8. package/dist/ExperimentPopover-DYy_s0kI.js.map +1 -0
  9. package/dist/{ExperimentSelectorModal-LsB1ABmh.js → ExperimentSelectorModal-Bfh5bdkv.js} +37 -8
  10. package/dist/ExperimentSelectorModal-Bfh5bdkv.js.map +1 -0
  11. package/dist/{SegmentedControl-Dz41vAFM.js → SegmentedControl-B8KgyOjd.js} +2 -2
  12. package/dist/{SegmentedControl-Dz41vAFM.js.map → SegmentedControl-B8KgyOjd.js.map} +1 -1
  13. package/dist/{SettingsModal-C-MSR-bk.js → SettingsModal-DwfrD1ER.js} +7 -7
  14. package/dist/{SettingsModal-C-MSR-bk.js.map → SettingsModal-DwfrD1ER.js.map} +1 -1
  15. package/dist/__tests__/components/AppTopBarCompact.test.d.ts +1 -0
  16. package/dist/__tests__/components/AppTopBarCompactReset.test.d.ts +1 -0
  17. package/dist/__tests__/components/AutoGroupModal.experimentLoad.test.d.ts +1 -0
  18. package/dist/components/AppTopBar.compact.d.ts +5 -0
  19. package/dist/components/AppTopBar.vue.d.ts +17 -17
  20. package/dist/components/FormBuilder.vue.d.ts +1 -1
  21. package/dist/components/PluginIcon.vue.d.ts +2 -2
  22. package/dist/components/PluginWorkspaceView.vue.d.ts +4 -0
  23. package/dist/components/SectionCard.vue.d.ts +13 -1
  24. package/dist/components/SettingsModal.vue.d.ts +1 -1
  25. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -2
  26. package/dist/components/internal/FormSchemaInternal.vue.d.ts +9 -9
  27. package/dist/components/internal/FormSectionRenderer.vue.d.ts +4 -4
  28. package/dist/components/internal/LucideIconInternal.vue.d.ts +17 -0
  29. package/dist/composables/appLayoutSidebar.d.ts +9 -0
  30. package/dist/composables/useExperimentSelector.d.ts +2 -0
  31. package/dist/composables/useOnboarding.d.ts +1 -1
  32. package/dist/composables/usePluginWorkspace.d.ts +2 -0
  33. package/dist/composables/useVersionedSave.d.ts +5 -2
  34. package/dist/{controlSchemaModel-BbXbMgc6.js → controlSchemaModel-BtFNZj8t.js} +2 -2
  35. package/dist/{controlSchemaModel-BbXbMgc6.js.map → controlSchemaModel-BtFNZj8t.js.map} +1 -1
  36. package/dist/{data-LzdkEZJ-.js → data-CJztFns8.js} +31 -15
  37. package/dist/{data-LzdkEZJ-.js.map → data-CJztFns8.js.map} +1 -1
  38. package/dist/data.js +8 -8
  39. package/dist/{experimentStatus-DCn2QKG7.js → experimentStatus-Deokk7Br.js} +2 -2
  40. package/dist/{experimentStatus-DCn2QKG7.js.map → experimentStatus-Deokk7Br.js.map} +1 -1
  41. package/dist/index.d.ts +0 -1
  42. package/dist/index.js +15 -20
  43. package/dist/index.js.map +1 -1
  44. package/dist/{install-UTqrAgHe.js → install-Bk02tJ5M.js} +15 -40
  45. package/dist/install-Bk02tJ5M.js.map +1 -0
  46. package/dist/install.js +1 -1
  47. package/dist/{lab-CbdDLAVr.js → lab-BYhb_cJI.js} +47 -117
  48. package/dist/lab-BYhb_cJI.js.map +1 -0
  49. package/dist/lab.js +4 -4
  50. package/dist/{records-CWRztE5M.js → records-GtiVMlTQ.js} +207 -171
  51. package/dist/records-GtiVMlTQ.js.map +1 -0
  52. package/dist/{runtime-DrL6lKAb.js → runtime-aZPUPxoB.js} +112 -54
  53. package/dist/runtime-aZPUPxoB.js.map +1 -0
  54. package/dist/runtime.js +6 -6
  55. package/dist/{shell-Ch7cJnjo.js → shell-Bk9mD4Lc.js} +5 -5
  56. package/dist/{shell-Ch7cJnjo.js.map → shell-Bk9mD4Lc.js.map} +1 -1
  57. package/dist/shell.js +5 -5
  58. package/dist/styles.css +315 -134
  59. package/dist/styles.d.ts +1 -0
  60. package/dist/types/components.d.ts +22 -2
  61. package/dist/types/generated-ui.d.ts +2 -0
  62. package/dist/types/index.d.ts +2 -2
  63. package/dist/types/instrument.d.ts +5 -17
  64. package/dist/{useDropdownState-uFIz6Yv8.js → useDropdownState-CcBzUYZ6.js} +14 -5
  65. package/dist/useDropdownState-CcBzUYZ6.js.map +1 -0
  66. package/dist/{useExperimentSamples-Cz2v3V_q.js → useExperimentSamples-CpNXvpUn.js} +3 -3
  67. package/dist/{useExperimentSamples-Cz2v3V_q.js.map → useExperimentSamples-CpNXvpUn.js.map} +1 -1
  68. package/dist/{useListReorder-QqJc1dpX.js → useListReorder-Bp4EAI2w.js} +16 -4
  69. package/dist/useListReorder-Bp4EAI2w.js.map +1 -0
  70. package/dist/{useRackEditor-BHGM1Ngz.js → useRackEditor-i2bdPUFf.js} +156 -27
  71. package/dist/useRackEditor-i2bdPUFf.js.map +1 -0
  72. package/dist/{useRealtime-CNQbrS01.js → useRealtime-BLQEjbuq.js} +3 -3
  73. package/dist/{useRealtime-CNQbrS01.js.map → useRealtime-BLQEjbuq.js.map} +1 -1
  74. package/dist/useRequest-CXyPw3eh.js +76 -0
  75. package/dist/useRequest-CXyPw3eh.js.map +1 -0
  76. package/dist/utils/deprecation.d.ts +2 -0
  77. package/dist/utils/lucideIcon.d.ts +4 -0
  78. package/package.json +7 -4
  79. package/src/__tests__/components/AppTopBar.test.ts +102 -20
  80. package/src/__tests__/components/AppTopBarCompact.test.ts +65 -0
  81. package/src/__tests__/components/AppTopBarCompactReset.test.ts +24 -0
  82. package/src/__tests__/components/AppTopBarOnboarding.test.ts +10 -0
  83. package/src/__tests__/components/AutoGroupModal.experimentLoad.test.ts +58 -0
  84. package/src/__tests__/components/BaseModal.test.ts +33 -2
  85. package/src/__tests__/components/ConfirmDialog.test.ts +2 -2
  86. package/src/__tests__/components/ExperimentPopover.test.ts +32 -0
  87. package/src/__tests__/components/ExperimentSelectorModal.test.ts +16 -0
  88. package/src/__tests__/components/PluginWorkspaceView.test.ts +20 -1
  89. package/src/__tests__/components/SectionCard.test.ts +33 -0
  90. package/src/__tests__/components/deprecationTags.test.ts +4 -18
  91. package/src/__tests__/composables/useJobCenter.test.ts +35 -0
  92. package/src/__tests__/composables/useJobsEndpoint.test.ts +50 -0
  93. package/src/__tests__/composables/useOnboarding.test.ts +2 -2
  94. package/src/__tests__/composables/useVersionedSave.test.ts +71 -6
  95. package/src/__tests__/docs/frontendDocsCatalog.test.ts +3 -3
  96. package/src/__tests__/styles/menusShellRefresh.test.ts +10 -0
  97. package/src/components/AppLayout.story.vue +5 -5
  98. package/src/components/AppLayout.vue +13 -1
  99. package/src/components/AppSidebar.vue +2 -2
  100. package/src/components/AppTopBar.compact.ts +60 -0
  101. package/src/components/AppTopBar.story.vue +39 -14
  102. package/src/components/AppTopBar.vue +222 -89
  103. package/src/components/BaseModal.vue +16 -5
  104. package/src/components/CollapsibleCard.vue +1 -1
  105. package/src/components/ConfirmDialog.vue +1 -1
  106. package/src/components/ExperimentPopover.story.vue +2 -2
  107. package/src/components/ExperimentPopover.vue +6 -22
  108. package/src/components/ExperimentSelectorModal.vue +35 -0
  109. package/src/components/FileUploader.vue +3 -35
  110. package/src/components/GeneratedPluginView.vue +34 -81
  111. package/src/components/GeneratedResultRenderer.vue +2 -1
  112. package/src/components/InstrumentStatusPanels.vue +3 -3
  113. package/src/components/PlotlyChart.vue +1 -1
  114. package/src/components/PluginIcon.story.vue +2 -2
  115. package/src/components/PluginIcon.vue +2 -2
  116. package/src/components/PluginWorkspaceView.vue +10 -1
  117. package/src/components/SectionCard.vue +42 -6
  118. package/src/components/internal/AppTopBarOnboardingInternal.vue +30 -14
  119. package/src/components/internal/AppTopBarPageSelectorInternal.vue +12 -0
  120. package/src/components/internal/AutoGroupModalInternal.vue +14 -3
  121. package/src/components/internal/LucideIconInternal.vue +23 -0
  122. package/src/composables/appLayoutSidebar.ts +11 -0
  123. package/src/composables/index.ts +1 -2
  124. package/src/composables/useExperimentSelector.ts +4 -0
  125. package/src/composables/useJobCenter.ts +21 -1
  126. package/src/composables/useJobsEndpoint.ts +15 -4
  127. package/src/composables/useOnboarding.ts +3 -3
  128. package/src/composables/usePluginWorkspace.ts +15 -4
  129. package/src/composables/useVersionedSave.ts +32 -7
  130. package/src/generated-ui/main.ts +3 -1
  131. package/src/index.ts +0 -3
  132. package/src/styles/components/alert-box.css +1 -1
  133. package/src/styles/components/app-layout.css +5 -0
  134. package/src/styles/components/app-sidebar.css +5 -5
  135. package/src/styles/components/app-top-bar.css +130 -9
  136. package/src/styles/components/button.css +3 -3
  137. package/src/styles/components/checkbox.css +1 -1
  138. package/src/styles/components/collapsible-card.css +1 -1
  139. package/src/styles/components/confirm-dialog.css +2 -2
  140. package/src/styles/components/file-uploader.css +3 -3
  141. package/src/styles/components/icon-button.css +3 -3
  142. package/src/styles/components/loading-spinner.css +1 -1
  143. package/src/styles/components/onboarding.css +39 -0
  144. package/src/styles/components/pill.css +1 -1
  145. package/src/styles/components/plugin-icon.css +2 -2
  146. package/src/styles/components/presence-avatars.css +4 -4
  147. package/src/styles/components/progress-bar.css +1 -1
  148. package/src/styles/components/section-card.css +65 -6
  149. package/src/styles/components/sequence-input.css +1 -1
  150. package/src/styles/components/slider.css +1 -1
  151. package/src/styles/components/toggle.css +1 -1
  152. package/src/styles/variables.css +10 -4
  153. package/src/types/components.ts +23 -2
  154. package/src/types/generated-ui.ts +2 -0
  155. package/src/types/index.ts +1 -1
  156. package/src/types/instrument.ts +5 -18
  157. package/src/utils/deprecation.ts +8 -0
  158. package/src/utils/lucideIcon.ts +25 -0
  159. package/src/utils/lucideIconIndex.d.ts +2 -0
  160. package/dist/AppSidebar-BDrd7Kwn.js.map +0 -1
  161. package/dist/ConfirmDialog-CjWoxAD1.js.map +0 -1
  162. package/dist/ExperimentPopover-BM92LlrQ.js.map +0 -1
  163. package/dist/ExperimentSelectorModal-LsB1ABmh.js.map +0 -1
  164. package/dist/deprecatedRootExports.d.ts +0 -191
  165. package/dist/install-UTqrAgHe.js.map +0 -1
  166. package/dist/lab-CbdDLAVr.js.map +0 -1
  167. package/dist/records-CWRztE5M.js.map +0 -1
  168. package/dist/runtime-DrL6lKAb.js.map +0 -1
  169. package/dist/useDropdownState-uFIz6Yv8.js.map +0 -1
  170. package/dist/useListReorder-QqJc1dpX.js.map +0 -1
  171. package/dist/useRackEditor-BHGM1Ngz.js.map +0 -1
  172. package/dist/useRequest-Bfsh7gIm.js +0 -215
  173. package/dist/useRequest-Bfsh7gIm.js.map +0 -1
  174. package/src/deprecatedRootExports.ts +0 -226
@@ -3,9 +3,15 @@
3
3
  * Full application top bar with brand logo, static plugin identity, page selector, centered pill nav, experiment popover, and an account slot.
4
4
  *
5
5
  * The `#icon` slot applies in standalone mode only; integrated branding comes from `Metadata.icon`.
6
+ *
7
+ * The order is fixed: identity or page selector, pills, the context group (standalone badge, run slot,
8
+ * onboarding, `spec.actions`), then the tail: theme toggle, settings, `#account`. Below 1024px of bar
9
+ * width the pills and the context group move to a second row. Inside a responsive AppLayout the bar
10
+ * holds the sidebar drawer button.
6
11
  */
7
12
  import { ref, computed, inject, defineAsyncComponent, onMounted, onBeforeUnmount, toValue, useSlots, watch, type Component } from 'vue'
8
13
  import type {
14
+ ActionMenuItem,
9
15
  PillNavOption,
10
16
  PillNavItem,
11
17
  PillNavItemInput,
@@ -13,7 +19,13 @@ import type {
13
19
  PageSelectorItemInput,
14
20
  PluginWorkspaceTopBarSpec,
15
21
  SettingsTab,
22
+ TopBarSettingsConfig,
16
23
  } from '../types/components'
24
+ import { APP_LAYOUT_SIDEBAR_KEY } from '../composables/appLayoutSidebar'
25
+ import { warnDeprecatedOnce } from '../utils/deprecation'
26
+ import ActionMenu from './ActionMenu.vue'
27
+ import BaseButton from './BaseButton.vue'
28
+ import LucideIconInternal from './internal/LucideIconInternal.vue'
17
29
  import { normalizeItemInput } from '../utils/items'
18
30
  import { applySpecDefaults } from '../composables/componentSpecs'
19
31
  import ThemeToggle from './ThemeToggle.vue'
@@ -27,7 +39,8 @@ import { useOnboarding } from '../composables/useOnboarding'
27
39
  import { pluginIdentity } from '../composables/pluginIdentity'
28
40
  import { integrationHost } from '../utils/integrationHost'
29
41
  import { trackTopBarForToasts } from './AppTopBar.toastOffset'
30
- import { CircleAlert, CircleCheck, CircleX, ListChecks, LoaderCircle } from 'lucide-vue-next'
42
+ import { trackTopBarCompact } from './AppTopBar.compact'
43
+ import { CircleAlert, CircleCheck, CircleX, ListChecks, Ellipsis, LoaderCircle, Menu, X } from 'lucide-vue-next'
31
44
  import type { JobStatus, TopBarJob } from '../types/jobs'
32
45
  import type { Tone } from '../types/components'
33
46
 
@@ -67,12 +80,23 @@ const config = computed(() => applySpecDefaults({
67
80
  variant: 'card',
68
81
  homePath: '/',
69
82
  logo: true,
70
- themeToggle: false,
71
- settings: false,
72
83
  standaloneLabel: 'Standalone',
73
84
  }, props.spec))
74
- const settingsConfig = computed(() => toValue(config.value.settings) || undefined)
75
- const showSettings = computed(() => settingsConfig.value !== undefined)
85
+ // The theme and settings buttons always close the bar; without settings the modal has only Appearance.
86
+ const settingsConfig = computed<TopBarSettingsConfig>(() => toValue(config.value.settings) || {})
87
+
88
+ /** Plugin buttons: two show, the rest fold into the More menu. */
89
+ const MAX_VISIBLE_ACTIONS = 2
90
+ const visibleActions = computed(() => (config.value.actions ?? []).slice(0, MAX_VISIBLE_ACTIONS))
91
+ const overflowActions = computed(() => (config.value.actions ?? []).slice(MAX_VISIBLE_ACTIONS))
92
+ const overflowMenuItems = computed<ActionMenuItem[]>(() =>
93
+ overflowActions.value.map(({ id, label, disabled, loading }) => ({ id, label, disabled: disabled || loading })),
94
+ )
95
+ function selectOverflowAction(item: ActionMenuItem) {
96
+ overflowActions.value.find(action => action.id === item.id)?.onSelect()
97
+ }
98
+
99
+ const drawer = inject(APP_LAYOUT_SIDEBAR_KEY, null)
76
100
 
77
101
  const SettingsModal = defineAsyncComponent(() => import('./SettingsModal.vue'))
78
102
  const ExperimentPopover = defineAsyncComponent(() => import('./ExperimentPopover.vue'))
@@ -90,10 +114,18 @@ const emit = defineEmits<{
90
114
  const settingsOpen = ref(false)
91
115
  const headerEl = ref<HTMLElement | null>(null)
92
116
  let untrackToastOffset: (() => void) | undefined
117
+ // Labels give way to icons when the content does not fit (AppTopBar.compact.ts).
118
+ const compact = ref(false)
119
+ let compactTracker: ReturnType<typeof trackTopBarCompact> | undefined
93
120
  onMounted(() => {
94
- if (headerEl.value) untrackToastOffset = trackTopBarForToasts(headerEl.value)
121
+ if (!headerEl.value) return
122
+ untrackToastOffset = trackTopBarForToasts(headerEl.value)
123
+ compactTracker = trackTopBarCompact(headerEl.value, compact)
124
+ })
125
+ onBeforeUnmount(() => {
126
+ untrackToastOffset?.()
127
+ compactTracker?.stop()
95
128
  })
96
- onBeforeUnmount(() => untrackToastOffset?.())
97
129
  const { integrated: isIntegrated, plugin } = usePlatform()
98
130
  const isStandalone = computed(() => !isIntegrated.value)
99
131
  const appExperiment = inject(APP_EXPERIMENT_KEY, null)
@@ -139,6 +171,19 @@ const JOB_ICONS: Record<JobStatus, Component> = {
139
171
  }
140
172
 
141
173
  const slots = useSlots()
174
+ if (props.spec.themeToggle !== undefined) {
175
+ warnDeprecatedOnce('topbar.themeToggle', 'AppTopBar spec.themeToggle is ignored: the theme toggle always shows. Remove it; it is removed in 2.0.')
176
+ }
177
+ for (const name of ['nav', 'center', 'actions', 'page-selector-icon', 'page-selector-item-icon'] as const) {
178
+ if (slots[name]) {
179
+ warnDeprecatedOnce(
180
+ `topbar.slot.${name}`,
181
+ name.startsWith('page-selector')
182
+ ? `AppTopBar #${name} is deprecated: give the page an icon (a Lucide name). It is removed in 2.0.`
183
+ : `AppTopBar #${name} is deprecated${name === 'actions' ? ': use spec.actions' : ''}. It is removed in 2.0.`,
184
+ )
185
+ }
186
+ }
142
187
  // Standalone: the seeded identity (providePluginIdentity) stands in for a missing icon and title.
143
188
  const pluginIcon = computed(() => plugin.value?.icon || (slots.logo ? undefined : pluginIdentity.icon))
144
189
  const seeded = computed(() => isStandalone.value && !props.title && !props.subtitle)
@@ -168,6 +213,28 @@ const pageSelectorPluginName = computed(() =>
168
213
  // Platforms before 1.4 send no subtitle; the description fills line 2.
169
214
  const identitySubtitle = computed(() => plugin.value?.subtitle || plugin.value?.description)
170
215
  const hasPillNav = computed(() => !!config.value.pills?.length)
216
+ const showStandaloneBadge = computed(() => config.value.standaloneLabel !== false && isStandalone.value)
217
+ const showRunSlot = computed(() => showExperiment.value || props.job !== undefined || runSlotJoined.value)
218
+ /** The context group: standalone badge, run slot, onboarding and plugin buttons. Below 1024px it is row 2. */
219
+ // New content can need (or free) the label room again. One source per fact, so a job's
220
+ // changing progress label (a new job object each update) does not re-measure.
221
+ watch(
222
+ [
223
+ () => config.value.actions?.map(action => action.label).join(),
224
+ () => props.job === undefined,
225
+ () => props.job === null,
226
+ showExperiment,
227
+ () => config.value.pills?.length,
228
+ ],
229
+ () => compactTracker?.reset(),
230
+ )
231
+ const hasContext = computed(() =>
232
+ showStandaloneBadge.value
233
+ || showRunSlot.value
234
+ || !!config.value.actions?.length
235
+ || !!slots.actions
236
+ || !!onboarding?.enabled.value,
237
+ )
171
238
  const hasTitleGroup = computed(
172
239
  () =>
173
240
  !hasPageSelector.value &&
@@ -202,11 +269,24 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
202
269
  :class="[
203
270
  'mint-topbar',
204
271
  `mint-topbar--${config.variant}`,
272
+ { 'mint-topbar--compact': compact },
205
273
  ]"
206
274
  >
207
- <div class="mint-topbar__container">
275
+ <!-- Below 1024px of bar width a bar with pills or context stacks into two rows (CSS container query). -->
276
+ <div :class="['mint-topbar__container', { 'mint-topbar__container--stackable': hasPillNav || $slots.center || hasContext }]">
208
277
  <!-- Left: brand -->
209
278
  <div class="mint-topbar__start">
279
+ <!-- Sidebar drawer button of the surrounding AppLayout; CSS shows it with the drawer (< 1024px). -->
280
+ <button
281
+ v-if="drawer"
282
+ type="button"
283
+ class="mint-topbar__icon-btn mint-topbar__sidebar-toggle"
284
+ :aria-label="drawer.open.value ? drawer.closeLabel.value : drawer.openLabel.value"
285
+ :aria-expanded="drawer.open.value"
286
+ @click="drawer.toggle()"
287
+ >
288
+ <component :is="drawer.open.value ? X : Menu" class="mint-topbar__icon-btn-icon" aria-hidden="true" />
289
+ </button>
210
290
  <div class="mint-topbar__brand">
211
291
  <a
212
292
  v-if="config.homePath && (config.homePath.startsWith('http') || config.homePath.startsWith('/'))"
@@ -348,104 +428,157 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
348
428
  </slot>
349
429
  </div>
350
430
 
351
- <!-- Right section -->
431
+ <!-- Right section: the context group, then the fixed tail -->
352
432
  <div class="mint-topbar__right">
353
- <span v-if="config.standaloneLabel !== false && isStandalone" class="mint-topbar__standalone-badge">
354
- {{ config.standaloneLabel }}
355
- </span>
433
+ <div v-if="hasContext" class="mint-topbar__context">
434
+ <span v-if="showStandaloneBadge" class="mint-topbar__standalone-badge">
435
+ {{ config.standaloneLabel }}
436
+ </span>
356
437
 
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"
438
+ <!-- Run slot: while a job shows, its progress takes the place of the experiment name and Save:
439
+ [ EXP-0142 ▾ | job ]. The idle Jobs button stands apart. -->
440
+ <div
441
+ v-if="showRunSlot"
442
+ :class="['mint-topbar__run-slot', { 'mint-topbar__run-slot--joined': runSlotJoined }]"
443
+ data-onboarding="topbar-run"
380
444
  >
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')"
445
+ <ExperimentPopover
446
+ v-if="showExperiment && appExperiment"
447
+ v-bind="job ? { ...appExperiment.popover.value, showSave: false } : appExperiment.popover.value"
448
+ @select="appExperiment.openModal()"
449
+ @save="appExperiment.handleSave()"
450
+ @detach="appExperiment.handleDetach()"
451
+ />
452
+ <!-- Status words only, so a moving percent does not repeat. -->
453
+ <span class="mint-topbar__job-status" aria-live="polite">{{ job ? JOB_STATUS_TEXT[job.status] : '' }}</span>
454
+ <!-- appear: the slot can mount together with its first job; before-enter then joins it. -->
455
+ <Transition
456
+ name="mint-topbar-job"
457
+ mode="out-in"
458
+ appear
459
+ @before-enter="syncRunSlotJoined"
460
+ @after-leave="syncRunSlotJoined"
391
461
  >
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>
462
+ <button
463
+ v-if="job"
464
+ key="run"
465
+ type="button"
466
+ :class="['mint-topbar__job', `mint-topbar__job--${JOB_TONES[job.status]}`]"
467
+ :title="jobName"
468
+ :aria-label="jobName"
469
+ :aria-expanded="jobsOpen"
470
+ :aria-controls="jobsPanelId"
471
+ @click="emit('job-click')"
472
+ >
473
+ <component
474
+ :is="JOB_ICONS[job.status]"
475
+ :class="['mint-topbar__job-icon', { 'mint-topbar__job-icon--spin': job.status === 'running' || job.status === 'queued' }]"
476
+ :size="14"
477
+ aria-hidden="true"
478
+ />
479
+ <span v-if="jobPercent != null" class="mint-topbar__job-bar" aria-hidden="true">
480
+ <span :style="{ width: `${jobPercent}%` }" />
481
+ </span>
482
+ </button>
483
+ <button
484
+ v-else-if="job === null"
485
+ key="idle"
486
+ type="button"
487
+ class="mint-topbar__job mint-topbar__job--idle"
488
+ :aria-label="jobsDone ? `Jobs, ${jobsDone} completed` : 'Jobs'"
489
+ title="Jobs"
490
+ :aria-expanded="jobsOpen"
491
+ :aria-controls="jobsPanelId"
492
+ @click="emit('job-click')"
493
+ >
494
+ <ListChecks class="mint-topbar__job-icon" :size="14" aria-hidden="true" />
495
+ <span class="mint-topbar__job-label">Jobs</span>
496
+ <span v-if="jobsDone" class="mint-topbar__job-count" aria-hidden="true">{{ jobsDone }}</span>
497
+ </button>
498
+ </Transition>
499
+ </div>
500
+
501
+ <!-- Onboarding: checklist chip, Help button and tour layer, when the app provides onboarding -->
502
+ <AppTopBarOnboardingInternal v-if="onboarding?.enabled.value" :state="onboarding" />
503
+
504
+ <!-- Plugin buttons: spec.actions (two, then More); the deprecated #actions slot is clipped to a fixed width -->
505
+ <template v-for="action in visibleActions" :key="action.id">
402
506
  <button
403
- v-else-if="job === null"
404
- key="idle"
507
+ v-if="action.icon"
405
508
  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')"
509
+ class="mint-topbar__icon-btn"
510
+ :aria-label="action.label"
511
+ :title="action.title ?? action.label"
512
+ :disabled="action.disabled || action.loading"
513
+ :aria-busy="action.loading || undefined"
514
+ :data-action="action.id"
515
+ @click="action.onSelect()"
411
516
  >
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>
517
+ <LoaderCircle v-if="action.loading" class="mint-topbar__icon-btn-icon mint-topbar__job-icon--spin" aria-hidden="true" />
518
+ <LucideIconInternal v-else :name="action.icon" :size="16" />
519
+ <span v-if="action.dot" :class="['mint-topbar__icon-btn-dot', `mint-topbar__icon-btn-dot--${action.dot}`]" aria-hidden="true" />
415
520
  </button>
416
- </Transition>
521
+ <BaseButton
522
+ v-else
523
+ variant="secondary"
524
+ size="sm"
525
+ :title="action.title"
526
+ :loading="action.loading"
527
+ :disabled="action.disabled"
528
+ :data-action="action.id"
529
+ @click="action.onSelect()"
530
+ >
531
+ {{ action.label }}
532
+ </BaseButton>
533
+ </template>
534
+ <ActionMenu
535
+ v-if="overflowMenuItems.length"
536
+ label="More actions"
537
+ :items="overflowMenuItems"
538
+ @select="selectOverflowAction"
539
+ >
540
+ <template #trigger="{ open, toggle, keydown }">
541
+ <button
542
+ type="button"
543
+ class="mint-topbar__icon-btn"
544
+ aria-label="More actions"
545
+ title="More actions"
546
+ aria-haspopup="menu"
547
+ :aria-expanded="open"
548
+ @click="toggle"
549
+ @keydown="keydown"
550
+ >
551
+ <Ellipsis class="mint-topbar__icon-btn-icon" aria-hidden="true" />
552
+ </button>
553
+ </template>
554
+ </ActionMenu>
555
+ <span v-if="$slots.actions" class="mint-topbar__legacy-actions"><slot name="actions" /></span>
417
556
  </div>
418
557
 
419
- <slot name="actions" />
558
+ <div class="mint-topbar__tail">
559
+ <ThemeToggle size="sm" />
420
560
 
421
- <!-- Onboarding: checklist chip, Help button and tour layer, when the app provides onboarding -->
422
- <AppTopBarOnboardingInternal v-if="onboarding?.enabled.value" :state="onboarding" />
423
-
424
- <!-- Theme toggle -->
425
- <ThemeToggle v-if="config.themeToggle" size="sm" />
426
-
427
- <!-- Settings -->
428
- <button
429
- v-if="showSettings"
430
- type="button"
431
- class="mint-topbar__settings-btn"
432
- aria-label="Open settings"
433
- data-onboarding="topbar-settings"
434
- @click="settingsOpen = true"
435
- >
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">
437
- <path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" />
438
- </svg>
439
- </button>
561
+ <button
562
+ type="button"
563
+ class="mint-topbar__settings-btn"
564
+ aria-label="Open settings"
565
+ data-onboarding="topbar-settings"
566
+ @click="settingsOpen = true"
567
+ >
568
+ <svg class="mint-topbar__settings-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
569
+ <path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" />
570
+ </svg>
571
+ </button>
440
572
 
441
- <!-- Account: the host application renders its account controls here -->
442
- <slot name="account" />
573
+ <!-- Account: the host application renders its account controls here -->
574
+ <slot name="account" />
575
+ </div>
443
576
  </div>
444
577
  </div>
445
578
  </header>
446
579
 
447
580
  <SettingsModal
448
- v-if="showSettings && settingsOpen"
581
+ v-if="settingsOpen"
449
582
  v-model:open="settingsOpen"
450
583
  :title="settingsConfig?.title"
451
584
  :tabs="normalizedSettingsTabs"
@@ -1,6 +1,18 @@
1
1
  <script lang="ts">
2
2
  /** Open modals, oldest first; shared by every BaseModal instance. */
3
3
  const openModals: object[] = []
4
+ /** Body overflow from before the first modal opened, restored when the last one closes. */
5
+ let bodyOverflowBeforeLock = ''
6
+
7
+ /** Body scroll follows the stack: locked while any modal is open. */
8
+ function syncBodyScrollLock(wasEmpty: boolean) {
9
+ if (wasEmpty && openModals.length > 0) {
10
+ bodyOverflowBeforeLock = document.body.style.overflow
11
+ document.body.style.overflow = 'hidden'
12
+ } else if (!wasEmpty && openModals.length === 0) {
13
+ document.body.style.overflow = bodyOverflowBeforeLock
14
+ }
15
+ }
4
16
  </script>
5
17
 
6
18
  <script setup lang="ts">
@@ -95,16 +107,14 @@ const containerRef = ref<HTMLElement | null>(null)
95
107
 
96
108
  useFocusTrap({ containerRef, active: toRef(props, 'open') })
97
109
 
98
- watch(() => props.open, (isOpen) => {
99
- document.body.style.overflow = isOpen ? 'hidden' : ''
100
- })
101
-
102
110
  // Every open modal listens on document, so only the most recently opened one answers Escape.
103
111
  const self = {}
104
112
  watch(() => props.open, (isOpen) => {
113
+ const wasEmpty = openModals.length === 0
105
114
  const index = openModals.indexOf(self)
106
115
  if (isOpen && index === -1) openModals.push(self)
107
116
  if (!isOpen && index !== -1) openModals.splice(index, 1)
117
+ syncBodyScrollLock(wasEmpty)
108
118
  }, { immediate: true })
109
119
 
110
120
  // A control inside the modal that handles Escape (an open select) prevents the default first.
@@ -130,9 +140,10 @@ function handleOverlayClick(event: MouseEvent) {
130
140
  useEventListener(() => document, 'keydown', handleEscapeKeydown)
131
141
 
132
142
  onUnmounted(() => {
133
- document.body.style.overflow = ''
143
+ const wasEmpty = openModals.length === 0
134
144
  const index = openModals.indexOf(self)
135
145
  if (index !== -1) openModals.splice(index, 1)
146
+ syncBodyScrollLock(wasEmpty)
136
147
  })
137
148
  </script>
138
149
 
@@ -23,7 +23,7 @@ interface Props {
23
23
 
24
24
  const props = withDefaults(defineProps<Props>(), {
25
25
  badge: undefined,
26
- badgeTone: 'cta',
26
+ badgeTone: 'primary',
27
27
  defaultOpen: false,
28
28
  disabled: false,
29
29
  dense: false,
@@ -41,7 +41,7 @@ function handleConfirm() {
41
41
 
42
42
  const confirmVariant = computed<ButtonVariant>(() => {
43
43
  if (props.tone === 'error') return 'danger'
44
- if (props.tone === 'warning' || props.tone === 'cta') return 'cta'
44
+ if (props.tone === 'warning' || props.tone === 'cta') return 'primary'
45
45
  if (props.tone === 'success') return 'success'
46
46
  return 'primary'
47
47
  })
@@ -282,8 +282,8 @@ function initPlayground() {
282
282
  />
283
283
  </div>
284
284
  <div style="text-align: center; font-size: 0.75rem; color: var(--text-muted); padding: 0 1rem 1rem;">
285
- Green surface with check icon. Success state auto-dismisses after 3s in the component itself; the story
286
- just shows the moment.
285
+ Green surface with check icon. Success state lasts while saveSuccessMessage is set; the parent clears it
286
+ (useAppExperiment after 3s). The story just shows the moment.
287
287
  </div>
288
288
  </Variant>
289
289
 
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  /** Top-bar control for the attached experiment: a trigger joined to an optional Save button, with a summary panel (change, open, detach). */
3
- import { ref, computed, watch, nextTick, onUnmounted, useTemplateRef } from 'vue'
3
+ import { ref, computed, watch, nextTick, useTemplateRef } from 'vue'
4
4
  import { ArrowLeftRight, Check, ChevronDown, CircleAlert, CloudUpload, ExternalLink, Link, LoaderCircle, Unlink } from 'lucide-vue-next'
5
5
  import { useDropdownState } from '../composables/useDropdownState'
6
6
  import { format as formatStatus } from '../utils/experimentStatus'
@@ -78,7 +78,8 @@ const { isOpen, close, toggle } = useDropdownState({
78
78
  triggerRef: useTemplateRef<HTMLElement>('trigger'),
79
79
  closeOnEscape: false,
80
80
  })
81
- const showSuccess = ref(false)
81
+ // The parent owns the success timer; the indicator lasts as long as its message.
82
+ const showSuccess = computed(() => !!props.saveSuccessMessage)
82
83
  const showConfirm = ref(false)
83
84
 
84
85
  // The panel hangs from the group's left edge; when that would cross the
@@ -95,7 +96,7 @@ watch(isOpen, async (open) => {
95
96
 
96
97
  const saveTitle = computed(() => {
97
98
  if (props.saveDisabled && props.saveDisabledMessage) return props.saveDisabledMessage
98
- if (showSuccess.value && props.saveSuccessMessage) return props.saveSuccessMessage
99
+ if (props.saveSuccessMessage) return props.saveSuccessMessage
99
100
  return `${props.saveLabel} to experiment`
100
101
  })
101
102
 
@@ -135,6 +136,7 @@ function handleSave() {
135
136
 
136
137
  function handleConfirmSave() {
137
138
  showConfirm.value = false
139
+ if (props.saveDisabled || props.saveLoading) return
138
140
  emit('save')
139
141
  }
140
142
 
@@ -142,24 +144,6 @@ function handleDetach() {
142
144
  emit('detach')
143
145
  close()
144
146
  }
145
-
146
- let successTimer: ReturnType<typeof setTimeout> | null = null
147
-
148
- // Show success state when saveSuccessMessage changes from empty to a value
149
- watch(() => props.saveSuccessMessage, (msg) => {
150
- if (successTimer) clearTimeout(successTimer)
151
- if (msg) {
152
- showSuccess.value = true
153
- successTimer = setTimeout(() => {
154
- showSuccess.value = false
155
- successTimer = null
156
- }, 3000)
157
- }
158
- })
159
-
160
- onUnmounted(() => {
161
- if (successTimer) clearTimeout(successTimer)
162
- })
163
147
  </script>
164
148
 
165
149
  <template>
@@ -221,7 +205,7 @@ onUnmounted(() => {
221
205
  <LoaderCircle v-if="saveLoading" class="mint-experiment-popover__save-spinner" :size="14" aria-hidden="true" />
222
206
  <Check v-else-if="showSuccess" :size="14" aria-hidden="true" />
223
207
  <CloudUpload v-else :size="14" aria-hidden="true" />
224
- <span>{{ saveLoading ? 'Saving…' : saveLabel }}</span>
208
+ <span class="mint-experiment-popover__save-label">{{ saveLoading ? 'Saving…' : saveLabel }}</span>
225
209
  <span v-if="dirty && !saveLoading" class="mint-experiment-popover__dirty" aria-hidden="true" />
226
210
  </button>
227
211
 
@@ -53,6 +53,28 @@ function close() {
53
53
 
54
54
  const experimentStore = useExperimentStore()
55
55
 
56
+ // The filter choices (not the search) survive a close or a page reload, so a
57
+ // user who works on their own ongoing experiments does not pick them again.
58
+ // One key for every plugin: all plugin pages share the platform origin. The
59
+ // experiment type is left out, since each plugin allows its own types.
60
+ const FILTERS_STORAGE_KEY = 'mint.experimentSelector.filters'
61
+ const REMEMBERED_FILTERS = ['status', 'project', 'datePreset', 'mine'] as const
62
+
63
+ function readRememberedFilters(): Partial<ExperimentFilters> {
64
+ try {
65
+ const saved: unknown = JSON.parse(globalThis.localStorage?.getItem(FILTERS_STORAGE_KEY) ?? '{}')
66
+ if (!saved || typeof saved !== 'object') return {}
67
+ const restored: Record<string, unknown> = {}
68
+ for (const key of REMEMBERED_FILTERS) {
69
+ const value = (saved as Record<string, unknown>)[key]
70
+ if (typeof value === 'string' || value === true) restored[key] = value
71
+ }
72
+ return restored as Partial<ExperimentFilters>
73
+ } catch {
74
+ return {}
75
+ }
76
+ }
77
+
56
78
  const {
57
79
  experiments,
58
80
  filters,
@@ -68,8 +90,21 @@ const {
68
90
  experimentType: props.experimentType,
69
91
  allowedExperimentTypes: props.allowedExperimentTypes,
70
92
  withStatusCounts: true,
93
+ initialFilters: readRememberedFilters(),
71
94
  })
72
95
 
96
+ watch(
97
+ () => REMEMBERED_FILTERS.map(key => filters[key]),
98
+ () => {
99
+ const saved = Object.fromEntries(REMEMBERED_FILTERS.filter(key => filters[key]).map(key => [key, filters[key]]))
100
+ try {
101
+ globalThis.localStorage?.setItem(FILTERS_STORAGE_KEY, JSON.stringify(saved))
102
+ } catch {
103
+ // Storage blocked (private mode): the filters last only while the page is open.
104
+ }
105
+ },
106
+ )
107
+
73
108
  // Re-mounting the list on open clears its keyboard highlight.
74
109
  const openCount = ref(0)
75
110
 
@@ -63,35 +63,8 @@ const maxSizeLabel = computed(() => {
63
63
  return `${props.maxSize} bytes`
64
64
  })
65
65
 
66
- const fileTypeMap: Record<string, { color: string; label: string }> = {
67
- csv: { color: '#10B981', label: 'CSV' },
68
- tsv: { color: '#10B981', label: 'TSV' },
69
- xlsx: { color: '#10B981', label: 'XLSX' },
70
- xls: { color: '#10B981', label: 'XLS' },
71
- pdf: { color: '#EF4444', label: 'PDF' },
72
- png: { color: '#8B5CF6', label: 'PNG' },
73
- jpg: { color: '#8B5CF6', label: 'JPG' },
74
- jpeg: { color: '#8B5CF6', label: 'JPEG' },
75
- gif: { color: '#8B5CF6', label: 'GIF' },
76
- svg: { color: '#8B5CF6', label: 'SVG' },
77
- webp: { color: '#8B5CF6', label: 'WEBP' },
78
- json: { color: '#F59E0B', label: 'JSON' },
79
- xml: { color: '#F59E0B', label: 'XML' },
80
- txt: { color: '#64748B', label: 'TXT' },
81
- zip: { color: '#6366F1', label: 'ZIP' },
82
- gz: { color: '#6366F1', label: 'GZ' },
83
- py: { color: '#3B82F6', label: 'PY' },
84
- js: { color: '#EAB308', label: 'JS' },
85
- ts: { color: '#3B82F6', label: 'TS' },
86
- fasta: { color: '#EC4899', label: 'FASTA' },
87
- fastq: { color: '#EC4899', label: 'FASTQ' },
88
- bam: { color: '#EC4899', label: 'BAM' },
89
- vcf: { color: '#EC4899', label: 'VCF' },
90
- }
91
-
92
- function getFileType(filename: string): { color: string; label: string } {
93
- const ext = filename.split('.').pop()?.toLowerCase() || ''
94
- return fileTypeMap[ext] || { color: '#94A3B8', label: ext.toUpperCase() || 'FILE' }
66
+ function fileTypeLabel(filename: string): string {
67
+ return filename.split('.').pop()?.toUpperCase() || 'FILE'
95
68
  }
96
69
 
97
70
  function validateFiles(files: FileList | File[]): File[] {
@@ -420,12 +393,7 @@ function formatFileSize(bytes: number): string {
420
393
  class="mint-file-uploader__file"
421
394
  >
422
395
  <div class="mint-file-uploader__file-info">
423
- <span
424
- class="mint-file-uploader__file-badge"
425
- :style="{ '--badge-color': getFileType(file.name).color }"
426
- >
427
- {{ getFileType(file.name).label }}
428
- </span>
396
+ <span class="mint-file-uploader__file-badge">{{ fileTypeLabel(file.name) }}</span>
429
397
  <div class="mint-file-uploader__file-meta">
430
398
  <span class="mint-file-uploader__file-name">{{ file.name }}</span>
431
399
  <span class="mint-file-uploader__file-size">{{ formatFileSize(file.size) }}</span>