CI Hex.pm Version Hex Downloads Hex Docs License

Pre-1.0. Until statifier_blocks reaches v1.0, its public surface may change between minor releases, sometimes drastically: a release may rename modules, callbacks, table columns, telemetry events or error vocabulary with no compatibility shim. Every such change is recorded in CHANGELOG.md under a bold Breaking heading that says what to do about it. Pinning to an exact minor - ~> X.Y.0 - is the recommended way to consume the package until 1.0.

Block document model, one-way SCXML compiler, and LiveView editor components for composing Statifier 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.

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

def deps do
  [
    {:statifier_blocks, "~> 0.30.0"}
  ]
end

Two dependencies are optional and neither is added for you: phoenix_live_view, without which no editor module compiles at all, and statifier_ui, which an :expression field uses for its expression editor when it resolves. See Embedding the editor.

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.

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"}

  # The document's subject path. This type is the flow's first block, so it
  # is the one whose entry names where the subject lives - and `produces`
  # above is a write there.
  @impl true
  def palette_entry, do: %{label: "Authorize", subject: "cards.current_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. Nothing flows between adjacent blocks: every value a block writes goes to a datamodel path by name, so produces and consumes are read as a write and a read at the document's subject path - the path the document's first block names with subject: on its palette_entry/0. A document whose first block names none has no subject, and the two keys declare nothing.

StatifierBlocks.Environment carries a map from datamodel path to type through the document, and the check at a position is whether what the environment holds there satisfies what the block reads. That check is StatifierDatamodel.Types.satisfies/3: unknown is permissive both ways, identity is nominal, and a record satisfies a shape when it covers the shape's required fields. There is no built-in type lattice here and no second read check.

A host relation still rides on the palette (Palette.new(types, assignability: MyApp.Blocks.Types)) and reaches every consumer through that one value, and it runs last - after unknown, identity and coverage - so it can only ever widen. 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 over the same walk, so widening the host module opens a drop target and clears the matching finding in the same edit.

Where a read refuses, Assignability.finding_reason/2 says why in a small vocabulary (:not_assignable, {:fixable_by, block_id}, {:shape_not_satisfied, missing_fields}), and Assignability.seam_reasons/3 names the reads 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. Seventeen types: the containers that arrange other blocks (core.sequence, core.group, core.branch, core.parallel, core.resumable_group, core.foreach, core.map, core.drafts), and the leaves that do a structural thing on their own (core.wait, core.await, core.on_event, core.invoke, core.subchart, core.send, core.raise, core.assign, core.placeholder). core.await is the author's "Wait for event": it holds until a named event arrives, with an optional deadline, and ends one of two declared ways - received or timed_out - where core.wait holds for a duration and ends one way. core.on_event, the interrupt handler, carries an optional capture map that writes values out of the firing event's payload into the datamodel, and its event field offers the completion events the blocks in the enclosing body raise as candidates on the field an author types. 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. core.map and core.foreach are the two that run something per item and they are not variations on each other: core.foreach runs the blocks inside it, one item at a time, in the parent chart; core.map runs another chart for every item, all at once, as one invocation whose host handler starts the runs and collects their answers into one datamodel location, in item order. StatifierBlocks.Core carries the table of all seventeen with their slots. In a running system an editor writes this tree; it is ordinary data either way.

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",
    datamodel: [
      %Document.DatamodelEntry{
        id: "budget_remaining",
        description: "What the card's budget has left before this authorization."
      },
      %Document.DatamodelEntry{id: "amount", description: "The amount being authorized."}
    ]
  )

The branch's condition reads budget_remaining and amount, so the document declares both. A declaration names a root - storage exists at that name, and everything beneath it is declared too (ADR-0001 decision 11). The document's datamodel key is one of three surfaces that declare: a host's own datamodel, the compile call's :declare roots, and this one. A document that declares nothing and whose siblings declare nothing gets no undeclared-path advisories at all - not because everything checked out, but because nobody made a claim to check against, which is the case this example used to be in.

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.

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:

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:

<scxml initial="s_blk_root" name="bdoc_card_capture" version="1.0" xmlns="...">
  <datamodel>
    <data id="budget_remaining"/>
    <data id="amount"/>
  </datamodel>
  <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.

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:

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.

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.

Declaring a type instead of spelling it

