<!-- demo:embed example="extensions/2-custom-element" -->

You've shipped a sample (B3) and a theme (B4). Now you'll go past the theme layer and add a **new element type** to the engine — a Countdown timer the buyer can drag into any page. By the end of this doc you'll have a working `CountdownElement` with its own template, its own settings panel controls, its own JSON shape — and the round-trip rule still holds. **Time budget: 60 minutes.**

## Pre-flight

Confirm the dev server boots and the live builder loads at `/builder.php`. Then open the canonical example you'll mirror:

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

Open <http://localhost:8000/examples/extensions/2-custom-element/>. The page renders a working Countdown — every step in this doc reproduces what's running on that page.

## What an Element IS

Every element type is a **class that subclasses `BaseElement`** (`src/includes/BaseElement.js`). The contract:

```javascript
class CountdownElement extends BaseElement {
    // 4 mandatory hooks
    constructor(targetIso, label) { /* … */ }
    getData()                     { /* JSON-out  → */ }
    static parse(json)            { /* JSON-in  ← */ }
    _doRender()                   { /* canvas paint */ }

    // 1 optional declaration
    requiredTemplateKeys = ['target_iso', 'label'];

    // 1 optional method (settings panel)
    getControls() { return [/* …Control instances… */]; }
}
```

The four mandatory hooks pair like this:

- `getData()` returns the JSON shape that goes into save (the inverse of `parse`).
- `static parse(json)` rehydrates an instance from that JSON (the inverse of `getData`).
- `_doRender()` paints the live canvas — sets `this.domNode.innerHTML` from the element's current state.
- `constructor(...)` accepts the per-instance args (typically just the format-bearing fields).

## Step 1 — Subclass BaseElement

<!-- buyer:try -->
```javascript
class CountdownElement extends BaseElement {
    constructor(targetIso = '2026-12-31T00:00:00Z', label = 'New Year') {
        super();
        this.target_iso = targetIso;
        this.label = label;
        this.requiredTemplateKeys = ['target_iso', 'label'];
    }

    getData() {
        return {
            name: 'CountdownElement',
            template: 'Countdown',
            formats: this.formats || {},
            target_iso: this.target_iso,
            label: this.label,
        };
    }

    static parse(json) {
        const el = new CountdownElement(json.target_iso, json.label);
        el.formats = json.formats || {};
        return el;
    }

    _doRender() {
        // BaseElement rendered the EJS template into this.domNode for
        // us. The template (next step) emits a [data-bjs-countdown]
        // node we'll target with a page-level setInterval.
    }
}
```

The shape here is the canonical CD-1 pattern from W6.C.12:

- `formats` is the engine's own field — we don't subclass-own it; just pass through what the controls write.
- `target_iso` + `label` are the element's domain attributes.
- `requiredTemplateKeys` declares which keys the EJS template MUST receive — the engine's pre-render guard rejects a render if any are missing (regression-scar in `BUILDER.md`'s element-dev rules).

## Step 2 — Register the class

For the engine's element registry to recognise the type, register it after `Builder` boots:

<!-- buyer:try -->
```javascript
Builder.registerElement('CountdownElement', CountdownElement);
```

Two things happen:

1. The constructor is mapped at `ELEMENT_REGISTRY['CountdownElement']` so `parse()` works for arbitrary JSON.
2. The class is published as `window.CountdownElement` so user-authored code can reach it.

Drop this line right after your `Builder` constructor call but **before** `builder.load(…)`. If you load a page tree containing `CountdownElement` nodes before the registry knows about the class, the engine warns "unknown element type" and skips the node.

## Step 3 — Author the template

Every element renders via an EJS template the active theme owns. Add `Countdown.template.html` under `themes/default/`:

<!-- buyer:try -->
```ejs
<!-- themes/default/Countdown.template.html -->
<div data-bjs-countdown
     data-target="<%= target_iso %>"
     class="my-countdown"
     style="text-align: center; padding: 20px; font-family: system-ui, sans-serif;">
    <div class="my-countdown-time" style="font-size: 48px; font-weight: 700;">--:--:--</div>
    <div class="my-countdown-label" style="font-size: 14px; color: #888; margin-top: 8px;">
        <%= label %>
    </div>
</div>
<style>
    .my-countdown { /* element-scoped — rules don't cross iframe boundaries */ }
</style>
```

Two non-obvious rules from W6.C.12:

- **Visual rules MUST live INSIDE the EJS template body.** Host-page `<style>` rules don't cross the iframe canvas boundary. If you write `.my-countdown { … }` in the host's `<style>`, it WILL render unstyled on canvas.
- **Inline `style="…"`** is fine — it ships with the element when exported. Class-based styling needs to ship via the template's own `<style>` tag.

Add `'Countdown'` to your theme's `index.json` `templates` array so the engine knows to load it:

<!-- buyer:try -->
```json
{
    "templates": ["Block", "Grid", "Cell", "P", "Heading", "..."],
    "templates": ["Block", "Grid", "Cell", "P", "Heading", "Countdown", "..."]
}
```

(The first line shown for context; the second is the change.)

## Step 4 — Wire the live tick

The template emits `[data-bjs-countdown]` nodes. A page-level `setInterval` updates them:

