StatifierBlocks.Composite.Collapse (StatifierBlocks v0.29.0)

Copy Markdown View Source

The proposer half of Collapse: an arrangement an author built by hand, read back as the StatifierBlocks.Composite.Data declaration that stands for it (ADR-0005 part (iii), amended 2026-09-07, clauses 15E to 20E).

propose/3 reads; it writes nothing. It takes no socket, no assigns and no StatifierBlocks.Edit.Session, it is callable from a test, a script or a host's own code with no LiveView in the picture, and it is the only entry point this package offers to Collapse's first half.

{:ok, declaration} = Collapse.propose(document, palette, ["blk_7", "blk_9"])

What comes back is the storable row, minus its name

The {:ok, ...} value is JSON-shaped, in the shape StatifierBlocks.Composite.Data.declaration/1 accepts, with "version", "params", "subtree" and - where the selection holds an unfilled slot - "slots". It carries no "type_name" (15E): a type name is a key in the host's palette namespace, the host is the only party that knows what is registered there, and a package that minted one would be minting a collision it cannot see. So declaration/1 refuses the row until the host names it, and that refusal is the seam working rather than a gap in it.

"sentence" and "palette_entry" are omitted for the same reason at one remove: both are prose or presentation the author never typed in this gesture, and a proposer that invented an English sentence or an icon would be inventing content and calling it a proposal.

Which values become params

18E. The proposed params are the config values the author marks in the gesture; where the author marks none, every value that differs from its field's declared default. A value left at its default is a value the author never chose, and a param whose default is the field's default parameterises nothing.

The marks are the gesture's, so they arrive beside the three arguments 15E fixes rather than inside them: propose/4's option list carries marks: %{block_id => [field key]}, and propose/3 - the record's entry point, and the whole of what a caller with no gesture behind it needs - is the unmarked reading. Neither of the three arguments is a name, which is the property RQ-SF038-1 fixes the arity for.

Each param's field declaration is the source field's: its "type", "label" and whichever of "required?", "value_path", "datamodel_path?", "hidden?" and "readonly?" the source field declares, carried across unchanged, with "default" set to the value the block's config held. Nothing is re-derived, so the control the author sees on the composite's form is the control they were looking at on the block.

A param's key is the source field's key. Where two blocks in one arrangement declare the same field key - two core.assigns both declare path - every colliding param takes <id_suffix>_<field key> instead and the un-colliding ones keep their bare keys.

The template, and the ids it mints

The template is the subtree with each proposed value replaced by %{"$param" => key} and every other config value carried across as the literal it is. A value that genuinely is a one-key "$param" (or "$literal") map is carried as %{"$literal" => ...}, which is what that escape exists for.

"id_suffix" is minted from the source block's type, not from its id: the type name's last dot-separated segment - core.invoke gives invoke, core.assign gives assign - with a positional discriminator appended where a type repeats, in document order: assign, assign_2, assign_3. A document id is arbitrary (blk_7), carries no meaning to a later reader, and need not match the "id_suffix" pattern, while a type segment does.

One consequence, stated because it is sharp: a use-composite twin of a collapsed declaration expands byte-identically only if its authored id_suffixes are the ones this rule mints. ADR-0002's "Guarded step" is authored with call and guard, so its expansion's ids are blk_GS_call and blk_GS_guard where a collapse of the same arrangement mints blk_GS_invoke and blk_GS_assign. The configs, the types and the tree shape are identical; the ids are not, and the rule cannot ask an author who is not there.

Slots

20E. Exactly one subtree under one parent (12E, unamended): two siblings, a block and a cousin, or a partial subtree with a child left outside are refused. A selection whose subtree holds an unfilled slot is admitted, and that slot is proposed as a pass-through slot - "slots" => %{name => [local_id, inner_slot]} - in the shape ADR-0002's pass-through amendment fixes. A filled slot is not proposed and its children are not lifted: they are part of what the author selected, and a Collapse that silently turned them into an opening would be deciding for the author that the blocks they put there were an example rather than the thing.

The replacement is a separate function the host calls, or does not

