@cat-factory/app 0.201.1 → 0.204.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 +130 -10
- package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
- package/app/components/initiative/InitiativePlanReview.vue +11 -1
- package/app/components/panels/AgentStepDetail.vue +10 -0
- package/app/components/panels/ResultWindowShell.vue +86 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
- package/app/components/pipeline/PipelineBuilder.vue +54 -0
- package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
- package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
- package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
- package/app/components/tutorial/TutorialCatalogue.vue +150 -0
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
- package/app/components/tutorial/TutorialOverlay.vue +296 -40
- package/app/components/tutorial/TutorialPrompt.vue +41 -22
- package/app/composables/useNavContributions.ts +4 -1
- package/app/composables/useTutorialLaunch.ts +50 -0
- package/app/composables/useTutorialTours.ts +37 -9
- package/app/docs/consumer-extensions.md +24 -11
- package/app/modular/agent-kinds.ts +6 -0
- package/app/modular/nav-contributions.spec.ts +7 -0
- package/app/modular/nav-contributions.ts +25 -13
- package/app/modular/slots.ts +5 -2
- package/app/modular/tutorial-tours.spec.ts +189 -53
- package/app/modular/tutorial-tours.ts +57 -8
- package/app/pages/index.vue +7 -2
- package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
- package/app/stores/pipelines/draftStepConfig.ts +38 -2
- package/app/stores/tutorial.spec.ts +167 -0
- package/app/stores/tutorial.ts +140 -4
- package/app/types/domain.ts +9 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/binaryOutput.spec.ts +307 -0
- package/app/utils/binaryOutput.ts +343 -0
- package/app/utils/tutorial.spec.ts +179 -9
- package/app/utils/tutorial.ts +233 -22
- package/i18n/locales/de.json +89 -7
- package/i18n/locales/en.json +101 -7
- package/i18n/locales/es.json +89 -7
- package/i18n/locales/fr.json +89 -7
- package/i18n/locales/he.json +89 -7
- package/i18n/locales/it.json +89 -7
- package/i18n/locales/ja.json +89 -7
- package/i18n/locales/pl.json +89 -7
- package/i18n/locales/tr.json +89 -7
- package/i18n/locales/uk.json +89 -7
- package/package.json +2 -2
|
@@ -1,33 +1,53 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { usePreferredReducedMotion } from '@vueuse/core'
|
|
2
3
|
import {
|
|
3
4
|
computeCoachMarkLayout,
|
|
4
5
|
DEFAULT_TARGET_WAIT_MS,
|
|
6
|
+
needsReveal,
|
|
7
|
+
TARGET_IDLE_INTERVAL_MS,
|
|
5
8
|
TARGET_TRACK_INTERVAL_MS,
|
|
6
9
|
} from '~/utils/tutorial'
|
|
7
10
|
import type { CoachMarkLayout, TutorialRect, TutorialStep, TutorialTour } from '~/utils/tutorial'
|
|
8
11
|
import {
|
|
12
|
+
boardNodeIdFor,
|
|
9
13
|
isTargetClickAdvance,
|
|
10
14
|
resolveSkip,
|
|
15
|
+
shouldFocusCard,
|
|
11
16
|
stepTargetSelectors,
|
|
12
17
|
unexpectedlySkippedSteps,
|
|
13
18
|
waitBudgetMs,
|
|
14
19
|
} from './TutorialOverlay.logic'
|
|
15
|
-
import type { TutorialDirection } from './TutorialOverlay.logic'
|
|
20
|
+
import type { TutorialAdvanceCause, TutorialDirection } from './TutorialOverlay.logic'
|
|
16
21
|
|
|
17
22
|
// The one shared tour runtime: resolves the running tour from the `tutorialTours` slot,
|
|
18
23
|
// anchors a highlight ring + tooltip to the current step's `data-testid`, and advances
|
|
19
24
|
// on Next or on a real click on the highlighted control. Mounted (from `pages/index.vue`)
|
|
20
25
|
// only while `tutorial.touring`, so all the DOM tracking below exists only mid-tour.
|
|
21
26
|
//
|
|
22
|
-
// Anchor tracking is
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
27
|
+
// Anchor tracking is not a one-shot query: board controls move (canvas pan/zoom, panels
|
|
28
|
+
// opening) and appear asynchronously (a step can point INTO the modal the previous step's
|
|
29
|
+
// click opens). So the runtime HUNTS for a not-yet-mounted anchor on a fast poll bounded by
|
|
30
|
+
// the step's wait budget, and then TRACKS the one it found off events (scroll, resize,
|
|
31
|
+
// element resize, board camera) with a slow backstop tick behind them. A target that never
|
|
32
|
+
// appears within the wait SKIPS the step — controls are RBAC/tier/deployment dependent, and
|
|
33
|
+
// a tour is a set of opportunities, not a fixed script.
|
|
34
|
+
//
|
|
35
|
+
// An anchor that is on the page but off SCREEN is revealed before it is pointed at, by
|
|
36
|
+
// whichever mechanism its container understands: the board is a transform-panned canvas
|
|
37
|
+
// (move the camera), everything else is an ordinary scroll container (`scrollIntoView`).
|
|
38
|
+
//
|
|
39
|
+
// Everything that DECIDES rather than measures lives in `TutorialOverlay.logic.ts`, and the
|
|
40
|
+
// geometry in `utils/tutorial.ts`; both are unit-tested, the SFC is not.
|
|
28
41
|
const { t } = useI18n()
|
|
29
42
|
const tutorial = useTutorialStore()
|
|
30
43
|
const { tours } = useTutorialTours()
|
|
44
|
+
const { fitView, viewport } = useBoardFlow()
|
|
45
|
+
// Reduced motion is honoured in BOTH directions here: the CSS below drops the ring's transition
|
|
46
|
+
// and the searching spinner behind `motion-safe:`, and this drives the JS half — an instant
|
|
47
|
+
// scroll and an instant camera move, since a reveal is involuntary motion the user did not ask
|
|
48
|
+
// for and is exactly what the preference is about.
|
|
49
|
+
const reducedMotion = usePreferredReducedMotion()
|
|
50
|
+
const motionMs = computed(() => (reducedMotion.value === 'reduce' ? 0 : 250))
|
|
31
51
|
|
|
32
52
|
/**
|
|
33
53
|
* The running tour's script, resolved ONCE from the slot when the tour starts and then HELD
|
|
@@ -58,18 +78,40 @@ const step = computed<TutorialStep | null>(() => tour.value?.steps[tutorial.step
|
|
|
58
78
|
const total = computed(() => tour.value?.steps.length ?? 0)
|
|
59
79
|
const isLast = computed(() => tour.value !== null && tutorial.stepIndex >= total.value - 1)
|
|
60
80
|
|
|
61
|
-
// The tour could not be resolved when it started (a stale persisted id, or a tour this board
|
|
62
|
-
//
|
|
63
|
-
//
|
|
81
|
+
// The tour could not be resolved when it started (a stale persisted id, or a tour this board is
|
|
82
|
+
// not offered at all): end it instead of rendering a dead overlay, and do NOT leave a resume
|
|
83
|
+
// point behind — it is a position in a script that could not be loaded, so offering it again
|
|
84
|
+
// would put the user straight back here. Since the script is held, this can no longer fire
|
|
85
|
+
// because a gate flipped mid-tour.
|
|
86
|
+
//
|
|
87
|
+
// A resolvable tour whose CURSOR is out of range is a different fact with a different fix: it
|
|
88
|
+
// means a resume landed past the end of a script the gates have thinned since. Rewind to the
|
|
89
|
+
// start rather than ending, or breaking off a tour would cost the user the tour itself.
|
|
64
90
|
watch(
|
|
65
91
|
() => [tour.value, step.value] as const,
|
|
66
92
|
([tr, st]) => {
|
|
67
|
-
if (tutorial.touring
|
|
93
|
+
if (!tutorial.touring) return
|
|
94
|
+
if (!tr) tutorial.stopTour({ resumable: false })
|
|
95
|
+
else if (!st) tutorial.setStepIndex(0)
|
|
68
96
|
},
|
|
69
97
|
{ immediate: true },
|
|
70
98
|
)
|
|
71
99
|
|
|
72
100
|
const targetRect = ref<TutorialRect | null>(null)
|
|
101
|
+
/**
|
|
102
|
+
* The resolved anchor, held between ticks. Event-driven re-measures (scroll, resize, canvas
|
|
103
|
+
* pan) reuse it rather than re-running the selector, and the slow backstop tick re-resolves —
|
|
104
|
+
* so a step still re-anchors when its control is replaced underneath it, without paying for a
|
|
105
|
+
* document query several times a second for the whole length of the tour.
|
|
106
|
+
*/
|
|
107
|
+
const anchorEl = ref<HTMLElement | null>(null)
|
|
108
|
+
/**
|
|
109
|
+
* The step index whose anchor has already been brought into view. A reveal is attempted at
|
|
110
|
+
* most ONCE per step: `fitView` and `scrollIntoView` are animations that take longer than a
|
|
111
|
+
* tracking tick, so re-deciding each tick would re-issue the move against a viewport still
|
|
112
|
+
* mid-flight and fight the user the moment they panned away deliberately.
|
|
113
|
+
*/
|
|
114
|
+
const revealedForStep = ref<number | null>(null)
|
|
73
115
|
const cardEl = ref<HTMLElement | null>(null)
|
|
74
116
|
const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'center' })
|
|
75
117
|
|
|
@@ -92,23 +134,73 @@ const unexpectedSkips = computed(() =>
|
|
|
92
134
|
)
|
|
93
135
|
const abridged = computed(() => unexpectedSkips.value.length > 0)
|
|
94
136
|
|
|
95
|
-
|
|
137
|
+
/**
|
|
138
|
+
* The browser viewport. Named apart from Vue Flow's `viewport` (the board CAMERA) above —
|
|
139
|
+
* and not `screen`, which would shadow the DOM global of that name for the whole component.
|
|
140
|
+
*/
|
|
141
|
+
const screenSize = () => ({ width: window.innerWidth, height: window.innerHeight })
|
|
96
142
|
/** The tooltip's own size, or a sensible guess before it has rendered once. */
|
|
97
143
|
const cardSize = () => ({
|
|
98
144
|
width: cardEl.value?.offsetWidth ?? 320,
|
|
99
145
|
height: cardEl.value?.offsetHeight ?? 180,
|
|
100
146
|
})
|
|
101
147
|
|
|
148
|
+
/** Is this element still in the document and still rendering a box? */
|
|
149
|
+
function isUsable(el: HTMLElement | null): el is HTMLElement {
|
|
150
|
+
// `getClientRects().length` distinguishes a mounted-but-hidden control (display:none
|
|
151
|
+
// drawer item) from a visible one; pointing at an invisible control helps nobody.
|
|
152
|
+
return el !== null && el.isConnected && el.getClientRects().length > 0
|
|
153
|
+
}
|
|
154
|
+
|
|
102
155
|
function queryTarget(s: TutorialStep): HTMLElement | null {
|
|
103
156
|
for (const selector of stepTargetSelectors(s)) {
|
|
104
157
|
const el = document.querySelector<HTMLElement>(selector)
|
|
105
|
-
|
|
106
|
-
// drawer item) from a visible one; pointing at an invisible control helps nobody.
|
|
107
|
-
if (el && el.getClientRects().length > 0) return el
|
|
158
|
+
if (isUsable(el)) return el
|
|
108
159
|
}
|
|
109
160
|
return null
|
|
110
161
|
}
|
|
111
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The anchor for this step. Reuses the held element unless `requery` is set or it has gone
|
|
165
|
+
* stale (unmounted, or hidden) — the two cases where it no longer describes anything on screen.
|
|
166
|
+
*/
|
|
167
|
+
function resolveAnchor(s: TutorialStep, requery: boolean): HTMLElement | null {
|
|
168
|
+
if (!requery && isUsable(anchorEl.value)) return anchorEl.value
|
|
169
|
+
anchorEl.value = queryTarget(s)
|
|
170
|
+
return anchorEl.value
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Bring an off-screen anchor into view, by whichever mechanism its container understands.
|
|
175
|
+
*
|
|
176
|
+
* The board is a transform-panned Vue Flow canvas, so a card that is off screen is not
|
|
177
|
+
* SCROLLED away and `scrollIntoView` on it does nothing at all; the camera has to move
|
|
178
|
+
* instead. Everything else — a panel's scroll container, a long modal body, the sidebar —
|
|
179
|
+
* is an ordinary scroll and `scrollIntoView` is exactly right.
|
|
180
|
+
*
|
|
181
|
+
* The camera move is clamped to the CURRENT zoom (`fitView` would otherwise zoom to fit a
|
|
182
|
+
* single small node, throwing away the user's own view of their board to point at a button).
|
|
183
|
+
*/
|
|
184
|
+
function revealAnchor(el: HTMLElement) {
|
|
185
|
+
const nodeId = boardNodeIdFor(el)
|
|
186
|
+
if (nodeId !== null) {
|
|
187
|
+
const zoom = viewport.value.zoom
|
|
188
|
+
fitView({
|
|
189
|
+
nodes: [nodeId],
|
|
190
|
+
padding: 0.3,
|
|
191
|
+
minZoom: zoom,
|
|
192
|
+
maxZoom: zoom,
|
|
193
|
+
duration: motionMs.value,
|
|
194
|
+
})
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
el.scrollIntoView({
|
|
198
|
+
behavior: motionMs.value === 0 ? 'auto' : 'smooth',
|
|
199
|
+
block: 'center',
|
|
200
|
+
inline: 'center',
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
112
204
|
/** Give up on a step whose anchor never appeared, continuing the user's own direction. */
|
|
113
205
|
function skipMissingStep(s: TutorialStep) {
|
|
114
206
|
skippedStepIds.value = new Set(skippedStepIds.value).add(s.id)
|
|
@@ -117,28 +209,44 @@ function skipMissingStep(s: TutorialStep) {
|
|
|
117
209
|
else tutorial.setStepIndex(outcome.index)
|
|
118
210
|
}
|
|
119
211
|
|
|
120
|
-
function measure() {
|
|
212
|
+
function measure(options?: { requery?: boolean }) {
|
|
121
213
|
const s = step.value
|
|
122
214
|
if (!s) return
|
|
123
215
|
if (!s.target) {
|
|
124
216
|
targetRect.value = null
|
|
125
217
|
} else {
|
|
126
|
-
const el =
|
|
218
|
+
const el = resolveAnchor(s, options?.requery === true)
|
|
127
219
|
if (!el) {
|
|
128
220
|
targetRect.value = null
|
|
129
221
|
// Centered while searching: the card must not sit at the PREVIOUS step's anchor —
|
|
130
222
|
// nor at its off-screen initial position — pointing at nothing.
|
|
131
|
-
layout.value = computeCoachMarkLayout(null, cardSize(),
|
|
223
|
+
layout.value = computeCoachMarkLayout(null, cardSize(), screenSize())
|
|
132
224
|
if (performance.now() >= searchDeadline.value) skipMissingStep(s)
|
|
133
225
|
return
|
|
134
226
|
}
|
|
135
227
|
const r = el.getBoundingClientRect()
|
|
136
|
-
|
|
228
|
+
const rect = { top: r.top, left: r.left, width: r.width, height: r.height }
|
|
229
|
+
// Reveal BEFORE publishing the rect, so the ring and tooltip are placed from the
|
|
230
|
+
// post-move position on the next tick rather than flashing at the off-screen one.
|
|
231
|
+
if (revealedForStep.value !== tutorial.stepIndex && needsReveal(rect, screenSize())) {
|
|
232
|
+
revealedForStep.value = tutorial.stepIndex
|
|
233
|
+
revealAnchor(el)
|
|
234
|
+
// Centre the card for the same reason the searching branch above does, and it is the
|
|
235
|
+
// same failure: returning without touching `layout` would render THIS step's copy at
|
|
236
|
+
// the PREVIOUS step's coordinates, pointing at a control the user has already left.
|
|
237
|
+
// Usually one frame, since the move emits scroll/camera events that re-enter here —
|
|
238
|
+
// but a reveal that moves nothing (a `fitView` over a node the canvas has dropped)
|
|
239
|
+
// emits none at all, and then it is the whole backstop tick.
|
|
240
|
+
layout.value = computeCoachMarkLayout(null, cardSize(), screenSize())
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
revealedForStep.value = tutorial.stepIndex
|
|
244
|
+
targetRect.value = rect
|
|
137
245
|
}
|
|
138
246
|
layout.value = computeCoachMarkLayout(
|
|
139
247
|
s.target ? targetRect.value : null,
|
|
140
248
|
cardSize(),
|
|
141
|
-
|
|
249
|
+
screenSize(),
|
|
142
250
|
s.placement,
|
|
143
251
|
)
|
|
144
252
|
}
|
|
@@ -149,19 +257,42 @@ function measure() {
|
|
|
149
257
|
watch(step, async (s) => {
|
|
150
258
|
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
151
259
|
targetRect.value = null
|
|
260
|
+
anchorEl.value = null
|
|
261
|
+
revealedForStep.value = null
|
|
152
262
|
await nextTick()
|
|
153
|
-
measure()
|
|
263
|
+
measure({ requery: true })
|
|
154
264
|
})
|
|
155
265
|
|
|
156
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Put focus on the tooltip so the tour's own controls are one Tab away. WHETHER to do that is
|
|
268
|
+
* `shouldFocusCard`'s call, in the logic module, so it is pinned by a test — this function is
|
|
269
|
+
* only the DOM half. `preventScroll` because the card is already placed.
|
|
270
|
+
*/
|
|
271
|
+
async function focusCard(cause: TutorialAdvanceCause) {
|
|
272
|
+
if (!shouldFocusCard(cause)) return
|
|
273
|
+
await nextTick()
|
|
274
|
+
cardEl.value?.focus({ preventScroll: true })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Move the cursor forward. The cause is REQUIRED rather than defaulted: it decides whether
|
|
279
|
+
* focus moves, and a new call site inheriting a default silently is exactly how a
|
|
280
|
+
* `target-click` advance came to steal focus from the modal it had just opened.
|
|
281
|
+
*/
|
|
282
|
+
function advance(cause: TutorialAdvanceCause) {
|
|
157
283
|
direction.value = 'forward'
|
|
158
|
-
if (isLast.value)
|
|
159
|
-
|
|
284
|
+
if (isLast.value) {
|
|
285
|
+
tutorial.completeTour()
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
tutorial.setStepIndex(tutorial.stepIndex + 1)
|
|
289
|
+
void focusCard(cause)
|
|
160
290
|
}
|
|
161
291
|
|
|
162
292
|
function back() {
|
|
163
293
|
direction.value = 'back'
|
|
164
294
|
tutorial.setStepIndex(tutorial.stepIndex - 1)
|
|
295
|
+
void focusCard('nav-control')
|
|
165
296
|
}
|
|
166
297
|
|
|
167
298
|
// "Now click this" steps: watch real clicks (capture phase, so a stopPropagation inside a
|
|
@@ -169,41 +300,157 @@ function back() {
|
|
|
169
300
|
// the real handler open its modal/submit its form before the tour moves its anchor.
|
|
170
301
|
function onDocumentClick(event: MouseEvent) {
|
|
171
302
|
if (isTargetClickAdvance(step.value, event.target)) {
|
|
172
|
-
window.setTimeout(advance, 0)
|
|
303
|
+
window.setTimeout(() => advance('target-click'), 0)
|
|
173
304
|
}
|
|
174
305
|
}
|
|
175
306
|
|
|
176
|
-
/**
|
|
307
|
+
/**
|
|
308
|
+
* Esc ends the tour, matching every other dismissible surface in the app — and, like Skip,
|
|
309
|
+
* leaves a resume point, because it is by far the easiest key to hit by accident and the
|
|
310
|
+
* position it discards is the entire walkthrough.
|
|
311
|
+
*/
|
|
177
312
|
function onKeydown(event: KeyboardEvent) {
|
|
178
313
|
if (event.key === 'Escape') tutorial.stopTour()
|
|
179
314
|
}
|
|
180
315
|
|
|
181
|
-
|
|
316
|
+
/**
|
|
317
|
+
* What a screen reader should be told about the current step.
|
|
318
|
+
*
|
|
319
|
+
* A dedicated `role="status"` region rather than `aria-live` on the card itself: the card is a
|
|
320
|
+
* `dialog` whose entire contents are replaced per step, and assistive tech does not reliably
|
|
321
|
+
* announce a wholesale subtree replacement inside a dialog. One key with placeholders, not
|
|
322
|
+
* concatenated fragments, per the i18n rules.
|
|
323
|
+
*
|
|
324
|
+
* This is also the SOLE announcement of the step — the card carries no `aria-describedby`,
|
|
325
|
+
* which would have the body read a second time on every focus move.
|
|
326
|
+
*/
|
|
327
|
+
const announcementText = computed(() =>
|
|
328
|
+
step.value
|
|
329
|
+
? t('tutorial.overlay.announcement', {
|
|
330
|
+
current: tutorial.stepIndex + 1,
|
|
331
|
+
total: total.value,
|
|
332
|
+
title: t(step.value.titleKey),
|
|
333
|
+
body: t(step.value.bodyKey, step.value.bodyParams ?? {}),
|
|
334
|
+
})
|
|
335
|
+
: '',
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* What the live region actually holds, lagging {@link announcementText} by a tick.
|
|
340
|
+
*
|
|
341
|
+
* Assistive tech announces a CHANGE to a live region, and routinely says nothing at all about
|
|
342
|
+
* one that was INSERTED already populated — the same unreliability that moved this out of the
|
|
343
|
+
* card in the first place. The overlay mounts with a step already resolved, so a region bound
|
|
344
|
+
* straight to the text above would arrive full and go unread, silently costing the first step
|
|
345
|
+
* of every tour. Publishing a tick later guarantees the empty region is in the DOM first, so
|
|
346
|
+
* the text is always a change to an existing node.
|
|
347
|
+
*/
|
|
348
|
+
const announcement = ref('')
|
|
349
|
+
watch(
|
|
350
|
+
announcementText,
|
|
351
|
+
async (text) => {
|
|
352
|
+
await nextTick()
|
|
353
|
+
announcement.value = text
|
|
354
|
+
},
|
|
355
|
+
{ immediate: true },
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
// Anchor tracking is EVENT-DRIVEN once an anchor is held, with a slow backstop tick behind it;
|
|
359
|
+
// only the hunt for a not-yet-mounted anchor polls fast, and that is bounded by the step's own
|
|
360
|
+
// wait budget. The events below are the ways a control that is already on screen can move:
|
|
361
|
+
// something scrolled (capture phase, so it catches every scroll container, not just the
|
|
362
|
+
// window), the window resized, the control itself resized, or the board camera panned/zoomed.
|
|
363
|
+
/**
|
|
364
|
+
* Re-measure from the held anchor — the cheap path every motion event takes — coalesced to at
|
|
365
|
+
* most once per frame.
|
|
366
|
+
*
|
|
367
|
+
* `measure()` READS layout (`getBoundingClientRect`, the card's offset size) and then WRITES
|
|
368
|
+
* it (the ring and tooltip styles), so running it per event thrashes layout. Capture-phase
|
|
369
|
+
* scroll is the one that makes this bite: it fires for every scroll container on the page and
|
|
370
|
+
* many times a frame under a momentum scroll, where the poll it replaced ran every 150 ms. A
|
|
371
|
+
* frame is also the most the user can see, so nothing is lost by waiting for one.
|
|
372
|
+
*/
|
|
373
|
+
let frameHandle = 0
|
|
374
|
+
function remeasure() {
|
|
375
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
376
|
+
measure()
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (frameHandle !== 0) return
|
|
380
|
+
frameHandle = requestAnimationFrame(() => {
|
|
381
|
+
frameHandle = 0
|
|
382
|
+
measure()
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let trackTimer: ReturnType<typeof setTimeout> | undefined
|
|
387
|
+
/** Re-resolve the selector on a cadence set by whether we currently HAVE an anchor. */
|
|
388
|
+
function scheduleTrack() {
|
|
389
|
+
const delay = targetRect.value ? TARGET_IDLE_INTERVAL_MS : TARGET_TRACK_INTERVAL_MS
|
|
390
|
+
trackTimer = setTimeout(() => {
|
|
391
|
+
measure({ requery: true })
|
|
392
|
+
scheduleTrack()
|
|
393
|
+
}, delay)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Follows the held anchor's own size changes (a card growing as its run progresses). */
|
|
397
|
+
const anchorResize =
|
|
398
|
+
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => remeasure())
|
|
399
|
+
watch(anchorEl, (el) => {
|
|
400
|
+
anchorResize?.disconnect()
|
|
401
|
+
if (el) anchorResize?.observe(el)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
// The board camera: a pan or zoom moves every canvas anchor without any scroll event at all.
|
|
405
|
+
watch(() => [viewport.value.x, viewport.value.y, viewport.value.zoom], remeasure)
|
|
406
|
+
|
|
182
407
|
onMounted(() => {
|
|
183
408
|
const s = step.value
|
|
184
409
|
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
185
410
|
document.addEventListener('click', onDocumentClick, true)
|
|
186
411
|
document.addEventListener('keydown', onKeydown)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
412
|
+
document.addEventListener('scroll', remeasure, { capture: true, passive: true })
|
|
413
|
+
window.addEventListener('resize', remeasure)
|
|
414
|
+
scheduleTrack()
|
|
415
|
+
measure({ requery: true })
|
|
416
|
+
void focusCard('tour-start')
|
|
190
417
|
})
|
|
191
418
|
onUnmounted(() => {
|
|
192
419
|
document.removeEventListener('click', onDocumentClick, true)
|
|
193
420
|
document.removeEventListener('keydown', onKeydown)
|
|
194
|
-
|
|
195
|
-
|
|
421
|
+
document.removeEventListener('scroll', remeasure, true)
|
|
422
|
+
window.removeEventListener('resize', remeasure)
|
|
423
|
+
anchorResize?.disconnect()
|
|
424
|
+
if (trackTimer !== undefined) clearTimeout(trackTimer)
|
|
425
|
+
if (frameHandle !== 0) cancelAnimationFrame(frameHandle)
|
|
196
426
|
})
|
|
197
427
|
</script>
|
|
198
428
|
|
|
199
429
|
<template>
|
|
200
430
|
<!-- Teleported so board/panel stacking contexts can't clip the marks; z-[70] sits above
|
|
201
|
-
the app's modals (z-50s), since steps legitimately point INTO an open modal
|
|
431
|
+
the app's modals (z-50s), since steps legitimately point INTO an open modal — with the
|
|
432
|
+
one exception of the tutorial's OWN windows (`ownWindowOpen`), which no step points into
|
|
433
|
+
and over which the same z-index would float a ring and a tooltip the user cannot use. -->
|
|
202
434
|
<Teleport to="body">
|
|
203
|
-
|
|
435
|
+
<!-- The step-change announcement. Visually hidden, and outside BOTH the dialog and the
|
|
436
|
+
`v-if` below, so the live region is a stable node whose TEXT changes for the whole
|
|
437
|
+
life of the overlay — never a subtree replaced wholesale, and never one inserted with
|
|
438
|
+
its content already in place. Assistive tech announces neither of those reliably. The
|
|
439
|
+
text itself also lands a tick after the node does; see `announcement`. -->
|
|
440
|
+
<div class="sr-only" role="status" aria-live="polite" data-testid="tutorial-announcement">
|
|
441
|
+
{{ announcement }}
|
|
442
|
+
</div>
|
|
443
|
+
<!-- SUPPRESSED, not unmounted, while a tutorial-owned window is open: this component holds
|
|
444
|
+
the running tour's resolved script (see `tour` above), and a remount would re-resolve it
|
|
445
|
+
against gates that may have flipped since the tour started — which is the very failure
|
|
446
|
+
that holding it fixed. The cursor, the tracking and the script all survive; only the
|
|
447
|
+
marks go, and they come back the moment the window closes. -->
|
|
448
|
+
<div v-if="step && !tutorial.ownWindowOpen" data-testid="tutorial-overlay">
|
|
449
|
+
<!-- `motion-safe:` on the ring's transition: it slides between controls on every step,
|
|
450
|
+
which is exactly the involuntary movement `prefers-reduced-motion` is about. -->
|
|
204
451
|
<div
|
|
205
452
|
v-if="targetRect"
|
|
206
|
-
class="ring-primary-400 outline-primary-400/25 pointer-events-none fixed z-[70] rounded-lg outline-4 ring-2 transition-all duration-150"
|
|
453
|
+
class="ring-primary-400 outline-primary-400/25 pointer-events-none fixed z-[70] rounded-lg outline-4 ring-2 motion-safe:transition-all motion-safe:duration-150"
|
|
207
454
|
:style="{
|
|
208
455
|
top: `${targetRect.top - 4}px`,
|
|
209
456
|
left: `${targetRect.left - 4}px`,
|
|
@@ -217,12 +464,19 @@ onUnmounted(() => {
|
|
|
217
464
|
layer, which sets `body { pointer-events: none }` (leaving this card inert) and
|
|
218
465
|
dismisses on a document-level pointerdown outside its own content (so a press on
|
|
219
466
|
this card would close the user's half-filled form instead of pressing a button). -->
|
|
467
|
+
<!-- `tabindex="-1"` so `focusCard()` can put focus here when the tour starts and on every
|
|
468
|
+
Next/Back — without it a keyboard user has to tab the whole page to reach Next, since
|
|
469
|
+
this is teleported to the end of `body`. No `aria-modal`: a coach mark is NOT modal,
|
|
470
|
+
and half the catalog asks the user to operate the real control behind it. No
|
|
471
|
+
`aria-describedby` on the body either: the live region above already reads the body
|
|
472
|
+
as part of a complete announcement, and pointing at it here would have every focus
|
|
473
|
+
move read it a second time. -->
|
|
220
474
|
<div
|
|
221
475
|
ref="cardEl"
|
|
222
476
|
role="dialog"
|
|
223
|
-
|
|
477
|
+
tabindex="-1"
|
|
224
478
|
:aria-label="t('tutorial.overlay.ariaLabel')"
|
|
225
|
-
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"
|
|
479
|
+
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 outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
|
226
480
|
:style="{ top: `${layout.top}px`, left: `${layout.left}px` }"
|
|
227
481
|
data-testid="tutorial-tooltip"
|
|
228
482
|
@pointerdown.stop
|
|
@@ -236,13 +490,15 @@ onUnmounted(() => {
|
|
|
236
490
|
<!-- `bodyParams` carries the fixed proper nouns a step names (a repository slug),
|
|
237
491
|
which live in the catalog's `{named}` placeholders rather than in nine
|
|
238
492
|
translations of the same literal. Absent for most steps. -->
|
|
239
|
-
<p class="text-sm text-slate-300">
|
|
493
|
+
<p class="text-sm text-slate-300">
|
|
494
|
+
{{ t(step.bodyKey, step.bodyParams ?? {}) }}
|
|
495
|
+
</p>
|
|
240
496
|
<p
|
|
241
497
|
v-if="searching"
|
|
242
498
|
class="mt-2 flex items-center gap-1.5 text-xs text-slate-400"
|
|
243
499
|
data-testid="tutorial-searching"
|
|
244
500
|
>
|
|
245
|
-
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
|
|
501
|
+
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 motion-safe:animate-spin" />
|
|
246
502
|
{{ t('tutorial.overlay.searching') }}
|
|
247
503
|
</p>
|
|
248
504
|
<p
|
|
@@ -295,7 +551,7 @@ onUnmounted(() => {
|
|
|
295
551
|
size="xs"
|
|
296
552
|
color="primary"
|
|
297
553
|
data-testid="tutorial-next"
|
|
298
|
-
@click="advance()"
|
|
554
|
+
@click="advance('nav-control')"
|
|
299
555
|
>
|
|
300
556
|
{{ isLast ? t('tutorial.overlay.done') : t('tutorial.overlay.next') }}
|
|
301
557
|
</UButton>
|
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// The tutorial launch prompt: asks once on first launch whether the user wants a guided
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// tours
|
|
2
|
+
// The tutorial launch prompt: asks once on first launch whether the user wants a guided tour,
|
|
3
|
+
// listing the tours this board can actually run right now (first-party + consumer, resolved
|
|
4
|
+
// against the same gates the nav uses), so it grows with the catalog rather than hard-coding
|
|
5
|
+
// tours.
|
|
6
|
+
//
|
|
7
|
+
// It is the OFFER, not the library: the full list — including the walkthroughs this board
|
|
8
|
+
// can't run yet and what would unlock them — is `TutorialCatalogue.vue`, one button away in
|
|
9
|
+
// the footer and permanently reachable from the sidebar's Help section. That split is why
|
|
10
|
+
// this stays a short, answerable question instead of growing into a browsing surface.
|
|
6
11
|
//
|
|
7
12
|
// The decision semantics live in the store: starting a tour or "No thanks" is SAVED (the
|
|
8
13
|
// prompt never auto-opens again), while closing without answering defers to next launch.
|
|
14
|
+
import { TUTORIAL_ACTION_KEYS } from '~/utils/tutorial'
|
|
15
|
+
|
|
9
16
|
const { t } = useI18n()
|
|
10
17
|
const tutorial = useTutorialStore()
|
|
11
18
|
const { tours } = useTutorialTours()
|
|
19
|
+
// Start / Resume / Repeat is decided in one place for both surfaces — see `useTutorialLaunch`.
|
|
20
|
+
const { actionFor, launch } = useTutorialLaunch()
|
|
12
21
|
|
|
13
22
|
const open = computed({
|
|
14
23
|
get: () => tutorial.promptOpen,
|
|
15
24
|
set: (v: boolean) => (v ? tutorial.openPrompt() : tutorial.closePrompt()),
|
|
16
25
|
})
|
|
17
26
|
|
|
18
|
-
// Only an unanswered prompt offers the persistent "No thanks"
|
|
19
|
-
//
|
|
27
|
+
// Only an unanswered prompt offers the persistent "No thanks": there is a decision to save.
|
|
28
|
+
// Once one exists — this window can still be opened by the store — the only dismissal left is
|
|
29
|
+
// a plain close, since declining something already answered would write nothing new.
|
|
20
30
|
const undecided = computed(() => tutorial.decision === null)
|
|
21
31
|
</script>
|
|
22
32
|
|
|
@@ -49,7 +59,7 @@ const undecided = computed(() => tutorial.decision === null)
|
|
|
49
59
|
size="sm"
|
|
50
60
|
data-testid="tutorial-tour-completed"
|
|
51
61
|
>
|
|
52
|
-
{{ t('tutorial.
|
|
62
|
+
{{ t('tutorial.status.completed') }}
|
|
53
63
|
</UBadge>
|
|
54
64
|
</div>
|
|
55
65
|
<p class="text-xs text-slate-400">{{ t(tour.descriptionKey) }}</p>
|
|
@@ -57,15 +67,11 @@ const undecided = computed(() => tutorial.decision === null)
|
|
|
57
67
|
<UButton
|
|
58
68
|
size="sm"
|
|
59
69
|
color="primary"
|
|
60
|
-
:variant="
|
|
70
|
+
:variant="actionFor(tour.id) === 'restart' ? 'soft' : 'solid'"
|
|
61
71
|
:data-testid="`tutorial-start-${tour.id}`"
|
|
62
|
-
@click="
|
|
72
|
+
@click="launch(tour.id)"
|
|
63
73
|
>
|
|
64
|
-
{{
|
|
65
|
-
tutorial.isCompleted(tour.id)
|
|
66
|
-
? t('tutorial.prompt.restart')
|
|
67
|
-
: t('tutorial.prompt.start')
|
|
68
|
-
}}
|
|
74
|
+
{{ t(TUTORIAL_ACTION_KEYS[actionFor(tour.id)]) }}
|
|
69
75
|
</UButton>
|
|
70
76
|
</li>
|
|
71
77
|
</ul>
|
|
@@ -88,14 +94,27 @@ const undecided = computed(() => tutorial.decision === null)
|
|
|
88
94
|
{{ t('tutorial.prompt.decline') }}
|
|
89
95
|
</UButton>
|
|
90
96
|
<span v-else />
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
<div class="flex items-center gap-2">
|
|
98
|
+
<!-- The way to the tours this board can't run yet, and to the ones already taken.
|
|
99
|
+
Browsing answers nothing, so it neither declines the offer nor accepts it. -->
|
|
100
|
+
<UButton
|
|
101
|
+
color="neutral"
|
|
102
|
+
variant="ghost"
|
|
103
|
+
icon="i-lucide-graduation-cap"
|
|
104
|
+
data-testid="tutorial-browse"
|
|
105
|
+
@click="tutorial.openCatalogue()"
|
|
106
|
+
>
|
|
107
|
+
{{ t('tutorial.prompt.browse') }}
|
|
108
|
+
</UButton>
|
|
109
|
+
<UButton
|
|
110
|
+
color="neutral"
|
|
111
|
+
variant="soft"
|
|
112
|
+
data-testid="tutorial-close"
|
|
113
|
+
@click="tutorial.closePrompt()"
|
|
114
|
+
>
|
|
115
|
+
{{ undecided ? t('tutorial.prompt.later') : t('common.close') }}
|
|
116
|
+
</UButton>
|
|
117
|
+
</div>
|
|
99
118
|
</div>
|
|
100
119
|
</template>
|
|
101
120
|
</UModal>
|
|
@@ -59,7 +59,10 @@ export function useNavContributions() {
|
|
|
59
59
|
operatorDashboard: () => ui.openOperatorDashboard(),
|
|
60
60
|
reports: () => ui.openReports(),
|
|
61
61
|
shortcuts: () => ui.openShortcutsHelp(),
|
|
62
|
-
|
|
62
|
+
// The CATALOGUE, not the launch prompt: reaching this from the sidebar or the palette is
|
|
63
|
+
// "show me the walkthroughs", and the prompt answers a narrower question (would you like
|
|
64
|
+
// one now?) that a returning user has already answered once.
|
|
65
|
+
tutorial: () => useTutorialStore().openCatalogue(),
|
|
63
66
|
// No-op under an env pin (`setMode` refuses), so the palette entry matches the sidebar
|
|
64
67
|
// switcher's read-only state rather than pretending to flip a tier the resolver fixes.
|
|
65
68
|
toggleUiMode: () => useUiModeStore().toggleMode(),
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { launchActionFor, tourState } from '~/utils/tutorial'
|
|
2
|
+
import type { TutorialLaunchAction, TutorialTourState } from '~/utils/tutorial'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Starting a tour, from whichever surface offers it.
|
|
6
|
+
*
|
|
7
|
+
* Both the launch prompt and the catalogue answer the same question per tour — start it,
|
|
8
|
+
* resume where it was broken off, take it again, or step back into the one already running —
|
|
9
|
+
* and getting that precedence subtly different between the two surfaces would show up as the
|
|
10
|
+
* same button doing different things on two screens. So the decision is made once here, over
|
|
11
|
+
* the pure {@link tourState} / {@link launchActionFor} pair, and each surface renders it.
|
|
12
|
+
*/
|
|
13
|
+
export function useTutorialLaunch() {
|
|
14
|
+
const tutorial = useTutorialStore()
|
|
15
|
+
|
|
16
|
+
/** Where this tour stands for this user right now. */
|
|
17
|
+
function stateOf(tourId: string): TutorialTourState {
|
|
18
|
+
return tourState({
|
|
19
|
+
active: tutorial.activeTourId === tourId,
|
|
20
|
+
resumable: tutorial.interruptedAt(tourId) !== null,
|
|
21
|
+
completed: tutorial.isCompleted(tourId),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What this tour's button will do — also what labels it. */
|
|
26
|
+
function actionFor(tourId: string): TutorialLaunchAction {
|
|
27
|
+
return launchActionFor(stateOf(tourId))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Act on that. `continue` only steps out of the way: the overlay for that tour is already
|
|
32
|
+
* on screen, so restarting it from step one — which is what a plain `startTour` would do —
|
|
33
|
+
* would throw away the position of the walkthrough the user was pointing at.
|
|
34
|
+
*/
|
|
35
|
+
function launch(tourId: string): void {
|
|
36
|
+
switch (actionFor(tourId)) {
|
|
37
|
+
case 'continue':
|
|
38
|
+
tutorial.closeCatalogue()
|
|
39
|
+
tutorial.closePrompt()
|
|
40
|
+
return
|
|
41
|
+
case 'resume':
|
|
42
|
+
tutorial.resumeTour(tourId)
|
|
43
|
+
return
|
|
44
|
+
default:
|
|
45
|
+
tutorial.startTour(tourId)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { stateOf, actionFor, launch }
|
|
50
|
+
}
|