@cat-factory/app 0.196.0 → 0.197.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +49 -3
  2. package/app/components/brainstorm/BrainstormWindow.vue +11 -4
  3. package/app/components/clarity/ClarityReviewWindow.vue +11 -4
  4. package/app/components/initiative/InitiativePlanReview.vue +44 -37
  5. package/app/components/initiative/InitiativeTrackerWindow.vue +12 -9
  6. package/app/components/layout/SideBar.vue +13 -2
  7. package/app/components/layout/UiModeSwitcher.vue +66 -39
  8. package/app/components/panels/ResultWindowShell.logic.spec.ts +174 -0
  9. package/app/components/panels/ResultWindowShell.logic.ts +31 -0
  10. package/app/components/panels/ResultWindowShell.vue +37 -8
  11. package/app/components/prReview/PrReviewWindow.vue +15 -7
  12. package/app/components/requirements/RequirementsReviewWindow.vue +15 -5
  13. package/app/components/spec/ServiceSpecWindow.vue +7 -4
  14. package/app/components/testing/TestReportWindow.vue +11 -6
  15. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +126 -0
  16. package/app/components/tutorial/TutorialOverlay.logic.ts +92 -0
  17. package/app/components/tutorial/TutorialOverlay.vue +273 -0
  18. package/app/components/tutorial/TutorialPrompt.vue +102 -0
  19. package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
  20. package/app/composables/useNavContributions.ts +1 -0
  21. package/app/composables/usePipelineErrorToast.ts +6 -164
  22. package/app/composables/useTutorialTours.ts +18 -0
  23. package/app/modular/nav-contributions.spec.ts +4 -0
  24. package/app/modular/nav-contributions.ts +38 -3
  25. package/app/modular/nav-gates.ts +7 -0
  26. package/app/modular/registry.spec.ts +1 -0
  27. package/app/modular/registry.ts +3 -1
  28. package/app/modular/slots.ts +7 -0
  29. package/app/modular/tutorial-tours.spec.ts +107 -0
  30. package/app/modular/tutorial-tours.ts +147 -0
  31. package/app/pages/index.vue +61 -0
  32. package/app/stores/board/dependencies.ts +52 -0
  33. package/app/stores/board/placement.ts +4 -37
  34. package/app/stores/execution/pendingGates.ts +109 -0
  35. package/app/stores/execution.ts +7 -94
  36. package/app/stores/requirements/recommendations.ts +77 -0
  37. package/app/stores/requirements.ts +17 -43
  38. package/app/stores/tutorial.spec.ts +135 -0
  39. package/app/stores/tutorial.ts +145 -0
  40. package/app/stores/workspace/commands.ts +77 -0
  41. package/app/stores/workspace.ts +11 -50
  42. package/app/utils/tutorial.spec.ts +68 -0
  43. package/app/utils/tutorial.ts +192 -0
  44. package/i18n/locales/de.json +90 -2
  45. package/i18n/locales/en.json +96 -2
  46. package/i18n/locales/es.json +90 -2
  47. package/i18n/locales/fr.json +90 -2
  48. package/i18n/locales/he.json +90 -2
  49. package/i18n/locales/it.json +90 -2
  50. package/i18n/locales/ja.json +90 -2
  51. package/i18n/locales/pl.json +90 -2
  52. package/i18n/locales/tr.json +90 -2
  53. package/i18n/locales/uk.json +90 -2
  54. package/package.json +1 -1
@@ -24,6 +24,7 @@
24
24
  * added — which is exactly why the detail stays reachable rather than being dropped).
25
25
  */
26
26
 
27
+ import { createBespokeConflictToasts } from '~/composables/pipelineErrorToast/bespokeConflicts'
27
28
  import type { ApiErrorCode, ConflictReason } from '@cat-factory/contracts'
28
29
  import { apiErrorEnvelope, apiErrorStatus } from './api/errors'
29
30
 
@@ -244,7 +245,7 @@ export function parseConflict(
244
245
  }
245
246
 
246
247
  /** The non-null parsed shape of a backend conflict, as returned by {@link parseConflict}. */
247
- type ParsedConflict = NonNullable<ReturnType<typeof parseConflict>>
248
+ export type ParsedConflict = NonNullable<ReturnType<typeof parseConflict>>
248
249
 