Most host types have nothing to say about most callbacks. use StatifierBlocks.BlockType declares the behaviour and injects the answer a type gives when it has none of its own - no slots, no fields, nothing to refuse, version 1, unconstrained assignability, no migration - each one overridable, so a type writes only the rows where it differs. emit/2 is deliberately not among them: there is no emission to default to, and one injected here would let a type that compiles nothing look complete.

A leaf step that names one host invoke type and waits for the answer is the shape a host writes over and over, so it has a base of its own. use StatifierBlocks.InvokeStep fills in every callback from a declaration:

defmodule MyApp.Blocks.Capture do
  @moduledoc "myapp.capture: captures the authorized amount."

  use StatifierBlocks.InvokeStep,
    invoke_type: "myapp:capture",
    produces: "myapp.capture",
    palette: %{
      label: "Capture",
      group: "Payments",
      description: "Captures the authorized amount.",
      icon: "banknotes"
    }
end

MyApp.Blocks.Capture.invoke_type()
#=> "myapp:capture"

Enum.map(MyApp.Blocks.Capture.config_schema(%{}), & &1.key)
#=> ["label", "invoke_type"]

MyApp.Blocks.Capture.outcomes(%{})
#=> [{"done", "Done"}, {"error", "Error"}]

MyApp.Blocks.Capture.io(%{})
#=> %{kinds: [:step], produces: "myapp.capture"}

That is the whole type. It compiles to an <invoke> in an inner state with one transition and one <final> per outcome - core.invoke's emission with the on_error slot taken out, since a leaf step has no children and its failure path is an outcome a parent may wire. That error outcome is failure-classed by default, so a step that comes back on it is a step that failed; a host whose error is routine overrides failure_outcomes/1 with [] in the same place it would override outcomes/1. :fields adds config fields after label and invoke_type, and every injected callback is overridable: a step with extra <param> children calls StatifierBlocks.InvokeStep.emit/4 itself, and one with a tighter rule composes its own validate_config/1 out of the checks the module exports.

It still only names an invoke type. What runs one is a handler the host registers separately, per session - the two-registry seam this package draws, which use does not cross. One type is the exception: see "The one handler this package does ship" below.

A type that stands for a subtree

An arrangement a host writes over and over - call a step, and record the failure somewhere if it comes back on error - can be one block type rather than a shape an author has to rebuild each time. use StatifierBlocks.Composite declares one from params plus a pure subtree/1:

defmodule MyApp.GuardedStep do
  alias StatifierBlocks.Block

  use StatifierBlocks.Composite,
    name: "myapp.guarded_step",
    params: [
      %{key: "invoke_type", type: :string, label: "Call",
        required?: true, default: ""},
      %{key: "failure_path", type: :string, label: "Record the failure at",
        required?: true, default: "", datamodel_path?: true}
    ],
    sentence: "Call {invoke_type}, recording failure at {failure_path}",
    palette_entry: %{label: "Guarded step", group: "Structure"},
    version: 1

  @impl true
  def subtree(params) do
    [
      Block.new("core.invoke",
        id: "call",
        config: %{"invoke_type" => params["invoke_type"], "assign_to" => ""},
        slots: %{"on_error" => [
          Block.new("core.assign",
            id: "guard",
            config: %{"path" => params["failure_path"], "value" => "failed"})
        ]}
      )
    ]
  end
end

params is a list of ordinary field declarations - no new key and no new field type - and it is the type's config_schema/1, so every declaration check the compiler already runs runs over it for free. subtree/1 is pure: the same params answer the same blocks, forever, and the ids it writes are local, minted by StatifierBlocks.Composite.expand/2 as composite_id <> "_" <> local_id so the members inherit the composite block's own uniqueness rather than being freshly generated on every call.

The use derives config_schema/1, slots/1, io/1, outcomes/1, current_version/0, sentence/1, palette_entry/0 and a raising emit/2; only sentence/1, palette_entry/0 and validate_config/1 are overridable, because the others are about the expansion rather than about presentation or refusal. It also derives a StatifierBlocks.Recipe at <Module>.Recipe whose insert/2 puts down one composite block - one command, not the expansion - for a host that already ships the arrangement as a recipe.

