@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.
- package/README.md +68 -2
- package/app/components/github/AddServiceFromRepoModal.vue +6 -1
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +55 -17
- package/app/components/tutorial/TutorialOverlay.logic.ts +43 -10
- package/app/components/tutorial/TutorialOverlay.vue +47 -13
- package/app/composables/usePipelineErrorToast.spec.ts +4 -1
- package/app/composables/usePipelineErrorToast.ts +18 -1
- package/app/modular/nav-contributions.spec.ts +10 -0
- package/app/modular/nav-contributions.ts +40 -5
- 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 +47 -0
- package/app/modular/registry.spec.ts +5 -0
- package/app/modular/tutorial-tours.spec.ts +117 -2
- package/app/modular/tutorial-tours.ts +275 -1
- package/app/utils/tutorial.spec.ts +49 -2
- package/app/utils/tutorial.ts +54 -0
- package/i18n/locales/de.json +120 -0
- package/i18n/locales/en.json +123 -0
- package/i18n/locales/es.json +120 -0
- package/i18n/locales/fr.json +120 -0
- package/i18n/locales/he.json +120 -0
- package/i18n/locales/it.json +120 -0
- package/i18n/locales/ja.json +120 -0
- package/i18n/locales/pl.json +120 -0
- package/i18n/locales/tr.json +120 -0
- package/i18n/locales/uk.json +120 -0
- package/package.json +1 -1
package/app/modular/nav-gates.ts
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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
|
|
package/app/utils/tutorial.ts
CHANGED
|
@@ -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
|