# The funnel document

One JSON file describes one complete quiz landing page. A funnel is a
sequence of **pages**, and a page is a scrollable **stack of blocks**.
A landing page and a
quiz step are the same thing, they just stack different blocks. The
renderer at `/f/[slug]` is generic: if two funnels differ, they differ
in their documents, never in code.

A document is validated every time it is written through the API or the
editor. A bad one is rejected with a 422 whose message names the exact
path that failed, for example `pages.3.next.default`. A key the format
does not have is refused too, with the keys that are valid there, rather
than silently dropped. A few obvious ones are renamed and reported
instead: `name` on a `quote` becomes `attribution`, `author` on a
`testimonial` becomes `name`, `question` and `answer` on a `faq` item
become `q` and `a`.

## Brand first

A funnel's look is not written by hand and not chosen by a model. It is
read from the brand's own website:

- `POST /api/v1/brand` with `{ "url": "https://brand.com" }` (the MCP
  tool `brand_from_url`) opens the site in a real browser, takes its
  logo (stored in the workspace as a transparent PNG), its page, text
  and button colours, the colour it uses most, and its heading and body
  faces matched to Google Fonts, and keeps that as the workspace's
  **brand kit**. The reply carries the kit and the `theme` built from it.
- `create_funnel` with a `prompt` needs a `website` (or `brand_colors`
  with at least an `accent`). The draft's theme, fonts and logo come
  from the kit and nothing the model writes can change them.
- Writing a document yourself, paste the kit's `theme` as it is and set
  `brand.logoUrl` to the kit's logo and `brand.website` to its site. A
  theme that drifts from the kit's colours is a blocking fix.

Three built-in templates show the patterns in full. Create one with
`POST /api/v1/funnels` and `{ "template": "b2b-free-offer" }`,
`{ "template": "wellness-quiz" }` or `{ "template": "saas-fit" }`, then
`GET` it back to read the document.

- `b2b-free-offer`: the landing page recipe below, block for block. A
  free offer in the hero, a product screenshot, proof, a symptom section
  with a second button, how it works, testimonials, who it is for, a
  founder quote, an FAQ and a closing band, then a role question first.

- `wellness-quiz`: the long-form quiz pattern. Long-form landing page
  with an embedded first question, emoji tile grids, filled option
  buttons, a multi-select, a measurements form, the "calculating your
  result" loader, an email capture, and a personalized result page.
- `saas-fit`: B2B SaaS pattern. Long-form landing page with an
  embedded qualifier, scored questions, a spend-based disqualification
  branch, a fit-check loader, and a work-email capture.

On phones every page is a single column. On desktop, a `top` page whose
copy is followed by an `image` or `video` block renders as a split hero,
copy on the left and the media on the right, with the remaining blocks
below at full width. Quiz steps and forms stay in a centred card.

## Top level

```jsonc
{
  "schemaVersion": 2,
  "name": "Halewell metabolism quiz",   // internal, shows in reporting
  "brand": { ... },
  "meta": { "title": "...", "description": "...",      // the <head>
            "adHooks": ["..."] },                       // optional: the ads' hooks, never shown on the page
  "theme": { ... },
  "settings": { ... },
  "pages": [ ... ]
}
```

### brand

```jsonc
{
  "name": "Halewell",              // the brand's name, used in titles and alt text
  "logoUrl": "https://...",        // required: a funnel without its logo is refused at publish
  "website": "https://halewell.com", // the site the brand kit was read from
  "logoWithName": true,            // the file is an icon: set the name beside it
  "logoPosition": "center",        // center | left. Centre with a centred page
  "logoSize": "md",                // sm | md | lg; lg grows again on a wide screen
  "logoLink": "https://halewell.com", // optional: tapping the logo opens this page (http/https)
  "footer": "Halewell Health, Austin, TX.",   // small print, end pages
  "legal": "Not affiliated with..."           // finer print on every end page, not-a-fit included
}
```

`logoWithName` is for a logo that is a mark with no words in it, a badge
or an app icon. The brand name is set beside it in the display face and
takes the theme's ink colour, so it stays readable on a light page and a
dark one. A full wordmark file leaves it out.

`logoLink` makes the header logo a link, usually to the brand's own
site. Leave it out unless the brand asks for it: a visitor who taps away
mid-funnel is a lead lost. It works on the live page only, never on the
edit canvas or a preview.

### theme

Eight colors, a radius scale, an option style, a button style, and two
Google Fonts families. Take it from the brand kit (see Brand first)
rather than writing it: the kit's theme is already contrast-checked.

