The declarations panel's arithmetic: every author gesture on the
document's datamodel key, as a pure list-to-list function
(ADR-0005's 2026-09-01 amendment, clauses 2g-2m).
Deliberately outside StatifierBlocks.Editor.*, and for decision 1's
reason: the editor's job is translation, so which entry moved and what
the new list is are decided here and asserted with phoenix_live_view
absent from the dependency tree. Nothing in this module renders, and
nothing in it writes a document - every function returns a candidate
list, which the caller hands to {:set_datamodel, entries} and the
command either accepts or refuses (2h).
Every function is total, and out of range is a no-op
An index arrives from a phx-value-index attribute, so a stale one is
an ordinary race - the author pressed a row's remove button on markup
that a concurrent change had already shortened - and a crafted one is a
payload, not a bug. Both get the same answer: the list unchanged. That
is the same stance StatifierBlocks.Shell.drawer_tab/2 takes for a tab
name off the wire, and it is what keeps the panel free of an error state
nobody can act on.
Why add/1 mints a name instead of appending a blank row
ADR-0001 11b gives id no empty spelling: it is
~r/\A[a-z][a-z0-9_]*\z/ or it is refused. A blank row would therefore
be a list {:set_datamodel, entries} refuses, so pressing Add would
produce a refusal rather than a row - the author would have to type a
legal name before the gesture they already made took effect.
add/1 mints root_1, root_2, ... - the first that no entry already
holds - so the list a press produces is always one the command accepts.
This is decision 2's minting discipline in a second place and for the
same reason it gives there: the value is completed at gesture time and
the recorded command carries a finished list, so replaying a command log
yields the same document every time.
Summary
Functions
Appends a freshly named entry: root_1, or the first root_N no entry
already holds.
The entry list a phx-change payload asks for: every field the form
carries, written onto the entry at index.
How many declarations the document carries - the drawer strip's count for the Declarations tab.
Swaps the entry at index with its neighbour in direction.
Writes one field of the entry at index.
A refusal from {:set_datamodel, entries}, as one sentence for the
panel.
Drops the entry at index, or returns entries unchanged when no entry
sits there.
Types
Functions
@spec add([StatifierBlocks.Document.DatamodelEntry.t()]) :: [ StatifierBlocks.Document.DatamodelEntry.t() ]
Appends a freshly named entry: root_1, or the first root_N no entry
already holds.
expr and description are nil, which is the absent spelling ADR-0001
11b gives both and the one 11d omits from the canonical bytes. The new
root therefore reads as undefined until something assigns it, which is
what an author who has just named a root has said about it.
iex> StatifierBlocks.Declarations.add([])
[%StatifierBlocks.Document.DatamodelEntry{id: "root_1", expr: nil, description: nil}]
iex> entry = %StatifierBlocks.Document.DatamodelEntry{id: "root_1"}
iex> [_kept, minted] = StatifierBlocks.Declarations.add([entry])
iex> minted.id
"root_2"
@spec change([StatifierBlocks.Document.DatamodelEntry.t()], term(), map()) :: [ StatifierBlocks.Document.DatamodelEntry.t() ]
The entry list a phx-change payload asks for: every field the form
carries, written onto the entry at index.
Keys the form did not send are left alone, so a panel that grows a field later does not silently blank the ones beside it, and a key outside 11b's three is ignored rather than written - the payload is untrusted input and this is the only place it becomes an entry.
The id field arrives under the name "name". That is not a rename of
11b's field, which is still id on the struct and in the bytes; it is
the one spelling LiveView leaves alone. An <input name="id"> inside a
form overrides the form element's own DOM id, which LiveView refuses at
compile time, so the wire name and the struct key differ here and this
table is where they are reconciled.
iex> entries = [%StatifierBlocks.Document.DatamodelEntry{id: "a"}]
iex> params = %{"name" => "signup", "description" => "the wizard", "nope" => "x"}
iex> [entry] = StatifierBlocks.Declarations.change(entries, 0, params)
iex> {entry.id, entry.description}
{"signup", "the wizard"}
@spec count(term()) :: non_neg_integer()
How many declarations the document carries - the drawer strip's count for the Declarations tab.
iex> StatifierBlocks.Declarations.count([%StatifierBlocks.Document.DatamodelEntry{id: "a"}])
1
iex> StatifierBlocks.Declarations.count(nil)
0
@spec move([StatifierBlocks.Document.DatamodelEntry.t()], term(), term()) :: [ StatifierBlocks.Document.DatamodelEntry.t() ]
Swaps the entry at index with its neighbour in direction.
Order is the emission order of the document's <data> elements
(ADR-0001 11a), so this is an authoring act with a visible meaning in the
compiled chart rather than a display preference - which is why it is a
document edit and not editor state.
A move off either end is a no-op, so the first row's "up" and the last row's "down" are inert rather than wrapping. Wrapping would make one press of a repeated gesture do the opposite of the press before it.
iex> a = %StatifierBlocks.Document.DatamodelEntry{id: "a"}
iex> b = %StatifierBlocks.Document.DatamodelEntry{id: "b"}
iex> StatifierBlocks.Declarations.move([a, b], 1, :up) |> Enum.map(& &1.id)
["b", "a"]
iex> StatifierBlocks.Declarations.move([a, b], 0, :up) |> Enum.map(& &1.id)
["a", "b"]
@spec put([StatifierBlocks.Document.DatamodelEntry.t()], term(), field(), term()) :: [ StatifierBlocks.Document.DatamodelEntry.t() ]
Writes one field of the entry at index.
expr and description are optional in ADR-0001 11b and absent is
spelled nil on the struct, so a cleared text input - the empty string a
browser sends for a field the author emptied - becomes nil here rather
than "". The alternative is a value 11b refuses and 11d would never
encode, reached by the ordinary act of clearing a box.
id is written through verbatim, blank included. It is required, so
there is nothing for a blank to mean, and the refusal the command answers
with is the panel's message to the author (2l) rather than something to
paper over here.
iex> entries = [%StatifierBlocks.Document.DatamodelEntry{id: "a", expr: "1"}]
iex> StatifierBlocks.Declarations.put(entries, 0, :expr, "") |> hd() |> Map.get(:expr)
nil
iex> StatifierBlocks.Declarations.put(entries, 0, :id, "signup") |> hd() |> Map.get(:id)
"signup"
A refusal from {:set_datamodel, entries}, as one sentence for the
panel.
ADR-0005 decision 11's anchors name a block, a slot or a config key, and
none of them can name a declaration entry, so a refusal here is not a
%StatifierBlocks.Finding{} and does not enter the findings pipeline
(2l). It is rendered in the panel that produced it, which is the only
place it is about.
Anything this function does not recognize gets the generic sentence rather than an inspected tuple: the panel is read by an author, and a term it cannot phrase is a term the author cannot act on either.
iex> StatifierBlocks.Declarations.refusal(
...> {:malformed_envelope, {:datamodel, {:duplicate_id, "signup"}}}
...> )
~s(Two declarations are named "signup". Every id must be unique.)
iex> StatifierBlocks.Declarations.refusal(:something_else)
"That change was refused."
@spec remove([StatifierBlocks.Document.DatamodelEntry.t()], term()) :: [ StatifierBlocks.Document.DatamodelEntry.t() ]
Drops the entry at index, or returns entries unchanged when no entry
sits there.
iex> entries = [%StatifierBlocks.Document.DatamodelEntry{id: "signup"}]
iex> StatifierBlocks.Declarations.remove(entries, 0)
[]
iex> StatifierBlocks.Declarations.remove(entries, 4) == entries
true