249
250
  /**
250
251
  * Generic translated description per STATUS CLASS, for a failure no `reason` code narrows.
@@ -332,169 +333,10 @@ export function usePipelineErrorToast() {
332
333
  const ui = useUiStore()
333
334
  const { t, te } = useI18n()
334
335
 
335
- // The headline case: a pipeline step's model has no usable provider. Name the
336
- // offending model(s), explain no provider is available, and offer the one-click jump
337
- // to the AI setup — the same remedy the startup "No AI model configured" banner gives.
338
- function presentProvidersUnconfigured(conflict: ParsedConflict): void {
339
- const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
340
- const list = models.join(', ')
341
- toast.add({
342
- title: t('errors.conflict.providersUnconfigured.title'),
343
- description: list
344
- ? t('errors.conflict.providersUnconfigured.body', { models: list })
345
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
346
- color: 'error',
347
- icon: 'i-lucide-cpu',
348
- // Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
349
- // auto-dismiss takes the one-click fix with it before the user can reach it.
350
- duration: 0,
351
- actions: [
352
- {
353
- label: t('errors.conflict.providersUnconfigured.action'),
354
- icon: 'i-lucide-settings',
355
- onClick: () => ui.openAiProviderSetup(),
356
- },
357
- ],
358
- })
359
- }
360
-
361
- // A pipeline step relies on binary-artifact storage (the UI Tester uploads screenshots)
362
- // but the account has none configured. Explain it and offer the jump to the content-storage
363
- // settings — the same shape as the providers-unconfigured case above. Prefer the localized
364
- // body (it carries no runtime interpolation) so non-English users see translated copy; the
365
- // raw backend prose is only the last-resort fallback when the locale lacks the key.
366
- function presentBinaryStorageUnconfigured(conflict: ParsedConflict): void {
367
- toast.add({
368
- title: t('errors.conflict.binaryStorageUnconfigured.title'),
369
- description: te('errors.conflict.binaryStorageUnconfigured.body')
370
- ? t('errors.conflict.binaryStorageUnconfigured.body')
371
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
372
- color: 'error',
373
- icon: 'i-lucide-image',
374
- // Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
375
- // remedy reachable instead of letting it auto-dismiss.
376
- duration: 0,
377
- actions: [
378
- {
379
- label: t('errors.conflict.binaryStorageUnconfigured.action'),
380
- icon: 'i-lucide-settings',
381
- onClick: () => ui.openContentStorageSettings(),
382
- },
383
- ],
384
- })
385
- }
386
-
387
- // A pipeline includes a Deployer, but the SERVICE's ephemeral-environment config (the in-repo
388
- // "what/where") is incomplete for its declared type. Steer the user straight to THAT service's
389
- // environment config — the compose wizard for docker-compose, the service inspector otherwise —
390
- // falling back to the workspace infrastructure window if the frame id wasn't carried.
391
- function presentDeployerServiceConfig(conflict: ParsedConflict): void {
392
- const frameId =
393
- typeof conflict.details.frameId === 'string' ? conflict.details.frameId : undefined
394
- const provisionType =
395
- typeof conflict.details.provisionType === 'string'
396
- ? conflict.details.provisionType
397
- : undefined
398
- const missing = Array.isArray(conflict.details.missing)
399
- ? conflict.details.missing.join(', ')
400
- : ''
401
- toast.add({
402
- title: t('errors.conflict.deployerServiceConfig.title'),
403
- description: missing
404
- ? t('errors.conflict.deployerServiceConfig.body', { missing })
405
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
406
- color: 'error',
407
- icon: 'i-lucide-server',
408
- // Sticky, like the other actionable conflicts: keep the "Fix configuration" jump reachable.
409
- duration: 0,
410
- actions: [
411
- {
412
- label: t('errors.conflict.deployerServiceConfig.action'),
413
- icon: 'i-lucide-settings',
414
- onClick: () => {
415
- if (frameId && provisionType === 'docker-compose') ui.openEnvironmentSetup(frameId)
416
- else if (frameId) ui.select(frameId)
417
- else ui.openProviderConnection('environment')
418
- },
419
- },
420
- ],
421
- })
422
- }
423
-
424
- // A pipeline includes a Deployer and the service config is sound, but no WORKSPACE handler
425
- // resolves for the service's provision type (missing or ambiguous). Steer to the Infrastructure
426
- // window's Test-environments tab. (Also raised by the Tester start gate — same fix applies.)
427
- function presentProvisionTypeUnhandled(conflict: ParsedConflict): void {
428
- const type =
429
- typeof conflict.details.provisionType === 'string' ? conflict.details.provisionType : ''
430
- toast.add({
431
- title: t('errors.conflict.provisionTypeUnhandled.title'),
432
- description: type
433
- ? t('errors.conflict.provisionTypeUnhandled.body', { type })
434
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
435
- color: 'error',
436
- icon: 'i-lucide-server-cog',
437
- duration: 0,
438
- actions: [
439
- {
440
- label: t('errors.conflict.provisionTypeUnhandled.action'),
441
- icon: 'i-lucide-settings',
442
- onClick: () => ui.openProviderConnection('environment'),
443
- },
444
- ],
445
- })
446
- }
447
-
448
- // A pipeline includes a Deployer, the config is structurally complete, but the live connection
449
- // probe of the resolved deployment integration failed (unreachable endpoint / apiserver, bad
450
- // token). Surface the provider's failure detail and steer to the handler to fix + re-test it.
451
- function presentDeployerConnectionFailed(conflict: ParsedConflict): void {
452
- const detail = typeof conflict.details.detail === 'string' ? conflict.details.detail : undefined
453
- toast.add({
454
- title: t('errors.conflict.deployerConnectionFailed.title'),
455
- description: detail
456
- ? t('errors.conflict.deployerConnectionFailed.body', { detail })
457
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
458
- color: 'error',
459
- icon: 'i-lucide-plug',
460
- duration: 0,
461
- actions: [
462
- {
463
- label: t('errors.conflict.deployerConnectionFailed.action'),
464
- icon: 'i-lucide-settings',
465
- onClick: () => ui.openProviderConnection('environment'),
466
- },
467
- ],
468
- })
469
- }
470
-
471
- /**
472
- * Dispatch the bespoke conflict reasons (a runtime-interpolated body + a "configure X" action,
473
- * each with its own key namespace — the ones excluded from `CONFLICT_INFO`). Returns `true` when
474
- * the reason was one of them (and the toast was raised), `false` to fall through to the generic
475
- * map. The reason values are mutually exclusive, so dispatch order is irrelevant.
476
- */
477
- function presentBespokeConflict(conflict: ParsedConflict): boolean {
478
- switch (conflict.reason) {
479
- case 'providers_unconfigured':
480
- presentProvidersUnconfigured(conflict)
481
- return true
482
- case 'binary_storage_unconfigured':
483
- presentBinaryStorageUnconfigured(conflict)
484
- return true
485
- case 'deployer_service_provisioning_incomplete':
486
- presentDeployerServiceConfig(conflict)
487
- return true
488
- case 'provision_type_unhandled':
489
- presentProvisionTypeUnhandled(conflict)
490
- return true
491
- case 'deployer_connection_test_failed':
492
- presentDeployerConnectionFailed(conflict)
493
- return true
494
- default:
495
- return false
496
- }
497
- }
336
+ // The five bespoke conflict reasons (a runtime-interpolated body + a "configure X" jump each)
337
+ // live in a sibling factory over the same toast/ui/i18n handles, so this composable stays
338
+ // within the per-function line budget.
339
+ const presentBespokeConflict = createBespokeConflictToasts({ toast, ui, t, te })
498
340
 
