@cat-factory/app 0.201.0 → 0.202.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.
@@ -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,30 +300,129 @@ 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
 
@@ -200,10 +430,20 @@ onUnmounted(() => {
200
430
  <!-- Teleported so board/panel stacking contexts can't clip the marks; z-[70] sits above
201
431
  the app's modals (z-50s), since steps legitimately point INTO an open modal. -->
202
432
  <Teleport to="body">
433
+ <!-- The step-change announcement. Visually hidden, and outside BOTH the dialog and the
434
+ `v-if` below, so the live region is a stable node whose TEXT changes for the whole
435
+ life of the overlay — never a subtree replaced wholesale, and never one inserted with
436
+ its content already in place. Assistive tech announces neither of those reliably. The
437
+ text itself also lands a tick after the node does; see `announcement`. -->
438
+ <div class="sr-only" role="status" aria-live="polite" data-testid="tutorial-announcement">
439
+ {{ announcement }}
440
+ </div>
203
441
  <div v-if="step" data-testid="tutorial-overlay">
442
+ <!-- `motion-safe:` on the ring's transition: it slides between controls on every step,
443
+ which is exactly the involuntary movement `prefers-reduced-motion` is about. -->
204
444
  <div
205
445
  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"
446
+ 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
447
  :style="{
208
448
  top: `${targetRect.top - 4}px`,
209
449
  left: `${targetRect.left - 4}px`,
@@ -217,12 +457,19 @@ onUnmounted(() => {
217
457
  layer, which sets `body { pointer-events: none }` (leaving this card inert) and
218
458
  dismisses on a document-level pointerdown outside its own content (so a press on
219
459
  this card would close the user's half-filled form instead of pressing a button). -->
460
+ <!-- `tabindex="-1"` so `focusCard()` can put focus here when the tour starts and on every
461
+ Next/Back — without it a keyboard user has to tab the whole page to reach Next, since
462
+ this is teleported to the end of `body`. No `aria-modal`: a coach mark is NOT modal,
463
+ and half the catalog asks the user to operate the real control behind it. No
464
+ `aria-describedby` on the body either: the live region above already reads the body
465
+ as part of a complete announcement, and pointing at it here would have every focus
466
+ move read it a second time. -->
220
467
  <div
221
468
  ref="cardEl"
222
469
  role="dialog"
223
- aria-live="polite"
470
+ tabindex="-1"
224
471
  :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"
472
+ 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
473
  :style="{ top: `${layout.top}px`, left: `${layout.left}px` }"
227
474
  data-testid="tutorial-tooltip"
228
475
  @pointerdown.stop
@@ -236,13 +483,15 @@ onUnmounted(() => {
236
483
  <!-- `bodyParams` carries the fixed proper nouns a step names (a repository slug),
237
484
  which live in the catalog's `{named}` placeholders rather than in nine
238
485
  translations of the same literal. Absent for most steps. -->
239
- <p class="text-sm text-slate-300">{{ t(step.bodyKey, step.bodyParams ?? {}) }}</p>
486
+ <p class="text-sm text-slate-300">
487
+ {{ t(step.bodyKey, step.bodyParams ?? {}) }}
488
+ </p>
240
489
  <p
241
490
  v-if="searching"
242
491
  class="mt-2 flex items-center gap-1.5 text-xs text-slate-400"
243
492
  data-testid="tutorial-searching"
244
493
  >
245
- <UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
494
+ <UIcon name="i-lucide-loader" class="h-3.5 w-3.5 motion-safe:animate-spin" />
246
495
  {{ t('tutorial.overlay.searching') }}
247
496
  </p>
248
497
  <p
@@ -295,7 +544,7 @@ onUnmounted(() => {
295
544
  size="xs"
296
545
  color="primary"
297
546
  data-testid="tutorial-next"
298
- @click="advance()"
547
+ @click="advance('nav-control')"
299
548
  >
300
549
  {{ isLast ? t('tutorial.overlay.done') : t('tutorial.overlay.next') }}
301
550
  </UButton>
@@ -18,6 +18,22 @@ const open = computed({
18
18
  // Only an unanswered prompt offers the persistent "No thanks"; once a decision exists this
19
19
  // is just a picker, and the only dismissal left is a plain close.
20
20
  const undecided = computed(() => tutorial.decision === null)
21
+
22
+ // A tour broken off mid-way (Esc, or Skip to get the overlay out of the way) offers to RESUME
23
+ // where it stopped rather than only to start over. Session-scoped, so this is only ever offered
24
+ // while the board is still in the state the tour left it in — see the store.
25
+ const isResumable = (tourId: string) => tutorial.interruptedAt(tourId) !== null
26
+
27
+ function launch(tourId: string) {
28
+ if (isResumable(tourId)) tutorial.resumeTour(tourId)
29
+ else tutorial.startTour(tourId)
30
+ }
31
+
32
+ /** Resume beats Completed: a tour taken again and broken off is offered where it stopped. */
33
+ function launchLabel(tourId: string): string {
34
+ if (isResumable(tourId)) return t('tutorial.prompt.resume')
35
+ return tutorial.isCompleted(tourId) ? t('tutorial.prompt.restart') : t('tutorial.prompt.start')
36
+ }
21
37
  </script>
22
38
 
23
39
  <template>
@@ -57,15 +73,11 @@ const undecided = computed(() => tutorial.decision === null)
57
73
  <UButton
58
74
  size="sm"
59
75
  color="primary"
60
- :variant="tutorial.isCompleted(tour.id) ? 'soft' : 'solid'"
76
+ :variant="tutorial.isCompleted(tour.id) && !isResumable(tour.id) ? 'soft' : 'solid'"
61
77
  :data-testid="`tutorial-start-${tour.id}`"
62
- @click="tutorial.startTour(tour.id)"
78
+ @click="launch(tour.id)"
63
79
  >
64
- {{
65
- tutorial.isCompleted(tour.id)
66
- ? t('tutorial.prompt.restart')
67
- : t('tutorial.prompt.start')
68
- }}
80
+ {{ launchLabel(tour.id) }}
69
81
  </UButton>
70
82
  </li>
71
83
  </ul>
@@ -1,3 +1,5 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
1
3
  import { describe, expect, it } from 'vitest'
2
4
  import en from '../../i18n/locales/en.json'
3
5
  import { TUTORIAL_TOURS, tutorialToursModule } from '~/modular/tutorial-tours'
@@ -51,6 +53,72 @@ function lookupKey(key: string): unknown {
51
53
  .reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en)
52
54
  }
53
55
 
56
+ /**
57
+ * The layer's srcDir. Anchored on the package directory rather than on `import.meta.url`,
58
+ * which under the happy-dom test environment is not a `file:` URL at all.
59
+ */
60
+ const SRC_DIR = join(process.cwd(), 'app')
61
+
62
+ /**
63
+ * The two ways this layer names a test id, both of which a tour may legitimately anchor on.
64
+ *
65
+ * - written straight onto an element, in either quoting style, including the bound form
66
+ * (`:data-testid="'foo'"`);
67
+ * - declared as a `testId` field on a DATA contribution — the nav catalog's items carry one
68
+ * and `SideBar.vue` renders it as `:data-testid="item.testId"`, which is how the whole
69
+ * `nav-*` family (`nav-add-from-repo` among them) reaches the DOM.
70
+ *
71
+ * A template literal (`` `tutorial-start-${id}` ``) matches neither, which is correct: that is
72
+ * not an id, it is a family of them, and no built-in step anchors on one.
73
+ */
74
+ const ID_PATTERNS = [
75
+ /data-testid\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
76
+ /\btestId\s*:\s*(?:'([^']*)'|"([^"]*)")/g,
77
+ ]
78
+
79
+ /**
80
+ * Every `.vue`/`.ts` file the layer SHIPS. Test sources are excluded in both spellings: an id
81
+ * that exists only in a spec or a fixture is not rendered by anything, so counting one would
82
+ * let the guard pass on a tour anchored to a control that no longer exists.
83
+ */
84
+ function walk(dir: string): string[] {
85
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
86
+ const path = join(dir, entry.name)
87
+ if (entry.isDirectory()) return walk(path)
88
+ return /\.(vue|ts)$/.test(entry.name) && !/\.(spec|test)\.ts$/.test(entry.name) ? [path] : []
89
+ })
90
+ }
91
+
92
+ /** Every anchor the built-in catalog declares, labelled with the step that declares it. */
93
+ function declaredAnchors(): { label: string; id: string }[] {
94
+ const out: { label: string; id: string }[] = []
95
+ for (const tour of TUTORIAL_TOURS) {
96
+ for (const s of tour.steps) {
97
+ for (const id of [s.target, ...(s.altTargets ?? [])]) {
98
+ if (id !== undefined) out.push({ label: `${tour.id}/${s.id}: ${id}`, id })
99
+ }
100
+ }
101
+ }
102
+ return out
103
+ }
104
+
105
+ /** Every static test id this layer actually renders. */
106
+ function renderedTestIds(): Set<string> {
107
+ const ids = new Set<string>()
108
+ for (const file of walk(SRC_DIR)) {
109
+ // The catalog itself declares the ids under test; counting it would make this vacuous.
110
+ if (file.endsWith(join('modular', 'tutorial-tours.ts'))) continue
111
+ const source = readFileSync(file, 'utf8')
112
+ for (const pattern of ID_PATTERNS) {
113
+ for (const match of source.matchAll(pattern)) {
114
+ const raw = (match[1] ?? match[2] ?? '').trim().replace(/^['"]|['"]$/g, '')
115
+ if (isSafeTargetId(raw)) ids.add(raw)
116
+ }
117
+ }
118
+ }
119
+ return ids
120
+ }
121
+
54
122
  describe('the built-in tutorial tour catalog', () => {
55
123
  it('has unique tour ids and unique step ids within each tour', () => {
56
124
  const tourIds = TUTORIAL_TOURS.map((t) => t.id)
@@ -92,19 +160,38 @@ describe('the built-in tutorial tour catalog', () => {
92
160
  })
93
161
 
94
162
  it('names plain data-testid values as targets, never selectors', () => {
95
- for (const tour of TUTORIAL_TOURS) {
96
- for (const s of tour.steps) {
97
- for (const target of [s.target, ...(s.altTargets ?? [])]) {
98
- if (target === undefined) continue
99
- // Asserted through the runtime's OWN guard, not a copy of its regex: the overlay
100
- // drops an id this rejects, so a built-in tour that tripped it would silently
101
- // lose the step rather than fail here.
102
- expect(isSafeTargetId(target), `${tour.id}/${s.id}: ${target}`).toBe(true)
103
- }
104
- }
163
+ for (const anchor of declaredAnchors()) {
164
+ // Asserted through the runtime's OWN guard, not a copy of its regex: the overlay
165
+ // drops an id this rejects, so a built-in tour that tripped it would silently
166
+ // lose the step rather than fail here.
167
+ expect(isSafeTargetId(anchor.id), anchor.label).toBe(true)
105
168
  }
106
169
  })
107
170
 
171
+ it('anchors every step on a data-testid this layer actually renders', () => {
172
+ // The drift guard. A tour's anchors are the ONE thing about it that nothing else in the
173
+ // build checks: a renamed `data-testid` passes typecheck, lint and the whole e2e suite,
174
+ // and five of the ids below (`nav-add-from-repo`, `add-service-repo-search`,
175
+ // `add-service-submit`, `pipeline-picker-trigger`, `inspector-merge-pr`) have no other
176
+ // consumer at all — the tour is the only thing that names them.
177
+ //
178
+ // The failure it prevents is worse than a dead step. None of those steps carries a `when`,
179
+ // so `unexpectedlySkippedSteps` counts the miss and EVERY user who takes that tour lands on
180
+ // a permanent "you missed N steps" notice: the tour would go on making a false claim about
181
+ // itself, in production, with nothing red anywhere.
182
+ //
183
+ // Scoped to the built-in catalog on purpose — a consumer deployment's tours anchor on
184
+ // controls that live in ITS layer, which this repo cannot see and must not fail over.
185
+ const rendered = renderedTestIds()
186
+ // Guard the guard: a scan that silently matched nothing would pass every assertion below.
187
+ expect(rendered.size).toBeGreaterThan(100)
188
+
189
+ const missing = declaredAnchors()
190
+ .filter((anchor) => !rendered.has(anchor.id))
191
+ .map((anchor) => anchor.label)
192
+ expect(missing).toEqual([])
193
+ })
194
+
108
195
  it('is contributed to the tutorialTours slot by the module', () => {
109
196
  expect(tutorialToursModule.slots?.tutorialTours).toEqual([...TUTORIAL_TOURS])
110
197
  })