# livepanels

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:

```bash
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:

```elixir
# mix.exs
{:livepanels, "~> 0.1.0"}
```

Wire the browser package into your application:

```bash
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:

```js
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:

```js
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:

- `LivePanelsHooks`
- `LivePanelsCanvas`
- `createLivePanelsCanvasHook`
- `mountLivePanelsMinimaps`

Root TypeScript exports:

- `LivePanelsCanvasRuntimeOptions`
- `LivePanelsMinimapHandle`
- `LivePanelsMinimapOptions`
- `ItemInteractionPolicy`
- `ItemDragConstraintContext`
- `ItemResizeConstraintContext`

If you import JavaScript and CSS from `assets/js/app.js`, keep the CSS import
beside the hook import:

```js
import "livepanels/runtime.css";
```

If your Phoenix app imports CSS from `assets/css/app.css`, put the CSS import
there instead:

```css
@import "livepanels/runtime.css";
```

Generate a working canvas route:

```bash
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:

```bash
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:

```elixir
defmodule MyAppWeb.BoardLive do
  use MyAppWeb, :live_view

  use LivePanels.Canvas,
    pubsub: MyApp.PubSub

  use LivePanels.Toolkit.Canvas
end
```

`use 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:

```elixir
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
end
```

## Try The Example App

Run the in-repo Phoenix harness before writing your own canvas:

```bash
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:

```elixir
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
end
```

`use 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:

```heex
<.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:

```heex
<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:

```heex
<: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:

```heex
<: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:

```heex
<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:

```elixir
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}
end
```

Guard runtime commands with a configured application policy when product data should
decide whether a canvas mutation may happen:

```elixir
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:

```elixir
case LivePanels.Canvas.Item.remove(socket, item_id) do
  {:ok, socket, _meta} ->
    {:noreply, socket}

  {:error, reason, socket, _meta} ->
    {:noreply, assign(socket, :canvas_error, reason)}
end
```

Browser-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:

```elixir
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
end
```

Return `: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:

```elixir
children = [
  {Phoenix.PubSub, name: MyApp.PubSub},
  LivePanels.Application
]
```

```elixir
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:

```bash
mix deps.get
cd assets && npm ci && cd ..
```

Useful local checks:

```bash
mix precommit
mix coverage
cd assets && npm run typecheck && npm test
```

Release-facing checks:

```bash
mix ci
mix docs --warnings-as-errors
mix hex.build
cd assets && npm run typecheck && npm test
```

Browser runtime build:

```bash
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`](guides/01-what-livepanels-is.md).
- Build the first route with
  [`Create Your First Canvas`](guides/02-create-your-first-canvas.md).
- Use [`Find The API You Need`](guides/03-find-the-api-you-need.md) when you
  know the task and need the right module.
- Add optional capabilities with
  [`Add Graph Connections`](guides/09-add-graph-connections.md),
  [`Add Drawing Tools`](guides/10-add-drawing-tools.md), and
  [`Use Shared Canvases`](guides/11-use-shared-canvases.md).
- Building a graph editor or node-and-connector product? Use the scoped
  [`Graph Apps With LivePanels`](guides/graph/01-graph-apps-with-livepanels.md)
  track after the intro graph guide.
- Building a whiteboard, annotation, or sketch editor? Use the scoped
  [`Drawing Apps With LivePanels`](guides/drawing/01-drawing-apps-with-livepanels.md)
  track after the intro drawing guide.

## License

MIT
