# Writing a Sandra theme

*Putting content into a site is a different job — see
[The content API](/docs/api).*

A theme decides what a site can be made of: which blocks exist, what fields they
take, how they render, and how one entry looks as its own page.

**There is no build step.** A theme is **declarative data** — JSON and Liquid,
validated on install, so there is nothing to compile: you write files, zip the
folder, and upload it.

**Nothing in a theme ever runs on the server** (D33). A theme may ship one
browser script, and that runs where every other script does — in the reader's
browser, and in a preview inside a sandbox. Its Liquid never executes; it is
rendered by a sandboxed engine that can only emit values already escaped.

## Start from the example

Rather than a blank folder, start from **Starter** — a complete, deliberately
minimal theme: five block types, one entry template, one declared collection,
and a stylesheet. Every file is commented with what it is demonstrating.

```bash
curl -O https://sandracms.com/starter-theme.zip
```

It is the shortest useful answer to "what does a theme look like". Rename the
keys, replace the CSS, and you have your own. What it covers:

| File | Shows |
|---|---|
| `theme.json` | palette roles, a declared collection |
| `blocks/starter_menu` | a **slot** block, and `nav.items` |
| `blocks/starter_hero` | escaping, `\| rich`, guarding unset fields |
| `blocks/starter_text` | a **repeater**, and why it is not an entry |
| `blocks/starter_posts` | listing a **collection**, and the `entries` drop |
| `blocks/starter_footer` | `site.fields`, the custom-field bag |
| `entries/blog.liquid` | one entry as its own page |
| `assets/theme.css` | the `--c-{role}` palette contract |

Starter is also the only theme source you can read: **Sandra Theme** ships
built-in and is not downloadable.

```
theme.json
blocks/{key}/schema.json
blocks/{key}/template.liquid
entries/{profile}.liquid
assets/theme.css              ← your stylesheet
assets/theme.js               ← optional, and the only script (see JavaScript)
assets/fonts/…, assets/img/…  ← anything it references
```

Zip the *contents* of that folder, not the folder itself — though one wrapping
directory is tolerated, since that is what most tools produce.

**Limits**, all refused at validation rather than silently truncated:

| | |
|---|---|
| Package size | 8 MB, compressed *and* expanded |
| Files in the package | 400 |
| Block types | 200 |
| `assets/theme.js` | 48 KB, and it is the only `.js` a package may contain |
| Media upload (separate, per file) | 24 MB |

Anything outside the four path shapes above is ignored rather than rejected, so
a stray `.DS_Store` will not fail an install.

## Check it before you install it

Writing a theme is iterative, and a half-installed theme is worse than none. So
ask first, as many times as you like:

```
POST /v1/themes/validate      multipart `theme`, or JSON {"content_base64": "…"}
```

It reports **every** problem at once — field-addressable, the same shape as any
other validation error — and installs nothing:

```json
{
  "ok": false,
  "errors": [
    { "field": "blocks.masthead.template",
      "message": "This template did not parse: Variable was not properly terminated with: }}" },
    { "field": "blocks.footer.fields.note.type",
      "message": "'php' is not a field type. See /v1/capabilities." }
  ]
}
```

When it says `ok`, `POST /v1/themes` with the same body installs it. Or upload
the zip in the Studio, which does both.

> Working inside the engine's own repo? `bin/php bin/build-theme.php <directory>`
> packages a folder and runs these same checks locally before writing the zip.
> It is a convenience, not a requirement — the API above is the real contract.

## theme.json

```json
{
  "key": "plainly",
  "version": "1.0.0",
  "name": "Plainly",
  "palette":     { "ink": { "label": "Ink", "default": "#141414" } },
  "collections": { "blog": { "label": "Writing", "profile": "blog" } }
}
```

`key` is lowercase letters, numbers and underscores. `version` is three numbers.
A new version is a new install, never an edit in place, so a site authored
against 1.0 can still be explained after 1.1 exists (D16).

### Shipping an update

You cannot publish. Installing a theme puts it on the server; it does not put it
on anybody's live site, and it does not move a site onto it — a site points at
one specific version and stays there until its owner says otherwise. So the last
step of shipping an update is always somebody else's.

What you control is whether they can see there is one to take:

- **Bump the version.** The owner's Studio then says the new version is ready,
  shows what changes, and offers a one-press update. This is the path to use for
  anything you want someone to actually adopt.
