@cat-factory/app 0.256.2 → 0.256.3

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
@@ -161,6 +161,10 @@ The failure is silent, which is why this is a rule rather than a preference. An
161
161
 
162
162
  `scripts/check-component-imports.mjs` enforces it (CI's `repo-guards` job). If a panel section is missing and the data looks right, check the import first.
163
163
 
164
+ ### Type a chip map with `BadgeColor`, never `string`
165
+
166
+ A status → chip map feeding a `<UBadge :color="…">` types its values as `BadgeColor` (`utils/badge.ts`), which is derived from `UBadge`'s own prop type rather than restated as a literal union. Typed `string`, the binding does not compile and the reflex is `as any` at each call site: seven of them had accumulated. That cast also accepts a colour Nuxt UI does not define, which renders as an unstyled badge with nothing failing.
167
+
164
168
  ## Interface modes (basic / advanced)
165
169
 
166
170
  The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
@@ -392,13 +392,9 @@ const ITEM_ICON: Record<string, string> = {
392
392
  </div>
393
393
  </div>
394
394
  <div class="flex items-center gap-1">
395
- <UBadge
396
- :color="statusMeta.chip as any"
397
- variant="subtle"
398
- size="sm"
399
- :title="statusHint"
400
- >{{ statusLabel }}</UBadge
401
- >
395
+ <UBadge :color="statusMeta.chip" variant="subtle" size="sm" :title="statusHint">{{
396
+ statusLabel
397
+ }}</UBadge>
402
398
  <!-- Board-authoring buttons (create task / from issue / recurring / initiative)
403
399
  are `board.write`, hidden for a read-only viewer, who keeps the status badge
404
400
  (the one view-only affordance here). -->
@@ -97,7 +97,7 @@ function onHandle(e: PointerEvent) {
97
97
  <UIcon name="i-lucide-milestone" class="h-4 w-4 shrink-0 text-indigo-400" />
98
98
  <div class="text-xs font-semibold text-white">{{ block.title }}</div>
99
99
  </div>
100
- <UBadge :color="INITIATIVE_STATUS_CHIPS[status] as any" variant="subtle" size="sm">
100
+ <UBadge :color="INITIATIVE_STATUS_CHIPS[status]" variant="subtle" size="sm">
101
101
  {{ statusLabel }}
102
102
  </UBadge>
103
103
  </div>
@@ -124,7 +124,7 @@ function openApprovalFor(approvalId: string) {
124
124
  {{ t('focus.typeSubtitle', { type: typeMeta.label }) }}
125
125
  </div>
126
126
  </div>
127
- <UBadge :color="statusMeta.chip as any" variant="subtle" class="ms-2">
127
+ <UBadge :color="statusMeta.chip" variant="subtle" class="ms-2">
128
128
  {{ statusMeta.label }}
129
129
  </UBadge>
130
130
  <div class="ms-auto flex items-center gap-2">
@@ -39,6 +39,7 @@ import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
39
39
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
40
40
  import MarkdownProse from '~/components/common/MarkdownProse.vue'
41
41
  import EmptyState from '~/components/common/EmptyState.vue'
42
+ import type { BadgeColor } from '~/utils/badge'
42
43
 
43
44
  const board = useBoardStore()
44
45
  const documents = useDocumentsStore()
@@ -105,8 +106,6 @@ const DISPOSITION_KEYS: Record<OutcomeDisposition, string> = {
105
106
  not_run: 'outcome.disposition.not_run',
106
107
  unknown: 'outcome.disposition.unknown',
107
108
  }
108
- /** The badge palette, named once so every colour map below is checked against it. */
109
- type BadgeColor = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
110
109
 
111
110
  const DISPOSITION_COLOR: Record<OutcomeDisposition, BadgeColor> = {
112
111
  merged: 'success',
@@ -328,7 +328,7 @@ const showOriginalDescription = ref(false)
328
328
  <div>
329
329
  <div class="text-sm font-semibold text-white">{{ block.title }}</div>
330
330
  <div class="mt-0.5 flex items-center gap-1.5">
331
- <UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">
331
+ <UBadge :color="statusMeta.chip" variant="subtle" size="sm">
332
332
  {{ statusLabel }}
333
333
  </UBadge>
334
334
  <span class="text-[10px] uppercase tracking-wide text-slate-500">{{ level }}</span>
@@ -521,18 +521,13 @@ const showOriginalDescription = ref(false)
521
521
  wrapper). Replaces the pre-slice-4 `v-if` fan; `subject-key` is the block
522
522
  id, so switching selections remounts panel content (matching the old
523
523
  per-panel `:key`). A consumer contributes its own panels to the SAME
524
- group via `registerAppModule`.
525
-
526
- The `subject` cast is an upstream typing quirk, not a modelling escape
527
- hatch: `<PanelsOutlet>` declares `subject` as `PropType<unknown>` with
528
- `default: null`, which Volar narrows to `null`, so passing a typed
529
- `Block | null` is rejected at compile time. `unknown` is the real
530
- runtime contract; `as any` is the minimal unblock until the binding
531
- types the prop explicitly (filed upstream — see the slice-4 residuals
532
- in backend/docs/adr/0049-modular-vue-adoption.md). -->
524
+ group via `registerAppModule`. `subject` used to need an `as any`: the
525
+ outlet's `default: null` narrowed the declared `PropType<unknown>` to
526
+ `null`, rejecting a typed `Block | null`. The published prop type now
527
+ resolves to `unknown`, so the binding passes through unasserted. -->
533
528
  <PanelsOutlet
534
529
  :group="inspectorPanels"
535
- :subject="(block ?? null) as any"
530
+ :subject="block ?? null"
536
531
  :subject-key="block?.id ?? ''"
537
532
  />
538
533
 
@@ -16,6 +16,7 @@ import { prReviewPhase } from '~/utils/prReviewProgress'
16
16
  import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
17
17
  import PrReviewPhaseBadge from '~/components/prReview/PrReviewPhaseBadge.vue'
18
18
  import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
19
+ import type { BadgeColor } from '~/utils/badge'
19
20
 
20
21
  const props = defineProps<{ instance: ExecutionInstance }>()
21
22
  const emit = defineEmits<{
@@ -147,15 +148,15 @@ const STATE_META = computed<Record<AgentState, { label: string; color: string; i
147
148
  )
148
149
 
149
150
  /** Visual language for the pipeline instance as a whole. */
150
- const STATUS_META = computed<Record<ExecutionInstance['status'], { label: string; chip: string }>>(
151
- () => ({
152
- running: { label: t('pipeline.progress.status.running'), chip: 'primary' },
153
- blocked: { label: t('pipeline.progress.status.blocked'), chip: 'warning' },
154
- paused: { label: t('pipeline.progress.status.paused'), chip: 'neutral' },
155
- done: { label: t('pipeline.progress.status.done'), chip: 'success' },
156
- failed: { label: t('pipeline.progress.status.failed'), chip: 'error' },
157
- }),
158
- )
151
+ const STATUS_META = computed<
152
+ Record<ExecutionInstance['status'], { label: string; chip: BadgeColor }>
153
+ >(() => ({
154
+ running: { label: t('pipeline.progress.status.running'), chip: 'primary' },
155
+ blocked: { label: t('pipeline.progress.status.blocked'), chip: 'warning' },
156
+ paused: { label: t('pipeline.progress.status.paused'), chip: 'neutral' },
157
+ done: { label: t('pipeline.progress.status.done'), chip: 'success' },
158
+ failed: { label: t('pipeline.progress.status.failed'), chip: 'error' },
159
+ }))
159
160
 
160
161
  const steps = computed(() => props.instance.steps)
161
162
  const total = computed(() => steps.value.length)
@@ -254,7 +255,7 @@ const ITEM_ICON: Record<string, string> = {
254
255
  <!-- summary -->
255
256
  <div class="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
256
257
  <div class="flex flex-wrap items-center gap-3">
257
- <UBadge :color="statusMeta.chip as any" variant="subtle">{{ statusMeta.label }}</UBadge>
258
+ <UBadge :color="statusMeta.chip" variant="subtle">{{ statusMeta.label }}</UBadge>
258
259
  <span class="text-sm text-slate-300">
259
260
  <i18n-t keypath="pipeline.progress.agentsComplete" tag="span" scope="global">
260
261
  <template #completed>
@@ -14,6 +14,7 @@ import type {
14
14
  InfraEngine,
15
15
  InfraHandlerConfig,
16
16
  } from '@cat-factory/contracts'
17
+ import { isKubernetesUrlSource } from '@cat-factory/contracts'
17
18
  import type { K3sSetupPrefill } from '~/stores/ui'
18
19
 
19
20
  // The kube branch of the discriminated handler config this form produces (the `local-k3s` /
@@ -22,6 +23,10 @@ import type { K3sSetupPrefill } from '~/stores/ui'
22
23
  // `as never` cast, so a wrong config shape is caught at the call site instead of server-side.
23
24
  type KubeHandlerConfig = Extract<InfraHandlerConfig, { engine: 'local-k3s' | 'remote-kubernetes' }>
24
25
  type KubeHandlerPayload = { config: KubeHandlerConfig; secrets: Record<string, string> }
26
+ /** The engine connection block itself, read off the variant so it cannot drift from it. */
27
+ type KubeEngineConfig = KubeHandlerConfig['kubernetes']
28
+ /** How the environment URL is derived: its own discriminated union, keyed by `source`. */
29
+ type KubeUrlSource = KubeEngineConfig['url']
25
30
 
26
31
  const props = defineProps<{
27
32
  /** `local-k3s` or `remote-kubernetes` — the engine this handler is registered under. */
@@ -46,12 +51,9 @@ const emit = defineEmits<{
46
51
 
47
52
  const { t } = useI18n()
48
53
 
49
- type UrlSource =
50
- | 'ingressTemplate'
51
- | 'ingressStatus'
52
- | 'serviceStatus'
53
- | 'gatewayStatus'
54
- | 'httpRouteStatus'
54
+ /** The `source` discriminants, read off the contract union: a source added there makes
55
+ * `buildUrl`'s switch non-exhaustive rather than leaving this list quietly short. */
56
+ type UrlSource = KubeUrlSource['source']
55
57
 
56
58
  const form = reactive({
57
59
  label: '',
@@ -101,23 +103,34 @@ watch(
101
103
  (h) => {
102
104
  const cfg = h?.config
103
105
  if (!cfg || (cfg.engine !== 'local-k3s' && cfg.engine !== 'remote-kubernetes')) return
104
- const k = cfg.kubernetes as Record<string, unknown>
105
- form.label = typeof k.label === 'string' ? k.label : ''
106
- form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
107
- form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
106
+ // Narrowing `cfg.engine` above types `kubernetes` as the engine config, so these read the
107
+ // contract directly. They used to widen it to a `Record` and `typeof`-guard every field,
108
+ // which re-derived at runtime what the discriminated union already states.
109
+ const k = cfg.kubernetes
110
+ form.label = k.label
111
+ form.apiServerUrl = k.apiServerUrl
112
+ form.caCertPem = k.caCertPem ?? ''
108
113
  form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
109
- form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
110
- form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
111
- const url = k.url as Record<string, unknown> | undefined
112
- const src = typeof url?.source === 'string' ? (url.source as UrlSource) : 'ingressTemplate'
113
- form.urlSource = src
114
- form.hostTemplate = typeof url?.hostTemplate === 'string' ? url.hostTemplate : ''
115
- form.ingressName = typeof url?.ingressName === 'string' ? url.ingressName : ''
116
- form.serviceName = typeof url?.serviceName === 'string' ? url.serviceName : ''
117
- form.servicePort = typeof url?.port === 'number' ? String(url.port) : ''
118
- form.gatewayName = typeof url?.gatewayName === 'string' ? url.gatewayName : ''
119
- form.httpRouteName = typeof url?.httpRouteName === 'string' ? url.httpRouteName : ''
120
- form.urlScheme = url?.scheme === 'http' || url?.scheme === 'https' ? url.scheme : 'default'
114
+ form.namespaceTemplate = k.namespaceTemplate ?? ''
115
+ form.imageTemplate = k.imageTemplate ?? ''
116
+ // Each url field is read off the ONE variant that carries it, so a field belonging to a
117
+ // different `source` cannot silently populate the form.
118
+ //
119
+ // Typed as present with an on-union `source`, read as neither: both were true when the
120
+ // connect form admitted this config, and the value has been through storage since — which is
121
+ // exactly why the backend re-parses a stored `providerConfig` rather than asserting it, and
122
+ // this form is where an operator REPAIRS one that drifted. An unrecognised source falls back
123
+ // to the form's default, because `buildUrl` has no branch to build a config out of one.
124
+ const url: KubeUrlSource | undefined = k.url
125
+ const source = url?.source
126
+ form.urlSource = isKubernetesUrlSource(source) ? source : 'ingressTemplate'
127
+ form.hostTemplate = url?.source === 'ingressTemplate' ? url.hostTemplate : ''
128
+ form.ingressName = url?.source === 'ingressStatus' ? (url.ingressName ?? '') : ''
129
+ form.serviceName = url?.source === 'serviceStatus' ? url.serviceName : ''
130
+ form.servicePort = url?.source === 'serviceStatus' && url.port != null ? String(url.port) : ''
131
+ form.gatewayName = url?.source === 'gatewayStatus' ? (url.gatewayName ?? '') : ''
132
+ form.httpRouteName = url?.source === 'httpRouteStatus' ? (url.httpRouteName ?? '') : ''
133
+ form.urlScheme = url?.scheme ?? 'default'
121
134
  },
122
135
  { immediate: true },
123
136
  )
@@ -222,42 +235,81 @@ const connectBlockedReason = computed(() => {
222
235
  return t('settings.infrastructure.kubernetesEngine.invalidPort')
223
236
  })
224
237
 
225
- function buildUrl(): Record<string, unknown> {
226
- const url: Record<string, unknown> = { source: form.urlSource }
227
- if (form.urlSource === 'ingressTemplate') {
228
- url.hostTemplate = form.hostTemplate.trim()
229
- } else if (form.urlSource === 'ingressStatus') {
230
- if (form.ingressName.trim()) url.ingressName = form.ingressName.trim()
231
- } else if (form.urlSource === 'serviceStatus') {
232
- url.serviceName = form.serviceName.trim()
233
- const port = Number(form.servicePort)
234
- if (form.servicePort.trim() && Number.isInteger(port)) url.port = port
235
- } else if (form.urlSource === 'gatewayStatus') {
236
- if (form.gatewayName.trim()) url.gatewayName = form.gatewayName.trim()
237
- } else {
238
- if (form.httpRouteName.trim()) url.httpRouteName = form.httpRouteName.trim()
238
+ /**
239
+ * The URL-derivation block, built as the contract's discriminated union rather than a `Record`.
240
+ * Each branch returns its OWN variant, so the fields a source carries are checked against that
241
+ * source: setting `hostTemplate` on a `serviceStatus` url stops compiling instead of shipping a
242
+ * config the backend rejects. `urlScheme` is the one field every variant shares, and the
243
+ * 'default' sentinel means "omit it and let the derivation decide".
244
+ */
245
+ function buildUrl(): KubeUrlSource {
246
+ const scheme = form.urlScheme === 'default' ? {} : { scheme: form.urlScheme }
247
+ switch (form.urlSource) {
248
+ case 'ingressTemplate':
249
+ return { source: 'ingressTemplate', hostTemplate: form.hostTemplate.trim(), ...scheme }
250
+ case 'ingressStatus': {
251
+ const ingressName = form.ingressName.trim()
252
+ return { source: 'ingressStatus', ...(ingressName ? { ingressName } : {}), ...scheme }
253
+ }
254
+ case 'serviceStatus': {
255
+ const port = Number(form.servicePort)
256
+ return {
257
+ source: 'serviceStatus',
258
+ serviceName: form.serviceName.trim(),
259
+ ...(form.servicePort.trim() && Number.isInteger(port) ? { port } : {}),
260
+ ...scheme,
261
+ }
262
+ }
263
+ case 'gatewayStatus': {
264
+ const gatewayName = form.gatewayName.trim()
265
+ return { source: 'gatewayStatus', ...(gatewayName ? { gatewayName } : {}), ...scheme }
266
+ }
267
+ case 'httpRouteStatus': {
268
+ const httpRouteName = form.httpRouteName.trim()
269
+ return { source: 'httpRouteStatus', ...(httpRouteName ? { httpRouteName } : {}), ...scheme }
270
+ }
271
+ default:
272
+ return refuseUnknownUrlSource(form.urlSource)
239
273
  }
240
- if (form.urlScheme !== 'default') url.scheme = form.urlScheme
241
- return url
274
+ }
275
+
276
+ /**
277
+ * A `source` outside the contract union, which the switch above therefore cannot build.
278
+ *
279
+ * The parameter is `never`, so this keeps BOTH properties at once: a source added to the contract
280
+ * without a case above still fails the typecheck (the argument stops being `never`), while a value
281
+ * the union never had is refused at runtime instead of falling off the end of the switch. That end
282
+ * is what the `default` exists to close: it returned `undefined`, which `buildPayload` then sent as
283
+ * the config's `url` for the backend to reject as a missing block.
284
+ *
285
+ * Deliberately NOT mapped onto a current source. Nothing here knows which one was meant, and a
286
+ * guess would silently rewrite the operator's URL derivation to something they never picked.
287
+ */
288
+ function refuseUnknownUrlSource(source: never): never {
289
+ throw new Error(`Unsupported Kubernetes URL source '${String(source)}'`)
242
290
  }
243
291
 
244
292
  function buildPayload(): KubeHandlerPayload {
245
- const kubernetes: Record<string, unknown> = {
293
+ // Built as the contract type rather than assembled into a `Record` and asserted: an optional
294
+ // field is a conditional SPREAD, so a key the config does not declare (or a value of the
295
+ // wrong type) fails the build here instead of surfacing as a server-side validation refusal.
296
+ const caCertPem = form.caCertPem.trim()
297
+ const namespaceTemplate = form.namespaceTemplate.trim()
298
+ const imageTemplate = form.imageTemplate.trim()
299
+ const kubernetes: KubeEngineConfig = {
246
300
  label: form.label.trim(),
247
301
  apiServerUrl: form.apiServerUrl.trim(),
248
302
  url: buildUrl(),
303
+ ...(caCertPem ? { caCertPem } : {}),
304
+ ...(form.insecureSkipTlsVerify ? { insecureSkipTlsVerify: true } : {}),
305
+ ...(namespaceTemplate ? { namespaceTemplate } : {}),
306
+ ...(imageTemplate ? { imageTemplate } : {}),
249
307
  }
250
- if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
251
- if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
252
- if (form.namespaceTemplate.trim()) kubernetes.namespaceTemplate = form.namespaceTemplate.trim()
253
- if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
254
- // One honest assertion at the boundary that actually builds the shape (the reactive form is
255
- // dynamically assembled, then validated server-side); the emitted config flows typed onward.
256
308
  // OMIT the token when the field is blank so the backend preserves the saved one (a blank
257
309
  // secret means "keep it") — only a typed value is sent, and it replaces the stored token.
258
310
  const token = apiToken.value.trim()
259
311
  return {
260
- config: { engine: props.engine, kubernetes } as unknown as KubeHandlerConfig,
312
+ config: { engine: props.engine, kubernetes },
261
313
  secrets: token ? { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: token } : {},
262
314
  }
263
315
  }
@@ -21,6 +21,7 @@ import {
21
21
  summarizeSpecStates,
22
22
  type RequirementStateFilter,
23
23
  } from './ServiceSpecWindow.logic'
24
+ import type { BadgeColor } from '~/utils/badge'
24
25
 
25
26
  const { t } = useI18n()
26
27
  const board = useBoardStore()
@@ -115,7 +116,7 @@ function retry() {
115
116
 
116
117
  // Exhaustive priority → label/chip map. Literal `t()` keys keep the typed-key drift
117
118
  // guard live, vs a runtime-built `spec.priority.${value}`.
118
- const PRIORITY_META: Record<RequirementPriority, { label: string; chip: string }> = {
119
+ const PRIORITY_META: Record<RequirementPriority, { label: string; chip: BadgeColor }> = {
119
120
  must: { label: t('spec.priority.must'), chip: 'error' },
120
121
  should: { label: t('spec.priority.should'), chip: 'warning' },
121
122
  could: { label: t('spec.priority.could'), chip: 'neutral' },
@@ -131,7 +132,7 @@ const KIND_LABELS: Record<RequirementKind, string> = {
131
132
  // Exhaustive implementation-state → presentation map. `established` is the only state that
132
133
  // means "the service is observed to do this"; everything else is a behaviour that has been
133
134
  // agreed and not yet seen to hold, which must never read as standing behaviour.
134
- const STATE_META: Record<RequirementState, { label: string; chip: string; icon: string }> = {
135
+ const STATE_META: Record<RequirementState, { label: string; chip: BadgeColor; icon: string }> = {
135
136
  established: {
136
137
  label: t('spec.state.established'),
137
138
  chip: 'success',
@@ -448,7 +449,7 @@ function kindLabel(item: RequirementItem): string {
448
449
  <!-- implementation state: agreed vs observed to hold. The distinction the
449
450
  build prompt and the tester act on, so a reader must see it too. -->
450
451
  <UBadge
451
- :color="stateMeta(req).chip as any"
452
+ :color="stateMeta(req).chip"
452
453
  variant="subtle"
453
454
  size="sm"
454
455
  :icon="stateMeta(req).icon"
@@ -456,7 +457,7 @@ function kindLabel(item: RequirementItem): string {
456
457
  >
457
458
  {{ stateMeta(req).label }}
458
459
  </UBadge>
459
- <UBadge :color="priorityMeta(req).chip as any" variant="subtle" size="sm">
460
+ <UBadge :color="priorityMeta(req).chip" variant="subtle" size="sm">
460
461
  {{ priorityMeta(req).label }}
461
462
  </UBadge>
462
463
  <UBadge color="neutral" variant="subtle" size="sm">{{ kindLabel(req) }}</UBadge>
@@ -3,7 +3,7 @@
3
3
  How the SPA stays in sync with the backend. The app is a **thin client**: it holds
4
4
  no business logic, calls the Worker for every mutation, and hydrates its stores
5
5
  from server snapshots plus pushed events. For the high-level tour see
6
- [`../README.md`](../README.md).
6
+ [`frontend/app/README.md`](../../README.md).
7
7
 
8
8
  ## The three paths
9
9
 
@@ -4,8 +4,8 @@ import type { PanelEntry } from '@modular-vue/core'
4
4
  import type { Block } from '~/types/domain'
5
5
 
6
6
  /** The engine's opaque component type on a `PanelEntry` (the neutral `UiComponent`,
7
- * which isn't exported by name). A Vue component is a valid one; the outlet renders
8
- * it as a Vue component. We reference it structurally so no cast reaches for `any`. */
7
+ * which isn't exported by name). Referenced structurally off `PanelEntry` so a
8
+ * `defineComponent` result is checked against it rather than asserted into it. */
9
9
  type PanelComponent = PanelEntry<Block>['component']
10
10
  import {
11
11
  INSPECTOR_PANELS_SLOT,
@@ -61,7 +61,7 @@ function blockPanel(component: Component, id: InspectorPanelId): PanelComponent
61
61
  const block = usePanelSubject<Block>()
62
62
  return () => h(component, { block: block.value })
63
63
  },
64
- }) as unknown as PanelComponent
64
+ })
65
65
  }
66
66
 
67
67
  /** Exhaustive id → sub-panel map. Typed `Record<InspectorPanelId, …>` so adding a
@@ -3,9 +3,29 @@ import { useServicesStore } from '~/stores/services'
3
3
  import { useWorkspaceStore } from '~/stores/workspace'
4
4
  import { createBoardDependencies } from './dependencies'
5
5
  import { moveRefusalKey } from './moveRefusal'
6
+ import type { Block } from '~/types/domain'
6
7
  import type { BoardWriteContext } from './context'
7
8
  import { UNDO_WINDOW_MS } from './context'
8
9
 
10
+ /** A field `updateBlock` may patch: the contract's key set, nothing wider. */
11
+ type PatchKey = keyof UpdateBlockInput
12
+ /** The pre-patch values of the fields one call touches, for the rollback. */
13
+ type PatchSnapshot = Partial<Record<PatchKey, unknown>>
14
+
15
+ /**
16
+ * View a block through the patch key set, for the optimistic write's snapshot + rollback.
17
+ *
18
+ * A plain widening, not an assertion: the rollback is inherently keyed by whatever the caller
19
+ * put in the patch, so it has to index the block dynamically, and this bounds that indexing to
20
+ * the contract instead of the `Record<string, unknown>` it used to widen to. Two patch keys
21
+ * (`customTaskTypeFields` / `builtinTaskTypeFields`) are request-only, since the server folds
22
+ * them into the block's `taskTypeFields`, so the view is `Partial`: they read as absent going in
23
+ * and are cleared again by a rollback, which is what the untyped version did.
24
+ */
25
+ function blockAsPatchable(block: Block): PatchSnapshot {
26
+ return block
27
+ }
28
+
9
29
  /**
10
30
  * The board's placement (drag/drop/reparent) and per-block edit operations — the writes that
11
31
  * move a block or patch its fields, including the dependency edges. Extracted from
@@ -194,10 +214,13 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
194
214
  // Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
195
215
  // (a patch may set several at once) rather than leaving a stale optimistic value stuck on
196
216
  // screen with no feedback — the same rollback contract the other mutations here follow.
197
- const prev: Record<string, unknown> = {}
198
- const patchRecord = patch as Record<string, unknown>
199
- const record = b as unknown as Record<string, unknown>
200
- for (const key of Object.keys(patch)) prev[key] = record[key]
217
+ // `Object.keys` is typed `string[]`, so the key set is narrowed to the patch contract once
218
+ // here; every read and write below then goes through `PatchKey`, and a key outside the
219
+ // contract cannot reach the block.
220
+ const keys = Object.keys(patch) as PatchKey[]
221
+ const prev: PatchSnapshot = {}
222
+ const before = blockAsPatchable(b)
223
+ for (const key of keys) prev[key] = before[key]
201
224
  Object.assign(b, patch) // optimistic
202
225
  try {
203
226
  upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
@@ -206,10 +229,11 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
206
229
  // swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
207
230
  // fields that still hold OUR optimistic value, so a newer server value that landed
208
231
  // mid-flight isn't clobbered by the rollback.
209
- const cur = getBlock(id) as unknown as Record<string, unknown> | undefined
210
- if (cur) {
211
- for (const key of Object.keys(patch)) {
212
- if (cur[key] === patchRecord[key]) cur[key] = prev[key]
232
+ const live = getBlock(id)
233
+ if (live) {
234
+ const cur = blockAsPatchable(live)
235
+ for (const key of keys) {
236
+ if (cur[key] === patch[key]) cur[key] = prev[key]
213
237
  }
214
238
  }
215
239
  toast.add({
@@ -0,0 +1,14 @@
1
+ import type { BadgeProps } from '@nuxt/ui'
2
+
3
+ /**
4
+ * The colour names a `UBadge` accepts, derived from the component's own prop type rather
5
+ * than restated as a literal union. Nuxt UI resolves the prop from the app config's badge
6
+ * theme, so deriving keeps a chip map honest if the deployment's palette gains or loses a
7
+ * colour, where a hand-written copy would just drift.
8
+ *
9
+ * A status → chip map types its values against this, which is what lets a `:color="…"`
10
+ * binding pass the value straight through. The maps used to be typed `string`, so every
11
+ * binding needed an `as any` to get past the prop's union; that cast also silently accepted
12
+ * a typo'd colour, which renders as an unstyled badge rather than failing the build.
13
+ */
14
+ export type BadgeColor = NonNullable<BadgeProps['color']>
@@ -7,6 +7,7 @@ import type {
7
7
  BlockType,
8
8
  TaskTypeMeta,
9
9
  } from '~/types/domain'
10
+ import type { BadgeColor } from '~/utils/badge'
10
11
 
11
12
  /** Simple unique id helper (fine for a client-only prototype). */
12
13
  export function uid(prefix = 'id'): string {
@@ -1022,7 +1023,7 @@ export function blockTypeMeta(type: BlockType): BlockTypeMeta {
1022
1023
  /** Color + iconography for each block status. */
1023
1024
  export const STATUS_META: Record<
1024
1025
  BlockStatus,
1025
- { label: string; color: string; chip: string; icon: string }
1026
+ { label: string; color: string; chip: BadgeColor; icon: string }
1026
1027
  > = {
1027
1028
  planned: {
1028
1029
  label: 'Planned',
@@ -7,16 +7,13 @@ import type {
7
7
  InitiativeQa,
8
8
  InitiativeStatus,
9
9
  } from '~/types/domain'
10
+ import type { BadgeColor } from '~/utils/badge'
10
11
 
11
12
  // Shared initiative presentation vocabulary, so the board card, the inspector body and
12
13
  // the tracker window render statuses/progress from ONE source. The exhaustive
13
14
  // `Record<Enum, …>` maps keep the tier-2 typecheck guard live (a new status without
14
15
  // a label/chip fails the build) without triplicating it across the components.
15
16
 
16
- /** Nuxt UI badge/chip colour names — mirrors `UBadge`'s `color` prop union, so a chip map
17
- * types its values against it and the `:color` binding needs no cast. */
18
- type BadgeColor = 'error' | 'info' | 'primary' | 'secondary' | 'success' | 'warning' | 'neutral'
19
-
20
17
  /** Initiative lifecycle status → i18n label key. */
21
18
  export const INITIATIVE_STATUS_LABEL_KEYS: Record<InitiativeStatus, string> = {
22
19
  planning: 'initiative.status.planning',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.256.2",
3
+ "version": "0.256.3",
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",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.41",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.283.1"
43
+ "@cat-factory/contracts": "0.284.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",