Three things are worth knowing before you reach for one:

  • The compiler expands it, at the Resolve stage. A document holding a composite compiles to bytes identical to the same document with that composite expanded in place, so nothing about the chart depends on which of the two the author stored. emit/2 raises if it is ever reached, because no composite block survives to Emit.
  • A finding inside an expansion is reported against the composite. It carries the key of the param that produced the expanded block, or no key at all where no single param is responsible - never an anchor on a block the author cannot see. A declaration that cannot be expanded is a :composite_expansion_failed finding at :resolve rather than a raise.
  • A composite exposes only the slots it declares. slots/1 is [] unless the declaration names pass-through slots - use StatifierBlocks.Composite, slots: [%{name: "body", to: {"call", "on_error"}}] on a module, or a declaration-level "slots" key on a Composite.Data row - and each one it names is drawn as an interior on the card. expand/2 splices the children the author put there into the mapped slot of the mapped member, ids unchanged, so the chart is still the one the expansion by hand would have produced. A child's findings are the child's own, at the child. Composite.pass_through/2 resolves each declared slot to the minted id it maps into, and Composite.mapping_errors/2 answers the three refusals a mapping can earn against its own subtree.

A host whose users save block types does not have to generate a module per type. StatifierBlocks.Composite.Data derives the same thing from a JSON-shaped declaration - params, a subtree template with one "$param" placeholder arm and one "$literal" escape - and registers as a {module, state} palette entry beside the bare modules, going through the same Composite.expand/2. A data composite and the module composite of the same shape expand to the same blocks and compile to the same bytes. Composite.Data.declaration/1 refuses a malformed declaration before it reaches a palette.

A declaration held as data reaches the same field-type set a module declares - all nine field types have a JSON spelling, select, path, list and type_expr being the type's name plus an optional "options" key - and it may carry two keys a module composite spells in Elixir instead. "slots" declares the pass-through slots above, name => [local_id, inner_slot]. "migrations" declares an ordered chain of rename / drop / default steps, each keyed by the type_version it migrates from, so a block an author saved against an older revision of a saved type resolves rather than being refused; Composite.Data.migrate_config/3 walks every step at or above the stored version in one call, and the whole chain - shapes, gaps, order, and keys the declaration's own params do not account for - is validated at declaration/1 rather than at the first stored block that needs it.

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:

KeyWhat it declaresAbsent means
accent_tokenthe NAME of a --sb-* custom property, never a colourthe editor's own accent
badgea short chip for the card headerno chip
join_labela one-argument function of config, phrasing the join marker under a side-by-side arrangementthe editor's own word

All three are read through a total normalizer that refuses rather than repairs: a badge longer than 32 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.

Typing a palette

A block type can declare what it reads and writes at a datamodel path, and the walk that carries those declarations through a document is what lets the editor refuse a step that reads a path nothing put the right thing at.

Both signatures are declared on a field, not on the block, so a finding anchors on the control an author has to change:

alias StatifierBlocks.{BlockType, Environment}

read = %{
  key: "subject",
  type: {:path, %{expects: "Settleable"}},
  label: "Transaction",
  required?: true,
  default: "cards.current_txn"
}

write = %{
  key: "assign_to",
  type: {:path, %{writes: "cards.settlement"}},
  label: "Write the settlement to",
  required?: true,
  default: "cards.settlement"
}

read_path = BlockType.value_path(read)
#=> ["subject"]

declarations =
  StatifierDatamodel.Declarations.from_document(%{
    "types" => [
      %{
        "name" => "cards.credit_txn",
        "kind" => "record",
        "label" => "Credit card transaction",
        "fields" => [
          %{"name" => "amount_minor", "type" => "integer", "required?" => true},
          %{"name" => "currency", "type" => "string", "required?" => true}
        ]
      },
      %{
        "name" => "Settleable",
        "kind" => "shape",
        "label" => "Settleable",
        "fields" => [
          %{"name" => "amount_minor", "type" => "integer", "required?" => true},
          %{"name" => "currency", "type" => "string", "required?" => true}
        ]
      }
    ]
  })

satisfied = Environment.satisfies(declarations, "cards.credit_txn", "Settleable")
#=> :covers

label = Environment.type_label(declarations, "cards.credit_txn")
#=> "Credit card transaction"

write_signature = %{write.key => write.type}
#=> %{"assign_to" => {:path, %{writes: "cards.settlement"}}}

