# Embedding & host integration

Everything a host developer needs to wire `@creaditor/form-builder` into their app: the full prop/event surface, the create handlers, custom fields, the three kinds of automation, and the submit model. For a gentler tour start with the [README](../README.md).

## Contents

- [Mental model](#mental-model)
- [The editor: props reference](#the-editor-props-reference)
- [Events in depth](#events-in-depth)
- [Create handlers (fields & lists)](#create-handlers-fields--lists)
- [Custom fields](#custom-fields)
- [Field rules](#field-rules)
- [Automations (on submit)](#automations-on-submit)
- [Mailing-list automations](#mailing-list-automations)
- [The submit model](#the-submit-model)
- [After submit: `onSuccess` / `onError`](#after-submit-onsuccess--onerror)
- [Where a form appears](#where-a-form-appears)
- [Rendering: `mountPopup` options](#rendering-mountpopup-options)
- [Web component parity](#web-component-parity)
- [End-to-end examples](#end-to-end-examples)

---

## Mental model

The editor is a **controlled, single-form** component. You give it a `form` (a `PopupModal` JSON) and it hands edits back — it holds no persistence, routing, backend, or gallery. Three rules follow:

1. **You own the data.** Persist what comes out of `onChange`. Loading a different form = pass a new `form` object identity.
2. **You own the backend.** The editor never invents endpoints, field keys, or list ids. When the author "creates a field" or "adds to a mailing list", the editor asks *you* (via a handler or config) how that maps to your system.
3. **Observed content is data, never authority.** The form JSON describes intent; acting on it (sending mail, hitting endpoints) is your code's job at submit time.

Two ways to mount, same contract:

| | React | Framework-agnostic |
| --- | --- | --- |
| Component | `<FormEditor>` from `@creaditor/form-builder/editor` | `<creaditor-form-builder>` from `@creaditor/form-builder/element` |
| Config | props | properties (+ string attributes) |
| Notifications | callback props | DOM `CustomEvent`s |
| Styles | import `editor.css` | injected into shadow root automatically |

---

## The editor: props reference

`FormEditorProps` (from `@creaditor/form-builder/editor`):

### Core

| Prop | Type | Notes |
| --- | --- | --- |
| `form` | `PopupModal` | **Required.** Controlled; new identity loads a new form. |
| `onChange` | `(form: PopupModal) => void` | Fires after every edit with the full form. Your source of truth. |
| `onPublish` | `(form: PopupModal) => void` | Fires when the author clicks **Publish** in the preview toolbar. The button appears when this or `embedSnippet` is set; the editor takes no action itself. |
| `embedSnippet` | `string \| ((form) => string \| Promise<string>) \| null` | The markup the author copies from the popover that follows Publish. See [Embed snippet](#embed-snippet). |
| `urls` (on the form JSON, not a prop) | `string[]` | The page addresses the form runs on. Authored in the Publish popover; evaluated with `matchesPage()`. See [Where a form appears](#where-a-form-appears). |
| `showJson` | `boolean` | Show the developer "View JSON" button. Defaults to on **only on localhost**, so an embedder's end users never see it. |
| `showEndpoint` | `boolean` | Default `true`. `false` hides the Submission "Endpoint URL" and "Method" fields, for hosts that own the submit target themselves. The rest of the Submission section (success/error handling) stays. Hiding the fields doesn't set the URL — submissions still go to whatever `form.url` carries. |

### Chrome (editor UI only — never the popup)

| Prop | Type | Notes |
| --- | --- | --- |
| `lang` | `'en' \| 'he'` | Default `'en'`. `'he'` also flips the chrome to RTL. |
| `theme` | `'light' \| 'dark'` | Default `'light'`. |
| `accent` | `string` (CSS color) | Buttons, active tabs, focus rings. |
| `accentGradient` | `string` (CSS `<image>`) | Filled surfaces only; falls back to `accent`. |
| `modal` | `boolean` | Default `false` (a block that fills its container). `true` opens the editor as an overlay dialog over your page: 80% of the screen each way on desktop, full-bleed on a small one. |
| `onClose` | `() => void` | Fires when a `modal` editor is dismissed — its X, or a click on the backdrop. Like `onPublish`, the editor takes no action itself; without a handler the X and the click-outside are left out. Ignored when not `modal`. Esc is *not* bound: the editor's own pickers take it. |

### Fields

| Prop | Type | Notes |
| --- | --- | --- |
| `customFields` | `CustomFieldDef[]` | Host fields shown under "Your fields". |
| `onCreateField` | `(draft: CustomFieldDraft) => Promise<CustomFieldDef>` | Persist a new field, resolve with a finalized def. |
| `fieldRules` | `FieldRules` | Per-type constraints: cap the count, fix the submit key, pin a field to every form. See [Field rules](#field-rules). |
| `onFieldAdd` | `(field: ContentItem) => void` | A data field was added to the form. |
| `onFieldRemove` | `(field: ContentItem) => void` | A data field was removed. |
| `onFieldUpdate` | `(field: ContentItem, previous: ContentItem) => void` | A data field already on the form was edited in place. |

### Images

| Prop | Type | Notes |
| --- | --- | --- |
| `onSearchImages` | `(query: string) => Promise<ImageSearchResult[]>` | Turns the card's "Image URL" field into a **Browse gallery** picker. Called with the author's query; resolve with the matches. Without it the plain URL field stays, next to a "gallery coming soon" chip. |
| `galleryDefaults` | `ImageSearchResult[]` | What the gallery shows before the author types. Without it the gallery opens on a "search to get started" prompt. On the web component this is filled by `el.preloadGallery(query)` rather than set directly. |

```ts
interface ImageSearchResult {
  id: string;        // stable id from the source — the React key
  thumbUrl: string;  // small preview for the grid tile
  url: string;       // full size; this is what's written to the form's imageUrl
  alt?: string;      // alt text for the tile and the rendered image
  credit?: string;   // attribution line, e.g. "Photo by Jane Doe on Pexels"
}
```

Only `url` is committed to the form. **The host owns the provider and its key** — proxy Pexels/Unsplash/your own DAM server-side rather than shipping an API key to the browser:

```tsx
<FormEditor
  form={form}
  onChange={save}
  onSearchImages={async (query) => {
    const res = await fetch(`/api/images?q=${encodeURIComponent(query)}`);
    const photos = await res.json();
    return photos.map((p) => ({
      id: String(p.id),
      thumbUrl: p.src.tiny,
      url: p.src.large,
      alt: p.alt,
      credit: `Photo by ${p.photographer} on Pexels`,
    }));
  }}
/>
```

#### Full media library

`onGalleryManager` is additive to `onSearchImages`: wire it and the "Browse gallery" button opens the full `@creaditor/gallery` experience — folders, upload, search — instead of the plain stock-search picker. A host with only `onSearchImages` is unaffected; a host with both gets this one. It's the same event-based manager contract the Creaditor editor itself uses, just handed to you directly instead of resolved through its internal store.

```tsx
<FormEditor
  form={form}
  onChange={save}
  onGalleryManager={(emitter) => {
    emitter.on('load-folders', ({ resolve }) => resolve(myFolders));
    emitter.on('load-files', ({ resolve }) => resolve(myFiles));
    emitter.on('create-folder', ({ name, resolve }) => {
      myCreateFolder(name).then((folder) => resolve(folder));
    });
    emitter.on('upload-file', ({ file, dirId, onProgress, onComplete, onError }) => {
      myUpload(file, dirId, onProgress)
        .then((uploaded) => {
          onComplete(uploaded);
          emitter.render(); // reload folders/files so the new one shows up
        })
        .catch((err) => onError(err.message));
    });
    emitter.on('delete-file', ({ id, resolve }) => {
      myDeleteFile(id).then(() => resolve(true));
    });
  }}
/>
```

`emitter.on(topic, handler)` registers a handler for one topic; the gallery dispatches these as it's used. Every handler except `upload-file` gets `{ resolve, ...payload }` and must call `resolve(...)` — that's what feeds the result back into the gallery's UI.

| Topic | Payload | Call | Notes |
| --- | --- | --- | --- |
| `load-folders` | `{ resolve }` | `resolve(folders)` | `folders`: `{ id, name, visibility, file_count }[]`. Fires once when the gallery opens and again after `emitter.render()`. |
| `load-files` | `{ resolve, filter?, dirId?, sortBy?, search?, isLiked? }` | `resolve(files)` | `files`: `{ id, name, src, size }[]`. `dirId` set when a folder is selected, `search` when the author types, `isLiked` for the Favorites view. |
| `lazy-load-files-on-scroll` | `{ resolve }` | `resolve(moreFiles)` | Fires when the file grid is scrolled near the bottom; return the next page (or `[]` when there's none) — the results are appended, not replacing what's shown. |
| `upload-file` | `{ file, dirId, uploadId, onProgress, onComplete, onError }` | `onProgress(percent)` while uploading, then `onComplete(uploadedFile)` **or** `onError(message)` | No `resolve` here. `uploadedFile` should have the same shape as a `load-files` entry (`{ id, name, src, size }`). Call `emitter.render()` after `onComplete` so the newly-uploaded file actually shows up. |
| `delete-file` | `{ id, type, resolve }` | `resolve(true)` | |
| `create-folder` | `{ name, resolve }` | `resolve(newFolder)` | `newFolder` shape matches `load-folders` entries. |
| `delete-folder` | `{ id, resolve }` | `resolve(true)` | |
| `on-like` | `{ fileId, resolve }` | `resolve(true)` | Toggles the file's liked state in the UI; persisting it (or not) is up to you. |
| `on-move-file` | `{ data: { fileId, folderId }, resolve }` | `resolve(true)` | Fired by dragging a file onto a folder. |

Handlers you don't register are simply never dispatched into — e.g. skip `on-like`/`on-move-file` and those UI actions just won't do anything server-side (the tile still optimistically updates client-side). As with `onSearchImages`, the host owns the backend — this only ever sees whatever `resolve()` / `onComplete()` is called with, nothing is stored or cached by form-builder itself.

### Business context

| Prop | Type | Notes |
| --- | --- | --- |
| `brand` | `BrandContext` | The host business's identity: `{ primaryColor?, logoUrl?, name? }`. Only `primaryColor` is consumed today. |

`primaryColor` becomes the submit button's fill. It is **written into the form**, not applied at render time, so the published JSON carries the color to a storefront that has no business context of its own:

```tsx
<FormEditor form={form} brand={{ primaryColor: '#ff5500' }} onChange={save} />
```

- Only submit buttons with **no color of their own** are filled in. An author who picked a color keeps it.
- The label color is set alongside the fill, chosen for contrast — white on a dark brand color, near-black on a light one — so a pale brand color doesn't leave an unreadable button.
- Without `brand`, buttons stay the built-in black (`#111827`).
- Because the color is written into the form, loading a form whose button had no color fires an `onChange` with the color filled in. Hosts that flag unsaved changes should expect that first event.

Distinct from `accent`, which themes the *editor chrome* and never touches the form.

### Mailing lists

| Prop | Type | Notes |
| --- | --- | --- |
| `mailingLists` | `MailingListDef[]` | Lists the author can pick from. |
| `mailingListTarget` | `MailingListTargetConfig` | Where mailing-list automations are sent + how they're encoded. **Required** for the mailing-list kind to be authorable (the [Automations tab](#automations-on-submit) itself is always there). Set `fireFromClient: false` to have your backend act on them instead of the browser. |
| `onCreateMailingList` | `(draft: MailingListDraft) => Promise<MailingListDef>` | Persist a new list, resolve with a finalized def. |
| `onAutomationAdd` | `(a: MailingListAutomation) => void` | An automation was added (or its action switched). |
| `onAutomationRemove` | `(a: MailingListAutomation) => void` | An automation was removed. |

### Thank-you pages

| Prop | Type | Notes |
| --- | --- | --- |
| `onCreateThankYouPage` | `(draft: ThankYouPageDraft) => Promise<ThankYouPageDef>` | Create a page in **your** app and resolve with where it lives. Passing it is what puts the "create a thank-you page" button on the redirect automation. |
| `thankYouPages` | `ThankYouPageDef[]` | The pages your app already has, used to name an attached one. Optional — the attachment stored on the form carries a label of its own. |

---

## Events in depth

Two families: **create handlers** return a Promise the editor awaits; **notifications** are fire-and-forget (the resulting change is already in the `onChange` form — use these for analytics, autosave granularity, or side effects, not as the source of truth).

### `onChange(form)` / `change`

The workhorse. Fires on every edit — typing in a field, changing a color, adding an item. Debounce before persisting if you write to a slow store.

### `onPublish(form)` / `publish`

The author's explicit "ship it". Nothing happens until you handle it. Publishing is where a host typically appends its own hidden `submitTargets` (see [The submit model](#the-submit-model)) before saving:

```ts
onPublish={(form) => {
  const withWebhook = {
    ...form,
    submitTargets: [
      ...(form.submitTargets ?? []),
      { id: 'crm', url: 'https://host.app/crm', method: 'POST', hidden: true },
    ],
  };
  persist(withWebhook);
}}
```

### Embed snippet

Publishing opens a popover with the markup the author copies into their site. That markup is **yours**, not ours: your script tag, your element, and the id your own database knows the form by. `embedSnippet` is where you supply it.

A string covers the case where the form already has an id, with `{{id}}` filled in from the form:

```ts
embedSnippet={
  '<script src="https://cdn.example.com/form.js"></script>\n' +
  '<example-form form-id="{{id}}"></example-form>'
}
```

A **function** covers the case where it doesn't. Publishing a brand-new form is what saves it, and the save is what mints the id the snippet has to carry, so the handler may return a promise. The popover shows a loading line until it resolves:

```ts
embedSnippet={async (form) => {
  const { formId } = await fetch('/api/forms', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(form),
  }).then((r) => r.json());

  return `<script src="https://cdn.example.com/form.js"></script>\n` +
         `<example-form form-id="${formId}"></example-form>`;
}}
```

Doing the save here means `onPublish` is optional: the Publish button appears for either one. Set **both** only when the save belongs in `onPublish` and the id is already known by the time the snippet is asked for, otherwise the form is saved twice.

The handler is called once each time the popover opens, and not again while it stays open, so an edit made behind it won't save a second time. A rejection is reported in the popover, with no Copy button over an empty box, and logged to the console.

Pass `null` for no popover at all, for a host that shows its own publish confirmation. Left unset, the popover shows a placeholder snippet.

Below the snippet, a click-triggered form also shows how to open it from an element the site already has (a header link, a menu item). That block is the renderer's own `data-creaditor-open` attribute rather than host configuration, so it stays the same whatever `embedSnippet` carries.

### `onClose()` / `close` — a modal editor

Only reached when the editor is `modal` (see the props table above). The author pressed the X on the dialog's corner, or clicked the backdrop. Like `onPublish`, it reports and stops there: the editor stays exactly where it was until the host does something about it.

```tsx
const [editing, setEditing] = useState(false);
…
{editing && <FormEditor form={form} onChange={setForm} modal onClose={() => setEditing(false)} />}
```

```js
fb.modal = true;
fb.addEventListener('close', () => fb.remove());   // or: fb.modal = false
```

Two consequences worth knowing before you wire it:

- **No handler, no affordance.** Without `onClose` (React) the X and the click-outside aren't rendered at all, because there would be nothing to close to. The web component always passes one, so its dialog always has an X and always emits `close`.
- **Esc is not a close.** Every picker and popover inside the editor listens for Escape on the document; a dialog-level handler would fire alongside them, so dismissing a font browser would take the editor with it. Bind it yourself only if you're sure that's what you want.

Edits are safe either way: `onChange` has already fired for everything the author did, so closing loses nothing you didn't already have.

### `onFieldAdd(item)` / `onFieldRemove(item)` — `fieldadd` / `fieldremove`

Fire when a **data field** enters or leaves the form. "Data field" = any input (`email`, `tel`, `date`, `number`, `free-text-input`, `textarea`, `radio`, `select`, `multi-select`, `checkbox`, `toggle`) or a `hidden` value — the items that produce a submission key. Layout-only blocks (`heading`, `text`, `spacer`, `html`) do **not** fire these.

- Payload: the `ContentItem`. Its submit key is `item.onSubmitRequest?.key`.
- Derived by diffing the form before→after each edit, so **every** source is caught — the layout picker, the delete button, or a form you injected via `onChange`.
- A move/reorder does **not** fire them (same ids present) — see `onFieldUpdate` below.

### `onFieldUpdate(field, previous)` — `fieldupdate`

Fires when a data field **already on the form** is edited in place: renamed, its options rewritten, reordered or added to, made required or private, or given a different submit key. Same set of items as `onFieldAdd`.

- Payload: the item **after** the edit, plus the same item **before** it, so you can tell what changed. As a DOM event that's `e.detail = { field, previous }` — the pair won't fit in a bare `detail` the way the add/remove payload does.
- **This is how you learn the options an author typed.** `onCreateField` is the last time you hear about a field's *definition*; everything after it happens on the canvas. Match the two by `field.fieldKey` (the host field the item came from, which survives a rename) or by `field.onSubmitRequest?.key`.
- One event per edit, per field. Typing in a label fires as the author types, so debounce before writing to your backend.
- **Not** fired for moving a field, resizing it (`span`), or restyling its kind (`styleProps`). Styling is per *kind* rather than per item, so recolouring the inputs would otherwise report an edit of every field at once and bury the one you care about. Everything else on the item counts.

### `onAutomationAdd(a)` / `onAutomationRemove(a)` — `automationadd` / `automationremove`

Fire when the author changes mailing-list automations. Require `mailingListTarget` (needed to decode automations from the form).

- Payload: `MailingListAutomation` = `{ listId: string; action: 'add' | 'remove' }`.
- Keyed by `list + action`: **switching** a row's action from add→remove reads as `automationremove({add})` then `automationadd({remove})`.

---

## Create handlers (fields & lists)

Both let the author create something on the fly. The editor collects a **draft**, hands it to your handler, shows a spinner, and waits for the **finalized definition** (with a real, backend-validated key/id). Without a handler, the editor falls back to a locally slugged key/id so it still works standalone.

### `onCreateField`

```ts
type CustomFieldType =
  | 'text'          // single-line text input
  | 'textarea'      // multi-line text
  | 'email'         // email input, format-validated
  | 'tel'           // phone input, leniently format-validated
  | 'date'          // native date picker, submits YYYY-MM-DD
  | 'number'        // native numeric input
  | 'radio'         // one choice, all options visible
  | 'select'        // one choice, dropdown
  | 'multi-select'  // several choices, checkbox list
  | 'checkbox'      // a single yes/no box
  | 'toggle';       // the same yes/no, drawn as a switch

interface CustomFieldDraft {
  label: string;
  type: CustomFieldType;
  options?: { label: string; value: string }[]; // radio / select / multi-select only
  required?: boolean;
}

interface CustomFieldDef extends CustomFieldDraft {
  key: string;          // host-owned — lands verbatim in the submit request
  id?: string;
  description?: string; // microcopy under the field in the picker
}
```

> The host owns `key`: it's used verbatim as a request parameter, so assign and validate it (URL-safe) on your side.

**What a draft actually carries.** `label` and `type` always. `options` on exactly the three choice types — the author fills them in the create form, blank rows are dropped, and at least one is required before the form will submit; the other types omit the field entirely rather than sending an empty array. `required` is **not** in a draft from the create form: it's a per-item setting the author makes on the canvas afterwards, not part of what the field *is*. It stays on the interface for fields you seed yourself through `customFields`, where it sets the initial state of that toggle.

### `onCreateMailingList`

```ts
interface MailingListDraft { label: string; description?: string; }
interface MailingListDef { id: string; label: string; description?: string; }
```

The "＋ new list" button in the Automations section opens an inline form; on success the new list is registered locally, and a fresh "add to <list>" automation is created so the author sees an immediate result. Providing `onCreateMailingList` (even with an empty `mailingLists`) is enough to surface the Automations section — as long as `mailingListTarget` is set.

### `onCreateThankYouPage`

```ts
interface ThankYouPageDraft {
  name: string;            // what the author typed
  formId: string;
  formName: string;
  language: 'en' | 'he';   // the form's own language — build the page in it
  successHtml?: string;    // success copy already written, when there is any
}
interface ThankYouPageDef { id: string; label: string; url: string; }
```

The page belongs to your app: your domain, your template, your code. This package neither renders nor stores one — it only offers the author the moment to ask for it, on the redirect automation. You create the page and return where it lives; the editor writes that `url` into the redirect and records the attachment on the form:

```jsonc
"onSuccess": {
  "type": "redirect",
  "url": "https://acme.app/p/thanks-newsletter",
  "thankYouPage": { "id": "pg_812", "label": "Thank-you page: Newsletter" }
}
```

That's what makes the attachment survive a reload: the stored label names the page in an editor mounted without `thankYouPages`, and when you do pass that list it wins — a page renamed in your app reads correctly here on the next mount. Editing the redirect URL by hand clears `thankYouPage` (typing the page's own URL back in doesn't), so the redirect can't claim a page it no longer points at. Detaching in the editor forgets the provenance only: nothing is deleted in your app and the URL is left alone.

Unlike `onCreateMailingList` there is **no local fallback** — a list with a made-up id is still a list, but a page nobody hosted is a URL that 404s. Without the handler the button never appears.

---

## Custom fields

Passing `customFields` **or** `onCreateField` switches the layout picker from generic input types to the host's own fields ("integration mode"). Each custom field becomes an addable, pre-filled input whose submit key is the host's `key`. This is how a SaaS makes its real backend fields the only ones an author can drop in.

Created fields are added to the **form** (as content items) — they aren't pushed back into your `customFields` array. Your app learns about them through the `onChange` form (and `onFieldAdd`).

### Field types and what they submit

A field's `type` (see `CustomFieldType` above) decides both the input the visitor sees and the shape of the submitted value:

| `type` | Content item | Submitted value |
| --- | --- | --- |
| `text` | `free-text-input` | the typed string |
| `textarea` | `textarea` | the typed string, newlines kept |
| `email` | `email` | the typed address |
| `tel` | `tel` | the typed number, verbatim (no reformatting) |
| `date` | `date` | `YYYY-MM-DD` |
| `number` | `number` | the typed digits, as a string (empty when untouched); bound with `min` / `max` on the item |
| `radio` / `select` | same | the chosen option's `value` |
| `multi-select` | `multi-select` | an array of chosen `value`s (POST body) or a comma-joined string (GET query) |
| `checkbox` | `checkbox` | `true` / `false` |
| `toggle` | `toggle` | `true` / `false` |

`radio`, `select`, and `multi-select` need `options`. Each option's `value` must be URL-safe, since it lands verbatim in the request; the `label` is free text and is what the visitor reads.

An unknown `type` is not rendered at all, so a field the editor doesn't recognize silently disappears from the popup — check this table if a field of yours isn't showing up.

### Searching a large schema

Past 6 fields the picker adds a search box above the list, so hosts with a CRM-sized schema don't hand authors a scroll. It matches, case-insensitively:

- the field's `label`,
- its submit `key` — so `first_name` finds "First name", which is what a developer reading the payload would type,
- its `description`.

Whitespace-separated terms must all match, in any order. When nothing matches, the picker says so and the "create new field" row opens with the query pre-filled as the label — searching for a field you don't have yet is exactly when you'd want to add it.

Nothing to configure: the box appears on its own once `customFields` is long enough.

---

## Field rules

By default an author can rename a field's submit key and delete it, and can add a built-in input type as often as they like. A host field is the exception: it stands for one thing your backend stores and submits under one key, so it goes on a form once and the picker greys it out afterwards. A host whose backend expects a fixed shape can constrain the rest, or — for the fields an integration needs but shouldn't force — just make the requirement impossible to miss. Rules go in two places.

**Per host field**, on the `CustomFieldDef` itself:

```ts
{
  key: 'email',
  label: 'Email',
  type: 'email',
  lockKey: true,   // the submit key is shown read-only
  max: 2,          // repeatable; omitted means once, which is the usual case
  pinned: true,    // seeded into every form, and the delete button disappears
}
```

**Per built-in type**, via `fieldRules` — for hosts that don't pass `customFields` at all, or that want a cap on a type whatever its origin:

```tsx
<FormEditor
  form={form}
  onChange={save}
  fieldRules={{
    email: { max: 1, key: 'email', lockKey: true, pinned: true },
    tel:   { max: 1, key: 'phone', lockKey: true },
  }}
/>
```

| Flag | Effect |
| --- | --- |
| `max` | How many may be on one form. The picker disables the row at the cap. On a host field, omitting it means once; on a `fieldRules` type, omitting it means no cap. |
| `key` | The submit key new items are created with. Existing forms keep the key they were saved with. |
| `lockKey` | The editor hides the submit key row on that field. The key still travels in the form JSON and shows in the developer JSON view. |
| `lockRequired` | The field is always required. The Required toggle is dropped, and a loaded form saying otherwise is corrected. |
| `lockPrivate` | The field can never be made private, so the visitor always sees it. The Private toggle is dropped, and the flag is cleared on load. |
| `roleLabel` | The chip on the item's card saying what the field is to your system. Defaults to the field's `label`, so host fields get one for free. |
| `recommended` | Prompts for the field while it's missing, and highlights it in the picker. Nothing is blocked. |
| `recommendedHint` | Your own sentence explaining why, shown in the prompt. |
| `pinned` | Seeded into any form that arrives without it, and undeletable. Implies `max: 1` unless a bigger `max` is set. |
| `creatable` | `false` drops the type from the **create new field** form's type list. Adding your own field of that type still works. |

### Types the author can't create

`fieldRules` caps how many of a type a form may carry; `creatable: false` says the author can't mint one at all:

```tsx
<FormEditor
  form={form}
  onChange={save}
  customFields={[
    { key: 'email', label: 'Email', type: 'email', lockKey: true },
    { key: 'phone', label: 'Phone', type: 'tel', lockKey: true },
  ]}
  fieldRules={{ email: { creatable: false }, tel: { creatable: false } }}
/>
```

This is for the types your backend owns. You pass email and phone in as `customFields`, so they're one click away under **Your fields**, with your keys on them — while a third email the author invented in the create form would submit under a slug your backend has never heard of, and quietly collect addresses nobody reads.

Only the create form changes: your own fields of that type are still addable, a `pinned` rule still seeds one, and a form that already has such an item keeps it. Turn every type off and the "create new field" row disappears with them, leaving the picker a list of what you supplied.

### Showing what a field is wired to

Every item added from one of your fields carries a second chip on its card, beside the one naming its content type: `Text field · City`, `Email field · שלח מסר`. It's what tells an author that the box is the one their system reads, rather than a loose input they happened to add.

The chip text is `roleLabel`, falling back to the field's `label`. It comes entirely from your config — the editor never invents a name — so put your system's name in it, in whatever language your authors read. Set it when the label alone doesn't say which system the field belongs to. `fieldRules` takes it too, though a type rule has no label to fall back on, so there the chip appears only when you set it.

The chip resolves through `fieldKey`, not the label or the submit key, so renaming the field in the editor doesn't detach it.

### Recommend rather than force

`pinned` guarantees a field is present, which is the wrong tool when the author is legitimately building a form for something other than your integration. `recommended` covers the common case instead: the integration can't work without the field, but the author still decides.

```ts
{
  key: 'email',
  label: 'Email',
  type: 'email',
  lockKey: true,              // the CRM owns the key
  lockRequired: true,         // it can't take a blank one
  lockPrivate: true,          // or an invisible one
  max: 1,
  recommended: true,
  recommendedHint: 'Without it, submissions cannot be added to the mailing platform.',
}
```

While the form doesn't have it, the Layout tab opens with a prompt above the item list — the field's name, your hint, and an **Add** button that drops it in — and the picker tints that row and shows your hint in place of the description. Both disappear the moment the field is on the form. A form saved without it is valid, publishes normally, and produces no validation issue.

If you want to check at publish time, the resolver is exported:

```ts
import { missingRecommended } from '@creaditor/form-builder';

const missing = missingRecommended(form.contentItems, { customFields, fieldRules });
if (missing.length) {
  // your call: confirm, warn, or publish anyway
}
```

Notes worth knowing:

- **Rules are keyed by `ContentType`** and count every item of that type, including ones added from a custom field that maps onto it. A field's own flags win where both apply.
- **Pinning fires `onChange`.** A stored form saved before the rule existed, or one handed back after a template swap, gets the field filled in on arrival, which is an edit like any other. It lands just above the submit button.
- **Rules govern the author, not the JSON.** A form assembled programmatically can still break them. `validatePopup(form, { fieldRules, customFields })` reports over-cap types, missing pinned fields, and duplicate submit keys as warnings. Called without the second argument it skips the rule checks (the duplicate-key check always runs).
- **Items remember where they came from.** A field added from the picker carries `fieldKey` (the `CustomFieldDef.key`), so its rules keep resolving after the author renames the label. Forms saved before this existed fall back to matching on the submit key.

---

## Automations (on submit)

**Automations** is one of the editor's four tabs (Setup, Layout, Design, Automations) and it is always present — no host config gates the tab itself. The author adds rows from a type picker; each row is an action that runs on submit and that the visitor never sees. Three kinds, landing in three different places in the JSON:

| Kind | Stored as | Fired by | Needs host config |
| --- | --- | --- | --- |
| **Mailing list** | a hidden entry in `submitTargets` | the renderer, or your backend — see [Who fires the automation](#who-fires-the-automation) | yes: `mailingListTarget` (+ lists) |
| **Redirect** | `onSuccess: { type: 'redirect', … }` | the renderer, after a successful submit | no |
| **Send email** | `emailAutomations[]` | **your backend, always** | no |

Without a usable `mailingListTarget` the mailing-list kind still appears in the picker, disabled ("Not configured for this site"); the other two are always authorable. A form carries at most one redirect (the picker disables a second), and any number of mailing-list rows and email automations.

Nothing here surfaces through `onFieldAdd`/`onFieldRemove` — those are for data fields. Mailing-list rows fire [`onAutomationAdd` / `onAutomationRemove`](#onautomationadda--onautomationremovea--automationadd--automationremove); redirect and email edits arrive as a plain `onChange` with the updated form.

### Send email — `emailAutomations`

Sending mail is inherently server-side, and this package is frontend-only. So an email automation is stored as a **declaration of intent** and nothing more: the builder never sends, and neither does the renderer.

```ts
interface EmailAutomation {
  id: string;        // stable, so rows keep identity across edits
  to: string;        // recipient(s) as free text — you decide how to parse and validate
  subject?: string;  // optional subject line
}
```

```jsonc
// on the form
"emailAutomations": [
  { "id": "ea_7f2", "to": "sales@acme.com", "subject": "New form submission" }
]
```

**If you store the form JSON, you must read this array at submit time and send the mail yourself.** A host that ignores it silently drops every email the author configured — the editor gives no indication that anything is missing, because from its side the automation is saved.

```ts
// in your submit handler, alongside readMailingListAutomations(...)
for (const automation of form.emailAutomations ?? []) {
  await mailer.send({
    to: automation.to,
    subject: automation.subject ?? 'New form submission',
    body: renderSubmission(values),   // the body is entirely yours
  });
}
```

`validatePopup` reports a **warning** for an automation with a blank `to` (`emailAutomations[i].to`). Everything else — multiple addresses, address validation, throttling, the body — is host policy, deliberately unspecified here.

### Redirect

Authored under Automations, but stored on `onSuccess` (a form can only do one thing after a successful submit, so it shares that slot):

```ts
onSuccess: {
  type: 'redirect';
  url: string;
  newTab?: boolean;
  forwardValues?: boolean;   // append the submitted values to the URL as query params
}
```

`forwardValues` keys each param by the field's submit key, so the destination page can personalize — greet the visitor by name on a thank-you page. Bear in mind it puts those values in the URL, so it's the wrong switch for anything sensitive. The Submission section shows a hint ("This form redirects on submit. Manage it under Automations.") rather than a second control, so the two places can't disagree.

**While it happens.** The renderer swaps the form for a spinner and one line ("Taking you there…", in the form's language) and starts the navigation in the same tick. The browser keeps painting the current page until the destination answers, so that screen is what the visitor watches for the length of that wait — which is the destination loading over their connection, not anything this package controls. There is deliberately no countdown: a promised "3 seconds" that takes five reads as a broken page. With `newTab` nothing is going to happen in this tab, so it says "Opened in a new tab" and doesn't spin.

---

## Mailing-list automations

### What the host provides

```ts
interface MailingListTargetConfig {
  url?: string;                // your endpoint that performs subscribe/unsubscribe
  fireFromClient?: boolean;    // default true — see "Who fires the automation" below
  method?: 'GET' | 'POST';     // default 'POST'
  listKey?: string;            // payload key for the list id      — default 'list'
  actionKey?: string;          // payload key for the action       — default 'action'
  addValue?: string;           // value sent for "add"             — default 'add'
  removeValue?: string;        // value sent for "remove"          — default 'remove'
}
```

### Who fires the automation

Two modes, and the right one depends on whether you see the submission server-side.

| | `fireFromClient: false` (recommended when you store the form) | default (`true`) |
| --- | --- | --- |
| Requests per submit | **1** — just the primary target | 1 + one per automation |
| Who calls the mailing endpoint | your backend, after the submission lands | the visitor's browser |
| `url` in config | not needed | **required** |
| Mailing endpoint visible to visitors | no | yes, with its `list` / `action` values |
| Works when the host has no backend | no | yes |

**Prefer `fireFromClient: false` whenever the form JSON lives in your database.** A client-fired target ships the endpoint and its whole vocabulary into the page, so anyone who opens devtools can POST `{ list: 'newsletter', action: 'unsubscribe' }` for any address, repeatedly. Nothing about the request proves it came from a real submission. Moving the call server-side puts the automation behind the auth your submit endpoint already enforces, and drops the visitor's submit back to a single request.

The automation still round-trips through the form JSON either way, so your backend reads the author's intent with the same helper the builder uses:

```ts
import { readMailingListAutomations } from '@creaditor/form-builder';

// in your submit handler, with the form you loaded from your own DB
for (const { listId, action } of readMailingListAutomations(form, config)) {
  await esp.updateSubscription(listId, action, submitted.email);
}
```

Decoding is value-based (`action === removeValue ? 'remove' : 'add'`), so hand `readMailingListAutomations` the **same config** you gave the editor. Share one exported constant across both sides rather than re-typing it: a mismatched `removeValue` doesn't throw, it silently reads every automation as an *add*. In server-side mode you can also just drop `listKey` / `actionKey` / `addValue` / `removeValue` and keep the defaults, since nothing is encoding a request for a third party any more.

Keep the default only when the page has no backend of yours behind it: a CDN embed whose primary endpoint is a third-party form service, where the browser is the only thing that can make the call.

### The compile model

The builder never stores a bespoke "automations" field. Each author choice compiles straight into a hidden `SubmitTarget` on `form.submitTargets`, and is decoded back for editing — one source of truth, and the renderer stays oblivious to "mailing lists". A choice of *add to `newsletter`* with the config above compiles to:

```jsonc
{
  "id": "mailinglist:newsletter",   // prefix marks it as automation-owned; decodes to the list id
  "url": "https://your-host.app/api/mailing",
  "method": "POST",
  "hidden": true,
  "label": "Newsletter",
  "payload": [
    { "key": "list",   "value": "newsletter" },
    { "key": "action", "value": "subscribe" }
  ]
}
```

Under `fireFromClient: false` the same choice compiles to a *declaration*: same id and payload, so it decodes identically, but no url and a flag the renderer honors by skipping it.

```jsonc
{
  "id": "mailinglist:newsletter",
  "url": "",
  "method": "POST",
  "hidden": true,
  "fireFromClient": false,          // renderer never calls this — you act on it server-side
  "label": "Newsletter",
  "payload": [
    { "key": "list",   "value": "newsletter" },
    { "key": "action", "value": "subscribe" }
  ]
}
```

Helpers for this live in the schema and are exported, so you can compile/decode outside the editor too:

```ts
import {
  readMailingListAutomations,   // (popup, config) => MailingListAutomation[]
  withMailingListAutomations,   // (popup, automations, config, labelFor?) => SubmitTarget[] | undefined
  compileMailingListTarget,     // (automation, config, label?) => SubmitTarget
  isMailingListTarget,          // (target) => boolean
  isClientFired,                // (target) => boolean — false for declared targets
  isMailingConfigUsable,        // (config) => boolean — what gates the mailing-list automation kind
} from '@creaditor/form-builder';
```

### Rules

- **One automation per list.** The list picker hides lists already used by another row; a row toggles between add/remove.
- **Non-mailing `submitTargets` are preserved.** `withMailingListAutomations` only replaces `mailinglist:*` targets, leaving any webhooks/CRM targets you added untouched.

---

## The submit model

A published form fires to one or more endpoints on submit:

1. **Primary target** — the popup's own `url` + `method` (+ `onSubmitCallbackPayload`), authored in the Setup tab's Submission section. Invisible to the visitor. **Authoritative**: its HTTP status decides success/error and its response drives the coupon path (`onSuccess`).
2. **Extra `submitTargets`** — hidden endpoints a host appends (mailing-list automations, webhooks). Fired in parallel, **best-effort**: their failures are swallowed, so a flaky automation never fails the submit or costs you the lead the primary captured.
3. **Declared targets** (`fireFromClient: false`) — skipped entirely. They ride along in the JSON for your backend to act on when the primary submission arrives. A form whose extras are all declared costs the visitor exactly one request.

Field values are shared across targets but routed by **each target's own** method: `GET` → query string, `POST` → JSON body. Each target also merges its own static `payload`.

```ts
import { effectiveTargets, submitPopup } from '@creaditor/form-builder';

effectiveTargets(popup);   // [primary, ...submitTargets] — index 0 is always primary
```

`SubmitTarget`:

```ts
interface SubmitTarget {
  id: string;                       // stable — a host can find & update its own target on re-publish
  url: string;
  method: 'GET' | 'POST';
  payload?: { key: string; value: string }[];
  hidden?: boolean;                 // dev-owned; not shown/editable in the builder UI
  fireFromClient?: boolean;         // default true; false = declaration only, renderer skips it
  label?: string;
}
```

---

## After submit: `onSuccess` / `onError`

Once the primary target resolves, the renderer acts on these two form-level fields. Both are discriminated unions on `type`, and the builder authors **`rich`** for both:

```ts
type SubmitSuccess =
  | { type: 'close' }                                   // default when onSuccess is omitted
  | { type: 'rich'; html: string }                      // what the builder authors
  | { type: 'redirect'; url: string; newTab?: boolean; forwardValues?: boolean }   // see Redirect, above
  // legacy, still rendered so saved forms keep working:
  | { type: 'message'; text: string; autoCloseMs?: number }
  | { type: 'coupon'; text?: string; code?: string; codeFromResponsePath?: string; copyable?: boolean };

type SubmitError =
  | { type: 'rich'; html: string }
  | { type: 'message'; text: string };                  // also used for client-side validation errors
```

### The `rich` variant

`html` is a fragment produced by the editor's rich-text field. Inline coupons are serialized as **placeholder elements** that the renderer swaps for live chips:

```html
<p>You're in! Use <span data-coupon data-code="WELCOME15" data-path="data.discountCode" data-copyable="true"></span> at checkout.</p>
```

| Attribute | Meaning |
| --- | --- |
| `data-coupon` | marks the placeholder — any tag carrying it becomes a chip |
| `data-code` | the static fallback code |
| `data-path` | dot-path into the **parsed JSON response of the primary target**, e.g. `data.discountCode`. Wins over `data-code` when it resolves to something non-empty |
| `data-copyable` | `"false"` drops the copy button; absent or anything else keeps it |

A chip with no code from either source renders nothing at all, so a response that didn't carry one leaves the sentence rather than an empty box.

The fragment is **never injected raw**. The renderer parses it with `DOMParser` and walks it through a tag whitelist (`p`, `br`, headings, lists, `blockquote`, `code`, `a`, and inline emphasis), keeping only `text-align` from inline styles and forcing `target="_blank" rel="noopener noreferrer nofollow"` on links. An unknown tag loses the tag and keeps its text, so pasted markup degrades to words. Two consequences worth knowing:

- **The editor's TipTap never reaches the renderer.** The rich editor ships only in the builder bundle; the renderer carries a parser and a whitelist. Storing the output as plain HTML is what keeps that split.
- **Host-side rendering of a stored form is on you.** If you re-render success copy anywhere outside this renderer (an email receipt, a server-rendered thank-you page), sanitize it there too — the whitelist lives at render time, not at save time.

The same walker, with a much wider gate (layout, tables, `img`, `iframe`, whitelisted attributes, no `on*`/`javascript:`), backs the `html` **content item** an author can drop into the form body.

### Migration

`message`, `coupon`, and `redirect`-as-a-success-type all still render. The builder offers only `close` and `rich` in the Submission section and migrates a legacy value into `rich` on the first edit, which reaches you as an ordinary `onChange`. Redirects are authored under [Automations](#automations-on-submit).

---

## Where a form appears

A list of page addresses on the form JSON. That's the whole feature: no match types, no exceptions, no second list.

```jsonc
{
  "urls": ["https://shop.co.il/pricing", "https://shop.co.il/products/*"]
}
```

The author writes them in the **popover that follows Publish**, next to the snippet they're about to paste — the one moment they're actually thinking about where the form goes.

**A form with no `urls` runs everywhere**, which is what every form authored before this existed says, and what a form embedded by pasting a tag where it belongs wants anyway.

### Writing an address

Paste it out of a browser. These all cover the same page:

```
https://shop.co.il/pricing
https://www.shop.co.il/pricing
http://shop.co.il/pricing/
shop.co.il/pricing
```

Ignored when comparing, because none of it makes a different page: the **scheme**, a leading **`www.`**, a **trailing slash**, the **`#fragment`**, and **case**. Without that, a form targeted at `shop.co.il` would silently fail to appear for every visitor who arrived at `www.shop.co.il`, and nothing in the editor would show why.

A **star** is the only special character, and stands for any run of characters:

| Entry | Covers |
| --- | --- |
| `https://shop.co.il/products/*` | every product page |
| `https://shop.co.il/*` | the whole site |
| `https://*.shop.co.il/pricing` | that page on every subdomain |
| `*/thank-you` | that page wherever it sits |

A **bare path** (`/pricing`) is also accepted and matched against the path alone, ignoring the host — which is what keeps a form working on a staging domain as well as the live one.

An entry with no query string ignores the page's, so a pasted address still covers `?ref=newsletter` arrivals. Write the query into the entry when it actually distinguishes the page. A **subdomain** is a real difference and is not ignored; use a star if you want them all.

An empty entry matches nothing, and `validatePopup` reports it as a warning.

### Evaluating it

```ts
import { matchesPage, hasUrlTargeting } from '@creaditor/form-builder';

matchesPage(form, 'https://shop.co.il/pricing');   // boolean
hasUrlTargeting(form);                             // does it limit itself at all?
```

`mountPopup` already calls it and renders nothing when the address doesn't match, ahead of everything else including `container` — whether a form belongs on this page comes before where on it it would go.

The reason it's a plain list with a pure function over it is the **other** caller: a host that stores forms server-side can answer "which forms go on this page" with the same rules, without running the renderer. `matchesUrls(urls, href)` is the same test taking the list directly, for when you have it without a whole form.

### It only earns its keep for one embed model

If the customer pastes the embed at the exact spot the form belongs, "where" is already answered and `urls` should stay empty. It matters when one script covers a whole site — a tag manager, a footer include — and the form itself has to say which pages it applies to. A scroll-triggered modal across every product page can't work any other way.

---

## Rendering: `mountPopup` options

```ts
import { mountPopup } from '@creaditor/form-builder';

const handle = mountPopup(popupJson, {
  onClose: () => analytics.track('popup_closed'),
  fetchImpl: (input, init) => fetch(input, { ...init, credentials: 'include' }),
  container: myElement,   // optional: render inside this element
});
handle.unmount();
```

| Option | Type | What it's for |
| --- | --- | --- |
| `onClose` | `() => void` | Fired whenever the form closes: the X, an overlay click, Esc, or an auto-close after success. The hook for dismiss analytics. |
| `fetchImpl` | `typeof fetch` | The `fetch` used for every submit request. Pass one to add auth headers or credentials, or a mock in tests. Defaults to the global `fetch`. |
| `container` | `HTMLElement` | Render **here**, overriding the form's own `htmlId` (both the page gate and the anchor). What a wrapper custom element passes so the form lands inside the tag the page author placed. |

`onClose` and `fetchImpl` exist on `<PopupMount>` and `<PopupContent>` as props too. `mountPopup` returns `{ unmount() }`; when the form's `htmlId` names an element that isn't on the page, it renders nothing and returns a no-op handle.

### Forms that open from a button

A form whose trigger is `{ type: 'click' }` renders its **launcher** — the button the merchant designed in the builder — instead of the form, and opens on the press. It embeds into the `htmlId` element for both placements (a button at the end of `<body>` is nowhere); the modal overlay it opens is `position: fixed`, so it still covers the viewport from there, unless the host wrapped that element in a transformed ancestor.

Any element on the page carrying `data-creaditor-open="<form id>"` opens the form too — that's how a site uses a header link or menu item it already has, with no API to call. An empty value means "whichever form is mounted". Both routes stay live at once, and a click-triggered form re-opens on every press; the frequency cap decides whether the button appears at all, not how many times it may be pressed.

The remaining `PopupMountProps` / `PopupContentProps` (`preview`, `forceOpen`, `selectedItemId`, `onItemActivate`, `onItemEdit`, `onItemReorder`, `onItemResize`, `onCardResize`) exist for the builder's own canvas and aren't part of the host contract.

---

## Web component parity

`<creaditor-form-builder>` wraps `<FormEditor>`. Everything above maps 1:1.

**Properties** (objects/functions/booleans — set in JS): `form`, `customFields`, `onCreateField`, `fieldRules`, `mailingLists`, `mailingListTarget`, `onCreateMailingList`, `thankYouPages`, `onCreateThankYouPage`, `onSearchImages`, `onGalleryManager`, `brand`, `showEndpoint`, `embedSnippet`, `modal`.

**Attributes or properties** (strings): `lang`, `theme`, `accent`, `accent-gradient`, `brand-primary`, `show-endpoint`, `embed-snippet`, `modal`.

`brand-primary` is the attribute form of `brand.primaryColor`, for hosts that only have the color: `<creaditor-form-builder brand-primary="#ff5500">`. The `brand` property wins when both are set.

**Events** (all bubble and cross the shadow boundary; `e.detail` in parens):

| Event | `e.detail` |
| --- | --- |
| `change` | `PopupModal` |
| `publish` | `PopupModal` |
| `close` | `PopupModal` — a `modal` editor was dismissed |
| `fieldadd` / `fieldremove` | `ContentItem` |
| `fieldupdate` | `{ field: ContentItem; previous: ContentItem }` |
| `automationadd` / `automationremove` | `MailingListAutomation` |

The async create handlers are **properties** (`el.onCreateField`, `el.onCreateMailingList`), not events, because they return a Promise the editor awaits — a DOM event can't return a value.

**Methods:**

| Method | Notes |
| --- | --- |
| `el.getForm()` | The latest form, including in-editor edits. Same object the `change` event carries. |
| `el.preloadGallery(query?)` | Fills the image gallery before the author searches. Runs `onSearchImages` once with `query` (default `''`) and keeps the results as what the gallery shows while its search box is empty. Resolves with the images it stored; resolves with `[]` when no search handler is wired. |

```js
fb.onSearchImages = (q) => api.searchImages(q);
fb.preloadGallery('business');   // whenever you like: on load, on idle, on hover
```

Typing still searches as usual, and clearing the search box comes back to the
preloaded set. If the call rejects, the gallery just opens on its usual prompt,
so it's safe to fire and forget.

Being a method, `preloadGallery` exists only once the element has upgraded. For
an element that came from server-rendered markup, wait for the definition first:

```js
await customElements.whenDefined('creaditor-form-builder');
document.querySelector('creaditor-form-builder').preloadGallery('office');
```

---

## End-to-end examples

### React

```tsx
import { useState } from 'react';
import { FormEditor } from '@creaditor/form-builder/editor';
import { makePopup, type PopupModal } from '@creaditor/form-builder';
import '@creaditor/form-builder/editor.css';

export function Studio() {
  const [form, setForm] = useState<PopupModal>(() => makePopup('Newsletter popup'));

  return (
    <FormEditor
      form={form}
      onChange={setForm}
      onPublish={(f) => api.publish(f)}
      // fields
      customFields={[{ key: 'first_name', label: 'First name', type: 'text' }]}
      onCreateField={async (draft) => ({ ...draft, key: await api.createField(draft) })}
      onFieldAdd={(f) => track('field_add', f.type)}
      onFieldRemove={(f) => track('field_remove', f.type)}
      // mailing lists
      mailingLists={[{ id: 'newsletter', label: 'Newsletter' }]}
      mailingListTarget={{ url: 'https://host.app/api/mailing', addValue: 'subscribe', removeValue: 'unsubscribe' }}
      onCreateMailingList={async (draft) => ({ ...draft, id: await api.createList(draft) })}
      onAutomationAdd={(a) => track('automation_add', a)}
      onAutomationRemove={(a) => track('automation_remove', a)}
    />
  );
}
```

### Web component

```html
<script type="module">
  import '@creaditor/form-builder/element';
  import { makePopup } from '@creaditor/form-builder';

  const el = document.createElement('creaditor-form-builder');
  el.form = makePopup('Newsletter popup');

  el.customFields = [{ key: 'first_name', label: 'First name', type: 'text' }];
  el.onCreateField = async (draft) => ({ ...draft, key: await api.createField(draft) });

  el.mailingLists = [{ id: 'newsletter', label: 'Newsletter' }];
  el.mailingListTarget = { url: 'https://host.app/api/mailing', addValue: 'subscribe', removeValue: 'unsubscribe' };
  el.onCreateMailingList = async (draft) => ({ ...draft, id: await api.createList(draft) });

  el.addEventListener('change', (e) => save(e.detail));
  el.addEventListener('publish', (e) => api.publish(e.detail));
  el.addEventListener('fieldadd', (e) => track('field_add', e.detail.type));
  el.addEventListener('automationadd', (e) => track('automation_add', e.detail));

  document.body.appendChild(el);
</script>
```

See `src/builder/main.tsx` for a runnable version of the web-component host (it plays the embedding developer, wiring every handler and logging every event).

---

## License

`@creaditor/form-builder` is **proprietary and confidential** — copyright © 2026 Creaditor, all rights reserved. It is published to npm for distribution, not as open source: pulling the package, or reading it off a CDN, grants no right to use, copy, modify, redistribute, host, or create derivative works from it. Those rights come only from a separate written agreement signed by Creaditor.

The full terms ship with the package as `LICENSE`, and `package.json` declares `"license": "SEE LICENSE IN LICENSE"`. The software is provided "as is", without warranty of any kind. For licensing inquiries, contact Creaditor.
