@cat-factory/app 0.236.1 → 0.237.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/README.md CHANGED
@@ -111,12 +111,27 @@ When the backend declares the fields and the SPA only collects them, render them
111
111
  surfaces use it: an initiative preset's create form and a reusable operation's per-case form on a
112
112
  custom task type (`AddTaskModal`). Adding a third is a `:fields` binding, not a component.
113
113
 
114
+ **Grouping is the descriptor's own, through `descriptorFieldSections`**, not a wrapper each surface
115
+ builds: consecutive fields sharing a `section` render under one caption, and the reduction applies
116
+ `showWhen` first, so a section whose every field is hidden renders no caption. Never re-group or
117
+ re-order the fields at a call site, or a form renders in an order its author never wrote.
118
+
119
+ **A captioned run is rendered FLAT, never as a per-run wrapper element** (`descriptorFormRows`
120
+ carries each run's caption on the field that opens it). Run membership is derived state that shifts
121
+ as `showWhen` reveals fields, while a field's identity does not: nesting the fields inside a wrapper
122
+ re-parents them when a boundary moves, and Vue can only do that by unmounting and remounting. The
123
+ remounted input is typically the one being TYPED INTO, because typing into the trigger is what moved
124
+ the boundary, so it loses focus, caret and IME composition mid-keystroke. Keep every field a sibling
125
+ keyed by `field.key` and the diff MOVES it instead. The same trap as keying any list by index, with a
126
+ worse symptom: `descriptorFields.spec.ts` pins that a reveal preserves every field key.
127
+
114
128
  Four rules travel with it. **Validate with the shared `validateDescriptorFields`** so the submit
115
129
  button reflects exactly what the server will refuse, and **submit the shared
116
130
  `sanitizeDescriptorFields` result** so a stale answer on a since-hidden `showWhen` field never
117
- reaches the wire. **The labels are deployment-authored English rendered verbatim**: only the chrome
118
- around them (a path-invalid message, section captions) is i18n, so no descriptor string enters a
119
- locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
131
+ reaches the wire. **Every string a descriptor carries is deployment-authored English rendered
132
+ verbatim**, labels, help, option captions and the `section` grouping captions alike: only the
133
+ platform's own chrome around them (the path-invalid message) is i18n, so no descriptor string enters
134
+ a locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
120
135
  (`defaultDescriptorValues` for the initial values, `setDescriptorValue` / `setDescriptorCheckbox` /
121
136
  `toggleDescriptorGroupValue` for one edit): what an edit freezes on an entity is what a unit test
122
137
  must be able to reach, and a rule inside a component is only reachable by mounting one.
@@ -8,16 +8,18 @@
8
8
  //
9
9
  // It extends `ProviderConnectionTab.vue`'s flat-field pattern with the shapes a declared form needs:
10
10
  // `checkbox-group` (multi-select whose value is `string[]`), `path` (a repo-relative dir with inline
11
- // safety validation), and single-condition `showWhen` visibility. Labels/help/option captions are
12
- // backend-supplied English (the `describeConfig` convention); only the chrome is i18n.
11
+ // safety validation), single-condition `showWhen` visibility, and `section` grouping captions.
12
+ // Labels/help/option/section captions are backend-supplied English (the `describeConfig`
13
+ // convention); only the chrome is i18n.
13
14
  //
14
15
  // The model is the typed `DescriptorFieldValues` map (scalars stay strings, `number` a number,
15
16
  // `checkbox` a boolean, `checkbox-group` a `string[]`), so it round-trips the wire contract and the
16
17
  // shared `validateDescriptorFields` / `sanitizeDescriptorFields` rules unchanged.
17
18
  import { computed } from 'vue'
18
- import { isDescriptorFieldVisible, isSafeRepoDirPath } from '@cat-factory/contracts'
19
+ import { isSafeRepoDirPath } from '@cat-factory/contracts'
19
20
  import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
20
21
  import {
22
+ descriptorFormRows,
21
23
  descriptorGroupValue,
22
24
  setDescriptorCheckbox,
23
25
  setDescriptorValue,
@@ -39,12 +41,20 @@ const props = withDefaults(
39
41
  const model = defineModel<DescriptorFieldValues>({ required: true })
40
42
  const { t } = useI18n()
41
43
 
42
- // Only fields whose `showWhen` holds against the current values are shown; a hidden field's stale
43
- // value is kept in the model (so re-showing restores it) but the server + client both drop it at
44
- // sanitize/validate time, so it can never freeze an unvalidated value.
45
- const visibleFields = computed(() =>
46
- props.fields.filter((f) => isDescriptorFieldVisible(f, model.value)),
47
- )
44
+ // The fields to render, each carrying the section caption that opens its run. Only fields whose
45
+ // `showWhen` holds against the current values are shown; a hidden field's stale value is kept in the
46
+ // model (so re-showing restores it) but the server + client both drop it at sanitize/validate time,
47
+ // so it can never freeze an unvalidated value.
48
+ //
49
+ // The grouping underneath is the shared `descriptorFieldSections`, so a caption spans exactly what
50
+ // one function says it spans, which is the same statement the boot check refuses a declaration
51
+ // against. A form declaring no section is one uncaptioned run, i.e. byte-for-byte the flat column
52
+ // this component always rendered.
53
+ //
54
+ // The rows are FLAT, and the template keeps them siblings keyed by `field.key`, because run
55
+ // membership shifts as `showWhen` reveals fields while a field's identity does not: see
56
+ // `descriptorFormRows` for why a per-run wrapper would remount the input being typed into.
57
+ const rows = computed(() => descriptorFormRows(props.fields, model.value))
48
58
 
49
59
  // The value-mutation rules live in `utils/descriptorFields.ts` as pure functions over the bag (what
50
60
  // an edit does to it, including the drop-when-empty rule that keeps an unset answer from freezing),
@@ -90,76 +100,93 @@ function selectItems(field: DescriptorField) {
90
100
  </script>
91
101
 
92
102
  <template>
93
- <div v-if="visibleFields.length" class="space-y-4">
94
- <UFormField
95
- v-for="field in visibleFields"
96
- :key="field.key"
97
- :label="field.label"
98
- :help="field.help"
99
- :required="field.required"
100
- :error="pathInvalid(field) ? t('common.pathInvalid') : undefined"
101
- :data-testid="`${testidPrefix}-${field.key}`"
102
- >
103
- <!-- checkbox-group: a vertical list of toggles whose value is the checked option set. -->
104
- <div v-if="field.type === 'checkbox-group'" class="space-y-1.5">
105
- <UCheckbox
106
- v-for="opt in field.options ?? []"
107
- :key="opt.value"
108
- :model-value="groupValue(field.key).includes(opt.value)"
109
- :label="opt.label"
110
- :data-testid="`${testidPrefix}-${field.key}-${opt.value}`"
111
- @update:model-value="
112
- (v: boolean | 'indeterminate') => toggleGroup(field.key, opt.value, v === true)
113
- "
114
- />
115
- </div>
103
+ <div v-if="rows.length" class="space-y-4">
104
+ <!-- One keyed fragment per FIELD, never per run: a run's caption rides the field that opens it,
105
+ so revealing a field re-captions rows in place instead of re-parenting the inputs (which
106
+ Vue can only do by remounting them, destroying the one being typed into). -->
107
+ <template v-for="{ field, caption, startsGroup } in rows" :key="field.key">
108
+ <!-- The section caption, rendered verbatim above its run (deployment-authored English, like
109
+ the field labels themselves). Its testid is the same on every caption, because a caption
110
+ is arbitrary Unicode a deployment writes in its own language: a spec addresses one by the
111
+ TEXT it is asserting about (`getByTestId(...).filter({ hasText })`) rather than by a
112
+ testid that would have to be slugified to be selectable. -->
113
+ <p
114
+ v-if="caption"
115
+ class="-mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
116
+ :class="{ 'pt-2': startsGroup }"
117
+ :data-testid="`${testidPrefix}-section`"
118
+ >
119
+ {{ caption }}
120
+ </p>
121
+ <UFormField
122
+ :label="field.label"
123
+ :help="field.help"
124
+ :required="field.required"
125
+ :error="pathInvalid(field) ? t('common.pathInvalid') : undefined"
126
+ :class="{ 'pt-2': startsGroup && !caption }"
127
+ :data-testid="`${testidPrefix}-${field.key}`"
128
+ >
129
+ <!-- checkbox-group: a vertical list of toggles whose value is the checked option set. -->
130
+ <div v-if="field.type === 'checkbox-group'" class="space-y-1.5">
131
+ <UCheckbox
132
+ v-for="opt in field.options ?? []"
133
+ :key="opt.value"
134
+ :model-value="groupValue(field.key).includes(opt.value)"
135
+ :label="opt.label"
136
+ :data-testid="`${testidPrefix}-${field.key}-${opt.value}`"
137
+ @update:model-value="
138
+ (v: boolean | 'indeterminate') => toggleGroup(field.key, opt.value, v === true)
139
+ "
140
+ />
141
+ </div>
116
142
 
117
- <USelect
118
- v-else-if="field.type === 'select'"
119
- :model-value="stringValue(field.key)"
120
- :items="selectItems(field)"
121
- class="w-full"
122
- :placeholder="field.placeholder"
123
- @update:model-value="(v: string) => set(field.key, v)"
124
- />
143
+ <USelect
144
+ v-else-if="field.type === 'select'"
145
+ :model-value="stringValue(field.key)"
146
+ :items="selectItems(field)"
147
+ class="w-full"
148
+ :placeholder="field.placeholder"
149
+ @update:model-value="(v: string) => set(field.key, v)"
150
+ />
125
151
 
126
- <USwitch
127
- v-else-if="field.type === 'checkbox'"
128
- :model-value="boolValue(field.key)"
129
- @update:model-value="(v: boolean) => setCheckbox(field, v)"
130
- />
152
+ <USwitch
153
+ v-else-if="field.type === 'checkbox'"
154
+ :model-value="boolValue(field.key)"
155
+ @update:model-value="(v: boolean) => setCheckbox(field, v)"
156
+ />
131
157
 
132
- <UTextarea
133
- v-else-if="field.type === 'textarea'"
134
- :model-value="stringValue(field.key)"
135
- :rows="3"
136
- autoresize
137
- class="w-full"
138
- :maxlength="field.maxLength"
139
- :placeholder="field.placeholder"
140
- @update:model-value="(v: string) => set(field.key, v)"
141
- />
158
+ <UTextarea
159
+ v-else-if="field.type === 'textarea'"
160
+ :model-value="stringValue(field.key)"
161
+ :rows="3"
162
+ autoresize
163
+ class="w-full"
164
+ :maxlength="field.maxLength"
165
+ :placeholder="field.placeholder"
166
+ @update:model-value="(v: string) => set(field.key, v)"
167
+ />
142
168
 
143
- <UInput
144
- v-else-if="field.type === 'number'"
145
- :model-value="numberStr(field.key)"
146
- type="number"
147
- class="w-full font-mono"
148
- :placeholder="field.placeholder"
149
- @update:model-value="(v: string) => set(field.key, v === '' ? undefined : Number(v))"
150
- />
169
+ <UInput
170
+ v-else-if="field.type === 'number'"
171
+ :model-value="numberStr(field.key)"
172
+ type="number"
173
+ class="w-full font-mono"
174
+ :placeholder="field.placeholder"
175
+ @update:model-value="(v: string) => set(field.key, v === '' ? undefined : Number(v))"
176
+ />
151
177
 
152
- <!-- path + text/password (the untyped default): a single-line input. `path`s stay mono. -->
153
- <UInput
154
- v-else
155
- :model-value="stringValue(field.key)"
156
- :type="field.type === 'password' ? 'password' : 'text'"
157
- class="w-full"
158
- :class="{ 'font-mono': field.type === 'path' }"
159
- :maxlength="field.maxLength"
160
- :placeholder="field.placeholder"
161
- @update:model-value="(v: string) => set(field.key, v)"
162
- />
163
- </UFormField>
178
+ <!-- path + text/password (the untyped default): a single-line input. `path`s stay mono. -->
179
+ <UInput
180
+ v-else
181
+ :model-value="stringValue(field.key)"
182
+ :type="field.type === 'password' ? 'password' : 'text'"
183
+ class="w-full"
184
+ :class="{ 'font-mono': field.type === 'path' }"
185
+ :maxlength="field.maxLength"
186
+ :placeholder="field.placeholder"
187
+ @update:model-value="(v: string) => set(field.key, v)"
188
+ />
189
+ </UFormField>
190
+ </template>
164
191
  </div>
165
192
  </template>
@@ -185,11 +185,15 @@ module). The SPA merges it into the create-task picker and the card-badge catalo
185
185
  `category` groups the picker (below).
186
186
  - **`fields`** are descriptor-driven create-form inputs over the shared descriptor-form vocabulary
187
187
  (`text` / `textarea` / `number` / `select` / `checkbox` / `checkbox-group` / `path`, with
188
- defaults and `showWhen` visibility; `password` is excluded by construction because a task field
189
- value reaches prompts and telemetry). Their values land in the task's sparse
190
- `taskTypeFields.custom` bag (no migration). A BACKEND-registered descriptor is enforced
191
- server-side on create as well (required answers, option lists, lengths); a code-shipped one is
192
- known only to the SPA, so the create form is its only check (see the Validation note below).
188
+ defaults, `showWhen` visibility and a `section` grouping caption; `password` is excluded by
189
+ construction because a task field value reaches prompts and telemetry). Their values land in the
190
+ task's sparse `taskTypeFields.custom` bag (no migration). A BACKEND-registered descriptor is
191
+ enforced server-side on create as well (required answers, option lists, lengths); a code-shipped
192
+ one is known only to the SPA, so the create form is its only check (see the Validation note below).
193
+ A `section` groups a long form into captioned runs and changes nothing else; declare a section's
194
+ fields consecutively, since a backend registration whose form could caption one twice fails boot
195
+ and a code-shipped one would simply render the caption twice. Interleaving a section with a
196
+ mutually exclusive `showWhen` branch is not that fault: only one half is ever on screen.
193
197
  - **`formPanel`** optionally names a bespoke create-form section component you contribute to the
194
198
  `taskTypeFormPanels` slot (paired by that id, like `resultViews`); shown INSTEAD of `fields`. An
195
199
  unpaired id degrades to the descriptor fields.
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
  import type { DescriptorField } from '~/types/domain'
3
3
  import {
4
4
  defaultDescriptorValues,
5
+ descriptorFormRows,
5
6
  descriptorGroupValue,
6
7
  setDescriptorCheckbox,
7
8
  setDescriptorValue,
@@ -124,3 +125,70 @@ describe('toggleDescriptorGroupValue', () => {
124
125
  })
125
126
  })
126
127
  })
