# Writing a Sandra theme

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. Nothing in a theme ever runs as code (D33), so there is
nothing to compile: you write files, zip the folder, and upload it.

## 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/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 |
| 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 console, 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).

`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.
- **`_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.

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` — already base-path aware |
| `entries.*` | published entries, by collection key **and** by profile |
| `appearance.*` | palette roles, fonts, type scale |

**Never hand-write a leading slash.** Use `{{ item.url }}` and `{{ site.home }}`:
a published bundle has to work unzipped in a subfolder as well as at a domain
root (D20).

## 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` and `active`, 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.

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`, `.woff`, `.woff2`, `.ttf`, `.otf`, `.svg`, `.png`, `.jpg`,
`.jpeg`, `.webp`, `.gif`, `.ico`. Nothing executable, and nothing is served from
the docroot — assets are streamed by the engine with an explicit content type.

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

**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 console, 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

- **`plus`, `minus` and friends refuse strings.** Unlike Shopify's Liquid, they
  raise rather than coerce, and a template error renders as *nothing*. Use a
  `number` field, which arrives as a real number.
- **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.
