---
name: mosaic-build-page
description: Build or modify a page on a Mosaic Pro site. Manage Mosaic themes, templates, the element tree (the building canvas), the design system (collections, variables, modes, breakpoints), components, and utility classes. Activate when the user asks to create or edit a page on a Mosaic site, switch themes, add elements to a template, set up design tokens, or work with Mosaic's component library and utility-class system.
---

# Building a page on Mosaic Pro

Mosaic is a visual page builder by Nextend that stores its entire data model in roughly 21 custom `<prefix>mosaic_*` tables (NOT WordPress CPTs). Themes, templates, element trees, components, collections, variables, breakpoints, settings, and locks all live in those tables rather than `wp_posts` / `wp_postmeta`.

Architecture in one diagram (Sprint 2 surface in **bold**):

```
**Theme** ─┬─ **Templates** ── nodes (element tree)
           ├─ **Collections** ── **variables** (per-skin/mode values)
           ├─ **Components** ── nodes (element tree)
           ├─ **Utility classes**
           ├─ Element classes ── sub-classes
           ├─ Breakpoints
           ├─ Modes / Skins / Styleguides
           ├─ Masters (reusable templates)
           ├─ Settings (per-theme config)
           ├─ Locks (read-modify-write guards)
           └─ template_assigns (page → template binding)
```

Every entity except locks/breakpoints carries the same column shape: `ID` (varchar UUID), `themeID`, `ordering` (varchar fractional index), `status` (`publish` / `delete`), `revision`, `version`, plus per-entity columns and an optional `data` longtext JSON.

## First call: check-setup

Always start with `novamira/mosaic-check-setup`. It returns:

- `plugin.active` (constants defined + class loaded)
- `plugin.version` / `min_supported` / `max_supported` (min is inclusive, max is exclusive)
- `plugin.min_satisfied` / `max_satisfied` — version is at or above the supported minimum AND strictly below the supported maximum (independent of tables; Mosaic at or above max_supported is rejected)
- `plugin.tables_present` — the core read tables exist
- `plugin.runtime_ready` — the full AND-gate (active + min_satisfied + max_satisfied + tables_present). Every other ability requires this true; if false, surface the gap to the user before calling anything else.
- `plugin.edition` (free / pro) / `plugin.channel` (stable / beta / dev)
- `entities.{themes, templates, components, collections}` — quick smoke counts

## Conventions

