@cat-factory/app 0.197.0 → 0.198.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,4 +1,5 @@
1
1
  import { computed } from 'vue'
2
+ import { hasActionablePark } from '~/modular/nav-gates.logic'
2
3
  import type { NavGates } from '~/modular/nav-contributions'
3
4
 
4
5
  /**
@@ -26,9 +27,36 @@ export function createNavGates(): NavGates {
26
27
  const providerConnections = useProviderConnectionsStore()
27
28
  const uiMode = useUiModeStore()
28
29
  const board = useBoardStore()
30
+ const execution = useExecutionStore()
31
+ const reviews = useReviewStage()
29
32
 
30
33
  // A top-level frame IS a service (see `app/types/domain.ts`); modules are sub-frames.
31
34
  const hasService = computed(() => board.blocks.some((b) => b.level === 'frame' && !b.parentId))
35
+ const hasTask = computed(() => board.blocks.some((b) => b.level === 'task'))
36
+
37
+ // Every run gate below is scoped to runs on TASK blocks, because every surface they gate is
38
+ // reached through a task card and its inspector (the card's Resolve action, the inspector's
39
+ // step list and result views). A frame-level run — a blueprint pass, an initiative plan —
40
+ // renders none of those, so counting it would offer a tour onto controls that do not exist.
41
+ const taskBlockIds = computed(
42
+ () => new Set(board.blocks.filter((b) => b.level === 'task').map((b) => b.id)),
43
+ )
44
+ const isTaskBlock = (blockId: string) => taskBlockIds.value.has(blockId)
45
+
46
+ const hasRun = computed(() => execution.instances.some((e) => isTaskBlock(e.blockId)))
47
+ // A run that finished successfully. `done` only: see `NavGates.boardHasFinishedRun` for
48
+ // why a `failed` run is not a subject for the review/merge tour.
49
+ const hasFinishedRun = computed(() =>
50
+ execution.instances.some((e) => e.status === 'done' && isTaskBlock(e.blockId)),
51
+ )
52
+ // Not the store's raw pending counts: those answer "is anything parked", while the tour
53
+ // these gate anchors on the card affordance a park RENDERS. See `hasActionablePark`.
54
+ const hasOpenDecision = computed(() =>
55
+ hasActionablePark(execution.openDecisions, isTaskBlock, reviews.isBackground),
56
+ )
57
+ const hasPendingApproval = computed(() =>
58
+ hasActionablePark(execution.openApprovals, isTaskBlock, reviews.isBackground),
59
+ )
32
60
 
33
61
  const infrastructureAvailable = computed(
34
62
  () =>
@@ -72,5 +100,24 @@ export function createNavGates(): NavGates {
72
100
  get boardHasService() {
73
101
  return hasService.value
74
102
  },
103
+ get boardHasTask() {
104
+ return hasTask.value
105
+ },
106
+ get boardHasRun() {
107
+ return hasRun.value
108
+ },
109
+ // The two park kinds read the execution store's existing open-decision / open-approval
110
+ // projections rather than re-scanning `instances` here, so a tour can never disagree with
111
+ // the queue that sent the user looking for it — then narrowed to the parks a task card
112
+ // actually offers an action for (`hasActionablePark`).
113
+ get boardHasOpenDecision() {
114
+ return hasOpenDecision.value
115
+ },
116
+ get boardHasPendingApproval() {
117
+ return hasPendingApproval.value
118
+ },
119
+ get boardHasFinishedRun() {
120
+ return hasFinishedRun.value
121
+ },
75
122
  }
76
123
  }
@@ -14,6 +14,11 @@ const NO_GATES: NavGates = {
14
14
  isAccountAdmin: false,
15
15
  advancedMode: false,
16
16
  boardHasService: false,
17
+ boardHasTask: false,
18
+ boardHasRun: false,
19
+ boardHasOpenDecision: false,
20
+ boardHasPendingApproval: false,
21
+ boardHasFinishedRun: false,
17
22
  }
18
23
 
19
24
  describe('app modular registry', () => {
@@ -16,6 +16,26 @@ const ALL_GATES: NavGates = {
16
16
  isAccountAdmin: true,
17
17
  advancedMode: true,
18
18
  boardHasService: true,
19
+ boardHasTask: true,
20
+ boardHasRun: true,
21
+ boardHasOpenDecision: true,
22
+ boardHasPendingApproval: true,
23
+ boardHasFinishedRun: true,
24
+ }
25
+
26
+ /**
27
+ * A workspace on its very first launch: fully permitted, source control wired, and nothing
28
+ * on the board yet. This is the state the launch prompt auto-opens in, so it is the gate set
29
+ * most of the availability cases below vary from.
30
+ */
31
+ const FRESH_BOARD: NavGates = {
32
+ ...ALL_GATES,
33
+ boardHasService: false,
34
+ boardHasTask: false,
35
+ boardHasRun: false,
36
+ boardHasOpenDecision: false,
37
+ boardHasPendingApproval: false,
38
+ boardHasFinishedRun: false,
19
39
  }
