@structuralists/scaffolding 0.10.1 → 0.11.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 (89) hide show
  1. package/eslint.config.mjs +56 -2
  2. package/package.json +1 -1
  3. package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +4 -4
  4. package/src/components/Layout/Bar/Bar.stories.tsx +1 -1
  5. package/src/components/Layout/Panels/Panels.stories.tsx +1 -1
  6. package/src/components/Layout/Stack/Stack.stories.tsx +1 -1
  7. package/src/components/Modals/LargeModal/LargeModal.stories.tsx +1 -1
  8. package/src/components/Modals/MediumModal/MediumModal.stories.tsx +3 -3
  9. package/src/components/Modals/internal/ModalHeader.tsx +1 -1
  10. package/src/components/Overlays/Popover/Popover.stories.tsx +3 -3
  11. package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +1 -1
  12. package/src/components/Tables/BigTable/BigTable.stories.tsx +1 -1
  13. package/src/forms/CLAUDE.md +115 -24
  14. package/src/{components/Forms → forms/elements}/Button/Button.stories.tsx +1 -1
  15. package/src/{components/Forms → forms/elements}/IconButton/index.tsx +1 -1
  16. package/src/{components/Forms → forms/elements}/Input/index.tsx +2 -0
  17. package/src/{components/Forms → forms/elements}/Input/types.ts +2 -1
  18. package/src/{components/Forms → forms/elements}/Select/MultiSelect/MultiSelect.stories.tsx +2 -2
  19. package/src/{components/Forms → forms/elements}/Select/MultiSelect/index.tsx +1 -1
  20. package/src/{components/Forms → forms/elements}/Select/SingleSelect/SingleSelect.stories.tsx +3 -3
  21. package/src/{components/Forms → forms/elements}/Select/SingleSelect/index.tsx +1 -1
  22. package/src/forms/plan.md +84 -38
  23. package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
  24. package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
  25. package/src/forms/{path → state/path}/path.test.ts +71 -1
  26. package/src/forms/state/path/path.ts +103 -0
  27. package/src/forms/{useFormState → state/useFormState}/FormDebugger.tsx +2 -1
  28. package/src/forms/{useFormState → state/useFormState}/errorAt.ts +8 -12
  29. package/src/forms/{useFormState → state/useFormState}/types.ts +33 -2
  30. package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
  31. package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
  32. package/src/forms/{useFormState → state/useFormState}/useFormDebugger.test.tsx +1 -0
  33. package/src/forms/{useFormState → state/useFormState}/useFormState.stories.tsx +167 -4
  34. package/src/forms/{useFormState → state/useFormState}/useFormState.test-d.ts +80 -1
  35. package/src/forms/{useFormState → state/useFormState}/useFormState.ts +12 -3
  36. package/src/index.ts +10 -10
  37. package/src/storybook/Composition.stories.tsx +4 -4
  38. package/src/storybook/_StoryUtils.stories.tsx +1 -1
  39. package/src/forms/path/path.ts +0 -53
  40. /package/src/{components/Forms → forms/elements}/Button/index.tsx +0 -0
  41. /package/src/{components/Forms → forms/elements}/Button/styles.module.css +0 -0
  42. /package/src/{components/Forms → forms/elements}/Button/types.ts +0 -0
  43. /package/src/{components/Forms → forms/elements}/ColorInput/index.tsx +0 -0
  44. /package/src/{components/Forms → forms/elements}/ColorInput/styles.module.css +0 -0
  45. /package/src/{components/Forms → forms/elements}/ColorInput/types.ts +0 -0
  46. /package/src/{components/Forms → forms/elements}/Field/Field.stories.tsx +0 -0
  47. /package/src/{components/Forms → forms/elements}/Field/index.tsx +0 -0
  48. /package/src/{components/Forms → forms/elements}/Field/styles.module.css +0 -0
  49. /package/src/{components/Forms → forms/elements}/Field/types.ts +0 -0
  50. /package/src/{components/Forms → forms/elements}/IconButton/IconButton.stories.tsx +0 -0
  51. /package/src/{components/Forms → forms/elements}/IconButton/styles.module.css +0 -0
  52. /package/src/{components/Forms → forms/elements}/IconButton/types.ts +0 -0
  53. /package/src/{components/Forms → forms/elements}/Input/Input.stories.tsx +0 -0
  54. /package/src/{components/Forms → forms/elements}/Input/styles.module.css +0 -0
  55. /package/src/{components/Forms → forms/elements}/SearchInput/index.tsx +0 -0
  56. /package/src/{components/Forms → forms/elements}/SearchInput/styles.module.css +0 -0
  57. /package/src/{components/Forms → forms/elements}/SearchInput/types.ts +0 -0
  58. /package/src/{components/Forms → forms/elements}/Select/MultiSelect/types.ts +0 -0
  59. /package/src/{components/Forms → forms/elements}/Select/SingleSelect/types.ts +0 -0
  60. /package/src/{components/Forms → forms/elements}/Select/index.ts +0 -0
  61. /package/src/{components/Forms → forms/elements}/Select/internal/OptionList.tsx +0 -0
  62. /package/src/{components/Forms → forms/elements}/Select/internal/SelectTrigger.tsx +0 -0
  63. /package/src/{components/Forms → forms/elements}/Select/internal/styles.module.css +0 -0
  64. /package/src/{components/Forms → forms/elements}/Textarea/Textarea.stories.tsx +0 -0
  65. /package/src/{components/Forms → forms/elements}/Textarea/index.tsx +0 -0
  66. /package/src/{components/Forms → forms/elements}/Textarea/styles.module.css +0 -0
  67. /package/src/{components/Forms → forms/elements}/Textarea/types.ts +0 -0
  68. /package/src/forms/{path → state/path}/types.test-d.ts +0 -0
  69. /package/src/forms/{path → state/path}/types.ts +0 -0
  70. /package/src/forms/{useFormState → state/useFormState}/FormDebugger.module.css +0 -0
  71. /package/src/forms/{useFormState → state/useFormState}/FormDebugger.test.tsx +0 -0
  72. /package/src/forms/{useFormState → state/useFormState}/deriveErrors.test.ts +0 -0
  73. /package/src/forms/{useFormState → state/useFormState}/deriveErrors.ts +0 -0
  74. /package/src/forms/{useFormState → state/useFormState}/errorAt.test.ts +0 -0
  75. /package/src/forms/{useFormState → state/useFormState}/inspectable.test.ts +0 -0
  76. /package/src/forms/{useFormState → state/useFormState}/inspectable.ts +0 -0
  77. /package/src/forms/{useFormState → state/useFormState}/snapshotStore.test.ts +0 -0
  78. /package/src/forms/{useFormState → state/useFormState}/snapshotStore.ts +0 -0
  79. /package/src/forms/{useFormState → state/useFormState}/useFormDebugger.ts +0 -0
  80. /package/src/forms/{useFormState → state/useFormState}/useFormState.test.tsx +0 -0
  81. /package/src/forms/{useFormState → state/useFormState}/useFormSubmit.test.tsx +0 -0
  82. /package/src/forms/{useFormState → state/useFormState}/useFormSubmit.ts +0 -0
  83. /package/src/forms/{validations → state/validations}/perField.ts +0 -0
  84. /package/src/forms/{validations → state/validations}/types.test-d.ts +0 -0
  85. /package/src/forms/{validations → state/validations}/types.ts +0 -0
  86. /package/src/forms/{validations → state/validations}/walk.test.ts +0 -0
  87. /package/src/forms/{validations → state/validations}/walk.ts +0 -0
  88. /package/src/forms/{validators → state/validators}/validators.test.ts +0 -0
  89. /package/src/forms/{validators → state/validators}/validators.ts +0 -0
