Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,434 @@
# OpenSpec Change: enterprise-react-forms-v2
- **Change ID**: `enterprise-react-forms-v2`
- **Package**: `@suaveplan/react-forms`
- **Category**: react
- **Target path**: `packages/forms/react-forms`
- **Status**: PROPOSED
- **Date**: 2026-05-05
- **Supersedes**: `openspec/changes/enterprise-react-forms/`
- **Master design**: `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` (§9, §11.2)
- **Prereqs (one SHIPPED, one in-flight)**:
- `enterprise-react-i18n` shipped 2026-05-05 — archived at `openspec/archive/2026-05-05-enterprise-react-i18n/`; capability spec at `openspec/specs/react-i18n/spec.md`. This change consumes `TranslationKey` for every `FieldConfig.label` / `placeholder` / `helperText` / `errorMessage`, `useTranslation` for label resolution at render time, `useErrorMessage` for `SuaveplanError`-coded validation failures, and the `ContributedTranslationKeys` augmentation pattern for default recipe button labels (`react-forms.wizard.next` etc.).
- `enterprise-react-ui-v2` (sibling, in-flight) — provides `Field` / `FieldGroup` / `FieldSet` / `InputGroup` / `Textarea` / `Checkbox` / `Switch` / `RadioGroup` / `Toggle` / `ToggleGroup` primitives the factory and recipes render onto. Wave 5b waits for Wave 3b before adding `@suaveplan/react-ui` as a peer dep (gate enforced via `scripts/lint/check-no-backwards-deps.ts` per-tier hierarchy).
---
## Why
The original `enterprise-react-forms` proposal bundled product-scope domain forms (`BankAccountForm`, `UserProfileForm`, `FriendRequestForm`, `GroupCreateForm`, `MessageComposeForm`, `ChangePasswordForm`, `TwoFactorForm`, `NotificationSettingsForm`, `PrivacySettingsForm`, `SiteConfigForm`, `SystemSettingsForm`, `NotificationPreferencesForm`) into the library. That conflates two layers: the **library** (primitives + composition machinery) and the **consumer's product** (the actual forms a particular app needs). The result is a package that nobody outside Suaveplan can adopt without inheriting our entity model, and that we ourselves cannot evolve without coordinating with every domain at once.
The user has approved a primitives + factory + recipes architecture instead. `@suaveplan/react-forms` v2 ships:
1. **Primitives** that wrap react-hook-form + react-ui controls into a tested, accessible, i18n'd surface.
2. **A `createDomainForm` factory** that takes a Zod schema + a typed field config and returns a working form component — without hiding react-hook-form. Three escape hatches (`children` render-prop, `<Form.Field>` static accessor, `useDomainForm()` hook) keep the abstraction transparent.
3. **Four recipes in v1** — `wizard`, `repeating-fieldset`, `conditional`, `async-search` — covering the multi-step, dynamic-array, dependent-field, and prefilled-edit patterns that every real app needs.
4. **A validation library** — synchronous validators (`required`, `minLength`, `maxLength`, `email`, `url`, `pattern`, `phone`, `postcode`, `creditCard`, `password-strength`, `iban`, `bic`, `custom`), async validators (`asyncEmailUnique`, `asyncUsernameAvailable`), and composers (`AND` / `OR` / `sequence`).
Domain-specific forms move out to consumer apps and to the example `@suaveplan/react-ui-pack-auth` (a separate change), which proves the factory contract end-to-end on register / login / password-reset / 2FA / magic-link.
The branded-`TranslationKey` discipline from §11.2 of the master design is the second non-negotiable: every user-facing string in a `FieldConfig` is typed as `TranslationKey`, never `string`. Validators return `messageKey: TranslationKey`. The CI gate (`scripts/lint/check-no-hardcoded-strings.ts`) catches literal regressions before they ship.
---
## Current-state audit (genesis)
The package does not exist on `genesis`. `STATUS.md` lists `react-forms` under `Pending`. There are no consumers waiting on a specific public surface yet — the example pack (`react-ui-pack-auth`) is the first consumer and will track this proposal.
---
## Available workspace deps on genesis
| Dep | Version source | Role |
|---|---|---|
| `@suaveplan/react-i18n` | `workspace:^` | `TranslationKey`, `useTranslation`, `<Trans>`, locale glue |
| `@suaveplan/react-ui` | `workspace:^` | `Field`, `FieldGroup`, `Input`, `Select`, `Combobox`, `Calendar`, `Button`, etc. |
| `@suaveplan/api-schemas` | `workspace:^` | Shared request / response Zod schemas the factory consumes via `schema:` |
| `@suaveplan/error` | `workspace:^` | `defineCodes("REACT_FORMS", [...])` registry; `ReactFormsError` |
| `@suaveplan/timing` | `workspace:^` | Debounce / dedup for async field validators |
| `@suaveplan/types` | `workspace:^` | `Clock`, `LoggerLike`, `StorageLike` (used by form persistence recipes) |
| `react-hook-form` | `catalog:forms` | Form state core; surfaced (never hidden) by the factory |
| `@hookform/resolvers` | `catalog:forms` | Zod resolver bridge into RHF |
| `zod` | `catalog:forms` | Schema spine |
| `date-fns` | `catalog:datetime` | Date / datetime field formatters |
| `@opentelemetry/api` | `catalog:otel` | `form.submit.duration_ms`, `form.validation.duration_ms`, `form.async-validator.duration_ms`, `form.error_total` |
External peer: `react@^18`. No new deps need to be added to the root catalog — every entry above already exists.
---
## Public API surface
### `primitives/`
`Field` (re-export from `@suaveplan/react-ui`, single source), `FieldGroup`, `AccessibleDatePicker`, `DateTimePicker`, `MoneyInput`, `SearchableSelect` (async), `TagInput`, `FileUploadField` (defers to `@suaveplan/react-file-upload` when present, falls back to native `<input type=file>`), `RichTextField` (defers to `@suaveplan/react-rich-text`), `CodeInput` (OTP / 2FA, wraps `InputOTP`).
Each primitive is a separate subpath export so consumers can tree-shake — e.g. `import { MoneyInput } from '@suaveplan/react-forms/primitives/money-input/money-input'`.
### `core/`
- `ApiClientContext` — `<ApiClientProvider value={api}>` + `useApiClient()`. The factory's `onSubmit` receives `api` from this context. Implementation-agnostic (consumer brings their own client).
- `AuthContext` — `<AuthProvider value={auth}>` + `useAuth()`. Surfaces `signIn` / `signOut` / `currentUser` so registration forms can auto-sign-in on success.
- `ValidationEngine` — combines a Zod schema, per-field async validators, cross-field rules, and a server-error mapper into a single resolver compatible with react-hook-form.
- `useFormValidation(schema, options)` — hook returning a memoised resolver from a `ValidationEngine`.
- `useDomainForm(config)` — the hook the factory wraps. Standalone-callable for consumers building bespoke forms that don't fit the factory's layout shape.
### `factory/`
- `createDomainForm(config)` — returns `ComponentType<FormProps>` plus a `.Field` static accessor and `.useFormContext` re-export.
- Field-kind registry typed via `FieldConfigByKind`; misconfigurations fail at module-evaluation time via Zod-validated `FieldConfig` schemas.
- Layout dispatcher (`vertical` / `horizontal` / `grid` / custom render fn).
### `recipes/`
- `createWizardForm({ steps, onComplete })` — multi-step form with per-step Zod sub-schema, navigation guards, and a `useWizardState()` hook.
- `createRepeatingFieldset({ minRows, maxRows, fieldset })` — dynamic-array rows backed by RHF's `useFieldArray`.
- `createConditionalField({ when, render })` — declarative show/hide based on other field values; participates in validation as a function of visibility.
- `createAsyncSearchForm({ load, schema, fields })` — async-prefilled forms (load entity by id, edit, save).
### `validation/`
- `validators/` — `required`, `minLength`, `maxLength`, `email`, `url`, `pattern`, `phone`, `postcode`, `creditCard`, `password-strength`, `iban`, `bic`, `custom`.
- `async/` — `asyncEmailUnique`, `asyncUsernameAvailable`. Each accepts `{ debounceMs, signal, dedupKey }`.
- `compose/` — `and(...validators)`, `or(...validators)`, `sequence(...validators)` (short-circuits on first failure).
All validators return `{ valid: true } | { valid: false; messageKey: TranslationKey; messageArgs?: Record<string, unknown> }`. The `messageKey` field is typed as `TranslationKey`, not `string` — passing a literal misses the translation pipeline and fails the CI no-hardcoded-strings gate.
---
## Design decisions
### 1. Five-layer surface
`primitives/ → core/ → factory/ → recipes/ → validation/`. Each layer compiles, tests, and ships independently; later layers consume earlier ones, never the reverse. Ports the master-design §9.1 layout verbatim.
### 2. The factory surfaces RHF — three escape hatches
`createDomainForm` is the 80%-case ergonomics layer. The other 20% of forms need direct access to the RHF context, partial layouts, or a completely custom shell. The three hatches:
- **`children` render-prop** — passing `children` with the render-prop signature gives the consumer raw `useFormContext()` access plus pre-rendered field elements keyed by name.
- **`<RegisterForm.Field name="email" />` static accessor** — for partial layouts where the consumer wants the factory's layout for some fields but inlines others into a custom JSX shape.
- **`useDomainForm()` hook** — the same hook the factory wraps, exported standalone. Consumers build their own component shell with full control over markup.
The factory never hides RHF; it only declares it. Consumers can always drop one layer down without losing their config.
### 3. `FieldConfig` strings are `TranslationKey`, never `string`
Every user-facing text property in `FieldConfig` is typed as `TranslationKey` (per master design §9.5.1 and §11.2). This is enforced at the type level:
```ts
export interface FieldConfigBase {
label: TranslationKey;
placeholder?: TranslationKey;
helperText?: TranslationKey;
errorMessage?: TranslationKey | { [code: string]: TranslationKey };
}
const RegisterForm = createDomainForm({
schema: RegisterRequestSchema,
fields: {
email: { kind: "email", label: "auth.email.label" }, // typechecks
name: { kind: "text", label: "Full name" satisfies string }, // TS error
},
// ...
});
```
The same constraint applies to `submit.label`, `submit.cancelLabel`, recipe step titles, and conditional-field gate labels.
### 4. `kind: 'custom'` is a first-class field kind
Captcha, signature pad, file dropzone with consumer-specific upload protocol — these are not escape hatches. They are first-class custom fields with full factory integration:
```ts
captcha: {
kind: "custom",
label: "auth.captcha.label",
render: ({ field, fieldState }) => <Captcha {...field} error={fieldState.error} />,
}
```
`kind: 'custom'` participates in layout, validation, error messaging, and submit wiring identically to built-in kinds. The factory exposes `field` (RHF `ControllerRenderProps`) and `fieldState` (RHF `ControllerFieldState`) to the render function.
### 5. All four recipes ship in v1
Splitting recipes across releases creates the "Phase 2 is unwritten" failure mode (`openspec/AGENTS.md` §2b). `wizard`, `repeating-fieldset`, `conditional`, `async-search` all ship in the v1 archive of this change. The factory's internal hook architecture is designed for all four from day one.
### 6. `ValidationEngine` semantics
The engine layers, in order, on every form mutation:
1. **Zod schema** — the spine. Synchronous; runs first.
2. **Cross-field rules** — e.g. `passwordConfirm matches password`. Synchronous; runs after the schema. Has access to the full form values object.
3. **Per-field async validators** — debounced via `@suaveplan/timing` (`debounce({ leading: false, trailing: true, maxWait })`), deduped by `(field, value)` key, cancelled via `AbortSignal` when the form unmounts or when a newer value supersedes the in-flight check.
4. **Server-error mapping** — when `onSubmit` throws a `ReactFormsError(REACT_FORMS.SERVER_VALIDATION)` carrying `{ field, code, messageArgs }`, the engine sets the corresponding RHF field error with the translated message resolved via `t(code, messageArgs)`.
`AbortSignal` threads through every async path. Switching to a different form mid-validation cancels in-flight checks instead of leaking promises.
### 7. Validator return shape is a discriminated union
`{ valid: true } | { valid: false; messageKey: TranslationKey; messageArgs?: Record<string, unknown> }`. Plain-string returns are a type error. The engine resolves `messageKey` against the active i18n instance at error-display time, so the same validator works in every locale with no additional wiring.
### 8. OTel-first observability
Every form lifecycle event emits structured telemetry (see § Telemetry below). Counter / histogram names are stable contracts — consumers can build dashboards against them.
### 9. Server Actions are out of scope
`onSubmit` stays a user-defined async function. RSC / Server Actions integration is a Wave 8+ follow-up — the surface is too unstable in React 19 to bake in now.
---
## What changes
### ADDED
```text
packages/forms/react-forms/
├── README.md
├── package.json
├── src/
│ ├── index.ts # explicit named exports only
│ ├── error-codes.ts # defineCodes("REACT_FORMS", [...])
│ ├── primitives/
│ │ ├── field/ # re-export from react-ui
│ │ ├── accessible-date-picker/
│ │ ├── date-time-picker/
│ │ ├── money-input/
│ │ ├── searchable-select/
│ │ ├── tag-input/
│ │ ├── file-upload-field/
│ │ ├── rich-text-field/
│ │ └── code-input/
│ ├── core/
│ │ ├── api-client-context/
│ │ ├── auth-context/
│ │ ├── validation-engine/
│ │ ├── use-form-validation/
│ │ └── use-domain-form/
│ ├── factory/
│ │ ├── create-domain-form/
│ │ ├── field-config/ # FieldConfig types + Zod schemas per kind
│ │ └── layout/ # vertical / horizontal / grid dispatchers
│ ├── recipes/
│ │ ├── wizard/
│ │ ├── repeating-fieldset/
│ │ ├── conditional/
│ │ └── async-search/
│ └── validation/
│ ├── validators/ # required, email, url, ...
│ ├── async/ # asyncEmailUnique, asyncUsernameAvailable
│ └── compose/ # and / or / sequence
├── e2e/
│ ├── factory.spec.ts
│ ├── recipes.spec.ts
│ └── a11y.spec.ts
├── tsconfig.json
├── tsconfig.lib.json
├── vite.config.ts
└── vitest.config.ts
```
### REMOVED (vs the legacy `enterprise-react-forms` proposal)
Every domain form below is dropped from the package. Justification (applies uniformly): **product scope; belongs in consumer apps; replaced by the `createDomainForm` factory and the example `@suaveplan/react-ui-pack-auth` pack.**
- `auth/forms/` — `SignInForm`, `SignUpForm`, `ForgotPasswordForm`, `ResetPasswordForm`
- `bank/forms/` — `BankAccountForm`, `BankTransactionForm`
- `user/forms/` — `UserCreateForm`, `UserEditForm`, `OptimizedUserCreateForm`
- `friend/` — `FriendRequestForm`
- `group/forms/` — `GroupCreateForm`, `GroupEditForm`
- `message/forms/` — `MessageCreateForm`, `MessageEditForm`
- `notification/forms/` and `notifications/forms/` — `NotificationCreateForm`, `NotificationEditForm` (duplicate domains in upstream collapsed)
- `security/` — `ChangePasswordForm`, `TwoFactorForm` (re-emerge in `react-ui-pack-auth`)
- `settings/forms/` — `SettingCreateForm`, `SettingEditForm`, `NotificationSettingsForm`, `PrivacySettingsForm`
- `site/forms/` — `SiteCreateForm`, `SiteEditForm`, `SiteConfigForm`
- `system/forms/` — `SystemCreateForm`, `SystemEditForm`, `SystemSettingsForm`
- `demo/DemoApp.tsx` — example app, not library code
Also dropped from upstream: `core/components/FormProgressBar`, `FormSection`, `FormSkeleton`, `FormWizard`, `FormProvider` — superseded by the recipes (`wizard` recipe covers `FormWizard`; `react-ui` Skeleton covers `FormSkeleton`; layout is the factory's job). `shared/components/VirtualizedList`, `LazyFormSection`, `OptimizedFormField` — performance scaffolding belongs in `react-ui` or in consumer apps. `core/storage/` form persistence — moves to a `recipes/persistent-form` follow-up if demand surfaces. `security/csrf`, `security/rate-limiting`, `security/sanitization` — cross-cutting concerns that belong in `@suaveplan/api-client` or the consumer's transport layer, not in a forms package.
---
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `Field` primitive | ✅ | ✅ | Re-exported from `@suaveplan/react-ui` to keep one source of truth |
| `AccessibleDatePicker` | ✅ | ✅ | Calendar + ARIA wired via react-ui `Calendar` |
| `DateTimePicker` | ✅ | ✅ | Time-of-day component composes date + time inputs |
| `MoneyInput` | ⬆️ | ⬆️ | Enhanced: locale-aware via `@suaveplan/i18n` formatters; upstream was hardcoded GBP |
| `SearchableSelect` (async) | ✅ | ⬆️ | Enhanced: debounce + dedup + AbortSignal via `@suaveplan/timing` |
| `TagInput` | ✅ | ✅ | |
| `FileUploadField` | ✅ | ✅ | Defers to `@suaveplan/react-file-upload` when present, native `<input>` fallback |
| `RichTextField` | ✅ | ✅ | Defers to `@suaveplan/react-rich-text` |
| `CodeInput` (OTP) | ✅ | ✅ | Wraps `react-ui` `InputOTP` |
| `ApiClientContext` | ✅ | ✅ | |
| `AuthContext` | ✅ | ✅ | |
| `TranslationContext` | ✅ | 🚫 | Replaced by `@suaveplan/react-i18n`'s `TranslationProvider`; forms package consumes that, doesn't redefine it |
| `useFormValidation` | ✅ | ✅ | Now returns a `ValidationEngine`-backed resolver |
| `ValidationEngine` | ✅ | ⬆️ | Enhanced: cross-field rules + AbortSignal-cancellable async + server-error mapping |
| `useFormSubmission` | ✅ | 🚫 | Replaced by the factory's `submit.onSubmit` callback + `useDomainForm()`'s `submit` return |
| `useFormPersistence` | ✅ | 🚫 | Deferred to a follow-up `recipes/persistent-form` (can be built on top of `useDomainForm` + `StorageLike` from `@suaveplan/types`) |
| `FormProvider` / `FormStateContext` | ✅ | 🚫 | RHF's `<FormProvider>` is sufficient; the factory wraps it |
| `FormProgressBar` | ✅ | 🚫 | Wizard recipe owns progress UI; consumers compose `react-ui` `Progress` if needed |
| `FormSection` | ✅ | 🚫 | Layout concern; the factory's layout dispatcher handles sections via `FieldGroup` |
| `FormSkeleton` | ✅ | 🚫 | `react-ui` Skeleton is the canonical source |
| `FormWizard` | ✅ | ⬆️ | Replaced by `createWizardForm` recipe (typed steps, sub-schemas, navigation guards) |
| `useDebounce` / `useDebouncedValidation` | ✅ | 🚫 | Replaced by `@suaveplan/timing`'s `debounce` directly inside the validation engine |
| `useLocalStorage` | ✅ | 🚫 | Out of scope; `StorageLike` from `@suaveplan/types` covers it generically |
| `useMemoryMonitor` / `usePerformanceMonitor` / `useRenderTracker` | ✅ | 🚫 | Dev-only telemetry; replaced by OTel histograms (see § Telemetry) |
| `useSchemaValidation` | ✅ | 🚫 | Folded into `ValidationEngine` |
| `usePagination` | ✅ | 🚫 | Out of scope for a forms package; `@suaveplan/pagination` covers this |
| `VirtualizedList` | ✅ | 🚫 | Belongs in `react-ui` if needed; not a forms concern |
| `LazyFormSection` / `OptimizedFormField` | ✅ | 🚫 | Premature optimisation; React 18 concurrent rendering covers the same need |
| `accessibility/` components + hooks | ✅ | ⬆️ | Subsumed by `react-ui`'s WCAG 2.2 AA baseline; forms inherit by composition |
| `core/i18n/` (custom translation context) | ✅ | 🚫 | Replaced by `@suaveplan/react-i18n` |
| `validation/` library | ✅ | ⬆️ | Enhanced: discriminated-union returns, `messageKey: TranslationKey` typing, async validators with debounce + dedup, compose helpers |
| `security/csrf`, `security/rate-limiting`, `security/sanitization` | ✅ | 🚫 | Cross-cutting transport concerns; belong in `@suaveplan/api-client`, not a forms package |
| `shared/state/` (form persistence) | ✅ | 🚫 | Deferred to `recipes/persistent-form` follow-up |
| `integration/` (mocks) | ✅ | 🚫 | Genesis uses `@suaveplan/testing` + real DI adapters per CLAUDE.md "no mocks" rule |
| **Domain forms** (auth, bank, user, friend, group, message, notification, settings, site, system) | ✅ | 🚫 | Product scope; replaced by `createDomainForm` factory. Auth set re-emerges in `react-ui-pack-auth`. |
| `createDomainForm` factory | ❌ | ✅ | New: declarative form assembly with three escape hatches |
| Wizard recipe | ❌ | ✅ | New: typed multi-step factory |
| Repeating-fieldset recipe | ❌ | ✅ | New: dynamic-array fieldsets via `useFieldArray` |
| Conditional-field recipe | ❌ | ✅ | New: declarative show/hide with validation gating |
| Async-search recipe | ❌ | ✅ | New: load-by-id then edit |
| Branded `TranslationKey` typing for FieldConfig | ❌ | ✅ | New: type-level enforcement of i18n discipline |
| OTel telemetry | ❌ | ✅ | New: form lifecycle counters + histograms |
---
## Field kinds shipped with v1
`text`, `email`, `password` (with optional `strengthMeter: boolean`), `number`, `currency`, `date`, `dateTime`, `select`, `multi-select`, `combobox` (async), `checkbox`, `radio`, `toggle`, `textarea`, `rich-text`, `file`, `tag-input`, `otp`, `custom`.
Each kind has a sibling Zod schema that validates its `FieldConfig` shape at factory-define time. A misconfigured field (e.g. a `select` without `options`) throws `ReactFormsError(REACT_FORMS.FIELD_CONFIG_INVALID)` at module evaluation, not on first render.
---
## Recipes
- **`createWizardForm({ steps, onComplete })`** — multi-step factory. Each step has its own Zod sub-schema; navigating forward validates the current step before advancing; navigating backward preserves entered data. Exposes `useWizardState()` for custom progress UIs.
- **`createRepeatingFieldset({ name, minRows, maxRows, fieldset })`** — dynamic-array fieldsets backed by RHF's `useFieldArray`. `fieldset` is itself a `FieldConfig` map, so rows nest factory-typed fields with full validation.
- **`createConditionalField({ when, render })`** — declarative show/hide. `when` is a predicate over the current form values; hidden fields are excluded from validation (and from the submitted payload) so optional sections don't pollute the schema.
- **`createAsyncSearchForm({ load, schema, fields })`** — async-prefilled forms. `load(id, signal)` fetches the entity; the form renders in a loading state until the promise resolves, then mounts with `defaultValues` populated.
---
## Validation library
### Synchronous validators
`required`, `minLength(min)`, `maxLength(max)`, `email`, `url`, `pattern(regex)`, `phone({ region })`, `postcode({ region })`, `creditCard` (Luhn), `password-strength({ minScore })`, `iban`, `bic`, `custom(predicate, messageKey)`.
### Async validators
`asyncEmailUnique({ check, debounceMs, dedupKey })`, `asyncUsernameAvailable({ check, debounceMs, dedupKey })`. Each accepts an injected `check(value, signal): Promise<boolean>` so the validator is transport-agnostic and testable without network mocks.
### Composers
`and(...validators)` — all must pass. `or(...validators)` — at least one must pass. `sequence(...validators)` — short-circuits on first failure (cheap-first ordering).
All return the discriminated union shape; all accept `AbortSignal` where async; all surface `messageKey: TranslationKey`.
---
## Telemetry
Every form lifecycle event emits OTel data via `@suaveplan/types`'s `LoggerLike` and `@opentelemetry/api`:
| Instrument | Kind | Attributes | Description |
|---|---|---|---|
| `form.submit.duration_ms` | Histogram | `form_id` | End-to-end submit latency |
| `form.validation.duration_ms` | Histogram | `form_id`, `phase` (schema / cross-field / async) | Per-phase validation latency |
| `form.async-validator.duration_ms` | Histogram | `form_id`, `validator` (`asyncEmailUnique` / `asyncUsernameAvailable` / consumer-named) | Per-validator async latency |
| `form.error_total` | Counter | `form_id`, `kind` (`schema` / `cross-field` / `async` / `submit` / `server`) | Error counter by failure class |
| `form.field.touched_total` | Counter | `form_id`, `field` | First-touch counter for field-level analytics |
Every histogram/counter name is a stable contract — see § Migration in the spec for the rule when these need to change.
---
## Out of scope
- **Server Actions / RSC integration** — `onSubmit` stays user-defined. Targets a follow-up once React 19 server-rendering APIs stabilise.
- **Server-rendered form recipes** — same reason.
- **Domain-specific forms** — moved to consumer apps and the example `@suaveplan/react-ui-pack-auth` pack.
- **Form persistence (`useFormPersistence`)** — deferred to `recipes/persistent-form` follow-up.
- **CSRF / rate-limiting / sanitisation primitives** — belong in `@suaveplan/api-client` and transport layers.
- **Performance hooks** (`useMemoryMonitor`, `usePerformanceMonitor`, `useRenderTracker`) — replaced by OTel-shipped telemetry.
---
## Source mapping
| Legacy requirement (`enterprise-react-forms`) | Migrated artefact in v2 | Justification if dropped |
|---|---|---|
| `BankAccountForm`, `BankTransactionForm` | `createDomainForm` factory | Product scope |
| `UserCreateForm`, `UserEditForm`, `OptimizedUserCreateForm` | `createDomainForm` + `createAsyncSearchForm` recipe | Product scope |
| `FriendRequestForm` | `createDomainForm` factory | Product scope |
| `GroupCreateForm`, `GroupEditForm` | `createDomainForm` + `createAsyncSearchForm` recipe | Product scope |
| `MessageCreateForm`, `MessageEditForm` | `createDomainForm` + `createAsyncSearchForm` recipe | Product scope |
| `NotificationCreateForm`, `NotificationEditForm`, `NotificationPreferencesForm` | `createDomainForm` factory | Product scope |
| `ChangePasswordForm` | `react-ui-pack-auth` (separate change) | Belongs in the auth pack |
| `TwoFactorForm` | `react-ui-pack-auth` (separate change) | Belongs in the auth pack |
| `SignInForm`, `SignUpForm`, `ForgotPasswordForm`, `ResetPasswordForm` | `react-ui-pack-auth` (separate change) | Belongs in the auth pack |
| `SettingCreateForm`, `SettingEditForm`, `NotificationSettingsForm`, `PrivacySettingsForm` | `createDomainForm` factory | Product scope |
| `SiteCreateForm`, `SiteEditForm`, `SiteConfigForm` | `createDomainForm` factory | Product scope |
| `SystemCreateForm`, `SystemEditForm`, `SystemSettingsForm` | `createDomainForm` factory | Product scope |
| `Field`, `AccessibleDatePicker`, `DateTimePicker`, `MoneyInput`, `SearchableSelect`, `TagInput`, `CodeInput`, `RichTextField`, `FileUploadField` | `primitives/<name>` | Direct port |
| `ApiClientContext`, `AuthContext` | `core/api-client-context`, `core/auth-context` | Direct port |
| `TranslationContext`, `core/i18n/` | `@suaveplan/react-i18n` | Cross-package consolidation |
| `useFormValidation`, `useFormSubmission`, `useFormPersistence` | `useFormValidation` (kept), others 🚫 | Submission folds into the factory; persistence is a follow-up recipe |
| `ValidationEngine` | `core/validation-engine` (enhanced) | ⬆️ AbortSignal + cross-field + server mapping |
| `validation/validators/`, `validation/async/`, `validation/compose/` | Direct port with `TranslationKey` typing | ⬆️ Discriminated-union returns |
| `FormWizard` component | `recipes/wizard/createWizardForm` | Refactored from component to factory |
| `FormProgressBar`, `FormSection`, `FormSkeleton`, `FormProvider` | 🚫 | Layout / chrome lives in `react-ui` |
| `useDebounce`, `useDebouncedValidation`, `useLocalStorage`, `useMemoryMonitor`, `usePerformanceMonitor`, `useRenderTracker`, `useSchemaValidation`, `usePagination` | 🚫 | Replaced by `@suaveplan/timing`, `@suaveplan/types` `StorageLike`, OTel telemetry, `@suaveplan/pagination` |
| `accessibility/` standalone components | 🚫 | Inherited via composition with `@suaveplan/react-ui` (WCAG 2.2 AA baseline) |
| `security/csrf`, `security/rate-limiting`, `security/sanitization` | 🚫 | Belongs in transport layer |
| `integration/mocks/` | 🚫 | Genesis uses real DI adapters; `@suaveplan/testing` provides the harness |
---
## Verification approach
Each requirement in `specs/react-forms/spec.md` carries a `**Verified by:**` clause naming the test files. The pre-publish gate is the standard CLAUDE.md sequence:
```bash
bunx turbo lint typecheck test --filter=@suaveplan/react-forms
bunx turbo test:coverage --filter=@suaveplan/react-forms # 100/100/100/100 hard gate
bunx turbo build --filter=@suaveplan/react-forms
bun scripts/lint/check-no-hardcoded-strings.ts packages/forms/react-forms
bun scripts/lint/check-translation-keys.ts packages/forms/react-forms
bun scripts/lint/check-test-runner-imports.ts packages/forms/react-forms
```
Plus the standard banned-pattern, barrel-shape, missing-`.md`, README, bare-version, and subpath-export scans from `openspec/AGENTS.md` §13.
---
## Migration impact
This is a pure additive on `genesis` — no existing package depends on `@suaveplan/react-forms` yet. The example pack `@suaveplan/react-ui-pack-auth` (a separate change) is the first consumer and will be developed against this proposal's surface in Wave 6.
For consumers porting from upstream `@suaveplan/react-forms@1.x`:
- **Old**: `import { SignInForm } from '@suaveplan/react-forms';`
- **New**: `import { LoginForm } from '@suaveplan/react-ui-pack-auth';` OR build with `createDomainForm({ schema: LoginRequestSchema, fields: { ... } })`.
A `MIGRATION.md` section in the package README enumerates each upstream form → new factory call. Consumer apps rebuild their domain forms at the call site once.
---
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/react-forms` to enumerate. Genesis state at proposal time:
- **No dependents on genesis yet** (verified by `bunx turbo ls --filter=...@suaveplan/react-forms`). The package does not exist; its first consumer (`@suaveplan/react-ui-pack-auth`) is itself a pending change tracking this proposal.
- `@suaveplan/react-ui-pack-auth` — [ ] tests run / [x] docs reviewed / [ ] no action needed (reason: separate change tracks this surface; both ship together in Wave 6 of the master design)
For foundation-package dependent sweep purposes, this package is **not foundational** — it sits high in the dep graph and has zero downstream consumers at proposal time.

View File

@@ -0,0 +1,279 @@
# react-forms — Spec Delta
## ADDED Requirements
### Requirement: Layered package surface
The `@suaveplan/react-forms` package SHALL expose its public surface in five layers — `primitives/`, `core/`, `factory/`, `recipes/`, and `validation/` — with each layer building only on the ones above it.
#### Scenario: Layer dependencies are unidirectional
- **GIVEN** the package source tree
- **WHEN** `bunx turbo build --filter=@suaveplan/react-forms` runs
- **THEN** no module under `primitives/` imports from `core/`, `factory/`, `recipes/`, or `validation/`
- **AND** no module under `core/` imports from `factory/`, `recipes/`, or `validation/`
- **AND** no module under `factory/` imports from `recipes/`
- **AND** subpath exports allow tree-shaking each layer independently.
**Verified by:** `tests/architecture/layer-deps.test.ts` (parses imports and asserts the dependency direction).
---
### Requirement: `createDomainForm` factory contract
The factory SHALL accept a Zod schema, a typed field-config map, a layout selector, and a submit handler, and SHALL return a `ComponentType<FormProps>` plus a `.Field` static accessor and a `.useFormContext` re-export of react-hook-form's `useFormContext`.
#### Scenario: Successful factory call returns a working component
- **GIVEN** a valid `RegisterRequestSchema` from `@suaveplan/api-schemas`
- **AND** a field-config map covering every field in the schema with `TranslationKey`-typed labels
- **WHEN** the consumer calls `createDomainForm({ schema, fields, layout, submit })`
- **THEN** the returned component renders without throwing
- **AND** the rendered DOM contains an accessible `<form>` with one labelled control per field
- **AND** submitting with valid values fires `submit.onSubmit({ values, ctx })` once.
**Verified by:** `factory/create-domain-form/create-domain-form.test.tsx`.
#### Scenario: Misconfigured field fails at module evaluation
- **GIVEN** a `select` field config without an `options` property
- **WHEN** the consumer calls `createDomainForm(...)` at module evaluation
- **THEN** the call throws `ReactFormsError(REACT_FORMS.FIELD_CONFIG_INVALID)` with the offending field name in the error context.
**Verified by:** `factory/field-config/field-config.test.ts`.
#### Scenario: Three escape hatches preserve direct RHF access
- **GIVEN** a form built with `createDomainForm(...)`
- **WHEN** the consumer renders `<RegisterForm>{({ form, fields, submit }) => <CustomShell {...} />}</RegisterForm>`
- **THEN** `form` is the same `UseFormReturn` reference that the underlying `useDomainForm()` returns
- **AND** the static accessor `<RegisterForm.Field name="email" />` renders the `email` field with its factory chrome
- **AND** calling `useDomainForm(config)` standalone produces the same `form` reference shape.
**Verified by:** `factory/create-domain-form/escape-hatches.test.tsx`.
---
### Requirement: `FieldConfig` strings are `TranslationKey`-typed
Every user-facing string property in a `FieldConfig` SHALL be typed as `TranslationKey` (from `@suaveplan/react-i18n`), never plain `string`. Properties covered: `label`, `placeholder`, `helperText`, `errorMessage` (or its `{ [code]: TranslationKey }` map form), `submit.label`, `submit.cancelLabel`, recipe step `title`, and recipe conditional-field gate labels.
#### Scenario: Plain-string label is a type error
- **GIVEN** a TypeScript file containing `createDomainForm({ fields: { email: { kind: "email", label: "Email" } } })`
- **WHEN** `bunx turbo typecheck --filter=@suaveplan/react-forms` runs
- **THEN** the compiler reports a type error on the literal `"Email"` because it is not a registered `TranslationKey`.
**Verified by:** `factory/field-config/field-config.types.test.ts` (uses `expectTypeOf` from `@suaveplan/testing/runner`).
#### Scenario: Registered translation key compiles
- **GIVEN** the consumer's `app.translations.augmentation.d.ts` registers `auth.email.label`
- **WHEN** the consumer writes `createDomainForm({ fields: { email: { kind: "email", label: "auth.email.label" } } })`
- **THEN** the compile passes
- **AND** the rendered label resolves via `useTranslation()` against the active locale.
**Verified by:** `factory/field-config/field-config.types.test.ts`.
---
### Requirement: `ValidationEngine` four-phase pipeline
The `ValidationEngine` SHALL run validation in four ordered phases on every form mutation: (1) Zod schema, (2) cross-field rules, (3) per-field async validators, (4) server-error mapping. Phases 1 and 2 are synchronous; phase 3 is debounced and `AbortSignal`-cancellable; phase 4 is invoked when `onSubmit` throws a `ReactFormsError(REACT_FORMS.SERVER_VALIDATION)`.
#### Scenario: Schema runs first and short-circuits cross-field rules
- **GIVEN** a form with a Zod schema requiring `email` to be a valid email
- **AND** a cross-field rule requiring `passwordConfirm` to match `password`
- **WHEN** the user submits `{ email: "not-an-email", password: "a", passwordConfirm: "b" }`
- **THEN** the validation result contains a schema error on `email`
- **AND** the cross-field rule does not fire (or its result is discarded)
- **AND** the rendered error message is `t(messageKey)` with the active locale.
**Verified by:** `core/validation-engine/phases.test.ts`.
#### Scenario: Async validator is debounced and cancelled on supersede
- **GIVEN** an `asyncEmailUnique` validator with `debounceMs: 300`
- **AND** an injectable `clock` from `@suaveplan/testing` allowing time advance
- **WHEN** the user types `a` then `ab` then `abc` within 100 ms each
- **THEN** only the final `check("abc", signal)` call resolves
- **AND** the earlier two calls receive an aborted `AbortSignal` (raised before the network mock fires).
**Verified by:** `core/validation-engine/async.test.ts`.
#### Scenario: Server error maps to RHF field error
- **GIVEN** `submit.onSubmit` throws `ReactFormsError(REACT_FORMS.SERVER_VALIDATION, { field: "email", code: "auth.email.taken" })`
- **WHEN** the engine processes the rejection
- **THEN** `form.formState.errors.email.message` equals `t("auth.email.taken")`
- **AND** the `form.error_total` counter increments with `kind: "server"`.
**Verified by:** `core/validation-engine/server-error.test.ts`.
---
### Requirement: `ApiClientContext` and `AuthContext` interfaces
The package SHALL export `<ApiClientProvider value={api}>` + `useApiClient<TApi>()` and `<AuthProvider value={auth}>` + `useAuth<TUser>()` as generic context wrappers. Both hooks SHALL throw `ReactFormsError(REACT_FORMS.CONTEXT_NOT_PROVIDED)` when called outside their provider. Neither context bakes in a specific transport or auth implementation.
#### Scenario: Provider value is consumed by the factory's submit handler
- **GIVEN** a `<ApiClientProvider value={apiInstance}>` wrapping a form built with `createDomainForm(...)`
- **WHEN** the form submits successfully
- **THEN** `submit.onSubmit({ values, ctx })` is called with `ctx.api === apiInstance`.
**Verified by:** `core/api-client-context/api-client-context.test.tsx`.
#### Scenario: Hook outside provider throws `CONTEXT_NOT_PROVIDED`
- **GIVEN** a component calling `useApiClient()` rendered without an `<ApiClientProvider>` ancestor
- **WHEN** the component mounts
- **THEN** rendering throws `ReactFormsError` with `code === "REACT_FORMS.CONTEXT_NOT_PROVIDED"` and `context.name === "ApiClient"`.
**Verified by:** `core/api-client-context/api-client-context.test.tsx`.
---
### Requirement: Wizard recipe contract
`createWizardForm({ steps, onComplete })` SHALL produce a multi-step form where each step has its own Zod sub-schema, navigating forward validates the current step, navigating backward preserves entered data, and `onComplete` runs after the final step's validation succeeds.
#### Scenario: Forward navigation blocks on invalid step
- **GIVEN** a wizard with two steps; step 1 requires `email`
- **WHEN** the user clicks `Next` without entering an email
- **THEN** the wizard remains on step 1
- **AND** an error message is rendered on the `email` field
- **AND** `useWizardState().currentStep.id === "step-1"`.
**Verified by:** `recipes/wizard/wizard.test.tsx`.
#### Scenario: Backward navigation preserves entered data
- **GIVEN** a wizard the user has filled through step 2
- **WHEN** the user clicks `Previous` then `Next`
- **THEN** step 2's previously entered values are still present in the form state.
**Verified by:** `recipes/wizard/wizard.test.tsx`.
---
### Requirement: Repeating-fieldset recipe contract
`createRepeatingFieldset({ name, minRows, maxRows, fieldset })` SHALL render a dynamic-array field backed by RHF's `useFieldArray`, and SHALL throw `ReactFormsError(REACT_FORMS.REPEATING_ROW_LIMIT)` when an `add()` call is made at `maxRows` or a `remove()` call is made at `minRows`.
#### Scenario: Add at max rows throws
- **GIVEN** a fieldset with `maxRows: 3` already containing three rows
- **WHEN** the consumer calls `add()`
- **THEN** the call throws `ReactFormsError` with `code === "REACT_FORMS.REPEATING_ROW_LIMIT"` and `context.action === "add"`.
**Verified by:** `recipes/repeating-fieldset/repeating-fieldset.test.tsx`.
---
### Requirement: Conditional-field recipe contract
`createConditionalField({ when, render })` SHALL hide its rendered field when `when(values)` returns `false`, AND SHALL exclude the hidden field's value from the submitted payload, AND SHALL clear any validation errors for the hidden field.
#### Scenario: Hidden field is not in submit payload
- **GIVEN** a conditional field `bankAccount` shown only when `paymentMethod === "transfer"`
- **AND** the user has selected `paymentMethod: "card"`
- **WHEN** the form submits
- **THEN** the values object passed to `submit.onSubmit` does NOT contain a `bankAccount` key
- **AND** any prior validation error on `bankAccount` is cleared.
**Verified by:** `recipes/conditional/conditional.test.tsx`.
---
### Requirement: Async-search recipe contract
`createAsyncSearchForm({ load, schema, fields })` SHALL render a `<Skeleton>` placeholder while `load(id, signal)` resolves, mount the underlying form with the resolved values as `defaultValues` on success, and surface a translated error message via `useTranslation` on failure. The `signal` SHALL be aborted if the consumer unmounts the form during the load.
#### Scenario: Load failure renders translated error
- **GIVEN** a `load` that rejects with a thrown `Error`
- **WHEN** the form renders
- **THEN** the user sees a region with the translated content of `t("react-forms.async-search.load-failed")`
- **AND** the underlying form does not mount.
**Verified by:** `recipes/async-search/async-search.test.tsx`.
#### Scenario: Unmount during load aborts the signal
- **GIVEN** a `load` that awaits a `signal.aborted` flag
- **WHEN** the consumer unmounts the form before `load` resolves
- **THEN** the `signal.aborted` flag becomes `true`
- **AND** the load is not retained in memory after unmount.
**Verified by:** `recipes/async-search/async-search.test.tsx`.
---
### Requirement: Validator return shape
Every validator (synchronous and asynchronous) SHALL return a discriminated union: `{ valid: true } | { valid: false; messageKey: TranslationKey; messageArgs?: Record<string, unknown> }`. Plain-string returns SHALL be a TypeScript error.
#### Scenario: Validator failure carries a translatable key
- **GIVEN** the `email` validator called with the value `"not-an-email"`
- **WHEN** the validator runs
- **THEN** the result equals `{ valid: false, messageKey: "react-forms.validation.email" }`
- **AND** `messageKey` typechecks as `TranslationKey` (registered in the package's `locales/en-GB.json`).
**Verified by:** `validation/validators/email/email.test.ts`.
#### Scenario: Composed `or` short-circuits on first success
- **GIVEN** `or(emailValidator, urlValidator)`
- **WHEN** the composed validator is called with a valid email
- **THEN** the result is `{ valid: true }`
- **AND** the `urlValidator` was not called.
**Verified by:** `validation/compose/or/or.test.ts`.
---
### Requirement: Field-kind validation at factory-define time
Every field kind in v1 (`text`, `email`, `password`, `number`, `currency`, `date`, `dateTime`, `select`, `multi-select`, `combobox`, `checkbox`, `radio`, `toggle`, `textarea`, `rich-text`, `file`, `tag-input`, `otp`, `custom`) SHALL have a sibling Zod schema validating its `FieldConfig` shape. Misconfigured fields SHALL throw `ReactFormsError(REACT_FORMS.FIELD_CONFIG_INVALID)` at module evaluation time, before the component renders.
#### Scenario: `kind: "custom"` requires `render`
- **GIVEN** a field config `{ kind: "custom", label: "auth.captcha.label" }` with no `render` property
- **WHEN** `createDomainForm(...)` is called
- **THEN** the call throws `ReactFormsError` with `code === "REACT_FORMS.CUSTOM_FIELD_NO_RENDER"` and `context.field === "<field-name>"`.
**Verified by:** `factory/field-config/custom-field.test.ts`.
---
### Requirement: No hardcoded user-facing strings invariant
No source file in `packages/forms/react-forms/src/` (excluding `*.test.ts(x)`, `*.spec.ts`, `*.stories.tsx`) SHALL contain a hardcoded user-facing string. Every user-perceivable string SHALL be a `TranslationKey` resolved at render time via `useTranslation` or `<Trans>`.
#### Scenario: CI gate flags literal `aria-label`
- **GIVEN** a source file containing `<button aria-label="Close">`
- **WHEN** `bun scripts/lint/check-no-hardcoded-strings.ts packages/forms/react-forms` runs
- **THEN** the script exits non-zero
- **AND** stderr contains the file, line, column, and the rule name `no-hardcoded-strings`.
**Verified by:** the CI gate itself; the package's own `src/` tree must produce zero output from the gate at archive time.
---
### Requirement: Telemetry contract
The package SHALL emit OTel histograms `form.submit.duration_ms`, `form.validation.duration_ms` (with `phase` attribute in `{schema, cross-field, async}`), `form.async-validator.duration_ms` (with `validator` attribute), the counter `form.field.touched_total` (with `field` attribute), and the counter `form.error_total` (with `kind` attribute in `{schema, cross-field, async, submit, server}`). Instrument names are stable contracts; renaming them requires a migration note per AGENTS.md §13a.
#### Scenario: Submit emits duration histogram
- **GIVEN** a form built with `createDomainForm(...)` and an in-memory OTel meter exporter
- **WHEN** the form submits successfully
- **THEN** the exporter records exactly one `form.submit.duration_ms` observation with `form_id` matching the configured id.
**Verified by:** `core/use-domain-form/telemetry.test.tsx`.

View File

@@ -0,0 +1,137 @@
# enterprise-react-forms-v2 — Implementation Tasks
Implementation order follows Wave 5 of the master design (`openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md`): each layer is gated independently and must hit `bunx turbo lint typecheck test --filter=@suaveplan/react-forms` green plus `test:coverage` at 100/100/100/100 plus 100% docs coverage before the next layer starts.
Prerequisites (must be green on `genesis` before this change starts):
- [ ] `@suaveplan/react-i18n` shipped (Wave 1) — provides `TranslationKey`, `TranslationProvider`, `useTranslation`, `<Trans>`.
- [ ] `@suaveplan/react-ui` core scaffolding shipped (Wave 2) — provides `Field`, `FieldGroup`, `Input`, `Select`, `Combobox`, `Calendar`, `Button`, `Skeleton`, `InputOTP`.
- [ ] `@suaveplan/react-ui` form-primitive batch shipped (Wave 3b) — full set of form-primitive components.
---
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 0. Scaffolding
- [ ] Create `packages/forms/react-forms/` with `package.json` (`workspace:^` for internal deps, `catalog:` for externals).
- [ ] Wire `tsconfig.json` and `tsconfig.lib.json` extending `@suaveplan/typescript-config/react-library.json`.
- [ ] Wire `vite.config.ts` (Vite 7) for the build.
- [ ] Wire `vitest.config.ts` via `createTestConfig()` from `@suaveplan/testing/config` — `provider: 'istanbul'`, thresholds `100/100/100/100`.
- [ ] Wire `playwright.config.ts` for `e2e/` via `@suaveplan/testing-e2e`.
- [ ] Add `README.md` (package name, one-paragraph description, install, quick-start, Modules section linking each co-located `.md`).
- [ ] Add `src/index.ts` as an explicit named-export barrel (no `export *`, per CLAUDE.md §10).
- [ ] Add `src/error-codes.ts` registering `defineCodes("REACT_FORMS", [...])` with codes: `FIELD_CONFIG_INVALID`, `SCHEMA_RESOLVER_FAILED`, `ASYNC_VALIDATOR_ABORTED`, `SERVER_VALIDATION`, `WIZARD_STEP_INVALID`, `WIZARD_STEP_NOT_FOUND`, `REPEATING_ROW_LIMIT`, `CUSTOM_FIELD_NO_RENDER`, `CONTEXT_NOT_PROVIDED`.
- [ ] Add `src/error-codes.md` documenting each code (purpose, when thrown, how to recover).
**Gate:** package builds, `bunx turbo build --filter=@suaveplan/react-forms` green; banned-pattern scan from AGENTS.md §13 step 4 returns zero output; barrel-shape scan from §13 step 4a returns zero output; test-runner import scan from §13 step 4b returns zero output.
---
## Layer 1 — Primitives
Each primitive ships as a five-files-per-feature module: `<name>.tsx` + `<name>.types.ts` + `<name>.test.tsx` + `<name>.stories.tsx` + `<name>.md` + `index.ts` (explicit named exports).
- [ ] **`primitives/field/`** — re-export `Field` and `FieldGroup` from `@suaveplan/react-ui` so the forms package has a single import path. Tests assert the re-exported references are identity-equal to the upstream symbols.
- [ ] **`primitives/accessible-date-picker/`** — Calendar + ARIA wrapper with `<Field>` integration; props typed with `TranslationKey` for `label`, `placeholder`, `helperText`.
- [ ] **`primitives/date-time-picker/`** — composes `AccessibleDatePicker` + a time input; supports `granularity: 'minute' | 'second' | 'hour'`.
- [ ] **`primitives/money-input/`** — locale-aware via `@suaveplan/i18n` `formatCurrency` / `parseCurrency`; `currency: string` (ISO 4217), `locale?: string` (defaults to active i18n locale).
- [ ] **`primitives/searchable-select/`** — async option loading; debounce + dedup via `@suaveplan/timing`'s `debounce({ leading: false, trailing: true, maxWait })`; `AbortSignal` cancellation on subsequent queries.
- [ ] **`primitives/tag-input/`** — chip-based multi-value input with keyboard (Enter / Backspace / arrow) navigation.
- [ ] **`primitives/file-upload-field/`** — peer-dep on `@suaveplan/react-file-upload`; falls back to native `<input type="file">` when peer absent.
- [ ] **`primitives/rich-text-field/`** — peer-dep on `@suaveplan/react-rich-text`; falls back to a styled `<textarea>` when peer absent.
- [ ] **`primitives/code-input/`** — wraps `react-ui`'s `InputOTP`; props for length, autoSubmit, validation pattern.
- [ ] Add a per-primitive subpath export to `package.json` (e.g. `"./primitives/money-input/money-input"`) so consumers tree-shake.
**Gate (Layer 1):** every primitive has 100% statement / branch / function / line coverage in vitest; every primitive has a `.md` meeting `docs:check`'s 200-word floor + all six required sections; every primitive renders a Storybook `Default`, `Playground`, `States`, `WithI18n`, and `WithTheme` story; `bunx turbo lint typecheck test --filter=@suaveplan/react-forms` green; `bun scripts/lint/check-no-hardcoded-strings.ts` returns zero output for the `primitives/` tree.
---
## Layer 2 — Core
- [ ] **`core/api-client-context/`** — `<ApiClientProvider value={api}>` + `useApiClient()`. Generic over `TApi`. Throws `REACT_FORMS.CONTEXT_NOT_PROVIDED` on use outside provider. Tests cover provider mount / unmount, value identity, and the throw-when-absent path.
- [ ] **`core/auth-context/`** — `<AuthProvider value={auth}>` + `useAuth()`. Generic over `TUser`. Surfaces `signIn` / `signOut` / `currentUser` / `isAuthenticated`. Same context-not-provided contract as ApiClientContext.
- [ ] **`core/validation-engine/`** — class implementing the four-phase pipeline (Zod schema → cross-field rules → async validators → server-error mapping). Constructor accepts `{ schema, crossFieldRules?, asyncValidators?, debounceMs?, clock?, signal? }`. Exposes `resolve(values, signal)` returning a RHF-compatible resolver result. Internal debounce + dedup via `@suaveplan/timing`. Tests cover each phase, AbortSignal cancellation, dedup keys, and server-error mapping with translated messages.
- [ ] **`core/use-form-validation/`** — hook returning a memoised resolver from a `ValidationEngine`. Stable identity across renders unless config dependencies change. Tests cover memoisation, re-creation on dep change, and correct resolver shape.
- [ ] **`core/use-domain-form/`** — the hook the factory wraps. Accepts the same config object. Returns `{ form: UseFormReturn, submit, isSubmitting, serverError }`. Tests cover RHF integration, submit lifecycle, and server-error mapping.
- [ ] Subpath exports for each core module.
**Gate (Layer 2):** Layer 1 still green; new modules at 100% coverage + 100% docs coverage; `bunx turbo lint typecheck test --filter=@suaveplan/react-forms` green.
---
## Layer 3 — Factory
- [ ] **`factory/field-config/`** — `FieldConfigBase` (with `label: TranslationKey`, `placeholder?: TranslationKey`, etc.); discriminated `FieldConfig` union over each of the 19 v1 field kinds (`text`, `email`, `password`, `number`, `currency`, `date`, `dateTime`, `select`, `multi-select`, `combobox`, `checkbox`, `radio`, `toggle`, `textarea`, `rich-text`, `file`, `tag-input`, `otp`, `custom`). Per-kind Zod schema validates the config at factory-define time. Tests cover valid configs, invalid configs (each kind), and the `REACT_FORMS.FIELD_CONFIG_INVALID` throw.
- [ ] **`factory/layout/`** — three built-in layout dispatchers (`vertical`, `horizontal`, `grid`) plus a `LayoutRenderFn` escape hatch typed as `(ctx: LayoutContext) => ReactNode`. Each layout accepts `FieldGroup` partitioning. Tests cover each layout's accessible-name structure.
- [ ] **`factory/create-domain-form/`** — `createDomainForm(config): ComponentType<FormProps> & { Field, useFormContext }`. Wraps `useDomainForm` + `<RHFProvider>` + the layout dispatcher. Mounts a Zod resolver via `@hookform/resolvers/zod` plus the `ValidationEngine`'s extra phases. Three escape hatches:
- [ ] `children` render-prop signature receives `{ form, fields, submit }` for raw composition.
- [ ] `Form.Field name={...}` static accessor renders a single field by name with the factory's chrome.
- [ ] `useDomainForm()` hook is exported standalone for consumers building a custom shell.
- [ ] Tests cover: factory throws on misconfigured field at module-eval time; renders all 19 kinds with correct ARIA; submit fires `onSubmit({ values, ctx })`; server error maps to RHF field error with translated message; each escape hatch works end-to-end.
- [ ] Subpath exports for `factory/create-domain-form/create-domain-form` and `factory/field-config/field-config`.
**Gate (Layer 3):** Layers 1-2 still green; factory at 100% coverage + 100% docs coverage; `e2e/factory.spec.ts` exercises the three escape hatches in a real browser via Playwright.
---
## Layer 4 — Recipes
- [ ] **`recipes/wizard/createWizardForm/`** — multi-step factory. `steps: WizardStep[]` where each `WizardStep = { id, title: TranslationKey, schema: ZodSchema, fields: FieldConfigMap }`. Internal state machine: `goNext()` validates current step before advancing; `goPrev()` preserves entered data; `complete()` runs after the final step's validation. Exposes `useWizardState()` for custom progress UIs. Throws `REACT_FORMS.WIZARD_STEP_NOT_FOUND` when navigating to an unknown id.
- [ ] **`recipes/repeating-fieldset/createRepeatingFieldset/`** — `name: string`, `minRows`, `maxRows`, `fieldset: FieldConfigMap`. Backed by RHF's `useFieldArray`. Add / remove / move row handlers; throws `REACT_FORMS.REPEATING_ROW_LIMIT` when `add()` is called at `maxRows` or `remove()` is called at `minRows`. ARIA `role="list"` on the fieldset, `role="listitem"` per row.
- [ ] **`recipes/conditional/createConditionalField/`** — `when: (values: TValues) => boolean`, `render: (ctx) => ReactNode`. Fields hidden by `when` are excluded from validation AND from the submitted payload (via RHF's `unregister` on hide). Tests cover: hidden field's value not in submit payload; hidden field's validation errors cleared; show / hide does not destroy entered data when the field re-mounts within the same submit cycle.
- [ ] **`recipes/async-search/createAsyncSearchForm/`** — `load: (id: string, signal: AbortSignal) => Promise<TValues>`, `schema`, `fields`. Renders `<Skeleton>` (from `@suaveplan/react-ui`) while `load` resolves; mounts the underlying `createDomainForm` with `defaultValues` populated on success; surfaces a translated error message on failure (key resolved via `useTranslation`).
- [ ] Tests for each recipe at 100% coverage; sibling `.md` files; Storybook stories; subpath exports.
- [ ] `e2e/recipes.spec.ts` exercises each recipe in a real browser.
**Gate (Layer 4):** Layers 1-3 still green; recipes at 100% coverage + 100% docs coverage; `e2e/recipes.spec.ts` green; `e2e/a11y.spec.ts` runs axe-core against every recipe story with zero violations.
---
## Layer 5 — Validation
- [ ] **`validation/validators/`** — one folder per validator (`required/`, `min-length/`, `max-length/`, `email/`, `url/`, `pattern/`, `phone/`, `postcode/`, `credit-card/`, `password-strength/`, `iban/`, `bic/`, `custom/`). Each validator returns the discriminated-union `{ valid: true } | { valid: false; messageKey: TranslationKey; messageArgs?: Record<string, unknown> }`. Per-validator tests with KAT cases (Luhn for `creditCard`, RFC 5321 for `email`, ISO 13616 for `iban`, ISO 9362 for `bic`). Sibling `.md` per validator.
- [ ] **`validation/async/`** — `async-email-unique/`, `async-username-available/`. Each accepts `{ check: (value, signal) => Promise<boolean>, debounceMs, dedupKey, signal }`. No fetch / network primitive baked in — `check` is injected. Tests cover debounce, dedup, AbortSignal cancellation; 100% branch coverage.
- [ ] **`validation/compose/`** — `and/`, `or/`, `sequence/`. Each accepts `(...validators)`. `sequence` short-circuits on first failure. Tests cover composition with sync + async mixes; 100% branch coverage.
- [ ] Subpath exports for every validator and every composer.
**Gate (Layer 5):** Layers 1-4 still green; validation library at 100% coverage + 100% docs coverage; CLAUDE.md "no banned patterns" scan green across `src/`.
---
## Telemetry
- [ ] Wire OTel histograms `form.submit.duration_ms`, `form.validation.duration_ms`, `form.async-validator.duration_ms` (with `validator` attribute), `form.field.touched_total` counter, `form.error_total` counter (with `kind` attribute).
- [ ] Document every instrument in `src/telemetry.md` with attributes, units, and a stable-contract pledge.
- [ ] Tests assert each instrument fires with the expected attribute set on the matching lifecycle event (use `@opentelemetry/sdk-metrics`'s in-memory exporter).
---
## Final pre-archive sweep
Before folding the spec delta and archiving the change, run the AGENTS.md §13 sequence in order — each step must exit green:
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/react-forms`
- [ ] `bun run check` (biome auto-fix)
- [ ] `bunx turbo test:coverage --filter=@suaveplan/react-forms` (must hit 100/100/100/100)
- [ ] `bunx turbo build --filter=@suaveplan/react-forms`
- [ ] Banned-pattern scan (`@deprecated`, `TODO`, `FIXME`, `ts-ignore`, `ts-expect-error`, `.skip`, `.only`) — zero output.
- [ ] Barrel-shape scan (§13 step 4a) — zero output.
- [ ] Test-runner import scan (§13 step 4b) — zero output.
- [ ] Missing `.md` scan — zero output.
- [ ] README check passes.
- [ ] Bare-version check — zero output.
- [ ] Subpath export count matches feature module count.
- [ ] Security scan (hardcoded secrets) — zero output.
- [ ] `bun scripts/lint/check-no-hardcoded-strings.ts packages/forms/react-forms` — zero output.
- [ ] `bun scripts/lint/check-translation-keys.ts packages/forms/react-forms` — zero output.
- [ ] `bun scripts/lint/check-test-runner-imports.ts packages/forms/react-forms` — zero output.
- [ ] Dependent sweep (`bunx turbo test --filter=...@suaveplan/react-forms`) — green; no dependents at first ship, but verify `react-ui-pack-auth` (when its change is in flight) compiles against the new surface.
- [ ] Update root manifest docs per AGENTS.md §13b: refresh `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`), refresh `MISSING.yml` (`bun scripts/generate-missing.ts`), edit `STATUS.md` (move row to Completed), edit `SUMMARY.md` (add per-package paragraph), run `bunx markdownlint-cli2 --fix`.
- [ ] Fold `specs/react-forms/spec.md` ADDED Requirements into `openspec/specs/react-forms/spec.md` (create the file if absent).
- [ ] `git mv openspec/changes/enterprise-react-forms-v2 openspec/archive/2026-MM-DD-enterprise-react-forms-v2`.
- [ ] Archive the legacy `openspec/changes/enterprise-react-forms/` folder via the standard supersession pattern (do NOT delete its history; archive with the supersession date).