# Writing a Treebars HTML in-app message

For agents and their people. Bridge version 3. Generated from Treebars' own tables; fetch it again rather than keeping a copy. The bridge as TypeScript: https://treebars.com/docs/html-in-app/types

## What you produce

A template's `content`: an object with one `in_app` block. Write `title` too — it names the message in lists and reports, and is not drawn.

```json
{ "title": "…", "body": "", "in_app": { "surface": "overlay", "layout": "modal", "body_mode": "html", "html": "<main>…</main>", "dismissible": true } }
```

- `surface`: `overlay`. (`inbox` is a row the app lists and draws itself; not covered here.)
- `body_mode`: `html`, and `html` the markup. Leave out `trigger`: when a message shows belongs to the campaign that sends it, not the template.
- `dismissible`: default true. A `fullscreen` that cannot be dismissed needs its own way out.
- `max_displays` (optional): how many times one device may show it.

Treebars keeps a message as a document and imports your markup into one when you save it: the one element around your screens becomes the message’s box (its classes and inline style are the box’s paint — background, radius, padding), what sits beside the screens is copied into each screen where it was, and each `data-tb-screen` becomes a screen. The device draws what that document compiles to; send the markup again to change it.

## Where it sits

| `layout` | `position` | The box |
|---|---|---|
| `modal` | — | Centred, `min(100% − 32px, 420px)` wide, as tall as your content up to the window less 32px; then it scrolls inside. |
| `sheet` | — | Rises from the bottom, full width on a phone, as tall as your content up to the window less the status bar; then it scrolls inside. |
| `banner` | `top`, `bottom` | Full width at that edge, as tall as your content up to 30% of the window. |
| `fullscreen` | — | The whole window, its background under the system bars and your content kept clear of them. |
| `nudge` | `top`, `bottom` | A small card that leaves the app usable around it: up to 20% of the window tall; nudges stack at an edge up to 30% of it, 3 on screen at most, and one that does not fit waits. HTML only, and never after an event: it shows on app open or a screen. |

Your markup gets no padding of ours in any shape, and the box is kept clear of the notch, the home bar and the keyboard for you — never pad for them by hand. Size it for a phone first (360–430 CSS pixels wide), and never use `position: fixed` or `vh`: each screen scrolls inside the box.

## The document it runs in

- Plain HTML: a fragment such as `<main>…</main>`, or a whole document. No custom elements, no framework. Style it inline or in a `<style>` element.
- A sandboxed frame. There is no `fetch`, XHR, WebSocket or `sendBeacon`, and no `localStorage`, `sessionStorage` or IndexedDB — use `treebars.setStoredValue` and `treebars.getStoredValue`. Scripts load only from inside the page; images, fonts and styles from any https address.
- The one global is `treebars` (below), and it is the only one. A call on any other global — the one another messaging platform’s templates use — does not exist here and is refused at publish; the method names and arguments are the same, so only the global changes.
- Do not include our runtime: Treebars adds it when the message is sent, and it is what makes the `data-tb-*` attributes work. Most messages need no script at all.
- Every link and action goes through the host, which records it first. A plain `<a href="treebars://dismiss">` closes the message even where scripts cannot run.
- Personalise with `{{ user.first_name | default: "there" }}` (any trait by its key); `| required` skips somebody without the value. A token inside a `<script>` is escaped for the script.
- Forms: give every field a `<label>`, a font of at least 16px (smaller zooms the page on iPhone), and controls at least 44px tall. The runtime sends a form itself; `data-tb-then` says where to go after.
- Size: up to 64 KiB travels with the message; a larger body is fetched by reference and works, but a smaller one draws sooner.
- Write the words in the language the person reads. `dir="rtl"` on the root for Arabic or Hebrew.

## Attributes: tracking and flow without script

