Apply the forms-builder specialist workflow. Build forms grounded in the factory's conventions, not generic react-hook-form code. Load factory-forms through the host's skill capability when needed.
How to think (in order)
What kind of form? Pick one:
- Single-step drawer-CRUD form — create/edit one entity → use the drawer mode union from
factory-frontend.md
- Single-step page form — login, settings, simple intake → vanilla react-hook-form
- Multi-step wizard — onboarding, complex intake → section files + field registry
- Inline edit — DataTable cell editing → different pattern, see
factory-frontend.md
How many fields?
- < 10 — inline schemas, hand-written JSX
- 10–30 — section files but no registry needed
- 30+ or AI-context-aware — field registry
Schemas — three variants (see factory-forms.md):
- Server input — strict (real types, no empty-string fallbacks)
- Client form — lenient (accepts empty strings because controlled inputs default to
'')
- Patch — partial input for updates
Conditional visibility? If any field's display depends on another's value:
- Declarative rules in
src/lib/utils/conditional-visibility.ts
- Auto-cleanup when parent toggles off — set hidden child's value to
undefined
- Otherwise stale conditional data sneaks into submissions
Sensitive fields? SSN, EIN, government IDs, financial accounts:
- Masked input (
mask="99-9999999" etc.)
- KMS encryption at rest — see
factory-security.md
- Mask in display, decrypt only at the handler that returns plaintext
Auto-save? If the form takes more than a couple minutes to fill out:
- 2s debounce on field blur
- 3min safety-net interval flush
- Server accepts the lenient schema during auto-save; strict schema on submission
Dynamic field arrays? (contacts, beneficial owners, line items)
useFieldArray from react-hook-form
- Max-count rule via
rules: { maxLength: { value: N, message: '...' } }
- Add/remove buttons; consider drag-reorder for ordered arrays
File uploads? Direct-to-S3 with presigned URLs (don't proxy through your server):
- Server action generates upload URL
- Client PUTs file to S3
- Server action confirms upload (records fileKey)
Submission UX?
useTransition wrapping the async submit
isPending gates the submit button
- Toast on success / error
- Invalidate the right TanStack Query keys on success
Reference: canonical drawer-CRUD form skeleton
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; // or mantine-form-zod-resolver
import { customerFormSchema } from './schema';
import { useCreateCustomer, useUpdateCustomer } from './hooks';
type CustomerDrawerMode =
| { kind: 'closed' }
| { kind: 'create' }
| { kind: 'edit'; customer: Customer };
export function CustomerDrawer({ mode, onClose }: { mode: CustomerDrawerMode; onClose: () => void }) {
const form = useForm({
resolver: zodResolver(customerFormSchema),
defaultValues: emptyCustomerFormValues,
});
const createMutation = useCreateCustomer();
const updateMutation = useUpdateCustomer();
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (mode.kind === 'edit') {
form.reset(toFormValues(mode.customer));
} else if (mode.kind === 'create') {
form.reset(emptyCustomerFormValues);
}
}, [mode.kind, mode.kind === 'edit' ? mode.customer.id : null]);
const => {
startTransition(async () => {
const mutation = mode.kind === 'edit' ? updateMutation : createMutation;
const result = await mutation.mutateAsync(values);
if ('error' in result) { toast.error(result.error); return; }
toast.success(mode.kind === 'edit' ? 'Updated' : 'Created');
onClose();
});
});
return (
<Drawer opened={mode.kind !== 'closed'}
<form
{/* fields */}
<Button type="submit" loading={isPending}>{mode.kind === 'edit' ? 'Save' : 'Create'}</Button>
</form>
</Drawer>
);
}
Reference: field registry shape (for ≥30-field forms)
// src/lib/fields/types.ts
export type FieldDef = {
id: string;
label: string;
type: 'text' | 'email' | 'masked' | 'select' | 'date' | 'checkbox' | 'array';
required?: boolean;
validation: z.ZodTypeAny;
options?: { value: string; label: string }[];
mask?: string;
sensitive?: boolean;
aiContext?: string; // for AI-augmented review / autofill
};
// src/lib/fields/sections/section-1-company.ts
export const SECTION_1_FIELDS: FieldDef[] = [
{ id: 'company_name', label: 'Legal name', type: 'text', required: true, validation: z.string().min(1) },
{ id: 'ein', label: 'EIN', type: 'masked', mask: '99-9999999', required: true, sensitive: true, validation: z.string().regex(/^\d{2}-\d{7}$/) },
// ...
];
Output format
## Restated request
<one sentence>
## Form shape
- Type: <drawer-CRUD / page / multi-step / inline edit>
- Field count: <count>
- Field registry: <yes / no — why>
- Conditional visibility: <yes / no>
- Auto-save: <yes / no>
- Sensitive fields: <list, with encryption strategy>
## Schemas
- Server input: <path>
- Client form: <path>
- Patch: <path>
## Files to create or modify
<bulleted with paths>
## Code
<organized by file>
## Conventions check
- Three Zod variants: <yes>
- Drawer mode union (if applicable): <yes>
- useTransition wrapping submit: <yes>
- Conditional auto-cleanup (if applicable): <yes>
- Sensitive fields encrypted at rest: <yes / N/A>
## Open questions
<things the user should confirm>
What you do NOT do
- Don't write a monolithic single-file form. Modular section files from day one.
- Don't unify server and client schemas. Server strict, client lenient.
- Don't leave stale conditional data in submissions. Auto-cleanup on visibility change.
- Don't proxy file uploads through your server. Direct-to-S3 with presigned URLs.
- Don't store SSN / PHI / government IDs in plaintext. KMS-at-rest, mask in display.
- Don't
await the audit log on the submission path. Fire-and-forget.
- Don't skip
useTransition. Double-submission is a real bug class.
- Don't reach for the field registry for a 5-field form. Reserved for ≥30-field or AI-context-aware forms.
When the request is too small for this framework
If the user asks to add one field to an existing form or change validation on a single field, do it directly. The framework is for new forms, new sections, or substantial form-state refactors.
1---2name: factory-forms-builder3description: Use when building multi-step forms, complex intake flows, drawer-CRUD forms, or anything beyond a one-shot Mantine form. Carries the factory's form conventions — react-hook-form + Zod via resolver, three Zod variants (server strict / client lenient / patch), modular section files from day one, field registry for AI-context-aware multi-step forms, declarative conditional visibility with auto-cleanup, debounced auto-save with dirty tracking, masked inputs for sensitive fields, dynamic field arrays, S3 presigned upload, `useTransition` for async submission, question numbering via context, review section with gap detection. Outputs form code that fits the house style — not bespoke form-state machinery.4---56Apply the **forms-builder** specialist workflow. Build forms grounded in the factory's conventions, not generic react-hook-form code. Load `factory-forms` through the host's skill capability when needed.78## How to think (in order)9101. **What kind of form?** Pick one:11 - **Single-step drawer-CRUD form** — create/edit one entity → use the drawer mode union from `factory-frontend.md`12 - **Single-step page form** — login, settings, simple intake → vanilla react-hook-form13 - **Multi-step wizard** — onboarding, complex intake → section files + field registry14 - **Inline edit** — DataTable cell editing → different pattern, see `factory-frontend.md`15162. **How many fields?**17 - **< 10** — inline schemas, hand-written JSX18 - **10–30** — section files but no registry needed19 - **30+** or AI-context-aware — field registry20213. **Schemas — three variants** (see `factory-forms.md`):22 - **Server input** — strict (real types, no empty-string fallbacks)23 - **Client form** — lenient (accepts empty strings because controlled inputs default to `''`)24 - **Patch** — partial input for updates25264. **Conditional visibility?** If any field's display depends on another's value:27 - Declarative rules in `src/lib/utils/conditional-visibility.ts`28 - **Auto-cleanup** when parent toggles off — set hidden child's value to `undefined`29 - Otherwise stale conditional data sneaks into submissions30315. **Sensitive fields?** SSN, EIN, government IDs, financial accounts:32 - Masked input (`mask="99-9999999"` etc.)33 - KMS encryption at rest — see `factory-security.md`34 - Mask in display, decrypt only at the handler that returns plaintext35366. **Auto-save?** If the form takes more than a couple minutes to fill out:37 - 2s debounce on field blur38 - 3min safety-net interval flush39 - Server accepts the lenient schema during auto-save; strict schema on submission40417. **Dynamic field arrays?** (contacts, beneficial owners, line items)42 - `useFieldArray` from react-hook-form43 - Max-count rule via `rules: { maxLength: { value: N, message: '...' } }`44 - Add/remove buttons; consider drag-reorder for ordered arrays45468. **File uploads?** Direct-to-S3 with presigned URLs (don't proxy through your server):47 - Server action generates upload URL48 - Client PUTs file to S349 - Server action confirms upload (records fileKey)50519. **Submission UX?**52 - `useTransition` wrapping the async submit53 - `isPending` gates the submit button54 - Toast on success / error55 - Invalidate the right TanStack Query keys on success5657## Reference: canonical drawer-CRUD form skeleton5859```tsx60'use client';6162import { useForm } from 'react-hook-form';63import { zodResolver } from '@hookform/resolvers/zod'; // or mantine-form-zod-resolver64import { customerFormSchema } from './schema';65import { useCreateCustomer, useUpdateCustomer } from './hooks';6667type CustomerDrawerMode =68 | { kind: 'closed' }69 | { kind: 'create' }70 | { kind: 'edit'; customer: Customer };7172export function CustomerDrawer({ mode, onClose }: { mode: CustomerDrawerMode; onClose: () => void }) {73 const form = useForm({74 resolver: zodResolver(customerFormSchema),75 defaultValues: emptyCustomerFormValues,76 });7778 const createMutation = useCreateCustomer();79 const updateMutation = useUpdateCustomer();80 const [isPending, startTransition] = useTransition();8182 useEffect(() => {83 if (mode.kind === 'edit') {84 form.reset(toFormValues(mode.customer));85 } else if (mode.kind === 'create') {86 form.reset(emptyCustomerFormValues);87 }88 }, [mode.kind, mode.kind === 'edit' ? mode.customer.id : null]);8990 const onSubmit = form.handleSubmit((values) => {91 startTransition(async () => {92 const mutation = mode.kind === 'edit' ? updateMutation : createMutation;93 const result = await mutation.mutateAsync(values);94 if ('error' in result) { toast.error(result.error); return; }95 toast.success(mode.kind === 'edit' ? 'Updated' : 'Created');96 onClose();97 });98 });99100 return (101 <Drawer opened={mode.kind !== 'closed'} onClose={onClose}>102 <form onSubmit={onSubmit}>103 {/* fields */}104 <Button type="submit" loading={isPending}>{mode.kind === 'edit' ? 'Save' : 'Create'}</Button>105 </form>106 </Drawer>107 );108}109```110111## Reference: field registry shape (for ≥30-field forms)112113```ts114// src/lib/fields/types.ts115export type FieldDef = {116 id: string;117 label: string;118 type: 'text' | 'email' | 'masked' | 'select' | 'date' | 'checkbox' | 'array';119 required?: boolean;120 validation: z.ZodTypeAny;121 options?: { value: string; label: string }[];122 mask?: string;123 sensitive?: boolean;124 aiContext?: string; // for AI-augmented review / autofill125};126127// src/lib/fields/sections/section-1-company.ts128export const SECTION_1_FIELDS: FieldDef[] = [129 { id: 'company_name', label: 'Legal name', type: 'text', required: true, validation: z.string().min(1) },130 { id: 'ein', label: 'EIN', type: 'masked', mask: '99-9999999', required: true, sensitive: true, validation: z.string().regex(/^\d{2}-\d{7}$/) },131 // ...132];133```134135## Output format136137```138## Restated request139<one sentence>140141## Form shape142- Type: <drawer-CRUD / page / multi-step / inline edit>143- Field count: <count>144- Field registry: <yes / no — why>145- Conditional visibility: <yes / no>146- Auto-save: <yes / no>147- Sensitive fields: <list, with encryption strategy>148149## Schemas150- Server input: <path>151- Client form: <path>152- Patch: <path>153154## Files to create or modify155<bulleted with paths>156157## Code158<organized by file>159160## Conventions check161- Three Zod variants: <yes>162- Drawer mode union (if applicable): <yes>163- useTransition wrapping submit: <yes>164- Conditional auto-cleanup (if applicable): <yes>165- Sensitive fields encrypted at rest: <yes / N/A>166167## Open questions168<things the user should confirm>169```170171## What you do NOT do172173- **Don't write a monolithic single-file form.** Modular section files from day one.174- **Don't unify server and client schemas.** Server strict, client lenient.175- **Don't leave stale conditional data in submissions.** Auto-cleanup on visibility change.176- **Don't proxy file uploads through your server.** Direct-to-S3 with presigned URLs.177- **Don't store SSN / PHI / government IDs in plaintext.** KMS-at-rest, mask in display.178- **Don't `await` the audit log on the submission path.** Fire-and-forget.179- **Don't skip `useTransition`.** Double-submission is a real bug class.180- **Don't reach for the field registry for a 5-field form.** Reserved for ≥30-field or AI-context-aware forms.181182## When the request is too small for this framework183184If the user asks to add one field to an existing form or change validation on a single field, do it directly. The framework is for new forms, new sections, or substantial form-state refactors.