ui-common — universal keys, vocabulary, layout, data binding, JSONata, actions¶
Factory dashboards, factory-editor pages, and orchestrator settings are built from composable YAML under default_ui.layout. Components nest recursively. Every spec is validated server-side against a strict Zod schema; unknown/legacy keys and bad shapes are rejected at chat-edit time and factory-load time — no tolerant mode.
Per-leaf component config lives in its own doc — read it via read_docs as ui-<component> (e.g. ui-table, ui-scatter). This doc covers only what's universal across every leaf.
Universal top-level keys¶
Every component block shares the same top-level keys. ComponentRenderer (the dispatcher) reads only these; everything else lives under config:.
component: <leaf> # REQUIRED — leaf type, snake_case
id: <stable_id> # Optional — cross-component reference
title: "Display Title" # Optional shorthand for layout-with-header leaves (card, modal, tabs, metrics)
data: {...} # Optional — data binding (6 modes; see Data binding)
filter: {...} # Optional — row-level winnowing, object form or JSONata string
transform: "$:..." # Optional — JSONata expression: rows → rows
on_click: {...} # Event handlers, TOP-LEVEL, FLAT sibling params on `action:` (no `args:` nest, no
on_change/on_select/... # `actions:` wrapper). Also on_row_click/on_node_click/on_point_click/on_search/
on_blur: ... # on_submit/on_toggle. Array form: on_click: [ { action: A }, { action: B } ]
preset: <name> # Optional — named base config; `config:` overrides selectively
overlays: [ { type: text|line|rect|arrow|image|point|band, ... } ] # Optional — chart/viz annotations
config: {...} # Per-leaf configuration — see the leaf's own ui-<component> doc
children: [ ... ] # Optional — nested children (layout components only)
slot: # Optional on a CHILD — names its slot (only inside slot-aware parents)
style: # Optional — CSS-style object for THIS component's box; top-level only, never `config:`
show_when: "$:..." # Optional — JSONata expression; component renders only when truthy.
No legacy keys. The strict gate rejects: type: (use component:), data_collection:/topic_full_data:/topic_incremental_data:/inline_data:/update_topic: (use data:), config.style:/config.data: (lift to top-level style:/data:), Table.filter_by:/ForceDirected.node_filter: (use top-level filter:), chart_type: (split into line_chart/bar_chart/treemap/scatter), action: publish/store/apply/delete/save_and_restart (see Actions), {{...}} templates and bare-jq expressions (use $: JSONata).
Event-handler shape. Canonical is a top-level on_<event>: key with flat sibling params on action:. A legacy actions: { on_click: { action, args: {...} } } shim still normalizes at render time with a console.warn — do not author new YAML in that shape. style: is a top-level key on every component, consumed as a CSS-style object; nesting it under config: is silently ignored.
Canonical config vocabulary¶
Every component exposes config in one vocabulary regardless of the rendering library underneath — the same x_field/label/format/sort_dir whether the leaf is recharts, d3, Monaco, FontAwesome, or raw HTML. Library-native prop names (recharts dataKey, d3 nodeId/chargeStrength, Monaco wordWrap/vs-dark, FontAwesome fas, HTML type="email", MUI/antd terms) MUST NOT appear in factory.yml — a needed library feature gets a wrapper key here first (closed-extension).
Field references (*_field)¶
Dot-paths accepted (user.profile.tier).
| Key | Role |
|---|---|
field |
Single field a leaf binds to (form inputs, displays). |
data_field |
Field on rows[0].data holding the chart/scatter payload array in latest: true mode. |
key_field |
Unique row identifier (Table, anything paginated). |
x_field, y_field |
Axis fields for 2D plots. |
series_field |
Groups rows into series (line_chart, bar_chart). |
name_field, value_field |
Hierarchical/KV plots (treemap). |
label_field |
Display-label field (StatusIndicator, ScatterPlot tooltip). |
color_field |
Categorical colour-map field (ScatterPlot). |
link_field |
URL a Table cell wraps. |
id_field, parent_field, order_field |
Tree shape (TreeEditor — flat collection, parent_id pointer). |
tooltip_fields |
Array of field names in a tooltip (plural — many values). |
One concept → one key across leaves (always key_field, never pk elsewhere). Component-specific roles end _field; arrays of field names are plural.
Display labels¶
title — header/page-title text on layout-with-header leaves (card, modal, tabs, metrics, empty_state, error_state). label — field/button-level text on form leaves, columns, metrics, row_actions. placeholder — greyed hint in an empty input (form leaves). hint — help text via the ? info icon (form leaves). description — body text under the title (empty_state, error_state). empty_text — inline placeholder for zero items (tag_list). No synonyms (caption, name, text, subtitle, tooltip are not canonical).
Sort / ordering¶
sort_field — a data key, or a row-meta field (_updated_at, _created_at, _state, _key). sort_dir — "asc"|"desc", never ascending/1/-1. sortable — per-column bool (Table only), false disables header-click sort, default true. sort_field/sort_dir set the default server-side sort across the whole collection (before pagination) for a data-bound Table; clicking a header overrides at runtime. Omit both → backend default _updated_at desc.
Sizing and pagination¶
| Key | Type | Where |
|---|---|---|
page_size |
int | Rows per page. |
max_rows |
int | Hard cap on rendered rows (Table). |
max_height |
CSS length | Max height of a scrollable body (Markdown). |
max_width, max_height, width, height, min_height |
CSS length | Modal sizing only — most leaves fill their flex slot. min_height floors tabbed/variable-content modals. |
rows |
int | Textarea visible rows. |
min_* |
matching units | Lower bounds (rare). |
truncate |
int | Max characters before an ellipsis (Table cells). Integer only. |
Avoid limit (ambiguous), count (aggregations), pageSize (camelCase), truncate: true, ellipsis:/clamp:.
Format / display¶
format is canonical for cell-level rendering: "number" (locale-grouped int or 2-decimal float), "percentage" (0–1 floats → XX.X%; >1 assumed already percent), "relative_time" ("just now"/"5m ago"/"2h ago"/"3d ago"), "duration" ("123ms", Metrics). Used by metrics, detail_list, read-only text_input/textarea via shared utils/formatValue; table keeps its own cell formatter (slightly different relative_time/boolean handling) deliberately. New values land here first, then in formatValue.
State / behaviour flags¶
| Prefix | Intent | Examples |
|---|---|---|
show_* |
Visibility of an internal sub-part. | show_dot, show_label, show_controls, show_summary, show_grid, show_arrows |
allow_* |
Permission for an interaction. | allow_selection |
auto_* |
Automatic opt-in behaviour. | (reserved — none today) |
read_only, required, disabled, loading |
Standard form/async states — the ONLY unprefixed booleans. | Form leaves, Button. |
Every other component-defined boolean carries a prefix. Avoid enable_*, is_*, *_enabled.
Colour and scale¶
scale: { scheme: <name> } — named colour scale for chart/categorical series (bar_chart/line_chart/treemap fills, scatter color_field dots; default theme_categorical). color_map: { <category>: <hex|css-var> } — per-category override of the auto-palette (ScatterPlot); unlisted categories still get a scale colour. default_color — fallback for a null/missing category value, not for "unmapped" ones. color: (direct string) is valid only inside style: or a JSONata-resolved style value, never a top-level config key. Chart fills come from scale.scheme; chrome comes from theme.*/var(--*). Raw #hex only inside color_map or a factory-passed style.
Icon references¶
FontAwesome icon names only (icon: chart-line, never icon: fas:chart-line or an object shape). icon_position: "left" | "right" for buttons. If a second icon source is adopted, icon: becomes a prefixed enum (material:settings); until then bare names are FA.
Action params¶
Every event handler is a top-level on_<event> key; params are flat siblings of action:, never under args:. Closed 6-entry enum (save_data_item, delete_data_item, open, close, set_url_param, custom:<name>) — see Actions below.
Slot names¶
Slot-aware parents (Tabs, Modal) define allowed child slots. Canonical: tab, panel, header, body, footer.
Naming style and rules of thumb for a new component¶
snake_case in YAML (x_field, page_size, read_only); PascalCase for JS component/export names; registry type names snake_case. No abbreviations (label not lbl) except dir in sort_dir. No mixed numeric/string enums (sort_dir: "asc", not 1).
- Concept already named? Use the existing key.
- Library-native name? Wrap it (Monaco
wordWrap→word_wrap; rechartsdataKey→x_field/y_field/value_field; d3-forcechargeStrength→charge_strength, acceptable since the physics concept is the role). - Library-native value? Wrap/enumerate (Monaco
theme: "vs-dark"→theme: "dark"|"light"; HTMLtype:enums leak — prefer purpose-named leaves). - Boolean? Prefix (
show_*/allow_*) unless a form-state quartet member. - Many of something? Pluralise. 6. Field reference? Suffix
_field. 7. Document it here first — a new vocabulary key is a doc edit before a JS edit.
Canonical keys (current)¶
These leaf-specific keys are canonical as written, no alternate spellings exist: color_map; mode: "compact"|"default" (container_status); show_close_button / show_quadrants (modal / scatter); edge_distance/edge_strength/edge_bundling/edge_bundling_offset/edge_bundling_min_offset/edge_anchor_center (force_directed — d3-internal names stay link*); row-click detail via top-level on_row_click.detail_modal (table); fire-and-forget signals via array-form on_click writing a domain collection with key: $uuid; flat-collection tree_editor with a parent_id field, internal save_data_item/delete_data_item dispatch. Also fixed by design: code_editor.theme: "vs-dark"|"light"; Table column type: "tags" (scope-polymorphic type: at column level is fine — only the top-level type: was replaced by component:); text_input.type: "email"|"password"|"number"|"text".
Layout & responsive¶
layout_row/layout_column size their children, own padding/scroll/gap, and adapt to mobile automatically. Most layouts need no style: blocks; reach for style: only when the primitives can't express what you need (it always wins, spread last).
The two primitives¶
layout_row — horizontal flex row, axis fixed (row), never inferred; places children side by side. layout_column — vertical flex column, axis fixed (column); stacks children top-to-bottom. Both take config: { gap, align, justify } (gap defaults to --spacing-gap, align→stretch, justify→flex-start); children take config: { flex: N } (see sizing below). Never hand-write flex/display/flex-direction on style:.
Axis inference (regions)¶
A region (modal body, tab panel, card body) has no fixed axis — it infers from its children: containing layout_column siblings ⇒ horizontal (columns sit side by side); containing layout_row siblings ⇒ vertical (rows stack); only leaves (no layout primitives) ⇒ vertical (leaves stack); exactly one child ⇒ vertical, the child fills the region. Rationale: a block containing layout_columns goes horizontal because each column is vertical internally, so columns belong side by side — pick the child primitive by how it should lay out internally, and the parent axis follows. layout_row/layout_column are always explicit, never inferred.
no-mixed-axis (hard constraint)¶
A region must not contain both a layout_row and a layout_column sibling — there's no single correct axis. At runtime the host (Card/Modal/Tabs) logs console.error and renders a red "Layout error" banner, falling back to a vertical stack. At discovery/editor time the backend validator (schemas/validate.js) emits a no-mixed-axis issue at the offending path. (Doesn't apply to tabs' flat children — slot: tab/slot: panel distribute into separate panel regions.) Fix: nest one inside the other — wrap layout_row siblings in an outer layout_column (or vice versa):
# WRONG: card children = [layout_row, layout_column] ✗ mixed siblings
# RIGHT: card children = [layout_column { children: [layout_row, layout_row] }]
- component: card
children:
- component: layout_column # one region: a column of rows ⇒ vertical
children:
- component: layout_row
children: [ ... ]
- component: layout_row
children: [ ... ]
redundant-singleton (check_ui warning)¶
A layout primitive earns its keep by laying multiple children out. check_ui (schemas/validate.js) flags a wrapper that has nothing to lay out:
- a
layout_column/layout_rowwith exactly one in-flow child — it adds no layout but still imposes its own flex defaults on that child (flex: 1 1 0; align-items: stretch), so the child stretches to the wrapper instead of sitting naturally in the parent slot. Fix: promote the single child into the wrapper's slot (delete the wrapper). Out-of-flow children —modal,commit_modal,confirm_destructive_modal(React-portaled, not flex siblings) — don't count, solayout_columnchildren[tabs, modal]is still a singleton (one in-flow child). Applies to the root too. If the wrapper exists only to co-host a modal beside one real child, move the modal to the top-leveldefault_ui.modalsarray (seeui-modal) and delete the wrapper. - a
tabswith exactly oneslot: tab/slot: panelpair — a one-tab strip has nothing to switch to; render the panel's content directly.
Suppressed (left alone) when the wrapper carries meaningful differentiating config that positions/sizes the child: config.justify, config.align, config.flex, or a top-level style: — e.g. a layout_column { config: { justify: center } } that exists purely to centre its child is legitimate. config.gap does not suppress it (gap only spaces siblings, so it's inert on a lone child).
# WRONG: a column wrapping a single table adds nothing
- component: layout_column
children:
- { component: table, data: { collection: rows } }
# RIGHT: use the table directly in the parent slot
- { component: table, data: { collection: rows } }
misplaced-under-config (check_ui error)¶
Some keys are read from the component node, never from config:. Nesting one inside config: is a silent dud — no runtime error, the content just never mounts or the handler never fires. check_ui (schemas/validate.js) now rejects them so edit_ui refuses the save instead of shipping a dead UI:
children:/data:/component:underconfig:→ the subtree never mounts (observed: adetail_modalwhose inputs were nested underconfig:→ dead fields, no error).on_row_click:underconfig:→ the whole table's row-click is inert: no pointer cursor, no detail modal, no error (observed in the wild: a factory whose entire opportunity/programme tables were unclickable).on_row_clickis a top-level sibling ofconfig:— unlikerow_actions:, which correctly lives insideconfig:. Don't mirrorrow_actions' placement.
# WRONG: on_row_click buried in config → rows silently unclickable
- component: table
config:
columns: [ { field: name, label: Name } ]
on_row_click: { open: detail_modal }
# RIGHT: on_row_click is a sibling of config
- component: table
config:
columns: [ { field: name, label: Name } ]
on_row_click: { open: detail_modal }
Child sizing — config.flex¶
Omitted, in a layout_row: grow/split width equally (flex: 1 1 0) — why a row of columns splits width with no per-child config. Omitted, in a layout_column: content height (flex: 0 0 auto), so a column of form fields isn't squished — opt one child into filling with config: { flex: 1 }. flex: N (N>0): N shares of leftover space along the main axis AND fills the cross axis (a card/chart/table given flex: 1 fills its whole box; larger N = larger share). flex: 0: size to content, no grow, no cross-axis fill — header strips, metrics rows, button_group.
# A column: a metrics strip (content height) above a chart that fills the rest.
- component: layout_column
children:
- { component: metrics, config: { flex: 0 }, data: { collection: kpis, latest: true } }
- component: card
config: { flex: 1 } # fills the remaining height AND width
children: [ { component: line_chart, data: { collection: revenue } } ]
Gotcha: a no-flex child in a layout_column sits at content height — a chart/table/card you want to fill the column needs config: { flex: 1 } explicitly. A layout_row of two layout_columns (each its own config: { flex: N } share) builds a full multi-column dashboard with no style: anywhere.
Regions own padding, scroll, gap¶
Automatically, from spacing tokens: token padding (--spacing-panel, 16px) insetting content, scroll boundary (auto for multi-child stacks, hidden for a single self-scrolling leaf like table/chart), and token gap (--spacing-gap, 16px) between siblings (override with config: { gap }; tight variant --spacing-gap-tight, 8px, for button groups/metric rows/modal footers). Modal min-height floor --spacing-modal-min, 480px. Don't hand-set overflow/padding, and don't wrap body content in a grid just for spacing.
Responsive behaviour¶
Engine reflows automatically — author desktop intent only. Auto-stack below ~600px viewport: a horizontal block of layout_column siblings, or a layout_row's children, collapse to a full-width vertical stack, via a viewport @media (max-width: 600px) rule on the region/row (not @container — stacking keys off viewport width, not the row's own box). Modals go full-screen below ~600px (full width/height, no border-radius). Measurement-driven leaves (charts, force-directed graphs, canvas, tables) observe their own container via ResizeObserver, never window. Long text truncates or wraps; no component pushes a horizontal scrollbar out of its card. No container-type/CSS containment is set anywhere.
Modal body: / footer: named keys¶
A modal accepts body:/footer: as named keys carrying child configs directly (single node or array) — no slot: wrapper needed. The body is a region (axis-inferred, scroll, token padding). Legacy slot: body/slot: footer/slot: header still works and may be mixed with named keys.
- component: modal
title: Edit order
body:
- component: detail_list
config: { fields: [ { field: customer, label: Customer }, { field: total, label: Total, format: currency } ] }
footer:
- component: button
title: Save
on_click: { action: save_data_item, collection: orders, key: "$: row.key", data_field: row }
grid is LEGACY¶
grid is deprecated — use layout_row/layout_column. Still renders for existing factories; do not author new grid blocks. Migrating: direction: horizontal→layout_row, direction: vertical→layout_column, per-child style: { flex: N }→config: { flex: N }, drop hand-wired padding/overflow/gap (the region owns it). style: { height: ... } on chart rows, flexShrink: 0/minWidth: 0/overflow: hidden workarounds are anti-patterns — the primitives size correctly without them.
Data binding (the data: block)¶
Six modes. The hook (useBoundData) chooses transport (REST GET / SSE push / poll-fallback / static) from the spec; components never branch on transport. Returns { rows, loading, error, lastUpdated, refetch, notFound, truncated, urlKey }; rows is always an array (latest: true and key_from_url produce a 0- or 1-element array).
Bounded, not unbounded. A collection-list binding (Mode 1/2, not latest: true) fetches the whole collection in one request up to a hard cap of 10,000 rows; paginating leaves (e.g. table) client-paginate over that full bounded set. Beyond the cap the result clips and the hook sets truncated: true (table shows a "showing the first N rows" notice). latest: true is exempt. A collection that routinely exceeds the cap wants server-side filtering (state:) or a purpose-built streaming view.
Transport: push-first via SSE. One EventSource per tab connects at /api/events (server mints a sessionId); subscriptions add via POST /api/events/:sessionId/subscribe (useEvents.js client / eventBus.js server). useBoundData rides on top for collection modes — while SSE is connected and the tab visible, freshness is push-driven and polling is suspended.
Mode 1 — full collection¶
Subscribes to SSE tf.{factory}.{collection}.changed, re-fetches on each event.
Mode 2 — collection filtered to state(s)¶
data:
collection: documents
state: drafted # single state — string
# OR
data:
collection: documents
state: [drafted, approved] # multi-state — IN list
Server applies the filter; subscription tracks _changed.
Mode 3 — latest single row from a (collection, state) channel¶
The canonical "latest-of-state" mode — an agent writes at the named state; the UI reads the latest one (metrics, markdown, scatter). Producer: tf.collection('<domain>').set('<key>', state='<topic>', data=…), or tf.on_schedule.every(N).<unit>.do(…) for scheduled stats.
Mode 4 — inline / static¶
No fetch, no subscribe — demos and static pickers.
Mode 5 — arbitrary HTTP endpoint¶
REST GET against an arbitrary endpoint — orchestrator-level pages (settings, usage, admin).
Mode 6 — single row keyed by a URL query param (key_from_url)¶
READ half of the symmetric pair with the set_url_param action (see Actions). Reads the URL param, fetches the single row at GET /api/factories/{factory}/data/{collection}/{key}, subscribes to .changed so live edits push through.
- No param value → no fetch,
rowsempty,notFound: false. - Param present but no such row (stale bookmark) →
rowsempty,notFound: true,urlKeyset to the param value so the component can render a bespoke message. - Re-fetches when the param changes (a
set_url_paramwrite, a[[slug]]wiki-link click, or Back/Forward).
Per-tab and bookmarkable — selection lives in the tab's URL, not a shared DB row. Requires collection; pairs with set_url_param on a sibling selector.
Polling (fallback only)¶
Push via SSE is primary. The poll loop runs only when SSE is disconnected, or the tab is hidden (document.visibilityState === 'hidden'); when SSE reconnects and the tab is visible, polling suspends. poll: 0 — one-shot fetch, no fallback timer. poll: <N> — fallback poll every N seconds when push unavailable (for endpoint: mode, with no push transport, this is the active interval). Absent — default fallback 5s when push unavailable, overridable via COMPOSABLE_UI_DEFAULT_POLLING_SECONDS.
Push lifecycle (tab visibility)¶
visible→hidden: unsubscribe from the SSE topic, stop the poll loop. hidden→visible: immediate refetch (backfills anything missed), re-subscribe. No per-component IntersectionObserver — long dashboards keep receiving pushes for offscreen cards while the tab is foregrounded (locked deferred decision).
Topic shape (for custom subscribers)¶
useBoundData derives topics for you; custom components using useEvents directly need: tf.{factory}.{collection}.changed (every write — what useBoundData consumes), tf.{factory}.{collection}.{state}.{op} (state-and-op-specific, op: insert|update|delete, not consumed by useBoundData), chat.{requestId} (chat streaming events).
Large-payload pattern. SSE frames carry metadata only, never the full row value. When _size_hint === 'large', consumers fetch the body via GET /api/factories/:name/data/:collection?since=<ts>. useBoundData handles this transparently; custom subscribers must do it themselves.
filter: is on top of data:¶
data.state is a CHANNEL selector (which subscription/server-side query); filter: is row-level winnowing applied on top, without changing subscription scope. Two forms, both supported at runtime on every data-bound component:
# Object form — each value resolved against the parent DataRef scope at render.
- component: table
data: { collection: messages }
filter:
prospect_id: "$: prospect_id" # parent row's id (e.g. nested in a detail_modal)
state: drafted # plain literal — equality
priority: [high, medium] # array — IN
score: { gte: 0.5 } # range op
# JSONata-string form — arbitrary per-row predicate.
- component: table
data: { collection: prospects }
filter: "$: touch_count >= 3"
Object-form values can be literals or $: expressions resolved against the PARENT context; string form evaluates per-row with the row as scope. When the source supports pushdown, object-form filters translate to server-side WHERE clauses; JSONata strings always run client-side.
Slot rendering¶
Some parents group children into named slots via the child's top-level slot:. modal/card allow header/body/footer (default body for children with no slot:); tabs allows tab/panel (no default — every child MUST declare slot:). A slot: value outside the parent's allowlist is rejected; children under a non-slot-aware parent MUST NOT carry slot:.
component: modal
title: Detail
children:
- { slot: body, component: tabs, children: [ ... ] }
- slot: footer
component: button_group
children:
- { component: button, config: { label: Close }, on_click: { action: close } }
JSONata for dynamic values¶
Any config taking a derived value (cell labels, formatted strings, computed styles, action params, conditional visibility) opts into JSONata via the $: prefix on a string.
Default is literal: label: "Email Drafter" → literal; numbers/booleans/arrays → literal. JSONata via $: prefix: label: "$:value.subject" → evaluated. Object form interchangeable: label: { jsonata: "value.subject" } ≡ prefix form. Escape a literal $: with "$$:...". Non-strings never evaluate. Reserved structural keys are literal-only: component, id, data.collection, data.state, data.latest, data.endpoint, data.poll, data.inline, on_<event>.action, slot, config.pagination.mode.
The current row/node/scope is the implicit root — reference fields with bare names (subject, value.body). $user, $factory, $page, $rowIndex are available named scope vars depending on site.
Common mistake — no
$.field. The mini-parser doesn't support the real-JSONata$root sigil."$:$.prospect_name"silently fails (resolver returnsundefined, renderer falls back to the literal string). Always use bare names:"$:prospect_name","$:'Review: ' & prospect_name","$:touch_count >= 3 ? 'exhausted' : 'active'".
Capabilities & limits¶
$: is a hand-rolled subset, not full JSONata — projection, defaults, concat, comparisons, conditional visibility. It does NOT build or reshape data. Unsupported expressions fail silently (resolver returns undefined, renderer falls back to the literal string).
| ✅ Supported | ❌ Not supported (silently fails) |
|---|---|
Literals (string/number/bool/null) |
Object construction { ... } |
| Field access — bare name + dotted path | Variable bindings := |
String concat & |
Statement blocks ; |
Compare = != < <= > >= |
Array transforms/map/reduce/lambdas |
Arithmetic + - * /, unary - |
Predicates/filters a[pred], regex |
Logical and/or/not |
$ root sigil / $$ root-array sigil |
Ternary cond ? then : else |
Higher-order functions |
Builtins $uppercase $lowercase $substring $string $number $boolean $not $length |
Any other builtin |
Two traps: (1) Only string leaves evaluate — resolveValue evaluates only a string starting $:; the object form data: { $: "<expr>" } passes through literally, so evaluate a whole expression with the string form data: "$: <expr>". (2) No object building — you cannot construct/reshape a stored object in YAML (data: "$: { md: ..., tiv: a + b }" fails).
Guidance — derive in an agent, not the UI. When a stored object needs to be built/reshaped/computed, have the UI write raw form fields (a data_field slice, or plain/$:-projected leaves), and let a Python agent (tf.on_state(collection, state).do(...)) read the raw row and write the derived shape — $: projects and formats, it doesn't construct.
$: evaluation is render-time only (Model A doctrine)¶
One evaluator, ComponentRenderer, at render time, against the current DataRef — parents never pre-evaluate a child's YAML. If a click-handler needs the clicked subject visible to children, the parent publishes the subject into the DataRef instead: force_directed node click merges { node: <clonedNode> }, scatter point click merges { point: <clonedPoint> }, table row click / row_actions merges { row: <clonedRow> }. Children in the opened subtree read $:node.type, $:point.label, $:row.invoice_id directly. The merge is shallow over the existing DataRef root — siblings keep seeing form state. The open action likewise publishes its subject: param before activating the modal — under the row key when the subject carries _tf_subjectKey: 'row' (table-origin / the chat open_ui_modal tool), else under subject — so a Button-open or a chat-opened modal body reads $: row.<field> / $: subject.<field>.
Which leaves can read a published subject (no data: block) — only DataRef-aware leaves: detail_list ✅ (the canonical label→value record for a subject; no-data: mode reads each fields[].field from DataRef — use instead of stacked read-only text_inputs), text_input/textarea ✅ (reads field from DataRef, read_only: true for display cells, format: still runs), markdown ✅ (no-data: mode reads field from DataRef, renders its own empty state when blank), tag_list ✅ (reads field from DataRef, a string-array field renders one chip per item, empty_text for zero). metrics ❌ — reads only via its own data: block (useBoundData), no DataRef fallback, renders <EmptyState> against a published subject; use detail_list instead.
A composite "Overview tab" (field record + summary) is built from detail_list + markdown, not metrics. Tab the body with tabs (slot: tab/slot: panel pairs) directly under the modal's slot: body.
open: accepts a string id only¶
Click-opens-a-component contracts (config.on_node_click.open, config.on_point_click.open, table detail_modal, any future open:) take a string id only — the id: of an id-registered component (a modal) mounted anywhere in the same view.
"Sibling" is about the id registry, not the DOM. open: resolves against the page-level id registry (the nearest DataRefProvider), NOT literal tree siblings — and a modal is React-portaled, so its position in the layout is irrelevant as long as it's mounted. Declare modals in the top-level default_ui.modals array (a sibling of default_ui.layout, keyed by id:) — they mount page-level in the same registry, so any trigger opens one by id and one def is reusable from many sites (see ui-modal). You therefore never wrap the layout in a layout_column to make a modal a "sibling" — that wrapper is a redundant singleton (a modal isn't in-flow) that check_ui/edit_ui flag; keep a root tabs as the single root node. Nesting a modal inside the layout tree still works but is deprecated (edit_ui warns) — move it to default_ui.modals.
- component: force_directed
config:
on_node_click:
open: "$: node.type = 'state' ? 'state_modal' : 'agent_modal'"
- component: modal
id: agent_modal
... # dormant until triggered
- component: modal
id: state_modal
...
Inline object form (open: { component: modal, ... }) is rejected by the strict gate — always declare the modal as a sibling with id: and reference it. open: may be a literal string or a $: expression yielding a string at click time. Id-ref targets are dormant until triggered. If resolution yields undefined, a non-string, or an unmatched id, the renderer fires an error toast and renders nothing — no silent failures.
Actions¶
Buttons, table row_actions, force-graph node clicks, and any interactive surface emit through top-level event-handler keys (on_click, on_change, on_row_click, on_node_click, on_point_click, on_select, on_blur). Each handler is an object whose action: names a dispatch target, with flat sibling params (no args: nest). A closed set of canonical actions is handled by the dispatcher; everything else bubbles to the host page via custom:<name>.
Canonical action enum (6 entries)¶
| Action | Effect | Required | Optional |
|---|---|---|---|
save_data_item |
PUT /api/factories/{factory}/data/{collection}/{key} with { data?, state? }; data shallow-merges into value (see Write semantics). Form saves, state transitions, and triggering agents (write to the agent's input collection/state, key: $uuid per click). |
collection, key |
state, data, data_field |
delete_data_item |
DELETE /api/factories/{factory}/data/{collection}/{key}; cascades to factory_vectors. |
collection, key |
— |
close |
Deactivate the host with id:, or top of the activation stack when omitted. Symmetric with open. |
— | id |
open |
Activate a registered sibling id. Same as on_<event>.open: <id> short-form. |
id |
subject (onto DataRef root) |
set_url_param |
Client-side nav: write a URL query param, no backend contact. Sibling key_from_url reader re-renders. |
param, value |
history (push default | replace) |
custom:<name> |
Bubbles through onAction to the host page, which interprets it (custom:start_factory, custom:save_and_restart). |
varies | varies |
Only these six are accepted — any other action: value is rejected by the strict gate.
Universal modifier: any action accepts then_close: true to synthesise a follow-up close. Canonical actions close on success; custom:* closes optimistically (immediately after dispatch — no success signal comes back from custom handlers).
Write semantics — data is a shallow merge, never a wipe¶
The backend PUT applies data as a shallow merge into value (value || patch): top-level keys in data overwrite, keys absent are preserved, nested objects replace wholesale (not deep-merged) — a value-wipe is structurally impossible. { data, state } merges data and sets state; { data } merges data (existing state preserved on update, new row defaults state: new); { state } sets state only, value untouched. Full-overwrite (replace: true) and key-deletion (unset: [...]) were designed but deferred (YAGNI) — derive-and-rewrite in a Python agent if needed.
Where the data patch comes from (every dispatcher resolves its action spec — including any data: map — against its local context first): data: { ... } — explicit authored map, honoured on every dispatcher, becomes the PUT data; leaves may be $: projections, but you cannot synthesise a whole map inside one $: expression. data_field: <field> — pulls a dot-path slice out of the resolved data (Button snapshot or authored data: map) and PUTs just that slice; may be nested (data_field: rubric.weights). Button/Modal-footer with neither — auto-attach the live DataRef snapshot ⇒ full-snapshot save; because the write merges, keys absent from the snapshot still survive. Bare dispatchers (Table row_actions, select.on_change, input on_blur, ConfirmDestructiveModal confirm) do NOT auto-attach a snapshot — state-only path (data omitted, value preserved, only state flips), for pure state transitions.
E.g. on_click: { action: save_data_item, collection: prompts, key: "$: prompt_key", data_field: text, then_close: true } slices the snapshot down to just text instead of saving the whole row. data_field reads from the live DataRef, including in-modal edits since open. To save several fields from a non-Button dispatcher, point data_field at a shared parent object, or author an explicit data: map with a $: leaf per field. Canonical shape is a top-level event key with flat sibling params — see "One example per event type" below.
Multi-action dispatch — array form on the same event key (notifications below is a domain collection — never a reserved _-prefixed one):
- component: button
on_click:
- { action: save_data_item, collection: rubric, key: default }
- { action: save_data_item, collection: notifications, key: $uuid, state: rubric_updated }
One example per event type¶
on_click (button): { action: save_data_item, collection: review_queue, key: "$: id", state: approved }. on_change (select): { action: save_data_item, collection: tickets, key: "$: ticket_id", data_field: status }. on_blur, save-on-defocus (textarea): { action: save_data_item, collection: invoices, key: "$: invoice_id", data_field: notes }. on_select, combo/list (multi_select): { action: save_data_item, collection: prospects, key: "$: id", data_field: tags }. on_row_click, opens registered modal by id (table): { open: invoice_detail_modal }. on_node_click (force_directed): { open: "$: node.type = 'state' ? 'state_modal' : 'agent_modal'" }. on_point_click (scatter): { open: point_detail_modal }.
key: $uuid — fresh-key-per-click pattern¶
When a button needs a brand-new row each click (e.g. fire-and-forget message), set key: $uuid — resolved to crypto.randomUUID() at action-dispatch time.
# collection = agent's input collection (never `_`-prefixed); key: $uuid → crypto.randomUUID() at click time
- component: button
on_click: { action: save_data_item, collection: Plan, key: $uuid, state: requested }
Canonical UI-triggered-agent pattern: each click writes a fresh row in the agent's input collection at the input state, and the agent subscribes with @tf.on_state('Plan', 'requested').do / def handler(row): ... (MUST transition or .remove() the row). There is ONE model: write a row into a domain collection at a state a consumer subscribes to. _-prefixed collections are reserved — never write to one. The agent's input collection/state is part of the contract; name them in factory.yml so UI and agent agree.
then_close: true — close-after-action (universal)¶
Add then_close: true to any on_click action for a follow-up close via the activation stack (no explicit modal id needed). Timing: save_data_item/delete_data_item fire the close AFTER the HTTP request resolves successfully; custom:<name> fires it OPTIMISTICALLY (immediately after dispatch) — if the custom op fails, the modal still closes.
- { component: button, on_click: { action: save_data_item, collection: rubric, key: default, then_close: true } }
- { component: button, on_click: { action: custom:save_and_restart, then_close: true } } # optimistic close
action: close (symmetric with open) takes optional id: — omitted closes the top of the activation stack: on_click: { action: close, id: edit_modal } or bare on_click: { action: close }.
action: set_url_param — URL-driven view navigation¶
SET half of the symmetric pair with key_from_url (see Data binding Mode 6). Use it when a click should pick "which thing is shown," per-tab, bookmarkable, back-button-navigable — without a DB write or a resolver agent.
# Left pane: a page list. Clicking a row selects which page the viewer shows.
- component: table
data: { collection: wiki }
on_row_click: { action: set_url_param, param: wiki_select, value: "$: row._key", history: push }
# Right pane: a viewer bound to the SAME param. Re-renders to follow the click.
- component: markdown
config:
field: content_md
wiki_link: { param: wiki_select } # [[slug]] inline links set the same param
data: { collection: wiki, key_from_url: wiki_select }
param (required) — query-param name, must match the reader's key_from_url. value (required) — resolved against the clicked subject, slug colons auto-encode via URLSearchParams. history — push (default, Back/Forward work) or replace (churny selectors; tabs uses this internally). Notifies any sibling key_from_url reader on the same param — re-renders in-app, no reload; tabs are independent. Pure client-side, no /api call, no chat-equivalent MCP tool.
State transitions are a save_data_item (no worker needed)¶
"A reviewer moves Email from Drafted → Approved" is just save_data_item with state: set (see on_click above) — a state-only write is a state patch (value preserved), firing {factory}.{collection}.{state} NOTIFY so an agent that needs to react subscribes via tf.on_state('review_queue', 'approved'). Don't write a worker whose only job is translating a topic into a state write — let the UI write the state.
factory.yml's states: entry manual_transition_states: is the authoritative list of transitions a reviewer may perform from the UI on rows in that state; UI authors wire matching save_data_item buttons, and the chat agent exposes the same list as MCP tools (GUI ↔ chat parity).
Topic / event patterns¶
All backing data lives in factory_data. Writes fan out: Postgres NOTIFY {factory}.{collection}.{state} on state changes (INSERT/UPDATE changing state) — Python workers subscribe via tf.on_state(). Postgres NOTIFY {factory}.{collection}._changed on any INSERT/UPDATE — consumed server-side by eventBus.js, which fans out to SSE tf.{factory}.{collection}.changed (any write; what UI data: { collection: ... } bindings consume via useBoundData) and SSE tf.{factory}.{collection}.{state}.{op} (state+op-specific, for custom useEvents subscribers). Components with a data: block auto-subscribe to the SSE .changed topic and re-fetch on each event — no manual wiring. The legacy _changed NOTIFY channel is now server-internal; browsers no longer listen to it directly.
Theme¶
Components use CSS custom properties for chrome (backgrounds, text, borders, status colours, focus rings); factory-passed palettes (e.g. scatter.color_map) may be raw hex. App-global tokens (set at orchestrator boot from THEME_* env vars): Brand --primary-50…--primary-900, --secondary-50…--secondary-900, --tertiary-50…--tertiary-900; Status --success-500/--warning-500/--error-500/--info-500 (+ 50–900 variants); Surface --bg-primary/--bg-secondary/--bg-tertiary; Text --text-primary/--text-secondary/--text-muted; Border --border-color. Use inside a top-level style: block (or a dynamic JSONata-evaluated style value):
- component: metrics
style: { color: "$: $.score >= 80 ? 'var(--success-500)' : 'var(--warning-500)'" }
Named colour scales (categorical/sequential/diverging) for chart components are documented per leaf. Raw hex inside chart axis chrome is rejected; factory-owned data palettes (color_map) remain raw hex.
Pre-loaded data sources (DataRef preload)¶
Declare named data sources at the top of default_ui to fetch factory_data before rendering. Each becomes a key on the page-level DataRef, so form leaves can bind to field: <name>.nested.path and edits save back via save_data_item.
default_ui:
data_sources:
rubric: { collection: rubric, key: default }
personas: { collection: personas, key: all }
layout:
component: tabs
children:
- { component: tab, slot: tab, title: Rubric }
- component: layout_column
slot: panel
config: { gap: 12px }
children:
- component: code_editor
config: { field: rubric, language: yaml, serialize: yaml } # bound to preloaded factory_data.rubric.default
- component: button
config: { label: Save, variant: primary }
on_click:
- { action: save_data_item, collection: rubric, key: default }
- action: save_data_item # trigger the pipeline; agent listens via
collection: rubric # tf.on_state('rubric', 'updated')
key: $uuid
state: updated
Example: 2-tab dashboard¶
Slot-paired tabs (one tab marker + one panel container per tab, in declaration order):
default_ui:
layout:
component: tabs
children:
# --- Tab 1: Overview ---
- { component: tab, slot: tab, title: Overview, config: { icon: chart-pie } }
- component: layout_row
slot: panel
config: { gap: 16px }
children:
- component: layout_column
config: { flex: 2, gap: 16px } # per-child flex is config, not style
children:
- component: metrics
data: { collection: stats, state: summary, latest: true }
config: { metrics: [ { field: total, label: Total, format: number } ] }
- component: card
title: Chart
config: { flex: 1 } # fills the remaining column height
children:
- component: line_chart
data: { collection: stats, state: summary, latest: true }
config: { data_field: daily, x_field: day, y_field: count }
- component: card
title: Assistant
config: { flex: 1 }
style: { maxWidth: 360px } # maxWidth is a real CSS constraint — stays in style
children: [ { component: chat_panel } ]
# --- Tab 2: Data ---
- { component: tab, slot: tab, title: Data, config: { icon: table } }
- component: table
slot: panel
data: { collection: invoices }
config:
key_field: id
columns: [ { field: name, label: Name }, { field: status, label: Status } ]