| Attribute | Example | What it does |
|---|---|---|
| `data-tb-screen` | `data-tb-screen="welcome"` | One screen of a flow. The first shows; give the rest `hidden`. |
| `data-tb-start` | `data-tb-start` | On a screen: show this one first instead. |
| `data-tb-final` | `data-tb-final` | On a screen: reaching it records the flow as completed. |
| `data-tb-id` | `data-tb-id="cta"` | Records the element’s presses under that id, as `in_app_clicked` — what the campaign report counts per element. |
| `data-tb-action` | `data-tb-action="screen:done"` | Acts after the click is recorded (the table below). |
| `data-tb-event` | `data-tb-event="offer_claimed"` | Records one of your own events when the element is pressed, which also declares it. Enter in a form presses its submit button, so an event there is recorded either way. |
| `data-tb-props` | `data-tb-props='{"plan":"annual"}'` | JSON properties for `data-tb-event` and `custom`. |
| `data-tb-save` | `data-tb-save="email"` | On an input: keeps its answer when the form is sent — `email` or `phone` as an opt-in, anything else as a trait of that name. On a fieldset of checkboxes: the checked values, comma-joined. |
| `data-tb-then` | `data-tb-then="screen:thanks"` | On whatever submits (the button or its form), or on a `push` ask: the action to take once the answers are sent or the permission answered. |
| `data-tb-track` | `data-tb-track="off"` | Turns click recording off inside it. |
| `data-tb-rating` | `data-tb-rating="nps"` | Around radios: picking one records the rating (stars, NPS). |
| `data-tb-progress` | `data-tb-progress="dots"` | `dots`, `bar` (fills its `[data-tb-bar]` child) or `count`: follows the screen shown. |
| `data-tb-bar` | `data-tb-bar` | The filling part of a `data-tb-progress="bar"`. |
| `data-tb-format` | `data-tb-format="{n} of {total}"` | How a `count` or a countdown reads: `{n}`, `{total}`; `{d}`, `{hh}`, `{mm}`, `{ss}`. |
| `data-tb-countdown` | `data-tb-countdown="2026-12-31T23:59:00Z"` | Ticks every second to that ISO time. Publish refuses one with no valid end; a `{{ token }}` filled at send passes. |
| `data-tb-ended` | `data-tb-ended="The sale has ended"` | What a countdown reads once its time has passed. |
| `data-tb-carousel` | `data-tb-carousel` | A scroll-snapping row of slides. |
| `data-tb-show` | `data-tb-show="mobile tablet"` | Shown only on those kinds of device — `mobile`, `tablet`, `desktop` — and hidden on the rest by the host before anything is drawn, scripts or not. On a screen, the screen is left out of the flow elsewhere: next skips it and the progress does not count it. |
| `data-tb-orientation` | `data-tb-orientation="landscape"` | Shown only while the device is held that way — `portrait` or `landscape` — and hidden otherwise by the host, following the device as it turns. Not on a `data-tb-screen`: publish refuses it there; put it on what is inside the screen. |
| `data-tb-carousel-dots` | `data-tb-carousel-dots` | The carousel’s next sibling: one dot per slide, the current one marked. |
| `data-tb-platforms` | `data-tb-platforms="ios android"` | On a screen: shown only on those platforms — `ios`, `android`, `web` — and left out of the flow on the rest, as `data-tb-show` does for kinds of device. |
| `data-tb-container` | `(written by Treebars)` | The message’s box around its screens. Treebars writes it when it compiles a message; do not write it — the element around your screens becomes it. |
| `data-tb-current` | `(written by Treebars)` | On the box: the screen shown, which a screen’s own CSS can style the box by. The runtime writes it. |

### `data-tb-action` values

| Value | What it does |
|---|---|
| `next` | Shows the next screen. |
| `back` | Goes back to the screen before. |
| `screen:<screen id>` | Shows the screen with that `data-tb-screen`. |
| `dismiss` | Closes the message, recorded as a dismissal. |
| `submit` | Sends the answers of the form or screen it is in; then `data-tb-then`. |
| `deeplink:<url>` | Opens an app link, recorded first; the message closes. |
| `url:<url>` | Opens a web page, recorded first; the message closes. |
| `landing:<url>` | Opens a page inside the app, recorded first; the message closes. |
| `push` | Asks for push permission; then `data-tb-then` on the same element. |
| `copy:<text>` | Copies the text. For a confirmation, call `treebars.copyText(text, message)` from a script instead: it shows `message` as a toast. |
| `share:<text>` | Opens the share sheet with the text. |
| `store-review` | Shows the store’s review prompt (apps only). |
| `custom` | Hands the element’s `data-tb-props` to the app’s own click listener. Prefer `app:` for anything new. |
| `app:<action name>` | Fires an app action: the app’s `onAction(name, …)` receives the name and the element’s `data-tb-props` as its payload (a JSON object, at most 4 KB). The click is recorded with the name; whether the app handled it is recorded too. Then `data-tb-then`. |
| `navigate:<screen name>` | Opens a screen of the app with the element’s `data-tb-props` as its props — the app’s `onAction('navigate', …)` receives `{ screen, props }`. Then `data-tb-then`. |

