@morscherlab/mint-sdk 1.5.0-beta.7 → 1.5.0-beta.9

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 (105) hide show
  1. package/dist/{AlertBox-CexONPhA.js → AlertBox-Cai0w7gF.js} +3 -3
  2. package/dist/{AlertBox-CexONPhA.js.map → AlertBox-Cai0w7gF.js.map} +1 -1
  3. package/dist/{AppSidebar-C4qQLHcH.js → AppSidebar-C-Vq5x4I.js} +361 -93
  4. package/dist/AppSidebar-C-Vq5x4I.js.map +1 -0
  5. package/dist/{ConfirmDialog-D9QXFpsy.js → ConfirmDialog-BjFsOpBe.js} +2 -2
  6. package/dist/{ConfirmDialog-D9QXFpsy.js.map → ConfirmDialog-BjFsOpBe.js.map} +1 -1
  7. package/dist/{ExperimentPopover-DyjoXwry.js → ExperimentPopover-DYy_s0kI.js} +21 -20
  8. package/dist/ExperimentPopover-DYy_s0kI.js.map +1 -0
  9. package/dist/{ExperimentSelectorModal-CUkKmgfr.js → ExperimentSelectorModal-Bfh5bdkv.js} +5 -5
  10. package/dist/{ExperimentSelectorModal-CUkKmgfr.js.map → ExperimentSelectorModal-Bfh5bdkv.js.map} +1 -1
  11. package/dist/{SegmentedControl-C-sckoJZ.js → SegmentedControl-B8KgyOjd.js} +2 -2
  12. package/dist/{SegmentedControl-C-sckoJZ.js.map → SegmentedControl-B8KgyOjd.js.map} +1 -1
  13. package/dist/{SettingsModal-B1ltysmg.js → SettingsModal-DwfrD1ER.js} +7 -7
  14. package/dist/{SettingsModal-B1ltysmg.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/PluginWorkspaceView.vue.d.ts +4 -0
  21. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -2
  22. package/dist/components/internal/LucideIconInternal.vue.d.ts +17 -0
  23. package/dist/composables/appLayoutSidebar.d.ts +9 -0
  24. package/dist/composables/usePluginWorkspace.d.ts +2 -0
  25. package/dist/composables/useVersionedSave.d.ts +5 -2
  26. package/dist/{controlSchemaModel-C_XqpkgE.js → controlSchemaModel-BtFNZj8t.js} +2 -2
  27. package/dist/{controlSchemaModel-C_XqpkgE.js.map → controlSchemaModel-BtFNZj8t.js.map} +1 -1
  28. package/dist/{data-CgIcnf9D.js → data-CJztFns8.js} +31 -15
  29. package/dist/{data-CgIcnf9D.js.map → data-CJztFns8.js.map} +1 -1
  30. package/dist/data.js +8 -8
  31. package/dist/{experimentStatus-DCn2QKG7.js → experimentStatus-Deokk7Br.js} +2 -2
  32. package/dist/{experimentStatus-DCn2QKG7.js.map → experimentStatus-Deokk7Br.js.map} +1 -1
  33. package/dist/index.js +14 -14
  34. package/dist/{install-DmyjbTJ5.js → install-Bk02tJ5M.js} +14 -15
  35. package/dist/{install-DmyjbTJ5.js.map → install-Bk02tJ5M.js.map} +1 -1
  36. package/dist/install.js +1 -1
  37. package/dist/{lab-BcX5JBSY.js → lab-BYhb_cJI.js} +22 -14
  38. package/dist/lab-BYhb_cJI.js.map +1 -0
  39. package/dist/lab.js +4 -4
  40. package/dist/{records-BAymyHAn.js → records-GtiVMlTQ.js} +198 -62
  41. package/dist/records-GtiVMlTQ.js.map +1 -0
  42. package/dist/{runtime-CjfAxufu.js → runtime-aZPUPxoB.js} +40 -19
  43. package/dist/runtime-aZPUPxoB.js.map +1 -0
  44. package/dist/runtime.js +6 -6
  45. package/dist/{shell-XDqDu_UD.js → shell-Bk9mD4Lc.js} +5 -5
  46. package/dist/{shell-XDqDu_UD.js.map → shell-Bk9mD4Lc.js.map} +1 -1
  47. package/dist/shell.js +5 -5
  48. package/dist/styles.css +138 -13
  49. package/dist/types/components.d.ts +22 -2
  50. package/dist/types/index.d.ts +1 -1
  51. package/dist/{useDropdownState-uFIz6Yv8.js → useDropdownState-CcBzUYZ6.js} +14 -5
  52. package/dist/useDropdownState-CcBzUYZ6.js.map +1 -0
  53. package/dist/{useExperimentSamples-DEDAKa2E.js → useExperimentSamples-CpNXvpUn.js} +3 -3
  54. package/dist/{useExperimentSamples-DEDAKa2E.js.map → useExperimentSamples-CpNXvpUn.js.map} +1 -1
  55. package/dist/{useListReorder-W1bzK_bo.js → useListReorder-Bp4EAI2w.js} +3 -3
  56. package/dist/{useListReorder-W1bzK_bo.js.map → useListReorder-Bp4EAI2w.js.map} +1 -1
  57. package/dist/{useRackEditor-Bmtuo0aj.js → useRackEditor-i2bdPUFf.js} +8 -8
  58. package/dist/{useRackEditor-Bmtuo0aj.js.map → useRackEditor-i2bdPUFf.js.map} +1 -1
  59. package/dist/{useRealtime-jOetF2mM.js → useRealtime-BLQEjbuq.js} +3 -3
  60. package/dist/{useRealtime-jOetF2mM.js.map → useRealtime-BLQEjbuq.js.map} +1 -1
  61. package/dist/useRequest-CXyPw3eh.js +76 -0
  62. package/dist/useRequest-CXyPw3eh.js.map +1 -0
  63. package/dist/utils/deprecation.d.ts +2 -0
  64. package/dist/utils/lucideIcon.d.ts +4 -0
  65. package/package.json +1 -1
  66. package/src/__tests__/components/AppTopBar.test.ts +102 -20
  67. package/src/__tests__/components/AppTopBarCompact.test.ts +65 -0
  68. package/src/__tests__/components/AppTopBarCompactReset.test.ts +24 -0
  69. package/src/__tests__/components/AutoGroupModal.experimentLoad.test.ts +58 -0
  70. package/src/__tests__/components/BaseModal.test.ts +33 -2
  71. package/src/__tests__/components/PluginWorkspaceView.test.ts +20 -1
  72. package/src/__tests__/composables/useJobCenter.test.ts +35 -0
  73. package/src/__tests__/composables/useVersionedSave.test.ts +71 -6
  74. package/src/__tests__/styles/menusShellRefresh.test.ts +10 -0
  75. package/src/components/AppLayout.story.vue +5 -5
  76. package/src/components/AppLayout.vue +13 -1
  77. package/src/components/AppTopBar.compact.ts +60 -0
  78. package/src/components/AppTopBar.story.vue +39 -14
  79. package/src/components/AppTopBar.vue +222 -89
  80. package/src/components/BaseModal.vue +16 -5
  81. package/src/components/ExperimentPopover.vue +1 -1
  82. package/src/components/GeneratedPluginView.vue +0 -1
  83. package/src/components/PluginWorkspaceView.vue +10 -1
  84. package/src/components/internal/AppTopBarPageSelectorInternal.vue +12 -0
  85. package/src/components/internal/AutoGroupModalInternal.vue +14 -3
  86. package/src/components/internal/LucideIconInternal.vue +23 -0
  87. package/src/composables/appLayoutSidebar.ts +11 -0
  88. package/src/composables/useJobCenter.ts +21 -1
  89. package/src/composables/usePluginWorkspace.ts +15 -4
  90. package/src/composables/useVersionedSave.ts +32 -7
  91. package/src/styles/components/app-layout.css +5 -0
  92. package/src/styles/components/app-top-bar.css +130 -9
  93. package/src/types/components.ts +23 -2
  94. package/src/types/index.ts +1 -0
  95. package/src/utils/deprecation.ts +8 -0
  96. package/src/utils/lucideIcon.ts +25 -0
  97. package/src/utils/lucideIconIndex.d.ts +2 -0
  98. package/dist/AppSidebar-C4qQLHcH.js.map +0 -1
  99. package/dist/ExperimentPopover-DyjoXwry.js.map +0 -1
  100. package/dist/lab-BcX5JBSY.js.map +0 -1
  101. package/dist/records-BAymyHAn.js.map +0 -1
  102. package/dist/runtime-CjfAxufu.js.map +0 -1
  103. package/dist/useDropdownState-uFIz6Yv8.js.map +0 -1
  104. package/dist/useRequest-D9-3BS-3.js +0 -215
  105. package/dist/useRequest-D9-3BS-3.js.map +0 -1
@@ -10,6 +10,7 @@ import type {
10
10
  PillNavItem,
11
11
  PageSelectorItem,
12
12
  PluginInfo,
13
+ TopBarAction,
13
14
  TopBarJob,
14
15
  } from '../types'
15
16
 
@@ -87,6 +88,17 @@ const samplePluginWithSubtitle: PluginInfo = {
87
88
  api_prefix: '/api/ms-planner',
88
89
  }
89
90
 
91
+ // Page icons are Lucide names; plugin buttons come from spec.actions (two show, the rest in More).
92
+ const sampleIconPages: PageSelectorItem[] = [
93
+ { id: 'monitor', label: 'Monitor', icon: 'activity' },
94
+ { id: 'planner', label: 'Planner', icon: 'calendar-days' },
95
+ { id: 'queue', label: 'Queue', icon: 'list-ordered' },
96
+ ]
97
+ const sampleActions: TopBarAction[] = [
98
+ { id: 'status', label: 'Database ready', icon: 'database', dot: 'success', onSelect: () => {} },
99
+ { id: 'export', label: 'Export', icon: 'download', onSelect: () => {} },
100
+ { id: 'reindex', label: 'Reindex', onSelect: () => {} },
101
+ ]
90
102
  // The seed is module state: clear it when the variant unmounts.
91
103
  function seedIdentity({ app }: { app: App }) {
92
104
  providePluginIdentity({
@@ -109,7 +121,6 @@ function seedIdentity({ app }: { app: App }) {
109
121
  <AppTopBar
110
122
  :spec="{
111
123
  pills: samplePillNav.slice(1, 3),
112
- themeToggle: true,
113
124
  homePath: '',
114
125
  standaloneLabel: false,
115
126
  }"
@@ -127,7 +138,7 @@ function seedIdentity({ app }: { app: App }) {
127
138
  <Variant title="Refresh · Workspace">
128
139
  <div style="padding: 2rem; background: var(--bg-primary); min-height: 22rem;">
129
140
  <AppTopBar
130
- :spec="{ pills: samplePillNav, themeToggle: true, homePath: '' }"
141
+ :spec="{ pills: samplePillNav, homePath: '' }"
131
142
  :page-selector="samplePageSelector"
132
143
  current-page-selector-id="workspace"
133
144
  pill="workspace"
@@ -139,7 +150,7 @@ function seedIdentity({ app }: { app: App }) {
139
150
  <div style="padding: 2rem; background: var(--bg-primary); min-height: 12rem;">
140
151
  <ExperimentProvider>
141
152
  <AppTopBar
142
- :spec="{ pills: samplePillNav, themeToggle: true, homePath: '' }"
153
+ :spec="{ pills: samplePillNav, homePath: '' }"
143
154
  :page-selector="samplePageSelector"
144
155
  current-page-selector-id="experiments"
145
156
  pill="experiments"
@@ -152,7 +163,7 @@ function seedIdentity({ app }: { app: App }) {
152
163
  <div style="padding: 2rem; background: var(--bg-primary); min-height: 12rem;">
153
164
  <ExperimentProvider :plugin="samplePlugin">
154
165
  <AppTopBar
155
- :spec="{ pills: samplePillNav, themeToggle: true, homePath: '' }"
166
+ :spec="{ pills: samplePillNav, homePath: '' }"
156
167
  :page-selector="samplePluginPages"
157
168
  current-page-selector-id="peaks"
158
169
  pill="experiments"
@@ -168,7 +179,7 @@ function seedIdentity({ app }: { app: App }) {
168
179
  <AppTopBar
169
180
  v-for="row in runSlotJobs"
170
181
  :key="row.label"
171
- :spec="{ themeToggle: true, homePath: '' }"
182
+ :spec="{ homePath: '' }"
172
183
  :page-selector="samplePluginPages"
173
184
  current-page-selector-id="peaks"
174
185
  :job="row.job"
@@ -193,7 +204,7 @@ function seedIdentity({ app }: { app: App }) {
193
204
  </div>
194
205
  <ExperimentProvider :plugin="samplePlugin" :save="false">
195
206
  <AppTopBar
196
- :spec="{ themeToggle: true, homePath: '' }"
207
+ :spec="{ homePath: '' }"
197
208
  :page-selector="samplePluginPages"
198
209
  current-page-selector-id="peaks"
199
210
  :job="runSlotJobs[liveRow].job"
@@ -205,7 +216,7 @@ function seedIdentity({ app }: { app: App }) {
205
216
  <Variant title="Refresh · Admin">
206
217
  <div style="padding: 2rem; background: var(--bg-primary); min-height: 12rem;">
207
218
  <AppTopBar
208
- :spec="{ pills: samplePillNav, themeToggle: true, homePath: '' }"
219
+ :spec="{ pills: samplePillNav, homePath: '' }"
209
220
  :page-selector="samplePageSelector"
210
221
  current-page-selector-id="admin"
211
222
  pill="admin"
@@ -217,7 +228,7 @@ function seedIdentity({ app }: { app: App }) {
217
228
  <div style="padding: 2rem; background: var(--bg-primary); min-height: 12rem;">
218
229
  <ExperimentProvider :preselect="false">
219
230
  <AppTopBar
220
- :spec="{ pills: samplePillNav, themeToggle: true, homePath: '' }"
231
+ :spec="{ pills: samplePillNav, homePath: '' }"
221
232
  :page-selector="samplePageSelector"
222
233
  current-page-selector-id="workspace"
223
234
  pill="workspace"
@@ -232,7 +243,6 @@ function seedIdentity({ app }: { app: App }) {
232
243
  <AppTopBar
233
244
  :spec="{
234
245
  pills: samplePillNav.slice(1, 3),
235
- themeToggle: true,
236
246
  homePath: '',
237
247
  standaloneLabel: false,
238
248
  }"
@@ -256,7 +266,6 @@ function seedIdentity({ app }: { app: App }) {
256
266
  :spec="{
257
267
  variant: state.variant,
258
268
  logo: state.showLogo,
259
- themeToggle: state.showThemeToggle,
260
269
  homePath: state.homePath,
261
270
  }"
262
271
  :title="state.title"
@@ -274,7 +283,6 @@ function seedIdentity({ app }: { app: App }) {
274
283
  :options="variants.map(v => ({ label: v, value: v }))"
275
284
  />
276
285
  <HstCheckbox v-model="state.showLogo" title="Show Logo" />
277
- <HstCheckbox v-model="state.showThemeToggle" title="Show Theme Toggle" />
278
286
  <HstText v-model="state.homePath" title="Home Path" />
279
287
  </template>
280
288
  </Variant>
@@ -282,7 +290,7 @@ function seedIdentity({ app }: { app: App }) {
282
290
  <Variant title="Base · Title + Subtitle">
283
291
  <div style="padding: 2rem;">
284
292
  <AppTopBar
285
- :spec="{ themeToggle: true }"
293
+ :spec="{}"
286
294
  title="Dose-Response Analysis"
287
295
  subtitle="Experiment EXP-2024-001"
288
296
  />
@@ -306,7 +314,7 @@ function seedIdentity({ app }: { app: App }) {
306
314
  <Variant title="Navigation · Page selector">
307
315
  <div style="padding: 2rem;">
308
316
  <AppTopBar
309
- :spec="{ themeToggle: true, homePath: '' }"
317
+ :spec="{ homePath: '' }"
310
318
  title="Results"
311
319
  :page-selector="samplePageSelector"
312
320
  current-page-selector-id="plugins"
@@ -354,11 +362,28 @@ function seedIdentity({ app }: { app: App }) {
354
362
  <div style="padding: 2rem;">
355
363
  <ExperimentProvider>
356
364
  <AppTopBar
357
- :spec="{ themeToggle: true, homePath: '' }"
365
+ :spec="{ homePath: '' }"
358
366
  title="Analysis"
359
367
  />
360
368
  </ExperimentProvider>
361
369
  </div>
362
370
  </Variant>
371
+
372
+ <Variant title="Layout · Fixed tail and two rows">
373
+ <!-- The theme and settings buttons always close the bar. Below 1024px of bar width the
374
+ pills and the context group (experiment, plugin buttons) move to a second row. -->
375
+ <div style="padding: 2rem; background: var(--bg-primary); display: grid; gap: 1rem;">
376
+ <ExperimentProvider v-for="width in ['1360px', '1180px', '900px', '768px']" :key="width" :plugin="samplePlugin">
377
+ <div :style="{ width, maxWidth: '100%' }">
378
+ <AppTopBar
379
+ :spec="{ pills: samplePillNav.slice(0, 3), homePath: '', actions: sampleActions }"
380
+ :page-selector="sampleIconPages"
381
+ current-page-selector-id="monitor"
382
+ pill="workspace"
383
+ />
384
+ </div>
385
+ </ExperimentProvider>
386
+ </div>
387
+ </Variant>
363
388
  </Story>
364
389
  </template>
@@ -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
 
@@ -205,7 +205,7 @@ function handleDetach() {
205
205
  <LoaderCircle v-if="saveLoading" class="mint-experiment-popover__save-spinner" :size="14" aria-hidden="true" />
206
206
  <Check v-else-if="showSuccess" :size="14" aria-hidden="true" />
207
207
  <CloudUpload v-else :size="14" aria-hidden="true" />
208
- <span>{{ saveLoading ? 'Saving…' : saveLabel }}</span>
208
+ <span class="mint-experiment-popover__save-label">{{ saveLoading ? 'Saving…' : saveLabel }}</span>
209
209
  <span v-if="dirty && !saveLoading" class="mint-experiment-popover__dirty" aria-hidden="true" />
210
210
  </button>
211
211
 
@@ -84,7 +84,6 @@ const workspace = computed(() => definePluginWorkspaceView({
84
84
  topBar: {
85
85
  variant: 'card',
86
86
  pills: pillNav.value,
87
- themeToggle: true,
88
87
  settings: false,
89
88
  standaloneLabel: props.showStandaloneLabel ? 'Standalone' : false,
90
89
  },