package/src/forms/plan.md CHANGED
@@ -16,25 +16,33 @@ TS wall can't strand finished work behind it.
16
16
  (PR #14, released 0.6.0)
17
17
  2. **Item 1 — validator arrays per key.** `ExcludedOf<C[number]>` already
18
18
  proven inside `allOf`. ✅ *done* — grammar (`FieldConstraint`), distributive
19
- `RefineField`, and the walk rewrite (`validations/walk.ts`, cast-free entry
19
+ `RefineField`, and the walk rewrite (`state/validations/walk.ts`, cast-free entry
20
20
  dispatch, `PathStep[]`-addressed errors) landed together; probe ratchet
21
21
  held (see the baseline note under the recursion budget).
22
22
  3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
23
23
  are single-key paths). Doesn't depend on nested constraints; hard
24
24
  prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
25
25
  ✅ *done* — `FormError<T>`/`FormErrors<T>` (`{ path: Path<T>; error }[]`
26
- in useFormState/types.ts), `isValid` derived from emptiness, and the
26
+ in state/useFormState/types.ts), `isValid` derived from emptiness, and the
27
27
  interim consumer accessor is a standalone `errorAt(errors, path)`
28
- (useFormState/errorAt.ts) — the smallest surface that keeps stories
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
31
  index-keys arrays).
32
32
  4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
33
- item 6 so the wrappers land in their final home once. *current*
33
+ item 6 so the wrappers land in their final home once. *done* — one
34
+ `src/forms/` umbrella: elements in `src/forms/elements/<Component>/`,
35
+ the state layer in `src/forms/state/{useFormState,validations,validators,path}/`.
34
36
  5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
35
- error model and the split; `Path`/`ValueAt` already validated.
36
- 6. **Type spike, then items 2/3/4** the recursion risk zone. Throwaway
37
- `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
37
+ error model and the split; `Path`/`ValueAt` already validated. ✅ *done* —
38
+ `getFormFieldPropsAt` on the hook result (value / typed onChange /
39
+ policy-aware errorMessage / onBlur), `write()` as the immutable mirror of
40
+ `read()` in `state/path/path.ts`, touched tracking as real state, and the
41
+ wrapper-style element shorthands prototyped (`state/bindings/`:
42
+ `TextInputForForm`, `SingleSelectForForm`). Probe ratchet held (see the
43
+ baseline note under the recursion budget).
44
+ 6. **Type spike, then items 2/3/4** — the recursion risk zone. ← *current*
45
+ Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
38
46
  any runtime work; three outcomes (works / slow / intractable) each with a
39
47
  known response. Worst case, everything above still shipped.
40
48
 
@@ -123,7 +131,7 @@ useFormState({
123
131
  ```ts
