30 KiB
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-i18nshipped 2026-05-05 — archived atopenspec/archive/2026-05-05-enterprise-react-i18n/; capability spec atopenspec/specs/react-i18n/spec.md. This change consumesTranslationKeyfor everyFieldConfig.label/placeholder/helperText/errorMessage,useTranslationfor label resolution at render time,useErrorMessageforSuaveplanError-coded validation failures, and theContributedTranslationKeysaugmentation pattern for default recipe button labels (react-forms.wizard.nextetc.).enterprise-react-ui-v2(sibling, in-flight) — providesField/FieldGroup/FieldSet/InputGroup/Textarea/Checkbox/Switch/RadioGroup/Toggle/ToggleGroupprimitives the factory and recipes render onto. Wave 5b waits for Wave 3b before adding@suaveplan/react-uias a peer dep (gate enforced viascripts/lint/check-no-backwards-deps.tsper-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:
- Primitives that wrap react-hook-form + react-ui controls into a tested, accessible, i18n'd surface.
- A
createDomainFormfactory that takes a Zod schema + a typed field config and returns a working form component — without hiding react-hook-form. Three escape hatches (childrenrender-prop,<Form.Field>static accessor,useDomainForm()hook) keep the abstraction transparent. - 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. - 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'sonSubmitreceivesapifrom this context. Implementation-agnostic (consumer brings their own client).AuthContext—<AuthProvider value={auth}>+useAuth(). SurfacessignIn/signOut/currentUserso 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 aValidationEngine.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)— returnsComponentType<FormProps>plus a.Fieldstatic accessor and.useFormContextre-export.- Field-kind registry typed via
FieldConfigByKind; misconfigurations fail at module-evaluation time via Zod-validatedFieldConfigschemas. - Layout dispatcher (
vertical/horizontal/grid/ custom render fn).
recipes/
createWizardForm({ steps, onComplete })— multi-step form with per-step Zod sub-schema, navigation guards, and auseWizardState()hook.createRepeatingFieldset({ minRows, maxRows, fieldset })— dynamic-array rows backed by RHF'suseFieldArray.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:
childrenrender-prop — passingchildrenwith the render-prop signature gives the consumer rawuseFormContext()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:
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:
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:
- Zod schema — the spine. Synchronous; runs first.
- Cross-field rules — e.g.
passwordConfirm matches password. Synchronous; runs after the schema. Has access to the full form values object. - Per-field async validators — debounced via
@suaveplan/timing(debounce({ leading: false, trailing: true, maxWait })), deduped by(field, value)key, cancelled viaAbortSignalwhen the form unmounts or when a newer value supersedes the in-flight check. - Server-error mapping — when
onSubmitthrows aReactFormsError(REACT_FORMS.SERVER_VALIDATION)carrying{ field, code, messageArgs }, the engine sets the corresponding RHF field error with the translated message resolved viat(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
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,ResetPasswordFormbank/forms/—BankAccountForm,BankTransactionFormuser/forms/—UserCreateForm,UserEditForm,OptimizedUserCreateFormfriend/—FriendRequestFormgroup/forms/—GroupCreateForm,GroupEditFormmessage/forms/—MessageCreateForm,MessageEditFormnotification/forms/andnotifications/forms/—NotificationCreateForm,NotificationEditForm(duplicate domains in upstream collapsed)security/—ChangePasswordForm,TwoFactorForm(re-emerge inreact-ui-pack-auth)settings/forms/—SettingCreateForm,SettingEditForm,NotificationSettingsForm,PrivacySettingsFormsite/forms/—SiteCreateForm,SiteEditForm,SiteConfigFormsystem/forms/—SystemCreateForm,SystemEditForm,SystemSettingsFormdemo/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. ExposesuseWizardState()for custom progress UIs.createRepeatingFieldset({ name, minRows, maxRows, fieldset })— dynamic-array fieldsets backed by RHF'suseFieldArray.fieldsetis itself aFieldConfigmap, so rows nest factory-typed fields with full validation.createConditionalField({ when, render })— declarative show/hide.whenis 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 withdefaultValuespopulated.
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 —
onSubmitstays 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-authpack. - Form persistence (
useFormPersistence) — deferred torecipes/persistent-formfollow-up. - CSRF / rate-limiting / sanitisation primitives — belong in
@suaveplan/api-clientand 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:
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 withcreateDomainForm({ 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.