@cat-factory/app 0.199.0 → 0.200.2

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 (39) hide show
  1. package/app/components/foundational/FoundationalContractSummary.vue +36 -0
  2. package/app/components/foundational/FoundationalServiceCatalogList.vue +170 -0
  3. package/app/components/foundational/FoundationalServiceManager.vue +111 -0
  4. package/app/components/foundational/FoundationalServicePanel.vue +37 -0
  5. package/app/components/foundational/FoundationalServiceRegistry.vue +339 -0
  6. package/app/components/foundational/FoundationalServiceSources.vue +398 -0
  7. package/app/components/foundational/FoundationalSuppressions.vue +75 -0
  8. package/app/components/layout/AccountFoundationalSettings.vue +25 -0
  9. package/app/components/layout/AccountSkillSettings.vue +1 -1
  10. package/app/components/settings/AccountSettingsPanel.vue +17 -1
  11. package/app/components/skills/SkillLibraryManager.vue +1 -1
  12. package/app/composables/api/foundationalServices.ts +131 -0
  13. package/app/composables/api/skills.ts +1 -1
  14. package/app/composables/useApi.ts +2 -0
  15. package/app/composables/useNavContributions.ts +1 -0
  16. package/app/composables/usePipelineErrorToast.ts +8 -0
  17. package/app/modular/nav-contributions.spec.ts +6 -0
  18. package/app/modular/nav-contributions.ts +26 -0
  19. package/app/pages/index.vue +4 -0
  20. package/app/stores/foundationalServices.spec.ts +121 -0
  21. package/app/stores/foundationalServices.ts +276 -0
  22. package/app/stores/skillLibrary.ts +1 -1
  23. package/app/stores/skills.spec.ts +1 -1
  24. package/app/stores/skills.ts +1 -1
  25. package/app/stores/ui/modals.ts +12 -0
  26. package/app/types/domain.ts +1 -0
  27. package/app/types/foundationalServices.ts +32 -0
  28. package/app/types/skills.ts +1 -1
  29. package/i18n/locales/de.json +146 -6
  30. package/i18n/locales/en.json +146 -6
  31. package/i18n/locales/es.json +146 -6
  32. package/i18n/locales/fr.json +146 -6
  33. package/i18n/locales/he.json +146 -6
  34. package/i18n/locales/it.json +146 -6
  35. package/i18n/locales/ja.json +146 -6
  36. package/i18n/locales/pl.json +146 -6
  37. package/i18n/locales/tr.json +146 -6
  38. package/i18n/locales/uk.json +146 -6
  39. package/package.json +2 -2
