# `MishkaCmsCore.Builder.Events`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/builder/events.ex#L5)

The page builder's plugin surface — every section of the editor is a `MishkaInstaller` hook, and
the core ships as the seeded first participant of its own events (ADR-14).

Extending the editor means registering a plugin, not patching core. A blog extension adds a
"Post binding" inspector section, a brand-lint plugin adds an audit check, an agency adds a Studio
tab — all through the same events the core itself uses, which is what keeps those seams honest:
if a core section can be expressed as a contribution, an extension's can too.

## The three shapes, following the conventions already in this codebase

  * **Registries** are accumulators. The payload carries a seeded list a plugin appends to — never
    `[]`, always the core set first — and `Enum.uniq_by/2` keeps the first declaration, so a plugin
    can add but never silently redefine a core entry. Precedent: `sidebar:menus`.
  * **Transforms** pass the value itself and honour the return, so a plugin can rewrite or veto.
    Precedent: `runtime:compile:css`. While a transform's plugins cannot run, one that can veto —
    its payload carries `:error`, or `:allowed?` — refuses through its `cannot_run:` answer, and
    any other goes on with its payload unchanged.
  * **Announcements** pass a summary map with `return: false`. Precedent: `runtime:site:after_purge`.

## Catalog

Every row below has a call site. A documented event with nothing firing it is worse than no event
at all — the author writes the plugin, registers it, and watches nothing happen — so this table and
`registries/0` are kept in step with the code, and an event that loses its last caller is removed.

### Registries — the seeded list a plugin appends to

| Event | Payload | Fired from |
|---|---|---|
| `builder:components` | `%{entries: [PaletteEntry]}` | the Components palette |
| `builder:blocks` | `%{blocks: [Block]}` | the Blocks palette |
| `builder:elements` | `%{elements: [Element]}` | the Elements palette |
| `builder:inspector:sections` | `%{sections: [Section]}` | the Inspector |
| `builder:rails` | `%{tabs: [Tab]}` | the right-hand rail |
| `builder:studio:tabs` | `%{tabs: [Tab]}` | Studio |
| `builder:canvas:toolbar` | `%{actions: [Action]}` | the canvas toolbar — pressed, it announces `builder:canvas:toolbar:invoked` |
| `builder:commands` | `%{commands: [Command]}` | the command palette |
| `builder:audit:checks` | `%{checks: [Check]}` | the Audit rail |
| `builder:page_starters` | `%{structures: [Starter]}` | the empty-page picker |
| `builder:icon:libraries` | `%{libraries: [Library]}` | the icon picker |
| `builder:interactions` | `%{actions: [Action]}` | the Interactions section — see `MishkaCmsCore.Builder.Action` |
| `builder:conditions` | `%{rules: [Rule]}` | the Show-when section — see `MishkaCmsCore.Runtime.PageBuilder.Conditions` |
| `builder:js_hooks` | `%{hooks: [Hook]}` | the hook picker |
| `builder:mention:sources` | `%{sources: [Source]}` | @-mentions in review notes |

### Transforms — the value itself, and the return is honoured

| Event | Payload |
|---|---|
| `builder:node:before_<op>` | `%{op, meta, error}`, `op` the validated `Op` and `meta` `%{token, section, resource_id, site_id, actor_id}` |
| `builder:serialize` | `%{heex, meta}`, `heex` the serialized HEEx string and `meta` `%{section, stage}` |
| `builder:history:query` | `%{query, source_type, source_id, branch_id}` |
| `builder:containment` | `%{allowed?, reason, parent, child, tree, components}` |
| `builder:preview:document` | `%{html, resource, record_id, site_id, note}` |
| `builder:examples` | `%{examples, entry, site_id}` |
| `builder:preset:choices` | `%{choices, site_id}` |
| `builder:token:before_set` | `%{key, value, site_id, site_name, actor_id, error}` |
| `builder:ai:before_ask` | `%{prompt, scope, backend, section, resource_id, site_id, actor_id, error}` |
| `builder:form:role` | `%{role, entry}` |
| `page:form:submitted` | `%{form, values, page, site_id, handled}` |

`builder:form:role` carries a component's INFERRED part in a form (`:wrapper`, `:field`,
`:submit`, `:none`, …) and the palette entry it was read from; answer another role to change it.
A kit's own `extra.form` declaration never passes through it. See `form_role/2`.

`page:form:submitted` is the one row here that fires from a published page rather than from the
editor, and the one whose plugins WRITE. A plugin that stored the submission answers with
`handled: true`, and a page reads that to tell a form with a destination from one whose values
reached nobody. It is declared here so the editor's Forms audit group and the runtime read one
catalog; `form_destination?/0` answers by reading the registration rather than by sending the seam
a made-up payload to see what happens to it.

