@morscherlab/mint-sdk 1.5.0-beta.7 → 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.
- package/dist/{AlertBox-CexONPhA.js → AlertBox-Cai0w7gF.js} +3 -3
- package/dist/{AlertBox-CexONPhA.js.map → AlertBox-Cai0w7gF.js.map} +1 -1
- package/dist/{AppSidebar-C4qQLHcH.js → AppSidebar-C1DT4nsZ.js} +351 -90
- package/dist/AppSidebar-C1DT4nsZ.js.map +1 -0
- package/dist/{ConfirmDialog-D9QXFpsy.js → ConfirmDialog-BjFsOpBe.js} +2 -2
- package/dist/{ConfirmDialog-D9QXFpsy.js.map → ConfirmDialog-BjFsOpBe.js.map} +1 -1
- package/dist/{ExperimentPopover-DyjoXwry.js → ExperimentPopover-DYy_s0kI.js} +21 -20
- package/dist/ExperimentPopover-DYy_s0kI.js.map +1 -0
- package/dist/{ExperimentSelectorModal-CUkKmgfr.js → ExperimentSelectorModal-Bfh5bdkv.js} +5 -5
- package/dist/{ExperimentSelectorModal-CUkKmgfr.js.map → ExperimentSelectorModal-Bfh5bdkv.js.map} +1 -1
- package/dist/{SegmentedControl-C-sckoJZ.js → SegmentedControl-B8KgyOjd.js} +2 -2
- package/dist/{SegmentedControl-C-sckoJZ.js.map → SegmentedControl-B8KgyOjd.js.map} +1 -1
- package/dist/{SettingsModal-B1ltysmg.js → SettingsModal-DPNblTe9.js} +7 -7
- package/dist/{SettingsModal-B1ltysmg.js.map → SettingsModal-DPNblTe9.js.map} +1 -1
- package/dist/__tests__/components/AppTopBarCompact.test.d.ts +1 -0
- package/dist/__tests__/components/AppTopBarCompactReset.test.d.ts +1 -0
- package/dist/__tests__/components/AutoGroupModal.experimentLoad.test.d.ts +1 -0
- package/dist/components/AppTopBar.compact.d.ts +5 -0
- package/dist/components/AppTopBar.vue.d.ts +17 -17
- package/dist/components/PluginWorkspaceView.vue.d.ts +4 -0
- package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -2
- package/dist/components/internal/LucideIconInternal.vue.d.ts +17 -0
- package/dist/composables/appLayoutSidebar.d.ts +9 -0
- package/dist/composables/useVersionedSave.d.ts +5 -2
- package/dist/{controlSchemaModel-C_XqpkgE.js → controlSchemaModel-BtFNZj8t.js} +2 -2
- package/dist/{controlSchemaModel-C_XqpkgE.js.map → controlSchemaModel-BtFNZj8t.js.map} +1 -1
- package/dist/{data-CgIcnf9D.js → data-CJztFns8.js} +31 -15
- package/dist/{data-CgIcnf9D.js.map → data-CJztFns8.js.map} +1 -1
- package/dist/data.js +8 -8
- package/dist/{experimentStatus-DCn2QKG7.js → experimentStatus-Deokk7Br.js} +2 -2
- package/dist/{experimentStatus-DCn2QKG7.js.map → experimentStatus-Deokk7Br.js.map} +1 -1
- package/dist/index.js +14 -14
- package/dist/{install-DmyjbTJ5.js → install-9FkKs0iW.js} +14 -15
- package/dist/{install-DmyjbTJ5.js.map → install-9FkKs0iW.js.map} +1 -1
- package/dist/install.js +1 -1
- package/dist/{lab-BcX5JBSY.js → lab-BYhb_cJI.js} +22 -14
- package/dist/lab-BYhb_cJI.js.map +1 -0
- package/dist/lab.js +4 -4
- package/dist/{records-BAymyHAn.js → records-GtiVMlTQ.js} +198 -62
- package/dist/records-GtiVMlTQ.js.map +1 -0
- package/dist/{runtime-CjfAxufu.js → runtime-DcMsvlaS.js} +29 -12
- package/dist/runtime-DcMsvlaS.js.map +1 -0
- package/dist/runtime.js +6 -6
- package/dist/{shell-XDqDu_UD.js → shell-XZYkAZS_.js} +5 -5
- package/dist/{shell-XDqDu_UD.js.map → shell-XZYkAZS_.js.map} +1 -1
- package/dist/shell.js +5 -5
- package/dist/styles.css +136 -12
- package/dist/types/components.d.ts +22 -2
- package/dist/types/index.d.ts +1 -1
- package/dist/{useDropdownState-uFIz6Yv8.js → useDropdownState-CcBzUYZ6.js} +14 -5
- package/dist/useDropdownState-CcBzUYZ6.js.map +1 -0
- package/dist/{useExperimentSamples-DEDAKa2E.js → useExperimentSamples-CpNXvpUn.js} +3 -3
- package/dist/{useExperimentSamples-DEDAKa2E.js.map → useExperimentSamples-CpNXvpUn.js.map} +1 -1
- package/dist/{useListReorder-W1bzK_bo.js → useListReorder-Bp4EAI2w.js} +3 -3
- package/dist/{useListReorder-W1bzK_bo.js.map → useListReorder-Bp4EAI2w.js.map} +1 -1
- package/dist/{useRackEditor-Bmtuo0aj.js → useRackEditor-i2bdPUFf.js} +8 -8
- package/dist/{useRackEditor-Bmtuo0aj.js.map → useRackEditor-i2bdPUFf.js.map} +1 -1
- package/dist/{useRealtime-jOetF2mM.js → useRealtime-BLQEjbuq.js} +3 -3
- package/dist/{useRealtime-jOetF2mM.js.map → useRealtime-BLQEjbuq.js.map} +1 -1
- package/dist/useRequest-CXyPw3eh.js +76 -0
- package/dist/useRequest-CXyPw3eh.js.map +1 -0
- package/dist/utils/deprecation.d.ts +2 -0
- package/dist/utils/lucideIcon.d.ts +4 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppTopBar.test.ts +102 -20
- package/src/__tests__/components/AppTopBarCompact.test.ts +65 -0
- package/src/__tests__/components/AppTopBarCompactReset.test.ts +24 -0
- package/src/__tests__/components/AutoGroupModal.experimentLoad.test.ts +58 -0
- package/src/__tests__/components/BaseModal.test.ts +33 -2
- package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -1
- package/src/__tests__/composables/useJobCenter.test.ts +35 -0
- package/src/__tests__/composables/useVersionedSave.test.ts +71 -6
- package/src/__tests__/styles/menusShellRefresh.test.ts +5 -0
- package/src/components/AppLayout.story.vue +5 -5
- package/src/components/AppLayout.vue +13 -1
- package/src/components/AppTopBar.compact.ts +60 -0
- package/src/components/AppTopBar.story.vue +39 -14
- package/src/components/AppTopBar.vue +222 -89
- package/src/components/BaseModal.vue +16 -5
- package/src/components/ExperimentPopover.vue +1 -1
- package/src/components/GeneratedPluginView.vue +0 -1
- package/src/components/PluginWorkspaceView.vue +8 -0
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +12 -0
- package/src/components/internal/AutoGroupModalInternal.vue +14 -3
- package/src/components/internal/LucideIconInternal.vue +23 -0
- package/src/composables/appLayoutSidebar.ts +11 -0
- package/src/composables/useJobCenter.ts +21 -1
- package/src/composables/usePluginWorkspace.ts +1 -1
- package/src/composables/useVersionedSave.ts +32 -7
- package/src/styles/components/app-layout.css +5 -0
- package/src/styles/components/app-top-bar.css +128 -8
- package/src/types/components.ts +23 -2
- package/src/types/index.ts +1 -0
- package/src/utils/deprecation.ts +8 -0
- package/src/utils/lucideIcon.ts +25 -0
- package/src/utils/lucideIconIndex.d.ts +2 -0
- package/dist/AppSidebar-C4qQLHcH.js.map +0 -1
- package/dist/ExperimentPopover-DyjoXwry.js.map +0 -1
- package/dist/lab-BcX5JBSY.js.map +0 -1
- package/dist/records-BAymyHAn.js.map +0 -1
- package/dist/runtime-CjfAxufu.js.map +0 -1
- package/dist/useDropdownState-uFIz6Yv8.js.map +0 -1
- package/dist/useRequest-D9-3BS-3.js +0 -215
- package/dist/useRequest-D9-3BS-3.js.map +0 -1
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
})
|
|
@@ -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.
|
|
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
|
|
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-
|
|
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
|
+
}
|
package/src/types/components.ts
CHANGED
|
@@ -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
|
-
/**
|
|
243
|
+
/** @deprecated Ignored: the theme toggle always shows. Removed in 2.0. */
|
|
243
244
|
themeToggle?: boolean
|
|
244
|
-
/**
|
|
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. */
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
}
|