# StatifierBlocks

[![CI](https://github.com/riddler/statifier_blocks/actions/workflows/ci.yml/badge.svg)](https://github.com/riddler/statifier_blocks/actions/workflows/ci.yml)
[![Hex.pm Version](https://img.shields.io/hexpm/v/statifier_blocks.svg)](https://hex.pm/packages/statifier_blocks)
[![Hex Downloads](https://img.shields.io/hexpm/dt/statifier_blocks.svg)](https://hex.pm/packages/statifier_blocks)
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/statifier_blocks/)
[![License](https://img.shields.io/hexpm/l/statifier_blocks.svg)](https://github.com/riddler/statifier_blocks/blob/main/LICENSE)

Block document model, one-way SCXML compiler, and LiveView editor components
for composing [Statifier](https://github.com/riddler/statifier-ex) statecharts
from typed blocks.

Statecharts are the right execution model for long-running workflows, and
SCXML is the right interchange format for them - but neither is something a
non-engineer will author by hand. This package is the authoring layer:

- **A block document model.** The authoring artifact is a document: a tree of
  typed blocks that a person composes, each block a unit with a declared
  shape rather than free-form XML. The document, not the chart, is the source
  of truth that gets stored, versioned, and edited.

- **A one-way SCXML compiler.** The compiler turns a block document into an
  SCXML chart that Statifier can run, and carries a provenance map so a
  runtime position in the chart can be pointed back at the block that
  produced it. The direction is deliberate: documents compile to charts, and
  nothing decompiles a chart back into blocks.

- **LiveView editor components.** The components a host embeds to let people
  compose, rearrange, and validate a block document in a browser - the
  editing surface over the model above, sharing the family's rendering and
  fixtures conventions with
  [statifier_ui](https://github.com/riddler/statifier-ui).

Blocks are typed and host-pluggable: a host registers the block types its own
domain needs, and the compiler and editor work off that registry rather than
off a closed built-in vocabulary.

## Installation

```elixir
def deps do
  [
    {:statifier_blocks, "~> 0.3"}
  ]
end
```

## A worked example

A card-processing flow: place a hold, and settle it when the account has the
budget for it. Everything below runs - it is the example the suite executes on
every build.

**1. Write the block types your domain needs.** A block type is a behaviour
module: a handful of declarations plus one `emit/2`. These two are invoking
leaves, so they share their emission.

```elixir
defmodule MyApp.Blocks do
  @moduledoc "Emission helpers shared by this host's invoking leaves."

  alias StatifierBlocks.{Block, Emission}
  alias StatifierBlocks.Compiler.Context
  alias StatifierBlocks.Core.Emit

  @doc "One compound state that starts an `<invoke>` and finishes either way."
  def invoke_leaf(%Block{config: config}, %Context{} = context) do
    done = Context.done_id(context)
    {:ok, running} = Context.role_id(context, "running")
    {:ok, invocation} = Context.role_id(context, "invocation")

    waiting =
      Emit.state(running, nil, [
        Emission.element("invoke", [
          {"id", invocation},
          {"type", Map.get(config, "invoke_type", "")}
        ]),
        Emit.transition(event: "done.invoke." <> invocation, target: done),
        Emit.transition(event: "error.execution", target: done)
      ])

    {:ok, Emit.state(context.state_id, running, [waiting, Emit.final(done)])}
  end
end

defmodule MyApp.Blocks.Authorize do
  @moduledoc "`myapp.authorize`: places a hold on the card."
  @behaviour StatifierBlocks.BlockType

  @impl true
  def current_version, do: 1

  @impl true
  def slots(_config), do: []

  @impl true
  def config_schema(_config),
    do: [%{key: "invoke_type", type: :string, label: "Invoke", required?: true, default: ""}]

  @impl true
  def validate_config(_config), do: :ok

  @impl true
  def io(_config), do: %{kinds: [:step], produces: "myapp.credit_card_txn"}

  @impl true
  def emit(block, context), do: MyApp.Blocks.invoke_leaf(block, context)
end

defmodule MyApp.Blocks.Capture do
  @moduledoc "`myapp.capture`: settles a hold this flow already placed."
  @behaviour StatifierBlocks.BlockType

  @impl true
  def current_version, do: 1

  @impl true
  def slots(_config), do: []

  @impl true
  def config_schema(_config),
    do: [%{key: "invoke_type", type: :string, label: "Invoke", required?: true, default: ""}]

  @impl true
  def validate_config(_config), do: :ok

  @impl true
  def io(_config), do: %{kinds: [:step], consumes: "myapp.credit_card_txn"}

  @impl true
  def emit(block, context), do: MyApp.Blocks.invoke_leaf(block, context)
end
```

`io/1` is where a type declares how data flows through it. `produces` and
`consumes` are opaque strings compared for identity, widened only by a
relation the host supplies - there is no built-in type lattice.

That relation rides on the palette (`Palette.new(types, assignability:
MyApp.Blocks.Types)`) and it reaches every consumer through that one value.
`Assignability.validate/3` - what the compiler runs over the whole document -
and `Edit.Targets.slot_verdicts/3` - what the editor runs once at drag start
to mark every droppable slot before the pointer moves - are the same
implementation reading the same relation, so widening the host module opens a
drop target and clears the matching finding in the same edit. The relation can
only widen: identity is checked first, so a host callback can never refuse
something the default rule accepts.

Where a seam refuses, `Assignability.finding_reason/2` says why in a small
vocabulary (`:not_assignable`, `{:fixable_by, block_id}`), and
`Assignability.seam_reasons/3` names the seams that passed only because a
block declared nothing (`:source_untyped`, `:target_untyped`,
`:both_untyped`) - the way to find the parts of a palette you have not typed
yet. The editor stamps a refused slot's reason beside its validity as
`data-drop-reason`.

**2. Compose the document.** Your two types, arranged by the `core.*`
vocabulary this package ships. Twelve types: the containers that arrange
other blocks (`core.sequence`, `core.group`, `core.branch`, `core.parallel`,
`core.resumable_group`), and the leaves that do a structural thing on their
own (`core.wait`, `core.on_event`, `core.invoke`, `core.subchart`,
`core.send`, `core.raise`,
`core.assign`). None of them knows a domain - `core.invoke` *names* an invoke
type for the host to run and never runs one, and `core.subchart` names
another chart the same way. `StatifierBlocks.Core` carries
the table of all twelve with their slots. In a running system an editor
writes this tree; it is ordinary data either way.

```elixir
alias StatifierBlocks.{Block, Compiler, Document, Palette, Provenance}

document =
  Document.new(
    Block.new("core.sequence",
      id: "blk_root",
      slots: %{
        "body" => [
          Block.new("myapp.authorize",
            id: "blk_authorize",
            config: %{"invoke_type" => "myapp:authorize"}
          ),
          Block.new("core.branch",
            id: "blk_approved",
            config: %{
              "arms" => [%{"slot" => "arm_approved", "cond" => "budget_remaining > amount"}]
            },
            slots: %{
              "arm_approved" => [
                Block.new("myapp.capture",
                  id: "blk_capture",
                  config: %{"invoke_type" => "myapp:capture"}
                )
              ]
            }
          )
        ]
      }
    ),
    id: "bdoc_card_capture"
  )
```

**3. Build a palette and compile.** A palette is a plain value - a
`type_name => module` map you build for one operation and pass explicitly.
It is deliberately not application config and not a named process, so two
tenants in one runtime never step on each other's block types.

```elixir
palette =
  Palette.new(
    Map.merge(Palette.core_types(), %{
      "myapp.authorize" => MyApp.Blocks.Authorize,
      "myapp.capture" => MyApp.Blocks.Capture
    })
  )

{:ok, compiled} = Compiler.compile(document, palette)
```

`Compiler.compile/3` is a total function of `{document, palette}`: no process
state, no clock, no IO. It returns `{:ok, %StatifierBlocks.Compiled{}}` or
`{:error, findings}` - never a raise, never a partial success. The artifact
carries the generated bytes, the provenance map, a compilation record joining
document identity to chart identity, and the invoke types the chart names:

```elixir
compiled.invoke_types
#=> ["myapp:authorize", "myapp:capture"]
```

The SCXML it produced is a chart Statifier runs as-is - one compound state per
block, completion signalled by `done.state`:

```xml
<scxml initial="s_blk_root" name="bdoc_card_capture" version="1.0" xmlns="...">
  <state id="s_blk_root" initial="s_blk_authorize">
    <transition event="done.state.s_blk_authorize" target="s_blk_approved" type="internal"/>
    <transition event="done.state.s_blk_approved" target="s_blk_root__o_done" type="internal"/>
    <state id="s_blk_authorize" initial="s_blk_authorize__running">
      <state id="s_blk_authorize__running">
        <invoke id="s_blk_authorize__invocation" type="myapp:authorize"/>
        ...
```

**4. Point a running position back at a block.** That is what the provenance
map is for. Hand it the active state ids of a live session and it answers with
the blocks the session is inside - which is how an editor highlights the step
a run is on, and how a chart-level finding routes back to the config field
somebody typed it into.

```elixir
active_state_ids = Map.keys(compiled.provenance.by_state_id)

blocks_in_play =
  compiled.provenance
  |> Provenance.owners_of_states(active_state_ids)
  |> Enum.map(& &1.block_id)
  |> Enum.uniq()
  |> Enum.sort()

#=> ["blk_approved", "blk_authorize", "blk_capture", "blk_root"]
```

For a fixed `{document canonical bytes, palette, compiler version}` the
generated SCXML is byte-identical on every machine and every run, and
`compiled.record` carries all three - so a host can skip a recompile on an
unchanged triple. The guarantee is not reversible: identical SCXML does not
mean an unchanged document, because `metadata` is not compiled.

The package's two full worked examples - this card-processing flow and a
signup wizard with A/B testing (`myapp:signup`, variants, conversion events) -
live in `test/support/document_fixtures.ex` and are stored as canonical bytes
under `test/fixtures/documents/`. Between them they reach the whole `core.*`
vocabulary.

## Config fields and where their values live

A block type's `config_schema/1` declares the fields the editor renders for
it. A field's `key` is its **identity**: the DOM id, the form param name, and
what a `{:config, block_id, key}` finding anchors to. Where the value is
*stored* is a second, separate question, and a field answers it with an
optional `value_path` - a list of keys and list indexes from the config root
down to the value it edits.

Most fields need no path: `key` alone addresses `config[key]`. Some cannot use
one. `core.branch` keys a condition field by the arm's slot name, because that
is what a finding has to name, while the condition itself is stored inside the
ordered `"arms"` list:

```elixir
alias StatifierBlocks.{BlockType, Core}

config = %{"arms" => [%{"slot" => "arm_approved", "cond" => "budget_remaining > amount"}]}

[field] = Core.Branch.config_schema(config)

field.key
#=> "arm_approved"

BlockType.value_path(field)
#=> ["arms", 0, "cond"]

BlockType.fetch_value(config, BlockType.value_path(field))
#=> {:ok, "budget_remaining > amount"}

BlockType.put_value(config, BlockType.value_path(field), "amount <= 5000")
#=> %{"arms" => [%{"cond" => "amount <= 5000", "slot" => "arm_approved"}]}
```

`value_path/1` answers `[key]` for a declaration that declares no path, so a
caller never branches on which case it has. `fetch_value/2` is total and
answers `:error` for a path that does not resolve; `put_value/3` writes the
last segment whether or not a value was already there - an arm with no
condition yet is exactly the one an author is about to type into - but never
invents an intermediate map or list a block type did not write. A host block
type that stores a value somewhere other than a top-level key declares the
path the same way.

## Registering your own block types

The `core.*` vocabulary is structural on purpose: it knows sequencing,
branching, waiting and parallelism, and nothing about anyone's domain. A
card-processing host adds the steps its own product has by writing a module
per step and handing the editor an **explicit list** of them where the editor
is mounted. There is no global registry, no application-configuration lookup,
and no discovery pass that finds every module implementing the behaviour -
each of those would make two tenants in one runtime share a vocabulary that
is supposed to be per palette.

```elixir
defmodule MyApp.Blocks.RiskHold do
  @moduledoc "myapp.risk_hold: parks an authorization until a reviewer clears it."

  @behaviour StatifierBlocks.BlockType

  alias StatifierBlocks.Compiler.Context
  alias StatifierBlocks.Core.Emit

  @impl true
  def current_version, do: 1

  @impl true
  def slots(_config), do: []

  @impl true
  def config_schema(_config),
    do: [
      %{
        key: "queue",
        type: :string,
        label: "Review queue",
        required?: true,
        default: "fraud"
      }
    ]

  @impl true
  def validate_config(config) do
    case Map.get(config, "queue") do
      queue when is_binary(queue) and queue != "" -> :ok
      _missing -> {:error, [{"queue", "name the queue a reviewer picks this up from"}]}
    end
  end

  @impl true
  def palette_entry,
    do: %{
      label: "Risk hold",
      group: "Payments",
      description: "Parks the authorization until a reviewer clears it.",
      badge: "manual review",
      accent_token: "--sb-accent-risk"
    }

  @impl true
  def emit(_block, context) do
    done = Context.done_id(context)

    with {:ok, holding} <- Context.role_id(context, "holding") do
      waiting =
        Emit.state(holding, nil, [
          Emit.transition(event: "myapp.risk.cleared", target: done)
        ])

      {:ok, Emit.state(context.state_id, holding, [waiting, Emit.final(done)])}
    end
  end
end

palette =
  StatifierBlocks.Palette.from_modules(
    [{"myapp.risk_hold", MyApp.Blocks.RiskHold}],
    core: true
  )

{:ok, risk_hold} = StatifierBlocks.Palette.fetch(palette, "myapp.risk_hold")

Map.has_key?(palette.types, "core.sequence")
#=> true

StatifierBlocks.BlockType.badge(risk_hold.palette_entry())
#=> "manual review"

StatifierBlocks.ViewModel.accent_token(risk_hold.palette_entry())
#=> "--sb-accent-risk"
```

`from_modules/2` is a value constructor and nothing more - the palette it
returns is passed into the editor, the compiler and validation explicitly,
the same way `Palette.new/2` and `Palette.core/0` are. The list is ordered
and later entries win, so `core: true` puts the core vocabulary underneath
and a host that deliberately swaps in its own `core.wait` writes it after.
The registration carries the type **name** as well as the module because the
document names a type by string and the palette resolves the string: the
mapping is the host's fact, which is what lets one module serve two names in
two tenants' palettes.

### The three presentation declarations

A palette entry may also say how the editor should draw the type, and three
of those keys are worth calling out because a host reaches for them
immediately:

| Key | What it declares | Absent means |
|---|---|---|
| `accent_token` | the NAME of a `--sb-*` custom property, never a colour | the editor's own accent |
| `badge` | a short chip for the card header | no chip |
| `join_label` | a one-argument function of config, phrasing the join marker under a side-by-side arrangement | the editor's own word |

All three are read through a total normalizer that **refuses rather than
repairs**: a badge longer than 24 characters is dropped, not clipped, and one
carrying a newline is dropped rather than collapsed to a space, because a
truncated chip reads as a bug in the editor where a missing one reads as the
declaration it is. An accent that is not an anchored `--sb-*` name never
reaches a style attribute. A `join_label` is host code on the layout path, so
it is a pure function of its argument and it is called inside a rescue - a
type with a bug in it gets an ordinary join marker rather than taking the
canvas down.

## Embedding the editor

The editor ships in this package, and a host that never renders anything must
not pay for it. `phoenix_live_view` is therefore an **optional** dependency,
and every module under `StatifierBlocks.Editor.*` is compiled behind a
presence guard: an authoring API that compiles documents in a background job,
a test suite that exercises validation, a migration script - none of them drag
in Phoenix, and none of them compile a line of editor code.

A host that wants the editor already has LiveView, since there is nowhere else
to put the editor, so it adds nothing to `mix.exs`. It does three things:

**1. Import the hook.** The package's entire client-side surface is one hook.
Add the package to `assets/package.json`:

```json
{ "dependencies": { "statifier_blocks": "file:../deps/statifier_blocks" } }
```

and register it in `app.js`:

```javascript
import { StatifierBlocksDrag } from "statifier_blocks";

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { StatifierBlocksDrag },
});
```

**2. Import the stylesheet.** It is structural CSS only - the column layout,
the drag affordances, the finding treatments - with no visual opinion and no
framework:

```css
@import "../../deps/statifier_blocks/assets/css/statifier_blocks.css";
```

**3. Render the component.**

```heex
<.live_component
  module={StatifierBlocks.Editor}
  id="editor"
  document={@document}
  palette={@palette}
  on_change={&save_draft/1}
/>
```

Optional assigns: `findings` (yours, merged with the ones the view model
derives), `icon` (a function component that turns an icon *name* into markup),
`expression_component` (an override for `:expression` fields), `theme`, and
`class`.

**Icons.** You do not have to pass `icon`. The package ships
`StatifierBlocks.Editor.Icons`, a small set of inline SVGs for the names the
core block types declare - no font, no CDN, nothing to register in your asset
pipeline - and the editor uses it when you pass nothing. Every glyph paints
with `currentColor` and fills its tile, so the two tokens the tile reads
(`--sb-block-accent` and `--sb-block-accent-tint`) are all a theme has to
touch. See [`docs/theming.md`](https://github.com/riddler/statifier_blocks/blob/main/docs/theming.md).

Pass `icon` when you have an icon set of your own, and it wins on every tile -
the canvas cards and the palette rows alike. It is a component taking `name`
and `class`, and the *name* is what the block type declared:

```heex
<.live_component
  module={StatifierBlocks.Editor}
  id="editor"
  document={@document}
  palette={@palette}
  icon={&icon/1}
/>
```

```elixir
# A heroicons-style component: the name in, your markup out. The core types
# name heroicons ("clock", "bars-3", "arrow-path", ...), so a host already
# using them resolves every one by prefixing.
attr :name, :string, required: true
attr :class, :string, default: nil

def icon(assigns) do
  ~H"""
  <span class={[@class, "hero-" <> @name]} aria-hidden="true" />
  """
end
```

Two rules the seam keeps. A block type declares a **name**, never markup, so
nothing a palette entry carries is injected into the editor's render tree. And
a block type that declares no icon at all gets **no tile** rather than an empty
one, in the shipped set and in yours: your component is never called with a
`nil` name.

Underneath the component is a pure command algebra - `StatifierBlocks.Edit`
(insert, remove, move, update config, each with its inverse) over
`StatifierBlocks.ViewModel` - with no UI framework dependency at all. A host
that wants to drive document edits from something other than this editor uses
those directly.

### What the mounted component holds

The `document` you pass in, an undo history over it, the current selection,
and a `drafts` map of config edits the validation gate has not accepted yet.
A draft is never in the document and never on the undo stack: a form whose
config has not been accepted names the fields that are outstanding and offers
"Discard edits", because a draft was never a command and so cannot be undone.

There is deliberately **no `datamodel` assign**. A field that names a
datamodel path - `core.assign`'s `path`, a `core.invoke` param - is checked
for *shape* and nothing more, because this package does not own the datamodel
path grammar and holds no declaration to check a path against. A host that
knows its own datamodel checks paths itself and hands the result in through
`findings`.

### The host seams that exist today

Everything a host can say about how its own types behave and look is a
declaration on a value it already passes in - the palette, the palette entry,
the theme - rather than a callback the editor calls back into:

| Seam | Declared on | What it does |
|---|---|---|
| `:assignability` | `Palette.new/2` (also `from_modules/2`) | the host's widening relation for "may this block land in this slot" - both gates, kind admission and data flow, run against the palette the caller passed (ADR-0003 decision 6) |
| `accent_token` | palette entry | the NAME of a `--sb-*` property, stamped on that type's cards and palette rows |
| `badge` | palette entry | a short chip for the card header |
| `join_label` | palette entry | a one-argument function of config, phrasing the join marker under a side-by-side arrangement |
| `slot_outcome_key` | palette entry | names the config key the blocks in one slot carry their outcome under, so a renderer routes an interrupt rule's escape without branching on a type name; it reaches the view model as `Slot.outcome_key` and the resolved value as `Node.outcome` |
| `--sb-*` tokens | the `theme` assign, or your own CSS | every colour, space, radius and drag treatment - see [`docs/theming.md`](https://github.com/riddler/statifier_blocks/blob/main/docs/theming.md) |
| compile findings | `findings` assign | `StatifierBlocks.Finding.from_compiler/2` adapts a compiler finding into the shape the editor renders, so a compile result routes back to the field somebody typed it into |

The metadata readers are total and refuse rather than repair: a badge that is
blank, carries a newline, or runs past 24 characters is dropped rather than
clipped, an accent that is not an anchored `--sb-*` name never reaches a style
attribute, and a `join_label` that raises degrades to the editor's own word.
Assignability answers with reason-carrying refusals (sb-ue7, in flight).

Routing a compile pass into the findings pane is two calls:

```elixir
{lint_findings, _refused} =
  StatifierBlocks.Finding.from_compiler_all(compiled.warnings)
```

`from_compiler_all/2` returns the findings it could anchor and, separately,
the ones it refused with the reason - a finding that names no block has
nowhere in the editor to land, and dropping it silently would be the wrong
answer. Pass the anchored ones as the `findings` assign, or straight into
`StatifierBlocks.ViewModel.build/3` if you are driving the view model
yourself.

### Not yet

Honest about the edges, so you do not go looking for these:

- **Connectors.** Blocks are arranged by containment, and there is no
  free-floating edge between two cards. Whether the editor grows one is an
  open ADR-0005 decision-7 question (`sb-y14`).
- **A fixtures pane.** No panel drives a document against fixture rows, and
  nothing marks a block as currently invoking. ADR-0005 decision 15 defers
  the live half to the family's trace conventions (`sui-13q`).
- **Datamodel path advisories.** As above: shape only. Whether an undeclared
  path becomes an advisory finding is decision 11d, pending a ruling.

### Theming

Every class the package emits is prefixed `sb-`, and every color, space,
radius and drag treatment is a `--sb-*` custom property with a default. Set
them through the `theme` assign, or in your own CSS against the prefix:

```heex
<.live_component
  module={StatifierBlocks.Editor}
  id="editor"
  theme={%{"--sb-accent" => "var(--brand-500)", "--sb-radius" => "10px"}}
  ...
/>
```

Enough that a host can make the editor look like its own product without
forking it, and not so much that the package acquires a theming DSL.

[`docs/theming.md`](https://github.com/riddler/statifier_blocks/blob/main/docs/theming.md)
is the full guide: the three tiers the surface is organised into, why
`--sb-color-scheme` is not optional, how a block type gets an identity of its
own by naming a token, and a complete host theme you can copy. The rule it
holds itself to is that a theme sets `--sb-*` properties and writes no other
declaration - and that example is read out of the document and audited in the
gate, so it is checked rather than promised.

### What stays yours

Which palette entries a tenant may use, who may edit or publish a document,
where it is stored, and what publishing means. The editor is also a
single-session component: it surfaces the `revision` it loaded so you can do
optimistic concurrency on save, and it does not merge or resolve anything.

## Design records

The contracts this package is built out of are written down as ADRs in
[`docs/adr/`](https://github.com/riddler/statifier_blocks/tree/main/docs/adr):
the document schema (0001), the block-type behaviour (0002), host-pluggable
assignability (0003), the compiler and its provenance map (0004), and the
editor architecture (0005). A module's docs cite the decision it implements;
when the two disagree, the record is the contract and the code is the bug.

## License

MIT - see
[LICENSE](https://github.com/riddler/statifier_blocks/blob/main/LICENSE).
