# `MishkaCmsCore.Runtime.PageBuilder.Inspector`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/page_builder/inspector.ex#L5)

Decides which control a component attribute gets in the property panel, and groups the attributes
into sections — all as pure functions, so the no-code surface is testable without a browser.

## Where the option lists come from

A Chelekom attribute almost never declares its own `opts.values`: it is `"string"` with a doc
string, and the real list of colours, sizes and variants lives in the component's
`helpers[].discriminators` — the axis clauses extracted from the kit's `.eex` source. That is why
the React editor could never have shown a variant dropdown: the data it needed was in a field the
editor was never sent.

`features/1` folds both sources the way `MishkaCmsCore.Runtime.Handlers.UiKitHandler` does
(helper-derived values win over attr-declared ones) so `widget/2` can offer a real select for the
~130 of 136 components that carry discriminators.

## Widget resolution

    boolean                                   → :toggle
    a known value list, ≤ 5 short entries     → {:segmented, values}
    a known value list                        → {:select, values}
    integer / float                           → {:number, min, max, step}
    string named like a colour, or editor hint → :color
    a long doc, or editor hint                → :textarea
    type "global" (the `@rest` catch-all)      → :global
    type "struct"                              → {:struct, name}
    anything else                              → :text

## The contract a kit can write instead — `opts.editor`

Everything above is a GUESS, and a guess reads one thing: how this kit happens to spell its
attributes. `icon` gets the picker because the word is on a list. A kit that calls the same thing
`glyph` gets a text box, and no amount of adding names fixes that — it moves the failure to the
next kit.

So a kit can say it outright. `opts["editor"]` on an attribute is read FIRST and is authoritative:

| `opts.editor` | control | for |
|---|---|---|
| `"color"` | a swatch | an actual colour value — a hex, a CSS colour |
| `"icon"` | the searchable icon picker | an icon class name |
| `"media"` | the media library | a picture the author picks |
| `"url"` | an address field | a link the author types |
| `"number"` | a stepper, honouring `min`/`max`/`step` | a count or a duration, EVEN IF its type is a string |
| `"toggle"` | a switch | a flag, even if its type is a string |
| `"textarea"` | a box with room | prose |
| `"select"` | a dropdown, or pills when the list is short | drawn from the attribute's own `values` |
| `"columns"` | a row of shapes | a layout count, drawn from `values` |
| `"text"` | a plain text box | the kit OVERRULING a guess — an attribute called `icon` that holds something else |

Three properties make this safe for a kit nobody here has seen:

  * **A declaration always wins.** It is read before every heuristic and before the value list, so a
    kit is never argued with about its own attributes.
  * **An unknown value degrades, it does not break.** A bundle written against a later version of
    this editor — `"editor" => "colour-wheel"` — loses the refinement and falls back to inference.
    It does not raise, and it does not collapse to a text box.
  * **Declaring nothing is still supported.** The heuristics below are the fallback, not the plan,
    and they stay for exactly that reason: a kit that says nothing must still get sensible controls.

`editors/0` is the accepted list, so a kit author can read it rather than guess at it.

## The two sections a form adds

Everything above answers "what control does this attribute get". Two sections answer a different
question — "what does this node need in order to be a working form" — and neither is derived from a
kit's attribute list, so both are declared: `form_sections/0` returns them as ordinary
`builder:inspector:sections` descriptors carrying their controls in `config`, seeded into the
registry by `MishkaCmsCoreWeb.Live.Admin.Builder.Panels.core_inspector_sections/0`. An extension
contributing a section of its own writes the same shape.

**Form** shows on a node that declares a page form (`form_node?/1`). Its one real control is the
seed — the single word every other name on the form is derived from — so `rename_ops/3` writes all
five of `MishkaCmsCore.Runtime.PageBuilder.PageForms.Naming.derived/0` as one batch through
`MishkaCmsCore.Runtime.PageBuilder.EditorSession.apply_ops/4`, with the token `rename_token/4`
picks. One history entry, and the five can never disagree because nothing edits one of them alone.
`seed_refusal/3` is what stops a rename landing on a name another form is already using, and
`rename_warning/2` says what stored code a rename would stop.

