Files
2026-05-27 23:29:53 +01:00

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-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:

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:

  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

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:

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.