@structuralists/scaffolding 0.10.0 → 0.10.2

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 (85) 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 +67 -31
  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}/Select/MultiSelect/MultiSelect.stories.tsx +2 -2
  17. package/src/{components/Forms → forms/elements}/Select/MultiSelect/index.tsx +1 -1
  18. package/src/{components/Forms → forms/elements}/Select/SingleSelect/SingleSelect.stories.tsx +3 -3
  19. package/src/{components/Forms → forms/elements}/Select/SingleSelect/index.tsx +1 -1
  20. package/src/forms/plan.md +42 -32
  21. package/src/forms/{useFormState → state/useFormState}/FormDebugger.tsx +3 -2
  22. package/src/forms/state/useFormState/deriveErrors.test.ts +76 -0
  23. package/src/forms/state/useFormState/deriveErrors.ts +35 -0
  24. package/src/forms/{useFormState → state/useFormState}/types.ts +2 -1
  25. package/src/forms/state/useFormState/useFormDebugger.test.tsx +57 -0
  26. package/src/forms/state/useFormState/useFormDebugger.ts +38 -0
  27. package/src/forms/{useFormState → state/useFormState}/useFormState.stories.tsx +4 -4
  28. package/src/forms/state/useFormState/useFormState.ts +69 -0
  29. package/src/forms/state/useFormState/useFormSubmit.test.tsx +86 -0
  30. package/src/forms/state/useFormState/useFormSubmit.ts +38 -0
  31. package/src/forms/{validations → state/validations}/walk.ts +3 -2
  32. package/src/index.ts +10 -10
  33. package/src/storybook/Composition.stories.tsx +4 -4
  34. package/src/storybook/_StoryUtils.stories.tsx +1 -1
  35. package/src/forms/useFormState/useFormState.ts +0 -109
  36. /package/src/{components/Forms → forms/elements}/Button/index.tsx +0 -0
  37. /package/src/{components/Forms → forms/elements}/Button/styles.module.css +0 -0
  38. /package/src/{components/Forms → forms/elements}/Button/types.ts +0 -0
  39. /package/src/{components/Forms → forms/elements}/ColorInput/index.tsx +0 -0
  40. /package/src/{components/Forms → forms/elements}/ColorInput/styles.module.css +0 -0
  41. /package/src/{components/Forms → forms/elements}/ColorInput/types.ts +0 -0
  42. /package/src/{components/Forms → forms/elements}/Field/Field.stories.tsx +0 -0
  43. /package/src/{components/Forms → forms/elements}/Field/index.tsx +0 -0
  44. /package/src/{components/Forms → forms/elements}/Field/styles.module.css +0 -0
  45. /package/src/{components/Forms → forms/elements}/Field/types.ts +0 -0
  46. /package/src/{components/Forms → forms/elements}/IconButton/IconButton.stories.tsx +0 -0
  47. /package/src/{components/Forms → forms/elements}/IconButton/styles.module.css +0 -0
  48. /package/src/{components/Forms → forms/elements}/IconButton/types.ts +0 -0
  49. /package/src/{components/Forms → forms/elements}/Input/Input.stories.tsx +0 -0
  50. /package/src/{components/Forms → forms/elements}/Input/index.tsx +0 -0
  51. /package/src/{components/Forms → forms/elements}/Input/styles.module.css +0 -0
  52. /package/src/{components/Forms → forms/elements}/Input/types.ts +0 -0
  53. /package/src/{components/Forms → forms/elements}/SearchInput/index.tsx +0 -0
  54. /package/src/{components/Forms → forms/elements}/SearchInput/styles.module.css +0 -0
  55. /package/src/{components/Forms → forms/elements}/SearchInput/types.ts +0 -0
  56. /package/src/{components/Forms → forms/elements}/Select/MultiSelect/types.ts +0 -0
  57. /package/src/{components/Forms → forms/elements}/Select/SingleSelect/types.ts +0 -0
  58. /package/src/{components/Forms → forms/elements}/Select/index.ts +0 -0
  59. /package/src/{components/Forms → forms/elements}/Select/internal/OptionList.tsx +0 -0
  60. /package/src/{components/Forms → forms/elements}/Select/internal/SelectTrigger.tsx +0 -0
  61. /package/src/{components/Forms → forms/elements}/Select/internal/styles.module.css +0 -0
  62. /package/src/{components/Forms → forms/elements}/Textarea/Textarea.stories.tsx +0 -0
  63. /package/src/{components/Forms → forms/elements}/Textarea/index.tsx +0 -0
  64. /package/src/{components/Forms → forms/elements}/Textarea/styles.module.css +0 -0
  65. /package/src/{components/Forms → forms/elements}/Textarea/types.ts +0 -0
  66. /package/src/forms/{path → state/path}/path.test.ts +0 -0
  67. /package/src/forms/{path → state/path}/path.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}/errorAt.test.ts +0 -0
  73. /package/src/forms/{useFormState → state/useFormState}/errorAt.ts +0 -0
  74. /package/src/forms/{useFormState → state/useFormState}/inspectable.test.ts +0 -0
  75. /package/src/forms/{useFormState → state/useFormState}/inspectable.ts +0 -0
  76. /package/src/forms/{useFormState → state/useFormState}/snapshotStore.test.ts +0 -0
  77. /package/src/forms/{useFormState → state/useFormState}/snapshotStore.ts +0 -0
  78. /package/src/forms/{useFormState → state/useFormState}/useFormState.test-d.ts +0 -0
  79. /package/src/forms/{useFormState → state/useFormState}/useFormState.test.tsx +0 -0
  80. /package/src/forms/{validations → state/validations}/perField.ts +0 -0
  81. /package/src/forms/{validations → state/validations}/types.test-d.ts +0 -0
  82. /package/src/forms/{validations → state/validations}/types.ts +0 -0
  83. /package/src/forms/{validations → state/validations}/walk.test.ts +0 -0
  84. /package/src/forms/{validators → state/validators}/validators.test.ts +0 -0
  85. /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 bridge runs