A field declaring expects and no writes is a read and not also a write. A {:path, opts} field carrying neither - and a :string field carrying datamodel_path?: true - writes :unknown: the path becomes known without becoming typed. io/1's consumes and produces are sugar for a read and a write at the document's subject path, which the entry block's palette entry names with subject:.

The records and shapes themselves live in the datamodel document's types key, which statifier_datamodel owns, and the read check is that package's StatifierDatamodel.Types.satisfies/3 - unknown is permissive both ways, identity is nominal, and a record satisfies a shape when it covers the shape's required fields. This package defines no second read check.

docs/typing-a-palette.md is the how-to: declaring records and shapes, naming the subject, projecting a host schema into the document with the scalar mapping table, and what a host re-resolves at publish.

The handlers this package does ship

The seam above is unchanged: a block type still only names an invoke type, and the host still registers the handler that runs it, per session. core.subchart is the one type where that handler is generic enough to write once and ship: "start the child chart this document names" is the same code in every host, because a subchart's contract already fixes both ends of it (StatifierBlocks.Core.Subchart's moduledoc) - a child compiled with child_use: true, its outcome carried on <donedata>, one final per outcome. Nothing host-specific is left for a handler to decide except which chart a document id names, so that is the one thing this handler asks the host for.

use StatifierBlocks.Runtime.Subchart builds that handler from two callbacks a host supplies: resolve_chart/2, which turns a document id into a chart (or refuses to), and palette/0, the palette the child compiles against. handlers/1 turns the resulting module into the map Statifier.Session.start_link/2 expects for :invoke_handlers:

defmodule MyApp.Charts do
  @moduledoc "Resolves a document id to the chart it names."

  use StatifierBlocks.Runtime.Subchart

  alias StatifierBlocks.{Document, Palette}

  @impl true
  def resolve_chart(document_id, _ctx) do
    case Map.fetch(document_store(), document_id) do
      {:ok, %Document{} = document} -> {:ok, document}
      :error -> :error
    end
  end

  @impl true
  def palette, do: Palette.core()

  defp document_store, do: Application.get_env(:my_app, :charts, %{})
end

StatifierBlocks.Runtime.Subchart.handlers(MyApp.Charts)
#=> %{"statifier_blocks:subchart" => MyApp.Charts}

A refusal to run the child surfaces on error.communication.invoke with one of exactly three reasons: unknown_document (the resolver could not place the id), child_compile_findings (the resolved document failed to compile as a child), or cycle_refused (the resolver detected a cross-document cycle a single-document compile cannot see for itself).

Placing the runtime is the host's job, not this package's: statifier ships no mod: application callback, so a host that wants Statifier.Session to run at all - subcharts or not - adds Statifier.Supervisor to its own supervision tree, then passes handlers/1's map as the :invoke_handlers option on every Statifier.Session.start_link/2 call that should run subcharts.

The durable variant

start/2 staying a pure planning callback is what scopes the handler above to the in-memory Statifier.Session case: a child that is its own durably persisted run has to record its parent linkage as part of starting, and a planning callback performs nothing.

So the durable variant is a second module, StatifierBlocks.Runtime.DurableSubchart (ADR-0008). It takes the same two callbacks, refuses for the same reasons, and answers at dispatch time instead - dispatch_fun/1 builds the fun StatifierPersistence.Driver's :dispatch option takes:

dispatch = StatifierBlocks.Runtime.DurableSubchart.dispatch_fun(MyApp.Charts)

The child then runs as its own persisted run, linked to the parent's run and invocation with a chart-identity pin, and its completion re-enters the parent through the driver's own done.invoke door long after the process that started it is gone. A durable start has one refusal reason the in-memory handler cannot have - child_run_creation_failed - and the driver raises it, since creating the run happens after this package has answered. Nesting works with no extra wiring; fan-out (one invocation, N children) is named by the record and not built.

Which variant runs is the host's session wiring, never the document: the same block document compiles to the same bytes either way. A host that wires the in-memory module into a durable run gets a child that does not survive the restart the parent was made durable to survive.

Nothing in this package depends on statifier_persistence; the durable module names no module from it and answers in plain tuples.

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 hooks. The package's entire client-side surface is two hooks, and the default export carries both, so registering them is one line. There are two ways to make the specifier resolve, and which one you want depends on whether your app runs npm install at all.

