Forms

CRM & Marketing engineStable
@wabbit/tome-formsv0.6.1

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

Install
  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
  3. Then install:

    npm install @wabbit/tome-forms

Overview

@wabbit/tome-forms

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.

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, behaviour is unchanged: the thank-you state shows unless the target returned a redirect.

Install

pnpm add @wabbit/tome-forms

| Peer | Range | |---|---| | @wabbit/tome-core | >=1.0.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).
  • `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).
  • `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.

All three 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 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.

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 | | 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 | ./blocks | RSC shell — import this in page Server Components. form is a definition (rendered as given) or a slug / { slug } (registry lookup) | | TomeFormClient | ./blocks | Client interactive form runtime (usually consumed via TomeForm, not directly) | | FieldRenderer | ./blocks | Per-field-type render dispatcher | | submitFormAction(form, payload) | ./server | HOF returning a bound 'use server' submit action | | compileFormSchema(form, opts) | ./server | Isomorphic zod schema compiler — safe in both RSC and client bundles | | formatZodError(err) | ./server | Format a ZodError to a single string for client display | | loadAdminForm({ payload, slug, formsSlug?, formsFieldsSlug? }), loadFormFromDocument(doc, opts?) | ./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 | ./server | Pure conditional-logic evaluator — re-exported here (not just internal) so TomeForm.client.tsx can import it for client-side advisory rule evaluation from the same subpath | | createFormsCollections field configs | ./collections | Direct collection-config access | | mock submission fixture | ./test | Test helper |

Server / client posture

The block render layer is a deliberate two-file split:

  • `blocks/TomeForm.server.tsx` — no 'use client' directive; a genuine RSC. 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 ./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 ./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

  • @wabbit/tome-forms
  • @wabbit/tome-forms/blocks
  • @wabbit/tome-forms/server
  • @wabbit/tome-forms/collections
  • @wabbit/tome-forms/test

Changelog

v0.6.1patch

aa3e5f8: 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.

  • aa3e5f8: 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.
v0.6.0minor

6f067d6: 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.

  • 6f067d6: 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.
v0.5.0minor

