<!-- source: openspec/current/forms.md -->

---
title: Forms
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Standard for every form in the studio — nine binding rules covering validation timing, error anatomy, accessibility, and discard guards.
---
# Forms — capability spec

> Standard for every form in the studio. Nine binding rules covering validation timing, error anatomy, error placement, accessibility, server-error mapping, optimistic updates, loading/pending states, discard guards, and cross-cutting principles. Implementation lives in `packages/ui/src/overlay/` (helpers) and `apps/studio/src/modules/<feature>/` (per-feature form bodies and dialog wrappers).
>
> Change source: `openspec/archive/2026-05-12-add-forms-standard/` (proposal + design).
> Builds on: [`linkable-ui.md`](linkable-ui.md) (overlay system), [`ui.md`](ui.md) (design system primitives).

---

## § The nine rules

### Rule 1 — Validation timing

* Library: `react-hook-form` ≥7.74 with `@hookform/resolvers/zod`.
* Mode: `useForm({ mode: 'onTouched', reValidateMode: 'onChange' })`.
* First validation fires after blur (not while typing).
* Once a field has errored, validation re-runs on every change so the error clears the moment the field becomes valid.
* Submit always re-validates the full form against the same Zod schema the server uses.
* Authorities: react-hook-form `useForm` reference; Adam Silver "Inline validation is problematic" (counter-argument); Nielsen Norman Group "Form errors design guidelines" (onBlur stance).
* Forbidden: `mode: 'onChange'` (rejected — fires while user is mid-typing); `mode: 'onSubmit'` only (rejected — Silver's onBlur objection only applies to first-time validation; revalidation of an already-erroring field benefits from immediate feedback).

### Rule 2 — Error message anatomy

* Format: `<verb> <thing>`. Examples: "Enter your email address", "Choose a project", "Pick at least one persona".
* Visually-hidden screen-reader prefix: every error message is rendered with a leading `<span class="sr-only">Error: </span>`. GOV.UK Design System pattern. Belt-and-braces alongside `aria-invalid` and the live region.
* Banned words in error copy: please, sorry, oops, valid, invalid, "this field". (GOV.UK style.)
* Localisation: errors flow through `next-intl` keys under `errors.<feature>.<field>.<rule>`. Zod schemas reference these keys, not hard-coded strings.
* Authorities: GOV.UK Design System "Error message" component; W3C WCAG 2.2 SC 3.3.1 (Error Identification, Level A) and SC 3.3.3 (Error Suggestion, Level AA).

### Rule 3 — Error placement

* Inline error message under the field — always.
* Error summary at the top of the form — only when at least one of:
  * ≥2 field errors fire simultaneously, or
  * ≥1 non-field error is present (network failure, permission denied, cross-field rule violation).
* Single-field dialogs (rename, slug-only confirm, etc.) skip the summary entirely.
* Summary text and inline text under each field are word-for-word identical (GOV.UK rule).
* Summary contains anchor links to each invalid field (`<a href="#field-id">`).
* Non-field errors live in the summary, not in toasts. Toasts disappear; non-field errors must persist until resolved.
* Authorities: GOV.UK Design System "Error summary"; Adam Silver *Form Design Patterns*; NN/g (counter-argument: inline-only). The compromise — summary only when justified by multi-error or non-field — respects both camps.

### Rule 4 — Accessibility

* `aria-invalid="true"` on each erroring field while the error is present; removed the moment the field validates.
* `aria-describedby` on each field is the **space-separated id list** of (a) the hint id (if any) and (b) the error message id. Errors **append** to the description; they never replace the hint.
* Inline error message containers don't need `role="alert"` if they render with the field on initial paint (no live-region announcement needed for visible text). Containers that appear dynamically (after submit) get `role="alert"`.
* Error summary container: `role="alert"` + `tabindex="-1"` + programmatic `.focus()` after submit failure. Focus the container, not the announcement text — APG permits this; APG forbids focus-into-the-alert-itself.
* On submit failure, focus moves to the summary container, never to a single field. Multi-field focus is a known footgun documented in react-hook-form's own `setError` reference (`shouldFocus: true` puts focus on whatever field happened to be added last to the error map).
* Forbidden: `setError(field, ..., { shouldFocus: true })` for multi-field results. Always rely on summary focus instead.
* Authorities: W3C WAI-ARIA Authoring Practices "Alert" pattern; Heydon Pickering *Inclusive Components* "Notifications" chapter; GOV.UK Design System "Error summary"; W3C WCAG 2.2 SC 3.3.1.

### Rule 5 — Server-error mapping

* Every server action returns the canonical shape:

```ts
// packages/db/src/action-result.ts
export type ActionResult<T> =
  | { ok: true; data: T }
  | { ok: false; fieldErrors: Record<string, string[]>; formErrors: string[] };
```

* On the client, the dialog body calls `applyServerErrors(form, result)` (helper from `packages/ui/src/overlay/apply-server-errors.ts`):
  * For each `(field, messages)` in `fieldErrors`: `form.setError(field, { type: 'server', message: messages[0] })`.
  * For each message in `formErrors`: `form.setError('root.serverError', { type: 'server', message })`.
  * `shouldFocus: false` on every call. The same focus path as client-validation failure (focus the summary) handles UX.
* The Zod schema lives in `apps/studio/src/modules/<feature>/schema.ts` and is imported by both the client form and the server action. There is no server-only validator.
* Cross-field errors ("either email or phone is required") attach to `formErrors`, not to one of the fields by convention. They surface in the summary.
* Forbidden: returning a single `string` error from a server action; returning a thrown exception as the user-facing error; using API routes for studio-internal forms (server actions only — D-2).
* Authorities: react-hook-form `setError` reference; React 19 `useActionState` shape (`{ errors, message }`); Conform `flatten().fieldErrors`.

### Rule 6 — Optimistic updates

* For reversible writes, use React 19 `useOptimistic` (and the project helper `useOptimisticItem<T>` in `packages/ui/src/overlay/use-optimistic-item.ts`).
* Items being mutated wear `pending: true` for visual treatment: opacity 60%, hover-actions disabled.
* On rejection: React reverts the optimistic value automatically (because the underlying state never changed); the dialog shows `toast.error(message, { action: { label: 'Retry', onClick: retry } })`.
* On confirmation: row is removed (for revoke / archive list operations) or replaced (for in-place updates); no toast on success — silent confirmation.
* **Reversible writes (optimistic by default)**: rename, archive, unarchive, toggle, list reorder, invitation revoke, instruction pin, role/persona change.
* **Irreversible writes (pessimistic, never optimistic)**: hard delete, payment, email send, server-generated id consumed by subsequent UI (e.g., create-project then immediate redirect to `/projects/[slug]` requires the slug from the response — pessimistic).
* Forbidden: hand-rolled rollback with `Set<id>` of pending operations; calling the optimistic setter outside a transition or form action (React revert won't fire).
* Authorities: React 19 `useOptimistic` reference; TanStack Query "Optimistic updates" (counter-pattern reference for the rollback semantics); Linear product changelog (toast-revert convention).

### Rule 7 — Loading & pending states

* Submit button: `disabled={pending}` while the action is in flight.
* Submit button label: switches to a verb-progressive ("Saving…", "Inviting…", "Creating…") instead of staying "Save". Width fixed via `min-width` to prevent layout shift.
* Spinner: rendered inline next to the label.
* Anti-flicker thresholds: don't render the spinner until the action has been pending ≥250ms; once rendered, hold visible for ≥500ms even if the action resolves sooner.
* Other inputs (text fields, selects, etc.) stay enabled while pending. Users can re-read or correct without waiting.
* The pending source of truth is `useFormStatus` (RSC) or `formState.isSubmitting` (RHF). `useTransition` wraps the action call to make `pending` reactive in client components.
* Forbidden: replacing the entire form with a skeleton during submit; disabling all fields; showing the spinner immediately on click (causes flicker for sub-250ms actions).
* Authorities: SAP Fiori "AI progress indicator" (500ms minimum visible); Adobe Commerce "Progress indicator" (250ms delay); Material 3 progress indicators overview; React 19 `useFormStatus` reference.

### Rule 8 — Discard / dirty guard

* The truth of "dirty" is `formState.isDirty` from react-hook-form.
* Close attempts (Esc, backdrop click, × button, URL navigation that removes `?dialog=`) trigger the dirty check via `useFormDirtyGuard(form, onClose)` (helper in `packages/ui/src/overlay/use-form-dirty-guard.ts`).
* When dirty: show an `<AlertDialog>` with title "Discard changes?", body "Your changes won't be saved.", cancel/discard buttons (Cancel is default focus, Discard is destructive variant).
* When not dirty: close immediately without prompt.
* Hard navigation (browser tab close, reload): register a `beforeunload` listener while dirty; native browser prompt fires.
* App Router client navigation cannot be programmatically blocked (Next.js issue #2236, discussion #47020 — no first-party API). The overlay system avoids this by owning the close-handler — `<OverlayHost>` calls `onClose()` before the URL update happens, giving the dirty-guard its chance.
* Forbidden in studio dialogs: auto-saving partial drafts. (Exception: marketing-site forms in `apps/web` MAY use `useFormDraft` from `@rapoport-studio/ui/hooks/use-form-draft` — see § Marketing-site form drafts.); silently discarding without confirmation; using the alert-dialog for non-destructive close (only fires when dirty).
* Authorities: Cloudscape "Communicating unsaved changes"; MDN `beforeunload` event; Next.js Discussion #47020 (App Router blocking is broken).

### Marketing-site form drafts

Marketing-site forms in `apps/web` that serve anonymous visitors on long-form conversion briefs (e.g., intake forms) MAY persist partial drafts using `useFormDraft` from `@rapoport-studio/ui/hooks/use-form-draft`.

**Surface:** Anonymous visitors on `apps/web` long-form briefs (not studio dialogs, not authenticated studio sessions).

**Contract:**
- Use `useFormDraft` only — no bespoke `localStorage` wiring.
- `localStorage`-backed; no server-side persistence in v1.
- PII expiry is the consumer's responsibility via the `transformOnLoad` callback (e.g., drop name/email fields after 7 days).
- Call `clear()` from the hook's return value on successful submit.

**Boundary:** Studio dialogs (`apps/studio`) MUST NOT use `useFormDraft`. The Rule 8 ban on auto-saving partial drafts stands in full for the studio surface.

**Rationale:** Anonymous marketing-site visitors have no auth session and no server-side recovery path. A tab close mid-brief is a real conversion loss. The `useFormDraft` primitive exists precisely for this surface — the studio carve-out is the architectural commitment.

### Rule 9 — Cross-cutting principles

Three rules that apply across all 8 above:

1. **Same UI for client and server errors.** The failure surface does not branch on origin. Validation, formatting, focus, and announcement code paths are identical for `setError` calls coming from the resolver, from the user typing past `maxLength`, or from `applyServerErrors`.
2. **Summary is the single focus target on submit failure.** Always. No exceptions for "single-field forms" (the summary just doesn't render in that case — focus stays where it was; the inline error announces via `role="alert"` if dynamic).
3. **Optimistic-by-default for reversible writes; pessimistic for irreversible.** The dividing line is encoded here, not re-litigated per feature. If a feature author thinks an irreversible operation needs to feel optimistic for UX reasons, the answer is "make the operation reversible" (e.g., soft-delete), not "be optimistic about something that can't be undone".

---

## § File layout per feature

Every form-bearing module follows a **four-layer split** under `apps/studio/src/modules/<feature>/`. Mandatory: Domain × 2, Fields, Form. Frame files added on demand.

```
apps/studio/src/modules/<feature>/
├── schema.ts                              # Domain — Zod schemas
├── actions.ts                             # Domain — server actions, ActionResult<T>
├── <entity>-fields.tsx                    # Fields — pure render, no chrome, no submit
├── <entity>-form.tsx                      # Form — RHF + slot API, frame-aware a11y only
├── <entity>.dialog.tsx                    # Frame — Dialog wrapper (most common)
├── <entity>.sheet.tsx                     # Frame — Sheet wrapper (optional, Phase 5+)
├── <entity>.page-section.tsx              # Frame — page-section wrapper (optional)
├── <entity>.inline-embed.tsx              # Frame — inline embed wrapper (optional)
├── <entity>.wizard-step.tsx               # Frame — wizard-step wrapper (optional)
└── __tests__/<entity>-form.test.tsx       # Test — fixture-based, asserts contract
```

AlertDialog confirmations live as React-state components in `apps/studio/src/components/<feature>/<action>-confirm.tsx`, NOT in the registry. They have no reusable body and are not part of the four-layer contract.

### Layer contracts

| Layer | File | Owns | Forbidden imports |
|---|---|---|---|
| **Domain** | `schema.ts` + `actions.ts` | Zod schemas, server actions, `ActionResult<T>` | React, Radix primitives, `@rapoport-studio/ui` |
| **Fields** | `<entity>-fields.tsx` | Pure render of fields via RHF `<FormField>`. Receives `form` instance + read-only `mode` prop. No buttons, no submit handler, no chrome. | `@rapoport-studio/ui/components/dialog`, `sheet`; `useDialog` |
| **Form** | `<entity>-form.tsx` | RHF setup, Zod resolver, dirty-guard, error summary, submit handler, optimistic plumbing. Composes `<EntityFields>`. Accepts slot props (`heading`, `actions`, `aside?`) and a `frame` prop. | `@rapoport-studio/ui/components/dialog`, `sheet`; `useDialog` |
| **Frame** | `*.dialog.tsx` / `*.sheet.tsx` / `*.page-section.tsx` / `*.inline-embed.tsx` / `*.wizard-step.tsx` | Chrome appropriate to the frame. Renders `<EntityForm>` and supplies slots. Single allowed importer of chrome primitives from app code. | None — top of the stack |

### Why shared body + per-frame wrappers, not one mode-switching dialog (D-3)

* Two URLs are easier to deep-link, log, and reason about than one URL with a `?d_mode=edit` flag.
* Edit dialogs need to fetch initial values; create dialogs don't. Branching on a URL param to decide whether to fetch turns into spaghetti.
* `apply-template`, `accept-lead-as-idea`, `convert-to-project` are not "edit" — they're third modes that need their own wrappers. The 2-mode-switching pattern doesn't extend cleanly to 3+ modes.
* The shared `<EntityForm>` body (without chrome) is reusable across frames without any changes to the form body code. Cost: an extra ~30-line wrapper per frame. Acceptable.

Reference module: `apps/studio/src/modules/team/invite-member.dialog.tsx` (four-layer retrofit in Phase 2 of `add-module-autonomy-standard`).

---

## § The dialog catalogue

24 URL-driven dialogs + 12 confirm AlertDialogs across 7 phases (Phase 8 team-management dialogs are deferred — see below). Reference module: `apps/studio/src/modules/team/invite-member.dialog.tsx`.

### Phase 1 — Projects (4 dialogs)

| id | op | URL params | Fields | Action |
|---|---|---|---|---|
| `create-project` | create | — | name, slug | `createProject` |
| `edit-project` | edit | `id` | name (slug immutable) | `updateProject` |
| (confirm) `archive-project` | confirm | — | — | `archiveProject` |
| (confirm) `delete-project` | confirm | — | typed-slug | `deleteProject` |

### Phase 2 — Idea sessions (5 dialogs)

| id | op | URL params | Fields | Action |
|---|---|---|---|---|
| `create-idea` | create | `projectId?` | title, slug, projectId | `createIdea` |
| `accept-lead-as-idea` | special | `leadId` | title, slug, projectId | `acceptLead` |
| `edit-idea` | edit | `id` | title, projectId | `updateIdea` |
| `convert-to-project` | special | `id` | projectName, slug | `convertIdeaToProject` |
| (confirm) `archive-idea` | confirm | — | — | `archiveIdea` |

### Phase 3 — Project connections (4 dialogs)

| id | op | URL params | Fields | Action |
|---|---|---|---|---|
| `connect-github` | connect | `projectId` | repo (async), repoUrl auto | `connectGithub` |
| `connect-linear` | connect | `projectId` | teamId (async), linearProjectId? | `connectLinear` |
| `edit-connection` | edit | `id`, `kind` | kind-conditional | `updateConnection` |
| (confirm) `disconnect-connection` | confirm | — | — | `disconnectConnection` |

### Phase 4 — Idea collaboration (6 dialogs)

| id | op | URL params | Fields | Action |
|---|---|---|---|---|
| `invite-to-spec` | create | `ideaId` | email, role, persona | `inviteToSpec` |
| `edit-invitation` | edit | `id` | role, persona | `updateInvitation` |
| `add-instruction` | create | `ideaId` | content, kind, pinned | `addInstruction` |
| `edit-instruction` | edit | `id` | content, kind, pinned | `updateInstruction` |
| (confirm) `revoke-invitation` | confirm | — | — | `revokeInvitation` |
| (confirm) `delete-instruction` | confirm | — | — | `deleteInstruction` |

### Phase 5 — Studio prompt templates (4 dialogs, owner-only)

| id | op | URL params | Fields | Action |
|---|---|---|---|---|
| `create-template` | create | — | name, slug, description, content, variables | `createTemplate` |
| `edit-template` | edit | `id` | name, description, content, variables (slug immutable) | `updateTemplate` |
| `apply-template` | special | `id`, `ideaId` | dynamic per-variable + preview | `applyTemplate` |
| (confirm) `delete-template` | confirm | — | — | `deleteTemplate` |

### Phase 6 — Community / inbox / OAuth (5 dialogs)

| id | op | URL params | Fields | Action |
|---|---|---|---|---|
| `edit-community-profile` | edit | — (current user) | primary_discipline, display_opt_in | `updateCommunityProfile` |
| `update-lead-status` | edit | `id` | status, notes? | `updateLeadStatus` |
| (confirm) `decline-lead` | confirm | — | reason? | `declineLead` |
| (confirm) `disconnect-linear-oauth` | confirm | — | — | `disconnectLinearOAuth` |
| (confirm) `regenerate-api-key` | confirm | — | typed-phrase | `regenerateApiKey` |

### Phase 7 — Engagements / Forge runs (4 dialogs, owner-only)

| id | op | URL params | Fields | Action |
|---|---|---|---|---|
| `edit-engagement` | edit | `id` | mode, methodology, status | `updateEngagement` |
| `create-forge-run` | create | — | linearIssueKey, command, model, costCapUsd, autoAdvance | `createForgeRun` |
| (confirm) `archive-engagement` | confirm | — | — | `archiveEngagement` |
| (confirm) `cancel-forge-run` | confirm | — | — | `cancelForgeRun` |

### Phase 8 — Team management (DEFERRED)

Six dialogs (`edit-member-role`, `remove-member`, `accept-invitation`, `decline-invitation`, `cancel-pending-invite`, `invite-member` rewrite-as-improvement) are deferred until multi-user is live in the platform. Specified in the archive change folder for completeness.

---

## § Key decisions

* **D-1: `react-hook-form` + `zodResolver` is the only form library.** No Conform, no `useActionState`-only patterns, no bare-state forms in new code.
* **D-2: Server actions are the only submit transport for studio dialogs.** API routes remain for public marketing-site forms (`/api/intake`, `/api/community/apply`) and external webhooks.
* **D-3: One shared `<EntityForm>` body (no chrome) + per-frame wrappers.** Accepts slot props (`heading`, `actions`, `aside?`) and a `frame` prop (a11y signal only). See § File layout and § Module autonomy contract.
* **D-4: AlertDialog confirmations stay in React state, not URL.** Destructive operations are not deep-linkable — exposing them as URLs would let phishing-like links pre-trigger destructive UI.
* **D-5: Phase 8 (team management) deferred until multi-user lands.** Today's `team` table is a stub for the owner-only single-user world.
* **D-6: Forge dispatch respects Linear `blockedBy` as the parallelism source of truth.** Wave structure in the archive change is descriptive; Linear's relation API is authoritative.

---

## § Module autonomy contract

> Applied 2026-05-13 from change `openspec/archive/2026-05-13-add-module-autonomy-standard/`.

The four-layer split enforces the decoupling D-3 always intended. Form bodies stop emitting chrome. The `frame` prop is the only a11y signal; it is never a styling branch (D-MAS-3).

### Slot API

Types `EntityFormFrame` and `EntityFormSlots<S>` exported from `@rapoport-studio/ui/overlay`.

Helpers `focusStrategyFor(frame)`, `dirtyGuardPresentationFor(frame)`, `announceForFrame(frame, msg)` from `@rapoport-studio/ui/overlay` — five frame values handled exhaustively.

### Slot rules

1. **Slots render once.** Form body renders each slot at the position dictated by form layout.
2. **No slot styling contract.** Form body does not constrain slot styling.
3. **Submit semantics.** The submit button (`type="submit"`) lives in the `actions` slot. The form body wires `onSubmit` on `<form>`.
4. **Cancel semantics.** Cancel button calls `confirmClose()` from `useFormDirtyGuard`, not directly to `onCancel`.

### ESLint enforcement

Three custom rules in `tools/eslint-rules/`, wired in `apps/studio/eslint.config.mjs`:

| Rule | Scope | Severity |
|---|---|---|
| `no-chrome-import-in-form-body` | `*-form.tsx`, `*-fields.tsx` | `error` for new; `warn` for legacy snapshot |
| `no-submit-button-in-fields` | `*-fields.tsx` | `error` always (Fields is net-new) |
| `no-frame-import-outside-frame-files` | all `apps/**` except frame files | `error` for new; `warn` for legacy snapshot |

Legacy snapshot: `tools/eslint-rules/legacy-form-modules.json`. Regenerate: `node tools/scripts/generate-legacy-form-modules-snapshot.mjs`. Remove when Q-MAS-D triggers (75% migrated or 2026-11-13).

### Existing-module migration pattern

Each of the existing form modules migrates in ~30-45 min when it next needs a new frame:

1. **Extract Fields** — carve the field JSX into `<entity>-fields.tsx`. No chrome, no buttons.
2. **Extract chrome to slots** — replace `<DialogHeader>`, `<DialogTitle>`, `<DialogFooter>` with `{props.heading}`, `{props.description}`, `{props.actions}`.
3. **Update Frame** — the `*.dialog.tsx` wrapper supplies slots and passes `frame="dialog"`.
4. **Verify** — `pnpm lint && pnpm typecheck && pnpm test -- <entity>`.
5. **Ship** — single commit: `feat(rap-NN): decouple <entity>-form from Dialog frame`.

---

## § Frame catalog

Per-frame a11y obligations. Authoritative — adding a new frame requires a change-proposal. Cross-reference: [`linkable-ui.md § Frame catalog cross-reference`](linkable-ui.md).

| Frame | Heading source | Focus trap | On-mount focus | Esc behaviour | Dirty-guard prompt | Live-region |
|---|---|---|---|---|---|---|
| **Dialog** | `<DialogTitle>` mandatory; `VisuallyHidden` allowed | yes (Radix) | per `initialFocus` hint | closes top-most dialog | `<AlertDialog>` overlay | `#a11y-status` polite |
| **Sheet** (Phase 5+) | `<SheetTitle>` mandatory; `VisuallyHidden` allowed | yes (Radix) | per `initialFocus` hint | closes sheet | `<AlertDialog>` overlay | `#a11y-status` polite |
| **Page-section** | real `<h2 id={headingId}>` by frame; form gets `aria-labelledby={headingId}` | no | only via `?focus=<form-id>` query | no-op (browser) | inline `<Callout>` + Save/Discard | `#a11y-status` polite + toast |
| **Inline-embed** | `<h3>` if visible; otherwise `aria-label` on `<form>` | no | first field on expand | optional: collapse | inline confirm row | toast only |
| **Wizard-step** | `<h2>` per step + `aria-current="step"` on stepper | no | first invalid field on Next | navigate to prev step | wizard-level guard | `#a11y-status` polite |

Use `dirtyGuardPresentationFor(frame)` and `announceForFrame(frame, msg)` from `@rapoport-studio/ui/overlay`.