If it does, declare the dependency in assets/package.json. Point it at the package's assets/ directory, which is where the package.json lives - the package ships no manifest at its root, so file:../deps/statifier_blocks names a directory npm cannot read:

{ "dependencies": { "statifier_blocks": "file:../deps/statifier_blocks/assets" } }

If it does not - the default for an app from the Phoenix generator, which has no assets/package.json and no npm step - let esbuild resolve it the same way it already resolves phoenix and phoenix_live_view, through NODE_PATH pointed at deps:

config :esbuild,
  my_app: [
    args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
  ]

On this route the specifier is the path into the package rather than the bare name, because NODE_PATH resolution walks deps/ as a module directory and does not read assets/package.json:

import StatifierBlocks from "statifier_blocks/assets/js/statifier_blocks.js";

Registration is the same on either route - only the specifier differs, and the bare name below is the npm one:

import StatifierBlocks from "statifier_blocks";

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

Register both: StatifierBlocksDrag turns pointer gestures into commands, and StatifierBlocksMeasure reports the laid-out geometry the server draws the connectors from - without it nothing measures the browser's boxes, so no connectors are drawn and the editor renders as stacked rows with no flow lines. Both are still available as named exports, and a host that wants measurement alone can import it from statifier_blocks/measure on the npm route, or from statifier_blocks/assets/js/statifier_blocks_measure.js on the NODE_PATH one (ADR-0005 decision 7 and its 2026-08-29 amendment, "a second hook that only measures").

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:

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

3. Render the component.

<.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), value_candidates (the values you offer per datamodel path), on_select (a panel of yours that follows the canvas selection), on_collapse (the declaration the "Save as a step" gesture proposes), debounce (what phx-debounce the inspector's config controls carry), theme, and class. StatifierBlocks.Editor's own moduledoc carries the full assign table; the seam table below says what a host declares on each of them.

Following the selection. on_select is on_change's sibling for the other subject a host may want: a one-argument function called with each new selection, so a drawer tab, a preview or a detail pane of your own can follow the canvas instead of making the operator pick the same block twice. What arrives is a descriptor - %{id:, type:, label:}, where label is the line the card draws - and nil when nothing is selected, which is how a panel knows to empty itself. It fires when the selection changes and not otherwise, it is optional, and a host that passes nothing sees no difference of any kind. The block's config is deliberately not in it: you already hold the document you passed in.

Expression fields. With statifier_ui on your load path an :expression renders that package's own expression editor: picklists of field, operator and value while the source is inside the subset predicator can round-trip, and a text input over everything else. It never refuses what an author typed and never rewrites it, and every control it draws writes a complete expression source string into the same input - the document still stores your author's text. Without statifier_ui the same field is the plain source input this package has always rendered, so the dependency is genuinely optional. Pass expression_component to override both.

When you pass a datamodel document, the kinds it declares reach that editor too: a clause on a path declared integer offers the numeric operators, and one on a date path the date set, rather than whichever set the clause's current source happens to imply. It is a control and not a check - the operator the source carries is still offered, the value in it is still kept, and a disagreement renders as an advisory beside the clause - and a path the document says nothing about renders exactly as it always did.

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.

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:

<.live_component
  module={StatifierBlocks.Editor}
  id="editor"
  document={@document}
  palette={@palette}
  icon={&icon/1}
/>
# 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

Yours is rendered as a function component, exactly as if the editor had written <.icon name={...} class={...} /> against it, so it gets a tracked assigns map and every Phoenix.Component helper works inside it - assign/3, assign_new/3, whatever you reach for to derive a value before the markup. It has to return a ~H template, which is the one thing the seam requires.

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.

With statifier_ui

statifier_ui is an optional dependency of this package, declared {:statifier_ui, "~> 0.8", optional: true} beside phoenix_live_view and optional in the same sense: its only consumer is a LiveView component, so a tree with no editor in it would be resolving a package nothing there can call. Nothing adds it for you, nothing warns at compile time when it is absent, and the resolution is a runtime Code.ensure_loaded?/1 plus function_exported?/3 against the module named by :statifier_blocks, :expression_component_module, which defaults to StatifierUI.Live.ExpressionInput.

What it buys is one surface: the control an :expression config field renders. If you want that control, there are two steps, and each is a second one beside a step you have already taken above.

1. A second file: entry. statifier-ui ships its JavaScript as source too (statifier-ui docs/adr/0009-javascript-ships-as-source.md), so on the npm route its dependency sits beside this package's, and both point at the package's assets/ directory:

{
  "dependencies": {
    "statifier_blocks": "file:../deps/statifier_blocks/assets",
    "statifier_ui": "file:../deps/statifier_ui/assets"
  }
}

That record documents this route and no other; statifier-ui's assets/package.json names js/index.js as the entry point, which is the file any other specifier would have to reach.

2. A second hook registration. The two packages' hooks are separate objects, and registering one does not register the other. Spread both:

import StatifierBlocks from "statifier_blocks";
import { StatifierUIHooks } from "statifier_ui";

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...StatifierBlocks, ...StatifierUIHooks },
});

