# Aura Builder - Complete AI & Developer Build Guide

> Single source of truth for building Aura Builder pages **by hand, with AI, or programmatically**.
>
> **Public CDN (agents / web):** https://cdn.cmsaura.com/aurabuilder/docs/AI-GUIDE.md
>
> Canonical file: `Joomla/com_aurabuilder/media/com_aurabuilder/docs/AI-GUIDE.md`.  
> Copies under `AuraBuilder/assets/docs/`, `apps/cmsaura/public/docs/`, and `cdn/docs/` are
> **auto-generated by `build.ps1`** — never edit them directly. Edit the Joomla copy only,
> then upload `cdn/docs/AI-GUIDE.md` to Cloudflare at `aurabuilder/docs/AI-GUIDE.md`.

This guide describes two JSON formats:

1. **Layout JSON** - the *canonical runtime format* that is saved to the database and read by
   `Renderer.php`. This is what the editor stores and the front-end renders.
2. **Compact JSON** - the *terse authoring format* the AI API uses to generate/patch pages. PHP
   expands it into full Layout JSON. Use this when calling the AI API or writing prompts.

If you are an AI assistant helping a user build a page, prefer the **Compact JSON** format for
generation (smaller, matches the API) and the **Layout JSON** format when editing saved layouts
directly in the database.

---

## Table of contents

