Forms
CapabilitiesTome native form engine — admin-authored form definitions, multi-step runtime, conditional-logic expression evaluator, server-authoritative zod validation, and tomeForm block render surface.
npm install @wabbit/tome-formsOverview
@wabbit/tome-forms
Tome's native form engine: admin-authored multi-step form definitions, a conditional-logic expression evaluator, server-authoritative zod validation, and the tomeForm Payload block render surface. domain layer per root ARCHITECTURE.md — depends on @wabbit/tome-core and @wabbit/tome-ui.
Note on this README's scope: this documents the package's structure as it exists on main today (src/hooks/{afterChange,beforeChange,beforeValidate}.ts flat files). A hooks-directory decomposition may be in flight on another branch at time of reading — if src/hooks/ has grown subdirectories, that change hasn't landed here; re-verify against src/ before trusting the file paths below.
Install
pnpm add @wabbit/tome-forms| Peer | Range | |---|---| | next | >=15.0.0 | | payload | >=3.67.0 | | react | >=19.0.0 | | react-hook-form | >=7.0.0 | | zod | ^4.0.0 |
@wabbit/tome-core and @wabbit/tome-ui are regular (non-peer) dependencies.
60-second quickstart
Define a form (pure, no Payload/DB touch — defineForm validates and deep-freezes at module load):
import { defineForm } from '@wabbit/tome-forms'
export const contactForm = defineForm({
slug: 'contact',
steps: [{ stepName: 'main', fields: [{ name: 'email', type: 'email' }] }],
submission: { target: 'intake' },
})Register the block + collections in payload.config.ts:
import { tomeFormBlock, initForms, createFormsCollections } from '@wabbit/tome-forms'
// blocks: [tomeFormBlock], collections: [...createFormsCollections()]Bind a server action and render (verified pattern from blocks/index.ts's own usage doc comment):
// app/(frontend)/contact/actions.ts — 'use server'
import { submitFormAction } from '@wabbit/tome-forms/server'
import { contactForm } from '@/lib/forms/contact'
export const submitContact = submitFormAction(contactForm, payload)// app/(frontend)/contact/page.tsx (RSC)
import { TomeForm } from '@wabbit/tome-forms/blocks'
import { submitContact } from './actions'
<TomeForm form={contactForm} action={submitContact} />Public API
| Export | Subpath | Description | |---|---|---| | defineForm, TomeFormsConfigError | root | Validated, deep-frozen form-definition factory (Kahn topological sort on derivations, repeater-depth cap, ref/target validation) | | registerFieldType, freezeFieldRegistry, getFieldTypeDescriptor, listFieldTypes | root | Field-type registry | | getForm, seedForms, replaceForm, invalidateForm | root | In-memory form-definition registry | | initForms | root | Layer init entry point | | createFormsCollections, withFormsAccess | root | Payload collection factories (forms, forms-fields, forms-submissions, forms-drafts) | | tomeFormBlock, createTomeFormBlock | root | React-free Payload Block config — safe for payload generate:types without pulling React | | TomeForm | ./blocks | RSC shell — import this in page Server Components | | TomeFormClient | ./blocks | Client interactive form runtime (usually consumed via TomeForm, not directly) | | FieldRenderer | ./blocks | Per-field-type render dispatcher | | submitFormAction(form, payload) | ./server | HOF returning a bound 'use server' submit action | | compileFormSchema(form, opts) | ./server | Isomorphic zod schema compiler — safe in both RSC and client bundles | | formatZodError(err) | ./server | Format a ZodError to a single string for client display | | evaluate, computeDerivation, resolveOperand | ./server | Pure conditional-logic evaluator — re-exported here (not just internal) so TomeForm.client.tsx can import it for client-side advisory rule evaluation from the same subpath | | createFormsCollections field configs | ./collections | Direct collection-config access | | mock submission fixture | ./test | Test helper |
Server / client posture
The block render layer is a deliberate two-file split:
- `blocks/TomeForm.server.tsx` — no
'use client'directive; a genuine RSC. Loads the form definition viagetForm(), compiles the per-step schema viacompileFormSchema(isomorphic — safe in RSC), renders the outer shell (<section>,data-theme, aria-live announcer, honeypot field), and forwards the consumer-bound server action untouched to the client boundary. Does not callgetPayload()itself and does not import@wabbit/tome-motion. - `blocks/TomeForm.client.tsx` — the
'use client'boundary; owns interactive state (react-hook-form, step navigation, client-side rule evaluation via the re-exported evaluator).
Only these two files (plus the top-level blocks/tomeFormBlock.ts, which is React-free config) touch React directly. hooks/beforeValidate.ts is marked with a server-only import guard — it's a Payload collection hook, never bundled client-side. Everything else (engine/evaluator.ts, engine/fieldRegistry.ts, engine/registry.ts, defineForm.ts) is plain TypeScript with no browser or Node-specific API, safe to import from either side — which is exactly what lets the evaluator be re-exported from ./server and consumed by the client component in the same module instance (spec §10's "dual-evaluation contract").
The root barrel deliberately excludes the React-bearing TomeForm/TomeFormClient (they live on ./blocks) so that importing @wabbit/tome-forms in a payload.config.ts for collection/field registration never risks pulling React or CSS Modules into the config-evaluation path.
Extending
New field types register via registerFieldType() before freezeFieldRegistry() is called (typically at initForms() time). New submission targets are a switch arm in defineForm.ts's validateSubmissionConfig plus a corresponding target module under server/targets/. See docs/superpowers/specs/2026-05-18-tome-forms-layer-design.md for the full field/rule/derivation contract.
Design history
docs/superpowers/specs/2026-05-18-tome-forms-layer-design.md,2026-05-18-tome-forms-orchestrate-prompt.md— original design + build plandocs/claude-gotchas.md→ Payload / Typing / Layer Design section — "RichText render contracts differ per pack — always render-walk" and "Config-time vs view split for tsx-evaluated modules" both apply directly to this package's block/collection split
Exports
@wabbit/tome-forms@wabbit/tome-forms/blocks@wabbit/tome-forms/server@wabbit/tome-forms/collections@wabbit/tome-forms/test
Changelog
6bc419c: R4 rulings #2 + #3 (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- 6bc419c: R4 rulings #2 + #3 (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- a93f478: Re-render and cleanup fixes: chrome's HeaderClient dead theme state + unreachable effect deleted; Navbar6/7 body-scroll-lock now saves and restores the pre-existing overflow value (LearnerSidebar pattern) instead of clobbering to ''; Navbar7's scroll listener is rAF-throttled. marketing-starter's Testimonial derives the clamped slide index during render instead of an effect. forms' `FieldRenderer` is wrapped in `React.memo` (call-site props verified stable), cutting whole-step re-render work per keystroke in multi-field forms. lms-ui's `useLearnerPrefs` gains optional `initialPrefs` server-seeding (non-breaking) + in-flight dedup with TTL for the unseeded path.
- aef2725: Monolith decompositions (behavior- and markup-preserving; public APIs unchanged; markup identity mechanically verified per file): forms' FieldRenderer 633→84 via a field-control registry + shared FieldChrome (consent/checkbox byte-identical branches merged) and TomeForm 656→451 via four extracted hooks (the ordering-critical resolver sync deliberately stays inline, documented); rpg's CharacterSheet 841→130 across panels + three editing hooks + persistence hook (the StrictMode XP-ledger charRef guard preserved verbatim); gallery's GalleryIndex 1032→431 (BlockThumb/BlockCard/Toolbar/useFilteredCatalog siblings, T2's debounce+memo preserved); webgl's WebglCanvasProvider 938→546 (useTransitionOrchestrator + useCanvasRenderer extracted; settle thresholds hoisted to named consts); admin's mergeAdminComponents 828→404 orchestrator + four helpers (all docblocks relocated, 717 tests unmodified) and Nav's config-reading now typed (6 of 8 `as any` casts eliminated); marketing-starter's PricingPlans extracts its GSAP toggle timeline hook + a memoized card. rpg additionally trusts the denormalized `xpTotal` on sheet load/save hot paths (full recompute stays at the XP-recording reconciliation point).
- Updated dependencies [6bc419c]
- Updated dependencies [36e537a]
- Updated dependencies [36e537a]
- Updated dependencies [aef2725]
- Updated dependencies [aef2725]
- Updated dependencies [aef2725] - @wabbit/tome-core@1.4.0 - @wabbit/tome-ui@0.9.9
bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
- bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
- Updated dependencies [bed3f90]
- Updated dependencies [850d51c] - @wabbit/tome-core@1.2.1 - @wabbit/tome-ui@0.9.7
Drop the inner `'use server'` directive from the action returned by `submitFormAction`. Under Next's server-action transform a directive here double-registers the action — `"Cannot redefine property: $$id"` when the consumer re-exports it, or a call-stack overflow when it is wrapped. The single registration point is the consumer's own `'use server'` module, which exports `submitFormAction(...)`'s result directly (spec §12). Plain async fn also keeps the tsx/test path working. Surfaced + proven by tome-starter under registry consumption: a browser submit at `/forms-demo` now writes a row to `forms-submissions` end-to-end.
- Drop the inner `'use server'` directive from the action returned by `submitFormAction`. Under Next's server-action transform a directive here double-registers the action — `"Cannot redefine property: $$id"` when the consumer re-exports it, or a call-stack overflow when it is wrapped. The single registration point is the consumer's own `'use server'` module, which exports `submitFormAction(...)`'s result directly (spec §12). Plain async fn also keeps the tsx/test path working. Surfaced + proven by tome-starter under registry consumption: a browser submit at `/forms-demo` now writes a row to `forms-submissions` end-to-end.
Re-export the React-free `tomeFormBlock` / `createTomeFormBlock` block config from the package **root** (`.`). Registry consumers can now register the `tomeForm` block in a Payload config / Pages layout without importing the `./blocks` barrel (which pulls the React render components + CSS modules and breaks `payload generate:types`). The component side stays in `./blocks`. Surfaced by tome-starter moving to registry consumption — `@wabbit/tome-forms/blocks/tomeFormBlock` is not an exported subpath, so the React-free config needs a first-class export.
- Re-export the React-free `tomeFormBlock` / `createTomeFormBlock` block config from the package **root** (`.`). Registry consumers can now register the `tomeForm` block in a Payload config / Pages layout without importing the `./blocks` barrel (which pulls the React render components + CSS modules and breaks `payload generate:types`). The component side stays in `./blocks`. Surfaced by tome-starter moving to registry consumption — `@wabbit/tome-forms/blocks/tomeFormBlock` is not an exported subpath, so the React-free config needs a first-class export.
Fix three fat-slug type defects the package's own scoped typecheck cannot catch (in isolation Payload's `CollectionSlug` is `string`; in a consumer with generated `payload-types.ts` it narrows to a union). Surfaced by the first real consumer (tome-starter): - `blocks/tomeFormBlock.ts` — `relationTo: formsSlug as CollectionSlug`. - `hooks/beforeChange.ts` — forms-drafts upsert read cast through `as unknown as`. - `server/targets/payload.ts` — dynamic-slug `payload.create` `collection`/`data` cast `as never`, result narrowed to `{ id }`. Type-only; no runtime change. Scoped `tsc` 0 + `smoke` 19/19 re-verified; tome-starter typechecks the forms source clean.
- Fix three fat-slug type defects the package's own scoped typecheck cannot catch (in isolation Payload's `CollectionSlug` is `string`; in a consumer with generated `payload-types.ts` it narrows to a union). Surfaced by the first real consumer (tome-starter): - `blocks/tomeFormBlock.ts` — `relationTo: formsSlug as CollectionSlug`. - `hooks/beforeChange.ts` — forms-drafts upsert read cast through `as unknown as`. - `server/targets/payload.ts` — dynamic-slug `payload.create` `collection`/`data` cast `as never`, result narrowed to `{ id }`. Type-only; no runtime change. Scoped `tsc` 0 + `smoke` 19/19 re-verified; tome-starter typechecks the forms source clean.
8c908d9: Initial release of `@wabbit/tome-forms` — the native foundational forms layer. A first-class layer (not a block pack, not a `@payloadcms/plugin-form-builder` wrapper): four collections (`forms`, `forms-fields`, `forms-submissions`, `forms-drafts`; `forms-` prefix reserved, all slugs overridable), a JSON-serializable conditional-logic AST evaluator, isomorphic zod validation, multi-step runtime, a field-type registry, the `tomeForm` block render surface, WCAG 2.2 AA, and theme-reactive styling. Five subpaths: `.`, `./blocks`, `./server`, `./collections`, `./test`. Sits beneath `@wabbit/tome-intake` in the acyclic chain `sites → forms → intake → onIntake → crm/marketing/lms`; forms imports none of them. Consumed downstream via intake's `onIntake` hook. Retires `YouFormBlock` + agency-essentials `FormBlock` + the `@payloadcms/plugin-form-builder` render dependency (migration tracked in spec §17; a REQUIRED `@wabbit/tome-intake` spec amendment is flagged in spec §16, to apply when that spec is next opened). Validated 2026-05-18: review + runtime smoke caught and root-fixed four runtime defects (D1 release-blocking zod-v4 `formatZodError` crash; D2 static-required emptiness; D3 guarded-derivation value clobber; D4 `defineForm` not fail-loud), plus a zod-v4 `ZodRawShape`-readonly compile fix in `zodCompiler`. Durable gate: `pnpm --filter ./packages/forms smoke` (19/19). Package is zod-v4-only (`zod` peer `^4.0.0`).
- 8c908d9: Initial release of `@wabbit/tome-forms` — the native foundational forms layer. A first-class layer (not a block pack, not a `@payloadcms/plugin-form-builder` wrapper): four collections (`forms`, `forms-fields`, `forms-submissions`, `forms-drafts`; `forms-` prefix reserved, all slugs overridable), a JSON-serializable conditional-logic AST evaluator, isomorphic zod validation, multi-step runtime, a field-type registry, the `tomeForm` block render surface, WCAG 2.2 AA, and theme-reactive styling. Five subpaths: `.`, `./blocks`, `./server`, `./collections`, `./test`. Sits beneath `@wabbit/tome-intake` in the acyclic chain `sites → forms → intake → onIntake → crm/marketing/lms`; forms imports none of them. Consumed downstream via intake's `onIntake` hook. Retires `YouFormBlock` + agency-essentials `FormBlock` + the `@payloadcms/plugin-form-builder` render dependency (migration tracked in spec §17; a REQUIRED `@wabbit/tome-intake` spec amendment is flagged in spec §16, to apply when that spec is next opened). Validated 2026-05-18: review + runtime smoke caught and root-fixed four runtime defects (D1 release-blocking zod-v4 `formatZodError` crash; D2 static-required emptiness; D3 guarded-derivation value clobber; D4 `defineForm` not fail-loud), plus a zod-v4 `ZodRawShape`-readonly compile fix in `zodCompiler`. Durable gate: `pnpm --filter ./packages/forms smoke` (19/19). Package is zod-v4-only (`zod` peer `^4.0.0`).