499
341
  /**
500
342
  * Per-reason copy from the exhaustive map: a translated title + description, and a jump
@@ -0,0 +1,18 @@
1
+ import { computed } from 'vue'
2
+ import { useReactiveSlots } from '@modular-vue/runtime'
3
+ import { sortTours } from '~/utils/tutorial'
4
+ import type { TutorialTour } from '~/utils/tutorial'
5
+ import type { AppSlots } from '~/modular/nav-contributions'
6
+
7
+ /**
8
+ * The tours the current user may take: the merged `tutorialTours` slot (first-party +
9
+ * consumer-contributed), already gated per tour by `navSlotFilter` (each tour's `when`
10
+ * runs over the reactive gates service, so a permission flip shows/hides tours live),
11
+ * in deterministic catalog order. The single source both the launch prompt and the
12
+ * coach-mark overlay resolve tours from.
13
+ */
14
+ export function useTutorialTours() {
15
+ const slots = useReactiveSlots<AppSlots>()
16
+ const tours = computed<TutorialTour[]>(() => sortTours(slots.value.tutorialTours ?? []))
17
+ return { tours }
18
+ }
@@ -25,6 +25,7 @@ const NO_GATES: NavGates = {
25
25
  // The permission axis is what these cases vary; keep the interface tier at `advanced`
26
26
  // so a dropped item is unambiguously an RBAC/availability drop, not a tier drop.
27
27
  advancedMode: true,
28
+ boardHasService: false,
28
29
  }