- **Slugs**: `novamira/mosaic-<verb>-<object>` (e.g. `novamira/mosaic-list-themes`).
- **Error codes**: prefixed `mo_`. The full vocabulary: `mo_runtime_unavailable` (Mosaic loaded but version out of range or required class missing), `mo_not_found` (entity gone or soft-deleted), `mo_invalid_input` (schema-level rejection — depth, length, type, malformed UUID, or trying to add at the root of a non-template document), `mo_invalid_ordering` (placement / fractional-index resolution failed), `mo_lock_conflict` (concurrent write on the same scope), `mo_concurrent_modification` (the row was modified, soft-deleted, or hard-deleted between this call's load and update — most often a Mosaic editor save committing on a different MySQL connection that bypasses our per-connection GET_LOCK; re-fetch and retry), `mo_no_active_master` (theme has no published master MResource), `mo_orphan_chain_too_deep` (defense-in-depth: cleanup cap of 256 cascade passes hit, empirically unreachable on this MySQL version, kept as belt-and-suspenders), `mo_payload_too_large` (encoded blob exceeds 1 MiB), `mo_facade_failed` (Mosaic deep-stack error, message includes the upstream throwable), `mo_write_failed` (`wpdb` returned `false` on the insert/update), `mo_override_corrupt` (more than one component-instance override row matches the same `(instance_id, original_node_id)` pair — only reachable via direct SQL, resolve manually). When Mosaic itself is deactivated, the abilities are not registered at all — callers see the framework's `ability_not_found` rather than any `mo_*` code.
- **Permission gate**: every ability uses `novamira_permission_callback` (manage_options) — the trust boundary across all Novamira specializations.
- **Status semantics**: Mosaic uses `publish` / `delete` (NOT `active` / `deleted`). Every list/get filters `status=publish` by default. The list helpers carry a `MO_STATUS_ANY` sentinel for cases that need deleted rows; the public abilities currently do not expose it.
- **Theme scoping**: every list except `mosaic-list-themes` is theme-scoped and requires `theme_id`. Get one via `mosaic-list-themes` first.
- **Casing**: Mosaic stores `themeID` / `parentID` / `parentType` in the DB but every ability surfaces `theme_id` / `parent_id` / `parent_type` (snake_case) in the response.
- **List vs get**: list responses carry only the compact identifying fields (id, name, status, ordering, plus a couple of key scalars per entity). The full `data` blob lives in `get-*` only — list responses on installs with thousands of variables would exceed 1MB otherwise. `list-templates` / `get-template` / `create-template` always surface `master_id` (empty string when the template is not derived from a master).

## Workflows

### W1 — Discover what the install has

```
mosaic-check-setup
→ mosaic-list-themes               # always start here; pick a themeID
→ mosaic-list-templates(theme_id)
→ mosaic-list-collections(theme_id)
→ mosaic-list-components(theme_id)
→ mosaic-list-utility-classes(theme_id)
```

### W2 — Inspect one template fully

```
mosaic-list-themes                          # pick themeID
mosaic-list-templates(theme_id)             # pick templateID
mosaic-get-template(id=templateID)          # full record + conditions JSON
mosaic-get-element-tree(document_type=template, document_id=templateID)
```

The `get-element-tree` response carries both a flat `nodes` array and a hierarchical `roots` array (each node has `children`). Use `roots` for navigation, `nodes` for stable diffing.

### W3 — Walk a design system

```
mosaic-list-themes                          # pick themeID
mosaic-list-collections(theme_id)           # pick collectionID
mosaic-list-variables(collection_id, …)     # compact: id, name, custom_property
mosaic-get-variable(id)                     # full record: usage, propertyGroups, skinsData
```

### W4 — Build a page from scratch

```
mosaic-list-themes                                    # pick themeID
mosaic-create-template(theme_id, name="Home")         # facade-backed renderable template
# template ships with the `template-internal` canvas already seeded; add real content
mosaic-add-element(document_type=template, document_id=<tpl>, parent_id=<tpl>, type=section,
  style={background: "rgb(33,150,243)", padding: "40px"},   # top-level dual-shape: string CSS or {prop:val} map
  attributes={"data-section": "hero"},                       # top-level HTML attrs
  class_names=["u-padding-md"])                              # top-level utility-class shortcut — strings looked up by name
mosaic-add-element(document_type=template, document_id=<tpl>, parent_id=<section-id>, type=text)
# Text widgets are WYSIWYG HOLDERS — the visible string lives in a child
# `wysiwyg-text` node, NOT in the `text` element's own data:
mosaic-add-element(document_type=template, document_id=<tpl>, parent_id=<text-id>, type=wysiwyg-text, data={text:"Hello from the agent"})
mosaic-edit-template(id=<tpl>, path="single-page.php")  # bind to the WP request layer Mosaic's renderer matches against; the wizard facade resets path to index.php on create
mosaic-edit-template(id=<tpl>, conditions=[…])         # OR-groups of ruleRecords; only honored under assign=auto. See "conditions wire shape" below.
mosaic-set-template-assign(theme_id, type="post", type_identifier="42", template_id=<tpl>)   # type is ALWAYS the literal "post" for singular bindings, regardless of WP post-type slug
mosaic-check-design-system(document_type=template, document_id=<tpl>)   # SELF-CHECK before finishing — flags elements using hardcoded inline CSS instead of variables / utility classes
```

`mosaic-create-template` routes through Mosaic's own `FinishWizardEditorInstance` facade (the same code path Mosaic's wizard uses), so the produced template carries a proper `masterID` linkage + root `template-internal` node, and the Mosaic editor opens it cleanly. **Front-end rendering needs an extra step**: the wizard facade always resets `path` to `index.php`, but Mosaic's renderer matches against the WP request type — `single-page.php` for `is_page()`, `single.php` for posts, `archive.php` for archives. After `create-template` + `set-template-assign`, call `edit-template(path=…)` with the matching template-path slug or Mosaic will fall through to the "Missing Mosaic template" page.

> **Styling: prefer the design system over per-element `style`.** `add-element` accepts a top-level `style={…}` (the inline option shown above), but for anything reused — colours, spacing, typography — define it once as a **variable** (a CSS custom property, see W6) and/or a **utility class**, then apply it with `class_names`. Reserve top-level `style` for genuine one-offs, and even then prefer `var(--token)` over a hardcoded colour/font literal. This keeps the theme's design tokens authoritative, the tree consistent, and matches how the Mosaic editor expects styling to live. (The same applies to text: put presentation on the element via `class_names`, not as inline `style="…"` baked into `wysiwyg-text` HTML.)

