@structuralists/scaffolding 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/eslint.config.mjs +3 -3
  2. package/package.json +1 -1
  3. package/src/components/Json/JsonTable/JsonLeafNode.tsx +20 -17
  4. package/src/components/Json/JsonTable/JsonTable.stories.tsx +87 -0
  5. package/src/components/Json/JsonTable/index.tsx +13 -6
  6. package/src/components/Json/JsonTable/styles.module.css +20 -0
  7. package/src/components/Json/JsonTable/types.ts +3 -5
  8. package/src/forms/CLAUDE.md +195 -41
  9. package/src/forms/elements/Input/index.tsx +2 -0
  10. package/src/forms/elements/Input/types.ts +2 -1
  11. package/src/forms/plan.md +146 -29
  12. package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
  13. package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
  14. package/src/forms/state/path/path.test.ts +71 -1
  15. package/src/forms/state/path/path.ts +50 -0
  16. package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
  17. package/src/forms/state/useFormState/FormDebugger.tsx +1 -0
  18. package/src/forms/state/useFormState/deriveErrors.test.ts +94 -0
  19. package/src/forms/state/useFormState/deriveErrors.ts +10 -10
  20. package/src/forms/state/useFormState/errorAt.test.ts +3 -3
  21. package/src/forms/state/useFormState/errorAt.ts +8 -12
  22. package/src/forms/state/useFormState/inspectable.test.ts +9 -9
  23. package/src/forms/state/useFormState/inspectable.ts +5 -7
  24. package/src/forms/state/useFormState/types.ts +35 -4
  25. package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
  26. package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
  27. package/src/forms/state/useFormState/useFormDebugger.test.tsx +1 -0
  28. package/src/forms/state/useFormState/useFormState.stories.tsx +190 -5
  29. package/src/forms/state/useFormState/useFormState.test-d.ts +516 -1
  30. package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
  31. package/src/forms/state/useFormState/useFormState.ts +12 -3
  32. package/src/forms/state/validations/types.ts +77 -17
  33. package/src/forms/state/validations/walk.test.ts +159 -19
  34. package/src/forms/state/validations/walk.ts +86 -25
  35. package/tokens.css +55 -0