136
+ // the other way: state's FormDebugger imports the JsonTable barrel
137
+ // (dev tooling), and the item-6 form-aware wrappers will live with
138
+ // the state side. State stories may also use element barrels.
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.0",
3
+ "version": "0.10.2",
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,24 @@
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
+ The one sanctioned state→elements-side dependency is the Debugger bridge:
14
+ `state/useFormState/FormDebugger.tsx` imports the `JsonTable` barrel from
15
+ `src/components/Json/` (dev tooling). The item-6 form-aware wrappers will be
16
+ the second bridge, living on the state side.
17
+
18
+ The headline feature of the state layer is **validation that propagates type
19
+ refinements to the submit handler** — passing the right constraints means the
20
+ submit handler's `values` argument has a tighter type than the initial form
21
+ values.
7
22
 
8
23
  ## Mental model
9
24
 
@@ -185,7 +200,7 @@ exposed:
185
200
  errorAt(errors, ['email']); // string | undefined
186
201
  ```
187
202
 
188
- `errorAt` (useFormState/errorAt.ts) matches by exact structural step
203
+ `errorAt` (state/useFormState/errorAt.ts) matches by exact structural step
189
204
  equality (no prefix matching); with first-error-wins validation there is at
190
205
  most one entry per path, and if collect-all ever lands the first entry stays
191
206
  the one shown. The path-addressed model is what the recursive grammar
@@ -222,7 +237,7 @@ in three layers:
222
237
  1. **At the `useFormState` boundary**: an illegal form type makes the call
223
238
  fail with an unsatisfiable `'ERROR: form state disallows unions of
224
239
  objects/lists …'` property whose type names the offending keys
225
- (`UnionPolicyCheck<T>` in `useFormState/types.ts`).
240
+ (`UnionPolicyCheck<T>` in `state/useFormState/types.ts`).
226
241
  2. **`Path<T>` refuses to descend** into a disallowed union — the field
227
242
  stays addressable as a leaf (reading it yields the union, which is
228
243
  honest), but no paths below it exist, so `.at(…)`/path literals into it
@@ -292,46 +307,67 @@ precision end-to-end. So we wire it up before adding any consumers.
292
307
 
293
308
  ## Layout
294
309
 