1. [Architecture](#1-architecture)
2. [Where data lives (databases & meta)](#2-where-data-lives)
3. [Entity & page/post types](#3-entities--page-types)
4. [Platform differences (Joomla / WordPress / Laravel)](#4-platform-differences)
5. [Brand kit](#5-brand-kit)
6. [Layout JSON schema (runtime format)](#6-layout-json-schema)
7. [Compact JSON schema (AI format)](#7-compact-json-schema)
8. [Block reference (V2 examples)](#8-block-reference-v2-examples)
9. [The AI API (endpoints, actions, credits, patch ops)](#9-the-ai-api)
10. [Worked examples](#10-worked-examples)
11. [Rules & schema safety](#11-rules--schema-safety)
12. [Common build rules (AI must follow)](#12-common-build-rules-ai-must-follow)
13. [MCP connector (Cursor / Claude)](#13-mcp-connector-cursor--claude)

---

## 1. Architecture

Aura Builder is one builder shared across three platforms. The **editor** and the **renderer** are
platform-agnostic; thin per-platform adapters handle CMS specifics (assets, media, posts, menus).

| Layer | File (canonical) | Role |
|---|---|---|
| Editor (React, no build step) | `cdn/js/editor.bundle.js` | All blocks, inspector UI, canvas, AI wiring |
| Renderer (PHP) | `Joomla/com_aurabuilder/site/src/Helper/Renderer.php` | Turns Layout JSON into HTML |
| Frontend block CSS | `.../media/com_aurabuilder/css/blocks.css` | Section/row/column/block styles (shared) |
| Editor/canvas CSS | `.../media/com_aurabuilder/admin/css/editor.css` | Builder-only styles |
| AI API | `api/*.php` (`index.php`, `chat.php`, `queue.php`, `worker.php`, `lib.php`) | Generation, chat, patching |

**Parity rule:** any change to a block's data shape or output must be made in **both**
`editor.bundle.js` (canvas) and `Renderer.php` (frontend), or it is incomplete.

**Build/sync:** `build.ps1` copies `Renderer.php`, `RendererPlatformInterface.php`, `editor.css`,
`blocks.css`, the minified bundle, **and this guide** from Joomla to the WordPress plugin and the
Laravel app. Run `powershell -ExecutionPolicy Bypass -File build.ps1`.

---

## 2. Where data lives

### 2.1 Joomla (`com_aurabuilder`)

Tables (with `#__` prefix). Schema in `Joomla/com_aurabuilder/sql/**` and `script.php` (`ensureSchema()`).

| Table | Purpose | Layout column(s) |
|---|---|---|
| `#__aura_pages` | Full builder pages (hierarchical, max 3 levels) | `layout_json`, `page_css`, `page_js` |
| `#__aura_sections` | Reusable section layouts | `layout_json`, `css` |
| `#__aura_archives` | Conditional / importable templates | `layout_json` |
| `#__aura_styles` | Brand kit / design tokens | `json` |
| `#__aura_templates` | List/card wrappers for archives | `layout_above`, `layout_below` |
| `#__aura_section_placements` | Maps sections to template positions | - |
| `#__aura_blockdefaults` | Per-site block default JSON | `defaults_json` |

`#__aura_pages` also stores SEO/OG/Schema.org columns: `meta_title`, `meta_description`,
`meta_robots`, `canonical_url`, `og_title`, `og_description`, `og_image`, `og_type`, and a large
`schema_*` set (`schema_type`, `schema_name`, ... `schema_custom_json`), plus `featured_image`,
`parent_id`, `level`, `alias`, `menu_item_id`.

> `layout_above`/`layout_below` live on `#__aura_templates`, **not** on pages.

**Front-end render path (Joomla):**
1. Menu item -> `index.php?option=com_aurabuilder&view=page&page_id={id}`
2. `site/src/View/Page/HtmlView.php` loads the row and calls
   `Renderer::renderLayoutJson($item->layout_json, $sectionResolver, ...)`.
3. The section resolver loads `#__aura_sections.layout_json` by id for embedded sections.
4. `page_css` is injected with `addStyleDeclaration()`, `page_js` with `addScriptDeclaration()`.

### 2.2 WordPress (`AuraBuilder`)

Post editing uses **post meta** (not the mirror tables). Keys (`includes/class-meta-box.php`):

| Meta key | Purpose |
|---|---|
| `_aura_builder_active` | Enables the builder on this post/page |
| `_aura_layout_json` | The Layout JSON |
| `_aura_page_css` | Per-page custom CSS |
| `_aura_page_js` | Per-page custom JS |
| `_aura_meta_title`, `_aura_meta_description` | SEO |
| `_aura_og_title`, `_aura_og_description`, `_aura_og_image`, `_aura_og_type` | Open Graph |
| `_aura_featured_image` | Legacy featured-image fallback |

- Enabled post types: `get_option('aura_builder_post_types', ['page','post'])` (filter
  `aura_builder_post_types`).
- Front-end render: `class-frontend.php` hooks `the_content`. When `_aura_builder_active` is set,
  it renders `_aura_layout_json` through the shared `Renderer.php`.
- Global settings live in the `options` table as `aura_builder_*`. Brand kit lives in the
  `{prefix}aura_styles` table (`json` column, `is_default = 1`).

> `get_the_content()` guard: while the builder is active with a layout, an invisible
> `<!-- aura-builder -->` marker is written to `post_content` so themes that gate output with
> `if ( get_the_content() )` still render the layout.

---

## 3. Entities & page types

### 3.1 Joomla entities

| Entity | Table | What it is |
|---|---|---|
| **Page** | `#__aura_pages` | A complete builder page. Has its own CSS/JS, SEO, hierarchy. |
| **Section** | `#__aura_sections` | A reusable section you can embed on many pages (block type `section`, or `mod_aurabuilder_section`). |
| **Archive** | `#__aura_archives` | A conditional template. `archive_type` is one of `archive`, `single`, `page`, `header`, `footer` and replaces the category listing, single article, an importable page template, or the site header/footer. |
| **Style** | `#__aura_styles` | Brand kit / global design tokens (`json`). One row is `is_default = 1`. |
| **Template** | `#__aura_templates` | List/card wrapper (`layout_above`/`layout_below`) used around article/post loops. |
| **Settings** | component params | Global CSS, fonts, image sizes, AI toggles, default spacing. |

Menu item types (`site/tmpl/`): "Aura Builder - Single Page" (`page_id`) and
"Aura Builder - Page List" (child pages grid).

### 3.2 WordPress

Sections, archives, styles are managed under the plugin's admin menu and mirror the Joomla
concepts. Regular WordPress `post`/`page` (and any enabled custom post type) hold their layout in
post meta as above. Archives/templates apply to WP queries and taxonomies.

### 3.3 Post/Article field blocks (dynamic content)

Inside an **archive** (single/loop template) you use field blocks that pull live CMS data at render:

- Joomla: `article_title`, `article_intro_image`, `article_full_image`, `article_introtext`,
  `article_fulltext`, `article_created`, `article_author`, `article_category`, `article_tags`,
  `article_field`, `article_link`.
- WordPress: `post_title`, `post_featured_image`, `post_excerpt`, `post_content`, `post_date`,
  `post_author`, `post_categories`, `post_tags`, `post_acf_field`, `post_meta_field`.

---

## 4. Platform differences

All three platforms consume the **same Layout JSON** and the **same `Renderer.php`**. Differences
are isolated to the platform adapter (`RendererPlatformInterface` implementations).

| Capability | Joomla | WordPress | Laravel / static |
|---|---|---|---|
| Assets | `Document::addStyleSheet/addScript` | `wp_enqueue_*` | direct `<link>/<script>` |
| Rich content | `content.prepare` plugins | `do_shortcode()` | raw |
| Modules/widgets | `ModuleHelper::renderModule` | `[widget]` / `dynamic_sidebar('aura-{pos}')` | none |
| Menus | `#__menu` + `Route::_()` | `wp_get_nav_menu_items()` | none |
| Posts | `com_content` articles/categories | `get_posts()` / `WP_Query` (custom types + taxonomies) | none |
| Builder page URL | `option=com_aurabuilder&view=page&page_id={id}` | `get_permalink($id)` | route |
| Aura Members / Docs / Forms / Mail | full queries | stubs (`[]`/`null`) | stubs |

**Platform-specific blocks:**
- Joomla-only: the `article_*` field blocks; `articlelist`, `module`, `menu` (Joomla flavour).
- WordPress-only: `post_*` field blocks; `wp_childpages`, `wp_query`.
- The `contentPrepare` wrapper flag defaults to `true` on WordPress, `false` on Joomla.

When a task is *integration wiring only* (not block schema), edit the platform files listed in the
`aura-source-files` rule - never edit the synced `Renderer.php` copies.

---

## 5. Brand kit

The brand kit is where colours and fonts come from. **Always prefer brand-kit colours over
hard-coded hex values** when generating.

| Platform | Storage | Row |
|---|---|---|
| Joomla | `#__aura_styles.json` | `is_default = 1` |
| WordPress | `{prefix}aura_styles.json` | `is_default = 1` |
| Editor runtime | `window.VB_ACTIVE_BRANDKIT` | from `data-brand-kit` on `.vb-builder` |

**Token keys** (as stored in the style JSON):
`color1`, `color2`, `color3`, `black`, `white`, `text`, `fontHeading`, `fontBody`,
`borderRadius`, `containerWidth`. (There is no `logo` token.)

**How the AI uses it:** the API accepts a `brand_kit` object with `color1`, `color2`, `color3`,
`text`, `light` (all validated as hex). The generator replaces the default brand colour `#6366f1`
in its schema with `color1` and appends an instruction to use the brand palette (Primary for
buttons/brand sections/accents, Secondary/Accent for highlights). `brand_kit` is used by
**generate** only (not chat/update).

`tpl_aura` maps the default style to CSS custom properties: `--vb-color1..3`, `--vb-black`,
`--vb-text`, `--vb-font-heading`, `--vb-font-body`, `--vb-container-width`.

---

## 6. Layout JSON schema

This is the canonical format saved in `layout_json` / `_aura_layout_json` and read by
`Renderer.php`. Older layouts may omit `version`/`meta` - V1 vs V2 is still inferred by which keys are present when those fields are missing.
The renderer reads both; new output should follow V2 but must never drop unknown V1 keys.

### 6.1 Top level

V2 page/section exports (and what new AI output should produce) look like:

```json
{
  "version": 2,
  "meta": {
    "container": "contained",
    "schema": "aura-builder-v2"
  },
  "nodes": [ /* section nodes */ ]
}
```

- `version` - integer schema version. Current exports use `2`.
- `meta.container` - page-level containment hint (e.g. `contained`).
- `meta.schema` - literal `aura-builder-v2` on current builder exports.
- `nodes` - array of section (or `sectionRef`) nodes.

Legacy still accepted:

- `{ "nodes": [ /* section nodes */ ] }` without `version`/`meta`
- A bare array (no `nodes` wrapper)

Only two top-level node `type`s render:

- `{ "type": "section", ... }`
- `{ "type": "sectionRef", "refId": 12 }` - embeds a saved section by id.

### 6.2 The tree

```
section
  rows[]
    row
      cols[]
        column
          blocks[]        <- content blocks
          rows[]          <- nested rows (max depth 1, for column sub-grids)
```

### 6.3 Node/level shapes (V1 vs V2)

Each level has **type/content** and **settings**. V2 splits them into `element` (layout-specific
props) and `settings` (common wrapper props); V1 puts everything flat in `settings` (section/row) or
`data` (block) or on the column root.

**Section**
```json
{
  "type": "section",
  "id": "sec_...",
  "element":  { "minHeight": 0, "minHeightUnit": "px", "layout": "container", "sectionVAlign": "center", "overlay": {} },
  "settings": { /* common: bg, padding, margin, border, shadow, animation, hidden, customCss, customId, customClass, conditions, parallax, hoverEffect, overflow */ },
  "responsive": { "tablet": { "element": {}, "settings": {} }, "mobile": { ... } },
  "rows": [ /* row objects */ ]
}
```

**Row**
```json
{
  "id": "row_...",
  "element":  { "gap": 24, "colWidths": [50,50], "alignItems": "stretch", "justifyContent": "flex-start", "overflow": "visible", "fluidRow": false },
  "settings": { /* bg, padding, margin, border, shadow, animation, hidden, customCss, customId, customClass, maxWidth, offsetX, offsetY, parallax, hoverEffect */ },
  "responsive": { "tablet": { "cols": 1 }, "mobile": { "cols": 1 } },
  "cols": [ /* column objects */ ]
}
```

**Column**
```json
{
  "element":  { "minHeight": 0, "minHeightUnit": "px", "fluidBlocks": false, "contained": false, "justifyContent": "", "alignItems": "" },
  "settings": { /* bg, padding, margin, maxWidth, offsetX/Y, border, shadow, animation, hidden, customCss, customId, customClass, parallax, hoverEffect */ },
  "responsive": { "tablet": { "element": { "order": 1 }, "settings": {}, "textAlign": "center" } },
  "blocks": [ /* block objects */ ],
  "rows":   [ /* nested rows, optional */ ]
}
```

**Block**
```json
{
  "id": "blk_...",
  "type": "heading",
  "element":  { /* V2 type-specific content (mirrors V1 data) */ },
  "settings": { /* common wrapper: padding, margin, bg, border, shadow, animation, hidden, customCss, customId, customClass, offsetX/Y, parallax, hoverEffect */ },
  "responsive": { "tablet": { "element": {}, "settings": {} } }
}
```

In V1 a block is `{ "id", "type", "data": { ...content + wrapper keys... } }`. The renderer maps V1
`data.blockPadding` -> padding, `data.blockMargin` -> margin, `data.blockBg` -> bg,
`data.blockId` -> customId, `data.blockClass` -> customClass, etc.

### 6.4 Common wrapper settings (every section/row/col/block)

| Setting | Shape / values |
|---|---|
| `padding` / `margin` | `{ "top": 0, "right": 0, "bottom": 0, "left": 0 }` (px integers) |
| `bg` | see [background object](#65-background-object) |
| `border` | `{ radiusTL, radiusTR, radiusBR, radiusBL, top:{width,color,style}, right:{...}, bottom:{...}, left:{...} }` |
| `shadow` | `{ enabled, x, y, blur, spread, color, inset }` |
| `animation` | `{ type, duration, delay, once }` (emits AOS `data-aos-*`) |
| `hidden` | `{ desktop, tablet, mobile }` booleans -> `vb-hide-on-*` classes (CSS-hidden per device, still in DOM) |
| `disabled` | boolean. `true` skips the element **entirely** on the frontend (not rendered at all). Default enabled - omit or `false` to render. Editor shows it dimmed with a "Disabled" badge. |
| `customCss` | string, or `{ main, before, after }` (scoped to the element id) |
| `customId` / `customClass` | string |
| `offsetX` / `offsetY` | number (CSS translate) |
| `parallax` | `{ enabled, distance, direction: "vertical"|"horizontal", invert }` |
| `hoverEffect` | `{ type: "none"|"zoom"|"lift"|"slide"|"bg", duration, scale, offset, offsetX, offsetY, bgColor }` |
| `conditions` | `{ enabled, action: "show"|"hide", match: "all"|"any", rules: [{ type: "url_param"|"cookie"|"session", key, op, value }] }` |

**Numeric rule:** store numbers as numbers (e.g. `fontSize: 48`, `borderRadius: 8`). Units (`px`)
are appended at render time. Never store `"48px"` where a number is expected.

### 6.5 Background object

```json
{
  "type": "none|color|gradient|image|video",
  "color": "#0f172a", "colorOpacity": 100,
  "gradientType": "linear|radial", "gradientStops": [{ "color": "#...", "pos": 0, "opacity": 100 }], "gradientAngle": 135,
  "radialShape": "circle", "radialPos": "center",
  "image": "https://...", "size": "cover", "position": "center", "repeat": "no-repeat", "attachment": "scroll", "priority": false,
  "overlayEnabled": false, "overlayType": "color|gradient", "overlayColor": "#000", "overlayColor2": "#000",
  "overlayOpacity": 40, "overlayAngle": 135, "overlayStop1": 0, "overlayStop2": 100, "overlayRadialShape": "circle",
  "videoUrl": "https://...", "videoLoop": true
}
```

### 6.6 Responsive model

- Two viewports only: **`tablet`** (`max-width: 991px`) and **`mobile`** (`max-width: 575px`).
  Row column stacking uses tablet `768-991px` / mobile `max-width: 767px`.
- Responsive-capable props: `padding`, `margin`, `bg`, `textAlign`, `customCss` at every level;
  additionally `cols` and `colWidths` on rows, and `order` on columns.
- **Visibility:** most blocks (and sections/rows/cols) support
  `settings.hidden: { desktop, tablet, mobile }` → `vb-hide-on-*`. Prefer this over CSS `display`
  hacks when you need a desktop-only CTA vs a mobile-only CTA.
- **Column order:** set `responsive.tablet|mobile.element.order` on a column to reorder without
  duplicating content.
- V2 stores them under `responsive[tablet|mobile].{element,settings}`; V1 stores flat keys under
  `settings.responsive[...]` (or `data.responsive[...]` for blocks).

**Do not override Aura’s mobile menu CSS.** The menu block emits a desktop nav
(`.vb-menu-desktop-nav`) and a hamburger toggle. At `max-width: 991px` Aura sets
`display: none` on the desktop nav. Any theme/custom CSS that forces
`display: flex !important` (or similar) on `.vb-menu-desktop-nav` / `.vb-menu` **breaks mobile**
and keeps the desktop links visible. Style fonts/colours only; never force `display` on the
desktop nav wrapper.

---

## 7. Compact JSON schema

This is what the AI API accepts/emits. PHP (`vb_expand_block`, `vb_expand_section`,
`vb_expand_row`, `vb_expand_col` in `api/lib.php`) expands it to full Layout JSON and assigns ids.
Omit any key equal to its default; never include ids.

### 7.1 Top level

```json
{ "sections": [ /* compact section objects */ ] }
```

### 7.2 Section / row / column (compact)

**Section**
```json
{
  "bg": "#hex" | ["#from", "#to", 135] | ["img", "https://picsum.photos/seed/word/1920/1080"],
  "pt": 80, "pb": 80,
  "fluid": false,      // false -> contained (.vb-container); true -> full width
  "va": "c",            // vertical align: c|fs|fe|sb|s
  "rows": [ /* rows */ ],
  "s": { /* optional compact settings, see 7.4 */ }
}
```

**Row**
```json
{ "cw": [58, 42], "g": 24, "ai": "s", "jc": "flex-start", "cols": [ /* cols */ ], "s": {} }
```
`cw` (column widths) must sum to 100. Prefer asymmetric splits like `[58,42]` or `[60,40]`.

**Column**
```json
{ "b": [ /* blocks */ ], "bg": "#hex", "ai": "flex-start", "jc": "flex-start", "rows": [], "s": {} }
```

### 7.3 Compact block codes (authoritative argument order)

Each block is an array: `[code, arg1, arg2, ...]`. Defaults shown in parentheses; omit trailing
defaults. **This table (from `vb_expand_block`) is authoritative** - the prompt schema is a subset.

| Code | type | args (in order) |
|---|---|---|
| `tx` | text | `text(HTML)`, `textColor(#333333)`, `fontSize(16px)`, `textAlign(left)` |
| `he` | heading | `text`, `tag(h2)`, `fontSize(36px)`, `textColor(#1e293b)`, `fontWeight(700)`, `textAlign(left)` |
| `img` | image | `src`, `alt(Image)`, `width(100%)` |
| `ico` | icon | `icon(fa-solid:star)`, `color(#6366f1)`, `size(48px)` |
| `btn` | button | `label(Button)`, `url(#)`, `style(primary)`, `size(md)`, `align(left)` |
| `btg` | buttongroup | `buttons[[label,url,style?,size?],...]`, `align(left)`, `gap(12)` |
| `sp` | spacer | `height(20)` |
| `dv` | divider | `color(#e2e8f0)` |
| `ls` | list | `listType(check)`, `items[]` (strings or `{text}`), `checkColor(#6366f1)` |
| `card` | card | `title`, `text(body HTML)`, `variant(default\|horizontal\|overlay)`, `image(url)`, `buttonLabel(Read More)`, `buttonUrl(#)` |
| `tst` | testimonial | `quote`, `author`, `role`, `rating(5)` |
| `acc` | accordion | `items[[title,html],...]`, `design(line)` |
| `tab` | tabs | `items[[label,html],...]`, `design(pills)` |
| `cnt` | counter | `end`, `label`, `prefix`, `suffix` |
| `mq` | marquee | `items[]` (string -> text; `{src,alt}` -> image), `speed(40)`, `textColor(#1e293b)` |
| `gal` | gallery | `columns(3)`, `urls[]`, `gap(12)` |
| `pt` | pricingtable | `name`, `price`, `period(/month)`, `features[]` (string or `{text,inc}`), `accentColor(#6366f1)` |
| `tbl` | table | `header[]` (string[]), `rows[][]` (string[][]) |

Any block not covered by a short code can be supplied as a **full block object** with a `type`
(and no `id`) in the `b`/blocks array - e.g. `slider`, `carousel`, `iframe`, `socialicons`,
`countdown`, and all integration blocks. It passes through and gets normalised.

### 7.4 Compact settings short keys (`s`)

`p` padding (`{t,r,b,l}`), `m` margin, `bg` background (`{t,c,i,s,p,a,r}` or a string),
`h` hidden (`{d,t,m}`), `id` customId, `cl` customClass, `cx` customCss, `bdr` border,
`shd` shadow, `an` animation, `px` parallax, `hv` hoverEffect, `ox` offsetX, `oy` offsetY.

### 7.5 Background expansion (`vb_expand_bg`)

- `"#0f172a"` -> solid colour
- `["#6366f1", "#8b5cf6", 135]` -> linear gradient (angle optional)
- `["img", "https://picsum.photos/seed/word/1920/1080"]` -> cover image

Use `https://picsum.photos/seed/KEYWORD/W/H` for placeholder images and `fa-solid:NAME` for icons.

---

## 8. Block reference (V2 examples)

Edit this guide only under `Joomla/com_aurabuilder/media/com_aurabuilder/docs/AI-GUIDE.md`. WP / Laravel / `cdn/docs` copies are synced by `build.ps1` — do not hand-edit those mirrors.

### V2 layout schema (blocks)

Authoritative saved shape for new output is **V2**. Each content block is:

```json
{ "id": "blk_...", "type": "heading", "element": { }, "settings": { } }
```

- `element` holds type-specific content keys (what V1 stored in `data`).
- `settings` holds [common wrapper settings](#64-common-wrapper-settings) (padding, background, hidden, etc.).
- V1 `{ id, type, data }` still loads; the editor migrates it. Prefer `element` in new JSON.
- Page exports also include top-level `version` + `meta` (see [6.1](#61-top-level)).
- Every block below also accepts common wrapper settings. `ensureBlock()` seeds missing keys.
- Do not invent keys. Examples match `editor.bundle.js` defaults / a real V2 export.

### Minimal page shell (V2)

```json
{
  "version": 2,
  "meta": {
    "container": "contained",
    "schema": "aura-builder-v2"
  },
  "nodes": [
    {
      "type": "section",
      "id": "sec_1",
      "element": {
        "layout": "container",
        "sectionVAlign": "center"
      },
      "settings": {
        "padding": {
          "top": 80,
          "right": 0,
          "bottom": 80,
          "left": 0
        }
      },
      "rows": [
        {
          "id": "row_1",
          "element": {
            "gap": 24,
            "colWidths": [
              100
            ]
          },
          "cols": [
            {
              "id": "col_1",
              "blocks": [
                {
                  "id": "blk_h",
                  "type": "heading",
                  "element": {
                    "text": "Hello",
                    "tag": "h1"
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}
```

### Sub-layout blocks (summary)

| Block | Nested layout key | Notes |
|---|---|---|
| `accordion` | `items[].rows` | FAQ panels |
| `tabs` | `tabs[].rows` | Tab panels |
| `modal` | `rows` | Modal body |
| `slider` | `slides[].rows` | Per-slide canvas |
| `carousel` | `items[].rows` | Per-card canvas |
| `table` | `cells[][].rows` | Rich cells (or `content` string) |
| `rowblock` | `cols[].blocks` | Inline row (no `rows`) |
| `postloop` | `loopLayout[]` | Repeated item template |
| `group` | `blocks[]` | Nested blocks, not rows |

Each nested `rows[]` is a full array of [row objects](#63-nodelevel-shapes-v1-vs-v2) (columns -> blocks), nestable one level deep.

### 8.1 Basic

Canvas (`editor.bundle.js`) and frontend (`Renderer.php`) share the same markup and CSS classes from `blocks.css`. Editor chrome (outlines, empty placeholders) must not change typography, padding, or colour of these blocks.

#### `text`

Rich HTML body wrapped in `wrapTag`. Canvas (`TextCanvasRender`) and frontend use:

```
<{wrapTag} class="vb-text-content [lineHeight] [sizeClass] [letterSpacingClass] [fw-*]">
  {text HTML}
</{wrapTag}>
```

**Keys**

| Key | Type | Default | Notes |
|---|---|---|---|
| `text` | HTML | lorem paragraph | Source of truth. Click canvas to edit inline. |
| `wrapTag` | string | `div` | `div` \| `p` \| `span` \| `blockquote` \| `h1`-`h6` |
| `textAlign` | string | `left` | `left` \| `center` \| `right` \| `justify` |
| `textColor` | CSS color | inherit | Inline `color` |
| `fontSize` | string | `""` | Inspector stores `"16px"` etc. Applied **only when `sizeClass` is empty**. Bare numbers get `px`. |
| `sizeClass` | string | `""` | Fluid clamp class `fs-display-1`..`6`. Wins over `fontSize`. Picking 24px+ in Font Size also sets this. |
| `fontWeight` | string | `""` | `300`..`900`. Adds `fw-*` class **and** inline `font-weight`. |
| `lineHeight` | string | `""` | Class only: `vb-lh-1` \| `vb-lh-tight` \| `vb-lh-snug` \| `vb-lh-normal` \| `vb-lh-relaxed` \| `vb-lh-loose` |
| `letterSpacingClass` | string | `""` | Class only: `vb-ls-tight` \| `vb-ls-wide` \| `vb-ls-wider` \| `vb-ls-widest` |
| `textTransform` | string | `""` | `uppercase` \| `lowercase` \| `capitalize` \| `full-width` |
| `textMask` | object | `{ type: "none" }` | `fade` (mask-image) or `gradient` (background-clip text) |
| `contentPrepare` | bool | WP: true, Joomla: false | Shortcodes / `{loadmoduleid}` on the frontend only |

Legacy V1 keys `content` / `align` / `color` migrate to `text` / `textAlign` / `textColor`.

Do **not** put inline `font-size` on the wrapper when `sizeClass` is set -- canvas and PHP both skip it so the clamp() class can win.

```json
{
  "id": "blk_text",
  "type": "text",
  "element": {
    "text": "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>",
    "wrapTag": "div",
    "textAlign": "left",
    "textColor": "#334155",
    "fontSize": "16px",
    "lineHeight": "vb-lh-normal"
  }
}
```

#### `heading`

Semantic heading. Canvas (`HeadingCanvasRender`) and frontend use:

```
<{tag} class="vb-heading-content [lineHeight] [sizeClass] [letterSpacingClass] [fw-*]">
  {text}
</{tag}>
```

Default sizes live on `:where(hN).vb-heading-content` in `blocks.css` (`h1` 2.5rem ... `h6` 1rem). `sizeClass` / inline `fontSize` override those.

**Keys**

| Key | Type | Default | Notes |
|---|---|---|---|
| `text` | HTML/string | `""` | If the saved string is a single wrapping `<hN>...</hN>`, canvas and PHP unwrap it so the block `tag` wins. |
| `tag` | string | `h2` | `h1` \| `h2` \| `h3` \| `h4` \| `h5` \| `h6` only |
| `textAlign` | string | `left` | Same as text |
| `textColor` | CSS color | inherit | |
| `fontSize` | string | `""` | Same rule as text: skipped when `sizeClass` is set |
| `sizeClass` | string | `""` | `fs-display-1`..`6` |
| `fontWeight` | string | `""` | `300`..`900` |
| `lineHeight` | string | `""` | `vb-lh-*` class |
| `letterSpacingClass` | string | `""` | `vb-ls-*` class |
| `textTransform` | string | `""` | Same as text |
| `textMask` | object | `{ type: "none" }` | Same as text |

```json
{
  "id": "blk_heading",
  "type": "heading",
  "element": {
    "text": "Your Heading Here",
    "tag": "h2",
    "textAlign": "left",
    "textColor": "#0f172a",
    "fontSize": "40px"
  }
}
```

#### `image`

Photo with optional link, lightbox, srcset, and hover zoom. Canvas (`ImageCanvasRender`) and frontend use:

```
<div style="display:flex;justify-content:{align}">
  [<a href="...">]
    [<picture><source type="image/webp">]
    <img class="vb-image-content vb-d-block" src="..." alt="...">
  [</a>]
</div>
```

Empty `src`: canvas shows a placeholder; frontend outputs nothing.

**Keys**

| Key | Type | Default | Notes |
|---|---|---|---|
| `src` | url | `""` | Also accepts legacy `image` |
| `alt` | string | `""` | |
| `linkUrl` | url | `""` | Legacy `url` / `link` migrate here. Ignored when `popupMode` is on. |
| `newTab` | bool | `false` | Or `linkTarget: "_blank"` |
| `linkNofollow` | bool | `false` | |
| `style.width` | CSS length | `""` | Max-width of the img |
| `style.imgWidth` | CSS length | `""` | Explicit width of the img (not the wrapper) |
| `style.height` | CSS length | `""` | |
| `style.objectFit` | string | `cover` | `cover` \| `contain` \| `fill` \| `scale-down` \| `none` |
| `style.align` | string | `left` | `left` \| `center` \| `right` -- flex justify on the wrapper |
| `style.borderRadius` | number | `0` | px |
| `style.boxShadow` | CSS | `""` | Preset strings from the inspector |
| `style.filter` | CSS | `""` | `invert(1)` / `grayscale(1)` / etc. |
| `zoomOnHover` | bool | `false` | Inspector flat key |
| `zoomScale` | string/number | `1.07` | |
| `zoomDuration` | number | `400` | ms |
| `hoverZoom` | object | | `{ enabled, scale, duration }` -- older layouts. Canvas and PHP read **both** this and the flat keys. |
| `popupMode` | bool | `false` | Fancybox lightbox (frontend). Canvas still shows the img. |
| `popupSrc` / `popupGroup` / `popupCaption` | string | `""` | Lightbox extras |
| `priority` | bool | `false` | Eager + preload. Also auto-on for first-section images. |
| `imgSrcset` | object | | `{ sm, md, sm_w, md_w, sm_webp, md_webp, full_webp }` |
| `naturalWidth` / `naturalHeight` | number | | Emitted as HTML width/height |

`object-fit` only crops when `style.height` is set. Alignment is on the wrapper, never by shrinking the wrapper to the image width.

```json
{
  "id": "blk_image",
  "type": "image",
  "element": {
    "src": "https://demo.cmsaura.com/images/logo 2.png",
    "alt": "Site logo",
    "linkUrl": "",
    "newTab": false,
    "linkNofollow": false,
    "style": {
      "width": "100%",
      "imgWidth": "",
      "height": "auto",
      "objectFit": "cover",
      "align": "left",
      "borderRadius": 0,
      "boxShadow": "none"
    },
    "zoomOnHover": false,
    "zoomScale": "1.07",
    "zoomDuration": 400
  }
}
```

#### `alert`

Notice bar. Canvas (`AlertCanvasRender`) and frontend use the **same classes** (no inline colours):

```
.vb-alert.vb-alert--{info|success|warning|danger}[role=alert]
  i.fas.fa-*
  .vb-alert-body
    strong.vb-alert-title
    span.vb-alert-message
  button.vb-alert-dismiss
```

**Keys**

| Key | Type | Default | Notes |
|---|---|---|---|
| `alertStyle` | string | `info` | `info` \| `success` \| `warning` \| `danger` |
| `title` | string | `""` | Hidden when empty |
| `message` | string | starter copy | Plain text (HTML-escaped on the frontend) |
| `icon` | bool | `true` | |
| `dismissible` | bool | `false` | Close button. Frontend markup only; no JS hide unless the site adds it. |

```json
{
  "id": "blk_alert",
  "type": "alert",
  "element": {
    "alertStyle": "success",
    "title": "",
    "message": "This is an important notice.",
    "icon": true,
    "dismissible": false
  }
}
```

#### `button`

Single CTA. Canvas and frontend use:

```
.vb-btn-wrapper[style=justify-content]
  a.vb-btn.vb-btn-{style}.vb-btn-{size}[.hoverAnim]
    [icon] <span>{label}</span>
```

**Keys**

| Key | Type | Default | Notes |
|---|---|---|---|
| `label` | string | `Click Here` | |
| `url` | url | `#` | |
| `newTab` | bool | `false` | Adds `rel="noopener noreferrer"` |
| `ariaLabel` | string | `""` | Optional `aria-label` on the `<a>` |
| `style` | string | `primary` | `primary` \| `secondary` \| `outline` \| `ghost` \| `custom` |
| `size` | string | `md` | `sm` \| `md` \| `lg` |
| `align` | string | `left` | `left` \| `center` \| `right` |
| `borderRadius` | number | unset | px. CSS default is 6px when omitted. |
| `iconClass` | string | `""` | `fas fa-*` renders `<i>`; any other Iconify id renders `<iconify-icon>` |
| `iconPos` | string | `before` | `before` \| `after` |
| `iconColor` | CSS color | `""` | |
| `hoverAnim` | string | `""` | `vb-btn-hover-scale` \| `vb-btn-hover-right` \| `vb-btn-hover-bounce` \| `vb-btn-hover-glow` |
| `bgColor` / `textColor` / `borderColor` | CSS color | `""` | Inline overrides. Setting these in the inspector switches `style` to `custom`. |
| `bgHoverColor` / `textHoverColor` / `borderHoverColor` | CSS color | `""` | Scoped `:hover` CSS |

Picking a preset style can stamp brand-kit / block-default colours onto the override keys. `custom` is a real class (`.vb-btn-custom`), not a fallback to primary.

```json
{
  "id": "blk_button",
  "type": "button",
  "element": {
    "label": "Click Here",
    "url": "#url",
    "style": "primary",
    "size": "md",
    "align": "left",
    "newTab": false,
    "iconClass": "fas fa-paper-plane",
    "iconPos": "after"
  }
}
```

#### `buttongroup`

Row of buttons, or a text link list. Canvas and frontend match.

**Group keys:** `layout` (`buttons` \| `list`), `align` (`left` \| `center` \| `right`), `gap` (px, buttons layout), `listColor` / `listSize` / `listSeparator` (list layout).

**Each button:** `{ id, label, url, style, size, newTab, ariaLabel, iconClass, iconPos, iconColor, hoverAnim, bgColor, textColor, borderColor, borderRadius, hoverBg, hoverText, hoverBorder }`.

`style` / `size` enums match the single `button` block. List layout ignores button chrome and renders `<a>` + separator spans.

```json
{
  "id": "blk_buttongroup",
  "type": "buttongroup",
  "element": {
    "layout": "buttons",
    "buttons": [
      {
        "id": "btn_1",
        "label": "Get started",
        "url": "/register",
        "style": "primary",
        "size": "md",
        "newTab": false
      },
      {
        "id": "btn_2",
        "label": "Learn more",
        "url": "/about",
        "style": "secondary",
        "size": "md",
        "newTab": false
      }
    ],
    "align": "left",
    "gap": 12
  }
}
```

#### `spacer`

Blank vertical gap. `height` is a **number** (px). Frontend: the block wrapper itself is `height:{n}px` plus class `vb-spacer`. Canvas: an inner `.vb-spacer` with the same height.

```json
{
  "id": "blk_spacer",
  "type": "spacer",
  "element": {
    "height": 60
  }
}
```

#### `divider`

Horizontal rule. Canvas and frontend:

```
.vb-divider-wrap[style=justify-content]
  hr.vb-divider-line[style=border-top + width%]
```

**Keys:** `divStyle` (`solid` \| `dashed` \| `dotted`), `color` (CSS), `thickness` (px, number), `width` (percent 10-100), `align` (`left` \| `center` \| `right`).

```json
{
  "id": "blk_divider",
  "type": "divider",
  "element": {
    "divStyle": "solid",
    "color": "#e2e8f0",
    "thickness": 1,
    "width": 100,
    "align": "center"
  }
}
```

#### `html`

Raw HTML in `html`. Wrapped in `.vb-html-content`. Frontend runs `{variable}` replacement. Prefer a native Aura block when one exists. Empty canvas shows a placeholder; do not add editor padding around rendered HTML.

```json
{
  "id": "blk_html",
  "type": "html",
  "element": {
    "html": "<div class=\"custom-note\"><strong>Note:</strong> Custom markup.</div>"
  }
}
```

### 8.2 Media

#### `icon`

`icon` is an Iconify id (e.g. `mdi:star`). `align`: `left`|`center`|`right`. `size` is px.

```json
{
  "id": "blk_icon",
  "type": "icon",
  "element": {
    "icon": "mdi:star",
    "size": 48,
    "color": "#1e293b",
    "align": "left"
  }
}
```

#### `video`

`url` accepts YouTube, Vimeo, or a direct file URL. `aspectRatio` e.g. `16/9`, `4/3`, `1/1`.

```json
{
  "id": "blk_video",
  "type": "video",
  "element": {
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "aspectRatio": "16/9",
    "controls": true
  }
}
```

#### `gallery`

`layout`: `grid`|`masonry`. `columns` is an integer. Optional `filterGroups[]` when `showFilter` is true.

```json
{
  "id": "blk_gallery",
  "type": "gallery",
  "element": {
    "images": [
      {
        "id": "img_1",
        "url": "https://demo.cmsaura.com/images/aura/20260715_030928_7c15e93b_full.jpg",
        "alt": "Gallery one"
      },
      {
        "id": "img_2",
        "url": "https://demo.cmsaura.com/images/aura/20260715_030928_7c15e93b_full.jpg",
        "alt": "Gallery two"
      }
    ],
    "layout": "grid",
    "columns": 3,
    "gap": 12,
    "imgHeight": "220px",
    "borderRadius": 4,
    "lightbox": true,
    "showFilter": false,
    "filterGroups": []
  }
}
```

#### `galleryslider`

`objectFit`: `cover`|`contain`. `interval` is ms when `autoplay` is true.

```json
{
  "id": "blk_galleryslider",
  "type": "galleryslider",
  "element": {
    "images": [
      {
        "id": "gs_1",
        "url": "https://demo.cmsaura.com/images/aura/20260715_030928_7c15e93b_full.jpg",
        "alt": "Slide one"
      }
    ],
    "autoplay": false,
    "interval": 4000,
    "minHeight": 420,
    "objectFit": "cover",
    "showDots": true,
    "showArrows": true
  }
}
```

#### `lottie`

`src` is a Lottie JSON/URL. `align`: `left`|`center`|`right`. `loop`/`autoplay` booleans; `speed` is a number.

```json
{
  "id": "blk_lottie",
  "type": "lottie",
  "element": {
    "src": "https://assets.cmsaura.com/lottie/success.json",
    "align": "center",
    "width": "280px",
    "height": "280px",
    "loop": true,
    "autoplay": true,
    "speed": 1
  }
}
```

#### `featuredimage`

Dynamic featured/intro image for the current article/post context. `link` wraps the image when true.

```json
{
  "id": "blk_featuredimage",
  "type": "featuredimage",
  "element": {
    "imgHeight": "300px",
    "borderRadius": 0,
    "shadow": false,
    "link": false
  }
}
```

### 8.3 Interactive

#### `accordion`

Each item: `{ id, title, rows[], defaultOpen? }`. Nested `rows[]` is a full row/col/block sub-layout. `allowMultiple` is boolean.

```json
{
  "id": "blk_accordion",
  "type": "accordion",
  "element": {
    "items": [
      {
        "id": "acc_1",
        "title": "Question 1",
        "defaultOpen": true,
        "rows": [
          {
            "id": "row_nested_1",
            "element": {
              "gap": 24,
              "alignItems": "stretch"
            },
            "cols": [
              {
                "id": "col_nested_1",
                "blocks": [
                  {
                    "id": "blk_nested_text",
                    "type": "text",
                    "element": {
                      "text": "<p>Nested content here.</p>"
                    }
                  }
                ]
              }
            ]
          }
        ]
      },
      {
        "id": "acc_2",
        "title": "Question 2",
        "defaultOpen": false,
        "rows": [
          {
            "id": "row_nested_1",
            "element": {
              "gap": 24,
              "alignItems": "stretch"
            },
            "cols": [
              {
                "id": "col_nested_1",
                "blocks": [
                  {
                    "id": "blk_nested_text",
                    "type": "text",
                    "element": {
                      "text": "<p>Nested content here.</p>"
                    }
                  }
                ]
              }
            ]
          }
        ]
      }
    ],
    "allowMultiple": true
  }
}
```

#### `tabs`

`design`: `underline`|`pills`|`boxed`. `orientation`: `top`|`left`. Each tab: `{ id, label, icon?, rows[] }`.

```json
{
  "id": "blk_tabs",
  "type": "tabs",
  "element": {
    "design": "underline",
    "orientation": "top",
    "activeColor": "#6366f1",
    "tabs": [
      {
        "id": "tab1",
        "label": "Tab One",
        "rows": [
          {
            "id": "row_nested_1",
            "element": {
              "gap": 24,
              "alignItems": "stretch"
            },
            "cols": [
              {
                "id": "col_nested_1",
                "blocks": [
                  {
                    "id": "blk_nested_text",
                    "type": "text",
                    "element": {
                      "text": "<p>Nested content here.</p>"
                    }
                  }
                ]
              }
            ]
          }
        ]
      },
      {
        "id": "tab2",
        "label": "Tab Two",
        "rows": [
          {
            "id": "row_nested_1",
            "element": {
              "gap": 24,
              "alignItems": "stretch"
            },
            "cols": [
              {
                "id": "col_nested_1",
                "blocks": [
                  {
                    "id": "blk_nested_text",
                    "type": "text",
                    "element": {
                      "text": "<p>Nested content here.</p>"
                    }
                  }
                ]
              }
            ]
          }
        ]
      }
    ]
  }
}
```

#### `modal`

`triggerType`: `button`|`image`|`text`. `triggerStyle`/`triggerSize` follow button enums. Body content lives in `rows[]`.

```json
{
  "id": "blk_modal",
  "type": "modal",
  "element": {
    "triggerType": "button",
    "triggerText": "Open Modal",
    "triggerStyle": "primary",
    "triggerSize": "md",
    "align": "left",
    "modalTitle": "Modal title",
    "modalWidth": 700,
    "rows": [
      {
        "id": "row_nested_1",
        "element": {
          "gap": 24,
          "alignItems": "stretch"
        },
        "cols": [
          {
            "id": "col_nested_1",
            "blocks": [
              {
                "id": "blk_nested_text",
                "type": "text",
                "element": {
                  "text": "<p>Nested content here.</p>"
                }
              }
            ]
          }
        ]
      }
    ]
  }
}
```

#### `slider`

Each slide may include `heading`, `subtext`, `buttonLabel`, `buttonUrl`, `buttonStyle`, `bgColor`, `textColor`, `align`, plus nested `rows[]`.

```json
{
  "id": "blk_slider",
  "type": "slider",
  "element": {
    "slides": [
      {
        "id": "sl_1",
        "heading": "Slide one",
        "subtext": "Supporting line",
        "buttonLabel": "Learn more",
        "buttonUrl": "#",
        "buttonStyle": "primary",
        "bgColor": "#1e293b",
        "textColor": "#ffffff",
        "align": "center",
        "rows": [
          {
            "id": "row_nested_1",
            "element": {
              "gap": 24,
              "alignItems": "stretch"
            },
            "cols": [
              {
                "id": "col_nested_1",
                "blocks": [
                  {
                    "id": "blk_nested_text",
                    "type": "text",
                    "element": {
                      "text": "<p>Nested content here.</p>"
                    }
                  }
                ]
              }
            ]
          }
        ]
      }
    ],
    "autoplay": false,
    "interval": 4000,
    "minHeight": 400,
    "showDots": true,
    "showArrows": true,
    "borderRadius": 0
  }
}
```

#### `carousel`

`arrowStyle`/`dotStyle`: e.g. `circle`. Each item: `{ id, bgImage?, bgColor?, rows[] }`. `perPage` is slides visible.

```json
{
  "id": "blk_carousel",
  "type": "carousel",
  "element": {
    "items": [
      {
        "id": "car_1",
        "bgColor": "#ffffff",
        "rows": [
          {
            "id": "row_nested_1",
            "element": {
              "gap": 24,
              "alignItems": "stretch"
            },
            "cols": [
              {
                "id": "col_nested_1",
                "blocks": [
                  {
                    "id": "blk_nested_text",
                    "type": "text",
                    "element": {
                      "text": "<p>Nested content here.</p>"
                    }
                  }
                ]
              }
            ]
          }
        ]
      },
      {
        "id": "car_2",
        "bgColor": "#f8fafc",
        "rows": [
          {
            "id": "row_nested_1",
            "element": {
              "gap": 24,
              "alignItems": "stretch"
            },
            "cols": [
              {
                "id": "col_nested_1",
                "blocks": [
                  {
                    "id": "blk_nested_text",
                    "type": "text",
                    "element": {
                      "text": "<p>Nested content here.</p>"
                    }
                  }
                ]
              }
            ]
          }
        ]
      }
    ],
    "perPage": 3,
    "gap": 24,
    "autoplay": false,
    "loop": true,
    "cardBg": "#ffffff",
    "arrowStyle": "circle",
    "dotStyle": "circle"
  }
}
```

#### `marquee`

`direction`: `left`|`right`. Items are `{ type:"text", text }` or `{ type:"image", image, link }`.

```json
{
  "id": "blk_marquee",
  "type": "marquee",
  "element": {
    "items": [
      {
        "type": "text",
        "text": "Ship faster with Aura"
      },
      {
        "type": "image",
        "image": "https://demo.cmsaura.com/images/logo 2.png",
        "link": "/"
      }
    ],
    "speed": 40,
    "gap": 80,
    "direction": "left",
    "textColor": "#1e293b",
    "fontSize": 18,
    "fontWeight": "600"
  }
}
```

#### `counter`

`align`: `left`|`center`|`right`. `duration` is ms. `endValue` is numeric.

```json
{
  "id": "blk_counter",
  "type": "counter",
  "element": {
    "endValue": 500,
    "prefix": "",
    "suffix": "+",
    "label": "Happy Clients",
    "fontSize": 56,
    "labelSize": 16,
    "duration": 2000,
    "color": "#0f172a",
    "labelColor": "#64748b",
    "fontWeight": "700",
    "align": "center"
  }
}
```

#### `countdown`

`targetDate` is ISO-8601. `align`: `left`|`center`|`right`.

```json
{
  "id": "blk_countdown",
  "type": "countdown",
  "element": {
    "targetDate": "2026-12-31T23:59:59",
    "numSize": 40,
    "accentColor": "#6366f1",
    "numColor": "#0f172a",
    "textColor": "#64748b",
    "align": "center",
    "gap": 16,
    "blockRadius": 10,
    "labelDays": "Days",
    "labelHours": "Hours",
    "labelMins": "Mins",
    "labelSecs": "Secs"
  }
}
```

#### `timeline`

`style`: `alternating`|`left`|`right`. Items: `{ id, year, title, text }`.

```json
{
  "id": "blk_timeline",
  "type": "timeline",
  "element": {
    "style": "alternating",
    "lineColor": "#e2e8f0",
    "dotColor": "#6366f1",
    "dotSize": 14,
    "cardBg": "#ffffff",
    "borderColor": "#e2e8f0",
    "borderRadius": 10,
    "titleColor": "#0f172a",
    "textColor": "#475569",
    "yearSize": 12,
    "titleSize": 17,
    "textSize": 14,
    "items": [
      {
        "id": "tl_1",
        "year": "2024",
        "title": "Launched",
        "text": "First public release."
      },
      {
        "id": "tl_2",
        "year": "2025",
        "title": "Grew",
        "text": "Added AI generation."
      }
    ]
  }
}
```

#### `testimonial`

Star count `stars` is 1-5. Optional `avatarImage` URL.

```json
{
  "id": "blk_testimonial",
  "type": "testimonial",
  "element": {
    "stars": 5,
    "quote": "Aura made rebuilding our site straightforward.",
    "author": "Alex Morgan",
    "role": "Marketing Lead",
    "avatarImage": "",
    "avatarColor": "#6366f1",
    "starColor": "#f59e0b",
    "textColor": "#334155",
    "authorColor": "#0f172a",
    "bgColor": "#ffffff",
    "borderRadius": 12,
    "shadow": true,
    "fontSize": 16
  }
}
```

#### `videotestimonial`

`videoUrl` is YouTube/Vimeo/file. `overlayOpacity` is 0-100.

```json
{
  "id": "blk_videotestimonial",
  "type": "videotestimonial",
  "element": {
    "image": "https://demo.cmsaura.com/images/aura/20260715_030928_7c15e93b_full.jpg",
    "videoUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "quote": "A clear win for our content team.",
    "author": "Jamie Lee",
    "role": "Editor",
    "imgHeight": 380,
    "borderRadius": 12,
    "overlayColor": "#000000",
    "overlayOpacity": 45
  }
}
```

#### `float`

`contentType`: `image`|`text`|`button`. `linkTarget`: `_self`|`_blank`. Position `posX`/`posY` are percent-like offsets.

```json
{
  "id": "blk_float",
  "type": "float",
  "element": {
    "contentType": "image",
    "src": "https://demo.cmsaura.com/images/logo 2.png",
    "alt": "Floating badge",
    "text": "<p>Need help?</p>",
    "btnLabel": "Chat",
    "btnStyle": "primary",
    "link": "/contact",
    "linkTarget": "_self",
    "posX": 80,
    "posY": 10,
    "width": 200,
    "zIndex": 10,
    "parallaxSpeed": 0,
    "opacity": 1,
    "borderRadius": 0,
    "shadow": "none"
  }
}
```

### 8.4 Content / cards

#### `group`

`groupDirection`: `vertical`|`horizontal`. `groupJustify`/`groupAlign`: flex values e.g. `flex-start`|`center`|`space-between`. Nested `blocks[]` are normal blocks.

```json
{
  "id": "blk_group",
  "type": "group",
  "element": {
    "blocks": [
      {
        "id": "blk_g_h",
        "type": "heading",
        "element": {
          "text": "Grouped heading",
          "tag": "h3"
        }
      },
      {
        "id": "blk_g_t",
        "type": "text",
        "element": {
          "text": "<p>Grouped paragraph.</p>"
        }
      }
    ],
    "groupDirection": "vertical",
    "groupJustify": "flex-start",
    "groupAlign": "flex-start",
    "groupGap": 0,
    "groupBgColor": "",
    "groupBorder": {},
    "groupShadow": "none"
  }
}
```

#### `card`

Single promotional / feature card. Canvas (`CardCanvasRender`) and frontend (`Renderer.php`) use the **same markup**:

```
.vb-card[.is-horizontal|.is-overlay]
  .vb-card-img-wrap > img
  [.vb-card-overlay-inner.is-pos-*]   overlay only
    .vb-card-body                     rich HTML from `text`
    .vb-card-footer                   optional button
```

**Variants (`variant`)** -- this is the card chrome, not a query card grid:

| Value | Layout | `contentPosition` |
|---|---|---|
| `default` | Image on top, text below, white card | Ignored (inspector hides it) |
| `horizontal` | Image left (~40%), text column right | Vertical align of the text column: `top` / `center` / `bottom` |
| `overlay` | Image fills the card; gradient + white text on top | Where the text sits on the photo: `top` / `center` / `bottom` |

Do **not** confuse this with Post Loop / WP Query `cardVariant` (`default` / `horizontal` / `overlay` / `hover` / `custom`). Those are list-item skins. This block is one authored card.

**Content model**

- `text` is the source of truth: rich HTML (typically `<h4 class="vb-card-title">` + `<p>`).
- Compact AI code `["card", title, body, variant, imageUrl]` still sends a separate `title`. Renderer and canvas prepend that title as an `h4` **only if** `text` has no `<h1>`-`<h6>` yet. Once the body has a heading, `title` is ignored and cleared on edit -- never re-injected while typing.
- There is no `titleTag`. Do not invent `custom` as a card `variant`.
- `imgHeight` is a **number** (px). Legacy `"200px"` strings are normalised to `200`.

**Keys**

| Key | Type | Default | Notes |
|---|---|---|---|
| `variant` | string | `default` | `default` \| `horizontal` \| `overlay` |
| `image` | url | `""` | Card photo |
| `imageAlt` | string | `""` | img alt |
| `imgHeight` | number | `220` | Image / overlay min-height in px |
| `text` | HTML | starter heading + paragraph | Click canvas body to edit; Edit HTML modal writes here |
| `title` | string | `""` | AI leftover; merged at render if missing from `text` |
| `contentPosition` | string | `bottom` | Overlay + horizontal only |
| `textAlign` | string | `left` | `left` \| `center` \| `right` |
| `textPadding` | number | `20` | Body padding px |
| `borderRadius` | number | `8` | Card radius px |
| `shadow` | bool | `true` | Drop shadow |
| `buttonShow` | bool | `false` | Footer CTA |
| `buttonLabel` | string | `Read More` | |
| `buttonUrl` | url | `#` | |
| `buttonStyle` | string | `primary` | `primary` \| `secondary` \| `outline` \| `ghost` |
| `newTab` | bool | `false` | |

**How it renders**

- Overlay uses a real `<img>` in `.vb-card-img-wrap` (absolute, inset 0) plus `.vb-card-overlay-inner.is-pos-*` for the gradient. Do not set `background-image` on `.vb-card` -- theme `background: #fff` was hiding the photo.
- Overlay text colour is CSS (`color: #fff` on the inner), not inline spans. Do not bake `color: rgb(255,255,255)` into `text`.
- Horizontal stacks image + `.vb-card-col` (flex column). Below 575px it becomes a stacked default card.
- Default is image, then body, then optional footer.

**Compact example**

```json
["card", "General Electrical", "<p>From power points and lighting to fault finding.</p>", "overlay", "https://example.com/photo.jpg"]
```

**Full block example**

```json
{
  "id": "blk_card",
  "type": "card",
  "element": {
    "variant": "overlay",
    "image": "https://demo.cmsaura.com/images/aura/20260715_030928_7c15e93b_full.jpg",
    "imageAlt": "Electrician testing a circuit",
    "imgHeight": 300,
    "text": "<h4 class=\"vb-card-title\">General Electrical</h4><p>From power points and lighting to fans, switchboards, fault finding and everything in between.</p>",
    "contentPosition": "bottom",
    "textAlign": "left",
    "textPadding": 20,
    "borderRadius": 8,
    "shadow": true,
    "buttonShow": false,
    "buttonLabel": "Read More",
    "buttonUrl": "#",
    "buttonStyle": "primary",
    "newTab": false
  }
}
```

#### `list`

`listType`: `ul`|`ol`|`check`. Items: `{ id, text }`.

```json
{
  "id": "blk_list",
  "type": "list",
  "element": {
    "listType": "ul",
    "items": [
      {
        "id": "li_1",
        "text": "First item"
      },
      {
        "id": "li_2",
        "text": "Second item"
      }
    ],
    "checkColor": "#6366f1"
  }
}
```

#### `table`

Cells are `{ id, content }` for plain text, or `{ id, rows[] }` for rich nested layout. Legacy `rows: string[][]` migrates to `cells`.

```json
{
  "id": "blk_table",
  "type": "table",
  "element": {
    "columns": 3,
    "headerRow": true,
    "striped": true,
    "bordered": true,
    "cells": [
      [
        {
          "id": "tc_h1",
          "content": "Column 1"
        },
        {
          "id": "tc_h2",
          "content": "Column 2"
        },
        {
          "id": "tc_h3",
          "content": "Column 3"
        }
      ],
      [
        {
          "id": "tc_r1c1",
          "content": "Row 1"
        },
        {
          "id": "tc_r1c2",
          "content": "Data"
        },
        {
          "id": "tc_r1c3",
          "content": "Data"
        }
      ]
    ]
  }
}
```

#### `pricingtable`

`buttonStyle`: button enums. `highlightStyle`: e.g. `ring`|`none`. `features` is an array of strings.

```json
{
  "id": "blk_pricingtable",
  "type": "pricingtable",
  "element": {
    "name": "Pro",
    "price": "$49",
    "period": "/month",
    "tagline": "For growing teams",
    "badge": "Popular",
    "features": [
      "Unlimited pages",
      "AI credits",
      "Priority support"
    ],
    "buttonLabel": "Choose Pro",
    "buttonUrl": "/pricing",
    "buttonStyle": "primary",
    "highlightStyle": "ring",
    "accentColor": "#6366f1",
    "bgColor": "#ffffff",
    "textColor": "#0f172a",
    "borderRadius": 12,
    "shadow": true
  }
}
```

#### `socialicons`

`style`: `brand`|`mono`|similar. `align`: `left`|`center`|`right`. Platforms e.g. `facebook`, `twitter`, `instagram`, `linkedin`, `youtube`.

```json
{
  "id": "blk_socialicons",
  "type": "socialicons",
  "element": {
    "links": [
      {
        "id": "soc_1",
        "platform": "facebook",
        "url": "https://facebook.com"
      },
      {
        "id": "soc_2",
        "platform": "linkedin",
        "url": "https://linkedin.com"
      }
    ],
    "size": 40,
    "style": "brand",
    "align": "left",
    "gap": 12,
    "rounded": true
  }
}
```

#### `contact`

Item `type`: `phone`|`email`. `layout`: `stacked`|`inline`. `align`: `left`|`center`|`right`.

```json
{
  "id": "blk_contact",
  "type": "contact",
  "element": {
    "items": [
      {
        "id": "ct_1",
        "type": "phone",
        "value": "+64 21 000 0000",
        "label": "Call us"
      },
      {
        "id": "ct_2",
        "type": "email",
        "value": "hello@example.com",
        "label": "Email"
      }
    ],
    "layout": "stacked",
    "showIcons": true,
    "iconColor": "#6366f1",
    "textColor": "#374151",
    "fontSize": 16,
    "align": "left"
  }
}
```

#### `hours`

Provide seven `items` rows: `{ day, hours, closed }`.

```json
{
  "id": "blk_hours",
  "type": "hours",
  "element": {
    "title": "Business Hours",
    "showTitle": true,
    "highlightToday": true,
    "closedText": "Closed",
    "headingColor": "#0f172a",
    "textColor": "#475569",
    "fontSize": 15,
    "items": [
      {
        "day": "Monday",
        "hours": "9:00 - 17:00",
        "closed": false
      },
      {
        "day": "Tuesday",
        "hours": "9:00 - 17:00",
        "closed": false
      },
      {
        "day": "Wednesday",
        "hours": "9:00 - 17:00",
        "closed": false
      },
      {
        "day": "Thursday",
        "hours": "9:00 - 17:00",
        "closed": false
      },
      {
        "day": "Friday",
        "hours": "9:00 - 17:00",
        "closed": false
      },
      {
        "day": "Saturday",
        "hours": "",
        "closed": true
      },
      {
        "day": "Sunday",
        "hours": "",
        "closed": true
      }
    ]
  }
}
```

#### `shape`

`shape`: e.g. `circle`|`square`|`triangle`|`blob`. Optional mask via `showMask` + `maskImage`.

```json
{
  "id": "blk_shape",
  "type": "shape",
  "element": {
    "shape": "circle",
    "size": 180,
    "fill": "#6366f1",
    "opacity": 1,
    "strokeColor": "",
    "strokeWidth": 0,
    "shadow": false,
    "align": "center",
    "showText": false,
    "textContent": "",
    "textColor": "#ffffff",
    "fontSize": 18,
    "fontWeight": "600",
    "showMask": false,
    "maskImage": "",
    "link": "",
    "linkTarget": false
  }
}
```

#### `shapedivider`

`shape`: e.g. `wave`|`tilt`|`curve`|`triangle`. `flipH`/`flipV` booleans.

```json
{
  "id": "blk_shapedivider",
  "type": "shapedivider",
  "element": {
    "shape": "wave",
    "height": 80,
    "topColor": "#ffffff",
    "bottomColor": "transparent",
    "flipH": false,
    "flipV": false
  }
}
```

### 8.5 Layout

#### `rowblock`

Inline multi-column row inside a column. `alignItems`: `stretch`|`flex-start`|`center`|`flex-end`. Optional `colWidths[]` percents.

```json
{
  "id": "blk_rowblock",
  "type": "rowblock",
  "element": {
    "cols": [
      {
        "id": "rbc_1",
        "blocks": [
          {
            "id": "blk_rb_h",
            "type": "heading",
            "element": {
              "text": "Left column",
              "tag": "h3"
            }
          }
        ]
      },
      {
        "id": "rbc_2",
        "blocks": [
          {
            "id": "blk_rb_t",
            "type": "text",
            "element": {
              "text": "<p>Right column copy.</p>"
            }
          }
        ]
      }
    ],
    "gap": 24,
    "alignItems": "stretch",
    "colWidths": [
      50,
      50
    ]
  }
}
```

### 8.6 Aura (integration blocks)

#### `section`

Embeds a saved Aura section by integer id. Do not invent ids - use an existing section.

```json
{
  "id": "blk_section",
  "type": "section",
  "element": {
    "sectionId": 12,
    "sectionTitle": "Site header"
  }
}
```

#### `auraform`

`formId` must be an existing Aura Form id. `showTitle` is boolean.

```json
{
  "id": "blk_auraform",
  "type": "auraform",
  "element": {
    "formId": 3,
    "showTitle": true
  }
}
```

#### `auramailsignup`

`listId` must be an existing Aura Mail list id.

```json
{
  "id": "blk_auramailsignup",
  "type": "auramailsignup",
  "element": {
    "listId": 2,
    "titleText": "Join our newsletter"
  }
}
```

#### `memberlist`

`status`: e.g. `active`|`all`. `limit` caps results.

```json
{
  "id": "blk_memberlist",
  "type": "memberlist",
  "element": {
    "status": "active",
    "limit": 12,
    "showPlan": true
  }
}
```

#### `auramembersplans`

`categoryId` 0 = all plan categories. `showToggle` shows Monthly/Yearly. `toggleSaveText` is the Yearly badge (default `Save 20%`; empty hides it). CTA links append `plan_id=` to `buttonUrl` (Aura Members checkout). Separate month/year plan records are filtered by tab; a single plan with both `price_monthly` and `price_yearly` swaps the amount on the same card.

```json
{
  "id": "blk_auramembersplans",
  "type": "auramembersplans",
  "element": {
    "categoryId": 0,
    "showToggle": true,
    "toggleSaveText": "2 months free",
    "buttonText": "Get Started",
    "buttonUrl": "/register",
    "accentColor": "#6366f1"
  }
}
```

#### `doclist`

Aura Docs list. `categoryId` 0 = all categories.

```json
{
  "id": "blk_doclist",
  "type": "doclist",
  "element": {
    "limit": 10,
    "categoryId": 0,
    "showDescription": true
  }
}
```

#### `docsingle`

Single Aura Doc by `documentId`.

```json
{
  "id": "blk_docsingle",
  "type": "docsingle",
  "element": {
    "documentId": 5,
    "showDescription": true
  }
}
```

#### `childpages`

Joomla child pages of the current page. `order`: e.g. `menu_order`|`title_asc`|`created_desc`. Also accepts shared card design / carousel keys (see CMS notes).

```json
{
  "id": "blk_childpages",
  "type": "childpages",
  "element": {
    "limit": 6,
    "order": "menu_order",
    "cardVariant": "default",
    "columns": 3,
    "carousel": false,
    "carouselShowArrows": true,
    "carouselShowDots": false,
    "carouselAutoplay": false,
    "carouselGap": 24
  }
}
```

### 8.7 External

#### `iframe`

`embedMode`: `url`|`code`. Use `url` + dimensions for maps/embeds, or `embedCode` when mode is `code`.

```json
{
  "id": "blk_iframe",
  "type": "iframe",
  "element": {
    "embedMode": "url",
    "url": "https://maps.google.com/",
    "height": 400,
    "iframeWidth": "100%",
    "embedCode": "",
    "title": "Map",
    "showBorder": false,
    "allowFullscreen": true
  }
}
```

#### `script`

Raw JS string in `script`. Prefer native blocks when possible.

```json
{
  "id": "blk_script",
  "type": "script",
  "element": {
    "script": "console.log('Aura script block');"
  }
}
```

### 8.8 CMS / dynamic

#### `articlelist`

Joomla articles. `order`: e.g. `created_desc`|`title_asc`|`hits_desc`. Shared card design keys: `cardVariant`, `columns`, `customTemplate`, `hoverPos`, `hoverAlign`. Carousel keys: `carousel`, `carouselShowArrows`, `carouselShowDots`, `carouselAutoplay`, `carouselGap`.

```json
{
  "id": "blk_articlelist",
  "type": "articlelist",
  "element": {
    "catId": 8,
    "limit": 6,
    "order": "created_desc",
    "featuredOnly": false,
    "cardVariant": "default",
    "columns": 3,
    "carousel": false,
    "carouselShowArrows": true,
    "carouselShowDots": false,
    "carouselAutoplay": false,
    "carouselGap": 24
  }
}
```

#### `categorylist`

`displayMode`: `cards`|`list`. `order`: e.g. `menu_order`|`title_asc`. `parentCatId`/`maxLevel` control tree depth.

```json
{
  "id": "blk_categorylist",
  "type": "categorylist",
  "element": {
    "excludeCatIds": [],
    "order": "menu_order",
    "limit": 12,
    "parentCatId": 0,
    "maxLevel": 0,
    "displayMode": "cards",
    "showEmpty": false,
    "showCount": false,
    "showImage": true,
    "showDescription": true,
    "showButton": true,
    "btnLabel": "View",
    "cardVariant": "default"
  }
}
```

#### `taglist`

`displayMode`: `list`|`cloud`|similar. `order`: e.g. `title_asc`.

```json
{
  "id": "blk_taglist",
  "type": "taglist",
  "element": {
    "excludeTagIds": [],
    "order": "title_asc",
    "limit": 30,
    "displayMode": "list",
    "showEmpty": false,
    "showCount": true
  }
}
```

#### `module`

Joomla module (or WP widget bridge). Prefer real `moduleId` from the site. `modulePosition` is optional.

```json
{
  "id": "blk_module",
  "type": "module",
  "element": {
    "moduleId": 101,
    "moduleTitle": "Main menu module",
    "modulePosition": ""
  }
}
```

#### `menu`

`layout`: `list`|`horizontal`|`hamburger` (and similar). `menuAlign`: `left`|`center`|`right`.
`endLevel` 0 = no end cap.

**Responsive menu (required pattern for headers):**

| Key | Purpose |
|---|---|
| `responsive: true` | Enables breakpoint behaviour |
| `responsiveBreak` | Usually `991` (matches Aura tablet) |
| `layout: "hamburger"` | Desktop links + mobile toggle |
| `mobileTriggerStyle` | e.g. `hamburger` |
| `mobileMenuStyle` | e.g. `drawer` \| `flyout` |
| `mobileTriggerLabel` | Accessible label for the toggle |

**Header row pattern (keeps 3 columns on tablet/mobile):**

1. Keep the header row at **3 columns** on tablet/mobile
   (`responsive.tablet|mobile.element.cols: 3` + sensible `colWidths`, e.g. `[60, 39, 1]`).
   Do **not** collapse the header to `cols: 1` — that breaks logo / menu / CTA placement.
2. Put the **menu** in the middle (or end) column; optionally set
   `responsive.tablet|mobile.element.order` so the hamburger sits where you want.
3. Use **`settings.hidden`** for device-specific CTAs:
   - Desktop CTA column: `hidden: { desktop: false, tablet: true, mobile: true }`
   - Mobile-only CTA (e.g. top utility row): `hidden: { desktop: true, tablet: false, mobile: false }`
4. Style menu link colour/weight in CSS if needed — **never** force `display` on
   `.vb-menu-desktop-nav` (see [§6.6](#66-responsive-model)).

```json
{
  "id": "blk_menu",
  "type": "menu",
  "element": {
    "menuType": "primary",
    "layout": "hamburger",
    "menuAlign": "right",
    "startLevel": 1,
    "endLevel": 0,
    "linkColor": "#111827",
    "linkHoverColor": "#0E59EF",
    "responsive": true,
    "responsiveBreak": 991,
    "mobileTriggerStyle": "hamburger",
    "mobileMenuStyle": "drawer",
    "mobileTriggerLabel": "Menu",
    "contentPrepare": true
  }
}
```

#### `postloop`

**Required on archive list templates** (`archive_type: archive` — category / taxonomy /
post-type archives). The Post Loop block is what lists the matched posts for the current query.
Open it and design each item with Post Field / Article Field blocks (title, image, excerpt, etc.).
`wp_query` is a separate query block and is **not** a substitute for `postloop` inside an archive
template.

`itemMode`: `blocks`|`custom`. Query options live under `settings`. Template is `loopLayout[]`
(section-like nodes) or `customTemplate` HTML when custom.

```json
{
  "id": "blk_postloop",
  "type": "postloop",
  "element": {
    "itemMode": "blocks",
    "customTemplate": "",
    "settings": {
      "amount": 6,
      "columns": 3,
      "order": "created_desc",
      "catId": 0,
      "featuredOnly": false,
      "excludes": "",
      "pagination": false
    },
    "loopLayout": []
  }
}
```

#### `wp_childpages`

WordPress child pages. Same card design / carousel keys as `childpages`.

```json
{
  "id": "blk_wp_childpages",
  "type": "wp_childpages",
  "element": {
    "limit": 6,
    "order": "menu_order",
    "cardVariant": "default",
    "columns": 3,
    "carousel": false
  }
}
```

#### `wp_query`

`orderby`: e.g. `date`|`title`|`menu_order`. `orderDir`: `DESC`|`ASC`. Optional `taxonomy` + `termId`.

```json
{
  "id": "blk_wp_query",
  "type": "wp_query",
  "element": {
    "postType": "post",
    "limit": 6,
    "orderby": "date",
    "orderDir": "DESC",
    "taxonomy": "",
    "termId": 0,
    "cardVariant": "default",
    "columns": 3,
    "carousel": false
  }
}
```

### 8.9 Article field blocks (Joomla)

#### `article_title`

Renders the current article title. Optional wrapper `tag` (e.g. `h1`|`h2`|`div`).

```json
{
  "id": "blk_article_title",
  "type": "article_title",
  "element": {
    "tag": "h1"
  }
}
```

#### `article_intro_image`

Intro/image. Optional `fillHeight` boolean for layout fill.

```json
{
  "id": "blk_article_intro_image",
  "type": "article_intro_image",
  "element": {
    "fillHeight": false
  }
}
```

#### `article_full_image`

Full article image for the current article context.

```json
{
  "id": "blk_article_full_image",
  "type": "article_full_image",
  "element": {}
}
```

#### `article_introtext`

Article intro text HTML.

```json
{
  "id": "blk_article_introtext",
  "type": "article_introtext",
  "element": {}
}
```

#### `article_fulltext`

Article full text HTML.

```json
{
  "id": "blk_article_fulltext",
  "type": "article_fulltext",
  "element": {}
}
```

#### `article_created`

Optional `dateFormat` (PHP-style, e.g. `d/m/Y` or `M j, Y`).

```json
{
  "id": "blk_article_created",
  "type": "article_created",
  "element": {
    "dateFormat": "d/m/Y"
  }
}
```

#### `article_author`

Optional wrapper `tag`.

```json
{
  "id": "blk_article_author",
  "type": "article_author",
  "element": {
    "tag": "div"
  }
}
```

#### `article_category`

Current article category label.

```json
{
  "id": "blk_article_category",
  "type": "article_category",
  "element": {}
}
```

#### `article_tags`

Current article tag list.

```json
{
  "id": "blk_article_tags",
  "type": "article_tags",
  "element": {}
}
```

#### `article_field`

Joomla custom field. Set `fieldKey` to the field name (e.g. `price`). Optional `tag`.

```json
{
  "id": "blk_article_field",
  "type": "article_field",
  "element": {
    "fieldKey": "price",
    "tag": "div"
  }
}
```

#### `article_link`

Read-more button to the full article. Uses `label` and optional `ariaLabel`.

```json
{
  "id": "blk_article_link",
  "type": "article_link",
  "element": {
    "label": "Read More",
    "ariaLabel": "Read more about this article"
  }
}
```

### 8.10 Post field blocks (WordPress)

#### `post_title`

Current post title. Optional wrapper `tag`.

```json
{
  "id": "blk_post_title",
  "type": "post_title",
  "element": {
    "tag": "h1"
  }
}
```

#### `post_featured_image`

Featured image for the current post.

```json
{
  "id": "blk_post_featured_image",
  "type": "post_featured_image",
  "element": {}
}
```

#### `post_excerpt`

Post excerpt.

```json
{
  "id": "blk_post_excerpt",
  "type": "post_excerpt",
  "element": {}
}
```

#### `post_content`

Post content HTML.

```json
{
  "id": "blk_post_content",
  "type": "post_content",
  "element": {}
}
```

#### `post_date`

Optional `dateFormat` (e.g. `M j, Y`).

```json
{
  "id": "blk_post_date",
  "type": "post_date",
  "element": {
    "dateFormat": "M j, Y"
  }
}
```

#### `post_author`

Optional wrapper `tag`.

```json
{
  "id": "blk_post_author",
  "type": "post_author",
  "element": {
    "tag": "div"
  }
}
```

#### `post_categories`

Post category list.

```json
{
  "id": "blk_post_categories",
  "type": "post_categories",
  "element": {}
}
```

#### `post_tags`

Post tag list.

```json
{
  "id": "blk_post_tags",
  "type": "post_tags",
  "element": {}
}
```

#### `post_acf_field`

ACF value. Set `fieldKey` to the ACF field name.

```json
{
  "id": "blk_post_acf_field",
  "type": "post_acf_field",
  "element": {
    "fieldKey": "price",
    "tag": "div"
  }
}
```

#### `post_meta_field`

Post meta value. Set `fieldKey` to the meta key.

```json
{
  "id": "blk_post_meta_field",
  "type": "post_meta_field",
  "element": {
    "fieldKey": "_custom_thing",
    "tag": "div"
  }
}
```

---

## 9. The AI API

Base URL: `https://api.cmsaura.com`. All requests are `POST` JSON.
For full V2 block object examples (every registered type), see [section 8](#8-block-reference-v2-examples).
The compact shorthand used in generate/chat prompts lives in `api/lib.php` (`vb_compact_schema_prompt`) and is intentionally shorter than this catalog.

### 9.1 Endpoints

| Endpoint | Mode | Use |
|---|---|---|
| `index.php` (`/`) | Sync (up to 300s) | Generate / update, direct |
| `queue.php` | Async when `VB_QUEUE_ENABLED` | POST -> `job_id`; GET `?job=<id>` polls. Used by "Build with AI". |
| `chat.php` | Sync | Conversational edits ("AI Chat"): returns `message` + `ops` |
| `update.php` | Sync | Patch-update fallback |
| `worker.php` | Background daemon | Processes queued jobs (supervisor) |

> "Build with AI" and "Update Page with AI" use the **async queue** (needs the workers running). "AI Chat" is synchronous for small edits. A long brief / full-page rewrite is handed to the same `update_page` queue.

### 9.2 Actions (`index.php` / `queue.php`)

`generate_page`, `generate_section`, `update_page`, `update_section`.

Request body: `action`, `prompt`, `ref_url`, `sections_count`, `ai_options`, `brand_kit`,
`site_key`, `exclude_hint`, `product_id`, `site_url`, and `current_json` (required for `update_*`).

- `generate_page` -> `{ sections: [...] }` (full Layout JSON sections).
- `generate_section` -> exactly one section.
- `update_*` -> applies AI patch ops to `current_json`. Long `update_page` briefs run one OpenAI call per section, then spend 1 credit if anything applied.

### 9.3 Chat response & ops

`chat.php` returns:
```json
{ "message": "1-3 sentences", "ops": [ ...operations... ], "sections": [ ...updated... ], "credits_remaining": 42 }
```

**Ops** target the page with JSON-Pointer paths against the array of sections:

```json
{ "op": "replace", "path": "/0/rows/0/cols/0/blocks/0/data/text", "value": "New copy" }
{ "op": "add",     "path": "/0/rows/0/cols/0/blocks/-", "value": ["he", "Title", "h2"] }
{ "op": "remove",  "path": "/1/rows/0" }
{ "op": "add_section",    "index": -1, "value": { /* compact section */ } }
{ "op": "remove_section", "index": 2 }
```

- Path segments: section index first (`/0`), then `rows`/`cols`/`blocks` with numeric indices; end
  with `/-` to append to a list.
- `add`/`add_section` values use **compact** format (block arrays / compact section objects).
- On `replace` of a text field, send the full final string (the skeleton preview shows `[len=N]`).

### 9.4 Skeleton (patch targeting)

The API builds a flat `path -> value` skeleton of the current page so the model knows exact paths
(e.g. `/0/settings/bg/color`, `/0/rows/0/settings/gap`,
`/0/rows/0/cols/0/blocks/0/data/text`). Long strings appear as `"[len=NNN] preview..."`. Noise keys
(`customCss`, `animation`, `shadow`, `border`, `responsive`, `colWidths`, ...) are omitted.

### 9.5 Credits

1 credit per successful operation (generate, update, or chat). Checked before the request
(HTTP 402 `insufficient_credits` when 0) and spent on success. Backed by Aura Members via
`VB_MEMBERS_URL` + `VB_SPEND_SECRET`, with a local cache. No `site_key` -> credit gate skipped.

### 9.6 Generation rules the model follows

Valid JSON only; no markdown fences; no ids; omit default keys; realistic copy (never empty);
vary section patterns (two-col, cards, accordion, stats, tabs); `fluid:true` on slider/marquee
sections; light text only on dark/brand backgrounds; prefer rich blocks (`acc`, `card`, `cnt`,
`tab`, `tst`) over plain icon grids. Chat/update must **never** touch integration blocks
(`articlelist`, `module`, `menu`, `featuredimage`, `childpages`, `section`, `auraform`,
`auramailsignup`, `memberlist`, `auramembersplans`, `doclist`, `docsingle`).

---

## 10. Worked examples

### 10.1 Compact - a hero + two-column feature section

```json
{
  "sections": [
    {
      "bg": ["#0f172a", "#1e293b", 135], "pt": 120, "pb": 120, "va": "c",
      "rows": [
        { "cols": [ { "b": [
          ["he", "Build faster with Aura", "h1", "56px", "#ffffff"],
          ["tx", "<p>Design polished pages in minutes.</p>", "#cbd5e1", "18px"],
          ["btg", [["Get started", "/register", "primary"], ["Live demo", "#demo", "outline"]]]
        ] } ] }
      ]
    },
    {
      "pt": 80, "pb": 80,
      "rows": [
        { "cw": [58, 42], "g": 40, "cols": [
          { "b": [
            ["he", "Everything you need", "h2"],
            ["ls", "check", ["Drag and drop", "60+ blocks", "AI generation", "Fully responsive"]]
          ] },
          { "b": [ ["img", "https://picsum.photos/seed/dashboard/900/700", "Editor"] ] }
        ] }
      ]
    }
  ]
}
```

### 10.2 Layout JSON - one section as saved in the database (V2)

```json
{
  "version": 2,
  "meta": {
    "container": "contained",
    "schema": "aura-builder-v2"
  },
  "nodes": [
    {
      "type": "section",
      "id": "sec_hero",
      "element": { "layout": "container", "sectionVAlign": "center" },
      "settings": {
        "bg": { "type": "gradient", "gradientType": "linear", "gradientAngle": 135,
                "gradientStops": [{ "color": "#0f172a", "pos": 0, "opacity": 100 },
                                  { "color": "#1e293b", "pos": 100, "opacity": 100 }] },
        "padding": { "top": 120, "right": 0, "bottom": 120, "left": 0 }
      },
      "rows": [
        { "id": "row_1", "element": { "gap": 24, "colWidths": [100] }, "cols": [
          { "id": "col_1", "blocks": [
            { "id": "blk_h", "type": "heading", "element": { "text": "Build faster with Aura", "tag": "h1", "textColor": "#ffffff", "fontSize": 56 } },
            { "id": "blk_b", "type": "button",  "element": { "label": "Get started", "url": "/register", "style": "primary" } }
          ] }
        ] }
      ]
    }
  ]
}
```
### 10.3 Chat op - recolour every section background

```json
{
  "message": "Set every section background to your brand navy.",
  "ops": [
    { "op": "replace", "path": "/0/settings/bg/color", "value": "#0f172a" },
    { "op": "replace", "path": "/1/settings/bg/color", "value": "#0f172a" }
  ]
}
```

---

## 11. Rules & schema safety

- **Non-destructive:** never rename or repurpose existing saved keys (`block.data.*`, row/col/
  section keys) without a compatibility bridge. Never drop unknown keys during normalize/export.
- **Migrations are additive & idempotent:** running them again must not change already-migrated data
  (e.g. table V1 `rows` -> V2 `cells`).
- **Load/save parity:** any JS change that transforms layout JSON must preserve both V1 and V2, and
  be mirrored in `Renderer.php` if output depends on it.
- **Numbers stay numbers:** store `fontSize: 48`, not `"48px"`. Units added at render.
- **Preserve `customCss`** and responsive settings through load -> edit -> copy/paste -> export ->
  render.
- **Never fabricate integration ids:** `auraform.formId`, `auramailsignup.listId`, `module.moduleId`,
  `section.sectionId`, `doclist/docsingle` ids must reference real records - ask the user if unknown.
- **Editor <-> renderer parity:** every block change must update both `cdn/js/editor.bundle.js` and
  `Joomla/com_aurabuilder/site/src/Helper/Renderer.php`, then run `build.ps1`.

---

## 12. Common build rules (AI must follow)

These are hard product rules. Follow them when generating or editing layouts.

### 12.1 Archive list templates need a Post Loop

If you are building an **Archive** template for a **list view**
(`archive_type: archive` — blog index, category, taxonomy, CPT archive):

1. The layout **must** include a **`postloop`** block.
2. Design each item inside `loopLayout` with CMS field blocks
   (WordPress: `post_title`, `post_featured_image`, `post_excerpt`, …;
   Joomla: `article_title`, `article_intro_image`, …).
3. Do **not** rely only on `wp_query` / `articlelist` for the main archive listing —
   those are separate query blocks. The archive’s matched posts are rendered by `postloop`.

Header / footer / single / page archives do **not** require `postloop`.

### 12.2 Breadcrumbs — use the CMS, never hand-built HTML

**Do not** invent breadcrumbs with a text/HTML block
(`<nav class="…">Home / About</nav>`, hardcoded links, etc.).

| Platform | Correct approach |
|---|---|
| **WordPress** | Yoast SEO shortcode in a text block with `contentPrepare: true`: `[wpseo_breadcrumb]`. Or the site’s breadcrumb plugin / block if Yoast is not present. |
| **Joomla** | Breadcrumbs **module** via the `module` block (`moduleId` of the Breadcrumbs module), or the template position that already outputs breadcrumbs. |

Style the CMS output with CSS; do not hardcode trail items in layout JSON.

### 12.3 Never override the mobile responsive menu

Aura’s hamburger menu hides `.vb-menu-desktop-nav` at `max-width: 991px`.

**Forbidden in theme/custom CSS:**

```css
/* BAD — overrides Aura mobile hide */
.cea-header .vb-menu-desktop-nav {
  display: flex !important;
}
```

**Allowed:** font, colour, letter-spacing, alignment helpers that do **not** set `display` on
the desktop nav wrapper.

Use the menu block’s own responsive keys (`responsive`, `responsiveBreak`, `layout: "hamburger"`,
`mobileMenuStyle`) plus row `cols` / `colWidths` / column `order` / `settings.hidden` for
device-specific CTAs (see [menu block](#menu)).

### 12.4 Prefer built-in visibility & column responsiveness

Most blocks (and sections/rows/columns) support:

- `settings.hidden: { desktop, tablet, mobile }` for show/hide per breakpoint
- Row `responsive.*.element.cols` / `colWidths`
- Column `responsive.*.element.order`

Prefer these over duplicate sections or CSS `display` overrides.

### 12.5 Reference: responsive header menu layout

Canonical pattern (abbreviated):

```json
{
  "type": "section",
  "settings": { "customClass": "cea-header" },
  "rows": [{
    "element": { "gap": 16, "alignItems": "center", "colWidths": [20, 60, 20] },
    "responsive": {
      "tablet": { "element": { "cols": 3, "colWidths": [60, 39, 1] } },
      "mobile": { "element": { "cols": 3, "colWidths": [60, 39, 1] } }
    },
    "cols": [
      { "blocks": [{ "type": "text", "element": { "text": "<!-- logo -->" } }] },
      {
        "responsive": {
          "tablet": { "element": { "order": 3 } },
          "mobile": { "element": { "order": 3 } }
        },
        "blocks": [{
          "type": "menu",
          "element": {
            "layout": "hamburger",
            "menuAlign": "right",
            "responsive": true,
            "responsiveBreak": 991,
            "mobileTriggerStyle": "hamburger",
            "mobileMenuStyle": "drawer"
          }
        }]
      },
      {
        "settings": { "hidden": { "desktop": false, "tablet": true, "mobile": true } },
        "blocks": [{ "type": "button", "element": { "label": "Request a Quote" } }]
      }
    ]
  }]
}
```

Pair with a utility row that shows a compact CTA on tablet/mobile only
(`hidden: { desktop: true, tablet: false, mobile: false }`).

---

## 13. MCP connector (Cursor / Claude)

Remote MCP URL: `https://mcp.cmsaura.com/mcp`

Auth header: `Authorization: Bearer YOUR_DOWNLOAD_ID`  
Or a dedicated `mcp-...` token from the cmsaura.com Members profile **Connect AI** tab.

Cursor `mcp.json` snippet:

```json
{
  "mcpServers": {
    "aura-builder": {
      "url": "https://mcp.cmsaura.com/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_DOWNLOAD_ID"
      }
    }
  }
}
```

Landing page: `https://mcp.cmsaura.com`

### What the tools do

| Tool | Credits | Writes to the customer site? |
|---|---|---|
| `get_ai_guide`, `list_blocks`, `validate_layout`, `get_credits` | No | No |
| `generate_page`, `generate_section`, `update_page`, `update_section` | 1 on success (`status: done`) | No -- returns layout JSON |
| `poll_job` | No extra | No -- finish a queued generate/site job |
| `list_sites`, `list_pages`, `get_page`, `create_page`, `apply_layout`, `save_section`, `list_sections`, `list_menus`, `create_menu_item`, `get_brand_kit` | No | Yes -- requires Aura Agent enabled in Joomla/WP settings |

`apply_layout` requires `confirm: true`. Generation always goes through `https://api.cmsaura.com` (same queue as the builder). The MCP server does not hold OpenAI keys or the spend secret.

`mcp.cmsaura.com`, `api.cmsaura.com`, and `updates.cmsaura.com` sit behind Cloudflare (proxied). Cloudflare kills HTTP requests around 100 seconds, so every MCP tool returns within ~70s. Generation jobs often take longer: if the tool returns `poll_again: true` with a `job_id` (`j_...` for AI queue, `job_...` for site agent), call `poll_job` until `status` is `done` or `error`. Do not treat `poll_again` as a failure and do not count credits until `status` is `done`.

Site writes need the plugin **Aura Agent** toggle. The site polls `https://mcp.cmsaura.com/agent/poll` every 15 seconds. There is no raw SQL or CLI. If a site tool returns `poll_again`, keep the site online and call `poll_job` -- do not fire the same write tool again (that would enqueue a second job).

---

*This document is generated for the Aura Builder ecosystem (Joomla, WordPress, Laravel). To update
it, edit the Joomla copy and run `build.ps1` to sync the WordPress and Laravel copies.*