124
132
  type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
125
133
  // MemberExcludes<C>: the per-member-sound union of a validator tuple's
126
- // excludes — it and SoundExcludedOf landed with phase 1 in validations/types.ts.
134
+ // excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
127
135
 
128
136
  type RefineField<F, C> =
129
137
  C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
@@ -185,11 +193,18 @@ multi-second check time:
185
193
  - post-step-3 (error-model swap: `Path<T>`-typed `FormError`, `errorAt`,
186
194
  plus probes): check 0.79 s, 113,724 instantiations, 52,294 types
187
195
  (+4.4% instantiations over pre-step-3 HEAD)
196
+ - post-item-5 split (0.10.1): check 0.81 s, 112,981 instantiations,
197
+ 52,504 types
198
+ - post-item-6 (field binding: `getFormFieldPropsAt`, `FieldBinding`
199
+ wrappers, plus deep-path probes at `InsuranceQuoteForm` scale): check
200
+ 0.82 s, 125,022 instantiations, 54,959 types (+10.7% instantiations over
201
+ post-item-5 — the `ValueAt` instantiations at each binding call site are
202
+ real but cheap; check time flat)
188
203
 
189
204
  ## Runtime consequences (can't be dodged)
190
205
 
191
206
  - **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
192
- `path/path.ts`; share the traversal or keep them deliberately parallel.
207
+ `state/path/path.ts`; share the traversal or keep them deliberately parallel.
193
208
  - **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
194
209
  ✅ *Landed with working-order step 3* (on the flat baseline, single-key
195
210
  paths). Decided: errors become a plain list of structured entries,
@@ -199,7 +214,7 @@ multi-second check time:
199
214
  // errors: FormError<T>[] isValid: errors.length === 0