`data-success` is on the form node, so it is a Form control rather than a Validation one even
though it is about what happens after a submit.

## The rest of the Form panel

`form_state/2` says which of these a node gets. A working form also has Form · Phoenix
(`form_names/1`, `phoenix_form_controls/2`, `address_ops/2`), a field inside one has Field options
(`field_controls/3`) and a button inside one has Send button (`send_controls/4`). Each control is
offered only where the node forwards its attribute (`Fields.takes?/3`), and Props and Advanced
leave `form_panel_attrs/0` to these sections.

**Validation** shows on a bound field inside a form (`validated_node?/2`). Every control writes one
ordinary HTML constraint attribute — `required`, `minlength`, `maxlength`, `min`, `max`, `pattern`,
plus `data-format` and `data-confirms` for the two things HTML has no word for. There is no rule
store: `MishkaCmsCore.Runtime.PageBuilder.PageForms` reads these back off the markup at compile
time and `MishkaCmsCore.Runtime.PageForm.Rules` executes them, so the browser and the server are
reading one declaration and cannot disagree about it.

# `control`

```elixir
@type control() :: %{
  key: String.t(),
  attr: String.t() | nil,
  label: String.t(),
  widget: term(),
  help: String.t()
}
```

# `addable_slots`

```elixir
@spec addable_slots(map()) :: [map()]
```

The slots that can be added to a component as `<:name>` blocks — every declared slot except
`inner_block`, for the reason given in `inner_block?/1`.

# `address_ops`

```elixir
@spec address_ops(
  MishkaCmsCore.Runtime.PageBuilder.Tree.t(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(),
  String.t() | nil,
  [map()]
) :: {:ok, [map()]} | {:error, String.t()}
```

The ops "Send to another address" issues for what was typed: an `https` address writes `action`,
`method="post"`, `csrf_token={false}` and the form's `phx-trigger-action` (`Naming.trigger/1`)
together, and removes a `target`, so the result opens in the tab it was sent from. It also turns
`multipart` on when a file field sits inside the form in `tree` (`FormPlacement.files_ops/3`). An
empty one removes the four. Anything else is refused with a sentence.

# `address_takes?`

```elixir
@spec address_takes?(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), [map()]) ::
  boolean()
```

Whether the form `node` forwards all four attributes "Send to another address" writes.

# `atom?`

```elixir
@spec atom?(map()) :: boolean()
```

Whether the attribute takes an ATOM rather than a string.

It matters at the point of writing: `kind="danger"` and `kind={:danger}` are different values, and
a component whose clauses match `:danger` falls through every one of them when handed the string —
silently, to whatever its catch-all renders. Three attributes in the Chelekom kit are atoms, and
one of them is `chelekom-alert`'s `kind`, which decides the alert's entire colour.

# `attrs`

```elixir
@spec attrs(map()) :: [map()]
```

A component's attribute definitions, however the caller happens to hold the component.

# `bound_handlers`

```elixir
@spec bound_handlers(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), [map()]) :: [
  String.t()
]
```

The Events-tab handlers this form's `phx-change` and `phx-submit` currently point at.

# `bound_key`

```elixir
@spec bound_key(
  MishkaCmsCore.Runtime.PageBuilder.Tree.t(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()
) :: atom() | nil
```

The key this node binds in the form it sits inside, or `nil`.

# `collection?`

```elixir
@spec collection?(map()) :: boolean()
```

Whether an attribute holds a collection rather than one value.

# `default`

```elixir
@spec default(map()) :: term()
```

The value the component falls back to when the attribute is unset.

# `doc`

```elixir
@spec doc(map()) :: String.t() | nil
```

The attribute's documentation string, used as the control's tooltip.

# `editors`

```elixir
@spec editors() :: [String.t()]
```

Every value a kit may put in `opts.editor`, so it can be read rather than guessed at.

Anything outside this list is not an error — it degrades to inference — but it is also not doing
anything, which is worth being able to check for.

# `element_widget`

```elixir
@spec element_widget(String.t() | nil, String.t()) :: :media | :url | :color | :text
```

The control a RAW HTML attribute deserves, decided from the tag it is written on.

`element_widget("img", "src")` is a picture chosen from the library; `element_widget("iframe",
"src")` is an address typed by hand. Anything this has no opinion about falls through to the name
heuristics `widget/3` uses, and then to a text box.