17E. replacement/4 answers the {:compound, ...} that puts the composite where the arrangement was. Nothing in this package calls it: the gesture does not, and on_collapse does not. A host that saves a declaration and never swaps the arrangement out has done a legitimate thing, and the swap can only happen after the host has stored the declaration, named it and rebuilt its palette with it - until then the :insert names a type the document cannot resolve.

The composite it inserts takes the collapsed root's own id. An expansion mints member ids as composite_id <> "_" <> local_id, so the id given here is the prefix of every state id the chart grows where the arrangement was, and a minted one would put a fresh UXID there on every commit.

Summary

Types

The proposed row: JSON-shaped, in Composite.Data's declaration shape and without its "type_name".

Which config values the gesture marked, per block.

Functions

Reads a selection back as the declaration that stands for it.

The compound that puts a composite of type_name where the arrangement was: the exact inverse of Expand.

Types

declaration()

@type declaration() :: %{optional(String.t()) => term()}

The proposed row: JSON-shaped, in Composite.Data's declaration shape and without its "type_name".

marks()

@type marks() :: %{optional(StatifierBlocks.Block.id()) => [String.t()]}

Which config values the gesture marked, per block.

Functions

propose(document, palette, ids, opts \\ [])

Reads a selection back as the declaration that stands for it.

Answers {:ok, declaration} - the storable row without its "type_name" - or {:error, reason}. Nothing is written in either case and the document is not touched.

The refusals, each a refused gesture and none of them a finding:

  • {:error, {:not_one_subtree, ids}} - the selection is not exactly one subtree under one parent (12E);
  • {:error, {:no_such_block, id}} - an id the document does not hold;
  • {:error, {:cannot_collapse_root, id}} - the document root, which has no target a composite could take;
  • {:error, {:unspellable_field, block_id, field_key}} - a value no Composite.Data spelling carries (19E). Two ids, because "this arrangement cannot be saved" is not actionable and "the payload field on blk_13 cannot be saved" is;
  • whatever StatifierBlocks.Palette.resolve/2 refuses a member with.

Options:

  • :marks - %{block_id => [field key]}, the config values the gesture marked. Absent, or an empty map - which is what a tray with nothing ticked hands over - is 18E's unmarked reading: every value that differs from its field's declared default.

    iex> alias StatifierBlocks.{Block, Document, Palette} iex> alias StatifierBlocks.Composite.Collapse iex> document = ...> Document.new( ...> Block.new("core.sequence", ...> id: "blk_ROOT", ...> slots: %{ ...> "body" => [ ...> Block.new("core.assign", ...> id: "blk_9", ...> config: %{"path" => "signup.state", "value" => "done"} ...> ) ...> ] ...> } ...> ) ...> ) iex> {:ok, declaration} = Collapse.propose(document, Palette.core(), ["blk_9"]) iex> Enum.map(declaration["params"], & &1["key"]) ["path", "value"] iex> Map.has_key?(declaration, "type_name") false

replacement(document, root_id, type_name, declaration)

The compound that puts a composite of type_name where the arrangement was: the exact inverse of Expand.

{:compound, [{:remove, root_id}, {:insert, target, block}]}

target is the arrangement's own - the same parent, the same slot, the same index the selection's root held - and the inserted block's config is each param's declared "default", which by 18E is the value the author had selected, so the composite expands to the arrangement they started with. Removing first and inserting at the freed position is Expand's ordering read backwards, and one remove with one insert is one undo entry (2n, 3E), so the author sees one gesture and no intermediate document in which the arrangement is gone and the composite is not yet there.

The inserted block carries the arrangement's own id, not a minted one. A composite's expansion mints its members' ids as composite_id <> "_" <> local_id, so whatever id this block is given is the prefix of every state id the compiled chart grows where the arrangement stood. A minted id would make those ids a function of the millisecond the host committed rather than of the document, and two hosts committing the same replacement on the same document would get two different charts. Taking root_id keeps the swap deterministic and the ids anchored where the arrangement was; the {:remove, root_id} ahead of it in the compound has already freed the id, so the insert is not a duplicate.

The host commits it through StatifierBlocks.Edit.Session.commit/2. It is answered rather than committed here, and refused rather than raised where the document cannot carry it: {:error, {:no_such_block, id}} for an id the document does not hold, {:error, {:cannot_collapse_root, id}} for the root.