Configurators
A configurator is a leaf item type that renders an editor for another item
in the same document — its target. Instead of hand-authoring a form of
controls, you point a configurator at a target by that target’s stable instance
id, and the resolver builds the editor for you from the target’s
configurable surface. The generated controls drive
config overrides that re-resolve the target
ephemerally, so a viewer can retune one item live without the document ever
being mutated on disk.
A configurator never has children.
Declaring a configurator
A configurator instance $refs the configurator item type and carries just two
config fields:
{
"$ref": "https://lattice.dev/schemas/items/configurator/1.0.0",
"id": "summary-configurator",
"config": {
"target": "summary",
"title": "Configure the summary table"
}
}
target(required). The stable instanceidof the item this configurator edits. It must reference an item declared in the same document that carries anid. Most items omitid; a configurator’s target is the first thing that makes a stableidrequired on the item it points at.title(optional). A heading rendered above the generated editor. It is itself a configurable field, so it can be retuned at runtime like any other.
That is the entire authored surface of a configurator — there is no per-field form to write. The editor is derived purely from the target.
Target validation
On every resolve the configurator pass builds a tree-wide id index once, then
validates each configurator’s target against it, fail-fast:
CONFIGURATOR_TARGET_MISSING_ID— thetargetreference is empty or whitespace-only, so there is no id to look up.CONFIGURATOR_TARGET_NOT_FOUND— thetargetis a well-formed id but no item in the document declares it; the reference dangles.
Both errors name the offending configurator’s path in their details.
Reserved document-scope targets
A target may instead be a reserved, $-prefixed keyword that points the
configurator at a document-level scope rather than an item:
| Target | Scope |
|---|---|
$manifest | the document manifest |
$variables | the document variable set |
$connections | the document connections |
$theme | the document default theme |
$root | the resolved root region |
A $-prefixed target is always routed to a document scope and is never
looked up as an item id, so a reserved keyword can never collide with — nor be
shadowed by — an item that happens to share the name. Conversely, an ordinary
item id (one without the $ sigil) is unaffected: an item literally named
theme is still targeted as an item by "target": "theme".
CONFIGURATOR_TARGET_SCOPE_UNKNOWN— thetargetis$-prefixed but names no recognized scope; it fails fast (it is not reinterpreted as an item id). The offending configurator’spathand the unknown scope keyword are in the error details.
Document-scope surfaces
Each reserved scope exposes its own configurable surface — declared on the
document schema under a top-level documentScopes keyword, mapping each
$-keyword to the same field → descriptor shape an item type’s
configurable keyword uses. A configurator pointed at a
reserved scope generates its editor from that surface through the same
form-generation path an item-targeting configurator uses (one control per
surface field, bound to the $scope.<field> override address). A scope with no
surface yields a present-but-empty form, exactly like a surface-less item.
The surface is the honest, machine-readable list of a scope’s runtime-tunable
fields, and it doubles as the guardrail: it enumerates the legal target
paths within the scope. Field names are validated against the scope’s real schema
properties — $manifest against the manifest’s properties, $theme against the
theme vocabulary’s tokens — so a scope surface can never drift out of
sync with what the scope accepts. The shipped scopes surface:
| Scope | Fields |
|---|---|
$manifest | title, description |
$theme | the six theme tokens (emphasis, spacing, density, tone, radius, border) |
$variables | (empty for now — no top-level scalar is runtime-tunable yet) |
$connections | (empty for now) |
$root | (empty for now) |
Generation is read-only: the resolver reads a scope surface and emits the editor but applies no change to the document — the authored manifest, theme, variables, connections, and root are passed through verbatim.
The auto-generated form
When the target resolves, the configurator pass reads the target’s validated configurable surface and generates one control per surface field, in surface (sorted field) order. For each field it picks:
- the field’s preferred
renderingwidget when the surface declares one, else - the canonical widget for the field’s value type —
string→text-input,number/integer→number-field,boolean→toggle,enum→select,array→multiselect.
The controls are laid out with the same flow layout a
form uses, so a renderer arranges them exactly like an authored
form. The whole editor is attached to the resolved configurator node as its
generated form: a target id, the list of widgets, and the flow layout.
Each generated widget records the widget item type that renders it, the
target field it edits, the field’s value type, a human label, and the
field’s opaque constraints (option sets, min/max, nested sub-fields)
passed through verbatim for the renderer to honor.
Because the form is derived from the surface and regenerated on every resolve, the editor can never drift out of sync with what the target actually accepts. A configurator that targets a surface-less item yields an empty (but present) form, so a renderer can always tell a resolved configurator from an unresolved one.
Composite fields are one control each. The configurable surface is top-level-only, so a table’s
columnsarray or a form’slayoutis a single surface entry — and the configurator generates a single control for the whole field, not one per sub-field. The field’s inner shape travels inconstraintsfor the renderer; per-sub-field editing is out of scope.
The ephemeral mutation model
Each generated widget carries the override address it drives:
<target-id>.<field>. When a viewer changes a control, the renderer posts a
config override keyed by that address, and the
document re-resolves with the override applied. As with every
runtime override:
- The override is ephemeral — it adjusts only the target’s resolved instance for that one resolution; the document on disk is never touched.
- It is applied after interpolation and validated against the target’s
configurable surface. A field not on the surface fails
CONFIG_OVERRIDE_FIELD_UNKNOWN; a value violating the field’s declared type or constraints failsCONFIG_OVERRIDE_VALUE_INVALID.
So a configurator is the authoring-side counterpart to the override system: the
configurable surface declares which fields are tunable, the
configurator renders the editor for them, and a
config override is what a change posts. The
served page wires this loop end to end — see
Supplying overrides through serve.
The override above is ephemeral — it adjusts only one resolution. The durable counterpart of the same edit is a JSON Patch changeset, gated by the same configurable surface; its application and persistence are future work.
Worked example
examples/configurator-dashboard.json places a summary table beside a
configurator that targets it by id. The table surfaces title, columns, and
query, so the resolver generates a three-control editor — a text-input for
the title and a control for each composite field — each bound to
summary.<field>. See Examples.