# Embedding in an ASP.NET (ASPX / Web Forms / MVC) page — no npm, no React

This guide is for teams whose site is a classic **ASP.NET** app (Web Forms `.aspx`, or
Razor/MVC `.cshtml`) with **no Node build step**. You don't need npm, a bundler, or React
on your page. You add **one `<script>` tag** and a custom HTML element, then wire two
events back to your server.

An ASPX page just emits HTML to the browser, and the editor ships as a self-contained
custom element (`<creaditor-form-builder>`) that renders into a **shadow root** — so it
never collides with your master-page CSS, themes, or other page scripts.

> **Who runs npm?** Only whoever *produces* the bundle (see [Getting the bundle](#1-getting-the-bundle)).
> The ASPX developer consuming it never touches npm — the `.js` file is treated like any
> other third-party script (jQuery, etc.).

> **Just want to see it work first?** After building the bundles, open
> [`examples/renderer.html`](../examples/renderer.html) and [`examples/editor.html`](../examples/editor.html)
> straight in a browser (`file://`, no server). They're plain HTML that loads the bundles
> from `dist-cdn/` exactly the way an ASPX page does — a working reference to copy from.

---

## What you can and can't do without a build step

| Task | Works with just a `<script>` tag? |
| --- | --- |
| **Author** a form — the editor UI | ✅ Yes — the editor CDN bundle is exactly this |
| Get the authored JSON out / load JSON in | ✅ Yes — via DOM events + a property |
| **Render** the finished popup on a public page | ✅ Yes — the renderer CDN bundle, see [§5](#5-rendering-the-finished-popup-on-a-public-page) |

There are **two** self-contained bundles, and they're independent — you'll usually use the
editor on an admin page and the renderer on your public pages:

| Bundle | Build command | Global | Use on |
| --- | --- | --- | --- |
| `creaditor-form-builder.js` | `npm run build:cdn` | `<creaditor-form-builder>` element | the **authoring** page |
| `creaditor-renderer.js` | `npm run build:cdn:renderer` | `window.CreaditorPopup` | your **public** pages |

---

## 1. Getting the bundle

The package is on **npm**, so both bundles are available from a public CDN — you don't have
to build or host anything. Just reference the URL (pin the version):

```
Editor:    https://unpkg.com/@creaditor/form-builder@0.2.1/dist-cdn/creaditor-form-builder.js
Renderer:  https://unpkg.com/@creaditor/form-builder@0.2.1/dist-cdn/creaditor-renderer.js
```

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

Each file contains **React + the code + all styles** — nothing else is needed on the page.
The examples below reference a local `~/Scripts/...` path; swap in the CDN URL above (or
self-host — see the note) as you prefer.

> **Self-hosting instead?** If you'd rather not depend on a third-party CDN, the same files
> live in the npm package under `dist-cdn/`, or can be built with `npm run build:cdn` /
> `npm run build:cdn:renderer`. Drop them into your project's static assets (e.g.
> `/Scripts/`). When you self-host, add a version to the filename or a `?v=` query string so
> browsers don't serve a stale cached copy after an update. (The CDN URL handles this via the
> pinned `@version`.)

---

## 2. Minimal page

### Web Forms (`.aspx`)

```aspx
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="PopupEditor.aspx.cs"
    Inherits="YourApp.PopupEditor" %>
<!DOCTYPE html>
<html>
<head runat="server"><title>Popup editor</title></head>
<body>
  <form id="form1" runat="server">
    <!-- The editor is a plain custom element. It does NOT postback. -->
    <creaditor-form-builder id="fb" style="display:block;height:100vh"></creaditor-form-builder>
  </form>

  <script src="<%= ResolveUrl("~/Scripts/creaditor-form-builder.js") %>"></script>
  <script>
    (function () {
      var fb = document.getElementById('fb');

      // Load the form to edit (see §3 for injecting real JSON from the server).
      fb.form = <%= FormJson %>;   // FormJson is a raw JSON string from code-behind

      // Every edit — persist it (see §4).
      fb.addEventListener('change', function (e) { savePopup(e.detail); });

      // Author clicked Publish.
      fb.addEventListener('publish', function (e) { publishPopup(e.detail); });
    })();
  </script>
</body>
</html>
```

### MVC / Razor (`.cshtml`)

Identical, minus the `runat="server"` form:

```cshtml
<creaditor-form-builder id="fb" style="display:block;height:100vh"></creaditor-form-builder>

<script src="@Url.Content("~/Scripts/creaditor-form-builder.js")"></script>
<script>
  var fb = document.getElementById('fb');
  fb.form = @Html.Raw(Model.FormJson);   // Model.FormJson is a JSON string
  fb.addEventListener('change', e => savePopup(e.detail));
  fb.addEventListener('publish', e => publishPopup(e.detail));
</script>
```

The element self-registers when the script loads — no init call required.

---

## 3. Getting JSON *in* (server → editor)

`fb.form` takes a **JavaScript object** (a `PopupModal`). Inject it as raw JSON from the
server. **Do not** HTML-encode it — it's a JS literal, not markup.

**Code-behind (Web Forms):**

```csharp
using System.Web.Script.Serialization; // or Newtonsoft.Json / System.Text.Json

public partial class PopupEditor : System.Web.UI.Page
{
    protected string FormJson;   // referenced by <%= FormJson %>

    protected void Page_Load(object sender, EventArgs e)
    {
        // Load from your DB, or "null" for a blank editor.
        object popup = LoadPopupFromDb(Request["id"]);
        FormJson = popup == null
            ? "null"
            : new JavaScriptSerializer().Serialize(popup);
    }
}
```

If `fb.form` is `null` the editor shows nothing until you assign a form, so pass a real
object to start editing. To start from a blank template, store a starter `PopupModal` JSON
server-side and serve that.

**Prefer fetching over inlining?** Leave the element empty and load JSON client-side from an
endpoint you already have:

```javascript
fetch('/api/popups/123')
  .then(r => r.json())
  .then(json => { fb.form = json; });
```

---

## 4. Getting edits *out* (editor → server)

The editor is JS-driven and **never uses ASP.NET postback**, so don't expect the form JSON
to arrive in `Page_Load` / a submit handler. Capture it from the `change` (or `publish`)
event and POST it yourself. A generic handler (`.ashx`), a `[WebMethod]`, or a Web API /
MVC controller all work.

```javascript
function savePopup(popup) {
  fetch('/SavePopup.ashx', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(popup),
  });
}
```

`change` fires on **every keystroke/edit**, so debounce before hitting the server if you
save on change:

```javascript
var t;
fb.addEventListener('change', function (e) {
  clearTimeout(t);
  t = setTimeout(function () { savePopup(e.detail); }, 800);
});
// ...or only persist on 'publish', and treat 'change' as "unsaved" state.
```

**Generic handler (`SavePopup.ashx`):**

```csharp
public class SavePopup : IHttpHandler
{
    public void ProcessRequest(HttpContext ctx)
    {
        string json;
        using (var r = new StreamReader(ctx.Request.InputStream))
            json = r.ReadToEnd();

        SavePopupToDb(json);           // store the raw PopupModal JSON
        ctx.Response.StatusCode = 204;
    }
    public bool IsReusable => true;
}
```

### Check the form before you store it

`validatePopup(form)` returns a list of `{ path, message, level }`; an empty list, or one with
no `level: 'error'` entries, means the form is structurally sound. **Publish is the moment to
run it.** A form stored with an error in it is a form that fails in front of a visitor, and the
errors it catches are exactly the ones nothing else will: an endpoint that can't be dialled, a
redirect with no destination, two fields fighting over one submit key.

The editor bundle exposes it on the same global the element registers under
(`CreaditorFormBuilder.validatePopup` / `.isValidPopup`), so an ASPX page needs nothing beyond
the `<script>` tag it already has:

```javascript
function publishPopup(popup) {
  var issues = CreaditorFormBuilder.validatePopup(popup)
                 .filter(function (i) { return i.level === 'error'; });
  if (issues.length) {
    // Your UI, your wording — but don't publish it.
    showErrors(issues.map(function (i) { return i.path + ': ' + i.message; }));
    return;
  }
  savePopup(popup);
}
```

**Do it on the server too**, because the browser is not the only way a row reaches your table:
an import, a migration, a form cloned by an admin tool, or a record your app creates *before*
anyone opens the editor all bypass the check above. That last one is the case worth guarding —
a form created with a placeholder endpoint (`""`, `"https://"`) is broken from birth, and if
you also pass `show-endpoint="false"` there is no field in which an author could ever notice.

```csharp
public class SavePopup : IHttpHandler
{
    public void ProcessRequest(HttpContext ctx)
    {
        string json;
        using (var r = new StreamReader(ctx.Request.InputStream))
            json = r.ReadToEnd();

        // Whatever creates a form — this handler, an importer, a "new form"
        // button — is responsible for its url. See "Where the submission goes".
        var errors = ValidatePopup(json);        // your port, or a Node/JS check
        if (errors.Count > 0)
        {
            ctx.Response.StatusCode = 400;
            ctx.Response.ContentType = "application/json";
            ctx.Response.Write(new JavaScriptSerializer().Serialize(errors));
            return;
        }

        SavePopupToDb(json);
        ctx.Response.StatusCode = 204;
    }
    public bool IsReusable => true;
}
```

There is no C# port of the validator in this package — it ships as JS. Either mirror the
handful of rules you care about (`url` present and dialable, `method` is GET or POST, a
redirect has a destination), or run the JS one in whatever Node step your build already has.
Mirroring `url` alone catches the failure that motivated this section.

Anti-forgery: if your site requires an ASP.NET anti-forgery/CSRF token on POSTs, include it
as a header/field in the `fetch` above per your existing convention.

---

## 5. Rendering the finished popup on a public page

The editor produces `PopupModal` JSON; the **renderer** turns that JSON into the live popup
(trigger, frequency cap, submit) on your public pages. Like the editor, it ships as a
self-contained CDN bundle — **no npm, no React, no build step** on the page.

Build it once (whoever owns this package):

```bash
npm run build:cdn:renderer     # → dist-cdn/creaditor-renderer.js
```

Host that file (e.g. `/Scripts/creaditor-renderer.js`) and add it to any public page. It
exposes a `window.CreaditorPopup` global with `mountPopup()`; the renderer injects its own
styles on first render, so there's no CSS to import:

```aspx
<script src="<%= ResolveUrl("~/Scripts/creaditor-renderer.js") %>"></script>
<script>
  // Same PopupModal JSON the editor produced — injected from the server (§3).
  CreaditorPopup.mountPopup(<%= FormJson %>);
</script>
```

`mountPopup(popup, opts?)` returns a handle:

```javascript
var handle = CreaditorPopup.mountPopup(popupJson, {
  onClose: function () { /* dismiss analytics */ },
  fetchImpl: undefined,  // optional: your own fetch for the submit request
  container: undefined   // optional: an element to render inside, overriding htmlId
});
// handle.unmount();   // tear it down (e.g. SPA navigation)
```

- It wires the **trigger**, **frequency cap**, and **placement**, then renders when
  appropriate — you don't manage when it shows.
- The popup submits to the endpoint authored in its Setup tab (plus any hidden mailing-list
  targets); nothing else to configure here.
- **`htmlId` page gate:** if the `PopupModal` has an `htmlId`, the popup renders only on
  pages that contain an element with that id — otherwise it renders nothing. A popup with no
  `htmlId` renders on every page it's loaded on. Use this to scope a popup to specific pages.
- **`htmlId` as the inline anchor:** for a form with `placement: "inline"` (the builder's
  default), that same element is also *where* the form renders — it's appended inside it, in
  the page flow, with no overlay. So put an empty `<div id="...">` where you want the form to
  sit. An `inline` form with no `htmlId` has nowhere to embed and lands at the end of
  `<body>`. A `modal` ignores the element's position and always covers the page.

Other globals on `CreaditorPopup` if you need them: `PopupContent` / `PopupMount` (React
components, for a bundler host), `validatePopup(json)` and `isValidPopup(json)` (sanity-check
JSON before mounting). For plain ASPX, `mountPopup` is all you need.

### Where a form appears

A form can carry a list of page addresses (`urls` on its JSON, written by the author in the
popover that follows Publish). `mountPopup` honours it: on a page the list doesn't cover, it
renders nothing.

A form with **no** list runs everywhere, so if your customers paste the embed exactly where
the form belongs, this stays empty and nothing changes.

Your ASPX side can ask the same question, which is the point of the list being plain data:

```javascript
// Which of these forms belong on the page we're on?
var forHere = allForms.filter(function (f) {
  return CreaditorPopup.matchesPage(f, window.location.href);
});
```

Simple enough to reimplement in C# if you'd rather decide server-side which forms to send to
a page at all. The whole rule:

- Compare the **host and path**. Ignore the scheme, a leading `www.`, a trailing slash, the
  `#fragment`, and case — none of those make a different page, and treating them as
  differences is how a form silently fails to appear.
- A **star** matches any run of characters.
- An entry written as a bare path (`/pricing`) is matched against the path only, ignoring
  the host.
- An entry with **no query string** ignores the page's.
- An **empty list** means every page.

See [EMBEDDING.md](./EMBEDDING.md#where-a-form-appears).

### Where the submission goes (`form.url`)

A form submits to `form.url` with `form.method`. **Set it on every form your app creates** — the
editor writes what the author types into the "Endpoint URL" field, and if you hide that field with
`show-endpoint="false"` (below), nobody types anything and whatever the record was created with is
what ships.

It can be relative, which is usually what you want on a server-rendered site:

```json
{ "url": "AddUserFromSite.aspx", "method": "GET" }
{ "url": "/handlers/FormProApi.ashx", "method": "POST" }
```

`GET` puts the fields in the query string (`?email=…`, which `Request.QueryString` reads); `POST`
sends them as a JSON body. Cross-origin either way needs `Access-Control-Allow-Origin` on the
response — a same-origin handler like the two above needs nothing.

> **A placeholder is worse than an empty field.** `"https://"` is a scheme with no site attached. It
> passes every "is it filled in" check, then fails inside `fetch` while the URL is being parsed —
> *before* a request object exists. Nothing appears in **DevTools → Network**, not even a red row,
> and the visitor just sees your `onError` message. Call `validatePopup(form)` before you store a
> form and it's reported as an error on `url`; at runtime the renderer logs
> `[creaditor] Form "…" could not submit to …` to the console.

**A redirect is not a substitute.** `onSuccess: { type: "redirect" }` decides where the visitor's
browser goes *after* `form.url` has already answered successfully. It is not a way to deliver the
submission — with no working `form.url` the submit fails first and the redirect never runs. If you
want your `.aspx` to receive the data, put its address in `form.url`.

### What your backend still has to do

Two things an author can configure in the editor are **declarations the frontend never acts
on** — if your ASPX side ignores them, they silently do nothing:

| On the form JSON | Your backend's job, when the submission arrives |
| --- | --- |
| `emailAutomations[]` — `{ id, to, subject? }` | **Send the mail.** The builder and renderer never send anything; this is stored intent only. |
| `submitTargets[]` with `fireFromClient: false` (what mailing-list automations compile to when you configure them that way) | Perform the subscribe/unsubscribe. `readMailingListAutomations(form, config)` decodes them back into `{ listId, action }`. |

Both keep the visitor's submit to a single request and keep your endpoints and credentials
off the public page, which is the reason to prefer them on a server-rendered site.

> **Editor vs renderer bundles:** the editor (`creaditor-form-builder.js`) is for the admin
> page where staff author forms; the renderer (`creaditor-renderer.js`) is for the public
> pages where visitors see them. They're separate files — don't load the (much larger)
> editor bundle on public pages.

---

## 6. Publish → copy the embed

Clicking **Publish** in the editor fires the `publish` event (§4) and opens a popover with a
copy button. What it offers for copying is **yours**: your script tag, your element, and the
id your own database knows the form by. Set it with `embed-snippet` or `fb.embedSnippet`.

If the form already has an id (the author is editing a saved form), emit the snippet from the
server and you're done — no JavaScript at all:

```aspx
<creaditor-form-builder id="fb"
    embed-snippet='<script src="https://cdn.yoursite.co.il/form.js"></script>
<your-form form-id="{{id}}"></your-form>'>
</creaditor-form-builder>
```

`{{id}}` is filled in from the form. Note the single quotes: the snippet contains double
quotes of its own.

A **new** form has no id yet, and publishing is what creates it. For that, set the property to
a function. It may return a promise, so the save and the id it hands back both happen before
the snippet appears (the popover shows a loading line meanwhile):

```html
<script>
  var fb = document.getElementById('fb');

  fb.embedSnippet = function (form) {
    return fetch('/SavePopup.ashx', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form)
    })
      .then(function (r) { return r.json(); })
      .then(function (saved) {
        return '<scr' + 'ipt src="https://cdn.yoursite.co.il/form.js"></scr' + 'ipt>\n' +
               '<your-form form-id="' + saved.formID + '"></your-form>';
      });
  };
</script>
```

> The `'<scr' + 'ipt'` split is the usual HTML trick: a literal `</script>` inside an inline
> `<script>` block would end it early. In an external `.js` file, write it normally.

Saving inside `embedSnippet` means you don't need the `publish` event as well — the Publish
button appears for either one. Wire up **both** only when the save belongs in `publish` and
the id is already known by the time the snippet is asked for, or the form gets saved twice.

The handler runs once per opening of the popover, so an edit made behind it won't re-save. If
it rejects, the popover says so and offers no Copy button, and the error goes to the console.

Set `embed-snippet=""` (or `fb.embedSnippet = null`) for no popover at all, if your panel
shows its own "published" confirmation.

> **Where does the snippet's script come from?** That's the wrapper *you* host: a small file
> defining `<your-form>`, which fetches the form JSON from your API by id and hands it to
> `CreaditorPopup.mountPopup` (§5). The editor doesn't care what's in it; it only shows the
> author what to paste.

---

## Configuration & API reference (the custom element)

Attributes (also settable as properties):

| Attribute | Values | Effect |
| --- | --- | --- |
| `lang` | `en` (default) · `he` | Editor chrome language (`he` also flips it to RTL) |
| `theme` | `light` (default) · `dark` | Editor chrome theme |
| `accent` | any CSS color | Accent color of the editor chrome |
| `accent-gradient` | any CSS gradient | Accent gradient of the editor chrome |
| `brand-primary` | any CSS color | The host business's primary color, **written into the form** as the submit button's fill (the attribute form of `fb.brand.primaryColor`) |
| `show-endpoint` | `false` / `0` to hide | Hides the Submission "Endpoint URL" and "Method" fields. **Hiding them doesn't set the URL** — you must set `form.url` yourself on every form ([above](#where-the-submission-goes-formurl)). Anything else, or absent, shows them |
| `embed-snippet` | any markup | What the author copies from the popover that follows Publish, with `{{id}}` standing for the form's id. Empty means no popover. See [Publish → copy the embed](#6-publish--copy-the-embed) |
| `modal` | present to enable, `false` / `0` to disable | Opens the editor as an overlay dialog over the page — 80% of the screen each way on desktop, full-bleed on a small one — instead of filling the element's own box. Dismissing it fires `close` |

```aspx
<creaditor-form-builder id="fb" lang="he" theme="dark" accent="#7c3aed"></creaditor-form-builder>
```

> `lang`/`theme`/`accent` style **only the editor UI**. The popup being edited keeps the
> colors and direction set in its own design section.

Properties (JS only — carry objects/functions, so not attributes):

| Property | Type | Purpose |
| --- | --- | --- |
| `fb.form` | `PopupModal` | The form to edit; assigning it (re)loads the editor |
| `fb.getForm()` | `() => PopupModal \| null` | The latest form incl. in-editor edits |
| `fb.customFields` | `CustomFieldDef[]` | Host-supplied inputs shown under "Your fields" |
| `fb.onCreateField` | `draft => Promise<CustomFieldDef>` | Persist a field created on the fly, resolve with its real key |
| `fb.fieldRules` | `FieldRules` | Per-type constraints: `{ email: { max: 1, key: 'email', lockKey: true } }`. Caps how many of a type a form may have and fixes the submit key. `recommended` prompts for a missing field without forcing it; `pinned` forces it |
| `fb.mailingLists` | `MailingListDef[]` | Lists offered for "on submit → add/remove" automations |
| `fb.mailingListTarget` | `MailingListTargetConfig` | Endpoint config that mailing-list automations compile to. With `fireFromClient: false` they compile to declarations your ASPX backend acts on at submit, instead of calls the browser makes |
| `fb.onCreateMailingList` | `draft => Promise<MailingListDef>` | Persist a list created on the fly, resolve with its real id |
| `fb.onSearchImages` | `query => Promise<ImageSearchResult[]>` | Turns the card's "Image URL" field into a **Browse gallery** picker. Proxy Pexels/Unsplash/your DAM from a handler on your side; don't put an API key in the page |
| `fb.preloadGallery(query?)` | `(query?: string) => Promise<ImageSearchResult[]>` | Fills the gallery before the author searches: runs `fb.onSearchImages` once and keeps the results as what the gallery shows while its search box is empty. Call it whenever you like after the element upgrades. Typing still searches; clearing the box comes back to this set. Safe to fire and forget |
| `fb.brand` | `BrandContext` | `{ primaryColor?, logoUrl?, name? }`. The color is written into the form, so the published JSON carries it. The `brand-primary` attribute covers the color alone |
| `fb.showEndpoint` | `boolean` | The property form of `show-endpoint`; the property wins when both are set |
| `fb.embedSnippet` | `string \| (form => string \| Promise<string>) \| null` | What Publish offers for copying. As a function it may return a promise, for the usual case where saving the form is what mints its id. See [§6](#6-publish--copy-the-embed) |
| `fb.modal` | `boolean` | The property form of `modal`; the property wins when both are set |

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

| Event | `e.detail` | Fires when |
| --- | --- | --- |
| `change` | `PopupModal` | After **every** edit — persist this |
| `publish` | `PopupModal` | Author clicks Publish |
| `close` | `PopupModal` | A `modal` editor is dismissed (its X, or a click on the backdrop). Reported only — hide or remove the element yourself |
| `fieldadd` | `ContentItem` | A data field was added |
| `fieldremove` | `ContentItem` | A data field was removed |
| `automationadd` | `MailingListAutomation` | A mailing-list automation was added |
| `automationremove` | `MailingListAutomation` | A mailing-list automation was removed |

The two **create** handlers (`onCreateField`, `onCreateMailingList`) are Promise-returning
**properties**, not events: your backend persists the new field/list and resolves with the
finalized definition (a real, validated key/id); the editor shows a loader until then.

Full payload shapes and the submit/automation model: [`EMBEDDING.md`](./EMBEDDING.md).

---

## ASP.NET-specific gotchas

- **`<form runat="server">` and postback.** The custom element lives happily inside the
  server form, but it does its own thing in JS — it will not surface data through postback.
  Always read edits via the `change`/`publish` events (§4), never `Request.Form`.

- **`ScriptManager` / `UpdatePanel` partial postbacks.** An async postback that re-renders
  the region containing `<creaditor-form-builder>` will **destroy and recreate** the element,
  losing in-progress edits. Keep the editor **outside** any `UpdatePanel`, or re-assign
  `fb.form` after the partial postback completes (`Sys.WebForms.PageRequestManager` →
  `add_endRequest`).

- **`ClientIDMode`.** We set the element's `id` ourselves (`id="fb"`), so ASP.NET's
  auto-generated `ClientID` mangling doesn't apply here — `document.getElementById('fb')`
  is reliable. If you put the element inside a naming container and let ASP.NET own the id,
  use `<%= fb.ClientID %>` in the script instead.

- **Serialize, don't `.ToString()`.** Inject `fb.form` via a real JSON serializer
  (`JavaScriptSerializer`, `System.Text.Json`, or Newtonsoft) and `<%= %>` / `@Html.Raw`.
  Don't HTML-encode it and don't hand-concatenate — one unescaped quote breaks the page.

- **Serving the `.js`.** IIS serves `.js` as `application/javascript` by default; if a
  hardened server strips unknown static files, ensure `.js` is an allowed static MIME type.

- **CSP.** The bundle inlines its styles into the shadow root, so a strict `style-src` may
  need `'unsafe-inline'` for the editor's `<style>`, or move to a nonce-based policy. The
  script itself is a normal external `.js` — no `eval`.

---

## Bottom line

- **Authoring in ASPX with no npm** → one `<script>`, one element, two events. §1–§4.
- **Rendering the popup on a public ASPX page with no npm** → one `<script>` +
  `CreaditorPopup.mountPopup(json)`. §5.

Both are self-contained bundles built once with npm (`build:cdn` and `build:cdn:renderer`);
the ASPX side just references the `.js` files.
