@cat-factory/app 0.12.0 → 0.13.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.
package/app/app.config.ts CHANGED
@@ -4,5 +4,23 @@ export default defineAppConfig({
4
4
  primary: 'indigo',
5
5
  neutral: 'slate',
6
6
  },
7
+ // Give every overlay the same layered dark palette the agent-run-details
8
+ // reader uses: a deep slate-950 surface so the slate-900 panels/cards inside
9
+ // pop, with slate-800 chrome. Applies to all UModal/USlideover instances so
10
+ // overlays stay consistent without per-instance `:ui` overrides.
11
+ modal: {
12
+ slots: {
13
+ content: 'bg-slate-950 ring-slate-800 divide-slate-800',
14
+ header: 'border-b border-slate-800',
15
+ title: 'text-white',
16
+ },
17
+ },
18
+ slideover: {
19
+ slots: {
20
+ content: 'bg-slate-950 ring-slate-800 divide-slate-800',
21
+ header: 'border-b border-slate-800',
22
+ title: 'text-white',
23
+ },
24
+ },
7
25
  },
8
26
  })
@@ -209,7 +209,7 @@ const groups = computed<IntegrationGroup[]>(() => {
209
209
  v-for="item in group.items"
210
210
  :key="item.key"
211
211
  type="button"
212
- class="flex w-full items-center gap-3 rounded-lg border border-slate-700 bg-slate-800/40 px-3 py-2.5 text-left transition hover:border-slate-500 hover:bg-slate-800"
212
+ class="flex w-full items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/50 px-3 py-2.5 text-left transition hover:border-slate-700 hover:bg-slate-900"
213
213
  @click="item.onClick()"
214
214
  >
215
215
  <UIcon :name="item.icon" class="h-5 w-5 shrink-0 text-slate-300" />
@@ -406,6 +406,20 @@ const showOriginalDescription = ref(false)
406
406
  <!-- task: context issues (tracker) -->
407
407
  <TaskContextIssues v-if="isTask" :block="block" />
408
408
 
409
+ <!-- service (frame): navigate the prescriptive spec tree (+ Gherkin scenarios when
410
+ the spec is on the repo's default branch) -->
411
+ <UButton
412
+ v-if="isFrame"
413
+ block
414
+ color="neutral"
415
+ variant="soft"
416
+ size="sm"
417
+ icon="i-lucide-scroll-text"
418
+ @click="ui.openServiceSpec(block.id)"
419
+ >
420
+ View Requirements
421
+ </UButton>
422
+
409
423
  <!-- service / module: tasks summary -->
410
424
  <ContainerSummary v-if="isContainer" :block="block" />
411
425
  <!-- service (frame): test infra + provisioning configuration -->
@@ -17,6 +17,7 @@ import TestReportWindow from '~/components/testing/TestReportWindow.vue'
17
17
  import GateResultView from '~/components/gates/GateResultView.vue'
18
18
  import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
19
19
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
20
+ import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
20
21
 
21
22
  const ui = useUiStore()
22
23
 
@@ -31,6 +32,9 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
31
32
  // Default dedicated view for a registered CUSTOM kind's structured (`custom`) output —
32
33
  // a read-only JSON viewer, so a proprietary agent ships a result view with no bespoke code.
33
34
  'generic-structured': GenericStructuredResultView,
35
+ // The service's prescriptive spec tree (+ Gherkin), opened from the inspector's "View
36
+ // Requirements" button. Not a pipeline-step view — opened directly via `ui.openServiceSpec`.
37
+ 'service-spec': ServiceSpecWindow,
34
38
  }
35
39
 
36
40
  const active = computed<Component | null>(() => {
@@ -0,0 +1,348 @@
1
+ <script setup lang="ts">
2
+ // Service-spec window — the dedicated surface for a service's prescriptive specification,
3
+ // opened from the inspector's "View Requirements" button (via the universal result-view
4
+ // host). It reads the sharded `spec/` artifact off the service repo's default branch and
5
+ // lets the human navigate the structured spec tree (modules → feature groups → requirements
6
+ // + acceptance criteria + domain rules). When the spec is present on main, a toggle switches
7
+ // to the rendered Gherkin scenarios (the seeded `.feature` files).
8
+ import type {
9
+ RequirementGroup,
10
+ RequirementItem,
11
+ RequirementPriority,
12
+ SpecModule,
13
+ } from '~/types/spec'
14
+
15
+ const board = useBoardStore()
16
+ const serviceSpec = useServiceSpecStore()
17
+
18
+ type ViewMode = 'structured' | 'gherkin'
19
+ const mode = ref<ViewMode>('structured')
20
+ // Selected feature group, keyed by its module + group index so a name collision can't
21
+ // cross-select. Null = show the service overview.
22
+ const selected = ref<{ m: number; g: number } | null>(null)
23
+
24
+ const { open, blockId, close } = useResultView('service-spec', {
25
+ onOpen: (id) => {
26
+ mode.value = 'structured'
27
+ selected.value = null
28
+ void serviceSpec.load(id)
29
+ },
30
+ })
31
+
32
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
33
+ const view = computed(() => (blockId.value ? serviceSpec.viewFor(blockId.value) : undefined))
34
+ const loading = computed(() => (blockId.value ? serviceSpec.isLoading(blockId.value) : false))
35
+ const errored = computed(() => (blockId.value ? serviceSpec.isErrored(blockId.value) : false))
36
+ const spec = computed(() => view.value?.spec ?? null)
37
+ const modules = computed<SpecModule[]>(() => spec.value?.modules ?? [])
38
+ const present = computed(() => !!view.value?.present && !!spec.value)
39
+ const hasGherkin = computed(() => (view.value?.features.length ?? 0) > 0)
40
+
41
+ // Auto-select the first non-empty group once a present spec is shown, so the main pane isn't
42
+ // empty. Depends on `blockId` as well as `present`: switching directly to ANOTHER already-cached
43
+ // present block (via the inspector, without closing) changes `blockId` while `present` stays
44
+ // true, so a watch on `present` alone would never re-fire and the second block would open on
45
+ // the empty Overview pane. `immediate` covers the first, uncached open (present flips false→true
46
+ // after the load). `onOpen` resets `selected` to null first (it runs before this watch, since
47
+ // `useResultView` registers its blockId watch earlier), so a stale selection never leaks across.
48
+ watch(
49
+ [present, blockId],
50
+ ([is]) => {
51
+ if (is && !selected.value) {
52
+ const m = modules.value.findIndex((mod) => (mod.groups?.length ?? 0) > 0)
53
+ if (m >= 0) selected.value = { m, g: 0 }
54
+ }
55
+ },
56
+ { immediate: true },
57
+ )
58
+
59
+ const selectedModule = computed<SpecModule | null>(() =>
60
+ selected.value ? (modules.value[selected.value.m] ?? null) : null,
61
+ )
62
+ const selectedGroup = computed<RequirementGroup | null>(() => {
63
+ if (!selected.value) return null
64
+ return selectedModule.value?.groups?.[selected.value.g] ?? null
65
+ })
66
+
67
+ // The Gherkin `.feature` content matching the selected group. Features carry display names
68
+ // (not slugs), and the harness permits same-named groups in one module (only the on-disk
69
+ // SLUGS are collision-suffixed), so a plain name match can cross-select. When several
70
+ // features share the (module, group) name pair, disambiguate by the group's ordinal among
71
+ // its same-named siblings — both lists derive from the same name-sorted walk, so the ordinals
72
+ // line up.
73
+ const selectedFeature = computed(() => {
74
+ const mod = selectedModule.value
75
+ const grp = selectedGroup.value
76
+ if (!mod || !grp) return null
77
+ const matches =
78
+ view.value?.features.filter((f) => f.module === mod.name && f.group === grp.name) ?? []
79
+ if (matches.length <= 1) return matches[0] ?? null
80
+ const sameNamed = (mod.groups ?? []).filter((g) => g.name === grp.name)
81
+ const ordinal = sameNamed.indexOf(grp)
82
+ return matches[ordinal] ?? matches[0] ?? null
83
+ })
84
+
85
+ function selectGroup(m: number, g: number) {
86
+ selected.value = { m, g }
87
+ }
88
+
89
+ const PRIORITY_META: Record<RequirementPriority, { label: string; chip: string }> = {
90
+ must: { label: 'Must', chip: 'error' },
91
+ should: { label: 'Should', chip: 'warning' },
92
+ could: { label: 'Could', chip: 'neutral' },
93
+ }
94
+
95
+ function reqCount(group: RequirementGroup): number {
96
+ return group.requirements?.length ?? 0
97
+ }
98
+ function priorityMeta(item: RequirementItem) {
99
+ return PRIORITY_META[item.priority] ?? PRIORITY_META.could
100
+ }
101
+ </script>
102
+
103
+ <template>
104
+ <Teleport to="body">
105
+ <div
106
+ v-if="open"
107
+ class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4 backdrop-blur-sm"
108
+ @click.self="close"
109
+ >
110
+ <div
111
+ class="flex h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
112
+ >
113
+ <!-- header -->
114
+ <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
115
+ <div
116
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500/15"
117
+ >
118
+ <UIcon name="i-lucide-scroll-text" class="h-5 w-5 text-indigo-300" />
119
+ </div>
120
+ <div class="min-w-0">
121
+ <h1 class="truncate text-base font-semibold text-white">Requirements</h1>
122
+ <p v-if="block" class="truncate text-xs text-slate-500">
123
+ {{ spec?.service || block.title }}
124
+ </p>
125
+ </div>
126
+ <div class="ml-auto flex items-center gap-1.5">
127
+ <!-- view toggle: Gherkin only when the spec (and its feature files) are on main -->
128
+ <div v-if="present" class="flex items-center rounded-lg border border-slate-700 p-0.5">
129
+ <UButton
130
+ :color="mode === 'structured' ? 'primary' : 'neutral'"
131
+ :variant="mode === 'structured' ? 'soft' : 'ghost'"
132
+ size="xs"
133
+ icon="i-lucide-list-tree"
134
+ @click="mode = 'structured'"
135
+ >
136
+ Structured
137
+ </UButton>
138
+ <UButton
139
+ :color="mode === 'gherkin' ? 'primary' : 'neutral'"
140
+ :variant="mode === 'gherkin' ? 'soft' : 'ghost'"
141
+ size="xs"
142
+ icon="i-lucide-square-check-big"
143
+ :disabled="!hasGherkin"
144
+ :title="hasGherkin ? 'Gherkin scenarios' : 'No scenarios on main yet'"
145
+ @click="mode = 'gherkin'"
146
+ >
147
+ Gherkin
148
+ </UButton>
149
+ </div>
150
+ <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" @click="close" />
151
+ </div>
152
+ </header>
153
+
154
+ <!-- loading -->
155
+ <div
156
+ v-if="loading && !view"
157
+ class="flex flex-1 items-center justify-center gap-2 text-sm text-slate-400"
158
+ >
159
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
160
+ Loading the specification…
161
+ </div>
162
+
163
+ <!-- error -->
164
+ <div
165
+ v-else-if="errored"
166
+ class="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center text-sm text-slate-400"
167
+ >
168
+ <UIcon name="i-lucide-triangle-alert" class="h-6 w-6 text-amber-400" />
169
+ Could not load the specification. Try reopening this window.
170
+ </div>
171
+
172
+ <!-- empty: no spec on the repo's default branch yet -->
173
+ <div
174
+ v-else-if="!present"
175
+ class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center"
176
+ >
177
+ <UIcon name="i-lucide-scroll-text" class="h-8 w-8 text-slate-600" />
178
+ <div>
179
+ <p class="text-sm font-medium text-slate-300">No specification yet</p>
180
+ <p class="mx-auto mt-1 max-w-md text-xs text-slate-500">
181
+ This service has no spec on its default branch. A specification is generated as tasks
182
+ run their pipelines; once it lands on main it will appear here, with its Gherkin
183
+ scenarios.
184
+ </p>
185
+ </div>
186
+ </div>
187
+
188
+ <!-- spec body: navigable tree + detail -->
189
+ <div v-else class="flex min-h-0 flex-1">
190
+ <!-- nav: modules → feature groups -->
191
+ <nav class="w-64 shrink-0 overflow-y-auto border-r border-slate-800 px-3 py-4">
192
+ <UButton
193
+ block
194
+ class="mb-2 justify-start"
195
+ :color="selected === null ? 'primary' : 'neutral'"
196
+ :variant="selected === null ? 'soft' : 'ghost'"
197
+ size="xs"
198
+ icon="i-lucide-info"
199
+ @click="selected = null"
200
+ >
201
+ Overview
202
+ </UButton>
203
+ <div v-for="(mod, mi) in modules" :key="mi" class="mb-3">
204
+ <div
205
+ class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
206
+ >
207
+ {{ mod.name }}
208
+ </div>
209
+ <ul class="space-y-0.5">
210
+ <li v-for="(group, gi) in mod.groups ?? []" :key="gi">
211
+ <button
212
+ type="button"
213
+ class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition"
214
+ :class="
215
+ selected?.m === mi && selected?.g === gi
216
+ ? 'bg-indigo-500/15 text-indigo-200'
217
+ : 'text-slate-300 hover:bg-slate-800'
218
+ "
219
+ @click="selectGroup(mi, gi)"
220
+ >
221
+ <span class="truncate">{{ group.name }}</span>
222
+ <span class="shrink-0 text-[10px] text-slate-500">{{ reqCount(group) }}</span>
223
+ </button>
224
+ </li>
225
+ <li
226
+ v-if="(mod.groups?.length ?? 0) === 0"
227
+ class="px-2 py-1 text-[11px] italic text-slate-600"
228
+ >
229
+ No feature groups
230
+ </li>
231
+ </ul>
232
+ </div>
233
+ </nav>
234
+
235
+ <!-- detail -->
236
+ <div class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
237
+ <!-- service overview -->
238
+ <template v-if="selected === null">
239
+ <h2 class="text-lg font-semibold text-white">{{ spec?.service }}</h2>
240
+ <p v-if="spec?.summary" class="mt-2 whitespace-pre-line text-sm text-slate-300">
241
+ {{ spec.summary }}
242
+ </p>
243
+ <p v-else class="mt-2 text-sm text-slate-500">No service summary.</p>
244
+ <p class="mt-4 text-xs text-slate-500">
245
+ {{ modules.length }} module(s). Select a feature group on the left to view its
246
+ requirements{{ hasGherkin ? ' or switch to the Gherkin scenarios' : '' }}.
247
+ </p>
248
+ </template>
249
+
250
+ <!-- selected feature group -->
251
+ <template v-else-if="selectedGroup">
252
+ <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
253
+ {{ selectedModule?.name }}
254
+ </div>
255
+ <h2 class="text-lg font-semibold text-white">{{ selectedGroup.name }}</h2>
256
+ <p v-if="selectedGroup.summary" class="mt-1 text-sm text-slate-400">
257
+ {{ selectedGroup.summary }}
258
+ </p>
259
+
260
+ <!-- GHERKIN view: the rendered .feature file for this group -->
261
+ <template v-if="mode === 'gherkin'">
262
+ <pre
263
+ v-if="selectedFeature"
264
+ class="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-950/60 p-4 text-[12.5px] leading-relaxed text-slate-200"
265
+ ><code>{{ selectedFeature.content }}</code></pre>
266
+ <div
267
+ v-else
268
+ class="mt-4 rounded-lg border border-dashed border-slate-700 p-6 text-center text-sm text-slate-500"
269
+ >
270
+ No Gherkin scenarios for this group.
271
+ </div>
272
+ </template>
273
+
274
+ <!-- STRUCTURED view: requirements + acceptance + domain rules -->
275
+ <template v-else>
276
+ <div v-if="reqCount(selectedGroup) === 0" class="mt-4 text-sm text-slate-500">
277
+ No requirements in this group.
278
+ </div>
279
+ <ul class="mt-4 space-y-4">
280
+ <li
281
+ v-for="req in selectedGroup.requirements ?? []"
282
+ :key="req.id"
283
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
284
+ >
285
+ <div class="flex items-start justify-between gap-3">
286
+ <h3 class="text-sm font-semibold text-slate-100">{{ req.title }}</h3>
287
+ <div class="flex shrink-0 items-center gap-1.5">
288
+ <UBadge :color="priorityMeta(req).chip as any" variant="subtle" size="sm">
289
+ {{ priorityMeta(req).label }}
290
+ </UBadge>
291
+ <UBadge color="neutral" variant="subtle" size="sm">{{ req.kind }}</UBadge>
292
+ </div>
293
+ </div>
294
+ <p
295
+ class="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-slate-300"
296
+ >
297
+ {{ req.statement }}
298
+ </p>
299
+ <!-- acceptance criteria (Given/When/Then) -->
300
+ <div v-if="(req.acceptance?.length ?? 0) > 0" class="mt-3 space-y-1.5">
301
+ <div
302
+ v-for="ac in req.acceptance ?? []"
303
+ :key="ac.id"
304
+ class="rounded-md border border-slate-800 bg-slate-950/50 px-3 py-2 text-[12.5px] leading-relaxed"
305
+ >
306
+ <p class="text-slate-300">
307
+ <span class="font-semibold text-emerald-400">Given</span> {{ ac.given }}
308
+ </p>
309
+ <p class="text-slate-300">
310
+ <span class="font-semibold text-sky-400">When</span> {{ ac.when }}
311
+ </p>
312
+ <p class="text-slate-300">
313
+ <span class="font-semibold text-violet-400">Then</span> {{ ac.outcome }}
314
+ </p>
315
+ </div>
316
+ </div>
317
+ </li>
318
+ </ul>
319
+
320
+ <!-- domain rules / invariants scoped to this group -->
321
+ <div v-if="(selectedGroup.rules?.length ?? 0) > 0" class="mt-6">
322
+ <div
323
+ class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
324
+ >
325
+ <UIcon name="i-lucide-shield-check" class="h-3.5 w-3.5" />
326
+ Domain rules
327
+ </div>
328
+ <ul class="space-y-1.5">
329
+ <li
330
+ v-for="rule in selectedGroup.rules ?? []"
331
+ :key="rule.id"
332
+ class="rounded-md border border-slate-800 bg-slate-900/60 px-3 py-2 text-[13px] text-slate-300"
333
+ >
334
+ {{ rule.rule }}
335
+ <span v-if="rule.rationale" class="text-slate-500">
336
+ — {{ rule.rationale }}</span
337
+ >
338
+ </li>
339
+ </ul>
340
+ </div>
341
+ </template>
342
+ </template>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ </div>
347
+ </Teleport>
348
+ </template>
@@ -0,0 +1,14 @@
1
+ import type { ServiceSpecView } from '~/types/spec'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * The service-spec read (the inspector's "View Requirements" window). Reassembles the
6
+ * sharded `spec/` artifact from the service repo's default branch. Always 200: a service
7
+ * with no spec on main (or no GitHub connected) returns `{ present: false }`.
8
+ */
9
+ export function specApi({ http, ws }: ApiContext) {
10
+ return {
11
+ getServiceSpec: (workspaceId: string, blockId: string) =>
12
+ http<ServiceSpecView>(`${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/spec`),
13
+ }
14
+ }
@@ -15,6 +15,7 @@ import { recurringApi } from './api/recurring'
15
15
  import { releaseHealthApi } from './api/releaseHealth'
16
16
  import { reviewsApi } from './api/reviews'
17
17
  import { slackApi } from './api/slack'
18
+ import { specApi } from './api/spec'
18
19
  import { tasksApi } from './api/tasks'
19
20
  import { workspacesApi } from './api/workspaces'
20
21
 
@@ -78,6 +79,7 @@ export function useApi() {
78
79
  ...documentsApi(ctx),
79
80
  ...tasksApi(ctx),
80
81
  ...reviewsApi(ctx),
82
+ ...specApi(ctx),
81
83
  ...notificationsApi(ctx),
82
84
  ...presetsApi(ctx),
83
85
  ...releaseHealthApi(ctx),
@@ -0,0 +1,72 @@
1
+ import { defineStore } from 'pinia'
2
+ import { reactive, ref } from 'vue'
3
+ import type { ServiceSpecView } from '~/types/spec'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * Service-spec read state for the inspector's "View Requirements" window. The spec lives
8
+ * sharded in the service repo under `spec/`; the backend reassembles it from the repo's
9
+ * default branch and serves a {@link ServiceSpecView}. Read-only and fetched on demand
10
+ * (per service frame block), cached per block. Nothing is persisted client-side.
11
+ */
12
+ export const useServiceSpecStore = defineStore('serviceSpec', () => {
13
+ const api = useApi()
14
+ const workspace = useWorkspaceStore()
15
+
16
+ /** The fetched view per block id (undefined = not yet fetched). */
17
+ const views = ref<Record<string, ServiceSpecView>>({})
18
+ /**
19
+ * In-flight loads keyed by block id — the SINGLE source of truth for "is this block
20
+ * loading". A reactive Map so `isLoading` (derived from `.has`) tracks set/delete, and it
21
+ * also coalesces overlapping loads onto one request: no separate loading-flag Set to keep
22
+ * in sync.
23
+ */
24
+ const inFlight = reactive(new Map<string, Promise<void>>())
25
+ /** Block ids whose last fetch failed (network / unexpected error). */
26
+ const erroredByBlock = ref<Set<string>>(new Set())
27
+
28
+ function viewFor(blockId: string): ServiceSpecView | undefined {
29
+ return views.value[blockId]
30
+ }
31
+ function isLoading(blockId: string): boolean {
32
+ return inFlight.has(blockId)
33
+ }
34
+ function isErrored(blockId: string): boolean {
35
+ return erroredByBlock.value.has(blockId)
36
+ }
37
+
38
+ function setErrored(key: string, on: boolean) {
39
+ const next = new Set(erroredByBlock.value)
40
+ if (on) next.add(key)
41
+ else next.delete(key)
42
+ erroredByBlock.value = next
43
+ }
44
+
45
+ /** Fetch (and cache) the spec view for a service frame block. */
46
+ async function load(blockId: string) {
47
+ if (!workspace.workspaceId) return
48
+ const pending = inFlight.get(blockId)
49
+ if (pending) return pending
50
+ setErrored(blockId, false)
51
+ const promise = (async () => {
52
+ try {
53
+ const view = await api.getServiceSpec(workspace.requireId(), blockId)
54
+ views.value = { ...views.value, [blockId]: view }
55
+ } catch {
56
+ setErrored(blockId, true)
57
+ } finally {
58
+ inFlight.delete(blockId)
59
+ }
60
+ })()
61
+ inFlight.set(blockId, promise)
62
+ return promise
63
+ }
64
+
65
+ return {
66
+ views,
67
+ viewFor,
68
+ isLoading,
69
+ isErrored,
70
+ load,
71
+ }
72
+ })
package/app/stores/ui.ts CHANGED
@@ -336,6 +336,10 @@ export const useUiStore = defineStore('ui', () => {
336
336
  function openClarityReview(blockId: string) {
337
337
  resultView.value = { view: 'clarity-review', blockId, instanceId: null, stepIndex: null }
338
338
  }
339
+ // Open the service-spec window for a service frame (the inspector's "View Requirements").
340
+ function openServiceSpec(blockId: string) {
341
+ resultView.value = { view: 'service-spec', blockId, instanceId: null, stepIndex: null }
342
+ }
339
343
  function closeResultView() {
340
344
  resultView.value = null
341
345
  }
@@ -440,6 +444,7 @@ export const useUiStore = defineStore('ui', () => {
440
444
  closeOpenRouter,
441
445
  openRequirementReview,
442
446
  openClarityReview,
447
+ openServiceSpec,
443
448
  closeRequirementReview,
444
449
  openStepDetail,
445
450
  closeStepDetail,
@@ -0,0 +1,74 @@
1
+ // The prescriptive, service-level SPECIFICATION read view. These shapes mirror the
2
+ // `@cat-factory/contracts` `spec.ts` wire schemas exactly (see the note in `domain.ts`),
3
+ // so the service-spec endpoint's payload drops straight into the store. The spec lives
4
+ // sharded in the service repo under `spec/`; the backend reassembles it from the repo's
5
+ // default branch and serves it as `ServiceSpecView` for the inspector's "View
6
+ // Requirements" window.
7
+
8
+ export type RequirementPriority = 'must' | 'should' | 'could'
9
+ export type RequirementKind = 'functional' | 'nonfunctional' | 'constraint'
10
+
11
+ /** One acceptance criterion in Given/When/Then form — the seed for a Gherkin scenario. */
12
+ export interface AcceptanceCriterion {
13
+ id: string
14
+ given: string
15
+ when: string
16
+ /** The Gherkin "Then" clause (named `outcome` so the object is never thenable). */
17
+ outcome: string
18
+ }
19
+
20
+ /** A single prescriptive requirement, traceable to the board task(s) it came from. */
21
+ export interface RequirementItem {
22
+ id: string
23
+ title: string
24
+ statement: string
25
+ kind: RequirementKind
26
+ priority: RequirementPriority
27
+ sourceBlockIds?: string[]
28
+ acceptance?: AcceptanceCriterion[]
29
+ }
30
+
31
+ /** A domain rule / invariant scoped to the feature group it governs. */
32
+ export interface DomainRule {
33
+ id: string
34
+ rule: string
35
+ rationale?: string
36
+ sourceBlockIds?: string[]
37
+ }
38
+
39
+ /** A feature / logical group: related requirements plus the domain rules scoped to them. */
40
+ export interface RequirementGroup {
41
+ name: string
42
+ summary?: string
43
+ requirements?: RequirementItem[]
44
+ rules?: DomainRule[]
45
+ }
46
+
47
+ /** A module (domain) — the top level of the taxonomy, holding feature groups. */
48
+ export interface SpecModule {
49
+ name: string
50
+ summary?: string
51
+ groups?: RequirementGroup[]
52
+ }
53
+
54
+ /** The unified prescriptive specification document for one service. */
55
+ export interface SpecDoc {
56
+ service: string
57
+ summary?: string
58
+ modules?: SpecModule[]
59
+ }
60
+
61
+ /** A rendered Gherkin feature file read back from the repo. */
62
+ export interface SpecFeatureFile {
63
+ module: string
64
+ group: string
65
+ path: string
66
+ content: string
67
+ }
68
+
69
+ /** The service-spec view: the reassembled tree + its Gherkin files (empty when none). */
70
+ export interface ServiceSpecView {
71
+ present: boolean
72
+ spec: SpecDoc | null
73
+ features: SpecFeatureFile[]
74
+ }
package/nuxt.config.ts CHANGED
@@ -15,6 +15,15 @@ export default defineNuxtConfig({
15
15
  // Render as a pure client-side SPA that talks to the cat-factory backend.
16
16
  ssr: false,
17
17
 
18
+ // The board is a single dark-themed surface (neutral is mapped to `slate` and
19
+ // every component is hand-styled in slate). Pin Nuxt UI's color mode to dark so
20
+ // its own chrome (modals, inputs, selects, dropdowns) matches instead of
21
+ // following the visitor's system preference and rendering light/white overlays.
22
+ colorMode: {
23
+ preference: 'dark',
24
+ fallback: 'dark',
25
+ },
26
+
18
27
  runtimeConfig: {
19
28
  public: {
20
29
  // Base URL of the cat-factory worker API. Defaults to the local wrangler
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",