@structuralists/scaffolding 0.11.0 → 0.13.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.
@@ -1,4 +1,5 @@
1
1
  import type { Preview } from '@storybook/react-vite';
2
+ import { useEffect } from 'storybook/preview-api';
2
3
  import { MemoryRouter } from 'react-router';
3
4
  import * as prettier from 'prettier/standalone';
4
5
  import * as babel from 'prettier/plugins/babel';
@@ -11,6 +12,28 @@ import '../tokens.css';
11
12
  const formatCache = new Map<string, string>();
12
13
 
13
14
  const preview: Preview = {
15
+ globalTypes: {
16
+ theme: {
17
+ description: 'Design-token theme (sets data-theme on <html>)',
18
+ toolbar: {
19
+ title: 'Theme',
20
+ icon: 'paintbrush',
21
+ items: [
22
+ { value: 'system', title: 'System (default)' },
23
+ { value: 'light-warm', title: 'Light / Warm' },
24
+ { value: 'light-paper', title: 'Light / Paper' },
25
+ { value: 'light-sepia', title: 'Light / Sepia' },
26
+ { value: 'dark-warm', title: 'Dark / Warm' },
27
+ { value: 'dark-neutral', title: 'Dark / Neutral' },
28
+ { value: 'dark-dimmed', title: 'Dark / Dimmed' },
29
+ ],
30
+ dynamicTitle: true,
31
+ },
32
+ },
33
+ },
34
+ initialGlobals: {
35
+ theme: 'system',
36
+ },
14
37
  parameters: {
15
38
  layout: 'padded',
16
39
 
@@ -71,6 +94,25 @@ const preview: Preview = {
71
94
  }
72
95
  },
73
96
  decorators: [
97
+ // Theme toggle: mirrors the toolbar selection onto <html data-theme="…">,
98
+ // matching how consuming apps select a theme (see tokens.css). 'system'
99
+ // leaves the attribute unset so the prefers-color-scheme fallback applies —
100
+ // that is also the initial global, so the vitest story run renders
101
+ // identically to before the toggle existed.
102
+ (Story, context) => {
103
+ const theme = context.globals.theme as string | undefined;
104
+
105
+ useEffect(() => {
106
+ const root = document.documentElement;
107
+ if (!theme || theme === 'system') {
108
+ root.removeAttribute('data-theme');
109
+ } else {
110
+ root.setAttribute('data-theme', theme);
111
+ }
112
+ }, [theme]);
113
+
114
+ return <Story />;
115
+ },
74
116
  (Story) => (
75
117
  <MemoryRouter>
76
118
  <Story />
package/AGENTS.md CHANGED
@@ -54,6 +54,15 @@ Built-in React hooks (`useState`, `useEffect`, etc.) keep their stock
54
54
  positional signatures — this rule applies only to hooks defined in this
55
55
  package.
56
56
 
57
+ ## Storybook theme toggle
58
+
59
+ The Storybook toolbar has a Theme control (paintbrush icon) listing all six
60
+ `tokens.css` themes plus "System (default)". It is wired in
61
+ `.storybook/preview.tsx` via `globalTypes` + a global decorator that sets
62
+ `data-theme` on `<html>` — the same mechanism consuming apps use. "System"
63
+ leaves the attribute unset (prefers-color-scheme fallback) and is the initial
64
+ value, so the vitest story run is unaffected by the toggle.
65
+
57
66
  ## Testing
58
67
 
59
68
  ### Story tests (the main gate)
package/README.md CHANGED
@@ -53,7 +53,7 @@ export const SignupCard = () => {
53
53
  };
54
54
  ```
55
55
 
56
- Browse every component (with live controls) in the Storybook: `bun run storybook`.
56
+ Browse every component (with live controls) in the Storybook: `bun run storybook`. The toolbar's Theme control (paintbrush icon) previews any of the themes above — "System (default)" leaves `data-theme` unset, following system preference.
57
57
 
58
58
  ## Consumer notes
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.11.0",
3
+ "version": "0.13.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,71 @@ 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; a
108
+ "required list" is a leaf validator on the list field.
109
+ - **`F` stays naked in the structural arms.** Nullable sections/lists work
110
+ purely by distribution; wrapping the checked type in `NonNullable` breaks
111
+ it and blows the recursion stack (TS2589).
112
+ - **An `each` spec validates every element.** Failures are addressed with
113
+ the numeric index step (`['drivers', 3, 'name']`) — the same step
114
+ semantics as `read()`/`Path`, so `errorAt` and the bindings look element
115
+ errors up like any other path. Elements fail independently
116
+ (first-error-wins applies within one field's validator array, not across
117
+ elements); an empty list passes.
72
118
 
73
119
  ## Aggregation: `perField`
74
120
 
@@ -79,7 +125,11 @@ const constraints = perField({
79
125
  ```
80
126
 
81
127
  `perField` is the entry point that produces a `Validations<FormType>`-shaped
82
- value while preserving the precise types of each individual validator.
128
+ value while preserving the precise types of each individual validator. It
129
+ currently admits only the leaf forms (a validator or validator array per
130
+ key) — extending it to pre-built nested/`each` specs is plan phase 4
131
+ (composition hardening); until then, write structural constraints inline in
132
+ the `useFormState` call.
83
133
 
84
134
  ### The precision-preserving ceremony, by call-site shape
85
135
 
@@ -137,10 +187,11 @@ unreliable in some intersections.
137
187
 
138
188
  ## `Refine<FormType, typeof constraints>`
139
189
 
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`.
190
+ Walks the constraints object recursively, mirroring the grammar: leaves
191
+ apply `Exclude<FormType[K], __excludes>` from their validators' markers,
192
+ nested specs recurse into the section, `each` specs refine the element type
193
+ and flow it through `Array<...>`. Fields without a constraint pass through
194
+ unchanged, at every level. The result is the type handed to `onSubmit`.
144
195
 
145
196
  ```ts
146
197
  type FormType = { a: string | undefined; b: number };
@@ -148,6 +199,18 @@ type C = typeof constraints; // a: notEmpty
148
199
  type SubmitType = Refine<FormType, C>; // { a: string; b: number }
149
200
  ```
150
201
 
202
+ `Refine` opens with an identity gate — `Validations<T> extends V ? T :
203
+ RefineObject<T, V>` — and the gate is load-bearing, not an optimization:
204
+ with constraints omitted the hook's default `V = Validations<T>` would
205
+ distribute `FieldConstraint<F> | undefined` through the walk at every key
206
+ and hand back unions of structurally-identical mapped *copies* of every
207
+ section — assignable to `T` but not identity-equal (mangled hover types;
208
+ the "without constraints, onSubmit receives the unrefined form type" probe
209
+ fails). Any concrete constraints literal is strictly narrower than
210
+ `Validations<T>`, so the gate stays open and the real walk runs. Nested
211
+ occurrences don't need their own gate: a concrete literal's `keyof V` holds
212
+ only the keys actually written, so uncovered fields take `T[K]` verbatim.
213
+
151
214
  Per-field dispatch lives in `RefineField<F, C>`, whose constraint parameter
152
215
  is deliberately **naked** so unions of constraint types distribute. Two
153
216
  regimes of "multiple markers per field" follow from that, both sound:
@@ -169,10 +232,17 @@ regimes of "multiple markers per field" follow from that, both sound:
169
232
  per-branch refinements* (`Exclude<F, A> | Exclude<F, B>`), never a
170
233
  narrowing the running branch didn't earn.
171
234
 
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.
235
+ Both regimes survive recursion verbatim the same soundness holds for
236
+ union-typed constraints inside nested and `each` specs (probed at depth in
237
+ `useFormState.test-d.ts`).
238
+
239
+ Branch order inside `RefineField`: `Refinement` first — validator functions
240
+ are objects, and a marked validator must not fall into a structural arm —
241
+ then arrays, then the structural arms, which interrogate the FIELD type
242
+ before the constraint's shape (`F extends FormValueList` is asked before
243
+ looking for an `each` key, so an object field literally named `each`
244
+ refines as a nested spec). A bare marker-less validator on a structural
245
+ field lands in `RefineObject` with an empty `keyof C` — an identity map.
176
246
 
177
247
  ## Hook surface
178
248
 
@@ -189,10 +259,16 @@ useFormState({
189
259
  Validation failures surface as a structured list, not a keyed record:
190
260
 
191
261
  ```ts
192
- type FormError<T> = { path: Path<T>; error: string }; // single-key paths on the flat grammar
262
+ type FormError<T> = { path: Path<T>; error: string }; // one entry per failing constrained node
193
263
  type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.length === 0)
194
264
  ```
195
265
 
266
+ Paths are as deep as the failing node: a root leaf contributes `['email']`,
267
+ a nested-spec leaf `['homeAddress', 'postalCode']`, a leaf inside a list
268
+ element carries the numeric index step (`['drivers', 3, 'name']`). Sibling
269
+ nodes fail independently — first-error-wins applies *within* one validator
270
+ array, not across fields or list elements.
271
+
196
272
  Read one field's message with the typed accessor, never by hand-assembled
197
273
  keys — serialized path strings (`'drivers.0.name'`) are deliberately not
198
274
  exposed:
@@ -390,9 +466,11 @@ precision end-to-end. So we wire it up before adding any consumers.
390
466
  cheap); `isValid` is its emptiness, derived inline in the hook. Its
391
467
  loop delegates per-entry semantics to `state/validations/walk.ts` and
392
468
  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`
469
+ `failure.path as Path<T>` — the walk extends the `[key]` seed only
470
+ along keys of specs type-checked against `T`'s subtree, so every
471
+ returned path is a valid `Path<T>`, but TS cannot compute `Path<T>`
472
+ for an unresolved generic `T` to see the correlation. It also hosts
473
+ the cast-free `ConstraintEntry`
396
474
  assignment that polices grammar growth (see the `state/validations/` bullet).
397
475
  - `errorAt.ts` — the typed error lookup.
398
476
  - `useFieldBinding.ts` — field binding: owns the touched list and builds
@@ -417,8 +495,8 @@ precision end-to-end. So we wire it up before adding any consumers.
417
495
  bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
418
496
  of the form's internal state. Only an *open* debugger window
419
497
  subscribes (`useSyncExternalStore`), so an unused Debugger costs
420
- ~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
421
- can render.
498
+ ~nothing. `inspectable.ts` converts Sets to string leaves `JsonTable`
499
+ can render (objects and arrays it renders natively).
422
500
  - `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
423
501
  `read()`), plus `write()` (the immutable-update mirror of `read()` behind
424
502
  granular field writes) and `pathsEqual` (the one definition of structural
@@ -427,14 +505,20 @@ precision end-to-end. So we wire it up before adding any consumers.
427
505
  Union handling is governed by the "Union policy" section above; the
428
506
  policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
429
507
  `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
508
+ - `state/validations/` — `perField`, the recursive grammar
509
+ (`Validations<T>` / `FieldConstraint<F>` / `ListConstraint<E>`),
510
+ `Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the recursive runtime
511
+ walk the hook delegates to (`validateEntry` returns every failure in the
512
+ entry's subtree, addresses accumulated as `PathStep[]`s). `Validations<T>`
513
+ accepts bare `(val) => string | null` functions too — they simply narrow
514
+ nothing. The walk disambiguates a structural spec against the VALUE at
515
+ the path (array ⇒ `{ each }`, run against every element with the index
516
+ as the path step; object ⇒ nested spec; absent ⇒ skip), never against
517
+ the constraint's shape.
518
+ The walk's entry type (`ConstraintEntry`) is how the compiler polices
435
519
  the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
436
520
  *assigned* to it, never cast,
437
- so a grammar form the walk doesn't understand (a nested spec, an `each`)
521
+ so a grammar form the walk doesn't understand (a future `self` slot, say)
438
522
  is a compile error at the assignment — a cast there would silently accept
439
523
  new grammar and misinterpret it at runtime (e.g. call an array as a
440
524
  function). Keep it cast-free. The one widening cast inside `validateEntry`