@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/eslint.config.mjs CHANGED
@@ -7,8 +7,11 @@ import tsParser from '@typescript-eslint/parser';
7
7
  // Component primitives live under src/components/<Section>/<Component>/.
8
8
  // "Umbrella sections" (Modals, Chat) are themselves the primitive — their
9
9
  // sub-folders share internals and must be reached through the section barrel.
10
- // Plain sections (Layout, Content, Forms, ...) treat each component folder as
11
- // its own primitive.
10
+ // Plain sections (Layout, Content, ...) treat each component folder as its
11
+ // own primitive. Form elements live under src/forms/elements/<Component>/ and
12
+ // follow the same per-folder primitive shape; the form-state layer
13
+ // (src/forms/state/) is one element with no barrel — its modules import each
14
+ // other file-directly.
12
15
  //
13
16
  // Three element types so rules can distinguish roles inside a primitive:
14
17
  // - primitive-entry → the barrel (`<primitive>/index.{ts,tsx}`)
@@ -33,6 +36,34 @@ export default [{
33
36
  },
34
37
  'boundaries/include': ['src/**/*'],
35
38
  'boundaries/elements': [
39
+ // Form-element primitives (src/forms/elements/<Component>/) — same
40
+ // shape and rules as plain-section component primitives.
41
+ {
42
+ type: 'primitive-entry',
43
+ pattern: 'src/forms/elements/*/index.{ts,tsx}',
44
+ mode: 'full',
45
+ capture: ['name'],
46
+ },
47
+ {
48
+ type: 'primitive-dogfood',
49
+ pattern: 'src/forms/elements/*/**/*.{stories,test}.{ts,tsx}',
50
+ mode: 'full',
51
+ capture: ['name'],
52
+ },
53
+ {
54
+ type: 'primitive',
55
+ pattern: 'src/forms/elements/*',
56
+ mode: 'folder',
57
+ capture: ['name'],
58
+ },
59
+ // The form-state layer, as a single element. No barrel: its modules
60
+ // (useFormState, validations, validators, path) import each other
61
+ // file-directly by design.
62
+ {
63
+ type: 'forms-state',
64
+ pattern: 'src/forms/state',
65
+ mode: 'folder',
66
+ },
36
67
  // Plain-section component primitives (depth-3 under src/components)
37
68
  {
38
69
  type: 'primitive-entry',
@@ -100,6 +131,29 @@ export default [{
100
131
  message:
101
132
  "Files inside a primitive folder must not import their own index barrel; import the neighbor directly.",
102
133
  },
134
+ // Presentational primitives (components and form elements) must
135
+ // not reach into the form-state layer. The sanctioned bridges run
136
+ // the other way: state's FormDebugger imports the JsonTable barrel
137
+ // (dev tooling), and the form-aware wrappers live on the state
138
+ // side (state/bindings/). State stories may also use element barrels.
139
+ {
140
+ from: [
141
+ { type: 'primitive' },
142
+ { type: 'primitive-entry' },
143
+ { type: 'primitive-dogfood' },
144
+ ],
145
+ disallow: [{ to: { type: 'forms-state' } }],
146
+ message:
147
+ 'Form elements and components must not import the form-state layer; the bridge layer lives on the state side.',
148
+ },
149
+ // When state files import a component/element primitive (the
150
+ // sanctioned bridge direction), they go through its barrel like
151
+ // any other external importer.
152
+ {
153
+ from: [{ type: 'forms-state' }],
154
+ disallow: [{ to: { type: 'primitive' } }],
155
+ message: "External imports must go through the folder's index barrel.",
156
+ },
103
157
  ],
104
158
  },
105
159
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -3,10 +3,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import { ChatRecipientsHeader } from './index';
4
4
  import type { PillComboboxOption } from '../PillCombobox/types';
5
5
  import { Stack } from '../../Layout/Stack';
6
- import { Textarea } from '../../Forms/Textarea';
7
- import { Input } from '../../Forms/Input';
8
- import { Field } from '../../Forms/Field';
9
- import { Button } from '../../Forms/Button';
6
+ import { Textarea } from '../../../forms/elements/Textarea';
7
+ import { Input } from '../../../forms/elements/Input';
8
+ import { Field } from '../../../forms/elements/Field';
9
+ import { Button } from '../../../forms/elements/Button';
10
10
  import { MediumModal } from '../../Modals';
11
11
  import { Toggle } from '../../../storybook';
12
12
 
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Bar } from './index';
3
- import { Button } from '../../Forms/Button';
3
+ import { Button } from '../../../forms/elements/Button';
4
4
  import { Debug } from '../Debug';
5
5
 
6
6
  const meta: Meta<typeof Bar> = {
@@ -2,7 +2,7 @@ import { useState } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import { Panels } from './index';
4
4
  import { Bar } from '../Bar';
5
- import { Button } from '../../Forms/Button';
5
+ import { Button } from '../../../forms/elements/Button';
6
6
  import { Text } from '../../Content/Text';
7
7
  import { Heading } from '../../Content/Heading';
8
8
  import { Stack } from '../Stack';
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Stack } from './index';
3
- import { Button } from '../../Forms/Button';
3
+ import { Button } from '../../../forms/elements/Button';
4
4
 
5
5
  const meta: Meta<typeof Stack> = {
6
6
  title: 'Layout/Stack',
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { LargeModal } from './index';
3
- import { Button } from '../../Forms/Button';
3
+ import { Button } from '../../../forms/elements/Button';
4
4
  import { Stack } from '../../Layout/Stack';
5
5
  import { Text } from '../../Content/Text';
6
6
  import { Lorem, Toggle } from '../../../storybook';
@@ -1,8 +1,8 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { MediumModal } from './index';
3
- import { Button } from '../../Forms/Button';
4
- import { Field } from '../../Forms/Field';
5
- import { Input } from '../../Forms/Input';
3
+ import { Button } from '../../../forms/elements/Button';
4
+ import { Field } from '../../../forms/elements/Field';
5
+ import { Input } from '../../../forms/elements/Input';
6
6
  import { Stack } from '../../Layout/Stack';
7
7
  import { Text } from '../../Content/Text';
8
8
  import { Lorem, Toggle } from '../../../storybook';
@@ -1,4 +1,4 @@
1
- import { IconButton } from '../../Forms/IconButton';
1
+ import { IconButton } from '../../../forms/elements/IconButton';
2
2
  import styles from './styles.module.css';
3
3
 
4
4
  type Props = {
@@ -1,9 +1,9 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useRef, useState } from 'react';
3
3
  import { Popover } from './index';
4
- import { Button } from '../../Forms/Button';
5
- import { Input } from '../../Forms/Input';
6
- import { Field } from '../../Forms/Field';
4
+ import { Button } from '../../../forms/elements/Button';
5
+ import { Input } from '../../../forms/elements/Input';
6
+ import { Field } from '../../../forms/elements/Field';
7
7
  import { Text } from '../../Content/Text';
8
8
  const meta: Meta<typeof Popover> = {
9
9
  title: 'Overlays/Popover',
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Tooltip } from './index';
3
3
  import type { TooltipPlacement } from './types';
4
- import { Button } from '../../Forms/Button';
4
+ import { Button } from '../../../forms/elements/Button';
5
5
 
6
6
  const meta: Meta<typeof Tooltip> = {
7
7
  title: 'Overlays/Tooltip',
@@ -17,7 +17,7 @@ import { LinedStack } from '../../Primitives/LinedStack';
17
17
  import { LongText } from '../../Primitives/LongText';
18
18
  import { Panels } from '../../Layout/Panels';
19
19
  import { Bar } from '../../Layout/Bar';
20
- import { Button } from '../../Forms/Button';
20
+ import { Button } from '../../../forms/elements/Button';
21
21
  import { Heading } from '../../Content/Heading';
22
22
  import { Stack } from '../../Layout/Stack';
23
23
 
@@ -1,9 +1,25 @@
1
1
  # src/forms
2
2
 
3
- Strongly-typed React form state hook. The headline feature is **validation that
4
- propagates type refinements to the submit handler** — passing the right
5
- constraints means the submit handler's `values` argument has a tighter type
6
- than the initial form values.
3
+ The forms umbrella, split into two named subtrees:
4
+
5
+ - `elements/` the presentational form elements (`Input`, `Field`, `Select`,
6
+ `Button`, `Textarea`, …), one component primitive per folder, same shape
7
+ and eslint-boundaries rules as `src/components/<Section>/<Component>/`.
8
+ Elements are state-free: they must not import the state layer (enforced by
9
+ eslint `boundaries`).
10
+ - `state/` — the strongly-typed React form state hook and its machinery.
11
+ Everything below this heading is about the state layer.
12
+
13
+ Two sanctioned state→elements-side bridges exist, both on the state side:
14
+ the Debugger (`state/useFormState/FormDebugger.tsx` imports the `JsonTable`
15
+ barrel from `src/components/Json/`, dev tooling) and the form-aware element
16
+ wrappers (`state/bindings/` — see "Field binding" below). State files
17
+ importing an element go through its barrel like any external importer.
18
+
19
+ The headline feature of the state layer is **validation that propagates type
20
+ refinements to the submit handler** — passing the right constraints means the
21
+ submit handler's `values` argument has a tighter type than the initial form
22
+ values.
7
23
 
8
24
  ## Mental model
9
25
 
@@ -185,12 +201,75 @@ exposed:
185
201
  errorAt(errors, ['email']); // string | undefined
186
202
  ```
187
203
 
188
- `errorAt` (useFormState/errorAt.ts) matches by exact structural step
189
- equality (no prefix matching); with first-error-wins validation there is at
190
- most one entry per path, and if collect-all ever lands the first entry stays
191
- the one shown. The path-addressed model is what the recursive grammar
192
- (errors just carry longer paths) and `getFormFieldPropsAt`'s `errorMessage`
193
- build on.
204
+ `errorAt` (state/useFormState/errorAt.ts) matches by exact structural step
205
+ equality (`pathsEqual` in state/path/path.ts — no prefix matching); with
206
+ first-error-wins validation there is at most one entry per path, and if
207
+ collect-all ever lands the first entry stays the one shown. The
208
+ path-addressed model is what the recursive grammar (errors just carry
209
+ longer paths) and `getFormFieldPropsAt`'s `errorMessage` build on. `errorAt`
210
+ is raw truth (no display gating); `errorMessage` is the display-policy-aware
211
+ reading (see "Field binding").
212
+
213
+ ## Field binding: `getFormFieldPropsAt(path)` + `state/bindings/`
214
+
215
+ One expression wires a field to the form:
216
+
217
+ ```tsx
218
+ <TextInputForForm label="City"
219
+ formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])} />
220
+ ```
221
+
222
+ `getFormFieldPropsAt` is a member of the hook's return value, typed by the
223
+ `state/path/` machinery:
224
+
225
+ ```ts
226
+ // on FormHelpers<T>:
227
+ getFormFieldPropsAt<P extends Path<T>>(path: P): FormFieldProps<ValueAt<T, P>>;
228
+
229
+ type FormFieldProps<V> = FieldBinding<V>;
230
+ type FieldBinding<Display, Emit = Display> = {
231
+ value: Display; // read(values, path)
232
+ onChange: (val: Emit) => void; // immutable write(values, path, val)
233
+ errorMessage: string | undefined; // display-policy-aware (below)
234
+ onBlur: () => void; // feeds touched tracking
235
+ };
236
+ ```
237
+
238
+ The pieces, and where each one's semantics live:
239
+
240
+ - **Granular path writes** — `write()` in `state/path/path.ts`, the
241
+ immutable-update mirror of `read()`: clones only the spine to the written
242
+ leaf, so untouched siblings keep identity. Dead-step semantics mirror
243
+ read() returning undefined: writing through an absent/null ancestor, a
244
+ shape mismatch, or an out-of-bounds index is an identity-preserving no-op
245
+ — you can't edit a field of an absent section; materialize the section
246
+ first. (Appending/splicing lists is item-8 territory, not a path write.)
247
+ `onValueChanges` (whole-value replacement) still exists but is no longer
248
+ the only write path.
249
+ - **Touched tracking** — real state in `useFieldBinding`: a `Path<T>[]`
250
+ compared with `pathsEqual`, fed by `onBlur`, visible in the Debugger
251
+ snapshot. Commit-style elements (selects) call `onBlur` on commit — the
252
+ commit IS their blur moment.
253
+ - **THE error-display policy** lives inside `errorMessage` and nowhere
254
+ else: show a field's error once the field is touched OR a submit has been
255
+ attempted. Elements render what they're given and stay policy-free. A UI
256
+ wanting a different policy (e.g. always-live) reads `errors`/`errorAt`
257
+ directly.
258
+
259
+ ### The element wrappers (`state/bindings/`)
260
+
261
+ The **wrapper style** is the prototyped shorthand (per the plan's deciding
262
+ criterion — simplest for agents to work with): `TextInputForForm` and
263
+ `SingleSelectForForm` compose `Field` (label/hint/error presentation)
264
+ around the element and accept the bundle as one `formFieldProps` prop.
265
+ The wrapper declares what it can display and emit via
266
+ `FieldBinding<Display, Emit>` — e.g. the text input takes
267
+ `FieldBinding<string | null | undefined, string>` — and a
268
+ `FormFieldProps<V>` is assignable exactly when `Emit ⊆ V ⊆ Display`. That
269
+ one structural check is the end-to-end type safety: binding a number- or
270
+ boolean-typed path (or a literal-union field, for free text) to a
271
+ text-shaped element fails to compile at the `formFieldProps` prop, with no
272
+ generics at the use site.
194
273
 
195
274
  ## Union policy — what form state may hold
196
275
 
@@ -222,7 +301,7 @@ in three layers:
222
301
  1. **At the `useFormState` boundary**: an illegal form type makes the call
223
302
  fail with an unsatisfiable `'ERROR: form state disallows unions of
