@cat-factory/app 0.207.0 → 0.208.1
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 +125 -92
- package/app/components/fragments/FragmentLibraryManager.vue +4 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +18 -6
- package/app/components/layout/IntegrationsHub.vue +3 -1
- package/app/components/pipeline/PipelineBuilder.vue +9 -2
- package/app/components/tutorial/TutorialPrompt.vue +20 -12
- package/app/composables/useTutorialTours.ts +9 -5
- package/app/docs/architecture.md +4 -4
- package/app/docs/consumer-extensions.md +47 -47
- package/app/modular/nav-contributions.ts +21 -9
- package/app/modular/tutorial-tours.spec.ts +190 -11
- package/app/modular/tutorial-tours.ts +226 -5
- package/app/utils/tutorial.spec.ts +19 -0
- package/app/utils/tutorial.ts +31 -1
- package/i18n/locales/de.json +101 -2
- package/i18n/locales/en.json +101 -2
- package/i18n/locales/es.json +101 -2
- package/i18n/locales/fr.json +101 -2
- package/i18n/locales/he.json +101 -2
- package/i18n/locales/it.json +101 -2
- package/i18n/locales/ja.json +101 -2
- package/i18n/locales/pl.json +101 -2
- package/i18n/locales/tr.json +101 -2
- package/i18n/locales/uk.json +101 -2
- package/package.json +2 -2
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
TUTORIAL_TOURS,
|
|
8
8
|
tutorialToursModule,
|
|
9
9
|
} from '~/modular/tutorial-tours'
|
|
10
|
-
import { resolveTourCatalogue, resolveTours } from '~/utils/tutorial'
|
|
10
|
+
import { isLaunchOffer, resolveTourCatalogue, resolveTours } from '~/utils/tutorial'
|
|
11
11
|
import { isSafeTargetId } from '~/components/tutorial/TutorialOverlay.logic'
|
|
12
|
-
import
|
|
12
|
+
import { NAV_CONTRIBUTIONS, navItemVisible } from '~/modular/nav-contributions'
|
|
13
|
+
import type { NavContribution, NavGates } from '~/modular/nav-contributions'
|
|
14
|
+
import type { TutorialStep, TutorialTour } from '~/utils/tutorial'
|
|
13
15
|
|
|
14
16
|
const ALL_GATES: NavGates = {
|
|
15
17
|
canWriteBoard: true,
|
|
@@ -100,6 +102,90 @@ function declaredAnchors(): { label: string; id: string }[] {
|
|
|
100
102
|
return out
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Every step whose anchor IS a nav entry, paired with the contribution it points at.
|
|
107
|
+
*
|
|
108
|
+
* Those steps are the ones a tour's `requires` has to agree with, because the anchor only
|
|
109
|
+
* exists while the sidebar/palette renders that entry. An anchor that is NOT a nav entry (a
|
|
110
|
+
* control inside the modal the click opens) has no contribution to pair with and is left to
|
|
111
|
+
* the anchor guard above; `altTargets` are included, since a fallback anchor is reached on
|
|
112
|
+
* exactly the same terms as the primary one.
|
|
113
|
+
*/
|
|
114
|
+
function navAnchoredSteps(): {
|
|
115
|
+
tour: TutorialTour
|
|
116
|
+
step: TutorialStep
|
|
117
|
+
item: NavContribution
|
|
118
|
+
label: string
|
|
119
|
+
}[] {
|
|
120
|
+
const byTestId = new Map(
|
|
121
|
+
NAV_CONTRIBUTIONS.flatMap((item) => (item.testId ? [[item.testId, item] as const] : [])),
|
|
122
|
+
)
|
|
123
|
+
return TUTORIAL_TOURS.flatMap((tour) =>
|
|
124
|
+
tour.steps.flatMap((step) =>
|
|
125
|
+
[step.target, ...(step.altTargets ?? [])].flatMap((target) => {
|
|
126
|
+
const item = target === undefined ? undefined : byTestId.get(target)
|
|
127
|
+
return item ? [{ tour, step, item, label: `${tour.id}/${step.id} -> ${item.id}` }] : []
|
|
128
|
+
}),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Every combination of the {@link NavGates} booleans, yielded lazily so the 2^N gate sets are
|
|
135
|
+
* never all live at once.
|
|
136
|
+
*
|
|
137
|
+
* Enumerating rather than reasoning is deliberate. The pairing below is an IMPLICATION over
|
|
138
|
+
* gate sets — anything that satisfies a tour must also render its entry — between two
|
|
139
|
+
* predicates written independently in two files, and nothing about their shape is guaranteed
|
|
140
|
+
* (either may be a conjunction, a disjunction, or read a field the other doesn't). At fifteen
|
|
141
|
+
* fields the whole matrix costs milliseconds, which is a fair price for a guard that needs no
|
|
142
|
+
* assumption about how either side is spelled.
|
|
143
|
+
*/
|
|
144
|
+
function* everyGateSet(): Generator<NavGates> {
|
|
145
|
+
const keys = Object.keys(ALL_GATES) as (keyof NavGates)[]
|
|
146
|
+
for (let mask = 0; mask < 2 ** keys.length; mask++) {
|
|
147
|
+
const gates = {} as Record<keyof NavGates, boolean>
|
|
148
|
+
for (const [bit, key] of keys.entries()) gates[key] = (mask & (1 << bit)) !== 0
|
|
149
|
+
yield gates as NavGates
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The gate fields a witness has turned OFF — the interesting half of a failure message. */
|
|
154
|
+
function absentGates(gates: NavGates): readonly string[] {
|
|
155
|
+
return Object.entries(gates)
|
|
156
|
+
.filter(([, value]) => value === false)
|
|
157
|
+
.map(([key]) => key)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A gate set that OFFERS this tour while the entry its step clicks is NOT rendered, or
|
|
162
|
+
* `undefined` when no such set exists (what the guard wants). A step the gate set DROPS
|
|
163
|
+
* (`when`) needs no anchor, so it cannot be a counterexample.
|
|
164
|
+
*
|
|
165
|
+
* Of the many counterexamples one break produces, the one reported is the SMALLEST: the gate
|
|
166
|
+
* set closest to fully-permitted, so its absent fields are exactly the ones that matter. The
|
|
167
|
+
* first witness the matrix happens to reach names most of `NavGates` and reads as noise, which
|
|
168
|
+
* is the difference between a failure that says "declare `advancedMode`" and one that says
|
|
169
|
+
* "something about fourteen gates".
|
|
170
|
+
*/
|
|
171
|
+
function navRequirementDrift(
|
|
172
|
+
pair: ReturnType<typeof navAnchoredSteps>[number],
|
|
173
|
+
): NavGates | undefined {
|
|
174
|
+
let smallest: NavGates | undefined
|
|
175
|
+
let fewestAbsent = Number.POSITIVE_INFINITY
|
|
176
|
+
for (const gates of everyGateSet()) {
|
|
177
|
+
if (!(pair.tour.requires ?? []).every((requirement) => requirement.met(gates))) continue
|
|
178
|
+
if (pair.step.when && !pair.step.when(gates)) continue
|
|
179
|
+
if (navItemVisible(pair.item, gates)) continue
|
|
180
|
+
const absent = absentGates(gates).length
|
|
181
|
+
if (absent < fewestAbsent) {
|
|
182
|
+
fewestAbsent = absent
|
|
183
|
+
smallest = gates
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return smallest
|
|
187
|
+
}
|
|
188
|
+
|
|
103
189
|
/** Every static test id this layer actually renders. */
|
|
104
190
|
function renderedTestIds(): Set<string> {
|
|
105
191
|
const ids = new Set<string>()
|
|
@@ -190,14 +276,52 @@ describe('the built-in tutorial tour catalog', () => {
|
|
|
190
276
|
expect(missing).toEqual([])
|
|
191
277
|
})
|
|
192
278
|
|
|
279
|
+
it('requires, of every step that clicks a nav entry, whatever renders that entry', () => {
|
|
280
|
+
// The other drift guard, and the one the availability cases below CANNOT stand in for.
|
|
281
|
+
// Those assert this pairing by restating the permission in a hand-built gate set, which
|
|
282
|
+
// says nothing about the nav catalog: both edits that really break the pairing happen in
|
|
283
|
+
// `nav-contributions.ts` and touch no tour.
|
|
284
|
+
//
|
|
285
|
+
// - an entry's `gate` gains a clause the tour doesn't require;
|
|
286
|
+
// - an entry is marked `advanced: true`, which removes it from BASIC mode — and basic is
|
|
287
|
+
// the SHIPPED DEFAULT, so the tour would then be offered to nearly every user and find
|
|
288
|
+
// nothing.
|
|
289
|
+
//
|
|
290
|
+
// Both land on the same production failure: the tour is offered, hunts for an anchor that
|
|
291
|
+
// this user's sidebar never renders, skips the step (no `when`, so the miss COUNTS) and
|
|
292
|
+
// leaves them on a permanent "you missed N steps" notice about a walkthrough that could
|
|
293
|
+
// not have gone any other way. So the requirement is derived from the entry's OWN
|
|
294
|
+
// visibility rule (`navItemVisible`, the function `navSlotFilter` itself filters with)
|
|
295
|
+
// rather than spelled out a second time here.
|
|
296
|
+
const pairs = navAnchoredSteps()
|
|
297
|
+
// Guard the guard, twice over. A pairing that matched nothing would pass vacuously, and
|
|
298
|
+
// there is one per tour that opens a sidebar surface: `add-service` plus the four platform
|
|
299
|
+
// tours. And a gate field that is not a boolean would silently never vary across the
|
|
300
|
+
// matrix, leaving whatever it gates unexercised.
|
|
301
|
+
expect(pairs.length).toBeGreaterThanOrEqual(5)
|
|
302
|
+
expect(Object.values(ALL_GATES).every((value) => typeof value === 'boolean')).toBe(true)
|
|
303
|
+
|
|
304
|
+
const drifted = pairs.flatMap((pair) => {
|
|
305
|
+
const witness = navRequirementDrift(pair)
|
|
306
|
+
if (witness === undefined) return []
|
|
307
|
+
return [`${pair.label} is offered without ${absentGates(witness).join(' / ')}`]
|
|
308
|
+
})
|
|
309
|
+
expect(drifted).toEqual([])
|
|
310
|
+
})
|
|
311
|
+
|
|
193
312
|
it('is contributed to the tutorialTours slot by the module', () => {
|
|
194
313
|
expect(tutorialToursModule.slots?.tutorialTours).toEqual([...TUTORIAL_TOURS])
|
|
195
314
|
})
|
|
196
315
|
})
|
|
197
316
|
|
|
198
317
|
describe('tour availability across the catalog', () => {
|
|
199
|
-
/** The ids a board can START right now —
|
|
318
|
+
/** The ids a board can START right now — every startable tour, whatever offers it. */
|
|
200
319
|
const ready = (gates: NavGates) => resolveTours(TUTORIAL_TOURS, gates).map((t) => t.id)
|
|
320
|
+
/** The narrower set the LAUNCH PROMPT asks about (`useTutorialTours().offered`). */
|
|
321
|
+
const offered = (gates: NavGates) =>
|
|
322
|
+
resolveTours(TUTORIAL_TOURS, gates)
|
|
323
|
+
.filter(isLaunchOffer)
|
|
324
|
+
.map((t) => t.id)
|
|
201
325
|
/** The catalogue's own view: every tour, with what is holding each one back. */
|
|
202
326
|
const entry = (gates: NavGates, tourId: string) =>
|
|
203
327
|
resolveTourCatalogue(TUTORIAL_TOURS, gates).find((e) => e.tour.id === tourId)
|
|
@@ -207,14 +331,19 @@ describe('tour availability across the catalog', () => {
|
|
|
207
331
|
})
|
|
208
332
|
|
|
209
333
|
it('lists the whole catalog whatever the gates say, holding back rather than hiding', () => {
|
|
210
|
-
// The catalogue surface's contract. A fresh board can run two
|
|
211
|
-
//
|
|
212
|
-
//
|
|
334
|
+
// The catalogue surface's contract. A fresh board can run the two delivery-loop tours that
|
|
335
|
+
// need no board state plus the whole platform half; dropping the rest (all a slot filter
|
|
336
|
+
// could do) would misrepresent the product as shipping fewer walkthroughs than it does, to
|
|
337
|
+
// exactly the user who came looking for them.
|
|
213
338
|
const catalogue = resolveTourCatalogue(TUTORIAL_TOURS, FRESH_BOARD)
|
|
214
339
|
expect(catalogue.map((e) => e.tour.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
|
|
215
340
|
expect(catalogue.filter((e) => e.availability === 'ready').map((e) => e.tour.id)).toEqual([
|
|
216
341
|
'board-basics',
|
|
217
342
|
'add-service',
|
|
343
|
+
'wire-models',
|
|
344
|
+
'design-pipeline',
|
|
345
|
+
'agent-standards',
|
|
346
|
+
'connect-systems',
|
|
218
347
|
])
|
|
219
348
|
})
|
|
220
349
|
|
|
@@ -234,18 +363,68 @@ describe('tour availability across the catalog', () => {
|
|
|
234
363
|
})
|
|
235
364
|
|
|
236
365
|
it('offers a brand-new board the orientation tour AND the way out of being empty', () => {
|
|
237
|
-
// The state the launch prompt actually auto-opens in
|
|
238
|
-
// new workspace with a tour of an empty canvas and no route
|
|
239
|
-
// is what `add-service` exists to fix — so it must survive exactly
|
|
240
|
-
|
|
366
|
+
// The state the launch prompt actually auto-opens in, asserted on what it ASKS ABOUT.
|
|
367
|
+
// Orientation alone would leave a new workspace with a tour of an empty canvas and no route
|
|
368
|
+
// to a first service, which is what `add-service` exists to fix — so it must survive exactly
|
|
369
|
+
// this gate set. And nothing else may join it here: this is a modal with one question, and
|
|
370
|
+
// the platform tours are all startable on a fresh board (they need only a permission), so
|
|
371
|
+
// without the offer/library split they would bury both of these four-to-two.
|
|
372
|
+
expect(offered(FRESH_BOARD)).toEqual(['board-basics', 'add-service'])
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('keeps the platform tours in the catalogue rather than in the launch offer', () => {
|
|
376
|
+
// The split, stated as a table so promoting a tour into the first-launch question has to be
|
|
377
|
+
// written down here. The delivery loop is the arc a first-time user is answering about; the
|
|
378
|
+
// platform half is reference material they go and get from the catalogue, where it is listed,
|
|
379
|
+
// counted and startable exactly like the rest.
|
|
380
|
+
const LAUNCH_ARC = [
|
|
381
|
+
'board-basics',
|
|
382
|
+
'add-service',
|
|
383
|
+
'first-task',
|
|
384
|
+
'run-task',
|
|
385
|
+
'answer-park',
|
|
386
|
+
'review-merge',
|
|
387
|
+
]
|
|
388
|
+
const CATALOGUE_ONLY = ['wire-models', 'design-pipeline', 'agent-standards', 'connect-systems']
|
|
389
|
+
expect(TUTORIAL_TOURS.filter(isLaunchOffer).map((t) => t.id)).toEqual(LAUNCH_ARC)
|
|
390
|
+
expect(TUTORIAL_TOURS.filter((t) => !isLaunchOffer(t)).map((t) => t.id)).toEqual(CATALOGUE_ONLY)
|
|
391
|
+
// Un-offered is not un-runnable: it thins the offer, never the library.
|
|
392
|
+
expect(ready(ALL_GATES)).toEqual(expect.arrayContaining(CATALOGUE_ONLY))
|
|
241
393
|
})
|
|
242
394
|
|
|
243
395
|
it('names the missing connection when no source control can list repositories', () => {
|
|
244
396
|
const noSource: NavGates = { ...FRESH_BOARD, githubAvailable: false }
|
|
245
|
-
expect(
|
|
397
|
+
expect(offered(noSource)).toEqual(['board-basics'])
|
|
246
398
|
expect(entry(noSource, 'add-service')?.unmet.map((r) => r.id)).toEqual(['source-control'])
|
|
247
399
|
})
|
|
248
400
|
|
|
401
|
+
it('holds each platform tour back on the permission its own sidebar entry needs', () => {
|
|
402
|
+
// Every one of these tours clicks a sidebar entry as its second step, so its requirement has
|
|
403
|
+
// to be the SAME fact that renders the entry. A weaker one offers the tour to a user with no
|
|
404
|
+
// such control: it would hunt for the anchor, skip the rest and report itself abridged.
|
|
405
|
+
const member: NavGates = {
|
|
406
|
+
...ALL_GATES,
|
|
407
|
+
canManageIntegrations: false,
|
|
408
|
+
canManageSettings: false,
|
|
409
|
+
}
|
|
410
|
+
expect(ready(member)).not.toContain('wire-models')
|
|
411
|
+
expect(ready(member)).not.toContain('connect-systems')
|
|
412
|
+
expect(ready(member)).not.toContain('agent-standards')
|
|
413
|
+
expect(entry(member, 'wire-models')?.unmet.map((r) => r.id)).toEqual(['integrations-manage'])
|
|
414
|
+
expect(entry(member, 'connect-systems')?.unmet.map((r) => r.id)).toEqual([
|
|
415
|
+
'integrations-manage',
|
|
416
|
+
])
|
|
417
|
+
expect(entry(member, 'agent-standards')?.unmet.map((r) => r.id)).toEqual(['settings-manage'])
|
|
418
|
+
// A viewer keeps the builder tour out too: it opens a board-write surface.
|
|
419
|
+
expect(ready({ ...ALL_GATES, canWriteBoard: false })).not.toContain('design-pipeline')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('names the disabled library when the deployment ships no fragment surface', () => {
|
|
423
|
+
const noLibrary: NavGates = { ...ALL_GATES, libraryAvailable: false }
|
|
424
|
+
expect(ready(noLibrary)).not.toContain('agent-standards')
|
|
425
|
+
expect(entry(noLibrary, 'agent-standards')?.unmet.map((r) => r.id)).toEqual(['library'])
|
|
426
|
+
})
|
|
427
|
+
|
|
249
428
|
it('offers the run tour once a task exists, and the review tour once a run finished', () => {
|
|
250
429
|
const withTask: NavGates = { ...FRESH_BOARD, boardHasService: true, boardHasTask: true }
|
|
251
430
|
expect(ready(withTask)).toContain('run-task')
|
|
@@ -31,11 +31,19 @@ import type { TutorialRequirement, TutorialTour } from '~/utils/tutorial'
|
|
|
31
31
|
* anonymous predicate: the catalogue lists every tour this deployment ships and has to say
|
|
32
32
|
* what a user must do before one it is holding back becomes available.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* The catalog is in two halves, and the split is what keeps the launch prompt answerable:
|
|
35
|
+
*
|
|
36
|
+
* - The DELIVERY LOOP, end to end — get a repo onto the board, put a task on it, run it, answer
|
|
37
|
+
* it when it asks, read the result and merge it — each tour requiring the state the previous
|
|
38
|
+
* one produces, so the prompt only ever offers what this board can actually demonstrate and
|
|
39
|
+
* the catalogue turns the rest into a to-do list rather than an absence.
|
|
40
|
+
* - The PLATFORM behind it (`offeredAtLaunch: false`) — the engine the agents run on, the
|
|
41
|
+
* pipelines that sequence them, the standards they read, the systems they talk to. Each is
|
|
42
|
+
* gated on a PERMISSION rather than on board state, so every one is startable on a brand-new
|
|
43
|
+
* board; offered at launch they would bury the two tours a first-time user can act on. They
|
|
44
|
+
* are reference material someone goes and gets from the catalogue when the question comes up,
|
|
45
|
+
* which is why each covers ONE surface and ends there rather than touring the sidebar: these
|
|
46
|
+
* surfaces open as modals, so a step after one cannot reach another sidebar entry anyway.
|
|
39
47
|
*/
|
|
40
48
|
|
|
41
49
|
/**
|
|
@@ -90,6 +98,28 @@ export const TUTORIAL_REQUIREMENTS = {
|
|
|
90
98
|
labelKey: 'tutorial.requirements.finishedRun',
|
|
91
99
|
met: (gates) => gates.boardHasFinishedRun,
|
|
92
100
|
},
|
|
101
|
+
// The platform half's requirements. Each mirrors, exactly, the `gate` of the sidebar entry the
|
|
102
|
+
// tour clicks (`nav-model-providers` / `nav-integrations`, `nav-fragments`). A requirement
|
|
103
|
+
// WEAKER than the gate of the control a step points at offers the tour to a user who has no
|
|
104
|
+
// such control: it then hunts for the anchor, skips every step behind it, and reports itself
|
|
105
|
+
// abridged — which is the state this mechanism exists to prevent. They are permissions and
|
|
106
|
+
// deployment wiring rather than board state, which is why those tours are startable on a board
|
|
107
|
+
// with nothing on it, and therefore why they are kept out of the launch offer.
|
|
108
|
+
integrationsManage: {
|
|
109
|
+
id: 'integrations-manage',
|
|
110
|
+
labelKey: 'tutorial.requirements.integrationsManage',
|
|
111
|
+
met: (gates) => gates.canManageIntegrations,
|
|
112
|
+
},
|
|
113
|
+
settingsManage: {
|
|
114
|
+
id: 'settings-manage',
|
|
115
|
+
labelKey: 'tutorial.requirements.settingsManage',
|
|
116
|
+
met: (gates) => gates.canManageSettings,
|
|
117
|
+
},
|
|
118
|
+
library: {
|
|
119
|
+
id: 'library',
|
|
120
|
+
labelKey: 'tutorial.requirements.library',
|
|
121
|
+
met: (gates) => gates.libraryAvailable,
|
|
122
|
+
},
|
|
93
123
|
} as const satisfies Record<string, TutorialRequirement>
|
|
94
124
|
|
|
95
125
|
export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
@@ -460,6 +490,197 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
460
490
|
},
|
|
461
491
|
],
|
|
462
492
|
},
|
|
493
|
+
// ---------------------------------------------------------------------------------------
|
|
494
|
+
// The platform half. Ordered after the whole delivery loop so the catalogue reads in the
|
|
495
|
+
// order someone meets these things: learn the loop, then the machinery under it.
|
|
496
|
+
// ---------------------------------------------------------------------------------------
|
|
497
|
+
{
|
|
498
|
+
id: 'wire-models',
|
|
499
|
+
order: 60,
|
|
500
|
+
icon: 'i-lucide-plug-zap',
|
|
501
|
+
titleKey: 'tutorial.tours.wireModels.title',
|
|
502
|
+
descriptionKey: 'tutorial.tours.wireModels.description',
|
|
503
|
+
// The one connection a deployment cannot live without: every pipeline step is a model call,
|
|
504
|
+
// so with no provider the whole product is inert. It is nonetheless catalogue-only, because a
|
|
505
|
+
// deployment with nothing wired already gets its own first-launch nudge (the provider
|
|
506
|
+
// onboarding advisory, which the launch prompt stands down for) — this is for the person who
|
|
507
|
+
// meets the question later, or who wants to know where the answer lives.
|
|
508
|
+
offeredAtLaunch: false,
|
|
509
|
+
requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
|
|
510
|
+
steps: [
|
|
511
|
+
{
|
|
512
|
+
id: 'intro',
|
|
513
|
+
titleKey: 'tutorial.tours.wireModels.steps.intro.title',
|
|
514
|
+
bodyKey: 'tutorial.tours.wireModels.steps.intro.body',
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
id: 'open',
|
|
518
|
+
target: 'nav-model-providers',
|
|
519
|
+
advanceOn: 'target-click',
|
|
520
|
+
placement: 'right',
|
|
521
|
+
titleKey: 'tutorial.tours.wireModels.steps.open.title',
|
|
522
|
+
bodyKey: 'tutorial.tours.wireModels.steps.open.body',
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: 'hub',
|
|
526
|
+
target: 'model-providers-hub',
|
|
527
|
+
// Inside the modal the previous click opens.
|
|
528
|
+
waitForTargetMs: 8000,
|
|
529
|
+
placement: 'bottom',
|
|
530
|
+
titleKey: 'tutorial.tours.wireModels.steps.hub.title',
|
|
531
|
+
bodyKey: 'tutorial.tours.wireModels.steps.hub.body',
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
// Deliberately prose rather than a step pointing at Model configuration: that entry is a
|
|
535
|
+
// sibling in the same sidebar section, and by now the hub modal is open over it, so a
|
|
536
|
+
// step anchored there would spend its wait budget on a control the user cannot reach.
|
|
537
|
+
id: 'finish',
|
|
538
|
+
titleKey: 'tutorial.tours.wireModels.steps.finish.title',
|
|
539
|
+
bodyKey: 'tutorial.tours.wireModels.steps.finish.body',
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
id: 'design-pipeline',
|
|
545
|
+
order: 70,
|
|
546
|
+
icon: 'i-lucide-workflow',
|
|
547
|
+
titleKey: 'tutorial.tours.designPipeline.title',
|
|
548
|
+
descriptionKey: 'tutorial.tours.designPipeline.description',
|
|
549
|
+
// `run-task` teaches picking a pipeline; nothing teaches that the sequence is yours to
|
|
550
|
+
// change. A user who never finds the builder treats the built-in catalog as the product's
|
|
551
|
+
// fixed shape and works around it in task descriptions instead.
|
|
552
|
+
offeredAtLaunch: false,
|
|
553
|
+
requires: [TUTORIAL_REQUIREMENTS.boardWrite],
|
|
554
|
+
steps: [
|
|
555
|
+
{
|
|
556
|
+
id: 'intro',
|
|
557
|
+
titleKey: 'tutorial.tours.designPipeline.steps.intro.title',
|
|
558
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.intro.body',
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
id: 'open',
|
|
562
|
+
target: 'nav-build-pipeline',
|
|
563
|
+
advanceOn: 'target-click',
|
|
564
|
+
placement: 'right',
|
|
565
|
+
titleKey: 'tutorial.tours.designPipeline.steps.open.title',
|
|
566
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.open.body',
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
id: 'palette',
|
|
570
|
+
target: 'pipeline-builder-palette',
|
|
571
|
+
// Inside the slideover the previous click opens.
|
|
572
|
+
waitForTargetMs: 8000,
|
|
573
|
+
placement: 'right',
|
|
574
|
+
titleKey: 'tutorial.tours.designPipeline.steps.palette.title',
|
|
575
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.palette.body',
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
id: 'chain',
|
|
579
|
+
target: 'pipeline-builder-draft',
|
|
580
|
+
placement: 'right',
|
|
581
|
+
titleKey: 'tutorial.tours.designPipeline.steps.chain.title',
|
|
582
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.chain.body',
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
// NOT `target-click`, and not for `run-task`'s budget reason: Save is DISABLED until the
|
|
586
|
+
// draft holds a step, and a click-to-advance step on a control that cannot be clicked
|
|
587
|
+
// strands the tour — the tooltip drops its Next button, so there is no way forward.
|
|
588
|
+
//
|
|
589
|
+
// Which is also why the copy DESCRIBES saving rather than instructing it, and says what
|
|
590
|
+
// lights the button up. The previous step invites a click on the palette but doesn't
|
|
591
|
+
// require one, so this step is routinely read with Save greyed out; an imperative title
|
|
592
|
+
// over a dead control reads as a tour pointing at something broken.
|
|
593
|
+
id: 'save',
|
|
594
|
+
target: 'pipeline-builder-save',
|
|
595
|
+
placement: 'top',
|
|
596
|
+
titleKey: 'tutorial.tours.designPipeline.steps.save.title',
|
|
597
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.save.body',
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
id: 'finish',
|
|
601
|
+
titleKey: 'tutorial.tours.designPipeline.steps.finish.title',
|
|
602
|
+
bodyKey: 'tutorial.tours.designPipeline.steps.finish.body',
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
id: 'agent-standards',
|
|
608
|
+
order: 80,
|
|
609
|
+
icon: 'i-lucide-book-marked',
|
|
610
|
+
titleKey: 'tutorial.tours.agentStandards.title',
|
|
611
|
+
descriptionKey: 'tutorial.tours.agentStandards.description',
|
|
612
|
+
// How you steer output without restating your conventions in every task description, which
|
|
613
|
+
// is what people do instead when they never find this.
|
|
614
|
+
offeredAtLaunch: false,
|
|
615
|
+
requires: [TUTORIAL_REQUIREMENTS.library, TUTORIAL_REQUIREMENTS.settingsManage],
|
|
616
|
+
steps: [
|
|
617
|
+
{
|
|
618
|
+
id: 'intro',
|
|
619
|
+
titleKey: 'tutorial.tours.agentStandards.steps.intro.title',
|
|
620
|
+
bodyKey: 'tutorial.tours.agentStandards.steps.intro.body',
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
id: 'open',
|
|
624
|
+
target: 'nav-fragments',
|
|
625
|
+
advanceOn: 'target-click',
|
|
626
|
+
placement: 'right',
|
|
627
|
+
titleKey: 'tutorial.tours.agentStandards.steps.open.title',
|
|
628
|
+
bodyKey: 'tutorial.tours.agentStandards.steps.open.body',
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
id: 'library',
|
|
632
|
+
target: 'fragment-library',
|
|
633
|
+
waitForTargetMs: 8000,
|
|
634
|
+
placement: 'bottom',
|
|
635
|
+
titleKey: 'tutorial.tours.agentStandards.steps.library.title',
|
|
636
|
+
bodyKey: 'tutorial.tours.agentStandards.steps.library.body',
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
id: 'finish',
|
|
640
|
+
titleKey: 'tutorial.tours.agentStandards.steps.finish.title',
|
|
641
|
+
bodyKey: 'tutorial.tours.agentStandards.steps.finish.body',
|
|
642
|
+
},
|
|
643
|
+
],
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
id: 'connect-systems',
|
|
647
|
+
order: 90,
|
|
648
|
+
icon: 'i-lucide-blocks',
|
|
649
|
+
titleKey: 'tutorial.tours.connectSystems.title',
|
|
650
|
+
descriptionKey: 'tutorial.tours.connectSystems.description',
|
|
651
|
+
// Each integration changes what a run can SEE or SAY, and none of them announces itself:
|
|
652
|
+
// a board with no tracker linked simply never mentions that issues could arrive on their own.
|
|
653
|
+
offeredAtLaunch: false,
|
|
654
|
+
requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
|
|
655
|
+
steps: [
|
|
656
|
+
{
|
|
657
|
+
id: 'intro',
|
|
658
|
+
titleKey: 'tutorial.tours.connectSystems.steps.intro.title',
|
|
659
|
+
bodyKey: 'tutorial.tours.connectSystems.steps.intro.body',
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
id: 'open',
|
|
663
|
+
target: 'nav-integrations',
|
|
664
|
+
advanceOn: 'target-click',
|
|
665
|
+
placement: 'right',
|
|
666
|
+
titleKey: 'tutorial.tours.connectSystems.steps.open.title',
|
|
667
|
+
bodyKey: 'tutorial.tours.connectSystems.steps.open.body',
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
id: 'hub',
|
|
671
|
+
target: 'integrations-hub',
|
|
672
|
+
waitForTargetMs: 8000,
|
|
673
|
+
placement: 'bottom',
|
|
674
|
+
titleKey: 'tutorial.tours.connectSystems.steps.hub.title',
|
|
675
|
+
bodyKey: 'tutorial.tours.connectSystems.steps.hub.body',
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
id: 'finish',
|
|
679
|
+
titleKey: 'tutorial.tours.connectSystems.steps.finish.title',
|
|
680
|
+
bodyKey: 'tutorial.tours.connectSystems.steps.finish.body',
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
},
|
|
463
684
|
]
|
|
464
685
|
|
|
465
686
|
/** The module that contributes the catalog; registered by `createAppRegistry`. */
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import en from '../../i18n/locales/en.json'
|
|
3
3
|
import {
|
|
4
4
|
computeCoachMarkLayout,
|
|
5
|
+
isLaunchOffer,
|
|
5
6
|
launchActionFor,
|
|
6
7
|
needsReveal,
|
|
7
8
|
resolveTourCatalogue,
|
|
@@ -92,6 +93,24 @@ describe('resolveTours', () => {
|
|
|
92
93
|
})
|
|
93
94
|
})
|
|
94
95
|
|
|
96
|
+
describe('isLaunchOffer', () => {
|
|
97
|
+
it('offers a tour that declares nothing, and only withholds an explicit opt-out', () => {
|
|
98
|
+
// The DEFAULT is the whole point: a consumer deployment contributes a tour with no extra
|
|
99
|
+
// field and it appears in the launch prompt beside the built-ins, exactly as documented.
|
|
100
|
+
// Only `false` withholds it, so a tour cannot fall out of the offer by omission.
|
|
101
|
+
expect(isLaunchOffer(tour('a', 10))).toBe(true)
|
|
102
|
+
expect(isLaunchOffer({ ...tour('a', 10), offeredAtLaunch: true })).toBe(true)
|
|
103
|
+
expect(isLaunchOffer({ ...tour('a', 10), offeredAtLaunch: false })).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('is orthogonal to availability, so an un-offered tour still resolves as ready', () => {
|
|
107
|
+
// It thins an OFFER, never the library: the catalogue lists, counts and starts these.
|
|
108
|
+
const t = { ...withSteps('a', [step('one')]), offeredAtLaunch: false }
|
|
109
|
+
expect(resolveTours([t], gates(true)).map((x) => x.id)).toEqual(['a'])
|
|
110
|
+
expect(resolveTourCatalogue([t], gates(true))[0]?.availability).toBe('ready')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
95
114
|
describe('resolveTourCatalogue', () => {
|
|
96
115
|
it('keeps an unavailable tour, saying which requirements are unmet', () => {
|
|
97
116
|
// The whole reason the catalogue resolves rather than filters: a tour dropped from the
|
package/app/utils/tutorial.ts
CHANGED
|
@@ -127,6 +127,24 @@ export interface TutorialTour {
|
|
|
127
127
|
icon?: string
|
|
128
128
|
/** Sort key in the tour list; ties break on `id` so the order is deterministic. */
|
|
129
129
|
order: number
|
|
130
|
+
/**
|
|
131
|
+
* Whether the LAUNCH PROMPT offers this tour. Absent = offered, so a consumer deployment's
|
|
132
|
+
* own tour appears beside the built-ins with nothing to declare.
|
|
133
|
+
*
|
|
134
|
+
* The prompt is one question a new user is trying to answer in a glance; the catalogue is the
|
|
135
|
+
* library. That split only holds while the prompt stays short, and the catalog does not: the
|
|
136
|
+
* built-ins now cover the platform (the engine, the pipeline builder, the standards library,
|
|
137
|
+
* the integrations) alongside the delivery loop, and every platform tour is startable on a
|
|
138
|
+
* brand-new board, because all it needs is a permission. Offered unfiltered they would put
|
|
139
|
+
* six walkthroughs in front of someone whose board has neither a repository nor a task,
|
|
140
|
+
* burying the two they can act on under four they have no reason to care about yet.
|
|
141
|
+
*
|
|
142
|
+
* This thins an OFFER, never the library — the distinction {@link resolveTourCatalogue} exists
|
|
143
|
+
* to keep. An un-offered tour is listed, startable, counted in the progress line and reachable
|
|
144
|
+
* from the prompt's own "See all tutorials" footer button, so nothing here can make a
|
|
145
|
+
* walkthrough disappear; only `requires` can hold one back, and that is always reported.
|
|
146
|
+
*/
|
|
147
|
+
offeredAtLaunch?: boolean
|
|
130
148
|
/**
|
|
131
149
|
* What this board/user must have before the tour can run, over the same reactive
|
|
132
150
|
* {@link NavGates} the nav catalog uses — so a tour about a surface the caller can't reach
|
|
@@ -246,7 +264,7 @@ export function resolveTourCatalogue(
|
|
|
246
264
|
})
|
|
247
265
|
}
|
|
248
266
|
|
|
249
|
-
/** The tours that can be started right now, resolved — the
|
|
267
|
+
/** The tours that can be started right now, resolved — what the overlay may run. */
|
|
250
268
|
export function resolveTours(
|
|
251
269
|
tours: readonly TutorialTour[],
|
|
252
270
|
gates: NavGates | null,
|
|
@@ -256,6 +274,18 @@ export function resolveTours(
|
|
|
256
274
|
.map((entry) => entry.tour)
|
|
257
275
|
}
|
|
258
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Does the launch prompt offer this tour? See {@link TutorialTour.offeredAtLaunch} for why the
|
|
279
|
+
* prompt shows a subset while the catalogue shows everything.
|
|
280
|
+
*
|
|
281
|
+
* One function rather than an inline `!== false` at each site, because the DEFAULT is the whole
|
|
282
|
+
* subtlety: a tour that declares nothing is offered, so a reader spelling the check out
|
|
283
|
+
* themselves has to get the polarity of an absent field right.
|
|
284
|
+
*/
|
|
285
|
+
export function isLaunchOffer(tour: TutorialTour): boolean {
|
|
286
|
+
return tour.offeredAtLaunch !== false
|
|
287
|
+
}
|
|
288
|
+
|
|
259
289
|
/**
|
|
260
290
|
* Where a tour stands for this user: the state the catalogue badges and the action label
|
|
261
291
|
* derive from. Camel-cased because the values ARE the i18n leaf keys
|