200
215
  ```
201
216
 
202
- reusing `path/`'s typed representation (this is the "surfacing per-field
217
+ reusing `state/path/`'s typed representation (this is the "surfacing per-field
203
218
  errors" role path/ was built for). **We will never expose
204
219
  `'drivers.0.name'`-style serialized-string key derivation.** At the scale
205
220
  these forms operate, a linear scan over `{path, error}[]` is fine. If fast
@@ -208,7 +223,7 @@ multi-second check time:
208
223
  fully encapsulated and opaque to everything outside it.
209
224
  - **`errors` display wiring** in stories/components goes through a typed
210
225
  accessor, never through hand-assembled keys. ✅ *Decided and landed with
211
- step 3*: a standalone `errorAt(errors, path)` (useFormState/errorAt.ts) —
226
+ step 3*: a standalone `errorAt(errors, path)` (state/useFormState/errorAt.ts) —
212
227
  structural step-equality lookup, first entry wins. Chosen over a
213
228
  cursor-based lookup as the smallest surface that keeps stories readable
214
229
  on the flat grammar; item 6's `errorMessage` reuses it internally.
@@ -228,7 +243,7 @@ tests, story updates where visible, probe ratchet.
228
243
  marker computation. ✅ *done* — plus a byproduct: `RefineField`
229
244
  distributes over a naked constraint parameter, which made union-typed
230
245
  *single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
231
- refinements, pinned in `validations/types.test-d.ts`).
246
+ refinements, pinned in `state/validations/types.test-d.ts`).
232
247
  2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
233
248
  ratchet matters most here. (The `{path, error}[]` error model already
234
249
  landed in working-order step 3 — nested errors just carry longer paths;
@@ -240,32 +255,62 @@ tests, story updates where visible, probe ratchet.
240
255
  `perField` still the entry point for pre-built specs, docs
241
256
  (forms/CLAUDE.md) updated to the new grammar.
242
257
 
243
- ## 5. Split forms into 'form elements' and 'form state'
258
+ ## 5. Split forms into 'form elements' and 'form state' ✅ done
244
259
 
245
- Today the form world lives in two places with unrelated names:
260
+ The form world used to live in two places with unrelated names
261
+ (`src/components/Forms/` vs `src/forms/` — an accidental `Forms`-vs-`forms`
262
+ distinction). The layout decision landed on **one `src/forms/` umbrella with
263
+ two named subtrees**:
246
264
 
247
- - `src/components/Forms/` — the presentational **elements** (`Input`,
248
- `Field`, `Select`, `Button`, `Textarea`, …)
249
- - `src/forms/` — the **state** layer (`useFormState`, `validations/`,
265
+ - `src/forms/elements/` — the presentational **elements** (`Input`,
266
+ `Field`, `Select`, `Button`, `Textarea`, …), one primitive per folder,
267
+ same shape as `src/components/<Section>/<Component>/`
268
+ - `src/forms/state/` — the **state** layer (`useFormState/`, `validations/`,
250
269
  `validators/`, `path/`)
251
270
 
252
- The intent: make that split explicit and named — 'form elements' vs
253
- 'form state' — rather than the current accidental `Forms`-vs-`forms`
254
- distinction. Recorded as direction; the target layout/naming is an open
255
- decision (rename in place? move both under one `forms/` umbrella with
256
- `elements/` and `state/` subtrees?).
257
-
258
- Blast radius to account for when this happens (not now):
259
-
260
- - the eslint `boundaries` element patterns in `eslint.config.mjs` are keyed
261
- to `src/components/<Section>/<Component>/` shapes,
262
- - barrel exports in `src/index.ts` (all the element components are public),
263
- - Storybook titles (`Forms/...` prefix) and story globs,
264
- - forms/CLAUDE.md and this plan's own paths.
265
-
266
- Independent of phases 1–4; can land before, between, or after them.
267
-
268
- ## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands
271
+ Blast radius that was handled when this landed:
272
+
273
+ - the eslint `boundaries` element patterns in `eslint.config.mjs` (were keyed
274
+ to `src/components/<Section>/<Component>/` shapes): element primitives got
275
+ matching patterns at their new home, `src/forms/state` became its own
276
+ element type, and two directional rules were added — elements/components
277
+ must not import the state layer, and state files importing a primitive go
278
+ through its barrel,
279
+ - barrel exports in `src/index.ts` (all the element components are public —
280
+ paths moved, exported surface unchanged),
281
+ - Storybook titles (`Forms/...` prefix) and story globs: titles kept as-is —
282
+ elements stay `Forms/<Component>`, the state story stays
283
+ `Forms/useFormState`; the story glob (`src/**/*.stories.*`) needed no
284
+ change,
285
+ - forms/CLAUDE.md and this plan's own paths,
286
+ - `FormDebugger.tsx` imports `JsonTable` from `src/components/Json/` — the
287
+ sanctioned state→elements bridge dependency (audit finding 3.3; the
288
+ Debugger is a designated bridge layer). Explicitly allowed for in the new
289
+ boundaries rules.
290
+
291
+ ## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands ✅ done
292
+
293
+ Landed as specced below; deltas and decisions:
294
+
295
+ - `write()` in `state/path/path.ts` is the immutable-update mirror of
296
+ `read()` — clones only the spine, dead-step semantics mirror read()
297
+ returning undefined (writing through an absent section is an
298
+ identity-preserving no-op; materialize the section first).
299
+ - Touched is real state (`useFieldBinding`), a `Path<T>[]` compared with
300
+ `pathsEqual` — the same structural representation as `FormErrors`. It
301
+ also shows up in the Debugger snapshot.
302
+ - The error-display policy (touched-or-submitAttempted) lives inside
303
+ `errorMessage`, nowhere else — see "Field binding" in forms/CLAUDE.md.
304
+ - **Element-shorthand style: the wrapper style is the prototype**
305
+ (`TextInputForForm`, `SingleSelectForForm` in `src/forms/state/bindings/`,
306
+ the second sanctioned state→elements bridge after the Debugger). The
307
+ wrappers declare what they display/emit via `FieldBinding<Display, Emit>`
308
+ and plain structural assignability rejects wrong-shaped bindings — no
309
+ generics at the use site. Union-typed props on the elements themselves
310
+ remain the fallback if the parallel component set gets heavy; judge after
311
+ the wrapper set grows past these two.
312
+
313
+ Original spec follows.
269
314
 
270
315
  Today every field is wired by hand in JSX: read `values.x`, spread-update via
271
316
  `onValueChanges`, look up the error, gate it on `submitAttempted`. That's
@@ -278,7 +323,7 @@ four decisions per field, all boilerplate. Target usage:
278
323
  ### `getFormFieldPropsAt(path)`
279
324
 
280
325
  A member of the hook's return value (it needs `values`, `errors`, and the
281
- setter), typed by the `path/` machinery:
326
+ setter), typed by the `state/path/` machinery:
282
327
 
283
328
  ```ts