## The bridge: `treebars`

Every method returns a Promise that resolves — a refusal is `{ ok: false, reason }`, never an error. Your own events: `trackEvent` records only a name the message declares — in a `data-tb-event`, in a `trackEvent('name', …)` call written with the name in quotes, or in `<script type="application/json" id="tb-manifest">{ "events": ["quiz_answered"] }</script>` for a name the script builds. At most 20 a display, a repeat of one name within 250 ms is dropped, and a name starting `in_app_` is refused. `setUserAttribute` never sets `id`, `user_id`, `identity_id`, `email`, `phone`, `first_seen_at`, `last_seen_at`, `created_at`.

| Method | Resolves to | Where | What it does |
|---|---|---|---|
| `dismissMessage()` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records `in_app_dismissed` (once per display) and closes the message. Closing this way IS counted, so a template need not record the dismissal itself first. |
| `trackDismiss(widgetId?: string | number)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records `in_app_dismissed` with the element; does not close. A `dismissMessage()` after it does not count twice. |
| `trackClick(widgetId?: string | number)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records `in_app_clicked` with the element (a whole number is also `button_index`); neither closes nor navigates. A `widgetId` of `0` is kept as `"0"` rather than read as no id at all. |
| `trackRating(rating: number)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records `in_app_rated` with the rating. |
| `trackEvent(eventName: string, generalAttrJson?: string | Record<string, unknown>, locationAttrJson?: string | Record<string, unknown>, dateAttrJson?: string | Record<string, unknown>, isNonInteractive?: boolean, shouldAttachCampaignMeta?: boolean)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records the event with the campaign, delivery and message always attached; the location and date objects are merged into its properties. At most 20 a display, and a repeat of one name within 250 ms is dropped. A name starting `in_app_` or reserved by the SDK is refused. The fifth and sixth arguments are accepted and IGNORED: a message on screen is activity, and the campaign is always attached. |
| `identifyUser(idOrAttributes: string | Record<string, unknown>)` | `{ ok: false; reason: 'identity_from_message' }` | every host | Refuses, and lint flags it at publish. Who a device belongs to is decided by the app, never by a message. |
| `setUniqueId(uniqueId: string)` | `{ ok: false; reason: 'identity_from_message' }` | every host | Refuses, and lint flags it at publish. Who a device belongs to is decided by the app, never by a message. |
| `setAlias(alias: string)` | `{ ok: false; reason: 'identity_from_message' }` | every host | Refuses, and lint flags it at publish. Who a device belongs to is decided by the app, never by a message. |
| `setEmailId(email: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Keeps the address as this person’s opt-in (the in-app form’s `save_as` path), signed in or not; never changes who they are. |
| `setMobileNumber(mobileNumber: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Keeps the number as this person’s opt-in, as `setEmailId` does. |
| `setUserName(value: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Sets the trait `name`. |
| `setFirstName(value: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Sets the trait `first_name`. |
| `setLastName(value: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Sets the trait `last_name`. |
| `setGender(value: 'male' | 'female' | 'other')` | `{ ok: true } \| { ok: false; reason: string }` | every host | Sets the trait `gender`. |
| `setBirthDate(value: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Sets the trait `birth_date` (an ISO 8601 date). |
| `setUserLocation(latitude: number, longitude: number)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Sets the trait `location`. |
| `setUserAttribute(name: string, value: string | number | boolean)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Sets a trait the template declares in its manifest; a platform trait or an undeclared one is refused. |
| `setUserAttributeDate(name: string, iso8601: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | As `setUserAttribute`, for a date. |
| `setUserAttributeLocation(name: string, latitude: number, longitude: number)` | `{ ok: true } \| { ok: false; reason: string }` | every host | As `setUserAttribute`, for a location. |
| `navigateToScreen(screenName: string, dataJson?: string | Record<string, unknown>)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records the click, then hands the app a screen and its key-values (`setClickActionListener`, the iOS delegate, `inAppCampaignCustomAction`; on the web, the `TREEBARS_ONSITE_MESSAGE_CLICKED` event). |
| `openDeepLink(url: string, dataJson?: string | Record<string, unknown>)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records the click, then opens the deep link. |
| `openRichLanding(url: string, dataJson?: string | Record<string, unknown>)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records the click, then opens the page in an in-app browser (a new tab on the web). |
| `openWebURL(url: string, dataJson?: string | Record<string, unknown>)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records the click, then opens the page in the system browser (this tab on the web). |
| `copyText(text: string, message?: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Copies the text, and shows `message` as a toast. `navigator.clipboard.writeText` inside a message is routed here, because a sandbox or a WebView usually refuses it. |
| `call(mobileNumber: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Opens the dialer with the number. |
| `sms(mobileNumber: string, message?: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Opens the SMS composer with the number and the text. |
| `share(text: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Opens the share sheet (on the web, where the browser has one). |
| `appAction(name: string, payload?: string | Record<string, unknown>)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records the click with the action’s name, then hands the app the action and its payload: the app subscribes with `onAction(name, …)` on every SDK, and one fired before it has is held for a few seconds. `navigate` is reserved, with `{ screen, props }`. A name that is not lower-case letters, digits, dots and underscores, or a payload over 4 KB, is refused. |
| `customAction(dataJson: string | Record<string, unknown>)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Records the click, then hands the key-values to the app: `setClickActionListener` on Android, the delegate’s `inAppClicked` on iOS, `inAppCampaignCustomAction` in React Native, `TREEBARS_ONSITE_MESSAGE_CLICKED` on the web. An object is accepted as well as a JSON string. |
| `requestNotificationPermission()` | `'granted' \| 'denied' \| 'unsupported'` | every host | Asks for push permission, and resolves with the answer. |
| `showPushOptIn()` | `'granted' \| 'denied' \| 'unsupported'` | every host | The same as `requestNotificationPermission`, under the name iOS developers expect. |
| `handleNotificationPopUp()` | `'granted' \| 'denied' \| 'unsupported'` | every host | The same as `requestNotificationPermission`. |
| `navigateToNotificationSettings()` | `{ ok: true } \| { ok: false; reason: string }` | Android and iOS | Opens this app’s notification settings. |
| `navigateToSettings()` | `{ ok: true } \| { ok: false; reason: string }` | Android and iOS | Opens this app’s settings. |
| `getContext()` | `{ locale: string; direction: 'ltr' \| 'rtl'; theme: 'light' \| 'dark'; platform: 'android' \| 'ios' \| 'web'; insets: { top: number; right: number; bottom: number; left: number }; screen: string \| null; deliveryId: string; campaignId: string \| null; bridgeVersion: number; preview: boolean }` | every host | Resolves the locale, direction, theme, platform, safe-area insets, the screen on show, the delivery and campaign, and the bridge version. |
| `showScreen(screenId: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Goes to a screen, and records `in_app_screen_viewed` with `how: "jump"`. |
| `nextScreen()` | `{ ok: true } \| { ok: false; reason: string }` | every host | Goes to the next screen. |
| `previousScreen()` | `{ ok: true } \| { ok: false; reason: string }` | every host | Goes back to the screen before. |
| `setState(key: string, value: unknown)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Keeps a value across this display’s screens. |
| `getState(key: string)` | `unknown` | every host | Reads a value `setState` kept. |
| `submitForm(formId?: string)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Checks and submits the screen’s inputs, and records `in_app_form_submitted`. |
| `claimReward(poolId: string)` | `{ won: boolean; prize?: string; code?: string } \| { ok: false; reason: string }` | every host | Asks the server for a reward from a pool, which it decides — never the page. Until games land (H6) it resolves `{ ok: false, reason: "not_available" }`. |
| `requestStoreReview()` | `{ ok: true } \| { ok: false; reason: string }` | Android and iOS | Shows the store’s review prompt. |
| `resize(height?: number)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Tells the host how tall the content is; the Screens runtime calls it by itself, and the shim measures when no height is given. |
| `getStoredValue(key: string)` | `string \| number \| boolean \| null` | every host | Reads a value kept for this person and this campaign, on our server — so a seven-day challenge keeps its streak across days and devices. Also readable at send as `{{ template.stored.<key> }}`. |
| `setStoredValue(key: string, value: string | number | boolean | null)` | `{ ok: true } \| { ok: false; reason: string }` | every host | Keeps a value for this person and this campaign (at most 32 keys, 1 KB each). `null` removes it. |

## What publish refuses

- A layout other than `modal`, `sheet`, `banner`, `fullscreen` or `nudge`; a nudge that is not HTML, or an inbox nudge.
- A field to type in on a `banner` or a `nudge` — the keyboard never comes up over one.
- A tap that goes to a screen the message does not have, and a message with no way out: put a close button (`data-tb-action="dismiss"`) on it.
- An empty body.
- A bridge call on any global but `treebars`.
- A call to a `treebars.` method that does not exist — it throws on every device. Only the names in the bridge table below.
- A `data-tb-countdown` whose end is not a valid time.
- When a campaign publishes it: a game naming a reward pool the project does not have. (Games and quizzes are placed from the editor's palette; their markup is not part of this bundle.)

And what it warns about without refusing: a bridge method the host cannot answer (identity methods everywhere, `requestStoreReview` on the web), low contrast, and small touch targets.

## Handing it over

- Through the Treebars connector (MCP): `propose_template` with `channel_type: "in_app"`, a `name`, and the `content` above. It is checked by the same rules as publish and becomes a draft; nothing is live until a person applies it and a campaign sends it.
- Or by a person: Templates → New → In-app → From your own HTML, which pastes or uploads the markup and reports what it found.

## Examples that publish

### onboarding-with-email

A three-screen welcome in a modal: a greeting, an email sign-up, and a thanks that completes the flow.

```json
{
  "title": "Welcome",
  "body": "",
  "in_app": {
    "surface": "overlay",
    "layout": "modal",
    "body_mode": "html",
    "dismissible": true,
    "html": "<main style=\"font:16px/1.5 system-ui,sans-serif;color:#14213d;background:#fff;padding:24px\">\n  <section data-tb-screen=\"welcome\">\n    <h1 style=\"margin:0 0 8px;font-size:22px\">Welcome, {{ user.first_name | default: \"there\" }}</h1>\n    <p style=\"margin:0 0 20px\">Three things worth knowing, in under a minute.</p>\n    <button type=\"button\" data-tb-id=\"start\" data-tb-action=\"next\" style=\"min-height:44px;padding:0 18px;border:0;border-radius:10px;font:inherit;font-weight:600;cursor:pointer;background:#1b4332;color:#fff\">Get started</button>\n    <button type=\"button\" data-tb-id=\"skip\" data-tb-action=\"dismiss\" style=\"min-height:44px;padding:0 18px;border:0;border-radius:10px;font:inherit;font-weight:600;cursor:pointer;background:transparent;color:#1b4332\">Not now</button>\n  </section>\n  <section data-tb-screen=\"email\" hidden>\n    <form data-tb-then=\"screen:thanks\">\n      <label for=\"email\" style=\"display:block;margin:0 0 8px\">Your email, for the weekly picks</label>\n      <input id=\"email\" type=\"email\" required autocomplete=\"email\" data-tb-save=\"email\" style=\"box-sizing:border-box;width:100%;min-height:44px;margin:0 0 16px;padding:0 12px;font-size:16px;border:1px solid #8a94a6;border-radius:10px\">\n      <button type=\"submit\" data-tb-id=\"subscribe\" style=\"min-height:44px;padding:0 18px;border:0;border-radius:10px;font:inherit;font-weight:600;cursor:pointer;background:#1b4332;color:#fff\">Sign me up</button>\n    </form>\n  </section>\n  <section data-tb-screen=\"thanks\" data-tb-final hidden>\n    <p style=\"margin:0 0 20px\">You are in. The first picks arrive on Friday.</p>\n    <button type=\"button\" data-tb-id=\"done\" data-tb-action=\"dismiss\" style=\"min-height:44px;padding:0 18px;border:0;border-radius:10px;font:inherit;font-weight:600;cursor:pointer;background:#1b4332;color:#fff\">Done</button>\n  </section>\n</main>"
  }
}
```

### nudge

A small bar at the bottom that leaves the app usable: one line, a link into the app, and a close.

```json
{
  "title": "Free delivery",
  "body": "",
  "in_app": {
    "surface": "overlay",
    "layout": "nudge",
    "position": "bottom",
    "body_mode": "html",
    "dismissible": true,
    "html": "<main style=\"display:flex;align-items:center;gap:12px;padding:12px 16px;font:15px/1.4 system-ui,sans-serif;color:#14213d;background:#fff\">\n  <p style=\"margin:0;flex:1\">Free delivery on orders over £40, today only.</p>\n  <button type=\"button\" data-tb-id=\"shop\" data-tb-action=\"deeplink:myapp://sale\" style=\"min-height:44px;padding:0 18px;border:0;border-radius:10px;font:inherit;font-weight:600;cursor:pointer;background:#1b4332;color:#fff\">Shop</button>\n  <button type=\"button\" data-tb-id=\"close\" data-tb-action=\"dismiss\" aria-label=\"Close\" style=\"min-height:44px;padding:0 18px;border:0;border-radius:10px;font:inherit;font-weight:600;cursor:pointer;background:transparent;color:#1b4332;min-width:44px;padding:0\">×</button>\n</main>"
  }
}
```

### nps-survey

A one-question NPS survey in a modal, a thanks screen after it, and one of your own events on the way.

```json
{
  "title": "How are we doing?",
  "body": "",
  "in_app": {
    "surface": "overlay",
    "layout": "modal",
    "body_mode": "html",
    "dismissible": true,
    "html": "<main style=\"font:16px/1.5 system-ui,sans-serif;color:#14213d;background:#fff;padding:24px\">\n  <section data-tb-screen=\"ask\">\n    <h1 id=\"question\" style=\"margin:0 0 16px;font-size:20px\">How likely are you to recommend us to a friend?</h1>\n    <form data-tb-then=\"screen:thanks\">\n      <fieldset data-tb-rating=\"nps\" aria-labelledby=\"question\" style=\"display:flex;flex-wrap:wrap;gap:4px;border:0;margin:0 0 16px;padding:0\">\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"0\" required style=\"margin:0\"> 0</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"1\" required style=\"margin:0\"> 1</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"2\" required style=\"margin:0\"> 2</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"3\" required style=\"margin:0\"> 3</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"4\" required style=\"margin:0\"> 4</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"5\" required style=\"margin:0\"> 5</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"6\" required style=\"margin:0\"> 6</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"7\" required style=\"margin:0\"> 7</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"8\" required style=\"margin:0\"> 8</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"9\" required style=\"margin:0\"> 9</label>\n        <label style=\"display:grid;place-items:center;min-width:44px;min-height:44px;border:1px solid #8a94a6;border-radius:8px\"><input type=\"radio\" name=\"nps\" value=\"10\" required style=\"margin:0\"> 10</label>\n      </fieldset>\n      <button type=\"submit\" data-tb-id=\"send\" data-tb-event=\"nps_answered\" style=\"min-height:44px;padding:0 18px;border:0;border-radius:10px;font:inherit;font-weight:600;cursor:pointer;background:#1b4332;color:#fff\">Send</button>\n    </form>\n  </section>\n  <section data-tb-screen=\"thanks\" data-tb-final hidden>\n    <p style=\"margin:0 0 20px\">Thank you — every answer is read.</p>\n    <button type=\"button\" data-tb-id=\"close\" data-tb-action=\"dismiss\" style=\"min-height:44px;padding:0 18px;border:0;border-radius:10px;font:inherit;font-weight:600;cursor:pointer;background:#1b4332;color:#fff\">Close</button>\n  </section>\n</main>"
  }
}
```