29
30
 
30
31
  const ALL_GATES: NavGates = {
@@ -37,6 +38,7 @@ const ALL_GATES: NavGates = {
37
38
  accountsEnabled: true,
38
39
  isAccountAdmin: true,
39
40
  advancedMode: true,
41
+ boardHasService: true,
40
42
  }
41
43
 
42
44
  const slots = (): AppSlots => ({
@@ -47,6 +49,7 @@ const slots = (): AppSlots => ({
47
49
  taskTypes: [],
48
50
  taskTypeFormPanels: [],
49
51
  appOverlays: [],
52
+ tutorialTours: [],
50
53
  })
51
54
  const ids = (s: unknown) => (s as AppSlots).nav.map((i) => i.id)
52
55
 
@@ -312,6 +315,7 @@ describe('nav grouping helpers', () => {
312
315
  'sandbox',
313
316
  'keyboard-shortcuts',
314
317
  'ui-mode',
318
+ 'tutorial',
315
319
  ])
316
320
  })
317
321
 
@@ -81,6 +81,14 @@ export interface NavGates {
81
81
  * available to a `gate` predicate that needs to combine it with something else.
82
82
  */
83
83
  advancedMode: boolean
84
+ /**
85
+ * The open board has at least one service frame. Availability, not permission: a
86
+ * surface that operates ON a service (today the task-creation tutorial tour) has
87
+ * nothing to point at until one exists, and offering it anyway means a walkthrough
88
+ * that hunts for absent controls. Reactive like the rest, so it flips the moment a
89
+ * service lands on the board.
90
+ */
91
+ boardHasService: boolean
84
92
  }
85
93
 
86
94
  /** Command-palette placement + copy for a contribution that appears in the palette. */
@@ -119,6 +127,7 @@ export const NAV_ACTIONS = [
119
127
  'operatorDashboard',
120
128
  'reports',
121
129
  'shortcuts',
130
+ 'tutorial',
122
131
  'toggleUiMode',
123
132
  ] as const
124
133
 
@@ -445,6 +454,25 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
445
454
  testId: 'nav-reports',
446
455
  sidebar: { group: 'configuration', order: 45 },
447
456
  },
