LivePanels graph nodes are app-rendered item shells with runtime port attrs.
The application decides what a node looks like. LivePanels needs enough attrs to know which DOM element represents each declared port and which command to send when the user starts or completes a connection.
Render A Node Shell
Use the :item slot:
<.canvas_runtime {canvas_runtime_attrs(assigns)}>
<:item :let={context}>
<article class={["node-card", context.selected? && "is-selected"]}>
<header {drag_handle_attrs(class: "node-card__title")}>
{context.item.label}
</header>
<section class="node-card__body">
{context.content}
</section>
<.resize_handles :if={context.view.resizable?} />
</article>
</:item>
</.canvas_runtime>Graph nodes still need ordinary item behavior: drag, focus, selection, resize, visibility, z-order, and item content.
Render Port Controls
context.ports contains render-ready ports for the current node:
<button
:for={port <- context.ports}
class={"node-port node-port--#{port.direction}"}
{graph_port_anchor_attrs(port)}
{connection_attrs(context.item, port)}
>
{port.label}
</button>Then choose the command attrs from direction:
defp connection_attrs(item, %{direction: :output} = port), do: begin_connector_attrs(item, port)
defp connection_attrs(item, %{direction: :input} = port), do: complete_connector_attrs(item, port)
defp connection_attrs(_item, _port), do: %{}graph_port_anchor_attrs/2 identifies the port for geometry and browser
interaction. begin_connector_attrs/3 and complete_connector_attrs/3 emit graph
commands.
Position Ports From Their Anchor
Use the same anchor declaration to style ports:
defp port_style(%{anchor: %{edge: edge, offset: offset}}) do
case edge do
:left -> "left:-0.5rem; top:#{offset}px; transform:translateY(-50%);"
:right -> "right:-0.5rem; top:#{offset}px; transform:translateY(-50%);"
:top -> "top:-0.5rem; left:#{offset}px; transform:translateX(-50%);"
:bottom -> "bottom:-0.5rem; left:#{offset}px; transform:translateX(-50%);"
end
endThat keeps visible handles aligned with the endpoint coordinates the server will use for connector projection.
Render Row-Based Ports
For table, form, or workflow nodes, derive port rows from item props:
<div :for={entry <- @column_port_entries} class="column-row">
<span>{entry.column.name}</span>
<button
class="column-port"
style={port_style(entry.port)}
title={port_title(entry)}
{graph_port_anchor_attrs(entry.port)}
{connection_attrs(@item, entry.port)}
/>
</div>Build the entries by matching declared ports to domain rows:
defp column_port_entries(columns, ports) do
port_lookup = Map.new(ports, &{&1.name, &1})
columns
|> Enum.flat_map(fn column ->
[
%{column: column, port: Map.get(port_lookup, "col_#{column.slot}_in")},
%{column: column, port: Map.get(port_lookup, "col_#{column.slot}_out")}
]
end)
|> Enum.reject(&is_nil(&1.port))
endThe row is product UI. The port is runtime metadata.
Show Connection Counts
Use LivePanels.Graph.Query to show connected ports:
defp connector_count(connectors, item_id, endpoint, port_name) do
connectors
|> Enum.count(fn connector ->
case {endpoint, connector} do
{:from, %{from: %{item_id: ^item_id, anchor: anchor}}} ->
LivePanels.Item.Connector.port_name(anchor) == port_name
{:to, %{to: %{item_id: ^item_id, anchor: anchor}}} ->
LivePanels.Item.Connector.port_name(anchor) == port_name
_ ->
false
end
end)
endOr use query helpers from the canvas LiveView:
state = LivePanels.Canvas.Read.state(socket)
connectors = LivePanels.Graph.connectors(state)
connected = LivePanels.Graph.Query.connectors_for_port(connectors, "table-1", "col_2_out")Render counts as application UI:
<span :if={entry.outgoing_count > 0} class="port-count">
{entry.outgoing_count}
</span>Pending Connector State
The runtime projects the current actor's connector draft as @livepanels.connector_draft.
Pass it to node components when the node should show compatible targets or
reconnect state:
<.node context={context} pending_connector={@livepanels.connector_draft} />Use pending state for visual hints only. Validation still happens on the server when the command completes.
Accessibility
Ports are real controls. Give them names:
<button
aria-label={"Connect #{context.item.label} #{port.label}"}
title={port.label}
{graph_port_anchor_attrs(port)}
{connection_attrs(context.item, port)}
/>If a visible port is decorative and another labelled control starts the connection, put graph attrs on the labelled control instead.
Application Responsibilities
The application owns:
- node layout and visual hierarchy;
- port shape, label, hover state, count badges, and disabled hints;
- node inspectors and property editors;
- how domain data appears inside the node.
LivePanels owns:
- port metadata projection;
- graph command attrs;
- endpoint validation;
- connector state and connector projection.