128
+
129
+ // The layout half of `section` grouping. What a caption SPANS is the shared contracts rule (tested
130
+ // there); what these assert is the property that rule's rendering has to preserve, and the reason the
131
+ // rows are flat at all: a field's identity survives a boundary move, so the keyed diff MOVES the
132
+ // live input instead of remounting it.
133
+ describe('descriptorFormRows', () => {
134
+ const keysOf = (rows: ReturnType<typeof descriptorFormRows>) => rows.map((r) => r.field.key)
135
+
136
+ it('carries each run caption on the field that OPENS it, once', () => {
137
+ const rows = descriptorFormRows(
138
+ [
139
+ field({ key: 'entity' }),
140
+ field({ key: 'style', section: 'Shape' }),
141
+ field({ key: 'verb', section: 'Shape' }),
142
+ field({ key: 'dir', type: 'path', section: 'Placement' }),
143
+ ],
144
+ {},
145
+ )
146
+ expect(rows.map((r) => [r.field.key, r.caption, r.startsGroup])).toEqual([
147
+ ['entity', undefined, false],
148
+ ['style', 'Shape', true],
149
+ ['verb', undefined, false],
150
+ ['dir', 'Placement', true],
151
+ ])
152
+ })
153
+
154
+ it('renders a sectionless form as the flat column, with no caption and no group gap', () => {
155
+ const rows = descriptorFormRows([field({ key: 'entity' }), field({ key: 'notes' })], {})
156
+ expect(rows.map((r) => [r.caption, r.startsGroup])).toEqual([
157
+ [undefined, false],
158
+ [undefined, false],
159
+ ])
160
+ expect(descriptorFormRows([], {})).toEqual([])
161
+ })
162
+
163
+ it('keeps a field key STABLE when a reveal moves it into another run', () => {
164
+ // The regression this shape exists for. `note` is unsectioned and gated, so revealing it splits
165
+ // the `Shape` run in two and re-captions `style`. Boot refuses THIS declaration (the split is
166
+ // reachable), and the renderer still has to be total over it, because a wire descriptor can
167
+ // arrive from a node whose build predates the refusal. Every field key present before is still
168
+ // present after, so Vue's keyed diff moves those nodes rather than unmounting them: typing into
169
+ // `advanced` (the trigger) cannot destroy the input being typed into.
170
+ const fields = [
171
+ field({ key: 'advanced', type: 'checkbox' }),
172
+ field({ key: 'verb', section: 'Shape' }),
173
+ field({ key: 'note', showWhen: { key: 'advanced', equals: true } }),
174
+ field({ key: 'style', section: 'Shape' }),
175
+ ]
176
+ expect(keysOf(descriptorFormRows(fields, {}))).toEqual(['advanced', 'verb', 'style'])
177
+
178
+ const revealed = descriptorFormRows(fields, { advanced: true })
179
+ expect(keysOf(revealed)).toEqual(['advanced', 'verb', 'note', 'style'])
180
+ // The caption moved (the second run needs its own) while the field identities did not.
181
+ expect(revealed.map((r) => r.caption)).toEqual([undefined, 'Shape', undefined, 'Shape'])
182
+ })
183
+
184
+ it('marks a later UNCAPTIONED run as opening a group too, so the gap is not caption-only', () => {
185
+ const rows = descriptorFormRows(
186
+ [field({ key: 'verb', section: 'Shape' }), field({ key: 'notes' })],
187
+ {},
188
+ )
189
+ expect(rows.map((r) => [r.field.key, r.startsGroup])).toEqual([
190
+ ['verb', false],
191
+ ['notes', true],
192
+ ])
193
+ })
194
+ })
@@ -1,4 +1,4 @@
1
- import { descriptorFieldDefaults } from '@cat-factory/contracts'
1
+ import { descriptorFieldDefaults, descriptorFieldSections } from '@cat-factory/contracts'
2
2
  import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
