@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.
@@ -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 type { NavGates } from '~/modular/nav-contributions'
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 — what the launch prompt offers. */
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 of the six walkthroughs;
211
- // dropping the other four (all a slot filter could do) would misrepresent the product as
212
- // shipping two, to exactly the user who came looking for the rest.
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. Orientation alone would leave a
238
- // new workspace with a tour of an empty canvas and no route to a first service, which
239
- // is what `add-service` exists to fix — so it must survive exactly this gate set.
240
- expect(ready(FRESH_BOARD)).toEqual(['board-basics', 'add-service'])
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(ready(noSource)).toEqual(['board-basics'])
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
- * Together the tours below walk the delivery loop end to end get a repo onto the board,
35
- * put a task on it, run it, answer it when it asks, read the result and merge it — with each
36
- * later tour requiring the state the previous one produces, so the launch prompt only ever
37
- * offers what this board can actually demonstrate, and the catalogue turns the rest into a
38
- * to-do list rather than an absence.
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
@@ -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 launch prompt's offer list. */
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