@structuralists/scaffolding 0.10.1 → 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 (84) 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 +29 -14
  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 +1 -1
  22. package/src/forms/{useFormState → state/useFormState}/useFormState.stories.tsx +4 -4
  23. package/src/index.ts +10 -10
  24. package/src/storybook/Composition.stories.tsx +4 -4
  25. package/src/storybook/_StoryUtils.stories.tsx +1 -1
  26. /package/src/{components/Forms → forms/elements}/Button/index.tsx +0 -0
  27. /package/src/{components/Forms → forms/elements}/Button/styles.module.css +0 -0
  28. /package/src/{components/Forms → forms/elements}/Button/types.ts +0 -0
  29. /package/src/{components/Forms → forms/elements}/ColorInput/index.tsx +0 -0
  30. /package/src/{components/Forms → forms/elements}/ColorInput/styles.module.css +0 -0
  31. /package/src/{components/Forms → forms/elements}/ColorInput/types.ts +0 -0
  32. /package/src/{components/Forms → forms/elements}/Field/Field.stories.tsx +0 -0
  33. /package/src/{components/Forms → forms/elements}/Field/index.tsx +0 -0
  34. /package/src/{components/Forms → forms/elements}/Field/styles.module.css +0 -0
  35. /package/src/{components/Forms → forms/elements}/Field/types.ts +0 -0
  36. /package/src/{components/Forms → forms/elements}/IconButton/IconButton.stories.tsx +0 -0
  37. /package/src/{components/Forms → forms/elements}/IconButton/styles.module.css +0 -0
  38. /package/src/{components/Forms → forms/elements}/IconButton/types.ts +0 -0
  39. /package/src/{components/Forms → forms/elements}/Input/Input.stories.tsx +0 -0
  40. /package/src/{components/Forms → forms/elements}/Input/index.tsx +0 -0
  41. /package/src/{components/Forms → forms/elements}/Input/styles.module.css +0 -0
  42. /package/src/{components/Forms → forms/elements}/Input/types.ts +0 -0
  43. /package/src/{components/Forms → forms/elements}/SearchInput/index.tsx +0 -0
  44. /package/src/{components/Forms → forms/elements}/SearchInput/styles.module.css +0 -0
  45. /package/src/{components/Forms → forms/elements}/SearchInput/types.ts +0 -0
  46. /package/src/{components/Forms → forms/elements}/Select/MultiSelect/types.ts +0 -0
  47. /package/src/{components/Forms → forms/elements}/Select/SingleSelect/types.ts +0 -0
  48. /package/src/{components/Forms → forms/elements}/Select/index.ts +0 -0
  49. /package/src/{components/Forms → forms/elements}/Select/internal/OptionList.tsx +0 -0
  50. /package/src/{components/Forms → forms/elements}/Select/internal/SelectTrigger.tsx +0 -0
  51. /package/src/{components/Forms → forms/elements}/Select/internal/styles.module.css +0 -0
  52. /package/src/{components/Forms → forms/elements}/Textarea/Textarea.stories.tsx +0 -0
  53. /package/src/{components/Forms → forms/elements}/Textarea/index.tsx +0 -0
  54. /package/src/{components/Forms → forms/elements}/Textarea/styles.module.css +0 -0
  55. /package/src/{components/Forms → forms/elements}/Textarea/types.ts +0 -0
  56. /package/src/forms/{path → state/path}/path.test.ts +0 -0
  57. /package/src/forms/{path → state/path}/path.ts +0 -0
  58. /package/src/forms/{path → state/path}/types.test-d.ts +0 -0
  59. /package/src/forms/{path → state/path}/types.ts +0 -0
  60. /package/src/forms/{useFormState → state/useFormState}/FormDebugger.module.css +0 -0
  61. /package/src/forms/{useFormState → state/useFormState}/FormDebugger.test.tsx +0 -0
  62. /package/src/forms/{useFormState → state/useFormState}/deriveErrors.test.ts +0 -0
  63. /package/src/forms/{useFormState → state/useFormState}/deriveErrors.ts +0 -0
  64. /package/src/forms/{useFormState → state/useFormState}/errorAt.test.ts +0 -0
  65. /package/src/forms/{useFormState → state/useFormState}/errorAt.ts +0 -0
  66. /package/src/forms/{useFormState → state/useFormState}/inspectable.test.ts +0 -0
  67. /package/src/forms/{useFormState → state/useFormState}/inspectable.ts +0 -0
  68. /package/src/forms/{useFormState → state/useFormState}/snapshotStore.test.ts +0 -0
  69. /package/src/forms/{useFormState → state/useFormState}/snapshotStore.ts +0 -0
  70. /package/src/forms/{useFormState → state/useFormState}/types.ts +0 -0
  71. /package/src/forms/{useFormState → state/useFormState}/useFormDebugger.test.tsx +0 -0
  72. /package/src/forms/{useFormState → state/useFormState}/useFormDebugger.ts +0 -0
  73. /package/src/forms/{useFormState → state/useFormState}/useFormState.test-d.ts +0 -0
  74. /package/src/forms/{useFormState → state/useFormState}/useFormState.test.tsx +0 -0
  75. /package/src/forms/{useFormState → state/useFormState}/useFormState.ts +0 -0
  76. /package/src/forms/{useFormState → state/useFormState}/useFormSubmit.test.tsx +0 -0
  77. /package/src/forms/{useFormState → state/useFormState}/useFormSubmit.ts +0 -0
  78. /package/src/forms/{validations → state/validations}/perField.ts +0 -0
  79. /package/src/forms/{validations → state/validations}/types.test-d.ts +0 -0
  80. /package/src/forms/{validations → state/validations}/types.ts +0 -0
  81. /package/src/forms/{validations → state/validations}/walk.test.ts +0 -0
  82. /package/src/forms/{validations → state/validations}/walk.ts +0 -0
  83. /package/src/forms/{validators → state/validators}/validators.test.ts +0 -0
  84. /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.1",
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,7 +307,7 @@ 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
312
  onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`.
298
313
  `useFormState.ts` itself is deliberately **plumbing only**: it holds the
@@ -308,12 +323,12 @@ precision end-to-end. So we wire it up before adding any consumers.
308
323
  (React-free) derivation of the structured `FormErrors<T>` list (see
309
324
  "The error model"), re-run every render (validators are pure and
310
325
  cheap); `isValid` is its emptiness, derived inline in the hook. Its
311
- loop delegates per-entry semantics to `validations/walk.ts` and
326
+ loop delegates per-entry semantics to `state/validations/walk.ts` and
312
327
  carries two documented honest widenings: the `Object.keys` cast, and
313
328
  `failure.path as Path<T>` — `[key]` is a valid single-key `Path<T>`,
314
329
  but TS cannot compute `Path<T>` for an unresolved generic `T` to see
315
330
  the correlation. It also hosts the cast-free `FlatConstraintEntry`
316
- assignment that polices grammar growth (see the `validations/` bullet).
331
+ assignment that polices grammar growth (see the `state/validations/` bullet).
317
332
  - `errorAt.ts` — the typed error lookup.
318
333
  - `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
