@cat-factory/app 0.196.1 → 0.198.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +105 -0
  2. package/app/components/github/AddServiceFromRepoModal.vue +6 -1
  3. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +164 -0
  4. package/app/components/tutorial/TutorialOverlay.logic.ts +125 -0
  5. package/app/components/tutorial/TutorialOverlay.vue +307 -0
  6. package/app/components/tutorial/TutorialPrompt.vue +102 -0
  7. package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
  8. package/app/composables/useNavContributions.ts +1 -0
  9. package/app/composables/usePipelineErrorToast.spec.ts +4 -1
  10. package/app/composables/usePipelineErrorToast.ts +24 -165
  11. package/app/composables/useTutorialTours.ts +18 -0
  12. package/app/modular/nav-contributions.spec.ts +14 -0
  13. package/app/modular/nav-contributions.ts +70 -0
  14. package/app/modular/nav-gates.logic.spec.ts +41 -0
  15. package/app/modular/nav-gates.logic.ts +36 -0
  16. package/app/modular/nav-gates.ts +54 -0
  17. package/app/modular/registry.spec.ts +6 -0
  18. package/app/modular/registry.ts +3 -1
  19. package/app/modular/slots.ts +7 -0
  20. package/app/modular/tutorial-tours.spec.ts +222 -0
  21. package/app/modular/tutorial-tours.ts +421 -0
  22. package/app/pages/index.vue +61 -0
  23. package/app/stores/board/dependencies.ts +52 -0
  24. package/app/stores/board/placement.ts +4 -37
  25. package/app/stores/execution/pendingGates.ts +109 -0
  26. package/app/stores/execution.ts +7 -94
  27. package/app/stores/requirements/recommendations.ts +77 -0
  28. package/app/stores/requirements.ts +17 -43
  29. package/app/stores/tutorial.spec.ts +135 -0
  30. package/app/stores/tutorial.ts +145 -0
  31. package/app/stores/workspace/commands.ts +77 -0
  32. package/app/stores/workspace.ts +11 -50
  33. package/app/utils/tutorial.spec.ts +115 -0
  34. package/app/utils/tutorial.ts +246 -0
  35. package/i18n/locales/de.json +209 -2
  36. package/i18n/locales/en.json +215 -2
  37. package/i18n/locales/es.json +209 -2
  38. package/i18n/locales/fr.json +209 -2
  39. package/i18n/locales/he.json +209 -2
  40. package/i18n/locales/it.json +209 -2
  41. package/i18n/locales/ja.json +209 -2
  42. package/i18n/locales/pl.json +209 -2
  43. package/i18n/locales/tr.json +209 -2
  44. package/i18n/locales/uk.json +209 -2
  45. package/package.json +1 -1