StatifierUIHooks is StatifierUIExpressionInput and StatifierUIExpressionPicklist, keyed by the names statifier-ui's component renders; a host that wants one of them imports it by name instead. Hook names and export names are public API under ADR-0009, the same as an exported function.

What degrades without them. Only the :expression field moves. The three states, in order:

StateAn :expression field renders
no statifier_ui on the load paththe plain source input this package has always rendered, with a <datalist> of the declared datamodel paths - clause 3 of the ordering StatifierBlocks.Editor.Field documents
statifier_ui resolves, hooks not registeredstatifier-ui's own component, since this package passes it no hook assign and its attributes take their shipped defaults - but the JavaScript those phx-hook names refer to is not in your bundle, so nothing upgrades the field: no caret-aware completion list, and the picklist controls have nothing to write the expression source with
statifier_ui resolves and its hooks are registeredpicklists of field, operator and value while the source is inside the subset predicator can round-trip, a text input over everything else, and caret-aware completion over the declared paths

Nothing else in the editor changes across those three rows. The canvas, the palette, the drawer and its tabs, findings, the undo history, the compiler and this package's own two hooks call no function from statifier-ui: across lib/ a StatifierUI module is reached in exactly one place, as the default value of the config key above. Without phoenix_live_view the question does not arise at all, because no editor module compiles.

An expression_component you pass still wins over every row: the host's own control is clause 1, and it is the answer whether or not statifier_ui is present.

Replacing a composite with its steps

A composite block's card carries one control of its own, reading "Replace with its steps": it takes the composite out and puts the blocks it stands for in where it stood, as a single compound edit, so one undo puts the composite back whole. The document it writes is the one StatifierBlocks.Composite.expand/2 answers, which is why the chart compiles to the same bytes on both sides of the gesture. It is refused, and nothing is written, when the slot the composite sits in will not admit the blocks that come out of it.

Saving an arrangement as a step

The inverse gesture reads a selected arrangement back as the composite that stands for it. The selected card carries a "Save as a step" control with a tray for marking which of the arrangement's values the saved step should ask for, and what it hands out is the Composite.Data declaration StatifierBlocks.Composite.Collapse.propose/3 answers - the params, the subtree template, and a pass-through slot for an unfilled slot inside the selection. It does not name the type: naming it is the host's act, and so is storing it.

The gesture edits no document and this package persists nothing. What arrives on the on_collapse callback - on_select's shape, a one-argument function - is a map, and which table it goes in, which tenant owns it, and whether it is saved at all are all the host's. A host that passes no on_collapse sees no control.

Swapping the arrangement for the new type is a second, separate act, after the host has registered what it saved: Collapse.replacement/4 answers the compound edit that performs it, and the host commits that compound itself through StatifierBlocks.Edit. Nothing in the package commits it, which is what keeps a type a host has not actually registered from reaching a document.

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.

The selection is the editor's, but a host with a selection surface of its own can move it: the selected_id assign is honoured on any update that carries it, and the result comes back on on_select like any other selection. An update that does not carry it leaves the author's selection alone, and an id the open document does not hold clears the selection rather than naming a block that is not there. It is not a document edit: no command, nothing serialized, nothing on the undo stack.

