@cat-factory/app 0.197.0 → 0.198.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,27 @@ over the WebSocket. How that sync works is written up in
55
55
  | `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
56
56
  | `utils/` | Small pure helpers. |
57
57
 
58
+ ### A store must be instantiable outside a component `setup`
59
+
60
+ A Pinia setup store runs its body on the FIRST `useStore()` anywhere in the app, and that
61
+ caller is not always a component: `plugins/modular.client.ts` builds the nav gates
62
+ (`createNavGates`) during plugin setup, which instantiates a handful of stores before any
63
+ component exists. So nothing a store reaches for at setup time may require an active
64
+ component instance.
65
+
66
+ The one that bites is **`useI18n()`, which throws `MUST_BE_CALL_SETUP_TOP` outside a
67
+ component** — and because it happens inside a plugin, Nuxt's error boundary replaces the
68
+ whole app with its 500 page rather than surfacing a broken feature. Resolve translations
69
+ through the Nuxt app's global i18n instance instead (`useNuxtApp().$i18n`, typed as
70
+ `ReturnType<typeof useI18n>`), as `stores/board.ts`, `stores/recurringPipelines.ts` and
71
+ `composables/usePipelineErrorToast.ts` do. This costs no typed-message-key coverage: tier 1
72
+ only sees literal keys written in a `<script setup>`, never in a `.ts` store or composable.
73
+
74
+ The blast radius is why this is a rule rather than a preference — a store reached one call
75
+ earlier than before takes the entire SPA down at boot, and the unit suite cannot see it
76
+ (nothing there installs the plugin). Every e2e spec does, because every one of them boots
77
+ the app.
78
+
58
79
  ### Always import a layer component explicitly
59
80
 
60
81
  **Import a component under `components/` by path before using it in a template.** Do not lean on Nuxt's auto-registration. This layer sets no `components` config, so the default `pathPrefix: true` applies and a component is registered under its path-prefixed name: `components/panels/StepEffortReport.vue` becomes `PanelsStepEffortReport`, and a bare `<StepEffortReport>` matches nothing.
@@ -184,13 +205,58 @@ congratulating the user on a walkthrough they did not see — and a tour that co
184
205
  be abridged should not be offered at all, which is what each tour's `when(gates)` is for (the
185
206
  task-creation tour requires board write AND a service frame to add a task to).
186
207
 
208
+ **A step carries its own `when(gates)` when its BRANCH, not its control, is the thing that
209
+ may not apply.** The two are different facts and only one of them is a defect: a skip means
210
+ the control should be here and isn't, while a `when` means this board is not on that branch
211
+ of the flow (a run parked on a decision has no approval gate, and the reverse). Reporting the
212
+ second as an abridged tour would tell a user who saw exactly the right walkthrough that they
213
+ missed half of it, every time. `resolveTours` (in `utils/tutorial.ts`, applied by
214
+ `navSlotFilter`) drops the rejected steps and then drops a tour left with none, so a tour
215
+ whose every step is branch-specific can never open on an empty cursor. With no gates service
216
+ wired at all (a bare install withholds nothing) every branch survives instead, and only one
217
+ of them can anchor — so the abridged notice ignores any skipped step that carries a `when`,
218
+ which has already declared that not applying is legitimate.
219
+
220
+ **Gates decide what is OFFERED; the running tour's script is resolved once and HELD.** The
221
+ overlay snapshots its tour when it starts rather than re-reading the gated slot on every
222
+ flip. This is not an optimisation: gates over live run state flip as a direct result of
223
+ following the tour — `answer-park` is offered while something waits for a human, so the
224
+ moment the user answers, its `when` goes false. A re-reading overlay tore itself down there,
225
+ one step short of its own finish card and with nothing recorded as completed, at exactly the
226
+ moment the user succeeded. Holding the script also freezes the branch `resolveTours` chose,
227
+ so a step can't be swapped underneath a stationary cursor.
228
+
229
+ **Gates must mean what the board RENDERS, not what the store holds.** `boardHasOpenDecision`
230
+ / `boardHasPendingApproval` are not the store's raw pending counts: a park on a frame block
231
+ has no task card, and a reviewer gate mid-cycle is deliberately suppressed by the card
232
+ (`useReviewStage().isBackground`), so either would offer a tour onto a control that isn't
233
+ there. `hasActionablePark` (`modular/nav-gates.logic.ts`) is the shared rule; the run gates
234
+ are task-scoped for the same reason.
235
+
236
+ **Fixed proper nouns ride `bodyParams`, not the catalogs.** A step naming the sample
237
+ repository slug (`SAMPLE_REPO` in `modular/tutorial-tours.ts`) passes it as a `{repo}`
238
+ interpolation, so it is written once in code rather than translated into ten catalogs that
239
+ each drift on their own — the same split components make for inline placeholders.
240
+
241
+ The built-ins walk the delivery loop end to end, each gated on the state the previous one
242
+ leaves behind, so the launch prompt only ever offers what this board can demonstrate: board
243
+ basics, add a repository (`add-service`), create a task (`first-task`), run it (`run-task`),
244
+ answer it when it parks (`answer-park`), review and merge the result (`review-merge`). One
245
+ deliberate asymmetry: `run-task` points at Start without click-to-advance, because starting a
246
+ run spends real model budget and nobody should discover they agreed to that by following a
247
+ tutorial.
248
+
187
249
  Two runtime constraints worth knowing before changing the overlay: it must keep
188
250
  `pointer-events-auto` and swallow `pointerdown`, because Nuxt UI modals are reka-ui
189
251
  dismissable layers that set `body { pointer-events: none }` and dismiss on an outside
190
252
  pointerdown — without both, the tooltip's own buttons go inert and pressing one closes the
191
253
  user's half-filled form. And everything that DECIDES (skip direction, wait budget,
192
- target-click matching) lives in `components/tutorial/TutorialOverlay.logic.ts` so it is
193
- unit-tested; the SFC keeps only the DOM work.
254
+ target-click matching, which skips count as abridged) lives in
255
+ `components/tutorial/TutorialOverlay.logic.ts` so it is unit-tested; the SFC keeps only the
256
+ DOM work. Note that target-click matching is by SELECTOR, not by the highlighted element:
257
+ several anchors (`task-card`, `task-resolve`, `run-step`) render once per board item and the
258
+ ring can only sit on one of them, so requiring the click to land on that one left a user who
259
+ clicked the card the copy asked for with no way forward — such a step renders no Next.
194
260
 
195
261
  The catalog is the `tutorialTours` slot: first-party tours live in
196
262
  `modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