### Gates — `{:error, message}` refuses, and the editor says so

| Event | Payload |
|---|---|
| `builder:save:before` / `builder:publish:before` | `%{tree, serialized, resource_id, section, error}` |
| `builder:draft:before_save` | `%{section, resource_id, draft_id, label, serialized, site_id, actor_id, error}` |
| `builder:review:before_comment` | `%{body, type, section, node_id, parent_comment_id, error}` |
| `builder:code:before_apply` | `%{source, tab, node_id, section, resource_id, site_id, actor_id, error}` |
| `builder:class:before_save` | `%{name, utilities, site_id, actor_id, error}` |
| `builder:element_css:before_save` | `%{node_id, css, owner, site_id, actor_id, error}` |

### Announcements — `return: false`, nothing waits on them

| Event | Payload |
|---|---|
| `builder:session:after_open` / `after_close` | `%{token, section, resource_id, site_id, actor_id}` |
| `builder:node:after_<op>` | `%{token, section, resource_id, site_id, actor_id, kind, node_id}` |
| `builder:save:after` / `builder:publish:after` | `%{record_id, section, stage}` |
| `builder:draft:after_save` | `%{section, resource_id, draft_id, label, serialized, site_id, actor_id}` |
| `builder:review:after_comment` | `%{id, body, type, node_id, reply?, section, source_id, site_id, actor_id}` |
| `builder:element_css:after_save` | `%{node_id, css, owner, site_id, actor_id, stage}` |
| `builder:canvas:toolbar:invoked` | `%{action, node_id, token}` |
| `builder:form:after_rename` | `%{node_id, from, to, token, change, submit, section, resource_id, site_id, actor_id}` |
| `builder:form:after_customize` | `%{node_id, event, kind, section, resource_id, site_id, actor_id}` |
| `builder:form:after_create` | `%{node_id, seed, token, how, section, resource_id, site_id, actor_id}` |
| `builder:form:after_setting` | `%{node_id, seed, setting, value, section, resource_id, site_id, actor_id}` |

`builder:canvas:toolbar:invoked` is the half of the toolbar registry that makes the button do
something. A descriptor's `run` is read by `builder:interactions` alone, so a toolbar entry has no
dispatch of its own; pressing one announces here instead, with the element it was pressed on and
the session token the plugin applies its own ops through. See `toolbar_invoked/1`.

`builder:interactions` is the one to read first if you are adding a feature rather than a list
entry, because it is the registry that had to grow from a list into a contract:
`MishkaCmsCore.Builder.Action` is what an action's `run` module implements, and it is the shape to
copy — the entry says what boxes the panel should draw for it, whether it runs on the client or
the server, what it writes into the document and how to read that back.

A plugin registers exactly as the dashboard widgets do:

    use MishkaInstaller.Event.Hook, event: "builder:audit:checks", initial: %{priority: 50}

    def call(%{checks: checks} = payload) do
      {:reply, %{payload | checks: checks ++ [MyCheck.descriptor()]}}
    end

# `after_comment`

```elixir
@spec after_comment(map()) :: :ok
```

After a review note was written.

# `after_draft_save`

```elixir
@spec after_draft_save(map()) :: :ok
```

After a draft was written.

# `after_element_css`

```elixir
@spec after_element_css(map()) :: :ok
```

After an element's own CSS was written.

# `after_node`

```elixir
@spec after_node(atom(), map()) :: :ok
```

Announces a completed node mutation.

# `after_publish`

```elixir
@spec after_publish(map()) :: :ok
```

Announces a completed publish.

# `after_save`

```elixir
@spec after_save(map()) :: :ok
```

Announces a completed save.

# `announce`

```elixir
@spec announce(String.t(), map(), keyword()) :: :ok
```

Fires an announcement. Never blocks and never consults the return.

# `before_ask`

```elixir
@spec before_ask(map()) :: {:ok, String.t()} | {:error, String.t()}
```

Before the assistant is asked. A plugin may rewrite the prompt or refuse it.

This is where prompt handling belongs for anything that wants a say — a house style, a redaction
pass, a refusal.

# `before_class_save`

```elixir
@spec before_class_save(map()) :: :ok | {:error, String.t()}
```

Before a saved class is written. A plugin refuses by setting `:error`.

# `before_code_apply`

```elixir
@spec before_code_apply(map()) :: :ok | {:error, String.t()}
```

Before hand-written source is applied to the document. A plugin refuses by setting `:error`.

# `before_comment`

```elixir
@spec before_comment(map()) :: :ok | {:error, String.t()}
```

Before a review note is written. A plugin refuses by setting `:error`.

# `before_draft_save`

```elixir
@spec before_draft_save(map()) :: :ok | {:error, String.t()}
```