# `features`

```elixir
@spec features(map()) :: %{optional(String.t()) =&gt; [String.t()]}
```

The option values available per attribute name, folded from the component's helper
discriminators and its attributes' own `opts.values`.

Accepts a palette entry (atom keys), a raw component row, or anything shaped like one.

# `field_autofill`

```elixir
@spec field_autofill(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), [map()]) ::
  String.t()
```

The autofill a field has when it names none, from the kind of field it is: `email` for an email
field, `tel`, `url`, `current-password`, and `on` for anything else.

# `field_code_controls`

```elixir
@spec field_code_controls() :: [control()]
```

The field bindings that name an event, offered only to authors who may write its code.

# `field_controls`

```elixir
@spec field_controls(
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil,
  [map()],
  keyword()
) :: [
  control()
]
```

The options of a field inside a form, each where the field forwards its attribute
(`Fields.takes?/3`). Autofill shows its default for the field's kind (`field_autofill/2`), or
`off` when the form it is in (`form:`) turns Autofill off. With `code_allowed?: true` the three
field bindings that name an event come last; nobody else sees them. Every control with no node.

# `form_controls`

```elixir
@spec form_controls() :: [control()]
```

The Form section's controls, in the order the panel draws them.

# `form_controls`

```elixir
@spec form_controls(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), map()) :: [
  control()
]
```

The Form section's controls for the working form `node`: `form_controls/0`, then How it works
where the form can work as they type (`FormPlacement.as_typed_offered?/2`). `session` is the
editing session.

# `form_names`

```elixir
@spec form_names(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()) :: [
  {String.t(), String.t() | nil}
]
```

The names this working form carries that only Form name changes, as `{name, text}`, with `nil`
for one it does not carry.

# `form_node?`

```elixir
@spec form_node?(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()) :: boolean()
```

Whether the Form section applies to this node — it names a page assign to keep its state in.

# `form_panel_attrs`

```elixir
@spec form_panel_attrs() :: [String.t()]
```

Every attribute the Form panel writes on a working form, so Props and Advanced leave them to it:
the five names and `:let`, the Success message, How it works, the Form · Phoenix options, the
four "Send to another address" writes and `multipart`.

# `form_part`

```elixir
@spec form_part(map(), MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil) ::
  :form | :field | :submit | :not_submit | :button | nil
```

The part this node plays in a form, for the Inspector's header: `:form`, `:field`, `:submit` (a
button that sends the form it is in), `:not_submit` (a button in a form that does not send it),
`:button` (a button outside every form, or one whose computed type cannot say) or `nil`.

`editor` is anything carrying the document's `tree` and the palette's `components`.

# `form_sections`

```elixir
@spec form_sections() :: [MishkaCmsCore.Builder.Descriptor.t()]
```

The two form sections, as `builder:inspector:sections` descriptors carrying their controls.

Sorted ahead of the styling groups, so a form's own name is near the top of the panel rather than
under the styling an author reaches for later. The panel draws them above the breakpoint strip:
every control here writes a plain attribute, which has one value for the whole page.

# `form_seed`

```elixir
@spec form_seed(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()) :: String.t() | nil
```

The seed this form is currently using, or `nil` when it names no params.

# `form_state`

```elixir
@spec form_state(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil, [map()]) ::
  :page_form | {:idle, boolean()} | :none
```

What this node is as a form, for the Inspector:

  * `:page_form` - a working form (`PageForms.form?/1`): Phoenix's `<.form>` or a kit wrapper
    wired to the page;
  * `{:idle, hands?}` - a form that does not work yet; `hands?` is whether it can be made one
    where it stands (`Fields.hands_form?/2`), which a raw `<form>` cannot;
  * `:none` - not a form.

# `form_story`

```elixir
@spec form_story(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), map() | nil) ::
  {String.t(), String.t()}
```

What a form does, in two sentences for someone who does not write code: what happens while a
visitor types, and where it goes when they press Send, or as they type when the form has no Send
button (`PageForms.as_typed?/1`). The second reads the audit's own check
(`FormChecks.receiver/2`), so it never says more than `form-no-destination` does. `record` is the
page, read for its stored handlers. No event name is in either sentence.