319
334
  (lets UIs gate error display) and the validity gate in front of
@@ -333,18 +348,18 @@ precision end-to-end. So we wire it up before adding any consumers.
333
348
  subscribes (`useSyncExternalStore`), so an unused Debugger costs
334
349
  ~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
335
350
  can render.
336
- - `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
351
+ - `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
337
352
  Will be used for granular setters and for surfacing per-field
338
353
  errors/touched state. Coupled to the form value model intentionally.
339
354
  Union handling is governed by the "Union policy" section above; the
340
355
  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>`,
356
+ `DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
357
+ - `state/validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
343
358
  `Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the runtime walk the
344
359
  hook delegates to (`validateEntry`). `Validations<T>` accepts bare
345
360
  `(val) => string | null` functions too — they simply narrow nothing.
346
361
  The walk's entry type (`FlatConstraintEntry`) is how the compiler polices
347
- the error loop in `useFormState/deriveErrors.ts`: `constraints[key]` is
362
+ the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
348
363
  *assigned* to it, never cast,
349
364
  so a grammar form the walk doesn't understand (a nested spec, an `each`)
350
365
  is a compile error at the assignment — a cast there would silently accept
@@ -352,7 +367,7 @@ precision end-to-end. So we wire it up before adding any consumers.
352
367
  function). Keep it cast-free. The one widening cast inside `validateEntry`
353
368
  mirrors `allOf`'s part-call: honest contravariant widening of a single,
354
369
  already-normalized validator.
