@cat-factory/app 0.196.1 → 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.
Files changed (45) hide show
  1. package/README.md +105 -0
  2. package/app/components/github/AddServiceFromRepoModal.vue +6 -1
  3. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +164 -0
  4. package/app/components/tutorial/TutorialOverlay.logic.ts +125 -0
  5. package/app/components/tutorial/TutorialOverlay.vue +307 -0
  6. package/app/components/tutorial/TutorialPrompt.vue +102 -0
  7. package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
  8. package/app/composables/useNavContributions.ts +1 -0
  9. package/app/composables/usePipelineErrorToast.spec.ts +4 -1
  10. package/app/composables/usePipelineErrorToast.ts +24 -165
  11. package/app/composables/useTutorialTours.ts +18 -0
  12. package/app/modular/nav-contributions.spec.ts +14 -0
  13. package/app/modular/nav-contributions.ts +70 -0
  14. package/app/modular/nav-gates.logic.spec.ts +41 -0
  15. package/app/modular/nav-gates.logic.ts +36 -0
  16. package/app/modular/nav-gates.ts +54 -0
  17. package/app/modular/registry.spec.ts +6 -0
  18. package/app/modular/registry.ts +3 -1
  19. package/app/modular/slots.ts +7 -0
  20. package/app/modular/tutorial-tours.spec.ts +222 -0
  21. package/app/modular/tutorial-tours.ts +421 -0
  22. package/app/pages/index.vue +61 -0
  23. package/app/stores/board/dependencies.ts +52 -0
  24. package/app/stores/board/placement.ts +4 -37
  25. package/app/stores/execution/pendingGates.ts +109 -0
  26. package/app/stores/execution.ts +7 -94
  27. package/app/stores/requirements/recommendations.ts +77 -0
  28. package/app/stores/requirements.ts +17 -43
  29. package/app/stores/tutorial.spec.ts +135 -0
  30. package/app/stores/tutorial.ts +145 -0
  31. package/app/stores/workspace/commands.ts +77 -0
  32. package/app/stores/workspace.ts +11 -50
  33. package/app/utils/tutorial.spec.ts +115 -0
  34. package/app/utils/tutorial.ts +246 -0
  35. package/i18n/locales/de.json +209 -2
  36. package/i18n/locales/en.json +215 -2
  37. package/i18n/locales/es.json +209 -2
  38. package/i18n/locales/fr.json +209 -2
  39. package/i18n/locales/he.json +209 -2
  40. package/i18n/locales/it.json +209 -2
  41. package/i18n/locales/ja.json +209 -2
  42. package/i18n/locales/pl.json +209 -2
  43. package/i18n/locales/tr.json +209 -2
  44. package/i18n/locales/uk.json +209 -2
  45. package/package.json +1 -1
