Documentation menu
Documentation / CRM & Marketing engine / forms
Forms
npm install @wabbit/tome-formsFirst install from our registry?
Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
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
| Peer | Range |
|---|---|
@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 askipSteprule skips stays hidden (and is not validated), and steps after the one markedisFinalStepare not shown. The root<section>gainsdata-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-justifyplaces it (flex-startputs 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 toappearance: nonewith 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 byaccent-color), so an unthemed form shows its choices; a theme that draws its own box setsappearance: noneagain. - `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 (codeappearance, or the adminformsAppearance 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). TheTomeFormprophideSingleStepLegendsets it per placement (trueorfalse; omit to use the form's setting). Forms with two or more steps ignore it. Set it on the form (codeappearance, or the adminformsAppearance 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 owndefaultValues and under a draft restored fromlocalStorage, 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.
multiselectandcheckboxGrouppost one entry per choice; the action reads them all (FormData.getAll, for any field type whose descriptor setsmultiple: 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'sredirect(). Otherwise the page reloads and shows thethankYoucontent. 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
_hpfield 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 (
_timeToSubmitMsarrives 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 setrequireJavaScripton 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). Anintakeform 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}toTomeFormto 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:
- 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 andvalidation.customMessagegive) andvalues(the visitor's own answers). The joinederrorstring stays, for anything that reads it. A submission target's ownfieldErrors(for a check only the target can make, such as a capacity limit) are passed through and win. - The re-render runs the rules on those answers. The answers are laid over the starting values (definition
defaultValues and the placement'sfieldDefaults), 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. - Each message sits under its field, at
{fieldId}-errorwithrole="alert", tied to the control byaria-describedby(with the help text, when there is one) andaria-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 getsautofocus, 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.
Link prefill
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=projectpreselects thepathfield 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#fragmentkeeps the browser's own scroll; a link to the same page with no fragment scrolls withscrollIntoView, instant underprefers-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':
| Surface | Behavior |
|---|---|
| Field types | Allowed: 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 terms | A field name or label that contains a policy.sensitiveTerms entry is refused. The list is yours; this package ships none. |
| When it is checked | defineForm 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. |
| Targets | webhook 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 saves | Forced to persistence: { mode: 'none' }: nothing in localStorage, and a write to forms-drafts for the form is refused. |
| Logs and errors | Internal-error log lines carry the error name, not its text, and a failed storage or delivery returns a generic message to the browser. |
| Notice | Rendered 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
| Export | Subpath | Description |
|---|---|---|
defineForm, TomeFormsConfigError | root | Validated, 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, listFieldTypes | root | Field-type registry |
getForm, seedForms, replaceForm, invalidateForm | root | In-memory form-definition registry |
resolveDataPolicy, isContactOnly, resolvePersistence, resolveDataNotice, getFormsSitePolicy, contactOnlyFieldProblems, CONTACT_ONLY_MESSAGE_MAX_LENGTH, DEFAULT_CONTACT_ONLY_NOTICE | root | Data-policy helpers: the policy that applies to a form, the persistence and notice it resolves to, and the field check behind the refusals |
formsRetentionPolicies | root | Hard-delete retention rows for forms-submissions, in core's retention model |
createFormsLayer | root | Layer entry point — canonical name; initForms is a deprecated pure alias |
createFormsCollections | root | Composes 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. |
withFormsAccess | root | Applies the correct access preset to a collection config |
tomeFormBlock, createTomeFormBlock | root | React-free Payload Block config — safe for payload generate:types without pulling React |
TomeForm | @wabbit/tome-forms/blocks | RSC shell — import this in page Server Components. form is a definition (rendered as given) or a slug / { slug } (registry lookup) |
TomeFormClient | @wabbit/tome-forms/blocks | Client interactive form runtime (usually consumed via TomeForm, not directly) |
FieldRenderer | @wabbit/tome-forms/blocks | Per-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/formPrefill | Framework-free link-prefill helper: preselect allowlisted fields from ?field=value and data-form-prefill links in any rendered form |
normalizeAcceptPrefill | @wabbit/tome-forms/blocks | The acceptPrefill allowlist as plain field names (strings or block rows in, deduplicated names out) |
submitFormAction(form, payload, opts?) | @wabbit/tome-forms/server | HOF 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/server | Isomorphic zod schema compiler — safe in both RSC and client bundles |
formatZodError(err) | @wabbit/tome-forms/server | Format a ZodError to a single string for client display |
loadAdminForm({ payload, slug, formsSlug?, formsFieldsSlug? }), loadFormFromDocument(doc, opts?) | @wabbit/tome-forms/server | Turn 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/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 | @wabbit/tome-forms/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. Uses the definition it is given, or loads it by slug viagetForm(), renders thedescriptionthroughrenderRichText, 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 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-onlydata policy, per form or site-wide, plus retention policies and GDPR registration for submissions.standardforms behave as before.dataPolicy: 'contact-only'(ondefineForm,defineIntakeForm, orpolicy.defaultoncreateFormsLayerandcreateIntakeLayer, withpolicy.lockandpolicy.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 formsemailtarget (useintakeorpayload), an unacknowledged custom target or router, and an intake emailhtmlBuilder. 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;
onIntakereceives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hookdata-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:
formsRetentionPoliciesandintakeRetentionPolicies(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-crmtakes an optionaldataPolicyon the intake submission input; with'contact-only'theform-submissionactivity writes no summary andintakeTagFieldsis not applied.Check this when you upgrade:
forms-submissionsgains an indexedemailcolumn (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 onemail, 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.submitNotewith 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.
submissionsReadAccessdefaults to'admin-only'onintake-submissionsandforms-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.createIntakeLayernow grantsintake:adminto theadminrole at startup (new optiongrantRoles, default['admin'],falseto grant it yourself). Without that grant, a plain admin on a site whoserolesis a relationship could not read submissions underadmin-only.withFormsAccess's'submissions'preset is now admin-only;'submissions-authenticated'keeps the old rules.@wabbit/tome-intakenow needs@wabbit/tome-core1.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 carriesfieldErrors(one message per field) andvalues(the visitor's own answers). A submission target can return its ownfieldErrors; 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 getsautofocus. - Fixed:
multiselectandcheckboxGroupanswers 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, asselectrejects one); a required one with nothing chosen says "Required". Custom field types can opt in with the new descriptor flagmultiple: true. - Never echoed: file inputs, author-declared
hiddenfields, 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,rangeorratingpasses; a required one says "Required" or the field'svalidation.customMessage), and text that is not a number says "Enter a number." (rating: "Enter a whole number.") or the author'scustomMessage. Theminandmaxmessages 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
TomeFormpropfocusOnError: '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
errorSummaryLabelsets 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 getsaria-invalid, its message under it tied byaria-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 nofieldErrorsis unchanged. Keys that are not a field of the form are listed as plain text. - A
radioorcheckboxGroupwith an error now carriesaria-invalid="true"andaria-describedby(the error message, plus any help text) on its fieldset, andaria-invalidon each option, on both paths. The group's innerrole="group"no longer repeats the description while an error shows. With no error the markup is byte-identical.
The
tomeFormblock carries both of those options. Two new optional block fields,errorSummaryLabel(text) andfocusOnError(select,field|summary), are forwarded byTomeFormBlocktoTomeForm, so a site that renders forms through the block no longer has to renderTomeFormdirectly 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
TomeFormprop and block fielderrorSummaryLabelOne(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) keepserrorSummaryLabelfor one error too, so markup is byte-identical.The block gains one more Payload field, so on Postgres generate a migration after upgrading.
- On a failed post made without JavaScript,
0.9.xMinor
A one-step form can now hide its step legend visually while screen readers still read it: set
appearance.hideSingleStepLegendon the form, or passhideSingleStepLegendtoTomeForm.The legend gets the sr-only class instead of being removed, so the fieldset keeps its accessible name. The admin
formsAppearance 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
TomeForma server action: it renders<form action>and posts to the same action, which validates and stores it as before.submitFormActionalso accepts the(previousState, formData)call React uses for a posted form, follows the form'sredirectUrlserver-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, andsubmitFormAction(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 asactionrenders 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
_hpinput from the form's section and sends it with the other values, so_honeypotTriggeredis 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
renderRichTextno longer crashes the render with "Functions cannot be passed directly to Client Components".TomeFormnow 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. TherenderRichTextprop onTomeFormandTomeFormBlockis unchanged, and output without arenderRichTextis byte-identical.TomeFormClient(normally rendered only byTomeForm) takesstepIntrosandthankYouin place of itsrenderRichTextprop.
0.8.xMinor
TomeFormmarks 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 toappearance: nonewith no border or fill, which left a checkbox or radio an invisible box and madeaccent-colordo 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: noneagain at higher specificity.
0.7.xMinor
A form placement can now preselect an answer from a link or the URL, through a per-placement
acceptPrefillallowlist.- `acceptPrefill[]` and `prefillNote` on the `tomeForm` block (and
TomeFormprops of the same names). Off untilacceptPrefilllists field names; with none the markup is byte-for-byte what it was. Values come from?field=valueon load and from clicks on in-page links carryingdata-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 savedlocalStoragedraft 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 tobody. - Polite note. The allowlist adds an empty
<p data-form-prefill-note aria-live="polite">to the markup so the change is announced;prefillNoteis its template ({label}is the chosen option's label). The field wrapper gainsdata-form-prefilled. New tokens--tome-forms-prefill-note-size,--tome-forms-prefill-note-colorand--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_NOTEandFORM_PREFILL_MAX_LENGTHlive 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/prefillre-exports them, and./blocksre-exports them withnormalizeAcceptPrefill. The@wabbit/tome-corepeer 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-legendon every step legend (a wizard's too; a stacked form already had it) anddata-form-fieldson 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
tomeFormblock gains two optional Payload fields, so Postgres sites that register it should runpayload migrate:createandpayload migrateafter upgrading. Nothing changes for a placement that leaves them empty.- `acceptPrefill[]` and `prefillNote` on the `tomeForm` block (and
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
formscollection's public read filtered on_status, a drafts field this collection doesn't have (its publish state is thestatusselect), so every anonymous read failed, including the population of atomeFormblock'sform. Anonymous reads now filter onstatus = 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/textareabeside an invalid field passed silently (zod skips an object's refinements once any field fails), and a whitespace-only value always passed. Requiredtext,textarea,email,tel,url,richtext,select,radio,date,datetimeandtimefields now fail a blank value at the field with the field'svalidation.customMessage, orRequired. A blank required email now saysRequiredrather thanInvalid email address. - A select or radio uses its custom message.
validation.customMessagenow replaces zod'sInvalid 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-weightand--tome-forms-optional-size. Unset (the default) renders nothing, so existing forms are unchanged. The adminformscollection gains an Optional Marker text field in its Appearance group: on Postgres, generate a migration.
- Public pages with a `tomeForm` block no longer 500. The
1 fix (0.6.1)
- 0.6.1
New token
--tome-forms-submit-justifyplaces the form's button row; the default (flex-end) is unchanged.A theme that sets the submit button first with the
submitNoteafter 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
formscollection or thetomeFormblock must run their Payload migration (payload migrate:create, thenpayload migrate) and regenerate types (payload generate:types) after upgrading. The footer column-rowlinkgroup becomes conditional (hidden on text rows), so its columns become nullable in that migration;rowTypedefaults tolink, so existing rows stay links.@wabbit/tome-chrome- Navbar 7 `data-nav-scrolled`. Navbar 7's existing
isScrolledstate (page scrolled past 20px) is exposed asdata-nav-scrolled="true"/"false"on its<nav>root ("false"at rest and on the server render) and mirrored onto the header frame (the element carryingdata-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 newuseReportNavScrolled(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(default0.75rem) and--tome-footer-fine-print-measure(default72ch). Exported asFooterFinePrintfor other variants. - Plain-text column rows.
navItems[].subNavItems[]gainsrowType(linkdefault |text),textand an optionalvalue. A text row renders<span data-footer-text-row>; with avalueit 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, default5.5rem). All eleven footers render both row types through the new sharedFooterColumnRow; link rows render byte-identically. New types:TomeFooterColumnRow,TomeFooterLinkRow,TomeFooterTextRow,TomeFooterFinePrintParagraph. - Navbar 7 theme tokens:
--tome-header-pad-block(header row block padding, default2rem, mobile bottom half of it),--tome-header-curtain-radius(default1.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 curtainstyleattribute no longer carriesborder-bottom-*-radius, and a non-defaultdesktopBreakpointoverride 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, default12px, 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,#fragmentand external links excluded). Links to the current page gain the attribute, an intended markup change; a caller's ownaria-currentwins. - `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 askipSteprule skips stay hidden and unvalidated, and steps after theisFinalStepstep are not shown. Root hookdata-form-steps="stacked"on the<section>.defineFormrejects other values. - `appearance.submitNote`: a line beside the submit button,
[data-form-submit-note]. - Both are on the code
appearanceconfig, the adminformscollection's Appearance group, and thetomeFormblock'sappearancegroup (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): thetomeFormblock renderer. It forwardsfieldDefaultsand only the block'sstepsDisplay/submitNotewhen 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.TomeFormitself gainsappearanceandfieldDefaultsprops for direct callers; without them it renders exactly as before. Also exported:pickBlockAppearance,mergeAppearance. - Starting values are server-rendered. A field's
defaultValueand the placement'sfieldDefaultsare 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 adefaultValuenow 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 callsreset(values, { keepFieldsRef: true }), and 7.60.0 is the first release whoseresethonorskeepFieldsRef(absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.
- Navbar 7 `data-nav-scrolled`. Navbar 7's existing
0.4.xMinor
Forms now honor a post-submit
redirectUrl: set it indefineFormor in the admin form's Redirect URL field, and a successful submit redirects there instead of showing the thank-you message.redirectUrlmust be a same-origin path (/thanks) or an absolutehttp(s)://URL;defineFormand the admin field reject anything else.loadAdminFormnow maps the admin field, which it previously dropped.A
redirectUrlreturned 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
TomeFormsConfigErrorfromloadAdminForm; fix the value in the admin and re-publish.CSS files are now copied to
dist/by a post-build script instead of a tsuponSuccesshook; no behavior change, and the publisheddist/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.ctsdeclarations, 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
.mapfiles and thesourceMappingURLcomments 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 snapshotsextractGallerySourcewrites 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
@deprecatedalias 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 gaincreate*Collectionnames for their formerdefine*Collectionfactories. Layer entries converge oncreateXLayer(config?) → bundle:createCrmLayer/createDealsLayer/createMarketingLayer/createCatalogLayer/createEconomyLayer/createChromeLayer/createLmsLayer/createAiLayer(+createFormsLayer/createIntakeLayer), returning bareCollectionConfig[]where the layer contributes only collections or an honest named bundle where it hands back more (chrome:{ globals }; lms/ai:{ collections, hooks }); void-returninginitCatalog/initEconomystay as the single registration call sites, delegated to internally.Naming note for forms consumers:
createFormsCollection(singular factory) vscreateFormsCollections(plural composer) vscreateFormsLayer(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'
FieldRendereris wrapped inReact.memo(call-site props verified stable), cutting whole-step re-render work per keystroke in multi-field forms. lms-ui'suseLearnerPrefsgains optionalinitialPrefsserver-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 anycasts eliminated); marketing-starter's PricingPlans extracts its GSAP toggle timeline hook + a memoized card. rpg additionally trusts the denormalizedxpTotalon 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 publishedformsdocument into a validatedTomeFormsDefinition, andTomeForm'sformprop now also accepts a definition, which it renders without a registry lookup. Saving an adminformsdocument no longer evicts a code-defined form with the same slug from the in-memory registry — code-seeded slugs take precedence.TomeFormrenders the form'sdescriptionthroughrenderRichText;descriptionandthankYouare now typed onTomeFormsConfig/TomeFormsDefinition, and a success state withoutthankYoucontent shows the default thank-you line even when a renderer is supplied. The no-op repeater-depth check indefineFormis removed: repeaters are opaque rows and cannot nest. - 0.3.8
Drops the unused
@wabbit/tome-uipeer 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: falsestill failed validation when left empty — e.g. an optional "Phone" field showed "Invalid phone number" and blocked submission even when untouched, becausecompileFormSchemawrapped non-required fields with.optional(), which acceptsundefinedbut not the empty string""an untouched HTML input actually submits. Non-required, non-hidden fields now normalize"", whitespace-only strings, andnulltoundefinedbefore 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 callcompileFormSchema. Stored submission data for a skipped optional field is nowundefined/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
.stepLegendfloat on.stepIntroand.fieldsContainer. - 0.3.3
dist now raw-Node loadable: relative specifiers get explicit extensions post-build.
buildgains&& node ../../scripts/fix-dist-extensions.mjs --strictas its last step, joining the 13 packages that already ran it. tsup buildsbundle: falseand emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESMERR_MODULE_NOT_FOUND; CJS worse,require('./x')finds the ESM.jstwin 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.mjsnow 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--strictexited 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/.cjsappended.assert-node-loadable.mjsgained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands undernode_modulesis a peer SKIP (next@15 has no exports map, sonext/imagefails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it asSyntaxError: 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 importmissing/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.
dispatchToWebhookcalledfetchwith noAbortSignal, 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).TomeFormsSubmissionTargetWebhookgains an optionaltimeoutMsto raise or lower it per target, andDEFAULT_WEBHOOK_TIMEOUT_MSis 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 rawTimeoutError/AbortErrormessage 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
payloadTransformchanged.The pinning test flipped with it:
webhook-target.test.tsassertedinit.signalwas undefined; it now asserts the signal is present and unaborted, that the default is 10s, thattimeoutMsoverrides it, and that bothTimeoutErrorandAbortErrormap 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'srequireAdminmatched arolesarray 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 issessionHasCapabilityOrLegacyAdmin(req, 'intake:admin')from@wabbit/tome-core/auth/repScoping, the same primitive crm and deals use.`@wabbit/tome-forms`.
buildDraftScopedAccessreaduser.role— SINGULAR. Every sibling layer, and core's own capability engine, read therolesARRAY.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 enrichedrole: string[], or a role fetch onreq.payload; a populatedsuper-adminrole matches every capability by the engine's wildcard rule), then the legacy bootstrap fallback — arolesARRAY containingadmin/superadmin/super-admin.Neither package declares its capability. Grep for
defineCapabilitiesfinds it in core's defaults and in@wabbit/tome-lms, nowhere else — sointake:adminandforms:adminare 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:admingrant passes; a populatedsuper-adminrole passes; the enrichedrole: string[]shape is consulted where it was previously ignored. Unchanged:createis denied for EVERYONE including admins (every legitimate write goes throughsubmitIntakeActionwithoverrideAccess: 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); aforms:admingrant passes; a populatedsuper-adminrole 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,createdenied for everyone,deleteadmin-only, and the anonymous save/resume flow which goes through the server actions withoverrideAccess: 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.tswas 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.tsis 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. - intake — a role holding an
- 0.3.3
Pin each layer's registered version to
package.jsoninstead of a hand-typed literal.registerLayer(name, { version })is the contract a consumer reads back throughhasLayer/getLayerto 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.Package Registered Actual @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.tsexporting<NAME>_LAYER_VERSION, read by itsregisterLayercall — 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-localTOME_*_VERSIONconsts 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 intoplatform-discipline.ymlpre-build) parses everyregisterLayercall in the repo, resolves itsversionargument 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 theassert:test-floorstarting-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.
homepagedeep-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.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --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(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. 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/matchOrCreateContactnormalize 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 canonicalnormalizeEmailvia a newnormalizeEmailFieldfield hook. Field-level rather than folded intobeforeValidateIntake, 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
emailRecipientconfig field gains the same hook, and the email field-type descriptor'ssanitizestops hand-rollingv.trim().toLowerCase()and delegates tonormalizeEmail. 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-primitivescatches exact-body forks, not inline expressions like this one.) - crm —
record-crm-activity-with-dedup.ts's account-resolutioncatchwas 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 throughreq.payload.logger.warnwith aconsole.warnfallback, naming the contact and the slug, matchingaccess/presets.tsandhooks/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.
- intake —
- 0.3.3
README fixes surfaced by the extended
assert:readme-contractgate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source.- blocks-core — the
./categoriesand./typesentry points are now named in the Public API section; both were published but undocumented. - core — added
/access/orgScoped,/access/vendorScoped,/infra/healthand/infra/env-scaffoldto the additional-subpaths table, and noted that/auth/collections/roleshas a real/auth/collections/Rolescase alias in the exports map. - crowdfund —
CROWDFUND_LAYER_VERSIONis 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-uiandtypescript, which are declaredpeerDependenciesbut appeared only in prose (or not at all). - lms-ui —
StudentProfileEditoris flagged@deprecatedin the component table, matching the tag its source already carries.
- blocks-core — the
- 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-utilsgets published HMAC-SHA256 known-answer vectors (RFC 4231 TC2, quick-brown-fox) plus a cross-check againstnode:cryptoas an independent oracle, and a full contract table fortimingSafeEqualHex(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. Thesecret(Encharge) vswebhookSecret(Kit) parameter-name split is pinned as a contract, not harmonised: passing the other package's key name leaves the secretundefined, 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 —
withIntakeAccessgets the full truth table:createdenied for everyone including admins (all writes go throughsubmitIntakeActionwithoverrideAccess: 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'sTODO: wire to tome-core capability registryis untouched — the suite pins the CURRENT roles-array heuristic, including its case-sensitivity and the fact that it ignoresrole/_populatedRoles, so the wiring change arrives as a deliberate diff. - forms —
server/targets/webhookcovered 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-Errorthrow, never throwing to the caller). The absence of any timeout is asserted explicitly rather than glossed:fetchis called with noAbortSignal, so a hanging endpoint hangs the submission — that assertion is the ticket, and it flips loudly when a timeout lands. - crm —
access/presetscovered for all three paths: seeded capability grants, the legacyadmin/superadmin/super-adminroles-array fallback, and the bootstrap fallback that openscrm:readto 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.buildAccountDeleteGuardand the once-per-process production warning are covered too.
All four packages gain a
testscript (vitest run) and a vitest devDependency, and are removed fromscripts/assert-test-floor.mjs's ALLOWLIST — a stale allowlist entry fails the assert in both directions. - marketing —
- 0.3.2
Add a
submissionsReadAccessoption toinitForms/initIntake(and their underlying collection factories).Set it to
'admin-only'to restrict read (and update) offorms-submissions/intake-submissionsto 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}Fieldstype for every collection, so any<forms>+<forms>-fieldsslug 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 defaultforms/forms-fieldsslugs 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-builderwrapper): 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, thetomeFormblock render surface, WCAG 2.2 AA, and theme-reactive styling. Five subpaths:.,./blocks,./server,./collections,./test.Sits beneath
@wabbit/tome-intakein the acyclic chainsites → forms → intake → onIntake → crm/marketing/lms; forms imports none of them. Consumed downstream via intake'sonIntakehook.Retires
YouFormBlock+ agency-essentialsFormBlock+ the@payloadcms/plugin-form-builderrender dependency (migration tracked; a REQUIRED@wabbit/tome-intakespec 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
formatZodErrorcrash; D2 static-required emptiness; D3 guarded-derivation value clobber; D4defineFormnot fail-loud), plus a zod-v4ZodRawShape-readonly compile fix inzodCompiler. Durable gate:pnpm --filter ./packages/forms smoke(19/19).Package is zod-v4-only (
zodpeer^4.0.0).
6 fixes (0.1.1 – 0.1.8)
- 0.1.8
Docs-manifest emitter pipeline (W3 ship-readiness).
@wabbit/tome-blocks-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-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
./metapayload-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'sBlockMetawas 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.mdto its publishedfilesarray 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 bysubmitFormAction.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 exportssubmitFormAction(...)'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-demonow writes a row toforms-submissionsend-to-end. - 0.1.2
Re-export the React-free
tomeFormBlock/createTomeFormBlockblock config from the package root (.).Registry consumers can now register the
tomeFormblock in a Payload config / Pages layout without importing the./blocksbarrel (which pulls the React render components + CSS modules and breakspayload generate:types). The component side stays in./blocks.Surfaced by tome-starter moving to registry consumption —
@wabbit/tome-forms/blocks/tomeFormBlockis 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
CollectionSlugisstring; in a consumer with generatedpayload-types.tsit 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 throughas unknown as.server/targets/payload.ts— dynamic-slugpayload.createcollection/datacastas never, result narrowed to{ id }.
Type-only; no runtime change. Scoped
tsc0 +smoke19/19 re-verified; tome-starter typechecks the forms source clean.