Documentation menu

Documentation / CRM & Marketing engine / forms

Forms

Package
@wabbit/tome-forms
Version
v0.12.2
npm install @wabbit/tome-forms
First install from our registry?
  1. Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
CRM & Marketing engineStable

Tome native form engine — admin-authored form definitions, multi-step runtime, conditional-logic expression evaluator, server-authoritative zod validation, and tomeForm block render surface.

Overview

Tome's native form engine: code-defined multi-step form definitions (defineForm), 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. Its CSS modules read @wabbit/tome-ui's Layer-2 tokens through var(--tome-*) with fallbacks, so @wabbit/tome-ui is not a peer.

What `TomeForm` renders. TomeForm takes either a form definition or a slug. A definition — from defineForm() in code, or from loadAdminForm() for a form an editor built in the admin forms collection — is rendered as given. A slug is looked up in the in-memory registry that createFormsLayer() (or its deprecated alias initForms()) seeds with your code-defined forms; an unknown slug renders a hidden placeholder <section data-tome-forms-placeholder>. Admin documents are never put into that registry automatically, so render an admin form by loading it:

// Server Component
import { loadAdminForm } from '@wabbit/tome-forms/server'

const form = await loadAdminForm({ payload, slug: 'contact' }) // published documents only
if (form) return <TomeForm form={form} action={submitContact} renderRichText={renderRichText} />

The bound action needs the same definition: build it inside your 'use server' function (submitFormAction(await loadAdminForm({ payload, slug }), payload)(formData)). The loader resolves forms-fields library references into inline fields and runs the result through defineForm, so an admin form gets the same structural validation as a code form and a malformed document throws TomeFormsConfigError. The admin Redirect URL field (submission.redirectUrl) carries over as the definition's redirectUrl: a same-origin path such as /thanks or an absolute http(s):// URL (the admin field and defineForm both reject anything else). On a successful submit the result carries it, so the browser redirects and the thankYou content is skipped — the redirect takes precedence, as the field's description says. Two things do not carry over from an admin document: a custom submission target (it needs a handler function, so loading throws) and named validation.pattern values (email/tel/url — the field type already validates those).

Code-defined forms take precedence. A slug seeded from code owns that slug in the registry. Saving an admin forms document runs its afterChange hook, which drops the registry entry for that slug only when the entry did not come from code, so an editor saving a document with the same slug as a code-defined form never blanks the live form. Only an explicit replaceForm()/invalidateForm() call changes a code-seeded entry.

Rich text. The definition's description renders above the form, and step intro and thankYou render in place, all through the renderRichText prop; without it they are omitted and a successful submit shows a plain "Thank you for your submission." line. defineForm accepts description and thankYou as any value your renderer understands. renderRichText runs on the server only: TomeForm renders the description, each step intro and the thank-you there and hands the client component the rendered nodes (the thank-you is rendered up front and shown after a successful submit), never the function, so a host can pass any renderRichText from a Server Component without hitting "Functions cannot be passed directly to Client Components". TomeFormClient's renderRichText prop is replaced by stepIntros and thankYou nodes; only code that rendered TomeFormClient directly is affected.

Redirect after submit. defineForm({ …, redirectUrl: '/thanks' }) sends the browser to that path (or absolute http(s):// URL) after every successful submit, in place of the thankYou state. When a submission target returns its own redirectUrl (a custom handler, for instance), the target's wins. Without redirectUrl, behavior is unchanged: the thank-you state shows unless the target returned a redirect.

Peer dependencies

PeerRange
@wabbit/tome-core>=1.23.0 <2.0.0
next>=15.0.0
payload>=3.67.0
react>=19.0.0
react-hook-form>=7.60.0
typescript>=5.7.0
zod^4.0.0

@wabbit/tome-core is a required peer (peerDependencies not dependencies — this package declares no dependencies field at all).

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, createFormsLayer, createFormsCollections } from '@wabbit/tome-forms'
// blocks: [tomeFormBlock], collections: [...createFormsCollections()]
// createFormsLayer(options) is the top-level layer entry — seeds + freezes the
// in-memory registries, calls createFormsCollections, registers the layer, and
// returns { collections, plugin }. initForms is a deprecated pure alias.

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} />

