Headless spatial compositor runtime for Phoenix LiveView.
LivePanels gives a Phoenix app a server-authoritative canvas: item placement, drag, resize, zoom, focus, layout persistence, graph connectors, vector items, and shared-canvas coordination. It does not ship a product shell, theme, window manager, or component kit.
| Package | Purpose |
|---|---|
livepanels | Core compositor runtime, Elixir APIs, prebuilt browser hooks, type declarations, sourcemaps, and runtime CSS. |
examples/demo | Phoenix demo app that proves the package wiring and demonstrates app-owned chrome and item renderers. |
Documentation
Build the generated documentation site with:
mix docs
The site publishes the README, the guides under guides/, API
reference pages, search data, and interactive graph diagrams used by the graph
connections guide.
Install
Add the dependency:
# mix.exs
{:livepanels, "~> 0.1.0"}Wire the browser package into your application:
mix livepanels.install
cd assets && npm install
mix livepanels.install adds a livepanels file dependency to
assets/package.json. Import the prebuilt hooks and CSS from your application assets:
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import { LivePanelsHooks } from "livepanels";
import "livepanels/runtime.css";
const hooks = {
...LivePanelsHooks
};
const liveSocket = new LiveSocket("/live", Socket, { hooks });Applications that need browser behavior beyond the bundled defaults can create their
own hook instance. itemInteractionPolicy runs inside the managed item
interaction pipeline, so drag and resize previews, throttled updates, finish
payloads, and LiveView reconciliation all receive the app-adjusted geometry:
import {
LivePanelsHooks,
createLivePanelsCanvasHook
} from "livepanels";
const axisLockedCanvas = createLivePanelsCanvasHook({
itemInteractionPolicy: {
constrainDrag(geometry, { state, event }) {
if (!event.altKey) return geometry;
return { y: state.origY };
},
constrainResize(geometry) {
return {
w: Math.round(geometry.w / 48) * 48,
h: Math.round(geometry.h / 48) * 48
};
}
}
});
const hooks = {
...LivePanelsHooks,
LivePanelsCanvas: axisLockedCanvas
};The package root intentionally exports the hook API, not concrete controller
classes. Applications configure behavior through createLivePanelsCanvasHook/1
options so internal controllers can keep changing with the runtime.
Root JavaScript exports:
LivePanelsHooksLivePanelsCanvascreateLivePanelsCanvasHookmountLivePanelsMinimaps
Root TypeScript exports:
LivePanelsCanvasRuntimeOptionsLivePanelsMinimapHandleLivePanelsMinimapOptionsItemInteractionPolicyItemDragConstraintContextItemResizeConstraintContext
If you import JavaScript and CSS from assets/js/app.js, keep the CSS import
beside the hook import:
import "livepanels/runtime.css";If your Phoenix app imports CSS from assets/css/app.css, put the CSS import
there instead:
@import "livepanels/runtime.css";Generate a working canvas route:
mix livepanels.new canvas --yes
The generator patches your Phoenix router and writes an ordinary route LiveView
at lib/<app>_web/live/canvas_live.ex. That one file contains a direct
source-owned item declaration and app-owned frame markup around the rendered
content, so you can start the server, visit /canvas, and immediately verify
add, drag, resize, and remove behavior. When the route grows, keep item
declarations in the canvas LiveView and extract by ownership: route helpers under
live/canvas_live/, shared function components under components/, and
process-backed item content as LiveViews.
Choose --toolkit canvas, graph, or drawing to match the
helpers your canvas should import. Choose
--toolkit none when you want the generated LiveView to skip toolkit imports.
Validate canvas declarations any time setup feels off:
mix livepanels.check
mix livepanels.check compiles the application and validates every
on_mount {LivePanels.Canvas, opts} declaration before you debug browser wiring.
In Phoenix apps, compose LivePanels after the app's ordinary LiveView macro so routes, layouts, verified routes, and HTML helpers come from the application:
defmodule MyAppWeb.BoardLive do
use MyAppWeb, :live_view
use LivePanels.Canvas,
pubsub: MyApp.PubSub
use LivePanels.Toolkit.Canvas
enduse LivePanels.Canvas attaches on_mount {LivePanels.Canvas, opts}.
Applications import toolkit helper modules explicitly, or compose the same
runtime pieces themselves when they want direct lifecycle ownership:
defmodule MyAppWeb.BoardLive do
use MyAppWeb, :live_view
use LivePanels.Toolkit.Canvas
@impl true
def mount(params, session, socket) do
LivePanels.Canvas.mount_runtime([pubsub: MyApp.PubSub], params, session, socket)
end
endTry The Example App
Run the in-repo Phoenix harness before writing your own canvas:
cd examples/demo
mix setup
mix phx.server
Routes in the harness:
| Route | Proves |
|---|---|
/ | install path, asset wiring, and an embedded LivePanels product canvas |
/workspace | local items, selection, history, clipboard, layout controls, remote embeds, and canvas-in-canvas composition |
/graph | ports, connectors, routing, graph controls |
/drawing | freehand, shapes, draw tools, vector item editing |
/shared/:room | the workspace pattern under Local shared mode |
First Canvas
For an ordinary draggable panel, declare the item directly in the canvas markup and put the panel content inside the slot:
defmodule MyAppWeb.CanvasLive do
use MyAppWeb, :live_view
use LivePanels.Canvas,
pubsub: MyApp.PubSub
def render(assigns) do
~H"""
<main class="canvas-app">
<.canvas_runtime {canvas_runtime_attrs(assigns, class: "app-canvas")}>
<:item :let={context} id="basic-1" at={{80, 80}} size={{640, 420}} resize>
<section class="item-shell">
<header {drag_handle_attrs(class: "item-titlebar")}>
<span>Note</span>
</header>
<div class="livepanels-item-body">
<textarea name="body" class="note-input">Hello</textarea>
</div>
<.resize_handles :if={context.view.resizable?} />
</section>
</:item>
</.canvas_runtime>
</main>
"""
end
enduse LivePanels.Canvas attaches runtime state with on_mount after the app's
LiveView macro has installed the application's ordinary Phoenix imports. Import
LivePanels.Toolkit.Canvas to use:
canvas_runtime/1, canvas_runtime_attrs/2, frame runtime attrs, resize
handles, input attrs, and add/remove item controls.
That is the ordinary path: item slots in canvas_runtime/1 and app-owned
frame/content markup. Use an id-less :item renderer when the canvas also needs
browser/server-created runtime items.
Beyond First Canvas
Source-owned and runtime-owned items use the same visible authoring shape. Put the item frame and content in HEEx; put runtime capabilities on the item attrs:
<.canvas_runtime {canvas_runtime_attrs(assigns)}>
<:item :let={context}>
<section class="item-shell">
<header {drag_handle_attrs()}>{context.item.label}</header>
<div class="item-body">
{LivePanels.Item.prop(context.item, :body, "")}
</div>
<.resize_handles :if={context.view.resizable?} />
</section>
</:item>
</.canvas_runtime>Application controls create runtime items by sending explicit type and option payloads:
<button
{add_item_attrs("note",
id: "note-#{System.unique_integer([:positive])}",
x: 160,
y: 120,
w: 380,
h: 240,
props: %{body: "Created from the toolbar"}
)}
>
New note
</button>For content-driven viewport UI such as menubars, docks, and HUDs, declare viewport item attrs directly on the item:
<:item
id="canvas-controls"
type="canvas_controls"
space={:viewport}
anchor={:top}
lock
persist={false}
render={%{frame_overflow: :visible, stream_update: :canvas_state}}
>
<nav class="canvas-controls">...</nav>
</:item>For profile-specific viewport item frames, configure viewport on the canvas
and viewport_frames on the viewport item:
<:item
id="canvas-controls"
type="canvas_controls"
space={:viewport}
lock
viewport_frames={%{
default: %{anchor: :bottom_right, x: 16, y: 16, w: 360, h: 72},
compact: %{anchor: :top_left, x: 0, y: 0, w: :fill, h: 96}
}}
>
<nav class="canvas-controls">...</nav>
</:item>Viewport frames are per-socket render projection for :viewport items. They do
not rewrite canonical item placement or shared layout state. World item geometry
belongs to the host or to canvas item commands.
Toolkit Guide
Toolkit modules are use presets. Use the preset that matches the helpers a
canvas renders.
| Toolkit | Use when | Adds |
|---|---|---|
LivePanels.Toolkit.Canvas | You need canvas runtime and canvas controls. | Runtime root, drag handles, resize handles, keyboard scope, focus exemption, item controls, selection, layout, history, clipboard, viewport JS. |
LivePanels.Toolkit.Graph | Your canvas has nodes with ports and connectors. | Canvas plus graph connector, routing, marquee, and navigation helpers. |
LivePanels.Toolkit.Drawing | Your canvas commits freehand or shape vector items. | Canvas plus drawing tool, style, vector item, and point-edit controls. |
LivePanels.Toolkit.Integration.ProtocolAttrs and LivePanels.Toolkit.Integration.Render remain public
for manual integration: command envelopes, custom frame loops, item ordering,
DOM ids, z-index, and vector item rendering. They are escape hatches, not
first-canvas imports.
Application Ownership
LivePanels owns runtime behavior:
- canonical canvas state and reducers
- item placement, focus, visibility, resize, drag, zoom, and keyboard scope
- item-level focus promotion policy through
interaction.promote_on_focus - layout persistence contracts
- browser/server event and payload validation
- shared-canvas coordinator behavior
- graph and drawing primitives that build on the same canvas model
Your application owns the visible UI:
- route layout, navigation, and product language
- frames, titlebars, close buttons, docks, HUDs, and toolbars
- graph node visuals, port treatment, drawing palettes, and editor chrome
- Tailwind, vanilla CSS, CSS modules, DaisyUI, or any other visual system you choose
Keep behavior on public helpers such as drag_handle_attrs/1,
focus_exempt_attrs/1, remove_item_attrs/2, graph attrs, drawing attrs,
viewport JS helpers, LivePanels.Canvas.Render.refresh/2,
mountLivePanelsMinimaps, and resize_handles/1. Keep visible markup
replaceable.
Common Runtime Paths
Use application controls for browser-triggered commands:
<button {add_item_attrs(:basic_note)}>New note</button>
<button {remove_item_attrs(@item)}>Close</button>
<button {layout_attrs(:grid)}>Layout</button>
<button {undo_attrs()}>Undo</button>Layout is strategy-based. Application code submits commands through
LivePanels.Canvas.Arrange.layout/3; planning adapters, registered strategy
lookup, and external result conversion live under LivePanels.Canvas.Layout.
Built-ins include :horizontal, :vertical, :grid, :cascade, :masonry,
:fit_rows, :fit_columns, :skyline_pack, :master_stack, :bsp_balance,
:bsp_split, and :ordered_treemap, and extensions can publish additional
browser-visible keys with :layout_strategies.
Use owner modules from canvas application events:
def handle_event("mute-item", %{"id" => id}, socket) do
socket =
socket
|> LivePanels.Canvas.Item.merge_props!(id, %{muted: true})
|> LivePanels.Canvas.Item.hide!(id)
{:noreply, socket}
endGuard runtime commands with a configured application policy when product data should decide whether a canvas mutation may happen:
defmodule MyApp.CanvasPolicy do
@behaviour LivePanels.Canvas.Policy
@impl true
def authorize(%LivePanels.Canvas.Policy.Request{action: :remove_item} = request) do
if MyApp.Workflows.item_locked?(request.canvas_id, request.item_ids, request.actor) do
{:error, :active_workflow_reference}
else
:ok
end
end
def authorize(_request), do: :ok
end
on_mount {LivePanels.Canvas, pubsub: MyApp.PubSub, policy: MyApp.CanvasPolicy}Policy runs in the reducer/coordinator command path. LivePanels catches policy
exceptions and rejects the command. Configure timeout_ms: to bound command
acceptance checks.
Server-authored commands have direct result forms:
case LivePanels.Canvas.Item.remove(socket, item_id) do
{:ok, socket, _meta} ->
{:noreply, socket}
{:error, reason, socket, _meta} ->
{:noreply, assign(socket, :canvas_error, reason)}
endBrowser-triggered command rejections are also recorded on the returned socket
and can be inspected with LivePanels.Canvas.Read.last_command/1.
When a canvas command must be coordinated with application-owned records, run that work in the application callback or context module. After the application write succeeds, issue the LivePanels command explicitly:
def handle_event("move_card", %{"id" => id, "dx" => dx, "dy" => dy}, socket) do
delta = {String.to_integer(dx), String.to_integer(dy)}
case MyApp.Boards.move_card(id, delta) do
{:ok, card} ->
socket =
LivePanels.Canvas.transaction!(socket, fn tx ->
tx
|> LivePanels.Canvas.Item.move_by!(id, delta)
|> LivePanels.Canvas.Item.merge_props!(id, %{
card_id: card.id,
card_version: card.lock_version
})
end)
{:noreply, socket}
{:error, reason} ->
{:noreply, assign(socket, :canvas_error, reason)}
end
endReturn :ok to allow, {:ok, metadata} to allow with command metadata, or
{:error, reason} / {:halt, reason} to reject. Policy does not append canvas
commands, rewrite commands, or run application write transactions.
Use LivePanels.Graph for graph state/helpers and LivePanels.Toolkit.Graph
for app-rendered ports and graph controls. Use LivePanels.Drawing plus
LivePanels.Toolkit.Drawing for committed vector item behavior.
Graph connectors and vector items share the runtime world SVG substrate:
connectors and committed vector items are ordered SVG items. The application
still renders node cards, port dots, connector styling, toolbars, and editor
chrome.
Shared Mode
Shared mode changes coordination, not the command grammar:
children = [
{Phoenix.PubSub, name: MyApp.PubSub},
LivePanels.Application
]use MyAppWeb, :live_view
use LivePanels.Canvas,
pubsub: MyApp.PubSub,
session: [
mode: :shared,
id: {:param, "canvas_id"},
backend: LivePanels.Canvas.Shared.Local
]For clustered coordination, provide a LivePanels.Canvas.Shared.Adapter and a
database-backed layout store such as LivePanels.Canvas.LayoutStore.Ecto.
LivePanels owns the coordinator contract, participant tracking contract,
command fanout, recovery reporting, and layout codec; the application owns
cluster membership, authentication, and collaboration UI.
See examples/demo/STRUCTURE.md only when changing the application harness layout or
extracting reusable app components.
Local Development
Root library setup:
mix deps.get
cd assets && npm ci && cd ..
Useful local checks:
mix precommit
mix coverage
cd assets && npm run typecheck && npm test
Release-facing checks:
mix ci
mix docs --warnings-as-errors
mix hex.build
cd assets && npm run typecheck && npm test
Browser runtime build:
cd assets && npm run build
mix ci is the CI/release Elixir gate. The browser runtime has its own npm
typecheck, tests, and package assertion under assets/.
mix coverage runs the ExUnit suite with ExCoveralls, writes an HTML report
under cover/, and enforces the repository coverage gate without uploading
results to a coverage service.
Where To Read Next
- Start with
What LivePanels Is. - Build the first route with
Create Your First Canvas. - Use
Find The API You Needwhen you know the task and need the right module. - Add optional capabilities with
Add Graph Connections,Add Drawing Tools, andUse Shared Canvases. - Building a graph editor or node-and-connector product? Use the scoped
Graph Apps With LivePanelstrack after the intro graph guide. - Building a whiteboard, annotation, or sketch editor? Use the scoped
Drawing Apps With LivePanelstrack after the intro drawing guide.
License
MIT