# Theme reference

> Pages, partials, template variables, helpers, manifest.json options and security settings for custom themes.

Source: https://9in.io/docs/reference/themes/

A theme is a set of Handlebars templates plus `manifest.json`. Templates run in a sandboxed engine: logic-less, whitelisted helpers only, no code execution. For an introduction see [Themes](https://9in.io/docs/themes/).

## Files

| File | |
|---|---|
| `layout` | The page shell. Defines named blocks with `{{#component name}}…{{/component}}` and places them with `{{#render name}}{{/render}}` |
| `login`, `registration`, `otp-options`, `otp-verification`, `password-recovery`, `password-reset`, `authorization`, `account`, `logout`, `error`, `lost`, `redirect`, `device`, `mfa`, `mfa-enroll` | One partial per page. Pages a theme doesn't include come from the default theme |
| Other partials | Any `{{> name}}` you use, e.g. `field-input` |
| `manifest.json` | Options and security settings |

Limits: 60 templates, 256 KB each, 2 MB in total; names use `a-z`, `0-9`, `-`, `_`. Uploaded files (CSS, scripts, fonts, images): 2 MB each, 100 per tenant, referenced as `{{ assets_url }}<file name>`.

## Template data

Available on every page:

| Variable | |
|---|---|
| `$` | Translations of the page's language: `{{ $.login.title }}`. Placeholders: `{{fmt $.login.continue_with provider=name}}` |
| `theme` | The theme's option values: `{{ theme.button_color }}` (images are ready-to-use URLs, or `""`) |
| `lang` | `{ code, name, direction, rtl }` |
| `languages` | `[{ code, name, url, active }]` when the tenant has several |
| `application` | `{ name, logo, alternative_logo, icon, env, sandbox, login_url, logout_url, logout_everywhere_url, password_recovery_url, register_url, policy_url, tos_url, support_url }` |
| `user` | When signed in: `{ id, established, display_name, initial, name, email, picture, profile }`, else null |
| `client` | On the consent page: `{ id, name, icon, tos, privacy_policy }` |
| `page` | `{ name, <name>: true }`, e.g. `{{#if page.login}}` |
| `errors`, `notices` | Arrays of messages to show |
| `csrf` | `{ key, token }`: put both in every form (the default theme's `csrf-fields` component does) |
| `tx` | The sign-in transaction id: keep it in forms |
| `csp_nonce` | Put `nonce="{{ csp_nonce }}"` on every `<script>` |
| `assets_url` | The base URL of uploaded files |
| `json:<name>` | Any object above as script-safe JSON, e.g. `{{{ json:application }}}` |

Page data:

| Page | Variables |
|---|---|
| `login` | `submit_url`, `identifier { label, input_type }`, `login_methods { password, otp }`, `old { identifier }`, `social.providers [{ code, name, color, icon, url }]` |
| `registration` | `submit_url`, `old`, `registration { fields [...], agreement }` |
| `otp-options` | `submit_url`, `otp.options [{ id, type, hint, description }]` |
| `otp-verification` | `submit_url`, `otp { challenge, channel, destination, length, change_option_url }` |
| `password-recovery` | `submit_url`, `identifier`, `old` |
| `password-reset` | `submit_url` |
| `authorization` | `submit_url`, `authorization.scopes [{ name, description }]`, `client` |
| `account` | `account { profile_url, password_url, requires_current_password, fields [...], sessions [{ device, ip, last_seen, current, terminate_url }] }` |
| `device` | `submit_url`, `device { state, user_code }` |
| `mfa` | `submit_url` |
| `mfa-enroll` | `submit_url`, `mfa { state, enroll_token, secret_display, qr, cancel_url }` |
| `error` | `error { status, title, message, code, retry_url }` |

Field objects (`registration.fields`, `account.fields`) have `name`, `label`, `input_type`, `autocomplete`, `value`, `required`, `readonly`, `maxlength` and, for dropdowns, `options`. Keep the form field names and hidden fields of the default theme: the server reads them.

## Helpers

| Helper | |
|---|---|
| `if`, `unless`, `each`, `with` | As in Handlebars; `{{else}}` and `{{else if}}` work |
| `eq`, `ne`, `lt`, `gt`, `and`, `or`, `not` | Comparisons, as subexpressions: `{{#if (eq theme.color_scheme "dark")}}` |
| `default` | `{{default user.name "there"}}` |
| `concat` | Joins values into a string |
| `lookup` | `{{lookup obj key}}` |
| `fmt` | Fills `{name}` placeholders: `{{fmt $.otp.sent_to destination=hint}}` |
| `json` | Script-safe JSON: `{{json $.layout.show_password}}` |
| `log` | Does nothing (kept for compatibility) |
| `option-target` | `{{option-target "logo" name="Logo"}}` inside a tag: marks the element for the console's preview editor; writes nothing on real pages |
| `component`, `render` | Define and place named blocks |
| Partials | `{{> name}}`, `{{> name context}}`, partial blocks and `{{#*inline "name"}}` |

`{{x}}` is HTML-escaped; `{{{x}}}` is raw (only for values you trust, such as `json`). Data lookups see only plain data, never prototypes or functions. Nesting is limited to 64 levels and output to 4 MB.

## manifest.json

```json
{
  "version": 1,
  "name": "Acme",
  "options": {
    "Look": {
      "collapsed": false,
      "properties": {
        "button_color": { "title": "Accent color", "type": "color", "default": "#1a1a1a" },
        "corner_radius": { "title": "Corners", "type": "enum", "enum": { "0": "Square", "10": "Rounded" }, "default": "10" },
        "show_side": { "title": "Show a side panel", "type": "boolean", "default": false },
        "side_tagline": { "title": "Tagline", "type": "string", "default": "", "when": "show_side" },
        "logo_dark": { "title": "Logo on dark pages", "type": "image", "default": "" }
      }
    }
  },
  "security": { "styleSrc": ["https://fonts.googleapis.com"], "fontSrc": ["https://fonts.gstatic.com"] }
}
```

**Options**, grouped as they appear in the console:

| Field | |
|---|---|
| `title`, `description` | Shown in the console |
| `type` | `enum`, `color` (`#rrggbb`), `boolean`, `string`, `number`, `image` |
| `enum` | For `enum`: `{ "value": "Label" }` |
| `default` | Used until the tenant changes it |
| `overrides` | For `enum`: other option values set when a value is chosen, e.g. a dark scheme setting dark colors |
| `when` | Show only while another option is on (`"show_side"`) or has a value (`"color_scheme=custom"`) |
| `target` | CSS selector of the elements it styles (for the preview editor) |
| `css_var`, `unit` | The CSS custom property it sets, for instant preview updates |
| `preview` | `{ "page": "account", "device": "mobile" }`: where the option shows |

Option keys use `a-z`, `0-9` and `_`. `"true"`/`"false"` enum values become booleans in templates.

**Security** (`security`): the outside origins the theme may load, added to the page's Content Security Policy. Lists of `https://` origins: `scriptSrc`, `styleSrc`, `imgSrc` (also `data:`), `fontSrc` (also `data:`), `connectSrc`, `frameSrc`. `allowInlineScripts: true` allows inline scripts without the nonce, for themes ported as they are from SSOfy; prefer nonces.

JSON Schema: [/schemas/theme-manifest.json](https://9in.io/schemas/theme-manifest.json).

## Validation and fallbacks

Uploads are validated: syntax, unknown helpers, missing partials, the manifest, and `option-target` names. If a custom theme fails while rendering a page anyway, 9in renders that page with the default theme, so a broken theme can never lock users out, and the console shows an alert.
