# Theme builder

Start from a preset, adjust what you care about, open the link. Nothing to install and no app build — the webview accepts a theme straight from the URL.

Already have a theme? Paste it into **Import an existing theme** below to keep editing it — a JSON payload, a base64url string, or a whole `?theme=` link all work.

This page hosts an interactive theme builder. Pick colours for the light and dark modes and it produces a link that opens the hosted Yodl UI with that palette applied, of the form:

```txt
https://sdk-webview.yodl.me/?theme=<base64url-encoded YodlTheme JSON>
```

The same JSON can be passed to YodlProvider as the theme prop. Use ?theme=default to clear a stored theme.

## How the link works

The hosted UI reads `?theme=` on load, validates it against the theme schema, applies it, persists it, and strips the parameter from the URL so it can't leak into navigation:

```txt
https://sdk-webview.yodl.me/?theme=<base64url-encoded JSON>
```

The payload is the same `YodlTheme` object you would pass to `<YodlProvider>` — so once a palette looks right here, [copy the JSON](/sdk/react-native/theming) into your app and it renders identically.

Raw URI-encoded JSON works too, which is handy for hand-editing:

```txt
?theme=%7B%22dark%22%3A%7B%22accent%22%3A%22%232dd4bf%22%7D%7D
```

:::tip
`?theme=default` (or `?theme=reset`) clears a stored theme and returns the UI to stock Yodl. Worth keeping to hand — a theme persists across reloads once applied, so this is how you get back.
:::

## What this covers

| Section | Tokens |
| --- | --- |
| Core colours, per mode | `bg` `surface` `surfaceSecondary` `text` `textSecondary` `textMuted` `border` `accent` `success` `error` `warning` |
| Brand surfaces | `brandSurface` `brandSheet` `brandText` `brandButton` `brandButtonText` |
| Shape and typography | `font` `radiusControl` `radiusSurface` |
| Images, per mode | `payBackground` `loading` `processing` |

The **fine-tune** tokens — `bgDeep`, `surfaceHover`, `skeleton`, `accentStrong`, `accentDeep` — are deliberately not exposed. They derive from the core colours when omitted, which is what makes a short palette come out coherent. Set them by hand in the JSON if you need to.

### Brand surfaces

These carry the most visible parts of the flow, so they are worth setting:

| Token | What it styles | Stock |
| --- | --- | --- |
| `brandSurface` | The **Pay shell** — the payment screen itself | `#5b21b6` |
| `brandSheet` | Sheets and onboarding | `#1c1530` |

They are **mode-invariant**: a value set in one mode mirrors into the other, so the builder offers one shared pair rather than two.

The other three — `brandText`, `brandButton`, `brandButtonText` — **derive from the Pay shell's luminance at runtime**, picking whichever of white or near-black contrasts better against it. That is why the builder leaves them off by default and shows you the derived value: a hand-picked colour can only lower the contrast the runtime would have chosen for you. Tick one to override it anyway.

### Shape and typography

`font`, `radiusControl` and `radiusSurface` are top level rather than per mode, because none is a colour and none varies between light and dark. Leave a field blank to keep the stock value.

`font` picks one of the faces the hosted UI ships: Inter (the stock face), Geist, Geist Mono, or the platform UI font. It is a fixed list rather than a family name you supply, because a font bundled in your app is not visible to the WebView that hosts the Yodl UI — naming it would silently fall back to Inter.

`radiusControl` sets the corner radius of buttons, inputs and badges; stock is a full pill. Cards, rows and sheets follow it in both directions, so one value reshapes the whole UI. Set `radiusSurface` only when containers should differ from the buttons. Both take a plain CSS length such as `0`, `12px` or `1.5rem`.

### Images

Media travels **by reference**, so the theme JSON stays small: you host the asset, the theme carries the URL. Each mode has three slots. `loading` and `processing` are **one visual slot** — the hosted UI has a single splash state, rendered from `loading` with `processing` consulted only when `loading` is empty — so setting `loading` alone covers everything.

A video URL — `.mp4`, `.m4v`, `.webm` or `.mov` — renders as an autoplaying, muted, looping video. **Everything else renders as an `<img>`** — that is the only branch, so any image format the browser decodes works, including animated WebP, APNG, animated AVIF and GIF. The builder tells you which one it detected before you generate the link.

:::tip
For an animated splash with alpha, prefer **animated WebP** (full colour, 8-bit alpha, small files) or **APNG** (equivalent fidelity, usually larger). GIF works, but its 256-colour palette and 1-bit transparency fringe around antialiased edges. Alpha-channel **WebM** is detected and played too.
:::

:::danger
Containers browsers don't decode natively — `.mkv`, `.avi`, `.wmv`, `.flv` — fail quietly: they fall through to `<img>` and render as a broken image with no error. The builder rejects them.

**`.ogv` is rejected too**, even though it plays in Chrome and Firefox. iOS WebViews have no Ogg/Theora decoder, so an Ogg splash that looks right on your desktop would be blank on every iPhone. Re-encode as `.webm` or `.mp4`.
:::

:::warning
URLs must be `https://`, at most 2048 characters, and free of whitespace, quotes and backslashes — that last part is not fussiness, it is what stops a URL escaping the CSS `url("…")` or `<img src>` it gets emitted into.

Note the schema itself only checks the URL *shape*. A `.pdf` or a plain page URL passes validation and then renders as a broken image, which is why the builder checks the extension too.
:::

:::warning
An invalid theme never breaks the UI — it falls back to stock Yodl and logs the reason in development. So if a palette silently doesn't apply, the payload failed validation rather than the link failing to arrive.
:::

## Contrast

The builder flags text-on-background below the WCAG AA threshold of 4.5:1, and a low-contrast accent. It checks the pairs that matter most, not every combination — treat a clean result as "no obvious problem" rather than an audit.