There is a datamodel assign, and it carries paths rather than logic. A host hands in the datamodel paths it declares, and the one thing that buys is the undeclared-path advisory ADR-0005 amendments 11e-11g specify: a config field a block type annotated datamodel_path?: true whose value is not in that set gets an :info finding anchored on the field. nil is the default, and per 11f it produces nothing anywhere - the check does not run at all - which is not the same as an empty set, a host declaring that its documents address nothing. Beyond that set the package still checks shape and nothing more, because it does not own the datamodel path grammar: a host that wants more than the advisory 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:

SeamDeclared onWhat it does
:assignabilityPalette.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. It runs last, after the datamodel document's own read check (ADR-0003 decision 6 as ADR-0011 decision 3 narrows it)
:validatorsPalette.new/2the host's own whole-document rules: a list of StatifierBlocks.DocumentValidator modules, each handed the document as authored and answering {anchor, message} (optionally with severity:) for anything it objects to. Every module in the list runs, in list order; the package stamps the source :lint and defaults the severity to :warning, because a host's rule cannot make a document not compile. It is for the rules that are about the document rather than about one block - "a decision step must precede an act step" - which validate_config/1 has no way to see (ADR-0005 11p-11t) - docs/host-validators.md is the how-to
accent_tokenpalette entrythe NAME of a --sb-* property, stamped on that type's cards and palette rows
badgepalette entrya short chip for the card header
join_labelpalette entrya one-argument function of config, phrasing the join marker under a side-by-side arrangement
subjectpalette entrya datamodel path. Read from the document's entry block - the first block of the root's body slot - it names where the document's subject lives, and io/1's consumes and produces are a read and a write there. Absent means the document has no subject and those two keys declare nothing (ADR-0011 decision 6)
singletonpalette entry:anywhere or :head - how many blocks of this type the document may hold, and (for :head) that the one it holds is first in the root's first declared slot. A document that does not comply draws one :config finding on the root, one per violating type; nothing is ever inserted, removed or moved for you (ADR-0005 10z/11o)
slot_outcome_keypalette entrynames 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-* tokensthe theme assign, or your own CSSevery colour, space, radius and drag treatment - see docs/theming.md
compile findingsfindings assignStatifierBlocks.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
datamodelthe datamodel assignthe datamodel paths the host declares; drives the undeclared-path advisory of ADR-0005 11e-11g, and nil (the default) turns it off entirely
{:path, opts} field typeconfig_schema/1 on your own block typesays a config field holds a datamodel path - the eighth member of ADR-0002 decision 7's closed field-type set, and the spelling datamodel_path?: true already had. StatifierBlocks.BlockType.datamodel_path?/1 answers true for it by construction, so the field carries the same undeclared-path advisory, and the editor renders it as a text input backed by a <datalist> of the paths StatifierBlocks.Datamodel.candidates/3 derives from what the host declared. It suggests and never constrains: free text stays valid, validate_config/1 stays the only authority, and with no datamodel handed in the list is empty and the plain input renders
compile_optionsthe compile_options assignthe rest of the option list the host compiles this document with - terminate:, known_invoke_types:, datamodel:. The editor recompiles the open document to resolve a run's state ids back to blocks, and those options change the emission: terminate: true alone adds a top-level <final> per root-block outcome, so a finished run sits on a state a recompile without the option has never heard of and the Run pane marks nothing. Hand over what you compiled with and the two agree. :declare is taken from the declare assign whatever this list says, and [] (the default) recompiles exactly as it did before
expression_componentthe expression_component assignthe control an :expression field renders. Unset, it resolves to statifier-ui's expression editor when statifier_ui is present and to the package's plain source input when it is not; set, the host's own function component wins over both
value_candidatesthe value_candidates assignthe values the host offers per datamodel path, %{path => [%{label:, value:} | binary]}. Read by whatever fills the expression_component seam - a path with no entry gets a free-text value control, because only a host knows which of its own paths have a value set
chart_outcomesthe chart_outcomes assignwhat the host says each of its stored documents finishes with, %{document id => [outcome]}. A core.subchart names a chart by document id and declares its outcomes by hand, because a block type cannot read the document it references; only the host knows which of its documents it compiles with :child_use - those are the ones a subchart may name - and what finals each of those emits. The editor offers that list on the outcomes field and reports a disagreement between it and what the block declares. A document the map does not name is unknown, which is not disagreement: %{}, the default, reports nothing about anything
selected_idthe selected_id assignthe block the editor is about, written by a host that draws its own selection surface. Honoured only on an update that carries it; an id the open document does not hold clears the selection. It moves the selection, it does not edit the document - nothing is serialized and nothing goes on the undo stack
profilethe profile assignwhich of the editor's surfaces this mount draws, and whether it edits at all: %{drawer_tabs:, inspector_tabs:, palette_groups:, toolbar:, read_only?:}, every key optional and every list :all by default. No arrangement of the map, %{} included, removes a surface a host did not name, and an id a list names that the package cannot resolve is dropped rather than raised. read_only?: true renders the document offering no way to change it - docs/profiles.md is the page that works it through
field_candidatesthe field_candidates assignthe values a host offers for one field, keyed {type_name, field_key}: [{value, label}] for a closed list, which a :string field draws as a <select>, or {:open, [{value, label}]} for an open one, drawn as a <datalist>. A {:path, opts} and an :expression field read it too and draw either spelling as a <datalist>, ahead of the declared datamodel paths, because the value stays typed by the control. %{} (the default) offers none. It draws a control and decides nothing: validate_config/1 is still the only authority on a value
on_collapsethe on_collapse assigna one-argument function called with each declaration the "Save as a step" gesture proposes, in on_select's shape. The gesture edits no document and this package persists nothing: which table the saved step lives in, which tenant owns it, and whether it is saved at all are the host's. Swapping the arrangement for the registered type is the host's second act, through the compound StatifierBlocks.Composite.Collapse.replacement/4 answers. Passing nothing draws no control
debouncethe debounce assign, and StatifierBlocks.Editor.ConfigForm.config_form/1 / StatifierBlocks.Editor.Field.field/1 directlywhat phx-debounce the inspector's config controls carry, for a host persisting what the form posts on its own on_change. It takes what LiveView takes - milliseconds, or :blur - and defaults to no attribute, which is what every existing caller already renders. It is the same attr from the mount as from the two components, so a host composing the form itself and a host mounting the editor say it the same way. Controls drawn by an expression_component override are that component's own and are not covered
capturecore.on_event config, in the documenta map that writes values out of the firing event's payload into the datamodel: the key is the destination (a datamodel path) and the value is the source (a path inside _event.data). One <assign> per pair is emitted on the handler's own transition, in the destinations' sorted order, before the <raise> that carries the outcome. config_schema/1 declares no field for it - the field-type set has no member that describes a map - so it is authored through the document rather than through the editor today. At run time a source whose root is bound but whose named member is missing writes the interpreter's explicit unbound marker and raises nothing (ADR-0002's capture Note, as corrected 2026-09-05), so a reader of a captured path tests for that marker rather than assuming an authored absence - unless the handler declares a payload, in which case the read is checked at compile and the marker write never happens
payloadcore.on_event config, an optional :string fieldthe name of a type the datamodel document declares (StatifierDatamodel.Declarations), saying what _event.data carries for the event this handler names. Two handlers for one event may declare different payloads; each governs its own capture. With one declared, a capture pair whose source path reads a member the payload does not carry is a :config refusal on the capture key - the first segment against the payload's fields, deeper segments only where the field's own type resolves to another declaration, and a scalar, list or opaque field stops the walk. Absent, blank, naming a type the document does not declare, or compiled with no :datamodel: nothing is refused and nothing changes. payload emits no SCXML of its own (ADR-0002's amendment of 2026-09-06)

The metadata readers are total and refuse rather than repair: a badge that is blank, carries a newline, or runs past 32 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 drawer's Findings tab is two calls:

{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 datamodel path grammar. The undeclared-path advisory 11e-11g settled is shipped, but only against the set of paths a host declares. Nothing here parses or validates a path beyond its shape, and a host that supplies no datamodel gets no advisory at all.

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:

<.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.

One of them is worth knowing before you embed rather than after. By default the editor is as tall as the document in it and your page scrolls, which puts the drawer below the fold on a long document. Set --sb-editor-height to a length - calc(100vh - 4rem), or 100% inside a box your own layout has already sized - to bound the editor instead: the panes scroll in their own boxes and the drawer stays pinned at the bottom of it. The default is auto, so a host that does not set it is unchanged.

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/: 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), and the typed environment a pre-order walk carries from datamodel path to type (0011). 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.