284
329
  type FormFieldProps<V> = {
@@ -297,7 +342,7 @@ Consequences this pulls in deliberately:
297
342
 
298
343
  - **Granular setters via path arrive here** — this retires the "likely
299
344
  remove" todo on `onValueChanges` as the only write path. Writing at a path
300
- is the immutable-update mirror of `read()` in `path/path.ts`.
345
+ is the immutable-update mirror of `read()` in `state/path/path.ts`.
301
346
  - **Touched tracking becomes real state** (`onBlur` feeds it). The
302
347
  error-display policy (submitAttempted / touched gating) moves *inside*
303
348
  `errorMessage`, so consuming elements render what they're given and stay
@@ -417,8 +462,9 @@ Possibly a hybrid: identity-matching as the default heuristic, owned ops as
417
462
  the guaranteed path. **Research topic for when we get here** — record
418
463
  findings in the learning map before committing to either.
419
464
 
420
- Sequencing: visited tracking lands with item 6 (its `onBlur` is the write
421
- path). Keys + array-sync matter from phase 3 (list `each` specs) onward —
465
+ Sequencing: visited tracking *landed with item 6* (its `onBlur` is the
466
+ write path; `touched: Path<T>[]` in `useFieldBinding`, structural equality
467
+ via `pathsEqual`). Keys + array-sync matter from phase 3 (list `each` specs) onward —
422
468
  error paths and per-element meta both need element identity once lists are
423
469
  editable.
424
470
 
@@ -0,0 +1,45 @@
1
+ import { Field } from '../../elements/Field';
2
+ import { SingleSelect } from '../../elements/Select';
3
+ import type { SelectOption, SelectSize } from '../../elements/Select';
4
+ import type { FieldBinding } from '../useFormState/types';
5
+
6
+ export type SingleSelectForFormProps<T extends string> = {
7
+ // Displays T|null|undefined, emits T — a FormFieldProps<V> fits when the
8
+ // path's value type sits between them, so the options' literal union and
9
+ // the field's type must line up or the binding fails to compile.
10
+ formFieldProps: FieldBinding<T | null | undefined, T>;
11
+ options: SelectOption<T>[];
12
+ label: string;
13
+ hint?: string;
14
+ placeholder?: string;
15
+ size?: SelectSize;
16
+ isDisabled?: boolean;
17
+ };
18
+
19
+ // The form-aware flavor of the single select. A select has no meaningful
20
+ // blur moment — committing an option IS the completed interaction — so the
21
+ // commit marks the field touched alongside the write.
22
+ export const SingleSelectForForm = <T extends string>(
23
+ props: SingleSelectForFormProps<T>,
24
+ ) => {
25
+ const { formFieldProps, options, label, hint, placeholder, size, isDisabled } =
26
+ props;
27
+ const { value, onChange, errorMessage, onBlur } = formFieldProps;
28
+
29
+ return (
30
+ <Field label={label} hint={hint} error={errorMessage}>
31
+ <SingleSelect
32
+ options={options}
33
+ value={value ?? null}
34
+ onChange={(next) => {
35
+ onChange(next);
36
+ onBlur();
37
+ }}
38
+ size={size}
39
+ isDisabled={isDisabled}
40
+ placeholder={placeholder}
41
+ ariaLabel={label}
42
+ />
43
+ </Field>
44
+ );
45
+ };
@@ -0,0 +1,45 @@
1
+ import { Field } from '../../elements/Field';
2
+ import { Input } from '../../elements/Input';
3
+ import type { InputProps } from '../../elements/Input';
4
+ import type { FieldBinding } from '../useFormState/types';
5
+
6
+ export type TextInputForFormProps = {
7
+ // The bundle from `getFormFieldPropsAt(path)`. The binding shape declares
8
+ // what this element can do — display string-ish, emit string — so a
9
+ // FormFieldProps<V> only fits when V is a text-shaped field
10
+ // (string / string|null / string|undefined / both). A number- or
11
+ // boolean-typed path is a compile error right here.
12
+ formFieldProps: FieldBinding<string | null | undefined, string>;
13
+ label: string;
14
+ hint?: string;
15
+ placeholder?: string;
16
+ id?: string;
17
+ type?: InputProps['type'];
18
+ size?: InputProps['size'];
19
+ disabled?: boolean;
20
+ };
21
+
22
+ // The form-aware flavor of the text input: one prop wires value, change,
23
+ // error display, and touched tracking. Composes Field (label/hint/error
24
+ // presentation) around Input; the error-display policy already happened
25
+ // inside `errorMessage` — this component just renders what it's given.
26
+ export const TextInputForForm = (props: TextInputForFormProps) => {
27
+ const { formFieldProps, label, hint, placeholder, id, type, size, disabled } =
28
+ props;
29
+ const { value, onChange, errorMessage, onBlur } = formFieldProps;
30
+
31
+ return (
32
+ <Field label={label} hint={hint} error={errorMessage} htmlFor={id}>
33
+ <Input
34
+ id={id}
35
+ type={type}
36
+ size={size}
37
+ value={value ?? ''}
38
+ onChange={(e) => onChange(e.target.value)}
39
+ onBlur={onBlur}
40
+ placeholder={placeholder}
41
+ disabled={disabled}
42
+ />
43
+ </Field>
44
+ );
45
+ };
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
- import { path, read } from './path';
2
+ import { path, pathsEqual, read, write } from './path';
3
3
 
4
4
  // These tests pin read()'s runtime semantics to exactly what the type level
5
5
  // (ValueAt) promises — see the union-semantics comments in ./types.ts and
@@ -95,6 +95,65 @@ describe('read step failures', () => {
95
95
  ).toBeUndefined();
96
96
  });
97
97
 
