@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.
- package/README.md +105 -0
- package/app/components/github/AddServiceFromRepoModal.vue +6 -1
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +164 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +125 -0
- package/app/components/tutorial/TutorialOverlay.vue +307 -0
- package/app/components/tutorial/TutorialPrompt.vue +102 -0
- package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.spec.ts +4 -1
- package/app/composables/usePipelineErrorToast.ts +24 -165
- package/app/composables/useTutorialTours.ts +18 -0
- package/app/modular/nav-contributions.spec.ts +14 -0
- package/app/modular/nav-contributions.ts +70 -0
- package/app/modular/nav-gates.logic.spec.ts +41 -0
- package/app/modular/nav-gates.logic.ts +36 -0
- package/app/modular/nav-gates.ts +54 -0
- package/app/modular/registry.spec.ts +6 -0
- package/app/modular/registry.ts +3 -1
- package/app/modular/slots.ts +7 -0
- package/app/modular/tutorial-tours.spec.ts +222 -0
- package/app/modular/tutorial-tours.ts +421 -0
- package/app/pages/index.vue +61 -0
- package/app/stores/board/dependencies.ts +52 -0
- package/app/stores/board/placement.ts +4 -37
- package/app/stores/execution/pendingGates.ts +109 -0
- package/app/stores/execution.ts +7 -94
- package/app/stores/requirements/recommendations.ts +77 -0
- package/app/stores/requirements.ts +17 -43
- package/app/stores/tutorial.spec.ts +135 -0
- package/app/stores/tutorial.ts +145 -0
- package/app/stores/workspace/commands.ts +77 -0
- package/app/stores/workspace.ts +11 -50
- package/app/utils/tutorial.spec.ts +115 -0
- package/app/utils/tutorial.ts +246 -0
- package/i18n/locales/de.json +209 -2
- package/i18n/locales/en.json +215 -2
- package/i18n/locales/es.json +209 -2
- package/i18n/locales/fr.json +209 -2
- package/i18n/locales/he.json +209 -2
- package/i18n/locales/it.json +209 -2
- package/i18n/locales/ja.json +209 -2
- package/i18n/locales/pl.json +209 -2
- package/i18n/locales/tr.json +209 -2
- package/i18n/locales/uk.json +209 -2
- 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
|
+
})
|