@cat-factory/app 0.196.1 → 0.197.0
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/README.md +39 -0
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +126 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +92 -0
- package/app/components/tutorial/TutorialOverlay.vue +273 -0
- package/app/components/tutorial/TutorialPrompt.vue +102 -0
- package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +6 -164
- package/app/composables/useTutorialTours.ts +18 -0
- package/app/modular/nav-contributions.spec.ts +4 -0
- package/app/modular/nav-contributions.ts +35 -0
- package/app/modular/nav-gates.ts +7 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/registry.ts +3 -1
- package/app/modular/slots.ts +7 -0
- package/app/modular/tutorial-tours.spec.ts +107 -0
- package/app/modular/tutorial-tours.ts +147 -0
- package/app/pages/index.vue +61 -0
- package/app/stores/board/dependencies.ts +52 -0
- package/app/stores/board/placement.ts +4 -37
- package/app/stores/execution/pendingGates.ts +109 -0
- package/app/stores/execution.ts +7 -94
- package/app/stores/requirements/recommendations.ts +77 -0
- package/app/stores/requirements.ts +17 -43
- package/app/stores/tutorial.spec.ts +135 -0
- package/app/stores/tutorial.ts +145 -0
- package/app/stores/workspace/commands.ts +77 -0
- package/app/stores/workspace.ts +11 -50
- package/app/utils/tutorial.spec.ts +68 -0
- package/app/utils/tutorial.ts +192 -0
- package/i18n/locales/de.json +89 -2
- package/i18n/locales/en.json +92 -2
- package/i18n/locales/es.json +89 -2
- package/i18n/locales/fr.json +89 -2
- package/i18n/locales/he.json +89 -2
- package/i18n/locales/it.json +89 -2
- package/i18n/locales/ja.json +89 -2
- package/i18n/locales/pl.json +89 -2
- package/i18n/locales/tr.json +89 -2
- package/i18n/locales/uk.json +89 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ The SPA source lives under `app/` (the Nuxt srcDir).
|
|
|
17
17
|
- [Layout](#layout)
|
|
18
18
|
- [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
|
|
19
19
|
- [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
|
|
20
|
+
- [In-app tutorial tours](#in-app-tutorial-tours)
|
|
20
21
|
- [Key UI surfaces](#key-ui-surfaces)
|
|
21
22
|
- [Develop & test](#develop--test)
|
|
22
23
|
|
|
@@ -159,6 +160,44 @@ picking what each of them runs on are halves of the same job.
|
|
|
159
160
|
whatever the tier — the same rule `showOverrideField` states for a single field: a row the
|
|
160
161
|
user can neither read nor clear is worse than a longer list.
|
|
161
162
|
|
|
163
|
+
## In-app tutorial tours
|
|
164
|
+
|
|
165
|
+
On first launch (once the board is up and no other startup advisory is open) the app asks
|
|
166
|
+
whether the user wants a guided tour. The answer is SAVED per browser (`stores/tutorial.ts`,
|
|
167
|
+
persisted like the interface tier): "no thanks" stops the prompt for good, closing without
|
|
168
|
+
answering defers it to the next launch, and the command palette's "Take a tour" entry is the
|
|
169
|
+
way back either way.
|
|
170
|
+
|
|
171
|
+
A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
|
|
172
|
+
control by its `data-testid` (the e2e anchor vocabulary — cover a control that has none by
|
|
173
|
+
adding the test id first) and carrying i18n keys for its copy. One shared runtime
|
|
174
|
+
(`components/tutorial/TutorialOverlay.vue`) renders every tour: it highlights the current
|
|
175
|
+
step's control, places the tooltip (`utils/tutorial.ts` owns the pure geometry + types),
|
|
176
|
+
advances on Next or — for `advanceOn: 'target-click'` steps — on the user really clicking
|
|
177
|
+
the control, so the app's real response (the actual modal, the actual task) is what the next
|
|
178
|
+
step anchors to. `target-click` is for BUTTONS, where the click is the completed action; a
|
|
179
|
+
text field keeps Next, or the tooltip would leave the instruction the moment the user clicked
|
|
180
|
+
in to type. A step whose anchor never appears within its wait is SKIPPED, because controls
|
|
181
|
+
come and go with RBAC, tier, and deployment wiring: a tour is a set of opportunities, not a
|
|
182
|
+
fixed script. Reaching the end having skipped steps is reported on the final card rather than
|
|
183
|
+
congratulating the user on a walkthrough they did not see — and a tour that could only ever
|
|
184
|
+
be abridged should not be offered at all, which is what each tour's `when(gates)` is for (the
|
|
185
|
+
task-creation tour requires board write AND a service frame to add a task to).
|
|
186
|
+
|
|
187
|
+
Two runtime constraints worth knowing before changing the overlay: it must keep
|
|
188
|
+
`pointer-events-auto` and swallow `pointerdown`, because Nuxt UI modals are reka-ui
|
|
189
|
+
dismissable layers that set `body { pointer-events: none }` and dismiss on an outside
|
|
190
|
+
pointerdown — without both, the tooltip's own buttons go inert and pressing one closes the
|
|
191
|
+
user's half-filled form. And everything that DECIDES (skip direction, wait budget,
|
|
192
|
+
target-click matching) lives in `components/tutorial/TutorialOverlay.logic.ts` so it is
|
|
193
|
+
unit-tested; the SFC keeps only the DOM work.
|
|
194
|
+
|
|
195
|
+
The catalog is the `tutorialTours` slot: first-party tours live in
|
|
196
|
+
`modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
|
|
197
|
+
`registerAppModule` — they appear in the launch prompt beside the built-ins, gated per tour
|
|
198
|
+
by its `when(gates)` predicate (the same reactive gates service the nav uses, filtered in
|
|
199
|
+
`navSlotFilter`). Completion is persisted per tour id, so renaming an id resets its state.
|
|
200
|
+
|
|
162
201
|
## Extending the layer (consumer modules)
|
|
163
202
|
|
|
164
203
|
A deployment can contribute its own components — result windows, nav entries, inspector
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
isSafeTargetId,
|
|
4
|
+
isTargetClickAdvance,
|
|
5
|
+
resolveSkip,
|
|
6
|
+
stepTargetIds,
|
|
7
|
+
stepTargetSelectors,
|
|
8
|
+
tourWasAbridged,
|
|
9
|
+
waitBudgetMs,
|
|
10
|
+
} from '~/components/tutorial/TutorialOverlay.logic'
|
|
11
|
+
import { DEFAULT_TARGET_WAIT_MS } from '~/utils/tutorial'
|
|
12
|
+
import type { TutorialStep } from '~/utils/tutorial'
|
|
13
|
+
|
|
14
|
+
const step = (over: Partial<TutorialStep> = {}): TutorialStep => ({
|
|
15
|
+
id: 'a-step',
|
|
16
|
+
titleKey: 'tutorial.tours.x.steps.a.title',
|
|
17
|
+
bodyKey: 'tutorial.tours.x.steps.a.body',
|
|
18
|
+
...over,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('stepTargetIds', () => {
|
|
22
|
+
it('lists the target then its fallbacks, in order', () => {
|
|
23
|
+
expect(
|
|
24
|
+
stepTargetIds(step({ target: 'frame-add-task', altTargets: ['frame-add-task-empty'] })),
|
|
25
|
+
).toEqual(['frame-add-task', 'frame-add-task-empty'])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('is empty for an untargeted (centered) step', () => {
|
|
29
|
+
expect(stepTargetIds(step())).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('isSafeTargetId', () => {
|
|
34
|
+
it('accepts the kebab-case ids the repo actually uses', () => {
|
|
35
|
+
for (const id of ['task-card', 'add-task-submit', 'board-fit-view', 'nav-tutorial', 'h2'])
|
|
36
|
+
expect(isSafeTargetId(id), id).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('rejects anything that could end up inside a selector as syntax', () => {
|
|
40
|
+
for (const id of ['od"d', "od'd", 'back\\slash', 'a b', 'a]b', 'Upper', ''])
|
|
41
|
+
expect(isSafeTargetId(id), id).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('stepTargetSelectors', () => {
|
|
46
|
+
it('builds the data-testid selectors, target first then fallbacks', () => {
|
|
47
|
+
const s = step({ target: 'frame-add-task', altTargets: ['frame-add-task-empty'] })
|
|
48
|
+
expect(stepTargetSelectors(s)).toEqual([
|
|
49
|
+
'[data-testid="frame-add-task"]',
|
|
50
|
+
'[data-testid="frame-add-task-empty"]',
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('drops a malformed id rather than building a selector that could throw', () => {
|
|
55
|
+
// A tour is DATA a consumer deployment authors, so an id arrives from outside this
|
|
56
|
+
// package. A bad one must degrade to "anchor not found" (a skipped step) — NOT to a
|
|
57
|
+
// SyntaxError raised out of the 150ms tracking interval, several times a second.
|
|
58
|
+
const s = step({ target: 'od"d', altTargets: ['task-card'] })
|
|
59
|
+
expect(stepTargetSelectors(s)).toEqual(['[data-testid="task-card"]'])
|
|
60
|
+
for (const selector of stepTargetSelectors(s))
|
|
61
|
+
expect(() => document.querySelector(selector)).not.toThrow()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('is empty for an untargeted step', () => {
|
|
65
|
+
expect(stepTargetSelectors(step())).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('waitBudgetMs', () => {
|
|
70
|
+
it('defaults, and honours a step that waits on a just-opened modal', () => {
|
|
71
|
+
expect(waitBudgetMs(step())).toBe(DEFAULT_TARGET_WAIT_MS)
|
|
72
|
+
expect(waitBudgetMs(step({ waitForTargetMs: 8000 }))).toBe(8000)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('resolveSkip', () => {
|
|
77
|
+
it('continues forward past a missing anchor', () => {
|
|
78
|
+
expect(resolveSkip(1, 'forward', 4)).toEqual({ kind: 'move', index: 2 })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('completes the tour when the last step is the one that went missing', () => {
|
|
82
|
+
expect(resolveSkip(3, 'forward', 4)).toEqual({ kind: 'complete' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('keeps travelling BACK when the user is stepping backwards', () => {
|
|
86
|
+
// Otherwise Back onto a step this deployment does not render bounces the user
|
|
87
|
+
// straight forward again, making the button unusable exactly where it is needed.
|
|
88
|
+
expect(resolveSkip(2, 'back', 4)).toEqual({ kind: 'move', index: 1 })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('reverses to forward rather than pinning on the first step', () => {
|
|
92
|
+
expect(resolveSkip(0, 'back', 4)).toEqual({ kind: 'move', index: 1 })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('completes a single-step tour whose only anchor never appeared', () => {
|
|
96
|
+
expect(resolveSkip(0, 'back', 1)).toEqual({ kind: 'complete' })
|
|
97
|
+
expect(resolveSkip(0, 'forward', 1)).toEqual({ kind: 'complete' })
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('isTargetClickAdvance', () => {
|
|
102
|
+
const el = document.createElement('button')
|
|
103
|
+
const child = document.createElement('span')
|
|
104
|
+
el.appendChild(child)
|
|
105
|
+
|
|
106
|
+
it('advances on a real click on the highlighted control, or inside it', () => {
|
|
107
|
+
const s = step({ target: 'add-task-submit', advanceOn: 'target-click' })
|
|
108
|
+
expect(isTargetClickAdvance(s, el, el)).toBe(true)
|
|
109
|
+
expect(isTargetClickAdvance(s, el, child)).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('ignores clicks elsewhere, on a Next-advanced step, or with no anchor', () => {
|
|
113
|
+
const s = step({ target: 'add-task-submit', advanceOn: 'target-click' })
|
|
114
|
+
expect(isTargetClickAdvance(s, el, document.createElement('div'))).toBe(false)
|
|
115
|
+
expect(isTargetClickAdvance(s, null, el)).toBe(false)
|
|
116
|
+
expect(isTargetClickAdvance(step({ target: 'x' }), el, el)).toBe(false)
|
|
117
|
+
expect(isTargetClickAdvance(null, el, el)).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('tourWasAbridged', () => {
|
|
122
|
+
it('is true once any step was skipped, so the finish card can say so', () => {
|
|
123
|
+
expect(tourWasAbridged(new Set())).toBe(false)
|
|
124
|
+
expect(tourWasAbridged(new Set(['addTask']))).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { DEFAULT_TARGET_WAIT_MS } from '~/utils/tutorial'
|
|
2
|
+
import type { TutorialStep } from '~/utils/tutorial'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The tour runtime's decision logic, extracted from `TutorialOverlay.vue` so it is
|
|
6
|
+
* unit-testable (the vitest setup has no SFC transform — same split as
|
|
7
|
+
* `AppOverlayHost.logic.ts`). The component keeps the DOM work (querying, measuring,
|
|
8
|
+
* listeners); everything that DECIDES something lives here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which way the step cursor is travelling. It matters only for a step whose anchor
|
|
13
|
+
* never appears: a skip must continue in the direction the user was already going,
|
|
14
|
+
* or pressing Back onto a step whose control this deployment doesn't render would
|
|
15
|
+
* bounce them straight forward again — an unusable Back button.
|
|
16
|
+
*/
|
|
17
|
+
export type TutorialDirection = 'forward' | 'back'
|
|
18
|
+
|
|
19
|
+
/** What the overlay does with a step whose anchor never appeared. */
|
|
20
|
+
export type SkipOutcome = { kind: 'move'; index: number } | { kind: 'complete' }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a `data-testid` may look like. Every one of the ~470 test ids in this layer is
|
|
24
|
+
* lowercase kebab-case, and the e2e suite's convention keeps it that way, so this rejects
|
|
25
|
+
* nothing real — which is what makes it usable as a GUARD rather than as escaping.
|
|
26
|
+
*
|
|
27
|
+
* A tour is DATA and a consumer deployment authors its own, so an id reaches
|
|
28
|
+
* `querySelector` from outside this package. Escaping it is the obvious move and the wrong
|
|
29
|
+
* one: `[data-testid="a\"b"]` is valid CSS that real selector engines disagree about (it
|
|
30
|
+
* throws in happy-dom), so an id with a quote could still take down the tracking interval —
|
|
31
|
+
* several times a second, for as long as the tour runs. Validating the shape instead means
|
|
32
|
+
* no selector is ever built from a string that could break one, and an id that fails simply
|
|
33
|
+
* finds no anchor, which the runtime already handles as a skipped step.
|
|
34
|
+
*/
|
|
35
|
+
export const TARGET_ID_PATTERN = /^[a-z0-9-]+$/
|
|
36
|
+
|
|
37
|
+
/** Is this a well-formed anchor id, i.e. safe to put in a selector? */
|
|
38
|
+
export function isSafeTargetId(id: string): boolean {
|
|
39
|
+
return TARGET_ID_PATTERN.test(id)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `data-testid`s a step declares, in priority order (target first, then fallbacks). */
|
|
43
|
+
export function stepTargetIds(step: TutorialStep): string[] {
|
|
44
|
+
return [step.target, ...(step.altTargets ?? [])].filter((id): id is string => id !== undefined)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The selectors to try for a step: its declared ids, minus any that are malformed. */
|
|
48
|
+
export function stepTargetSelectors(step: TutorialStep): string[] {
|
|
49
|
+
return stepTargetIds(step)
|
|
50
|
+
.filter(isSafeTargetId)
|
|
51
|
+
.map((id) => `[data-testid="${id}"]`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How long this step may spend looking for its anchor before it is skipped. */
|
|
55
|
+
export function waitBudgetMs(step: TutorialStep): number {
|
|
56
|
+
return step.waitForTargetMs ?? DEFAULT_TARGET_WAIT_MS
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where the cursor goes when the current step's anchor never appeared.
|
|
61
|
+
*
|
|
62
|
+
* Travelling BACK off the first step has nowhere further back to go, so it reverses to
|
|
63
|
+
* forward rather than pinning the tour on an anchor that is never coming.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveSkip(
|
|
66
|
+
index: number,
|
|
67
|
+
direction: TutorialDirection,
|
|
68
|
+
total: number,
|
|
69
|
+
): SkipOutcome {
|
|
70
|
+
if (direction === 'back' && index > 0) return { kind: 'move', index: index - 1 }
|
|
71
|
+
return index + 1 < total ? { kind: 'move', index: index + 1 } : { kind: 'complete' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Does this real click count as the "now click this" step's advance? */
|
|
75
|
+
export function isTargetClickAdvance(
|
|
76
|
+
step: TutorialStep | null,
|
|
77
|
+
targetEl: { contains: (node: Node) => boolean } | null,
|
|
78
|
+
eventTarget: EventTarget | null,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (!step || step.advanceOn !== 'target-click' || !targetEl) return false
|
|
81
|
+
return eventTarget instanceof Node && targetEl.contains(eventTarget)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A tour is ABRIDGED when it reached its end having skipped steps: the controls those
|
|
86
|
+
* steps point at aren't part of this board/role/deployment. The final card says so
|
|
87
|
+
* instead of congratulating the user on a walkthrough they never saw — absent and
|
|
88
|
+
* complete must not render the same.
|
|
89
|
+
*/
|
|
90
|
+
export function tourWasAbridged(skippedStepIds: ReadonlySet<string>): boolean {
|
|
91
|
+
return skippedStepIds.size > 0
|
|
92
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
computeCoachMarkLayout,
|
|
4
|
+
DEFAULT_TARGET_WAIT_MS,
|
|
5
|
+
TARGET_TRACK_INTERVAL_MS,
|
|
6
|
+
} from '~/utils/tutorial'
|
|
7
|
+
import type { CoachMarkLayout, TutorialRect, TutorialStep } from '~/utils/tutorial'
|
|
8
|
+
import {
|
|
9
|
+
isTargetClickAdvance,
|
|
10
|
+
resolveSkip,
|
|
11
|
+
stepTargetSelectors,
|
|
12
|
+
tourWasAbridged,
|
|
13
|
+
waitBudgetMs,
|
|
14
|
+
} from './TutorialOverlay.logic'
|
|
15
|
+
import type { TutorialDirection } from './TutorialOverlay.logic'
|
|
16
|
+
|
|
17
|
+
// The one shared tour runtime: resolves the running tour from the `tutorialTours` slot,
|
|
18
|
+
// anchors a highlight ring + tooltip to the current step's `data-testid`, and advances
|
|
19
|
+
// on Next or on a real click on the highlighted control. Mounted (from `pages/index.vue`)
|
|
20
|
+
// only while `tutorial.touring`, so all the DOM tracking below exists only mid-tour.
|
|
21
|
+
//
|
|
22
|
+
// Anchor tracking is a poll, not a one-shot query: board controls move (canvas pan/zoom,
|
|
23
|
+
// panels opening) and appear asynchronously (a step can point INTO the modal the previous
|
|
24
|
+
// step's click opens), so every tick re-queries and re-measures. A target that never
|
|
25
|
+
// appears within the step's wait SKIPS the step — controls are RBAC/tier/deployment
|
|
26
|
+
// dependent, and a tour is a set of opportunities, not a fixed script. Everything that
|
|
27
|
+
// DECIDES rather than measures lives in `TutorialOverlay.logic.ts`, which is unit-tested.
|
|
28
|
+
const { t } = useI18n()
|
|
29
|
+
const tutorial = useTutorialStore()
|
|
30
|
+
const { tours } = useTutorialTours()
|
|
31
|
+
|
|
32
|
+
const tour = computed(() => tours.value.find((x) => x.id === tutorial.activeTourId) ?? null)
|
|
33
|
+
const step = computed<TutorialStep | null>(() => tour.value?.steps[tutorial.stepIndex] ?? null)
|
|
34
|
+
const total = computed(() => tour.value?.steps.length ?? 0)
|
|
35
|
+
const isLast = computed(() => tour.value !== null && tutorial.stepIndex >= total.value - 1)
|
|
36
|
+
|
|
37
|
+
// The running tour vanished from the slot (a gate flipped, a consumer module withdrew it)
|
|
38
|
+
// or the cursor ran past the end: end the tour instead of rendering a dead overlay.
|
|
39
|
+
watch(
|
|
40
|
+
() => [tour.value, step.value] as const,
|
|
41
|
+
([tr, st]) => {
|
|
42
|
+
if (tutorial.touring && (!tr || !st)) tutorial.stopTour()
|
|
43
|
+
},
|
|
44
|
+
{ immediate: true },
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const targetEl = ref<HTMLElement | null>(null)
|
|
48
|
+
const targetRect = ref<TutorialRect | null>(null)
|
|
49
|
+
const cardEl = ref<HTMLElement | null>(null)
|
|
50
|
+
const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'center' })
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wall-clock deadline for the current step's anchor search. A deadline rather than a
|
|
54
|
+
* per-tick counter: `measure()` also runs on resize and on every step change, so counting
|
|
55
|
+
* invocations let a resize drag burn a "4000 ms" budget in a fraction of that time.
|
|
56
|
+
*/
|
|
57
|
+
const searchDeadline = ref(0)
|
|
58
|
+
/** Which way `skipMissingStep` travels — see `resolveSkip`. */
|
|
59
|
+
const direction = ref<TutorialDirection>('forward')
|
|
60
|
+
/** Steps this run gave up on, so the final card can be honest about an abridged tour. */
|
|
61
|
+
const skippedStepIds = ref<Set<string>>(new Set())
|
|
62
|
+
|
|
63
|
+
/** A targeted step whose anchor hasn't been found yet (renders the waiting note). */
|
|
64
|
+
const searching = computed(() => step.value?.target !== undefined && targetRect.value === null)
|
|
65
|
+
const abridged = computed(() => tourWasAbridged(skippedStepIds.value))
|
|
66
|
+
|
|
67
|
+
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight })
|
|
68
|
+
/** The tooltip's own size, or a sensible guess before it has rendered once. */
|
|
69
|
+
const cardSize = () => ({
|
|
70
|
+
width: cardEl.value?.offsetWidth ?? 320,
|
|
71
|
+
height: cardEl.value?.offsetHeight ?? 180,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
function queryTarget(s: TutorialStep): HTMLElement | null {
|
|
75
|
+
for (const selector of stepTargetSelectors(s)) {
|
|
76
|
+
const el = document.querySelector<HTMLElement>(selector)
|
|
77
|
+
// `getClientRects().length` distinguishes a mounted-but-hidden control (display:none
|
|
78
|
+
// drawer item) from a visible one; pointing at an invisible control helps nobody.
|
|
79
|
+
if (el && el.getClientRects().length > 0) return el
|
|
80
|
+
}
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Give up on a step whose anchor never appeared, continuing the user's own direction. */
|
|
85
|
+
function skipMissingStep(s: TutorialStep) {
|
|
86
|
+
skippedStepIds.value = new Set(skippedStepIds.value).add(s.id)
|
|
87
|
+
const outcome = resolveSkip(tutorial.stepIndex, direction.value, total.value)
|
|
88
|
+
if (outcome.kind === 'complete') tutorial.completeTour()
|
|
89
|
+
else tutorial.setStepIndex(outcome.index)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function measure() {
|
|
93
|
+
const s = step.value
|
|
94
|
+
if (!s) return
|
|
95
|
+
if (!s.target) {
|
|
96
|
+
targetEl.value = null
|
|
97
|
+
targetRect.value = null
|
|
98
|
+
} else {
|
|
99
|
+
const el = queryTarget(s)
|
|
100
|
+
targetEl.value = el
|
|
101
|
+
if (!el) {
|
|
102
|
+
targetRect.value = null
|
|
103
|
+
// Centered while searching: the card must not sit at the PREVIOUS step's anchor —
|
|
104
|
+
// nor at its off-screen initial position — pointing at nothing.
|
|
105
|
+
layout.value = computeCoachMarkLayout(null, cardSize(), viewport())
|
|
106
|
+
if (performance.now() >= searchDeadline.value) skipMissingStep(s)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const r = el.getBoundingClientRect()
|
|
110
|
+
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
|
|
111
|
+
}
|
|
112
|
+
layout.value = computeCoachMarkLayout(
|
|
113
|
+
s.target ? targetRect.value : null,
|
|
114
|
+
cardSize(),
|
|
115
|
+
viewport(),
|
|
116
|
+
s.placement,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Re-arm per step: fresh wait budget, drop the stale anchor immediately (the ring must not
|
|
121
|
+
// linger on the previous control while the next anchor is located), and re-measure once
|
|
122
|
+
// the card has re-rendered its new copy (its size feeds the layout).
|
|
123
|
+
watch(step, async (s) => {
|
|
124
|
+
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
125
|
+
targetEl.value = null
|
|
126
|
+
targetRect.value = null
|
|
127
|
+
await nextTick()
|
|
128
|
+
measure()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function advance() {
|
|
132
|
+
direction.value = 'forward'
|
|
133
|
+
if (isLast.value) tutorial.completeTour()
|
|
134
|
+
else tutorial.setStepIndex(tutorial.stepIndex + 1)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function back() {
|
|
138
|
+
direction.value = 'back'
|
|
139
|
+
tutorial.setStepIndex(tutorial.stepIndex - 1)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// "Now click this" steps: watch real clicks (capture phase, so a stopPropagation inside a
|
|
143
|
+
// control can't hide them) and follow along AFTER the app has reacted — the deferral lets
|
|
144
|
+
// the real handler open its modal/submit its form before the tour moves its anchor.
|
|
145
|
+
function onDocumentClick(event: MouseEvent) {
|
|
146
|
+
if (isTargetClickAdvance(step.value, targetEl.value, event.target)) {
|
|
147
|
+
window.setTimeout(advance, 0)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Esc ends the tour, matching every other dismissible surface in the app. */
|
|
152
|
+
function onKeydown(event: KeyboardEvent) {
|
|
153
|
+
if (event.key === 'Escape') tutorial.stopTour()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let trackTimer: ReturnType<typeof setInterval> | undefined
|
|
157
|
+
onMounted(() => {
|
|
158
|
+
const s = step.value
|
|
159
|
+
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
160
|
+
document.addEventListener('click', onDocumentClick, true)
|
|
161
|
+
document.addEventListener('keydown', onKeydown)
|
|
162
|
+
window.addEventListener('resize', measure)
|
|
163
|
+
trackTimer = setInterval(measure, TARGET_TRACK_INTERVAL_MS)
|
|
164
|
+
measure()
|
|
165
|
+
})
|
|
166
|
+
onUnmounted(() => {
|
|
167
|
+
document.removeEventListener('click', onDocumentClick, true)
|
|
168
|
+
document.removeEventListener('keydown', onKeydown)
|
|
169
|
+
window.removeEventListener('resize', measure)
|
|
170
|
+
if (trackTimer !== undefined) clearInterval(trackTimer)
|
|
171
|
+
})
|
|
172
|
+
</script>
|
|
173
|
+
|
|
174
|
+
<template>
|
|
175
|
+
<!-- Teleported so board/panel stacking contexts can't clip the marks; z-[70] sits above
|
|
176
|
+
the app's modals (z-50s), since steps legitimately point INTO an open modal. -->
|
|
177
|
+
<Teleport to="body">
|
|
178
|
+
<div v-if="step" data-testid="tutorial-overlay">
|
|
179
|
+
<div
|
|
180
|
+
v-if="targetRect"
|
|
181
|
+
class="ring-primary-400 outline-primary-400/25 pointer-events-none fixed z-[70] rounded-lg outline-4 ring-2 transition-all duration-150"
|
|
182
|
+
:style="{
|
|
183
|
+
top: `${targetRect.top - 4}px`,
|
|
184
|
+
left: `${targetRect.left - 4}px`,
|
|
185
|
+
width: `${targetRect.width + 8}px`,
|
|
186
|
+
height: `${targetRect.height + 8}px`,
|
|
187
|
+
}"
|
|
188
|
+
data-testid="tutorial-highlight"
|
|
189
|
+
/>
|
|
190
|
+
<!-- `pointer-events-auto` AND the swallowed `pointerdown` are both required for the
|
|
191
|
+
steps that point INSIDE an open modal: Nuxt UI's modal is a reka-ui dismissable
|
|
192
|
+
layer, which sets `body { pointer-events: none }` (leaving this card inert) and
|
|
193
|
+
dismisses on a document-level pointerdown outside its own content (so a press on
|
|
194
|
+
this card would close the user's half-filled form instead of pressing a button). -->
|
|
195
|
+
<div
|
|
196
|
+
ref="cardEl"
|
|
197
|
+
role="dialog"
|
|
198
|
+
aria-live="polite"
|
|
199
|
+
:aria-label="t('tutorial.overlay.ariaLabel')"
|
|
200
|
+
class="pointer-events-auto fixed z-[70] w-80 max-w-[calc(100vw-16px)] rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
|
|
201
|
+
:style="{ top: `${layout.top}px`, left: `${layout.left}px` }"
|
|
202
|
+
data-testid="tutorial-tooltip"
|
|
203
|
+
@pointerdown.stop
|
|
204
|
+
>
|
|
205
|
+
<div class="mb-1 flex items-start justify-between gap-3">
|
|
206
|
+
<h3 class="text-sm font-semibold text-slate-100">{{ t(step.titleKey) }}</h3>
|
|
207
|
+
<span class="shrink-0 text-xs text-slate-500">
|
|
208
|
+
{{ t('tutorial.overlay.progress', { current: tutorial.stepIndex + 1, total }) }}
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
<p class="text-sm text-slate-300">{{ t(step.bodyKey) }}</p>
|
|
212
|
+
<p
|
|
213
|
+
v-if="searching"
|
|
214
|
+
class="mt-2 flex items-center gap-1.5 text-xs text-slate-400"
|
|
215
|
+
data-testid="tutorial-searching"
|
|
216
|
+
>
|
|
217
|
+
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
|
|
218
|
+
{{ t('tutorial.overlay.searching') }}
|
|
219
|
+
</p>
|
|
220
|
+
<p
|
|
221
|
+
v-else-if="step.advanceOn === 'target-click'"
|
|
222
|
+
class="text-primary-300 mt-2 text-xs"
|
|
223
|
+
data-testid="tutorial-click-hint"
|
|
224
|
+
>
|
|
225
|
+
{{ t('tutorial.overlay.clickHint') }}
|
|
226
|
+
</p>
|
|
227
|
+
<!-- Reaching the end having skipped steps is NOT the same as having been shown the
|
|
228
|
+
whole tour: say so rather than congratulating the user either way. -->
|
|
229
|
+
<p
|
|
230
|
+
v-if="isLast && abridged"
|
|
231
|
+
class="mt-2 text-xs text-amber-300/90"
|
|
232
|
+
data-testid="tutorial-abridged"
|
|
233
|
+
>
|
|
234
|
+
{{ t('tutorial.overlay.abridged', { count: skippedStepIds.size }, skippedStepIds.size) }}
|
|
235
|
+
</p>
|
|
236
|
+
<div class="mt-3 flex items-center justify-between gap-2">
|
|
237
|
+
<UButton
|
|
238
|
+
size="xs"
|
|
239
|
+
variant="ghost"
|
|
240
|
+
color="neutral"
|
|
241
|
+
data-testid="tutorial-skip"
|
|
242
|
+
@click="tutorial.stopTour()"
|
|
243
|
+
>
|
|
244
|
+
{{ t('tutorial.overlay.skip') }}
|
|
245
|
+
</UButton>
|
|
246
|
+
<div class="flex items-center gap-2">
|
|
247
|
+
<UButton
|
|
248
|
+
v-if="tutorial.stepIndex > 0"
|
|
249
|
+
size="xs"
|
|
250
|
+
variant="soft"
|
|
251
|
+
color="neutral"
|
|
252
|
+
data-testid="tutorial-back"
|
|
253
|
+
@click="back()"
|
|
254
|
+
>
|
|
255
|
+
{{ t('tutorial.overlay.back') }}
|
|
256
|
+
</UButton>
|
|
257
|
+
<!-- A click-to-advance step hides Next so the real control is the only way
|
|
258
|
+
forward (its Done form stays on a last step, which must be finishable). -->
|
|
259
|
+
<UButton
|
|
260
|
+
v-if="step.advanceOn !== 'target-click' || isLast"
|
|
261
|
+
size="xs"
|
|
262
|
+
color="primary"
|
|
263
|
+
data-testid="tutorial-next"
|
|
264
|
+
@click="advance()"
|
|
265
|
+
>
|
|
266
|
+
{{ isLast ? t('tutorial.overlay.done') : t('tutorial.overlay.next') }}
|
|
267
|
+
</UButton>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</Teleport>
|
|
273
|
+
</template>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The tutorial launch prompt: asks once on first launch whether the user wants a guided
|
|
3
|
+
// tour, and doubles as the tour picker for later visits (command palette: "Take a tour").
|
|
4
|
+
// Lists whatever the merged `tutorialTours` slot offers this user (first-party + consumer
|
|
5
|
+
// tours, RBAC-gated per tour), so it grows with the catalog rather than hard-coding tours.
|
|
6
|
+
//
|
|
7
|
+
// The decision semantics live in the store: starting a tour or "No thanks" is SAVED (the
|
|
8
|
+
// prompt never auto-opens again), while closing without answering defers to next launch.
|
|
9
|
+
const { t } = useI18n()
|
|
10
|
+
const tutorial = useTutorialStore()
|
|
11
|
+
const { tours } = useTutorialTours()
|
|
12
|
+
|
|
13
|
+
const open = computed({
|
|
14
|
+
get: () => tutorial.promptOpen,
|
|
15
|
+
set: (v: boolean) => (v ? tutorial.openPrompt() : tutorial.closePrompt()),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Only an unanswered prompt offers the persistent "No thanks"; once a decision exists this
|
|
19
|
+
// is just a picker, and the only dismissal left is a plain close.
|
|
20
|
+
const undecided = computed(() => tutorial.decision === null)
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<UModal v-model:open="open" :title="t('tutorial.prompt.title')" :ui="{ content: 'max-w-lg' }">
|
|
25
|
+
<template #body>
|
|
26
|
+
<!-- NOTE for spec authors: this marks the modal's BODY, so it is the "prompt is
|
|
27
|
+
showing" signal, not a container for everything in the prompt. The footer's
|
|
28
|
+
decline/close buttons are in a SIBLING slot — address them from the page by
|
|
29
|
+
their own test ids rather than scoping a locator under this one. -->
|
|
30
|
+
<div class="space-y-4" data-testid="tutorial-prompt">
|
|
31
|
+
<p class="text-sm text-slate-300">{{ t('tutorial.prompt.intro') }}</p>
|
|
32
|
+
<ul class="space-y-2">
|
|
33
|
+
<li
|
|
34
|
+
v-for="tour in tours"
|
|
35
|
+
:key="tour.id"
|
|
36
|
+
class="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
37
|
+
>
|
|
38
|
+
<UIcon
|
|
39
|
+
:name="tour.icon ?? 'i-lucide-compass'"
|
|
40
|
+
class="h-5 w-5 shrink-0 text-primary-400"
|
|
41
|
+
/>
|
|
42
|
+
<div class="min-w-0 flex-1">
|
|
43
|
+
<div class="flex items-center gap-2">
|
|
44
|
+
<span class="text-sm font-medium text-slate-100">{{ t(tour.titleKey) }}</span>
|
|
45
|
+
<UBadge
|
|
46
|
+
v-if="tutorial.isCompleted(tour.id)"
|
|
47
|
+
color="success"
|
|
48
|
+
variant="subtle"
|
|
49
|
+
size="sm"
|
|
50
|
+
data-testid="tutorial-tour-completed"
|
|
51
|
+
>
|
|
52
|
+
{{ t('tutorial.prompt.completed') }}
|
|
53
|
+
</UBadge>
|
|
54
|
+
</div>
|
|
55
|
+
<p class="text-xs text-slate-400">{{ t(tour.descriptionKey) }}</p>
|
|
56
|
+
</div>
|
|
57
|
+
<UButton
|
|
58
|
+
size="sm"
|
|
59
|
+
color="primary"
|
|
60
|
+
:variant="tutorial.isCompleted(tour.id) ? 'soft' : 'solid'"
|
|
61
|
+
:data-testid="`tutorial-start-${tour.id}`"
|
|
62
|
+
@click="tutorial.startTour(tour.id)"
|
|
63
|
+
>
|
|
64
|
+
{{
|
|
65
|
+
tutorial.isCompleted(tour.id)
|
|
66
|
+
? t('tutorial.prompt.restart')
|
|
67
|
+
: t('tutorial.prompt.start')
|
|
68
|
+
}}
|
|
69
|
+
</UButton>
|
|
70
|
+
</li>
|
|
71
|
+
</ul>
|
|
72
|
+
<!-- Every tour gated away (e.g. a viewer on a write-only catalog): say so rather
|
|
73
|
+
than showing an unexplained empty list. -->
|
|
74
|
+
<p v-if="tours.length === 0" class="text-sm text-slate-400">
|
|
75
|
+
{{ t('tutorial.prompt.empty') }}
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
|
79
|
+
<template #footer>
|
|
80
|
+
<div class="flex w-full items-center justify-between gap-2">
|
|
81
|
+
<UButton
|
|
82
|
+
v-if="undecided"
|
|
83
|
+
color="neutral"
|
|
84
|
+
variant="ghost"
|
|
85
|
+
data-testid="tutorial-decline"
|
|
86
|
+
@click="tutorial.decline()"
|
|
87
|
+
>
|
|
88
|
+
{{ t('tutorial.prompt.decline') }}
|
|
89
|
+
</UButton>
|
|
90
|
+
<span v-else />
|
|
91
|
+
<UButton
|
|
92
|
+
color="neutral"
|
|
93
|
+
variant="soft"
|
|
94
|
+
data-testid="tutorial-close"
|
|
95
|
+
@click="tutorial.closePrompt()"
|
|
96
|
+
>
|
|
97
|
+
{{ undecided ? t('tutorial.prompt.later') : t('common.close') }}
|
|
98
|
+
</UButton>
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
101
|
+
</UModal>
|
|
102
|
+
</template>
|