98
+ test('write replaces the value at a deep path without mutating the original', () => {
99
+ const next = write(filled, ['address', 'city'], 'Paris') as Form;
100
+
101
+ expect(next.address?.city).toBe('Paris');
102
+ expect(filled.address?.city).toBe('London');
103
+ });
104
+
105
+ test('write clones only the spine — untouched siblings keep their identity', () => {
106
+ const next = write(filled, ['a', 'b', 'c'], 'deeper') as Form;
107
+
108
+ // The spine to the written leaf is new at every level …
109
+ expect(next).not.toBe(filled);
110
+ expect(next.a).not.toBe(filled.a);
111
+ expect(next.a?.b).not.toBe(filled.a?.b);
112
+ // … while every branch off the spine is the same object.
113
+ expect(next.address).toBe(filled.address);
114
+ expect(next.entries).toBe(filled.entries);
115
+ });
116
+
117
+ test('write into a list element clones the array and that element only', () => {
118
+ const twoEntries: Form = {
119
+ ...filled,
120
+ entries: [
121
+ { title: 'first', qty: 1 },
122
+ { title: 'second', qty: 2 },
123
+ ],
124
+ };
125
+ const next = write(twoEntries, ['entries', 1, 'qty'], 5) as Form;
126
+
127
+ expect(next.entries?.[1]).toEqual({ title: 'second', qty: 5 });
128
+ expect(next.entries).not.toBe(twoEntries.entries);
129
+ expect(next.entries?.[0]).toBe(twoEntries.entries?.[0]);
130
+ expect(twoEntries.entries?.[1].qty).toBe(2);
131
+ });
132
+
133
+ test('write with an empty path replaces the root', () => {
134
+ expect(write(filled, [], { name: 'Eve' })).toEqual({ name: 'Eve' });
135
+ });
136
+
137
+ test('write through a dead ancestor is an identity-preserving no-op', () => {
138
+ // Mirrors read() returning undefined for a dead step: you cannot edit a
139
+ // field of an absent section — materialize the section first.
140
+ expect(write(empty, ['address', 'city'], 'Paris')).toBe(empty);
141
+ expect(write(empty, ['entries', 0, 'title'], 'x')).toBe(empty);
142
+ });
143
+
144
+ test('write on a shape mismatch or out-of-bounds index is a no-op', () => {
145
+ // Numeric step on a non-array, key step on a scalar, index past the end
146
+ // (appending is list manipulation, not a path write).
147
+ expect(write(filled, ['name', 'length'], 3)).toBe(filled);
148
+ expect(write(filled, ['entries', 5, 'title'], 'x')).toBe(filled);
149
+ const list = [1, 2];
150
+ expect(write(list, ['key'], 'x')).toBe(list);
151
+ });
152
+
153
+ test('writing the value already present is an identity-preserving no-op', () => {
154
+ expect(write(filled, ['address', 'city'], 'London')).toBe(filled);
155
+ });
156
+
98
157
  test('rejecting narrow predicate returns undefined; accepting one passes through', () => {
99
158
  const isString = (val: unknown): val is string => typeof val === 'string';
100
159
  const narrowed = path<Form>()
@@ -110,3 +169,14 @@ describe('read step failures', () => {
110
169
  expect(read(filled, rejecting)).toBeUndefined();
111
170
  });
112
171
  });