- **Re-upload the same version** and the templates are replaced where they
  stand. Sites already on that version change immediately, with no decision to
  make — which is what you want while you are still building, and not what you
  want once somebody is relying on it. Their *published* site keeps the old HTML
  until they publish again; the Studio tells them so, but it is a nudge, not
  a version they chose.

Either way the site owner has to press Publish for anything to reach the live
site. Say so when you hand a theme over — it is the step people forget.

`collections` **declares** what the theme expects. Declaration is not creation:
the owner accepts it, and the field set is copied into their site. After that the
fields are theirs, and no theme upgrade or switch can reshape them (D36).

### `profile` and `role` are different words for different things

This trips people up, so it is worth being blunt about:

| | What it is | Vocabulary | Example |
|---|---|---|---|
| **Block `role`** | what a *block* is for | `SCHEMA_ROLES`, 18 of them | `hero`, `navigation`, `entry_list` |
| **Collection `profile`** | which shape a *collection* follows | `COLLECTION_PROFILES` | `blog` |

They never overlap. `entry_list` is a block that shows a list of entries;
`blog` is what a collection of posts *is*. An entry template is named after the
**profile** — `entries/blog.liquid` — never after a block role.

## Block types

`blocks/{key}/schema.json` is the same format the engine already uses:

```json
{
  "label": "Opening",
  "category": "hero",
  "slot": "hero",
  "role": "hero",
  "fields": {
    "heading": { "type": "text", "label": "Heading", "maxLength": 120 },
    "body":    { "type": "richtext", "label": "Body" },
    "_bg":     { "type": "palette_colour", "label": "Background", "group": "Appearance" }
  }
}
```

- **`slot`** is `menu`, `hero`, `footer`, or absent for page content — see
  *Slot blocks* below.
- **`role`** is what the block is *for* — a **block** role, not a collection
  profile. Names are yours — `pbr_masthead` is
  fine — but the role is how anything else finds it: a theme switch, an agent
  asking for "a call to action", a page preset. See `/v1/capabilities` for the
  vocabulary. It is closed: a typo is refused rather than accepted and unmatched.
  Omit it if nothing here fits — an honest `null` beats a role that will be
  mapped onto wrongly.

| Role | What it is for |
|---|---|
| `navigation` | Site navigation |
| `hero` | Opening statement at the top of a page |
| `page_title` | A page heading, where a full hero would be too much |
| `footer` | Site footer |
| `text` | Prose, with or without an image |
| `cta` | A call to action |
| `feature_list` | Several short selling points |
| `gallery` | Images shown together |
| `faq` | Questions and answers |
| `team` | People |
| `testimonial` | A quote or endorsement |
| `pricing` | What things cost |
| `contact` | How to make contact |
| `hours` | When you are open |
| `product_list` | Several products or menu items |
| `product_detail` | One product in full |
| `entry_list` | A list of entries from a collection |
| `entry_detail` | One entry rendered in full |

`entry_list` and `entry_detail` are for blocks that show entries from a
collection. They are block roles, not collection profiles — see below.

- **`_bg`** and `_accent` are the engine's appearance fields. Include `_bg` unless
  the block genuinely cannot be tinted.

**You cannot invent a field type.** The vocabulary is `text`, `textarea`,
`richtext`, `lines`, `link`, `image`, `select`, `toggle`, `repeater`,
`palette_colour`, `date`, `number`, `collection`. This is where editorial control
lives: every field a theme uses is validated and escaped by the engine, whoever
wrote the theme (D30).

## Templates

Liquid, in a sandbox with no filesystem and no arbitrary method calls.

```liquid
<header class="pl-opening"{% if block.appearance != '' %} style="{{ block.appearance }}"{% endif %}>
  <div class="pl-inner">
    <h1>{{ block.heading }}</h1>
    {%- if block.body != '' %}<div class="pl-rich">{{ block.body | rich }}</div>{% endif %}
  </div>
</header>
```

Every value arrives **already escaped**. `{{ block.heading }}` is safe by
construction. `| rich` is the only raw output path, and it works on `richtext`
fields only — applying it to a plain field throws rather than trusting you.

### What survives a richtext write

`| rich` is the read side. The **write** side sanitises, and it matters most when
importing existing HTML, so here is exactly what happens:

| | |
|---|---|
| **Kept** | `p` `br` `div` `h2` `h3` `strong` `b` `em` `i` `u` `a` `blockquote` `ul` `ol` `li` `pre` `code` `img` |
| **Removed entirely** | `script`, `style`, `template` — contents and all, because their contents are code, not writing |
| **Unwrapped** | anything else — `figure`, `table` and the rest lose the tag, and their text is kept |
| **Attributes** | `href` on `a`, `src` and `alt` on `img`. Everything else goes, on every tag — `class`, `style`, `width`, and every `on*` handler |
| **Rewritten** | every `<a>` gains `rel="noopener nofollow"`; entities normalise to characters, so `&mdash;` is stored as `—` |

Unwrapping rather than deleting is deliberate: losing a tag costs you formatting,
losing the text costs you the content. Import something with tables and you get
the words, not an empty gap — so check the result rather than assuming it round
tripped.

`script`, `style` and `template` are the exception, because the same reasoning
inverts: their contents are not writing. Keeping the text of a tracking snippet
put `trackEvent('pageview');` on the page as a paragraph — inert, and obviously
not what anyone meant.

`img` is the exception to that reasoning, which is why it is allowed. An image
has no inner text, so unwrapping it left nothing at all — a migration lost its
pictures with no trace in the output. `src` is held to the same rule as `href`:
`http`, `https`, `mailto`, `tel` or a relative path. An image whose `src` does
not pass is **removed entirely** rather than left as an empty box.

**A media token works as a `src`.** Put the token `add_image` gave you straight
into the markup and the engine resolves it, the same way it resolves an `image`
field — pointing at the engine in a preview and inside the bundle at publish,
at the right depth for the page it is on:

```html
<img src="m_8bfd0624fa74" alt="The rendered output">
```

Do not hard-code `assets/media/…`. That is the bundle's internal layout and it
is not a theme's to know; it also bakes in how deep the page happens to sit, so
one nesting change breaks every inline image at once.

Put `{{ block.appearance }}` on the root element, or the block cannot be tinted.

What a template can see:

| Drop | What it holds |
|---|---|
| `block.*` | this block's fields, plus `appearance` and `type` |
| `site.*` | `name`, `home`, `fields.*` (custom fields) |
| `page.*` | `title`, `slug`, `fields.*` |
| `nav.items` | `label`, `url`, `active`, `children` — already base-path aware |
| `entries.*` | published entries, by collection key **and** by profile — in an entry template too, so an article can list its siblings |
| `appearance.*` | palette roles, fonts, type scale |

**Never hand-write a leading slash, and never build a URL from a slug.** Use
`{{ item.url }}`, `{{ site.home }}` and `{{ entry.url }}` as they come.

They are not the same string on every surface, and they are not meant to be. A
published bundle has to work unzipped in a subfolder as well as at a domain root,
so its links are relative — `about.html`, `services/design.html`. A preview is
not a file tree: its pages are served by a preview route, so the same links come
back with that route in front of them. The **path is identical either way**; only
the base changes, and the base is the engine's business.

So do not compare a URL against a literal, strip a prefix off one, or append to
one. If you find yourself needing to, say what you actually want — `item.active`
already tells you which page is the current one.

**A `link` field is resolved too, and always was meant to be.** Whatever an
editor types into one — `get-in-touch.html` — reaches your template already
pointing at the right place from the page being rendered, exactly like
`item.url`. Output it directly:

```liquid
<a href="{{ block.cta_url }}">{{ block.cta_text }}</a>
```

> **If your theme prefixes link fields by hand, remove that now.** Building a
> base out of `site.home` and appending it to a link field was a real workaround
> for a real bug, and it will now produce `../../get-in-touch.html` where it
> used to produce `../get-in-touch.html`. Only a link field is resolved — a
> `text` field that happens to hold something path-shaped is still just text.

Left alone, because they already say where they point: a full URL, `mailto:`,
`tel:`, a protocol-relative `//host`, anything starting `/`, and an anchor or a
bare query, which are deliberately relative to the page they sit on.

## Slot blocks

A slot block is an **ordinary block**. It gets the same drops, renders through
the same pipeline, and there is no special mechanism to learn. The slot decides
only two things:

1. **Where it lands.** `menu` renders above the page's content, `hero` next,
   `footer` last. Body blocks sit between hero and footer, in their own order.
2. **That it can be shared.** A slot block chosen for the *site* is stored once
   and rendered on every page, so a header is edited in one place. A page may
   override its own slot, or hide it.

So a menu block is written exactly like any other:

```liquid
<nav class="pl-masthead"{% if block.appearance != '' %} style="{{ block.appearance }}"{% endif %}>
  <a href="{{ site.home }}">{{ block.brand }}</a>
  <ul>
    {%- for item in nav.items %}
      <li><a href="{{ item.url }}"{% if item.active %} aria-current="page"{% endif %}>{{ item.label }}</a></li>
    {%- endfor %}
  </ul>
</nav>
```

`nav.items` is the page list, ready to render: `label`, `url`, `active` and
`children`, with URLs already base-path aware. It is available to **every**
block, not just menus — a footer with a sitemap column reads the same drop.

**Pages go two levels deep.** Every item has `children`, always an array, so a
template can loop without a guard — and one nested loop is the whole of it, never
recursion:

```liquid
{%- for item in nav.items %}
  <li>
    <a href="{{ item.url }}"{% if item.active %} aria-current="page"{% endif %}>{{ item.label }}</a>
    {%- if item.children.size > 0 %}
      <ul>
        {%- for child in item.children %}
          <li><a href="{{ child.url }}">{{ child.label }}</a></li>
        {%- endfor %}
      </ul>
    {%- endif %}
  </li>
{%- endfor %}
```

A parent is `active` when **it or anything inside it** is the current page, so a
section stays lit while a reader is on one of its pages. A child publishes to
`{parent}/{child}.html`, and its links climb out on their own — never write a
leading slash.

A dropdown is best left to CSS even now that a theme may ship a script — it
works before the script loads, and for anyone whose script never arrives:
`:hover` and
`:focus-within` to open it, and on narrow screens show it indented instead. A
dropdown that needs a pointer does not exist on a phone. Starter's menu does
exactly this.

A theme needs at least one block per slot it expects to fill, and each must
declare the matching `slot`. A block with `"slot": "menu"` can only be chosen for
the menu; a block with no slot can only go in the body.

## Styling

`assets/theme.css` is linked on every page of a site using your theme. Put your
whole stylesheet there — converting an existing site usually means pasting it in
almost unchanged.

Anything it references goes beside it:

```
assets/theme.css
assets/fonts/inter.woff2      →  url('fonts/inter.woff2')
assets/img/texture.png        →  url('img/texture.png')
```

Reference them **relatively from the stylesheet**. A published bundle copies the
whole `assets/` tree in beside your CSS, so relative URLs keep working unzipped
anywhere; an absolute path would break the moment the site lived in a subfolder.

Allowed: `.css`, `.js`, `.woff`, `.woff2`, `.ttf`, `.otf`, `.svg`, `.png`,
`.jpg`, `.jpeg`, `.webp`, `.gif`, `.ico`. Nothing is served from the docroot —
assets are streamed by the engine with an explicit content type and `nosniff`.

### JavaScript

A theme may ship **one script, at `assets/theme.js`**. The engine links it with
`defer`, so it runs against a finished document, and it travels into the
published bundle beside the stylesheet.

```
assets/theme.css
assets/theme.js     ← one file, this name
```

**Write it as enhancement, not as the page.** The bundle is a standalone site on
somebody else's host: if the script fails there, nothing of ours can intervene.
So build markup that already works, and let the script improve it.

A collapsing menu is the worked example, because it is the thing most themes
want first. The template renders a button and the links:

```liquid
<nav class="t-menu">
  <ul class="t-menu__links">
    {%- for item in nav.items %}
      <li><a href="{{ item.url }}">{{ item.label }}</a></li>
    {%- endfor %}
  </ul>
  <button type="button" class="t-menu__toggle" data-menu-toggle aria-label="Menu">&#9776;</button>
</nav>
```

The stylesheet hides the button wherever the links fit, and hides the links only
where they do not — so a page with no script still shows every link at the width
where there is room for them:

```css
.t-menu__toggle { display: none; }

@media (max-width: 720px) {
  .t-menu__toggle { display: inline-flex; }
  .t-menu__links  { display: none; }
  .t-menu.is-open .t-menu__links { display: flex; flex-direction: column; }
}
```