@@ -0,0 +1,339 @@
1
+ <script setup lang="ts">
2
+ // The services THIS tier registers (backend/docs/adr/0031-foundational-services.md) — the raw list,
3
+ // not the merge — plus the editor that creates and patches one.
4
+ //
5
+ // The editor's shape follows the feature's contract rules rather than plain CRUD convenience:
6
+ // the `id` is what an Architect writes in its design, so it is fixed at creation and shown
7
+ // read-only afterwards; and `contracts`, when submitted, REPLACES the whole uploaded set, which
8
+ // is stated on the form because a partial edit would silently drop a document. A repo-sourced row
9
+ // is not editable here at all — its next sync would overwrite the edit — so it is labelled with
10
+ // its source instead.
11
+ import { computed, reactive, ref } from 'vue'
12
+ import type {
13
+ ApiContractFormat,
14
+ FoundationalService,
15
+ FoundationalServiceOwnerKind,
16
+ UploadApiContract,
17
+ } from '~/types/domain'
18
+ import {
19
+ useFoundationalServices,
20
+ useFoundationalServicesStore,
21
+ } from '~/stores/foundationalServices'
22
+ import FoundationalContractSummary from '~/components/foundational/FoundationalContractSummary.vue'
23
+
24
+ const props = defineProps<{ kind: FoundationalServiceOwnerKind; ownerId: string }>()
25
+
26
+ const catalog =
27
+ props.kind === 'workspace'
28
+ ? useFoundationalServicesStore()
29
+ : useFoundationalServices(props.kind, props.ownerId)
30
+ const toast = useToast()
31
+ const { t } = useI18n()
32
+ const { confirm } = useConfirm()
33
+
34
+ // Exhaustive format→label map of literal `t(...)` keys (keeps the typed-key drift guard live).
35
+ const formatLabel = computed<Record<ApiContractFormat, string>>(() => ({
36
+ openapi: t('foundational.format.openapi'),
37
+ 'toad-contract': t('foundational.format.toadContract'),
38
+ 'lokalise-api-contract': t('foundational.format.lokaliseApiContract'),
39
+ }))
40
+ const formatItems = computed(() =>
41
+ (Object.keys(formatLabel.value) as ApiContractFormat[]).map((value) => ({
42
+ value,
43
+ label: formatLabel.value[value],
44
+ })),
45
+ )
46
+
47
+ function notifyError(title: string, e: unknown) {
48
+ toast.add({
49
+ title,
50
+ description: e instanceof Error ? e.message : String(e),
51
+ icon: 'i-lucide-triangle-alert',
52
+ color: 'error',
53
+ })
54
+ }
55
+
56
+ // ---- the editor ------------------------------------------------------------
57
+ /** null = closed; '' = creating; a service id = editing that row. */
58
+ const editing = ref<string | null>(null)
59
+ const saving = ref(false)
60
+ const draft = reactive({
61
+ id: '',
62
+ name: '',
63
+ summary: '',
64
+ description: '',
65
+ capabilities: '',
66
+ /** Left untouched ⇒ the stored set is kept; edited ⇒ the whole set is replaced. */
67
+ contracts: [] as UploadApiContract[],
68
+ contractsTouched: false,
69
+ })
70
+
71
+ const isCreating = computed(() => editing.value === '')
72
+
73
+ function openCreate() {
74
+ Object.assign(draft, {
75
+ id: '',
76
+ name: '',
77
+ summary: '',
78
+ description: '',
79
+ capabilities: '',
80
+ contracts: [],
81
+ contractsTouched: false,
82
+ })
83
+ editing.value = ''
84
+ }
85
+
86
+ function openEdit(service: FoundationalService) {
87
+ Object.assign(draft, {
88
+ id: service.id,
89
+ name: service.name,
90
+ summary: service.summary,
91
+ description: service.description,
92
+ capabilities: service.capabilities.join(', '),
93
+ // Deliberately NOT prefilled from the manifest: the manifest carries no bodies, so
94
+ // prefilling would submit empty documents over real ones. Untouched ⇒ nothing is sent.
95
+ contracts: [],
96
+ contractsTouched: false,
97
+ })
98
+ editing.value = service.id
99
+ }
100
+
101
+ function addContract() {
102
+ draft.contracts.push({ contractId: '', format: 'openapi', title: '', body: '' })
103
+ draft.contractsTouched = true
104
+ }
105
+
106
+ function removeContract(index: number) {
107
+ draft.contracts.splice(index, 1)
108
+ draft.contractsTouched = true
109
+ }
110
+
111
+ const capabilityList = computed(() =>
112
+ draft.capabilities
113
+ .split(',')
114
+ .map((c) => c.trim())
115
+ .filter(Boolean),
116
+ )
117
+
118
+ const draftValid = computed(() => {
119
+ if (!draft.name.trim() || !draft.summary.trim()) return false
120
+ if (isCreating.value && !/^[a-z0-9][a-z0-9-]*$/.test(draft.id.trim())) return false
121
+ return draft.contracts.every((c) => c.contractId.trim() && c.title.trim() && c.body.trim())
122
+ })
123
+
124
+ async function save() {
125
+ if (!draftValid.value) return
126
+ saving.value = true
127
+ try {
128
+ const contracts = draft.contractsTouched ? draft.contracts : undefined
129
+ if (isCreating.value) {
130
+ await catalog.create({
131
+ id: draft.id.trim(),
132
+ name: draft.name.trim(),
133
+ summary: draft.summary.trim(),
134
+ description: draft.description.trim(),
135
+ capabilities: capabilityList.value,
136
+ ...(contracts ? { contracts } : {}),
137
+ })
138
+ toast.add({ title: t('foundational.toast.created'), icon: 'i-lucide-check' })
139
+ } else {
140
+ await catalog.update(draft.id, {
141
+ name: draft.name.trim(),
142
+ summary: draft.summary.trim(),
143
+ description: draft.description.trim(),
144
+ capabilities: capabilityList.value,
145
+ ...(contracts ? { contracts } : {}),
146
+ })
147
+ toast.add({ title: t('foundational.toast.updated'), icon: 'i-lucide-check' })
148
+ }
149
+ editing.value = null
150
+ } catch (e) {
151
+ notifyError(t('foundational.toast.saveFailed'), e)
152
+ } finally {
153
+ saving.value = false
154
+ }
155
+ }
156
+
157
+ // Deleting at the WORKSPACE tier leaves a tombstone, and that tombstone also suppresses an
158
+ // account service registered under the same id — the board lands in the opt-out list without ever
159
+ // having opted out. That is the intended model (a workspace tombstone IS the suppression), but it
160
+ // is invisible at the point of the click, so the confirmation says it and names where to undo it.
161
+ // An account tier has nothing above it to shadow, so it keeps the plain wording.
162
+ const deleteBody = (service: FoundationalService) =>
163
+ props.kind === 'workspace'
164
+ ? t('foundational.confirmDelete.bodyWorkspace', { name: service.name })
165
+ : t('foundational.confirmDelete.body', { name: service.name })
166
+
167
+ async function remove(service: FoundationalService) {
168
+ const ok = await confirm({
169
+ title: t('foundational.confirmDelete.title'),
170
+ description: deleteBody(service),
171
+ variant: 'destructive',
172
+ confirmLabel: t('foundational.confirmDelete.confirm'),
173
+ icon: 'i-lucide-trash-2',
174
+ })
175
+ if (!ok) return
176
+ try {
177
+ await catalog.remove(service.id)
178
+ toast.add({ title: t('foundational.toast.deleted'), icon: 'i-lucide-trash-2' })
179
+ } catch (e) {
180
+ notifyError(t('foundational.toast.deleteFailed'), e)
181
+ }
182
+ }
183
+ </script>
184
+
185
+ <template>
186
+ <div class="flex flex-col gap-3" data-testid="foundational-registry">
187
+ <div
188
+ v-for="s in catalog.services"
189
+ :key="s.id"
190
+ class="rounded-md border border-slate-800 bg-slate-900/60 p-3"
191
+ >
192
+ <div class="flex items-start gap-2">
193
+ <UIcon name="i-lucide-boxes" class="mt-0.5 h-4 w-4 shrink-0 text-sky-400" />
194
+ <div class="min-w-0 flex-1">
195
+ <p class="truncate text-sm font-medium text-slate-100">
196
+ {{ s.name }}
197
+ <code class="ms-1 text-[11px] text-slate-500">{{ s.id }}</code>
198
+ </p>
199
+ <p class="text-xs text-slate-400">{{ s.summary }}</p>
200
+ <div v-if="s.capabilities.length" class="mt-1 flex flex-wrap gap-1">
201
+ <UBadge v-for="c in s.capabilities" :key="c" size="xs" variant="subtle" color="neutral">
202
+ {{ c }}
203
+ </UBadge>
204
+ </div>
205
+ <FoundationalContractSummary :contracts="s.contracts" :format-label="formatLabel" />
206
+ <p v-if="s.sourceId" class="mt-1 text-[11px] text-slate-500">
207
+ {{ t('foundational.registry.fromSource', { path: s.sourcePath ?? '' }) }}
208
+ </p>
209
+ </div>
210
+ <div class="flex shrink-0 gap-1">
211
+ <UButton
212
+ v-if="!s.sourceId"
213
+ icon="i-lucide-pencil"
214
+ size="xs"
215
+ variant="ghost"
216
+ :title="t('foundational.registry.edit')"
217
+ @click="openEdit(s)"
218
+ />
219
+ <UButton
220
+ icon="i-lucide-trash-2"
221
+ size="xs"
222
+ color="error"
223
+ variant="ghost"
224
+ :title="t('foundational.registry.delete')"
225
+ @click="remove(s)"
226
+ />
227
+ </div>
228
+ </div>
229
+ </div>
230
+ <p v-if="!catalog.services.length" class="text-sm text-slate-500">
231
+ {{ t('foundational.registry.empty') }}
232
+ </p>
233
+
234
+ <UButton
235
+ v-if="editing === null"
236
+ icon="i-lucide-plus"
237
+ size="sm"
238
+ variant="soft"
239
+ class="self-start"
240
+ data-testid="foundational-add-service"
241
+ @click="openCreate"
242
+ >
243
+ {{ t('foundational.registry.add') }}
244
+ </UButton>
245
+
246
+ <div v-else class="rounded-md border border-slate-800 p-3">
247
+ <p class="mb-2 text-sm font-medium">
248
+ {{
249
+ isCreating ? t('foundational.registry.addTitle') : t('foundational.registry.editTitle')
250
+ }}
251
+ </p>
252
+ <div class="flex flex-col gap-2">
253
+ <!-- The id is what an Architect names in its design, so it is fixed once registered. -->
254
+ <UInput
255
+ v-if="isCreating"
256
+ v-model="draft.id"
257
+ :placeholder="t('foundational.registry.idPlaceholder')"
258
+ />
259
+ <p v-else class="text-xs text-slate-500">
260
+ <code class="text-slate-300">{{ draft.id }}</code>
261
+ — {{ t('foundational.registry.idFixed') }}
262
+ </p>
263
+ <UInput v-model="draft.name" :placeholder="t('foundational.registry.namePlaceholder')" />
264
+ <UInput
265
+ v-model="draft.summary"
266
+ :placeholder="t('foundational.registry.summaryPlaceholder')"
267
+ />
268
+ <UTextarea
269
+ v-model="draft.description"
270
+ :rows="4"
271
+ :placeholder="t('foundational.registry.descriptionPlaceholder')"
272
+ />
273
+ <UInput
274
+ v-model="draft.capabilities"
275
+ :placeholder="t('foundational.registry.capabilitiesPlaceholder')"
276
+ />
277
+
278
+ <div class="rounded-md border border-slate-800 p-2">
279
+ <p class="text-xs font-medium text-slate-300">
280
+ {{ t('foundational.registry.contractsTitle') }}
281
+ </p>
282
+ <p class="mb-2 text-[11px] text-slate-500">
283
+ {{
284
+ draft.contractsTouched
285
+ ? t('foundational.registry.contractsReplace')
286
+ : t('foundational.registry.contractsKeep')
287
+ }}
288
+ </p>
289
+ <div v-for="(c, i) in draft.contracts" :key="i" class="mb-2 flex flex-col gap-1">
290
+ <div class="flex gap-2">
291
+ <UInput
292
+ v-model="c.contractId"
293
+ :placeholder="t('foundational.registry.contractIdPlaceholder')"
294
+ class="flex-1"
295
+ />
296
+ <USelect v-model="c.format" :items="formatItems" class="w-56" />
297
+ <UButton
298
+ icon="i-lucide-x"
299
+ size="xs"
300
+ color="error"
301
+ variant="ghost"
302
+ :title="t('foundational.registry.removeContract')"
303
+ @click="removeContract(i)"
304
+ />
305
+ </div>
306
+ <UInput
307
+ v-model="c.title"
308
+ :placeholder="t('foundational.registry.contractTitlePlaceholder')"
309
+ />
310
+ <UTextarea
311
+ v-model="c.body"
312
+ :rows="6"
313
+ class="font-mono"
314
+ :placeholder="t('foundational.registry.contractBodyPlaceholder')"
315
+ />
316
+ </div>
317
+ <UButton icon="i-lucide-plus" size="xs" variant="ghost" @click="addContract">
318
+ {{ t('foundational.registry.addContract') }}
319
+ </UButton>
320
+ </div>
321
+
322
+ <div class="flex gap-2">
323
+ <UButton
324
+ size="sm"
325
+ :disabled="!draftValid"
326
+ :loading="saving"
327
+ data-testid="foundational-save-service"
328
+ @click="save"
329
+ >
330
+ {{ t('foundational.registry.save') }}
331
+ </UButton>
332
+ <UButton size="sm" variant="ghost" @click="editing = null">
333
+ {{ t('foundational.registry.cancel') }}
334
+ </UButton>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </div>
339
+ </template>