224
303
  objects/lists …'` property whose type names the offending keys
225
- (`UnionPolicyCheck<T>` in `useFormState/types.ts`).
304
+ (`UnionPolicyCheck<T>` in `state/useFormState/types.ts`).
226
305
  2. **`Path<T>` refuses to descend** into a disallowed union — the field
227
306
  stays addressable as a leaf (reading it yields the union, which is
228
307
  honest), but no paths below it exist, so `.at(…)`/path literals into it
@@ -292,12 +371,13 @@ precision end-to-end. So we wire it up before adding any consumers.
292
371
 
293
372
  ## Layout
294
373
 
295
- - `useFormState/` — the hook + the value-model types (`FormValueSimple`,
374
+ - `state/useFormState/` — the hook + the value-model types (`FormValueSimple`,
296
375
  `FormValuesObject`, `FormValueList`). Hook surface: `{ values,
297
- onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`.
376
+ onValueChanges, errors, isValid, submitAttempted, submit,
377
+ getFormFieldPropsAt, Debugger }`.
298
378
  `useFormState.ts` itself is deliberately **plumbing only**: it holds the
299
- values `useState` (a bare `useState` today; granular path-based setters
300
- are what would earn values state a module of its own), links up the
379
+ values `useState` (a bare `useState`; the granular path writes layer on
380
+ top of its setter via `useFieldBinding`), links up the
301
381
  modules below, and recomposes their outputs into `FormHelpers<T>`. Each
302
382
  slice lives in its own file so it is independently comprehensible and
303
383
  unit-testable at its own boundary:
@@ -308,13 +388,19 @@ precision end-to-end. So we wire it up before adding any consumers.
308
388
  (React-free) derivation of the structured `FormErrors<T>` list (see
309
389
  "The error model"), re-run every render (validators are pure and
310
390
  cheap); `isValid` is its emptiness, derived inline in the hook. Its
311
- loop delegates per-entry semantics to `validations/walk.ts` and
391
+ loop delegates per-entry semantics to `state/validations/walk.ts` and
312
392
  carries two documented honest widenings: the `Object.keys` cast, and
313
393
  `failure.path as Path<T>` — `[key]` is a valid single-key `Path<T>`,
314
394
  but TS cannot compute `Path<T>` for an unresolved generic `T` to see
315
395
  the correlation. It also hosts the cast-free `FlatConstraintEntry`
316
- assignment that polices grammar growth (see the `validations/` bullet).
396
+ assignment that polices grammar growth (see the `state/validations/` bullet).
317
397
  - `errorAt.ts` — the typed error lookup.
398
+ - `useFieldBinding.ts` — field binding: owns the touched list and builds
399
+ `getFormFieldPropsAt` (see "Field binding" above — the error-display
400
+ policy lives here). Two documented honest casts correlate `read()`/
401
+ `write()` results with `ValueAt<T, P>`/`T` for generic `T`; both are
402
+ truths the runtime upholds by construction, same family as `errorAt`'s
403
+ path widening.
318
404
  - `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
