# Neleto CMS — Convert any HTML design into a fully component-driven page (MCP recipe v3)

**Context:** Use the Neleto MCP server (neleto_pr Connector) to rebuild a self-contained HTML page (markup + CSS + JS in one file) as a component-driven page on the CMS. Every piece of text, link, number, and image must be editable by the customer through the CMS — nothing hardcoded that the client would want to change. This recipe is design-agnostic: it works for any single-file HTML page.

> **What changed in v3:** the connector's tool docs were updated since v2, and this revision re-verified every claim against the live server rather than trusting the changelog. Two real fixes landed (`*_list` endpoints no longer error; `component_update`/`component_create` descriptions gained real detail on relation helpers). One claimed fix did **not** hold up under test (built-in Page Content id `0` — still returns "Not found", the workaround stays). And this version adds an entirely new capability the tool schema still doesn't explain: the `builder` form-property type for client-editable repeating lists (used to build a working FAQ/Awards component this session, after two rounds of trial-and-error the tool docs still don't cover). See "What's still wrong / undocumented" at the very end for the exact discrepancies found, in case someone wants to fix the docs further.

---

## Engine in one minute (read this first)

Neleto's template language is **Handlebars-based** with a few rules that differ from vanilla Handlebars and from what you might assume:

- **Inline helpers require parentheses and comma-separated args:**
  `{{ render_elements(page.elements) }}`, `{{ date(page.created_at, "%Y") }}`, `{{ image_path(img, "w_800,f_webp") }}`
- **Block helpers bind with `as`:**
  `{{#if value as v}} … {{else}} … {{/if}}`, `{{#each list as item}} … {{/each}}`, `{{#with x as y}} … {{/with}}`
- **String literals use double quotes** inside `{{ }}` — including when nested inside a double-quoted HTML attribute (`src="{{ image_path(img, "w_800") }}"` is valid).
- **Strict symbols:** an undefined variable/helper or wrong helper arity is a **hard compile error that breaks the whole page** — not silent empty output. Guard nullable values with `{{#if … as …}}`.
- **Never use `{{{ triple braces }}}`** — the engine panics/stack-overflows.
- Values printed with `{{ }}` are HTML-escaped, so `&`, `<`, etc. in form values are safe; put structural HTML (line breaks, bold, italic spans) in the template, not in text properties.

---

## Architecture to build

**Layout** (shell shared by every page):

- `template`: `[favicon data-URI link][Google Fonts link tags]{{ render_elements(page.layout.elements) }}`
- `style`: **leave empty** — the layout style field is **not injected** into rendered pages (see CSS placement).
- `script`: **empty** — the layout script field is **not injected** either.
- Layout elements, in order:
  - `order 0` → Navigation component
  - `order 1` → **Page Content** component (the slot — you must create this yourself, see below)
  - `order 2` → Footer component
  - (optional) site-wide JS-only / Global-CSS components if you want them on every page

**Page** (e.g. route `/`):

- `template`: `{{ render_elements(page.elements) }}`
- Elements, in visual order: a Global-Styles component and any JS-only components first, then one element per visual section.

---

## Critical rules

### Rendering

- Page content: `{{ render_elements(page.elements) }}` in the page template.
- Layout content: `{{ render_elements(page.layout.elements) }}` in the layout template.
- **Child elements** of a container component: `{{ render_elements(children) }}`.
  Do **not** use `{{#elements children}}…{{/elements}}` or `{{element child}}` — `child` is undefined and the page fails to compile.
- **Never** use `{{ slot }}` or `{{ render_elements(layout.elements) }}` in the *layout* template.

### The Page Content slot (the built-in id=0 still doesn't work — re-verified)

The layout needs one element that marks where the page's own content is injected. `layout_update`'s tool description currently reads: *"The Page Content component (id=0) in the elements list is the injection point for page content"* — implying it's a built-in you can just reference. **This was re-tested and does not hold**: `component_get(id: 0)` still returns `"Not found"`. Whatever id=0 refers to server-side, it isn't retrievable/usable through the MCP tools as of this writing.

**Reliable approach (unchanged from v2):** create your **own** Page Content component:

```jsonc
component_create {
  "template": "<div>{{ slot }}</div>",
  "style": "", "script": "",
  "category": "layout", "canHaveChildren": false, "icon": "tabler:layout-dashboard",
  "form": { "label": { "en": "Page Content", "de": "Seiteninhalt" }, "showDataKey": null, "properties": [] }
}
```

Use the returned id as the middle layout element (order 1). `{{ slot }}` works inside a *component* template (just never in the layout template).

### Icons — Tabler, with a `tabler:` prefix (undocumented in the tool schema)

Every component's `icon` field is typed as a bare string with **no description at all** in `component_create`/`component_update` — it will not tell you the format. In practice, icons come from the **Tabler icon set** (https://tabler.io/icons) and must be referenced as `"tabler:<icon-name>"`, e.g. `"tabler:award-filled"`, `"tabler:layout-grid"`, `"tabler:message-circle-2"`. Tabler ships outline and filled variants of most icons, distinguished by a `-filled` suffix. Bare guesses like `"award"`, `"star"`, `"mail"` will very likely not render correctly in the admin UI — always prefix with `tabler:`.

### CSS placement (the layout style field does NOT inject)

Only **component** `style` fields are injected into rendered pages. The layout's own `style` field behaves like its `script` field — it is **not** injected.

- Put **all global/shared CSS** (`:root` design tokens, reset, container/`.wrap`, typography, buttons, animation/`@keyframes`, utility classes) into a dedicated **Global Styles component** (`template: ""`, `script: ""`, all CSS in `style`). Place it as a layout element (so it loads on every page) or as the first page element.
- Once any injected component style is in the document `<head>`, its rules are global — so every other component (page **and** layout elements, including nav/footer) resolves the shared variables.
- Put **section-specific CSS** in each section component's own `style` field.

### ⚠️ CSS specificity ties with shared utility classes

Component styles get merged into one stylesheet, but **the order they end up in is not guaranteed to match page-element order.** If a shared utility class (like `.wrap`) and a component-specific class (like `.banner-inner`) both set the *same property* on the *same element* — e.g. a template like `<div class="wrap banner-inner">` where both `.wrap` and `.banner-inner` declare `padding` — the two rules have equal specificity (one class each), and whichever landed later in the merged stylesheet wins. This is not predictable and is not tied to the components' order on the page. In practice this silently discarded a component's intended padding in favor of the shared `.wrap` padding.

**Rule:** if a component needs to override a property that a shared utility class also sets, never rely on source order to break the tie. Increase specificity instead, by scoping with an ancestor selector:

```css
/* Fragile — depends on injection order, can silently lose: */
.banner-inner{ padding:70px 40px; }

/* Reliable — always wins regardless of order: */
.banner .banner-inner{ padding:70px 40px; }
```

Audit every component whose template combines a shared layout class (`.wrap`, or any other class also used elsewhere) with a component-specific class, and scope any overlapping property this way.

### JavaScript

- Put all JS in **component** `script` fields (the layout `script` field is never injected).
- Wrap every component script in `document.addEventListener('DOMContentLoaded', function () { … })` — component scripts are hoisted to `<head>`, so the DOM isn't ready otherwise.
- `position: fixed` elements that live in **page** content (custom cursors, overlays, modals) must be created at runtime with `document.body.appendChild()` — static page-template HTML is trapped inside a wrapper. A `position: fixed` nav placed as a **layout** element is fine in template HTML, because the layout is the outermost shell.
- Site-wide effects (cursor, nav-scroll, scroll-reveal observers, counters) go in JS-only components placed as layout elements (every page) or page elements (single page).
- Guard every DOM lookup that isn't guaranteed to exist on every page (e.g. `document.getElementById('nav')`) with a null check before using it — a single-page site's Site Behaviors component may run on pages that don't have every element.

### Form properties (the editable fields)

- One entry per editable value: camelCase `id`, human `label` (`{en, de}`), a `type`, and a `defaultValue` taken from the original HTML.
- **Types:** `text` (labels, headings, hrefs, short copy), `textarea` (multi-line body copy — also the only way besides `richtext`/`markdown` to make text translatable per language), `richtext`, `number`, `checkbox`, `select`, `relation` (files/pages/posts/events/etc. — see Images/Linking), `builder` (client-editable repeating lists — see its own section below).
- **Extract only what the client should edit:** headings, body copy, button text, link hrefs, stat numbers, image choices/alt text. **Do NOT** turn structural classes, decorative SVG/markup, `aria-*`, or animation hooks into properties — leave those hardcoded.
- Long paragraphs → `textarea`/`richtext`, never split one paragraph across several `text` props.
- Put line breaks / bold / italic accents in the **template** structure (e.g. `<h2>{{ properties.line1 }}<br>{{ properties.line2 }} <span class="serif-i">{{ properties.accent }}</span></h2>`), not inside text values.

### Repeating structured lists — the `builder` property type (new in v3, still undocumented by the tool itself)

For a client-editable list of small structured rows (an FAQ, a list of awards, a set of testimonials) where each row is just a couple of plain text/textarea fields, `builder` is lighter-weight than the child-component pattern (no separate component, no `canHaveChildren`). The property type is *listed* in `component_create`'s schema ("builder (array of structured items)") but its actual JSON shape is not explained anywhere — it had to be reverse-engineered by hitting `component_create`'s validation errors repeatedly, and even a manual edit through the admin UI surfaced a real bug. Use this confirmed-working shape as-is:

```jsonc
{
  "id": "items",
  "type": "builder",
  "label": { "en": "Questions", "de": "Fragen" },
  "description": null,
  "hint": null,
  "required": null,
  "defaultValue": [],
  "items": [
    {
      "id": "question",
      "type": "text",
      "name": "question",
      "label": { "en": "Item", "de": "Item" },
      "icon": null,
      "items": [],
      "data": "",
      "defaultValue": "",
      "property": { "type": "text", "defaultValue": "" },
      "props": [
        { "id": "question", "type": "textarea", "label": { "en": "Question", "de": "Frage" }, "defaultValue": null, "description": null, "hint": null, "required": null },
        { "id": "answer",   "type": "textarea", "label": { "en": "Answer",   "de": "Antwort" }, "defaultValue": null, "description": null, "hint": null, "required": null }
      ]
    }
  ]
}
```

Rules that are easy to get wrong:

- The property's `items` array holds **exactly one entry** — a single "row template" object, not one entry per field. The real per-row fields (`question`/`answer` here) go inside that one entry's **`props`** array as plain, flat property definitions.
- That single row-template entry still needs `id`, `name`, `data`, `defaultValue`, `items` (empty array), and a `property: {type, defaultValue}` object to pass validation, even though none of it is used for actual row content.
- **Set `data` and `defaultValue` on that row-template entry to an empty string `""`, never a real placeholder string.** Leaving a real string there (discovered the hard way) causes the admin UI's builder editor to spread that string's characters into every saved row as junk numeric keys (`{"0":"H","1":"o",...}`) alongside the real values on save. Harmless for rendering, but it's corrupt data — avoid triggering it.
- Row **values** (both the property's `defaultValue` and what's stored in an element's `data[<propId>]`) are an array of `{ "id": "<rowId>", "data": { "<fieldId>": "<value>", ... } }` objects — not a flat `{ "<fieldId>": "<value>" }` per row.
- In the template, read row values as `item.data.<fieldId>`, **not** `item.<fieldId>`:
  ```handlebars
  {{#each properties.items as item}}
    <div class="faq-item">
      <h3>{{ item.data.question }}</h3>
      <p>{{ item.data.answer }}</p>
    </div>
  {{/each}}
  ```
- Leave the property's top-level `defaultValue: []` (empty) unless you specifically want seed rows to ship with every new instance of the component.

**When to use `builder` vs. a child component:** use `builder` when every row is just a handful of plain text/textarea fields rendered inline in a loop (FAQ, awards list, simple testimonial quotes). Use a child component (`canHaveChildren: true` parent + `{{ render_elements(children) }}`) when each row needs its own image/relation, its own nested markup, or should be independently stylable/orderable as a full element in the CMS tree.

### Images & files — use `image_path`, not the raw relation

- An editable image is a **`file` relation** property plus a sibling alt-text `text` property:
  ```jsonc
  { "id": "image",   "type": "relation", "relationName": "file", "multiple": false, "defaultValue": null }
  { "id": "imageAlt","type": "text",     "defaultValue": "…" }
  ```
- **Render it with the `image_path` helper, guarded by `{{#if}}`.** The raw `{{ relations.image }}` resolves to the file *object* (an unusable `src`), so the image never shows:
  ```handlebars
  {{#if relations.image as img}}
    <img src="{{ image_path(img, "w_800,f_webp") }}" alt="{{ properties.imageAlt }}">
  {{/if}}
  ```
- `image_path(file, "options")` takes **two** arguments — the file and a **comma-separated options string** (`<letter>_<value>`). Omitting the options string is an error. Options:

  | Option | Meaning | Example |
  |---|---|---|
  | `w_` | width (px), aspect kept | `w_800` |
  | `h_` | height (px) | `h_600` |
  | `s_` | size — sets width **and** height equal | `s_300` |
  | `f_` | **format**: `webp` `jpeg` `png` `avif` `gif` | `f_webp` |
  | `q_` | quality (jpeg only) | `q_80` |
  | `e_` | extract/crop `top_left_w_h` | `e_0_0_100_100` |
  | `f_` | **fit** (only with size/width/height): `cover`/`outside`, `contain`/`inside`, `fill` | `f_cover` |

  ### ⚠️ `f_` is overloaded — format and fit collide (still true, unchanged in v3)

  Notice the table above has **two different meanings for `f_`**: format (`f_webp`) and fit (`f_cover`). The endpoint cannot always tell which one you meant, and combining `h_`/`w_` with a fit value (e.g. `"w_560,h_448,f_cover"`) returned a **hard `400` error** in testing — not the "serves the original" fallback the spec describes for malformed strings. Every image using that combination silently failed to load.

  **Rule: never pass a fit value.** Stick to `w_`, `h_`, and `f_<format>` only, and let CSS handle cropping with `object-fit: cover` on the `<img>` (which a well-built section component should already have for any image that needs to fill a fixed-aspect box). This sidesteps the collision entirely and is what CSS is for anyway:
  ```handlebars
  {{#if relations.image as img}}
    <img src="{{ image_path(img, "w_560,h_448,f_webp") }}" alt="{{ properties.imageAlt }}">
    <!-- .project-img img { object-fit: cover } in the component's own CSS handles the crop -->
  {{/if}}
  ```
  - Set the width (and height, if used) to roughly the on-screen display size; the endpoint at `/images/user-upload/<id>?options=…` scales and caches automatically.
  - Combine safely: `"w_560,f_webp"`, `"w_560,h_448,f_webp"`, `"s_100,f_webp"`.
- Decorative/fixed assets that never change (background texture, inline SVG icons, favicon) stay **hardcoded** in templates, not properties.

### Linking to other content types (new in v3, unverified — check before relying on it)

`component_update`'s tool description now mentions sibling helpers to `image_path` — **`post_path`, `event_path`, `file_path`** — for linking to blog posts, events, and generic files respectively, plus a "responsive multi-source example" for `image_path`. Neither the exact argument signature nor the responsive example text is fully retrievable through the tool schema (it gets cut off). If a build needs to link to posts/events, read `component_create`'s full tool description directly before guessing at the syntax — don't assume it mirrors `image_path`'s two-argument shape without checking.

### Uploading image assets (base64 — do it reliably)

`file_upload` takes `base64Data`, which now accepts **either raw base64 or a full data-URI** (`data:image/jpeg;base64,...`) — both work. On success it returns a `serveUrl` you can use directly (in addition to the file `id`).

**Hand-transcribing long base64 still corrupts it** — this part is unchanged. Every failure encountered in practice was on a base64 string in the 15–48KB range passed through a long tool call, with errors like "Invalid padding" or "Invalid symbol N at offset Y." **The reported offset is not reliable** — it does not necessarily point at the actual corruption, so don't try to hex-edit around it. Do this instead:

- **Optimize first** (resize to the largest display size you'll request, quality ~70) so the base64 is small. Prefer this over shipping the original at full resolution regardless of size.
- **Set a size ceiling.** If the optimized base64 string is still long (as a rule of thumb, above ~20K characters), don't trust a single manual pass through a tool call — either:
  - shrink further (smaller dimensions / lower quality) until it's comfortably under that ceiling, or
  - delegate the upload to a sub-agent that writes the base64 to a file and verifies it programmatically before calling `file_upload`.
- **Before calling `file_upload`,** do a cheap sanity check on the string you're about to send: length is a multiple of 4, and it ends in `=`, `==`, or an alphanumeric character (never anything else). This catches most transcription corruption before you spend a round-trip on it.
- If `file_upload` still errors, don't try to patch the existing string — re-fetch/re-read the source and send the whole thing again, or fall back to a smaller pre-optimized variant. Trying to debug the corruption in place wastes far more time than a clean retry.
- Upload with `status: "public"` (the default file status may be `draft`, which won't render publicly).
- Capture each returned file **`id`** (or use the returned `serveUrl` directly) and set it as the element's relation data (`data.image = "<file-id>"`).
- Uploading originals is optional — you can ship with `defaultValue: null` and let the client pick images in the CMS — but the page only *looks* complete once images exist.

### Elements & IDs

- Every element object needs: `pageId` **or** `layoutId` (mutually exclusive), `visible: true`, `tempId`, `componentId`, `order`, `parentId`, `parentTempId`, `data: {}`, `children: []`.
- **Create the page empty first to obtain its real `id`,** then `page_update` with the elements (each element needs the real `pageId`, which doesn't exist until the page does).
- **Updating:** pass each existing element's `id` **and its `tempId`** to update in place — `tempId` is required even on elements that already have a real `id`, or the API rejects the whole call with `missing field tempId`. New elements (no `id`) are created fresh; omitting ids on a second call **duplicates** everything. Remove stale elements via `deletedElements: [ids]`.
- **Children:** nest them in the parent element's `children` array; each child carries its own `componentId`, `pageId`, `data`, `parentTempId` (the parent's `tempId`), and — on updates — its own `id`.
- **`*_list` endpoints are reliable now (fixed since v2)** — `layout_list`, `page_list`, and `component_list` were re-tested this session and now return clean data instead of a DB error, with a **lightweight payload** (no nested element/component detail) — use them freely to resolve ids cheaply. Reserve `*_get` for when you actually need full element/component detail.
- **`page_get` can still blow the response token limit** on any page with more than a handful of elements, because it nests each element's *full* component definition (template/style/script/form schema) repeated per element. When this happens the result is saved to a file and the error message suggests `jq`/Python — but if you're working through an agent harness where that file isn't reachable by a general shell (only by file-reading tools), use targeted regex extraction instead: search for `"componentId":X,"data":{` or similar short, unique anchors with a small character-count cap per match (a few hundred chars), and stitch the page back together element-by-element rather than trying to read or grep the whole blob at once.
- **Component/page edits to a `prerendered` page took effect on the very next request** in testing — there was no need to manually bust a cache. If a fix doesn't seem to show up, re-check the actual request (see Verify below) before assuming it's a caching issue.

---

## Step-by-step

1. **Analyse the HTML.** Split into: a Global-Styles block (all shared CSS), any JS-only behaviours (cursor, scroll/reveal, counters), Navigation, each visual section, Footer. Note which images should be client-swappable, which lists/galleries the client might extend as full elements (→ child components), and which are simple repeating text rows (→ `builder` property). Also note any element whose class list combines a shared utility class (`.wrap` or similar) with a component-specific class — flag it for the specificity rule above.
2. **Create the Layout** — template = favicon + font links + `{{ render_elements(page.layout.elements) }}`; `style: ""`; `script: ""`; `elements: []`. Note the layout id.
3. **Create the Page Content component** (`<div>{{ slot }}</div>`, icon `tabler:layout-dashboard` or similar). Note its id — don't rely on the built-in id `0`, it still doesn't resolve.
4. **Create the Global Styles component** — all shared CSS in `style`, empty template/script.
5. **Create the JS-only components** — behaviour in `script` (wrapped in `DOMContentLoaded`, with null checks on page-specific DOM lookups); `position:fixed` nodes via `appendChild`.
6. **Create Navigation, Footer, and one component per section** — every editable value as a form property; images via `image_path` (format + dimensions only, no fit) + `{{#if … as img}}`; use CSS `object-fit` for cropping; use `tabler:<name>` icons throughout; use `builder` for any simple client-extensible list.
7. **Repeating content:** for galleries/card grids where each item needs its own image or complex markup, build a small **child component** and render it in the parent via `{{ render_elements(children) }}` (parent `canHaveChildren: true`). For a simple list of plain-text rows (FAQ, awards, quick facts), use the `builder` property instead — no extra component needed. For genuinely fixed structures (primary nav, a 3-step process), numbered props are still fine.
8. **(Optional) Upload image assets** (see Uploading — optimize small, sanity-check the base64 length, and prefer a sub-agent or a smaller variant if a string is large) and capture file ids/`serveUrl`s.
9. **Create the page** (route, title, `layoutId`, `renderMode: "prerendered"`, `status: "public"`, `useTailwind: false`, template `{{ render_elements(page.elements) }}`, `elements: []`). Then **`page_update`** with all elements in visual order — Global-Styles + JS components first, then each section — filling `data` (and image relation ids), nesting children, and including `tempId` on every element (new and existing alike).
10. **`layout_update`** with three elements: Navigation (order 0), your Page Content component (order 1), Footer (order 2). Resend the layout `template` each time so it isn't blanked.
11. **Verify — in a real browser, not just a text fetch** (see below).

---

## Worked example — one image component (copy this shape)

```jsonc
component_create {
  "icon": "tabler:photo", "category": "misc", "canHaveChildren": false,
  "template": "<section class=\"about\"><div class=\"wrap about-grid\">\n  <div class=\"about-text\">\n    <p class=\"label\">{{ properties.label }}</p>\n    <h2>{{ properties.heading }} <span class=\"serif-i\">{{ properties.headingItalic }}</span></h2>\n    <p>{{ properties.body }}</p>\n    <a href=\"{{ properties.ctaHref }}\" class=\"btn btn-line\">{{ properties.ctaLabel }}</a>\n  </div>\n  <div class=\"about-media\">\n    {{#if relations.image as img}}<img src=\"{{ image_path(img, \"w_700,f_webp\") }}\" alt=\"{{ properties.imageAlt }}\">{{/if}}\n  </div>\n</div></section>",
  "style": ".about{padding:120px 0}.about-grid{display:grid;grid-template-columns:1fr 1fr;gap:70px;align-items:center;object-fit:cover} /* section-scoped CSS only; note no property here also lives on .wrap */",
  "script": "",
  "form": {
    "label": { "en": "About", "de": "Über uns" },
    "showDataKey": "heading",
    "properties": [
      { "id": "label",        "label": {"en":"Eyebrow label","de":"Label"},      "type": "text",     "defaultValue": "The studio" },
      { "id": "heading",      "label": {"en":"Heading","de":"Überschrift"},       "type": "text",     "defaultValue": "We believe good design is" },
      { "id": "headingItalic","label": {"en":"Italic accent","de":"Kursiv"},      "type": "text",     "defaultValue": "felt" },
      { "id": "body",         "label": {"en":"Body","de":"Text"},                 "type": "textarea", "defaultValue": "…" },
      { "id": "ctaLabel",     "label": {"en":"Button label","de":"Button-Text"},  "type": "text",     "defaultValue": "How we work" },
      { "id": "ctaHref",      "label": {"en":"Button URL","de":"Button-URL"},     "type": "text",     "defaultValue": "#process" },
      { "id": "image",        "label": {"en":"Image","de":"Bild"},               "type": "relation", "relationName": "file", "multiple": false, "defaultValue": null },
      { "id": "imageAlt",     "label": {"en":"Image alt text","de":"Alt-Text"},  "type": "text",     "defaultValue": "Inside the studio" }
    ]
  }
}
```

Container/parent with a repeatable child uses `{{ render_elements(children) }}` where the cards go, and `canHaveChildren: true`. For a plain repeating list of text rows instead, use the `builder` shape from its own section above.

---

## MCP quirks & gotchas

| Behaviour | What to do |
|---|---|
| Layout `style` **and** `script` not injected | Put all CSS in a Global-Styles **component**; put JS in component `script` fields. |
| Built-in Page Content (`componentId 0`) — tool docs now claim it works, `component_get(0)` still says "Not found" | Create your own `<div>{{ slot }}</div>` component and use its id. Don't trust the doc claim without re-testing. |
| Icon field has no documented format | Use `"tabler:<icon-name>"` (Tabler icon set), e.g. `"tabler:award-filled"`. |
| `{{#elements children}}` / `{{element child}}` fail | Render children with `{{ render_elements(children) }}`. |
| Raw `{{ relations.image }}` shows nothing | Use `{{ image_path(img, "w_…,f_webp") }}` inside `{{#if relations.image as img}}`. |
| `image_path` "Invalid amount of arguments: got 1, expected 2" | It needs `(File, "options-string")` — always pass an options string. |
| `image_path` returns `400` with `h_`/`w_` + `f_cover`/`f_contain`/`f_fill` | `f_` collides between format and fit. Drop the fit value entirely; use `w_`/`h_`/`f_<format>` and CSS `object-fit`. |
| A shared class (`.wrap`) and a component class set the same CSS property on the same element | Equal-specificity tie resolved by unpredictable injection order. Scope the component's rule with an ancestor selector (`.banner .banner-inner`) to force it to win. |
| `render_elements(layout.elements)` / `{{ slot }}` in layout template | Use `{{ render_elements(page.layout.elements) }}`; the slot lives in the Page Content component. |
| `{{{ triple braces }}}` | Never use — engine panics. |
| `*_list` endpoints | **Fixed as of this update** — reliable, lightweight, use freely to resolve ids. |
| `page_get` on an element-heavy page exceeds the response size limit | Use targeted short-anchor regex extraction on the saved-to-file result instead of trying to read/grep the whole blob at once. |
| `builder` property — shape totally undocumented by the tool schema | Use the confirmed shape in "Repeating structured lists" above: exactly one row-template entry in `items`, real fields in its `props`, `data`/`defaultValue: ""` on the row-template (not a real string), row values as `{id, data:{...}}`, template access via `item.data.<field>`. |
| `page_update`/`layout_update` create duplicates | Pass existing element `id`s (and `tempId` on every element, new or existing); clean up with `deletedElements`. Resend layout `template`/`style` placement so they aren't blanked. |
| Uploaded files invisible on public page | Upload with `status: "public"`. |
| Hand-typed/long base64 corrupts (`Invalid padding`, `Invalid symbol at offset N`) | The reported offset isn't reliable. Optimize small, sanity-check length %4 before sending, and re-send/shrink rather than patch in place. Data-URI input and a `serveUrl` in the response are both supported now, for convenience. |
| `position:fixed` page content trapped in wrapper | `document.body.appendChild()` at runtime (fine in template only for layout elements). |
| A text-only fetch of the rendered page reports "looks fine" | It can't see non-200 image requests or CSS losing a specificity tie. Load the page in a real browser and check network + computed styles. |

---

## Common mistakes → fixes

| Mistake | Fix |
|---|---|
| `{{ heading }}` | `{{ properties.heading }}` |
| Global CSS in the layout `style` field | Move it to a Global-Styles **component** style |
| `{{ relations.image }}` as an `<img src>` | `{{ image_path(img, "w_800,f_webp") }}` guarded by `{{#if relations.image as img}}` |
| `image_path(img)` (one arg) | `image_path(img, "w_800,f_webp")` |
| `image_path(img, "w_800,h_600,f_cover")` | Drop the fit value: `image_path(img, "w_800,h_600,f_webp")` + CSS `object-fit:cover` |
| `{{#elements children}}…{{/elements}}` | `{{ render_elements(children) }}` |
| Relying on built-in Page Content id 0 | Create your own `{{ slot }}` component |
| JS in layout `script` | JS in component `script` (wrapped in `DOMContentLoaded`) |
| `page_update` without element ids and `tempId` | Pass existing ids **and** `tempId` every time, on every element |
| `.component-class{ padding: X }` where a shared `.wrap`-style class also sets padding | Scope it: `.section .component-class{ padding: X }` |
| Bare icon name (`"award"`) | `"tabler:award-filled"` |
| `builder` property row values read as `item.question` | `item.data.question` |
| `builder` row-template entry with a real placeholder string in `data`/`defaultValue` | Use `""` (empty string) there instead — avoids row-data corruption on save |
| Verifying only by fetching page text | Load it in a real browser; check the network tab and computed styles |

---

## Verify

A page that "compiles" is not the same as a page that renders correctly — both bugs found in practice (a broken image option and a lost CSS override) were **invisible to a plain text fetch** of the rendered HTML. Verify in a real browser:

1. **Compile check.** Fetch/load the rendered HTML and confirm there's no "Could not resolve symbol" / helper-arity / parse error anywhere, sections appear exactly once in the correct DOM order (nav first, footer last), and no duplicate or orphaned elements.
2. **Network check (mandatory).** Load the live page in a real browser (Claude in Chrome or equivalent) and inspect the network requests for every image/asset URL. Any non-200 status on an `image_path`/`user-upload` request means the options string is wrong — check for the `f_` fit/format collision first.
3. **Computed-style check.** For every element whose class list combines a shared utility class with a component-specific class, spot-check `getComputedStyle(...)` on the properties both classes touch (padding, margin, display) and confirm the component's intended value actually won — don't just eyeball a screenshot.
4. **Console check.** Confirm there are no script errors from the page's own domain (ignore unrelated browser-extension noise).
5. **Builder-property check.** If the page uses any `builder` property, actually read back the stored element `data` (via `page_get`, chunked if needed) and confirm each row is a clean `{id, data:{...}}` object with no stray numeric keys — don't just eyeball the rendered text, since a corrupted row can still render correctly while carrying garbage data.
6. **Visual check.** Screenshot or view the rendered page directly — global styling present (tokens, container width, buttons, animations), images visible and correctly cropped, spacing matching the source design.

If you cannot fetch/load the rendered HTML through the MCP or a browser tool, ask for the site's preview URL — the list/render endpoints don't expose it, and a live load is the only way to confirm the template compiles *and* renders correctly end-to-end.

---

## What's still wrong / undocumented (worth fixing upstream)

Re-verified against the live connector this session:

- **Fixed since v2:** `layout_list`, `page_list`, `component_list` no longer throw a DB error — confirmed working with clean, lightweight responses.
- **Not actually fixed, despite the doc change:** `layout_update`'s description now asserts componentId `0` is the built-in Page Content injection point. `component_get(id: 0)` still returns `"Not found"`. Either the doc is aspirational/wrong, or id `0` is usable through some other mechanism not exposed via these tools — don't rely on it without re-testing.
- **Still undocumented:** the `builder` property type's actual JSON shape (this recipe now carries the full tribal knowledge, but the tool's own `form` field description still just says "builder (array of structured items)" with zero detail).
- **Still undocumented:** the `icon` field's `tabler:` naming convention (zero description on the field in both `component_create` and `component_update`).
- **Newly mentioned but not fully documented:** `post_path`, `event_path`, `file_path` relation helpers, and a "responsive multi-source example" for `image_path` — referenced in `component_update`'s description but the detail is truncated/inaccessible through the tool schema as surfaced to an agent. Worth digging up the full text directly from the server source if these are ever needed.