3
3
 
4
4
  // Form-side helpers over the shared descriptor-field vocabulary (`contracts/src/form-fields.ts`),
@@ -75,6 +75,45 @@ export function descriptorGroupValue(values: DescriptorFieldValues, key: string)
75
75
  return Array.isArray(value) ? value : []
76
76
  }
77
77
 
78
+ /** One row of a rendered descriptor form: a field, plus the section chrome that precedes it. */
79
+ export interface DescriptorFormRow {
80
+ /** The field to render. Its `key` is the row's identity in the keyed diff. */
81
+ field: DescriptorField
82
+ /** The caption to print above this field, set only on the field that OPENS a captioned run. */
83
+ caption?: string
84
+ /** Whether this field opens a run with another run before it, i.e. needs the between-runs gap. */
85
+ startsGroup: boolean
86
+ }
87
+
88
+ /**
89
+ * A descriptor form reduced to a FLAT list of rows: the shared `descriptorFieldSections` grouping,
90
+ * with each run's caption carried on the field that opens it rather than on a wrapper around it.
91
+ *
92
+ * Flat is the whole point, and it is a correctness rule rather than a layout preference. Run
93
+ * membership is DERIVED state that changes as `showWhen` reveals and hides fields, while a field's
94
+ * identity does not. Rendering the runs as nested `v-for`s re-parents a field the moment a boundary
95
+ * moves, and Vue cannot move a node between two parents: it unmounts and remounts it. The field being
96
+ * remounted is typically the one being TYPED INTO, because typing into a `showWhen` trigger is what
97
+ * moved the boundary, so the input loses focus, caret and any in-flight IME composition mid-keystroke.
98
+ * Keeping every field a sibling under one parent, keyed by `field.key`, makes that a MOVE, which
99
+ * preserves the live input: the behaviour the flat column had before sections existed.
100
+ *
101
+ * Presentation, so it lives here rather than in contracts: what a caption spans is the shared rule,
102
+ * and this is only how the SPA lays that out.
103
+ */
104
+ export function descriptorFormRows(
105
+ fields: readonly DescriptorField[],
106
+ values: DescriptorFieldValues,
107
+ ): DescriptorFormRow[] {
108
+ return descriptorFieldSections(fields, values).flatMap((group, groupIndex) =>
109
+ group.fields.map((field, fieldIndex) => ({
110
+ field,
111
+ ...(fieldIndex === 0 && group.section !== undefined ? { caption: group.section } : {}),
112
+ startsGroup: fieldIndex === 0 && groupIndex > 0,
113
+ })),
114
+ )
115
+ }
116
+
78
117
  /** One option toggled on/off in a `checkbox-group` field's value (deduped, order-preserving). */
79
118
  export function toggleDescriptorGroupValue(
80
119
  values: DescriptorFieldValues,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.236.1",
3
+ "version": "0.237.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",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.256.0"
43
+ "@cat-factory/contracts": "0.257.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",