@@ -380,7 +380,11 @@ function done() {
380
380
  :description="t('github.addService.repositoryHint')"
381
381
  required
382
382
  >
383
- <div class="space-y-1.5">
383
+ <!-- The wrapper, not the UInputMenu itself, carries the anchor: a tutorial tour
384
+ stop needs an element that is present the moment the modal mounts, and it
385
+ highlights the whole field rather than whichever inner node Nuxt UI happens
386
+ to forward the attribute to. -->
387
+ <div class="space-y-1.5" data-testid="add-service-repo-search">
384
388
  <UInputMenu
385
389
  v-model="selectedRepoId"
386
390
  v-model:search-term="repoSearch"
@@ -553,6 +557,7 @@ function done() {
553
557
  icon="i-lucide-plus"
554
558
  :loading="adding"
555
559
  :disabled="!canAdd"
560
+ data-testid="add-service-submit"
556
561
  @click="add"
557
562
  >
558
563
  {{ t('github.addService.add') }}
@@ -5,7 +5,7 @@ import {
5
5
  resolveSkip,
6
6
  stepTargetIds,
7
7
  stepTargetSelectors,
8
- tourWasAbridged,
8
+ unexpectedlySkippedSteps,
9
9
  waitBudgetMs,
10
10
  } from '~/components/tutorial/TutorialOverlay.logic'
11
11
  import { DEFAULT_TARGET_WAIT_MS } from '~/utils/tutorial'
@@ -99,28 +99,66 @@ describe('resolveSkip', () => {
99
99
  })
100
100
 
101
101
  describe('isTargetClickAdvance', () => {
102
- const el = document.createElement('button')
103
- const child = document.createElement('span')
104
- el.appendChild(child)
105
-
106
- it('advances on a real click on the highlighted control, or inside it', () => {
102
+ /** A control carrying `id`, with a nested span, mounted so `closest` can walk to it. */
103
+ const control = (id: string) => {
104
+ const el = document.createElement('button')
105
+ el.setAttribute('data-testid', id)
106
+ el.appendChild(document.createElement('span'))
107
+ document.body.appendChild(el)
108
+ return el
109
+ }
110
+
111
+ it('advances on a real click on the control, or inside it', () => {
112
+ const el = control('add-task-submit')
107
113
  const s = step({ target: 'add-task-submit', advanceOn: 'target-click' })
108
- expect(isTargetClickAdvance(s, el, el)).toBe(true)
109
- expect(isTargetClickAdvance(s, el, child)).toBe(true)
114
+ expect(isTargetClickAdvance(s, el)).toBe(true)
115
+ expect(isTargetClickAdvance(s, el.firstChild)).toBe(true)
116
+ })
117
+
118
+ it('advances on ANY instance of a control the board renders per item', () => {
119
+ // `task-card` / `task-resolve` / `run-step` exist once per board item, and the ring can
120
+ // only sit on one of them. Requiring the click to land on THAT one left a user who
121
+ // clicked the card the step's copy asked for with no way forward — a click-to-advance
122
+ // step renders no Next button.
123
+ control('task-card')
124
+ const second = control('task-card')
125
+ const s = step({ target: 'task-card', advanceOn: 'target-click' })
126
+ expect(isTargetClickAdvance(s, second)).toBe(true)
127
+ })
128
+
129
+ it('advances on a fallback anchor too, since either is the control the step named', () => {
130
+ const s = step({ target: 'ui-mode-switcher', altTargets: ['ui-mode-toggle'] })
131
+ expect(
132
+ isTargetClickAdvance({ ...s, advanceOn: 'target-click' }, control('ui-mode-toggle')),
133
+ ).toBe(true)
110
134
  })
111
135
 
112
- it('ignores clicks elsewhere, on a Next-advanced step, or with no anchor', () => {
136
+ it('ignores clicks elsewhere, on a Next-advanced step, or on a non-element', () => {
113
137
  const s = step({ target: 'add-task-submit', advanceOn: 'target-click' })
114
- expect(isTargetClickAdvance(s, el, document.createElement('div'))).toBe(false)
115
- expect(isTargetClickAdvance(s, null, el)).toBe(false)
116
- expect(isTargetClickAdvance(step({ target: 'x' }), el, el)).toBe(false)
117
- expect(isTargetClickAdvance(null, el, el)).toBe(false)
138
+ expect(isTargetClickAdvance(s, control('run-start'))).toBe(false)
139
+ expect(isTargetClickAdvance(s, null)).toBe(false)
140
+ expect(isTargetClickAdvance(s, document.createTextNode('stray'))).toBe(false)
141
+ expect(isTargetClickAdvance(step({ target: 'add-task-submit' }), control('x'))).toBe(false)
142
+ expect(isTargetClickAdvance(null, control('y'))).toBe(false)
118
143
  })
119
144
  })
120
145
 
121
- describe('tourWasAbridged', () => {
122
- it('is true once any step was skipped, so the finish card can say so', () => {
123
- expect(tourWasAbridged(new Set())).toBe(false)
124
- expect(tourWasAbridged(new Set(['addTask']))).toBe(true)
146
+ describe('unexpectedlySkippedSteps', () => {
147
+ const plain = step({ id: 'addTask', target: 'frame-add-task' })
148
+ const branch = step({ id: 'approve', target: 'step-approve', when: () => true })
149
+
150
+ it('counts a skipped step whose control simply was not there', () => {
151
+ expect(unexpectedlySkippedSteps(new Set(['addTask']), [plain, branch])).toEqual([plain])
152
+ })
153
+
154
+ it('does not count a branch-gated step, whose absence it already declared legitimate', () => {
155
+ // The gates-absent case (a bare install withholds nothing, so BOTH branches of a tour
156
+ // are kept and only one can ever anchor). Reporting that as abridged would put a
157
+ // permanent "you missed some of this" on a tour that showed exactly the right branch.
158
+ expect(unexpectedlySkippedSteps(new Set(['approve']), [plain, branch])).toEqual([])
159
+ })
160
+
161
+ it('is empty for a tour that skipped nothing', () => {
162
+ expect(unexpectedlySkippedSteps(new Set(), [plain, branch])).toEqual([])
125
163
  })
126
164
  })
@@ -71,22 +71,55 @@ export function resolveSkip(
71
71
  return index + 1 < total ? { kind: 'move', index: index + 1 } : { kind: 'complete' }
72
72
  }
73
73
 
74
- /** Does this real click count as the "now click this" step's advance? */
74
+ /** The part of a clicked node this check needs: CSS-selector ancestry. */
75
+ interface ClickedNode {
76
+ closest(selector: string): unknown
77
+ }
78
+
79
+ /** The clicked node as something we can ask about ancestry, or null (a text node, `document`). */
80
+ function asClickedNode(eventTarget: EventTarget | null): ClickedNode | null {
81
+ const node = eventTarget as ClickedNode | null
82
+ return node && typeof node.closest === 'function' ? node : null
83
+ }
84
+
85
+ /**
86
+ * Does this real click count as the "now click this" step's advance?
87
+ *
88
+ * Matched against the step's SELECTORS rather than against the one element the tracker
89
+ * happened to highlight, because several of the controls a tour points at are rendered once
90
+ * PER BOARD ITEM: `task-card`, `task-resolve`, `run-step`. The tracker anchors its ring to
91
+ * the first match in the DOM, which is not necessarily the card the step's own copy is
92
+ * asking for ("open a task whose run has finished"). Requiring the click to land inside THAT
93
+ * element meant a user who clicked the right card got no advance at all — and a
94
+ * click-to-advance step renders no Next button, so the tour had no way forward but Skip.
95
+ * Any instance of the control the step names is the action the step asked for.
96
+ */
75
97
  export function isTargetClickAdvance(
76
98
  step: TutorialStep | null,
77
- targetEl: { contains: (node: Node) => boolean } | null,
78
99
  eventTarget: EventTarget | null,
79
100
  ): boolean {
80
- if (!step || step.advanceOn !== 'target-click' || !targetEl) return false
81
- return eventTarget instanceof Node && targetEl.contains(eventTarget)
101
+ if (!step || step.advanceOn !== 'target-click') return false
102
+ const node = asClickedNode(eventTarget)
103
+ if (!node) return false
104
+ return stepTargetSelectors(step).some((selector) => node.closest(selector) != null)
82
105
  }
83
106
 
84
107
  /**
85
- * A tour is ABRIDGED when it reached its end having skipped steps: the controls those
86
- * steps point at aren't part of this board/role/deployment. The final card says so
87
- * instead of congratulating the user on a walkthrough they never saw — absent and
88
- * complete must not render the same.
108
+ * The skipped steps whose absence is a DEFECT which is what the final card's "abridged"
109
+ * notice is about: the controls those steps point at aren't part of this board/role/
110
+ * deployment, so saying nothing would congratulate the user on a walkthrough they never saw.
111
+ *
112
+ * A step carrying a `when` is excluded, because it has already declared that not applying is
113
+ * a legitimate state rather than a missing control. Normally such a step is DROPPED before
114
+ * the tour runs (see `resolveTours`), so it never reaches the skip path at all — but with no
115
+ * gates service wired (a bare install, where nothing is withheld) every branch of a tour is
116
+ * kept, and exactly one of them can ever anchor. Counting the other as abridged would put a
117
+ * permanent "you missed some of this" on a tour that showed the user precisely the branch
118
+ * their board is on.
89
119
  */
90
- export function tourWasAbridged(skippedStepIds: ReadonlySet<string>): boolean {
91
- return skippedStepIds.size > 0
120
+ export function unexpectedlySkippedSteps(
121
+ skippedStepIds: ReadonlySet<string>,
122
+ steps: readonly TutorialStep[],
123
+ ): TutorialStep[] {
124
+ return steps.filter((s) => skippedStepIds.has(s.id) && s.when === undefined)
92
125
  }
@@ -4,12 +4,12 @@ import {
4
4
  DEFAULT_TARGET_WAIT_MS,
5
5
  TARGET_TRACK_INTERVAL_MS,
6
6
  } from '~/utils/tutorial'
7
- import type { CoachMarkLayout, TutorialRect, TutorialStep } from '~/utils/tutorial'
7
+ import type { CoachMarkLayout, TutorialRect, TutorialStep, TutorialTour } from '~/utils/tutorial'
8
8
  import {
9
9
  isTargetClickAdvance,
10
10
  resolveSkip,
11
11
  stepTargetSelectors,
12
- tourWasAbridged,
12
+ unexpectedlySkippedSteps,
13
13
  waitBudgetMs,
14
14
  } from './TutorialOverlay.logic'
15
15
  import type { TutorialDirection } from './TutorialOverlay.logic'
@@ -29,13 +29,38 @@ const { t } = useI18n()
29
29
  const tutorial = useTutorialStore()
30
30
  const { tours } = useTutorialTours()
31
31
 
32
- const tour = computed(() => tours.value.find((x) => x.id === tutorial.activeTourId) ?? null)
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
+
33
57
  const step = computed<TutorialStep | null>(() => tour.value?.steps[tutorial.stepIndex] ?? null)
34
58
  const total = computed(() => tour.value?.steps.length ?? 0)
35
59
  const isLast = computed(() => tour.value !== null && tutorial.stepIndex >= total.value - 1)
36
60
 
37
- // The running tour vanished from the slot (a gate flipped, a consumer module withdrew it)
38
- // or the cursor ran past the end: end the tour instead of rendering a dead overlay.
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.
39
64
  watch(
40
65
  () => [tour.value, step.value] as const,
41
66
  ([tr, st]) => {
@@ -44,7 +69,6 @@ watch(
44
69
  { immediate: true },
45
70
  )
46
71
 
47
- const targetEl = ref<HTMLElement | null>(null)
48
72
  const targetRect = ref<TutorialRect | null>(null)
49
73
  const cardEl = ref<HTMLElement | null>(null)
50
74
  const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'center' })
@@ -62,7 +86,11 @@ const skippedStepIds = ref<Set<string>>(new Set())
62
86
 
63
87
  /** A targeted step whose anchor hasn't been found yet (renders the waiting note). */
64
88
  const searching = computed(() => step.value?.target !== undefined && targetRect.value === null)
65
- const abridged = computed(() => tourWasAbridged(skippedStepIds.value))
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)
66
94
 
67
95
  const viewport = () => ({ width: window.innerWidth, height: window.innerHeight })
68
96
  /** The tooltip's own size, or a sensible guess before it has rendered once. */
@@ -93,11 +121,9 @@ function measure() {
93
121
  const s = step.value
94
122
  if (!s) return
95
123
  if (!s.target) {
96
- targetEl.value = null
97
124
  targetRect.value = null
98
125
  } else {
99
126
  const el = queryTarget(s)
100
- targetEl.value = el
101
127
  if (!el) {
102
128
  targetRect.value = null
103
129
  // Centered while searching: the card must not sit at the PREVIOUS step's anchor —
@@ -122,7 +148,6 @@ function measure() {
122
148
  // the card has re-rendered its new copy (its size feeds the layout).
123
149
  watch(step, async (s) => {
124
150
  searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
125
- targetEl.value = null
126
151
  targetRect.value = null
127
152
  await nextTick()
128
153
  measure()
@@ -143,7 +168,7 @@ function back() {
143
168
  // control can't hide them) and follow along AFTER the app has reacted — the deferral lets
144
169
  // the real handler open its modal/submit its form before the tour moves its anchor.
145
170
  function onDocumentClick(event: MouseEvent) {
146
- if (isTargetClickAdvance(step.value, targetEl.value, event.target)) {
171
+ if (isTargetClickAdvance(step.value, event.target)) {
147
172
  window.setTimeout(advance, 0)
148
173
  }
149
174
  }
@@ -208,7 +233,10 @@ onUnmounted(() => {
208
233
  {{ t('tutorial.overlay.progress', { current: tutorial.stepIndex + 1, total }) }}
209
234
  </span>
210
235
  </div>
211
- <p class="text-sm text-slate-300">{{ t(step.bodyKey) }}</p>
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>
212
240
  <p
213
241
  v-if="searching"
214
242
  class="mt-2 flex items-center gap-1.5 text-xs text-slate-400"
@@ -231,7 +259,13 @@ onUnmounted(() => {
231
259
  class="mt-2 text-xs text-amber-300/90"
232
260
  data-testid="tutorial-abridged"
233
261
  >
234
- {{ t('tutorial.overlay.abridged', { count: skippedStepIds.size }, skippedStepIds.size) }}
262
+ {{
263
+ t(
264
+ 'tutorial.overlay.abridged',
265
+ { count: unexpectedSkips.length },
266
+ unexpectedSkips.length,
267
+ )
268
+ }}
235
269
  </p>
236
270
  <div class="mt-3 flex items-center justify-between gap-2">
237
271
  <UButton
@@ -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) {
@@ -220,6 +220,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
220
220
  titleKey: 'errors.conflict.title.pipeline_schedule_requires_recurring',
221
221
  descriptionKey: 'errors.conflict.description.pipeline_schedule_requires_recurring',
222
222
  },
223
+ foundational_service_exists: {
224
+ titleKey: 'errors.conflict.title.foundational_service_exists',
225
+ descriptionKey: 'errors.conflict.description.foundational_service_exists',
226
+ },
223
227
  pipeline_schedule_intake_unconfigured: {
224
228
  titleKey: 'errors.conflict.title.pipeline_schedule_intake_unconfigured',
225
229
  descriptionKey: 'errors.conflict.description.pipeline_schedule_intake_unconfigured',
@@ -331,7 +335,24 @@ export function describeGenericFailure(error: unknown): GenericFailure {
331
335
  export function usePipelineErrorToast() {
332
336
  const toast = useToast()
333
337
  const ui = useUiStore()
334
- const { t, te } = useI18n()
338
+ // Resolved through the Nuxt app's global i18n instance rather than `useI18n()`, which
339
+ // requires an active component instance — the same pattern (and the same reason) as the
340
+ // board / recurring-pipelines stores.
341
+ //
342
+ // This composable is called from STORE SETUP (`stores/execution.ts`, `stores/agentRuns.ts`),
343
+ // and a Pinia setup store runs its body on the FIRST `useStore()` anywhere. That used to be
344
+ // a component, so `useI18n()` happened to be legal; the moment anything instantiated one of
345
+ // those stores earlier — `createNavGates()` does, from the `enforce: 'post'` modular plugin —
346
+ // vue-i18n threw `MUST_BE_CALL_SETUP_TOP`, the plugin threw, and Nuxt's error boundary
347
+ // replaced the entire app with its 500 page. Every single e2e spec failed on a blank board.
348
+ // A store must be instantiable outside a component, so the i18n handle it reaches for has
349
+ // to be too.
350
+ //
351
+ // Typed as `useI18n`'s own return (`$i18n` IS that global Composer in composition mode), so
352
+ // `t`/`te` keep their real signatures. No typed-message-key coverage is lost by the switch:
353
+ // tier 1 only sees literal keys written in a `<script setup>`, never in a `.ts` composable —
354
+ // the drift guard here is the exhaustive `CONFLICT_INFO` / `ApiErrorCode` records above.
355
+ const { t, te } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
335
356
 
336
357
  // The five bespoke conflict reasons (a runtime-interpolated body + a "configure X" jump each)
337
358
  // live in a sibling factory over the same toast/ui/i18n handles, so this composable stays
@@ -26,6 +26,11 @@ const NO_GATES: NavGates = {
26
26
  // so a dropped item is unambiguously an RBAC/availability drop, not a tier drop.
27
27
  advancedMode: true,
28
28
  boardHasService: false,
29
+ boardHasTask: false,
30
+ boardHasRun: false,
31
+ boardHasOpenDecision: false,
32
+ boardHasPendingApproval: false,
33
+ boardHasFinishedRun: false,
29
34
  }
30
35
 
31
36
  const ALL_GATES: NavGates = {
@@ -39,6 +44,11 @@ const ALL_GATES: NavGates = {
39
44
  isAccountAdmin: true,
40
45
  advancedMode: true,
41
46
  boardHasService: true,
47
+ boardHasTask: true,
48
+ boardHasRun: true,
49
+ boardHasOpenDecision: true,
50
+ boardHasPendingApproval: true,
51
+ boardHasFinishedRun: true,
42
52
  }
43
53
 
44
54
  const slots = (): AppSlots => ({
@@ -1,4 +1,5 @@
1
1
  import { defineModule } from '@modular-vue/core'
2
+ import { resolveTours } from '~/utils/tutorial'
2
3
  import type { AppSlots } from './slots'
3
4
 
4
5
  // Re-exported for the slice-1 importers that reach `AppSlots` through this
@@ -89,6 +90,41 @@ export interface NavGates {
89
90
  * service lands on the board.
90
91
  */
91
92
  boardHasService: boolean
93
+ /**
94
+ * The open board has at least one task block. The service frame that {@link boardHasService}
95
+ * reports is where a task GOES; this is whether one is actually there to run, which is what
96
+ * a tour about the run controls needs — they live in the inspector of a task, and a board of
97
+ * empty frames offers nothing to open.
98
+ */
99
+ boardHasTask: boolean
100
+ /**
101
+ * Some run on a TASK block is cached for this board, in any state. Availability for the
102
+ * steps that explain a run's ANATOMY (the step list, its live progress), which have nothing
103
+ * to anchor to until a run has been started at least once.
104
+ *
105
+ * Task-scoped, like the three below: every surface these gates open is reached through a
106
+ * task card and its inspector, and a frame-level run (a blueprint pass, an initiative plan)
107
+ * renders none of them.
108
+ */
109
+ boardHasRun: boolean
110
+ /**
111
+ * Some task card is offering a human an unanswered DECISION to resolve.
112
+ *
113
+ * "Offering" rather than "exists": these two report what the board actually RENDERS, which
114
+ * is what a tour anchoring on the card's action can point at — see `hasActionablePark` for
115
+ * the two ways a park can exist with no control to show for it.
116
+ */
117
+ boardHasOpenDecision: boolean
118
+ /** Some task card is offering a human an unanswered APPROVAL gate. See above. */
119
+ boardHasPendingApproval: boolean
120
+ /**
121
+ * Some run on a task block has finished successfully. Availability for the review/merge
122
+ * tour: its subject is the OUTPUT of a run, so there has to be one that produced output. A
123
+ * FAILED run deliberately does not count — the failure banner is its own surface, and a
124
+ * tour about reading a result and merging it would spend its steps pointing at controls a
125
+ * failed run never renders.
126
+ */
127
+ boardHasFinishedRun: boolean
92
128
  }
93
129
 
94
130
  /** Command-palette placement + copy for a contribution that appears in the palette. */
@@ -542,11 +578,10 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
542
578
  )
543
579
  : nav,
544
580
  // 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,
581
+ // caller can't reach (e.g. creating tasks without board write) never shows, and a step
582
+ // about a branch this board isn't on is dropped rather than skipped (see `resolveTours`).
583
+ // Same gates-absent pass-through as `nav`.
584
+ tutorialTours: gates ? resolveTours(tutorialTours, gates) : tutorialTours,
550
585
  }
551
586
  }
552
587
 
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { hasActionablePark } from '~/modular/nav-gates.logic'
3
+ import type { ParkedGateRef } from '~/modular/nav-gates.logic'
4
+
5
+ const onlyTasks = (id: string) => id.startsWith('task_')
6
+ const nothingIsBackground = () => false
7
+
8
+ describe('hasActionablePark', () => {
9
+ it('reports a park a task card really shows an action for', () => {
10
+ const parks: ParkedGateRef[] = [{ blockId: 'task_login', agentKind: 'architect' }]
11
+ expect(hasActionablePark(parks, onlyTasks, nothingIsBackground)).toBe(true)
12
+ })
13
+
14
+ it('ignores a park on a block that renders no task card', () => {
15
+ // A frame/module run parks too, but the affordance the tour anchors on is TaskCard's.
16
+ const parks: ParkedGateRef[] = [{ blockId: 'frame_billing', agentKind: 'blueprints' }]
17
+ expect(hasActionablePark(parks, onlyTasks, nothingIsBackground)).toBe(false)
18
+ })
19
+
20
+ it('ignores a reviewer gate the card suppresses as background work', () => {
21
+ // Mirrors `TaskCard.pendingApproval`: while the review is folding answers / re-reviewing
22
+ // it needs no human, so no Resolve button exists to point a tour at.
23
+ const parks: ParkedGateRef[] = [{ blockId: 'task_login', agentKind: 'requirements-review' }]
24
+ const isBackground = (kind: string | undefined) => kind === 'requirements-review'
25
+ expect(hasActionablePark(parks, onlyTasks, isBackground)).toBe(false)
26
+ })
27
+
28
+ it('reports the actionable one when a board holds both kinds at once', () => {
29
+ const parks: ParkedGateRef[] = [
30
+ { blockId: 'frame_billing', agentKind: 'architect' },
31
+ { blockId: 'task_login', agentKind: 'requirements-review' },
32
+ { blockId: 'task_signup', agentKind: 'coder' },
33
+ ]
34
+ const isBackground = (kind: string | undefined) => kind === 'requirements-review'
35
+ expect(hasActionablePark(parks, onlyTasks, isBackground)).toBe(true)
36
+ })
37
+
38
+ it('is false for a board with nothing parked at all', () => {
39
+ expect(hasActionablePark([], onlyTasks, nothingIsBackground)).toBe(false)
40
+ })
41
+ })
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The decisions the `NavGates` service makes that are worth testing without a Pinia runtime.
3
+ * `nav-gates.ts` itself is pure store wiring (getters over computeds); everything that
4
+ * DECIDES something lives here, the same split as `TutorialOverlay.logic.ts`.
5
+ */
6
+
7
+ /** One park (an open decision or a pending approval gate) as the execution store projects it. */
8
+ export interface ParkedGateRef {
9
+ blockId: string
10
+ agentKind?: string
11
+ }
12
+
13
+ /**
14
+ * Is some park actually SHOWING a human an action to take?
15
+ *
16
+ * The gates this answers (`boardHasOpenDecision` / `boardHasPendingApproval`) exist to offer
17
+ * a tour that anchors on a task card's attention affordance (`task-resolve`), so they have to
18
+ * mean what makes that affordance RENDER — not merely "a park exists somewhere in the cached
19
+ * runs". Two facts the raw store counts miss, both of which would offer the tour onto a board
20
+ * with no control to point at (the tour then anchor-skips and reports itself abridged, which
21
+ * is exactly the noise per-step `when` gating exists to avoid):
22
+ *
23
+ * - a park on a frame or module block has no task card, so nothing renders the action;
24
+ * - a reviewer gate mid-cycle is deliberately SUPPRESSED by the card (`TaskCard.pendingApproval`
25
+ * → `useReviewStage().isBackground`): while the driver is folding answers or re-reviewing,
26
+ * the gate needs no human and the card shows a working indicator instead.
27
+ *
28
+ * Both predicates are injected rather than reached for, so the rule is checkable on plain data.
29
+ */
30
+ export function hasActionablePark(
31
+ parks: readonly ParkedGateRef[],
32
+ isTaskBlock: (blockId: string) => boolean,
33
+ isBackground: (agentKind: string | undefined, blockId: string) => boolean,
34
+ ): boolean {
35
+ return parks.some((p) => isTaskBlock(p.blockId) && !isBackground(p.agentKind, p.blockId))
36
+ }