Packages

shadcn/ui-inspired component library for Phoenix LiveView with eject-based distribution. CSS-first theme system, 829 components across 20+ categories — Calendar, Chart, Animation, DnD, Editor, Collaboration, Typography, Navigation, Background, Surface, and more.

Current section

Files

Jump to
phia_ui lib phiaui_design mcp batch_design.ex
Raw

lib/phiaui_design/mcp/batch_design.ex

defmodule PhiaUiDesign.Mcp.BatchDesign do
@moduledoc """
Execute multiple design operations atomically with bindings and rollback.
Parses a JSON-based DSL of operations (Insert, Copy, Update, Replace,
Move, Delete) and executes them sequentially against the scene ETS.
If any operation fails, the entire batch is rolled back to the pre-execution
snapshot.
## Operations
| Op | Action | Required fields |
|-----|---------|------------------------------------------|
| `I` | Insert | `parent`, `data` (type, component/tag) |
| `C` | Copy | `id`, `parent` |
| `U` | Update | `id`, `data` (attrs/slots/classes/layout) |
| `R` | Replace | `id`, `data` (new node definition) |
| `M` | Move | `id`, `parent`, optional `index` |
| `D` | Delete | `id` |
## Bindings
Operations with `"bind"` store the created/resulting node ID.
Subsequent operations can reference `"$binding_name"` in `id` or `parent` fields.
## Limits
Maximum 25 operations per batch (matching OpenPencil convention).
"""
alias PhiaUiDesign.Canvas.{Scene, Node}
@max_operations 25
@doc """
Execute a batch of operations against the scene.
Returns `{:ok, results, bindings}` on success or `{:error, message, op_index}` on failure.
On failure, the scene is rolled back to its pre-execution state.
"""
@spec execute(reference(), [map()], map()) :: {:ok, [map()], map()} | {:error, String.t(), integer()}
def execute(scene, operations, state \\ %{}) do
_ = state
if length(operations) > @max_operations do
{:error, "Maximum #{@max_operations} operations per batch (got #{length(operations)})", -1}
else
snapshot = :ets.tab2list(scene)
do_execute(scene, operations, snapshot)
end
end
# ---------------------------------------------------------------------------
# Execution loop
# ---------------------------------------------------------------------------
defp do_execute(scene, operations, snapshot) do
result =
operations
|> Enum.with_index()
|> Enum.reduce_while({[], %{}}, fn {op, index}, {results, bindings} ->
case execute_op(scene, op, bindings) do
{:ok, result} ->
new_bindings = maybe_store_binding(bindings, op, result)
{:cont, {results ++ [Map.put(result, "index", index)], new_bindings}}
{:error, reason} ->
rollback(scene, snapshot)
{:halt, {:error, reason, index}}
end
end)
case result do
{:error, reason, index} -> {:error, reason, index}
{results, bindings} -> {:ok, results, bindings}
end
end
# ---------------------------------------------------------------------------
# Operation dispatch
# ---------------------------------------------------------------------------
defp execute_op(scene, %{"op" => "I"} = op, bindings) do
parent_id = resolve_ref(op["parent"], bindings)
data = op["data"] || %{}
type = data["type"] || "frame"
case type do
"phia_component" ->
insert_component(scene, parent_id, data)
"html_element" ->
insert_element(scene, parent_id, data)
"text" ->
insert_text(scene, parent_id, data)
_ ->
insert_frame(scene, parent_id, data)
end
end
defp execute_op(scene, %{"op" => "C"} = op, bindings) do
source_id = resolve_ref(op["id"], bindings)
parent_id = resolve_ref(op["parent"], bindings)
overrides = op["data"] || %{}
deep_copy(scene, source_id, parent_id, overrides)
end
defp execute_op(scene, %{"op" => "U"} = op, bindings) do
node_id = resolve_ref(op["id"], bindings)
data = op["data"] || %{}
update_node(scene, node_id, data)
end
defp execute_op(scene, %{"op" => "R"} = op, bindings) do
node_id = resolve_ref(op["id"], bindings)
data = op["data"] || %{}
replace_node(scene, node_id, data)
end
defp execute_op(scene, %{"op" => "M"} = op, bindings) do
node_id = resolve_ref(op["id"], bindings)
parent_id = resolve_ref(op["parent"], bindings)
index = op["index"] || -1
move_node(scene, node_id, parent_id, index)
end
defp execute_op(scene, %{"op" => "D"} = op, bindings) do
node_id = resolve_ref(op["id"], bindings)
delete_node(scene, node_id)
end
defp execute_op(_scene, %{"op" => op}, _bindings) do
{:error, "Unknown operation: #{op}"}
end
defp execute_op(_scene, op, _bindings) do
{:error, "Missing 'op' field in operation: #{inspect(op)}"}
end
# ---------------------------------------------------------------------------
# Insert operations
# ---------------------------------------------------------------------------
defp insert_component(scene, parent_id, data) do
component = safe_to_atom(data["component"] || "div")
attrs = atomize_map(data["attrs"] || %{})
slots = atomize_map(data["slots"] || %{})
name = data["name"]
layout_opts = extract_layout_opts(data)
registry_entry = PhiaUi.ComponentRegistry.get(component)
node =
Node.new_component(component,
module: registry_entry && registry_entry.module,
attrs: attrs,
slots: slots,
parent_id: parent_id,
name: name || to_string(component)
)
node = apply_layout_opts(node, layout_opts)
{:ok, _} = Scene.insert(scene, node)
{:ok, %{"op" => "I", "id" => node.id, "status" => "ok"}}
end
defp insert_element(scene, parent_id, data) do
tag = data["tag"] || "div"
classes = data["classes"]
name = data["name"] || tag
layout_opts = extract_layout_opts(data)
node = Node.new_element(tag, parent_id: parent_id, classes: classes, name: name)
node = apply_layout_opts(node, layout_opts)
{:ok, _} = Scene.insert(scene, node)
{:ok, %{"op" => "I", "id" => node.id, "status" => "ok"}}
end
defp insert_text(scene, parent_id, data) do
content = data["content"] || data["text_content"] || ""
name = data["name"]
opts = [parent_id: parent_id]
opts = if name, do: Keyword.put(opts, :name, name), else: opts
node = Node.new_text(content, opts)
{:ok, _} = Scene.insert(scene, node)
{:ok, %{"op" => "I", "id" => node.id, "status" => "ok"}}
end
defp insert_frame(scene, parent_id, data) do
classes = data["classes"]
name = data["name"] || "Frame"
layout_opts = extract_layout_opts(data)
node = Node.new_frame(parent_id: parent_id, classes: classes, name: name)
node = apply_layout_opts(node, layout_opts)
{:ok, _} = Scene.insert(scene, node)
{:ok, %{"op" => "I", "id" => node.id, "status" => "ok"}}
end
# ---------------------------------------------------------------------------
# Copy (deep clone)
# ---------------------------------------------------------------------------
defp deep_copy(scene, source_id, parent_id, overrides) do
case Scene.get_node(scene, source_id) do
{:ok, source} ->
do_deep_copy(scene, source, parent_id, overrides)
{:error, _} ->
{:error, "Node #{source_id} not found for copy"}
end
end
defp do_deep_copy(scene, source, parent_id, overrides) do
new_id = generate_id()
new_node = %{source |
id: new_id,
parent_id: parent_id,
children: []
}
# Apply overrides
new_node =
if attrs = overrides["attrs"] do
%{new_node | attrs: Map.merge(new_node.attrs, atomize_map(attrs))}
else
new_node
end
new_node =
if name = overrides["name"] do
%{new_node | name: name}
else
new_node
end
{:ok, _} = Scene.insert(scene, new_node)
# Recursively copy children
for child_id <- source.children do
case Scene.get_node(scene, child_id) do
{:ok, child} -> do_deep_copy(scene, child, new_id, %{})
_ -> :ok
end
end
{:ok, %{"op" => "C", "id" => new_id, "status" => "ok"}}
end
# ---------------------------------------------------------------------------
# Update
# ---------------------------------------------------------------------------
defp update_node(scene, node_id, data) do
case Scene.get_node(scene, node_id) do
{:ok, node} ->
changes = %{}
changes =
if attrs = data["attrs"] do
Map.put(changes, :attrs, Map.merge(node.attrs, atomize_map(attrs)))
else
changes
end
changes =
if slots = data["slots"] do
Map.put(changes, :slots, Map.merge(node.slots, atomize_map(slots)))
else
changes
end
changes =
if classes = data["classes"] do
Map.put(changes, :classes, classes)
else
changes
end
changes =
if name = data["name"] do
Map.put(changes, :name, name)
else
changes
end
# Layout property updates
layout_opts = extract_layout_opts(data)
changes = Map.merge(changes, layout_opts)
{:ok, _} = Scene.update_node(scene, node_id, changes)
{:ok, %{"op" => "U", "id" => node_id, "status" => "ok"}}
{:error, _} ->
{:error, "Node #{node_id} not found for update"}
end
end
# ---------------------------------------------------------------------------
# Replace
# ---------------------------------------------------------------------------
defp replace_node(scene, node_id, data) do
case Scene.get_node(scene, node_id) do
{:ok, old_node} ->
parent_id = old_node.parent_id
# Delete old node (but remember its position)
siblings =
if parent_id do
case Scene.get_node(scene, parent_id) do
{:ok, parent} -> parent.children
_ -> []
end
else
Scene.get_root_children(scene)
end
index = Enum.find_index(siblings, &(&1 == node_id)) || -1
# Delete old (including children)
Scene.delete_node(scene, node_id)
# Insert new at same position
type = data["type"] || "frame"
result =
case type do
"phia_component" -> insert_component(scene, parent_id, data)
"html_element" -> insert_element(scene, parent_id, data)
"text" -> insert_text(scene, parent_id, data)
_ -> insert_frame(scene, parent_id, data)
end
case result do
{:ok, %{"id" => new_id} = res} ->
# Move to original position
if index >= 0 do
Scene.move_node(scene, new_id, parent_id, index)
end
{:ok, %{res | "op" => "R"}}
error ->
error
end
{:error, _} ->
{:error, "Node #{node_id} not found for replace"}
end
end
# ---------------------------------------------------------------------------
# Move
# ---------------------------------------------------------------------------
defp move_node(scene, node_id, parent_id, index) do
# Circular move detection
if parent_id && is_descendant?(scene, parent_id, node_id) do
{:error, "Cannot move node #{node_id} into its own descendant #{parent_id}"}
else
case Scene.move_node(scene, node_id, parent_id, index) do
{:ok, _} -> {:ok, %{"op" => "M", "id" => node_id, "status" => "ok"}}
{:error, reason} -> {:error, "Move failed: #{inspect(reason)}"}
end
end
end
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
defp delete_node(scene, node_id) do
case Scene.delete_node(scene, node_id) do
:ok -> {:ok, %{"op" => "D", "id" => node_id, "status" => "ok"}}
{:error, _} -> {:error, "Node #{node_id} not found for delete"}
end
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp resolve_ref(nil, _bindings), do: nil
defp resolve_ref("$" <> name, bindings), do: Map.get(bindings, name)
defp resolve_ref(value, _bindings), do: value
defp maybe_store_binding(bindings, %{"bind" => name}, %{"id" => id}) when is_binary(name) do
Map.put(bindings, name, id)
end
defp maybe_store_binding(bindings, _op, _result), do: bindings
defp rollback(scene, snapshot) do
# Clear all entries
:ets.delete_all_objects(scene)
# Restore snapshot
:ets.insert(scene, snapshot)
end
defp is_descendant?(scene, candidate_parent, ancestor_id) do
case Scene.get_node(scene, candidate_parent) do
{:ok, node} ->
cond do
node.parent_id == ancestor_id -> true
node.parent_id == nil -> false
true -> is_descendant?(scene, node.parent_id, ancestor_id)
end
_ ->
false
end
end
defp extract_layout_opts(data) do
layout_keys = ~w(layout gap padding justify_content align_items wrap width height)
Enum.reduce(layout_keys, %{}, fn key, acc ->
case Map.get(data, key) do
nil -> acc
value -> Map.put(acc, safe_to_atom(key), coerce_layout_value(key, value))
end
end)
end
defp apply_layout_opts(node, opts) when map_size(opts) == 0, do: node
defp apply_layout_opts(node, opts), do: Map.merge(node, opts)
defp coerce_layout_value("layout", v) when is_binary(v), do: safe_to_atom(v)
defp coerce_layout_value("justify_content", v) when is_binary(v), do: safe_to_atom(v)
defp coerce_layout_value("align_items", v) when is_binary(v), do: safe_to_atom(v)
defp coerce_layout_value("wrap", v) when is_boolean(v), do: v
defp coerce_layout_value("wrap", "true"), do: true
defp coerce_layout_value("wrap", _), do: false
defp coerce_layout_value("width", "fill"), do: :fill
defp coerce_layout_value("width", "hug"), do: :hug
defp coerce_layout_value("width", v) when is_integer(v), do: v
defp coerce_layout_value("width", v) when is_binary(v), do: maybe_parse_int(v)
defp coerce_layout_value("height", "fill"), do: :fill
defp coerce_layout_value("height", "hug"), do: :hug
defp coerce_layout_value("height", v) when is_integer(v), do: v
defp coerce_layout_value("height", v) when is_binary(v), do: maybe_parse_int(v)
defp coerce_layout_value("gap", v) when is_integer(v), do: v
defp coerce_layout_value("gap", v) when is_binary(v), do: maybe_parse_int(v)
defp coerce_layout_value("padding", v) when is_integer(v), do: v
defp coerce_layout_value("padding", v) when is_list(v), do: v
defp coerce_layout_value("padding", v) when is_binary(v), do: maybe_parse_int(v)
defp coerce_layout_value(_key, v), do: v
defp maybe_parse_int(s) do
case Integer.parse(s) do
{i, ""} -> i
_ -> s
end
end
defp generate_id do
:crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)
end
defp safe_to_atom(str) when is_binary(str) do
String.to_existing_atom(str)
rescue
ArgumentError -> String.to_atom(str)
end
defp safe_to_atom(atom) when is_atom(atom), do: atom
defp atomize_map(map) when is_map(map) do
Map.new(map, fn {k, v} ->
key = if is_binary(k), do: safe_to_atom(k), else: k
{key, coerce_value(v)}
end)
end
defp coerce_value("true"), do: true
defp coerce_value("false"), do: false
defp coerce_value(":" <> rest), do: safe_to_atom(rest)
defp coerce_value(v) when is_binary(v) do
case Integer.parse(v) do
{int, ""} -> int
_ -> v
end
end
defp coerce_value(v), do: v
end