20
40
 
21
41
  const slots = (): AppSlots =>
@@ -58,6 +78,19 @@ describe('the built-in tutorial tour catalog', () => {
58
78
  }
59
79
  })
60
80
 
81
+ it('supplies a param for every placeholder its copy names, and no unused ones', () => {
82
+ // A body naming `{repo}` with no `bodyParams` renders the literal braces to the user,
83
+ // and a param with no placeholder is dead weight the next copy edit trips over. Both
84
+ // are invisible until someone runs that exact step, so they are pinned here.
85
+ for (const tour of TUTORIAL_TOURS) {
86
+ for (const s of tour.steps) {
87
+ const body = lookupKey(s.bodyKey)
88
+ const named = new Set([...String(body).matchAll(/\{(\w+)\}/g)].map((m) => m[1]))
89
+ expect(new Set(Object.keys(s.bodyParams ?? {})), `${tour.id}/${s.id}`).toEqual(named)
90
+ }
91
+ }
92
+ })
93
+
61
94
  it('names plain data-testid values as targets, never selectors', () => {
62
95
  for (const tour of TUTORIAL_TOURS) {
63
96
  for (const s of tour.steps) {
@@ -96,8 +129,37 @@ describe('navSlotFilter over tutorialTours', () => {
96
129
  // have taught the core loop; `board-basics` is what an empty board can deliver.
97
130
  const emptyBoard: NavGates = { ...ALL_GATES, boardHasService: false }
98
131
  const filtered = navSlotFilter(slots(), { gates: emptyBoard })
99
- const ids = filtered.tutorialTours.map((t) => t.id)
100
- expect(ids).toEqual(['board-basics'])
132
+ expect(filtered.tutorialTours.map((t) => t.id)).not.toContain('first-task')
133
+ })
134
+
135
+ it('offers a brand-new board the orientation tour AND the way out of being empty', () => {
136
+ // The state the launch prompt actually auto-opens in. Orientation alone would leave a
137
+ // new workspace with a tour of an empty canvas and no route to a first service, which
138
+ // is what `add-service` exists to fix — so it must survive exactly this gate set.
139
+ const filtered = navSlotFilter(slots(), { gates: FRESH_BOARD })
140
+ expect(filtered.tutorialTours.map((t) => t.id)).toEqual(['board-basics', 'add-service'])
141
+ })
142
+
143
+ it('drops the repo tour when no source-control connection can list repositories', () => {
144
+ const noSource: NavGates = { ...FRESH_BOARD, githubAvailable: false }
145
+ expect(navSlotFilter(slots(), { gates: noSource }).tutorialTours.map((t) => t.id)).toEqual([
146
+ 'board-basics',
147
+ ])
148
+ })
149
+
150
+ it('offers the run tour once a task exists, and the review tour once a run finished', () => {
151
+ const withTask: NavGates = { ...FRESH_BOARD, boardHasService: true, boardHasTask: true }
152
+ expect(navSlotFilter(slots(), { gates: withTask }).tutorialTours.map((t) => t.id)).toContain(
153
+ 'run-task',
154
+ )
155
+ expect(
156
+ navSlotFilter(slots(), { gates: withTask }).tutorialTours.map((t) => t.id),
157
+ ).not.toContain('review-merge')
158
+
159
+ const finished: NavGates = { ...withTask, boardHasRun: true, boardHasFinishedRun: true }
160
+ expect(navSlotFilter(slots(), { gates: finished }).tutorialTours.map((t) => t.id)).toContain(
161
+ 'review-merge',
162
+ )
101
163
  })
102
164
 
103
165
  it('passes tours through untouched when no gates service is wired', () => {
@@ -105,3 +167,56 @@ describe('navSlotFilter over tutorialTours', () => {
105
167
  expect(filtered.tutorialTours.map((t) => t.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
106
168
  })
107
169
  })
170
+
171
+ describe('the parked-run tour branches', () => {
172
+ const stepIds = (gates: NavGates, tourId: string) =>
173
+ navSlotFilter(slots(), { gates })
174
+ .tutorialTours.find((t) => t.id === tourId)
175
+ ?.steps.map((s) => s.id)
176
+
177
+ it('is not offered while nothing is waiting for a human', () => {
178
+ const ids = navSlotFilter(slots(), { gates: FRESH_BOARD }).tutorialTours.map((t) => t.id)
179
+ expect(ids).not.toContain('answer-park')
180
+ })
181
+
182
+ it('shows the decision branch only, for a run parked on a decision', () => {
183
+ const decision: NavGates = { ...FRESH_BOARD, boardHasOpenDecision: true }
184
+ expect(stepIds(decision, 'answer-park')).toEqual(['intro', 'resolve', 'decide', 'finish'])
185
+ })
186
+
187
+ it('shows the approval branch only, for a run parked on an approval', () => {
188
+ const approval: NavGates = { ...FRESH_BOARD, boardHasPendingApproval: true }
189
+ expect(stepIds(approval, 'answer-park')).toEqual(['intro', 'resolve', 'approve', 'finish'])
190
+ })
191
+
192
+ it('follows the card`s own precedence when a board has both', () => {
193
+ // `TaskCard.attention` prefers a decision, so the Resolve click lands on the decision
194
+ // modal: an approval step here would point at a control this click never opens, and
195
+ // the tour would report itself abridged on a board that showed exactly the right thing.
196
+ const both: NavGates = {
197
+ ...FRESH_BOARD,
198
+ boardHasOpenDecision: true,
199
+ boardHasPendingApproval: true,
200
+ }
201
+ expect(stepIds(both, 'answer-park')).toEqual(['intro', 'resolve', 'decide', 'finish'])
202
+ })
203
+
204
+ it('drops the run-anatomy step until a run exists to point at', () => {
205
+ const noRun: NavGates = { ...FRESH_BOARD, boardHasService: true, boardHasTask: true }
206
+ expect(stepIds(noRun, 'run-task')).not.toContain('steps')
207
+ expect(stepIds({ ...noRun, boardHasRun: true }, 'run-task')).toContain('steps')
208
+ })
209
+
210
+ it('keeps every branch when no gates service is wired', () => {
211
+ // Same dev-open parity as `nav`: with nothing to gate against, nothing is withheld —
212
+ // including the per-step branches, which a bare install must not silently thin out.
213
+ const answerPark = navSlotFilter(slots(), {}).tutorialTours.find((t) => t.id === 'answer-park')
214
+ expect(answerPark?.steps.map((s) => s.id)).toEqual([
215
+ 'intro',
216
+ 'resolve',
217
+ 'decide',
218
+ 'approve',
219
+ 'finish',
220
+ ])
221
+ })
222
+ })
@@ -21,8 +21,28 @@ import type { TutorialTour } from '~/utils/tutorial'
21
21
  * next step anchors to. Steps whose anchor only exists after that response give it a
22
22
  * longer `waitForTargetMs`.
23
23
  * - Copy lives under `tutorial.tours.<tourCamelId>.steps.<stepId>` in the i18n catalogs;
24
- * tours never carry display strings.
24
+ * tours never carry display strings. The exception is a fixed proper noun such as
25
+ * {@link SAMPLE_REPO}, which rides `bodyParams` so it is written once here rather than
26
+ * translated ten times.
27
+ * - A step whose branch of the flow this board simply isn't on declares `when`, so it is
28
+ * DROPPED rather than skipped: a skip is reported as an abridged tour, and a parked run
29
+ * that has a decision and no approval gate is not an abridged anything.
30
+ *
31
+ * Together the tours below walk the delivery loop end to end — get a repo onto the board,
32
+ * put a task on it, run it, answer it when it asks, read the result and merge it — with each
33
+ * later tour gated on the state the previous one produces, so the launch prompt only ever
34
+ * offers what this board can actually demonstrate.
25
35
  */
36
+
37
+ /**
38
+ * The practice project the tours point at: a deliberately small Hono service, kept
39
+ * unfinished on purpose so there is always a real task to hand an agent.
40
+ *
41
+ * Named in code rather than in `en.json` because it is a repository slug: it must not be
42
+ * translated, and nine other catalogs would each hold their own copy of it to drift.
43
+ */
44
+ export const SAMPLE_REPO = 'kibertoad/cat-factory-sample-repository'
45
+
26
46
  export const TUTORIAL_TOURS: readonly TutorialTour[] = [
27
47
  {
28
48
  id: 'board-basics',
@@ -64,6 +84,19 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
64
84
  titleKey: 'tutorial.tours.boardBasics.steps.toolbar.title',
65
85
  bodyKey: 'tutorial.tours.boardBasics.steps.toolbar.body',
66
86
  },
87
+ {
88
+ // Basic mode is the shipped default, and it HIDES a whole half of the product
89
+ // (sandbox, Kaizen, bootstrap, the operator surfaces). A user who never finds the
90
+ // switcher never learns that half exists, so the orientation tour is the one place
91
+ // that has to name it — the switcher is deliberately visible in both tiers for the
92
+ // same reason (see `nav-contributions.ts`).
93
+ id: 'interfaceTier',
94
+ target: 'ui-mode-switcher',
95
+ altTargets: ['ui-mode-toggle'],
96
+ placement: 'right',
97
+ titleKey: 'tutorial.tours.boardBasics.steps.interfaceTier.title',
98
+ bodyKey: 'tutorial.tours.boardBasics.steps.interfaceTier.body',
99
+ },
67
100
  {
68
101
  id: 'finish',
69
102
  titleKey: 'tutorial.tours.boardBasics.steps.finish.title',
@@ -71,6 +104,62 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
71
104
  },
72
105
  ],
73
106
  },
107
+ {
108
+ id: 'add-service',
109
+ order: 15,
110
+ icon: 'i-lucide-folder-git-2',
111
+ titleKey: 'tutorial.tours.addService.title',
112
+ descriptionKey: 'tutorial.tours.addService.description',
113
+ // The tour that unblocks every later one: `first-task` needs a service frame to add a
114
+ // task to, and until one exists a new workspace is offered `board-basics` and nothing
115
+ // else — an orientation tour of an empty canvas ending on "you're all set". Linking a
116
+ // repo is a board write against a connected source, and in basic interface mode
117
+ // add-from-repo is the ONLY route (bootstrap is advanced), which is what makes this
118
+ // worth a tour rather than a hint.
119
+ when: (gates) => gates.canWriteBoard && gates.githubAvailable,
120
+ steps: [
121
+ {
122
+ id: 'intro',
123
+ titleKey: 'tutorial.tours.addService.steps.intro.title',
124
+ bodyKey: 'tutorial.tours.addService.steps.intro.body',
125
+ },
126
+ {
127
+ id: 'open',
128
+ target: 'nav-add-from-repo',
129
+ advanceOn: 'target-click',
130
+ placement: 'right',
131
+ titleKey: 'tutorial.tours.addService.steps.open.title',
132
+ bodyKey: 'tutorial.tours.addService.steps.open.body',
133
+ },
134
+ {
135
+ id: 'search',
136
+ target: 'add-service-repo-search',
137
+ // Inside the modal the previous click opens.
138
+ waitForTargetMs: 8000,
139
+ placement: 'bottom',
140
+ titleKey: 'tutorial.tours.addService.steps.search.title',
141
+ bodyKey: 'tutorial.tours.addService.steps.search.body',
142
+ // The sample repo is a suggestion, not an instruction: the picker lists what this
143
+ // workspace's installation can see, and someone practising on their own repo is
144
+ // doing the right thing too. The copy says as much — a step that told them to pick
145
+ // a repo they may not have would strand them at exactly this anchor.
146
+ bodyParams: { repo: SAMPLE_REPO },
147
+ },
148
+ {
149
+ id: 'add',
150
+ target: 'add-service-submit',
151
+ advanceOn: 'target-click',
152
+ placement: 'top',
153
+ titleKey: 'tutorial.tours.addService.steps.add.title',
154
+ bodyKey: 'tutorial.tours.addService.steps.add.body',
155
+ },
156
+ {
157
+ id: 'finish',
158
+ titleKey: 'tutorial.tours.addService.steps.finish.title',
159
+ bodyKey: 'tutorial.tours.addService.steps.finish.body',
160
+ },
161
+ ],
162
+ },
74
163
  {
75
164
  id: 'first-task',
76
165
  order: 20,
@@ -137,6 +226,191 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
137
226
  },
138
227
  ],
139
228
  },
229
+ {
230
+ id: 'run-task',
231
+ order: 30,
232
+ icon: 'i-lucide-play',
233
+ titleKey: 'tutorial.tours.runTask.title',
234
+ descriptionKey: 'tutorial.tours.runTask.description',
235
+ // Everything that MAKES this a delivery platform lives one click inside a task: the
236
+ // pipeline it will run, the start control, the live step list. `first-task` stops at the
237
+ // card, so without this tour a user who finished the shipped walkthrough has never seen
238
+ // the inspector. Needs a task to open, not merely a service to hold one.
239
+ when: (gates) => gates.canWriteBoard && gates.boardHasTask,
240
+ steps: [
241
+ {
242
+ id: 'intro',
243
+ titleKey: 'tutorial.tours.runTask.steps.intro.title',
244
+ bodyKey: 'tutorial.tours.runTask.steps.intro.body',
245
+ },
246
+ {
247
+ id: 'openTask',
248
+ target: 'task-card',
249
+ advanceOn: 'target-click',
250
+ placement: 'right',
251
+ titleKey: 'tutorial.tours.runTask.steps.openTask.title',
252
+ bodyKey: 'tutorial.tours.runTask.steps.openTask.body',
253
+ },
254
+ {
255
+ id: 'inspector',
256
+ target: 'inspector-panel',
257
+ waitForTargetMs: 8000,
258
+ placement: 'left',
259
+ titleKey: 'tutorial.tours.runTask.steps.inspector.title',
260
+ bodyKey: 'tutorial.tours.runTask.steps.inspector.body',
261
+ },
262
+ {
263
+ id: 'pipeline',
264
+ target: 'pipeline-picker-trigger',
265
+ placement: 'left',
266
+ titleKey: 'tutorial.tours.runTask.steps.pipeline.title',
267
+ bodyKey: 'tutorial.tours.runTask.steps.pipeline.body',
268
+ },
269
+ {
270
+ // Deliberately NOT `target-click`, unlike every other "here is the button" step in
271
+ // the catalog: starting a run spends the workspace's model budget for real. The
272
+ // tour points the control out and hands the decision back, so nobody discovers they
273
+ // agreed to a paid run by following a tutorial.
274
+ id: 'start',
275
+ target: 'run-start',
276
+ placement: 'left',
277
+ titleKey: 'tutorial.tours.runTask.steps.start.title',
278
+ bodyKey: 'tutorial.tours.runTask.steps.start.body',
279
+ },
280
+ {
281
+ // The anatomy of a run in flight. Dropped on a board that has never run anything —
282
+ // there is no step list to point at, and that is a state, not an omission.
283
+ id: 'steps',
284
+ target: 'run-step',
285
+ when: (gates) => gates.boardHasRun,
286
+ waitForTargetMs: 6000,
287
+ placement: 'left',
288
+ titleKey: 'tutorial.tours.runTask.steps.steps.title',
289
+ bodyKey: 'tutorial.tours.runTask.steps.steps.body',
290
+ },
291
+ {
292
+ id: 'finish',
293
+ titleKey: 'tutorial.tours.runTask.steps.finish.title',
294
+ bodyKey: 'tutorial.tours.runTask.steps.finish.body',
295
+ },
296
+ ],
297
+ },
298
+ {
299
+ id: 'answer-park',
300
+ order: 40,
301
+ icon: 'i-lucide-circle-help',
302
+ titleKey: 'tutorial.tours.answerPark.title',
303
+ descriptionKey: 'tutorial.tours.answerPark.description',
304
+ // The one gap with a standing cost attached: a parked run waits for a human
305
+ // INDEFINITELY by design (there is deliberately no park timeout), so a user who does not
306
+ // realise a run is asking them something has a run that never finishes and a workspace
307
+ // in-flight slot held open. Offered only while something is actually waiting, because
308
+ // the whole tour anchors on controls that exist only then.
309
+ when: (gates) => gates.boardHasOpenDecision || gates.boardHasPendingApproval,
310
+ steps: [
311
+ {
312
+ id: 'intro',
313
+ titleKey: 'tutorial.tours.answerPark.steps.intro.title',
314
+ bodyKey: 'tutorial.tours.answerPark.steps.intro.body',
315
+ },
316
+ {
317
+ // The card's own attention action, which resolves to whichever surface this park
318
+ // needs — one anchor for both branches, rather than a decision route and an approval
319
+ // route the tour would have to choose between before the user has clicked anything.
320
+ id: 'resolve',
321
+ target: 'task-resolve',
322
+ advanceOn: 'target-click',
323
+ placement: 'right',
324
+ titleKey: 'tutorial.tours.answerPark.steps.resolve.title',
325
+ bodyKey: 'tutorial.tours.answerPark.steps.resolve.body',
326
+ },
327
+ {
328
+ id: 'decide',
329
+ target: 'decision-option',
330
+ when: (gates) => gates.boardHasOpenDecision,
331
+ waitForTargetMs: 8000,
332
+ placement: 'top',
333
+ titleKey: 'tutorial.tours.answerPark.steps.decide.title',
334
+ bodyKey: 'tutorial.tours.answerPark.steps.decide.body',
335
+ },
336
+ {
337
+ // Mirrors the card's own precedence (`TaskCard.attention` prefers a decision when a
338
+ // block has both), so this branch is included exactly when the click above will
339
+ // really land on an approval — not merely when an approval exists somewhere.
340
+ id: 'approve',
341
+ target: 'step-approve',
342
+ when: (gates) => gates.boardHasPendingApproval && !gates.boardHasOpenDecision,
343
+ waitForTargetMs: 8000,
344
+ placement: 'top',
345
+ titleKey: 'tutorial.tours.answerPark.steps.approve.title',
346
+ bodyKey: 'tutorial.tours.answerPark.steps.approve.body',
347
+ },
348
+ {
349
+ id: 'finish',
350
+ titleKey: 'tutorial.tours.answerPark.steps.finish.title',
351
+ bodyKey: 'tutorial.tours.answerPark.steps.finish.body',
352
+ },
353
+ ],
354
+ },
355
+ {
356
+ id: 'review-merge',
357
+ order: 50,
358
+ icon: 'i-lucide-git-merge',
359
+ titleKey: 'tutorial.tours.reviewMerge.title',
360
+ descriptionKey: 'tutorial.tours.reviewMerge.description',
361
+ // The last mile: a task is only DONE when its PR actually merged, so a user who never
362
+ // finds the result and the merge control has a board full of finished-looking work that
363
+ // shipped nothing. Its subject is a run's output, so it needs a run that produced one.
364
+ when: (gates) => gates.boardHasFinishedRun,
365
+ steps: [
366
+ {
367
+ id: 'intro',
368
+ titleKey: 'tutorial.tours.reviewMerge.steps.intro.title',
369
+ bodyKey: 'tutorial.tours.reviewMerge.steps.intro.body',
370
+ },
371
+ {
372
+ id: 'openTask',
373
+ target: 'task-card',
374
+ advanceOn: 'target-click',
375
+ placement: 'right',
376
+ titleKey: 'tutorial.tours.reviewMerge.steps.openTask.title',
377
+ bodyKey: 'tutorial.tours.reviewMerge.steps.openTask.body',
378
+ },
379
+ {
380
+ id: 'openStep',
381
+ target: 'run-step',
382
+ advanceOn: 'target-click',
383
+ waitForTargetMs: 8000,
384
+ placement: 'left',
385
+ titleKey: 'tutorial.tours.reviewMerge.steps.openStep.title',
386
+ bodyKey: 'tutorial.tours.reviewMerge.steps.openStep.body',
387
+ },
388
+ {
389
+ id: 'result',
390
+ target: 'step-detail',
391
+ waitForTargetMs: 8000,
392
+ placement: 'left',
393
+ titleKey: 'tutorial.tours.reviewMerge.steps.result.title',
394
+ bodyKey: 'tutorial.tours.reviewMerge.steps.result.body',
395
+ },
396
+ {
397
+ // Left to the anchor-skip rather than given a `when`: a finished run whose pipeline
398
+ // opened no PR genuinely has no merge control, which is what a skip MEANS. Gating it
399
+ // would need a "this block has an open PR" gate that nothing else wants.
400
+ id: 'merge',
401
+ target: 'inspector-merge-pr',
402
+ waitForTargetMs: 6000,
403
+ placement: 'left',
404
+ titleKey: 'tutorial.tours.reviewMerge.steps.merge.title',
405
+ bodyKey: 'tutorial.tours.reviewMerge.steps.merge.body',
406
+ },
407
+ {
408
+ id: 'finish',
409
+ titleKey: 'tutorial.tours.reviewMerge.steps.finish.title',
410
+ bodyKey: 'tutorial.tours.reviewMerge.steps.finish.body',
411
+ },
412
+ ],
413
+ },
140
414
  ]
141
415
 
142
416
  /** The module that contributes the catalog; registered by `createAppRegistry`. */
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { computeCoachMarkLayout, sortTours } from '~/utils/tutorial'
3
- import type { TutorialTour } from '~/utils/tutorial'
2
+ import { computeCoachMarkLayout, resolveTours, sortTours } from '~/utils/tutorial'
3
+ import type { TutorialStep, TutorialTour } from '~/utils/tutorial'
4
+ import type { NavGates } from '~/modular/nav-contributions'
4
5
 
5
6
  const tour = (id: string, order: number): TutorialTour => ({
6
7
  id,
@@ -19,6 +20,52 @@ describe('sortTours', () => {
19
20
  })
20
21
  })
21
22
 
23
+ /**
24
+ * Only the fields the predicates below read; the gate object is otherwise irrelevant to
25
+ * `resolveTours`, which never looks at it itself.
26
+ */
27
+ const gates = (advanced: boolean) => ({ advancedMode: advanced }) as NavGates
28
+
29
+ const step = (id: string, when?: TutorialStep['when']): TutorialStep => ({
30
+ id,
31
+ titleKey: `t.${id}`,
32
+ bodyKey: `b.${id}`,
33
+ when,
34
+ })
35
+
36
+ const withSteps = (id: string, steps: TutorialStep[], when?: TutorialTour['when']): TutorialTour =>
37
+ ({ ...tour(id, 10), steps, when }) as TutorialTour
38
+
39
+ describe('resolveTours', () => {
40
+ it('drops a tour its own `when` rejects', () => {
41
+ const tours = [
42
+ withSteps('a', [step('one')], (g) => g.advancedMode),
43
+ withSteps('b', [step('one')]),
44
+ ]
45
+ expect(resolveTours(tours, gates(false)).map((t) => t.id)).toEqual(['b'])
46
+ })
47
+
48
+ it('drops the steps their own `when` rejects, keeping the tour', () => {
49
+ const t = withSteps('a', [step('always'), step('advancedOnly', (g) => g.advancedMode)])
50
+ const [resolved] = resolveTours([t], gates(false))
51
+ expect(resolved?.steps.map((s) => s.id)).toEqual(['always'])
52
+ })
53
+
54
+ it('drops a tour left with no steps at all', () => {
55
+ // Otherwise the launch prompt offers a tour that ends the instant it starts: the overlay
56
+ // has no step to render, so Start looks like a dead button.
57
+ const t = withSteps('a', [step('advancedOnly', (g) => g.advancedMode)])
58
+ expect(resolveTours([t], gates(false))).toEqual([])
59
+ })
60
+
61
+ it('returns the original tour object when nothing was dropped', () => {
62
+ // Identity matters: the prompt lists tours by reference, so minting a new object on
63
+ // every gate read would re-render the list on flips that changed nothing about it.
64
+ const t = withSteps('a', [step('one')])
65
+ expect(resolveTours([t], gates(true))[0]).toBe(t)
66
+ })
67
+ })
68
+
22
69
  const viewport = { width: 1000, height: 800 }
23
70
  const tooltip = { width: 300, height: 150 }
24
71
 
@@ -52,6 +52,19 @@ export interface TutorialStep {
52
52
  altTargets?: readonly string[]
53
53
  titleKey: string
54
54
  bodyKey: string
55
+ /**
56
+ * Interpolation values for {@link bodyKey}'s `{named}` placeholders.
57
+ *
58
+ * The reason this exists rather than the value living in the catalogs: a step that names
59
+ * something FIXED and untranslatable — the sample repository a tour tells you to search
60
+ * for — would otherwise be spelled out in `en.json` and copied into nine other locales,
61
+ * where it reads as prose to translate and drifts the moment the value changes. Declared
62
+ * here it is written once, in code, beside the tour that needs it.
63
+ *
64
+ * Prose still belongs in the catalog: this is for proper nouns and code-shaped literals,
65
+ * the same split `frontend/app/README.md` states for inline placeholders in components.
66
+ */
67
+ bodyParams?: Record<string, string | number>
55
68
  placement?: TutorialPlacement
56
69
  /** Defaults to `'next'`. */
57
70
  advanceOn?: TutorialAdvance
@@ -64,6 +77,22 @@ export interface TutorialStep {
64
77
  * longer wait.
65
78
  */
66
79
  waitForTargetMs?: number
80
+ /**
81
+ * Applicability gate over the same reactive {@link NavGates} the tour's own `when` reads.
82
+ * A step this rejects is DROPPED from the tour before it runs; absent = always included.
83
+ *
84
+ * Deliberately distinct from the anchor skip above, because the two are different facts
85
+ * and only one of them is a defect in the tour. A skip means "this step's control should
86
+ * be here and isn't", which is what the final card reports as an abridged walkthrough. A
87
+ * `when` says "this branch of the flow is not what this board is doing" — a run parked on
88
+ * a decision has no approval gate to point at, and vice versa. Rendering the second as
89
+ * the first would tell a user who saw exactly the right walkthrough that they missed
90
+ * half of it, every single time.
91
+ *
92
+ * Use it only for a step whose ABSENCE is expected on a legitimate board; an anchor that
93
+ * merely might be slow keeps the wait budget instead.
94
+ */
95
+ when?: (gates: NavGates) => boolean
67
96
  }
68
97
 
69
98
  export interface TutorialTour {
@@ -94,6 +123,31 @@ export function sortTours(tours: readonly TutorialTour[]): TutorialTour[] {
94
123
  return [...tours].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))
95
124
  }
96
125
 
126
+ /**
127
+ * Resolve every tour against the gates: drop the tours whose own `when` rejects them, drop
128
+ * the steps whose `when` rejects them, and then drop a tour left with no steps at all.
129
+ *
130
+ * That last rule is what makes per-step gating safe to reach for. A tour whose every step
131
+ * is branch-specific (the parked-run tour: some boards have a decision waiting, some an
132
+ * approval) would otherwise survive its own `when` and open on an empty cursor — which the
133
+ * overlay ends immediately, so the user presses Start and nothing happens.
134
+ *
135
+ * Pure and total, so `navSlotFilter` (which runs inside a reactive computed, once per gate
136
+ * flip) stays a straight fold and the rules above are unit-testable without a Vue runtime.
137
+ */
138
+ export function resolveTours(tours: readonly TutorialTour[], gates: NavGates): TutorialTour[] {
139
+ const out: TutorialTour[] = []
140
+ for (const tour of tours) {
141
+ if (tour.when && !tour.when(gates)) continue
142
+ const steps = tour.steps.filter((s) => (s.when ? s.when(gates) : true))
143
+ if (steps.length === 0) continue
144
+ // Reuse the original object when nothing was dropped: the prompt keys its list on the
145
+ // tour, and a fresh object per gate read would re-render it on every unrelated flip.
146
+ out.push(steps.length === tour.steps.length ? tour : { ...tour, steps })
147
+ }
148
+ return out
149
+ }
150
+
97
151
  /** A DOMRect-shaped box, structurally typed so the geometry below is unit-testable. */
98
152
  export interface TutorialRect {
99
153
  top: number