---
type: reference
---

# Widget

The customer-facing browser surface — a single web component plus two events. Distributed two ways, identical behavior:

- **CDN:** `https://cdn.jsdelivr.net/npm/@caputchin/widget@1/dist/widget.js` (script tag, auto-registers). See [ADR-0009](adr/0009-jsdelivr-over-custom-cdn.md) for why we use jsDelivr instead of a custom CDN.
- **npm:** `@caputchin/widget` (browser-runtime only). The game-author `register()` helper is a separate package, [`@caputchin/game-sdk`](game-sdk.md) — see [ADR-0008](adr/0008-split-widget-and-game-sdk-packages.md).

## Element shape

```html
<caputchin-widget sitekey="cpt_pub_..." game="@cooperative-games/bubble-pop"></caputchin-widget>
```

| Attribute | Required | Behavior |
|---|---|---|
| `sitekey` | yes | Public site key from the [dashboard](dashboard.md) |
| `game` | optional | Marketplace game ID, or a customer-chosen ID used together with `game-src` |
| `games` | optional | Comma-separated list of marketplace IDs; widget picks one at random client-side |
| `game-src` | optional | Absolute or page-relative URL to a customer-hosted game script. Required when the `game` ID is not a marketplace ID. See [game-distribution](game-distribution.md). |
| `mode` | optional | Trigger and control mode. See [Modes](#modes). |

If neither `game` / `games` nor `game-src` is set, the widget runs the **invisible default** — Cap-only verification, no game UI, `score: null` in the wrapped token.

On successful completion the widget injects a hidden `<input name="caputchin-token">` into the enclosing form. Customer's backend posts that field to [`/siteverify`](api.md) with the secret.

## Events

```javascript
const widget = document.querySelector('caputchin-widget');
widget.addEventListener('start',    (e) => { /* e.detail.gameId */ });
widget.addEventListener('complete', (e) => { /* e.detail.token, score, durationMs */ });
widget.addEventListener('nickname', (e) => { /* e.detail.nickname */ });
widget.addEventListener('error',    (e) => { /* e.detail.code, message, originalCode */ });
```

| Event | Fires when | `event.detail` |
|---|---|---|
| `start` | Game has begun. In iframe modes (`auto`+game, `form-submit`+game), fires when the iframe sends `game-started`. In the invisible default (no game) and `manual` mode, fires when Cap `solve()` begins — no iframe is involved so there is no `game-started` to wait for. | `{ gameId }` — the resolved game ID, or `null` in the invisible default |
| `complete` | Wrapped token is built (first completion only) | `{ token, score, durationMs }` — `token` is also injected as `caputchin-token` into the enclosing form. `score` / `durationMs` are nullable. |
| `nickname` | Scoreboard handle was set, either by the widget's own prompt or by `widget.setNickname()` *(Post-MVP)* | `{ nickname }` — the 3-letter string |
| `error` | Anything blocks the flow | `{ code, message, originalCode? }` — `code` is a value from the widget's documented `ErrorCode` set. `originalCode` is set only when a game calls `bridge.error({ code })` with a code outside that set; `code` is then `game-error-relayed` and `originalCode` carries the author-supplied value verbatim. |

The `start` event ordering depends on whether a game is configured. In iframe modes (`auto`+game, `form-submit`+game), Cap's `solve()` begins in parallel with the game loading inside the sandboxed iframe. The `start` event fires only after the iframe sends `game-started` — confirming the game actually loaded and ran — so customers can rely on `start` as a signal that gameplay has begun, not merely that Cap has started churning. In the invisible default (no game) and in `manual` mode there is no iframe, so `start` fires immediately when Cap begins.

Every programmatic method fires the corresponding event so customers can observe progress in any mode without owning the call site. By design — see [principles](principles.md#smallest-possible-api). Anything fancier (per-round score deltas, stats, replay timelines) belongs in the [dashboard](dashboard.md), not in widget callbacks.

## Modes

The `mode` attribute controls **when Cap starts** and **who drives the lifecycle**. Game presence is orthogonal to the trigger: any non-manual mode supports either an iframe-hosted game (when `game` / `game-src` is set) or the invisible default (when neither is set).

| `mode` value | When Cap fires | Driver | Compatible with `game` / `game-src` |
|---|---|---|---|
| *(unset)* / `auto` | On widget mount | Widget | Yes — game loads at the same moment |
| `form-submit` | When the enclosing `<form>` is submitted; submission is held until the wrapped token is ready | Widget intercepts form submit | Yes |
| `manual` | When the customer calls `widget.start()` | Customer fully drives lifecycle from the host page realm; widget loads no game | **No** — setting `game` or `game-src` together with `mode="manual"` emits `error` with code `invalid-config` |

`manual` is the deliberate opt-out from sandbox isolation. The customer's own host-realm code runs whatever game / UX they like and calls the widget back to drive Cap. There is no third-party code involved by definition, so the iframe contributes nothing — the customer trusts their own bundle the same way they trust the rest of their app. Marketplace and customer-hosted modes remain sandboxed; manual is explicit and verbose in markup so the trust trade is visible at the integration point. See [principles — untrusted by default](principles.md#untrusted-by-default) and [ADR-0015](adr/0015-sandbox-game-iframe.md).

## Programmatic API (manual mode)

```javascript
const widget = document.querySelector('caputchin-widget');

widget.start();                          // begin Cap solve; held until complete()
widget.complete({ score, durationMs });  // release redeem; emit complete event
widget.setNickname('AAA');               // Post-MVP scoreboards only; optional
```

| Method | Effect |
|---|---|
| `widget.start()` | Calls Cap `solve()` in the widget realm, fires [`/game/start`](api.md#post-gamestart-browser--platform). Idempotent — repeat calls are no-ops. |
| `widget.complete({ score, durationMs })` | Releases the gated Cap redeem via `CAP_CUSTOM_FETCH`, fires [`/game/complete`](api.md#post-gamecomplete-browser--platform), builds the wrapped token, emits the `complete` event. Repeat calls record additional rounds; the wrapped token is locked at the first call. Either field is optional and metadata-only — see [game data is metadata](principles.md#game-data-is-metadata-not-security). |
| `widget.setNickname(letters)` *(Post-MVP, scoreboards)* | **Not yet implemented — throws `Error('not implemented')` in the current build.** Will call [`/game/nickname`](api.md#post-gamenickname-browser--platform) when the platform endpoint ships. |

Only available in `manual` mode. In `auto` and `form-submit` modes the widget owns the lifecycle and these methods are not exposed.

## Lifecycle

```mermaid
%%{init: {'themeVariables': {'actorBkg':'#6a4c93','actorBorder':'#3d2a5e','actorTextColor':'#fff'}}}%%
sequenceDiagram
    participant W as Widget
    participant C as Cap
    participant I as Iframe
    participant P as Platform API

    alt auto / form-submit mode (with game)
        W->>W: connectedCallback / form submit
        W->>P: GET /games/:id/resolve
        P-->>W: { url, integrity }
        W->>I: mount srcdoc iframe
        W->>C: cap.solve()
        W->>P: POST /game/start
        I->>I: load & run game script
        I-->>W: postMessage game-started
        W-->>W: emit start event { gameId }
        I-->>W: postMessage game-complete { score, durationMs }
        W->>W: update platform { score, durationMs }
        W->>C: releaseRedeemGate()
        C->>P: POST /game/complete (redeem)
        P-->>C: { powToken, wrappedToken }
        C-->>W: solve() resolves
        W->>W: assemble wrappedToken
        W->>W: inject caputchin-token input
        W-->>W: emit complete event
    else invisible default (no game configured)
        W->>W: connectedCallback
        W->>C: cap.solve()
        W->>P: POST /game/start
        W->>W: releaseRedeemGate() immediately
        W-->>W: emit start event { gameId: null }
        C->>P: POST /game/complete (redeem)
        P-->>C: { powToken, wrappedToken }
        C-->>W: solve() resolves
        W->>W: assemble wrappedToken (score: null)
        W->>W: inject caputchin-token input
        W-->>W: emit complete event
    else manual mode
        W->>W: widget.start() called by customer
        W->>C: cap.solve()
        W->>P: POST /game/start
        W-->>W: emit start event { gameId: null }
        Note over W,I: No iframe. Customer drives game UX in host page.
        W->>W: widget.complete({ score, durationMs }) called by customer
        W->>W: update platform { score, durationMs }
        W->>C: releaseRedeemGate()
        C->>P: POST /game/complete (redeem)
        P-->>C: { powToken, wrappedToken }
        C-->>W: solve() resolves
        W->>W: assemble wrappedToken
        W->>W: inject caputchin-token input
        W-->>W: emit complete event
    end
    Note over W,P: Token locked at first completion. Subsequent rounds record score only.
```

The widget defers expensive work until the user actually engages. Cap's `solve()` is **not** called on widget mount — it fires when the game starts, so users who never play don't generate PoW challenges. Game code runs inside a sandboxed cross-origin iframe mounted by the widget; the widget and the iframe communicate via `postMessage`. See [ADR-0015](adr/0015-sandbox-game-iframe.md) for the isolation model.

1. **Mount** — render container in the host page, no network until the trigger fires. The iframe is not yet built.
2. **Trigger** — depends on `mode`. In `auto` (default), the trigger is mount itself. In `form-submit`, it is the enclosing `<form>` submit event (which is gated until completion). In `manual`, it is the customer's `widget.start()` call. On trigger the widget resolves the game ID via [`/games/:id/resolve`](api.md) (for marketplace IDs) or uses the customer-supplied `game-src` (for customer-private games), builds the iframe `srcdoc` with a path-pinned `script-src` CSP, mounts the iframe, and calls Cap `solve()` plus `/game/start` in the host realm. In `manual` mode, no iframe and no game are loaded; only Cap runs.
3. **Game runs inside the sandboxed iframe** (non-manual modes with `game` / `game-src` set) — opaque origin, no access to the customer's cookies, `localStorage`, DOM, or same-origin `fetch`. The iframe loads the game script via `<script src>` (with SRI for marketplace games); the game's top-level `register()` call writes into the iframe's `window.Caputchin.games`. The iframe runtime invokes the registered factory with a `container` element and a push-only `bridge` — see [game-sdk](game-sdk.md#bridge-methods). In the invisible default (no game configured), no iframe is mounted at all — Cap runs alone.
4. **Completion** — for iframe-hosted games, the iframe relays `bridge.complete({ score, durationMs })` to the widget via `postMessage`. For `manual` mode, the customer calls `widget.complete({ score, durationMs })` directly. For the invisible default, Cap completing is itself the completion; the widget builds the wrapped token with `score: null`. In all cases the widget releases the held Cap `redeem` via `CAP_CUSTOM_FETCH`, which fires `/game/complete`.
5. **Wrapped token returned** — widget drops `caputchin-token` into the form and emits the `complete` event.
6. **Subsequent completion calls** (replay, multi-round) — widget calls `/game/complete` again for scoreboard recording, but the wrapped token is already locked.
7. **Optional 3-letter nickname prompt** (scoreboard-enabled site keys only, Post-MVP) — surfaced after the first completion as a retro-style 3-character entry rendered by the widget in the host realm, *not* by the game inside the iframe. Non-blocking: the wrapped token and the `complete` event have already fired by this point, so the customer's form submission is unaffected. Widget posts the entry to [`/game/nickname`](api.md#post-gamenickname-browser--platform); the user may close the tab and the score persists anonymously. Game authors are not involved — the widget owns this surface end-to-end. See [ADR-0014](adr/0014-scoreboard-3letter-async-naming.md).

## Game loading

The widget resolves the game ID to a script URL and loads it inside the iframe — never in the host page realm. Two paths:

- **Marketplace ID** (e.g., `@cooperative-games/bubble-pop`) — widget calls [`/games/:id/resolve`](api.md) and receives `{ url, integrity }`. The iframe loads the script via `<script src integrity>`.
- **Customer-hosted game** — customer passes a `game-src` attribute pointing at a script they host (their static-asset directory, any URL). The iframe loads that URL. No integrity hash because no canonical hash exists for customer-private code; the customer's own deploy pipeline is the trust root.

In both cases the game's top-level [`register()`](game-sdk.md) call writes into the iframe's own `window.Caputchin.games` — there is no host-page registry to write to. See [game-distribution](game-distribution.md) and [ADR-0015](adr/0015-sandbox-game-iframe.md).

## Pool selection (`games=`)

When `games="a,b,c"` is set, the widget picks one ID at random per session. There is no server-side pool config, no allowlist, no game-pool lock — by design. See [principles](principles.md#honesty-over-theater) and [ADR-0006](adr/0006-author-declared-support-flags.md) for the related "no fake config knobs" stance.

## Isolation

The widget mounts every game inside an `<iframe sandbox="allow-scripts" srcdoc="...">`. Sandbox without `allow-same-origin` gives the iframe an opaque origin distinct from the customer's page. The iframe's inline CSP path-pins `script-src` to exactly the runtime URL plus the resolved game URL — no wildcards. Customer-side CSP is not affected by what runs inside the sandbox; customers do not need to allowlist jsDelivr or the game origin in their own policy. See [ADR-0015](adr/0015-sandbox-game-iframe.md) for the full threat model and the rationale for `srcdoc` over a dedicated subdomain.

## Mobile

The widget works inside a WebView with no special configuration. The sandboxed iframe is supported across iOS and Android WebView builds in the project's support matrix. See [mobile](mobile.md) for the embed pattern.

## Cap masking inside the widget

Runtime traffic (PoW challenge / redeem, `/siteverify`) originates from `caputchin.com` domains. The widget script itself loads from jsDelivr under the `@caputchin/widget` package name; Cap's WASM is inlined as base64 inside `cap.min.js` (pre-1.0), so `widget.js` is a single self-contained file — see [ADR-0016](adr/0016-cap-wasm-inlined.md) and [cap-integration](cap-integration.md) for the full masking posture.