355
- - `validators/` — the standard-library validators (`notEmpty`, `minLength`,
370
+ - `state/validators/` — the standard-library validators (`notEmpty`, `minLength`,
356
371
  `matches`, `min`) plus `allOf`, which composes validators on one field and
357
372
  carries the union of the parts' refinements. `allOf` derives its input type
358
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';
@@ -5,10 +5,10 @@ import { useFormState } from './useFormState';
5
5
  import { errorAt } from './errorAt';
6
6
  import type { FormErrors } from './types';
7
7
  import { matches, minLength, notEmpty } from '../validators/validators';
8
- import { Field } from '../../components/Forms/Field';
9
- import { Input } from '../../components/Forms/Input';
10
- import { Button } from '../../components/Forms/Button';
11
- import { SingleSelect } from '../../components/Forms/Select';
8
+ import { Field } from '../../elements/Field';
9
+ import { Input } from '../../elements/Input';
10
+ import { Button } from '../../elements/Button';
11
+ import { SingleSelect } from '../../elements/Select';
12
12
 
13
13
  const meta: Meta = {
14
14
  title: 'Forms/useFormState',
package/src/index.ts CHANGED
@@ -3,26 +3,26 @@ export { Heading, type HeadingProps } from './components/Content/Heading';
3
3
  export { Link, type LinkProps } from './components/Content/Link';
4
4
  export { Stack, type StackProps } from './components/Layout/Stack';
5
5
  export { Grid, type GridProps } from './components/Layout/Grid';
6
- export { Button, type ButtonProps } from './components/Forms/Button';
6
+ export { Button, type ButtonProps } from './forms/elements/Button';
7
7
  export {
8
8
  IconButton,
9
9
  type IconButtonProps,
10
10
  type IconButtonVariant,
11
11
  type IconButtonSize,
12
- } from './components/Forms/IconButton';
12
+ } from './forms/elements/IconButton';
13
13
  export {
14
14
  Tooltip,
15
15
  type TooltipProps,
16
16
  type TooltipPlacement,
17
17
  } from './components/Overlays/Tooltip';
18
- export { Input, type InputProps } from './components/Forms/Input';
19
- export { SearchInput, type SearchInputProps } from './components/Forms/SearchInput';
20
- export { ColorInput, type ColorInputProps } from './components/Forms/ColorInput';
21
- export { Textarea, type TextareaProps } from './components/Forms/Textarea';
22
- export { SingleSelect, type SingleSelectProps } from './components/Forms/Select';
23
- export { MultiSelect, type MultiSelectProps } from './components/Forms/Select';
24
- export type { SelectOption, SelectSize } from './components/Forms/Select';
25
- export { Field, type FieldProps } from './components/Forms/Field';
18
+ export { Input, type InputProps } from './forms/elements/Input';
19
+ export { SearchInput, type SearchInputProps } from './forms/elements/SearchInput';
20
+ export { ColorInput, type ColorInputProps } from './forms/elements/ColorInput';
21
+ export { Textarea, type TextareaProps } from './forms/elements/Textarea';
22
+ export { SingleSelect, type SingleSelectProps } from './forms/elements/Select';
23
+ export { MultiSelect, type MultiSelectProps } from './forms/elements/Select';
24
+ export type { SelectOption, SelectSize } from './forms/elements/Select';
25
+ export { Field, type FieldProps } from './forms/elements/Field';
26
26
  export { MediumModal, LargeModal, ConfirmModal } from './components/Modals';
27
27
  export type { MediumModalProps, LargeModalProps, ConfirmModalProps } from './components/Modals';
28
28
  export { Divider } from './components/Layout/Divider';
@@ -1,12 +1,12 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useRef, useState } from 'react';
3
- import { Button } from '../components/Forms/Button';
4
- import { Field } from '../components/Forms/Field';
5
- import { Input } from '../components/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 { MediumModal } from '../components/Modals/MediumModal';
7
7
  import { Menu } from '../components/Content/Menu';
8
8
  import { Popover } from '../components/Overlays/Popover';
9
- import { SingleSelect } from '../components/Forms/Select/SingleSelect';
9
+ import { SingleSelect } from '../forms/elements/Select/SingleSelect';
10
10
  import { Stack } from '../components/Layout/Stack';
11
11
  import { Text } from '../components/Content/Text';
12
12
  import { Tooltip } from '../components/Overlays/Tooltip';
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Lorem, Placeholder, Toggle, Repeat } from './index';
3
- import { Button } from '../components/Forms/Button';
3
+ import { Button } from '../forms/elements/Button';
4
4
  import { Stack } from '../components/Layout/Stack';
5
5
  import { Text } from '../components/Content/Text';
6
6
  import { MediumModal } from '../components/Modals';
File without changes
File without changes
File without changes
File without changes