# `grouped`

```elixir
@spec grouped(map()) :: [{String.t(), [map()]}]
```

Groups a component's attributes into the panel's sections. Anything that matches no group heading
lands in "Properties", and `global` attributes are pulled out entirely — they are the collapsed
"HTML attributes" editor, not a property.

# `groups`

```elixir
@spec groups() :: [String.t()]
```

The section headings, in panel order.

# `helpers`

```elixir
@spec helpers(map()) :: [map()]
```

A component's helper definitions — where the discriminator axes live.

# `holds_file_field?`

```elixir
@spec holds_file_field?(
  MishkaCmsCore.Runtime.PageBuilder.Tree.t(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(),
  [map()]
) :: boolean()
```

Whether a file field sits inside `form`: an `<input type="file">`, a `live_file_input`, or a
component whose markup renders one.

# `icon_in`

```elixir
@spec icon_in(term()) :: String.t() | nil
```

The icon name inside a value of an `icon_map?/1` attribute, whichever shape it arrives in — the
bundle's `__atom_map__` envelope, a real map, or the source of the map expression the editor
itself writes. `nil` when there is no icon in it.

# `icon_map?`

```elixir
@spec icon_map?(map()) :: boolean()
```

Whether this attribute carries its icon inside a MAP rather than as a bare name.

A label is not always a word. `chelekom-pagination` declares its five arrows and its separator as
`map`, each defaulting to `%{type: :icon, value: "hero-chevron-left"}` so an author can put either
a glyph or a caption there. Read as a plain map attribute, every one of them drew a text box the
author had nothing to type into — reported as "we cannot select the icon".

The shape is asked of the DECLARED DEFAULT, which is the only thing that says what the component
will do with the value; a kit that spells the same idea on a differently-named attribute is
covered without being named.

# `inner_block?`

```elixir
@spec inner_block?(map()) :: boolean()
```

Whether a slot definition is Phoenix's `inner_block` — the content written *between* a component's
tags rather than in a `<:name>` block of its own.

It is the one slot that must never be offered as something to add: `<:inner_block>` is rejected by
the HEEx compiler outright ("the slot name :inner_block is reserved"), because writing it is the
same as writing nothing and putting the content there directly. 82 of the kit's 120 slot-bearing
components name their only slot this way, so this is the common case, not an edge one.

# `label`

```elixir
@spec label(String.t() | map()) :: String.t()
```

A human label for an attribute — `start_icon` becomes "Start icon". The kit ships no labels, only
names, so this is derived rather than looked up.

# `name`

```elixir
@spec name(map()) :: String.t() | nil
```

An attribute's declared name.

# `opts`

```elixir
@spec opts(map()) :: map()
```

An attribute's option map (`opts`), always a map.

# `owning_form`

```elixir
@spec owning_form(
  MishkaCmsCore.Runtime.PageBuilder.Tree.t(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()
) :: MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil
```

The nearest form above this node, or `nil`.

# `phoenix_form_controls`

```elixir
@spec phoenix_form_controls(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil, [
  map()
]) :: [
  control()
]
```

The Form · Phoenix section's options for the form `node`: the ones whose attribute it forwards to
the form it renders (`Fields.takes?/3`). Open result in is left out while the form sends to
another address, which opens the result in the same tab. Every one of them with no node.

# `rename_ops`

```elixir
@spec rename_ops(
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(),
  String.t() | nil,
  String.t() | nil
) ::
  [map()]
```

The ops a rename issues, ready for `MishkaCmsCore.Runtime.PageBuilder.EditorSession.apply_ops/4`,
with `token` inside both event names (`nil` writes the untagged names).

All five in one batch, because five separate ops would be five undo steps and four of them would
leave the page in a state where the form's id, params, assign and events disagree.

# `rename_token`

```elixir
@spec rename_token(
  MishkaCmsCore.Runtime.PageBuilder.Tree.t(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(),
  [map()],
  keyword()
) :: String.t() | nil
```