```jsonc
{
  "ground": "#F7F4ED",     // page background
  "surface": "#FFFFFF",    // cards and inputs
  "surfaceHi": "#E9E4D8",  // progress track
  "ink": "#22271F",        // body text
  "inkMuted": "#6B7263",   // secondary text
  "line": "#E2DCCC",       // input borders
  "accent": "#33707E",     // buttons, progress fill, active states
  "accentInk": "#FFFFFF",  // text on top of accent
  "accentAlt": "#C7E14A",  // optional second colour, decoration only
  "radius": "xl",                          // sm | md | lg | xl
  "style": "soft",                         // soft | app | classic. See below
  "optionStyle": "card",                   // card | filled (solid tiles)
  "button": { "shape": "rounded", "caps": false },
  "fonts": { "display": "Lora", "body": "Inter", "displayWeight": 600 }
}
```

`fonts.display` and `fonts.body` take any family Google Fonts serves,
spelled as fonts.google.com spells it, or a family uploaded to the
workspace (see Custom fonts). Anything else loads nothing and the page
falls back to a system face; the design review flags it as
`type.unknown_font` with the closest free face. A theme built from a
website may also carry `fonts.brandFaces` (`{ "display": "Gotham" }`):
the site's own faces that Google has no copy of. It is never loaded; the
Design tab uses it to offer the upload to whoever holds the licence.

`style: "soft"` is the look every AI draft and every brand kit theme
uses: cards are tinted surfaces with no outline, icons are plain line
icons with no disc behind them, eyebrows and badges stay in sentence
case, and section bands run edge to edge. Leave it out and a document
renders the classic way.

`style: "app"` is the mobile quiz app pattern every built-in template
wears (the reasoning is in docs/QUIZ-DESIGN-PATTERNS.md). It changes the
quiz pages, not the landing page:

- A question page (layout `center`, after the first page) has a top bar
  with back and the progress on one line and no logo. The question hangs
  from the top of the screen instead of floating in the middle.
- Answers are tinted cards. The picked one takes the accent and a check
  pops in; `optionStyle: "filled"` is ignored here, so a column of
  answers never reads as a column of buttons.
- The page's button (a `cta`, a form's submit, a multi-select's
  Continue) sinks to the bottom of the screen and sticks there while the
  page scrolls under it.
- A loader page (`autoAdvance`) shows a ring counting to 100 instead of
  a spinner, and a `list` block on it ticks its rows off one by one
  across the wait. Give it three or four short rows ("Reading your
  answers", "Matching your goal").
- An outcome page opens with a small mark and lets its headline reveal
  the result. Put the payoff there: the answer echoed back
  (`{{field}}`), a `chart`, a `features` list of what they get.

Between questions, an insight page gives something back: an `eyebrow`,
a `heading`, then a `chart` or a `testimonial` with `rating`, and a `cta`
labelled Continue. One every two or three questions keeps a quiz from
feeling like a form.

The theme can carry the brand's gradient, defined once and referenced by
bands and buttons rather than repeated:

```jsonc
"gradient": { "stops": ["#062141", "#13b3cf", "#23c2b7", "#43e285"], "angle": 90, "ink": "#062141",
              "rules": true, "progress": true }
```

Two to four stops, an angle in degrees, and the ink that reads on top.
Without one, the accent into the second accent stands in. `rules` draws
a short gradient line under every headline that follows an eyebrow;
`progress` paints the funnel's progress bar with it.

`"backdrop": "glow"` lights the page's ground from two corners with the
gradient's bright colours (the top right, and the left a phone screen
down), faint enough that copy still reads on the ground. It is for a
light page that feels like a blank sheet of paper; pair it with a ground
a shade off white rather than pure white. Absent (or `"flat"`) is the
ground colour alone.

`accentAlt` is for decoration and nothing else. Actions stay one colour,
which is what keeps "this is tappable" unambiguous while a page still
gets to use more than one hue. Left out, it falls back to `accent`.

#### Emphasis

Emphasis is optional and used at most once a page: wrap the two or three
words a headline turns on in `[[double brackets]]`, or leave every
heading plain. More than one on a page is usually one too many.
The words take the treatment defined once on the theme:

```jsonc
"emphasis": {
  "font": "script",        // "display", "body", or an extra or custom font slot id. Absent: inherits
  "color": "accent",       // accent | accentAlt | ink | inkMuted | ground | accentInk
  "italic": true,
  "weight": 700,           // optional override
  "highlight": "none"      // none | accent | accentAlt | surface | surfaceHi
}
```

Defining it on the theme rather than in the copy is deliberate. A
headline that mixes a script face with a block of colour is one design
decision, not a styling instruction repeated in every string, and a
model writing copy only has to mark which words carry the idea.

A `highlight` other than `none` paints a block of colour behind the
words, the marker-pen look, and wraps correctly onto a second line. With
one set, `color` has to read against that block rather than against the
page: `ground` and `accentInk` exist for exactly that, since on a dark
theme every other option is light and so is the highlight.
`[[]]` with nothing inside disappears; an unclosed `[[` is left alone
rather than guessed at, so a half-typed marker never eats a paragraph.

