@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.
Files changed (47) hide show
  1. package/README.md +130 -10
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
  3. package/app/components/initiative/InitiativePlanReview.vue +11 -1
  4. package/app/components/panels/AgentStepDetail.vue +10 -0
  5. package/app/components/panels/ResultWindowShell.vue +86 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +54 -0
  8. package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
  9. package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
  10. package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
  11. package/app/components/tutorial/TutorialCatalogue.vue +150 -0
  12. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
  13. package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
  14. package/app/components/tutorial/TutorialOverlay.vue +296 -40
  15. package/app/components/tutorial/TutorialPrompt.vue +41 -22
  16. package/app/composables/useNavContributions.ts +4 -1
  17. package/app/composables/useTutorialLaunch.ts +50 -0
  18. package/app/composables/useTutorialTours.ts +37 -9
  19. package/app/docs/consumer-extensions.md +24 -11
  20. package/app/modular/agent-kinds.ts +6 -0
  21. package/app/modular/nav-contributions.spec.ts +7 -0
  22. package/app/modular/nav-contributions.ts +25 -13
  23. package/app/modular/slots.ts +5 -2
  24. package/app/modular/tutorial-tours.spec.ts +189 -53
  25. package/app/modular/tutorial-tours.ts +57 -8
  26. package/app/pages/index.vue +7 -2
  27. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  28. package/app/stores/pipelines/draftStepConfig.ts +38 -2
  29. package/app/stores/tutorial.spec.ts +167 -0
  30. package/app/stores/tutorial.ts +140 -4
  31. package/app/types/domain.ts +9 -0
  32. package/app/types/execution.ts +5 -0
  33. package/app/utils/binaryOutput.spec.ts +307 -0
  34. package/app/utils/binaryOutput.ts +343 -0
  35. package/app/utils/tutorial.spec.ts +179 -9
  36. package/app/utils/tutorial.ts +233 -22
  37. package/i18n/locales/de.json +89 -7
  38. package/i18n/locales/en.json +101 -7
  39. package/i18n/locales/es.json +89 -7
  40. package/i18n/locales/fr.json +89 -7
  41. package/i18n/locales/he.json +89 -7
  42. package/i18n/locales/it.json +89 -7
  43. package/i18n/locales/ja.json +89 -7
  44. package/i18n/locales/pl.json +89 -7
  45. package/i18n/locales/tr.json +89 -7
  46. package/i18n/locales/uk.json +89 -7
  47. 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 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.
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
- // is not offered at all) or the cursor ran past the end: end it instead of rendering a dead
63
- // overlay. Since the script is held, this can no longer fire because a gate flipped mid-tour.
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 && (!tr || !st)) tutorial.stopTour()
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
- const viewport = () => ({ width: window.innerWidth, height: window.innerHeight })
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
- // `getClientRects().length` distinguishes a mounted-but-hidden control (display:none
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 = queryTarget(s)
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(), viewport())
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
- targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
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
- viewport(),
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
- function advance() {
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) tutorial.completeTour()
159
- else tutorial.setStepIndex(tutorial.stepIndex + 1)
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
- /** Esc ends the tour, matching every other dismissible surface in the app. */
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
- let trackTimer: ReturnType<typeof setInterval> | undefined
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
- window.addEventListener('resize', measure)
188
- trackTimer = setInterval(measure, TARGET_TRACK_INTERVAL_MS)
189
- measure()
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
- window.removeEventListener('resize', measure)
195
- if (trackTimer !== undefined) clearInterval(trackTimer)
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
- <div v-if="step" data-testid="tutorial-overlay">
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
- aria-live="polite"
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">{{ t(step.bodyKey, step.bodyParams ?? {}) }}</p>
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
- // 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.
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"; once a decision exists this
19
- // is just a picker, and the only dismissal left is a plain close.
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.prompt.completed') }}
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="tutorial.isCompleted(tour.id) ? 'soft' : 'solid'"
70
+ :variant="actionFor(tour.id) === 'restart' ? 'soft' : 'solid'"
61
71
  :data-testid="`tutorial-start-${tour.id}`"
62
- @click="tutorial.startTour(tour.id)"
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
- <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>
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
- tutorial: () => useTutorialStore().openPrompt(),
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
+ }