# Wellfy Embed — Partner Integration Guide (v1)

Embed the Wellfy Clarity Score experience — quiz, reveal, results — inside your
own site. The widget runs in a sandboxed iframe, inherits your brand through a
validated theme contract, and delivers results to your page through events.

**In embed mode, no score or answer data is sent to Wellfy's servers.**
Everything the user types stays in the iframe; everything you receive arrives
via the event callback on your own page. See [Data privacy](#data-privacy).

## Quickstart

```html
<div id="wellfy"></div>

<script src="https://YOUR-WELLFY-HOST/sdk/v1/wellfy.js"></script>
<script>
  var widget = Wellfy.mount(document.getElementById('wellfy'), {
    partnerKey: 'pk_your_key',
    theme: { amber: '#b89b5e', cardRadius: '6px' },
    onEvent: function (name, payload) {
      if (name === 'score_computed') {
        console.log('Clarity Score:', payload.score, payload.archetype);
      }
    }
  });

  // When you no longer need the widget:
  // widget.unmount();
</script>
```

The SDK is a plain script (no modules, no build step, ~3 KB). It exposes one
global, `window.Wellfy`, with one method, `mount`. Multiple widgets on one page
are supported — each `mount` call returns its own handle.

A working reference integration lives at [`partners/demo.html`](../partners/demo.html).

## `Wellfy.mount(el, options)`

Creates the widget iframe inside `el` and returns `{ unmount() }`.
`unmount()` removes the iframe and all listeners the SDK registered.

| Option | Type | Default | Description |
|---|---|---|---|
| `partnerKey` | `string` | `null` | Your publishable partner key (`pk_…`). Safe to expose in page source. Validated: `mount` throws on a malformed key, and the widget verifies the key and your page's origin against Wellfy's partner registry before booting — see [Partner tenancy](#partner-tenancy). |
| `theme` | `object` | `null` | Brand overrides. Validated inside the iframe against the [theme contract](#theme-contract); invalid entries are silently dropped. |
| `onEvent` | `function(name, payload)` | — | Called for each [lifecycle event](#events). This is how results reach your page. |
| `src` | `string` | derived from the script tag's own URL | Embed base override, e.g. `https://embed.wellfy.app`. Only needed if you self-host or proxy the SDK file. |

The iframe is created with `title="Wellfy"`, `width: 100%`, no border, a
480 px minimum height, and no extra permissions. Height then tracks the
widget's content automatically — give the container a width and the widget
manages the rest.

## Events

Your `onEvent(name, payload)` callback receives exactly these events:

| Event | Payload | When |
|---|---|---|
| `quiz_started` | `{}` | The quiz UI has booted and the first question is on screen (fires once, right after the handshake — before any user interaction). |
| `chapter_completed` | `{ chapter }` | A quiz chapter (pillar) is finished. |
| `score_computed` | `{ score, band, archetype, pillarScores }` | The Clarity Score is calculated. This is the result delivery — persist it on your side if you need it. |
| `cta_clicked` | `{ cta }` | The user clicks a call-to-action inside the widget. |

`score_computed` carries the score (0–100), the score band, the archetype
label, and per-pillar scores. **It never contains individual answers and never
contains PII.** Event names outside this list are rejected by both sides of
the protocol; there is no mechanism for arbitrary data to cross the boundary.

## Theme contract

Eight keys are brand-overridable. Anything else — other keys, other CSS — is
rejected by an allowlist validator inside the iframe. Structural tokens
(spacing, motion, secondary ink shades) are not overridable by design: they
are what makes the widget read as Wellfy.

| Key | Sets | Accepted values |
|---|---|---|
| `parchment` | page background | Hex (`#rgb` … `#rrggbbaa`), `rgb()`/`rgba()`, `hsl()`/`hsla()` |
| `stock` | card background | same |
| `ink` | primary text | same |
| `amber` | accent / signature moments | same |
| `cardRadius` | card corner radius | A length like `16px`, `1rem`, `2em` |
| `fontAuthority` | headings and figures | Font-family list: letters, spaces, commas, quotes, hyphens only |
| `fontSoul` | editorial voice text | same |
| `mode` | color scheme | `'light'` or `'dark'` — see [Dark mode](#dark-mode) |

Validation notes:

- Color values must match `#[0-9a-fA-F]{3,8}` or be a plain `rgb()`/`hsl()`
  function. `url(…)`, `var(…)`, semicolons, and anything resembling CSS
  injection fail validation and are dropped — the widget falls back to its
  default for that token.
- Fonts must already be available on the page rendering the iframe (Wellfy's
  embed page self-hosts its defaults). The font value is a family name list,
  not an `@font-face` loader.
- Pick `ink` and `parchment` with contrast in mind: Wellfy's defaults are
  WCAG AA tested; your overrides are your responsibility.

Changing the theme after mount requires a remount (`unmount()` then `mount()`
with the new theme) — see the demo page for the pattern.

When a `partnerKey` is set, the theme registered with Wellfy for your
partnership applies first; keys you pass in `mount(…, { theme })` override it.
Both pass the same allowlist validator.

### Dark mode

The widget ships two palettes: the warm-parchment light mode and a
"candlelit study" dark mode (deep umber surfaces, warmed cream ink, same
amber accent — both WCAG AA). By default the widget follows the visitor's
`prefers-color-scheme`. To pin one:

```js
Wellfy.mount(el, { theme: { mode: 'dark' } });   // or 'light'
```

`mode` is applied as a `data-theme` attribute on the widget's own document
root (the host page can't reach inside the iframe, which is why this is a
theme key rather than an attribute you set yourself). Interaction with
color overrides: the color keys above (`parchment`, `stock`, `ink`,
`amber`) are applied as inline custom properties, which always win over
both built-in palettes — if you override colors, they apply in either
mode, so either pin a `mode` alongside them or supply colors that work on
both schemes.

## Partner tenancy

A `partnerKey` claims a registered partnership, so it is enforced:

- On `mount`, a malformed key (`not pk_…`) throws immediately.
- On boot, the widget checks the key against Wellfy's partner registry
  (`partner-config`), including whether your **page's origin** is on your
  partnership's allowed-origins list. This lookup sends only the key and the
  origin — never user data.
- **Fail closed:** an unknown key, a deactivated partnership, or an
  unregistered origin renders a quiet error card instead of the quiz. Ask
  Wellfy to register every origin you embed from (including staging hosts).
- **Fail open:** if the registry itself is unreachable (Wellfy outage), the
  widget boots unbranded rather than taking your page down.

Omitting `partnerKey` mounts a generic, unbranded widget — nothing is enforced.

### Persisting scores with Wellfy (optional)

By default Wellfy stores nothing (`data_mode: events_only`). Partnerships on
`data_mode: ingest` may push results **server-to-server** for later use in the
advisor dashboard, keyed on your own user identifier:

```
POST https://qxywazzaqfauprwicvym.supabase.co/functions/v1/partner-score-ingest
Authorization: Bearer sk_your_ingest_secret
Content-Type: application/json

{ "externalUserId": "user-123", "score": 62, "band": "aligned",
  "archetype": "The Anxious Achiever",
  "pillarScores": { "spend": 70, "save": 55, "debt": 80, "plan": 40, "invest": 58 } }
```

- The ingest secret (`sk_…`) is issued alongside your key. It must live only
  on your server — the endpoint sends no CORS headers, so browsers cannot call
  it. Wellfy stores only a hash of it.
- One row per `externalUserId` — repeat posts update it. Choose an identifier
  that is pseudonymous on your side (an internal ID, not an email).
- Wire it from your `onEvent` handler: on `score_computed`, forward the
  payload to your backend, which posts it here.

## Data privacy

- **Embed mode sends no user data to Wellfy's servers.** No answers, no
  scores, no identifiers land in Wellfy's database from an embedded widget.
  The default persistence layer in embed mode is a null store. The only
  request the widget makes to Wellfy is the partner-key lookup described
  above, and only when a `partnerKey` is set.
- Scores reach Wellfy's database only if your backend explicitly posts them
  via the [ingest endpoint](#persisting-scores-with-wellfy-optional).
- Results are delivered to your page exclusively through the `onEvent`
  callback (`score_computed`). What you store, and under which lawful basis,
  is between you and your user.
- The `partnerKey` is a publishable identifier, not a secret. It grants no
  read access to anything.

## Security notes

- **Origin validation, both directions.** The SDK passes your page's origin to
  the iframe (`embed.html?origin=…`). The iframe accepts messages only from
  that origin and addresses every outgoing message to it — never `*`. The SDK
  in turn accepts messages only when they come from its own iframe's
  `contentWindow` *and* from the embed base's origin.
- **Pin the embed origin.** Load the SDK from Wellfy's canonical host and, if
  you use the `src` override, hard-code the exact origin — do not build it
  from user input or query parameters.
- Every message is tagged with a protocol version (`wellfy: 1`); malformed or
  unversioned messages are ignored by both sides.
- Wellfy's consumer-facing pages set their own framing policy
  (`frame-ancestors`) separately; that configuration is outside this guide.
- If you operate a strict Content-Security-Policy, allow the Wellfy embed
  origin in `frame-src` and the SDK URL in `script-src`.

## Versioning

The SDK URL is versioned (`/sdk/v1/`). Breaking changes to the message
protocol or theme contract ship as `/sdk/v2/` — `v1` stays stable once
published.