319
405
  (lets UIs gate error display) and the validity gate in front of
320
406
  `onSubmit`; its `submit()` performs the one honest *refinement* cast
@@ -333,18 +419,20 @@ precision end-to-end. So we wire it up before adding any consumers.
333
419
  subscribes (`useSyncExternalStore`), so an unused Debugger costs
334
420
  ~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
335
421
  can render.
336
- - `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
337
- Will be used for granular setters and for surfacing per-field
338
- errors/touched state. Coupled to the form value model intentionally.
422
+ - `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
423
+ `read()`), plus `write()` (the immutable-update mirror of `read()` behind
424
+ granular field writes) and `pathsEqual` (the one definition of structural
425
+ path equality — `errorAt` and touched tracking both use it). Coupled to
426
+ the form value model intentionally.
339
427
  Union handling is governed by the "Union policy" section above; the
340
428
  policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
341
- `DisallowedFormUnion`) live with the value model in `useFormState/types.ts`.
342
- - `validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
429
+ `DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
430
+ - `state/validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
343
431
  `Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the runtime walk the
344
432
  hook delegates to (`validateEntry`). `Validations<T>` accepts bare
345
433
  `(val) => string | null` functions too — they simply narrow nothing.
346
434
  The walk's entry type (`FlatConstraintEntry`) is how the compiler polices
