@morscherlab/mint-sdk 1.4.0-beta.15 → 1.4.0-beta.17

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 (36) hide show
  1. package/dist/{AppSidebar-Bi8g9mRt.js → AppSidebar-CB0-RQ4m.js} +876 -114
  2. package/dist/AppSidebar-CB0-RQ4m.js.map +1 -0
  3. package/dist/{SettingsModal-jra_2u0o.js → SettingsModal-CoZK3EDN.js} +2 -2
  4. package/dist/{SettingsModal-jra_2u0o.js.map → SettingsModal-CoZK3EDN.js.map} +1 -1
  5. package/dist/__tests__/components/AppTopBarOnboarding.test.d.ts +1 -0
  6. package/dist/__tests__/composables/useOnboarding.test.d.ts +1 -0
  7. package/dist/components/AppTopBar.vue.d.ts +4 -4
  8. package/dist/components/internal/AppTopBarOnboardingInternal.vue.d.ts +7 -0
  9. package/dist/components/internal/OnboardingLayerInternal.vue.d.ts +7 -0
  10. package/dist/composables/useOnboarding.d.ts +119 -0
  11. package/dist/index.js +5 -5
  12. package/dist/{install-Bx_6KwJA.js → install-BKgb5qZw.js} +5 -5
  13. package/dist/{install-Bx_6KwJA.js.map → install-BKgb5qZw.js.map} +1 -1
  14. package/dist/install.js +1 -1
  15. package/dist/{runtime-BJvGms6h.js → runtime-Cz1GJa_O.js} +2 -2
  16. package/dist/{runtime-BJvGms6h.js.map → runtime-Cz1GJa_O.js.map} +1 -1
  17. package/dist/runtime.js +2 -2
  18. package/dist/{shell-Czyrzk7l.js → shell-kq9p4VsX.js} +7 -2
  19. package/dist/{shell-Czyrzk7l.js.map → shell-kq9p4VsX.js.map} +1 -1
  20. package/dist/shell.d.ts +1 -0
  21. package/dist/shell.js +3 -3
  22. package/dist/styles.css +385 -0
  23. package/package.json +1 -1
  24. package/src/__tests__/components/AppTopBarOnboarding.test.ts +160 -0
  25. package/src/__tests__/composables/useOnboarding.test.ts +317 -0
  26. package/src/__tests__/styles/v2AuditRules.test.ts +8 -0
  27. package/src/components/AppTopBar.onboarding.story.vue +69 -0
  28. package/src/components/AppTopBar.vue +11 -2
  29. package/src/components/internal/AppTopBarOnboardingInternal.vue +172 -0
  30. package/src/components/internal/OnboardingLayerInternal.vue +173 -0
  31. package/src/composables/useOnboarding.ts +465 -0
  32. package/src/shell.ts +17 -0
  33. package/src/styles/components/onboarding.css +368 -0
  34. package/src/styles/index.css +1 -0
  35. package/src/styles/variables.css +16 -0
  36. package/dist/AppSidebar-Bi8g9mRt.js.map +0 -1
