<!-- demo:embed example="basic/3-custom-theme" -->

You shipped a sample inside the `default` theme in [Create a sample](/docs/06-create-sample/). Sometimes one sample isn't enough — your brand needs its own logo, its own primary colour, its own headline typography. This doc walks you through creating a brand-owned theme dir end-to-end. **Time budget: 60 minutes.**

Two paths from here, picked by ambition:

1. **Fork the default theme** (recommended for your first theme) — copy `themes/default/`, rename, swap the brand assets. You inherit 30+ tuned templates and 50+ samples.
2. **Author from scratch** (only when you need a radically different rendered HTML shape) — start from the shipped `themes/example-mini/` blank-slate. Tiny surface; you write each template yourself.

This doc walks **path 1** in lock-step (copy, swap, register). Path 2 is documented at the bottom for buyers whose brand needs render-shape ownership.

## Step 1 — Pre-flight

Confirm the dev server boots and the gallery shows 43 cards (or 44 if you completed the previous doc):

<!-- buyer:try -->
```bash
php -S localhost:8000 -t demo/
```

<!-- buyer:verify -->
- <http://localhost:8000/gallery.php> renders without errors.
- <http://localhost:8000/builder.php> renders the Minimal sample.

## Step 2 — Pick brand identity

Decide three things — write them on paper:

1. **Theme dir name** — folder under `demo/themes/<name>/`. Use `kebab-case` so it matches the shipped naming (`example-mini`). For this walkthrough: `acme-brand`.
2. **Brand title** — display label in the gallery + theme picker. For this walkthrough: `"Acme Brand"`.
3. **Brand colour palette** — primary, primary foreground, accent (3 hex values). For this walkthrough: `#5b21b6` purple, `#ffffff` white, `#a855f7` lighter purple.

Keep within the 4-colour cap (RULE-H8). Two greyscale tones (off-white + dark grey) plus your primary + accent puts you at 4 — exactly the limit.

## Step 3 — Fork the default theme

Copy the dir at the filesystem level:

<!-- buyer:try -->
```bash
cp -r demo/themes/default demo/themes/acme-brand
```

That's ~50 MB (the shipped imagery is most of it). The copy preserves every template, sample, and asset.

<!-- buyer:verify -->
```bash
ls demo/themes/acme-brand/
# index.json
# *.template.html
# master/
```

You now have a complete theme dir under your brand name. Every sample in it works without any further change — but the gallery doesn't know it exists. Next step.

## Step 4 — Edit the manifest

Open `demo/themes/acme-brand/index.json`. Three fields change:

<!-- buyer:try -->
```json
{
    "name": "acme-brand",
    "title": "Acme Brand",
    "description": "Acme's branded email + page library",
    "_brand": {
        "primary":    "#5b21b6",
        "primary_fg": "#ffffff",
        "accent":     "#a855f7"
    },
    "pages": ["Page", "Form"],
    "templates": ["Block", "Grid", "Cell", "P", "Heading", "Image", "Button", "..."],
    "configData": { "..." },
    "formats": { "..." }
}
```

Keep the existing `templates` + `pages` + `configData` + `formats` arrays — your fork inherits everything. The three you change:

- `name` — must match the dir name.
- `title` — buyer-facing label.
- `description` — gallery card subtitle.

Plus add the optional `_brand` block with your three colours. The shipped templates don't read this block (they read `formats.*` overrides per-page); it's a buyer-facing convention so future you remembers what the palette is.

## Step 5 — Swap brand-critical assets

Three assets typically change between brands:

<!-- buyer:try -->
1. **Logo** — replace `demo/themes/acme-brand/master/assets/image/email/logo.png` with your brand's logo PNG. Same filename so JSON pointers don't change. Recommended: 260×80 px source for retina-clean rendering.
2. **Favicon** — replace any `favicon.svg` or `favicon.png` referenced by templates.
3. **Brand glyphs** — if your shipped samples reference any social-icon or logo sprite, drop your replacements in the same paths.

The shipped imagery library has ~150 photos + illustrations. You can keep most of them — they're royalty-free and brand-agnostic. Only swap what's actually brand-bearing.

## Step 6 — Edit the brand-critical templates

Open `demo/themes/acme-brand/Page.template.html`. The shipped Page template emits a full `<html><head><style>…</style></head><body>` shell + a single `<%- page %>` slot for the rendered content tree. Most brand changes hit:

- The `<body>` `font-family` (defaults to Arial; override via the manifest's `formats.font_family`)
- The container width via `container_width` (`narrow` 600 px / `medium` 800 px / `wide` 1024 px / `xl` 1200 px / `full`)
- The default-block background via `default_block_background_color` in the manifest's `configData`
- Optional brand strip at the top (a 4 px colour bar above the page content — add a top-of-`<body>` `<div>`)

For this walkthrough, leave Page unchanged — the default looks fine for Acme.

Now the heading templates — one per level (`H1.template.html`, `H2.template.html`, …, `H6.template.html`). Each renders `<h1 style="<%- formatter.toStyleStringAll() %>"><%- text %></h1>` and reads its CSS from the formats your `getControls()` writes. Brand typography typically lives in the `Page.template.html`'s `formats` block (font-family) — the heading templates inherit it.

`Button.template.html` is where your brand most often shows up — primary CTA shape, hover state, primary colour. Open it and confirm `formats.background_color` flows through to the rendered `<a>` element. Yes — it does. So your brand colour applies via per-element overrides; you don't need to edit the template.

This matters: most theme forks leave templates **untouched** and rely on `formats.*` overrides. You only edit a template when you want a structural change the shipped formats can't express (e.g. a Heading wrapped in a custom `<div class="brand-headline">`).

## Step 7 — Register the theme

Two paths to register; pick one.

**Option A — Edit `ThemeRegistry::SEED`** (matches the way the shipped themes ship):

<!-- buyer:try -->
Open `demo/backend/_lib/ThemeRegistry.php`, find `private const SEED`, append a new bucket:

```php
'standard' => [
    /* …existing rows… */
    ['dir' => 'acme-brand', 'name' => 'Welcome', 'sample' => 'master/sample/email/WelcomeCustomers'],
    ['dir' => 'acme-brand', 'name' => 'Newsletter', 'sample' => 'master/sample/email/Editorial_Newsletter'],
],
```

Each row points at one sample inside the `acme-brand` dir (which inherits all the shipped sample files because of the dir copy in Step 3). You're effectively re-publishing the shipped samples under your brand name.

**Option B — Add a `registry.json` inside the theme dir** (preferred for hand-off cleanliness):

<!-- buyer:try -->
Create `demo/themes/acme-brand/registry.json`:

```json
{
    "rows": [
        { "name": "Welcome",    "sample": "master/sample/email/WelcomeCustomers" },
        { "name": "Newsletter", "sample": "master/sample/email/Editorial_Newsletter" }
    ]
}
```

The `ThemeRegistry::loadAll()` discovery loop reads `registry.json` from every theme dir on first request. Option B keeps your theme-specific config out of the shared `ThemeRegistry::SEED` constant, so future merges don't conflict.

## Step 8 — Verify the theme shows up

<!-- buyer:try -->
Reload the gallery: <http://localhost:8000/gallery.php>.

<!-- buyer:verify -->
- The "Standard email" / "Rich content" / "Page templates" sections now include your `acme-brand` rows.
- Click any of them. The builder opens with your sample loaded against your theme.
- The page-level background, font, and button colours should pick up from the formats / brand block you edited.

Open the new card; in DevTools, confirm `THEME_CONFIG_DATA._brand.primary === '#5b21b6'` to verify the manifest threaded through.

## Step 9 — Set the buyer-default theme

When `demo/builder.php` boots without a `?theme=` query, it loads the default theme. To make `acme-brand` the *default* default:

<!-- buyer:try -->
Open `demo/builder.php`, find the `$builderConfig` block at the top, and change:

```php
'defaultTheme' => 'default',
```

to:

```php
'defaultTheme' => 'acme-brand',
```

<!-- buyer:verify -->
Reload <http://localhost:8000/builder.php> (no query string). The Minimal sample now renders against your branded theme.

## Step 10 — Smoke E2E

Run the master-samples-smoke spec — every sample in your new theme is auto-discovered:

<!-- buyer:try -->
```bash
npx playwright test e2e/master-samples-smoke.spec.js -g acme-brand
```

<!-- buyer:verify -->
- All samples in `acme-brand` pass the boot test.
- If a sample 404s on render, check that step 5's logo replacement didn't break paths.

## Author from scratch (path 2)

If your brand requires fundamentally different rendered HTML — a wholly custom `Page` shape, a non-table-based email layout, or a templating language other than EJS-via-the-engine — you author from scratch starting from `themes/example-mini/`.

Read its layout: 4 files (`index.json`, `Block.template.html`, `Page.template.html`, `master/sample/email/...`). That's the legal minimum: one page wrapper, one block wrapper, one sample. Add element templates as you need them (Heading, Image, Button, …); each one you don't add narrows what samples can use the theme.

This path is **rare**. Most teams that think they need it actually need a fork. Start with fork; switch to scratch only after you've felt the pain of editing 30+ templates' worth of inheritance.

## Did you make it?

You should now have:

- ✅ A `demo/themes/acme-brand/` directory with the shipped templates + samples + your brand manifest.
- ✅ Logos / brand assets swapped at the same paths as the original.
- ✅ A `registry.json` (or new SEED rows) registering your samples in the gallery.
- ✅ Your theme appearing as cards in `/gallery.php` and clicking through to the builder.
- ✅ All shipped samples in your theme passing the smoke E2E.

If a step didn't land:

- 🩹 *Theme not in gallery* → check `ThemeRegistry::loadAll()` log. The most common cause is a JSON parse error in `registry.json`. Run `php -r "json_decode(file_get_contents('demo/themes/acme-brand/registry.json')); echo json_last_error_msg();"`.
- 🩹 *Logo doesn't update* → confirm the new file is exactly at `demo/themes/acme-brand/master/assets/image/email/logo.png`. JSON pointers are case-sensitive.
- 🩹 *Built-in samples render but the brand colour doesn't apply* → the shipped JSON has hardcoded colour values per element. Either edit the JSON to reference your `_brand.primary`, or accept that brand colour is per-sample, not per-theme. Most projects bake brand colour into the JSON of each sample.

What's next?

- → [Layout best practice](/docs/08-layout-best-practice/) — the rules every theme must respect, distilled.
- 🔗 [`examples/basic/3-custom-theme/`](/examples/basic/3-custom-theme/) — a runnable end-to-end demo of a host page that loads a custom theme via the `?theme=<dir>` URL.
- 🔗 `themes/example-mini/README.md` — the author-from-scratch starter kit.

Stuck?

- 🐛 grep `themes/example-mini/` for the absolute minimum required theme.
- 🐛 if your fork breaks one of the shipped samples (template error after editing), revert that template — the engine logs which template threw at the failing element.
- 🐛 file an issue with: your `index.json`, your `Page.template.html` if customised, error from `npx playwright test` output.