347
- the error loop in `useFormState/deriveErrors.ts`: `constraints[key]` is
435
+ the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
348
436
  *assigned* to it, never cast,
349
437
  so a grammar form the walk doesn't understand (a nested spec, an `each`)
350
438
  is a compile error at the assignment — a cast there would silently accept
@@ -352,7 +440,10 @@ precision end-to-end. So we wire it up before adding any consumers.
352
440
  function). Keep it cast-free. The one widening cast inside `validateEntry`
353
441
  mirrors `allOf`'s part-call: honest contravariant widening of a single,
354
442
  already-normalized validator.
355
- - `validators/` — the standard-library validators (`notEmpty`, `minLength`,
443
+ - `state/bindings/` — the form-aware element wrappers (`TextInputForForm`,
444
+ `SingleSelectForForm`), the second sanctioned state→elements bridge (see
445
+ "Field binding" above). Element imports go through the element barrels.
446
+ - `state/validators/` — the standard-library validators (`notEmpty`, `minLength`,
356
447
  `matches`, `min`) plus `allOf`, which composes validators on one field and
357
448
  carries the union of the parts' refinements. `allOf` derives its input type
358
449
  as the intersection of the parts' inputs from the const tuple (not a
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Button } from './index';
3
- import { Stack } from '../../Layout/Stack';
3
+ import { Stack } from '../../../components/Layout/Stack';
4
4
 