> **Self-check before you finish: `mosaic-check-design-system`.** After building or editing, call `mosaic-check-design-system(document_type, document_id)`. It returns the elements that styled themselves with custom CSS *outside* the design system — a hardcoded colour (`#hex`, `rgb()`, `hsl()`) or `font-family` literal baked into inline `style` instead of a `var(--token)` or a utility class — as a compact report (per-violation counts + the offending node ids). Fix each flagged element (swap the literal for a variable from `mosaic-list-variables`, or move the rule into a utility class applied via `class_names`) and re-run until `flagged_nodes` is 0. Un-editable inline CSS is exactly what the customer does not want; this is the check that keeps the build inside Mosaic's design system.

> **Text element pattern.** Adding `type=text` alone produces a visually empty widget. Text is a holder for one or more child `wysiwyg-text` nodes (the slug uses a dash). The agent must add a child of that type to make text reach the DOM.

> **`set-template-assign.type` is `"post"`, not the WP post-type slug.** Mosaic 1.0.3's resource model labels every singular content surface as resource type `"post"` regardless of WP post_type. A `page`-post-type WP page IS still `type="post"` in this row. The WP post-type lives implicitly on the template's `path` field instead (e.g. `path="single-page.php"` for pages, `path="single-post.php"` for blog posts, `path="single-<cpt-slug>.php"` for custom types). Verified by inspecting Mosaic's `PathPostTypePage.php` and `PathPostTypePost.php` — both call `setResourceType('post')` regardless of which post-type they target.

> **`set-template-assign` requires post-type opt-in.** Mosaic only honors rows for post types that declare `post_type_supports($post_type, 'mosaic_manual_assign')`. By default `page` is supported but `post` is not — agents targeting blog posts (path=`single-post.php`) need a mu-plugin snippet: `add_post_type_support('post', 'mosaic_manual_assign');`. Without the support flag the binding row writes successfully but the renderer skips it.

> `type` is the WP post-type slug (`post`, `page`, or any registered custom post type). `type_identifier` is the numeric post ID, validated as a string — pass `"42"`, not `42`. The gating is `post_type_supports($type, 'mosaic_manual_assign')` keyed by the slug you pass in `type`.

### W5 — Restructure an existing tree

```
mosaic-get-element-tree(document_type=template, document_id=<tpl>)
mosaic-move-element(id=<node>, parent_id=<new-parent>, placement=after, anchor_id=<sibling>)
mosaic-edit-element(id=<node>, data={…}, mode=merge)
mosaic-delete-element(id=<dead-section>)
```

### W6 — Build a design system (Sprint 4)

### W7 — Create loops (Sprint 5)

Loops repeat a template section over a collection of items (posts, attachments, users, terms, or a custom array). The structure is hierarchical: a `loop` element wraps `loop-items`, which contains `loop-item` children, optionally flanked by `loop-no-result` (rendered when the loop is empty) and `loop-pagination` (pagination controls).