0d925a4: 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 behaviour). 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 `defaultValue`s 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` honours `keepFieldsRef` (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.

  • 0d925a4: 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 behaviour). 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 `defaultValue`s 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` honours `keepFieldsRef` (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.
v0.4.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence 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 licence: `/*! @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 behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence 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.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence 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 licence: `/*! @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 behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence 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.
v0.4.0minor

bb354ae: Forms now honour 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.

  • bb354ae: Forms now honour 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.
  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
v0.3.8patch

02f05ab: 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.

  • 02f05ab: 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.
  • 0aa80a3: Drops the unused `@wabbit/tome-ui` peer dependency; nothing in the package imported it.
  • b47f122: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.
  • 520bcbd: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.3.7patch

d5303ec: 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.

  • d5303ec: 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.
v0.3.6patch

ddbde22: 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.

  • ddbde22: 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.
v0.3.5patch

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

  • 0d5b2e3: TomeForm no longer steals focus and scrolls the page on first render — only on an actual step change.
v0.3.4patch

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

  • f54c4c3: Fix the multi-step form's fields container collapsing to zero width by clearing the full-width `.stepLegend` float on `.stepIntro` and `.fieldsContainer`.
v0.3.3patch

0836ef5: 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.

  • 0836ef5: 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.
  • 73081e6: 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 behaviour:** 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.
  • ce3d12d: 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 honoured, 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 labelled and every unchanged row labelled. `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.
  • b01ca1f: 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. | 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-forms` | `TOME_FORMS_VERSION = '0.1.0'` | 0.3.2 | | `@wabbit/tome-intake` | `TOME_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.
  • 73081e6: 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.
  • 670d2a1: **`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.
  • 090e984: 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.
  • 670d2a1: First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7). No behaviour changed in marketing or intake; forms and crm ship one behaviour 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.
v0.3.2patch

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.

  • 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.
v0.3.1patch

3109a55: 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.

  • 3109a55: 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.
v0.3.0minor

6bc419c: 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.

  • 6bc419c: 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.
  • a93f478: Re-render and cleanup fixes: chrome's HeaderClient dead theme state + unreachable effect deleted; Navbar6/7 body-scroll-lock now saves and restores the pre-existing overflow value (LearnerSidebar pattern) instead of clobbering to ''; Navbar7's scroll listener is rAF-throttled. marketing-starter's Testimonial derives the clamped slide index during render instead of an effect. forms' `FieldRenderer` is wrapped in `React.memo` (call-site props verified stable), cutting whole-step re-render work per keystroke in multi-field forms. lms-ui's `useLearnerPrefs` gains optional `initialPrefs` server-seeding (non-breaking) + in-flight dedup with TTL for the unseeded path.
  • aef2725: Monolith decompositions (behavior- and markup-preserving; public APIs unchanged; markup identity mechanically verified per file): forms' FieldRenderer 633→84 via a field-control registry + shared FieldChrome (consent/checkbox byte-identical branches merged) and TomeForm 656→451 via four extracted hooks (the ordering-critical resolver sync deliberately stays inline, documented); rpg's CharacterSheet 841→130 across panels + three editing hooks + persistence hook (the StrictMode XP-ledger charRef guard preserved verbatim); gallery's GalleryIndex 1032→431 (BlockThumb/BlockCard/Toolbar/useFilteredCatalog siblings, T2's debounce+memo preserved); webgl's WebglCanvasProvider 938→546 (useTransitionOrchestrator + useCanvasRenderer extracted; settle thresholds hoisted to named consts); admin's mergeAdminComponents 828→404 orchestrator + four helpers (all docblocks relocated, 717 tests unmodified) and Nav's config-reading now typed (6 of 8 `as any` casts eliminated); marketing-starter's PricingPlans extracts its GSAP toggle timeline hook + a memoized card. rpg additionally trusts the denormalized `xpTotal` on sheet load/save hot paths (full recompute stays at the XP-recording reconciliation point).
  • Updated dependencies [6bc419c]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-core@1.4.0 - @wabbit/tome-ui@0.9.9
v0.1.8patch

bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

  • bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
  • Updated dependencies [bed3f90]
  • Updated dependencies [850d51c] - @wabbit/tome-core@1.2.1 - @wabbit/tome-ui@0.9.7
v0.1.3patch

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.

  • 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.
v0.1.2patch

Re-export the React-free `tomeFormBlock` / `createTomeFormBlock` block config from the package **root** (`.`). Registry consumers can now register the `tomeForm` block in a Payload config / Pages layout without importing the `./blocks` barrel (which pulls the React render components + CSS modules and breaks `payload generate:types`). The component side stays in `./blocks`. Surfaced by tome-starter moving to registry consumption — `@wabbit/tome-forms/blocks/tomeFormBlock` is not an exported subpath, so the React-free config needs a first-class export.

  • Re-export the React-free `tomeFormBlock` / `createTomeFormBlock` block config from the package **root** (`.`). Registry consumers can now register the `tomeForm` block in a Payload config / Pages layout without importing the `./blocks` barrel (which pulls the React render components + CSS modules and breaks `payload generate:types`). The component side stays in `./blocks`. Surfaced by tome-starter moving to registry consumption — `@wabbit/tome-forms/blocks/tomeFormBlock` is not an exported subpath, so the React-free config needs a first-class export.
v0.1.1patch

Fix three fat-slug type defects the package's own scoped typecheck cannot catch (in isolation Payload's `CollectionSlug` is `string`; in a consumer with generated `payload-types.ts` it narrows to a union). Surfaced by the first real consumer (tome-starter): - `blocks/tomeFormBlock.ts` — `relationTo: formsSlug as CollectionSlug`. - `hooks/beforeChange.ts` — forms-drafts upsert read cast through `as unknown as`. - `server/targets/payload.ts` — dynamic-slug `payload.create` `collection`/`data` cast `as never`, result narrowed to `{ id }`. Type-only; no runtime change. Scoped `tsc` 0 + `smoke` 19/19 re-verified; tome-starter typechecks the forms source clean.

  • Fix three fat-slug type defects the package's own scoped typecheck cannot catch (in isolation Payload's `CollectionSlug` is `string`; in a consumer with generated `payload-types.ts` it narrows to a union). Surfaced by the first real consumer (tome-starter): - `blocks/tomeFormBlock.ts` — `relationTo: formsSlug as CollectionSlug`. - `hooks/beforeChange.ts` — forms-drafts upsert read cast through `as unknown as`. - `server/targets/payload.ts` — dynamic-slug `payload.create` `collection`/`data` cast `as never`, result narrowed to `{ id }`. Type-only; no runtime change. Scoped `tsc` 0 + `smoke` 19/19 re-verified; tome-starter typechecks the forms source clean.
v0.1.0minor

8c908d9: Initial release of `@wabbit/tome-forms` — the native foundational forms layer. A first-class layer (not a block pack, not a `@payloadcms/plugin-form-builder` wrapper): four collections (`forms`, `forms-fields`, `forms-submissions`, `forms-drafts`; `forms-` prefix reserved, all slugs overridable), a JSON-serializable conditional-logic AST evaluator, isomorphic zod validation, multi-step runtime, a field-type registry, the `tomeForm` block render surface, WCAG 2.2 AA, and theme-reactive styling. Five subpaths: `.`, `./blocks`, `./server`, `./collections`, `./test`. Sits beneath `@wabbit/tome-intake` in the acyclic chain `sites → forms → intake → onIntake → crm/marketing/lms`; forms imports none of them. Consumed downstream via intake's `onIntake` hook. Retires `YouFormBlock` + agency-essentials `FormBlock` + the `@payloadcms/plugin-form-builder` render dependency (migration tracked; 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`).

  • 8c908d9: Initial release of `@wabbit/tome-forms` — the native foundational forms layer. A first-class layer (not a block pack, not a `@payloadcms/plugin-form-builder` wrapper): four collections (`forms`, `forms-fields`, `forms-submissions`, `forms-drafts`; `forms-` prefix reserved, all slugs overridable), a JSON-serializable conditional-logic AST evaluator, isomorphic zod validation, multi-step runtime, a field-type registry, the `tomeForm` block render surface, WCAG 2.2 AA, and theme-reactive styling. Five subpaths: `.`, `./blocks`, `./server`, `./collections`, `./test`. Sits beneath `@wabbit/tome-intake` in the acyclic chain `sites → forms → intake → onIntake → crm/marketing/lms`; forms imports none of them. Consumed downstream via intake's `onIntake` hook. Retires `YouFormBlock` + agency-essentials `FormBlock` + the `@payloadcms/plugin-form-builder` render dependency (migration tracked; 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`).