# @creaditor/form-builder

An embeddable, JSON-driven form/popup builder. Two independent halves that share one schema:

- **Editor** — a controlled UI (`<FormEditor>` for React, `<creaditor-form-builder>` for anything else) that lets a person author a form and hands you the JSON. Authoring happens on the rendered form itself: click a field to select it, type its label in place, drag it into a new slot.
- **Renderer** — turns that JSON into a working popup on your page (trigger, frequency, submit).

The editor owns no persistence, navigation, or backend. You give it a form and listen for changes; **you** decide what saving, publishing, fields, and mailing lists mean. That contract is the whole point — see [Events & host integration](#events--host-integration).

```
author  ──▶  <FormEditor> ──(onChange / events)──▶  your app  ──▶  save / publish
                                                          │
                                              PopupModal JSON
                                                          │
your page  ◀── mountPopup(json) ◀─────────────────────────┘
```

---

## Install

`@creaditor/form-builder` is published to **npm**. Two ways in, depending on who's integrating:
install it into a bundler app (**A**), or load a self-contained bundle over a public CDN with
no npm/build at all (**B**).

### A. In a React / bundler app — from npm

```bash
npm i @creaditor/form-builder
# pin a version:  npm i @creaditor/form-builder@0.2.1
```

`react` / `react-dom` are **peer** dependencies (the host app provides them).

Entry points:

| Import | What you get |
| --- | --- |
| `@creaditor/form-builder` | Renderer + schema (`mountPopup`, `PopupContent`, types, `validatePopup`, `makePopup`) |
| `@creaditor/form-builder/editor` | React `FormEditor` + `FormEditorProps`, `Lang` |
| `@creaditor/form-builder/editor.css` | The editor stylesheet (import once, React only) |
| `@creaditor/form-builder/element` | The `<creaditor-form-builder>` web component (side-effect import registers it) |
| `@creaditor/form-builder/templates` | Optional starter `PRESETS` to seed the editor's `form` |

> Installing from a local path? Add `resolve: { dedupe: ['react', 'react-dom'] }` to the consuming app's Vite config, or a symlinked package resolves its own React copy and hooks break.

### B. On a plain page — over a public CDN (no npm, no build)

The published package ships two **self-contained** bundles (React + everything, one file each)
under `dist-cdn/`. Because they're on npm, any page can load them straight from a public CDN —
[unpkg](https://unpkg.com) or [jsDelivr](https://www.jsdelivr.com) — with a single `<script>`
tag. No bundler, no import map, no React on the page. This is the path for a plain HTML page,
a CMS, or an **ASP.NET / ASPX** site.

| Bundle | CDN URL (pin the `@version`) | Global |
| --- | --- | --- |
| **Editor** | `https://unpkg.com/@creaditor/form-builder@0.2.1/dist-cdn/creaditor-form-builder.js` | `<creaditor-form-builder>` element |
| **Renderer** | `https://unpkg.com/@creaditor/form-builder@0.2.1/dist-cdn/creaditor-renderer.js` | `window.CreaditorPopup` |

jsDelivr serves the same files at `https://cdn.jsdelivr.net/npm/@creaditor/form-builder@0.2.1/dist-cdn/<file>`.

**Editor** — the authoring UI (drop into an admin page):

```html
<script src="https://unpkg.com/@creaditor/form-builder@0.2.1/dist-cdn/creaditor-form-builder.js"></script>

<creaditor-form-builder id="fb"></creaditor-form-builder>
<script>
  const fb = document.getElementById('fb');
  fb.form = { /* a PopupModal JSON — e.g. what your app produced earlier */ };
  fb.addEventListener('change', (e) => save(e.detail));      // edited form
  fb.addEventListener('publish', (e) => publish(e.detail));
</script>
```

**Renderer** — show the finished popup on a public page:

```html
<script src="https://unpkg.com/@creaditor/form-builder@0.2.1/dist-cdn/creaditor-renderer.js"></script>
<script>
  CreaditorPopup.mountPopup(/* the PopupModal JSON */);   // wires trigger, frequency, submit
</script>
```

The editor registers `<creaditor-form-builder>` on load and renders into a shadow root — React
and every style live **inside** the file, so nothing leaks in from (or out to) the host page.
Configure via attributes (`lang`, `theme`, `accent`, `accent-gradient`, `brand-primary`, `show-endpoint`, `embed-snippet`, `modal`) or properties; the
[events table below](#events--host-integration) applies unchanged. The renderer injects its own
styles on first render — no CSS import needed.

> **Pin the version** (`@0.2.1`) in production. The unversioned URL follows `latest` and can
> change under you. Prefer to self-host? The same two files are in the package under
> `dist-cdn/` — put them on your own CDN and swap the URL.

> **Getting the JSON in:** it's assigned inline via `fb.form` (editor) or passed to
> `mountPopup` (renderer). To load a form by id from your backend, fetch it in the page first.

> **On an ASP.NET (ASPX / Web Forms / MVC) site?** Full step-by-step guide — injecting JSON
> from code-behind, posting edits back to a handler, rendering popups on public pages, and the
> `UpdatePanel`/postback gotchas: [`docs/ASPX.md`](./docs/ASPX.md).

---

## Quick start

### 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}                 // fires on every edit — persist this
      onPublish={(f) => publish(f)}      // Publish button in the preview toolbar
    />
  );
}
```

`form` is **controlled**: pass a new object identity to load a different form. Edits come back through `onChange` (and the granular events below) — the editor never mutates your prop.

### Anywhere else (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.addEventListener('change', (e) => save(e.detail));      // edited form
  el.addEventListener('publish', (e) => publish(e.detail));
  document.body.appendChild(el);
</script>
```

The bundle also exposes `CreaditorFormBuilder.validatePopup(form)` and `.isValidPopup(form)` on the same global, so a page with no build step can check a form before it stores it — see [Locking the submit endpoint](#locking-the-submit-endpoint) for why that matters when you hide the endpoint fields.

Renders into a shadow root, so the host page's CSS neither leaks in nor is polluted. String config (`lang`, `theme`, `accent`, `accent-gradient`, `brand-primary`, `show-endpoint`, `embed-snippet`, `modal`) works as attributes or properties; objects/functions (`form`, `customFields`, …) are properties. `el.getForm()` reads the latest form, including in-editor edits, at any time.

> This ESM import expects a bundler (React resolved from `node_modules`). For a **plain page with no build step**, use the self-contained CDN bundle instead — see [B. On a plain page](#b-on-a-plain-page--over-a-public-cdn-no-npm-no-build).

---

## Element types

Everything an author can place on a form. The `type` is what lands in `contentItems`; the full per-type reference — which of `value` / `options` / `required` each one honours — is [§5 of the schema](./POPUP-COMPONENT-JSON-SCHEMA.md#5-per-type-details).

**Inputs** — these produce a submission key, and are the items the field events report:

| `type` | Renders as | Submits |
| --- | --- | --- |
| `email` | an email input | the address, format-validated |
| `tel` | a phone input | the number as typed, leniently validated |
| `date` | a native date picker | `YYYY-MM-DD` |
| `number` | a native numeric input, optionally bounded by `min` / `max` | the digits as a string; empty when untouched |
| `free-text-input` | a single-line text input | the typed string |
| `textarea` | a multi-line box (`rows`, default 4) | the typed string, newlines kept |
| `select` | a dropdown | the chosen option's `value` |
| `radio` | a radio group, all options visible | the chosen option's `value` |
| `multi-select` | a checkbox list | every chosen `value` — a JSON array in a POST body, comma-joined in a query string |
| `checkbox` | a single tick box | `true` / `false` |
| `toggle` | a switch | `true` / `false` |
| `hidden` | nothing | a fixed value the author sets — never shown |

`checkbox` and `toggle` are the same boolean by two drawings: a checkbox for an agreement ("I accept the terms"), a toggle for a setting ("Email me deals"). Any input can be marked `private` — rendered nowhere, its value seeded from the page URL's query string, which is how a ref or campaign code rides through the form.

**Layout & content** — no submission key, no field events:

| `type` | Renders as |
| --- | --- |
| `heading` | a heading |
| `text` | a paragraph |
| `spacer` | a fixed vertical gap (`height`, default 16px) |
| `html` | the author's own markup, sanitized |
| `page-break` | nothing — it marks where a step form's next screen starts |
| `submit-button` | the button that fires the submit |

Hosts supplying their own fields use `CustomFieldType`, which is the same set minus the layout pieces and with `text` in place of `free-text-input` — see [Custom fields](#custom-fields-host-supplied-inputs).

---

## Events & host integration

The editor surfaces everything a host needs to react to as callbacks (React) / DOM events (web component). **Full reference with payload shapes and end-to-end examples: [`docs/EMBEDDING.md`](./docs/EMBEDDING.md).** At a glance:

| Concern | React prop | Web-component event | Fires when |
| --- | --- | --- | --- |
| Any edit | `onChange(form)` | `change` | the form changes at all |
| Publish | `onPublish(form)` | `publish` | the author clicks Publish |
| Close | `onClose()` | `close` | a `modal` editor is dismissed (its X, or the backdrop) |
| Field added | `onFieldAdd(item)` | `fieldadd` | an input/hidden field is added |
| Field removed | `onFieldRemove(item)` | `fieldremove` | a field is removed |
| Field edited | `onFieldUpdate(item, previous)` | `fieldupdate` | a field already on the form is renamed, re-optioned, made required… |
| Automation added | `onAutomationAdd(a)` | `automationadd` | a mailing-list action is added |
| Automation removed | `onAutomationRemove(a)` | `automationremove` | a mailing-list action is removed |
| **Create** a field | `onCreateField(draft) → Promise` | `onCreateField` (property) | the author creates a field on the fly |
| **Create** a mailing list | `onCreateMailingList(draft) → Promise` | `onCreateMailingList` (property) | the author creates a list on the fly |

The two **create** handlers are Promises, not events: the host persists the new field/list on its backend and resolves with the finalized definition (a real, validated key/id), and the editor shows a loader until then. Everything else is a fire-and-forget notification — the resulting change is already reflected in the `onChange` form.

---

## Custom fields (host-supplied inputs)

Pass the fields your backend knows about and the editor shows them under **Your fields** in the layout picker instead of generic inputs. Each becomes a pre-filled input whose submit `key` is yours.

```tsx
<FormEditor
  form={form}
  onChange={setForm}
  customFields={[
    { key: 'first_name', label: 'First name', type: 'text' },
    { key: 'phone', label: 'Phone', type: 'text', required: true },
  ]}
  onCreateField={async (draft) => {
    const key = await backend.createField(draft); // persist, get a validated key
    return { ...draft, key };
  }}
/>
```

Passing `customFields` **or** `onCreateField` puts the editor in "integration mode". Without `onCreateField`, created fields fall back to a locally slugged key so the editor still works standalone.

Past a handful of fields the picker grows a **search box**, so a CRM-sized schema stays usable. It matches the label, the submit `key`, and the description — so `first`, `first_name`, and a word from the description all find the same field, and multiple terms narrow further. Searching for something that doesn't exist offers "create new field" with the query already filled in as the label.

### Field rules

If your backend expects a fixed shape, say so. Flags go on the field itself, or on `fieldRules` for the built-in types:

```tsx
<FormEditor
  form={form}
  onChange={setForm}
  customFields={[
    {
      key: 'email',
      label: 'Email',
      type: 'email',
      lockKey: true,                // your key, not the author's
      lockRequired: true,           // always required, no toggle to switch off
      lockPrivate: true,            // always visible to the visitor
      // max: 2,                    // repeatable; omitted means once per form
      roleLabel: 'שלח מסר',          // chip on the card: what it is to your system
      recommended: true,            // prompt for it, don't force it
      recommendedHint: 'Without it, submissions cannot be added to SendMsg.',
    },
  ]}
  fieldRules={{ tel: { max: 1, key: 'phone', lockKey: true, creatable: false } }}
/>
```

A host field goes on a form once by default: it submits under one key, so a second copy would collect the same answer twice. The picker greys the row out and says "Already in this form." `max` overrides that (a number above 1 for a genuinely repeatable field, and on a `fieldRules` type it's the cap where there'd otherwise be none), `key` fixes the submit key on new items, and `lockKey` hides the key row from the author entirely (it stays in the form JSON). `lockRequired` and `lockPrivate` drop the Required and Private toggles and normalize the item to match, for a field your backend can't take blank or invisible. `roleLabel` puts a chip on the item's card saying what the field is to your system: the text is yours, in whatever language your authors read, and it defaults to the field's label so host fields get a chip for free.

`creatable: false` takes a type out of the **create new field** form: the author can add the email field *you* passed in, but can't mint one of their own that submits under a key your backend has never heard of. Your own fields of that type stay addable, and with every type turned off the create row disappears entirely.

`recommended` is for the field your integration needs but the author still owns: while it's missing, the Layout tab shows a prompt with your `recommendedHint` and a one-click **Add**, and the picker highlights the row. Nothing is blocked — a form without it is valid and publishes normally, which keeps the same builder usable for forms that have nothing to do with your integration. `pinned` is the strict version: seeded into every form and undeletable.

See [Field rules](docs/EMBEDDING.md#field-rules).

---

## Automations (on submit)

**Automations** is a tab of its own in the editor, and it's always there — the author picks from three kinds, all of which run on submit and none of which the visitor sees:

| Kind | Where it lands in the JSON | Who acts on it |
| --- | --- | --- |
| **Mailing list** — add/remove the submitter from a list | a hidden `submitTargets` entry | the renderer, or your backend (see `fireFromClient` below) |
| **Redirect** — send the visitor to another page after submit | `onSuccess: { type: 'redirect', … }` | the renderer |
| **Send email** — notify an address on submit | `emailAutomations[]` | **your backend, always** |

Only the mailing-list kind needs configuring: without a usable `mailingListTarget` it's offered but greyed out ("Not configured for this site"), while redirect and email need nothing from the host to author. A form may carry at most one redirect.

### Send email (`emailAutomations`)

Sending mail is inherently server-side and this package is frontend-only, so an email automation is stored as a **declaration** and nothing more. The author fills in a recipient and an optional subject; the builder and the renderer never send anything:

```ts
form.emailAutomations
// [{ id: 'ea_1', to: 'sales@acme.com', subject: 'New form submission' }]
```

**If you store the form JSON, read this array when a submission arrives and send the mail yourself** — otherwise every email the author configured is silently dropped. `validatePopup` warns on an automation with a blank `to`; everything else (multiple addresses, formatting, the body) is yours to define.

### Redirect

> **A redirect is where the visitor goes, not where the data goes.** It runs *after* the primary
> endpoint (`url`) has already answered successfully — a failed submit shows `onError` and never
> reaches it. If your server is meant to *receive* the submission, its address belongs in `url`
> ([The submit model](#the-submit-model)), not here. Putting it here alone means nothing is ever
> sent: the submit fails first, and the redirect you were relying on never fires.

Authored here, but stored on `onSuccess` — a form can only do one thing after a successful submit, so the two share a slot and the Submission section links here instead of offering a second control. `forwardValues` appends the submitted values to the destination URL, keyed by each field's submit key, so a thank-you page can greet the visitor by name. It puts those values in the URL, so it's the wrong switch for anything sensitive.

#### The thank-you page

The page a redirect lands on is **yours** — your domain, your template, your code. The builder doesn't render or store one; it just offers the author the moment to ask for it. Pass a handler and a "create a thank-you page" button appears on the redirect automation:

```tsx
<FormEditor
  form={form}
  onChange={setForm}
  thankYouPages={pages}                         // what your app already has (optional)
  onCreateThankYouPage={async (draft) => {
    // draft: { name, formId, formName, language, successHtml? }
    const page = await api.createThankYouPage(draft);
    return { id: page.id, label: page.title, url: page.url };   // ThankYouPageDef
  }}
/>
```

The author names the page, you create it, and the URL you return is written into the redirect. The attachment is recorded on the form as `onSuccess.thankYouPage = { id, label }`, so **after a reload the editor still says which page is attached** — the stored label covers an editor mounted without `thankYouPages`, and when you do pass that list it wins, so a page renamed in your app reads correctly here.

The draft carries the form's id, name and language plus any success copy already written, so the page can be seeded rather than blank. Editing the redirect URL by hand detaches the page (typing the page's own URL back in doesn't), so the two can't disagree. Detaching only forgets the provenance: it deletes nothing in your app and leaves the URL alone. Without `onCreateThankYouPage` no button appears — there'd be nobody to create the page, and unlike a mailing list there's no sensible local fallback: a URL nobody hosts is a 404.

While the redirect happens the popup shows a spinner and one line ("Taking you there…", in the form's language). Navigation starts the instant the submit resolves, but 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. There is deliberately no countdown: the wait is the destination page loading over the visitor's connection, which nothing here can predict, and a promised "3 seconds" that takes five reads as a broken page. `newTab` says "Opened in a new tab" instead — nothing is going to happen in this one.

### Mailing list

Let the author say *"on submit → add to Newsletter"* / *"remove from Trial"* without touching code or endpoints. You supply the lists and one endpoint config; each choice compiles into a **hidden submit target** the visitor never sees.

```tsx
<FormEditor
  form={form}
  onChange={setForm}
  mailingLists={[
    { id: 'newsletter', label: 'Newsletter', description: 'Weekly product news.' },
    { id: 'promos', label: 'Promotions' },
  ]}
  mailingListTarget={{
    url: 'https://your-host.app/api/mailing',
    method: 'POST',
    listKey: 'list', actionKey: 'action',        // all optional — sane defaults
    addValue: 'subscribe', removeValue: 'unsubscribe',
  }}
  onCreateMailingList={async (draft) => {
    const id = await backend.createList(draft);   // persist, get a real id
    return { ...draft, id };
  }}
/>
```

The mailing-list kind is offered for real once `mailingListTarget` is usable — it carries a `url`, *or* it sets `fireFromClient: false` — *and* there's at least one list or an `onCreateMailingList` handler. (`isMailingConfigUsable(config)` is exported if you need the same test.)

**If you store the form JSON, set `fireFromClient: false`.** The author's choices still compile into the form and your backend reads them back with `readMailingListAutomations(form, config)` when the submission arrives, but the renderer never calls them: the visitor's submit stays **one** request, and the mailing endpoint is never exposed to the browser. That last part matters, because a client-fired target publishes both the URL and the `list` / `action` vocabulary to anyone with devtools, who can then subscribe or unsubscribe any address they like, as often as they like. Server-side, the automation rides in behind whatever auth your submit endpoint already has.

```tsx
mailingListTarget={{ fireFromClient: false }}   // no url needed — nothing is dialed
```

The default (`fireFromClient` unset) fires each automation from the browser, in parallel with the primary target and best-effort, so a flaky mailing call never costs you the lead. Keep it only when you *don't* see the submission server-side, e.g. a CDN embed posting to a third-party form service. See [`docs/EMBEDDING.md`](./docs/EMBEDDING.md#mailing-list-automations) for the compile model and how automations round-trip through `submitTargets`.

---

## Image gallery (host-supplied search)

The card image is a URL field by default. Wire `onSearchImages` and it becomes a **Browse gallery** picker: the author types a query, you return the matches, and picking a tile writes its `url` into the form.

```tsx
<FormEditor
  form={form}
  onChange={setForm}
  onSearchImages={async (query) => {
    const photos = await fetch(`/api/images?q=${encodeURIComponent(query)}`).then((r) => r.json());
    return photos.map((p) => ({
      id: String(p.id),
      thumbUrl: p.src.tiny,      // the grid tile — keep it small
      url: p.src.large,          // what lands in the form's imageUrl
      alt: p.alt,
      credit: `Photo by ${p.photographer} on Pexels`,
    }));
  }}
/>
```

On the web component it's a property, not an attribute: `el.onSearchImages = fn`.

**You own the provider and its key.** Proxy Pexels / Unsplash / your own DAM from your backend, as above, rather than calling them from the browser with a key in the bundle. Only `url` is committed to the form; `thumbUrl`, `alt`, and `credit` drive the picker UI. Without the handler the plain Image URL field stays, next to a "gallery coming soon" chip.

### Media library (folders, upload, browse — like the Creaditor editor)

`onSearchImages` above is a one-shot search box. If you have (or want) an actual media library behind the Card image field — folders, drag-and-drop upload, a persistent file grid — wire `onGalleryManager` instead. It's additive: set it alongside or instead of `onSearchImages`, and "Browse gallery" opens the full library UI. This is the same `@creaditor/gallery` component the Creaditor editor itself uses.

```tsx
<FormEditor
  form={form}
  onChange={setForm}
  onGalleryManager={(emitter) => {
    // Called once per topic your library supports. Every handler gets
    // `resolve` to hand data back, except upload, which reports progress.
    emitter.on('load-folders', ({ resolve }) => {
      fetch('/api/media/folders').then((r) => r.json()).then(resolve);
    });
    emitter.on('load-files', ({ resolve, dirId, search }) => {
      const q = new URLSearchParams({ dirId: dirId ?? '', search: search ?? '' });
      fetch(`/api/media/files?${q}`).then((r) => r.json()).then(resolve);
    });
    emitter.on('upload-file', ({ file, dirId, onProgress, onComplete, onError }) => {
      const body = new FormData();
      body.append('file', file);
      if (dirId) body.append('dirId', dirId);
      fetch('/api/media/upload', { method: 'POST', body })
        .then((r) => r.json())
        .then((uploaded) => {
          onComplete(uploaded); // { id, name, src, size }
          emitter.render();     // reload the grid so it shows up
        })
        .catch((err) => onError(err.message));
    });
  }}
/>
```

That's the minimum to get folders + browsing + upload working. There's a full event reference — every topic, its exact payload, and what to resolve with — in [Embedding & host integration → Full media library](./docs/EMBEDDING.md#full-media-library).

On the web component it's a property too: `el.onGalleryManager = fn`.

---

## Rendering the result on your page

The editor's output is a `PopupModal` JSON (shape: [`POPUP-COMPONENT-JSON-SCHEMA.md`](./POPUP-COMPONENT-JSON-SCHEMA.md)). Three entry points, most to least batteries-included:

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

- **`mountPopup(json)`** — for a storefront/plain page. Wires the trigger, frequency cap, and placement, then renders when appropriate. Returns `{ unmount() }`.
- **`<PopupMount popup={json} />`** — the same mount behavior as a React component.
- **`<PopupContent popup={json} />`** — just the drawing (design + fields + submit); you decide when it shows.

```ts
const handle = mountPopup(popupJson, {
  onClose: () => track('popup_dismissed'),   // fired when the form closes (X, overlay, esc, auto-close)
  fetchImpl: myFetch,                        // optional: submit through your own fetch
  container: myElement,                      // optional: render inside this element
});
// handle.unmount() to tear down
```

All three are optional; `onClose` and `fetchImpl` exist on `<PopupMount>` too. `onClose` is the hook for dismiss analytics; `fetchImpl` swaps the `fetch` used for the submit (auth headers, a mock in tests). `container` says where to render, overriding the form's own `htmlId` — that's what a wrapper custom element passes (`mountPopup(json, { container: this })`) so the form lands inside the tag the page author placed.

The renderer injects its own styles on first render — no CSS import at the call site. See `src/renderer/demo.ts` for a full standalone example (`npm run dev` → the renderer demo at `/renderer-demo.html`).

### Placement: inline vs. modal

`placement` decides where the form lands. **`inline` is the default** — the form embeds in the page flow with no overlay:

```html
<!-- the form renders inside this element -->
<div id="signup-slot"></div>
```

```ts
mountPopup({ ...form, placement: 'inline', htmlId: 'signup-slot' });
```

`htmlId` does double duty. For both placements it's a **page gate**: set it and the form renders only on pages containing that element; leave it off and the form renders everywhere the script loads. For an `inline` form the matched element is also the **anchor** it embeds into. An inline form with no `htmlId` has nowhere to embed, so it lands at the end of `<body>`.

`placement: 'modal'` opts into the centered full-page overlay instead, where the element's position is irrelevant. Only a modal uses the mount layer — trigger timing, the frequency cap, the dismiss affordance, and the backdrop — so the builder hides those controls when the placement is inline. The one exception is the `click` trigger below, which means the same thing for both placements.

### Opening from a button

`trigger: { type: 'click' }` holds the form back until someone asks for it. Two things can ask:

```jsonc
{
  "trigger": { "type": "click" },
  "launcher": {                       // our button — designed in the builder
    "label": "Get 15% off",
    "styleProps": { "backgroundColor": "#663dff", "align": "center" },
    "borderRadius": 10
  }
}
```

- **The launcher** renders where the form would have gone — inside the `htmlId` element, or at the end of `<body>` without one. Every field on it is optional: unset, it takes the submit button's fill, the card's rounding, and the renderer's own wording in the form's language, so the button matches the form it opens without the merchant designing anything.
- **The site's own element**: put `data-creaditor-open="<form id>"` on any element on the page and clicking it opens the form. The value may be left empty to mean "whichever form is mounted". The listener is on the document, so an element rendered later still works.

Both routes stay live at once — a form can ship with our button and still be opened from a menu link. A click-triggered form re-opens each time the button is pressed; the frequency cap still decides whether the button appears at all.


### Card width

`width` + `widthUnit` cap the card. Each template carries the width it's designed around, and picking a template in the builder writes that width onto the form:

| Template | Width |
| --- | --- |
| Basic, image-behind, image-top | 500px |
| Image-left, image-right | 720px |
| Wide (row form) | 820px |

`defaultCardWidth(design, formLayout)` is exported if you need the same numbers host-side. Leave `width` unset and the stylesheet's own defaults apply (520px modal, 500px inline). A `%` unit is read against the container the form sits in.

Not to be confused with the per-item `span`, which sizes one content item rather than the card. The form body is a **12-column grid** and each item declares the columns it takes: two fields at `6` sit side by side, three at `4` make a row of thirds, and an item wraps to the next line when it no longer fits. Unset means full width. Read it with the exported `spanOf(item)`, which also carries older forms that authored a percentage `styleProps.width`. See [`POPUP-COMPONENT-JSON-SCHEMA.md`](./POPUP-COMPONENT-JSON-SCHEMA.md).

### The submit model

A form submits to a **primary** endpoint (`url` + `method`, authored in the Setup tab's Submission section, invisible to the visitor) and, optionally, to hidden **`submitTargets`** a host appends (mailing-list automations, webhooks). Field values are routed by each target's own method (GET → query, POST → JSON body). The primary target's response drives success/coupon; extra targets are fired best-effort. Details in [`docs/EMBEDDING.md`](./docs/EMBEDDING.md#the-submit-model).

**`url` must be an address a browser can actually dial.** Absolute (`https://api.example.com/subscribe`)
or relative to the page (`AddUserFromSite.aspx`, `/api/subscribe`) both work. A scheme on its own —
`https://` — does not: `fetch` rejects it while parsing, *before* a request exists, so the browser's
network panel stays empty and there is nothing to inspect. `validatePopup` reports this as an error on
`url`, and the renderer logs the endpoint it couldn't reach; run the validator before you save a form
and you'll never meet this at runtime.

The primary endpoint is **required**, including when it's hidden from the author with
[`showEndpoint={false}`](#locking-the-submit-endpoint) — hiding the field doesn't fill it in.

### After submit

What the visitor sees next is `onSuccess` / `onError` on the form. The builder authors **`{ type: 'rich', html }`** for both: an HTML fragment composed in the editor's rich-text field, with inline coupon chips serialized as `<span data-coupon data-code data-path data-copyable>` placeholders. The renderer hydrates each placeholder into a live chip — copy button, and the code pulled from the submit response by `data-path` when it's set — and walks the whole fragment through a tag/attribute whitelist rather than injecting it raw, so a pasted `<script>` can only ever lose its markup. `{ type: 'close' }` is the other option, and the default.

The older `message`, `coupon`, and (as a success type) `redirect` variants still render, so saved forms keep working; the builder migrates them into `rich` on first edit and authors redirects under [Automations](#automations-on-submit) instead. TipTap ships only in the editor bundle — the renderer parses the stored HTML with `DOMParser` and carries no editor code.

---

## Theming & language

These apply only to the **editor chrome** — the popup being edited keeps the colors and direction set in its own design section.

```tsx
<FormEditor form={form} onChange={setForm}
  lang="he"            // 'en' (default) | 'he' (also flips the chrome to RTL)
  theme="dark"         // 'light' (default) | 'dark'
  accent="#7c3aed"
  accentGradient="linear-gradient(135deg,#7c3aed,#ec4899)"
/>
```

On the web component: `el.setAttribute('lang','he')`, `theme`, `accent`, `accent-gradient`. In local dev, `npm run dev` also accepts `?lang=he&theme=dark`.

Translations live in `src/builder/i18n/en.ts` and `he.ts` — one file per language, same shape (TypeScript enforces it). Add a language by adding a file and registering it in `src/builder/i18n/index.tsx`.

### The form's own language

Separate from the chrome: `language: 'en' | 'he'` on the form decides the words the **renderer** says on its own behalf — Next, Back, Submitting…, the validation complaints, the wait on a redirect — and the direction the card lays out in. Copy the author typed is never translated; this is the language of the frame around it. An English builder can author a Hebrew form and vice versa, and the Design tab's picker is where it's chosen.

`direction` is still written alongside it, so a host reading direction off the form JSON keeps working. It's also the fallback: this used to be one control labelled with language names, so a form saved before the split is read as Hebrew when its direction is `rtl` — which is what that control meant at the time. Read either through `languageOf(form)` / `directionOf(form)` rather than off the field.

Adding a language to the *renderer* means a value in `PopupLanguage`, a direction to go with it, and a column in `src/renderer/strings.ts`.

### Business context (`brand`)

Unlike the above, this one *does* reach the form. Pass the host business's identity and its primary color becomes the submit button's fill:

```tsx
<FormEditor form={form} onChange={setForm} brand={{ primaryColor: '#663dff', logoUrl, name }} />
```

```html
<creaditor-form-builder brand-primary="#663dff"></creaditor-form-builder>
```

The color is **written into the form**, not applied at render time, so the published JSON carries it to a storefront that has no business context of its own. Only buttons with no color of their own are filled in — an author who picked a color keeps it — and the label color is chosen for contrast (white on a dark brand color, near-black on a light one). Without `brand`, buttons stay the built-in black.

Because it edits the form, loading a form whose button had no color fires an `onChange` with the color filled in. Only `primaryColor` is consumed today; `logoUrl` and `name` are carried so the whole context can be passed in one object.

### Locking the submit endpoint

Hosts that own the submit target (a CRM URL the developer sets, not the author) can hide the Submission "Endpoint URL" and "Method" fields:

```tsx
<FormEditor form={form} onChange={setForm} showEndpoint={false} />
```

```html
<creaditor-form-builder show-endpoint="false"></creaditor-form-builder>
```

The rest of the Submission section (success and error handling) stays.

> **Hiding the fields makes `form.url` your job.** It doesn't set, default or validate anything —
> submissions still go to whatever `form.url` carries, so set it on the form you pass in *and* on every
> form your app creates. A record that starts life with a placeholder (`""`, `"https://"`) and no field
> in which to correct it produces a form that cannot submit, and whose author has no way to see why.
> `validatePopup(form)` catches it before you store it.

### Where a form appears (`urls`)

A form can limit itself to particular pages. The author pastes their addresses into the popover that follows **Publish**, right beside the snippet they're about to copy, and they land on the form JSON:

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

Paste an address straight out of a browser. The scheme, a leading `www.`, a trailing slash, the `#fragment` and case are all ignored, so `https://www.shop.co.il/pricing` and `http://shop.co.il/pricing/` are the same page — without that, a form would silently miss every visitor who arrived at the other spelling. A star means anything (`/products/*`), and a bare path (`/pricing`) matches on any host, which keeps a form working on staging too.

**A form with no `urls` runs everywhere**, so nothing already published behaves differently.

`mountPopup` honours it automatically. Your own code can ask the same question without rendering anything:

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

matchesPage(form, 'https://shop.co.il/pricing');   // boolean
```

That's the point of it being a plain list: a backend that stores forms can answer "which forms belong on this page" with the same rules. Full reference in [`docs/EMBEDDING.md`](./docs/EMBEDDING.md#where-a-form-appears).

### What Publish offers to copy (`embedSnippet`)

Publishing opens a popover with the markup the author pastes into their site. That markup is the **host's**: your script tag, your element, and the id your own database knows the form by. Supply it with `embedSnippet`.

A string is copied as-is, with `{{id}}` filled in from the form:

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

```html
<creaditor-form-builder embed-snippet='<example-form form-id="{{id}}"></example-form>'>
</creaditor-form-builder>
```

A **new** form usually has no id yet, and publishing is what creates it. Pass a function instead, which may return a promise: the popover waits on it, so the save can mint the id the snippet has to carry.

```tsx
embedSnippet={async (form) => {
  const { formId } = await saveToMyDatabase(form);
  return `<example-form form-id="${formId}"></example-form>`;
}}
```

Saving there means `onPublish` is optional, since the Publish button appears for either one. Use both only when the id is already known by the time the snippet is asked for, or the form is saved twice. Pass `null` for no popover, for a host with its own publish confirmation.

### Opening the editor as a dialog (`modal`)

By default the editor is a plain block that fills whatever box you give it. Set `modal` and it floats over your page instead: a dimmed backdrop with the editor centered on it, **80% of the screen in each direction** on desktop, full-bleed on a small screen.

```tsx
{editing && (
  <FormEditor form={form} onChange={setForm} modal onClose={() => setEditing(false)} />
)}
```

The web component's version of "mount it when the author asks", which is what `modal-demo.html` does:

```html
<button id="edit">Edit form</button>
<script>
  document.getElementById('edit').onclick = () => {
    const fb = document.createElement('creaditor-form-builder');
    fb.modal = true;                                    // or: fb.setAttribute('modal', '')
    fb.form = myFormJson;
    fb.addEventListener('change', (e) => save(e.detail));
    fb.addEventListener('close', () => fb.remove());    // the host's half of it
    document.body.appendChild(fb);
  };
</script>
```

`modal` is an attribute or a property, and reads like any HTML boolean: bare `modal` is on, `modal="false"` (or `"0"`) is off, and the property wins when both are set. Leaving it out is the default block editor, so a host that mounts the editor into its own panel changes nothing.

Dismissing it — the X on its corner, or a click on the backdrop — reports and nothing more: `onClose` / the `close` event fires, and the host decides what closing means (unmount it, or set `modal` off). Without a handler there is nothing to close to, so the X and the click-outside are left out; the React prop is ignored entirely when `modal` isn't set.

Esc is deliberately *not* a close: the pickers and popovers inside the editor take it for themselves, and dismissing a font browser shouldn't take the whole editor with it.

---

## Scripts

```bash
npm run dev                 # the demo pages below, from source (Vite)
npm run build               # build the installable library into lib/  (alias of build:lib)
npm run build:all           # lib/ + both dist-cdn/ standalone bundles (what publish ships)
npm run build:cdn           # → dist-cdn/creaditor-form-builder.js  (editor, <creaditor-form-builder>)
npm run build:cdn:renderer  # → dist-cdn/creaditor-renderer.js  (renderer, window.CreaditorPopup)
npm run build:app           # the dev demo site as a static build, for deploying a playground
npm run preview             # serve that build locally
npm run typecheck           # tsc -b --noEmit
```

## Demo pages

Four of them, and the first is the odd one out: it loads the **published bundle** rather than the source, so it opens by hand with no dev server, which makes it the one to hand someone.

| Page | How to open | What it shows |
| --- | --- | --- |
| `demo.html` | open the file directly | The editor beside a live monitor of every event it fires, with the payloads. Loads `./dist-cdn/creaditor-form-builder.js` if the checkout has been through `npm run build:cdn`, and falls back to unpkg. |
| `/` | `npm run dev` | The editor as a host would mount it: custom fields, field rules, mailing lists, image search, all wired in `src/builder/main.tsx`. |
| `/renderer-demo.html` | `npm run dev` | The other half: a form rendered on a page, with its trigger, frequency cap and submit. |
| `/modal-demo.html` | `npm run dev` | A pretend host app opening the editor as a [dialog](#opening-the-editor-as-a-dialog-modal), and removing it on `close`. |

The three dev-server pages import from `src/`, so they show the working tree.

## Publishing to npm

The package is scoped and published **public** (`publishConfig.access = "public"`), so the
CDN URLs in [Install → B](#b-on-a-plain-page--over-a-public-cdn-no-npm-no-build) resolve. What
ships is the `files` whitelist: `lib/` (the ESM library + `.d.ts` types), `dist-cdn/` (the two
standalone bundles), `docs/`, the `LICENSE`, and the two markdown references. `docs/` is in that
list on purpose: this repo is private, so npm can't resolve the README's relative links back to
it, and the guides have to travel inside the tarball to be readable at all. `prepublishOnly` runs
`build:all`, so a publish always rebuilds every artifact first — you don't need to build by hand.

This is a **closed-source** distribution: the package ships compiled JS, type declarations,
and the standalone bundles — **not** the TypeScript source. `src/` is excluded from `files`,
and sourcemaps are off in every build config (a `.map` would embed the original source).

```bash
npm login                        # once, as a member of the @creaditor org with publish rights
npm version patch                # or minor / major — bumps package.json + tags the commit
npm publish                      # prepublishOnly builds lib/ + both CDN bundles, then publishes
git push --follow-tags
```

Then verify the CDN picked it up (replace the version):

```
https://unpkg.com/@creaditor/form-builder@<version>/dist-cdn/creaditor-renderer.js
```

- **Preview first:** `npm publish --dry-run` (or `npm pack`) prints the exact file list without
  publishing.
- **Version = CDN URL.** Bump the version on every change and hand consumers the new pinned URL;
  the pinned `@version` is immutable on the CDN, so caches never serve them a stale file.
- **Closed source, but not secret.** No `src/` or sourcemaps ship, so your TypeScript isn't on
  npm. The compiled `lib/*.js` and `dist-cdn/*.js` are still readable (minified) JS — anyone can
  read a published bundle. npm has no "public package, hidden code" mode; true privacy needs a
  paid private registry.
- **License:** `package.json` says `SEE LICENSE IN LICENSE`, and the `LICENSE` file ships in the
  package. Publishing publicly puts the code where anyone can read it; the license is what says
  who may use it. Keep the two in step if the terms change.

## Project structure

```
src/
  schema/     Shared source of truth: types, factories (makePopup…), validation,
              and the mailing-list compile/decode helpers.
  renderer/   Consumes a PopupModal JSON:
                PopupContent  → pure rendering (design + fields + submit)
                PopupMount    → mount layer (trigger, frequency, dismiss, placement)
                mountPopup    → vanilla loader for embedding anywhere
  builder/    The authoring UI: FormEditor + child editors, host-integration
              providers (customFields, mailingLists, imageSearch, brand), i18n.
  templates/  The optional PRESETS starter forms (own entry point).
  webcomponent/  <creaditor-form-builder> wrapper around FormEditor.
  fonts.ts    Google Font loading, shared by both halves.
  dragSort.ts Pointer-driven reorder, shared by the canvas and the list.
```

The **schema** is imported by both halves, so the renderer and editor can never drift on the data shape.