The theme lands as `--fn-*` CSS variables on the funnel root. Funnel
components only reference those variables, which is the mechanism that
lets one renderer serve every brand.

#### More than two fonts

A brand that uses three or more faces declares extra font slots and
says where each one lands. Both fields are optional; without them,
headings take the display font and everything else takes the body font,
exactly as before.

```json
"fonts": {
  "display": "Fraunces", "body": "Inter", "displayWeight": 600,
  "extra": [{ "id": "mono", "family": "Space Grotesk", "weight": 500 }]
},
"typography": { "button": "mono", "eyebrow": "mono" }
```

`typography` accepts the roles `heading`, `body`, `button` and
`eyebrow`. Each value is `"display"`, `"body"`, or the id of an extra
slot; anything else fails validation at parse time, the same way a bad
routing target does. Slot weights are loaded from Google Fonts along
with everything else in one stylesheet request.

#### Custom fonts

For a brand font that is not on Google Fonts, and only one the client
holds a web licence for: the file is publicly downloadable from any
funnel that uses it.

1. Upload the file with `upload_font` (MCP) or `POST /api/v1/media`
   with `"kind": "font"`. woff2, woff, ttf or otf, up to 5MB; a ttf or
   otf is converted to woff2, and what is stored must come out at 2MB or
   less. It answers with `url`, `family_suggestion` (read from the file)
   and `custom_entry`, which is ready to paste into `fonts.custom`.
2. Add it to `fonts.custom` with an `id`, which makes it a font slot.
3. Point `typography` roles or `emphasis.font` at that id.

```jsonc
"fonts": {
  "display": "Inter", "body": "Inter",
  "custom": [{ "id": "brand", "family": "Control Cursive", "url": "https://.../ControlCursive-Bold.woff2", "weight": 700 }]
},
"emphasis": { "font": "brand", "color": "accent" }
```

Each entry in `fonts.custom` is one file:

| field | |
|---|---|
| `id` | optional, lowercase letters, numbers and dashes, up to 24. Unique across `fonts.extra` and `fonts.custom`, and not `display` or `body` |
| `family` | the CSS family name. Letters, numbers, spaces, dots, underscores and dashes, up to 60 |
| `url` | the URL the upload returned. It must be a font uploaded to this workspace: anything else, another workspace's upload or an external host, is refused with a 422, "font does not belong to this workspace" |
| `weight` | this file's weight, default 400 |
| `style` | `normal` (default) or `italic` |
| `format` | `woff2`, `woff`, `truetype` or `opentype`. Optional, read from the URL's extension |

The page gets an `@font-face` rule (with `font-display: swap`) and a
preload for each file. A slot id resolves to its family, then the
funnel's body font, then the system sans serif, so text stays readable
while the file loads. A family with several files (Regular, Bold,
Italic) lists each one with the same `family`; only the one the roles
point at needs an `id`, and the browser picks the others by weight and
style. For an italic face on emphasis, set `emphasis.italic` too.

`display` and `body` take family names, not slot ids. A custom family
works there as well: `"display": "Control Cursive"` with that family in
`fonts.custom` is loaded from the upload, never from Google.

Fonts belong to the workspace that uploaded them. `list_fonts` (MCP) or
`GET /api/v1/media?kind=font` shows this workspace's library and nothing
else, and a document copied into another workspace has to have its fonts
uploaded there again.

Uploaded fonts are a Growth plan feature. On a plan without them, a live
page drops the uploaded faces and every role falls back as above.

### settings

```jsonc
{
  "progress": "bottom",         // bottom (default) | top | none. Question pages only, never the landing page
  "resumeOnReturn": true,       // sessionStorage resume after refresh
  "campaignLabel": "halewell_metabolism_quiz",   // stamped on payloads
  "metaPixelId": "1234567890",  // this funnel's browser pixel
  "openaiPixelId": "Cwnx7LYSrvdcZYZdP1Zr3c",  // ChatGPT Ads pixel: page viewed, lead created, appointment scheduled
  "googleAdsId": "AW-123456789",                         // Google tag for Google Ads
  "googleConversions": { "submit_lead_form": "AbCdEfGh123" }, // conversion action labels, by category
  "metaEvents": { "lead": "Lead", "qualified": "CompleteRegistration" },
  "webhooks": {                 // server-fired, up to 3 URLs each
    "newLead": ["https://hooks.zapier.com/..."],
    "funnelCompleted": []
  }
}
```

