What an element does when it is clicked — the registry the panel reads and a package appends to.
Why not a script
Under LiveView the server owns the DOM: it sends a diff, and the client patches to match. A script that reaches in and changes a class is undone by the next patch that touches that element, which makes "add some JS to this button" the wrong shape of answer here even though it is the answer every WordPress builder gives.
Phoenix.LiveView.JS is the shape that fits. A command is declared in the markup, runs on the
client with no round trip, and survives patches because it is part of what the server rendered.
What the browser cannot do alone — a flash, which lives on the socket — takes the ordinary
JS.push/2 route instead, and says which it is: see MishkaCmsCore.Builder.Action.
What is here, and what is not
This module owns the list and the rules everything on it must follow. It owns no verb. Each
action is a module implementing MishkaCmsCore.Builder.Action, and core's seven arrive as the seed
of the builder:interactions registry — the same ADR-14 event a plugin appends to, so a package's
action reaches the panel through the door core already uses for its own.
That door was open for the list and shut for everything else: source/3 answered nil for any
action outside a private table, so a package's entry appeared in the dropdown and then did nothing.
What it compiles to
Nothing invented — plain HEEx a developer could have written:
<button class="pb-7f3a91b2" phx-click={JS.toggle_class("is-open", to: ".pb-7f3a91b2")}>What is checked
An action hands over terms rather than source — MishkaCmsCore.Builder.Command prints them — so
what reaches a document is a JS call carrying data by construction, and there is no text of a
package's to take on trust. What is still checked is that the command exists and that
Phoenix.LiveView.JS accepts the options it was given; a contribution that fails either writes
nothing and says why in the log.
Reading it back
By parsing the expression and asking each action whether it wrote it, never by matching text. An
expression nothing claims — anything hand-written, anything piped into something else — is reported
as :custom and left exactly as it is. The panel then shows it read-only rather than offering to
rewrite something it does not understand, which is the contract Tree.Styles already keeps for a
class attribute it cannot read.
Summary
Functions
The actions offered for a target, as {key, label} in panel order.
Whether this action takes a class name — the one field the audit and the tests still name.
The boxes the panel draws for one action, in order — see MishkaCmsCore.Builder.Action.
The attribute each open verb looks for on a component that declares its own open command.
Reads an attribute value back into {:ok, key, values}, :none, or :custom.
Every action the editor offers, core's first and a plugin's after them.
Core's own actions, in panel order — the seed every plugin appends to.
Whether this action reaches the server.
Every server action, as %{key:, event:, handler:} — what the page compiler needs and nothing else.
The expression source for one interaction, or nil when there is nothing to write.
The scope class an interaction points AT, or nil when it points at nothing named that way.
Functions
The actions offered for a target, as {key, label} in panel order.
Why the list depends on what is being aimed at
A verb the target cannot honour is worse than a missing verb: it writes a command that runs, does
something visible, and is not what the author asked for. Each action answers for itself through
offer?/2 — a modal declaring open and close is offered Show and Hide, a navbar declaring only a
toggle is offered only "Show or hide", and a component that declares nothing is offered everything,
because there is no better guess to make.
Whether this action takes a class name — the one field the audit and the tests still name.
The boxes the panel draws for one action, in order — see MishkaCmsCore.Builder.Action.
This is what keeps a package out of panel code: it says it wants a line of text called message,
and the Inspector renders a labelled input, remembers it and reads it back.
The attribute each open verb looks for on a component that declares its own open command.
Reads an attribute value back into {:ok, key, values}, :none, or :custom.
:custom is the important one: nothing in the registry claims the expression, so the panel must
show it rather than replace it.
@spec registry() :: [map()]
Every action the editor offers, core's first and a plugin's after them.
Collected through builder:interactions, which de-duplicates by key so a plugin can add but never
silently redefine a core verb. The call is a read of compiled plugin state, which is what the
Inspector has always paid for this list.
@spec seed() :: [map()]
Core's own actions, in panel order — the seed every plugin appends to.
Whether this action reaches the server.
false is a Phoenix.LiveView.JS command and no round trip, which is six of core's seven. true
is a push and a handler, and a page carrying one gets a handle_event/3 compiled into it.
Every server action, as %{key:, event:, handler:} — what the page compiler needs and nothing else.
An action missing either half is left out rather than half-compiled into somebody's page, and the handler is checked to be a function that exists: its module name is written into every published page using the action, so a typo would otherwise be a page that crashes on a click.
ONE ACTION PER EVENT. The compiler writes one handle_event/3 clause per event and a second for a
name the first already matches is unreachable code in a published page, so the first wins — which,
because MishkaCmsCore.Builder.Events keeps the core seed ahead of every contribution, means
core's. A plugin answering to pb:say would be a plugin reading the messages every page on the
site sends.
The expression source for one interaction, or nil when there is nothing to write.
opts carries what the click already knows and what the author typed: :target is another
element's scope class (nil aims at the clicked element itself), :opener is what that target
says about being opened, and every other key is a value from one of the action's own boxes.
Why a target at all
Because the commonest thing anyone wants from a button is to open something ELSE. A modal, a drawer
and twelve more render their own content closed, and with to: pinned to the element being
clicked, "open that dialog" was not a thing an author could ask for. target is another element's
scope class and it is still a scope: the panel offers the nodes it knows about rather than a
free-text selector, so an interaction can no more reach outside the document than it could before.
The scope class an interaction points AT, or nil when it points at nothing named that way.
Read apart from read/1 rather than widened into it, because read/1's answer is what the panel
and its tests are written against and the target is a different thing about the same expression.
The dot is dropped: everything else in the editor speaks in bare scope classes —
Tree.ensure_scope/2 answers one, ElementCss keys on one — and a selector is this module's own
spelling.