@@ -0,0 +1,307 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ computeCoachMarkLayout,
4
+ DEFAULT_TARGET_WAIT_MS,
5
+ TARGET_TRACK_INTERVAL_MS,
6
+ } from '~/utils/tutorial'
7
+ import type { CoachMarkLayout, TutorialRect, TutorialStep, TutorialTour } from '~/utils/tutorial'
8
+ import {
9
+ isTargetClickAdvance,
10
+ resolveSkip,
11
+ stepTargetSelectors,
12
+ unexpectedlySkippedSteps,
13
+ waitBudgetMs,
14
+ } from './TutorialOverlay.logic'
15
+ import type { TutorialDirection } from './TutorialOverlay.logic'
16
+
17
+ // The one shared tour runtime: resolves the running tour from the `tutorialTours` slot,
18
+ // anchors a highlight ring + tooltip to the current step's `data-testid`, and advances
19
+ // on Next or on a real click on the highlighted control. Mounted (from `pages/index.vue`)
20
+ // only while `tutorial.touring`, so all the DOM tracking below exists only mid-tour.
21
+ //
22
+ // Anchor tracking is a poll, not a one-shot query: board controls move (canvas pan/zoom,
23
+ // panels opening) and appear asynchronously (a step can point INTO the modal the previous
24
+ // step's click opens), so every tick re-queries and re-measures. A target that never
25
+ // appears within the step's wait SKIPS the step — controls are RBAC/tier/deployment
26
+ // dependent, and a tour is a set of opportunities, not a fixed script. Everything that
27
+ // DECIDES rather than measures lives in `TutorialOverlay.logic.ts`, which is unit-tested.
28
+ const { t } = useI18n()
29
+ const tutorial = useTutorialStore()
30
+ const { tours } = useTutorialTours()
31
+
32
+ /**
33
+ * The running tour's script, resolved ONCE from the slot when the tour starts and then HELD
34
+ * for its duration. Gates decide what is OFFERED; they do not get to rewrite a walkthrough
35
+ * that is already under way.
36
+ *
37
+ * Re-reading the gated slot on every flip was fine while the gates were slow-moving facts
38
+ * (a permission, a connection). Gates over live RUN state flip as a DIRECT RESULT of
39
+ * following the tour: `answer-park` is offered while something is waiting for a human, so
40
+ * the moment the user answered — the very thing the tour teaches — its `when` went false,
41
+ * the slot dropped the tour, and the watch below tore the overlay down one step short of its
42
+ * own finish card, with no completion recorded. Holding the script also freezes the branch
43
+ * `resolveTours` picked, so a step can't be swapped underneath a stationary cursor when a
44
+ * board that had both a decision and an approval loses one of them mid-tour.
45
+ */
46
+ const tour = shallowRef<TutorialTour | null>(null)
47
+ watch(
48
+ () => tutorial.activeTourId,
49
+ (id) => {
50
+ // Read untracked (a watch callback registers no dependencies), which is what pins the
51
+ // script: only starting a DIFFERENT tour re-resolves it.
52
+ tour.value = id ? (tours.value.find((x) => x.id === id) ?? null) : null
53
+ },
54
+ { immediate: true },
55
+ )
56
+
57
+ const step = computed<TutorialStep | null>(() => tour.value?.steps[tutorial.stepIndex] ?? null)
58
+ const total = computed(() => tour.value?.steps.length ?? 0)
59
+ const isLast = computed(() => tour.value !== null && tutorial.stepIndex >= total.value - 1)
60
+
61
+ // The tour could not be resolved when it started (a stale persisted id, or a tour this board
62
+ // is not offered at all) or the cursor ran past the end: end it instead of rendering a dead
63
+ // overlay. Since the script is held, this can no longer fire because a gate flipped mid-tour.
64
+ watch(
65
+ () => [tour.value, step.value] as const,
66
+ ([tr, st]) => {
67
+ if (tutorial.touring && (!tr || !st)) tutorial.stopTour()
68
+ },
69
+ { immediate: true },
70
+ )
71
+
72
+ const targetRect = ref<TutorialRect | null>(null)
73
+ const cardEl = ref<HTMLElement | null>(null)
74
+ const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'center' })
75
+
76
+ /**
77
+ * Wall-clock deadline for the current step's anchor search. A deadline rather than a
78
+ * per-tick counter: `measure()` also runs on resize and on every step change, so counting
79
+ * invocations let a resize drag burn a "4000 ms" budget in a fraction of that time.
80
+ */
81
+ const searchDeadline = ref(0)
82
+ /** Which way `skipMissingStep` travels — see `resolveSkip`. */
83
+ const direction = ref<TutorialDirection>('forward')
84
+ /** Steps this run gave up on, so the final card can be honest about an abridged tour. */
85
+ const skippedStepIds = ref<Set<string>>(new Set())
86
+
87
+ /** A targeted step whose anchor hasn't been found yet (renders the waiting note). */
88
+ const searching = computed(() => step.value?.target !== undefined && targetRect.value === null)
89
+ /** The skips the final card must own up to — a branch-gated step's absence is not one. */
90
+ const unexpectedSkips = computed(() =>
91
+ unexpectedlySkippedSteps(skippedStepIds.value, tour.value?.steps ?? []),
92
+ )
93
+ const abridged = computed(() => unexpectedSkips.value.length > 0)
94
+
95
+ const viewport = () => ({ width: window.innerWidth, height: window.innerHeight })
96
+ /** The tooltip's own size, or a sensible guess before it has rendered once. */
97
+ const cardSize = () => ({
98
+ width: cardEl.value?.offsetWidth ?? 320,
99
+ height: cardEl.value?.offsetHeight ?? 180,
100
+ })
101
+
102
+ function queryTarget(s: TutorialStep): HTMLElement | null {
103
+ for (const selector of stepTargetSelectors(s)) {
104
+ const el = document.querySelector<HTMLElement>(selector)
105
+ // `getClientRects().length` distinguishes a mounted-but-hidden control (display:none
106
+ // drawer item) from a visible one; pointing at an invisible control helps nobody.
107
+ if (el && el.getClientRects().length > 0) return el
108
+ }
109
+ return null
110
+ }
111
+
112
+ /** Give up on a step whose anchor never appeared, continuing the user's own direction. */
113
+ function skipMissingStep(s: TutorialStep) {
114
+ skippedStepIds.value = new Set(skippedStepIds.value).add(s.id)
115
+ const outcome = resolveSkip(tutorial.stepIndex, direction.value, total.value)
116
+ if (outcome.kind === 'complete') tutorial.completeTour()
117
+ else tutorial.setStepIndex(outcome.index)
118
+ }
119
+
120
+ function measure() {
121
+ const s = step.value
122
+ if (!s) return
123
+ if (!s.target) {
124
+ targetRect.value = null
125
+ } else {
126
+ const el = queryTarget(s)
127
+ if (!el) {
128
+ targetRect.value = null
129
+ // Centered while searching: the card must not sit at the PREVIOUS step's anchor —
130
+ // nor at its off-screen initial position — pointing at nothing.
131
+ layout.value = computeCoachMarkLayout(null, cardSize(), viewport())
132
+ if (performance.now() >= searchDeadline.value) skipMissingStep(s)
133
+ return
134
+ }
135
+ const r = el.getBoundingClientRect()
136
+ targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
137
+ }
138
+ layout.value = computeCoachMarkLayout(
139
+ s.target ? targetRect.value : null,
140
+ cardSize(),
141
+ viewport(),
142
+ s.placement,
143
+ )
144
+ }
145
+
146
+ // Re-arm per step: fresh wait budget, drop the stale anchor immediately (the ring must not
147
+ // linger on the previous control while the next anchor is located), and re-measure once
148
+ // the card has re-rendered its new copy (its size feeds the layout).
149
+ watch(step, async (s) => {
150
+ searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
151
+ targetRect.value = null
152
+ await nextTick()
153
+ measure()
154
+ })
155
+
156
+ function advance() {
157
+ direction.value = 'forward'
158
+ if (isLast.value) tutorial.completeTour()
159
+ else tutorial.setStepIndex(tutorial.stepIndex + 1)
160
+ }
161
+
162
+ function back() {
163
+ direction.value = 'back'
164
+ tutorial.setStepIndex(tutorial.stepIndex - 1)
165
+ }
166
+
167
+ // "Now click this" steps: watch real clicks (capture phase, so a stopPropagation inside a
168
+ // control can't hide them) and follow along AFTER the app has reacted — the deferral lets
169
+ // the real handler open its modal/submit its form before the tour moves its anchor.
170
+ function onDocumentClick(event: MouseEvent) {
171
+ if (isTargetClickAdvance(step.value, event.target)) {
172
+ window.setTimeout(advance, 0)
173
+ }
174
+ }
175
+
176
+ /** Esc ends the tour, matching every other dismissible surface in the app. */
177
+ function onKeydown(event: KeyboardEvent) {
178
+ if (event.key === 'Escape') tutorial.stopTour()
179
+ }
180
+
181
+ let trackTimer: ReturnType<typeof setInterval> | undefined
182
+ onMounted(() => {
183
+ const s = step.value
184
+ searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
185
+ document.addEventListener('click', onDocumentClick, true)
186
+ document.addEventListener('keydown', onKeydown)
187
+ window.addEventListener('resize', measure)
188
+ trackTimer = setInterval(measure, TARGET_TRACK_INTERVAL_MS)
189
+ measure()
190
+ })
191
+ onUnmounted(() => {
192
+ document.removeEventListener('click', onDocumentClick, true)
193
+ document.removeEventListener('keydown', onKeydown)
194
+ window.removeEventListener('resize', measure)
195
+ if (trackTimer !== undefined) clearInterval(trackTimer)
196
+ })
197
+ </script>
198
+
199
+ <template>
200
+ <!-- Teleported so board/panel stacking contexts can't clip the marks; z-[70] sits above
201
+ the app's modals (z-50s), since steps legitimately point INTO an open modal. -->
202
+ <Teleport to="body">
203
+ <div v-if="step" data-testid="tutorial-overlay">
204
+ <div
205
+ v-if="targetRect"
206
+ class="ring-primary-400 outline-primary-400/25 pointer-events-none fixed z-[70] rounded-lg outline-4 ring-2 transition-all duration-150"
207
+ :style="{
208
+ top: `${targetRect.top - 4}px`,
209
+ left: `${targetRect.left - 4}px`,
210
+ width: `${targetRect.width + 8}px`,
211
+ height: `${targetRect.height + 8}px`,
212
+ }"
213
+ data-testid="tutorial-highlight"
214
+ />
215
+ <!-- `pointer-events-auto` AND the swallowed `pointerdown` are both required for the
216
+ steps that point INSIDE an open modal: Nuxt UI's modal is a reka-ui dismissable
217
+ layer, which sets `body { pointer-events: none }` (leaving this card inert) and
218
+ dismisses on a document-level pointerdown outside its own content (so a press on
219
+ this card would close the user's half-filled form instead of pressing a button). -->
220
+ <div
221
+ ref="cardEl"
222
+ role="dialog"
223
+ aria-live="polite"
224
+ :aria-label="t('tutorial.overlay.ariaLabel')"
225
+ class="pointer-events-auto fixed z-[70] w-80 max-w-[calc(100vw-16px)] rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
226
+ :style="{ top: `${layout.top}px`, left: `${layout.left}px` }"
227
+ data-testid="tutorial-tooltip"
228
+ @pointerdown.stop
229
+ >
230
+ <div class="mb-1 flex items-start justify-between gap-3">
231
+ <h3 class="text-sm font-semibold text-slate-100">{{ t(step.titleKey) }}</h3>
232
+ <span class="shrink-0 text-xs text-slate-500">
233
+ {{ t('tutorial.overlay.progress', { current: tutorial.stepIndex + 1, total }) }}
234
+ </span>
235
+ </div>
236
+ <!-- `bodyParams` carries the fixed proper nouns a step names (a repository slug),
237
+ which live in the catalog's `{named}` placeholders rather than in nine
238
+ translations of the same literal. Absent for most steps. -->
239
+ <p class="text-sm text-slate-300">{{ t(step.bodyKey, step.bodyParams ?? {}) }}</p>
240
+ <p
241
+ v-if="searching"
242
+ class="mt-2 flex items-center gap-1.5 text-xs text-slate-400"
243
+ data-testid="tutorial-searching"
244
+ >
245
+ <UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
246
+ {{ t('tutorial.overlay.searching') }}
247
+ </p>
248
+ <p
249
+ v-else-if="step.advanceOn === 'target-click'"
250
+ class="text-primary-300 mt-2 text-xs"
251
+ data-testid="tutorial-click-hint"
252
+ >
253
+ {{ t('tutorial.overlay.clickHint') }}
254
+ </p>
255
+ <!-- Reaching the end having skipped steps is NOT the same as having been shown the
256
+ whole tour: say so rather than congratulating the user either way. -->
257
+ <p
258
+ v-if="isLast && abridged"
259
+ class="mt-2 text-xs text-amber-300/90"
260
+ data-testid="tutorial-abridged"
261
+ >
262
+ {{
263
+ t(
264
+ 'tutorial.overlay.abridged',
265
+ { count: unexpectedSkips.length },
266
+ unexpectedSkips.length,
267
+ )
268
+ }}
269
+ </p>
270
+ <div class="mt-3 flex items-center justify-between gap-2">
271
+ <UButton
272
+ size="xs"
273
+ variant="ghost"
274
+ color="neutral"
275
+ data-testid="tutorial-skip"
276
+ @click="tutorial.stopTour()"
277
+ >
278
+ {{ t('tutorial.overlay.skip') }}
279
+ </UButton>
280
+ <div class="flex items-center gap-2">
281
+ <UButton
282
+ v-if="tutorial.stepIndex > 0"
283
+ size="xs"
284
+ variant="soft"
285
+ color="neutral"
286
+ data-testid="tutorial-back"
287
+ @click="back()"
288
+ >
289
+ {{ t('tutorial.overlay.back') }}
290
+ </UButton>
291
+ <!-- A click-to-advance step hides Next so the real control is the only way
292
+ forward (its Done form stays on a last step, which must be finishable). -->
293
+ <UButton
294
+ v-if="step.advanceOn !== 'target-click' || isLast"
295
+ size="xs"
296
+ color="primary"
297
+ data-testid="tutorial-next"
298
+ @click="advance()"
299
+ >
300
+ {{ isLast ? t('tutorial.overlay.done') : t('tutorial.overlay.next') }}
301
+ </UButton>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ </Teleport>
307
+ </template>
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ // The tutorial launch prompt: asks once on first launch whether the user wants a guided
3
+ // tour, and doubles as the tour picker for later visits (command palette: "Take a tour").
4
+ // Lists whatever the merged `tutorialTours` slot offers this user (first-party + consumer
5
+ // tours, RBAC-gated per tour), so it grows with the catalog rather than hard-coding tours.
6
+ //
7
+ // The decision semantics live in the store: starting a tour or "No thanks" is SAVED (the
8
+ // prompt never auto-opens again), while closing without answering defers to next launch.
9
+ const { t } = useI18n()
10
+ const tutorial = useTutorialStore()
11
+ const { tours } = useTutorialTours()
12
+
13
+ const open = computed({
14
+ get: () => tutorial.promptOpen,
15
+ set: (v: boolean) => (v ? tutorial.openPrompt() : tutorial.closePrompt()),
16
+ })
17
+
18
+ // Only an unanswered prompt offers the persistent "No thanks"; once a decision exists this
19
+ // is just a picker, and the only dismissal left is a plain close.
20
+ const undecided = computed(() => tutorial.decision === null)
21
+ </script>
22
+
23
+ <template>
24
+ <UModal v-model:open="open" :title="t('tutorial.prompt.title')" :ui="{ content: 'max-w-lg' }">
25
+ <template #body>
26
+ <!-- NOTE for spec authors: this marks the modal's BODY, so it is the "prompt is
27
+ showing" signal, not a container for everything in the prompt. The footer's
28
+ decline/close buttons are in a SIBLING slot — address them from the page by
29
+ their own test ids rather than scoping a locator under this one. -->
30
+ <div class="space-y-4" data-testid="tutorial-prompt">
31
+ <p class="text-sm text-slate-300">{{ t('tutorial.prompt.intro') }}</p>
32
+ <ul class="space-y-2">
33
+ <li
34
+ v-for="tour in tours"
35
+ :key="tour.id"
36
+ class="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
37
+ >
38
+ <UIcon
39
+ :name="tour.icon ?? 'i-lucide-compass'"
40
+ class="h-5 w-5 shrink-0 text-primary-400"
41
+ />
42
+ <div class="min-w-0 flex-1">
43
+ <div class="flex items-center gap-2">
44
+ <span class="text-sm font-medium text-slate-100">{{ t(tour.titleKey) }}</span>
45
+ <UBadge
46
+ v-if="tutorial.isCompleted(tour.id)"
47
+ color="success"
48
+ variant="subtle"
49
+ size="sm"
50
+ data-testid="tutorial-tour-completed"
51
+ >
52
+ {{ t('tutorial.prompt.completed') }}
53
+ </UBadge>
54
+ </div>
55
+ <p class="text-xs text-slate-400">{{ t(tour.descriptionKey) }}</p>
56
+ </div>
57
+ <UButton
58
+ size="sm"
59
+ color="primary"
60
+ :variant="tutorial.isCompleted(tour.id) ? 'soft' : 'solid'"
61
+ :data-testid="`tutorial-start-${tour.id}`"
62
+ @click="tutorial.startTour(tour.id)"
63
+ >
64
+ {{
65
+ tutorial.isCompleted(tour.id)
66
+ ? t('tutorial.prompt.restart')
67
+ : t('tutorial.prompt.start')
68
+ }}
69
+ </UButton>
70
+ </li>
71
+ </ul>
72
+ <!-- Every tour gated away (e.g. a viewer on a write-only catalog): say so rather
73
+ than showing an unexplained empty list. -->
74
+ <p v-if="tours.length === 0" class="text-sm text-slate-400">
75
+ {{ t('tutorial.prompt.empty') }}
76
+ </p>
77
+ </div>
78
+ </template>
79
+ <template #footer>
80
+ <div class="flex w-full items-center justify-between gap-2">
81
+ <UButton
82
+ v-if="undecided"
83
+ color="neutral"
84
+ variant="ghost"
85
+ data-testid="tutorial-decline"
86
+ @click="tutorial.decline()"
87
+ >
88
+ {{ t('tutorial.prompt.decline') }}
89
+ </UButton>
90
+ <span v-else />
91
+ <UButton
92
+ color="neutral"
93
+ variant="soft"
94
+ data-testid="tutorial-close"
95
+ @click="tutorial.closePrompt()"
96
+ >
97
+ {{ undecided ? t('tutorial.prompt.later') : t('common.close') }}
98
+ </UButton>
99
+ </div>
100
+ </template>
101
+ </UModal>
102
+ </template>
@@ -0,0 +1,181 @@
1
+ import type { ParsedConflict } from '~/composables/usePipelineErrorToast'
2
+
3
+ /**
4
+ * The bespoke conflict toasts: the five 409 reasons whose copy interpolates runtime detail and
5
+ * whose remedy is a one-click jump into the panel that fixes them. Split out of
6
+ * {@link usePipelineErrorToast} purely for size — it closes over the SAME toast / ui / i18n
7
+ * handles, so behaviour is identical to the former in-composable functions.
8
+ */
9
+ export function createBespokeConflictToasts(deps: {
10
+ toast: ReturnType<typeof useToast>
11
+ ui: ReturnType<typeof useUiStore>
12
+ t: ReturnType<typeof useI18n>['t']
13
+ te: ReturnType<typeof useI18n>['te']
14
+ }): (conflict: ParsedConflict) => boolean {
15
+ const { toast, ui, t, te } = deps
16
+ // The headline case: a pipeline step's model has no usable provider. Name the
17
+ // offending model(s), explain no provider is available, and offer the one-click jump
18
+ // to the AI setup — the same remedy the startup "No AI model configured" banner gives.
19
+ function presentProvidersUnconfigured(conflict: ParsedConflict): void {
20
+ const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
21
+ const list = models.join(', ')
22
+ toast.add({
23
+ title: t('errors.conflict.providersUnconfigured.title'),
24
+ description: list
25
+ ? t('errors.conflict.providersUnconfigured.body', { models: list })
26
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
27
+ color: 'error',
28
+ icon: 'i-lucide-cpu',
29
+ // Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
30
+ // auto-dismiss takes the one-click fix with it before the user can reach it.
31
+ duration: 0,
32
+ actions: [
33
+ {
34
+ label: t('errors.conflict.providersUnconfigured.action'),
35
+ icon: 'i-lucide-settings',
36
+ onClick: () => ui.openAiProviderSetup(),
37
+ },
38
+ ],
39
+ })
40
+ }
41
+
42
+ // A pipeline step relies on binary-artifact storage (the UI Tester uploads screenshots)
43
+ // but the account has none configured. Explain it and offer the jump to the content-storage
44
+ // settings — the same shape as the providers-unconfigured case above. Prefer the localized
45
+ // body (it carries no runtime interpolation) so non-English users see translated copy; the
46
+ // raw backend prose is only the last-resort fallback when the locale lacks the key.
47
+ function presentBinaryStorageUnconfigured(conflict: ParsedConflict): void {
48
+ toast.add({
49
+ title: t('errors.conflict.binaryStorageUnconfigured.title'),
50
+ description: te('errors.conflict.binaryStorageUnconfigured.body')
51
+ ? t('errors.conflict.binaryStorageUnconfigured.body')
52
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
53
+ color: 'error',
54
+ icon: 'i-lucide-image',
55
+ // Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
56
+ // remedy reachable instead of letting it auto-dismiss.
57
+ duration: 0,
58
+ actions: [
59
+ {
60
+ label: t('errors.conflict.binaryStorageUnconfigured.action'),
61
+ icon: 'i-lucide-settings',
62
+ onClick: () => ui.openContentStorageSettings(),
63
+ },
64
+ ],
65
+ })
66
+ }
67
+
68
+ // A pipeline includes a Deployer, but the SERVICE's ephemeral-environment config (the in-repo
69
+ // "what/where") is incomplete for its declared type. Steer the user straight to THAT service's
70
+ // environment config — the compose wizard for docker-compose, the service inspector otherwise —
71
+ // falling back to the workspace infrastructure window if the frame id wasn't carried.
72
+ function presentDeployerServiceConfig(conflict: ParsedConflict): void {
73
+ const frameId =
74
+ typeof conflict.details.frameId === 'string' ? conflict.details.frameId : undefined
75
+ const provisionType =
76
+ typeof conflict.details.provisionType === 'string'
77
+ ? conflict.details.provisionType
78
+ : undefined
79
+ const missing = Array.isArray(conflict.details.missing)
80
+ ? conflict.details.missing.join(', ')
81
+ : ''
82
+ toast.add({
83
+ title: t('errors.conflict.deployerServiceConfig.title'),
84
+ description: missing
85
+ ? t('errors.conflict.deployerServiceConfig.body', { missing })
86
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
87
+ color: 'error',
88
+ icon: 'i-lucide-server',
89
+ // Sticky, like the other actionable conflicts: keep the "Fix configuration" jump reachable.
90
+ duration: 0,
91
+ actions: [
92
+ {
93
+ label: t('errors.conflict.deployerServiceConfig.action'),
94
+ icon: 'i-lucide-settings',
95
+ onClick: () => {
96
+ if (frameId && provisionType === 'docker-compose') ui.openEnvironmentSetup(frameId)
97
+ else if (frameId) ui.select(frameId)
98
+ else ui.openProviderConnection('environment')
99
+ },
100
+ },
101
+ ],
102
+ })
103
+ }
104
+
105
+ // A pipeline includes a Deployer and the service config is sound, but no WORKSPACE handler
106
+ // resolves for the service's provision type (missing or ambiguous). Steer to the Infrastructure
107
+ // window's Test-environments tab. (Also raised by the Tester start gate — same fix applies.)
108
+ function presentProvisionTypeUnhandled(conflict: ParsedConflict): void {
109
+ const type =
110
+ typeof conflict.details.provisionType === 'string' ? conflict.details.provisionType : ''
111
+ toast.add({
112
+ title: t('errors.conflict.provisionTypeUnhandled.title'),
113
+ description: type
114
+ ? t('errors.conflict.provisionTypeUnhandled.body', { type })
115
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
116
+ color: 'error',
117
+ icon: 'i-lucide-server-cog',
118
+ duration: 0,
119
+ actions: [
120
+ {
121
+ label: t('errors.conflict.provisionTypeUnhandled.action'),
122
+ icon: 'i-lucide-settings',
123
+ onClick: () => ui.openProviderConnection('environment'),
124
+ },
125
+ ],
126
+ })
127
+ }
128
+
129
+ // A pipeline includes a Deployer, the config is structurally complete, but the live connection
130
+ // probe of the resolved deployment integration failed (unreachable endpoint / apiserver, bad
131
+ // token). Surface the provider's failure detail and steer to the handler to fix + re-test it.
132
+ function presentDeployerConnectionFailed(conflict: ParsedConflict): void {
133
+ const detail = typeof conflict.details.detail === 'string' ? conflict.details.detail : undefined
134
+ toast.add({
135
+ title: t('errors.conflict.deployerConnectionFailed.title'),
136
+ description: detail
137
+ ? t('errors.conflict.deployerConnectionFailed.body', { detail })
138
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
139
+ color: 'error',
140
+ icon: 'i-lucide-plug',
141
+ duration: 0,
142
+ actions: [
143
+ {
144
+ label: t('errors.conflict.deployerConnectionFailed.action'),
145
+ icon: 'i-lucide-settings',
146
+ onClick: () => ui.openProviderConnection('environment'),
147
+ },
148
+ ],
149
+ })
150
+ }
151
+
152
+ /**
153
+ * Dispatch the bespoke conflict reasons (a runtime-interpolated body + a "configure X" action,
154
+ * each with its own key namespace — the ones excluded from `CONFLICT_INFO`). Returns `true` when
155
+ * the reason was one of them (and the toast was raised), `false` to fall through to the generic
156
+ * map. The reason values are mutually exclusive, so dispatch order is irrelevant.
157
+ */
158
+ function presentBespokeConflict(conflict: ParsedConflict): boolean {
159
+ switch (conflict.reason) {
160
+ case 'providers_unconfigured':
161
+ presentProvidersUnconfigured(conflict)
162
+ return true
163
+ case 'binary_storage_unconfigured':
164
+ presentBinaryStorageUnconfigured(conflict)
165
+ return true
166
+ case 'deployer_service_provisioning_incomplete':
167
+ presentDeployerServiceConfig(conflict)
168
+ return true
169
+ case 'provision_type_unhandled':
170
+ presentProvisionTypeUnhandled(conflict)
171
+ return true
172
+ case 'deployer_connection_test_failed':
173
+ presentDeployerConnectionFailed(conflict)
174
+ return true
175
+ default:
176
+ return false
177
+ }
178
+ }
179
+
180
+ return presentBespokeConflict
181
+ }
@@ -47,6 +47,7 @@ export function useNavContributions() {
47
47
  operatorDashboard: () => ui.openOperatorDashboard(),
48
48
  reports: () => ui.openReports(),
49
49
  shortcuts: () => ui.openShortcutsHelp(),
50
+ tutorial: () => useTutorialStore().openPrompt(),
50
51
  // No-op under an env pin (`setMode` refuses), so the palette entry matches the sidebar
51
52
  // switcher's read-only state rather than pretending to flip a tier the resolver fixes.
52
53
  toggleUiMode: () => useUiModeStore().toggleMode(),
@@ -49,7 +49,10 @@ beforeEach(() => {
49
49
  }
50
50
  vi.stubGlobal('useToast', () => ({ add, update }))
51
51
  vi.stubGlobal('useUiStore', () => ui)
52
- vi.stubGlobal('useI18n', () => ({ t, te: (key: string) => hasKey(key) }))
52
+ // Stubbed on the Nuxt app's GLOBAL i18n instance, which is what this composable resolves
53
+ // (never `useI18n()`): it is called from store setup, where no component instance exists.
54
+ // See `frontend/app/README.md` — "A store must be instantiable outside a component setup".
55
+ vi.stubGlobal('useNuxtApp', () => ({ $i18n: { t, te: (key: string) => hasKey(key) } }))
53
56
  })
54
57
 
55
58
  function conflict(reason?: string, details: Record<string, unknown> = {}, message?: string) {