@morscherlab/mld-sdk 0.10.0 → 0.10.1

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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared helpers for story files that need experiment selector integration.
3
+ * Provides mock data, API interceptors, and a provider wrapper component.
4
+ */
5
+ import { defineComponent, onUnmounted } from 'vue'
6
+ import { useAppExperiment } from '../composables/useAppExperiment'
7
+ import { useApi } from '../composables/useApi'
8
+ import type { ExperimentSummary } from '../types'
9
+
10
+ export const mockExperiments: ExperimentSummary[] = [
11
+ { id: 1, experiment_code: 'DR-HeLa-DOX-001', name: 'Doxorubicin dose-response in HeLa cells', status: 'completed', experiment_type: 'dose_response', project: 'Oncology Screen Q1', project_name: 'Oncology Screen Q1', created_at: '2026-02-28T14:30:00Z', updated_at: '2026-03-01T09:15:00Z', has_design_data: true },
12
+ { id: 2, experiment_code: 'CV-MCF7-24H-002', name: 'MCF-7 cell viability 24h time course', status: 'completed', experiment_type: 'cell_viability', project: 'Oncology Screen Q1', project_name: 'Oncology Screen Q1', created_at: '2026-02-25T10:00:00Z', updated_at: '2026-02-26T16:45:00Z', has_design_data: true },
13
+ { id: 3, experiment_code: 'DR-PANC1-GEM-003', name: 'Gemcitabine IC50 in PANC-1', status: 'ongoing', experiment_type: 'dose_response', project: 'Pancreatic Cancer', project_name: 'Pancreatic Cancer', created_at: '2026-03-01T08:00:00Z', updated_at: '2026-03-02T11:30:00Z', has_design_data: true },
14
+ { id: 4, experiment_code: 'CV-A549-CIS-004', name: 'Cisplatin cytotoxicity A549 lung', status: 'ongoing', experiment_type: 'cell_viability', project: 'Lung Cancer Panel', project_name: 'Lung Cancer Panel', created_at: '2026-03-01T13:00:00Z', updated_at: '2026-03-02T08:00:00Z', has_design_data: false },
15
+ { id: 5, experiment_code: 'DR-HEK293-CTRL-005', name: 'HEK-293T control viability baseline', status: 'completed', experiment_type: 'dose_response', project: 'Toxicity Controls', project_name: 'Toxicity Controls', created_at: '2026-02-20T09:00:00Z', updated_at: '2026-02-22T17:00:00Z', has_design_data: true },
16
+ ]
17
+
18
+ const mockExperimentTypes = [
19
+ { name: 'dose_response', color: '#3b82f6' },
20
+ { name: 'cell_viability', color: '#10b981' },
21
+ ]
22
+
23
+ const mockProjects = [
24
+ { id: 1, name: 'Oncology Screen Q1' },
25
+ { id: 2, name: 'Pancreatic Cancer' },
26
+ { id: 3, name: 'Lung Cancer Panel' },
27
+ { id: 4, name: 'Toxicity Controls' },
28
+ ]
29
+
30
+ export function installMockInterceptor() {
31
+ const { client } = useApi()
32
+ client.interceptors.request.use((config) => {
33
+ const url = config.url ?? ''
34
+ if (url.includes('/experiments/experiment-types')) {
35
+ config.adapter = () => Promise.resolve({ data: mockExperimentTypes, status: 200, statusText: 'OK', headers: {}, config })
36
+ return config
37
+ }
38
+ if (url.includes('/projects')) {
39
+ config.adapter = () => Promise.resolve({ data: mockProjects, status: 200, statusText: 'OK', headers: {}, config })
40
+ return config
41
+ }
42
+ if (url.startsWith('/api/experiments')) {
43
+ const params = new URLSearchParams(url.split('?')[1] ?? '')
44
+ const skip = Number(params.get('skip') ?? 0)
45
+ const limit = Number(params.get('limit') ?? 100)
46
+ let filtered = [...mockExperiments]
47
+ const status = params.get('status')
48
+ const search = params.get('search')?.toLowerCase()
49
+ if (status) filtered = filtered.filter(e => e.status === status)
50
+ if (search) filtered = filtered.filter(e => e.name.toLowerCase().includes(search) || e.experiment_code?.toLowerCase().includes(search))
51
+ config.adapter = () => Promise.resolve({ data: { experiments: filtered.slice(skip, skip + limit), total: filtered.length }, status: 200, statusText: 'OK', headers: {}, config })
52
+ }
53
+ return config
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Wrapper component that fakes integrated mode and provides useAppExperiment context.
59
+ * Use in story files to demonstrate experiment selector integration.
60
+ *
61
+ * Props:
62
+ * preselect (boolean, default true) - pre-select the first mock experiment
63
+ */
64
+ export const ExperimentProvider = defineComponent({
65
+ props: { preselect: { type: Boolean, default: true } },
66
+ setup(props, { slots }) {
67
+ ;(window as any).__MLD_PLATFORM__ = { isIntegrated: true, theme: 'system' }
68
+ installMockInterceptor()
69
+ const { set } = useAppExperiment({
70
+ onSave: async () => 'Saved!',
71
+ })
72
+ if (props.preselect) {
73
+ set({ id: 1, name: 'Doxorubicin dose-response in HeLa cells', status: 'completed' })
74
+ }
75
+ onUnmounted(() => { delete (window as any).__MLD_PLATFORM__ })
76
+ return () => slots.default?.()
77
+ },
78
+ })
@@ -5,13 +5,18 @@ import axios from 'axios'
5
5
  vi.mock('axios')
6
6
  const mockedAxios = vi.mocked(axios, true)
7
7
 
8
+ const unmountedCallbacks: Array<() => void> = []
9
+
8
10
  // Mock vue lifecycle hooks since we're not in a component
9
11
  vi.mock('vue', async () => {
10
- const actual = await vi.importActual('vue')
12
+ const actual = await vi.importActual<typeof import('vue')>('vue')
11
13
  return {
12
14
  ...actual,
15
+ getCurrentInstance: vi.fn(() => ({})),
13
16
  onMounted: vi.fn((cb: () => void) => cb()),
14
- onUnmounted: vi.fn(),
17
+ onUnmounted: vi.fn((cb: () => void) => {
18
+ unmountedCallbacks.push(cb)
19
+ }),
15
20
  }
16
21
  })
17
22
 
@@ -23,6 +28,7 @@ describe('useAuth', () => {
23
28
  beforeEach(() => {
24
29
  setActivePinia(createPinia())
25
30
  vi.useFakeTimers()
31
+ unmountedCallbacks.length = 0
26
32
 
27
33
  // Mock settings store
28
34
  const settingsStore = useSettingsStore()
@@ -118,6 +124,45 @@ describe('useAuth', () => {
118
124
  })
119
125
  })
120
126
 
127
+ describe('refreshToken - lifecycle behavior', () => {
128
+ it('keeps scheduled refresh when one of multiple consumers unmounts', async () => {
129
+ mockedAxios.post.mockImplementation((url: string) => {
130
+ if (url.endsWith('/auth/login')) {
131
+ return Promise.resolve({
132
+ data: { access_token: 'test-token', expires_in: 301, token_type: 'bearer' },
133
+ })
134
+ }
135
+ if (url.endsWith('/auth/refresh')) {
136
+ return Promise.resolve({
137
+ data: { access_token: 'refreshed-token', expires_in: 3600, token_type: 'bearer' },
138
+ })
139
+ }
140
+ throw new Error(`Unexpected POST ${url}`)
141
+ })
142
+ mockedAxios.get.mockResolvedValue({
143
+ data: { id: '1', username: 'test', shortname: null, email: null, role: 'user', is_active: true },
144
+ })
145
+
146
+ const authA = useAuth()
147
+ useAuth()
148
+
149
+ await authA.login('test', 'password')
150
+
151
+ expect(unmountedCallbacks).toHaveLength(2)
152
+ unmountedCallbacks[0]!()
153
+
154
+ vi.advanceTimersByTime(2_000)
155
+ await Promise.resolve()
156
+
157
+ const refreshCalls = mockedAxios.post.mock.calls.filter(
158
+ ([url]) => typeof url === 'string' && url.endsWith('/auth/refresh'),
159
+ )
160
+ expect(refreshCalls).toHaveLength(1)
161
+
162
+ unmountedCallbacks[1]!()
163
+ })
164
+ })
165
+
121
166
  describe('verifyToken', () => {
122
167
  it('should return false when no token', async () => {
123
168
  const { verifyToken } = useAuth()
@@ -0,0 +1,116 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const unmountedCallbacks: Array<() => void> = []
4
+
5
+ type MessageHandler = (event: MessageEvent) => void
6
+ let messageHandler: MessageHandler | null = null
7
+
8
+ vi.mock('vue', async () => {
9
+ const actual = await vi.importActual<typeof import('vue')>('vue')
10
+ return {
11
+ ...actual,
12
+ onMounted: vi.fn((cb: () => void) => cb()),
13
+ onUnmounted: vi.fn((cb: () => void) => {
14
+ unmountedCallbacks.push(cb)
15
+ }),
16
+ }
17
+ })
18
+
19
+ import { usePlatformContext } from '../../composables/usePlatformContext'
20
+
21
+ function sendThemeMessage(origin: string, theme: 'light' | 'dark' | 'system'): void {
22
+ if (!messageHandler) {
23
+ throw new Error('Message handler was not registered')
24
+ }
25
+
26
+ messageHandler({
27
+ source: window.parent,
28
+ origin,
29
+ data: {
30
+ type: 'mld:theme-changed',
31
+ payload: theme,
32
+ },
33
+ } as MessageEvent)
34
+ }
35
+
36
+ describe('usePlatformContext', () => {
37
+ beforeEach(() => {
38
+ unmountedCallbacks.length = 0
39
+ messageHandler = null
40
+
41
+ vi.spyOn(window, 'addEventListener').mockImplementation((type, listener) => {
42
+ if (type === 'message') {
43
+ messageHandler = listener as MessageHandler
44
+ }
45
+ })
46
+ vi.spyOn(window, 'removeEventListener').mockImplementation(() => {})
47
+ })
48
+
49
+ afterEach(() => {
50
+ while (unmountedCallbacks.length > 0) {
51
+ const callback = unmountedCallbacks.pop()
52
+ callback?.()
53
+ }
54
+ vi.restoreAllMocks()
55
+ })
56
+
57
+ it('clears allowAnyOrigin policy after last consumer unmounts', () => {
58
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
59
+
60
+ const first = usePlatformContext({ allowAnyOrigin: true })
61
+ sendThemeMessage('https://evil.example', 'dark')
62
+ expect(first.theme.value).toBe('dark')
63
+
64
+ expect(unmountedCallbacks).toHaveLength(1)
65
+ unmountedCallbacks[0]!()
66
+ unmountedCallbacks.length = 0
67
+
68
+ const second = usePlatformContext()
69
+ sendThemeMessage('https://evil.example', 'light')
70
+ expect(second.theme.value).toBe('system')
71
+ expect(
72
+ warnSpy.mock.calls.some(([msg]) => String(msg).includes('Rejected postMessage')),
73
+ ).toBe(true)
74
+ })
75
+
76
+ it('does not leak explicit allowed origins to new consumers', () => {
77
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
78
+
79
+ const first = usePlatformContext({ allowedOrigins: ['https://trusted.example'] })
80
+ sendThemeMessage('https://trusted.example', 'dark')
81
+ expect(first.theme.value).toBe('dark')
82
+
83
+ expect(unmountedCallbacks).toHaveLength(1)
84
+ unmountedCallbacks[0]!()
85
+ unmountedCallbacks.length = 0
86
+
87
+ const second = usePlatformContext()
88
+ sendThemeMessage('https://trusted.example', 'light')
89
+ expect(second.theme.value).toBe('system')
90
+ expect(
91
+ warnSpy.mock.calls.some(([msg]) => String(msg).includes('Rejected postMessage')),
92
+ ).toBe(true)
93
+ })
94
+
95
+ it('removes allowAnyOrigin scope when that consumer unmounts while others remain', () => {
96
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
97
+
98
+ usePlatformContext({ allowAnyOrigin: true })
99
+ const second = usePlatformContext()
100
+
101
+ sendThemeMessage('https://evil.example', 'dark')
102
+ expect(second.theme.value).toBe('dark')
103
+
104
+ expect(unmountedCallbacks).toHaveLength(2)
105
+ unmountedCallbacks[0]!()
106
+
107
+ sendThemeMessage('https://evil.example', 'light')
108
+ expect(second.theme.value).toBe('dark')
109
+ expect(
110
+ warnSpy.mock.calls.some(([msg]) => String(msg).includes('Rejected postMessage')),
111
+ ).toBe(true)
112
+
113
+ unmountedCallbacks[1]!()
114
+ unmountedCallbacks.length = 0
115
+ })
116
+ })
@@ -7,6 +7,7 @@ import BaseSlider from './BaseSlider.vue'
7
7
  import BaseSelect from './BaseSelect.vue'
8
8
  import BaseToggle from './BaseToggle.vue'
9
9
  import BaseButton from './BaseButton.vue'
10
+ import { ExperimentProvider } from '../__stories__/experiment-helpers'
10
11
  import type { SidebarToolSection, TopBarTab } from '../types'
11
12
 
12
13
  const activeTab = ref('analysis')
@@ -214,5 +215,78 @@ const logScale = ref(false)
214
215
  </AppLayout>
215
216
  </div>
216
217
  </Variant>
218
+
219
+ <Variant title="With Experiment Selector">
220
+ <ExperimentProvider>
221
+ <div style="height: 600px;">
222
+ <AppLayout :floating="true" sidebar-width="280px">
223
+ <template #topbar>
224
+ <AppTopBar
225
+ plugin-name="IC50 Calculator"
226
+ title="Analysis"
227
+ :tabs="tabs"
228
+ :current-tab-id="activeTab"
229
+ :show-theme-toggle="true"
230
+ home-path=""
231
+ @tab-select="t => activeTab = t.id"
232
+ />
233
+ </template>
234
+ <template #sidebar>
235
+ <AppSidebar
236
+ :panels="toolPanels"
237
+ :active-view="activeTab"
238
+ :floating="false"
239
+ >
240
+ <template #section-parameters>
241
+ <BaseSlider v-model="threshold" label="Threshold" :min="0" :max="100" />
242
+ <BaseSelect v-model="method" label="Method" :options="methods" />
243
+ </template>
244
+ <template #section-filters>
245
+ <BaseToggle v-model="showOutliers" label="Exclude outliers" />
246
+ </template>
247
+ <template #section-display>
248
+ <BaseToggle v-model="showOutliers" label="Show outliers" />
249
+ <BaseToggle v-model="logScale" label="Log scale" />
250
+ </template>
251
+ <template #section-export>
252
+ <BaseButton size="sm" variant="secondary">Export CSV</BaseButton>
253
+ </template>
254
+ </AppSidebar>
255
+ </template>
256
+ <div style="padding: 2rem;">
257
+ <h2 style="margin: 0 0 1rem; color: var(--text-primary, #1e293b);">
258
+ {{ activeTab.charAt(0).toUpperCase() + activeTab.slice(1) }}
259
+ </h2>
260
+ <p style="color: var(--text-muted, #94a3b8);">
261
+ Full layout with experiment selector in the top bar.
262
+ Click the flask icon to open the experiment popover and selector modal.
263
+ </p>
264
+ </div>
265
+ </AppLayout>
266
+ </div>
267
+ </ExperimentProvider>
268
+ </Variant>
269
+
270
+ <Variant title="With Experiment Selector (No Selection)">
271
+ <ExperimentProvider :preselect="false">
272
+ <div style="height: 600px;">
273
+ <AppLayout :floating="true">
274
+ <template #topbar>
275
+ <AppTopBar
276
+ plugin-name="Plate Analyzer"
277
+ title="Dashboard"
278
+ :show-theme-toggle="true"
279
+ home-path=""
280
+ />
281
+ </template>
282
+ <div style="padding: 2rem;">
283
+ <p style="color: var(--text-muted, #94a3b8);">
284
+ No experiment selected yet. Click the flask icon in the top bar to select one.
285
+ </p>
286
+ </div>
287
+ </AppLayout>
288
+ </div>
289
+ </ExperimentProvider>
290
+ </Variant>
217
291
  </Story>
218
292
  </template>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import AppTopBar from './AppTopBar.vue'
3
+ import { ExperimentProvider } from '../__stories__/experiment-helpers'
3
4
  import type { TopBarVariant, TopBarPage, TopBarTab } from '../types'
4
5
 
5
6
  const variants: TopBarVariant[] = ['card', 'default']
@@ -108,5 +109,33 @@ const sampleTabs: TopBarTab[] = [
108
109
  <AppTopBar :show-logo="true" home-path="/" />
109
110
  </div>
110
111
  </Variant>
112
+
113
+ <Variant title="With Experiment Selector">
114
+ <div style="padding: 2rem;">
115
+ <ExperimentProvider>
116
+ <AppTopBar
117
+ plugin-name="IC50 Calculator"
118
+ title="Analysis"
119
+ variant="card"
120
+ :show-theme-toggle="true"
121
+ home-path=""
122
+ />
123
+ </ExperimentProvider>
124
+ </div>
125
+ </Variant>
126
+
127
+ <Variant title="With Experiment Selector (Empty)">
128
+ <div style="padding: 2rem;">
129
+ <ExperimentProvider :preselect="false">
130
+ <AppTopBar
131
+ plugin-name="Plate Analyzer"
132
+ title="Dashboard"
133
+ variant="card"
134
+ :show-theme-toggle="true"
135
+ home-path=""
136
+ />
137
+ </ExperimentProvider>
138
+ </div>
139
+ </Variant>
111
140
  </Story>
112
141
  </template>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, watch, onMounted, onUnmounted } from 'vue'
3
+ import ConfirmDialog from './ConfirmDialog.vue'
3
4
 
4
5
  interface Props {
5
6
  experimentName?: string
@@ -10,6 +11,9 @@ interface Props {
10
11
  saveLoading?: boolean
11
12
  saveSuccessMessage?: string
12
13
  saveDisabledMessage?: string
14
+ confirmSave?: boolean
15
+ confirmTitle?: string
16
+ confirmMessage?: string
13
17
  }
14
18
 
15
19
  const props = withDefaults(defineProps<Props>(), {
@@ -17,6 +21,7 @@ const props = withDefaults(defineProps<Props>(), {
17
21
  showDetach: false,
18
22
  saveDisabled: false,
19
23
  saveLoading: false,
24
+ confirmSave: true,
20
25
  })
21
26
 
22
27
  const emit = defineEmits<{
@@ -28,6 +33,7 @@ const emit = defineEmits<{
28
33
  const isOpen = ref(false)
29
34
  const popoverRef = ref<HTMLElement | null>(null)
30
35
  const showSuccess = ref(false)
36
+ const showConfirm = ref(false)
31
37
 
32
38
  function toggle() {
33
39
  isOpen.value = !isOpen.value
@@ -44,6 +50,15 @@ function handleSelect() {
44
50
 
45
51
  function handleSave() {
46
52
  if (props.saveDisabled || props.saveLoading) return
53
+ if (props.confirmSave) {
54
+ showConfirm.value = true
55
+ } else {
56
+ emit('save')
57
+ }
58
+ }
59
+
60
+ function handleConfirmSave() {
61
+ showConfirm.value = false
47
62
  emit('save')
48
63
  }
49
64
 
@@ -58,12 +73,16 @@ function handleClickOutside(event: MouseEvent) {
58
73
  }
59
74
  }
60
75
 
76
+ let successTimer: ReturnType<typeof setTimeout> | null = null
77
+
61
78
  // Show success state when saveSuccessMessage changes from empty to a value
62
79
  watch(() => props.saveSuccessMessage, (msg) => {
80
+ if (successTimer) clearTimeout(successTimer)
63
81
  if (msg) {
64
82
  showSuccess.value = true
65
- setTimeout(() => {
83
+ successTimer = setTimeout(() => {
66
84
  showSuccess.value = false
85
+ successTimer = null
67
86
  }, 3000)
68
87
  }
69
88
  })
@@ -74,6 +93,7 @@ onMounted(() => {
74
93
 
75
94
  onUnmounted(() => {
76
95
  document.removeEventListener('click', handleClickOutside)
96
+ if (successTimer) clearTimeout(successTimer)
77
97
  })
78
98
 
79
99
  // Format status for display (e.g., "ready_to_extract" -> "Ready to extract")
@@ -84,33 +104,67 @@ function formatStatus(status: string): string {
84
104
 
85
105
  <template>
86
106
  <div ref="popoverRef" class="mld-experiment-popover">
87
- <!-- Trigger button -->
88
- <button
89
- type="button"
107
+ <!-- Split trigger: experiment pill + inline save -->
108
+ <div
90
109
  :class="[
91
- 'mld-experiment-popover__trigger',
92
- { 'mld-experiment-popover__trigger--active': isOpen },
93
- { 'mld-experiment-popover__trigger--empty': !experimentName },
110
+ 'mld-experiment-popover__split',
111
+ { 'mld-experiment-popover__split--with-save': showSave && experimentName },
94
112
  ]"
95
- @click.stop="toggle"
96
113
  >
97
- <!-- Flask icon -->
98
- <svg class="mld-experiment-popover__trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
99
- <path
100
- stroke-linecap="round"
101
- stroke-linejoin="round"
102
- stroke-width="1.75"
103
- d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
104
- />
105
- </svg>
106
- <span class="mld-experiment-popover__trigger-text">
107
- {{ experimentName || 'No experiment' }}
108
- </span>
109
- <!-- Chevron -->
110
- <svg class="mld-experiment-popover__trigger-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
111
- <path d="m6 9 6 6 6-6" />
112
- </svg>
113
- </button>
114
+ <!-- Left: experiment trigger (opens popover) -->
115
+ <button
116
+ type="button"
117
+ :class="[
118
+ 'mld-experiment-popover__trigger',
119
+ { 'mld-experiment-popover__trigger--active': isOpen },
120
+ { 'mld-experiment-popover__trigger--empty': !experimentName },
121
+ ]"
122
+ @click.stop="toggle"
123
+ >
124
+ <!-- Flask icon -->
125
+ <svg class="mld-experiment-popover__trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
126
+ <path
127
+ stroke-linecap="round"
128
+ stroke-linejoin="round"
129
+ stroke-width="1.75"
130
+ d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
131
+ />
132
+ </svg>
133
+ <span class="mld-experiment-popover__trigger-text">
134
+ {{ experimentName || 'No experiment' }}
135
+ </span>
136
+ <!-- Chevron -->
137
+ <svg class="mld-experiment-popover__trigger-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
138
+ <path d="m6 9 6 6 6-6" />
139
+ </svg>
140
+ </button>
141
+
142
+ <!-- Right: inline save button (direct action) -->
143
+ <button
144
+ v-if="showSave && experimentName"
145
+ type="button"
146
+ :class="[
147
+ 'mld-experiment-popover__save-trigger',
148
+ { 'mld-experiment-popover__save-trigger--loading': saveLoading },
149
+ { 'mld-experiment-popover__save-trigger--success': showSuccess },
150
+ { 'mld-experiment-popover__save-trigger--disabled': saveDisabled && !showSuccess },
151
+ ]"
152
+ :disabled="saveDisabled && !showSuccess"
153
+ :title="saveDisabled && saveDisabledMessage ? saveDisabledMessage : showSuccess && saveSuccessMessage ? saveSuccessMessage : 'Save to Experiment'"
154
+ @click.stop="handleSave"
155
+ >
156
+ <!-- Loading spinner -->
157
+ <span v-if="saveLoading" class="mld-experiment-popover__spinner--inline" />
158
+ <!-- Success check -->
159
+ <svg v-else-if="showSuccess" class="mld-experiment-popover__save-trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
160
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
161
+ </svg>
162
+ <!-- Save icon -->
163
+ <svg v-else class="mld-experiment-popover__save-trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
164
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
165
+ </svg>
166
+ </button>
167
+ </div>
114
168
 
115
169
  <!-- Popover panel -->
116
170
  <div v-if="isOpen" class="mld-experiment-popover__panel">
@@ -161,40 +215,18 @@ function formatStatus(status: string): string {
161
215
  </div>
162
216
  </div>
163
217
  </div>
164
-
165
- <!-- Save section -->
166
- <template v-if="showSave">
167
- <div class="mld-experiment-popover__divider" />
168
- <div class="mld-experiment-popover__footer">
169
- <button
170
- type="button"
171
- :class="[
172
- 'mld-experiment-popover__save-btn',
173
- { 'mld-experiment-popover__save-btn--loading': saveLoading },
174
- { 'mld-experiment-popover__save-btn--success': showSuccess },
175
- ]"
176
- :disabled="saveDisabled && !showSuccess"
177
- @click="handleSave"
178
- >
179
- <!-- Loading spinner -->
180
- <span v-if="saveLoading" class="mld-experiment-popover__spinner" />
181
- <!-- Success check -->
182
- <svg v-else-if="showSuccess" class="mld-experiment-popover__check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
183
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
184
- </svg>
185
- <!-- Save icon -->
186
- <svg v-else class="mld-experiment-popover__save-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
187
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
188
- </svg>
189
- <!-- Label -->
190
- <span>{{ showSuccess && saveSuccessMessage ? saveSuccessMessage : 'Save to Experiment' }}</span>
191
- </button>
192
- <div v-if="saveDisabled && saveDisabledMessage && !showSuccess" class="mld-experiment-popover__save-hint">
193
- {{ saveDisabledMessage }}
194
- </div>
195
- </div>
196
- </template>
197
218
  </div>
219
+
220
+ <!-- Save confirmation dialog -->
221
+ <ConfirmDialog
222
+ v-model="showConfirm"
223
+ :title="confirmTitle ?? 'Save to Experiment'"
224
+ :message="confirmMessage ?? `Save current data to ${experimentName}?`"
225
+ variant="info"
226
+ confirm-label="Save"
227
+ :loading="saveLoading"
228
+ @confirm="handleConfirmSave"
229
+ />
198
230
  </div>
199
231
  </template>
200
232
 
@@ -61,6 +61,7 @@ export interface UseAuthReturn {
61
61
  // across multiple useAuth() instances
62
62
  let _refreshPromise: Promise<boolean> | null = null
63
63
  let _refreshTimerId: number | null = null
64
+ let _mountedConsumerCount = 0
64
65
 
65
66
  export function useAuth(): UseAuthReturn {
66
67
  const authStore = useAuthStore()
@@ -385,15 +386,20 @@ export function useAuth(): UseAuthReturn {
385
386
 
386
387
  if (getCurrentInstance()) {
387
388
  onMounted(() => {
389
+ _mountedConsumerCount += 1
388
390
  checkInterval = window.setInterval(checkAndRefreshIfNeeded, TOKEN_REFRESH_CHECK_INTERVAL_MS)
389
391
  })
390
392
 
391
393
  onUnmounted(() => {
392
- stopTokenRefresh()
393
394
  if (checkInterval !== null) {
394
395
  window.clearInterval(checkInterval)
395
396
  checkInterval = null
396
397
  }
398
+
399
+ _mountedConsumerCount = Math.max(0, _mountedConsumerCount - 1)
400
+ if (_mountedConsumerCount === 0) {
401
+ stopTokenRefresh()
402
+ }
397
403
  })
398
404
 
399
405
  // Watch for token changes to reschedule refresh