@cat-factory/app 0.201.1 → 0.204.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 (47) hide show
  1. package/README.md +130 -10
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
  3. package/app/components/initiative/InitiativePlanReview.vue +11 -1
  4. package/app/components/panels/AgentStepDetail.vue +10 -0
  5. package/app/components/panels/ResultWindowShell.vue +86 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +54 -0
  8. package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
  9. package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
  10. package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
  11. package/app/components/tutorial/TutorialCatalogue.vue +150 -0
  12. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
  13. package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
  14. package/app/components/tutorial/TutorialOverlay.vue +296 -40
  15. package/app/components/tutorial/TutorialPrompt.vue +41 -22
  16. package/app/composables/useNavContributions.ts +4 -1
  17. package/app/composables/useTutorialLaunch.ts +50 -0
  18. package/app/composables/useTutorialTours.ts +37 -9
  19. package/app/docs/consumer-extensions.md +24 -11
  20. package/app/modular/agent-kinds.ts +6 -0
  21. package/app/modular/nav-contributions.spec.ts +7 -0
  22. package/app/modular/nav-contributions.ts +25 -13
  23. package/app/modular/slots.ts +5 -2
  24. package/app/modular/tutorial-tours.spec.ts +189 -53
  25. package/app/modular/tutorial-tours.ts +57 -8
  26. package/app/pages/index.vue +7 -2
  27. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  28. package/app/stores/pipelines/draftStepConfig.ts +38 -2
  29. package/app/stores/tutorial.spec.ts +167 -0
  30. package/app/stores/tutorial.ts +140 -4
  31. package/app/types/domain.ts +9 -0
  32. package/app/types/execution.ts +5 -0
  33. package/app/utils/binaryOutput.spec.ts +307 -0
  34. package/app/utils/binaryOutput.ts +343 -0
  35. package/app/utils/tutorial.spec.ts +179 -9
  36. package/app/utils/tutorial.ts +233 -22
  37. package/i18n/locales/de.json +89 -7
  38. package/i18n/locales/en.json +101 -7
  39. package/i18n/locales/es.json +89 -7
  40. package/i18n/locales/fr.json +89 -7
  41. package/i18n/locales/he.json +89 -7
  42. package/i18n/locales/it.json +89 -7
  43. package/i18n/locales/ja.json +89 -7
  44. package/i18n/locales/pl.json +89 -7
  45. package/i18n/locales/tr.json +89 -7
  46. package/i18n/locales/uk.json +89 -7
  47. package/package.json +2 -2
@@ -7,6 +7,7 @@ import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
7
7
  import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
8
8
  import EstimateThresholdFields from '~/components/pipeline/EstimateThresholdFields.vue'
9
9
  import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
10
+ import BinaryOutputStepPicker from '~/components/pipeline/BinaryOutputStepPicker.vue'
10
11
  import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
11
12
  import { showOverrideField } from '~/utils/uiMode'
12
13
  import {
@@ -202,6 +203,36 @@ const stepsDisallowedByPurpose = computed(() =>
202
203
  }),
203
204
  )
204
205
 
206
+ // The workspace's foundational-services catalog, for the binary-output storage/context picker.
207
+ const foundational = useFoundationalServicesStore()
208
+
209
+ /**
210
+ * Whether this step's kind is a BINARY-OUTPUT generator, and therefore needs the storage +
211
+ * context picker. Read off the kind's projected `binaryOutput` flag rather than a kind-id list,
212
+ * so a deployment's generator opts in by carrying the trait exactly as the engine's own checks
213
+ * key on it.
214
+ *
215
+ * Deliberately NOT behind `showOverrideField` / `isAdvanced` the way the variant picker is: a
216
+ * variant OVERRIDES what the kind ships, while this selection is REQUIRED. A basic-mode user
217
+ * who cannot see it has a step that cannot be saved and no way to find out why.
218
+ */
219
+ function showBinaryOutputPicker(kind: AgentKind): boolean {
220
+ return agentKindMeta(kind).binaryOutput === true
221
+ }
222
+
223
+ // An enabled generator step with no storage selection — mirrors the backend save/start
224
+ // rejection (`assertValidBinaryOutputSteps`), surfaced as an inline hint so the user fixes it
225
+ // before the round trip. Same disposition as `skillStepNeedsPick`, for the same reason: both
226
+ // are a step parametrized by a selection it cannot run without.
227
+ const binaryOutputStepNeedsPick = computed(() =>
228
+ pipelines.draft.some(
229
+ (kind, i) =>
230
+ showBinaryOutputPicker(kind) &&
231
+ pipelines.draftEnabled[i] !== false &&
232
+ !pipelines.draftBinaryOutput(i)?.storageServiceId,
233
+ ),
234
+ )
235
+
205
236
  // A step's picked skill id is no longer in the account catalog (the source dir was renamed or