Webhooks are how leads reach your CRM: `newLead` fires when a form
converts and `funnelCompleted` when a visitor reaches an end page. The
payload carries every answer, the score, the UTM and ad taxonomy
params, the Meta identifiers (`fbclid` is the raw click id, `fbc` and
`fbp` the browser cookies), `meta_event_id` so the CRM row can be
joined to the Meta Lead event, and the outcome the visitor is about to see: `result_page` (the
id of the page after the form, skipping auto-advance interstitials),
`result_heading` (that page's first heading, e.g. "The Clarity Gap")
and `tally_leader` (the tally a `tallyMax` rule picked). A CRM message
can quote the result instead of decoding answer values. `metaEvents` maps funnel moments (`landing`, `step`,
`lead`, `qualified`, `disqualified`) to Meta standard events, or `off`.

Google Ads works differently from Meta and ChatGPT: every conversion is
a conversion action in the advertiser's own account, with its own label.
So `settings.events` names a Google conversion category for each moment
(`submit_lead_form`, `book_appointment`, `qualified_lead`, `contact`,
`request_quote`, `sign_up`, `page_view`, `outbound_click`, or `off`;
defaults: lead `submit_lead_form`, booked `book_appointment`, the rest
off), and `googleConversions` holds the label of the action each category
sends to. Do not write `googleAdsId` or `googleConversions` by hand when
the account can be connected: Connect Google Ads in the app, or
`connect_google_ads` then `set_up_google_ads` over MCP, creates the
actions and fills both in. A category with no label is not sent from the
browser.

### compliance

Three independent switches, all off by default, edited on the
editor's Compliance tab or written here.

```jsonc
"compliance": {
  "cookieBanner": {
    "enabled": true,
    "mode": "notice",          // notice: inform, track at once (US)
                               // optIn: pixel and Meta cookies wait for Accept
    "text": "...",             // blank = built-in wording
    "acceptLabel": "Got it", "declineLabel": "Decline"
  },
  "privacyUrl": "https://yourbrand.com/privacy",
  "termsUrl": "https://yourbrand.com/terms",
  "smsConsent": { "enabled": true, "required": true, "text": "..." }
}
```

`privacyUrl` and `termsUrl` appear as a footer row on every page, in
the banner, and after consent wording. `smsConsent` puts a TCPA
checkbox on every `lead` form that has no `consent` of its own; blank
`text` uses built-in express-consent wording with the brand name. When
a visitor ticks it, the lead carries `sms_consent: true` plus
`sms_consent_text`, `sms_consent_at`, `sms_consent_ip`,
`sms_consent_user_agent` and `sms_consent_url`, in the lead row and
in every webhook payload. In `optIn` mode, Unfunnel's own step events
still fire (first-party, needed to run the funnel) but the Meta pixel,
the `_fbc` and `_fbp` cookies and the Conversions API leg wait for
Accept, and Decline keeps them off for that visitor.

## Pages

```jsonc
{
  "id": "landing",
  "layout": "top",          // top: scrollable long-form
                            // center: quiz step, vertically centered
                            // hero: full-bleed image with a glass card
  "align": "center",        // center (default) | left
  "decorations": [          // max 4, optional
    { "shape": "circle", "color": "accentAlt", "size": "lg",
      "position": "top-right", "opacity": 1 }
  ],
  "background": { "imageUrl": "...", "overlay": 0.8 },   // hero only
  "blocks": [ ... ],
  "next": { "default": "gender" },
  "hero": "auto",                       // top pages on a wide screen: auto (split) | stacked
  "autoAdvance": { "afterMs": 2800 },   // loader page, spinner + auto next
  "outcome": "qualified"                // terminal marker, see below
}
```

- A page with `autoAdvance` renders a spinner above its blocks and
  advances by itself: the familiar "your result is being calculated"
  pattern.
- A page with `outcome` is terminal. `qualified` fires
  CompleteRegistration once per session; `disqualified` does not. Brand
  footer and legal text render on terminal pages.
- `align` decides which edge the copy hangs off. Centre every page: a
  landing page that hangs off one edge beside centred questions reads
  as two products.
- `decorations` are blocks of colour behind the copy: the circle
  bleeding off a corner, the square under a headline. Shapes are placed
  by keyword rather than by coordinate, so there is no combination that
  breaks a layout. They are clipped to the page, never interactive and
  hidden from screen readers. Colours are palette references only; a
  page never invents one. Most pages want none: a real photo of the
  brand does that job better.
- Validation rejects dead ends: every non-terminal page needs `next`
  plus a block that can trigger navigation, and every route target must
  exist.

## The landing page

Write the landing page as a **plan**, not as blocks: what it says, and
Unfunnel lays it out. Pass it as `landing` to `POST /api/v1/funnels`
(with a `doc` of quiz pages or a `template`) or to `PATCH
/api/v1/funnels/:id`; over MCP it is the `landing` input of
`create_funnel` and `update_funnel`. An AI draft writes one too.

```jsonc
{
  "eyebrow": "For owners of service businesses",          // optional
  "headline": "Know your real profit every Monday morning",
  "subhead": "Get a free books health check: we review your last 3 months and show you what your numbers are hiding.",
  "cta": { "label": "Get my free books check →", "note": "Free · No commitment" },
  "media": { "kind": "video", "url": "https://...", "alt": "" },   // optional, only real media
  "proof": { "logosHeading": "Trusted by", "logos": [{ "name": "..." }], "stats": [{ "value": "$600", "label": "value, free", "icon": "dollar-sign" }] },
  "sections": [
    { "kind": "problem", "heading": "Sound familiar?", "items": [{ "title": "...", "text": "..." }] },
    { "kind": "steps", "heading": "How it works", "items": [{ "title": "...", "text": "..." }] },
    { "kind": "benefits", "heading": "...", "items": [{ "icon": "clock", "title": "...", "text": "..." }] },
    { "kind": "testimonials", "items": [{ "quote": "...", "name": "..." }] },
    { "kind": "audience", "heading": "Built for", "items": ["...", "..."] },
    { "kind": "faq", "items": [{ "q": "...", "a": "..." }] }
  ],
  "closing": { "heading": "See what your numbers are hiding", "subhead": "Two minutes to start." }
}
```

How it is laid out, the same way every time, in the brand's colours and
type:

- **Hero**: eyebrow, a big centred headline, the offer, one full-width
  button and its note, then the picture. What sits behind it is
  optional: `"backdrop": "wash"` (a soft gradient of the brand colour),
  `"tint"` (a flat tint) or `"plain"` (the page). Left out, pages vary. With no `media`, the picture is made for you: the funnel's
  first question in a phone on a soft brand tint.
- **Proof** straight under it: the logo strip, then up to three big
  numbers.
- **Sections** each get a centred heading and one card pattern (symptom
  rows, numbered steps, icon cards, quotes, a checklist, questions). On a
  page with three or more, the middle one sits on a dark full-bleed band
  with the button again under it.
- **Closing**: the heading and the same button on a dark band.
  `"closing": { "style": "glow" }` lights the band with the brand colours,
  `"plain"` leaves it on the page.

Two to four sections tell a story best. Leave a kind out rather than
pad it: real quotes, names and numbers only. The quiz pages that follow
are written as ordinary pages (below).

**The quiz.** The first question is an easy identity question (role,
business type, team size) with at most four answers, each with an icon.
Ask about problems second. Later questions have at most six answers,
each under 40 characters.

**Ad hooks.** Put the hooks of the ads that send traffic to the page in
`meta.adHooks`, word for word, and echo one of them in the hero headline
or subtext: a visitor who tapped one promise and landed on another
leaves. A hero that shares no words with any hook comes back as a fix.

### Fixes

Every create and update through the API or MCP replies with any fixes
the page still needs: what to change, where (`pages.0.blocks.3`), and
whether publishing waits for it. Make them and save again. Publishing is
refused while a blocking fix is open, unless `force: true` is passed,
which is logged.

Blocking fixes, the ones publishing waits for, are the few things that
make a page look broken: no `brand.logoUrl`, colours that are not the
brand kit's, a social share image used as a photo, an offer hidden at
the bottom, a hero button that says nothing ("Learn more"), and long
dashes in copy. How long a line is, and which face it is set in, are left to
the layout.

On colour and type: one heading face, one accent (the brand's button
colour), tints of the brand's second colour for cards and bands, and a
white or off-white ground when that is the brand's. Cards are tinted,
never outlined. All of it comes from the brand kit.

## Blocks

**Presentational**: `eyebrow` (optional `emoji` before the text), `heading`
(sizes md, lg, xl), `subtext` (`size` md or lg for a hero paragraph),
`spacer` (xs to xl),
`section` (a background band, below),
`badge`, `stats`, `image`, `video` (an uploaded file or a YouTube/Vimeo link, below),
`list` (checkmarked), `testimonial` (`quote` and `name`, optional
`rating` 1 to 5 for stars over the quote), `chart` (below), `quote` (founder
pull quote: `text` and `attribution`, never `name`), `faq` (below),
`features` (icon cards, rows or numbered steps), `logos` (customer or press strip),
`spacer`, `divider`, `legal`.

A `faq` block is questions and answers as an accordion: one answer open
at a time, every question a keyboard-reachable button. Write the
objections a visitor would otherwise leave to go and ask, three to six
of them, each answer two sentences at most.

```jsonc
{ "type": "faq", "items": [
  { "q": "Is it really free?", "a": "Yes. One team, up to 10 people. No commitment." },
  { "q": "How long does it take?", "a": "About 20 minutes each, online." } ] }
```

A `chart` block is the projection curve a quiz shows between
questions: a smooth line from where the visitor is now to where they will
be, nothing plotted. `trend` is `rise` (savings, leads, energy) or `fall`
(weight, hours, cost). `from` and `to` label the two ends and may use
`{{field}}`; `marker` is a short tag on the end point; `caption` is one
muted line under it.

```jsonc
{ "type": "chart", "trend": "fall", "from": "Today", "to": "Week 8",
  "marker": "{{goal_weight_kg}} kg", "caption": "Most of the change happens in the first eight weeks." }
```

A `logos` block is the "trusted by" row under a hero. Items are names
set in the display face, or logo images when `imageUrl` is given. With
`marquee: true` (the default) the strip scrolls continuously; `false`
wraps the items into a static centred row. `tone: "paper"` puts the
strip on a light card: full-colour brand marks are unreadable on a dark
theme, and a customer's logo colours are theirs to keep, so this is the
one block allowed a surface the theme does not define. Leave it out (or
`"ink"`) for the strip to sit on the page.

```jsonc
{ "type": "logos", "heading": "Trusted by", "marquee": true, "tone": "paper",
  "items": [{ "name": "Acme" }, { "name": "Globex", "imageUrl": "https://..." }] }
```

A `section` block opens a background band: every block after it, up to
the next `section` or the end of the page, sits on the chosen colour,
inset in the column with the theme's radius. The colours are palette
roles, so the band takes the theme's own ink, surface, hairline and
button colours remapped for contrast, and a block never knows where it
sits. `ground` ends a band.

```jsonc
{ "type": "section", "tone": "ink" },        // the dark panel mid-page
{ "type": "heading", "text": "Why work with us?" },
{ "type": "features", "items": [ ... ] },
{ "type": "cta", "label": "Start" },
{ "type": "section", "tone": "ground" }      // back on the page
```

Tones: `ink` (dark), `accent`, `surface`, `surfaceHi` (a tint), `ground`.

### Icons

Answers, feature items and stat tiles take an `icon`: a line icon by
name, drawn in the accent colour on a soft disc of it, so it reads as
part of the brand where an emoji would not. The names are the closed
set in `src/lib/funnel/icons.ts` (`store`, `fuel`, `shield-check`,
`trending-up`, `calendar`, ...); an unknown name fails validation and
the error lists the valid ones. When an item has both, the icon wins.

```jsonc
{ "type": "features", "layout": "list", "items": [
  { "icon": "dollar-sign", "title": "Estimated charging revenue", "text": "" },
  { "icon": "calendar", "title": "Projected payback period", "text": "" }
]},
{ "type": "features", "layout": "steps", "items": [
  { "title": "See if your site qualifies", "text": "Take the 60 second qualifier." }
]},
{ "type": "stats", "items": [{ "icon": "clock", "value": "10+", "label": "Sessions a day" }] }
```

`features.layout`: `cards` (the default tile grid), `list` (stacked
rows, icon left, text optional) or `steps` (rows numbered in accent
circles, the "how it works" pattern).

A band also takes a `fill`: `flat` (the tone), `gradient` (painted with
the theme's gradient, text in the gradient's ink) or `glow` (the tone,
lit from two corners by the gradient's colours). A `cta` with `"style":
"gradient"` wears the gradient too.

```jsonc
{ "type": "section", "tone": "ink", "fill": "glow" },
{ "type": "heading", "text": "Ready to see what your property could earn?" },
{ "type": "cta", "label": "Get a site assessment", "style": "gradient" }
```

Text in `eyebrow`, `heading` and `subtext` supports `{{field}}`
interpolation from collected answers: "{{first_name}}, your result is
on its way."

### video

```jsonc
{ "type": "video",
  "url": "https://.../storage/v1/object/public/videos/<workspace>/...-intro.mp4",
  "poster": "https://.../...-intro-poster.jpg",   // optional: the picture before play
  "aspect": "16:9",                               // optional: 16:9 (default) | 9:16 | 1:1
  "autoplay": true }                              // optional: start muted once on screen (default false)
```

`url` is either a video uploaded to this workspace or a YouTube or Vimeo
link, which is framed instead. Upload with `upload_video` (MCP) or
`POST /api/v1/media`: MP4 (H.264) or WebM, up to 100MB. The answer
carries the `url` to use and a `poster_url` for `poster`, plus
`duration_seconds`, `width` and `height`; a clip taller than it is wide
wants `"aspect": "9:16"`. `list_videos` shows the workspace's library.

The player shows controls, plays inline on an iPhone, loads only the
first frame and the length until someone presses play, streams as it
plays, and never starts by itself unless `autoplay` is on, so nobody
arriving from an ad is met with sound.

`"autoplay": true` starts the video muted once at least half of it is on
screen, and pauses it when it scrolls away, so it never plays under the
fold. While it plays muted, a "Tap for sound" button in the theme's
accent colour sits bottom-left; tapping it turns the sound on and starts
again from 0:00, and it goes away once the sound is on, however the
viewer turned it on. It does not loop. Visitors whose device asks for
reduced motion get the ordinary poster and play button instead. On a
YouTube or Vimeo link it adds the host's own muted-autoplay parameters.
Browsers only allow autoplay without sound, so sound always waits for a
tap. Portrait and square frames are capped in width and centred,
so a tall clip never outgrows a desktop screen.

Like fonts, videos belong to the workspace that uploaded them: a video
file from another workspace, or from any other host, is refused with a
422, "video does not belong to this workspace". A `poster` may be any
public image, but one on Unfunnel storage has to be this workspace's
too. A video block with an empty `url` saves, and shows "No video chosen
yet" until one is picked.

**Interactive**:

### choice

```jsonc
{
  "type": "choice",
  "field": "body_type",       // key the answer is stored under
  "variant": "grid",          // grid: 2-col tiles | list: stacked rows
  "multiple": false,          // true shows checkboxes + Continue button
  "options": [
    { "value": "apple", "label": "Apple shape",
      "sublabel": "Weight around the midsection",
      "icon": "store",                   // a line icon in the accent, or "emoji": "🍎", or "imageUrl": "..."
      "score": 5 }
  ]
}
```

Single choice advances on tap. Multiple choice toggles and shows a
`submitLabel` button. Whether options render as bordered cards or solid
accent tiles comes from `theme.optionStyle`, not from the block.

### form

```jsonc
{
  "type": "form",
  "action": "lead",          // lead: fires Lead + webhook | continue
  "leadEvent": false,        // optional; false: saves + webhooks, no Lead event
  "fields": [
    { "field": "first_name", "kind": "name", "placeholder": "Your first name...", "emoji": "👋" },
    { "field": "email", "kind": "email", "placeholder": "Your email...", "emoji": "✉️" }
  ],
  "consent": { "field": "sms_consent", "text": "I agree to...", "required": true },
  "submit": { "label": "Get the results", "loadingLabel": "Sending...", "note": "No spam." },
  "skip": { "label": "I'm not sure", "field": "model_reference", "storeValue": "not sure" }
}
```

Field kinds: `text`, `email`, `tel` (auto formats US numbers),
`number`, `name`. Use `action: "continue"` for mid-funnel field pages
like measurements; only `action: "lead"` fires the Lead event.
`"leadEvent": false` on a lead form saves the lead and fires the
webhooks but sends no Lead event to any ad platform: use it for a
details form on a not-a-fit path, so an ad set optimising for Lead
does not learn from the visitors the funnel screens out.

### cta

```jsonc
{ "type": "cta", "label": "Start the test now", "note": "Takes 2 minutes" }
{ "type": "cta", "label": "Call us", "href": "tel:9499031698", "style": "ghost" }
```

Without `href` it advances through the page's routing. With `href` it
is a plain link: tel:, sms:, or https:.

`"passParams": true` on an https link adds the visit's ad parameters to
it when tapped: utm_*, click ids (fbclid, gclid and the like) and ad ids,
from the address the visitor landed on, or from the ad that first brought
them if they came back direct. Never answers or anything else in the
URL, and values the link already sets stay as they are. The site the
button opens then knows which ad sent the visitor.

```jsonc
{ "type": "cta", "label": "Visit our website", "href": "https://example.com", "passParams": true }
```

### embed

```jsonc
{ "type": "embed", "url": "https://calendar...", "prefill": ["email", "phone"], "height": 640 }
```

Iframe for GHL, Calendly or cal.com booking calendars. With a named
`provider` the visitor's name, email and phone from earlier answers are
filled into the calendar automatically; `prefill` appends extra answer
fields as query params.

Publishing checks the address. Calendly (`calendly.com`), cal.com
(`cal.com`) and GHL's own hosts (`leadconnectorhq.com`, `msgsndr.com`)
are accepted for any provider. A GHL calendar on the agency's
white-label domain is accepted with `"provider": "ghl"` when it is the
calendar's widget link, `https://<domain>/widget/booking/<calendar id>`
(or `/widget/bookings/<slug>`, `/widget/group/<id>`). Any other page is
refused at publish and not drawn on a live page. The frame is sandboxed:
the calendar runs its scripts, forms and popups but cannot navigate the
funnel page.

A booking page needs no button. When the visitor completes the booking,
the calendar tells the page and it advances through `next` by itself,
so put the confirmation page in `next.default`.

## Routing and scoring

```jsonc
"next": {
  "rules": [
    { "when": { "field": "homeowner", "op": "eq", "value": "rent" }, "goto": "not-fit" },
    { "when": { "field": "any", "op": "scoreGte", "value": 35 }, "goto": "strong-fit" }
  ],
  "default": "next-question"
}
```

Rules evaluate in order, first match wins, falling through to
`default`. Operators: `eq`, `neq`, `in`, `notIn`, `gt`, `lt`,
`contains` (multi-select), `scoreGte`, `tallyMax`. The score is the
sum of the `score` values on every selected option, recomputed from
answers so back navigation cannot double count.

A "which of these is your team" quiz has no threshold: each answer
points at one of several outcomes and the outcome with the most
answers wins. Give each option a `tally` (the outcome's name) and,
optionally, a `tallyWeight` so a summing-up question counts double.
Route on the leader with `tallyMax`, one rule per outcome:

```jsonc
"options": [
  { "value": "wait", "label": "People wait for the leader to decide", "tally": "dependency" },
  { "value": "unclear", "label": "Nobody is sure who owns it", "tally": "ownership" }
]
...
"rules": [
  { "when": { "field": "any", "op": "tallyMax", "value": "dependency" }, "goto": "result-dependency" },
  { "when": { "field": "any", "op": "tallyMax", "value": "ownership" }, "goto": "result-ownership" }
]
```

A dead heat goes to whichever outcome is listed first, so order the
rules by which result you would rather show when it is close. A tally
no answer counted toward never wins, and the page `default` still
covers a visitor who somehow answered nothing.

## Tracking

The renderer reuses `src/lib/meta/pixel.ts` untouched.

| Moment | Event |
|---|---|
| Funnel loaded | `PageView` (fired by the root layout) |
| Page completed | a custom event named for the page, see below |
| Outbound button tapped | a custom event named for the page, `_Clicked` |
| Lead form submitted | `Lead`, dual leg, with hashed email and phone |
| Qualified end reached | `CompleteRegistration`, guarded against refires |

### Named step events

Every page sends one custom event when a visitor completes it (moves
forward from it), named from the page id so a media buyer can build a
custom conversion without opening the editor:

| Page | Default event |
|---|---|
| first page | `QuizStart` |
| `roster-size` with a choice block | `RosterSize_Answered` |
| `contact` with a form block | `Contact_Submitted` |
| `book` with a booking embed | `Book_Booked` |
| any other page with a button | `{PascalId}_Completed` |
| loader or end page | nothing |

The event carries `content_name` (the page id), `content_category`
(the funnel slug), `next_page`, and `answer` when the page was a
question. A button with an `href` sends `{PascalId}_Clicked` on the
tap, since the visitor is leaving the funnel.

Override either with an `event` field: `"event": "Q2_Answered"` on a
page, or on a cta block. `"event": "off"` sends nothing. Names are
letters, numbers and underscores, 40 characters at most. The `step`
entry in `settings.metaEvents` still switches the whole set to a
standard event or off.

## Building a funnel

1. Read the brand first: `POST /api/v1/brand` (`brand_from_url`). Its
   reply is the logo, the theme and the fonts the funnel uses.
2. The quickest route is `create_funnel` with a `prompt` and the
   `website`: the copy is drafted, the brand applied, a hero picture
   made if the brand has none, and the result checked visually and
   revised before it comes back. Writing it yourself, pass the landing
   page as a `landing` plan (above) with a `doc` of quiz pages, put the
   kit's `theme` on the doc and set `brand.logoUrl` and `brand.website`. Keep `field` keys snake_case and stable, they become
   lead columns and CRM fields.
3. Put images in the workspace with `POST /api/v1/media` and reference the
   URLs it returns: `brand.logoUrl`, `meta.image`, `image` blocks and hero
   `background.imageUrl`. Any public URL works, but a copy you own keeps
   working when whatever hosted the original moves it. A file on your own
   machine goes up through an upload link (`POST /api/v1/media/uploads`,
   the MCP tool `get_upload_url`) rather than as base64.

   `meta.image` and `meta.favicon` are optional. Without them the link
   preview is drawn from the funnel itself (its colours, font, logo and
   headline) and the tab icon falls back to the logo, then to a mark made
   from the brand initial. Set them when the brand has real artwork.
4. `POST /api/v1/funnels` with `{ "doc": ... }`. A 422 names the exact
   path that failed; fix it and post again. The response lists any
   fixes the page still needs; make them with `PATCH`, which replies with
   whatever is still open.
5. Preview it (`GET /api/v1/funnels/:id/preview`, or `preview_funnel`)
   to see it, and check nothing is left to fix before publishing.
6. Publish with a slug, open `/f/<slug>`, and walk every branch on a
   phone and a desktop before sending traffic.
