@morscherlab/mint-sdk 1.5.0-beta.6 → 1.5.0-beta.8

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 (106) 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-FxlF5_dI.js → AppSidebar-C1DT4nsZ.js} +351 -90
  4. package/dist/AppSidebar-C1DT4nsZ.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-Bbd6RaoH.js → ExperimentPopover-DYy_s0kI.js} +25 -37
  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-BIC0dAYc.js → SettingsModal-DPNblTe9.js} +7 -7
  14. package/dist/{SettingsModal-BIC0dAYc.js.map → SettingsModal-DPNblTe9.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/useVersionedSave.d.ts +5 -2
  25. package/dist/{controlSchemaModel-C_XqpkgE.js → controlSchemaModel-BtFNZj8t.js} +2 -2
  26. package/dist/{controlSchemaModel-C_XqpkgE.js.map → controlSchemaModel-BtFNZj8t.js.map} +1 -1
  27. package/dist/{data-CfY7o8QC.js → data-CJztFns8.js} +31 -15
  28. package/dist/{data-CfY7o8QC.js.map → data-CJztFns8.js.map} +1 -1
  29. package/dist/data.js +8 -8
  30. package/dist/{experimentStatus-DCn2QKG7.js → experimentStatus-Deokk7Br.js} +2 -2
  31. package/dist/{experimentStatus-DCn2QKG7.js.map → experimentStatus-Deokk7Br.js.map} +1 -1
  32. package/dist/index.js +14 -14
  33. package/dist/{install-QF3Sh82O.js → install-9FkKs0iW.js} +14 -15
  34. package/dist/{install-QF3Sh82O.js.map → install-9FkKs0iW.js.map} +1 -1
  35. package/dist/install.js +1 -1
  36. package/dist/{lab-BcX5JBSY.js → lab-BYhb_cJI.js} +22 -14
  37. package/dist/lab-BYhb_cJI.js.map +1 -0
  38. package/dist/lab.js +4 -4
  39. package/dist/{records-BAymyHAn.js → records-GtiVMlTQ.js} +198 -62
  40. package/dist/records-GtiVMlTQ.js.map +1 -0
  41. package/dist/{runtime-DngeJtdq.js → runtime-DcMsvlaS.js} +29 -12
  42. package/dist/runtime-DcMsvlaS.js.map +1 -0
  43. package/dist/runtime.js +6 -6
  44. package/dist/{shell-CWZi97nW.js → shell-XZYkAZS_.js} +5 -5
  45. package/dist/{shell-CWZi97nW.js.map → shell-XZYkAZS_.js.map} +1 -1
  46. package/dist/shell.js +5 -5
  47. package/dist/styles.css +136 -12
  48. package/dist/types/components.d.ts +22 -2
  49. package/dist/types/index.d.ts +1 -1
  50. package/dist/{useDropdownState-uFIz6Yv8.js → useDropdownState-CcBzUYZ6.js} +14 -5
  51. package/dist/useDropdownState-CcBzUYZ6.js.map +1 -0
  52. package/dist/{useExperimentSamples-DEDAKa2E.js → useExperimentSamples-CpNXvpUn.js} +3 -3
  53. package/dist/{useExperimentSamples-DEDAKa2E.js.map → useExperimentSamples-CpNXvpUn.js.map} +1 -1
  54. package/dist/{useListReorder-W1bzK_bo.js → useListReorder-Bp4EAI2w.js} +3 -3
  55. package/dist/{useListReorder-W1bzK_bo.js.map → useListReorder-Bp4EAI2w.js.map} +1 -1
  56. package/dist/{useRackEditor-Bmtuo0aj.js → useRackEditor-i2bdPUFf.js} +8 -8
  57. package/dist/{useRackEditor-Bmtuo0aj.js.map → useRackEditor-i2bdPUFf.js.map} +1 -1
  58. package/dist/{useRealtime-jOetF2mM.js → useRealtime-BLQEjbuq.js} +3 -3
  59. package/dist/{useRealtime-jOetF2mM.js.map → useRealtime-BLQEjbuq.js.map} +1 -1
  60. package/dist/useRequest-CXyPw3eh.js +76 -0
  61. package/dist/useRequest-CXyPw3eh.js.map +1 -0
  62. package/dist/utils/deprecation.d.ts +2 -0
  63. package/dist/utils/lucideIcon.d.ts +4 -0
  64. package/package.json +1 -1
  65. package/src/__tests__/components/AppTopBar.test.ts +102 -20
  66. package/src/__tests__/components/AppTopBarCompact.test.ts +65 -0
  67. package/src/__tests__/components/AppTopBarCompactReset.test.ts +24 -0
  68. package/src/__tests__/components/AutoGroupModal.experimentLoad.test.ts +58 -0
  69. package/src/__tests__/components/BaseModal.test.ts +33 -2
  70. package/src/__tests__/components/ExperimentPopover.test.ts +32 -0
  71. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -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 +5 -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.story.vue +2 -2
  82. package/src/components/ExperimentPopover.vue +6 -22
  83. package/src/components/GeneratedPluginView.vue +0 -1
  84. package/src/components/PluginWorkspaceView.vue +8 -0
  85. package/src/components/internal/AppTopBarPageSelectorInternal.vue +12 -0
  86. package/src/components/internal/AutoGroupModalInternal.vue +14 -3
  87. package/src/components/internal/LucideIconInternal.vue +23 -0
  88. package/src/composables/appLayoutSidebar.ts +11 -0
  89. package/src/composables/useJobCenter.ts +21 -1
  90. package/src/composables/usePluginWorkspace.ts +1 -1
  91. package/src/composables/useVersionedSave.ts +32 -7
  92. package/src/styles/components/app-layout.css +5 -0
  93. package/src/styles/components/app-top-bar.css +128 -8
  94. package/src/types/components.ts +23 -2
  95. package/src/types/index.ts +1 -0
  96. package/src/utils/deprecation.ts +8 -0
  97. package/src/utils/lucideIcon.ts +25 -0
  98. package/src/utils/lucideIconIndex.d.ts +2 -0
  99. package/dist/AppSidebar-FxlF5_dI.js.map +0 -1
  100. package/dist/ExperimentPopover-Bbd6RaoH.js.map +0 -1
  101. package/dist/lab-BcX5JBSY.js.map +0 -1
  102. package/dist/records-BAymyHAn.js.map +0 -1
  103. package/dist/runtime-DngeJtdq.js.map +0 -1
  104. package/dist/useDropdownState-uFIz6Yv8.js.map +0 -1
  105. package/dist/useRequest-D9-3BS-3.js +0 -215
  106. package/dist/useRequest-D9-3BS-3.js.map +0 -1
@@ -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
 
@@ -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
  },