@@ -0,0 +1,222 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import en from '../../i18n/locales/en.json'
3
+ import { TUTORIAL_TOURS, tutorialToursModule } from '~/modular/tutorial-tours'
4
+ import { NAV_CONTRIBUTIONS, navSlotFilter } from '~/modular/nav-contributions'
5
+ import { isSafeTargetId } from '~/components/tutorial/TutorialOverlay.logic'
6
+ import type { AppSlots, NavGates } from '~/modular/nav-contributions'
7
+
8
+ const ALL_GATES: NavGates = {
9
+ canWriteBoard: true,
10
+ canManageIntegrations: true,
11
+ canManageSettings: true,
12
+ githubAvailable: true,
13
+ libraryAvailable: true,
14
+ infrastructureAvailable: true,
15
+ accountsEnabled: true,
16
+ isAccountAdmin: true,
17
+ advancedMode: true,
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,
39
+ }
40
+
41
+ const slots = (): AppSlots =>
42
+ ({
43
+ nav: [...NAV_CONTRIBUTIONS],
44
+ tutorialTours: [...TUTORIAL_TOURS],
45
+ }) as unknown as AppSlots
46
+
47
+ /** Resolve a dot-path against the en catalog; undefined when any hop is missing. */
48
+ function lookupKey(key: string): unknown {
49
+ return key
50
+ .split('.')
51
+ .reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en)
52
+ }
53
+
54
+ describe('the built-in tutorial tour catalog', () => {
55
+ it('has unique tour ids and unique step ids within each tour', () => {
56
+ const tourIds = TUTORIAL_TOURS.map((t) => t.id)
57
+ expect(new Set(tourIds).size).toBe(tourIds.length)
58
+ for (const tour of TUTORIAL_TOURS) {
59
+ const stepIds = tour.steps.map((s) => s.id)
60
+ expect(new Set(stepIds).size).toBe(stepIds.length)
61
+ expect(tour.steps.length).toBeGreaterThan(0)
62
+ }
63
+ })
64
+
65
+ it('resolves every i18n key it names against the en catalog', () => {
66
+ // Tour copy is looked up with runtime-assembled keys, which the typed-key check
67
+ // cannot cover (i18n drift-guard tier 2): pin the catalog here instead, so a renamed
68
+ // key or a new step without copy fails a test rather than rendering a raw key path.
69
+ for (const tour of TUTORIAL_TOURS) {
70
+ for (const key of [tour.titleKey, tour.descriptionKey]) {
71
+ expect(typeof lookupKey(key), key).toBe('string')
72
+ }
73
+ for (const s of tour.steps) {
74
+ for (const key of [s.titleKey, s.bodyKey]) {
75
+ expect(typeof lookupKey(key), key).toBe('string')
76
+ }
77
+ }
78
+ }
79
+ })
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
+
94
+ 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
+ }
105
+ }
106
+ })
107
+
108
+ it('is contributed to the tutorialTours slot by the module', () => {
109
+ expect(tutorialToursModule.slots?.tutorialTours).toEqual([...TUTORIAL_TOURS])
110
+ })
111
+ })
112
+
113
+ describe('navSlotFilter over tutorialTours', () => {
114
+ it('keeps every tour for a fully-gated user', () => {
115
+ const filtered = navSlotFilter(slots(), { gates: ALL_GATES })
116
+ expect(filtered.tutorialTours.map((t) => t.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
117
+ })
118
+
119
+ it('drops the task-creating tour for a read-only viewer', () => {
120
+ const viewer: NavGates = { ...ALL_GATES, canWriteBoard: false }
121
+ const filtered = navSlotFilter(slots(), { gates: viewer })
122
+ const ids = filtered.tutorialTours.map((t) => t.id)
123
+ expect(ids).toContain('board-basics')
124
+ expect(ids).not.toContain('first-task')
125
+ })
126
+
127
+ it('drops the task-creating tour on a board with no service to add a task to', () => {
128
+ // Every targeted step of that tour would time out in turn and it would then claim to
129
+ // have taught the core loop; `board-basics` is what an empty board can deliver.
130
+ const emptyBoard: NavGates = { ...ALL_GATES, boardHasService: false }
131
+ const filtered = navSlotFilter(slots(), { gates: emptyBoard })
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
+ )
163
+ })
164
+
165
+ it('passes tours through untouched when no gates service is wired', () => {
166
+ const filtered = navSlotFilter(slots(), {})
167
+ expect(filtered.tutorialTours.map((t) => t.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
168
+ })
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
+ })
@@ -0,0 +1,421 @@
1
+ import { defineModule } from '@modular-vue/core'
2
+ import type { TutorialTour } from '~/utils/tutorial'
3
+
4
+ /**
5
+ * The first-party tutorial-tour catalog, contributed to the `tutorialTours` slot the same
6
+ * way the nav catalog fills `nav`: declared ONCE as data, rendered by one shared runtime
7
+ * (`TutorialOverlay`), and open to consumer deployments — `registerAppModule` a module
8
+ * with its own `tutorialTours` entries and they appear in the launch prompt beside these.
9
+ *
10
+ * Authoring rules (what keeps a tour evolvable as the app changes):
11
+ *
12
+ * - A step points at a control by its `data-testid` — the same stable anchor vocabulary
13
+ * the e2e suite owns. Covering a control that has none means adding the test id first
14
+ * (a behaviour-neutral change), never inventing a parallel attribute.
15
+ * - A missing anchor SKIPS the step rather than stranding the tour: controls come and go
16
+ * with RBAC, interface tier, and deployment wiring, so a tour must be a set of
17
+ * opportunities, not a fixed script. Gate a whole tour on `when` only when its SUBJECT
18
+ * requires it (e.g. board-write for a tour that creates a task).
19
+ * - "Now click this" steps use `advanceOn: 'target-click'` so the user drives the real
20
+ * control and the app's real response (the actual modal, the actual task) is what the
21
+ * next step anchors to. Steps whose anchor only exists after that response give it a
22
+ * longer `waitForTargetMs`.
23
+ * - Copy lives under `tutorial.tours.<tourCamelId>.steps.<stepId>` in the i18n catalogs;
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.
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
+
46
+ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
47
+ {
48
+ id: 'board-basics',
49
+ order: 10,
50
+ icon: 'i-lucide-map',
51
+ titleKey: 'tutorial.tours.boardBasics.title',
52
+ descriptionKey: 'tutorial.tours.boardBasics.description',
53
+ steps: [
54
+ {
55
+ id: 'welcome',
56
+ titleKey: 'tutorial.tours.boardBasics.steps.welcome.title',
57
+ bodyKey: 'tutorial.tours.boardBasics.steps.welcome.body',
58
+ },
59
+ {
60
+ id: 'canvas',
61
+ target: 'board-canvas',
62
+ placement: 'right',
63
+ titleKey: 'tutorial.tours.boardBasics.steps.canvas.title',
64
+ bodyKey: 'tutorial.tours.boardBasics.steps.canvas.body',
65
+ },
66
+ {
67
+ id: 'sidebar',
68
+ target: 'sidebar',
69
+ placement: 'right',
70
+ titleKey: 'tutorial.tours.boardBasics.steps.sidebar.title',
71
+ bodyKey: 'tutorial.tours.boardBasics.steps.sidebar.body',
72
+ },
73
+ {
74
+ id: 'commandBar',
75
+ target: 'command-bar-launcher',
76
+ placement: 'right',
77
+ titleKey: 'tutorial.tours.boardBasics.steps.commandBar.title',
78
+ bodyKey: 'tutorial.tours.boardBasics.steps.commandBar.body',
79
+ },
80
+ {
81
+ id: 'toolbar',
82
+ target: 'board-fit-view',
83
+ placement: 'bottom',
84
+ titleKey: 'tutorial.tours.boardBasics.steps.toolbar.title',
85
+ bodyKey: 'tutorial.tours.boardBasics.steps.toolbar.body',
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
+ },
100
+ {
101
+ id: 'finish',
102
+ titleKey: 'tutorial.tours.boardBasics.steps.finish.title',
103
+ bodyKey: 'tutorial.tours.boardBasics.steps.finish.body',
104
+ },
105
+ ],
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
+ },
163
+ {
164
+ id: 'first-task',
165
+ order: 20,
166
+ icon: 'i-lucide-list-plus',
167
+ titleKey: 'tutorial.tours.firstTask.title',
168
+ descriptionKey: 'tutorial.tours.firstTask.description',
169
+ // Creating a task is a board WRITE; a viewer has no add-task button to point at.
170
+ // It also needs somewhere to PUT the task: on a board with no service frame every
171
+ // targeted step below would time out in turn, so the tour would spend half a minute
172
+ // hunting for controls and then claim to have taught the core loop. Offering it only
173
+ // once a service exists is the honest version — and the launch prompt still lists
174
+ // `board-basics`, which is the tour an empty board can actually deliver.
175
+ when: (gates) => gates.canWriteBoard && gates.boardHasService,
176
+ steps: [
177
+ {
178
+ id: 'intro',
179
+ titleKey: 'tutorial.tours.firstTask.steps.intro.title',
180
+ bodyKey: 'tutorial.tours.firstTask.steps.intro.body',
181
+ },
182
+ {
183
+ id: 'addTask',
184
+ target: 'frame-add-task',
185
+ // An empty frame renders its add-task affordance as a full-width button instead.
186
+ altTargets: ['frame-add-task-empty'],
187
+ advanceOn: 'target-click',
188
+ placement: 'bottom',
189
+ titleKey: 'tutorial.tours.firstTask.steps.addTask.title',
190
+ bodyKey: 'tutorial.tours.firstTask.steps.addTask.body',
191
+ },
192
+ {
193
+ id: 'describe',
194
+ target: 'add-task-title',
195
+ // Deliberately NOT `target-click`: clicking a text field is how you START typing,
196
+ // so click-to-advance would move the tooltip off the instruction the moment the
197
+ // user acted on it. The user reads, types, and presses Next when they are ready;
198
+ // `target-click` is for buttons, where the click IS the completed action.
199
+ placement: 'right',
200
+ // The anchor lives inside the modal the previous click opens; allow it to mount.
201
+ waitForTargetMs: 8000,
202
+ titleKey: 'tutorial.tours.firstTask.steps.describe.title',
203
+ bodyKey: 'tutorial.tours.firstTask.steps.describe.body',
204
+ },
205
+ {
206
+ id: 'create',
207
+ target: 'add-task-submit',
208
+ advanceOn: 'target-click',
209
+ placement: 'top',
210
+ titleKey: 'tutorial.tours.firstTask.steps.create.title',
211
+ bodyKey: 'tutorial.tours.firstTask.steps.create.body',
212
+ },
213
+ {
214
+ id: 'card',
215
+ target: 'task-card',
216
+ placement: 'bottom',
217
+ // The card arrives over the live event stream after the create round-trips.
218
+ waitForTargetMs: 10000,
219
+ titleKey: 'tutorial.tours.firstTask.steps.card.title',
220
+ bodyKey: 'tutorial.tours.firstTask.steps.card.body',
221
+ },
222
+ {
223
+ id: 'finish',
224
+ titleKey: 'tutorial.tours.firstTask.steps.finish.title',
225
+ bodyKey: 'tutorial.tours.firstTask.steps.finish.body',
226
+ },
227
+ ],
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
+ },
414
+ ]
415
+
416
+ /** The module that contributes the catalog; registered by `createAppRegistry`. */
417
+ export const tutorialToursModule = defineModule({
418
+ id: 'cat-factory:tutorial-tours',
419
+ version: '1.0.0',
420
+ slots: { tutorialTours: [...TUTORIAL_TOURS] },
421
+ })