<!-- buyer:try -->
```javascript
function tickAllCountdowns() {
    // Walk every iframe — each block renders inside its own iframe for
    // style isolation. Top-document querySelectorAll misses everything.
    const iframes = document.querySelectorAll('iframe');
    iframes.forEach((frame) => {
        try {
            const doc = frame.contentDocument;
            if (!doc) return;
            doc.querySelectorAll('[data-bjs-countdown]').forEach((node) => {
                const target = new Date(node.dataset.target).getTime();
                const remaining = Math.max(0, target - Date.now());
                const days = Math.floor(remaining / 86400000);
                const hours = Math.floor((remaining % 86400000) / 3600000);
                const mins = Math.floor((remaining % 3600000) / 60000);
                const secs = Math.floor((remaining % 60000) / 1000);
                node.querySelector('.my-countdown-time').textContent =
                    `${days}d ${hours}h ${mins}m ${secs}s`;
            });
        } catch (_) { /* same-origin iframe access edge-case */ }
    });
}

setInterval(tickAllCountdowns, 1000);
```

The iframe-walk is non-obvious until you trip over it: the engine sandboxes each block render in its own `<iframe>` for style isolation, so top-document `document.querySelectorAll('[data-bjs-countdown]')` returns nothing.

## Step 5 — Add settings-panel controls

The buyer needs to pick the target date + the label. Override `getControls()`:

<!-- buyer:try -->
```javascript
class CountdownElement extends BaseElement {
    /* …constructor + getData + parse + _doRender from steps 1-2… */

    getControls() {
        return [
            new TextControl('Label', this.label, (newLabel) => {
                this.label = newLabel;
                this.notifySyncListeners();
            }),
            new TextControl('Target ISO', this.target_iso, (newIso) => {
                this.target_iso = newIso;
                this.notifySyncListeners();
            }),
        ];
    }
}
```

Each Control receives a label, the current value, and a callback. The callback mutates the element's own state and calls `notifySyncListeners()` — that triggers a re-render with the new value.

The shipped controls catalogue (`TextControl`, `SelectControl`, `ColorPickerControl`, `DimensionControl`, …) lives in `src/includes/`; mix-and-match by what your element needs. To author a *new* Control type that doesn't exist yet, see [Build a custom Control](/docs/10-control-dev/).

## Step 6 — Test the round-trip

The whole point of the static `parse()` + instance `getData()` pair is round-trip integrity:

<!-- buyer:verify -->
1. Insert a Countdown into the canvas (programmatically or via a Widget — see [Build a custom Widget](/docs/11-widget-dev/)).
2. Edit the Label in the settings panel.
3. Call `builder.getData()` in DevTools — confirm your label is in the JSON.
4. Call `builder.load(builder.getData(), themeTemplates, themeConfigData, mediaUrl)` to round-trip.
5. The Countdown still renders with your edited label, the timer still ticks.

If any step fails, the issue is one of: `getData()` missing a key, `parse()` not setting that key, or `requiredTemplateKeys` lying about what the template needs.

## Step 7 — Validator awareness

The engine ships a JSON-shape validator at `BuilderJsonStructureValidator`. Custom elements aren't auto-listed in `supportedLeafElements` — the validator **warns** but doesn't block; the canvas renders fine. To silence the warning, append your class name to the validator's set or extend the validator subclass. Most projects accept the warning until they automate validation in CI.

## Did you make it?

You should now have:

- ✅ A `CountdownElement` class extending `BaseElement` with the 4 mandatory hooks.
- ✅ `Builder.registerElement('CountdownElement', CountdownElement)` called before `builder.load()`.
- ✅ `Countdown.template.html` shipped in your theme + listed in `index.json`.
- ✅ A page-level `setInterval` walking iframes to tick the timers.
- ✅ A settings panel with at least one Control for label + target.
- ✅ Round-trip: `builder.load(builder.getData(), …)` preserves the element.

If a step didn't work:

- 🩹 *Engine warns "unknown element type"* → confirm `Builder.registerElement('CountdownElement', CountdownElement)` ran BEFORE `builder.load()`. The registry is consulted at load time.
- 🩹 *Element renders but settings panel is empty* → confirm `getControls()` returns an array of Control instances, not raw DOM. The engine wires each Control's render slot for you.
- 🩹 *Visual styling missing on canvas* → moved the rules from the host `<style>` into the EJS template's own `<style>` tag (host CSS doesn't cross iframe — W6.C.12 lesson).
- 🩹 *EJS error "ReferenceError: formatter is not defined"* → drop any `<%- formatter ? formatter(text) : text %>` ternaries from your template; the engine's EJS uses `with(locals)` and trips on undefined identifiers BEFORE the ternary short-circuits. Either define a no-op formatter or remove the reference.

What's next?

- → [Build a custom Control](/docs/10-control-dev/) ⭐ — when the shipped controls don't cover your domain (brand color picker, range slider with units, dependent dropdowns).
- 🔗 [`examples/extensions/2-custom-element/`](/examples/extensions/2-custom-element/) — the runnable Countdown end-to-end with the iframe-tick pattern wired.
- 🔗 `src/includes/BaseElement.js` — the canonical contract.
- 🔗 `src/includes/PElement.js` + `HeadingElement.js` — engine-shipped element classes for reference shape.

Stuck?

- 🐛 grep `src/includes/<TypeName>Element.js` for any shipped element matching what you want.
- 🐛 the `[data-bjs-countdown]` iframe-walk trick is W6.C.12's lesson — re-read its DISCOVERIES entry for the long version.
- 🐛 file an issue with: your full subclass, the EJS template, the warning text from console, the JSON `getData()` produces.
