> For the complete documentation index, see [llms.txt](https://kb.craftformswp.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://kb.craftformswp.com/documentation/for-developers/ai-form-builder-guide.md).

# AI Form Builder Guide

A reference for building CraftForms starters and demo pages using WP CLI and block HTML.

***

### Abilities API (WordPress 6.9+) <a href="#abilities-api-wordpress-69" id="abilities-api-wordpress-69"></a>

[CraftForms registers two abilities](#abilities-api-wordpress-69) with the WordPress Abilities API. **For AI agents, abilities are the preferred way to create forms and add fields** — they handle post creation, meta setup, and block HTML generation correctly without requiring WP CLI access.

#### Registered abilities <a href="#registered-abilities" id="registered-abilities"></a>

| Ability                  | What it does                                                                                                                                   |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `craftforms/create-form` | Creates a `craftforms_form` post, sets `_craftforms_form_meta` (formula, transformations, submit actions), and scaffolds minimal block content |
| `craftforms/add-field`   | Generates correct block HTML for a field type and inserts it into the form's `post_content` before the submit button                           |

#### What the abilities can and cannot do <a href="#what-the-abilities-can-and-cannot-do" id="what-the-abilities-can-and-cannot-do"></a>

Abilities generate guaranteed-valid block markup, so prefer them **for the field types they cover**. They are deliberately a small surface — know the ceiling before you plan a build around them:

|                 | Covered by abilities                                                   | Not covered — author blocks yourself                                                                          |
| --------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Field types** | `text`, `email`, `number`, `textarea`, `select`, `radio`, `checkboxes` | datepicker, file upload, range slider, color picker, repeater, payment, WooCommerce, order summary, infoblock |
| **Structure**   | append a field before the submit button                                | reorder, edit, or delete a field; layout groups; multi-step                                                   |
| **Pricing**     | `formula` + `transformations` at **create time only**                  | any later change to formula, smart variables, or conditional logic                                            |

`craftforms/add-field` is append-only. There is no ability to modify or remove a field once added — an agent can build up, not refactor.

#### Ability vs WP CLI <a href="#ability-vs-wp-cli" id="ability-vs-wp-cli"></a>

* **Use abilities** for the seven basic field types — they handle post creation, meta, ARIA wiring, and block validation correctly with no chance of a markup mistake.
* **Use WP CLI + hand-authored block HTML** for everything else: any field type in the table above, any edit to an existing form, and any complete starter file. This is the majority of real builds — follow the block patterns in this guide and heed the two CRITICAL sections below.
* A common, effective pattern is **both**: scaffold with `create-form` (which gets meta and the form wrapper right), then hand-author the remaining field blocks into `post_content`.

#### Calling abilities over MCP <a href="#calling-abilities-over-mcp" id="calling-abilities-over-mcp"></a>

Abilities are a registry, not an MCP server. To let an MCP client (Claude, Cursor, etc.) discover and call them, install the [WordPress MCP Adapter](https://github.com/WordPress/mcp-adapter) and register a server that allowlists the CraftForms abilities. Note that MCP tool names are slugified — `craftforms/create-form` is exposed as `craftforms-create-form`. See [doc page regarding Abilities API](#abilities-api-wordpress-69) for the server-registration snippet.

#### Authoring conventions still apply <a href="#authoring-conventions-still-apply" id="authoring-conventions-still-apply"></a>

When `craftforms/add-field` generates block HTML, it follows the same patterns described in this guide (label + input + form-error structure, ARIA attributes on radio/checkboxes wrappers, `name[]` suffix for checkboxes, etc.). If you are generating block HTML yourself outside of the ability (e.g. for a full starter file), continue to follow the patterns in this guide.

#### Further reference <a href="#further-reference" id="further-reference"></a>

See [doc page regarding Abilities API](#abilities-api-wordpress-69) for the full input/output schema for each ability.

***

### CRITICAL: Forms must be `craftforms_form` posts <a href="#critical-forms-must-be-craftforms_form-posts" id="critical-forms-must-be-craftforms_form-posts"></a>

**Forms MUST be created as `craftforms_form` custom post type posts — never as regular pages or any other post type.**

* All form logic (`_craftforms_form_meta`, formula, transformations, actions) lives on a `craftforms_form` CPT post.
* A regular WordPress page should never have form block HTML inserted directly via WP CLI `--post_content`. This does not work — the form meta is detached from the block and the form cannot function.
* The correct way to place a form on a page is to use the block editor, insert the `craftforms/form` block, and set its `ref` attribute to the `craftforms_form` post ID. The page itself holds no form logic — only a reference.
* **For starters:** use the CraftForms import UI to import the HTML starter into a new `craftforms_form` post. The `<!--craftforms-meta-->` block in the starter file is parsed on import to set `_craftforms_form_meta` automatically.
* Do not use WP CLI `wp post create --post_type=page` to deploy a form. Only use `wp post create --post_type=craftforms_form` to create the form post itself.

***

### CRITICAL: Anything set via WP CLI MUST conform to what the editor UI produces <a href="#critical-anything-set-via-wp-cli-must-conform-to-what-the-editor-ui-produces" id="critical-anything-set-via-wp-cli-must-conform-to-what-the-editor-ui-produces"></a>

WP CLI bypasses the import healing and the editor's save pipeline, so it is easy to write data the UI cannot read back or that fails block validation. Two rules:

1. **Form meta must be stored as a PHP array, not a JSON string** — always pass `--format=json` to `wp post meta update` . A string round-trips on the frontend but is invisible in the editor's Smart Variables / Price Formula panels.
2. **Every value you put in block HTML must have a matching block-comment attribute** the field's `save()` knows about, and vice-versa — otherwise the editor throws "Block validation failed". The canonical example is the required message on radio/checkboxes (`requiredMessage` ⇄ `data-validate-minselected-message`). Whenever you hand-write a `data-validate-*-message` (or any non-default field attribute) into HTML, set the corresponding block-comment attribute to the **same** value. The attribute names that back each validation message are listed in Validation messages.

After any WP CLI authoring, open the form once in the editor to confirm it loads without a validation notice and that formula/variables/messages appear in the panels.

***

### CRITICAL: Export the form — don't hand-write the starter file <a href="#critical-export-the-form--dont-hand-write-the-starter-file" id="critical-export-the-form--dont-hand-write-the-starter-file"></a>

Once a form is built and verified in the editor, **use the Export button rather than hand-authoring the starter HTML**. In the form editor sidebar, open the **Export** panel and click **Export Form**; you get `form-{id}.craftform.html` in exactly the starter format described in this guide.

This matters for correctness, not just convenience. The export pipeline (`Form_Importer::get_form_export_data()` → `render_form_file()`) is a normalisation pass:

* **It repairs string meta.** If `_craftforms_form_meta` was stored as a JSON string (the rule-1 mistake above), export decodes it and writes clean JSON into the `<!--craftforms-meta-->` block. The imported copy comes back as a proper array.
* **It re-serialises meta from the database**, so the exported file reflects what the editor actually read — not what you hoped you wrote.
* **It strips the `ref` attribute** from the `craftforms/form` block. `ref` is the form's own post ID and must not be carried between installs; import re-assigns it to the new post.
* **It embeds images** as base64 in a `<!--craftforms-assets-->` block, so the file is self-contained and sideloads on import (deduplicated).
* **It sets `Version:` by feature detection**, not by your licence — `detect_form_version()` returns `pro` if the form has a `formula`, `transformations`, conditional-logic rules, a `user_registration`/`create_post` submit action, or a `craftforms/file-field`. Otherwise `free`. A `pro` file is refused on import into a free install, so a form with a price formula is not portable to free regardless of where it was exported from.

**Treat a successful export as the conformance check.** The recommended loop for agent-authored forms:

1. Build the form locally (abilities and/or WP CLI).
2. Open it in the block editor — confirm no validation notice, and that formula/variables/messages appear in the panels.
3. **Export.** The downloaded file is the reviewable, diffable, version-controllable deliverable.
4. Import on the target site (or drop it into `starters/forms/` to ship it as a starter).

Hand-writing the starter file skips every guarantee in that list. Build it, verify it, export it.

***

### Overview <a href="#overview" id="overview"></a>

* **Starters** are block HTML files in `starters/forms/` with an embedded `<!--craftforms-meta-->` JSON block that the system imports into `_craftforms_form_meta` on the `craftforms_form` CPT post
* **Form meta** (formula, transformations, actions) lives in `_craftforms_form_meta` post meta on a `craftforms_form` CPT post
* The `ref` attribute in `craftforms/form` = the `craftforms_form` post ID
* **Infoblock** uses `connectedForm` attribute = same form post ID; renders inner content as a live-updating template

***

### Authoring Conventions <a href="#authoring-conventions" id="authoring-conventions"></a>

Rules to follow when creating CraftForms starters.

#### Transformation type priority <a href="#transformation-type-priority" id="transformation-type-priority"></a>

Choose the most appropriate type — in order of preference:

1. **`table`** — for any field→value price lookup (e.g. diameter → price per foot). Preferred: data is manageable via CSV export/import in the UI without touching code.
2. **`conditional`** — for condition-based values. Use instead of ternary chains in expressions. Can return strings, not just numbers — use for text outputs like application messages.
3. **`linked`** — to resolve a choice field's selected option to one of its attributes (e.g. `price`). The PHP auto-builds the table from the field's `options` array at render time.
4. **`expression`** — only when you need math functions, string operations, or a calculation that mixes multiple variables in ways the other types can't cover.

#### Option `price` attribute <a href="#option-price-attribute" id="option-price-attribute"></a>

For radio/checkbox fields that act as price multipliers, set a `price` attribute on each option. Then add a `linked` transformation that resolves the selected option's price into the formula pool.

```json
{"label": "Galvanized", "value": "galvanized", "price": 1}
{"label": "304 Stainless Steel", "value": "stainless", "price": 1.7}
```

Transformation:

```json
{
  "name": "material",
  "type": "linked",
  "linkedField": "material",
  "lookupColumn": "price",
  "lookupStrategy": "exact",
  "defValue": 1
}
```

Formula: `diameter_price * material * length` — `material` resolves to the selected option's `price`.

#### Formula — no `round()` needed <a href="#formula--no-round-needed" id="formula--no-round-needed"></a>

Price output is auto-rounded by the system. Write clean formulas without wrapping in `round()`.

#### Field layout — max 2 columns <a href="#field-layout--max-2-columns" id="field-layout--max-2-columns"></a>

Never place more than 2 fields in a row. Standalone fields go full-width outside any grid group: radio/checkbox groups (especially `card`/swatch styles), textarea, range slider, repeater, file upload, booking datepicker, infoblock, and the preview blocks (`product-image`, `layered-image`, `chart`, `image-cropper`). For WC-ready forms, the `qty` number input and the Submit button go in the packaged Add to Cart pattern (see [Add to Cart](#add-to-cart)) — reuse that exact pattern rather than inventing your own layout for this pair.

#### Notification block placement <a href="#notification-block-placement" id="notification-block-placement"></a>

Keep `<!-- wp:craftforms/notification /-->` in its own standalone vertical flex group, **separate from the submit button**. This matches the rendering contract expected by the frontend.

```html
<!-- wp:craftforms/submit-button {"label":"Submit"} /-->

<!-- wp:group {"layout":{"type":"flex","orientation":"vertical"}} -->
<div class="wp-block-group"><!-- wp:craftforms/notification /--></div>
<!-- /wp:group -->
```

#### WC-ready starters <a href="#wc-ready-starters" id="wc-ready-starters"></a>

For forms designed for WooCommerce product pages:

* Name prefix: **`WC:`** (e.g. `WC: Pipe`)
* File name: `wc-{product}.html`
* **Always reuse the Add to Cart pattern** for the `qty` input + Submit button — see [Add to Cart](#add-to-cart) for the exact markup and why `<!-- wp:craftforms/add-to-cart /-->` cannot be written directly into a starter file. Never invent a different layout for this pair from scratch.
* **Do not include `qty` in the price formula** — WooCommerce handles quantity multiplication at cart level
* If the form already uses `qty` as a radio/select for a product variant tier (e.g. print quantity), name that field something other than `qty` (e.g. `print_qty`, `stkr_qty`) to keep `qty` free for the WC cart quantity input

#### Transformation `expose` flag <a href="#transformation-expose-flag" id="transformation-expose-flag"></a>

Add `"expose": true` to a transformation to make its result available as `{{field.name}}` in infoblock templates. This is the canonical display path — **only exposed transformations can be referenced in infoblock content**.

```json
{"name": "max_width_cm", "type": "table", ..., "expose": true}
```

**Expose rules:**

* **Expose** any transformation whose result is displayed in the form (infoblock output, dynamic labels).
* **Expose** any transformation used as a dynamic validation constraint (`min`/`max` on a number field).
* **Do not expose** intermediate calculations that are only inputs to other transformations or the formula — these have no use in templates and cluttering the field pool with them is noise.

***

### Complete Workflow <a href="#complete-workflow" id="complete-workflow"></a>

#### 1. Create the craftforms\_form post <a href="#id-1-create-the-craftforms_form-post" id="id-1-create-the-craftforms_form-post"></a>

```bash
FORM_ID=$(wp post create \
  --post_type=craftforms_form \
  --post_title="My Form Title" \
  --post_status=publish \
  --path=/path/to/wp \
  --porcelain)

# Set a human-readable UUID (used as data-craftforms-form identifier)
wp post meta add $FORM_ID _craftforms_form_uuid "form_myformslug" \
  --path=/path/to/wp
```

#### 2. Set form meta <a href="#id-2-set-form-meta" id="id-2-set-form-meta"></a>

> **CRITICAL — use `--format=json`, never a raw JSON string.** `_craftforms_form_meta` is registered as a REST `object` meta (`type => object`, `single => true`). The block editor reads it via `getEditedPostAttribute('meta')._craftforms_form_meta`, and WordPress's REST layer only exposes object meta when it is stored as a real (serialized) PHP **array**. If you pass a raw JSON **string**, `wp post meta update` stores the literal string; the frontend still computes price (PHP `json_decode`s it), but the **editor UI shows no formula and no smart variables** because the string fails the object schema and REST returns it empty. Always add `--format=json` so WP-CLI decodes the JSON and stores an array:

```bash
wp post meta update $FORM_ID _craftforms_form_meta \
  '{"formula":"...","transformations":[...],"submitActions":[...],"sendEmails":true,"createEntries":true}' \
  --format=json \
  --path=/path/to/wp
```

To verify it stored correctly (must print `array`, not `string`):

```bash
wp eval 'var_dump( gettype( get_post_meta($FORM_ID, "_craftforms_form_meta", true) ) );' --path=/path/to/wp
```

If an existing form was saved as a string, repair it:

```bash
wp eval '$r=get_post_meta($FORM_ID,"_craftforms_form_meta",true); if(is_string($r)){update_post_meta($FORM_ID,"_craftforms_form_meta",json_decode($r,true));}' --path=/path/to/wp
```

#### 3. Create the starter HTML file <a href="#id-3-create-the-starter-html-file" id="id-3-create-the-starter-html-file"></a>

File: `starters/forms/my-form.html`

Header format:

```html
<!--
Name: Human Readable Name
Description: Brief description shown in form picker UI
Type: form
Version: pro
CraftForms-Version: 1
Icon: email
-->
```

Immediately after the header, embed the form meta as a `<!--craftforms-meta-->` block. This is parsed by the system when the starter is imported — it sets `_craftforms_form_meta` automatically, so WP CLI Step 2 is not needed for starters:

```html
<!--craftforms-meta
{
  "formula": "...",
  "transformations": [...],
  "submitActions": [...],
  "sendEmails": true,
  "createEntries": true
}
-->
```

Then the block HTML starting with `<!-- wp:craftforms/form ... -->`.

#### 4. Create a demo page <a href="#id-4-create-a-demo-page" id="id-4-create-a-demo-page"></a>

```bash
CONTENT=$(awk '/^<!-- wp:craftforms\/form/,0' starters/my-form.html)

wp post create \
  --post_type=page \
  --post_title="My Form Page" \
  --post_status=publish \
  --post_content="$CONTENT" \
  --path=/path/to/wp \
  --porcelain
```

***

### Block HTML Structure Patterns <a href="#block-html-structure-patterns" id="block-html-structure-patterns"></a>

#### Form wrapper <a href="#form-wrapper" id="form-wrapper"></a>

```html
<!-- wp:craftforms/form {"ref":FORM_ID,"style":{"spacing":{"padding":{"top":"var:preset|spacing|30","bottom":"var:preset|spacing|30"}}},"layout":{"type":"constrained"}} -->
  ...fields...
<!-- /wp:craftforms/form -->
```

#### Text input field <a href="#text-input-field" id="text-input-field"></a>

```html
<!-- wp:craftforms/text-input-field {"name":"fieldname","label":"Label Text","required":true} -->
<!-- wp:craftforms/label {"content":"Label Text"} /-->

<!-- wp:craftforms/text-input {"name":"fieldname","required":true,"fontSize":"medium","style":{"spacing":{"padding":{"top":"var:preset|spacing|20","bottom":"var:preset|spacing|20","left":"var:preset|spacing|20","right":"var:preset|spacing|20"}}}} /-->

<!-- wp:craftforms/form-error {"content":"{{error.fieldname}}"} /-->
<!-- /wp:craftforms/text-input-field -->
```

For `type` variants: add `"type":"email"` or `"type":"number"` to both `text-input-field` and `text-input`.

**`min`/`max` on `text-input` are static values only** (a plain number string, e.g. `"min":"1"`). Never put a transformation name in these — that is not what the Validation panel is for, and it won't show up as a bound smart variable anywhere in the editor UI. For a constraint that must track a transformation (e.g. a max width looked up from a table), use the `cfMin`/`cfMax` attributes instead — see [Dynamic Validation](#dynamic-validation--expression-driven-constraints):

```html
<!-- wp:craftforms/text-input {"name":"width","type":"number","min":"1","cfMax":"max_width_cm","required":true,...} /-->
```

#### Textarea field <a href="#textarea-field" id="textarea-field"></a>

```html
<!-- wp:craftforms/textarea-field {"name":"message","label":"Message","required":true} -->
<!-- wp:craftforms/label {"content":"Message"} /-->

<!-- wp:craftforms/textarea {"name":"message","required":true,"fontSize":"medium","style":{"spacing":{"padding":{"top":"var:preset|spacing|20","bottom":"var:preset|spacing|20","left":"var:preset|spacing|20","right":"var:preset|spacing|20"}}}} /-->

<!-- wp:craftforms/form-error {"content":"{{error.message}}"} /-->
<!-- /wp:craftforms/textarea-field -->
```

#### Select field <a href="#select-field" id="select-field"></a>

```html
<!-- wp:craftforms/select-field {"name":"subject","label":"Subject","options":[{"label":"Option A","value":"option-a"},{"label":"Option B","value":"option-b"}],"required":true} -->
<!-- wp:craftforms/label {"content":"Subject"} /-->

<!-- wp:craftforms/select {"name":"subject","options":[{"label":"Option A","value":"option-a"},{"label":"Option B","value":"option-b"}],"required":true,"style":{"spacing":{"padding":{"top":"var:preset|spacing|20","bottom":"var:preset|spacing|20","left":"var:preset|spacing|20","right":"var:preset|spacing|20"}}}} /-->

<!-- wp:craftforms/form-error {"content":"{{error.subject}}"} /-->
<!-- /wp:craftforms/select-field -->
```

Note: `options` array must be duplicated on both `select-field` and `select` blocks.

#### Radio field <a href="#radio-field" id="radio-field"></a>

> **STRUCTURE REBUILT — the old `<label><input>…</label>` option markup is gone.** Each `radio-option` / `checkbox-option` is now an **InnerBlocks container** whose only child is a `choice-label` block holding the visible (rich) label. The `<input>` is **not** in the saved HTML at all. On the frontend, `radio-field` / `checkboxes-field` are **dynamic, PHP-rendered, PRO-gated** blocks: PHP regenerates every input/swatch/card from the field's `options[]` array, using each option's `choice-label` inner HTML as the visible label (matched by `value`). So:
>
> * The **`options[]` array on the field block is the source of truth** for the frontend (value, label, `price`, `color`, `image`, `description`, `annotation`, `checked`). Always populate it fully.
> * The inner option blocks + `choice-label` exist for the **editor** (block validation + rich-label editing). Author them correctly so the form opens without a validation notice.
> * `choice-label` carries its text as **body HTML** (`<span class="wp-block-craftforms-choice-label cf-choice-label">…</span>`), not as a comment attribute — supports bold/italic/links.

> **Critical:** `radio-option`, `checkbox-option`, `choice-label`, and `options-group` all have non-null `save()`. They MUST include their rendered HTML — never use self-closing comments (`/-->`) for these blocks. `label` and `form-error`, by contrast, are dynamic (`save()` returns null) and **are** self-closing.

> **CRITICAL — block-comment attributes MUST match the rendered HTML (block validation):** The field wrapper `<div>` and the `options-group` `<div>` are regenerated by `save()` from their attributes. Two rules that bite:
>
> 1. **Required message** — keep three in lockstep: `"required":true` ⇄ `data-validate-minselected="1"`, and `"requiredMessage":"<msg>"` ⇄ `data-validate-minselected-message="<msg>"` (exact same string). `"required":true` alone produces only `data-validate-minselected="1"`, never the message. Same rule applies to **checkboxes-field**.
> 2. **Layout classes** — `options-group` no longer hardcodes `is-layout-flex is-vertical`; layout is driven by the block's own layout support. Whatever layout class you put on the `<div>` must come from a matching `"className"` (and/or `"layout"`) attribute in the comment. The safe vertical default is `{"className":"is-layout-flex is-vertical"}` ⇄ `class="wp-block-craftforms-options-group craftforms-options-group is-layout-flex is-vertical"`. The field wrapper takes no layout class unless you add a matching `"className"`.

```html
<!-- wp:craftforms/radio-field {"name":"choice","label":"Choose One","options":[{"label":"Option A","value":"option-a"},{"label":"Option B","value":"option-b"}],"required":true,"requiredMessage":"This field is required"} -->
<div class="wp-block-craftforms-radio-field" data-fieldname="choice" aria-describedby="choice-error" data-validate-minselected="1" data-validate-minselected-message="This field is required" role="group" aria-labelledby="choice-label" data-craftforms-field="choice"><!-- wp:craftforms/label {"content":"Choose One"} /-->

<!-- wp:craftforms/options-group {"className":"is-layout-flex is-vertical"} -->
<div class="wp-block-craftforms-options-group craftforms-options-group is-layout-flex is-vertical"><!-- wp:craftforms/radio-option {"label":"Option A","value":"option-a","name":"choice"} -->
<div class="wp-block-craftforms-radio-option"><!-- wp:craftforms/choice-label -->
<span class="wp-block-craftforms-choice-label cf-choice-label">Option A</span>
<!-- /wp:craftforms/choice-label --></div>
<!-- /wp:craftforms/radio-option -->

<!-- wp:craftforms/radio-option {"label":"Option B","value":"option-b","name":"choice"} -->
<div class="wp-block-craftforms-radio-option"><!-- wp:craftforms/choice-label -->
<span class="wp-block-craftforms-choice-label cf-choice-label">Option B</span>
<!-- /wp:craftforms/choice-label --></div>
<!-- /wp:craftforms/radio-option --></div>
<!-- /wp:craftforms/options-group -->

<!-- wp:craftforms/form-error {"content":"{{error.choice}}"} /-->
</div>
<!-- /wp:craftforms/radio-field -->
```

When `required=false` (no required attribute): omit `"required"` / `"requiredMessage"` from the block comment **and** omit the `data-validate-minselected` / `data-validate-minselected-message` attributes from the wrapper div — keep the comment and HTML in sync in both directions.

#### Checkboxes field <a href="#checkboxes-field" id="checkboxes-field"></a>

Same container/`choice-label` structure as radio. The input `[]` suffix is applied by PHP at render time — you don't write `<input>` markup at all.

```html
<!-- wp:craftforms/checkboxes-field {"name":"addons","label":"Add-ons","options":[{"label":"Option A","value":"option-a","checked":false},{"label":"Option B","value":"option-b","checked":false}]} -->
<div class="wp-block-craftforms-checkboxes-field" data-fieldname="addons" aria-describedby="addons-error" role="group" aria-labelledby="addons-label" data-craftforms-field="addons"><!-- wp:craftforms/label {"content":"Add-ons"} /-->

<!-- wp:craftforms/options-group {"className":"is-layout-flex is-vertical"} -->
<div class="wp-block-craftforms-options-group craftforms-options-group is-layout-flex is-vertical"><!-- wp:craftforms/checkbox-option {"label":"Option A","value":"option-a","name":"addons"} -->
<div class="wp-block-craftforms-checkbox-option"><!-- wp:craftforms/choice-label -->
<span class="wp-block-craftforms-choice-label cf-choice-label">Option A</span>
<!-- /wp:craftforms/choice-label --></div>
<!-- /wp:craftforms/checkbox-option -->

<!-- wp:craftforms/checkbox-option {"label":"Option B","value":"option-b","name":"addons"} -->
<div class="wp-block-craftforms-checkbox-option"><!-- wp:craftforms/choice-label -->
<span class="wp-block-craftforms-choice-label cf-choice-label">Option B</span>
<!-- /wp:craftforms/choice-label --></div>
<!-- /wp:craftforms/checkbox-option --></div>
<!-- /wp:craftforms/options-group -->

<!-- wp:craftforms/form-error {"content":"{{error.addons}}"} /-->
</div>
<!-- /wp:craftforms/checkboxes-field -->
```

**Notes:**

* `name` on each option block must match the field `name`. The input `name="addons[]"` suffix is added by PHP — not in your HTML.
* `options` array must be repeated on the field block comment attributes — it is the frontend source of truth.
* `choice-label` content lives in the `<span>` body, not the comment JSON. For a plain label the comment is bare (`<!-- wp:craftforms/choice-label -->`); the same plain text is mirrored into the option's `"label"` attribute for validation/catalog/conditional-logic/submission readability.
* The field wrapper div class is `wp-block-craftforms-{radio,checkboxes}-field` (+ `cf-field-card` when `optionStyle` is `card`, + any layout class from a matching `"className"`).
* The same `optionStyle` / `optionShape` set on the field should be repeated on each option block (see [Option styles & rich options](#option-styles--rich-options)).

#### Option styles & rich options <a href="#option-styles--rich-options" id="option-styles--rich-options"></a>

Radio and checkbox options support several **visual styles** plus rich per-option data. Set `optionStyle` (and, for swatches, `optionShape`) on the **field** block and **repeat the same `optionStyle` on every option block**. The per-option visual data lives in the field's `options[]` array; PHP renders it.

| `optionStyle` | Renders as                                                | Per-option data used                                                                     |
| ------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `default`     | Standard radio/checkbox + text label (the `choice-label`) | `label` (rich), `annotation`                                                             |
| `text`        | Text "pill" swatch (button-like selectable label)         | `label`, `annotation` (can sit inside the pill via field's `textSwatchAnnotationInside`) |
| `color`       | Color swatch chip                                         | `color` (hex), `label` (tooltip)                                                         |
| `image`       | Image swatch chip                                         | `image` `{id,url,alt}`, `label` (tooltip)                                                |
| `card`        | Card with image, title, description, price                | `image`, `label` (title), `description`, `price`                                         |

Per-option attributes (set in the field's `options[]` **and** mirrored onto the matching option block):

* `price` — number; combine with a `linked` transformation (`lookupColumn:"price"`) to fold into the formula. This is the canonical product-configurator pattern.
* `color` — hex string for `color` style.
* `image` — `{"id":123,"url":"https://…","alt":""}` for `image` / `card` styles.
* `description` — short line shown under the card title (`card` style).
* `annotation` — live Mustache string (see [Option annotations](#option-annotations)).
* `checked` — preselect (checkboxes); for radio use the field's `defaultValue`.

Field-level swatch/card sizing & styling attributes: `optionSwatchWidth` / `optionSwatchHeight`, `optionCardImgWidth` / `optionCardImgHeight`, `optionFontSize`, and the `textSwatch*` group (`textSwatchFontSize`, `textSwatchColor`, `textSwatchBackground`, `textSwatchColorChecked`, `textSwatchBackgroundChecked`, `textSwatchBorder*`, `textSwatchPaddingV/H`, `textSwatchAnnotationInside`) plus field border (`fieldBorderWidth/Style/Color/Radius`).

**`optionSwatchWidth`/`optionSwatchHeight` are NOT optional for `color`/`image` swatches — they default to `0`, which PHP renders as CSS `width:auto;height:auto` on an empty `<span class="cf-swatch-visual">`.** An empty auto-sized span collapses to a near-invisible sliver (no intrinsic content to give it size), not a full-size chip. Always set an explicit pixel size (e.g. `48`) on any field using `optionStyle:"color"` or `optionStyle:"image"`, or the swatches will render as tiny unclickable dots. (`text`-style swatches are less affected since the pill has label text giving it intrinsic width, but `optionSwatchHeight` is still worth setting for a consistent row height.)

**Colour swatch example:**

```html
<!-- wp:craftforms/radio-field {"name":"frame_color","label":"Frame Colour","options":[{"label":"Black","value":"black","color":"#171717"},{"label":"White","value":"white","color":"#f4f1ea"}],"optionStyle":"color","optionSwatchWidth":48,"optionSwatchHeight":48,"defaultValue":"black","required":true,"requiredMessage":"Please choose a frame colour","className":"is-layout-flex"} -->
<div data-fieldname="frame_color" aria-describedby="frame_color-error" data-validate-minselected="1" data-validate-minselected-message="Please choose a frame colour" role="group" aria-labelledby="frame_color-label" data-craftforms-field="frame_color" class="wp-block-craftforms-radio-field is-layout-flex"><!-- wp:craftforms/label {"content":"Frame Colour"} /-->

<!-- wp:craftforms/options-group {"layout":{"type":"flex","orientation":"horizontal"},"className":"is-layout-flex"} -->
<div class="wp-block-craftforms-options-group craftforms-options-group is-layout-flex"><!-- wp:craftforms/radio-option {"label":"Black","value":"black","checked":true,"name":"frame_color","color":"#171717","optionStyle":"color"} -->
<div class="wp-block-craftforms-radio-option"><!-- wp:craftforms/choice-label -->
<span class="wp-block-craftforms-choice-label cf-choice-label">Black</span>
<!-- /wp:craftforms/choice-label --></div>
<!-- /wp:craftforms/radio-option -->

<!-- wp:craftforms/radio-option {"label":"White","value":"white","name":"frame_color","color":"#f4f1ea","optionStyle":"color"} -->
<div class="wp-block-craftforms-radio-option"><!-- wp:craftforms/choice-label -->
<span class="wp-block-craftforms-choice-label cf-choice-label">White</span>
<!-- /wp:craftforms/choice-label --></div>
<!-- /wp:craftforms/radio-option --></div>
<!-- /wp:craftforms/options-group -->

<!-- wp:craftforms/form-error {"content":"{{error.frame_color}}","style":{"color":{"text":"#cc1818"},"typography":{"fontSize":"0.8em"}}} /--></div>
<!-- /wp:craftforms/radio-field -->
```

`optionSwatchWidth`/`optionSwatchHeight` are read from the **field** block only (PHP never looks at them on the option blocks) — no need to repeat them per-option the way `optionStyle`/`color`/`image` must be.

**Card-style product configurator example** (image + title + description + price per option). The option block still wraps a `choice-label` for the editor; PHP renders the card on the frontend from the `options[]` data:

```html
<!-- wp:craftforms/radio-field {"name":"wood_type","label":"Wood type","options":[{"label":"Oak","value":"oak","price":0,"description":"Classic & solid","image":{"id":1205,"url":"https://site.test/wp-content/uploads/oak.jpg","alt":""}},{"label":"Walnut","value":"walnut","price":150,"description":"Rich & premium","image":{"id":1206,"url":"https://site.test/wp-content/uploads/walnut.jpg","alt":""}}],"optionStyle":"card","optionCardImgWidth":200,"optionCardImgHeight":100,"defaultValue":"oak","required":true,"requiredMessage":"Please choose a wood type","className":"is-layout-flex"} -->
<div data-fieldname="wood_type" aria-describedby="wood_type-error" data-validate-minselected="1" data-validate-minselected-message="Please choose a wood type" role="group" aria-labelledby="wood_type-label" data-craftforms-field="wood_type" class="wp-block-craftforms-radio-field cf-field-card is-layout-flex"><!-- wp:craftforms/label {"content":"Wood type"} /-->

<!-- wp:craftforms/options-group {"layout":{"type":"flex","orientation":"horizontal"},"className":"is-layout-flex"} -->
<div class="wp-block-craftforms-options-group craftforms-options-group is-layout-flex"><!-- wp:craftforms/radio-option {"label":"Oak","value":"oak","checked":true,"name":"wood_type","price":0,"image":{"id":1205,"url":"https://site.test/wp-content/uploads/oak.jpg","alt":""},"optionStyle":"card","description":"Classic & solid"} -->
<div class="wp-block-craftforms-radio-option"><!-- wp:craftforms/choice-label -->
<span class="wp-block-craftforms-choice-label cf-choice-label">Oak</span>
<!-- /wp:craftforms/choice-label --></div>
<!-- /wp:craftforms/radio-option -->

<!-- wp:craftforms/radio-option {"label":"Walnut","value":"walnut","name":"wood_type","price":150,"image":{"id":1206,"url":"https://site.test/wp-content/uploads/walnut.jpg","alt":""},"optionStyle":"card","description":"Rich & premium"} -->
<div class="wp-block-craftforms-radio-option"><!-- wp:craftforms/choice-label -->
<span class="wp-block-craftforms-choice-label cf-choice-label">Walnut</span>
<!-- /wp:craftforms/choice-label --></div>
<!-- /wp:craftforms/radio-option --></div>
<!-- /wp:craftforms/options-group -->

<!-- wp:craftforms/form-error {"content":"{{error.wood_type}}","style":{"color":{"text":"#cc1818"},"typography":{"fontSize":"0.8em"}}} /--></div>
<!-- /wp:craftforms/radio-field -->
```

Notes:

* The field wrapper gets `cf-field-card` automatically when `optionStyle` is `card` — include it in the wrapper `class` (matches `save()`).
* The card's price comes from the option's `price`; to add it to the form total, define a `linked` transformation on the field (`lookupColumn:"price"`) and use it in the formula — exactly the existing option-`price` pattern.
* Rich labels: put bold/italic/links inside the `choice-label` span; the plain-text version is mirrored to the option's `"label"` for matching.

#### Option annotations <a href="#option-annotations" id="option-annotations"></a>

Each radio or checkbox option can carry an `annotation` — a Mustache template string rendered live beside the option label as form values change. Use it to show dynamic per-option data such as tier prices, discounts, stock, or any transformation result.

Add `"annotation"` to the option object in the `options` array on the field block:

```json
{"label": "100", "value": "100", "annotation": "{{form.currency}}{{price_100}} · Discount {{discount_100}}%"}
```

Put the `annotation` in **both** the field's `options[]` entry and the matching option block's comment. You do **not** write the annotation `<span>` in the HTML — PHP renders the `.cf-option-annotation[data-cf-template]` span at frontend, and the JS runtime resolves it live on every form state change. The option block stays the standard container + `choice-label`:

```html
<!-- wp:craftforms/radio-option {"label":"100","value":"100","name":"qty","annotation":"{{form.currency}}{{price_100}} · Discount {{discount_100}}%"} -->
<div class="wp-block-craftforms-radio-option"><!-- wp:craftforms/choice-label -->
<span class="wp-block-craftforms-choice-label cf-choice-label">100</span>
<!-- /wp:craftforms/choice-label --></div>
<!-- /wp:craftforms/radio-option -->
```

Options without an annotation omit it entirely.

**Variables available in annotations** — same pool as infoblocks:

* `{{form.currency}}`, `{{form.price}}` — form-level values
* `{{transformations.name}}` — any transformation result
* `{{field.name}}` — fields and exposed transformations

**Quantity-tier pricing example (sticker-qty pattern):**

This pattern powers a quantity selector where each tier shows its total price and discount vs. the base tier:

1. One `table` transformation per tier, keyed by a size/variant field:

   ```json
   {"name": "price_100", "type": "table", "tableData": [["","1"],["50x50",65],["75x75",84]], "colVar": "size", "rowVar": "size", "lookupStrategy": "exact", "defValue": 0}
   ```
2. One `expression` transformation per tier for the discount percentage:

   ```json
   {"name": "discount_100", "type": "expression", "value": "price_50 > 0 ? round((1 - price_100 / price_50 * 50 / 100) * 100, 0) : 0", "defValue": 0}
   ```
3. Annotation on the option: `"{{form.currency}}{{price_100}} · Discount {{discount_100}}%"`

As the user changes an upstream field (e.g. `size`), all tier price and discount annotations update live without requiring any infoblock.

#### Infoblock (live price / dynamic display) <a href="#infoblock-live-price--dynamic-display" id="infoblock-live-price--dynamic-display"></a>

```html
<!-- wp:craftforms/infoblock {"connectedForm":FORM_ID,"style":{"color":{"background":"#f0f4ff"},"spacing":{"padding":{"top":"var:preset|spacing|30","bottom":"var:preset|spacing|30","left":"var:preset|spacing|30","right":"var:preset|spacing|30"}},"border":{"radius":"8px"}}} -->
<!-- wp:paragraph {"style":{"typography":{"fontWeight":"700","fontSize":"1.5rem"}}} -->
<p style="font-weight:700;font-size:1.5rem">Estimated Price: {{form.currency}}{{form.price}}</p>
<!-- /wp:paragraph -->
<!-- /wp:craftforms/infoblock -->
```

Template variables update live as the user fills the form.

**Conditional or long messages — use a transformation, not inline logic in the infoblock.** When a displayed message is conditional (e.g. show only when discount > 0, or varies by field value), create a `conditional` transformation with `"expose": true` that returns the full message string or empty string, and reference it as `{{field.name}}` in the infoblock. This keeps the infoblock HTML clean and the logic testable in the meta JSON.

```json
{
  "name": "discount_msg",
  "type": "conditional",
  "conditions": [
    {"when": "discount > 0", "value": "\"Save \" + discount + \"% vs base price\""}
  ],
  "conditionDefault": "\"\"",
  "defValue": "",
  "expose": true
}
```

In the infoblock, just: `{{field.discount_msg}}` — renders empty string when the condition isn't met, so no stray text or placeholders appear.

#### Two-column responsive layout <a href="#two-column-responsive-layout" id="two-column-responsive-layout"></a>

Never exceed 2 fields per row. Wrap pairs of fields in a `wp:group` with `"type":"grid"` layout. WordPress's CSS grid auto-fill creates 2 columns when space allows, stacks to 1 column on mobile. The `minimumColumnWidth` controls the breakpoint.

```html
<!-- wp:group {"layout":{"type":"grid","minimumColumnWidth":"280px"}} -->
<div class="wp-block-group is-layout-grid">
<!-- first field block -->
<!-- second field block -->
</div>
<!-- /wp:group -->
```

Fields that should span full width (radio groups, textarea, infoblock) go outside the group as standalone blocks.

#### Error message styling <a href="#error-message-styling" id="error-message-styling"></a>

Apply on every `craftforms/form-error` block to make errors red and small:

```json
{"content":"{{error.fieldname}}","style":{"color":{"text":"#cc1818"},"typography":{"fontSize":"0.8em"}}}
```

#### Submit group <a href="#submit-group" id="submit-group"></a>

The submit button and notification block must be in **separate** groups:

```html
<!-- wp:craftforms/submit-button {"label":"Submit"} /-->

<!-- wp:group {"layout":{"type":"flex","orientation":"vertical"}} -->
<div class="wp-block-group"><!-- wp:craftforms/notification /--></div>
<!-- /wp:group -->
```

**For WC-ready forms, use the Add to Cart pattern for the `qty` + Submit pair** — see [Add to Cart](#add-to-cart) for the exact markup to reuse (it's the same pattern the `add-to-cart` block itself expands into; don't invent your own layout for this pair):

```html
<!-- wp:group {"layout":{"type":"grid","columnCount":4}} -->
<div class="wp-block-group"><!-- wp:craftforms/text-input-field {"name":"qty","label":"Quantity","type":"number","value":"1","required":true,"min":"1","layout":{"type":"flex","orientation":"horizontal","flexWrap":"nowrap"}} -->
<!-- wp:craftforms/label {"content":"Quantity"} /-->

<!-- wp:craftforms/text-input {"name":"qty","type":"number","value":"1","required":true,"min":"1"} /-->

<!-- wp:craftforms/form-error {"content":"{{error.qty}}"} /-->
<!-- /wp:craftforms/text-input-field -->

<!-- wp:craftforms/submit-button {"label":"Add to Cart"} /--></div>
<!-- /wp:group -->

<!-- wp:group {"layout":{"type":"flex","orientation":"vertical"}} -->
<div class="wp-block-group"><!-- wp:craftforms/notification /--></div>
<!-- /wp:group -->
```

***

### More Field Blocks <a href="#more-field-blocks" id="more-field-blocks"></a>

These are newer input field blocks. They all follow the **same wrapper-less pattern as `text-input-field`**: the `*-field` block's `save()` returns just `<InnerBlocks.Content />` (no wrapper `<div>` to hand-write), and the inner input block (`range-slider`, `color-picker`, `file`, `booking-datepicker`) is **dynamic / PHP-rendered**, so it is **self-closing** (`/-->`). Compose them as: `*-field` comment → `label` (`/-->`) → inner input (`/-->`) → `form-error` (`/-->`) → close. Most are **PRO**. Validation messages and dynamic-validation `cf*` attributes work as described in their own sections.

#### Range Slider <a href="#range-slider" id="range-slider"></a>

Single- or double-handle numeric slider. The field value is a number (single) or a `from`/`to` pair (double). Great for budget, length, capacity, or min/max range inputs that feed the price formula.

```html
<!-- wp:craftforms/range-slider-field {"name":"budget","label":"Budget","min":0,"max":5000,"step":50,"defaultValue":1000,"showTooltip":true,"tooltipUnits":"$","required":true} -->
<!-- wp:craftforms/label {"content":"Budget"} /-->

<!-- wp:craftforms/range-slider {"name":"budget","min":0,"max":5000,"step":50,"defaultValue":1000,"showTooltip":true,"tooltipUnits":"$"} /-->

<!-- wp:craftforms/form-error {"content":"{{error.budget}}","style":{"color":{"text":"#cc1818"},"typography":{"fontSize":"0.8em"}}} /-->
<!-- /wp:craftforms/range-slider-field -->
```

* Key attributes (repeat on both `-field` and inner block): `min`, `max`, `step`, `round`, `doubleHandles`, `defaultValue` (single) / `defaultValueFrom` + `defaultValueTo` (double), `showTooltip`, `tooltipUnits`, `showMarks`. Styling: `trackFillColor`, `trackBgColor`, `handleColor`, `handleSize`, `marksColor`, `marksFontSize`.
* Double handles: set `"doubleHandles":true`; the value resolves to a range — reference `budget` in transformations/formula per the runtime's range handling.
* Dynamic constraints: `cfMin` / `cfMax` (expression) and `cfRequired` are supported.

#### Color Picker <a href="#color-picker" id="color-picker"></a>

Color input with optional preset swatches. Value is a color string in the chosen `format` (`hex` / `rgb` / `hsl`).

```html
<!-- wp:craftforms/color-picker-field {"name":"frame_color","label":"Frame color","format":"hex","swatches":["#000000","#ffffff","#c0392b","#2980b9"],"defaultValue":"#000000"} -->
<!-- wp:craftforms/label {"content":"Frame color"} /-->

<!-- wp:craftforms/color-picker {"name":"frame_color","format":"hex","swatches":["#000000","#ffffff","#c0392b","#2980b9"],"defaultValue":"#000000"} /-->

<!-- wp:craftforms/form-error {"content":"{{error.frame_color}}","style":{"color":{"text":"#cc1818"},"typography":{"fontSize":"0.8em"}}} /-->
<!-- /wp:craftforms/color-picker-field -->
```

* `swatches` is an array of preset colors; `hideInput:true` hides the free-form input (swatches only). Pairs well with a `layered-image` recolor layer.

#### File Upload <a href="#file-upload" id="file-upload"></a>

File input with size/type/count validation. Uses its own **"Error Messages"** panel attributes (`error*` naming).

```html
<!-- wp:craftforms/file-field {"name":"artwork","label":"Upload artwork","multiple":true,"maxFiles":3,"maxSizeMB":10,"allowedTypes":["image/png","image/jpeg","application/pdf"],"required":true,"errorRequired":"Please attach your artwork","errorMaxSize":"Each file must be under 10 MB","errorFileTypes":"PNG, JPG or PDF only","errorMaxFiles":"Up to 3 files"} -->
<!-- wp:craftforms/label {"content":"Upload artwork"} /-->

<!-- wp:craftforms/file {"name":"artwork","multiple":true,"maxFiles":3,"maxSizeMB":10,"allowedTypes":["image/png","image/jpeg","application/pdf"],"required":true,"errorRequired":"Please attach your artwork","errorMaxSize":"Each file must be under 10 MB","errorFileTypes":"PNG, JPG or PDF only","errorMaxFiles":"Up to 3 files"} /-->

<!-- wp:craftforms/form-error {"content":"{{error.artwork}}","style":{"color":{"text":"#cc1818"},"typography":{"fontSize":"0.8em"}}} /-->
<!-- /wp:craftforms/file-field -->
```

* `allowedTypes` are MIME types; `maxSizeMB` is per file; `maxFiles` only applies when `multiple:true`.

#### Booking Datepicker <a href="#booking-datepicker" id="booking-datepicker"></a>

Date / date-range / time-slot picker (HotelDatepicker). The cornerstone of bookings, rentals, and appointment forms. PHP-rendered.

```html
<!-- wp:craftforms/booking-datepicker-field {"name":"stay","label":"Check-in / Check-out","formFieldType":"dates-range","bookingType":"day-based","minNights":1,"weeklySchedule":[],"required":true,"requiredMessage":"Please pick your dates"} -->
<!-- wp:craftforms/label {"content":"Check-in / Check-out"} /-->

<!-- wp:craftforms/booking-datepicker {"name":"stay","formFieldType":"dates-range","bookingType":"day-based","minNights":1,"weeklySchedule":[]} /-->

<!-- wp:craftforms/form-error {"content":"{{error.stay}}","style":{"color":{"text":"#cc1818"},"typography":{"fontSize":"0.8em"}}} /-->
<!-- /wp:craftforms/booking-datepicker-field -->
```

* `formFieldType`: `single-date` | `dates-range` | `seasonal` | `continuous-range`. `bookingType`: `day-based` (nights) | `fixed-slots` (slots via `weeklySchedule` + `slotInterval`) | `flexible-range` (per-date `custom_slots`) | `seasonal` (slot times vary by season — see below) | `continuous-range` (freeform start + multi-slot duration, e.g. meeting rooms — see below).
* Constraints: `minNights` / `maxNights`, `startDate` / `endDate`, `disabledDaysOfWeek`, `noCheckinDays` / `noCheckoutDays`, `selectForward`, `advanceNotice`, `startOfWeek`, `lang`, `format`.
* **What a date selection puts in the pool** (the field value is an object, flattened to dotted keys — there is no bare numeric alias): day-based → `field.checkin` + `field.checkout` (date strings); `fixed-slots` / `seasonal` → `field.date` + `field.time` (`time` is `HH:mm`); `continuous-range` → `field.date` + `field.start` + `field.end` + `field.count` (see below). Nights are **not** auto-injected — compute with `count_nights(field.checkin, field.checkout)`. To derive a month/hour/etc. from the date, use the date-part functions below.
* Combine with dynamic validation to cap quantity at remaining capacity (`"cfMax":"_booking_capacity"`).

**Continuous duration (`formFieldType:"continuous-range"`) — PRO**

Use when the customer books **one or more consecutive slots with no internal gap** instead of a single fixed appointment — meeting rooms, studios, courts. The customer picks a start time, then a duration (a slot count); the two combined must be a continuous block of `count × slotInterval` minutes, e.g. three 30-minute slots starting 9:00 books 9:00–10:30 as one uninterrupted booking.

```html
<!-- wp:craftforms/booking-datepicker-field {"name":"booking","label":"Room booking","formFieldType":"continuous-range","bookingType":"continuous-range","weeklySchedule":[{"day":1,"enabled":true,"open":"09:00","close":"18:00","break_from":"13:00","break_to":"14:00"}],"slotInterval":30,"minSlotCount":1,"maxSlotCount":6,"required":true,"requiredMessage":"Please pick a time"} -->
<!-- wp:craftforms/label {"content":"Room booking"} /-->

<!-- wp:craftforms/booking-datepicker {"name":"booking","formFieldType":"continuous-range","bookingType":"continuous-range","weeklySchedule":[ ...same schedule... ],"slotInterval":30,"minSlotCount":1,"maxSlotCount":6} /-->

<!-- wp:craftforms/form-error {"content":"{{error.booking}}","style":{"color":{"text":"#cc1818"},"typography":{"fontSize":"0.8em"}}} /-->
<!-- /wp:craftforms/booking-datepicker-field -->
```

* `weeklySchedule` entries support an optional `break_from`/`break_to` (e.g. lunch) — a hard closed window, distinct from the turnaround buffer below. `slotInterval` is the bookable unit in minutes; `minSlotCount`/`maxSlotCount` bound how many consecutive units one booking may span (`maxSlotCount:0` = unlimited, capped only by remaining open time).
* **Pricing**: `field.count` is the number of priced slot-units — key your formula on it directly, e.g. `field.count * 15` for a €15/30-min room. There's no separate duration-in-minutes var; derive it if needed with `field.count * <slotInterval>`.
* **Free vs catalog:** without a catalog item, start-time/duration options are generated client-side from `weeklySchedule` alone (no real availability enforcement — nothing to book against). Link a **booking catalog item** (`booking_type:"continuous-range"`, with `slot_interval`, `min_slot_count`, `max_slot_count`, `turnaround_buffer`) to get real interval-overlap-checked availability — the catalog's schedule then overrides the block's, and a dedicated `craftforms_inventory_ranges` table (not the discrete-slot counters used by `fixed-slots`/`seasonal`) tracks booked `[start_time, end_time)` intervals per resource/date.
* **Turnaround buffer** (`turnaround_buffer`, catalog-only — there's no block-level attribute for it, since it only matters against other bookings, which the no-catalog client-side mode has no visibility into): the minimum gap required between any two *different* bookings that day. It is never applied inside one customer's own consecutive slots — 3×30min books as one unbroken 90-minute block. If a booking ends at 10:30 and the buffer is 15 minutes, the next customer's earliest valid start is 10:45, not the next slot-interval boundary from opening time — free-window start times are anchored to when the previous booking's buffer clears, not a fixed clock grid.
* Occupancy is always exclusive/single-resource for this mode — there's no `capacity`/`occupancy_model` concept like the other appointment types (one meeting room = one booking at a time).

**Seasonal time slots (`formFieldType:"seasonal"`)**

Use when the selectable **slot start-times change by the date's season** (e.g. rentals/tours: Apr–May slots 10:00 & 14:00; Jun 09:30 & 14:00; Jul–Aug 08:30 / 12:00 / 15:30). A *season* is a recurring `MM-DD` range that owns a per-weekday list of explicit start-times. It carries **only the schedule** — pricing stays separate (key a `table` on `month(field.date)`, see below).

```html
<!-- wp:craftforms/booking-datepicker-field {"name":"booking","label":"Rental date & time slot","formFieldType":"seasonal","bookingType":"seasonal","seasons":[{"id":"s1","label":"April–May","from":"04-01","to":"05-31","schedule":[{"day":0,"enabled":true,"times":["10:00","14:00"]},{"day":1,"enabled":true,"times":["10:00","14:00"]}]},{"id":"s2","label":"July–August","from":"07-01","to":"08-31","schedule":[{"day":0,"enabled":true,"times":["08:30","12:00","15:30"]},{"day":1,"enabled":true,"times":["08:30","12:00","15:30"]}]}],"required":true,"requiredMessage":"Please pick your date and time"} -->
<!-- wp:craftforms/label {"content":"Rental date & time slot"} /-->

<!-- wp:craftforms/booking-datepicker {"name":"booking","formFieldType":"seasonal","bookingType":"seasonal","seasons":[ ...same seasons array... ]} /-->

<!-- wp:craftforms/form-error {"content":"{{error.booking}}","style":{"color":{"text":"#cc1818"},"typography":{"fontSize":"0.8em"}}} /-->
<!-- /wp:craftforms/booking-datepicker-field -->
```

* `season` shape: `{ id, label, from:"MM-DD", to:"MM-DD", schedule:[{day:0-6, enabled, times:["HH:mm",…]}] }`. `day` is `0=Sun … 6=Sat`. `to < from` wraps the year-end. Populate a `schedule` entry for **every** weekday you want bookable (list them all — one per `day`).
* **Repeat the full `seasons` array on both** the `-field` and inner `booking-datepicker` blocks (same source-of-truth rule as `weeklySchedule`).
* Dates matching **no** season render disabled (no slots). First matching season wins — keep ranges non-overlapping.
* **Free vs catalog:** without a catalog item, slots are generated client-side from the `seasons` attribute (no capacity). Link a **booking catalog item** (`booking_type:"seasonal"`, with `capacity`, `occupancy_model`, `capacity_measure_var`) to get real per-date+slot inventory — the catalog's `seasons` then override the block's. Inventory reuses the standard `craftforms_inventory_slots` counters (per date + `time_slot`).

#### Date/time formula functions <a href="#datetime-formula-functions" id="datetime-formula-functions"></a>

Available in `formula` and in `expression` / `conditional` transformations (and, once materialized as a pool key, as a `table` `rowVar`/`colVar`). All parse both `YYYY-MM-DD` and `DD/MM/YYYY`; JS preview and PHP re-eval agree.

| Function                          | Input                         | Returns                     |
| --------------------------------- | ----------------------------- | --------------------------- |
| `month(dateStr)`                  | date                          | month `1-12` (`0` invalid)  |
| `year(dateStr)`                   | date                          | full year e.g. `2026`       |
| `day(dateStr)`                    | date                          | day of month `1-31`         |
| `week_day(dateStr)`               | date                          | day of week `0-6` (`0=Sun`) |
| `hour(timeStr)`                   | `HH:mm` slot time or datetime | hour `0-23`                 |
| `count_nights(ci, co)`            | two dates                     | nights between              |
| `is_date_between(d, "start:end")` | date + range                  | bool                        |
| `is_time_between(t, "start-end")` | time + range                  | bool                        |

**Seasonal / per-month pricing pattern** (prices detached from the schedule): materialize the month, then look it up in a per-month `table`.

```json
[
  {"name": "mnth", "type": "expression", "value": "month(booking.date)", "defValue": 0, "expose": true},
  {"name": "boat_price", "type": "table",
    "tableData": [["","1"],["4",130],["5",130],["6",145],["7",160],["8",160],["9",145],["10",120]],
    "colVar": "mnth", "rowVar": "mnth", "lookupStrategy": "exact", "defValue": 0, "expose": true}
]
```

Formula: `boat_price * qty`. Because `table` `rowVar` must be a pool **key** (not an inline expression), always compute `month(...)` into its own expression transformation first, then key the table on that name. The same two-step works for `week_day` (weekday/weekend pricing), `hour(booking.time)` (time-of-day), or `year`+`month`+`day` (specific dates).

#### Repeater <a href="#repeater" id="repeater"></a>

A repeatable group of fields the visitor can add/remove (line items, attendees, custom rows). The `repeater-field` wraps a single `repeater-group` template; blocks placed inside the group are cloned per row on the frontend.

```html
<!-- wp:craftforms/repeater-field {"name":"attendees","label":"Attendees","minItems":1,"maxItems":10,"addButtonText":"Add attendee","removeButtonText":"Remove"} -->
<div class="wp-block-craftforms-repeater-field form-field"><!-- wp:craftforms/label {"content":"Attendees"} /-->

<!-- wp:craftforms/repeater-group -->
<div class="wp-block-craftforms-repeater-group">
  <!-- field blocks here (text-input-field, select-field, etc.) -->
</div>
<!-- /wp:craftforms/repeater-group --></div>
<!-- /wp:craftforms/repeater-field -->
```

* `minItems` / `maxItems`, `startEmpty`, and add/remove button text/icon/SVG + styling are all attributes on `repeater-field`.
* Repeated field values arrive as arrays keyed by the repeater `name`; sum or count them with `table`/`expression` transformations as needed.

#### Select — multiple <a href="#select--multiple" id="select--multiple"></a>

`select` / `select-field` are unchanged structurally (inline `options` array on both blocks, no child option blocks). New: `"multiple":true` for a multi-select whose value is an array (validate with `any-of`/`all-of`/`none-of`, same as checkboxes).

***

### Commerce, Payment & Preview Blocks <a href="#commerce-payment--preview-blocks" id="commerce-payment--preview-blocks"></a>

Higher-level blocks for WooCommerce, payments, and live product visualization. Most are **PRO**. Several connect to a form via a `connectedForm` (form post ID) attribute and read its live field values / smart variables.

#### Add to Cart <a href="#add-to-cart" id="add-to-cart"></a>

Convenience **inserter shortcut** for the Quantity field + Add-to-Cart button pair on a WooCommerce product form. It only exists as an editor UX helper — the moment you insert it in the block editor, its `edit()` immediately expands into the constituent blocks below and removes itself (`save()` returns `null`, and there is no PHP `render_callback`). This means:

* **In the block editor UI**: inserting the "Add to Cart" block is the fastest way to get this pattern — use it there.
* **In starter HTML files, WP-CLI-authored content, or anywhere else you hand-write block comments**: writing `<!-- wp:craftforms/add-to-cart /-->` literally does nothing (renders blank — confirmed via `do_blocks()`, no button, no qty field). You must author the **expanded pattern directly** instead — this is exactly what the block itself expands into (`TEMPLATE` in `src/blocks/add-to-cart/index.js`), so it is always correct to reuse verbatim:

```html
<!-- wp:group {"layout":{"type":"grid","columnCount":4}} -->
<div class="wp-block-group"><!-- wp:craftforms/text-input-field {"name":"qty","label":"Quantity","type":"number","value":"1","required":true,"min":"1","layout":{"type":"flex","orientation":"horizontal","flexWrap":"nowrap"}} -->
<!-- wp:craftforms/label {"content":"Quantity"} /-->

<!-- wp:craftforms/text-input {"name":"qty","type":"number","value":"1","required":true,"min":"1"} /-->

<!-- wp:craftforms/form-error {"content":"{{error.qty}}"} /-->
<!-- /wp:craftforms/text-input-field -->

<!-- wp:craftforms/submit-button {"label":"Add to Cart"} /--></div>
<!-- /wp:group -->
```

**Always reuse this exact pattern for every WC-ready starter's qty+submit pair — never invent a different layout for it.** `qty` is still **not** in the price formula — WooCommerce multiplies at cart level.

#### Payment <a href="#payment" id="payment"></a>

Enables online payment (e.g. Stripe) for a non-WooCommerce form. Configure gateway keys in **CraftForms → Payment Settings**; the block just selects the `mode`.

```html
<!-- wp:craftforms/payment {"mode":"stripe"} /-->
```

#### Order Summary <a href="#order-summary" id="order-summary"></a>

Renders a CraftForms order on the payment **success page**. Reads the order reference from the `cf_order` URL parameter — place it on the page the gateway redirects to (not inside the form).

```html
<!-- wp:craftforms/order-summary /-->
```

#### Product Image <a href="#product-image" id="product-image"></a>

Image viewer (thumbnails, zoom) whose displayed image(s) react to form choices via **image conditional logic** — the visual counterpart to field conditional logic. Connect it to the form with `connectedForm`.

```html
<!-- wp:craftforms/product-image {"connectedForm":FORM_ID,"images":[...],"imageConditionalLogic":[...],"zoomEnabled":true,"aspectRatio":"1/1"} -->
<!-- /wp:craftforms/product-image -->
```

* `images` is the gallery; `imageConditionalLogic` maps field conditions → which image(s) to show (built in the editor UI).

#### Layered Image <a href="#layered-image" id="layered-image"></a>

Composites a background plus PNG **layers** driven by form options — swap a layer's image or recolor a layer shape per choice, producing a live product preview that can be saved to the order. Ideal for configurators (apparel, signage, packaging).

```html
<!-- wp:craftforms/layered-image {"connectedForm":FORM_ID,"backgroundType":"image","background":{...},"layers":[...],"saveOnSubmit":true,"saveResultField":"preview","previewLabel":"Your design","canvasWidth":800,"canvasHeight":800} -->
<!-- /wp:craftforms/layered-image -->
```

* Each layer binds to a form field (typically a radio/select with `image` or `color` options): the selected option's `image` swaps the layer, or its `color` recolors the layer shape.
* `saveOnSubmit` + `saveResultField` persists the composited preview into a form field saved with the submission/order.

#### Image Cropper <a href="#image-cropper" id="image-cropper"></a>

Interactive crop (aspect-locked to form width/height smart variables), flip, and B\&W / sepia filters; the result is saved with the submission. PRO.

```html
<!-- wp:craftforms/image-cropper {"connectedForm":FORM_ID,"varWidth":"width","varHeight":"height","targetField":"cropped","saveOnSubmit":true,"enableFlip":true,"enableBw":true,"enableSepia":true} -->
<!-- /wp:craftforms/image-cropper -->
```

* `varWidth` / `varHeight` reference form variables that drive the crop aspect ratio; `targetField` receives the result.

#### Chart <a href="#chart" id="chart"></a>

Live pie / donut chart driven by the connected form's field values and smart variables. Purely presentational. PRO.

```html
<!-- wp:craftforms/chart {"connectedForm":FORM_ID,"chartType":"donut","dataPoints":[...],"showLegend":true,"chartTitle":"Cost breakdown"} -->
<!-- /wp:craftforms/chart -->
```

* `dataPoints` map labels to transformation/field values (e.g. a price breakdown). `chartType`: `pie` | `donut` (with `donutWidth`).

#### QR Code / Barcode <a href="#qr-code--barcode" id="qr-code--barcode"></a>

Renders a QR code or barcode **into a generated PDF** (see the PDF builder). The `value` may contain merge tags so each document is unique. Place inside a PDF template, not a web form.

```html
<!-- wp:craftforms/qr-barcode {"value":"{{pdf.number}}","symbology":"qrcode","size":120,"ecLevel":"M","align":"center"} /-->
```

* `symbology`: `qrcode`, `code128`, `ean13`, etc. `ecLevel` is QR error correction; `barHeight` applies to 1D barcodes; `color` / `bgColor` / `align` style it.

***

### Form Meta Schema Reference <a href="#form-meta-schema-reference" id="form-meta-schema-reference"></a>

```json
{
  "formula": "expression using transformation names and field names",
  "transformations": [...],
  "conditionalLogicRules": [...],
  "submitActions": [...],
  "sendEmails": true,
  "createEntries": true
}
```

#### Transformation types <a href="#transformation-types" id="transformation-types"></a>

**`table` — lookup a value from a table by field value**

For a 1D lookup (field → value), the header row is `["", "col_name"]` where the empty string is the corner cell and `col_name` is an internal identifier. Set `colVar` and `rowVar` to the same field name.

```json
{
  "name": "diameter_price",
  "type": "table",
  "tableData": [
    ["", "1"],
    ["3", "45.5"],
    ["4", "77.05"],
    ["5", "108.6"]
  ],
  "colVar": "diameter",
  "rowVar": "diameter",
  "lookupStrategy": "exact",
  "defValue": ""
}
```

For a 2D lookup (row field × column field → value), the header row lists the column field's possible values:

```json
{
  "name": "rate",
  "type": "table",
  "tableData": [
    ["", "standard", "express"],
    ["small", 10, 20],
    ["large", 25, 50]
  ],
  "colVar": "turnaround",
  "rowVar": "size",
  "lookupStrategy": "exact",
  "defValue": 0
}
```

`lookupStrategy` can be `"exact"`, `"closest-up"` (smallest value ≥ input, useful for tiered pricing), or `"closest"` (nearest value).

**`linked` — auto-build a table from a choice field's options**

Used to resolve a radio/select/checkbox field's selected value to one of its option attributes (commonly `price`). The PHP builds the lookup table from the field's `options` array at render time, including any catalog overrides.

```json
{
  "name": "material",
  "type": "linked",
  "linkedField": "material",
  "lookupColumn": "price",
  "lookupStrategy": "exact",
  "defValue": 1
}
```

`lookupColumn` is the option attribute to return — `"price"` or `"label"`. The transformation name is typically the same as `linkedField` so the formula uses it directly.

**`conditional` — return a value based on conditions**

Use instead of a ternary-chain `expression` when selecting from discrete values based on field state. Conditions are evaluated top-to-bottom; the first truthy `when` wins. Can return strings as well as numbers.

```json
{
  "name": "rush_fee",
  "type": "conditional",
  "conditions": [
    {"when": "turnaround == 'same-day'", "value": "150"},
    {"when": "turnaround == 'next-day'", "value": "75"}
  ],
  "conditionDefault": "",
  "defValue": 0
}
```

String output example (application message):

```json
{
  "name": "application_msg",
  "type": "conditional",
  "conditions": [
    {"when": "glass_type == 'solar'", "value": "\"This film is for exterior application only\""}
  ],
  "conditionDefault": "\"This film is for interior application only\"",
  "defValue": ""
}
```

* `when` and `value` are Jexl expressions evaluated against the current field pool
* `conditionDefault` is a Jexl expression evaluated when no condition matches (optional)
* `defValue` is the static fallback when `conditionDefault` is empty or returns null

**`expression` — evaluate a Jexl expression**

Use only when you need math functions, string operations, or a calculation mixing multiple variables that `table` or `conditional` can't cover.

```json
{
  "name": "my_var",
  "type": "expression",
  "value": "\"option\" in fieldname ? 50 : 0",
  "defValue": 0
}
```

For checkboxes (array values), use `"value" in fieldname` syntax.

#### Dynamic Validation — expression-driven constraints <a href="#dynamic-validation--expression-driven-constraints" id="dynamic-validation--expression-driven-constraints"></a>

Validation constraints (`min`, `max`, `minlength`, `maxlength`, `step`, `required`) can be driven by smart variables or expressions that resolve against the live `resolvedDataPool` on every form state change, so constraints update dynamically as the user fills the form.

**Plain `min`/`max`/`minlength`/`maxlength`/`step` (the "Validation" panel) are for static values only** — a literal number, nothing else. **Any transformation-driven or expression-driven constraint always goes through the separate `cf*` attributes below (the "Dynamic Validation" panel).** Do not put a transformation name in the plain `min`/`max` attribute — even though the runtime happens to still resolve it there for backward compatibility with older forms, it is not the authoring convention: it won't be recognized as a bound smart variable anywhere in the editor UI, and it mixes static/dynamic intent on one attribute. Always use `cfMin`/`cfMax` for a transformation-driven constraint on new forms.

**`data-cf-*` expression attributes (the only correct way to bind a transformation to a constraint)**

Use the **Dynamic Validation** inspector panel (separate from the Validation panel) to set expressions for any constraint. Expressions are stored in `data-cf-*` HTML attributes (`data-cf-min`, `data-cf-max`, `data-cf-minlength`, `data-cf-maxlength`, `data-cf-step`, `data-cf-required`) and resolved at runtime. The resolved value is written back to the native HTML attribute before each validation pass, so browser constraint validation picks it up natively.

All transformation results are flat top-level keys in `resolvedDataPool` by their `name`. The resolution order for a `cf*` expression is:

1. Mustache render: `"cfMax":"{{max_width_cm}}"` → renders to value then parses as float
2. Direct pool key: `"cfMax":"max_width_cm"` → resolves `pool['max_width_cm']` (preferred — cleaner)
3. Literal number: `"cfMax":"153"` → uses 153 as-is

**In block HTML** — write the transformation name as the `cf*` attribute string on the `text-input` block:

```html
<!-- wp:craftforms/text-input {"name":"width","type":"number","min":"1","cfMax":"max_width_cm","required":true,...} /-->
```

**Setting via editor UI**: In the **Dynamic Validation** panel (not the Validation panel), the Max Value (expression) input accepts plain text — type the transformation name directly (e.g. `max_width_cm`).

**Range Slider — `min`/`max` vs `validateMin`/`validateMax`:** on the range-slider field, `min`/`max` are the **slider track endpoints** (the visible/draggable range), not the validation constraint. To constrain the *accepted* value separately, set `validateMin` / `validateMax` on the `range-slider-field` (they fall back to `min`/`max` when empty and are emitted as `data-validate-min` / `data-validate-max`). For dynamic constraints use `cfMin` / `cfMax` on the inner `range-slider`.

Supported expression syntax:

* **Pool key**: `my_transformation` → resolves `resolvedDataPool['my_transformation']`
* **Mustache**: `{{my_transformation}}` → same result via mustache render
* **Dot-path**: `form.booking_capacity` → nested resolution
* **Expression** (for `required` only): `field1 == "option"` → evaluated via Jexl

**In block JSON** — use `cf*` attributes on the `text-input` block:

```json
{"name":"qty","type":"number","cfMin":"booking_min","cfMax":"booking_capacity","cfRequired":"needs_qty"}
```

**In editor UI**: Open the "Dynamic Validation" panel (separate from the Validation panel). Leave any expression blank to fall back to the static Validation value. **Which `cf*` attributes a field actually supports** (only these exist on the block — using any other `cf*` is silently ignored):

| Field                | Supported `cf*` attributes                                             |
| -------------------- | ---------------------------------------------------------------------- |
| text-input(-field)   | `cfMin`, `cfMax`, `cfMinlength`, `cfMaxlength`, `cfStep`, `cfRequired` |
| range-slider(-field) | `cfMin`, `cfMax`, `cfRequired`                                         |
| select(-field)       | `cfRequired`                                                           |
| color-picker(-field) | `cfRequired`                                                           |
| radio-field          | `cfRequired`                                                           |
| checkboxes-field     | `cfRequired`                                                           |

> **textarea, file, and booking-datepicker have NO dynamic-validation (`cf*`) attributes** — do not author them. textarea supports only static `minlength`/`maxlength` + custom messages; file uses its own size/type/count limits; booking uses `minNights`/`maxNights` etc.

**`required` expressions**: A truthy resolved value makes the field required; falsy makes it optional. Supported on text-input, range-slider, color-picker, select, radio, and checkboxes (the fields with `cfRequired` above).

**Error message**: for `min`/`max`, the native browser error message reads the resolved value from the DOM attribute — no extra hint block needed.

**Important**: The `transformations.name` dot-path syntax is only valid in infoblock Mustache templates. In `min`/`max` attributes and `data-cf-*` expressions, use the transformation name directly without a namespace prefix.

#### Validation messages <a href="#validation-messages" id="validation-messages"></a>

Every field can carry **custom error messages** per validation rule. In the editor these are set in the **"Validation Messages"** inspector panel (file-field uses its own **"Error Messages"** panel). In block HTML / WP CLI you set them as **block-comment attributes** that the field emits as `data-validate-<rule>-message` on the rendered element; the runtime reads those and overrides the default message. Mustache vars are supported (e.g. `{{min}}`, `{{max}}`, field names).

| Field                    | Rules with a custom message                              | Block-comment attribute(s)                                                                                              |
| ------------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| text-input(-field)       | required, minlength, maxlength, min, max, pattern, email | `requiredMessage`, `minlengthMessage`, `maxlengthMessage`, `minMessage`, `maxMessage`, `patternMessage`, `emailMessage` |
| textarea(-field)         | required, minlength, maxlength                           | `requiredMessage`, `minlengthMessage`, `maxlengthMessage`                                                               |
| select(-field)           | required                                                 | `requiredMessage`                                                                                                       |
| radio-field              | required                                                 | `requiredMessage`                                                                                                       |
| checkboxes-field         | required, min (minselected), max (maxselected)           | `requiredMessage`, `minMessage`, `maxMessage`                                                                           |
| booking-datepicker-field | required                                                 | `requiredMessage`                                                                                                       |
| file-field               | required, file size, file type, max files                | `errorRequired`, `errorMaxSize`, `errorFileTypes`, `errorMaxFiles` (note the `error*` naming)                           |

> **WP CLI / hand-authored HTML must conform to the UI (block validation).** radio-field and checkboxes-field emit a wrapper `<div>` from their `save()` (regenerated from the block-comment attributes), so a message that appears only in the HTML (e.g. `data-validate-minselected-message="…"`) but not in the comment (`requiredMessage`/`minMessage`/`maxMessage`) will fail validation. **Always set both, with identical strings.** (These fields are PHP-rendered on the frontend, but the editor still validates the saved wrapper.) For the wrapper-less fields — text-input, textarea, select, range-slider, color-picker, file, booking — the message only needs to be in the block-comment JSON; the runtime/PHP render emits the matching `data-validate-*-message` for you. Mapping for those:
>
> * `requiredMessage` → `data-validate-required-message`
> * `minlengthMessage` → `data-validate-minlength-message`, `maxlengthMessage` → `data-validate-maxlength-message`
> * `minMessage` → `data-validate-min-message`, `maxMessage` → `data-validate-max-message`
> * `patternMessage` → `data-validate-pattern-message`, `emailMessage` → `data-validate-email-message`

Example — text input with custom messages (the `data-validate-*` attributes are emitted by PHP, so you only write the comment JSON):

```html
<!-- wp:craftforms/text-input {"name":"email","type":"email","required":true,"requiredMessage":"We need your email","emailMessage":"That doesn't look like an email"} /-->
```

Example — required radio (static block: set the message in **both** places):

```html
<!-- wp:craftforms/radio-field {"name":"base","label":"Base","options":[...],"required":true,"requiredMessage":"Please choose a base"} -->
<div class="wp-block-craftforms-radio-field is-layout-flex" data-fieldname="base" aria-describedby="base-error" data-validate-minselected="1" data-validate-minselected-message="Please choose a base" role="group" aria-labelledby="base-label" data-craftforms-field="base">...</div>
<!-- /wp:craftforms/radio-field -->
```

#### Formula syntax <a href="#formula-syntax" id="formula-syntax"></a>

The `formula` field is a Jexl expression. You can reference:

* Raw field names (e.g., `pages`, `price_base`)
* Transformation names (e.g., `service_rate`, `turnaround_multiplier`)
* Standard arithmetic: `+`, `-`, `*`, `/`, `()`
* Functions incl. `round`, `ceil`, `floor`, `min`, `max`, `count`, `sum`, and the date/time helpers `month` / `year` / `day` / `week_day` / `hour` / `count_nights` / `is_date_between` / `is_time_between` (see [Date/time formula functions](#datetime-formula-functions))

**Decimal literals — avoid trailing zeros after the decimal point.** `45` causes a Jexl parse error. Use a plain integer (`45`) or a real decimal with at least one non-zero digit after the point (`45.50`). Never write `45`, `1.00`, `0.50` — always `45`, `1`, `0.5`.

Example:

```
service_base * pages * turnaround_multiplier + hosting_cost + maintenance_cost
```

Ternary for clamping (e.g. minimum height 1m from cm input):

```
(max_width_cm / 100) * (height >= 100 ? height / 100 : 1) * 45
```

#### Submit actions <a href="#submit-actions" id="submit-actions"></a>

**`save_submission`**

```json
{
  "id": "action-save-1",
  "type": "save_submission",
  "order": 1,
  "settings": {}
}
```

**`send_email`**

```json
{
  "id": "action-email-2",
  "type": "send_email",
  "order": 2,
  "settings": {
    "send_to": "{{site.admin_email}}",
    "subject": "New submission from {{email.name}}",
    "body": "Name: {{email.name}}\nEmail: {{email.email}}\nPrice: ${{price}}"
  }
}
```

***

### Conditional Logic Rules <a href="#conditional-logic-rules" id="conditional-logic-rules"></a>

Pro feature. Rules stored in the `conditionalLogicRules` array inside `_craftforms_form_meta`. Evaluated on every form state change; hidden fields are excluded from the `resolvedDataPool` (their values don't affect transformations or price).

#### Rule schema <a href="#rule-schema" id="rule-schema"></a>

Author each rule with the **full canonical key set** — exactly the shape the editor UI emits. Include `cssSelector`, `targetField`, `targetOptions`, `conditionValue`, and `conditionValues` even when empty, so the rule round-trips cleanly through the Conditional Logic editor:

```json
{
  "id": "rule-engraving-text",
  "action": "show",
  "targets": ["engraving_text"],
  "cssSelector": "",
  "targetField": "",
  "targetOptions": [],
  "conditionField": "engraving",
  "operator": "any-of",
  "conditionValue": "",
  "conditionValues": ["yes"]
}
```

#### Prefer the positive `show` action <a href="#prefer-the-positive-show-action" id="prefer-the-positive-show-action"></a>

State the rule the way you'd describe it: "**show** the engraving text field **when** engraving is selected." Use `show` + a positive operator.

* ✅ **Good:** `"action": "show"`, `"operator": "any-of"` (checkbox) / `"one-of"` (radio) → compiles to a clean positive expression like `includes('yes', engraving)` or `engraving == 'yes'`.
* ❌ **Avoid:** `"action": "hide"` + a negative operator (`none-of`/`not-equals`). This compiles to a double negative such as `!(!includes('yes', engraving))`, which is harder to read and easy to get wrong.

Both can produce the same end state, but the positive `show` form is what the editor generates, is self-documenting, and avoids the double-negative trap.

#### Action types <a href="#action-types" id="action-types"></a>

| Action                          | Key(s) used                                                           | Applies to                               |
| ------------------------------- | --------------------------------------------------------------------- | ---------------------------------------- |
| `show` / `hide`                 | `targets` (array of field names)                                      | Entire fields                            |
| `enable` / `disable`            | `targets` (array of field names)                                      | Entire fields                            |
| `show-element` / `hide-element` | `cssSelector` (CSS selector string)                                   | Any DOM element on the page              |
| `show-option` / `hide-option`   | `targetField` (field name) + `targetOptions` (array of option values) | Individual radio/checkbox/select options |

#### Operators <a href="#operators" id="operators"></a>

| Operator       | Condition                    | Value key                 | Field type                       |
| -------------- | ---------------------------- | ------------------------- | -------------------------------- |
| `equals`       | field == value               | `conditionValue`          | single-value (radio/select/text) |
| `not-equals`   | field != value               | `conditionValue`          | single-value                     |
| `is-empty`     | field has no value           | —                         | any                              |
| `is-not-empty` | field has a value            | —                         | any                              |
| `one-of`       | field equals one of several  | `conditionValues` (array) | single-value                     |
| `any-of`       | array field contains any of  | `conditionValues` (array) | multi-value (checkbox)           |
| `all-of`       | array field contains all of  | `conditionValues` (array) | multi-value (checkbox)           |
| `none-of`      | array field contains none of | `conditionValues` (array) | multi-value (checkbox)           |

#### CRITICAL: match the operator to the condition field's type <a href="#critical-match-the-operator-to-the-condition-fields-type" id="critical-match-the-operator-to-the-condition-fields-type"></a>

The operator must match how the runtime stores the field's value, or the rule silently never fires:

* **Single-value fields** (radio, select, text/number) store a **scalar string**. Use `equals` / `not-equals` / `one-of`. These compile to equality (`engraving == 'yes'`).
* **Multi-value fields** (checkboxes) store an **array** (e.g. `["yes"]`, or `[]` when unchecked). Use `any-of` / `all-of` / `none-of`. These compile to membership (`includes('yes', engraving)`).

**Common mistake:** using `one-of` on a **checkbox** condition field. It compiles to `engraving == 'yes'`, but the value is the array `["yes"]`, so `["yes"] == 'yes'` is always false and the rule never triggers. For a checkbox, use `any-of`. (For the same reason, a transformation testing a checkbox uses array syntax: `"yes" in engraving`.)

#### Starters must include the compiled runtime maps <a href="#starters-must-include-the-compiled-runtime-maps" id="starters-must-include-the-compiled-runtime-maps"></a>

`conditionalLogicRules` is the **editor's source of truth**, but the frontend runtime does **not** read it directly. On save, the editor compiles the rules into three flat maps the runtime actually consumes:

| Map                   | Built from actions                                | Key format                                                     | Value                              |
| --------------------- | ------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------- |
| `displayFieldsLogic`  | `show` / `hide` / `show-element` / `hide-element` | CSS selector (e.g. `[data-craftforms-field="engraving_text"]`) | Jexl expression; truthy = visible  |
| `disableFieldsLogic`  | `enable` / `disable`                              | `field:name`                                                   | Jexl expression; truthy = disabled |
| `displayOptionsLogic` | `show-option` / `hide-option`                     | `fieldName:optionValue`                                        | Jexl expression; truthy = visible  |

Because a starter is imported as raw meta (not re-saved through the editor first), **you must hand-write both `conditionalLogicRules` and the matching compiled map(s)**, kept in sync. How each rule compiles (see `buildRuntimeLogic` in `ConditionalLogicModal.js`):

* `show` + `any-of ["yes"]` on `engraving` → `"[data-craftforms-field=\"engraving_text\"]": "includes('yes', engraving)"`
* `show` + `one-of ["yes"]` on a radio → `"[data-craftforms-field=\"…\"]": "engraving == 'yes'"`
* `hide` + same condition → the expression is wrapped in `!( … )` (the double-negative to avoid)

#### Example: filter dependent field options based on another field <a href="#example-filter-dependent-field-options-based-on-another-field" id="example-filter-dependent-field-options-based-on-another-field"></a>

Show only compatible privacy level options based on the selected glass type. Use `hide-option` rules for each incompatible combination:

```json
{
  "conditionalLogicRules": [
    {
      "id": "rule-privacy-frosted",
      "action": "hide-option",
      "targetField": "privacy_level",
      "targetOptions": ["privacy-50", "privacy-80"],
      "conditionField": "glass_type",
      "operator": "equals",
      "conditionValue": "frosted"
    },
    {
      "id": "rule-privacy-solar",
      "action": "hide-option",
      "targetField": "privacy_level",
      "targetOptions": ["privacy-10", "privacy-80"],
      "conditionField": "glass_type",
      "operator": "equals",
      "conditionValue": "solar"
    }
  ]
}
```

When a currently-selected option becomes hidden, its value is automatically cleared and the field re-validates. The rules run on every field change until all conditions stabilize (convergence loop, max 100 iterations).

#### Note on Pro gating <a href="#note-on-pro-gating" id="note-on-pro-gating"></a>

The conditional logic editor UI is a Pro feature. For starters, write `conditionalLogicRules` directly in the form meta JSON — the runtime engine executes them regardless of license.

***

### Template Variable Reference <a href="#template-variable-reference" id="template-variable-reference"></a>

#### In infoblock content <a href="#in-infoblock-content" id="in-infoblock-content"></a>

Infoblocks render with Mustache templating. The event data object passed on every form change has these namespaces:

| Variable                  | Description                                                                         |
| ------------------------- | ----------------------------------------------------------------------------------- |
| `{{form.price}}`          | Computed formula result (rounded)                                                   |
| `{{form.currency}}`       | Site currency symbol (e.g. `€`)                                                     |
| `{{form.priceFormatted}}` | Price formatted per site number format setting                                      |
| `{{field.fieldname}}`     | Current value of a form field                                                       |
| `{{field.name}}`          | Result of a transformation that has `"expose": true` — **canonical display path**   |
| `{{fieldname}}`           | Shorthand for `{{field.fieldname}}` — works, but `{{field.fieldname}}` is canonical |

**`{{field.name}}` is the only supported path for displaying transformation results in infoblocks.** The transformation must have `"expose": true`. Use it for all lookup results — VLT %, warranty text, film codes, application messages, computed lengths, etc.

**Optimization — option values as display values**: When a radio/select field's value will be displayed directly in the infoblock (e.g. a privacy percentage), set the option `value` to the display value itself (e.g. `"47"`, `"65"`, `"82"` instead of `"privacy_47"`). Then `{{field.privacy}}` renders the display-ready string directly, with no extra transformation needed. Avoid creating a `table` transformation just to map option values to labels when the values can be the labels.

**Important syntax difference**:

* In **infoblock** Mustache: `{{field.max_width_cm}}` (transformation must have `expose: true`) ✓
* In **`cfMin`/`cfMax` HTML attributes** on number fields: use `max_width_cm` (bare name, uses form pool) ✓

#### In email settings <a href="#in-email-settings" id="in-email-settings"></a>

| Variable               | Description                            |
| ---------------------- | -------------------------------------- |
| `{{email.fieldname}}`  | Submitted field value                  |
| `{{price}}`            | Computed formula result at submit time |
| `{{site.admin_email}}` | WordPress admin email                  |
| `{{site.name}}`        | Site name                              |

***

### Complex Form Patterns <a href="#complex-form-patterns" id="complex-form-patterns"></a>

#### Multi-output product data form <a href="#multi-output-product-data-form" id="multi-output-product-data-form"></a>

Use when the user selects 2+ choice fields and the form must display multiple data values from a matched row (specs, codes, parameters) in addition to computing a price. Classic example: glass film configurator where glass type + privacy level identify a product SKU row.

**Architecture overview:**

1. **One `table` transformation per data column** — each 2D lookup uses the same `rowVar`/`colVar` but targets a different column. Name them after what they return: `vlt`, `uv_rejection`, `warranty`, `max_width_cm`, `max_height_cm`, `film_code`, `application_msg`, etc.
2. **Conditional logic** (`hide-option`) to filter the dependent field's options based on the first field's value. Configure one rule per incompatible option group.
3. **Number fields with dynamic constraints** — set `"cfMax":"max_width_cm"` and `"cfMax":"max_height_cm"` in the block HTML `text-input` attributes. Error messages auto-resolve the constraint value — no extra hint block needed.
4. **Infoblock** displaying all looked-up values alongside the price (all referenced transformations must have `"expose": true`):

   ```
   Film code: {{field.film_code}}
   VLT: {{field.vlt}}%
   UV rejection: {{field.uv_rejection}}%
   Warranty: {{field.warranty}} years
   {{field.application_msg}}
   Price: {{form.currency}}{{form.price}}
   ```
5. **Formula** uses the looked-up roll width (not the user-entered width) multiplied by clamped height and a hardcoded price per m²:

   ```
   (max_width_cm / 100) * (height >= 100 ? height / 100 : 1) * 45
   ```

   `max_width_cm` = transformation result (full roll width from data table), `height` = user input field, `45` = price per m² hardcoded directly in the formula.

**Example transformations for a glass film table (glass\_type × privacy\_level → per-column values):**

```json
[
  {
    "name": "max_width_cm",
    "type": "table",
    "tableData": [
      ["", "frosted", "solar", "decorative"],
      ["low", 153, 122, 90],
      ["high", 153, 122, 90]
    ],
    "colVar": "glass_type",
    "rowVar": "privacy_level",
    "lookupStrategy": "exact",
    "defValue": 0
  },
  {
    "name": "max_height_cm",
    "type": "table",
    "tableData": [
      ["", "frosted", "solar", "decorative"],
      ["low", 600, 600, 400],
      ["high", 600, 600, 400]
    ],
    "colVar": "glass_type",
    "rowVar": "privacy_level",
    "lookupStrategy": "exact",
    "defValue": 0
  },
  {
    "name": "vlt",
    "type": "table",
    "tableData": [
      ["", "frosted", "solar", "decorative"],
      ["low", 70, 45, 80],
      ["high", 30, 20, 60]
    ],
    "colVar": "glass_type",
    "rowVar": "privacy_level",
    "lookupStrategy": "exact",
    "defValue": 0
  },
  {
    "name": "film_code",
    "type": "table",
    "tableData": [
      ["", "frosted", "solar", "decorative"],
      ["low", "FR70", "SL45", "DC80"],
      ["high", "FR30", "SL20", "DC60"]
    ],
    "colVar": "glass_type",
    "rowVar": "privacy_level",
    "lookupStrategy": "exact",
    "defValue": ""
  },
  {
    "name": "application_msg",
    "type": "conditional",
    "conditions": [
      {"when": "glass_type == 'solar'", "value": "\"This film is for exterior application only\""}
    ],
    "conditionDefault": "\"This film is for interior application only\"",
    "defValue": ""
  }
]
```

***

### WP CLI Cheatsheet <a href="#wp-cli-cheatsheet" id="wp-cli-cheatsheet"></a>

```bash
# Create form post
wp post create --post_type=craftforms_form --post_title="Title" --post_status=publish --path=/path/to/wp --porcelain

# Set UUID
wp post meta add $ID _craftforms_form_uuid "form_slug" --path=/path/to/wp

# Set form meta (JSON) — ALWAYS --format=json so it stores as an array (object meta),
# otherwise the editor UI won't read the formula/smart variables.
wp post meta update $ID _craftforms_form_meta '{"formula":"..."}' --format=json --path=/path/to/wp

# Create demo page
wp post create --post_type=page --post_title="Title" --post_status=publish --post_content="..." --path=/path/to/wp --porcelain

# Get page URL
wp post get $ID --field=guid --path=/path/to/wp

# List forms
wp post list --post_type=craftforms_form --path=/path/to/wp

# Check form meta
wp post meta get $ID _craftforms_form_meta --path=/path/to/wp
```

***

### Starters Checklist <a href="#starters-checklist" id="starters-checklist"></a>

When creating a new starter:

* [ ] Create `craftforms_form` post via WP CLI, save the ID
* [ ] Set `_craftforms_form_uuid` meta
* [ ] Set `_craftforms_form_meta` with formula, transformations, submitActions
* [ ] Create `starters/forms/form-name.html` with header comment and `<!--craftforms-meta ... -->` block and block HTML
* [ ] Use the actual `$FORM_ID` in `ref` (form block) and `connectedForm` (infoblock)
* [ ] Transformation types: prefer `table` → `conditional` → `linked` → `expression` (in that order)
* [ ] If using option `price` attributes, add a `linked` transformation for the field
* [ ] Notification block in its own standalone flex group, separate from submit button
* [ ] No more than 2 fields per row
* [ ] For every required radio/checkboxes field: the block-comment JSON carries `"required":true` **and** `"requiredMessage":"<msg>"`, matching the wrapper's `data-validate-minselected` / `data-validate-minselected-message="<msg>"` exactly (prevents "Block validation failed")
* [ ] Radio/checkbox options use the **new container shape**: each `radio-option`/`checkbox-option` is a `<div class="wp-block-craftforms-{radio,checkbox}-option">` wrapping a `choice-label` (`<span class="wp-block-craftforms-choice-label cf-choice-label">…</span>`) — **no `<label>`/`<input>` in the HTML**. Populate the field's `options[]` array (the frontend source of truth) with full per-option data (`value`, `label`, `price`, `color`, `image`, `description`, `annotation`)
* [ ] `options-group` layout class on the `<div>` must match a `"className"` (and/or `"layout"`) attribute in the comment — default `{"className":"is-layout-flex is-vertical"}`
* [ ] If using option styles (`text`/`color`/`image`/`card`): set `optionStyle` (+ `optionShape` for swatches) on the field **and repeat on each option**; field wrapper gets `cf-field-card` for card style
* [ ] Create demo page pointing to same content
* [ ] Verify live price updates and form submission work

**If using conditional logic:**

* [ ] Add `conditionalLogicRules` array to form meta JSON, each rule with the full canonical key set
* [ ] Prefer the positive `show` action over `hide` + a negative operator (avoid double-negative expressions)
* [ ] Match the operator to the condition field type: `equals`/`one-of` for single-value (radio/select), `any-of`/`all-of`/`none-of` for checkboxes (array values)
* [ ] Also hand-write the compiled runtime map(s) (`displayFieldsLogic` / `disableFieldsLogic` / `displayOptionsLogic`) — the runtime reads these, not `conditionalLogicRules` — and keep them in sync with the rules
* [ ] For option filtering: use `hide-option` action with `targetField` + `targetOptions`
* [ ] Verify hidden options clear their values when they become hidden

**If using dynamic validation (min/max/etc. driven by a transformation):**

* [ ] Open the "Dynamic Validation" panel on the field block in the editor
* [ ] Enter the expression in the relevant field (e.g. `booking_capacity` for Max Value)
* [ ] For `required`, use an expression that resolves to true/false (e.g. `some_flag == "yes"`)
* [ ] In block HTML, use `cf*` attributes: `{"cfMax":"booking_capacity","cfRequired":"needs_qty"}`
* [ ] Verify the `data-cf-*` attributes appear on the `<input>` in the page source
* [ ] Verify the constraint updates live as dependent fields change

**If using option annotations:**

* [ ] Add `"annotation": "{{template}}"` to each option object in the `options` array
* [ ] Add `annotation` attribute + rendered `<span class="cf-option-annotation" data-cf-template="...">...</span>` to each `radio-option` / `checkbox-option` block HTML
* [ ] Ensure all Mustache variables referenced in annotations are available as transformation names or form fields

**For WC-ready starters:**

* [ ] Name prefix `WC:`, file name `wc-{product}.html`
* [ ] Author the qty+button pair using the exact [Add to Cart](#add-to-cart) expanded pattern (`core/group` grid + `qty` text-input-field + submit-button) — **never** write `<!-- wp:craftforms/add-to-cart /-->` directly in a starter file, it renders blank outside the block editor
* [ ] If another field already uses the name `qty`, rename it (e.g. `print_qty`) before adding the WC qty input
* [ ] `qty` is NOT in the formula
* [ ] Submit button label: "Add to Cart"


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://kb.craftformswp.com/documentation/for-developers/ai-form-builder-guide.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
