@cat-factory/app 0.145.0 → 0.146.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 (30) hide show
  1. package/app/components/board/TaskDependencyEdges.vue +37 -73
  2. package/app/components/brainstorm/BrainstormWindow.vue +2 -7
  3. package/app/components/clarity/ClarityReviewWindow.vue +2 -2
  4. package/app/components/consensus/ConsensusSessionWindow.vue +2 -2
  5. package/app/components/docs/DocInterviewWindow.vue +1 -1
  6. package/app/components/environments/EnvSetupStepper.vue +70 -0
  7. package/app/components/environments/EnvironmentSetupWizard.vue +9 -59
  8. package/app/components/forkDecision/ForkDecisionWindow.vue +1 -1
  9. package/app/components/initiative/InitiativePlanningWindow.vue +1 -1
  10. package/app/components/initiative/InitiativeTrackerWindow.vue +1 -1
  11. package/app/components/prReview/PrReviewWindow.vue +2 -2
  12. package/app/components/requirements/RequirementsReviewWindow.vue +2 -2
  13. package/app/components/spec/ServiceSpecWindow.vue +2 -2
  14. package/app/composables/useResultView.ts +26 -3
  15. package/app/modular/journeys/environmentSetup.logic.spec.ts +46 -15
  16. package/app/modular/journeys/environmentSetup.logic.ts +102 -35
  17. package/app/modular/journeys/environmentSetup.ts +17 -68
  18. package/app/stores/pipelines.ts +50 -19
  19. package/app/stores/ui/modals.ts +599 -532
  20. package/i18n/locales/de.json +1 -0
  21. package/i18n/locales/en.json +1 -0
  22. package/i18n/locales/es.json +1 -0
  23. package/i18n/locales/fr.json +1 -0
  24. package/i18n/locales/he.json +1 -0
  25. package/i18n/locales/it.json +1 -0
  26. package/i18n/locales/ja.json +1 -0
  27. package/i18n/locales/pl.json +1 -0
  28. package/i18n/locales/tr.json +1 -0
  29. package/i18n/locales/uk.json +1 -0
  30. package/package.json +4 -4
@@ -108,88 +108,52 @@ function border(cx: number, cy: number, hw: number, hh: number, tx: number, ty:
108
108
  return { x: cx + dx * t, y: cy + dy * t }
109
109
  }
110
110
 
