@structuralists/scaffolding 0.11.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.11.0",
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
  };
@@ -50,25 +50,67 @@ function composition.
50
50
 
51
51
  ## What a constraints key may map to
52
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
+
53
56
  ```ts
54
57
  type FieldConstraint<F> =
58
+ // leaf forms — legal for ANY field type, structural fields included
55
59
  | FieldValidator<F> // one validator
56
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: … }
57
64
  ```
58
65
 
59
66
  The array form is the everyday way to stack validators on a field at the
60
- 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:
61
69
 
62
70
  ```ts
63
71
  constraints: {
64
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
+ },
65
83
  }
66
84
  ```
67
85
 
68
- Semantics are identical to `allOf` (which remains the tool for building
69
- *reusable, named* composite validators): validators run in array order,
70
- the first error is the field's error, and later validators don't run once
71
- 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.
72
114
 
73
115
  ## Aggregation: `perField`
74
116
 
@@ -79,7 +121,11 @@ const constraints = perField({
79
121
  ```
80
122
 
81
123
  `perField` is the entry point that produces a `Validations<FormType>`-shaped
82
- 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.
83
129
 
84
130
  ### The precision-preserving ceremony, by call-site shape
85
131
 
@@ -137,10 +183,11 @@ unreliable in some intersections.
137
183
 
138
184
  ## `Refine<FormType, typeof constraints>`
139
185
 
140
- Walks the constraints object, reads each field's validator's
141
- `__excludes` marker, applies `Exclude<FormType[K], __excludes>`. Fields
142
- without a constraint pass through unchanged. The result is the type
143
- 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`.
144
191
 
145
192
  ```ts
146
193
  type FormType = { a: string | undefined; b: number };
@@ -148,6 +195,18 @@ type C = typeof constraints; // a: notEmpty
148
195
  type SubmitType = Refine<FormType, C>; // { a: string; b: number }
149
196
  ```
150
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
+
151
210
  Per-field dispatch lives in `RefineField<F, C>`, whose constraint parameter
152
211
  is deliberately **naked** so unions of constraint types distribute. Two
153
212
  regimes of "multiple markers per field" follow from that, both sound:
@@ -169,10 +228,17 @@ regimes of "multiple markers per field" follow from that, both sound:
169
228
  per-branch refinements* (`Exclude<F, A> | Exclude<F, B>`), never a
170
229
  narrowing the running branch didn't earn.
171
230
 
172
- Branch order inside `RefineField`: the `Refinement` check must stay ahead of
173
- the array check and any future structural (object) check validator
174
- functions are objects, and a marked validator must not fall into a
175
- 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.
176
242
 
177
243
  ## Hook surface
178
244
 
@@ -189,10 +255,15 @@ useFormState({
189
255
  Validation failures surface as a structured list, not a keyed record:
190
256
 
191
257
  ```ts
192
- 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
193
259
  type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.length === 0)
194
260
  ```
195
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
+
196
267
  Read one field's message with the typed accessor, never by hand-assembled
197
268
  keys — serialized path strings (`'drivers.0.name'`) are deliberately not
198
269
  exposed:
@@ -390,9 +461,11 @@ precision end-to-end. So we wire it up before adding any consumers.
390
461
  cheap); `isValid` is its emptiness, derived inline in the hook. Its
391
462
  loop delegates per-entry semantics to `state/validations/walk.ts` and
392
463
  carries two documented honest widenings: the `Object.keys` cast, and
393
- `failure.path as Path<T>` — `[key]` is a valid single-key `Path<T>`,
394
- but TS cannot compute `Path<T>` for an unresolved generic `T` to see
395
- 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`
396
469
  assignment that polices grammar growth (see the `state/validations/` bullet).
397
470
  - `errorAt.ts` — the typed error lookup.
398
471
  - `useFieldBinding.ts` — field binding: owns the touched list and builds
@@ -417,8 +490,8 @@ precision end-to-end. So we wire it up before adding any consumers.
417
490
  bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
418
491
  of the form's internal state. Only an *open* debugger window
419
492
  subscribes (`useSyncExternalStore`), so an unused Debugger costs
420
- ~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
421
- can render.
493
+ ~nothing. `inspectable.ts` converts Sets to string leaves `JsonTable`
494
+ can render (objects and arrays it renders natively).
422
495
  - `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
423
496
  `read()`), plus `write()` (the immutable-update mirror of `read()` behind
424
497
  granular field writes) and `pathsEqual` (the one definition of structural
@@ -427,14 +500,19 @@ precision end-to-end. So we wire it up before adding any consumers.
427
500
  Union handling is governed by the "Union policy" section above; the
428
501
  policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
429
502
  `DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
430
- - `state/validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
431
- `Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the runtime walk the
432
- hook delegates to (`validateEntry`). `Validations<T>` accepts bare
433
- `(val) => string | null` functions too they simply narrow nothing.
434
- 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
435
513
  the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
436
514
  *assigned* to it, never cast,
437
- 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)
438
516
  is a compile error at the assignment — a cast there would silently accept
439
517
  new grammar and misinterpret it at runtime (e.g. call an array as a
440
518
  function). Keep it cast-free. The one widening cast inside `validateEntry`
package/src/forms/plan.md CHANGED
@@ -28,7 +28,8 @@ TS wall can't strand finished work behind it.
28
28
  (state/useFormState/errorAt.ts) — the smallest surface that keeps stories
29
29
  readable; item 6 folds the same lookup into `getFormFieldPropsAt`'s
30
30
  `errorMessage`. The Debugger needed no change (`toInspectable`
31
- index-keys arrays).
31
+ index-keyed arrays at the time; it now passes them through, since
32
+ `JsonTable` renders arrays natively).
32
33
  4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
33
34
  item 6 so the wrappers land in their final home once. ✅ *done* — one
34
35
  `src/forms/` umbrella: elements in `src/forms/elements/<Component>/`,
@@ -45,6 +46,17 @@ TS wall can't strand finished work behind it.
45
46
  Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
46
47
  any runtime work; three outcomes (works / slow / intractable) each with a
47
48
  known response. Worst case, everything above still shipped.
49
+ - **Spike: ✅ done — verdict WORKS, decisively.** The full recursive
50
+ grammar (nested specs, `each`, arrays at every level, recursive
51
+ `Refine`) plus a stress tier (~110-leaf form, 12-level object ladder,
52
+ 4-deep `each` ladder) cost **1.31× instantiations, check time flat** —
53
+ nowhere near the ~10×/multi-second failure thresholds. No depth caps,
54
+ lazy-recursion or boxing tricks needed. Two binding grammar
55
+ adjustments came out of it, both folded into the sketches below:
56
+ value-model-first branch order in `RefineField`, and the `Refine`
57
+ identity gate for the default-`V` case.
58
+ - **Phase 2 (nested object specs + recursive `Refine`): ✅ done** — see
59
+ "Phases" below.
48
60
 
49
61
  ## Goal
50
62
 
@@ -126,30 +138,55 @@ useFormState({
126
138
 
127
139
  ## Refinement through the grammar
128
140
 
129
- `Refine` becomes recursive, mirroring the grammar. Sketch:
141
+ `Refine` is recursive, mirroring the grammar. As shipped (phase 2, with both
142
+ type-spike adjustments):
130
143
 
131
144
  ```ts
132
- type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
133
145
  // MemberExcludes<C>: the per-member-sound union of a validator tuple's
134
146
  // excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
135
147
 
136
- type RefineField<F, C> =
137
- C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
138
- : C extends readonly unknown[] ? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
139
- : C extends { readonly each: infer E } ? F extends FormValueList
140
- ? Array<RefineObject<F[number], E>> : F // per-element
141
- : C extends object ? F extends FormValuesObject
142
- ? RefineObject<F, C> : F // nested spec
143
- : F; // bare fn / no marker
148
+ type RefineField<F, C> = C extends Refinement<infer Excluded>
149
+ ? Exclude<F, Excluded> // single marked validator
150
+ : C extends readonly unknown[]
151
+ ? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
152
+ : C extends object // structural: interrogate F (the value model) FIRST
153
+ ? F extends FormValueList
154
+ ? C extends { readonly each: infer E }
155
+ ? Array<RefineObject<F[number], E>> // per-element
156
+ : F
157
+ : F extends FormValuesObject
158
+ ? RefineObject<F, C> // nested spec
159
+ : F
160
+ : F; // bare fn / no marker
144
161
 
145
162
  type RefineObject<T, V> = { [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K] };
163
+
164
+ // The identity gate — NOT optional (spike adjustment 2): with constraints
165
+ // omitted, the default V = Validations<T> would distribute through the walk
166
+ // and hand back unions of structurally-identical mapped copies of every
167
+ // section — assignable to T but not identity-equal (mangled hover types,
168
+ // fails toEqualTypeOf). Validations<T> extends V exactly when V is the
169
+ // default (or empty/fully-widened — no markers survive anyway).
170
+ type Refine<T extends FormValuesObject, V extends Validations<T>> =
171
+ Validations<T> extends V ? T : RefineObject<T, V>;
146
172
  ```
147
173
 
148
- Branch-order constraints discovered in the baseline, which this sketch bakes
149
- in:
174
+ Branch-order constraints baked in (baseline + spike):
150
175
 
151
176
  - **Check `Refinement` before `object`** — validator functions are objects.
152
- - **A bare function** (no marker) falls to the final `F` arm — narrows nothing.
177
+ - **The structural arms branch on the value model (`F`) before the
178
+ constraint's shape** (spike adjustment 1): asking `C extends { each }`
179
+ first would strand an object field that legitimately owns a key named
180
+ `each` — its nested spec would match the `each` arm, fail the list test,
181
+ and fall back unrefined. Pinned by the `audit` disambiguation probe in
182
+ `useFormState.test-d.ts`.
183
+ - **A bare function** (no marker) falls through: on a scalar field to the
184
+ final `F` arm; on a structural field into `RefineObject<F, C>` with
185
+ `keyof C` empty — an identity map, structurally unchanged, as a
186
+ whole-value validator should be.
187
+ - Nested occurrences need no identity gate: a concrete literal `V` has only
188
+ the keys actually written, so uncovered fields take the `T[K]` arm
189
+ verbatim at every level.
153
190
  - The runtime cast in `submit()` stays the same single honest cast: valid ⇒
154
191
  every constrained node passed, at every depth, which is what the recursive
155
192
  `Refine` now encodes.
@@ -200,11 +237,39 @@ multi-second check time:
200
237
  0.82 s, 125,022 instantiations, 54,959 types (+10.7% instantiations over
201
238
  post-item-5 — the `ValueAt` instantiations at each binding call site are
202
239
  real but cheap; check time flat)
240
+ - type spike (full recursive grammar, chunky + stress probes, throwaway
241
+ worktree — not merged): 163,262 instantiations / 65,036 types / 0.86s
242
+ check — 1.31× post-item-6; verdict works, no mitigation tricks required.
243
+ The real phases should land well under that: the spike carried a stress
244
+ tier (~110-leaf mega form with a large spec inlined twice, 12-level
245
+ object ladder, 4-deep `each` ladder) the phases don't.
246
+ - post-phase-2 (recursive grammar + recursive `Refine` with identity gate,
247
+ ported chunky/boundary/negative probe suite, recursive walk): check
248
+ 0.87 s, 133,956 instantiations, 57,762 types (+7.1% instantiations over
249
+ post-item-6; matches the spike's equivalent non-stress milestone at
250
+ 134,441 almost exactly)
203
251
 
204
252
  ## Runtime consequences (can't be dodged)
205
253
 
206
- - **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
207
- `state/path/path.ts`; share the traversal or keep them deliberately parallel.
254
+ - **The validator walk becomes a tree walk.** *Landed with phase 2*
255
+ (`validateEntry` in `state/validations/walk.ts` recurses, accumulating the
256
+ `PathStep[]` address; deliberately parallel to `read()`, not shared —
257
+ the walk descends a *constraints* tree, `read()` a *path*). Interpretation
258
+ of a structural spec is directed by the VALUE at the path, mirroring the
259
+ type level (see "Disambiguation").
260
+ - **Absent sections: a nested spec on an absent/null section is skipped.**
261
+ *Decided with the type spike.* The type level commits to this implicitly —
262
+ `F` distributes naked through the structural grammar arms, so a spec on
263
+ `UsAddress | undefined` refines only the present branch and the
264
+ nullability survives around the refined interior — and the runtime walk
265
+ mirrors it: nothing to walk when the section is absent (same for `each`
266
+ over a null list). A **required section** is expressed as a *leaf*
267
+ validator on the section field (`mailingAddress: notEmpty(…)`), which
268
+ stays legal on structural fields — with the recorded caveat that leaf and
269
+ structural forms are mutually exclusive per key (the "whole-value +
270
+ structural constraints on the same key" open decision below). Never
271
+ "help" the grammar arms with `NonNullable<F>` wrappers — that breaks the
272
+ distribution and blows the recursion stack (TS2589).
208
273
  - **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
209
274
  ✅ *Landed with working-order step 3* (on the flat baseline, single-key
210
275
  paths). Decided: errors become a plain list of structured entries,
@@ -244,13 +309,29 @@ tests, story updates where visible, probe ratchet.
244
309
  distributes over a naked constraint parameter, which made union-typed
245
310
  *single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
246
311
  refinements, pinned in `state/validations/types.test-d.ts`).
247
- 2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
248
- ratchet matters most here. (The `{path, error}[]` error model already
249
- landed in working-order step 3 — nested errors just carry longer paths;
250
- no error plumbing changes in this phase.)
251
- 3. **List `each` specs.** Runtime walks every element; error paths carry the
252
- numeric step (`['drivers', 3, 'name']`). Refined element type flows
253
- through `Array<...>`.
312
+ 2. **Nested object specs + recursive `Refine`.** The risk phase — de-risked
313
+ by the type spike. *done* full recursive grammar
314
+ (`FieldConstraint`/`ListConstraint`/`Validations` in
315
+ state/validations/types.ts) and recursive `Refine` with both spike
316
+ adjustments (value-model-first `RefineField` branch order; the identity
317
+ gate); recursive `validateEntry` walk with absent-section skip; the
318
+ spike's chunky/boundary/soundness/negative probe suite ported into
319
+ `useFormState.test-d.ts` (the error model needed no change, as
320
+ predicted — nested errors just carry longer paths). **The `each` TYPE
321
+ arm landed here too** (the spike proved the grammar whole, and carving
322
+ it out of `RefineField` would have been artificial), but the runtime
323
+ walk for it is phase 3 — until then an `each` constraint on a present
324
+ list THROWS from the walk (pinned in walk.test.ts) rather than silently
325
+ not validating; on a null list it skips, which already matches phase-3
326
+ semantics.
327
+ 3. **List `each` specs — runtime.** The walk visits every element; error
328
+ paths carry the numeric step (`['drivers', 3, 'name']`), replacing the
329
+ phase-2 throw. (The type level — refined element flowing through
330
+ `Array<...>` — already landed with phase 2.) Two probes the spike says
331
+ to keep pinned are already in the phase-2 suite: the `audit`-style
332
+ "object field literally named `each`" disambiguation probe, and the
333
+ `each: <bare validator>` negative (rejected by TypeScript's weak-type
334
+ check — an obscure checker rule worth a canary).
254
335
  4. **Composition hardening.** Deep mixed fixtures (list-in-object-in-list),
255
336
  `perField` still the entry point for pre-built specs, docs
256
337
  (forms/CLAUDE.md) updated to the new grammar.