172
+
173
+ describe('pathsEqual', () => {
174
+ test('structural equality over steps — same steps equal, no prefix matching', () => {
175
+ expect(pathsEqual(['a', 0, 'b'], ['a', 0, 'b'])).toBe(true);
176
+ expect(pathsEqual([], [])).toBe(true);
177
+ expect(pathsEqual(['a'], ['a', 'b'])).toBe(false);
178
+ expect(pathsEqual(['a', 0], ['a', 1])).toBe(false);
179
+ // A numeric step never equals its string spelling.
180
+ expect(pathsEqual(['a', 0], ['a', '0'])).toBe(false);
181
+ });
182
+ });
@@ -0,0 +1,103 @@
1
+ import type { Cursor, CursorStep, Path, PathStep, ValueAt } from './types';
2
+
3
+ const makeCursor = <T>(steps: readonly CursorStep[]): Cursor<T> => ({
4
+ at<P extends Path<T>>(p: P): Cursor<ValueAt<T, P>> {
5
+ const keySteps: CursorStep[] = (p as readonly PathStep[]).map((key) => ({
6
+ kind: 'key',
7
+ key,
8
+ }));
9
+
10
+ return makeCursor<ValueAt<T, P>>([...steps, ...keySteps]);
11
+ },
12
+ narrow<U extends T>(predicate: (val: T) => val is U): Cursor<U> {
13
+ return makeCursor<U>([
14
+ ...steps,
15
+ { kind: 'narrow', predicate: predicate as (val: unknown) => boolean },
16
+ ]);
17
+ },
18
+ build() {
19
+ return [...steps];
20
+ },
21
+ });
22
+
23
+ export const path = <T>(): Cursor<T> => makeCursor<T>([]);
24
+
25
+ // Structural path equality: same steps, same order, no prefix matching.
26
+ // The one sanctioned way to compare paths — `errorAt` and touched tracking
27
+ // both build on it, so "do these address the same field?" has exactly one
28
+ // definition.
29
+ export const pathsEqual = (
30
+ a: readonly PathStep[],
31
+ b: readonly PathStep[],
32
+ ): boolean => a.length === b.length && a.every((step, index) => step === b[index]);
33
+
34
+ // Walk steps against a value. Returns undefined if any step fails — a dead
35
+ // value (null or undefined) encountered mid-path, missing key, out-of-bounds
36
+ // index, or a narrow predicate that rejects the current value. Always
37
+ // undefined, never null, even when the dead value was null — ValueAt in
38
+ // ./types.ts mirrors this exactly (see "Union policy" in src/forms/CLAUDE.md).
39
+ // Callers using the typed cursor know what shape to expect; this is the
40
+ // runtime escape hatch that surfaces "the path didn't resolve."
41
+ export const read = (root: unknown, steps: readonly CursorStep[]): unknown => {
42
+ let cursor: unknown = root;
43
+
44
+ for (const step of steps) {
45
+ if (cursor == null) return undefined;
46
+
47
+ if (step.kind === 'narrow') {
48
+ if (!step.predicate(cursor)) return undefined;
49
+ continue;
50
+ }
51
+
52
+ if (typeof step.key === 'number') {
53
+ if (!Array.isArray(cursor)) return undefined;
54
+ cursor = cursor[step.key];
55
+ } else {
56
+ if (typeof cursor !== 'object') return undefined;
57
+ cursor = (cursor as Record<string, unknown>)[step.key];
58
+ }
59
+ }
60
+
61
+ return cursor;
62
+ };
63
+
64
+ // The immutable-update mirror of read(): returns a new root with `value`
65
+ // placed at `steps`, cloning only the containers along the path — untouched
66
+ // siblings keep their identity. Key-only steps (a `Path<T>` value), not
67
+ // CursorStep — there is no meaningful way to write "through" a narrow
68
+ // predicate.
69
+ //
70
+ // Dead-step semantics mirror read() returning undefined: a step that hits a
71
+ // null/undefined container, a shape mismatch (key step on a non-object,
72
+ // numeric step on a non-array), or an out-of-bounds index makes the whole
73
+ // write a no-op that returns `root` by identity — you can't edit a field of
74
+ // an absent section; materialize the section first. (Appending/splicing is
75
+ // list-manipulation territory, deliberately not a path write.) Writing a
76
+ // value that is already there (Object.is) is also an identity-preserving
77
+ // no-op, so state setters can bail out of re-rendering.
78
+ export const write = (
79
+ root: unknown,
80
+ steps: readonly PathStep[],
81
+ value: unknown,
82
+ ): unknown => {
83
+ if (steps.length === 0) return value;
84
+ const [head, ...rest] = steps;
85
+
86
+ if (typeof head === 'number') {
87
+ if (!Array.isArray(root)) return root;
88
+ if (head < 0 || head >= root.length) return root;
89
+ const child = write(root[head], rest, value);
90
+ if (Object.is(child, root[head])) return root;
91
+ const clone = root.slice();
92
+ clone[head] = child;
93
+ return clone;
94
+ }
95
+
96
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
97
+ return root;
98
+ }
99
+ const record = root as Record<string, unknown>;
100
+ const child = write(record[head], rest, value);
101
+ if (Object.is(child, record[head])) return root;
102
+ return { ...record, [head]: child };
103
+ };
@@ -1,7 +1,7 @@
1
1
  import { useState, useSyncExternalStore } from 'react';
2
2
  import type { ReactElement } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
- import { JsonTable } from '../../components/Json/JsonTable';
4
+ import { JsonTable } from '../../../components/Json/JsonTable';
5
5
  import { toInspectable } from './inspectable';
6
6
  import type { SnapshotStore } from './snapshotStore';
7
7
  import type { FormDebugSnapshot, FormValuesObject } from './types';
@@ -43,6 +43,7 @@ export const createFormDebugger = <T extends FormValuesObject>(
43
43
  submitAttempted: snapshot.submitAttempted,
44
44
  values: snapshot.values,
45
45
  errors: snapshot.errors,
46
+ touched: snapshot.touched,
46
47
  })}
47
48
  />
48
49
  </div>
@@ -1,11 +1,12 @@
1
+ import { pathsEqual } from '../path/path';
1
2
  import type { Path, PathStep } from '../path/types';
2
3
  import type { FormErrors, FormValuesObject } from './types';
3
4
 
4
5
  // Typed lookup into the structured `{ path, error }[]` error list — the