The token a rename of this form writes.

  * a tagged form keeps its token, so its stored handlers are still found by it;
  * an untagged form whose stored handlers answered the `:origin` form's event names by exact name
    gets `nil`, the untagged names, so typing the old name back reattaches them. With
    `code_allowed?: true` it gets a fresh token instead, and `retagged/3` names the handlers that
    move to it;
  * any other untagged form gets a fresh token.

`events` are the page's Events-tab entries.

## Options

  * `:origin` - the form as it was when this rename began. Defaults to `node`.
  * `:code_allowed?` - whether the author may write the page's code. Defaults to `false`.

# `rename_warning`

```elixir
@spec rename_warning(
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(),
  map(),
  boolean()
) ::
  String.t() | nil
```

What a rename would break, or `nil`.

`node` is the form as it was when the rename began, and `code` is the page's stored code,
`%{events: [...], infos: [...], helpers: [...]}`.

  * An untagged form names the handlers that answer its event names exactly: they stop. With
    `code_allowed?` they move with the form instead (`retagged/3`), so they raise nothing.
  * Any form: when a stored entry mentions the form's seed (`:contact_form`, `:contact` or
    `"contact"`), a rename changes `as` and `for` under it.
  * A tagged form's own handlers follow it by token, so they alone raise nothing.

# `renders_slot?`

```elixir
@spec renders_slot?(map(), String.t()) :: boolean()
```

Whether the component ever renders what is written INSIDE this slot.

Declaring a slot and rendering its body are two different things, and thirteen of the kit's slots
do only the first: `chelekom-video` declares `:source` and its markup is
`<source :for={s <- @source} src={s.src} type={s.type} />` — it reads the slot's ATTRIBUTES and
never calls `render_slot`. So a card dropped into `:source` is accepted, is stored, appears in the
tree, and is drawn nowhere. The author is left looking at a slot with something in it and a canvas
that disagrees, with nothing anywhere saying why.

Two shapes count as rendering, because Phoenix writes multi-slots the second way:

    {render_slot(@caption)}
    <div :for={{item, i} <- Enum.with_index(@item, 1)}>{render_slot(item)}</div>

READ OFF THE MARKUP, because nothing else knows. A slot declaration says a slot exists and what
attributes it takes; whether its body reaches the page is a property of the template alone. That
makes this a scan of source text rather than a lookup, with the limits that implies — a slot
rendered by a helper this does not see, or through a binding it cannot follow, would be read as
unrendered.

Which is why it answers **true when it cannot tell**. Being wrong the other way would refuse drops
into a slot that does take content, and a false "you cannot put anything here" is much worse than
the silence this exists to end: `inner_block` (always rendered by definition), a component with no
markup stored, and any slot this cannot make sense of are all treated as taking content.

# `required?`

```elixir
@spec required?(map()) :: boolean()
```

Whether the attribute must be set. True on about 1.4% of the Chelekom kit; rendered as `*`.

# `rest_attrs`

```elixir
@spec rest_attrs(map()) :: [map()]
```

The `@rest` keys a component's own body actually reads, as offerable attributes.

## Why these are invisible otherwise

A kit declares the switch it supports through `attr :rest, :global, include: ~w(animated)`, and
two things drop it on the way here: the bundle exporter does not carry an `include:` list, and
`grouped/1` pulls `global` attributes out entirely — they are the collapsed "HTML attributes"
editor, not properties. So a skeleton's `animated` had nowhere to appear, and neither did fifteen
other components' switches: `pinging` and `circle` on a badge and three buttons, `disabled` on six
field components, four on the pagination.

## Derived from the body, not from a list

What the component CONSULTS is the honest answer to "what does it support", and it needs no kit
change and no re-export: `@rest[:animated]` in the template is the component saying it reads that
key. A name the body never mentions is not offered, so this cannot invent a control that does
nothing.

The TYPE comes from the use site for the same reason. `@rest[:x] && "class"` is a flag and gets a
toggle; `@rest[:x] || "fallback"` or `attr={@rest[:x]}` is a value and gets a text box — which is
what keeps a blockquote's `cite` a URL and a card's `role` a word rather than two switches.

Anything the component also declares properly wins: those are subtracted, so nothing appears
twice.

# `retagged`

```elixir
@spec retagged(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), String.t() | nil, [
  map()
]) :: %{
  required(String.t()) =&gt; String.t()
}
```