457
+ {
458
+ // Deliberately NOT `advanced`: the tours exist for exactly the users basic mode serves,
459
+ // and the palette entry is the way back to them after the launch prompt was declined
460
+ // or dismissed. Ungated: every tour gates itself via its own `when` predicate, and the
461
+ // prompt is worth reaching even when it can only list some tours.
462
+ id: 'tutorial',
463
+ labelKey: 'layout.commandBar.cmd.tutorial',
464
+ icon: 'i-lucide-graduation-cap',
465
+ surfaces: S('command'),
466
+ action: 'tutorial',
467
+ testId: 'nav-tutorial',
468
+ command: {
469
+ // After the pre-slice-1 tail (the workspace group pins that order): genuinely new
470
+ // entries append rather than interleave.
471
+ group: 'workspace',
472
+ order: 100,
473
+ keywordsKey: 'layout.commandBar.keywords.tutorial',
474
+ },
475
+ },
448
476
  {
449
477
  id: 'keyboard-shortcuts',
450
478
  labelKey: 'layout.commandBar.cmd.shortcuts',
@@ -460,9 +488,9 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
460
488
  {
461
489
  // Deliberately NOT `advanced`: this is the way BACK. Basic mode is the shipped default, so
462
490
  // for most users the sidebar switcher is their first sight of the tier — and in the basic
463
- // rail it renders icon-only, which is a thin thread to hang the entire advanced half of the
464
- // product on. The palette is reachable in both tiers, searchable by name, and is already
465
- // "the primary way to reach every action", so the tier belongs in it.
491
+ // rail it collapses to a single toggle, which is a thin thread to hang the entire advanced
492
+ // half of the product on. The palette is reachable in both tiers, searchable by name, and
493
+ // is already "the primary way to reach every action", so the tier belongs in it.
466
494
  id: 'ui-mode',
467
495
  labelKey: 'layout.commandBar.cmd.toggleUiMode',
468
496
  icon: 'i-lucide-toggle-right',
@@ -503,6 +531,7 @@ export const navigationModule = defineModule({
503
531
  export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppSlots {
504
532
  const gates = deps.gates
505
533
  const nav = slots.nav ?? []
534
+ const tutorialTours = slots.tutorialTours ?? []
506
535
  return {
507
536
  ...slots,
508
537
  // No gates service wired (tests / bare install) ⇒ show everything, matching
@@ -512,6 +541,12 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
512
541
  (i) => (i.advanced ? gates.advancedMode : true) && (i.gate ? i.gate(gates) : true),
513
542
  )
514
543
  : nav,
544
+ // Tutorial tours gate over the same reactive service, so a tour about a surface the
545
+ // caller can't reach (e.g. creating tasks without board write) never shows. Same
546
+ // gates-absent pass-through as `nav`.
547
+ tutorialTours: gates
548
+ ? tutorialTours.filter((t) => (t.when ? t.when(gates) : true))
549
+ : tutorialTours,
515
550
  }
516
551
  }
517
552
 
@@ -25,6 +25,10 @@ export function createNavGates(): NavGates {
25
25
  const auth = useAuthStore()
26
26
  const providerConnections = useProviderConnectionsStore()
27
27
  const uiMode = useUiModeStore()
28
+ const board = useBoardStore()
29
+
30
+ // A top-level frame IS a service (see `app/types/domain.ts`); modules are sub-frames.
31
+ const hasService = computed(() => board.blocks.some((b) => b.level === 'frame' && !b.parentId))
28
32
 
29
33
  const infrastructureAvailable = computed(
30
34
  () =>
@@ -65,5 +69,8 @@ export function createNavGates(): NavGates {
65
69
  get advancedMode() {
66
70
  return uiMode.isAdvanced
67
71
  },
72
+ get boardHasService() {
73
+ return hasService.value
74
+ },
68
75
  }
69
76
  }
@@ -13,6 +13,7 @@ const NO_GATES: NavGates = {
13
13
  accountsEnabled: false,
14
14
  isAccountAdmin: false,
15
15
  advancedMode: false,
16
+ boardHasService: false,
16
17
  }
17
18
 
18
19
  describe('app modular registry', () => {
@@ -2,6 +2,7 @@ import type { AnyModuleDescriptor } from '@modular-vue/core'
2
2
  import { createRegistry } from '@modular-vue/runtime'
3
3
  import { journeysPlugin } from '@modular-vue/journeys'
4
4
  import { navigationModule } from '~/modular/nav-contributions'
5
+ import { tutorialToursModule } from '~/modular/tutorial-tours'
5
6
  import type { NavGates } from '~/modular/nav-contributions'
6
7
  import type { AppSlots } from '~/modular/slots'
7
8
 
@@ -37,7 +38,7 @@ export type AppDeps = {
37
38
  * First-party modules the layer always registers. Real feature modules land
38
39
  * here as each area is converted; slice 1 adds the navigation catalog.
39
40
  */
40
- const FIRST_PARTY_MODULES: readonly AnyModuleDescriptor[] = [navigationModule]
41
+ const FIRST_PARTY_MODULES: readonly AnyModuleDescriptor[] = [navigationModule, tutorialToursModule]
41
42
 
42
43
  /**
43
44
  * Consumer-contributed modules, collected before the layer resolves its
@@ -109,6 +110,7 @@ export function createAppRegistry(
109
110
  taskTypes: [],
110
111
  taskTypeFormPanels: [],
111
112
  appOverlays: [],
113
+ tutorialTours: [],
112
114
  },
113
115
  }).use(journeysPlugin())
114
116
  for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
@@ -1,6 +1,7 @@
1
1
  import type { Component } from 'vue'
2
2
  import type { ComponentEntry, PanelEntry } from '@modular-vue/core'
3
3
  import type { Block, CustomAgentKind, CustomTaskType } from '~/types/domain'
4
+ import type { TutorialTour } from '~/utils/tutorial'
4
5
  import type { NavContribution } from './nav-contributions'
5
6
 
6
7
  /**
@@ -31,6 +32,11 @@ import type { NavContribution } from './nav-contributions'
31
32
  * per custom task type, addressed by the type's `formPanel` id and paired via
32
33
  * `resolveComponentRegistry` (same shape as `resultViews`); shown INSTEAD of the
33
34
  * descriptor-driven `fields`. An unpaired id degrades to the descriptor fields.
35
+ * - `tutorialTours` — the in-app tutorial catalog ({@link TutorialTour}: data-only
36
+ * guided tours anchored to `data-testid`s, no components). First-party tours come
37
+ * from `modular/tutorial-tours.ts`; a consumer contributes its own to the same slot
38
+ * and they appear in the launch prompt beside the built-ins, gated per tour by its
39
+ * `when(gates)` predicate in the same reactive `slotFilter` that gates `nav`.
34
40
  * - `appOverlays` (extension slice D) — top-level modals/overlays a consumer module
35
41
  * contributes ({@link OverlayContribution}, an id → component `ComponentEntry`),
36
42
  * opened by `ui.openOverlay(id, subject?)` / `useAppOverlays().open(...)` and
@@ -52,6 +58,7 @@ export interface AppSlots {
52
58
  taskTypes: CustomTaskType[]
53
59
  taskTypeFormPanels: ResultViewContribution[]
54
60
  appOverlays: OverlayContribution[]
61
+ tutorialTours: TutorialTour[]
55
62
  [key: string]: unknown[]
56
63
  }
57
64
 
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import en from '../../i18n/locales/en.json'
3
+ import { TUTORIAL_TOURS, tutorialToursModule } from '~/modular/tutorial-tours'
4
+ import { NAV_CONTRIBUTIONS, navSlotFilter } from '~/modular/nav-contributions'
5
+ import { isSafeTargetId } from '~/components/tutorial/TutorialOverlay.logic'
6
+ import type { AppSlots, NavGates } from '~/modular/nav-contributions'
7
+
8
+ const ALL_GATES: NavGates = {
9
+ canWriteBoard: true,
10
+ canManageIntegrations: true,
11
+ canManageSettings: true,
12
+ githubAvailable: true,
13
+ libraryAvailable: true,
14
+ infrastructureAvailable: true,
15
+ accountsEnabled: true,
16
+ isAccountAdmin: true,
17
+ advancedMode: true,
18
+ boardHasService: true,
19
+ }
20
+
21
+ const slots = (): AppSlots =>
22
+ ({
23
+ nav: [...NAV_CONTRIBUTIONS],
24
+ tutorialTours: [...TUTORIAL_TOURS],
25
+ }) as unknown as AppSlots
26
+
27
+ /** Resolve a dot-path against the en catalog; undefined when any hop is missing. */
28
+ function lookupKey(key: string): unknown {
29
+ return key
30
+ .split('.')
31
+ .reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en)
32
+ }
33
+
34
+ describe('the built-in tutorial tour catalog', () => {
35
+ it('has unique tour ids and unique step ids within each tour', () => {
36
+ const tourIds = TUTORIAL_TOURS.map((t) => t.id)
37
+ expect(new Set(tourIds).size).toBe(tourIds.length)
38
+ for (const tour of TUTORIAL_TOURS) {
39
+ const stepIds = tour.steps.map((s) => s.id)
40
+ expect(new Set(stepIds).size).toBe(stepIds.length)
41
+ expect(tour.steps.length).toBeGreaterThan(0)
42
+ }
43
+ })
44
+
45
+ it('resolves every i18n key it names against the en catalog', () => {
46
+ // Tour copy is looked up with runtime-assembled keys, which the typed-key check
47
+ // cannot cover (i18n drift-guard tier 2): pin the catalog here instead, so a renamed
48
+ // key or a new step without copy fails a test rather than rendering a raw key path.
49
+ for (const tour of TUTORIAL_TOURS) {
50
+ for (const key of [tour.titleKey, tour.descriptionKey]) {
51
+ expect(typeof lookupKey(key), key).toBe('string')
52
+ }
53
+ for (const s of tour.steps) {
54
+ for (const key of [s.titleKey, s.bodyKey]) {
55
+ expect(typeof lookupKey(key), key).toBe('string')
56
+ }
57
+ }
58
+ }
59
+ })
60
+
61
+ it('names plain data-testid values as targets, never selectors', () => {
62
+ for (const tour of TUTORIAL_TOURS) {
63
+ for (const s of tour.steps) {
64
+ for (const target of [s.target, ...(s.altTargets ?? [])]) {
65
+ if (target === undefined) continue
66
+ // Asserted through the runtime's OWN guard, not a copy of its regex: the overlay
67
+ // drops an id this rejects, so a built-in tour that tripped it would silently
68
+ // lose the step rather than fail here.
69
+ expect(isSafeTargetId(target), `${tour.id}/${s.id}: ${target}`).toBe(true)
70
+ }
71
+ }
72
+ }
73
+ })
74
+
75
+ it('is contributed to the tutorialTours slot by the module', () => {
76
+ expect(tutorialToursModule.slots?.tutorialTours).toEqual([...TUTORIAL_TOURS])
77
+ })
78
+ })
79
+
80
+ describe('navSlotFilter over tutorialTours', () => {
81
+ it('keeps every tour for a fully-gated user', () => {
82
+ const filtered = navSlotFilter(slots(), { gates: ALL_GATES })
83
+ expect(filtered.tutorialTours.map((t) => t.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
84
+ })
85
+
86
+ it('drops the task-creating tour for a read-only viewer', () => {
87
+ const viewer: NavGates = { ...ALL_GATES, canWriteBoard: false }
88
+ const filtered = navSlotFilter(slots(), { gates: viewer })
89
+ const ids = filtered.tutorialTours.map((t) => t.id)
90
+ expect(ids).toContain('board-basics')
91
+ expect(ids).not.toContain('first-task')
92
+ })
93
+
94
+ it('drops the task-creating tour on a board with no service to add a task to', () => {
95
+ // Every targeted step of that tour would time out in turn and it would then claim to
96
+ // have taught the core loop; `board-basics` is what an empty board can deliver.
97
+ const emptyBoard: NavGates = { ...ALL_GATES, boardHasService: false }
98
+ const filtered = navSlotFilter(slots(), { gates: emptyBoard })
99
+ const ids = filtered.tutorialTours.map((t) => t.id)
100
+ expect(ids).toEqual(['board-basics'])
101
+ })
102
+
103
+ it('passes tours through untouched when no gates service is wired', () => {
104
+ const filtered = navSlotFilter(slots(), {})
105
+ expect(filtered.tutorialTours.map((t) => t.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
106
+ })
107
+ })
@@ -0,0 +1,147 @@
1
+ import { defineModule } from '@modular-vue/core'
2
+ import type { TutorialTour } from '~/utils/tutorial'
3
+
4
+ /**
5
+ * The first-party tutorial-tour catalog, contributed to the `tutorialTours` slot the same
6
+ * way the nav catalog fills `nav`: declared ONCE as data, rendered by one shared runtime
7
+ * (`TutorialOverlay`), and open to consumer deployments — `registerAppModule` a module
8
+ * with its own `tutorialTours` entries and they appear in the launch prompt beside these.
9
+ *
10
+ * Authoring rules (what keeps a tour evolvable as the app changes):
11
+ *
12
+ * - A step points at a control by its `data-testid` — the same stable anchor vocabulary
13
+ * the e2e suite owns. Covering a control that has none means adding the test id first
14
+ * (a behaviour-neutral change), never inventing a parallel attribute.
15
+ * - A missing anchor SKIPS the step rather than stranding the tour: controls come and go
16
+ * with RBAC, interface tier, and deployment wiring, so a tour must be a set of
17
+ * opportunities, not a fixed script. Gate a whole tour on `when` only when its SUBJECT
18
+ * requires it (e.g. board-write for a tour that creates a task).
19
+ * - "Now click this" steps use `advanceOn: 'target-click'` so the user drives the real
20
+ * control and the app's real response (the actual modal, the actual task) is what the
21
+ * next step anchors to. Steps whose anchor only exists after that response give it a
22
+ * longer `waitForTargetMs`.
23
+ * - Copy lives under `tutorial.tours.<tourCamelId>.steps.<stepId>` in the i18n catalogs;
24
+ * tours never carry display strings.
25
+ */
26
+ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
27
+ {
28
+ id: 'board-basics',
29
+ order: 10,
30
+ icon: 'i-lucide-map',
31
+ titleKey: 'tutorial.tours.boardBasics.title',
32
+ descriptionKey: 'tutorial.tours.boardBasics.description',
33
+ steps: [
34
+ {
35
+ id: 'welcome',
36
+ titleKey: 'tutorial.tours.boardBasics.steps.welcome.title',
37
+ bodyKey: 'tutorial.tours.boardBasics.steps.welcome.body',
38
+ },
39
+ {
40
+ id: 'canvas',
41
+ target: 'board-canvas',
42
+ placement: 'right',
43
+ titleKey: 'tutorial.tours.boardBasics.steps.canvas.title',
44
+ bodyKey: 'tutorial.tours.boardBasics.steps.canvas.body',
45
+ },
46
+ {
47
+ id: 'sidebar',
48
+ target: 'sidebar',
49
+ placement: 'right',
50
+ titleKey: 'tutorial.tours.boardBasics.steps.sidebar.title',
51
+ bodyKey: 'tutorial.tours.boardBasics.steps.sidebar.body',
52
+ },
53
+ {
54
+ id: 'commandBar',
55
+ target: 'command-bar-launcher',
56
+ placement: 'right',
57
+ titleKey: 'tutorial.tours.boardBasics.steps.commandBar.title',
58
+ bodyKey: 'tutorial.tours.boardBasics.steps.commandBar.body',
59
+ },
60
+ {
61
+ id: 'toolbar',
62
+ target: 'board-fit-view',
63
+ placement: 'bottom',
64
+ titleKey: 'tutorial.tours.boardBasics.steps.toolbar.title',
65
+ bodyKey: 'tutorial.tours.boardBasics.steps.toolbar.body',
66
+ },
67
+ {
68
+ id: 'finish',
69
+ titleKey: 'tutorial.tours.boardBasics.steps.finish.title',
70
+ bodyKey: 'tutorial.tours.boardBasics.steps.finish.body',
71
+ },
72
+ ],
73
+ },
74
+ {
75
+ id: 'first-task',
76
+ order: 20,
77
+ icon: 'i-lucide-list-plus',
78
+ titleKey: 'tutorial.tours.firstTask.title',
79
+ descriptionKey: 'tutorial.tours.firstTask.description',
80
+ // Creating a task is a board WRITE; a viewer has no add-task button to point at.
81
+ // It also needs somewhere to PUT the task: on a board with no service frame every
82
+ // targeted step below would time out in turn, so the tour would spend half a minute
83
+ // hunting for controls and then claim to have taught the core loop. Offering it only
84
+ // once a service exists is the honest version — and the launch prompt still lists
85
+ // `board-basics`, which is the tour an empty board can actually deliver.
86
+ when: (gates) => gates.canWriteBoard && gates.boardHasService,
87
+ steps: [
88
+ {
89
+ id: 'intro',
90
+ titleKey: 'tutorial.tours.firstTask.steps.intro.title',
91
+ bodyKey: 'tutorial.tours.firstTask.steps.intro.body',
92
+ },
93
+ {
94
+ id: 'addTask',
95
+ target: 'frame-add-task',
96
+ // An empty frame renders its add-task affordance as a full-width button instead.
97
+ altTargets: ['frame-add-task-empty'],
98
+ advanceOn: 'target-click',
99
+ placement: 'bottom',
100
+ titleKey: 'tutorial.tours.firstTask.steps.addTask.title',
101
+ bodyKey: 'tutorial.tours.firstTask.steps.addTask.body',
102
+ },
103
+ {
104
+ id: 'describe',
105
+ target: 'add-task-title',
106
+ // Deliberately NOT `target-click`: clicking a text field is how you START typing,
107
+ // so click-to-advance would move the tooltip off the instruction the moment the
108
+ // user acted on it. The user reads, types, and presses Next when they are ready;
109
+ // `target-click` is for buttons, where the click IS the completed action.
110
+ placement: 'right',
111
+ // The anchor lives inside the modal the previous click opens; allow it to mount.
112
+ waitForTargetMs: 8000,
113
+ titleKey: 'tutorial.tours.firstTask.steps.describe.title',
114
+ bodyKey: 'tutorial.tours.firstTask.steps.describe.body',
115
+ },
116
+ {
117
+ id: 'create',
118
+ target: 'add-task-submit',
119
+ advanceOn: 'target-click',
120
+ placement: 'top',
121
+ titleKey: 'tutorial.tours.firstTask.steps.create.title',
122
+ bodyKey: 'tutorial.tours.firstTask.steps.create.body',
123
+ },
124
+ {
125
+ id: 'card',
126
+ target: 'task-card',
127
+ placement: 'bottom',
128
+ // The card arrives over the live event stream after the create round-trips.
129
+ waitForTargetMs: 10000,
130
+ titleKey: 'tutorial.tours.firstTask.steps.card.title',
131
+ bodyKey: 'tutorial.tours.firstTask.steps.card.body',
132
+ },
133
+ {
134
+ id: 'finish',
135
+ titleKey: 'tutorial.tours.firstTask.steps.finish.title',
136
+ bodyKey: 'tutorial.tours.firstTask.steps.finish.body',
137
+ },
138
+ ],
139
+ },
140
+ ]
141
+
142
+ /** The module that contributes the catalog; registered by `createAppRegistry`. */
143
+ export const tutorialToursModule = defineModule({
144
+ id: 'cat-factory:tutorial-tours',
145
+ version: '1.0.0',
146
+ slots: { tutorialTours: [...TUTORIAL_TOURS] },
147
+ })