5
- // sanctioned way to read one field's error. Path equality is structural:
6
- // same steps, same order, no prefix matching. With first-error-wins
7
- // validation there is at most one entry per path today; should collect-all
8
- // ever land, the first entry stays the one shown.
6
+ // sanctioned way to read one field's error. Path equality is structural
7
+ // (`pathsEqual`): same steps, same order, no prefix matching. With
8
+ // first-error-wins validation there is at most one entry per path today;
9
+ // should collect-all ever land, the first entry stays the one shown.
9
10
  export const errorAt = <T extends FormValuesObject>(
10
11
  errors: FormErrors<T>,
11
12
  path: Path<T>,
@@ -14,14 +15,9 @@ export const errorAt = <T extends FormValuesObject>(
14
15
  // prove it for an unresolved T. Same honest widening as `Cursor.at`.
15
16
  const steps = path as readonly PathStep[];
16
17
 
17
- const match = errors.find((candidate) => {
18
- const candidateSteps = candidate.path as readonly PathStep[];
19
-
20
- return (
21
- candidateSteps.length === steps.length &&
22
- candidateSteps.every((step, index) => step === steps[index])
23
- );
24
- });
18
+ const match = errors.find((candidate) =>
19
+ pathsEqual(candidate.path as readonly PathStep[], steps),
20
+ );
25
21
 
26
22
  return match?.error;
27
23
  };
@@ -1,7 +1,7 @@
1
1
  // Type-only imports; FormDebugger.tsx and path/types.ts import value types
2
2
  // from this file in turn, but the cycles never exist at runtime.
3
3
  import type { FormDebuggerComponent } from './FormDebugger';
4
- import type { Path } from '../path/types';
4
+ import type { Path, ValueAt } from '../path/types';
5
5
 
6
6
  export type FormValueSimple = string | number | bigint | boolean | string[] | Set<string> | undefined | null;
7
7
 
@@ -90,6 +90,28 @@ export type FormError<T extends FormValuesObject> = {
90
90
 
91
91
  export type FormErrors<T extends FormValuesObject> = readonly FormError<T>[];
92
92
 
93
+ // What a form-aware element declares it needs from a field binding: it
94
+ // *displays* `Display` and *emits* `Emit`. A `FormFieldProps<V>` is
95
+ // assignable exactly when V sits between them (`Emit ⊆ V ⊆ Display`), which
96
+ // is how "a number-typed path bound to a text-shaped element" becomes a
97
+ // compile error at the `formFieldProps` prop — no generics needed on the
98
+ // wrapper, structural assignability does the checking.
99
+ export type FieldBinding<Display, Emit = Display> = {
100
+ value: Display;
101
+ onChange: (val: Emit) => void;
102
+ // Already display-policy-aware (see `useFieldBinding`): undefined until
103
+ // the field's error should be SHOWN, so elements render what they're
104
+ // given and stay policy-free.
105
+ errorMessage: string | undefined;
106
+ // Feeds touched tracking; wire it to the element's blur (or, for
107
+ // commit-style elements like selects, to the commit).
108
+ onBlur: () => void;
109
+ // room to grow: name/id derivation, disabled, ...
110
+ };
111
+
112
+ // The bundle `getFormFieldPropsAt(path)` returns for the field at that path.
113
+ export type FormFieldProps<V> = FieldBinding<V>;
114
+
93
115
  // What the hook publishes (via `useFormDebugger`) to its Debugger after
94
116
  // every commit. Snapshots are
95
117
  // replaced whole (never mutated) so `useSyncExternalStore` consumers can
@@ -99,11 +121,14 @@ export type FormDebugSnapshot<T extends FormValuesObject> = {
99
121
  errors: FormErrors<T>;
100
122
  isValid: boolean;
101
123
  submitAttempted: boolean;
124
+ touched: readonly Path<T>[];
102
125
  };
103
126
 
104
127
  export type FormHelpers<T extends FormValuesObject> = {
105
128
  values: T;
106
- // todo: likely remove once other setter are available
129
+ // Whole-value replacement. No longer the only write path (field-level
130
+ // writes go through `getFormFieldPropsAt(path).onChange`); whether this
131
+ // survives long-term is a separate decision.
107
132
  onValueChanges: (val: T | ((prev: T) => T)) => void;
108
133
  // Live-derived from the current values on every render; UIs that only want
109
134
  // errors after a submit attempt gate on `submitAttempted`.
@@ -111,6 +136,12 @@ export type FormHelpers<T extends FormValuesObject> = {
111
136
  isValid: boolean;
112
137
  submitAttempted: boolean;
113
138
  submit: () => void;
139
+ // One expression wires a field: value, typed onChange (an immutable write
140
+ // at the path), display-policy-aware errorMessage, and onBlur (touched
141
+ // tracking). `ValueAt<T, P>` types value/onChange end-to-end, so binding a
142
+ // wrong-shaped path to an element fails to compile at the element's
143
+ // `formFieldProps` prop.
144
+ getFormFieldPropsAt: <P extends Path<T>>(path: P) => FormFieldProps<ValueAt<T, P>>;
114
145
  // Dev-time introspection overlay bound to this form instance: a fixed
115
146
  // trigger that opens a window showing the form's live internal state.
116
147
  // Render it anywhere (it portals to <body>); omit it and nothing mounts.