295
- - `useFormState/` — the hook + the value-model types (`FormValueSimple`,
310
+ - `state/useFormState/` — the hook + the value-model types (`FormValueSimple`,
296
311
  `FormValuesObject`, `FormValueList`). Hook surface: `{ values,
297
- onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`;
298
- `errors` is the structured `FormErrors<T>` list (see "The error model"),
299
- live-derived from current values each render (validators are pure and
300
- cheap) with `isValid` its emptiness; `errorAt.ts` holds the typed lookup;
301
- `submitAttempted` lets UIs gate error display; and `submit()` performs
302
- the one honest *refinement* cast to `Refine<T, V>` earned because the
303
- validators just passed at runtime. The hook's error loop carries one
304
- further documented widening (`failure.path as Path<T>`, same species as
305
- its `Object.keys` cast): `[key]` is a valid single-key `Path<T>`, but TS
306
- cannot compute `Path<T>` for an unresolved generic `T` to see the
307
- correlation. `Debugger` is a per-instance
308
- dev-time overlay (fixed trigger, bottom-right, portaled to `<body>`) that
309
- opens a live `JsonTable` view of the form's internal state. Plumbing: the
310
- hook publishes a `FormDebugSnapshot` into a tiny `snapshotStore` after
311
- every commit; only an *open* debugger window subscribes
312
- (`useSyncExternalStore`), so an unused Debugger costs ~nothing. The
313
- component is created once per hook instance (lazy ref) — its identity must
314
- stay stable across renders or it would remount on every keystroke.
315
- `inspectable.ts` converts arrays/Sets to shapes `JsonTable` can render.
316
- - `path/` typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
312
+ onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`.
313
+ `useFormState.ts` itself is deliberately **plumbing only**: it holds the
314
+ values `useState` (a bare `useState` today; granular path-based setters
315
+ are what would earn values state a module of its own), links up the
316
+ modules below, and recomposes their outputs into `FormHelpers<T>`. Each
317
+ slice lives in its own file so it is independently comprehensible and
318
+ unit-testable at its own boundary:
319
+ - `types.ts` the value model, the union-policy gate
320
+ (`UnionPolicyCheck` and friends), the `FormError`/`FormErrors` model,
321
+ `FormDebugSnapshot`, `FormHelpers`.
322
+ - `deriveErrors.ts` `deriveFormErrors(values, constraints)`, the pure
323
+ (React-free) derivation of the structured `FormErrors<T>` list (see
324
+ "The error model"), re-run every render (validators are pure and
325
+ cheap); `isValid` is its emptiness, derived inline in the hook. Its
326
+ loop delegates per-entry semantics to `state/validations/walk.ts` and
327
+ carries two documented honest widenings: the `Object.keys` cast, and
328
+ `failure.path as Path<T>` `[key]` is a valid single-key `Path<T>`,
329
+ but TS cannot compute `Path<T>` for an unresolved generic `T` to see
330
+ the correlation. It also hosts the cast-free `FlatConstraintEntry`
331
+ assignment that polices grammar growth (see the `state/validations/` bullet).
332
+ - `errorAt.ts` — the typed error lookup.
333
+ - `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
334
+ (lets UIs gate error display) and the validity gate in front of
335
+ `onSubmit`; its `submit()` performs the one honest *refinement* cast
336
+ to `Refine<T, V>` — earned because the validators just passed at
337
+ runtime. `V` is not inferable from `onSubmit`'s parameter position,
338
+ so `useFormState` applies `<T, V>` explicitly.
339
+ - `useFormDebugger.ts` — debugger plumbing: creates one `snapshotStore`
340
+ + one Debugger component per hook instance (lazy ref — the component's
341
+ identity must stay stable across renders or it would remount, and lose
342
+ its open/closed state, on every keystroke) and publishes the
343
+ `FormDebugSnapshot` after every commit.
344
+ - `FormDebugger.tsx` / `snapshotStore.ts` / `inspectable.ts` — the
345
+ `Debugger` itself: a per-instance dev-time overlay (fixed trigger,
346
+ bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
347
+ of the form's internal state. Only an *open* debugger window
348
+ subscribes (`useSyncExternalStore`), so an unused Debugger costs
349
+ ~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
350
+ can render.
351
+ - `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
317
352
  Will be used for granular setters and for surfacing per-field
318
353
  errors/touched state. Coupled to the form value model intentionally.
319
354
  Union handling is governed by the "Union policy" section above; the
320
355
  policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
321
- `DisallowedFormUnion`) live with the value model in `useFormState/types.ts`.
322
- - `validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
356
+ `DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
357
+ - `state/validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
323
358
  `Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the runtime walk the
324
359
  hook delegates to (`validateEntry`). `Validations<T>` accepts bare
325
360
  `(val) => string | null` functions too — they simply narrow nothing.
326
361
  The walk's entry type (`FlatConstraintEntry`) is how the compiler polices
327
- the hook's error loop: `constraints[key]` is *assigned* to it, never cast,
362
+ the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
363
+ *assigned* to it, never cast,
328
364
  so a grammar form the walk doesn't understand (a nested spec, an `each`)
329
365
  is a compile error at the assignment — a cast there would silently accept
330
366
  new grammar and misinterpret it at runtime (e.g. call an array as a
331
367
  function). Keep it cast-free. The one widening cast inside `validateEntry`
332
368
  mirrors `allOf`'s part-call: honest contravariant widening of a single,
333
369
  already-normalized validator.
334
- - `validators/` — the standard-library validators (`notEmpty`, `minLength`,
370
+ - `state/validators/` — the standard-library validators (`notEmpty`, `minLength`,
335
371
  `matches`, `min`) plus `allOf`, which composes validators on one field and
336
372
  carries the union of the parts' refinements. `allOf` derives its input type
337
373
  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,
@@ -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';
package/src/forms/plan.md CHANGED
@@ -16,23 +16,25 @@ 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.
37
+ error model and the split; `Path`/`ValueAt` already validated. ← *current*
36
38
  6. **Type spike, then items 2/3/4** — the recursion risk zone. Throwaway
37
39
  `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
38
40
  any runtime work; three outcomes (works / slow / intractable) each with a
@@ -123,7 +125,7 @@ useFormState({
123
125
  ```ts
124
126
  type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
125
127
  // 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.
128
+ // excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
127
129
 
128
130
  type RefineField<F, C> =
129
131
  C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
@@ -189,7 +191,7 @@ multi-second check time:
189
191
  ## Runtime consequences (can't be dodged)
190
192
 
191
193
  - **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
192
- `path/path.ts`; share the traversal or keep them deliberately parallel.
194
+ `state/path/path.ts`; share the traversal or keep them deliberately parallel.
193
195
  - **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
194
196
  ✅ *Landed with working-order step 3* (on the flat baseline, single-key
195
197
  paths). Decided: errors become a plain list of structured entries,
@@ -199,7 +201,7 @@ multi-second check time:
199
201
  // errors: FormError<T>[] isValid: errors.length === 0
200
202
  ```
201
203
 
202
- reusing `path/`'s typed representation (this is the "surfacing per-field
204
+ reusing `state/path/`'s typed representation (this is the "surfacing per-field
203
205
  errors" role path/ was built for). **We will never expose
204
206
  `'drivers.0.name'`-style serialized-string key derivation.** At the scale
205
207
  these forms operate, a linear scan over `{path, error}[]` is fine. If fast
@@ -208,7 +210,7 @@ multi-second check time:
208
210
  fully encapsulated and opaque to everything outside it.
209
211
  - **`errors` display wiring** in stories/components goes through a typed
210
212
  accessor, never through hand-assembled keys. ✅ *Decided and landed with
211
- step 3*: a standalone `errorAt(errors, path)` (useFormState/errorAt.ts) —
213
+ step 3*: a standalone `errorAt(errors, path)` (state/useFormState/errorAt.ts) —
212
214
  structural step-equality lookup, first entry wins. Chosen over a
213
215
  cursor-based lookup as the smallest surface that keeps stories readable
214
216
  on the flat grammar; item 6's `errorMessage` reuses it internally.
@@ -228,7 +230,7 @@ tests, story updates where visible, probe ratchet.
228
230
  marker computation. ✅ *done* — plus a byproduct: `RefineField`
229
231
  distributes over a naked constraint parameter, which made union-typed
230
232
  *single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
231
- refinements, pinned in `validations/types.test-d.ts`).
233
+ refinements, pinned in `state/validations/types.test-d.ts`).
232
234
  2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
233
235
  ratchet matters most here. (The `{path, error}[]` error model already
234
236
  landed in working-order step 3 — nested errors just carry longer paths;
@@ -240,30 +242,38 @@ tests, story updates where visible, probe ratchet.
240
242
  `perField` still the entry point for pre-built specs, docs
241
243
  (forms/CLAUDE.md) updated to the new grammar.
242
244
 
243
- ## 5. Split forms into 'form elements' and 'form state'
245
+ ## 5. Split forms into 'form elements' and 'form state' ✅ done
244
246
 
245
- Today the form world lives in two places with unrelated names:
247
+ The form world used to live in two places with unrelated names
248
+ (`src/components/Forms/` vs `src/forms/` — an accidental `Forms`-vs-`forms`
249
+ distinction). The layout decision landed on **one `src/forms/` umbrella with
250
+ two named subtrees**:
246
251
 
247
- - `src/components/Forms/` — the presentational **elements** (`Input`,
248
- `Field`, `Select`, `Button`, `Textarea`, …)
249
- - `src/forms/` — the **state** layer (`useFormState`, `validations/`,
252
+ - `src/forms/elements/` — the presentational **elements** (`Input`,
253
+ `Field`, `Select`, `Button`, `Textarea`, …), one primitive per folder,
254
+ same shape as `src/components/<Section>/<Component>/`
255
+ - `src/forms/state/` — the **state** layer (`useFormState/`, `validations/`,
250
256
  `validators/`, `path/`)
251
257
 
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.
258
+ Blast radius that was handled when this landed:
259
+
260
+ - the eslint `boundaries` element patterns in `eslint.config.mjs` (were keyed
261
+ to `src/components/<Section>/<Component>/` shapes): element primitives got
262
+ matching patterns at their new home, `src/forms/state` became its own
263
+ element type, and two directional rules were added — elements/components
264
+ must not import the state layer, and state files importing a primitive go
265
+ through its barrel,
266
+ - barrel exports in `src/index.ts` (all the element components are public —
267
+ paths moved, exported surface unchanged),
268
+ - Storybook titles (`Forms/...` prefix) and story globs: titles kept as-is —
269
+ elements stay `Forms/<Component>`, the state story stays
270
+ `Forms/useFormState`; the story glob (`src/**/*.stories.*`) needed no
271
+ change,
272
+ - forms/CLAUDE.md and this plan's own paths,
273
+ - `FormDebugger.tsx` imports `JsonTable` from `src/components/Json/` — the
274
+ sanctioned state→elements bridge dependency (audit finding 3.3; the
275
+ Debugger is a designated bridge layer). Explicitly allowed for in the new
276
+ boundaries rules.
267
277
 
268
278
  ## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands
269
279
 
@@ -278,7 +288,7 @@ four decisions per field, all boilerplate. Target usage:
278
288
  ### `getFormFieldPropsAt(path)`
279
289
 
280
290
  A member of the hook's return value (it needs `values`, `errors`, and the
281
- setter), typed by the `path/` machinery:
291
+ setter), typed by the `state/path/` machinery:
282
292
 
283
293
  ```ts
284
294
  type FormFieldProps<V> = {
@@ -297,7 +307,7 @@ Consequences this pulls in deliberately:
297
307
 
298
308
  - **Granular setters via path arrive here** — this retires the "likely
299
309
  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`.
310
+ is the immutable-update mirror of `read()` in `state/path/path.ts`.
301
311
  - **Touched tracking becomes real state** (`onBlur` feeds it). The
302
312
  error-display policy (submitAttempted / touched gating) moves *inside*
303
313
  `errorMessage`, so consuming elements render what they're given and stay
@@ -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';
@@ -16,7 +16,8 @@ export type FormDebuggerProps = {
16
16
  export type FormDebuggerComponent = (props: FormDebuggerProps) => ReactElement;
17
17
 
18
18
  // Builds the per-hook-instance Debugger component. Called once per
19
- // `useFormState` instance (from a lazy ref) so the returned component's
19
+ // `useFormState` instance (from `useFormDebugger`'s lazy ref) so the
20
+ // returned component's
20
21
  // identity is stable across renders — a component recreated each render
21
22
  // would remount, and lose its open/closed state, on every keystroke.
22
23
  export const createFormDebugger = <T extends FormValuesObject>(