The stored handler names a rename moves onto `token`: each untagged event name of `origin` that a
stored handler answers by exact name, mapped to the tagged name of the same kind. Empty for a
`nil` token. `MishkaCmsCore.Runtime.PageBuilder.PageForms.follow/3` applies it.

# `rule_op`

```elixir
@spec rule_op(
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(),
  control(),
  String.t() | boolean() | nil
) ::
  map()
```

The op a control issues for `value`, ready for
`MishkaCmsCore.Runtime.PageBuilder.EditorSession.apply_op/3`.

Turning a rule off DELETES its attribute rather than writing an empty one: `required=""` is still
required to a browser, and `minlength=""` reads back as no rule on the server, so an emptied
control that only wrote would leave the two disagreeing.

Every control here writes one attribute, so the Form section's seed is not one of them: it writes
five at once and goes through `rename_ops/2` instead. A caller that dispatches the whole of
`form_controls/0` through here has to send the seed the other way, and gets a `FunctionClauseError`
naming the control if it does not.

# `rule_value`

```elixir
@spec rule_value(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), control()) ::
  String.t() | boolean() | nil
```

What a control currently reads on this node — `true`/`false` for a switch, the attribute's text
otherwise, and the seed for the one control that is not an attribute.

# `seed_refusal`

```elixir
@spec seed_refusal(
  MishkaCmsCore.Runtime.PageBuilder.Tree.t(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(),
  String.t() | nil
) :: String.t() | nil
```

Why this seed cannot be used on this form, or `nil`.

Answered against the MINTED name rather than the typed one, so `My Form` and `my_form` are refused
as the same answer they would both become.

# `send_controls`

```elixir
@spec send_controls(
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil,
  [map()],
  boolean(),
  String.t() | nil
) :: [control()]
```

The options of a Send button: Text while sending where the button forwards it, and Button type
where its type is written through `type` (`type_attr`, from `Fields.type_attr/3`). `send?` is
whether it sends its form now, what Button type shows when no type is written.

# `slots`

```elixir
@spec slots(map()) :: [map()]
```

A component's slot definitions.

# `template`

```elixir
@spec template(map()) :: String.t()
```

A component's template, however the caller happens to hold the component.

# `type`

```elixir
@spec type(map()) :: String.t()
```

An attribute's declared type, defaulting to `"string"`.

# `unmet`

```elixir
@spec unmet(
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.t(),
  %{optional(String.t()) =&gt; map()}
) :: [{:attr | :slot, String.t()}]
```

Everything this node was told it must have and has not got — its required properties left blank,
and the required slots nobody added.

READ OFF THE DECLARATION, never off a list of names. A kit this editor has never seen says which of
its properties are required the same way Chelekom does — `opts.required` on the attribute, and
Phoenix's own `:required` on a slot, which is the only option it allows inside one. So this asks
the component that is actually on the page and works for a component nobody here has heard of.

ONE ANSWER, THREE READERS. Publishing is refused when this is non-empty, the layers rail draws a
badge from it, and the Inspector marks the field — and those three used to be two implementations
of the same rule with the rail not implementing it at all. A gate the author cannot see until they
press Publish is a gate that finds the problem at the worst moment; a gate that disagrees with the
badge beside it is worse.

A slot's properties are declared on the component that OWNS it, which is why the tree is needed to
answer for one. `inner_block` is never counted: it is whatever is written between the tags, and
`<:inner_block>` is not something anybody adds.

# `validated_node?`

```elixir
@spec validated_node?(
  MishkaCmsCore.Runtime.PageBuilder.Tree.t(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()
) :: boolean()
```

Whether the Validation section applies — a node bound to a key of the form it sits inside.

An unbound node in a form has no rules to carry, and a bound node with no form above it has nothing
to validate it: `MishkaCmsCore.Runtime.PageBuilder.PageForms.FormPlacement` wraps that one on the
way in and the audit rail reports the ones that arrived some other way.

# `validation_controls`

```elixir
@spec validation_controls() :: [control()]
```

The Validation section's controls, in the order the panel draws them.

# `widget`

The control an attribute should get. `values` comes from `features/1`; pass `%{}` when unknown and
the attribute falls back to a plain text input.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