111
+ /** Resolve the on-screen, origin-relative border-to-border segment between two blocks,
112
+ * or null when either end is missing or both collapsed into the same frame. */
113
+ function segmentBetween(sourceId: string, targetId: string, origin: DOMRect) {
114
+ const a = anchorEl(sourceId)
115
+ const b = anchorEl(targetId)
116
+ if (!a || !b || a === b) return null // missing, or both collapsed into the same frame
117
+ const ra = a.getBoundingClientRect()
118
+ const rb = b.getBoundingClientRect()
119
+ const ax = ra.left + ra.width / 2 - origin.left
120
+ const ay = ra.top + ra.height / 2 - origin.top
121
+ const bx = rb.left + rb.width / 2 - origin.left
122
+ const by = rb.top + rb.height / 2 - origin.top
123
+ const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
124
+ const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
125
+ return { x1: start.x, y1: start.y, x2: end.x, y2: end.y }
126
+ }
127
+
128
+ /** Map a list of {id, source, target} links to their drawable (arrowhead-agnostic) segments. */
129
+ function linkSegments(
130
+ links: { id: string; source: string; target: string }[],
131
+ origin: DOMRect,
132
+ ): { id: string; x1: number; y1: number; x2: number; y2: number }[] {
133
+ const out: { id: string; x1: number; y1: number; x2: number; y2: number }[] = []
134
+ for (const link of links) {
135
+ const seg = segmentBetween(link.source, link.target, origin)
136
+ if (seg) out.push({ id: link.id, ...seg })
137
+ }
138
+ return out
139
+ }
140
+
111
141
  function recompute() {
112
142
  const el = svg.value
113
143
  if (!el) return
114
144
  const origin = el.getBoundingClientRect()
115
- const next: Seg[] = []
116
145
 
146
+ const next: Seg[] = []
117
147
  for (const d of taskDeps.value) {
118
- const a = anchorEl(d.source)
119
- const b = anchorEl(d.target)
120
- if (!a || !b || a === b) continue // missing, or both collapsed into the same frame
121
-
122
- const ra = a.getBoundingClientRect()
123
- const rb = b.getBoundingClientRect()
124
- const ax = ra.left + ra.width / 2 - origin.left
125
- const ay = ra.top + ra.height / 2 - origin.top
126
- const bx = rb.left + rb.width / 2 - origin.left
127
- const by = rb.top + rb.height / 2 - origin.top
128
-
129
- const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
130
- const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
131
-
132
- next.push({
133
- id: d.id,
134
- x1: start.x,
135
- y1: start.y,
136
- x2: end.x,
137
- y2: end.y,
138
- done: board.getBlock(d.source)?.status === 'done',
139
- })
148
+ const seg = segmentBetween(d.source, d.target, origin)
149
+ if (!seg) continue
150
+ next.push({ id: d.id, ...seg, done: board.getBlock(d.source)?.status === 'done' })
140
151
  }
141
152
  segments.value = next
142
153
 
143
- const members: MemberSeg[] = []
144
- for (const link of epicLinks.value) {
145
- const a = anchorEl(link.source)
146
- const b = anchorEl(link.target)
147
- if (!a || !b || a === b) continue
148
- const ra = a.getBoundingClientRect()
149
- const rb = b.getBoundingClientRect()
150
- const ax = ra.left + ra.width / 2 - origin.left
151
- const ay = ra.top + ra.height / 2 - origin.top
152
- const bx = rb.left + rb.width / 2 - origin.left
153
- const by = rb.top + rb.height / 2 - origin.top
154
- const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
155
- const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
156
- members.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
157
- }
158
- memberSegments.value = members
159
-
160
- const fes: FrontendSeg[] = []
161
- for (const link of frontendLinks.value) {
162
- const a = anchorEl(link.source)
163
- const b = anchorEl(link.target)
164
- if (!a || !b || a === b) continue
165
- const ra = a.getBoundingClientRect()
166
- const rb = b.getBoundingClientRect()
167
- const ax = ra.left + ra.width / 2 - origin.left
168
- const ay = ra.top + ra.height / 2 - origin.top
169
- const bx = rb.left + rb.width / 2 - origin.left
170
- const by = rb.top + rb.height / 2 - origin.top
171
- const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
172
- const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
173
- fes.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
174
- }
175
- frontendSegments.value = fes
176
-
177
- const conns: ConnectionSeg[] = []
178
- for (const link of connectionLinks.value) {
179
- const a = anchorEl(link.source)
180
- const b = anchorEl(link.target)
181
- if (!a || !b || a === b) continue
182
- const ra = a.getBoundingClientRect()
183
- const rb = b.getBoundingClientRect()
184
- const ax = ra.left + ra.width / 2 - origin.left
185
- const ay = ra.top + ra.height / 2 - origin.top
186
- const bx = rb.left + rb.width / 2 - origin.left
187
- const by = rb.top + rb.height / 2 - origin.top
188
- const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
189
- const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
190
- conns.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
191
- }
192
- connectionSegments.value = conns
154
+ memberSegments.value = linkSegments(epicLinks.value, origin)
155
+ frontendSegments.value = linkSegments(frontendLinks.value, origin)
156
+ connectionSegments.value = linkSegments(connectionLinks.value, origin)
193
157
  }
194
158
 
195
159
  const { pause, resume } = useRafFn(recompute, { immediate: false })