And `assets/theme.js` gives the button its behaviour:

```js
document.querySelectorAll('[data-menu-toggle]').forEach(function (button) {
  var nav = button.closest('.t-menu');

  // Set here, not in the template: there is no disclosure to announce until
  // this file has run.
  button.setAttribute('aria-expanded', 'false');

  button.addEventListener('click', function () {
    var open = !nav.classList.contains('is-open');
    nav.classList.toggle('is-open', open);
    button.setAttribute('aria-expanded', open ? 'true' : 'false');
  });

  // Escape closes it and puts the caret back where a keyboard reader left it.
  nav.addEventListener('keydown', function (e) {
    if (e.key === 'Escape' && nav.classList.contains('is-open')) {
      nav.classList.remove('is-open');
      button.setAttribute('aria-expanded', 'false');
      button.focus();
    }
  });
});
```

Note what the script does **not** do: it does not build the menu, fetch it, or
decide what is in it. The document is complete before it runs. That is the whole
contract, and it is what makes a script that fails to load a cosmetic problem
rather than a site with no navigation.

Four rules, refused at install rather than discovered later:

| | |
|---|---|
| **One file** | `assets/theme.js`. Bundle anything else into it. |
| **No external hosts** | No CDN `import`, no `fetch` to another origin, no injected `<script src="https://…">`. Your own files are fine. A bundle that phones out is not standalone. |
| **48KB** | A ceiling, not a target. Enough for a lot of hand-written behaviour; not enough for a framework. |
| **No `document.write`** | It runs deferred, so `document.write` would replace the finished page. |

**In a preview your script runs sandboxed.** A preview is served from the same
origin as the Studio, so the engine sends
`Content-Security-Policy: sandbox`: an opaque origin, where `document.cookie`
and `localStorage` throw and a credentialed request to the API is refused.
Stylesheets, fonts and images load normally. Nothing you would write for a
static site notices — it is there so that installing a theme can never mean
handing someone your session.

> **One place your script does not run: the Studio's editing canvas.** That view
> stays same-origin so the Studio can attach its editing chrome, which the
> sandbox would forbid — so it carries no theme script. Use **Preview ↗** to
> check behaviour. Preview and the published bundle stay identical; this is a
> difference between two views inside the Studio, not between preview and
> publish.

### Colours are yours, and so are the custom properties

> **Palette roles are yours to name.** `/v1/capabilities` reports
> `default_theme.palette` as `primary/accent/dark/light/white` — those are the
> *built-in theme's* names, not a fixed vocabulary. Your theme declares whatever
> roles suit it: `ink`, `paper`, `wash`, `brand`, as many or as few as you like.
> For the roles a particular site can use, ask
> `GET /v1/content-types?site={site}` and read `theme.palette`.


**The engine emits no CSS of its own.** There is no site-wide colour picker: how
a site looks is a property of the theme (D43). A theme built for one client can
simply be opinionated, and one that wants options can offer them later.

That makes the custom properties a **contract between your `theme.json` and your
stylesheet**. Declare palette roles:

```json
"palette": {
  "primary": { "label": "Primary", "default": "#14503A" },
  "accent":  { "label": "Accent",  "default": "#C6F24E" }
}
```

…then define the matching pair for each in your CSS:

```css
:root {
  --c-primary: #14503A;  --c-on-primary: #FFFFFF;   /* background, and ink on it */
  --c-accent:  #C6F24E;  --c-on-accent:  #16240F;
}
```

Those role names become the options in each block's **Background** control, and
`{{ block.appearance }}` resolves to `background: var(--c-primary)` and friends.
**Declare no palette roles and the control disappears**, which is what a bespoke
theme wants.

The full set `block.appearance` can reference:

| Property | Meaning |
|---|---|
| `--c-{role}` | the background for that palette role |
| `--c-on-{role}` | readable ink on that background |
| `--colour-bg`, `--colour-text` | set *by* `block.appearance` on a tinted block — read them inside it |
| `--colour-primary`, `--c-on-primary` | set by `_accent`, for buttons and links within a block |

Everything else — fonts, type scale, spacing — is ordinary CSS, unless you
choose to offer it as a setting.

### Offering settings