package/eslint.config.mjs CHANGED
@@ -132,10 +132,10 @@ export default [{
132
132
  "Files inside a primitive folder must not import their own index barrel; import the neighbor directly.",
133
133
  },
134
134
  // Presentational primitives (components and form elements) must
135
- // not reach into the form-state layer. The sanctioned bridge runs
135
+ // not reach into the form-state layer. The sanctioned bridges run
136
136
  // the other way: state's FormDebugger imports the JsonTable barrel
137
- // (dev tooling), and the item-6 form-aware wrappers will live with
138
- // the state side. State stories may also use element barrels.
137
+ // (dev tooling), and the form-aware wrappers live on the state
138
+ // side (state/bindings/). State stories may also use element barrels.
139
139
  {
140
140
  from: [
141
141
  { type: 'primitive' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -1,36 +1,39 @@
1
- import { Text } from '../../Content/Text';
1
+ import styles from './styles.module.css';
2
2
 
3
3
  export type JsonLeafNodeProps = {
4
4
  value: unknown;
5
- /** When true, render `null` / `undefined` via Text with the monospace font.
6
- * JsonTable sets this on recursive calls so leaves inside a table inherit
7
- * the same font as the surrounding monospace cells (Text otherwise sets
8
- * its own font-family and would break the inheritance). */
9
- isMono?: boolean;
10
5
  };
11
6
 
12
7
  /**
13
- * Renders one value as a leaf — text only, no JSON tree viewer. Handles
14
- * the primitives React can't render directly (boolean, null, undefined)
15
- * and lets strings/numbers flow through as JSX children. Anything else
16
- * (objects, arrays, Dates, Maps, …) currently falls through to `{value}`
17
- * that will throw at render time for non-renderable types and is the
18
- * placeholder until JsonTable grows specialized renderers for them.
8
+ * Renders one value as a leaf — text only, no JSON tree viewer. Each
9
+ * primitive type gets a color class so the value reads like Chrome's
10
+ * inspector: strings in orange (with quotes), numbers in blue, booleans
11
+ * in purple, `null`/`undefined` in pink. Anything else (objects, arrays,
12
+ * Dates, Maps, …) currently falls through to `{value}` that will throw
13
+ * at render time for non-renderable types and is the placeholder until
14
+ * JsonTable grows specialized renderers for them.
19
15
  */
20
16
  export const JsonLeafNode = (props: JsonLeafNodeProps) => {
21
- const { value, isMono } = props;
22
- const size = isMono ? 'small' : undefined;
17
+ const { value } = props;
23
18
 
24
19
  if (value === null) {
25
- return <Text isMuted isMono={isMono} size={size}>null</Text>;
20
+ return <span className={styles.nil}>null</span>;
26
21
  }
27
22
 
28
23
  if (value === undefined) {
29
- return <Text isMuted isMono={isMono} size={size}>undefined</Text>;
24
+ return <span className={styles.nil}>undefined</span>;
30
25
  }
31
26
 
32
27
  if (typeof value === 'boolean') {
33
- return <>{String(value)}</>;
28
+ return <span className={styles.boolean}>{String(value)}</span>;
29
+ }
30
+
31
+ if (typeof value === 'string') {
32
+ return <span className={styles.string}>{JSON.stringify(value)}</span>;
33
+ }
34
+
35
+ if (typeof value === 'number') {
36
+ return <span className={styles.number}>{value}</span>;
34
37
  }
35
38
 
36
39
  return <>{value}</>;
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { expect, within } from 'storybook/test';
2
3
  import { JsonTable } from './index';
3
4
 
4
5
  const meta: Meta<typeof JsonTable> = {
@@ -50,3 +51,89 @@ export const PrimitivesAtTopLevel: Story = {
50
51
  </div>
51
52
  ),
52
53
  };
54
+
55
+ export const ValueColors: Story = {
56
+ name: 'Value colors per primitive type',
57
+ render: () => (
58
+ <JsonTable
59
+ title="Primitive value colors"
60
+ value={{
61
+ aString: 'hello world',
62
+ anEmptyString: '',
63
+ aQuotedString: 'say "hi"',
64
+ aMultilineString: 'line one\nline two',
65
+ aNumber: 12345,
66
+ aFloat: 3.14159,
67
+ aTrue: true,
68
+ aFalse: false,
69
+ aNull: null,
70
+ anUndefined: undefined,
71
+ }}
72
+ />
73
+ ),
74
+ play: async ({ canvasElement }) => {
75
+ const canvas = within(canvasElement);
76
+ await expect(canvas.getByText('"hello world"')).toBeInTheDocument();
77
+ await expect(canvas.getByText('"say \\"hi\\""')).toBeInTheDocument();
78
+ await expect(canvas.getByText('"line one\\nline two"')).toBeInTheDocument();
79
+ },
80
+ };
81
+
82
+ const deepSample = {
83
+ id: 'org_8821',
84
+ name: 'Northwind Logistics',
85
+ region: 'us-west-2',
86
+ active: true,
87
+ founded: 2014,
88
+ headquarters: {
89
+ address: {
90
+ street: '120 Pier Ave',
91
+ city: 'Seattle',
92
+ state: 'WA',
93
+ zip: '98101',
94
+ coords: { lat: 47.6062, lng: -122.3321 },
95
+ },
96
+ capacity: 240,
97
+ isLeased: false,
98
+ },
99
+ teams: [
100
+ {
101
+ id: 'team_platform',
102
+ name: 'Platform',
103
+ headcount: 12,
104
+ lead: { id: 'usr_1042', name: 'Alice Park', email: 'alice@northwind.io' },
105
+ members: [
106
+ { id: 'usr_1042', name: 'Alice Park', role: 'lead', onCall: true },
107
+ { id: 'usr_1109', name: 'Ben Ortiz', role: 'engineer', onCall: false },
108
+ { id: 'usr_1188', name: 'Chen Wu', role: 'engineer', onCall: false },
109
+ ],
110
+ tags: ['core', 'oncall', 'sre'],
111
+ },
112
+ {
113
+ id: 'team_data',
114
+ name: 'Data',
115
+ headcount: 7,
116
+ lead: { id: 'usr_1301', name: 'Dana Reyes', email: 'dana@northwind.io' },
117
+ members: [
118
+ { id: 'usr_1301', name: 'Dana Reyes', role: 'lead', onCall: false },
119
+ { id: 'usr_1422', name: 'Evan Liu', role: 'analyst', onCall: false },
120
+ ],
121
+ tags: ['analytics', 'etl'],
122
+ },
123
+ ],
124
+ integrations: [
125
+ { kind: 'slack', enabled: true, channel: '#alerts', retries: 3 },
126
+ { kind: 'pagerduty', enabled: true, escalation: { primary: 'usr_1042', secondary: null } },
127
+ { kind: 'jira', enabled: false, project: null },
128
+ ],
129
+ flags: {
130
+ betaFeatures: ['new-dashboard', 'inline-edit'],
131
+ rateLimits: { rpm: 600, burst: 1200, throttleAfter: null },
132
+ },
133
+ notes: undefined,
134
+ };
135
+
136
+ export const DeepNested: Story = {
137
+ name: 'Deeply nested (objects + arrays)',
138
+ render: () => <JsonTable value={deepSample} title="Organization" />,
139
+ };
@@ -13,8 +13,13 @@ import styles from './styles.module.css';
13
13
  export const JsonTable = (props: JsonTableProps) => {
14
14
  const { value, title, isNested } = props;
15
15
 
16
- if (isPlainObject(value)) {
17
- const entries = Object.entries(value);
16
+ const isObject = isPlainObject(value);
17
+ const isArray = Array.isArray(value);
18
+
19
+ if (isObject || isArray) {
20
+ const entries: [string, unknown][] = isArray
21
+ ? value.map((item, i) => [String(i), item])
22
+ : Object.entries(value as Record<string, unknown>);
18
23
 
19
24
  const headerRow = title ? (
20
25
  <QuickTableHeaderRow>
@@ -26,12 +31,14 @@ export const JsonTable = (props: JsonTableProps) => {
26
31
  <QuickTable headerRow={headerRow} hasColumnDividers hasOuterBorder={!isNested}>
27
32
  {entries.map((entry) => {
28
33
  const [key, child] = entry;
29
- const childIsObject = isPlainObject(child);
34
+ const childIsContainer = isPlainObject(child) || Array.isArray(child);
30
35
 
31
36
  return (
32
37
  <QuickTableRow key={key}>
33
- <QuickTableCell width={1}>{key}</QuickTableCell>
34
- <QuickTableCell hasPadding={!childIsObject}>
38
+ <QuickTableCell width={1}>
39
+ <span className={styles.key}>{key}</span>
40
+ </QuickTableCell>
41
+ <QuickTableCell hasPadding={!childIsContainer}>
35
42
  <JsonTable value={child} title={key} isNested />
36
43
  </QuickTableCell>
37
44
  </QuickTableRow>
@@ -43,7 +50,7 @@ export const JsonTable = (props: JsonTableProps) => {
43
50
  return isNested ? table : <div className={styles.root}>{table}</div>;
44
51
  }
45
52
 
46
- return <JsonLeafNode value={value} isMono={isNested} />;
53
+ return <JsonLeafNode value={value} />;
47
54
  };
48
55
 
49
56
  export type { JsonTableProps };
@@ -5,3 +5,23 @@
5
5
  .root thead tr {
6
6
  border-bottom-color: var(--ui-border-subtle);
7
7
  }
8
+
9
+ .key {
10
+ color: var(--ui-json-key);
11
+ }
12
+
13
+ .string {
14
+ color: var(--ui-json-string);
15
+ }
16
+
17
+ .number {
18
+ color: var(--ui-json-number);
19
+ }
20
+
21
+ .boolean {
22
+ color: var(--ui-json-boolean);
23
+ }
24
+
25
+ .nil {
26
+ color: var(--ui-json-nil);
27
+ }
@@ -12,11 +12,9 @@ export type JsonTableProps = {
12
12
  * labeled table. */
13
13
  title?: ReactNode;
14
14
  /** @internal Set by JsonTable on every recursive call (i.e. when rendering
15
- * a child entry of a parent table). Two effects: (1) on a nested object,
15
+ * a child entry of a parent table). On a nested object or array,
16
16
  * suppresses the outer border so the inner table sits flush inside its
17
- * parent cell — whose padding is also dropped at the parent level; (2) on
18
- * a primitive leaf, signals JsonLeafNode to render `null` / `undefined`
19
- * via Text with the monospace font so they inherit the surrounding font
20
- * set by the JsonTable root. Not intended for external callers. */
17
+ * parent cell — whose padding is also dropped at the parent level. Not
18
+ * intended for external callers. */
21
19
  isNested?: boolean;
22
20
  };
@@ -10,10 +10,11 @@ The forms umbrella, split into two named subtrees:
10
10
  - `state/` — the strongly-typed React form state hook and its machinery.
11
11
  Everything below this heading is about the state layer.
12
12
 
13
- The one sanctioned state→elements-side dependency is the Debugger bridge:
14
- `state/useFormState/FormDebugger.tsx` imports the `JsonTable` barrel from
15
- `src/components/Json/` (dev tooling). The item-6 form-aware wrappers will be
16
- the second bridge, living on the state side.
13
+ Two sanctioned state→elements-side bridges exist, both on the state side:
14
+ the Debugger (`state/useFormState/FormDebugger.tsx` imports the `JsonTable`
15
+ barrel from `src/components/Json/`, dev tooling) and the form-aware element
16
+ wrappers (`state/bindings/` see "Field binding" below). State files
17
+ importing an element go through its barrel like any external importer.
17
18
 
18
19
  The headline feature of the state layer is **validation that propagates type
19
20
  refinements to the submit handler** — passing the right constraints means the
@@ -49,25 +50,67 @@ function composition.
49
50
 
50
51
  ## What a constraints key may map to
51
52
 
53
+ The grammar is recursive. What a key admits is directed by the *field's*
54
+ type, never guessed from the constraint's shape:
55
+
52
56
  ```ts
53
57
  type FieldConstraint<F> =
58
+ // leaf forms — legal for ANY field type, structural fields included
54
59
  | FieldValidator<F> // one validator
55
60
  | readonly FieldValidator<F>[] // several, run in order, first error wins
61
+ // structural forms — only where the field type permits
62
+ | (F extends FormValuesObject ? Validations<F> : never) // nested spec
63
+ | (F extends FormValueList ? ListConstraint<F[number]> : never); // { each: … }
56
64
  ```
57
65
 
58
66
  The array form is the everyday way to stack validators on a field at the
59
- constraint site:
67
+ constraint site; a nested spec addresses the fields of an object section;
68
+ `each` applies a spec to every element of a list. All compose freely:
60
69
 
61
70
  ```ts
62
71
  constraints: {
63
72
  email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
73
+ homeAddress: {
74
+ city: notEmpty('city'),
75
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
76
+ },
77
+ drivers: {
78
+ each: {
79
+ name: notEmpty('name'),
80
+ incidents: { each: { date: notEmpty('date') } },
81
+ },
82
+ },
64
83
  }
65
84
  ```
66
85
 
67
- Semantics are identical to `allOf` (which remains the tool for building
68
- *reusable, named* composite validators): validators run in array order,
69
- the first error is the field's error, and later validators don't run once
70
- one fails. An empty array passes and narrows nothing.
86
+ Array semantics are identical to `allOf` (which remains the tool for
87
+ building *reusable, named* composite validators): validators run in array
88
+ order, the first error is the field's error, and later validators don't run
89
+ once one fails. An empty array passes and narrows nothing.
90
+
91
+ Grammar doctrine, in force at the type level and in the runtime walk:
92
+
93
+ - **Disambiguation is by the value model.** An object field that owns a key
94
+ literally named `each` still takes a nested spec — for an object-typed
95
+ field only the `Validations<F>` arm is live, for a list-typed field only
96
+ `ListConstraint`. Both `RefineField` and the walk interrogate the
97
+ field/value before the constraint's shape.
98
+ - **Leaf forms stay legal on structural fields.** A bare validator on an
99
+ object field is a whole-section validator (`mailingAddress:
100
+ notEmpty(…)` is how "required section" is expressed); a validator array
101
+ on a list field validates the list as a value. Leaf and structural forms
102
+ are mutually exclusive per key (recorded open decision — a future `self`
103
+ slot).
104
+ - **A nested spec on an absent (null/undefined) section validates
105
+ nothing**, at the type level (only the present branch refines;
106
+ nullability survives around the refined interior) and at runtime (the
107
+ walk skips — nothing to walk). Same for `each` over a null list.
108
+ - **`F` stays naked in the structural arms.** Nullable sections/lists work
109
+ purely by distribution; wrapping the checked type in `NonNullable` breaks
110
+ it and blows the recursion stack (TS2589).
111
+ - **Runtime status of `each`:** type-level only until plan phase 3. An
112
+ `each` constraint on a present list makes the walk THROW a clear error
113
+ instead of silently not validating.
71
114
 
72
115
  ## Aggregation: `perField`
73
116
 
@@ -78,7 +121,11 @@ const constraints = perField({
78
121
  ```
79
122
 
80
123
  `perField` is the entry point that produces a `Validations<FormType>`-shaped
81
- value while preserving the precise types of each individual validator.
124
+ value while preserving the precise types of each individual validator. It
125
+ currently admits only the leaf forms (a validator or validator array per
126
+ key) — extending it to pre-built nested/`each` specs is plan phase 4
127
+ (composition hardening); until then, write structural constraints inline in
128
+ the `useFormState` call.
82
129
 
83
130
  ### The precision-preserving ceremony, by call-site shape
84
131
 
@@ -136,10 +183,11 @@ unreliable in some intersections.
136
183
 
137
184
  ## `Refine<FormType, typeof constraints>`
138
185
 
139
- Walks the constraints object, reads each field's validator's
140
- `__excludes` marker, applies `Exclude<FormType[K], __excludes>`. Fields
141
- without a constraint pass through unchanged. The result is the type
142
- handed to `onSubmit`.
186
+ Walks the constraints object recursively, mirroring the grammar: leaves
187
+ apply `Exclude<FormType[K], __excludes>` from their validators' markers,
188
+ nested specs recurse into the section, `each` specs refine the element type
189
+ and flow it through `Array<...>`. Fields without a constraint pass through
190
+ unchanged, at every level. The result is the type handed to `onSubmit`.
143
191
 
144
192
  ```ts
145
193
  type FormType = { a: string | undefined; b: number };
@@ -147,6 +195,18 @@ type C = typeof constraints; // a: notEmpty
147
195
  type SubmitType = Refine<FormType, C>; // { a: string; b: number }
148
196
  ```
149
197
 
198
+ `Refine` opens with an identity gate — `Validations<T> extends V ? T :
199
+ RefineObject<T, V>` — and the gate is load-bearing, not an optimization:
200
+ with constraints omitted the hook's default `V = Validations<T>` would
201
+ distribute `FieldConstraint<F> | undefined` through the walk at every key
202
+ and hand back unions of structurally-identical mapped *copies* of every
203
+ section — assignable to `T` but not identity-equal (mangled hover types;
204
+ the "without constraints, onSubmit receives the unrefined form type" probe
205
+ fails). Any concrete constraints literal is strictly narrower than
206
+ `Validations<T>`, so the gate stays open and the real walk runs. Nested
207
+ occurrences don't need their own gate: a concrete literal's `keyof V` holds
208
+ only the keys actually written, so uncovered fields take `T[K]` verbatim.
209
+
150
210
  Per-field dispatch lives in `RefineField<F, C>`, whose constraint parameter
151
211
  is deliberately **naked** so unions of constraint types distribute. Two
152
212
  regimes of "multiple markers per field" follow from that, both sound:
@@ -168,10 +228,17 @@ regimes of "multiple markers per field" follow from that, both sound:
168
228
  per-branch refinements* (`Exclude<F, A> | Exclude<F, B>`), never a
169
229
  narrowing the running branch didn't earn.
170
230
 
171
- Branch order inside `RefineField`: the `Refinement` check must stay ahead of
172
- the array check and any future structural (object) check validator
173
- functions are objects, and a marked validator must not fall into a
174
- structural arm.
231
+ Both regimes survive recursion verbatim the same soundness holds for
232
+ union-typed constraints inside nested and `each` specs (probed at depth in
233
+ `useFormState.test-d.ts`).
234
+
235
+ Branch order inside `RefineField`: `Refinement` first — validator functions
236
+ are objects, and a marked validator must not fall into a structural arm —
237
+ then arrays, then the structural arms, which interrogate the FIELD type
238
+ before the constraint's shape (`F extends FormValueList` is asked before
239
+ looking for an `each` key, so an object field literally named `each`
240
+ refines as a nested spec). A bare marker-less validator on a structural
241
+ field lands in `RefineObject` with an empty `keyof C` — an identity map.
175
242
 
176
243
  ## Hook surface
177
244
 
@@ -188,10 +255,15 @@ useFormState({
188
255
  Validation failures surface as a structured list, not a keyed record:
189
256
 
190
257
  ```ts
191
- type FormError<T> = { path: Path<T>; error: string }; // single-key paths on the flat grammar
258
+ type FormError<T> = { path: Path<T>; error: string }; // one entry per failing constrained node
192
259
  type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.length === 0)
193
260
  ```
194
261
 
262
+ Paths are as deep as the failing node: a root leaf contributes `['email']`,
263
+ a nested-spec leaf `['homeAddress', 'postalCode']`. Sibling nodes fail
264
+ independently — first-error-wins applies *within* one validator array, not
265
+ across fields.
266
+
195
267
  Read one field's message with the typed accessor, never by hand-assembled
196
268
  keys — serialized path strings (`'drivers.0.name'`) are deliberately not
197
269
  exposed:
@@ -201,11 +273,74 @@ errorAt(errors, ['email']); // string | undefined
201
273
  ```
202
274
 
203
275
  `errorAt` (state/useFormState/errorAt.ts) matches by exact structural step
204
- equality (no prefix matching); with first-error-wins validation there is at
205
- most one entry per path, and if collect-all ever lands the first entry stays
206
- the one shown. The path-addressed model is what the recursive grammar
207
- (errors just carry longer paths) and `getFormFieldPropsAt`'s `errorMessage`
208
- build on.
276
+ equality (`pathsEqual` in state/path/path.ts — no prefix matching); with
277
+ first-error-wins validation there is at most one entry per path, and if
278
+ collect-all ever lands the first entry stays the one shown. The
279
+ path-addressed model is what the recursive grammar (errors just carry
280
+ longer paths) and `getFormFieldPropsAt`'s `errorMessage` build on. `errorAt`
281
+ is raw truth (no display gating); `errorMessage` is the display-policy-aware
282
+ reading (see "Field binding").
283
+
284
+ ## Field binding: `getFormFieldPropsAt(path)` + `state/bindings/`
285
+
286
+ One expression wires a field to the form:
287
+
288
+ ```tsx
289
+ <TextInputForForm label="City"
290
+ formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])} />
291
+ ```
292
+
293
+ `getFormFieldPropsAt` is a member of the hook's return value, typed by the
294
+ `state/path/` machinery:
295
+
296
+ ```ts
297
+ // on FormHelpers<T>:
298
+ getFormFieldPropsAt<P extends Path<T>>(path: P): FormFieldProps<ValueAt<T, P>>;
299
+
300
+ type FormFieldProps<V> = FieldBinding<V>;
301
+ type FieldBinding<Display, Emit = Display> = {
302
+ value: Display; // read(values, path)
303
+ onChange: (val: Emit) => void; // immutable write(values, path, val)
304
+ errorMessage: string | undefined; // display-policy-aware (below)
305
+ onBlur: () => void; // feeds touched tracking
306
+ };
307
+ ```
308
+
309
+ The pieces, and where each one's semantics live:
310
+
311
+ - **Granular path writes** — `write()` in `state/path/path.ts`, the
312
+ immutable-update mirror of `read()`: clones only the spine to the written
313
+ leaf, so untouched siblings keep identity. Dead-step semantics mirror
314
+ read() returning undefined: writing through an absent/null ancestor, a
315
+ shape mismatch, or an out-of-bounds index is an identity-preserving no-op
316
+ — you can't edit a field of an absent section; materialize the section
317
+ first. (Appending/splicing lists is item-8 territory, not a path write.)
318
+ `onValueChanges` (whole-value replacement) still exists but is no longer
319
+ the only write path.
320
+ - **Touched tracking** — real state in `useFieldBinding`: a `Path<T>[]`
321
+ compared with `pathsEqual`, fed by `onBlur`, visible in the Debugger
322
+ snapshot. Commit-style elements (selects) call `onBlur` on commit — the
323
+ commit IS their blur moment.
324
+ - **THE error-display policy** lives inside `errorMessage` and nowhere
325
+ else: show a field's error once the field is touched OR a submit has been
326
+ attempted. Elements render what they're given and stay policy-free. A UI
327
+ wanting a different policy (e.g. always-live) reads `errors`/`errorAt`
328
+ directly.
329
+
330
+ ### The element wrappers (`state/bindings/`)
331
+
332
+ The **wrapper style** is the prototyped shorthand (per the plan's deciding
333
+ criterion — simplest for agents to work with): `TextInputForForm` and
334
+ `SingleSelectForForm` compose `Field` (label/hint/error presentation)
335
+ around the element and accept the bundle as one `formFieldProps` prop.
336
+ The wrapper declares what it can display and emit via
337
+ `FieldBinding<Display, Emit>` — e.g. the text input takes
338
+ `FieldBinding<string | null | undefined, string>` — and a
339
+ `FormFieldProps<V>` is assignable exactly when `Emit ⊆ V ⊆ Display`. That
340
+ one structural check is the end-to-end type safety: binding a number- or
341
+ boolean-typed path (or a literal-union field, for free text) to a
342
+ text-shaped element fails to compile at the `formFieldProps` prop, with no
343
+ generics at the use site.
209
344
 
210
345
  ## Union policy — what form state may hold
211
346
 
@@ -309,10 +444,11 @@ precision end-to-end. So we wire it up before adding any consumers.
309
444
 
310
445
  - `state/useFormState/` — the hook + the value-model types (`FormValueSimple`,
311
446
  `FormValuesObject`, `FormValueList`). Hook surface: `{ values,
312
- onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`.
447
+ onValueChanges, errors, isValid, submitAttempted, submit,
448
+ getFormFieldPropsAt, Debugger }`.
313
449
  `useFormState.ts` itself is deliberately **plumbing only**: it holds the
314
- values `useState` (a bare `useState` today; granular path-based setters
315
- are what would earn values state a module of its own), links up the
450
+ values `useState` (a bare `useState`; the granular path writes layer on
451
+ top of its setter via `useFieldBinding`), links up the
316
452
  modules below, and recomposes their outputs into `FormHelpers<T>`. Each
317
453
  slice lives in its own file so it is independently comprehensible and
318
454
  unit-testable at its own boundary:
@@ -325,11 +461,19 @@ precision end-to-end. So we wire it up before adding any consumers.
325
461
  cheap); `isValid` is its emptiness, derived inline in the hook. Its
326
462
  loop delegates per-entry semantics to `state/validations/walk.ts` and
327
463
  carries two documented honest widenings: the `Object.keys` cast, and
328
- `failure.path as Path<T>` — `[key]` is a valid single-key `Path<T>`,
329
- but TS cannot compute `Path<T>` for an unresolved generic `T` to see
330
- the correlation. It also hosts the cast-free `FlatConstraintEntry`
464
+ `failure.path as Path<T>` — the walk extends the `[key]` seed only
465
+ along keys of specs type-checked against `T`'s subtree, so every
466
+ returned path is a valid `Path<T>`, but TS cannot compute `Path<T>`
467
+ for an unresolved generic `T` to see the correlation. It also hosts
468
+ the cast-free `ConstraintEntry`
331
469
  assignment that polices grammar growth (see the `state/validations/` bullet).
332
470
  - `errorAt.ts` — the typed error lookup.
471
+ - `useFieldBinding.ts` — field binding: owns the touched list and builds
472
+ `getFormFieldPropsAt` (see "Field binding" above — the error-display
473
+ policy lives here). Two documented honest casts correlate `read()`/
474
+ `write()` results with `ValueAt<T, P>`/`T` for generic `T`; both are
475
+ truths the runtime upholds by construction, same family as `errorAt`'s
476
+ path widening.
333
477
  - `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
334
478
  (lets UIs gate error display) and the validity gate in front of
335
479
  `onSubmit`; its `submit()` performs the one honest *refinement* cast
@@ -346,27 +490,37 @@ precision end-to-end. So we wire it up before adding any consumers.
346
490
  bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
347
491
  of the form's internal state. Only an *open* debugger window
348
492
  subscribes (`useSyncExternalStore`), so an unused Debugger costs
349
- ~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
350
- can render.
351
- - `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
352
- Will be used for granular setters and for surfacing per-field
353
- errors/touched state. Coupled to the form value model intentionally.
493
+ ~nothing. `inspectable.ts` converts Sets to string leaves `JsonTable`
494
+ can render (objects and arrays it renders natively).
495
+ - `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
496
+ `read()`), plus `write()` (the immutable-update mirror of `read()` behind
497
+ granular field writes) and `pathsEqual` (the one definition of structural
498
+ path equality — `errorAt` and touched tracking both use it). Coupled to
499
+ the form value model intentionally.
354
500
  Union handling is governed by the "Union policy" section above; the
355
501
  policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
356
502
  `DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
357
- - `state/validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
358
- `Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the runtime walk the
359
- hook delegates to (`validateEntry`). `Validations<T>` accepts bare
360
- `(val) => string | null` functions too they simply narrow nothing.
361
- The walk's entry type (`FlatConstraintEntry`) is how the compiler polices
503
+ - `state/validations/` — `perField`, the recursive grammar
504
+ (`Validations<T>` / `FieldConstraint<F>` / `ListConstraint<E>`),
505
+ `Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the recursive runtime
506
+ walk the hook delegates to (`validateEntry` returns every failure in the
507
+ entry's subtree, addresses accumulated as `PathStep[]`s). `Validations<T>`
508
+ accepts bare `(val) => string | null` functions too — they simply narrow
509
+ nothing. The walk disambiguates a structural spec against the VALUE at
510
+ the path (array ⇒ `{ each }`, which throws until phase 3; object ⇒
511
+ nested spec; absent ⇒ skip), never against the constraint's shape.
512
+ The walk's entry type (`ConstraintEntry`) is how the compiler polices
362
513
  the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
363
514
  *assigned* to it, never cast,
364
- so a grammar form the walk doesn't understand (a nested spec, an `each`)
515
+ so a grammar form the walk doesn't understand (a future `self` slot, say)
365
516
  is a compile error at the assignment — a cast there would silently accept
366
517
  new grammar and misinterpret it at runtime (e.g. call an array as a
367
518
  function). Keep it cast-free. The one widening cast inside `validateEntry`
368
519
  mirrors `allOf`'s part-call: honest contravariant widening of a single,
369
520
  already-normalized validator.
521
+ - `state/bindings/` — the form-aware element wrappers (`TextInputForForm`,
522
+ `SingleSelectForForm`), the second sanctioned state→elements bridge (see
523
+ "Field binding" above). Element imports go through the element barrels.
370
524
  - `state/validators/` — the standard-library validators (`notEmpty`, `minLength`,
371
525
  `matches`, `min`) plus `allOf`, which composes validators on one field and
372
526
  carries the union of the parts' refinements. `allOf` derives its input type
@@ -14,6 +14,7 @@ export const Input = (props: InputProps) => {
14
14
  value,
15
15
  defaultValue,
16
16
  onChange,
17
+ onBlur,
17
18
  placeholder,
18
19
  required,
19
20
  autoFocus,
@@ -29,6 +30,7 @@ export const Input = (props: InputProps) => {
29
30
  value={value}
30
31
  defaultValue={defaultValue}
31
32
  onChange={onChange}
33
+ onBlur={onBlur}
32
34
  placeholder={placeholder}
33
35
  required={required}
34
36
  autoFocus={autoFocus}
@@ -1,4 +1,4 @@
1
- import type { ChangeEventHandler } from 'react';
1
+ import type { ChangeEventHandler, FocusEventHandler } from 'react';
2
2
 
3
3
  export type InputSize = 'small' | 'medium';
4
4
  export type InputType = 'text' | 'email' | 'password';
@@ -9,6 +9,7 @@ export type InputProps = {
9
9
  value?: string;
10
10
  defaultValue?: string;
11
11
  onChange?: ChangeEventHandler<HTMLInputElement>;
12
+ onBlur?: FocusEventHandler<HTMLInputElement>;
12
13
  placeholder?: string;
13
14
  required?: boolean;
14
15
  autoFocus?: boolean;