Before a draft is written. A plugin refuses by setting `:error`.

# `before_element_css`

```elixir
@spec before_element_css(map()) :: :ok | {:error, String.t()}
```

Before an element's own CSS is written. A plugin refuses by setting `:error`.

# `before_node`

```elixir
@spec before_node(atom(), map(), map()) :: {:ok, map()} | {:error, String.t()}
```

The transform seam every node mutation passes through. A plugin may rewrite the op or veto it by
setting `:error`; the session applies whatever comes back.

# `before_publish`

```elixir
@spec before_publish(map()) :: :ok | {:error, String.t()}
```

The publish gate, same contract as `before_save/1`.

# `before_save`

```elixir
@spec before_save(map()) :: :ok | {:error, String.t()}
```

The save gate — the seam content lint, broken-link checks and schema validation hang off. A veto
surfaces to the editor as a flash and an inline error, and nothing is written.

# `before_token_set`

```elixir
@spec before_token_set(map()) :: {:ok, term()} | {:error, String.t()}
```

Before a design token is set. A plugin may normalise the value or refuse it.

# `collect`

```elixir
@spec collect(atom(), list()) :: list()
```

Collects a registry: fires its event with `seed` already in the payload and returns the resulting
list, de-duplicated by `key` and sorted by `priority`.

The core's own entry wins a collision, whichever way the plugin assembled its answer — see
`kept/4`, which is where that stopped being a convention and became a rule.

A plugin that returns something unusable leaves the seed untouched rather than emptying the
editor — a broken extension must not be able to take a rail or the palette down.

# `collect_appended`

```elixir
@spec collect_appended(atom(), list()) :: list()
```

Collects a registry whose ORDER IS THE POINT, appending what plugins contribute to the end.

The shape above covers a registry of descriptors, where `priority` decides the order and every
entry carries a `key`. Some of the editor's lists are not that: the Blocks and Elements libraries,
the page starters and the icon libraries are curated, ordered lists identified by `:id` — or, for
icon libraries, bare modules. Running those through `collect/2` would sort them by
`Descriptor.key/1`, which for an entry with no `:key` is `inspect/1` of the whole map: the list
comes back in alphabetical order of its own inspected contents, silently rearranging a library
somebody curated.

So the seed keeps its order and contributions arrive after it, de-duplicated so a plugin can add
but not silently replace — which `kept/4` enforces rather than trusting. Same event system, same
`MishkaInstaller.Event.Hook.call/2`; a different promise about order.

# `containment`

```elixir
@spec containment(boolean(), map()) :: {:ok, boolean(), String.t() | nil}
```

The core's containment verdict, after it has been reached.

A plugin may tighten it — refuse a placement the core allows — but never loosen one the core
refused, because the core's rule is what keeps a document renderable.

# `examples`

```elixir
@spec examples(list(), map()) :: list()
```

A component's examples, before the wizard shows them.

# `fetch`

```elixir
@spec fetch(atom(), list(), term()) :: term() | nil
```

One entry of `collect_appended(registry, seed)` by id, or `nil`. An entry that is not a map is no
entry — a contribution of the wrong shape stays inert here rather than travelling.

A PLUGIN'S CARD IS A CARD. The Blocks and Elements palettes and the empty-page chooser draw the
merged list and are handed an id back by the click, and each resolved it in its core library
alone — so a card an extension contributed was drawn and then could not be found: the palette
answered "that element is no longer on the page", and the chooser, which has no error to show,
said nothing at all. `:interactions` and `:conditions` never had this, because each owns a merged
`registry/0` and asks THAT; here the core library is the only accessor there is.

`seed` is the list the caller rendered, because the two passes have to agree. Fired with `[]` the
payload breaks the promise this module makes to every plugin — "never `[]`, always the core set
first" — so a plugin written to that promise answers the click differently than it answered the
render. With the seed in, core's own entries answer here too and the caller needs no second
lookup, while `kept/4` still gives a shared id to the core. Once per placement, which is a
gesture, not a keystroke.

# `form_created`

```elixir
@spec form_created(map()) :: :ok
```

A form was made in the editor: `how` is `:started` (a field dropped with no form around it),
`:wired` (a kit form that arrived working), `:moved` (a field moved into a new form) or `:fixed`
(the audit's Fix made it, or made it work). `seed` and `token` are its name and the token in its
event names.

# `form_customized`

```elixir
@spec form_customized(map()) :: :ok
```

A form's event was customized in the Code window: its default code (`event`, of `kind`
`:validate` or `:submit`) was added to the Handlers tab, not stored yet.

# `form_destination?`

```elixir
@spec form_destination?() :: boolean()
```

Whether anything is registered to store page form submissions.

