The json-render flat Spec model and its fail-closed decoder, scoped to this
package's component set and pinned to @json-render/core v0.19.0.
A Spec is the canonical flat form: { root, elements, state } where elements is a
map keyed by id and children are referenced by string key (never nested inline).
Fail-closed. The decoder rejects, rather than silently dropping:
type (json-render's renderer is fail-open
here; we are not);repeat element with no children
(the structural floor validateSpec enforces).A rejected manifest never produces a partial tree: the host shows an error stub.
Decode a flat json-render Spec, fail-closed. See the module doc for what is
rejected. On any structural or off-catalog error the decoder fails with a diagnostic
message; the host renders an error stub rather than a partial tree.
Why a manifest failed the fail-closed decode, as far as the decoder's own diagnostics can tell. A host shows its audience one sentence, not a decoder dump, and this is what decides which sentence:
UnknownCatalogSurface — the manifest named a component type, an icon, or any other key this
catalog does not have. "Any other key" is the whole rejectUnknownKeys surface, not just
props: an unsupported element key (visible, watch), action-binding key (onSuccess),
confirm key, repeat key, or Table column key all land here. The publisher is describing an
interface some newer renderer would understand, so the honest reading is version skew, not a
broken manifest.Malformed — everything else: a missing required field, a wrong-shaped prop, a body that is
not even JSON. Nothing here suggests a newer renderer would fare better.This lives beside the Decode.fail arms it classifies, and reads the very strings those arms are
built from, so the two cannot drift apart: rewording a diagnostic moves the marker with it.
Classify a decode failure message (the Err from JsonRender.decodeString,
which is Decode.errorToString output and so embeds the failing arm's own text).
Substring matching is the only tool available — Elm decode errors are strings by the time they
reach a host. Decode.errorToString also prints offending JSON, so a manifest containing one of
these markers verbatim as data would be read as skew. That mis-picks which reassuring sentence the
reader sees and nothing else: both kinds refuse to render, which is the part that matters.
One element: its (validated) component type, its strictly-decoded props, its child
keys, its event bindings, and an optional repeat. Mirrors json-render's UIElement
shape (type/props/children/on/repeat), minus the visible/watch siblings this
renderer does not support.
The allowlisted component types. Anything else fails the decode.
The component type's wire name, for diagnostics.
Strictly-decoded props, one variant per component type. The variant always agrees
with the element's ComponentType (both come from a single read of
the type field).
Stack layout direction.
Card props. title is optional and may be any expression.
Stack props: a layout direction and a numeric gap.
Badge props: the value expression (e.g. a status string mapped to a tone) and an
optional variant expression. When variant is present its resolved string becomes the
badge's data-state attribute (so a manifest can key styling on a stable token while showing
different display text); when absent, data-state falls back to the resolved value text.
Button props: the label expression, an optional icon name, and an optional disabled
expression. When disabled resolves truthy the button renders inert (the native disabled
attribute, a jr-button--disabled class, and no press handler at all); absent means enabled, the
historical behavior.
icon is a name from a closed set ("trash", "close", "external", "refresh"), never
markup and never an expression: the renderer draws the glyph itself, so a manifest can ask for one
of a handful of shapes and cannot inject an image. Any other name is refused at decode time, like
every other off-catalog value. When the resolved label is non-empty it stays visible next to the
glyph; an empty label with an icon renders the button icon-only, with the renderer supplying the
accessible name.
Checkbox props: an optional label and an optional checked binding (typically a
two-way $bindState / $bindItem).
CountPills props: the bind expression pointing at an array of records, and the vocabulary
for counting them.
Everything but bind is optional. groupBy, groupOrder, itemNoun and itemNounPlural fall
back to the host's Render.CountPillDefaults when absent;
emptyLabel is the exception and has no host default. That is deliberate: the words a publisher
counts in — what a row is called, which field groups them, which group leads — are the
publisher's, not the renderer's, and a manifest written before these keys existed must keep
rendering in them.
Optional does not mean lenient. A key that is PRESENT but malformed (a null groupBy, a
groupOrder carrying a non-string member, a $computed in emptyLabel) rejects the whole
manifest rather than decoding as absent.
groupBy — the record field to group on.groupOrder — the group values, in the order they should be shown. Groups outside the list
sort after the listed ones, by descending count and then name, which is also what an empty
order does to everything. Matching is case-insensitive, and a value listed more than once
takes its FIRST position.itemNoun / itemNounPlural — what one row and many rows are called, for the total.emptyLabel — what a manifest says definitively about an empty table. Alone among these
keys it has NO host default: absent, the renderer synthesizes "No <plural> yet" from whichever
plural noun is in play. That fallback is right for a table that has nothing bound yet and
wrong for a completed, genuinely empty one; only the publisher knows which it is, so it
supplies the string. Resolving to an empty string means no empty-state node at all.Iframe props: the src URL expression and a title expression. Both are required.
The renderer only emits an <iframe> when the resolved src is an https URL whose origin
is in the host-provided allowlist; otherwise it shows a benign placeholder (fail-closed).
Table props: the columns to show (each a key/label pair) and a bind
expression resolving to an array of row objects. Each cell reads row[column.key]; a
missing key renders an empty cell.
One Table column: the row-object key to read and the header label to show.
Alert props: a tone, an optional title expression, and the required message
expression (which may itself be a $template or other binding).
Disclosure props: a label expression shown in the always-visible summary, and an
optional open literal bool (default False) that sets the initial expanded state. Children
render inside the disclosure body.
Alert severity tone, driving the jr-alert--<tone> modifier class. Any other
string fails the decode (fail-closed).
An event binding: a named verb, its (unresolved) params, and an optional confirm
dialog. The key is action/params per the pinned format, never a URL.
A confirm dialog shown by the renderer before an action emits. title and message
may be expressions (e.g. a $template interpolating a row field).
Element-level iteration: statePath points at a state array, key is the optional
stable-list item field.
A flat json-render spec: a root element key, a keyed map of elements, and the host-owned initial state.