One config field, dispatching on the closed field-type set (ADR-0005 decision 9, ADR-0002 decision 7).
The set is closed precisely so this renderer can be total, and the mapping is the record's, unchanged:
| Field type | Rendering |
|---|---|
:string | single-line text input |
:integer | number input, step 1 |
:boolean | checkbox |
{:select, choices} | select, choices in declared order |
:expression | statifier-ui's expression editor when that package is present, else a single-line source input |
:duration | one text control; predicator duration strings primary, with on-screen examples |
{:list, t} | repeatable rows of t's renderer, with add and remove |
:duration's row is decision 9 as amended 2026-08-29. One text control,
not a value/unit pair and not a pair with an escape hatch beside it: the
compound control could not spell PT1H30M at all, and a control plus an
escape hatch is two ways to say one thing with a rule about which wins.
Predicator duration strings are primary with the examples on screen,
ISO-8601 stays accepted, and an empty field omits the key.
What the typed text means is StatifierBlocks.DurationInput's, not this
module's - it is a function of the text alone, so it is asserted with
LiveView absent. Where an omitted key is omitted is
StatifierBlocks.Editor.ConfigForm's, which owns where a decoded value
is written. This module renders the control and shows the refusal.
The refusal shown beneath a :duration is the inline check, and it
is earlier than decision 9's gate rather than a second one: the gate
still decides what reaches the document, and the inline sentence tells
the author which spelling they are failing while they are still typing
it. Nothing here is stored - the stored form is the author's string
verbatim, byte for byte.
A required field is marked with the word, not with an asterisk
(parity item 1.9). An asterisk is a convention that has to be learned
from a legend the editor does not have, it is read aloud as "star", and
it is one character wide next to a label it is easy to miss. Required
says the same thing to a reader and to a screen reader, and it is the
field record's own required? that decides - never a key name and never
a type.
Two control types carry a placeholder, and neither is chosen by key
or by type name: an :expression says what kind of thing belongs in it,
and a :duration shows the spelling it stores. A bare :string says
nothing, because there is nothing a type as wide as "string" can suggest -
which is the rule, and it is what makes the two that do say something the
editor's own presentation rather than a special case.
Whether a block type may declare a placeholder on its own fields is a
question this module deliberately does not answer: ADR-0002 decision 7
closes the field TYPE set, not the keys of a field record, so admitting
one is a widening of that record and belongs to whoever amends it.
:expression renders through the expression_component seam. Predicator
source is statifier-ui's subject (sui-bob, sui-ADR-0006), and decision 9
records a richer affordance as a deferral, so this component accepts an
override for exactly that seam - and, since sb-m6e0, fills the seam
itself when the package the deferral names is on the load path.
Which control an :expression gets
Three answers, in this order, and the order is the whole rule:
- an
expression_componentthe host passed - the host asked for its own control and gets it, whatever else is available; StatifierUI.Live.ExpressionInput, whenstatifier_uiresolves - value picklists over the subset predicator can round-trip, a text input over everything else, and the author's own source string either way;- the plain source input this package has always rendered, with the
<datalist>of declared paths described below.
statifier_ui is an optional dependency, resolved the way
phoenix_live_view is: absent, clause 3 is what an :expression
renders, nothing raises, and nothing warns at compile time. The
resolution is a runtime Code.ensure_loaded?/1 against a module read
from :statifier_blocks, :expression_component_module, which is the
same indirection statifier-ui itself uses for Predicator.Simple - it
is what makes clause 3 assertable on a machine where clause 2 resolves.
Two properties of clause 2 are load-bearing and neither is this module's to weaken. The component never refuses a source string and never rewrites one: source it cannot draw as rows is drawn as text. And every control it draws writes a complete expression source string into the same named input the text mode edits, so the document still stores the author's text and this package still holds no structured expression model of its own.
value_candidates
The values a host offers per datamodel path, %{path => [candidate]},
where a candidate is %{label: , value: } or a bare string. It reaches
the expression_component beside candidates and is read by whatever
is behind the seam; nothing in this package interprets it, because only
a host knows which of its own paths have a value set at all. A path with
no entry gets a free-text value control, which is the same "suggests,
never constrains" posture the path <datalist> takes.
The :expression path suggestions (sb-0vt)
That plain input gains a <datalist> of the declared datamodel paths
when path_candidates is non-empty, on exactly the invoke_type terms
below: it suggests and does not constrain, free text stays valid, an
undeclared path stays the :info advisory StatifierBlocks.Datamodel
already produced rather than becoming a refusal, and an empty list
renders the input the package has always rendered. The same list reaches
the expression_component override as :candidates, so a host that
fills the seam is handed the paths rather than re-deriving them.
This is the data, not the feature. Decision 9's deferral of rich
expression editing to statifier-ui is untouched, and one property of a
<datalist> is why that matters rather than being a formality: the
browser matches options against the input's whole value, so the list
is live while the author is typing the leading path and goes quiet the
moment the expression grows an operator. That is genuinely useful for
the bare-path condition and for the first token of any other, and it is
not completion. Mid-expression completion needs to know where the caret
is inside the source, which needs either a hook this package may not add
(decision 7's two-hook limit) or the richer component decision 9 defers -
and it needs predicator's operator vocabulary, which px-15q tracks.
Ordering follows the same reasoning as the clause order below: the override wins over the datalist, because a host that supplied a component asked for its own control and getting the package's suggestion markup stapled beside it would be the package overriding the override.
This module is a renderer, not a gate. Nothing here decides whether a
value is acceptable: validate_config/1 does, through
StatifierBlocks.Edit.check_config/3, which is why an unparseable
integer reaches the draft config as the string the author typed rather
than being silently coerced or dropped.
The invoke_type suggestion list
invoke_types is the one control this module chooses by key, and
the exception is deliberate rather than an oversight of the rule above.
A host that knows which invoke types it has registered can pass them as
an editor assign, and an invoke_type field then renders as a text
input bound to a <datalist> of those strings. With the assign absent
or empty the same field renders as the plain text input it has always
been, so the suggestion list is additive and a host that supplies
nothing loses nothing.
Three properties make this a suggestion rather than a vocabulary, and each of them is ADR-0004 decision 8 rather than a choice made here:
- Free text stays valid. A
<datalist>suggests; it does not constrain, which is exactly why it is the control used and a{:select, choices}is not. An author can type a type that is not on the list and the editor stores it verbatim. - An unknown type stays a lint. The two-registry check is the
compiler's opt-in
:known_invoke_typeslint, and it reports; it never refuses. Nothing here changes what compiles. - The handler set is deployment state, not authoring state. Which types a host can actually run is a property of the deployment the document is run in, not of the document, so it arrives as an assign the host fills in and never as anything stored in the block.
The assign shares its name with the compiler's invoke_types surface -
StatifierBlocks.Compiled's field and the compiler's
:known_invoke_types option - and the two are separate surfaces that
happen to describe the same vocabulary from opposite ends. The
compiler's is derived from a document: the sorted set of types that
document actually emits. This one is supplied by a host: the types it
is prepared to answer. Neither reads the other.
Keying a control on a field's key is a narrower thing than the
placeholder question above, which is why it does not reopen it: a
block type declaring an invoke_type field of some other type keeps
that type's control, because this clause is reached only after every
typed clause has had its turn.
Summary
Functions
Decodes one field's slice of a form's params back into a config value, dispatching on the same closed type set the renderer does.
One field: its label, its control, and its own findings (decision 11).
The DOM id for a field's control. Part of decision 7's DOM contract.
The form param name a field's control posts under.
Functions
@spec decode(StatifierBlocks.BlockType.field_type(), term()) :: StatifierBlocks.Block.json()
Decodes one field's slice of a form's params back into a config value, dispatching on the same closed type set the renderer does.
Total, and deliberately non-coercing at the edges: an integer field
whose input does not parse yields the string the author typed, so
validate_config/1 reports it and decision 9's gate keeps it out of
the document. Coercing to zero here would silently discard the author's
intent and commit a value they never asked for.
One field: its label, its control, and its own findings (decision 11).
Attributes
field(StatifierBlocks.ViewModel.Field) (required)target(:any) (required)class(:string) - Defaults tonil.expression_component(:any) - Override for:expression, per ADR-0005 decision 9's seam. Receives the same assigns. Defaults tonil.invoke_types(:list) - The invoke types the host is prepared to answer. Suggestions for aninvoke_typefield, never a constraint on it; empty is no list supplied and renders the plain input.Defaults to
[].path_candidates(:list) - The declared datamodel paths, fromStatifierBlocks.Datamodel.candidates/3. Suggestions on an:expressionfield and passed toexpression_componentas:candidates; empty renders the plain input.Defaults to
[].value_candidates(:map) - The values a host offers per datamodel path,%{path => [candidate]}. Passed toexpression_componentas:value_candidatesand read only there;%{}offers none and a path with no entry gets free text.Defaults to
%{}.
@spec input_id(StatifierBlocks.ViewModel.Field.t()) :: String.t()
The DOM id for a field's control. Part of decision 7's DOM contract.
@spec input_name(StatifierBlocks.ViewModel.Field.t()) :: String.t()
The form param name a field's control posts under.