@@ -22,7 +22,6 @@ import type {
22
22
 
23
23
  const board = useBoardStore()
24
24
  const brainstorm = useBrainstormStore()
25
- const ui = useUiStore()
26
25
  const toast = useToast()
27
26
  const { t } = useI18n()
28
27
  const access = useWorkspaceAccess()
@@ -32,15 +31,11 @@ const redoComment = ref('')
32
31
  const showRedo = ref(false)
33
32
 
34
33
  const { open, blockId, stage, close } = useResultView('brainstorm', {
35
- // `onOpen` fires synchronously from `useResultView`'s immediate watch, BEFORE the `stage`
36
- // const below is initialised — so read the stage straight off the store here (referencing
37
- // `stage.value` would hit its temporal dead zone and throw on every open).
38
- onOpen: (id) => {
34
+ onOpen: ({ blockId, stage }) => {
39
35
  drafts.value = {}
40
36
  redoComment.value = ''
41
37
  showRedo.value = false
42
- const openStage = ui.resultView?.stage
43
- if (openStage) void brainstorm.load(id, openStage)
38
+ if (stage) void brainstorm.load(blockId, stage)
44
39
  },
45
40
  })
46
41
  const activeStage = computed<BrainstormStage>(() => stage.value ?? 'requirements')
@@ -43,12 +43,12 @@ const showRedo = ref(false)
43
43
  // fresh each open, so a non-immediate per-window watch used to leave it empty for whichever
44
44
  // route (a pipeline step / "Review & approve") didn't warm the cache by selecting the block.
45
45
  const { open, blockId, close } = useResultView('clarity-review', {
46
- onOpen: (id) => {
46
+ onOpen: ({ blockId }) => {
47
47
  drafts.value = {}
48
48
  seededReply.value = {}
49
49
  redoComment.value = ''
50
50
  showRedo.value = false
51
- void clarity.load(id)
51
+ void clarity.load(blockId)
52
52
  },
53
53
  // Flush any typed-but-unblurred answer before the view tears down (X, backdrop, Escape) so
54
54
  // closing the window never silently drops it (UX-33).
@@ -20,8 +20,8 @@ const consensus = useConsensusStore()
20
20
  const models = useModelsStore()
21
21
 
22
22
  const { open, blockId, close } = useResultView('consensus-session', {
23
- onOpen: (id) => {
24
- void consensus.load(id)
23
+ onOpen: ({ blockId }) => {
24
+ void consensus.load(blockId)
25
25
  },
26
26
  })
27
27
 
@@ -16,7 +16,7 @@ const { t } = useI18n()
16
16
  const access = useWorkspaceAccess()
17
17
 
18
18
  const { open, blockId, close } = useResultView('doc-interview', {
19
- onOpen: (id) => void docInterview.load(id),
19
+ onOpen: ({ blockId }) => void docInterview.load(blockId),
20
20
  })
21
21
 
22
22
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ // The environment-setup wizard's stepper header, driven entirely by
3
+ // `useJourneyProgress` (modular-react#83, production-feedback item 4). The ordered
4
+ // steps, the live position, and the "Step X of N" total all come from
5
+ // `resolveStepSequence` walking the journey's transition graph — there is no
6
+ // hand-maintained step-order array to keep in sync (the old `ENV_STEP_ORDER` +
7
+ // `crumbState` is gone). Rendered inside `<JourneyHost>`, which hands it the live
8
+ // `instanceId`; each step's label is the i18n key carried in the journey's `steps`
9
+ // metadata.
10
+ import type { InstanceId } from '@modular-vue/journeys'
11
+ import { useJourneyProgress } from '@modular-vue/journeys'
12
+ import { environmentSetupJourney } from '~/modular/journeys/environmentSetup'
13
+ import type { EnvSetupInput } from '~/modular/journeys/environmentSetup.logic'
14
+
15
+ const props = defineProps<{
16
+ /** The live journey instance from `<JourneyHost>`, or null before it starts. */
17
+ instanceId: InstanceId | null
18
+ /** The launch input; frozen for the host's lifetime, so the resolved spine is
19
+ * stable (`useJourneyProgress` reads `sequence.input` once at setup). */
20
+ input: EnvSetupInput
21
+ }>()
22
+
23
+ const { t, te } = useI18n()
24
+
25
+ const { index, total, steps } = useJourneyProgress(
26
+ () => props.instanceId,
27
+ environmentSetupJourney,
28
+ { sequence: { input: props.input } },
29
+ )
30
+
31
+ /** Resolve a step's progress-label key (from the journey's `steps` metadata)
32
+ * through i18n, tolerating a locale that omits it rather than leaking the key. */
33
+ function stepLabel(key: string | undefined): string {
34
+ return key && te(key) ? t(key) : ''
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <div class="space-y-2" data-testid="env-setup-stepper">
40
+ <p v-if="total" class="text-[11px] font-medium text-slate-400" data-testid="env-setup-progress">
41
+ {{ t('environmentWizard.progress', { index: index + 1, total }) }}
42
+ </p>
43
+ <ol class="flex items-center gap-2 text-[11px]">
44
+ <li
45
+ v-for="(step, i) in steps"
46
+ :key="step.entry"
47
+ class="flex items-center gap-2"
48
+ :data-testid="`env-setup-crumb-${step.entry}`"
49
+ >
50
+ <span
51
+ class="flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold"
52
+ :class="{
53
+ 'bg-primary-500 text-white': i === index,
54
+ 'bg-emerald-600/70 text-white': i < index,
55
+ 'bg-slate-700 text-slate-300': i > index,
56
+ }"
57
+ >{{ i + 1 }}</span
58
+ >
59
+ <span :class="i === index ? 'text-slate-100' : 'text-slate-500'">
60
+ {{ stepLabel(step.progressLabel) }}
61
+ </span>
62
+ <UIcon
63
+ v-if="i < steps.length - 1"
64
+ name="i-lucide-chevron-right"
65
+ class="h-3 w-3 text-slate-600"
66
+ />
67
+ </li>
68
+ </ol>
69
+ </div>
70
+ </template>
@@ -8,14 +8,15 @@
8
8
  // journey (`~/modular/journeys/environmentSetup`), hosted here by `<JourneyHost>`:
9
9
  // it starts the journey on open (RESUMING the in-flight instance for the same
10
10
  // frame when one was left mid-flow, via the Pinia persistence adapter), renders
11
- // the current step through `<JourneyOutlet>`, and abandons it on close. Each step
12
- // component drives the `environmentWizard` store for its data/actions and fires
13
- // the journey's exits to advance. On `complete` (the save step's Done) the journey
14
- // clears its persisted blob and we close the modal.
11
+ // the current step through `<JourneyOutlet>`, and abandons it on close. The
12
+ // stepper header + "Step X of N" are derived from the journey's transition graph
13
+ // by `<EnvSetupStepper>` (via `useJourneyProgress`), not a hand-maintained step
14
+ // list. Each step component drives the `environmentWizard` store for its
15
+ // data/actions and fires the journey's exits to advance. On `complete` (the save
16
+ // step's Done) the journey clears its persisted blob and we close the modal.
15
17
  import { computed } from 'vue'
16
18
  import { JourneyHost, JourneyOutlet } from '@modular-vue/journeys'
17
19
  import { environmentSetupHandle } from '~/modular/journeys/environmentSetup'
18
- import { ENV_STEP_ORDER, type EnvStep } from '~/modular/journeys/environmentSetup.logic'
19
20
 
20
21
  const ui = useUiStore()
21
22
  const { t } = useI18n()
@@ -30,23 +31,6 @@ const open = computed({
30
31
  // Read once at journey start; keyed for resume. Frozen for the host's lifetime,
31
32
  // so the modal remounts the host per open (it renders under `v-if` upstream).
32
33
  const input = computed(() => ({ frameId: ui.environmentWizardFrameId }))
33
-
34
- const STEP_LABEL = computed<Record<EnvStep, string>>(() => ({
35
- pick: t('environmentWizard.steps.pick'),
36
- review: t('environmentWizard.steps.review'),
37
- preflight: t('environmentWizard.steps.preflight'),
38
- save: t('environmentWizard.steps.save'),
39
- }))
40
-
41
- /** The crumb index of a step, relative to the current journey step. */
42
- function crumbState(
43
- entry: EnvStep,
44
- currentEntry: EnvStep | undefined,
45
- ): 'current' | 'done' | 'todo' {
46
- if (!currentEntry) return 'todo'
47
- if (entry === currentEntry) return 'current'
48
- return ENV_STEP_ORDER.indexOf(entry) < ENV_STEP_ORDER.indexOf(currentEntry) ? 'done' : 'todo'
49
- }
50
34
  </script>
51
35
 
52
36
  <template>
@@ -63,43 +47,9 @@ function crumbState(
63
47
  :input="input"
64
48
  @finished="ui.closeEnvironmentSetup()"
65
49
  >
66
- <template #default="{ instanceId, instance }">
67
- <!-- stepper header, driven by the journey's current step -->
68
- <ol class="flex items-center gap-2 text-[11px]">
69
- <li
70
- v-for="(s, i) in ENV_STEP_ORDER"
71
- :key="s"
72
- class="flex items-center gap-2"
73
- :data-testid="`env-setup-crumb-${s}`"
74
- >
75
- <span
76
- class="flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold"
77
- :class="{
78
- 'bg-primary-500 text-white':
79
- crumbState(s, (instance?.step?.entry as EnvStep) ?? undefined) === 'current',
80
- 'bg-emerald-600/70 text-white':
81
- crumbState(s, (instance?.step?.entry as EnvStep) ?? undefined) === 'done',
82
- 'bg-slate-700 text-slate-300':
83
- crumbState(s, (instance?.step?.entry as EnvStep) ?? undefined) === 'todo',
84
- }"
85
- >{{ i + 1 }}</span
86
- >
87
- <span
88
- :class="
89
- crumbState(s, (instance?.step?.entry as EnvStep) ?? undefined) === 'current'
90
- ? 'text-slate-100'
91
- : 'text-slate-500'
92
- "
93
- >
94
- {{ STEP_LABEL[s] }}
95
- </span>
96
- <UIcon
97
- v-if="i < ENV_STEP_ORDER.length - 1"
98
- name="i-lucide-chevron-right"
99
- class="h-3 w-3 text-slate-600"
100
- />
101
- </li>
102
- </ol>
50
+ <template #default="{ instanceId }">
51
+ <!-- stepper header, derived from the journey graph -->
52
+ <EnvSetupStepper :instance-id="instanceId" :input="input" />
103
53
 
104
54
  <!-- the current step (pick / review / preflight / save) -->
105
55
  <div class="mt-4">
@@ -26,7 +26,7 @@ const { t } = useI18n()
26
26
  // Hybrid: state rides the coder step (like follow-ups), but warm it from the GET on open too.
27
27
  // No `stepRef`: this is a pre-run decision, so there's no "restart from here".
28
28
  const { open, blockId, instanceId, stepIndex, close } = useResultView('fork-decision', {
29
- onOpen: (id) => void forkDecision.load(id),
29
+ onOpen: ({ blockId }) => void forkDecision.load(blockId),
30
30
  })
31
31
 
32
32
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -17,7 +17,7 @@ const initiatives = useInitiativesStore()
17
17
  const { t } = useI18n()
18
18
 
19
19
  const { open, blockId, close } = useResultView('initiative-planning', {
20
- onOpen: (id) => void initiatives.load(id),
20
+ onOpen: ({ blockId }) => void initiatives.load(blockId),
21
21
  })
22
22
 
23
23
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -26,7 +26,7 @@ const { t } = useI18n()
26
26
  const toast = useToast()
27
27
 
28
28
  const { open, blockId, close } = useResultView('initiative-tracker', {
29
- onOpen: (id) => void initiatives.load(id),
29
+ onOpen: ({ blockId }) => void initiatives.load(blockId),
30
30
  })
31
31
 
32
32
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -30,8 +30,8 @@ const access = useWorkspaceAccess()
30
30
  const { t } = useI18n()
31
31
 
32
32
  const { open, blockId, instanceId, stepIndex, close } = useResultView('pr-review', {
33
- onOpen: (_id) => {
34
- if (instanceId.value) void prReview.load(instanceId.value)
33
+ onOpen: ({ instanceId }) => {
34
+ if (instanceId) void prReview.load(instanceId)
35
35
  },
36
36
  })
37
37
 
@@ -55,7 +55,7 @@ const docCollapsedOverride = ref<boolean | null>(null)
55
55
  // fresh each open, so a non-immediate per-window watch used to leave it empty for whichever
56
56
  // route (a pipeline step / "Review & approve") didn't warm the cache by selecting the block.
57
57
  const { open, blockId, instanceId, stepIndex, close } = useResultView('requirements-review', {
58
- onOpen: (id) => {
58
+ onOpen: ({ blockId }) => {
59
59
  drafts.value = {}
60
60
  seededReply.value = {}
61
61
  recommendMode.value = new Set()
@@ -64,7 +64,7 @@ const { open, blockId, instanceId, stepIndex, close } = useResultView('requireme
64
64
  redoComment.value = ''
65
65
  showRedo.value = false
66
66
  docCollapsedOverride.value = null
67
- void requirements.load(id)
67
+ void requirements.load(blockId)
68
68
  },
69
69
  // Closing the window (X, backdrop, Escape) must not silently drop an answer the user typed
70
70
  // but never blurred out of. Flush before the view tears down; flushDrafts captures the
@@ -25,10 +25,10 @@ const mode = ref<ViewMode>('structured')
25
25
  const selected = ref<{ m: number; g: number } | null>(null)
26
26
 
27
27
  const { open, blockId, close } = useResultView('service-spec', {
28
- onOpen: (id) => {
28
+ onOpen: ({ blockId }) => {
29
29
  mode.value = 'structured'
30
30
  selected.value = null
31
- void serviceSpec.load(id)
31
+ void serviceSpec.load(blockId)
32
32
  },
33
33
  })
34
34
 
@@ -24,9 +24,24 @@
24
24
  * overlay stack (top overlay closes first, focus/scroll managed too). A listener here would
25
25
  * double-fire `close`, so it was removed once the last window converted onto the shell.
26
26
  */
27
+ /**
28
+ * The fully-resolved view context handed to `onOpen`. Every field is already initialised by
29
+ * the time `onOpen` fires, so a loader takes exactly what it needs from here and never reaches
30
+ * back into the store or the composable's own return refs. That matters because `onOpen` fires
31
+ * synchronously from the `immediate` watch below — DURING the caller's `setup`, before the
32
+ * `const { … } = useResultView(…)` destructure has been assigned — so any callback that closed
33
+ * over those refs would hit their temporal dead zone and throw on every open.
34
+ */
35
+ export interface OpenResultView {
36
+ blockId: string
37
+ instanceId: string | null
38
+ stepIndex: number | null
39
+ stage: 'requirements' | 'architecture' | null
40
+ }
41
+
27
42
  export function useResultView(
28
43
  viewId: string,
29
- opts?: { onOpen?: (blockId: string) => void; onClose?: () => void },
44
+ opts?: { onOpen?: (view: OpenResultView) => void; onClose?: () => void },
30
45
  ) {
31
46
  const ui = useUiStore()
32
47
 
@@ -44,12 +59,20 @@ export function useResultView(
44
59
  ui.closeResultView()
45
60
  }
46
61
 
47
- // The load-on-open contract: fire immediately on mount and on any later block switch.
62
+ // The load-on-open contract: fire immediately on mount and on any later block switch. The
63
+ // callback receives the fully-resolved context (see OpenResultView) rather than the return
64
+ // refs, which aren't assigned yet at the initial synchronous fire.
48
65
  if (opts?.onOpen) {
49
66
  watch(
50
67
  blockId,
51
68
  (id) => {
52
- if (id) opts.onOpen!(id)
69
+ if (id)
70
+ opts.onOpen!({
71
+ blockId: id,
72
+ instanceId: instanceId.value,
73
+ stepIndex: stepIndex.value,
74
+ stage: stage.value,
75
+ })
53
76
  },
54
77
  { immediate: true },
55
78
  )
@@ -1,20 +1,28 @@
1
1
  import { describe, expect, it } from 'vitest'
2
+ import { resolveStepSequence } from '@modular-vue/journeys'
2
3
  import {
3
- ENV_STEP_ORDER,
4
+ ENV_MODULE_ID,
5
+ environmentSetupJourney,
4
6
  envInitialState,
5
- envNextAfter,
6
7
  envStartStep,
7
8
  } from './environmentSetup.logic'
9
+ import en from '../../../i18n/locales/en.json'
8
10
 
9
- // Pure navigation graph for the environment-setup journey (slice 3). `envNextAfter`
10
- // is the source of truth the journey's `advance` transitions derive their target
11
- // entries from (see `environmentSetup.ts`), so pinning the order, start branch, and
12
- // forward edges here means a wiring regression can't silently reorder or drop a step.
13
- describe('environment-setup journey logic', () => {
14
- it('orders the steps pick → review → preflight → save', () => {
15
- expect(ENV_STEP_ORDER).toEqual(['pick', 'review', 'preflight', 'save'])
16
- })
11
+ /** Dot-path lookup into the real `en.json`, mirroring which keys actually ship. */
12
+ function hasKey(path: string): boolean {
13
+ return (
14
+ path.split('.').reduce<unknown>((node, seg) => {
15
+ return node && typeof node === 'object' ? (node as Record<string, unknown>)[seg] : undefined
16
+ }, en) !== undefined
17
+ )
18
+ }
17
19
 
20
+ // Pure navigation graph for the environment-setup journey (slice 3). The step
21
+ // order is derived from the annotated transition graph (production-feedback item
22
+ // 4), so `resolveStepSequenceResult` walking the real journey definition is the
23
+ // guard against a silent reorder or dropped step — no hand-maintained order array
24
+ // to pin separately.
25
+ describe('environment-setup journey logic', () => {
18
26
  it('seeds state from the launch input', () => {
19
27
  expect(envInitialState({ frameId: 'blk_1' })).toEqual({ frameId: 'blk_1' })
20
28
  expect(envInitialState({ frameId: null })).toEqual({ frameId: null })
@@ -25,10 +33,33 @@ describe('environment-setup journey logic', () => {
25
33
  expect(envStartStep({ frameId: null }, { frameId: null })).toBe('pick')
26
34
  })
27
35
 
28
- it('advances linearly and terminates after save', () => {
29
- expect(envNextAfter('pick')).toBe('review')
30
- expect(envNextAfter('review')).toBe('preflight')
31
- expect(envNextAfter('preflight')).toBe('save')
32
- expect(envNextAfter('save')).toBe('done')
36
+ it('derives the pick review → preflight → save order from the transition graph', () => {
37
+ const steps = resolveStepSequence(environmentSetupJourney, { input: { frameId: null } })
38
+ expect(steps.map((s) => s.entry)).toEqual(['pick', 'review', 'preflight', 'save'])
39
+ expect(steps.every((s) => s.module === ENV_MODULE_ID)).toBe(true)
40
+ expect(steps.map((s) => s.progressLabel)).toEqual([
41
+ 'environmentWizard.steps.pick',
42
+ 'environmentWizard.steps.review',
43
+ 'environmentWizard.steps.preflight',
44
+ 'environmentWizard.steps.save',
45
+ ])
46
+ })
47
+
48
+ it('starts the derived sequence at review when a frame is preselected', () => {
49
+ const steps = resolveStepSequence(environmentSetupJourney, { input: { frameId: 'blk_1' } })
50
+ expect(steps.map((s) => s.entry)).toEqual(['review', 'preflight', 'save'])
51
+ })
52
+
53
+ // The stepper resolves each label through a VARIABLE key (`t(step.progressLabel)`), so
54
+ // the tier-1 typed-message-keys check + `vue-i18n-extract` can no longer see these keys
55
+ // as used and can't catch a typo or a dropped key. Pin the missing-key guard here instead:
56
+ // every `progressLabel` the real definition emits must resolve to a shipping `en.json` key.
57
+ it('carries a shipping en.json label key for every derived step', () => {
58
+ const steps = resolveStepSequence(environmentSetupJourney, { input: { frameId: null } })
59
+ for (const step of steps) {
60
+ const key = step.progressLabel
61
+ expect(key, `missing i18n key for step "${step.entry}"`).toBeTruthy()
62
+ expect(hasKey(key ?? ''), `key "${key}" not in en.json`).toBe(true)
63
+ }
33
64
  })
34
65
  })