A theme built for one client should be opinionated: declare nothing, put your
values in your stylesheet, done. Nobody is handed a colour picker that changes
a design you were paid to decide.

A theme meant to build **many** sites is the opposite case — if its colours live
only in CSS, every site made with it looks the same. So a theme can **declare**
settings, and each site using it answers for itself:

```json
"settings": {
  "brand":     { "type": "colour", "label": "Brand colour",
                 "group": "Palette", "default": "#14503A", "css": "--c-brand" },
  "on_brand":  { "type": "colour", "label": "Text on brand",
                 "group": "Palette", "default": "#FFFFFF", "css": "--c-on-brand" },
  "font_body": { "type": "select", "label": "Body font", "group": "Fonts",
                 "options": { "Georgia, serif": "Georgia", "system-ui": "System" },
                 "default": "system-ui", "css": "--font-body" },
  "base_size": { "type": "number", "label": "Body size", "group": "Text",
                 "min": 12, "max": 24, "default": "17",
                 "css": "--fs-base", "css_unit": "px" }
}
```

- Fields use the **same vocabulary** as block fields, so the editor that builds
  block forms builds this one, and the validator that checks block values checks
  these. `colour` is a real colour; `palette_colour` picks one of your roles.
- **`css`** names the custom property the answer sets. Omit it and the setting
  is still a setting — you can read it in a template — but emits no CSS.
- **`group`** groups the controls on screen, in the order you declare them.
- **`default`** is used until somebody answers. Define the same properties in
  your stylesheet too, so a site that has answered nothing still renders.

The engine emits **only** what you declared, and nothing at all if you declared
nothing. Sandra Theme — the built-in one — declares its palette, fonts and type
sizes, which is what makes it a site *builder* rather than a single design.

A theme with no `assets/theme.css` is refused at install — an unstyled site is a
confusing way to discover a missing file. If you genuinely want no styles, ship
an empty one.

## Entry templates

`entries/{profile}.liquid` renders one entry as its own page — so a blog is
`entries/blog.liquid`.

Named after the **collection profile**, not the collection key, because a theme
cannot know that a site called its blog `journal`. And not after a block role:
`entry_detail` is a block that shows an entry *inside a page*, and is never a
filename here.

The profiles you can write a template for are listed at
`GET /v1/collection-profiles`. There is one at launch, `blog`, with core fields
`title`, `excerpt`, `body`, `published_at` and `image`.

```liquid
<article class="pl-entry">
  <h1>{{ entry.title }}</h1>
  {%- if entry.published_at != '' %}
    <time datetime="{{ entry.published_at }}">{{ entry.published_at | date_long }}</time>
  {% endif %}
  {%- if entry.body != '' %}<div class="pl-rich">{{ entry.body | rich }}</div>{% endif %}
</article>
```

The engine wraps this in the site's header, footer and document shell, so an
entry page cannot drift into being a page from a different website.

A collection whose profile the theme has no template for produces **no** entry
pages, and the publish says so rather than shipping empty documents.

## Installing, and what a switch costs

Upload the zip in the Studio, or `POST /v1/themes`. A theme you install is
**yours**: it appears on your sites and nobody else's.

Before switching, the picker reports what it would cost — which of your blocks
have no counterpart, and which collections would produce no entry pages. You may
switch anyway.

**Nothing is ever deleted.** A block whose type the new theme lacks renders
through whatever fills its role; if nothing does, it renders as nothing. Its
values are untouched, so switching back restores everything. A missing render is
blank, never broken.

That is why roles matter more than they look: they are what lets a second theme
render content the first one created.

## Gotchas worth knowing

- **Every field value arrives as a string, including `number`.** Arithmetic and
  comparison still work — `{{ n | plus: 1 }}` and `{% if n > 10 %}` behave the
  same whether the value is `"50"` or `50` — so the uniformity costs nothing and
  means a decimal keeps the trailing zero you typed. This page used to claim
  numbers arrived as real numbers and that the filters refused strings. Neither
  was true, and it sent at least one theme author looking for a bug that was not
  there.
- **A number field left blank arrives as `''`**, not `0`, so `{% if x != '' %}`
  still means "was it set".
- **`| url_param`, not `| url_encode`.** Values are escaped, and `url_encode`
  would encode the entities.
- **Field order is the order you write it.** It is the order of the generated
  editor, so put the important fields first.