@@ -0,0 +1,160 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
3
+ import { computed, defineComponent, h, nextTick } from 'vue'
4
+ import { createPinia } from 'pinia'
5
+ import AppTopBar from '../../components/AppTopBar.vue'
6
+ import { provideOnboarding, type OnboardingDefinition } from '../../composables/useOnboarding'
7
+
8
+ vi.mock('../../composables/usePlatform', () => ({
9
+ usePlatform: vi.fn(() => ({
10
+ integrated: computed(() => false),
11
+ isAdmin: computed(() => true),
12
+ can: () => true,
13
+ plugin: computed(() => undefined),
14
+ user: computed(() => undefined),
15
+ theme: computed(() => 'system' as const),
16
+ features: computed(() => undefined),
17
+ })),
18
+ }))
19
+
20
+ const definition: OnboardingDefinition = {
21
+ tours: [{
22
+ id: 'intro',
23
+ title: 'Top bar tour',
24
+ steps: [
25
+ { id: 'settings', anchor: 'topbar-settings', title: 'Settings live here', body: 'Theme and palette.' },
26
+ { id: 'help', anchor: 'topbar-help', title: 'Help', body: 'Replay tours.', advanceOn: 'click' },
27
+ ],
28
+ }],
29
+ checklist: [{ id: 'create', title: 'Create an experiment' }],
30
+ }
31
+
32
+ enableAutoUnmount(afterEach)
33
+
34
+ function mountTopBar(onboarding?: OnboardingDefinition) {
35
+ return mount(defineComponent({
36
+ setup() {
37
+ if (onboarding) provideOnboarding(onboarding, { appId: 'test', userId: 1, autoStart: false })
38
+ return () => h(AppTopBar, { spec: { settings: { tabs: [] } } })
39
+ },
40
+ }), { global: { plugins: [createPinia()], stubs: { 'router-link': true } }, attachTo: document.body })
41
+ }
42
+
43
+ const card = () => document.querySelector('.mint-onboarding__card')
44
+
45
+ describe('AppTopBar onboarding', () => {
46
+ beforeEach(() => localStorage.clear())
47
+
48
+ it.each([
49
+ [undefined, false],
50
+ [definition, true],
51
+ ])('should show the Help button and checklist chip when onboarding is %s', (onboarding, shown) => {
52
+ const wrapper = mountTopBar(onboarding)
53
+
54
+ expect(wrapper.find('[data-onboarding="topbar-help"]').exists()).toBe(shown)
55
+ expect(wrapper.find('.mint-onboarding-help__chip').exists()).toBe(shown)
56
+ if (shown) expect(wrapper.find('.mint-onboarding-help__chip').text()).toContain('0/1')
57
+ })
58
+
59
+ it('should run a tour picked in the Help panel and end it with Escape', async () => {
60
+ const wrapper = mountTopBar(definition)
61
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
62
+ const tour = [...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!
63
+ tour.click()
64
+ await flushPromises()
65
+
66
+ expect(document.querySelector('.mint-onboarding-help__panel')).toBeNull()
67
+ expect(card()?.getAttribute('role')).toBe('dialog')
68
+ expect(card()?.textContent).toContain('Settings live here')
69
+ expect(card()?.textContent).toContain('Next')
70
+
71
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
72
+ await nextTick()
73
+
74
+ expect(card()).toBeNull()
75
+ expect(JSON.parse(localStorage.getItem('mint.onboarding.test.1')!)).toEqual(
76
+ expect.arrayContaining(['step:intro/settings', 'step:intro/help']),
77
+ )
78
+ })
79
+
80
+ it('should focus the card, not the Next button, when a step opens', async () => {
81
+ const wrapper = mountTopBar(definition)
82
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
83
+ ;[...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!.click()
84
+ await flushPromises()
85
+
86
+ expect(document.activeElement).toBe(card())
87
+ })
88
+
89
+ it.each([
90
+ [{ key: 'Tab', shiftKey: true }, 'Next'],
91
+ [{ key: 'Tab', shiftKey: false }, ''],
92
+ ])('should keep %o from the focused card inside the card', async (init, label) => {
93
+ const wrapper = mountTopBar(definition)
94
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
95
+ ;[...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!.click()
96
+ await flushPromises()
97
+
98
+ document.dispatchEvent(new KeyboardEvent('keydown', { ...init, bubbles: true, cancelable: true }))
99
+
100
+ expect(card()!.contains(document.activeElement)).toBe(true)
101
+ expect((document.activeElement as HTMLElement).textContent?.trim()).toBe(label)
102
+ })
103
+
104
+ it('should leave Escape and Tab to a control outside the tour that has focus', async () => {
105
+ const wrapper = mountTopBar(definition)
106
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
107
+ ;[...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!.click()
108
+ await flushPromises()
109
+ const input = document.body.appendChild(document.createElement('input'))
110
+ input.focus()
111
+
112
+ for (const key of ['Tab', 'Escape']) document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
113
+ await nextTick()
114
+
115
+ expect(document.activeElement).toBe(input)
116
+ expect(card()).not.toBeNull()
117
+ input.remove()
118
+ })
119
+
120
+ it('should go to the next step when Enter is pressed on the focused card', async () => {
121
+ const wrapper = mountTopBar(definition)
122
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
123
+ ;[...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!.click()
124
+ await flushPromises()
125
+
126
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
127
+ await flushPromises()
128
+
129
+ expect(card()?.textContent).toContain('Replay tours.')
130
+ })
131
+
132
+ it('should show no Next button on an advanceOn click step', async () => {
133
+ const wrapper = mountTopBar(definition)
134
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
135
+ document.querySelector<HTMLElement>('.mint-menu-item')!.click()
136
+ await flushPromises()
137
+ ;(document.querySelector('.mint-onboarding__card .mint-button') as HTMLElement).click()
138
+ await flushPromises()
139
+
140
+ expect(card()?.textContent).toContain('Click the highlighted control.')
141
+ expect(card()?.textContent).not.toContain('Done')
142
+ })
143
+
144
+ it('should keep Tab on the anchor and the card on an advanceOn click step', async () => {
145
+ const wrapper = mountTopBar(definition)
146
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
147
+ document.querySelector<HTMLElement>('.mint-menu-item')!.click()
148
+ await flushPromises()
149
+ ;(document.querySelector('.mint-onboarding__card .mint-button') as HTMLElement).click()
150
+ await flushPromises()
151
+ const anchor = document.querySelector<HTMLElement>('[data-onboarding="topbar-help"]')!
152
+ expect(document.activeElement).toBe(anchor)
153
+
154
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }))
155
+
156
+ expect(card()?.contains(document.activeElement)).toBe(true)
157
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }))
158
+ expect(document.activeElement).toBe(anchor)
159
+ })
160
+ })
@@ -0,0 +1,317 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { defineComponent, h, nextTick, ref } from 'vue'
3
+ import { mount, type VueWrapper } from '@vue/test-utils'
4
+ import {
5
+ ANCHOR_WAIT_MS,
6
+ provideOnboarding,
7
+ type OnboardingDefinition,
8
+ type OnboardingState,
9
+ type ProvideOnboardingOptions,
10
+ } from '../../composables/useOnboarding'
11
+
12
+ const KEY = 'mint.onboarding.app.7'
13
+
14
+ const definition: OnboardingDefinition = {
15
+ tours: [
16
+ {
17
+ id: 'intro',
18
+ title: 'Intro',
19
+ steps: [
20
+ { id: 'a', anchor: 'a', title: 'A', body: '' },
21
+ { id: 'b', anchor: 'b', title: 'B', body: '' },
22
+ ],
23
+ },
24
+ { id: 'list', title: 'List', route: 'experiments', steps: [{ id: 'new', anchor: 'new', title: 'New', body: '' }] },
25
+ ],
26
+ checklist: [{ id: 'create', title: 'Create' }],
27
+ whatsNew: [{ id: 'jobs', anchor: 'a', title: 'Jobs', body: '' }],
28
+ }
29
+
30
+ let wrapper: VueWrapper | undefined
31
+
32
+ function addAnchor(id: string) {
33
+ const element = document.createElement('button')
34
+ element.dataset.onboarding = id
35
+ document.body.append(element)
36
+ return element
37
+ }
38
+
39
+ function setup(options: Partial<ProvideOnboardingOptions> = {}, def = definition): OnboardingState {
40
+ let state!: OnboardingState
41
+ wrapper = mount(defineComponent({
42
+ setup() {
43
+ state = provideOnboarding(def, { appId: 'app', userId: 7, ...options })
44
+ return () => h('div')
45
+ },
46
+ }))
47
+ return state
48
+ }
49
+
50
+ async function flush() {
51
+ await vi.advanceTimersByTimeAsync(0)
52
+ await nextTick()
53
+ }
54
+
55
+ const stored = () => JSON.parse(localStorage.getItem(KEY) ?? 'null') as string[] | null
56
+
57
+ describe('provideOnboarding', () => {
58
+ beforeEach(() => {
59
+ vi.useFakeTimers()
60
+ localStorage.clear()
61
+ addAnchor('a')
62
+ addAnchor('b')
63
+ })
64
+
65
+ afterEach(() => {
66
+ wrapper?.unmount()
67
+ document.body.innerHTML = ''
68
+ vi.useRealTimers()
69
+ })
70
+
71
+ it('should start the first tour of the route and mark each shown step as seen', async () => {
72
+ const state = setup()
73
+ await flush()
74
+
75
+ expect(state.active.value?.step.id).toBe('a')
76
+ state.next()
77
+ await flush()
78
+
79
+ expect(state.active.value?.step.id).toBe('b')
80
+ expect(stored()).toEqual(expect.arrayContaining(['step:intro/a', 'step:intro/b']))
81
+ })
82
+
83
+ it('should mark the existing news as seen on the first run only', async () => {
84
+ const first = setup({ autoStart: false })
85
+ expect(first.unseenNews.value).toBe(0)
86
+ wrapper?.unmount()
87
+
88
+ const later = setup({ autoStart: false }, { ...definition, whatsNew: [...definition.whatsNew!, { id: 'pins', anchor: 'b', title: 'Pins', body: '' }] })
89
+
90
+ expect(later.news.value.map((item) => item.unseen)).toEqual([false, true])
91
+ })
92
+
93
+ it('should show a returning user only the steps with new ids', async () => {
94
+ localStorage.setItem(KEY, JSON.stringify(['step:intro/a']))
95
+ const state = setup()
96
+ await flush()
97
+
98
+ expect(state.active.value).toMatchObject({ step: { id: 'b' }, index: 0, count: 1 })
99
+ })
100
+
101
+ it('should mark every step seen on dismiss and not start the tour again', async () => {
102
+ const state = setup()
103
+ await flush()
104
+ state.dismiss()
105
+ wrapper?.unmount()
106
+
107
+ const again = setup()
108
+ await flush()
109
+
110
+ expect(stored()).toEqual(expect.arrayContaining(['step:intro/a', 'step:intro/b']))
111
+ expect(again.active.value).toBeNull()
112
+ })
113
+
114
+ it('should skip a step whose anchor does not appear in time and keep it unseen', async () => {
115
+ document.querySelector('[data-onboarding="a"]')!.remove()
116
+ const state = setup()
117
+ await flush()
118
+ expect(state.active.value).toBeNull()
119
+
120
+ await vi.advanceTimersByTimeAsync(ANCHOR_WAIT_MS)
121
+ await nextTick()
122
+
123
+ expect(state.active.value?.step.id).toBe('b')
124
+ expect(stored()).not.toContain('step:intro/a')
125
+ })
126
+
127
+ it('should leave a missing anchor out of the count and skip it at once after Next', async () => {
128
+ const state = setup({}, { tours: [{ id: 't', title: 'T', steps: [
129
+ { id: 'a', anchor: 'a', title: 'A', body: '' },
130
+ { id: 'gone', anchor: 'gone', title: 'Gone', body: '' },
131
+ { id: 'b', anchor: 'b', title: 'B', body: '' },
132
+ ] }] })
133
+ await flush()
134
+ expect(state.active.value).toMatchObject({ step: { id: 'a' }, index: 0, count: 2 })
135
+
136
+ state.next()
137
+ await flush()
138
+
139
+ expect(state.active.value).toMatchObject({ step: { id: 'b' }, index: 1, count: 2 })
140
+ })
141
+
142
+ it('should still show a later step whose anchor renders after the tour started', async () => {
143
+ const state = setup({}, { tours: [{ id: 't', title: 'T', steps: [
144
+ { id: 'a', anchor: 'a', title: 'A', body: '' },
145
+ { id: 'late', anchor: 'late', title: 'Late', body: '' },
146
+ { id: 'b', anchor: 'b', title: 'B', body: '' },
147
+ ] }] })
148
+ await flush()
149
+ expect(state.active.value).toMatchObject({ step: { id: 'a' }, count: 2 })
150
+
151
+ addAnchor('late')
152
+ state.next()
153
+ await flush()
154
+
155
+ expect(state.active.value).toMatchObject({ step: { id: 'late' }, index: 1, count: 3 })
156
+ })
157
+
158
+ it('should keep the position and count in step after Back when a shown anchor went away', async () => {
159
+ addAnchor('c')
160
+ const state = setup({}, { tours: [{ id: 't', title: 'T', steps: [
161
+ { id: 'a', anchor: 'a', title: 'A', body: '' },
162
+ { id: 'b', anchor: 'b', title: 'B', body: '' },
163
+ { id: 'c', anchor: 'c', title: 'C', body: '' },
164
+ ] }] })
165
+ await flush()
166
+ state.next()
167
+ await flush()
168
+ state.next()
169
+ await flush()
170
+ document.querySelector('[data-onboarding="b"]')!.remove()
171
+
172
+ state.back()
173
+ await flush()
174
+ expect(state.active.value).toMatchObject({ step: { id: 'a' }, index: 0, count: 2 })
175
+ state.next()
176
+ await flush()
177
+
178
+ expect(state.active.value).toMatchObject({ step: { id: 'c' }, index: 1, count: 2 })
179
+ })
180
+
181
+ it('should show a step whose anchor appears while it waits', async () => {
182
+ document.querySelector('[data-onboarding="a"]')!.remove()
183
+ const state = setup()
184
+ await flush()
185
+
186
+ addAnchor('a')
187
+ await flush()
188
+
189
+ expect(state.active.value?.step.id).toBe('a')
190
+ })
191
+
192
+ it('should wait for an open modal to close before it starts a tour', async () => {
193
+ const modal = document.createElement('div')
194
+ modal.setAttribute('aria-modal', 'true')
195
+ document.body.append(modal)
196
+ const state = setup()
197
+ await flush()
198
+ expect(state.active.value).toBeNull()
199
+
200
+ modal.remove()
201
+ await flush()
202
+
203
+ expect(state.active.value?.step.id).toBe('a')
204
+ })
205
+
206
+ it('should drop a pending auto-start when the user ends a tour picked by hand', async () => {
207
+ const modal = document.createElement('div')
208
+ modal.setAttribute('aria-modal', 'true')
209
+ document.body.append(modal)
210
+ const state = setup({}, { tours: [
211
+ definition.tours![0],
212
+ { id: 'more', title: 'More', steps: [{ id: 'b', anchor: 'b', title: 'B', body: '' }] },
213
+ ] })
214
+ await flush()
215
+ state.startTour('intro')
216
+ await flush()
217
+ state.dismiss()
218
+
219
+ modal.remove()
220
+ await flush()
221
+
222
+ expect(state.active.value).toBeNull()
223
+ })
224
+
225
+ it('should start the tour of a route when the app reaches that route', async () => {
226
+ localStorage.setItem(KEY, JSON.stringify(['step:intro/a', 'step:intro/b']))
227
+ addAnchor('new')
228
+ const route = ref('home')
229
+ const state = setup({ route })
230
+ await flush()
231
+ expect(state.tours.value.map((tour) => tour.id)).toEqual(['intro'])
232
+ expect(state.active.value).toBeNull()
233
+
234
+ route.value = 'experiments'
235
+ await flush()
236
+
237
+ expect(state.active.value?.step.id).toBe('new')
238
+ })
239
+
240
+ it.each([
241
+ ['finishes', (state: OnboardingState) => { state.next(); return flush().then(() => state.next()) }, 'new'],
242
+ ['is dismissed', (state: OnboardingState) => state.dismiss(), undefined],
243
+ ])('should start the next tour of the page only when the first %s', async (_, end, next) => {
244
+ addAnchor('new')
245
+ const state = setup({ route: 'experiments' })
246
+ await flush()
247
+ expect(state.active.value?.step.id).toBe('a')
248
+
249
+ await end(state)
250
+ await flush()
251
+
252
+ expect(state.active.value?.step.id).toBe(next)
253
+ })
254
+
255
+ it('should leave out a step that is not available', async () => {
256
+ const state = setup({}, { tours: [{ id: 't', title: 'T', steps: [
257
+ { id: 'a', anchor: 'a', title: 'A', body: '', available: () => false },
258
+ { id: 'b', anchor: 'b', title: 'B', body: '' },
259
+ ] }] })
260
+ await flush()
261
+
262
+ expect(state.active.value).toMatchObject({ step: { id: 'b' }, count: 1 })
263
+ })
264
+
265
+ it('should keep the keys another tab stored when it writes', () => {
266
+ const state = setup({ autoStart: false })
267
+ localStorage.setItem(KEY, JSON.stringify([...stored()!, 'task:create']))
268
+
269
+ state.setChecklistHidden(true)
270
+
271
+ expect(stored()).toEqual(expect.arrayContaining(['task:create', 'hide:checklist']))
272
+ })
273
+
274
+ it('should move on when the anchor of an advanceOn click step is clicked', async () => {
275
+ const state = setup({}, { tours: [{ id: 't', title: 'T', steps: [
276
+ { id: 'a', anchor: 'a', title: 'A', body: '', advanceOn: 'click' },
277
+ { id: 'b', anchor: 'b', title: 'B', body: '' },
278
+ ] }] })
279
+ await flush()
280
+
281
+ document.querySelector<HTMLElement>('[data-onboarding="a"]')!.click()
282
+ await flush()
283
+
284
+ expect(state.active.value?.step.id).toBe('b')
285
+ })
286
+
287
+ it('should keep a task done after its predicate turns false', async () => {
288
+ const created = ref(true)
289
+ const state = setup({ autoStart: false }, { checklist: [{ id: 'create', title: 'Create', done: () => created.value }] })
290
+ created.value = false
291
+ state.refresh()
292
+ await nextTick()
293
+
294
+ expect(state.tasks.value[0].done).toBe(true)
295
+ expect(state.checklistVisible.value).toBe(false)
296
+ })
297
+
298
+ it.each([
299
+ ['complete', (state: OnboardingState) => state.complete('create'), false],
300
+ ['hide', (state: OnboardingState) => state.setChecklistHidden(true), false],
301
+ ['nothing', () => {}, true],
302
+ ])('should show the checklist chip after %s: %s', (_, act, visible) => {
303
+ const state = setup({ autoStart: false })
304
+ act(state)
305
+
306
+ expect(state.checklistVisible.value).toBe(visible)
307
+ })
308
+
309
+ it('should stay off without a user', async () => {
310
+ const state = setup({ userId: null })
311
+ await flush()
312
+
313
+ expect(state.enabled.value).toBe(false)
314
+ expect(state.active.value).toBeNull()
315
+ expect(localStorage.length).toBe(0)
316
+ })
317
+ })
@@ -20,6 +20,14 @@ function rule(text: string, selector: string): string {
20
20
  const rem = (value: string): number => Number(value.replace('rem', '')) * 16
21
21
 
22
22
  describe('v2 audit style rules', () => {
23
+ it('should stop the page bounce in a tab vertically and in a PWA on both axes', () => {
24
+ const variables = css('variables.css')
25
+ expect(rule(variables, 'html,\nbody')).toMatch(/overscroll-behavior-y:\s*none/)
26
+ expect(variables).toMatch(
27
+ /@media \(display-mode: standalone\)\s*\{\s*html,\s*body\s*\{\s*overscroll-behavior:\s*none;\s*\}/,
28
+ )
29
+ })
30
+
23
31
  it('should keep the outermost cards at least 16px from the window edge', () => {
24
32
  const inset = /--mint-page-inset:\s*([\d.]+rem)/.exec(css('variables.css'))?.[1] ?? '0rem'
25
33
  expect(rem(inset)).toBeGreaterThanOrEqual(16)
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { defineComponent, h, ref } from 'vue'
3
+ import AppTopBar from './AppTopBar.vue'
4
+ import BaseButton from './BaseButton.vue'
5
+ import { defineOnboarding, provideOnboarding, useOnboarding } from '../composables/useOnboarding'
6
+ import type { PillNavItem } from '../types'
7
+
8
+ const pills: PillNavItem[] = [
9
+ { id: 'home', label: 'Home' },
10
+ { id: 'experiments', label: 'Experiments' },
11
+ { id: 'instruments', label: 'Instruments' },
12
+ ]
13
+
14
+ const onboarding = defineOnboarding({
15
+ tours: [
16
+ {
17
+ id: 'intro',
18
+ title: 'The top bar',
19
+ steps: [
20
+ { id: 'pages', anchor: 'topbar-pills', title: 'Move between pages', body: 'Home, Experiments and Instruments are one click away on every page.' },
21
+ { id: 'new', anchor: 'story-new', title: 'Create an experiment', body: 'Click New experiment to go on.', placement: 'bottom-start', advanceOn: 'click' },
22
+ { id: 'help', anchor: 'topbar-help', title: 'Help', body: 'Open the checklist, play a tour again, or see what is new.', placement: 'bottom-end' },
23
+ ],
24
+ },
25
+ ],
26
+ checklist: [
27
+ { id: 'create', title: 'Create an experiment', action: { label: 'Open', run: () => {} } },
28
+ { id: 'project', title: 'Create a project', action: { label: 'Open', run: () => {} } },
29
+ { id: 'theme', title: 'Pick a theme' },
30
+ ],
31
+ whatsNew: [
32
+ { id: 'settings', anchor: 'topbar-settings', title: 'Palettes in Settings', body: 'Pick an accent color in Settings → Appearance.', placement: 'bottom-end' },
33
+ ],
34
+ })
35
+
36
+ // The stories are returning users with no state yet, so the news shows too.
37
+ const OnboardingProvider = defineComponent({
38
+ props: { appId: { type: String, required: true }, news: Boolean },
39
+ setup(props, { slots }) {
40
+ const key = `mint.onboarding.${props.appId}.local`
41
+ if (props.news && localStorage.getItem(key) === null) localStorage.setItem(key, '["task:theme"]')
42
+ provideOnboarding(onboarding, { appId: props.appId, userId: 'local' })
43
+ return () => slots.default?.()
44
+ },
45
+ })
46
+
47
+ const PageBody = defineComponent({
48
+ setup() {
49
+ const state = useOnboarding()
50
+ return () => h('div', { style: 'padding: 1.5rem; display: flex; gap: 0.5rem' }, [
51
+ h(BaseButton, { 'data-onboarding': 'story-new', onClick: () => state?.complete('create') }, () => 'New experiment'),
52
+ h(BaseButton, { variant: 'secondary', onClick: () => { localStorage.clear(); location.reload() } }, () => 'Reset onboarding'),
53
+ ])
54
+ },
55
+ })
56
+
57
+ const pill = ref('home')
58
+ </script>
59
+
60
+ <template>
61
+ <Story title="Layout/AppTopBar/Onboarding" :layout="{ type: 'single', iframe: true }">
62
+ <Variant title="Tour, checklist and news">
63
+ <OnboardingProvider app-id="story-onboarding" news>
64
+ <AppTopBar v-model:pill="pill" :spec="{ pills, settings: { tabs: [] }, standaloneLabel: false }" title="MINT" />
65
+ <PageBody />
66
+ </OnboardingProvider>
67
+ </Variant>
68
+ </Story>
69
+ </template>
@@ -19,9 +19,11 @@ import { applySpecDefaults } from '../composables/componentSpecs'
19
19
  import ThemeToggle from './ThemeToggle.vue'
20
20
  import AppTopBarPageSelectorInternal from './internal/AppTopBarPageSelectorInternal.vue'
21
21
  import AppTopBarPillNavInternal from './internal/AppTopBarPillNavInternal.vue'
22
+ import AppTopBarOnboardingInternal from './internal/AppTopBarOnboardingInternal.vue'
22
23
  import PluginIcon from './PluginIcon.vue'
23
24
  import { usePlatform } from '../composables/usePlatform'
24
25
  import { APP_EXPERIMENT_KEY } from '../composables/useAppExperiment'
26
+ import { useOnboarding } from '../composables/useOnboarding'
25
27
  import { pluginIdentity } from '../composables/pluginIdentity'
26
28
  import { integrationHost } from '../utils/integrationHost'
27
29
  import { trackTopBarForToasts } from './AppTopBar.toastOffset'
@@ -87,6 +89,7 @@ onBeforeUnmount(() => untrackToastOffset?.())
87
89
  const { integrated: isIntegrated, plugin } = usePlatform()
88
90
  const isStandalone = computed(() => !isIntegrated.value)
89
91
  const appExperiment = inject(APP_EXPERIMENT_KEY, null)
92
+ const onboarding = useOnboarding()
90
93
  const canBindExperiment = computed(() => plugin.value?.plugin_type !== 'static')
91
94
  const showExperiment = computed(() => !!appExperiment && !isStandalone.value && canBindExperiment.value)
92
95
  // The border joins only once the job is on screen and stays until it has left,
@@ -295,9 +298,10 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
295
298
  </span>
296
299
 
297
300
  <!-- Left: page selector (the platform integration script renders the nav_items one) -->
298
- <span v-if="hasPlatformPageSelector" ref="platformPageSelectorEl" class="mint-topbar__platform-page-selector" />
301
+ <span v-if="hasPlatformPageSelector" ref="platformPageSelectorEl" class="mint-topbar__platform-page-selector" data-onboarding="topbar-page-selector" />
299
302
  <AppTopBarPageSelectorInternal
300
303
  v-else-if="hasPropPageSelector"
304
+ data-onboarding="topbar-page-selector"
301
305
  :pages="normalizedPageSelector"
302
306
  :current-page-id="currentPageSelectorId"
303
307
  :plugin-name="pageSelectorPluginName"
@@ -324,7 +328,7 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
324
328
  </div>
325
329
 
326
330
  <!-- Center: pill nav -->
327
- <div v-if="hasPillNav || $slots.center" class="mint-topbar__center">
331
+ <div v-if="hasPillNav || $slots.center" class="mint-topbar__center" data-onboarding="topbar-pills">
328
332
  <slot name="center">
329
333
  <AppTopBarPillNavInternal
330
334
  v-if="hasPillNav"
@@ -347,6 +351,7 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
347
351
  <div
348
352
  v-if="showExperiment || job !== undefined || runSlotJoined"
349
353
  :class="['mint-topbar__run-slot', { 'mint-topbar__run-slot--joined': runSlotJoined }]"
354
+ data-onboarding="topbar-run"
350
355
  >
351
356
  <ExperimentPopover
352
357
  v-if="showExperiment && appExperiment"
@@ -399,6 +404,9 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
399
404
 
400
405
  <slot name="actions" />
401
406
 
407
+ <!-- Onboarding: checklist chip, Help button and tour layer, when the app provides onboarding -->
408
+ <AppTopBarOnboardingInternal v-if="onboarding?.enabled.value" :state="onboarding" />
409
+
402
410
  <!-- Theme toggle -->
403
411
  <ThemeToggle v-if="config.themeToggle" size="sm" />
404
412
 
@@ -408,6 +416,7 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
408
416
  type="button"
409
417
  class="mint-topbar__settings-btn"
410
418
  aria-label="Open settings"
419
+ data-onboarding="topbar-settings"
411
420
  @click="settingsOpen = true"
412
421
  >
413
422
  <svg class="mint-topbar__settings-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">