@@ -24,6 +24,7 @@ import type { JobState } from '../types/jobs'
24
24
  import { useAppExperiment } from '../composables/useAppExperiment'
25
25
  import { useJobCenter } from '../composables/useJobCenter'
26
26
  import { usePluginWorkspace } from '../composables/usePluginWorkspace'
27
+ import { warnDeprecatedOnce } from '../utils/deprecation'
27
28
  import AppToastContainer from './AppToastContainer.vue'
28
29
  import JobsStatusTray from './JobsStatusTray.vue'
29
30
  import WorkspaceControlsInternal from './internal/WorkspaceControlsInternal.vue'
@@ -51,12 +52,16 @@ const emit = defineEmits<PluginWorkspaceViewEmits>()
51
52
 
52
53
  defineSlots<{
53
54
  default?: (props: PluginWorkspaceSlotProps) => unknown
55
+ /** @deprecated Configure the bar with `spec.topBar` and page icons; removed in 2.0. */
54
56
  topbar?: (props: PluginWorkspaceSlotProps) => unknown
55
57
  sidebar?: (props: PluginWorkspaceSlotProps) => unknown
56
58
  icon?: (props: PluginWorkspaceForwardedSlotProps) => unknown
57
59
  logo?: (props: PluginWorkspaceForwardedSlotProps) => unknown
60
+ /** @deprecated Removed in 2.0. */
58
61
  nav?: (props: PluginWorkspaceForwardedSlotProps) => unknown
62
+ /** @deprecated Removed in 2.0. */
59
63
  center?: (props: PluginWorkspaceForwardedSlotProps) => unknown
64
+ /** @deprecated Use `spec.topBar.actions`; removed in 2.0. */
60
65
  actions?: (props: PluginWorkspaceForwardedSlotProps) => unknown
61
66
  'settings-appearance'?: (props: PluginWorkspaceForwardedSlotProps) => unknown
62
67
  header?: (props: PluginWorkspaceForwardedSlotProps) => unknown
@@ -66,6 +71,9 @@ defineSlots<{
66
71
  }>()
67
72
 
68
73
  const slots = useSlots()
74
+ if (slots.topbar) {
75
+ warnDeprecatedOnce('workspace.slot.topbar', 'PluginWorkspaceView #topbar is deprecated: configure spec.topBar and give pages icons. It is removed in 2.0.')
76
+ }
69
77
 
70
78
  // The experiment chip is setup-time structure, like the rest of the spec.
71
79
  if (props.spec.experiment) {
@@ -6,7 +6,9 @@ import { useMenuKeyboard } from '../../composables/useMenuKeyboard'
6
6
  import type { PageSelectorItem, PageSelectorItemInput } from '../../types/components'
7
7
  import { normalizeItemInput } from '../../utils/items'
8
8
  import { isPluginIconFormat } from '../../utils/pluginIcon'
9
+ import { isLucideIconName } from '../../utils/lucideIcon'
9
10
  import ActionItemInternal from './ActionItemInternal.vue'
11
+ import LucideIconInternal from './LucideIconInternal.vue'
10
12
  import PluginIcon from '../PluginIcon.vue'
11
13
 
12
14
  interface Props {
@@ -123,6 +125,11 @@ function handleSelect(page: PageSelectorItem) {
123
125
  size="sm"
124
126
  variant="solid"
125
127
  />
128
+ <LucideIconInternal v-else-if="isLucideIconName(current.icon)" :name="current.icon">
129
+ <template #fallback>
130
+ <span class="mint-page-selector__icon-fallback">{{ current.label.charAt(0) }}</span>
131
+ </template>
132
+ </LucideIconInternal>
126
133
  <span v-else class="mint-page-selector__icon-fallback">{{ current.label.charAt(0) }}</span>
127
134
  </slot>
128
135
  </span>
@@ -180,6 +187,11 @@ function handleSelect(page: PageSelectorItem) {
180
187
  size="sm"
181
188
  variant="solid"
182
189
  />
190
+ <LucideIconInternal v-else-if="isLucideIconName(page.icon)" :name="page.icon">
191
+ <template #fallback>
192
+ <span class="mint-page-selector__icon-fallback">{{ page.label.charAt(0) }}</span>
193
+ </template>
194
+ </LucideIconInternal>
183
195
  <span v-else class="mint-page-selector__icon-fallback">{{ page.label.charAt(0) }}</span>
184
196
  </slot>
185
197
  </span>
@@ -209,8 +209,12 @@ function close() {
209
209
  emit('update:open', false)
210
210
  }
211
211
 
212
+ // A close or a newer fetch makes the response of an earlier fetch obsolete.
213
+ let fetchGeneration = 0
214
+
212
215
  async function fetchExperimentData() {
213
216
  if (!props.experimentId && !props.designData) return
217
+ const generation = ++fetchGeneration
214
218
  autoGroup.inputMode.value = 'experiment'
215
219
  experimentError.value = null
216
220
  experimentLoading.value = true
@@ -221,6 +225,7 @@ async function fetchExperimentData() {
221
225
  } else {
222
226
  const result = await api.get<Record<string, unknown>>(`/experiments/${props.experimentId}/data`)
223
227
  pluginData = result.data as Record<string, unknown> | undefined
228
+ if (generation !== fetchGeneration) return
224
229
  }
225
230
  if (!pluginData || typeof pluginData !== 'object') {
226
231
  experimentError.value = 'No design data found for this experiment'
@@ -232,9 +237,11 @@ async function fetchExperimentData() {
232
237
  }
233
238
  setQcRouting(qcRouting.value)
234
239
  } catch (e) {
235
- experimentError.value = e instanceof Error ? e.message : 'Failed to fetch experiment data'
240
+ if (generation === fetchGeneration) {
241
+ experimentError.value = e instanceof Error ? e.message : 'Failed to fetch experiment data'
242
+ }
236
243
  } finally {
237
- experimentLoading.value = false
244
+ if (generation === fetchGeneration) experimentLoading.value = false
238
245
  }
239
246
  }
240
247
 
@@ -244,7 +251,11 @@ async function fetchExperimentData() {
244
251
  watch(
245
252
  () => props.open,
246
253
  (open) => {
247
- if (!open) return
254
+ if (!open) {
255
+ fetchGeneration += 1
256
+ experimentLoading.value = false
257
+ return
258
+ }
248
259
  activeWorkflow.value = props.initialMode
249
260
  experimentError.value = null
250
261
  qcAsOverlay.value = true
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ /** Internal: renders a Lucide icon by name; shows `fallback` until it loads or when the name is unknown. */
3
+ import { shallowRef, watchEffect, type Component } from 'vue'
4
+ import { loadLucideIcon } from '../../utils/lucideIcon'
5
+
6
+ const props = defineProps<{
7
+ name: string
8
+ size?: number
9
+ }>()
10
+
11
+ const icon = shallowRef<Component>()
12
+ watchEffect(async (onCleanup) => {
13
+ let stale = false
14
+ onCleanup(() => { stale = true })
15
+ const loaded = await loadLucideIcon(props.name)
16
+ if (!stale) icon.value = loaded
17
+ })
18
+ </script>
19
+
20
+ <template>
21
+ <component :is="icon" v-if="icon" :size="size ?? 14" aria-hidden="true" />
22
+ <slot v-else name="fallback" />
23
+ </template>
@@ -0,0 +1,11 @@
1
+ import type { ComputedRef, InjectionKey } from 'vue'
2
+
3
+ /** The responsive sidebar drawer of an AppLayout; its AppTopBar renders the drawer button. */
4
+ export interface AppLayoutSidebarDrawer {
5
+ open: ComputedRef<boolean>
6
+ toggle: () => void
7
+ openLabel: ComputedRef<string>
8
+ closeLabel: ComputedRef<string>
9
+ }
10
+
11
+ export const APP_LAYOUT_SIDEBAR_KEY: InjectionKey<AppLayoutSidebarDrawer> = Symbol('mint-app-layout-sidebar')
@@ -298,6 +298,12 @@ export function useJobCenter(
298
298
  watch([source, ownActiveJobs], ([currentSource, now], [previousSource, before]) => {
299
299
  if (currentSource !== previousSource) {
300
300
  justFinished.value = null
301
+ // The failures a replacement source already lists are its baseline.
302
+ if (previousSource) {
303
+ hasBaseline = false
304
+ awaitingLoad = false
305
+ if (jobs.value.length > 0) takeBaseline()
306
+ }
301
307
  return
302
308
  }
303
309
  const nowIds = new Set(now.map(job => job.job_id))
@@ -405,6 +411,16 @@ export function useJobCenter(
405
411
  }
406
412
  }
407
413
 
414
+ // A cancelled job stays pending until the source reports another status.
415
+ const cancelling = new Map<string, JobStatus>()
416
+ watch(jobs, (list) => {
417
+ for (const [jobId, status] of cancelling) {
418
+ if (list.find(job => job.job_id === jobId)?.status === status) continue
419
+ cancelling.delete(jobId)
420
+ setPending(jobId, false)
421
+ }
422
+ })
423
+
408
424
  async function cancelJob(job: JobState): Promise<boolean> {
409
425
  const sourceCancel = source.value?.cancel
410
426
  if (!job.can_cancel || !sourceCancel || pendingJobIds.value.has(job.job_id)) {
@@ -412,14 +428,18 @@ export function useJobCenter(
412
428
  }
413
429
  setPending(job.job_id, true)
414
430
  error.value = null
431
+ let cancelled = false
415
432
  try {
416
433
  await sourceCancel(job.job_id)
434
+ cancelled = true
417
435
  return active
418
436
  } catch (cause) {
419
437
  if (active) error.value = errorMessage(cause, 'Failed to cancel job')
420
438
  return false
421
439
  } finally {
422
- if (active) setPending(job.job_id, false)
440
+ const status = jobs.value.find(listed => listed.job_id === job.job_id)?.status
441
+ if (cancelled && status === job.status) cancelling.set(job.job_id, status)
442
+ else if (active) setPending(job.job_id, false)
423
443
  }
424
444
  }
425
445
 
@@ -69,7 +69,7 @@ export function usePluginWorkspace(options: UsePluginWorkspaceOptions): UsePlugi
69
69
  const sidebarSpec = computed<false | PluginWorkspaceSidebarSpec>(() => props.spec?.sidebar ?? {})
70
70
  const topBarSpec = computed(() => props.spec?.topBar === false
71
71
  ? false
72
- : applySpecDefaults({ themeToggle: true, settings: {}, standaloneLabel: false }, props.spec?.topBar))
72
+ : applySpecDefaults({ settings: {}, standaloneLabel: false }, props.spec?.topBar))
73
73
 
74
74
  const controlsState = usePluginWorkspaceControls({
75
75
  model: () => sidebarSpec.value === false ? undefined : sidebarSpec.value.controls,
@@ -26,8 +26,11 @@ export interface UseVersionedSaveOptions<T> {
26
26
  /**
27
27
  * Refetch the entity and reset the form to it. The user's edits are discarded.
28
28
  * Reject when no fresh record arrives: the form then keeps the edits and the conflict.
29
+ * `auto` is true for the refetch after another user's write on a clean form: when
30
+ * the form holds edits by the time the record arrives, skip the reset (the stale
31
+ * banner then shows the write).
29
32
  */
30
- reload: () => unknown
33
+ reload: (auto?: boolean) => unknown
31
34
  /**
32
35
  * The realtime topic of the open entity (`experiment:42`). `null` turns the
33
36
  * stale banner off: pass it while the form is closed or the platform's
@@ -43,7 +46,7 @@ export interface UseVersionedSaveOptions<T> {
43
46
  }
44
47
 
45
48
  export interface UseVersionedSaveReturn<T> {
46
- /** Save with `If-Match`. Resolves `undefined` on a 412: `conflict` is then set. Other errors reject. */
49
+ /** Save with `If-Match`. Resolves `undefined` on a 412 (`conflict` set, `stale` cleared) and, unsent, while a save runs. Other errors reject. */
47
50
  save: () => Promise<T | undefined>
48
51
  /** Save again on top of the version the 412 reported. Resolves `undefined` on another 412. */
49
52
  overwrite: () => Promise<T | undefined>
@@ -99,8 +102,11 @@ export function useVersionedSave<T>(options: UseVersionedSaveOptions<T>): UseVer
99
102
  const conflict = ref<VersionConflict | null>(null)
100
103
  const stale = ref<StaleChange | null>(null)
101
104
  const fieldLabel = options.fieldLabel ?? fieldNameLabel
105
+ // A newer reload or another topic makes the result of an automatic reload obsolete.
106
+ let reloadToken = 0
102
107
 
103
108
  async function send(version: number | null | undefined): Promise<T | undefined> {
109
+ if (saving.value) return undefined
104
110
  saving.value = true
105
111
  try {
106
112
  const headers: Record<string, string> = version == null ? {} : { 'If-Match': `"v${version}"` }
@@ -111,7 +117,9 @@ export function useVersionedSave<T>(options: UseVersionedSaveOptions<T>): UseVer
111
117
  } catch (cause) {
112
118
  const refused = conflictVersion(cause)
113
119
  if (!refused) throw cause
120
+ // One banner: the conflict dialog replaces the stale banner.
114
121
  conflict.value = refused
122
+ stale.value = null
115
123
  return undefined
116
124
  } finally {
117
125
  saving.value = false
@@ -119,19 +127,35 @@ export function useVersionedSave<T>(options: UseVersionedSaveOptions<T>): UseVer
119
127
  }
120
128
 
121
129
  async function reload(): Promise<void> {
130
+ reloadToken += 1
122
131
  await options.reload()
123
132
  conflict.value = null
124
133
  stale.value = null
125
134
  }
126
135
 
127
- function onEvent(event: RealtimeEvent): void {
128
- // A resync names no writer, and the user's own save echoes back while it runs.
129
- if (event.type === 'resync' || saving.value || event.topic !== toValue(options.topic)) return
130
- if (!toValue(options.dirty)) {
136
+ async function autoReload(event: RealtimeEvent): Promise<void> {
137
+ const token = ++reloadToken
138
+ try {
139
+ await options.reload(true)
140
+ } catch {
131
141
  // A failed background refetch leaves a clean form as it is; the host reports its own faults.
132
- reload().catch(() => {})
133
142
  return
134
143
  }
144
+ if (token !== reloadToken || conflict.value) return
145
+ // The user started editing during the refetch: the host kept the edits, so show the write.
146
+ if (toValue(options.dirty)) markStale(event)
147
+ else stale.value = null
148
+ }
149
+
150
+ function onEvent(event: RealtimeEvent): void {
151
+ // A resync names no writer, the user's own save echoes back while it runs,
152
+ // and an open conflict dialog already says that the record changed.
153
+ if (event.type === 'resync' || saving.value || conflict.value || event.topic !== toValue(options.topic)) return
154
+ if (!toValue(options.dirty)) void autoReload(event)
155
+ else markStale(event)
156
+ }
157
+
158
+ function markStale(event: RealtimeEvent): void {
135
159
  const actor = (event.actor_id && options.userName?.(event.actor_id)) || 'Someone'
136
160
  const previous = stale.value
137
161
  stale.value = {
@@ -147,6 +171,7 @@ export function useVersionedSave<T>(options: UseVersionedSaveOptions<T>): UseVer
147
171
  }, onEvent)
148
172
  // Another entity, or a closed form: the old writes and the old 412 no longer apply.
149
173
  watch(() => toValue(options.topic), () => {
174
+ reloadToken += 1
150
175
  conflict.value = null
151
176
  stale.value = null
152
177
  })
@@ -182,3 +182,8 @@
182
182
  transition: none;
183
183
  }
184
184
  }
185
+
186
+ /* An AppTopBar in the layout renders the drawer button itself. */
187
+ .mint-layout--responsive-sidebar:has(.mint-topbar__sidebar-toggle) .mint-layout__sidebar-toggle {
188
+ display: none;
189
+ }
@@ -12,6 +12,8 @@
12
12
  padding: 0.75rem 1rem;
13
13
  background-color: var(--bg-secondary);
14
14
  box-sizing: border-box;
15
+ /* The two-row layout follows the bar's own width, not the window's. */
16
+ container: mint-topbar / inline-size;
15
17
  }
16
18
 
17
19
  /* Main container layout — equal side columns keep the centre nav on the bar
@@ -72,11 +74,62 @@
72
74
  grid-column: 3;
73
75
  display: flex;
74
76
  align-items: center;
75
- gap: 0.125rem;
77
+ gap: 0.375rem;
76
78
  min-width: 0;
77
79
  justify-content: flex-end;
78
80
  }
79
81
 
82
+ /* Context group (standalone badge, run slot, onboarding, plugin buttons), then
83
+ the fixed tail: theme, settings and the host's account controls, always last. */
84
+ .mint-topbar__context,
85
+ .mint-topbar__tail {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 0.125rem;
89
+ min-width: 0;
90
+ }
91
+
92
+ .mint-topbar__tail {
93
+ flex-shrink: 0;
94
+ }
95
+
96
+ /* The deprecated #actions slot keeps a fixed width, so its content cannot push the bar. */
97
+ .mint-topbar__legacy-actions {
98
+ display: inline-flex;
99
+ align-items: center;
100
+ max-width: 16rem;
101
+ overflow: hidden;
102
+ }
103
+
104
+ /* Below 1024px of bar width: row 1 is identity + tail, row 2 the pills + context.
105
+ Row 1 never holds more than the identity, so the tail never moves. */
106
+ @container mint-topbar (max-width: 1023.98px) {
107
+ .mint-topbar__container--stackable {
108
+ /* The pills keep their width while it fits; the context group takes the rest and its experiment
109
+ name truncates. On a bar narrower than the pills they overflow their cell, never the tail. */
110
+ grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto;
111
+ grid-template-areas:
112
+ "start start tail"
113
+ "center context context";
114
+ row-gap: 0.375rem;
115
+ }
116
+
117
+ .mint-topbar__container--stackable .mint-topbar__start { grid-area: start; }
118
+ .mint-topbar__container--stackable .mint-topbar__right { display: contents; }
119
+ .mint-topbar__container--stackable .mint-topbar__tail { grid-area: tail; }
120
+
121
+ .mint-topbar__container--stackable .mint-topbar__center {
122
+ grid-area: center;
123
+ justify-content: flex-start;
124
+ }
125
+
126
+ .mint-topbar__container--stackable .mint-topbar__context {
127
+ /* Stretch, not justify-self: end, which would size the group to its nowrap min-content. */
128
+ grid-area: context;
129
+ justify-content: flex-end;
130
+ }
131
+ }
132
+
80
133
  /* Logo styles */
81
134
  .mint-topbar__logo {
82
135
  display: flex;
@@ -176,11 +229,13 @@
176
229
  white-space: nowrap;
177
230
  }
178
231
 
179
- /* The settings button has the IconButton ghost look: 32px, --radius-md,
180
- 16px icon, neutral ink, offset focus ring. */
181
- .mint-topbar__settings-btn {
232
+ /* The settings button, plugin icon buttons and the drawer button have the
233
+ IconButton ghost look: 32px, --radius-md, 16px icon, neutral ink, offset focus ring. */
234
+ .mint-topbar__settings-btn,
235
+ .mint-topbar__icon-btn {
182
236
  position: relative;
183
237
  display: inline-flex;
238
+ flex-shrink: 0;
184
239
  align-items: center;
185
240
  justify-content: center;
186
241
  width: 2rem;
@@ -195,23 +250,62 @@
195
250
  transition: background-color var(--mint-transition), color var(--mint-transition);
196
251
  }
197
252
 
198
- .mint-topbar__settings-btn:hover {
253
+ .mint-topbar__settings-btn:hover,
254
+ .mint-topbar__icon-btn:hover:not(:disabled) {
199
255
  background: var(--bg-hover);
200
256
  color: var(--text-primary);
201
257
  }
202
258
 
203
- .mint-topbar__settings-btn:focus-visible {
259
+ .mint-topbar__settings-btn:focus-visible,
260
+ .mint-topbar__icon-btn:focus-visible {
204
261
  outline: none;
205
262
  box-shadow: var(--focus-ring-offset);
206
263
  }
207
264
 
208
- .mint-topbar__settings-icon {
265
+ .mint-topbar__icon-btn:disabled {
266
+ cursor: not-allowed;
267
+ opacity: 0.5;
268
+ }
269
+
270
+ .mint-topbar__settings-icon,
271
+ .mint-topbar__icon-btn-icon {
209
272
  width: 1rem;
210
273
  height: 1rem;
211
274
  }
212
275
 
276
+ /* Status dot of a plugin icon button (TopBarAction.dot). */
277
+ .mint-topbar__icon-btn-dot {
278
+ position: absolute;
279
+ top: 0.3125rem;
280
+ right: 0.3125rem;
281
+ width: 0.4375rem;
282
+ height: 0.4375rem;
283
+ border-radius: 50%;
284
+ box-shadow: 0 0 0 2px var(--bg-secondary);
285
+ background: var(--text-muted);
286
+ }
287
+
288
+ .mint-topbar__icon-btn-dot--primary,
289
+ .mint-topbar__icon-btn-dot--cta { background: var(--color-primary); }
290
+ .mint-topbar__icon-btn-dot--success { background: var(--mint-success); }
291
+ .mint-topbar__icon-btn-dot--warning { background: var(--mint-warning); }
292
+ .mint-topbar__icon-btn-dot--error { background: var(--mint-error); }
293
+ .mint-topbar__icon-btn-dot--info { background: var(--mint-info); }
294
+
295
+ /* The drawer button shows only while the AppLayout sidebar is a drawer. */
296
+ .mint-topbar__sidebar-toggle {
297
+ display: none;
298
+ }
299
+
300
+ @media (max-width: 1023.98px) {
301
+ .mint-topbar__sidebar-toggle {
302
+ display: inline-flex;
303
+ }
304
+ }
305
+
213
306
  @media (prefers-reduced-motion: reduce) {
214
- .mint-topbar__settings-btn {
307
+ .mint-topbar__settings-btn,
308
+ .mint-topbar__icon-btn {
215
309
  transition: none;
216
310
  }
217
311
  }
@@ -393,3 +487,29 @@
393
487
  clip-path: inset(50%);
394
488
  white-space: nowrap;
395
489
  }
490
+
491
+ /* ─── Labels or icons, by fit ───
492
+ Save and Jobs show their words while the content fits; AppTopBar.compact.ts
493
+ sets --compact when it does not (a name is cut, the experiment chip is
494
+ squeezed or a group overflows), and they keep only their icons. Plugin
495
+ buttons with an icon are always icon-only. Accessible names and tooltips
496
+ keep the words. */
497
+ .mint-topbar--compact .mint-experiment-popover__save {
498
+ width: 32px;
499
+ padding: 0;
500
+ justify-content: center;
501
+ }
502
+
503
+ .mint-topbar--compact .mint-experiment-popover__save-label,
504
+ .mint-topbar--compact .mint-topbar__job-label {
505
+ display: none;
506
+ }
507
+
508
+ .mint-topbar--compact .mint-topbar__job--idle {
509
+ padding-inline: 0.5rem;
510
+ }
511
+
512
+ /* One total width cap for the experiment chip, code and chevron included. */
513
+ .mint-topbar .mint-experiment-popover__trigger {
514
+ max-width: 16rem;
515
+ }
@@ -220,6 +220,7 @@ export type PillNavItemInput = (Omit<PillNavItem, 'children'> & {
220
220
  export interface PageSelectorItem {
221
221
  id: string
222
222
  label: string
223
+ /** A Lucide icon name (`'activity'`, `'flask-conical'`) or a plugin icon (path, data URL, `mint-icon` JSON). Without one the page shows its first letter. */
223
224
  icon?: string
224
225
  hint?: string
225
226
  to?: string
@@ -239,12 +240,32 @@ export interface PluginWorkspaceTopBarSpec {
239
240
  logo?: boolean
240
241
  /** Centered pill navigation for the local modes of the current route (bind `v-model:pill`). */
241
242
  pills?: PillNavItemInput[]
242
- /** Show the theme toggle. */
243
+ /** @deprecated Ignored: the theme toggle always shows. Removed in 2.0. */
243
244
  themeToggle?: boolean
244
- /** Settings button and modal; `false` hides the button. A getter can switch it per user. */
245
+ /**
246
+ * Settings modal of the always-present settings button. `false` (or a getter that returns it)
247
+ * gives the modal only its Appearance tab; it no longer hides the button.
248
+ */
245
249
  settings?: import('vue').MaybeRefOrGetter<false | TopBarSettingsConfig>
246
250
  /** Standalone badge text when the plugin runs outside the platform; `false` hides it. */
247
251
  standaloneLabel?: false | string
252
+ /** Plugin buttons, before the theme and settings buttons. The first two show; the rest go into a More menu. */
253
+ actions?: TopBarAction[]
254
+ }
255
+
256
+ /** One plugin button of the top bar. With `icon` it is an icon button and `label` is its accessible name and tooltip. */
257
+ export interface TopBarAction {
258
+ id: string
259
+ label: string
260
+ /** A Lucide icon name, such as `'database'`. */
261
+ icon?: string
262
+ /** A small status dot on the button. */
263
+ dot?: Tone
264
+ /** Tooltip; defaults to `label`. */
265
+ title?: string
266
+ loading?: boolean
267
+ disabled?: boolean
268
+ onSelect: () => void
248
269
  }
249
270
 
250
271
  /** Sidebar part of a PluginWorkspaceView spec; also the `:spec` of AppSidebar. */
@@ -31,6 +31,7 @@ export type {
31
31
  PageSelectorItem,
32
32
  PageSelectorItemInput,
33
33
  PluginWorkspaceTopBarSpec,
34
+ TopBarAction,
34
35
  PluginWorkspaceSidebarSpec,
35
36
  PluginWorkspaceViewSpec,
36
37
  // Well Plate types
@@ -0,0 +1,8 @@
1
+ const warned = new Set<string>()
2
+
3
+ /** Warn once per page load that a deprecated SDK option or slot is in use. */
4
+ export function warnDeprecatedOnce(key: string, message: string): void {
5
+ if (warned.has(key)) return
6
+ warned.add(key)
7
+ console.warn(`[mint-sdk] ${message}`)
8
+ }
@@ -0,0 +1,25 @@
1
+ /// <reference path="./lucideIconIndex.d.ts" />
2
+ import type { Component } from 'vue'
3
+
4
+ /** A Lucide icon name in kebab case, such as `activity` or `flask-conical`. */
5
+ const LUCIDE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
6
+
7
+ export function isLucideIconName(value?: string): value is string {
8
+ return !!value && LUCIDE_NAME.test(value)
9
+ }
10
+
11
+ function pascalCase(name: string): string {
12
+ return name.replace(/(^|-)([a-z0-9])/g, (_, __, char: string) => char.toUpperCase())
13
+ }
14
+
15
+ // ponytail: loads the whole Lucide set (~150 KB gzip) as one lazy chunk on first use;
16
+ // switch to a per-icon import map if page icons become common on first paint.
17
+ // The icon index, not the package root: the root is statically imported elsewhere,
18
+ // so a dynamic import of it would put the whole set in the startup bundle.
19
+ let iconSet: Promise<Record<string, Component>> | undefined
20
+
21
+ /** The Lucide component for `name`, or undefined when Lucide has no such icon. */
22
+ export async function loadLucideIcon(name: string): Promise<Component | undefined> {
23
+ iconSet ??= import('lucide-vue-next/dist/esm/icons/index.js') as Promise<Record<string, Component>>
24
+ return (await iconSet)[pascalCase(name)]
25
+ }
@@ -0,0 +1,2 @@
1
+ // The Lucide icon index ships without types; utils/lucideIcon.ts loads it on first use.
2
+ declare module 'lucide-vue-next/dist/esm/icons/index.js'