**Loop data shape (`loop` element `data`):**
- `loopType` — the query source IDENTIFIER, NOT a category. Mosaic generates one identifier per registered source at runtime:
  - public post types → `wpPostType<Name>`: `post` → `wpPostTypePost`, `page` → `wpPostTypePage`, `attachment` → `wpPostTypeAttachment`, any public CPT `book` → `wpPostTypeBook` (Mosaic's exact rule is `'wpPostType' . ucfirst(camelCased post_type name)`).
  - users → the literal `wpUser`.
  - public taxonomies → `wpTaxonomy<Name>`, one per taxonomy: `category` → `wpTaxonomyCategory`, `post_tag` → `wpTaxonomyPostTag`.
  - custom data → `localContext`.
  All of the above except `localContext` share the same underlying source TYPE (`simple`); `localContext` is the only other type. Pick the exact identifier string for the collection you want, not a generic placeholder.
- `loopNamespace` — scopes the per-item variables. The agent sets this freely; Mosaic's built-in defaults are `post` (post-type loops), `users` (the user loop), and the taxonomy slug itself for term loops (`category`, `post_tag`, … — NOT a generic `term`). Per-item properties are accessed as `@VAR('<loopNamespace>/<property>')` inside child nodes.
- Type-specific options live under a `<loopType>Options` block (`getOptionsName()` = identifier + `Options`), e.g. `wpPostTypePostOptions`, `wpUserOptions`, `wpTaxonomyCategoryOptions`. For `simple` sources the keys are `filters`, `orderBy`, `offset`, `maxItems` (the per-page count — the property is literally `maxItems`, NOT `itemsPerPage`), and `paginationKey`. For `localContext` the block is `localContextOptions` and holds a single `loopSource` (a dynamic-code object of the form `{"v": "<dynamic-code>"}`).

**Nested structure:**
```
loop (data: {loopType, loopNamespace, <loopType>Options})
├── loop-items
│   └── loop-item (data: {mode: 'item'|'beforeMatch'|'afterMatch'|'separator'})
│       └── [any content — image, text → wysiwyg-variable, etc., referencing @VAR('namespace/property')]
├── loop-no-result (optional)
└── loop-pagination (optional)
    ├── loop-pagination-button-previous
    ├── loop-pagination-numbers
    │   └── loop-pagination-number
    └── loop-pagination-button-next
```

**Per-item binding:**
Variables inside `loop-item` children bind to the current item via `loopNamespace`, written `@VAR('<loopNamespace>/<property>')`. Where Mosaic injects the value depends on the host element: an `image` carries `data.image = {v: "@VAR('post/featuredImage')"}`, a `button` carries `data.url = {v: "@VAR('post/permalink')"}`, and a `wysiwyg-variable` text node carries `data.dynamicCode = "@VAR('post/title')"` (verbatim from Mosaic's seed layout). Property names vary by source:
- posts/pages/attachments (namespace `post`): `title`, `content`, `permalink`, `publish_date`, `featuredImage`, `featuredImageAlt`, `author_id`, `author_name`, … (there is NO `excerpt` and NO `date` — use `content` / `publish_date`).
- users (namespace `users`): `user_id`, `user_login`, `display_name`, `user_email`, `avatar_url`, `author_permalink`, `roles`, `user_registered`, …
- terms (namespace = the taxonomy slug, e.g. `category`): `name`, `slug`, `count`, …

**Pagination:**
Only `simple` sources whose definition enables it (the `wpPostType*` sources do; `localContext` cannot) support pagination. Set `paginationKey` to the query-parameter name (defaults to the loop element's UUID when omitted); Mosaic's renderer reads `$_GET[$paginationKey]` to pick the current page.

```
mosaic-list-themes                                              # pick themeID
mosaic-edit-theme-settings(theme_id, settings={containerWidth:"1200px"})   # global theme prefs (data.settings)

# Buckets: one collection per design-token family
mosaic-create-collection(theme_id, name="Brand colors")          # → collection_id_a
mosaic-create-collection(theme_id, name="Spacings")              # → collection_id_b

# Tokens: variables under a collection, emitted as CSS custom properties
mosaic-create-variable(collection_id=<a>, name="Primary",   custom_property="--brand-primary",   type="color")
mosaic-create-variable(collection_id=<a>, name="Secondary", custom_property="--brand-secondary", type="color")
mosaic-create-variable(collection_id=<b>, name="Spacing md", custom_property="--space-md",       type="length")

# Atomic CSS classes: utility classes the agent applies by NAME to elements
mosaic-create-utility-class(theme_id, name="Card", css_class="u-card") # → utility_class.id
mosaic-set-utility-class-style(id=<utility-class-id>, state=normal, breakpoint_id=base,
  property=padding-top, value="var(--space-md)")
mosaic-set-utility-class-style(id=<utility-class-id>, state=hover, breakpoint_id=base,
  property=background-color, value="var(--brand-primary)")
mosaic-set-utility-class-style(id=<utility-class-id>, state=hover, breakpoint_id=tablet,
  property=opacity, value="0.9")

# Apply utility classes to elements (lookup by name → uuid is automatic)
mosaic-add-element(document_type=template, document_id=<tpl>, parent_id=<tpl>, type=section,
  class_names=["u-card"])                                       # agent shape — strings
# or, with explicit uuid (power user / classes not in utility_classes table):
mosaic-add-element(..., class_names=[{className:"u-x", uuid:"<id>"}])
```

> **Use `mosaic-set-utility-class-style` for state CSS; `mosaic-set-variable-value` for design-token VALUES.**
> `mosaic-set-utility-class-style` writes one property at
> `utility_class.data.states[state][breakpoint]` through Mosaic's
> `SupportedStyleProperties` validator and resource facade. `state`
> defaults to `hover`; `normal` aliases `&`. Breakpoint aliases are
> `base` (`_`), `tablet` (`_t`), and `mobile` (`_m`), and custom
> breakpoint UUIDs are accepted when registered in the theme. The call
> merges one property and preserves all unrelated states, breakpoints,
> and properties. Make one call per property.
>
> The agent can also create / rename / delete utility classes, collections, and
> variables, and set a variable's actual value with `mosaic-set-variable-value(id, value, skin?, mode?)`
> for the `color`, `length`, and `n-length` types (plain CSS values — hex/named/rgb()/hsl()/…
> for color; number+unit or calc()/clamp()/var() for length). `skin` / `mode` default to the
> collection's first published skin/mode, which covers the common single-skin/single-mode case.
> `font` and `stacked` variables (background / box-shadow / text-shadow) still hold a structured
> object value that stays editor-only. Practical pattern for font/stacked variable values today:
> the agent declares the design-system shape (names + slots), the user fills in those specific
> values via Mosaic's visual editor.
>
> Never use `novamira/execute-php`, direct SQL, or a hand-built JSON blob for either of these:
> bypassing Mosaic's typed model can corrupt the row and trigger a fatal error when core decodes it.

## Element-tree primitives (Sprint 3)

Every node lives in the shared `wp_mosaic_nodes` table; the abilities resolve the owning document automatically:

- **`mosaic-get-element-tree`** — flat + hierarchical view, capped at `max_nodes` (default 500, hard cap 5000).
- **`mosaic-add-element`** — inserts under a parent (parent_id = document_id for root nodes, else a node id). Position with `placement` = `at_end` (default) / `at_start` / `before` (+`anchor_id`) / `after` (+`anchor_id`).
- **`mosaic-edit-element`** — patches the node's `data` blob. `mode=merge` (default) shallow-merges your supplied keys onto the existing blob, so the classes, children and attributes you do NOT send are PRESERVED; `mode=replace` overwrites the whole blob verbatim (use only when sending the complete blob). Nested keys are NOT deep-merged. The same `merge` default applies to `mosaic-edit-theme-settings` (a partial `settings` update keeps the other settings, such as the font stack).
- **`mosaic-delete-element`** — soft-deletes the node AND every descendant (BFS over the document's nodes). Status flips to `delete`; rows stay in the table. Cross-document stale refs are NOT cascaded: deleting a node inside a `component` document leaves every `component-instance` reference in other templates dangling. Mosaic silently empty-renders those instances by design — agent should sweep referencing instances explicitly when deleting a Component subtree.
- **`mosaic-move-element`** — re-parents and/or re-orders. Cycle prevention is built in. `parent_id = document_id` moves the node to the root.

`ordering` is a fractional-index string (Mosaic's `FractionalIndex::generateKeyBetween`). Never edit it directly — let the placement parameters do the work.

### Component-instance overrides

A Mosaic component is a reusable subtree (`mosaic-list-components` / documentType=`component`); a `component-instance/<component UUID>` node is one placement of it inside a template/master/styleGuide/another component. By default every property on an instance inherits LIVE from the shared component. An **override** lets an editor change ONE property on ONE instance without touching the shared definition — verified against the Mosaic Pro 1.0.5 source (`ComponentInstanceElementMResource`, `NodeMResourceManagerAbstract::createNodeOverride`):

- The override is a SEPARATE `wp_mosaic_nodes` row, living in the HOST document (the instance's own document, not the component's): `parentType = 'override'`, `parentID = <instance node UUID>`, `type = <original node's type>`, `data = { override: { originalID: <original node UUID> }, ...overridden properties }`. The overridden properties are SIBLINGS of `data.override`, not nested inside it.
- Overrides for an entire instance subtree — including nodes reached through a component nested several levels deep — are all FLAT children of the SAME outermost instance node; Mosaic never nests an override under another override.
- `mosaic-get-element-tree` on the HOST document never surfaces these rows (it only walks `parentType=node`); `mosaic-add-element` cannot create them (it forces non-root inserts to `parentType=node`); `mosaic-edit-element` can only touch one if its UUID is already known some other way. Use the two abilities below instead of raw SQL.

- **`mosaic-edit-component-instance-override`** — `instance_id` (the `component-instance/<uuid>` node) + `original_node_id` (the node inside that component, or a component nested inside it, whose property you're overriding) + `data` / `style` / `attributes` / `class_names`. Creates the override row on first call for a given `original_node_id`, merges onto it (preserving properties you don't send) on every call after. `expected_revision` is an optional optimistic-concurrency guard for the UPDATE path only — supplying it on a first-time create returns `mo_invalid_input`.
- **`mosaic-clear-component-instance-override`** — `instance_id` + `original_node_id`. Soft-deletes the override row; the instance reverts to fully inheriting from the shared component. No-op (`cleared: false`) when no override exists.
- Both abilities validate `original_node_id` actually belongs to the instance's component (or a component nested inside it) before writing, and reject with `mo_override_corrupt` if more than one override row is ever found for the same `(instance_id, original_node_id)` pair — that shape only happens via direct SQL bypassing these abilities.

## Template CRUD (Sprint 3)

- **`mosaic-create-template`** — fresh, empty tree. `name` required; `assign` defaults to `manual`, `path` is coerced to `index.php` by Mosaic's wizard facade regardless of input, `conditions` defaults to `[]`.
- **`mosaic-edit-template`** — partial patch of `name` / `assign` / `path` / `conditions`. Conditions are replaced wholesale.
- **`mosaic-delete-template`** — soft-delete. Element tree is left untouched: re-publishing the template row via SQL (`UPDATE wp_mosaic_templates SET status='publish' WHERE ID = ?`) restores the canvas exactly as it was. `mosaic-get-element-tree` keeps returning the canvas even while the template is soft-deleted (it filters on node status, not parent template status) — useful for "preview before restore". Caveat: if both template AND nodes were independently soft-deleted, republishing the template alone gives an empty canvas — node rows must be republished separately.

### `conditions` wire shape

Mosaic stores `conditions` as a JSON ARRAY of OR-groups. Each group is `{ruleRecords: [{ruleID, uuid, "<ruleID>Options": {operator, settings, value}}]}`. Recognized `ruleID`s in Mosaic 1.0.3:

| ruleID | Matches |
|---|---|
| `httpGet` | a GET query parameter |
| `httpCookie` | a cookie value |
| `userLoggedIn` | the visitor's auth state |
| `userRole` / `userCapability` / `userFields` / `userMeta` | properties of the logged-in user |
| `post<Tax>` (e.g. `postCategory`, `postPostTag`) | a public taxonomy on the current post — one rule auto-registered per public taxonomy |

Empty `[]` matches every page under `assign=auto`. Unknown `ruleID`s silently never match. Conditions are **ignored entirely under `assign=manual`** — to bind a manual template, use `mosaic-set-template-assign` instead. There is no `posts` / `archive` / `singular` / `front_page` rule at this layer.

## Template assigns (Sprint 3)

`wp_mosaic_template_assigns` is Mosaic's **manual template assignment** feature: bind a single post to a specific template. Mosaic 1.0.3 reads ONE pattern from this table:

- `type` = Mosaic's INTERNAL resource type. For singular post bindings always pass `"post"` — Mosaic uses the same resource type for pages, blog posts, and every registered CPT (its `PathPostTypePage` / `PathPostTypePost` definitions both call `setResourceType('post')`).
- `type_identifier` = the numeric post ID as a string.
- The WP post-type lives on the template's `path` field instead: `single-page.php` for pages, `single-post.php` for blog posts, `single-<cpt-slug>.php` for CPTs.

Every other content surface (archives, taxonomies, 404, search, front page) is matched at render time via the template's `conditions` JSON, NOT via this table — use `mosaic-edit-template` to set `conditions` for those.

- **`mosaic-list-template-assigns`** — every binding under a theme.
- **`mosaic-set-template-assign`** — PUT-style binding. Re-running the same triple replaces the previous template_id.
- **`mosaic-clear-template-assign`** — removes a binding. Idempotent (returns `deleted: true` even when no row matched).

## Gotchas

- **Onboarding is license-gated.** On a free Mosaic install the wizard cannot complete; the install can show `tables_present=true` but `entities.themes=0`. Surface this state to the user and ask them to complete the wizard before write abilities can run.
- **`var(--<row-id>)` is wrong.** Variables emit a CSS custom property from `data.customProperty`, NOT from the row UUID. The Sprint 2 compact summary surfaces `custom_property` exactly so the agent picks the right reference.
- **`assign` enum.** `auto` (Mosaic decides which front-end pages match) vs `manual` (the user wires it via template_assigns).
- **`status='delete'` is soft.** Deleted rows stay in the table; `mosaic-get-theme` / `mosaic-get-template` / `mosaic-get-element-tree` hide them. Resurrection is manual SQL.
- **Lock keys are hashed.** `mo_with_lock()` uses sha1 of `(blog_id, key)` because Mosaic UUIDs blow past MySQL's 64-char GET_LOCK limit. Telemetry in the lock-name prefix only.
- **Ordering uses BINARY collation.** Every `ORDER BY ordering` and `WHERE ordering <op> ?` is wrapped in `BINARY` because the table is `utf8mb4_unicode_520_ci` (case-insensitive) but `FractionalIndex` assumes ASCII binary collation. Match this pattern in any new ordering query.
- **JSON object/list shapes are type contracts, not interchangeable.** Template `conditions` is typed `?array`, so its empty form is `[]`; `{}` decodes to `stdClass` and can cause a site-wide TypeError. Object-typed entity `data` columns (nodes, themes, utility classes, settings) use `{}` when empty. Inside a utility-class `data` object, `states`, each state, and each breakpoint are objects. `localStates` and `interactions`, when non-empty/present, are arrays; Mosaic's exporter omits them when empty. `mosaic-set-utility-class-style` verifies this envelope before Mosaic persists it. Never hand-encode or write these blobs through `execute-php` / SQL.
- **Blob depth ≤ 255.** `data` / `conditions` get rejected with `mo_invalid_input` when nested past 255 levels — the practical encode/decode ceiling. PHP's default JSON depth (512) would otherwise let writes succeed but reads silently return `{}` on round-trip. Flatten before retrying.
- **Blob bytes ≤ 1 MiB.** `data` / `conditions` payloads larger than 1 048 576 bytes (after JSON-encoding) are rejected with `mo_payload_too_large`. The depth cap covers nesting but not WIDTH — without this guard a depth-200 blob with 1 MiB strings at every level can OOM wpdb at prepare time. Real-world Mosaic blobs are typically <10 KB; if you're near the cap, you're packing the wrong thing into element data.
- **Field-length caps.** Column-level ceilings are enforced at the boundary with `mo_invalid_input` rather than silently truncating: `template.name` ≤ 191, `template.path` ≤ 191, `node.type` ≤ 128, `template_assign.type` ≤ 36, `template_assign.type_identifier` ≤ 100. Counts are in characters (matching MySQL's utf8mb4 char-count semantics), so emoji count as one each.
- **Read-path decode failure marker.** If a blob persisted via direct SQL (bypassing our write-side validation) is too deep or malformed for `mo_decode_data` to parse, the response carries `{_decode_failed: true, _bytes: N}` instead of an empty `{}`. Distinguishes "blob was empty" from "blob was unparseable" — the latter signals a corruption that needs operator attention.
- **UUIDs are case-insensitive in queries, case-sensitive in PHP.** Mosaic stores UUIDs lowercase (via `wp_generate_uuid4()`) and MySQL's collation matches case-insensitively — but the abilities lowercase every UUID-bearing input at the boundary so PHP `===` comparisons (cycle guard, document scoping) work regardless of casing the agent passes.

## `novamira_pro_mosaic_changed` hook

Every successful write ability fires `do_action('novamira_pro_mosaic_changed', $payload)`. The payload always carries `ability`, `operation`, `entity`, `entity_id`, `document_type`, `document_id`, `theme_id`, `template_id`, `user_id` (nullable fields default to `''`). Use this to mirror agent writes into your own audit log / cache invalidation / activity stream.

Three contract details listeners must respect:

- **Emits fire under the write lock.** MySQL `GET_LOCK` is re-entrant on the same connection (verified empirically), and WordPress runs the whole request on one wpdb connection, so a listener that calls back into Mosaic abilities in-process does NOT deadlock. The two real hazards instead: (1) the listener's writes commit *while the outer write still holds its session lock* — other in-request readers observe the listener's mutations in real time, even though they're nested inside the outer write; (2) a **separate** HTTP request / WP-CLI process contending for the same `nodes:{document_id}` / `template-write:{theme}` / `template-assigns:{theme}` key WILL block up to 5s and return `mo_lock_conflict`. A synchronous listener that makes an outbound HTTP call back to MCP on the SAME `document_id` inflicts that full 5s lock-timeout window on the ORIGINAL caller's wall-clock — the original write commits, but the agent waits 5s for the response. Defer outbound work via `wp_schedule_single_event` / queue dispatch when the listener's target can hit the same lock key.
- **Listener exceptions don't roll back the outer write.** A throw inside a `novamira_pro_mosaic_changed` handler propagates up as `ability_callback_exception`, but the outer ability's DB write has already committed (no transaction wraps `mo_with_lock`). Listeners that may fail should catch their own throws and decide how to surface the failure separately.
- **Idempotent no-ops on the delete path do NOT fire.** `delete-element` on an already-soft-deleted node, `clear-template-assign` on a non-existent binding, and `delete-template` on an already-deleted row all return `deleted: true` to the agent but skip the hook because no rows changed.
- **No-op edits DO currently fire** (known gap). `edit-template` / `edit-element` / `move-element` rotate the `revision` UUID and emit the hook every call, even when the supplied fields match the current values. Listeners that mirror writes will see spurious "changed" events until the no-op detector lands. If you need to react only to real state changes, compare the post-call `revision` against your cached `revision` before reacting — they'll differ even on no-ops today, so the safer signal is a content-level diff your listener computes itself.

## Cohabitation with the Mosaic editor

When a user has the Mosaic admin editor open on the same template the agent is writing to, two contracts surface:

- **Last-writer-wins, no compare-and-swap.** Mosaic's editor commit endpoint (`RESTEditorInstanceCommit::isCommitAllowed()`) returns `true` unconditionally. The editor sends `revisionEnvelopes` verbatim with no compare-against-server-revision check. If the user opens the editor at T0, the agent edits a node at T1, the editor's poller picks up the change at T2, and the user clicks Update at T3 — the editor commits its local snapshot (taken at T0) for every dirtied node, silently overwriting the agent's T1 edit on any node the user didn't visibly touch. No 409, no warning toast, no merge dialog. Mitigation today is procedural: ask the user to close the editor before scripted edits, or accept the race and re-run the agent's writes if the user-edited revision wins.
- **Template revision doesn't rotate on per-node edits.** Every node edit rotates the node's own `revision` UUID. The parent template's `revision` does NOT rotate. An external poller that watches only `mosaic_templates.revision` for "did anything change in this template" misses every node-level edit. Mosaic's own editor polls per-node envelopes; third-party consumers of `novamira_pro_mosaic_changed` should mirror that pattern (track `entity` + `entity_id` from the payload, not just `template_id`).

## Sprint status

- ✅ **S1 (baseline)** — `mosaic-check-setup`
- ✅ **S2 (read-only discovery)** — `list-themes`, `list-templates`, `list-components`, `list-collections`, `list-variables`, `list-utility-classes`, `get-theme`, `get-template`, `check-design-system` (design-system compliance audit — flags hardcoded inline CSS vs variables / utility classes)
- ✅ **S3 (element tree + templates + assigns)** — `get-element-tree`, `add-element`, `edit-element`, `delete-element`, `move-element`, `create-template`, `edit-template`, `delete-template`, `list-template-assigns`, `set-template-assign`, `clear-template-assign`
- ✅ **S4 (design system writes)** — `get-utility-class`, `create-utility-class`, `edit-utility-class`, `delete-utility-class`, `set-utility-class-style` (one validated property at a state × breakpoint), `get-collection`, `create-collection`, `edit-collection`, `delete-collection`, `get-variable`, `create-variable`, `edit-variable`, `delete-variable`, `get-theme-settings`, `edit-theme-settings`. Top-level `class_names` shortcut on `add-element` / `edit-element`.
- ✅ **S5 (sub-data writes, partial)** — `set-variable-value` (writes the per-skin × per-mode value graph for `color` / `length` / `n-length` variables; `font` / `stacked` types still need Mosaic's editor). Also fixed a pre-existing bug where `create-variable(type=n-length|font)` always failed (wrong internal Mosaic type string — `nLengthPercentage`/`font` instead of `numberLengthPercentage`/`fontFamily`).
- ✅ **Component-instance overrides** — `edit-component-instance-override`, `clear-component-instance-override`.
- ⏳ **S5 (remaining)** — `font` / `stacked` variable values (structured object, still editor-only), `mosaic-get-element-data-schema` (deferred: Mosaic doesn't expose a registered element-type catalog at the PHP layer)