# `form_renamed`

```elixir
@spec form_renamed(map()) :: :ok
```

A form was renamed from the Inspector: `from` and `to` are its seeds, `token` the token its event
names carry (`nil` for untagged names), `change` and `submit` its new event names.

# `form_role`

```elixir
@spec form_role(atom(), map()) :: atom()
```

A component's inferred part in a form, before the editor uses it. A plugin answers another role
under `:role`; anything that is not `:none` or a `MishkaCmsCore.Builder.FormRole` role leaves
the inferred one.

    use MishkaInstaller.Event.Hook, event: "builder:form:role"

    def call(%{entry: %{name: "acme-send"}} = payload), do: {:reply, %{payload | role: :submit}}
    def call(payload), do: {:reply, payload}

# `form_setting`

```elixir
@spec form_setting(map()) :: :ok
```

A working form's setting was changed from the Inspector's Form panel: `setting` is `:how`
(`value` `:typing` or `:send`) or `:address` (`value` the web address its answers are sent to
once checked, `nil` when removed). `seed` is the form's name.

# `form_submitted`

```elixir
@spec form_submitted(map()) :: :handled | :unhandled
```

Hands a visitor's form submission to whoever stores it.

The payload carries `form` (the form's param name), `values` (the cast map, keyed by the fields
the form declares), `page` and `site_id`. A plugin that took it answers with `handled: true`;
anything else means the values reached nobody, which the page logs by form and field names only,
and which the editor's `form-no-destination` note reports.

    use MishkaInstaller.Event.Hook, event: "page:form:submitted"

    def call(payload) do
      {:reply, %{payload | handled: true}}
    end

# `gate`

```elixir
@spec gate(String.t(), map()) :: :ok | {:error, String.t()}
```

Fires a veto-capable gate. A plugin refuses by putting a message under `:error` in the payload it
returns; anything else lets the action through.

# `history_query`

```elixir
@spec history_query(Ash.Query.t(), map()) :: Ash.Query.t()
```

The timeline's read, before it runs.

A query rather than the loaded rows, deliberately: the sheet pages, and a plugin that dropped rows
after the read would leave a page of twenty showing twelve with no way to ask for the rest. Scoping
in SQL keeps the count and the page honest, and costs nothing on a table this size.

A plugin that answers with anything but a query leaves the read as it was.

# `listening?`

```elixir
@spec listening?(String.t()) :: boolean()
```

Whether `event` has a started plugin behind it.

Reads the compiled dispatch module rather than firing the event, so asking the question about a
seam whose plugins write something cannot itself cause a write. An event nobody ever registered on
has no dispatch module at all, which is the same answer.

# `preset_choices`

```elixir
@spec preset_choices(map(), map()) :: map()
```

The preset choices offered for a component's attributes.

# `preview_document`

```elixir
@spec preview_document(String.t(), map()) :: String.t()
```

The assembled preview document, before it is shown.

Fires wherever a preview is built — the editor's own preview, and the Preview tab on every runtime
modal — so a plugin that has to appear inside a preview (an overlay, a grid, a locale banner) has
one place to put it. Returning anything but a string leaves the document as it was.

# `registries`

```elixir
@spec registries() :: %{required(atom()) =&gt; {String.t(), atom()}}
```

Every registry event name the builder exposes, for documentation and tests.

# `serialize`

```elixir
@spec serialize(String.t(), map()) :: String.t()
```

The serialization transform — the AST-level sibling of the runtime's `runtime:compile:css`. A
plugin receives the generated HEEx and may return a rewritten string.

# `session_closed`

```elixir
@spec session_closed(map()) :: :ok
```

Announces that an editing session closed.

# `session_opened`

```elixir
@spec session_opened(map()) :: :ok
```

Announces that an editing session opened.

# `toolbar_invoked`

```elixir
@spec toolbar_invoked(map()) :: :ok
```

A canvas toolbar action a plugin contributed was pressed, on `node_id`, in the session `token`
names.

The core's own five are handled where they are — two document ops, two rail openers and the layout
panel — so nothing of the core's arrives here. Everything else the registry offers does, because a
toolbar descriptor has no `run` dispatch: the button used to fall through to "duplicate the
element", which is the one thing no plugin asked for.

An announcement rather than a transform: the plugin's work IS the effect, and it does that work
through the token, the same funnel the editor's own ops go down.

# `transform`

```elixir
@spec transform(String.t(), map()) :: map()
```

Fires a transform event and returns the (possibly rewritten) payload. The plugin's return is
honoured; anything that is not a map of the same shape is discarded in favour of the original.

While the event's plugins cannot run, a payload that can veto comes back refused: its `:error`
set, or its `:allowed?` false with a `:reason`. Any other comes back unchanged.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
