15 KiB
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-i18nshipped (Wave 1) — providesTranslationKey,TranslationProvider,useTranslation,<Trans>.@suaveplan/react-uicore scaffolding shipped (Wave 2) — providesField,FieldGroup,Input,Select,Combobox,Calendar,Button,Skeleton,InputOTP.@suaveplan/react-uiform-primitive batch shipped (Wave 3b) — full set of form-primitive components.
0. Pre-flight (non-negotiable)
- 0.1 Confirm clean working tree (
git statusreports nothing) and no in-flight worktrees with uncommitted work (git worktree listthengit statusin 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.mdmatchesfind packages -maxdepth 3 -name package.jsoncount. Drift here means a prior change archived without regenerating the manifest.
0. Scaffolding
- Create
packages/forms/react-forms/withpackage.json(workspace:^for internal deps,catalog:for externals). - Wire
tsconfig.jsonandtsconfig.lib.jsonextending@suaveplan/typescript-config/react-library.json. - Wire
vite.config.ts(Vite 7) for the build. - Wire
vitest.config.tsviacreateTestConfig()from@suaveplan/testing/config—provider: 'istanbul', thresholds100/100/100/100. - Wire
playwright.config.tsfore2e/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.tsas an explicit named-export barrel (noexport *, per CLAUDE.md §10). - Add
src/error-codes.tsregisteringdefineCodes("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.mddocumenting 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-exportFieldandFieldGroupfrom@suaveplan/react-uiso 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 withTranslationKeyforlabel,placeholder,helperText.primitives/date-time-picker/— composesAccessibleDatePicker+ a time input; supportsgranularity: 'minute' | 'second' | 'hour'.primitives/money-input/— locale-aware via@suaveplan/i18nformatCurrency/parseCurrency;currency: string(ISO 4217),locale?: string(defaults to active i18n locale).primitives/searchable-select/— async option loading; debounce + dedup via@suaveplan/timing'sdebounce({ leading: false, trailing: true, maxWait });AbortSignalcancellation 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/— wrapsreact-ui'sInputOTP; 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 overTApi. ThrowsREACT_FORMS.CONTEXT_NOT_PROVIDEDon use outside provider. Tests cover provider mount / unmount, value identity, and the throw-when-absent path.core/auth-context/—<AuthProvider value={auth}>+useAuth(). Generic overTUser. SurfacessignIn/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? }. Exposesresolve(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 aValidationEngine. 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(withlabel: TranslationKey,placeholder?: TranslationKey, etc.); discriminatedFieldConfigunion 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 theREACT_FORMS.FIELD_CONFIG_INVALIDthrow.factory/layout/— three built-in layout dispatchers (vertical,horizontal,grid) plus aLayoutRenderFnescape hatch typed as(ctx: LayoutContext) => ReactNode. Each layout acceptsFieldGrouppartitioning. Tests cover each layout's accessible-name structure.factory/create-domain-form/—createDomainForm(config): ComponentType<FormProps> & { Field, useFormContext }. WrapsuseDomainForm+<RHFProvider>+ the layout dispatcher. Mounts a Zod resolver via@hookform/resolvers/zodplus theValidationEngine's extra phases. Three escape hatches:childrenrender-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-formandfactory/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 eachWizardStep = { 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. ExposesuseWizardState()for custom progress UIs. ThrowsREACT_FORMS.WIZARD_STEP_NOT_FOUNDwhen navigating to an unknown id.recipes/repeating-fieldset/createRepeatingFieldset/—name: string,minRows,maxRows,fieldset: FieldConfigMap. Backed by RHF'suseFieldArray. Add / remove / move row handlers; throwsREACT_FORMS.REPEATING_ROW_LIMITwhenadd()is called atmaxRowsorremove()is called atminRows. ARIArole="list"on the fieldset,role="listitem"per row.recipes/conditional/createConditionalField/—when: (values: TValues) => boolean,render: (ctx) => ReactNode. Fields hidden bywhenare excluded from validation AND from the submitted payload (via RHF'sunregisteron 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) whileloadresolves; mounts the underlyingcreateDomainFormwithdefaultValuespopulated on success; surfaces a translated error message on failure (key resolved viauseTranslation).- Tests for each recipe at 100% coverage; sibling
.mdfiles; Storybook stories; subpath exports. e2e/recipes.spec.tsexercises 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 forcreditCard, RFC 5321 foremail, ISO 13616 foriban, ISO 9362 forbic). Sibling.mdper 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 —checkis injected. Tests cover debounce, dedup, AbortSignal cancellation; 100% branch coverage.validation/compose/—and/,or/,sequence/. Each accepts(...validators).sequenceshort-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(withvalidatorattribute),form.field.touched_totalcounter,form.error_totalcounter (withkindattribute). - Document every instrument in
src/telemetry.mdwith 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-formsbun 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
.mdscan — 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 verifyreact-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), refreshMISSING.yml(bun scripts/generate-missing.ts), editSTATUS.md(move row to Completed), editSUMMARY.md(add per-package paragraph), runbunx markdownlint-cli2 --fix. - Fold
specs/react-forms/spec.mdADDED Requirements intoopenspec/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).