206
237
  // unlinked). The step will fail cleanly at dispatch; flag it so the user re-picks.
207
238
  function skillMissing(index: number): boolean {
@@ -226,6 +257,11 @@ watch(open, (isOpen) => {
226
257
  // The workspace's per-kind output ceilings, which the per-step field shows as its inherited
227
258
  // placeholder and the prompt editor edits. Best-effort on the same terms as the prompt index.
228
259
  if (isOpen) agentSettings.load().catch(() => {})
260
+ // The resolved foundational-services catalog, which the binary-output picker offers from.
261
+ // Single-flighted per workspace, so this shares the panel's load rather than adding one. A
262
+ // failure is not swallowed into an empty picker: the store records `available: false`, and
263
+ // the picker says the catalog is unreachable rather than "no services exist".
264
+ if (isOpen) void foundational.ensureProbed()
229
265
  })
230
266
 
231
267
  function add(kind: AgentKind) {
@@ -531,6 +567,15 @@ async function clone(p: Pipeline) {
531
567
  {{ t('pipeline.builder.skillNeedsPick') }}
532
568
  </p>
533
569
 
570
+ <p
571
+ v-if="binaryOutputStepNeedsPick"
572
+ class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
573
+ data-testid="binary-output-needs-pick"
574
+ >
575
+ <UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
576
+ {{ t('pipeline.builder.binaryOutputNeedsPick') }}
577
+ </p>
578
+
534
579
  <p
535
580
  v-if="stepsDisallowedByPurpose.length"
536
581
  class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
@@ -792,6 +837,15 @@ async function clone(p: Pipeline) {
792
837
  />
793
838
  </div>
794
839
 
840
+ <!-- Binary-output picker: a generator kind's step is parametrized by the
841
+ foundational STORAGE service its artifacts go through (`stepOptions.binaryOutput`)
842
+ plus any services consulted for the generation's scope. Required, not an
843
+ override — so it shows in both interface tiers. -->
844
+ <BinaryOutputStepPicker
845
+ v-if="showBinaryOutputPicker(unit.kind)"
846
+ :index="unit.index"
847
+ />
848
+
795
849
  <!-- This step's own output-token ceiling. An OVERRIDE of the workspace's per-kind
796
850
  setting (itself an override of the deployment routing default), so it is
797
851
  advanced-only until a value is pinned; empty inherits. -->
@@ -32,10 +32,13 @@ const back = useIntegrationBack(open)
32
32
  const RECOMMENDED_SLUGS = [
33
33
  'anthropic/claude-fable-5',
34
34
  'anthropic/claude-opus-5',
35
- 'openai/gpt-5.5',
36
- 'google/gemini-3-pro',
37
- 'deepseek/deepseek-chat',
35
+ 'openai/gpt-5.6-sol',
36
+ 'openai/gpt-5.6-terra',
37
+ 'google/gemini-3.1-pro-preview',
38
+ 'google/gemini-3.6-flash',
39
+ 'deepseek/deepseek-v4-flash',
38
40
  'moonshotai/kimi-k2.7-code',
41
+ 'z-ai/glm-5.2',
39
42
  ]
40
43
 
41
44
  // Whether the workspace/user has an OpenRouter key connected at any reachable scope.
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildCatalogueRows, summarizeProgress } from './TutorialCatalogue.logic'
3
+ import type { TutorialCatalogueEntry, TutorialTourState } from '~/utils/tutorial'
4
+
5
+ const entry = (
6
+ id: string,
7
+ availability: TutorialCatalogueEntry['availability'],
8
+ stepCount = 3,
9
+ ): TutorialCatalogueEntry => ({
10
+ tour: {
11
+ id,
12
+ order: 10,
13
+ titleKey: `tutorial.tours.${id}.title`,
14
+ descriptionKey: `tutorial.tours.${id}.description`,
15
+ steps: Array.from({ length: stepCount }, (_, i) => ({
16
+ id: `s${i}`,
17
+ titleKey: 't',
18
+ bodyKey: 'b',
19
+ })),
20
+ },
21
+ availability,
22
+ unmet:
23
+ availability === 'blocked'
24
+ ? [{ id: 'service', labelKey: 'tutorial.requirements.service', met: () => false }]
25
+ : [],
26
+ })
27
+
28
+ const states = (map: Record<string, TutorialTourState>) => (id: string) => map[id] ?? 'notStarted'
29
+
30
+ describe('buildCatalogueRows', () => {
31
+ it('carries every tour through, ready or not', () => {
32
+ const rows = buildCatalogueRows(
33
+ [entry('a', 'ready'), entry('b', 'blocked'), entry('c', 'not-applicable')],
34
+ states({}),
35
+ )
36
+ expect(rows.map((r) => r.tour.id)).toEqual(['a', 'b', 'c'])
37
+ expect(rows.map((r) => r.startable)).toEqual([true, false, false])
38
+ })
39
+
40
+ it('counts the steps of a runnable tour and withholds a count for the rest', () => {
41
+ // A blocked tour's resolved script is not what the user gets once they unblock it, and a
42
+ // number that quietly changes under them is worse than no number.
43
+ const rows = buildCatalogueRows([entry('a', 'ready', 4), entry('b', 'blocked', 4)], states({}))
44
+ expect(rows[0]?.stepCount).toBe(4)
45
+ expect(rows[1]?.stepCount).toBeNull()
46
+ })
47
+
48
+ it('labels each row from the user`s own progress', () => {
49
+ const rows = buildCatalogueRows(
50
+ [entry('a', 'ready'), entry('b', 'ready'), entry('c', 'ready')],
51
+ states({ a: 'completed', b: 'paused', c: 'inProgress' }),
52
+ )
53
+ expect(rows.map((r) => r.action)).toEqual(['restart', 'resume', 'continue'])
54
+ })
55
+
56
+ it('keeps a blocked tour`s unmet requirements for the reason list', () => {
57
+ const [row] = buildCatalogueRows([entry('a', 'blocked')], states({}))
58
+ expect(row?.unmet.map((r) => r.id)).toEqual(['service'])
59
+ })
60
+ })
61
+
62
+ describe('summarizeProgress', () => {
63
+ /** The launch offer is still unanswered, so only the rows can make anything resettable. */
64
+ const unanswered = { launchOfferAnswered: false }
65
+ const rows = (map: Record<string, TutorialTourState>, ids: string[]) =>
66
+ buildCatalogueRows(
67
+ ids.map((id) => entry(id, 'ready')),
68
+ states(map),
69
+ )
70
+
71
+ it('counts completions against the WHOLE catalog, not the runnable part', () => {
72
+ // Counting only what this board can offer today would move the denominator every time a
73
+ // repo was linked or a run finished — and "2 of 2" on a board with four walkthroughs
74
+ // still waiting reads as a finished tutorial, which is what this surface disproves.
75
+ const all = buildCatalogueRows(
76
+ [entry('a', 'ready'), entry('b', 'blocked'), entry('c', 'not-applicable')],
77
+ states({ a: 'completed' }),
78
+ )
79
+ expect(summarizeProgress(all, unanswered)).toMatchObject({ completed: 1, total: 3 })
80
+ })
81
+
82
+ it('offers a reset for a paused tour, not only for completed ones', () => {
83
+ expect(summarizeProgress(rows({}, ['a', 'b']), unanswered).resettable).toBe(false)
84
+ expect(summarizeProgress(rows({ a: 'paused' }, ['a', 'b']), unanswered).resettable).toBe(true)
85
+ expect(summarizeProgress(rows({ a: 'completed' }, ['a', 'b']), unanswered).resettable).toBe(
86
+ true,
87
+ )
88
+ })
89
+
90
+ it('offers a reset to a user who only ever answered the launch offer', () => {
91
+ // The case keying Reset off the rows alone got wrong, and the one that matters most: someone
92
+ // who clicked "No thanks" and took no tour has nothing completed and nothing paused, yet the
93
+ // saved answer is exactly what stops the prompt returning. Hiding the control left them no
94
+ // route back to the first-launch experience Reset promises.
95
+ expect(summarizeProgress(rows({}, ['a', 'b']), { launchOfferAnswered: true }).resettable).toBe(
96
+ true,
97
+ )
98
+ })
99
+
100
+ it('offers no reset on a genuinely untouched install', () => {
101
+ expect(summarizeProgress([], unanswered).resettable).toBe(false)
102
+ })
103
+ })
@@ -0,0 +1,102 @@
1
+ import { launchActionFor } from '~/utils/tutorial'
2
+ import type {
3
+ TutorialAvailability,
4
+ TutorialCatalogueEntry,
5
+ TutorialLaunchAction,
6
+ TutorialRequirement,
7
+ TutorialTour,
8
+ TutorialTourState,
9
+ } from '~/utils/tutorial'
10
+
11
+ /**
12
+ * What the catalogue renders per tour, and the progress line above the list.
13
+ *
14
+ * Extracted from `TutorialCatalogue.vue` for the same reason the overlay's decisions are
15
+ * (`TutorialOverlay.logic.ts`): the vitest setup has no SFC transform, so anything that
16
+ * DECIDES has to live outside the component to be tested. Here that is the whole of what the
17
+ * surface claims — which tours can be started, which are held back and by what, and how much
18
+ * of the catalog this user has been through.
19
+ */
20
+
21
+ /** One rendered row: an entry, plus everything derived from it and the user's progress. */
22
+ export interface TutorialCatalogueRow {
23
+ tour: TutorialTour
24
+ availability: TutorialAvailability
25
+ /** What is standing in the way, when {@link availability} is `blocked`. */
26
+ unmet: readonly TutorialRequirement[]
27
+ state: TutorialTourState
28
+ action: TutorialLaunchAction
29
+ /**
30
+ * How many steps a start would walk this board through — the RESOLVED count, not the
31
+ * declared one, since branch steps that don't apply here are already gone.
32
+ *
33
+ * Null for a tour that cannot run: its resolved script is not what the user would get once
34
+ * the missing requirement is met, and a number that quietly changes when they unblock it is
35
+ * worse than no number.
36
+ */
37
+ stepCount: number | null
38
+ /** Whether the row's button does anything (a blocked tour's is inert, not hidden). */
39
+ startable: boolean
40
+ }
41
+
42
+ /** The list, in catalog order — the entries arrive sorted by `resolveTourCatalogue`. */
43
+ export function buildCatalogueRows(
44
+ entries: readonly TutorialCatalogueEntry[],
45
+ stateOf: (tourId: string) => TutorialTourState,
46
+ ): TutorialCatalogueRow[] {
47
+ return entries.map((entry) => {
48
+ const ready = entry.availability === 'ready'
49
+ const state = stateOf(entry.tour.id)
50
+ return {
51
+ tour: entry.tour,
52
+ availability: entry.availability,
53
+ unmet: entry.unmet,
54
+ state,
55
+ action: launchActionFor(state),
56
+ stepCount: ready ? entry.tour.steps.length : null,
57
+ startable: ready,
58
+ }
59
+ })
60
+ }
61
+
62
+ /** The headline count. */
63
+ export interface TutorialProgressSummary {
64
+ completed: number
65
+ /** Every tour this deployment ships, available or not — the honest denominator. */
66
+ total: number
67
+ /**
68
+ * Whether there is anything for Reset to clear — which is everything `resetProgress` writes,
69
+ * not only what this list shows. See {@link summarizeProgress}.
70
+ */
71
+ resettable: boolean
72
+ }
73
+
74
+ /**
75
+ * Progress across the WHOLE catalog, not just the runnable part.
76
+ *
77
+ * Counting only what this board can offer today would move the denominator under the user
78
+ * every time they linked a repo or finished a run — "2 of 2 completed" on a board with four
79
+ * more walkthroughs waiting behind requirements reads as a finished tutorial, which is the
80
+ * one thing this surface exists to disprove.
81
+ *
82
+ * `launchOfferAnswered` is the store's `decision`, and it is here rather than derived from the
83
+ * rows because `resetProgress` clears it too — Reset restores the FIRST-LAUNCH experience, and
84
+ * the saved answer to "would you like a tour?" is most of that. Keying the control on the rows
85
+ * alone hid it from the one user who most needs it: someone who clicked "No thanks" and took no
86
+ * tour has nothing completed and nothing paused, so the only route back to the offer was the
87
+ * control that was not being drawn.
88
+ */
89
+ export function summarizeProgress(
90
+ rows: readonly TutorialCatalogueRow[],
91
+ input: { launchOfferAnswered: boolean },
92
+ ): TutorialProgressSummary {
93
+ const completed = rows.filter((row) => row.state === 'completed').length
94
+ return {
95
+ completed,
96
+ total: rows.length,
97
+ // A paused tour is progress too: clearing it is exactly what someone handing this to a
98
+ // colleague wants, and offering Reset only for completions would leave it behind.
99
+ resettable:
100
+ completed > 0 || rows.some((row) => row.state === 'paused') || input.launchOfferAnswered,
101
+ }
102
+ }
@@ -0,0 +1,150 @@
1
+ <script setup lang="ts">
2
+ // The tutorial catalogue: every guided walkthrough this deployment ships, startable (or
3
+ // re-startable) at any time from the sidebar's Help section and the command palette.
4
+ //
5
+ // It is deliberately NOT the launch prompt with a second entry point. The prompt asks a
6
+ // question once, offers what this board can run, and goes away; this answers "what else is
7
+ // there, and why can't I take that one yet?" — so it lists the tours that are HELD BACK too,
8
+ // each with the requirement still missing. Omitting them (which is all a slot filter could do)
9
+ // makes a deployment shipping six walkthroughs look like one shipping two.
10
+ //
11
+ // Everything that decides lives in `TutorialCatalogue.logic.ts` (rows, progress) and
12
+ // `utils/tutorial.ts` (availability, state, action), so the SFC only renders.
13
+ import { buildCatalogueRows, summarizeProgress } from './TutorialCatalogue.logic'
14
+ import type { TutorialCatalogueRow } from './TutorialCatalogue.logic'
15
+ import { TUTORIAL_ACTION_KEYS, TUTORIAL_STATUS_KEYS } from '~/utils/tutorial'
16
+
17
+ const { t } = useI18n()
18
+ const tutorial = useTutorialStore()
19
+ const { catalogue } = useTutorialTours()
20
+ const { stateOf, launch } = useTutorialLaunch()
21
+
22
+ const open = computed({
23
+ get: () => tutorial.catalogueOpen,
24
+ set: (v: boolean) => (v ? tutorial.openCatalogue() : tutorial.closeCatalogue()),
25
+ })
26
+
27
+ const rows = computed(() => buildCatalogueRows(catalogue.value, stateOf))
28
+ const progress = computed(() =>
29
+ summarizeProgress(rows.value, { launchOfferAnswered: tutorial.decision !== null }),
30
+ )
31
+
32
+ /** A badge only where there is something to say: "not started" is the unremarkable default. */
33
+ const showsStatus = (row: TutorialCatalogueRow) => row.state !== 'notStarted'
34
+
35
+ const statusColor = (row: TutorialCatalogueRow) =>
36
+ row.state === 'completed' ? 'success' : row.state === 'inProgress' ? 'primary' : 'neutral'
37
+ </script>
38
+
39
+ <template>
40
+ <UModal
41
+ v-model:open="open"
42
+ :title="t('tutorial.catalogue.title')"
43
+ :description="t('tutorial.catalogue.intro')"
44
+ :ui="{ content: 'max-w-2xl' }"
45
+ >
46
+ <template #body>
47
+ <div class="space-y-4" data-testid="tutorial-catalogue">
48
+ <p v-if="rows.length > 0" class="text-xs text-slate-400" data-testid="tutorial-progress">
49
+ {{
50
+ t('tutorial.catalogue.progress', {
51
+ completed: progress.completed,
52
+ total: progress.total,
53
+ })
54
+ }}
55
+ </p>
56
+ <ul class="space-y-2">
57
+ <li
58
+ v-for="row in rows"
59
+ :key="row.tour.id"
60
+ class="flex items-start gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
61
+ :class="row.startable ? '' : 'opacity-75'"
62
+ :data-testid="`tutorial-catalogue-entry-${row.tour.id}`"
63
+ >
64
+ <UIcon
65
+ :name="row.tour.icon ?? 'i-lucide-compass'"
66
+ class="mt-0.5 h-5 w-5 shrink-0 text-primary-400"
67
+ />
68
+ <div class="min-w-0 flex-1 space-y-1">
69
+ <div class="flex flex-wrap items-center gap-2">
70
+ <span class="text-sm font-medium text-slate-100">{{ t(row.tour.titleKey) }}</span>
71
+ <UBadge
72
+ v-if="showsStatus(row)"
73
+ :color="statusColor(row)"
74
+ variant="subtle"
75
+ size="sm"
76
+ :data-testid="`tutorial-catalogue-status-${row.tour.id}`"
77
+ >
78
+ {{ t(TUTORIAL_STATUS_KEYS[row.state]) }}
79
+ </UBadge>
80
+ </div>
81
+ <p class="text-xs text-slate-400">{{ t(row.tour.descriptionKey) }}</p>
82
+ <p v-if="row.stepCount !== null" class="text-xs text-slate-500">
83
+ {{ t('tutorial.catalogue.steps', { count: row.stepCount }, row.stepCount) }}
84
+ </p>
85
+ <!-- A held-back tour says what would unlock it, rather than vanishing from the
86
+ list: these are things the reader can go and do. -->
87
+ <div
88
+ v-else-if="row.availability === 'blocked'"
89
+ class="text-xs text-slate-500"
90
+ :data-testid="`tutorial-catalogue-requirements-${row.tour.id}`"
91
+ >
92
+ <span>{{ t('tutorial.catalogue.blocked') }}</span>
93
+ <ul class="mt-1 space-y-0.5">
94
+ <li v-for="req in row.unmet" :key="req.id" class="flex items-center gap-1.5">
95
+ <UIcon name="i-lucide-lock" class="h-3 w-3 shrink-0" />
96
+ <span>{{ t(req.labelKey) }}</span>
97
+ </li>
98
+ </ul>
99
+ </div>
100
+ <!-- Requirements met, but every step is about a branch this board isn't on:
101
+ nothing to go and fix, so it must not read like the case above. -->
102
+ <p v-else class="text-xs text-slate-500">
103
+ {{ t('tutorial.catalogue.notApplicable') }}
104
+ </p>
105
+ </div>
106
+ <UButton
107
+ size="sm"
108
+ color="primary"
109
+ :variant="row.state === 'completed' ? 'soft' : 'solid'"
110
+ :disabled="!row.startable"
111
+ :data-testid="`tutorial-catalogue-start-${row.tour.id}`"
112
+ @click="launch(row.tour.id)"
113
+ >
114
+ {{ t(TUTORIAL_ACTION_KEYS[row.action]) }}
115
+ </UButton>
116
+ </li>
117
+ </ul>
118
+ <!-- No tours at all is a real state (a deployment may register none of its own and
119
+ strip the built-ins), and it is not the same as one whose tours are all blocked. -->
120
+ <p v-if="rows.length === 0" class="text-sm text-slate-400">
121
+ {{ t('tutorial.catalogue.empty') }}
122
+ </p>
123
+ </div>
124
+ </template>
125
+ <template #footer>
126
+ <div class="flex w-full items-center justify-between gap-2">
127
+ <UButton
128
+ v-if="progress.resettable"
129
+ color="neutral"
130
+ variant="ghost"
131
+ icon="i-lucide-rotate-ccw"
132
+ :title="t('tutorial.catalogue.resetHint')"
133
+ data-testid="tutorial-catalogue-reset"
134
+ @click="tutorial.resetProgress()"
135
+ >
136
+ {{ t('tutorial.catalogue.reset') }}
137
+ </UButton>
138
+ <span v-else />
139
+ <UButton
140
+ color="neutral"
141
+ variant="soft"
142
+ data-testid="tutorial-catalogue-close"
143
+ @click="tutorial.closeCatalogue()"
144
+ >
145
+ {{ t('common.close') }}
146
+ </UButton>
147
+ </div>
148
+ </template>
149
+ </UModal>
150
+ </template>
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
+ boardNodeIdFor,
3
4
  isSafeTargetId,
4
5
  isTargetClickAdvance,
5
6
  resolveSkip,
7
+ shouldFocusCard,
6
8
  stepTargetIds,
7
9
  stepTargetSelectors,
8
10
  unexpectedlySkippedSteps,
@@ -162,3 +164,47 @@ describe('unexpectedlySkippedSteps', () => {
162
164
  expect(unexpectedlySkippedSteps(new Set(), [plain, branch])).toEqual([])
163
165
  })
164
166
  })
167
+
168
+ describe('boardNodeIdFor', () => {
169
+ /** A stand-in for the DOM ancestry lookup: `closest` hits when the selector is the one
170
+ * Vue Flow wraps its nodes in, and the hit carries whatever `data-id` we hand it. */
171
+ const el = (nodeId: string | null) => ({
172
+ closest: (selector: string) =>
173
+ selector === '.vue-flow__node' && nodeId !== null
174
+ ? { getAttribute: (name: string) => (name === 'data-id' ? nodeId : null) }
175
+ : null,
176
+ })
177
+
178
+ it('reports the node id for an anchor on the board canvas', () => {
179
+ expect(boardNodeIdFor(el('block-42'))).toBe('block-42')
180
+ })
181
+
182
+ it('reports none for an anchor outside the canvas, so the caller scrolls it instead', () => {
183
+ // A panel row, a modal button, the sidebar: an ordinary scroll container, where a camera
184
+ // move would do nothing and `scrollIntoView` is the right mechanism.
185
+ expect(boardNodeIdFor(el(null))).toBeNull()
186
+ expect(boardNodeIdFor(null)).toBeNull()
187
+ })
188
+
189
+ it('treats an empty data-id as absent', () => {
190
+ // `fitView` over an unknown id silently does nothing, which would look exactly like a
191
+ // reveal that ran — and the step would sit pointing off screen with its budget ticking.
192
+ expect(boardNodeIdFor(el(''))).toBeNull()
193
+ })
194
+ })
195
+
196
+ describe('shouldFocusCard', () => {
197
+ it('takes focus when the tour starts and when the user drives it', () => {
198
+ // The overlay is teleported to the end of `body`, so without this a keyboard user has to
199
+ // tab the whole page to reach Next.
200
+ expect(shouldFocusCard('tour-start')).toBe(true)
201
+ expect(shouldFocusCard('nav-control')).toBe(true)
202
+ })
203
+
204
+ it('leaves focus alone when the step advanced because the user clicked the real control', () => {
205
+ // Such a click routinely opens a modal that autofocuses its own first field, and the next
206
+ // step is typically the one telling the user to type in it. Pulling focus back onto the
207
+ // coach mark puts their caret on a tooltip instead of the form the tour just pointed at.
208
+ expect(shouldFocusCard('target-click')).toBe(false)
209
+ })
210
+ })
@@ -19,6 +19,33 @@ export type TutorialDirection = 'forward' | 'back'
19
19
  /** What the overlay does with a step whose anchor never appeared. */
20
20
  export type SkipOutcome = { kind: 'move'; index: number } | { kind: 'complete' }
21
21
 
22
+ /**
23
+ * Why the step cursor moved. The overlay's only reason for tracking this is the focus
24
+ * decision below, which is why it is a CAUSE rather than a boolean the call sites pass: the
25
+ * question "who moved the cursor" has an obvious answer at every call site, where "should
26
+ * this one steal focus" has to be re-derived — and getting it wrong is silent.
27
+ */
28
+ export type TutorialAdvanceCause = 'tour-start' | 'nav-control' | 'target-click'
29
+
30
+ /**
31
+ * Does the coach mark take focus now that the cursor has moved?
32
+ *
33
+ * Yes for the tour's own controls: the overlay is teleported to the end of `body`, so without
34
+ * this a keyboard user has to tab the whole page to reach Next.
35
+ *
36
+ * No for a `target-click` advance, and that is the whole reason this is a function. Such a
37
+ * step advances because the user operated a REAL control, which routinely opens a modal that
38
+ * rightly autofocuses its own first field — and the next step is typically the one telling
39
+ * them to type in it. Pulling focus back onto our card leaves the caret on a tooltip instead
40
+ * of the form, so the tour breaks the very interaction it just asked for.
41
+ *
42
+ * Not a focus trap either way: half the catalog asks the user to operate the control behind
43
+ * the card, which a trap would put out of reach.
44
+ */
45
+ export function shouldFocusCard(cause: TutorialAdvanceCause): boolean {
46
+ return cause !== 'target-click'
47
+ }
48
+
22
49
  /**
23
50
  * What a `data-testid` may look like. Every one of the ~470 test ids in this layer is
24
51
  * lowercase kebab-case, and the e2e suite's convention keeps it that way, so this rejects
@@ -71,6 +98,32 @@ export function resolveSkip(
71
98
  return index + 1 < total ? { kind: 'move', index: index + 1 } : { kind: 'complete' }
72
99
  }
73
100
 
101
+ /**
102
+ * Vue Flow's per-node wrapper, which carries the node id in `data-id`. The board is a
103
+ * TRANSFORM-panned canvas rather than a scroll container, so an anchor inside one of these is
104
+ * revealed by moving the camera (`fitView({ nodes })`), where every other anchor is revealed
105
+ * by `scrollIntoView`. Asking the DOM which of the two an element is beats keying off the
106
+ * step's target id, since the same id (`task-card`) is a canvas node on the board and a plain
107
+ * list row in a panel.
108
+ */
109
+ export const BOARD_NODE_SELECTOR = '.vue-flow__node'
110
+
111
+ /** The part of an element the reveal path needs: ancestry, and that ancestor's node id. */
112
+ interface RevealNode {
113
+ closest(selector: string): { getAttribute(name: string): string | null } | null
114
+ }
115
+
116
+ /**
117
+ * The board-canvas node id owning this element, or null when the element is not on the canvas
118
+ * (so the caller scrolls it into view instead). An empty `data-id` counts as absent: Vue Flow
119
+ * would match nothing, and a `fitView` over an unknown id silently does nothing at all — which
120
+ * would look exactly like a reveal that ran.
121
+ */
122
+ export function boardNodeIdFor(el: RevealNode | null): string | null {
123
+ const id = el?.closest(BOARD_NODE_SELECTOR)?.getAttribute('data-id') ?? null
124
+ return id !== null && id.length > 0 ? id : null
125
+ }
126
+
74
127
  /** The part of a clicked node this check needs: CSS-selector ancestry. */
75
128
  interface ClickedNode {
76
129
  closest(selector: string): unknown