Stacked steps, submit note and per-placement defaults

  • `appearance.stepsDisplay`: 'wizard' (default, one step at a time) or 'stacked'. Stacked renders every step at once as a numbered <fieldset> whose <legend data-form-step-legend> holds <span data-form-step-number> and the step label, with one submit button. The submit validates every visible step; a step a skipStep rule skips stays hidden (and is not validated), and steps after the one marked isFinalStep are not shown. The root <section> gains data-form-steps="stacked".
  • `appearance.submitNote`: a line beside the submit button, <p data-form-submit-note> (e.g. a response-time promise). Shown wherever the submit button is. The button row is end-aligned by default; --tome-forms-submit-justify places it (flex-start puts the button first at the start, the note after it).
  • Checkboxes and radios show their box. @wabbit/tome-ui's base stylesheet resets every input to appearance: none with no border or fill, which left a checkbox or a radio in a form an invisible box. The field styles now restore the platform control (appearance: auto, tinted by accent-color), so an unthemed form shows its choices; a theme that draws its own box sets appearance: none again.
  • `appearance.optionalMarker`: text after each optional field's label, e.g. '(optional)', in <span data-field-optional>, styled muted (tokens --tome-forms-optional-color, --tome-forms-optional-weight, --tome-forms-optional-size). Unset shows no marker; a lone checkbox never shows it. Set it on the form (code appearance, or the admin forms Appearance group).
  • `appearance.hideSingleStepLegend`: opt-in, default off. A form with exactly one step renders <legend data-form-step-legend> with the step label, which usually only repeats the form heading. Set this to hide it visually with an sr-only class while it stays in the accessibility tree (the fieldset keeps its name for screen readers). The TomeForm prop hideSingleStepLegend sets it per placement (true or false; omit to use the form's setting). Forms with two or more steps ignore it. Set it on the form (code appearance, or the admin forms Appearance group, checkbox "Hide Legend On Single-Step Forms").
  • `fieldDefaults`: per-placement starting values, [{ name, value }], so the same form placed on two pages can arrive with different preselections. Values are text, converted for the field (true/false, numbers, a comma list for multi-choice fields, an option's value or label for selects). They layer over the definition's own defaultValues and under a draft restored from localStorage, and never replace what the visitor types.

Three of these can be set per placement on the tomeForm block (appearance.stepsDisplay, appearance.submitNote, fieldDefaults[]); the first two can also be set on the form (code appearance, or the admin forms Appearance group). Render the block with TomeFormBlock, which forwards exactly those options:

import { TomeFormBlock } from '@wabbit/tome-forms/blocks'

// in your block map
tomeForm: (block) => <TomeFormBlock {...block} action={actionFor(block.form)} renderRichText={renderRichText} />

TomeFormBlock forwards fieldDefaults, acceptPrefill, prefillNote and, from the block's appearance group, only stepsDisplay and submitNote when they are set. The block's older options (layout, progressIndicator, themeOverride) have never been forwarded, and they still are not, because they always carry their select defaults and would override the form's own settings. A block without the new options renders exactly as <TomeForm form action />. A direct TomeForm caller can pass appearance (any key with a value overrides) and fieldDefaults itself.

Starting values (a field's defaultValue and the placement's fieldDefaults) are rendered into the server HTML (selected, value, checked), so the first paint already shows them. A draft saved in localStorage is applied right after mount and wins over them.

Submitting without JavaScript

A single-step form submits with JavaScript turned off, as long as the action you pass to TomeForm is a server action (the 'use server' export shown in the quickstart). TomeForm then renders <form action={action}>, and a browser that cannot run scripts, or posts before they load, posts the form to that same action. With JavaScript running nothing changes: the client submit handler takes over, and the form behaves as it always did.

  • Several choices. multiselect and checkboxGroup post one entry per choice; the action reads them all (FormData.getAll, for any field type whose descriptor sets multiple: true), drops blanks and duplicates, and validates each against the options, so an unknown value is rejected and a required group with nothing chosen says "Required". A failed no-JavaScript post echoes the chosen set.
  • Same pipeline. The action accepts a bare FormData (the client handler) and the (previousState, formData) shape React uses for a posted form. Both go through the same sanitizing, server rule evaluation, derivations, zod validation and target dispatch, so a no-JavaScript post is validated exactly like any other.
  • After the post. If the form has a redirectUrl (or the target returns one), the action redirects server-side with Next's redirect(). Otherwise the page reloads and shows the thankYou content. A failed post comes back as the form again, with the visitor's answers kept and one message under each field that needs fixing (see "A failed post without JavaScript" below).
  • Honeypot. In this mode the _hp field sits inside the form so it is posted; on the JavaScript path the client reads it from the form's section and sends it. Either way a filled honeypot is flagged (_honeypotTriggered; flagged, not rejected).
  • What the no-JavaScript path cannot do. There is no client-side validation, live rule-driven show/hide (the rules run once per post, on the server render; see below), draft saving or time-to-submit stamp (_timeToSubmitMs arrives empty). Rate limits and CAPTCHA checks are not part of @wabbit/tome-forms: any you add around the action run on this path too, because it is the same action. A check that needs a browser script, such as a Turnstile widget, cannot pass without JavaScript, so set requireJavaScript on that form's action: submitFormAction(form, payload, { requireJavaScript: true }) refuses a no-JavaScript post with an error before anything is validated or stored (it fails closed). An intake form whose intake verification provider is not 'none' already refuses every submission from this layer, with or without JavaScript, because the layer does not forward a verification token.
  • Multi-step forms still need JavaScript. Back and Next are buttons that run script, so only a form with exactly one step renders as a real form post. Pass progressiveSubmit={false} to TomeForm to keep the JavaScript-only form for a single-step form too.
  • Default render. When the action is a plain function (not a server action), or the form has more than one step, the markup is byte-identical to before.

Number fields and their messages

number, range and rating never show zod's own wording (such as "Invalid input: expected number, received NaN"). A blank answer (empty, spaces, or the NaN an empty number input reads as) is "not provided": an optional field passes and a required one says "Required". Text that is not a number says "Enter a number." (rating: "Enter a whole number."), and min/max say "Enter a number of 1 or more." / "Enter a number of 40 or less." validation.customMessage replaces every one of these, as it does for the other types.

A failed post without JavaScript

Without script, the rules cannot react while the visitor types: a field a rule hides on the starting values is simply not on the first page. So the round trip carries the answers back to the server render:

  1. The post returns the answers and one message per field. On the form-post shape only, a failed result adds fieldErrors (field name to message, the same messages the zod schema and validation.customMessage give) and values (the visitor's own answers). The joined error string stays, for anything that reads it. A submission target's own fieldErrors (for a check only the target can make, such as a capacity limit) are passed through and win.
  2. The re-render runs the rules on those answers. The answers are laid over the starting values (definition defaultValues and the placement's fieldDefaults), so a visitor who picked a type and posted gets that type's fields on the returned page, with their answers and their messages. A field that was not on the page when it was posted keeps its starting value.
  3. Each message sits under its field, at {fieldId}-error with role="alert", tied to the control by aria-describedby (with the help text, when there is one) and aria-invalid="true". The error summary above the form lists the messages in field order, as "Label: message", each linking to its control (for a radio or checkbox set, the chosen option, else the first). The first invalid field on the page gets autofocus, so the browser lands on it with no script.

What is never echoed back: file inputs (a browser cannot prefill one), author-declared hidden fields (they re-render with the author's value, so a tampered value is not reflected), section and repeater fields, the honeypot and the other engine fields (_hp, _timeToSubmitMs, attribution), names the form does not define, a choice outside a field's options, and any field the rules hid, or a skipped step removed, on the submitted answers. So a value typed into a field that a later answer hid is dropped: it is neither echoed nor sent to the target, exactly as the server already treated it. The page re-renders the visitor's own answers and nothing else: no stored data, no other submission. The client also checks the echoed answers against the definition (known field, echoed type, right shape) before using them.

With JavaScript running none of this applies: the client submit handler calls the action with a bare FormData, gets the same result as before (no fieldErrors, no values), and validates in the browser as it always did; every render with no errors is byte-identical to before. When a Next or submit fails validation, the client shows the same error summary the round trip does (one "Label: message" line per invalid field in field order, each linking to its control, in the role="alert" box), and focus moves to that box. The list follows the visitor's fixes and is gone when the last error is. A no-JavaScript result can also reach a page where script has since loaded (React replays a form posted before hydration); it seeds the form the same way, beats an older localStorage draft, and the messages give way to the browser's own once the visitor submits again.

Limits: the round trip applies to single-step forms only (multi-step forms need JavaScript, see above). A rule that reads a field which is itself hidden still sees that field's posted value on the server, while the re-render sees its starting value, so a chain of rules (C depends on B, B hidden by A) can differ between the two; keep no-JavaScript-critical rules one level deep.

A page can send a visitor to a form with one answer already chosen. It is off until a placement sets acceptPrefill, and then it works two ways:

  • From the URL: /contact?path=project preselects the path field on load.
  • From an in-page link: <a href="#contact" data-form-prefill="path=ongoing">Write to me</a> applies the value when clicked, moves focus to the changed field, and scrolls to the form (a link with a #fragment keeps the browser's own scroll; a link to the same page with no fragment scrolls with scrollIntoView, instant under prefers-reduced-motion). A link counts only when it points at this page and either has no fragment or names an element that contains the form, so links to other pages and other anchors are left alone. Modified clicks (Ctrl, Cmd, Shift, Alt) are left to the browser.

Only the allowlisted field names are set, and a value must fit the control: a radio or select needs one of its options (matched by value, then by label), a text-like input takes the text (capped at 500 characters). Anything else is ignored. A field the visitor's saved localStorage draft already answers is left alone on load; a click on a link is an explicit choice and applies.

Set it on the tomeForm block (acceptPrefill[] rows of { field }, and an optional prefillNote) or pass acceptPrefill (names, or the block rows) and prefillNote to TomeForm. After a prefill the note is written into <p data-form-prefill-note aria-live="polite">, which is in the markup (empty) as soon as the allowlist is set so the change is announced; prefillNote is a template where {label} is the chosen option's label, and the default reads "{label} is preselected from the link you followed. Change it if you like." The field's wrapper gains data-form-prefilled. The note takes --tome-forms-prefill-note-size, --tome-forms-prefill-note-color and --tome-forms-prefill-note-gap. With no allowlist the markup is byte-for-byte what it was.

The block gains two Payload fields (acceptPrefill, prefillNote), so on Postgres generate a migration after upgrading.

TomeFormBlock also forwards three optional block fields to TomeForm: errorSummaryLabel (text; the error summary heading, with {count} for the number of invalid fields, e.g. "{count} things need fixing:"), errorSummaryLabelOne (text; the heading used when exactly one field is invalid, e.g. "1 thing needs fixing:", so the plural never reads "1 things"; empty falls back to errorSummaryLabel) and focusOnError (select, field | summary; where focus goes after a failed Next or submit with script on). All three are empty by default, and a block that leaves them empty renders byte-identically. They are plain (not localized) text and select fields; on Postgres generate a migration after upgrading.

A form that does not use the forms engine can reuse the same behavior through the framework-free helper, which lives in @wabbit/tome-core/utilities/formPrefill (free tier) and is re-exported from this package's @wabbit/tome-forms/prefill subpath. It works on the markup (it sets the control and fires input and change, so a form library sees an ordinary edit), reads its window from the element, and returns a function that removes its click listener:

import { applyFormPrefill } from '@wabbit/tome-core/utilities/formPrefill'

// in an effect, once the form is in the DOM
const stop = applyFormPrefill(formElement, {
  allow: ['path'],
  noteElement: noteElement, // an aria-live="polite" element rendered up front
  note: '{label} is preselected.',
})

Data policy: contact-only

Forms were built for lead capture, so by default (dataPolicy: 'standard') every declared value is stored and a form can ask for anything. A form, or a whole site, can opt in to 'contact-only', which limits what the form asks for and where the answers travel.

import { defineForm, createFormsLayer } from '@wabbit/tome-forms'

const inquiry = defineForm({
  slug: 'inquiry',
  dataPolicy: 'contact-only',
  dataNotice: 'Please do not include medical details here.', // optional; replaces the default notice
  steps: [
    {
      stepName: 'main',
      stepLabel: 'Get in touch',
      fields: [
        { name: 'name', type: 'text', label: 'Name', required: true },
        { name: 'email', type: 'email', label: 'Email', required: true },
        { name: 'message', type: 'textarea', label: 'Message', validation: { maxLength: 500 } },
      ],
    },
  ],
  submission: { target: 'intake' },
})

createFormsLayer({
  forms: [inquiry],
  // Site-wide: `default` applies to forms that set no policy; `lock` refuses a form that sets 'standard';
  // `sensitiveTerms` refuses a field whose name or label contains one (case-insensitive, substring).
  policy: { default: 'contact-only', lock: true, sensitiveTerms: ['symptom'], retention: { contactOnlyDays: 90 } },
})

What it does under 'contact-only':

SurfaceBehavior
Field typesAllowed: text, email, tel, hidden, select, radio, checkbox, consent, section, and one textarea with a validation.maxLength of 500 or fewer. Every other built-in type (including url, number and time), a second textarea, a library field reference in code, and a custom type that does not set contactOnlyAllowed: true on its descriptor are refused.
Sensitive termsA field name or label that contains a policy.sensitiveTerms entry is refused. The list is yours; this package ships none.
When it is checkeddefineForm throws TomeFormsConfigError at module load; createFormsLayer checks every form again once it knows the site policy; the admin forms collection refuses the save with a message naming the field, and checks forms-fields library entries where they are used.
Targetswebhook is refused. custom is refused unless the form sets dataPolicyAcknowledged: true, which records that your handler now carries responsibility for the values it receives. email is refused as well: it stores nothing and carries no details, so an inquiry would be lost; use intake or payload. The intake target forwards a contact-only marker, so @wabbit/tome-intake treats that submission as contact-only even if its own form says 'standard'.
Partial savesForced to persistence: { mode: 'none' }: nothing in localStorage, and a write to forms-drafts for the form is refused.
Logs and errorsInternal-error log lines carry the error name, not its text, and a failed storage or delivery returns a generic message to the browser.
NoticeRendered above the submit button with the hook data-form-data-notice. Default: "Please don't include sensitive personal details in this form."

What it does not do: it does not scan or filter what a person types into the message box, and it cannot stop someone entering anything there. The message is stored until a retention policy removes it, and staff can read it in the admin. It does not encrypt data beyond what your host provides, and it does not make your email provider, host or database a party to any agreement. It is not a legal or regulatory determination: using it does not make a site compliant with anything, so have counsel review how you describe and use it.

standard forms behave exactly as before. A payload target still stores the full declared payload for them.

Retention

formsRetentionPolicies({ days, formSlugs }) returns @wabbit/tome-core/gdpr/retention policy rows: hard-delete forms-submissions rows whose submittedAt is older than days, in the listed forms only. Without arguments it covers every registered contact-only form and uses policy.retention.contactOnlyDays. It builds rows and runs nothing: schedule them yourself, and run runRetention({ dryRun: true }) first to read each policy's matched count, because a hard delete is permanent.

import { formsRetentionPolicies } from '@wabbit/tome-forms'
import { defineRetentionPolicies, createRetentionEndpoint } from '@wabbit/tome-core/gdpr/retention'

const policies = defineRetentionPolicies(formsRetentionPolicies({ days: 90, formSlugs: ['inquiry'] }))
// then: createRetentionEndpoint({ policies, lock }) or createRetentionTask({ policies, lock })

createFormsLayer logs one warning at startup for a contact-only form when policy.retention.contactOnlyDays is not set; it does not refuse, because scheduling lives outside this package.

Export and erasure

createFormsLayer registers forms-submissions with core's GDPR registry, matched on a new indexed email column that is filled from the form's first email field (for every form that has one). Core's data export and account-erasure pipelines then find those rows. The column is an additive schema change, so a database that applies migrations needs one. Rows written before this release have no email value and are not found until it is backfilled.

Public API

ExportSubpathDescription
defineForm, TomeFormsConfigErrorrootValidated, deep-frozen form-definition factory (Kahn topological sort on derivations, ref/target validation). Repeaters are opaque row arrays — a field definition has no item-field list, so repeaters cannot nest
registerFieldType, freezeFieldRegistry, getFieldTypeDescriptor, listFieldTypesrootField-type registry
getForm, seedForms, replaceForm, invalidateFormrootIn-memory form-definition registry
resolveDataPolicy, isContactOnly, resolvePersistence, resolveDataNotice, getFormsSitePolicy, contactOnlyFieldProblems, CONTACT_ONLY_MESSAGE_MAX_LENGTH, DEFAULT_CONTACT_ONLY_NOTICErootData-policy helpers: the policy that applies to a form, the persistence and notice it resolves to, and the field check behind the refusals
formsRetentionPoliciesrootHard-delete retention rows for forms-submissions, in core's retention model
createFormsLayerrootLayer entry point — canonical name; initForms is a deprecated pure alias
createFormsCollectionsrootComposes all FOUR forms-* collections (forms, forms-fields, forms-submissions, forms-drafts) — distinct from createFormsCollection (singular, ./collections/forms.ts), which builds only the forms definitions collection. See the naming-hazard note in createFormsLayer's own docblock.
withFormsAccessrootApplies the correct access preset to a collection config
tomeFormBlock, createTomeFormBlockrootReact-free Payload Block config — safe for payload generate:types without pulling React
TomeForm@wabbit/tome-forms/blocksRSC shell — import this in page Server Components. form is a definition (rendered as given) or a slug / { slug } (registry lookup)
TomeFormClient@wabbit/tome-forms/blocksClient interactive form runtime (usually consumed via TomeForm, not directly)
FieldRenderer@wabbit/tome-forms/blocksPer-field-type render dispatcher
applyFormPrefill, parseFormPrefill, FORM_PREFILL_DEFAULT_NOTE@wabbit/tome-forms/prefill (also @wabbit/tome-forms/blocks); lives in @wabbit/tome-core/utilities/formPrefillFramework-free link-prefill helper: preselect allowlisted fields from ?field=value and data-form-prefill links in any rendered form
normalizeAcceptPrefill@wabbit/tome-forms/blocksThe acceptPrefill allowlist as plain field names (strings or block rows in, deduplicated names out)
submitFormAction(form, payload, opts?)@wabbit/tome-forms/serverHOF returning a bound 'use server' submit action; also accepts the (previousState, formData) shape of a form posted without JavaScript, whose failed result adds fieldErrors and values for the re-render. opts.requireJavaScript refuses that post
compileFormSchema(form, opts)@wabbit/tome-forms/serverIsomorphic zod schema compiler — safe in both RSC and client bundles
formatZodError(err)@wabbit/tome-forms/serverFormat a ZodError to a single string for client display
loadAdminForm({ payload, slug, formsSlug?, formsFieldsSlug? }), loadFormFromDocument(doc, opts?)@wabbit/tome-forms/serverTurn a published admin forms document into the TomeFormsDefinition that TomeForm and submitFormAction take; resolves undefined when no published document has the slug
evaluate, computeDerivation, resolveOperand@wabbit/tome-forms/serverPure 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@wabbit/tome-forms/collectionsDirect collection-config access
mock submission fixture./testTest 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. Uses the definition it is given, or loads it by slug via getForm(), renders the description through renderRichText, compiles the per-step schema via compileFormSchema (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 call getPayload() 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 carries no server-only import guard — the only match for "server-only" in that file is a comment ("server-only path") — but it's a Payload collection hook, never bundled client-side by construction. 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 @wabbit/tome-forms/server and consumed by the client component in the same module instance (the "dual-evaluation contract").

The root barrel deliberately excludes the React-bearing TomeForm/TomeFormClient (they live on @wabbit/tome-forms/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 createFormsLayer() time). New submission targets are a switch arm in defineForm.ts's validateSubmissionConfig plus a corresponding target module under server/targets/.

Design history

  • Original design centered on the field/rule/derivation contract: a topologically-sorted derivation graph, a pluggable field-type registry, and the RSC/client split described above (the "dual-evaluation contract" that lets the same evaluator run server-side and client-side).
  • docs/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

Import path
@wabbit/tome-forms
@wabbit/tome-forms/blocks
@wabbit/tome-forms/prefill
@wabbit/tome-forms/server
@wabbit/tome-forms/collections
@wabbit/tome-forms/test

Changelog

0.12.xMinor

  • Forms and intake gain an opt-in contact-only data policy, per form or site-wide, plus retention policies and GDPR registration for submissions.

    standard forms behave as before.

    dataPolicy: 'contact-only' (on defineForm, defineIntakeForm, or policy.default on createFormsLayer and createIntakeLayer, with policy.lock and policy.sensitiveTerms) limits a form to contact fields and one textarea of at most 500 characters, and refuses other field types, sensitive terms, a webhook target, the forms email target (use intake or payload), an unacknowledged custom target or router, and an intake email htmlBuilder. It is checked when the form is defined, when the layer is created, and when an admin form is saved.

    Under the policy, intake stores declared fields only; staff email and subject lines carry no submitted values; onIntake receives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hook data-form-data-notice). The policy reduces what a form asks for and where the answers travel.

    It does not scan what people type, and it is not a legal or regulatory determination.

    New exports: formsRetentionPolicies and intakeRetentionPolicies (hard-delete rows in core's retention model; nothing runs until you schedule them), and the data-policy helpers listed in each README. @wabbit/tome-crm takes an optional dataPolicy on the intake submission input; with 'contact-only' the form-submission activity writes no summary and intakeTagFields is not applied.

    Check this when you upgrade: forms-submissions gains an indexed email column (filled from the form's first email field), so a database that applies migrations needs one; older rows have no value until backfilled. Both packages now register their submissions collection with core's GDPR registry, matched on email, so core's data export and account erasure include those rows from now on.

2 fixes (0.12.1 – 0.12.2)
  • 0.12.2
    The changelog, README and license text now use American spellings.

    No code or behavior changes.

  • 0.12.1
    A one-step form no longer numbers its step, and a submit note that only repeats the contact-only data notice is printed once.

    In the stacked layout a single step has no "1" before its label. Under the contact-only policy the data notice above the submit button is always printed; an appearance.submitNote with exactly the same text is no longer printed a second time beside the button, and a different note still is.

0.11.xMinor

  • Submissions are now staff-only by default.

    submissionsReadAccess defaults to 'admin-only' on intake-submissions and forms-submissions; before, read and update were open to any signed-in session, which on a site with open signup means any account.

    Check this when you upgrade: if non-admin staff read submissions, pass submissionsReadAccess: 'authenticated', on a site where every account is staff.

    createIntakeLayer now grants intake:admin to the admin role at startup (new option grantRoles, default ['admin'], false to grant it yourself). Without that grant, a plain admin on a site whose roles is a relationship could not read submissions under admin-only.

    withFormsAccess's 'submissions' preset is now admin-only; 'submissions-authenticated' keeps the old rules. @wabbit/tome-intake now needs @wabbit/tome-core 1.25.0 or later.

0.10.xMinor

  • A visitor without JavaScript who posts a form with a mistake now gets the form back with their answers kept, the fields their choices call for, and a message under each field to fix.

    Before, a single-step form posted without JavaScript came back with every answer gone and one joined error line, and a field that a rule shows (say, "How many teams?" after choosing Teams) never appeared, because the rules only ran on the starting values. Now:

    • On a failed post made without JavaScript, submitFormAction's result also carries fieldErrors (one message per field) and values (the visitor's own answers). A submission target can return its own fieldErrors; they are passed through.
    • The re-rendered form lays those answers over its starting values and runs its rules on them, so the chosen type's fields render with their answers and messages. Each message is tied to its control by aria-describedby; the error summary lists them in field order with a link to each control, and the first invalid field gets autofocus.
    • Fixed: multiselect and checkboxGroup answers were refused on every submit, with or without JavaScript, because the server read only the first choice. They are now read in full, blanks and duplicates dropped, and checked against the field's options (an unknown value is rejected, as select rejects one); a required one with nothing chosen says "Required". Custom field types can opt in with the new descriptor flag multiple: true.
    • Never echoed: file inputs, author-declared hidden fields, the honeypot and other engine fields, unknown names, and any field the rules hid on the submitted answers (that value is dropped, as the server already did).

    Additive, no action needed: with JavaScript running, the client path's result and every render are byte-identical to before. The round trip covers single-step forms; multi-step forms still need JavaScript.

    New exported types: TomeFormsSubmittedValue, TomeFormsSubmittedValues.

    Also fixed, with script and without:

    • A blank number field no longer shows "Invalid input: expected number, received NaN". Blank is "not provided" (an optional number, range or rating passes; a required one says "Required" or the field's validation.customMessage), and text that is not a number says "Enter a number." (rating: "Enter a whole number.") or the author's customMessage. The min and max messages are plain too ("Enter a number of 1 or more."). Before, a required number field left blank or filled with letters on the server was read as 0 and passed.
    • With JavaScript running, a form that fails validation on Next or submit now shows the same error summary as the no-script round trip: one "Label: message" line per invalid field in field order, each linking to its control, in the role="alert" box, shown on every failure. Forms with no errors render exactly as before.

    Focus on error, with script: after a failed Next or submit, focus now goes to the first invalid field (the summary still renders, each entry linking to its field). New TomeForm prop focusOnError: 'field' | 'summary', default 'field'; 'summary' keeps the earlier behavior of focusing the summary box.

    Without script the first invalid field is still autofocused. New opt-in errorSummaryLabel sets the summary heading, with a {count} token for the number of invalid fields (for example "{count} things need fixing:"); unset keeps "Please fix the following errors:".

    Field errors from a submission target and radio-group errors, with script on:

    • A submission target's own fieldErrors (a lead time, a seated cap) now land on the named fields on the client path, as they already did without script: each field gets aria-invalid, its message under it tied by aria-describedby, a line in the error summary in field order, and the first invalid field takes focus (focusOnError, default 'field'; 'summary' still focuses the box). Before, they appeared only as a joined line in the summary and focus went to the summary. A failure with no fieldErrors is unchanged. Keys that are not a field of the form are listed as plain text.
    • A radio or checkboxGroup with an error now carries aria-invalid="true" and aria-describedby (the error message, plus any help text) on its fieldset, and aria-invalid on each option, on both paths. The group's inner role="group" no longer repeats the description while an error shows. With no error the markup is byte-identical.

    The tomeForm block carries both of those options. Two new optional block fields, errorSummaryLabel (text) and focusOnError (select, field | summary), are forwarded by TomeFormBlock to TomeForm, so a site that renders forms through the block no longer has to render TomeForm directly to set the summary heading or where focus goes.

    Both are empty by default and an empty field changes nothing: markup is byte-identical. The block gains two Payload fields, so on Postgres generate a migration after upgrading.

    The summary heading in the singular: new optional TomeForm prop and block field errorSummaryLabelOne (for example "1 thing needs fixing:") is used when exactly one field is invalid, so "{count} things need fixing:" no longer reads "1 things need fixing:". Unset (or empty) keeps errorSummaryLabel for one error too, so markup is byte-identical.

    The block gains one more Payload field, so on Postgres generate a migration after upgrading.

0.9.xMinor

  • A one-step form can now hide its step legend visually while screen readers still read it: set appearance.hideSingleStepLegend on the form, or pass hideSingleStepLegend to TomeForm.

    The legend gets the sr-only class instead of being removed, so the fieldset keeps its accessible name. The admin forms Appearance group gains a "Hide Legend On Single-Step Forms" checkbox.

    Off by default, and forms with two or more steps ignore it, so existing renders are unchanged.

  • A single-step form now submits with JavaScript turned off when you pass TomeForm a server action: it renders <form action> and posts to the same action, which validates and stores it as before.

    submitFormAction also accepts the (previousState, formData) call React uses for a posted form, follows the form's redirectUrl server-side after a successful post, and otherwise re-renders the page with the thank-you message or the validation error. The honeypot moves inside the form on this path, and submitFormAction(form, payload, { requireJavaScript: true }) refuses a no-JavaScript post, for forms guarded by a script-only check such as Turnstile.

    Multi-step forms still need JavaScript; progressiveSubmit={false} keeps a single-step form JavaScript-only. A plain function passed as action renders exactly as before.

  • A filled honeypot field is now flagged on the JavaScript submit path too; before, the client never sent it, so only a no-JavaScript post could trip it.

    The client submit reads the _hp input from the form's section and sends it with the other values, so _honeypotTriggered is set exactly as on the no-JavaScript path. The honeypot stays where it was in the markup, so default renders are unchanged.

2 fixes (0.9.1 – 0.9.2)
  • 0.9.2

    README: subpath and file mentions are written package-qualified (for example @wabbit/tome-x/render) or as code text, so crawlers no longer resolve them as site URLs.

  • 0.9.1
    Fix: a host-supplied renderRichText no longer crashes the render with "Functions cannot be passed directly to Client Components".

    TomeForm now renders the description, step intros and thank-you on the server and passes the client view the rendered nodes (stepIntros, thankYou) instead of the function. The renderRichText prop on TomeForm and TomeFormBlock is unchanged, and output without a renderRichText is byte-identical.

    TomeFormClient (normally rendered only by TomeForm) takes stepIntros and thankYou in place of its renderRichText prop.

0.8.xMinor

  • TomeForm marks the row that holds its Back, Next and Submit buttons (and the submit note) [data-form-actions], so a theme can lay the submit beside the fields without reaching it by :has(button).

    Additive: the markup is otherwise unchanged.

1 fix (0.8.1)
  • 0.8.1
    Checkboxes and radios in a form now show their box.

    @wabbit/tome-ui's base stylesheet resets every input to appearance: none with no border or fill, which left a checkbox or radio an invisible box and made accent-color do nothing. The field styles restore the platform control (appearance: auto), so an unthemed form shows its choices.

    A theme that draws its own box sets appearance: none again at higher specificity.

0.7.xMinor

  • A form placement can now preselect an answer from a link or the URL, through a per-placement acceptPrefill allowlist.
    • `acceptPrefill[]` and `prefillNote` on the `tomeForm` block (and TomeForm props of the same names). Off until acceptPrefill lists field names; with none the markup is byte-for-byte what it was. Values come from ?field=value on load and from clicks on in-page links carrying data-form-prefill="field=value" that point at the form's page or anchor. Only allowlisted fields are set, and a value must fit the control (a radio or select option, matched by value then label; text capped at 500 characters). A field a saved localStorage draft already answers is left alone on load. A click moves focus to the changed field (the radio itself when it is only visually hidden, else its label, else another control in the group) and scrolls to the form when the link has no fragment. With a fragment link the focus is kept after the browser's own hash navigation, which would otherwise drop it to body.
    • Polite note. The allowlist adds an empty <p data-form-prefill-note aria-live="polite"> to the markup so the change is announced; prefillNote is its template ({label} is the chosen option's label). The field wrapper gains data-form-prefilled. New tokens --tome-forms-prefill-note-size, --tome-forms-prefill-note-color and --tome-forms-prefill-note-gap.
    • Framework-free helper in core, re-exported here. applyFormPrefill(root, { allow, note, noteElement, search, skip, links }), parseFormPrefill, FORM_PREFILL_DEFAULT_NOTE and FORM_PREFILL_MAX_LENGTH live in @wabbit/tome-core/utilities/formPrefill, so a form that does not use the forms engine (for example a Form Builder form) can reuse them without this package. @wabbit/tome-forms/prefill re-exports them, and ./blocks re-exports them with normalizeAcceptPrefill. The @wabbit/tome-core peer floor is now >=1.23.0.
    • Step hooks. data-form-step-count="<n>" on the form root (the steps the visitor will see), data-form-step-legend on every step legend (a wizard's too; a stacked form already had it) and data-form-fields on a step's field container. They carry no styling here: a theme can, for example, hide the legend of a one-step form, which only repeats the form's own heading. Nothing else in the markup changes; a render with these three attributes removed is byte-for-byte the previous output (pinned by the golden files).

    Migration: the tomeForm block gains two optional Payload fields, so Postgres sites that register it should run payload migrate:create and payload migrate after upgrading. Nothing changes for a placement that leaves them empty.

0.6.xMinor

  • Fixes found by the Counsel theme demo, plus an optional-field marker.
    • Public pages with a `tomeForm` block no longer 500. The forms collection's public read filtered on _status, a drafts field this collection doesn't have (its publish state is the status select), so every anonymous read failed, including the population of a tomeForm block's form. Anonymous reads now filter on status = published; signed-in reads are unchanged. A site that patched the read access itself can drop the patch.
    • Required text-like fields refuse a blank value. A blank required text/textarea beside an invalid field passed silently (zod skips an object's refinements once any field fails), and a whitespace-only value always passed. Required text, textarea, email, tel, url, richtext, select, radio, date, datetime and time fields now fail a blank value at the field with the field's validation.customMessage, or Required. A blank required email now says Required rather than Invalid email address.
    • A select or radio uses its custom message. validation.customMessage now replaces zod's Invalid option: expected one of …, which listed every raw option value to the visitor. Without a custom message the default stands.
    • New `appearance.optionalMarker`. Text after each optional field's label, such as (optional), in <span data-field-optional>, styled muted through --tome-forms-optional-color, --tome-forms-optional-weight and --tome-forms-optional-size. Unset (the default) renders nothing, so existing forms are unchanged. The admin forms collection gains an Optional Marker text field in its Appearance group: on Postgres, generate a migration.
1 fix (0.6.1)
  • 0.6.1
    New token --tome-forms-submit-justify places the form's button row; the default (flex-end) is unchanged.

    A theme that sets the submit button first with the submitNote after it, at the start of the row, sets --tome-forms-submit-justify: flex-start. Before, only a structural selector on the row could move it.

0.5.xMinor

  • Counsel v1 chrome and forms hooks.

    Every new field is optional, and existing output is unchanged when it is empty.

    Migration: the new Payload fields add columns and tables. Sites that register the Footer global, the forms collection or the tomeForm block must run their Payload migration (payload migrate:create, then payload migrate) and regenerate types (payload generate:types) after upgrading. The footer column-row link group becomes conditional (hidden on text rows), so its columns become nullable in that migration; rowType defaults to link, so existing rows stay links.

    @wabbit/tome-chrome

    • Navbar 7 `data-nav-scrolled`. Navbar 7's existing isScrolled state (page scrolled past 20px) is exposed as data-nav-scrolled="true" / "false" on its <nav> root ("false" at rest and on the server render) and mirrored onto the header frame (the element carrying data-visible / data-overlay / data-nav-theme) once the navbar hydrates. Frames around the other navbars never carry it. Custom navbars can opt in with the new useReportNavScrolled(isScrolled) export.
    • Footer `finePrint` (array of { lead?, text }, admin-visible for designVersion 7): small-type paragraphs Footer 7 renders below the link columns and above the bottom bar as [data-footer-fine-print], each lead in [data-footer-fine-print-lead]. Tokens --tome-footer-fine-print-size (default 0.75rem) and --tome-footer-fine-print-measure (default 72ch). Exported as FooterFinePrint for other variants.
    • Plain-text column rows. navItems[].subNavItems[] gains rowType (link default | text), text and an optional value. A text row renders <span data-footer-text-row>; with a value it is a label/value pair, <dl data-footer-text-row data-footer-pair><dt>…</dt><dd>…</dd></dl> (Footer 7: label column at least --tome-footer-pair-label-min, default 5.5rem). All eleven footers render both row types through the new shared FooterColumnRow; link rows render byte-identically. New types: TomeFooterColumnRow, TomeFooterLinkRow, TomeFooterTextRow, TomeFooterFinePrintParagraph.
    • Navbar 7 theme tokens: --tome-header-pad-block (header row block padding, default 2rem, mobile bottom half of it), --tome-header-curtain-radius (default 1.5rem) and --tome-header-curtain-shadow (default the previous shadow). Computed styles are unchanged when they are unset. The curtain radius moves from an inline style to the stylesheet, so Navbar 7's curtain style attribute no longer carries border-bottom-*-radius, and a non-default desktopBreakpoint override writes the padding through the same token.
    • Navbar 7 curtain fill and blur tokens: --tome-header-curtain-bg (the solid curtain's fill when the nav background is transparent, default the previous 80% page ground) and --tome-header-curtain-blur (its backdrop blur, default 12px, also used by the token-background states). Computed styles are unchanged when they are unset.
    • `aria-current="page"` on any NavLink (every navbar and footer) that points at the current pathname (root-relative, trailing slash and query ignored, #fragment and external links excluded). Links to the current page gain the attribute, an intended markup change; a caller's own aria-current wins.
    • `header.menuLabel` (text, localized, admin-visible for Navbar 7): optional visible text in the menu button beside the icon, [data-menu-label]. When set it is the button's accessible name; unset keeps the icon-only button and its "Toggle menu" name.

    @wabbit/tome-forms

    • `appearance.stepsDisplay: 'wizard' | 'stacked'` (default wizard, today's behavior). Stacked renders every step as a numbered <fieldset> (<legend data-form-step-legend> with <span data-form-step-number>), one submit button, and validates every visible step on submit; steps a skipStep rule skips stay hidden and unvalidated, and steps after the isFinalStep step are not shown. Root hook data-form-steps="stacked" on the <section>. defineForm rejects other values.
    • `appearance.submitNote`: a line beside the submit button, [data-form-submit-note].
    • Both are on the code appearance config, the admin forms collection's Appearance group, and the tomeForm block's appearance group (no block default, so an empty option keeps the form's own setting).
    • `tomeForm` block `fieldDefaults[] { name, value }`: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's defaultValues and under a restored draft, so the visitor's own input always wins.
    • `TomeFormBlock` (@wabbit/tome-forms/blocks): the tomeForm block renderer. It forwards fieldDefaults and only the block's stepsDisplay / submitNote when set, so a site gets the stacked intake just by setting the block options. The block's older appearance options (layout, progressIndicator, themeOverride) were never forwarded and still are not, so a block without the new options renders byte-identically. TomeForm itself gains appearance and fieldDefaults props for direct callers; without them it renders exactly as before. Also exported: pickBlockAppearance, mergeAppearance.
    • Starting values are server-rendered. A field's defaultValue and the placement's fieldDefaults are written into the initial HTML (<option selected>, value, checked), so there is no placeholder flash before hydration. Fields without a starting value render unchanged; forms whose fields declare a defaultValue now show it in the server HTML.
    • A saved `localStorage` draft is now restored right after mount (with reset()), instead of during the first client render. The first client render now matches the server HTML, where the draft read caused a hydration mismatch before. The draft still wins over starting values.
    • BREAKING: `react-hook-form` peer floor raised from `>=7.0.0` to `>=7.60.0` (devDependency ^7.60.0). The draft restore calls reset(values, { keepFieldsRef: true }), and 7.60.0 is the first release whose reset honors keepFieldsRef (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.

0.4.xMinor

  • Forms now honor a post-submit redirectUrl: set it in defineForm or in the admin form's Redirect URL field, and a successful submit redirects there instead of showing the thank-you message.

    redirectUrl must be a same-origin path (/thanks) or an absolute http(s):// URL; defineForm and the admin field reject anything else. loadAdminForm now maps the admin field, which it previously dropped.

    A redirectUrl returned by the submission target itself still takes precedence, and forms without one behave as before.

    An admin form saved before this release with a Redirect URL that fails that rule now throws TomeFormsConfigError from loadAdminForm; fix the value in the admin and re-publish.

  • CSS files are now copied to dist/ by a post-build script instead of a tsup onSuccess hook; no behavior change, and the published dist/ is identical.

1 fix (0.4.1)
  • 0.4.1
    Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps.

    What you install: one compiled .js (ESM) and .cjs (CommonJS) file per source module, its .d.ts / .d.cts declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: /*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.

    The .map files and the sourceMappingURL comments that pointed at them are gone, which roughly halves the size of each tarball.

    Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line.

    A 'use client' directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged.

    No API change, no runtime behavior change, and nothing to do on upgrade.

    In @wabbit/tome-blocks-gallery, the source snapshots extractGallerySource writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

0.3.xMinor

  • Naming convergence (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.

  • 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.

  • 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
18 fixes (0.3.1 – 0.3.8)
  • 0.3.8
    Admin-authored forms can now be rendered, and saving one no longer blanks a code-defined form with the same slug.

    Admin-authored forms can now be rendered: loadAdminForm / loadFormFromDocument (from @wabbit/tome-forms/server) turn a published forms document into a validated TomeFormsDefinition, and TomeForm's form prop now also accepts a definition, which it renders without a registry lookup. Saving an admin forms document no longer evicts a code-defined form with the same slug from the in-memory registry — code-seeded slugs take precedence.

    TomeForm renders the form's description through renderRichText; description and thankYou are now typed on TomeFormsConfig/TomeFormsDefinition, and a success state without thankYou content shows the default thank-you line even when a renderer is supplied. The no-op repeater-depth check in defineForm is removed: repeaters are opaque rows and cannot nest.

  • 0.3.8

    Drops the unused @wabbit/tome-ui peer dependency; nothing in the package imported it.

  • 0.3.8
    Internal refactor: collection slugs are now typed through the shared typedSlug() helper instead of inline casts.

    No API or behavior change.

  • 0.3.8

    customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.

  • 0.3.7
    Fix optional fields (tel, email, url, number, select, date, datetime, time) rejecting blank input.

    A field declared required: false still failed validation when left empty — e.g. an optional "Phone" field showed "Invalid phone number" and blocked submission even when untouched, because compileFormSchema wrapped non-required fields with .optional(), which accepts undefined but not the empty string "" an untouched HTML input actually submits. Non-required, non-hidden fields now normalize "", whitespace-only strings, and null to undefined before the field-type rule runs, so leaving an optional field blank passes and a genuinely invalid value (e.g. a malformed phone number) still fails.

    Applies identically server-side (submitFormAction) and client-side (TomeForm.client.tsx's inline resolver), since both call compileFormSchema. Stored submission data for a skipped optional field is now undefined/omitted rather than a persisted empty string.

  • 0.3.6

    TomeForm's focus/announce effect now tracks the step index directly instead of a first-render flag, since the prior guard still re-fired on the render right after mount and stole focus/scroll on load.

  • 0.3.5

    TomeForm no longer steals focus and scrolls the page on first render — only on an actual step change.

  • 0.3.4

    Fix the multi-step form's fields container collapsing to zero width by clearing the full-width .stepLegend float on .stepIntro and .fieldsContainer.

  • 0.3.3
    dist now raw-Node loadable: relative specifiers get explicit extensions post-build.

    build gains && node ../../scripts/fix-dist-extensions.mjs --strict as its last step, joining the 13 packages that already ran it. tsup builds bundle: false and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM ERR_MODULE_NOT_FOUND; CJS worse, require('./x') finds the ESM .js twin and Node 22+ require(esm) then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node, generate:types, generate:importmap, ops scripts, codegen tools.

    No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.

    Two supporting changes made the wiring possible, both in repo scripts rather than package source. fix-dist-extensions.mjs now skips bundler-asset specifiers (.css, .module.css, .scss, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since --strict exited 1 on any package with a relative stylesheet import.

    Dotted MODULE names (./config.meta, ./x.variants, ./y.demo) are deliberately NOT treated as assets and still get .js/.cjs appended. assert-node-loadable.mjs gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under node_modules is a peer SKIP (next@15 has no exports map, so next/image fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as SyntaxError: Unexpected token '.' raised from inside the stylesheet).

    Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a directory import missing /index). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.

  • 0.3.3
    Outbound webhooks now time out.

    dispatchToWebhook called fetch with no AbortSignal, so a submission target that accepted the connection and never answered held the submitter's own request open for as long as the runtime's fetch default allowed. The URL is operator-configured but third-party: this was a denial of service available to whoever the operator pasted into a form config, and the audit (§7) had already pinned the absence in a test as a standing ticket.

    New default behavior: every request carries AbortSignal.timeout(10_000). TomeFormsSubmissionTargetWebhook gains an optional timeoutMs to raise or lower it per target, and DEFAULT_WEBHOOK_TIMEOUT_MS is exported so a consumer can read the default rather than restate it.

    Ten seconds is long enough for a cold serverless receiver to wake up and short enough that a hung endpoint is not the submitter's problem.

    A timeout surfaces through the same never-throws shape as every other delivery failure — { ok: false, error: 'Webhook did not respond within 10000ms' }. The raw TimeoutError/AbortError message is replaced because it is runtime-dependent and says nothing about which webhook failed; the duration is the part an operator can act on.

    Nothing about the success path, the non-2xx path, the 200-character body truncation, or the deliberate pass-through of a throwing payloadTransform changed.

    The pinning test flipped with it: webhook-target.test.ts asserted init.signal was undefined; it now asserts the signal is present and unaborted, that the default is 10s, that timeoutMs overrides it, and that both TimeoutError and AbortError map to the duration-naming failure result. 18 tests pass. A future change that drops the signal fails here.

  • 0.3.3
    Put both packages' admin checks on core's primitive, and fix the role-singular defect in forms (2026-09-01 sale-readiness audit §5.2, §7, T3(g)).

    Both of these WIDEN who counts as an admin. Neither narrows it — no session that passed before fails now.

    `@wabbit/tome-intake`. withIntakeAccess's requireAdmin matched a roles array against three literal strings and carried a TODO at the top of the file: "wire to tome-core capability registry when intake:admin capability is seeded". The TODO is discharged and deleted; the check is sessionHasCapabilityOrLegacyAdmin(req, 'intake:admin') from @wabbit/tome-core/auth/repScoping, the same primitive crm and deals use.

    `@wabbit/tome-forms`. buildDraftScopedAccess read user.role — SINGULAR. Every sibling layer, and core's own capability engine, read the roles ARRAY.

    That is a defect, not a style difference: on a site whose users carry roles: ['admin'] — the shape core's legacy fallback and five other layers assume — this gate saw no admin at all, and every admin silently received the rep-scoped { user: { equals: self } } Where clause instead of unrestricted access. It failed CLOSED, which is why nobody noticed: a form draft an admin could not see looked exactly like a draft that did not exist.

    It is now sessionHasCapabilityOrLegacyAdmin(req, 'forms:admin').

    The primitive answers in order: a real capability grant via canAsync (through _populatedRoles, the enriched role: string[], or a role fetch on req.payload; a populated super-admin role matches every capability by the engine's wildcard rule), then the legacy bootstrap fallback — a roles ARRAY containing admin / superadmin / super-admin.

    Neither package declares its capability. Grep for defineCapabilities finds it in core's defaults and in @wabbit/tome-lms, nowhere else — so intake:admin and forms:admin are not in any default vocabulary, and on a site that has not granted them itself the capability branch always misses and the legacy role array is what actually makes admin access work. That is not a defect; it is the bootstrap path, and it is why a site can install either layer and have a working admin gate on day one.

    Both file headers say so in as many words, so the next reader does not mistake the capability name for a wired-up feature. A site that DOES seed and grant the capability now gets it honored, which is what intake's TODO was asking for.

    The exact widening, per package:

    • intake — a role holding an intake:admin grant passes; a populated super-admin role passes; the enriched role: string[] shape is consulted where it was previously ignored. Unchanged: create is denied for EVERYONE including admins (every legitimate write goes through submitIntakeAction with overrideAccess: true, which is what makes the verify→persist pipeline unbypassable); the default preset still lets any authenticated session read and update; the legacy branch is still case-sensitive, still requires an array, still rejects near-misses.
    • forms — roles: ['admin' | 'superadmin' | 'super-admin'] now passes (the defect fix); a forms:admin grant passes; a populated super-admin role passes. No longer special: role: 'admin' as a bare STRING, which the old code coerced into a one-element array and matched. No consumer in this repo writes that shape and core's engine does not read it, so such a session now falls through to the scoped Where clause. Unchanged: anonymous denied outright, non-admins scoped to their own drafts, create denied for everyone, delete admin-only, and the anonymous save/resume flow which goes through the server actions with overrideAccess: true + HMAC verification and never touched this gate.

    Both checks are asynchronous now, because the capability engine may resolve roles through req.payload; Payload access functions may return a promise, so this changes each module's internals, not its contract.

    Tests: intake/tests/access.test.ts was written by an earlier wave to pin the old heuristic "exactly as it behaves today, including the parts that are arguably wrong", explicitly so that "the capability wiring, when it lands, arrives as a deliberate diff against a stated baseline." This is that diff — the file is rewritten as the NEW truth table, with every moved row labeled and every unchanged row labeled. forms/tests/draft-access.test.ts is new and does the same job for the drafts preset.

    Both vitest configs gain a bare @wabbit/tome-core → source alias, which is load-bearing rather than cosmetic: the capability GRANT REGISTRY is module-scoped state, so a partial alias would split it in two and make the capability rows pass for the wrong reason.

  • 0.3.3
    Pin each layer's registered version to package.json instead of a hand-typed literal.

    registerLayer(name, { version }) is the contract a consumer reads back through hasLayer/getLayer to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed silently — nothing throws when a version string is stale.

    PackageRegisteredActual
    @wabbit/tome-rpg'0.1.2'0.2.2
    @wabbit/tome-gamification'0.1.0'0.3.1
    @wabbit/tome-crm'0.3.0'0.5.0
    @wabbit/tome-ai'0.1.0'0.4.0
    @wabbit/tome-formsTOME_FORMS_VERSION = '0.1.0'0.3.2
    @wabbit/tome-intakeTOME_INTAKE_VERSION = '0.1.0'0.3.1
    @wabbit/tome-marketing'0.1.0'0.4.0
    @wabbit/tome-chrome'0.6.0'0.8.5

    Each package now carries a leaf src/version.ts exporting <NAME>_LAYER_VERSION, read by its registerLayer call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local TOME_*_VERSION consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is.

    The forcing function ships with the fix. pnpm assert:layer-version (new, wired into platform-discipline.yml pre-build) parses every registerLayer call in the repo, resolves its version argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test.

    Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run.

    crm, forms, intake, marketing and rpg gained their first test suite in the process (tests/layer-version.test.ts) and were removed from the assert:test-floor starting-debt allowlist.

    No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.

  • 0.3.3
    Manifest metadata: homepage, bugs, engines.

    All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change.

    • homepage deep-links to that package README on GitHub (.../tree/main/packages/<dir>#readme). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want.
    • bugs.url points at the repo issue tracker, so a paying customer has a place to report a defect that is not email.
    • engines.node is >=22, matching the root engines and .nvmrc set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for node --test, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version.

    The forcing function ships with the change: scripts/assert-manifest-metadata.mjs (root pnpm assert:manifest-metadata, wired into platform-discipline.yml beside assert:license-metadata) fails when any publishable manifest lacks description, repository.directory matching its own folder, homepage, bugs, engines.node equal to the repo floor, license, files or sideEffects. It reported 138 violations before this change and 0 after.

  • 0.3.3
    `normalizeEmail` adoption on the email fields the 2026-09-01 audit flagged, plus the one swallowed error that had no logger.

    Email is the cross-layer join key — forms hands a submission to intake, intake to CRM contact matching, CRM to marketing suppression — and findContactByEmail / matchOrCreateContact normalize before they query. Any layer that stores a raw address forks the same person into two records at the first hand-off.

    • intake — intake-submissions.email (required, indexed, the CRM join column) now normalizes through core's canonical normalizeEmail via a new normalizeEmailField field hook. Field-level rather than folded into beforeValidateIntake, which returns early on anything but a create: an admin retyping an address on update is exactly the case a create-only hook misses.
    • forms — the emailRecipient config field gains the same hook, and the email field-type descriptor's sanitize stops hand-rolling v.trim().toLowerCase() and delegates to normalizeEmail. That fork agreed byte-for-byte today, which is the problem: the day the shared helper learns anything, forms silently stops agreeing. (assert:no-forked-primitives catches exact-body forks, not inline expressions like this one.)
    • crm — record-crm-activity-with-dedup.ts's account-resolution catch was the one swallowed error in the repo's sample with no logger call at all (audit §6). The swallow is correct — an activity row without an account link beats a dropped webhook — but a contacts lookup failing there is normally a slug misconfiguration or a permissions change, and every later activity lands unlinked until someone notices. It now warns through req.payload.logger.warn with a console.warn fallback, naming the contact and the slug, matching access/presets.ts and hooks/stage-change-dispatch.ts.

    Normalization stays deliverable-address preserving (trim + lowercase only; no dot-stripping, no plus-tag removal) — asserted, because the stored value is what gets emailed.

  • 0.3.3
    README fixes surfaced by the extended assert:readme-contract gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source.
    • blocks-core — the ./categories and ./types entry points are now named in the Public API section; both were published but undocumented.
    • core — added /access/orgScoped, /access/vendorScoped, /infra/health and /infra/env-scaffold to the additional-subpaths table, and noted that /auth/collections/roles has a real /auth/collections/Roles case alias in the exports map.
    • crowdfund — CROWDFUND_LAYER_VERSION is also published standalone at ./version; the row now says so.
    • dispatch — the eight per-block ./blocks/* config subpaths and all eight ./components/* component subpaths are enumerated instead of one "etc." row.
    • forms — the peer table now lists @wabbit/tome-core, @wabbit/tome-ui and typescript, which are declared peerDependencies but appeared only in prose (or not at all).
    • lms-ui — StudentProfileEditor is flagged @deprecated in the component table, matching the tag its source already carries.
  • 0.3.3
    First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7).

    No behavior changed in marketing or intake; forms and crm ship one behavior change each, described below and covered by the same suites.

    • marketing — webhooks/verify-utils gets published HMAC-SHA256 known-answer vectors (RFC 4231 TC2, quick-brown-fox) plus a cross-check against node:crypto as an independent oracle, and a full contract table for timingSafeEqualHex (case folding, single-nibble mismatch, length short-circuit, and the fact that it does not validate hex — two empty strings compare equal, so callers must check presence first). Both adapters' verify functions are covered end to end: valid token accepts, one-character change rejects, missing/empty/differently-cased header rejects, malformed URL fails closed, and the unconfigured-secret pass-through is asserted explicitly rather than left implicit. The secret (Encharge) vs webhookSecret (Kit) parameter-name split is pinned as a contract, not harmonised: passing the other package's key name leaves the secret undefined, which means bypass mode — a rename would open both endpoints silently. Also documents a real Web Crypto/node divergence: an empty secret THROWS rather than signing, which is the fail-closed outcome and is now pinned.
    • intake — withIntakeAccess gets the full truth table: create denied for everyone including admins (all writes go through submitIntakeAction with overrideAccess: true), read/update per preset, delete admin-only regardless of preset, plus wrapper semantics (overrides incoming access, shallow clone, defines exactly four keys). The file's TODO: wire to tome-core capability registry is untouched — the suite pins the CURRENT roles-array heuristic, including its case-sensitivity and the fact that it ignores role/_populatedRoles, so the wiring change arrives as a deliberate diff.
    • forms — server/targets/webhook covered for request shape (method, header merge and override, payloadTransform) and every failure path (non-2xx with detail, 200-char body truncation, body-read failure, network rejection, non-Error throw, never throwing to the caller). The absence of any timeout is asserted explicitly rather than glossed: fetch is called with no AbortSignal, so a hanging endpoint hangs the submission — that assertion is the ticket, and it flips loudly when a timeout lands.
    • crm — access/presets covered for all three paths: seeded capability grants, the legacy admin/superadmin/super-admin roles-array fallback, and the bootstrap fallback that opens crm:read to any authenticated session. The last one is asserted in both directions — what it opens (read on every collection) and what it still refuses (write, delete) — because an un-seeded production site is running on it. buildAccountDeleteGuard and the once-per-process production warning are covered too.

    All four packages gain a test script (vitest run) and a vitest devDependency, and are removed from scripts/assert-test-floor.mjs's ALLOWLIST — a stale allowlist entry fails the assert in both directions.

  • 0.3.2
    Add a submissionsReadAccess option to initForms/initIntake (and their underlying collection factories).

    Set it to 'admin-only' to restrict read (and update) of forms-submissions / intake-submissions to admins, so non-admin authenticated sessions — e.g. a public demo login — cannot read inbound submission PII via the data API. Defaults to 'authenticated', preserving the existing contract.

    Delete remains admin-only regardless.

    The feature code merged to main previously but was never released; this changeset ships it as the first published version to carry it.

  • 0.3.1
    forms-fields collection: explicit GraphQL type names (<PascalSlug>Def/<PascalSlug>Defs).

    Payload's policies builder emits a ${TypeName}Fields type for every collection, so any <forms>+<forms>-fields slug pair collided ("Schema must contain uniquely named types but contains multiple types named 'TomeFormsFields'") and broke the consumer's entire GraphQL endpoint at boot. The default forms/forms-fields slugs had the same inherent collision.

0.1.xMinor

  • 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; a REQUIRED @wabbit/tome-intake spec amendment is flagged, 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).

6 fixes (0.1.1 – 0.1.8)
  • 0.1.8
    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.

  • 0.1.8

    Updated dependencies [bed3f90]

  • 0.1.8
    Updated dependencies [850d51c]
    • @wabbit/tome-core@1.2.1
    • @wabbit/tome-ui@0.9.7
  • 0.1.3
    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.

    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.

  • 0.1.2
    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.

  • 0.1.1
    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.

Full changelog across all packages