5
5
  const meta: Meta<typeof Button> = {
6
6
  title: 'Forms/Button',
@@ -1,5 +1,5 @@
1
1
  import { cx } from '../../../utils';
2
- import { Tooltip } from '../../Overlays/Tooltip';
2
+ import { Tooltip } from '../../../components/Overlays/Tooltip';
3
3
  import type {
4
4
  IconButtonProps,
5
5
  IconButtonSize,
@@ -14,6 +14,7 @@ export const Input = (props: InputProps) => {
14
14
  value,
15
15
  defaultValue,
16
16
  onChange,
17
+ onBlur,
17
18
  placeholder,
18
19
  required,
19
20
  autoFocus,
@@ -29,6 +30,7 @@ export const Input = (props: InputProps) => {
29
30
  value={value}
30
31
  defaultValue={defaultValue}
31
32
  onChange={onChange}
33
+ onBlur={onBlur}
32
34
  placeholder={placeholder}
33
35
  required={required}
34
36
  autoFocus={autoFocus}
@@ -1,4 +1,4 @@
1
- import type { ChangeEventHandler } from 'react';
1
+ import type { ChangeEventHandler, FocusEventHandler } from 'react';
2
2
 
3
3
  export type InputSize = 'small' | 'medium';
4
4
  export type InputType = 'text' | 'email' | 'password';
@@ -9,6 +9,7 @@ export type InputProps = {
9
9
  value?: string;
10
10
  defaultValue?: string;
11
11
  onChange?: ChangeEventHandler<HTMLInputElement>;
12
+ onBlur?: FocusEventHandler<HTMLInputElement>;
12
13
  placeholder?: string;
13
14
  required?: boolean;
14
15
  autoFocus?: boolean;
@@ -2,8 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useState } from 'react';
3
3
  import { MultiSelect } from './index';
4
4
  import { Field } from '../../Field';
5
- import { Stack } from '../../../Layout/Stack';
6
- import { Text } from '../../../Content/Text';
5
+ import { Stack } from '../../../../components/Layout/Stack';
6
+ import { Text } from '../../../../components/Content/Text';
7
7
 
8
8
  const meta: Meta<typeof MultiSelect> = {
9
9
  title: 'Forms/MultiSelect',
@@ -1,5 +1,5 @@
1
1
  import { useRef, useState } from 'react';
2
- import { Popover } from '../../../Overlays/Popover';
2
+ import { Popover } from '../../../../components/Overlays/Popover';
3
3
  import { OptionList } from '../internal/OptionList';
4
4
  import { SelectTrigger } from '../internal/SelectTrigger';
5
5
  import type { MultiSelectProps } from './types';
@@ -3,9 +3,9 @@ import { useState } from 'react';
3
3
  import { SingleSelect } from './index';
4
4
  import { Button } from '../../Button';
5
5
  import { Field } from '../../Field';
6
- import { MediumModal } from '../../../Modals';
7
- import { Stack } from '../../../Layout/Stack';
8
- import { Text } from '../../../Content/Text';
6
+ import { MediumModal } from '../../../../components/Modals';
7
+ import { Stack } from '../../../../components/Layout/Stack';
8
+ import { Text } from '../../../../components/Content/Text';
9
9
  import { Toggle } from '../../../../storybook';
10
10
 
11
11
  const meta: Meta<typeof SingleSelect> = {
@@ -1,5 +1,5 @@
1
1
  import { useRef, useState } from 'react';
2
- import { Popover } from '../../../Overlays/Popover';
2
+ import { Popover } from '../../../../components/Overlays/Popover';
3
3
  import { OptionList } from '../internal/OptionList';
4
4
  import { SelectTrigger } from '../internal/SelectTrigger';
5
5
  import type { SingleSelectProps } from './types';