View system

This describes the shipped, as-built view system: the valem-view evaluation engine, the ViewDefinition/ComponentSpec format, the EvaluatedView contract, and the built-in valem-view-react renderer.

  1. Context
  2. Architecture Overview
  3. Maven Module: valem-view
    1. view/model package
    2. Component Type Catalog
    3. view/engine package
  4. Default Visibility / ReadOnly Inheritance
  5. npm Library: valem-view-react
  6. Built-in UI integration

Context

Valem models expose data and logic through REST/WebSocket/console APIs. A View Definition — a declarative JSON artifact embedded in ModelSpec — describes how that data is presented as a UI form: component layout, field bindings, visibility rules, and event handlers. The definition is renderer-agnostic; the built-in renderer is a React UI backed by Spring. AI agents and other clients (mobile, Angular, CLI) can implement their own renderers against the same EvaluatedView REST/console contract.

Two modules implement the feature: valem-view (Java, no Spring) holds the data model and evaluation engine; valem-view-react (npm library) holds the React component library, hooks, and TypeScript types. The valem-ui app depends on valem-view-react as a local workspace package.


Architecture Overview

ModelSpec.viewDefinition (JsonNode, raw)
        │
        │ parsed by valem-view (Maven)
        ▼
ViewDefinition ─► ViewSpec ─► ComponentSpec[]

                    ┌──────────────────────┐
                    │    ViewEvaluator      │  valem-view/engine
                    │                      │
ViewSpec ──────────►│ + mergedDocument     │──► EvaluatedView
metaCache ─────────►│ + metaCache          │       └─ EvaluatedComponent[]
ExpressionCache ───►│ + ExpressionCache    │           (all dynamics resolved)
                    └──────────────────────┘

REST (valem-api)
  GET /models/{id}/view           → EvaluatedView
  GET /models/{id}/view/{viewId}  → EvaluatedView

Console (valem-console)
  {"cmd":"get-view","id":"..."}   → EvaluatedView

Built-in renderer (valem-ui + valem-view-react)
  <ViewPanel>
    <ViewRenderer spec={viewDef} state={mergedDoc} onMutate={mutate} />
      └─ <ComponentRenderer component={c} />
           ├─ <TextField/>  <SelectField/>  <CountrySelector/> …
           ├─ <DataTable/>  <DataChart/>
           ├─ <Group/>  <SectionList/>
           └─ <Button/>  <Menu/>

Pluggability contract: ViewDefinition (JSON) is the renderer-agnostic spec. EvaluatedView (JSON from REST) is the computed snapshot any renderer can consume. The React library is one implementation; future renderers just need to parse either.

Dependency chain (no circular deps):

  • valem-viewvalem-core (ExpressionCache, ObjectNode, JSONata)
  • valem-servicevalem-core + valem-view
  • valem-apivalem-service (valem-view transitive)
  • ModelSpec.viewDefinition stays as raw JsonNode in valem-core (same pattern as schema)

Maven Module: valem-view

Package: org.json_kula.valem.view

valem-view/pom.xml dependencies:

  • valem-core
  • jackson-databind
  • junit-jupiter, assertj-core (test scope)

view/model package

ViewDefinition, ViewSpec and the supporting records use @JsonCreator factory methods; the ComponentSpec records bind through their canonical constructors. All of it is read with FAIL_ON_UNKNOWN_PROPERTIES = false.

Write-time validation. A viewDefinition is validated when a model is created or evolved, not deferred to first render: ModelSpecValidator enforces (structurally, over the raw JSON) that view ids are unique, component ids are unique within a view, and defaultView / sectionList.itemView name existing views; the service additionally parses the definition into these records. Any failure is a 422 at write time rather than a 500 at render. Views and components are therefore addressable by id, which is what makes the upsertViews / upsertComponents evolution tiers (see model-spec/tests-and-evolution.md) well-defined.

ViewDefinition

record ViewDefinition(
    String renderer,         // optional, reserved; default "builtin"
    List<ViewSpec> views,
    String defaultView
)

ViewSpec

record ViewSpec(
    String id,
    String label,
    String layout,           // vertical | horizontal | grid | tabs | wizard
    Integer columns,         // for grid layout
    List<ComponentSpec> components,
    EventHandler onOpen,
    EventHandler onClose
)

ComponentSpec — a sealed interface, not a flat record. type is the discriminator (@JsonTypeInfo(use = NAME, include = EXISTING_PROPERTY, property = "type", visible = true)), so the JSON stays flat and unchanged while each component type binds to a record carrying only the fields it actually uses. A badge with a pageSize is not representable.

The interface itself declares only what the generic evaluation pipeline reads; every other field is reachable exclusively by pattern-matching the concrete record, which is what makes ViewEvaluator’s switch exhaustive:

sealed interface ComponentSpec permits BasicInputSpec, TextAreaSpec, /* … */ UnknownComponentSpec {
    String id();
    String type();
    default String   bind()     { return null; }  // $.path — also the meta-inheritance anchor
    default JsonNode visible()  { return null; }  // Boolean | String (JSONata) | null → inherit meta
    default JsonNode enabled()  { return null; }  // Boolean | String (JSONata) | null → !readOnly
    default JsonNode readOnly() { return null; }  // Boolean | String (JSONata) | null → inherit meta
    default JsonNode required() { return null; }  // Boolean | String (JSONata) | null → `#required`
}

id and type are required on every component — a spec missing either is rejected at parse time (a 422 at write, not a nameless component at render).

Records are grouped by field shape, not one per type — the same grouping EvaluatedComponent uses on the output side:

Record type values Fields beyond id/type/bind/visible
BasicInputSpec textField, numericField, currencyField, percentField, passwordField, emailField, phoneNumberField, checkboxField, toggleField, dateField, dateTimeField, timeField, countrySelector label, enabled, readOnly, required, placeholder, helperText, tooltip, format, currency, onChange
TextAreaSpec textAreaField, richTextField basic-input fields + rows, toolbar
ChoiceInputSpec selectField, radioField, multiSelectField, autocompleteField, comboBox, tagsField basic-input fields + options, optionsExpr, optionsUrl, optionsPath, allowCustom, onOpen, onClose
DependentSelectorSpec countryRegionSelector basic-input fields + options, dependsOn
SliderSpec sliderField, ratingField, numericStepper label, enabled, readOnly, required, helperText, tooltip, min, max, step, onChange
DateRangeSpec dateRangeField label, enabled, readOnly, required, bindFrom, bindTo, fromLabel, toLabel, helperText, tooltip, minDate, maxDate, onChange
FileUploadSpec fileUploadField label, enabled, readOnly, required, helperText, tooltip, accept, multiple, minFiles, maxFiles, minSize, maxSize, allowedMediaTypes, onChange
LabelSpec label label, text
StaticTextSpec staticText text, format
BadgeSpec badge, alert, callout label, text, variant
SeparatorLineSpec separatorLine, spacer size
ImageSpec image label, src, alt, width, height, fit
LinkSpec link label, href, text, target, icon
ProgressBarSpec progressBar, gauge label, min, max, showValue, format, helperText, tooltip
DataTableSpec dataTable label, tableColumns, pageSize, tooltip
DataChartSpec dataChart, sparkline label, chartType, chartX, chartSeries
KeyValueListSpec keyValueList, summaryList label, items, columns, tooltip
StatTileSpec statTile, metric label, value, delta, caption, trend, format, currency, variant, icon, tooltip
JsonViewerSpec jsonViewer label, collapsed, maxDepth, tooltip
TracePanelSpec explainPanel, auditTimeline label, limit, showConstraints, collapsed, tooltip
ValidationSummarySpec validationSummary label, pathPrefix, variant, maxItems, emptyText
EffectStatusSpec effectStatus label, effectId, errorPath, showRetry, retryLabel, tooltip, onRetry
ContainerSpec group, fieldSet, card, toolbar, buttonGroup, tabs, tabItem, accordion, collapsible, sectionItem label, layout, columns, legend, collapsed, components
SectionListSpec sectionList label, itemView, canAdd, canRemove, addLabel, removeLabel, layout, columns, components, onChange
ButtonSpec button label, enabled, variant, icon, onClick
MenuSpec menu, stepper, breadcrumb orientation, menuItems
UnknownComponentSpec anything else (no fixed shape — see below)

Why so many type names on so few records. Grouping by field shape is what makes most of the catalog cheap: a ratingField is a slider’s min/max/step, an alert is a badge’s label/text/variant, a card is a group with a heading. Only nine of the types added beyond the original catalog needed a record of their own. It also means the alternative spellings cost nothing at evaluation time and cannot diverge in behaviour — switching a group to a card provably cannot change what a view computes, because the evaluator runs the same arm.

The full vocabulary is enumerated three timesViewComponentTypes (valem-core, write-time validation), @JsonSubTypes (valem-view, record binding), and KNOWN_COMPONENT_TYPES (valem-view-react, dispatch). Each drifts silently on its own, so ViewComponentTypesCoverageTest in valem-view reads all three (including the TypeScript source) and fails until they agree.

bind is on every record, not only the value-carrying ones, because it is the anchor for the meta-driven visible/readOnly/required inheritance described below — not just a value locator.

Unknown types. A type none of the built-in records claim binds to UnknownComponentSpec, which keeps the component’s raw JSON verbatim — a custom type may carry any property at all, including ones no built-in type declares, and nothing is dropped at parse time. The common fields the pipeline reads are projected out of that node; everything else is reachable via property(String), and the record serializes back to exactly the JSON it was parsed from. ViewEvaluator renders these as a basic input, which is what an unrecognised type has always resolved to.

className was declared on the old flat record but read by nothing — no evaluator, no renderer. It is not part of any record, nor of the TypeScript mirror. Since specs are stored and served as raw JSON, a className in an existing spec still reaches the client untouched; it simply is not modelled anywhere.

Supporting records:

record OptionSpec(String value, String label)
record ColumnSpec(String field, String header, String format, String width)
record ChartSeriesSpec(String field, String label, String color)
record MenuItemSpec(String label, String targetView, String icon)
record EventHandler(String mutations, String navigate)
  // mutations: JSONata → {"$.path": value, ...}
  // navigate:  view id to activate

Component Type Catalog

Input fields (bind, label, visible, enabled, readOnly, required, placeholder, helperText, onChange on all):

type Extra fields Notes
textField single-line text
textAreaField rows multiline
numericField number input; min/max from meta
passwordField masked
emailField email validation
phoneNumberField built-in country code picker (restcountries.com IDD)
checkboxField boolean
toggleField boolean switch
selectField options, optionsExpr, optionsUrl, optionsPath dropdown
radioField options, optionsExpr radio group
multiSelectField options, optionsExpr, optionsUrl, optionsPath  
dateField date picker
dateTimeField date + time picker
timeField time-only picker (HH:mm)
sliderField min, max, step range slider; dragging only updates the local draft — the mutation is sent when the thumb is released (pointer-up / key-up), or on blur/debounce as a backstop
fileUploadField accept, multiple, minFiles/maxFiles, minSize/maxSize, allowedMediaTypes POSTs multipart to /blobs; stores BlobRef {$blobId,$mediaType,$bytes} in bound field. minFiles/maxFiles/minSize/maxSize/allowedMediaTypes fall back to a minItems/maxItems/minSize/maxSize/allowedMediaTypes metaDerivation on the bind path when unset on the component (ViewEvaluator meta-cache lookup, same pattern as readOnly/required)
countrySelector fetches from restcountries.com automatically
countryRegionSelector dependsOn dependsOn = bind path of countrySelector
currencyField / percentField format, currency numeric field plus a display convention; the stored value stays a plain number. percent appends % and does not rescale
richTextField rows, toolbar stores markdown, not HTML — the value stays readable to derivations and constraints
autocompleteField / comboBox options, optionsExpr, optionsUrl, optionsPath, allowCustom filtering select; allowCustom defaults true for comboBox
tagsField options, allowCustom array of scalars as chips; writes the whole array in one mutation
dateRangeField bindFrom, bindTo, fromLabel, toLabel, minDate, maxDate two paths, not one object bind — each end mutates independently so the audit shows which changed
ratingField / numericStepper min, max, step slider affordances; commit on click rather than deferring

Data output (bind, label, visible on all):

type Extra fields Notes
label text dynamic text or bound value
staticText text, format format: markdown (default, escaped) / text / html (unescaped, opt-in)
badge text, variant status indicator. variant is a plain string, not JSONata-capable server-side — ViewEvaluator passes it through unevaluated. The bundled React renderer re-evaluates a JSONata variant expression client-side, so it only resolves correctly through the built-in UI, not through the raw GET /models/{id}/view (or MCP/console) response.
separatorLine horizontal rule
dataTable tableColumns, pageSize tabular view of array
dataChart chartType, chartX, chartSeries recharts chart
sparkline chartType, chartSeries axis-less inline trend; first series only
progressBar min, max, showValue, format numeric value as filled bar; format: percent (default) or value (75 / 100)
gauge min, max, showValue, format the same value as a 180° arc
alert / callout text, variant block-level badge; danger/warning render as role="alert"
spacer size vertical gap without a rule
image src, alt, width, height, fit resolves a bound BlobRef to /blobs/{id}, so an upload and its preview share one path
link href, text, target, icon external anchor; _blank gets rel="noopener noreferrer" automatically
keyValueList / summaryList items, columns caption/value summary; rows resolved server-side. bind on a row wins over text; format/currency are per row
statTile / metric value, delta, caption, trend, format, currency, variant, icon trend is authored, not inferred from the delta’s sign — whether “up” is good is a domain question
jsonViewer collapsed, maxDepth bound subtree as JSON; bind to $ for the whole merged document
explainPanel / auditTimeline limit, showConstraints, collapsed declaration only — the renderer fetches /explain/{path} or /audit itself (see below)
validationSummary pathPrefix, variant, maxItems, emptyText the flag-policy violations that have no field to sit beside
effectStatus effectId, errorPath, showRetry, retryLabel, onRetry an effect’s statusPath machine; fully resolved server-side since the status is ordinary model state

Aggregates:

type Key fields Notes
group layout, columns, components layout container
fieldSet legend, components HTML <fieldset>
card label, layout, components group on a titled surface
toolbar / buttonGroup components row of actions; ignore layout
tabs / tabItem label, components one panel per child, captioned by the child’s label
accordion / collapsible label, collapsed, components collapsed is the initial state, not a live binding
sectionList bind, itemView, canAdd, canRemove, labels array add/remove
sectionItem bind, components single element editor (sub-view); evaluates to an EvaluatedContainer carrying bind

Actions:

type Key fields Notes
button variant, icon, onClick, enabled  
menu menuItems, orientation view navigation
stepper / breadcrumb menuItems, orientation the same items as a progression or a trail; position is the active view id, so it survives a reload

Tabs and wizards hold no model state. Which tab is open is local UI state and deliberately not in the document — it is not something a derivation, a constraint or an audit record should have an opinion about. When the position must survive a reload, use separate views and navigate between them: the active view id is then the step, and a stepper renders it.

What the server does not evaluate. explainPanel, auditTimeline and validationSummary evaluate to a declaration — which path, how many rows — not to data. ViewEvaluator receives only mergedDocument/metaCache/exprCache/constants; it has no access to the trace ring buffer, the audit store or the runtime’s flagged-constraint set, and wiring one in would put an unbounded read inside every view evaluation and every viewDelta. The renderer fetches those itself, the same division of labour as optionsUrl. effectStatus and jsonViewer are the exceptions: their data is ordinary model state, so both resolve fully server-side.

view/engine package

EvaluatedComponent — a sealed interface, not a flat record. Each component type serializes as one of 26 concrete records, each carrying only the fields relevant to that type (@JsonInclude(NON_NULL), with a BooleanTrueFilter suppressing default true/false booleans): EvaluatedBasicInput, EvaluatedTextArea, EvaluatedSelectField, EvaluatedDependentSelector, EvaluatedSlider, EvaluatedDateRange, EvaluatedFileUpload, EvaluatedLabel, EvaluatedStaticText, EvaluatedBadge, EvaluatedImage, EvaluatedLink, EvaluatedProgressBar, EvaluatedDataTable, EvaluatedDataChart, EvaluatedKeyValueList, EvaluatedStatTile, EvaluatedJsonViewer, EvaluatedTracePanel, EvaluatedValidationSummary, EvaluatedEffectStatus, EvaluatedContainer, EvaluatedSectionList, EvaluatedButton, EvaluatedMenu, EvaluatedSeparatorLine. For example EvaluatedBadge carries only id, type, visible, variant, text, label — no bind/value/enabled. This mirrors the ComponentSpec hierarchy on the input side one-for-one, minus UnknownComponentSpec (an unrecognised type evaluates to an EvaluatedBasicInput).

EvaluatedKeyValueList additionally nests EvaluatedKeyValueItem, the only supporting record on the output side that is itself resolved (each row’s value is read out of the merged document).

EvaluatedContainer covers all ten container types; its bind is populated only by sectionItem, and is omitted from the JSON otherwise. It carries label because for a card, a tabItem and a collapsible the label is the visible heading — the tab’s title, the panel’s summary row — rather than decoration a renderer may drop.

The interface exposes ~25 default methods (returning null/false for fields a given subtype doesn’t carry) so callers can treat any EvaluatedComponent uniformly:

sealed interface EvaluatedComponent permits EvaluatedBasicInput, EvaluatedTextArea, /* … */ {
    String id(); String type();
    default String label()          { return null; }
    default boolean visible()       { return true; }
    default boolean enabled()       { return true; }
    default boolean readOnly()      { return false; }
    default boolean required()      { return false; }
    default String bind()           { return null; }
    default JsonNode value()        { return null; }
    // ...+ options/text/components/tableColumns/chartX/menuItems/variant/min/max/step/
    //     accept/multiple/minFiles/maxFiles/minSize/maxSize/allowedMediaTypes/showValue/
    //     format/onClick/onChange/onOpen/onClose, each with a type-appropriate default
}

EvaluatedView:

record EvaluatedView(
    String modelId, String viewId, String title, String layout,
    List<EvaluatedComponent> components
)

ViewEvaluator — a stateless utility class (private constructor, static methods only):

public final class ViewEvaluator {
    public static EvaluatedView evaluate(
        String modelId,
        ViewSpec view,
        ObjectNode mergedDocument,
        Map<String, JsonNode> metaCache,
        ExpressionCache exprCache
    )

    // Overload: also binds the model's named constants as $const in every view expression.
    public static EvaluatedView evaluate(
        String modelId, ViewSpec view, ObjectNode mergedDocument,
        Map<String, JsonNode> metaCache, ExpressionCache exprCache,
        ObjectNode constants   // nullable — null means no $const binding
    )
}

After resolving the common dynamics, the evaluator dispatches with a switch pattern-matching the sealed ComponentSpec, not on the type string. The switch is exhaustive over the permits list, so a new component record does not compile until it is handled — adding a type can no longer fall through to the generic input branch unnoticed.

Per-component evaluation steps:

  1. Resolve visible → null: check metaCache["$.bind#relevant"] (absent → true); bool, else any textual string is evaluated as JSONata (no $ required — see step 5’s contrast)
  2. Resolve readOnly → null: check metaCache["$.bind#read_only"] (absent → false); bool/JSONata
  3. Resolve required → null: check metaCache["$.bind#required"] (absent → false); bool/JSONata. There is no JSON-Schema-required-array fallbackViewEvaluator never receives the schema (only mergedDocument/metaCache/exprCache/constants); a spec that relies solely on the schema’s required array (no explicit #required metaDerivation) renders required=false in the view.
  4. Resolve enabled → null: !effectiveReadOnly; bool/JSONata
  5. Resolve text/value/delta/caption/trend (resolveText/resolveNode) → the string is evaluated as JSONata only if it contains a $; otherwise it is kept as a literal. This is the deliberate asymmetry with the boolean dynamics in steps 1–4, which evaluate any string: the $-gate keeps display literals like "Underweight" or "25 - 29.9" from being parsed as expressions. A bare field reference ("myField") therefore renders verbatim server-side — reference a field through a $ function ($string(myField)) or via bind. (The built-in React renderer has no such gate; see useJSONataLiteral.) Documented for authors in Field value kinds.
  6. Look up bind path in mergedDocumentvalue
  7. Resolve options — static list passthrough only. optionsExpr/optionsUrl/optionsPath are never read by the server — they are declared on ComponentSpec but dead from the engine’s perspective; resolution happens entirely client-side (see the Explainability boundary note below).
  8. Recurse into components for aggregates

Default Visibility / ReadOnly Inheritance

ComponentSpec field Null (default) source
visible metaCache["$.bind#relevant"] → false = hidden; absent = visible
readOnly metaCache["$.bind#read_only"] → true = read-only; absent = editable
required metaCache["$.bind#required"] → absent = false. Not derived from the JSON Schema required array — you must add an explicit #required metaDerivation.
enabled !effectiveReadOnly

The survey example’s issueCategory (driven by relevant and readOnly metaDerivations) becomes hidden/disabled with zero extra ViewDefinition config.

Wiring. ModelSpec.viewDefinition and SpecEvolution.newViewDefinition are raw nullable JsonNodes in valem-core (same pattern as schema), parsed by valem-view. ModelService exposes getEvaluatedView(id, viewId); valem-api serves it via ViewController (GET /models/{id}/view[/{viewId}], 404 through ModelNotFoundException) and valem-console via the get-view command. There is no role/access parameter anywhere in the view evaluation path, consistent with there being no per-field authorization in Valem (see security-model.md).


npm Library: valem-view-react

Location: valem-view-react/ (repo root sibling to valem-ui)

package.json peer dependencies: react ^18, react-dom ^18

Runtime dependencies: jsonata, recharts

Build tooling: Vite in lib mode, TypeScript

Public exports (index.ts):

  • ViewRenderer — main entry component
  • ViewContext, ViewContextProvider
  • All component types (re-exported for custom composition)
  • TypeScript types: ViewDefinition, ViewSpec, ComponentSpec, EvaluatedView, EvaluatedComponent, OptionSpec, EventHandler, etc.
  • The ComponentSpec variants (BasicInputSpec, SliderSpec, ContainerSpec, …), KnownComponentSpec, UnknownComponentSpec, plus the isKnownComponent / hasChildComponents narrowing guards and the KNOWN_COMPONENT_TYPES list
  • Hooks: useJSONata, useCountries, useRegions

ComponentSpec is a discriminated union here too, mirroring the Java sealed hierarchy variant-for-variant: same grouping, same field sets, same UnknownComponentSpec fallback (typed with an index signature, so a custom type may carry any property). Consequences in the renderer:

  • BaseComponentProps<C> is generic, so each implementation receives only its own variant — SliderField takes a SliderSpec. Reading c.pageSize inside Badge is a compile error.
  • ComponentRenderer narrows with isKnownComponent(c) before dispatching, so the unknown-type branch is explicit rather than a default: fallthrough, and the switch over KnownComponentSpec is exhaustiveness-checked by its ReactElement return type — a missing case fails tsc with “function lacks ending return statement”.

The one place the mirror is deliberately looser: the TS base carries enabled/readOnly/ required for every variant (matching the Java interface’s default methods), whereas the Java records only bind them where the type is interactive.

ViewRenderer props:

interface ViewRendererProps {
  modelId: string;
  viewDef: ViewDefinition;           // raw spec — client evaluates dynamics
  state: Record<string, unknown>;    // merged model state
  meta: Record<string, unknown>;     // meta cache
  onMutate: (mutations: Record<string, unknown>) => Promise<void>;
  onNavigate?: (viewId: string) => void;
  activeViewId?: string;
}

Rendering strategy (hybrid, not purely client-side):

  • ViewRenderer/ComponentRenderer take the raw ViewDefinition/ComponentSpec and evaluate visible/enabled/readOnly/required/text JSONata expressions client-side via jsonata npm, against the fetched state + meta maps — this is the primary render path.
  • The server-evaluated EvaluatedView (GET /models/{id}/view[/{viewId}]) is also fetched by ViewPanel on mount, purely to seed/refresh the meta map (readOnly/visible/required) that the client evaluator reads — the client re-derives the actual displayed values from the raw spec rather than consuming the server’s already-resolved EvaluatedComponents directly.
  • After every mutation (POST /models/{id}/mutations[/patch] with an X-View header), the server returns a viewDelta — a Map<String, EvaluatedComponent> keyed by component id, containing only the components whose bind path was mutated or re-derived (mutatedPaths ∪ derivedUpdated). ViewPanel applies this delta to patch local view state optimistically instead of doing a full view re-fetch. See api-reference.md for the MutationResponse.viewDelta shape.
  • On WebSocket ChangeEvent: ViewPanel re-fetches state, passes to ViewRenderer
  • onClick / onChange: evaluate mutations JSONata → call onMutate() → parent POSTs mutation

Explainability boundary (client-side evaluation). onClick/onChange mutations JSONata are evaluated in the browser, not on the server, and so do not appear in the server-side derivation/constraint trace (GET /models/{id}/explain/{path}) until the resulting mutation is POSTed (which then runs the full audited pipeline). optionsExpr/ optionsUrl/optionsPath are likewise resolved only client-side todayViewEvaluator does not evaluate any of them (see evaluation step 7 above), so optionsExpr currently provides no more server-side auditability than optionsUrl, and neither is subject to the server’s SSRF controls. Where auditability matters, compute the option-relevant value via a derivations field and bind the component to that instead.

Public REST APIs (fetched client-side, cached in module-level maps):

Component URL
countrySelector https://restcountries.com/v3.1/all?fields=name,cca2
countryRegionSelector https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/master/states.json
phoneNumberField https://restcountries.com/v3.1/all?fields=name,cca2,idd

Source tree:

src/
  index.ts
  types.ts
  format.ts                  ← formatValue / currencySymbol, for `format` + `currency`
  ViewRenderer.tsx
  ViewContext.tsx
  ComponentRenderer.tsx
  hooks/
    useJSONata.ts, useCountries.ts, useRegions.ts,
    useDeferredMutate.ts, useResolvedOptions.ts
  fields/
    TextField.tsx, TextAreaField.tsx, RichTextField.tsx, NumericField.tsx,
    PasswordField.tsx, EmailField.tsx, CheckboxField.tsx, ToggleField.tsx,
    SelectField.tsx, RadioField.tsx, MultiSelectField.tsx,
    AutocompleteField.tsx, TagsField.tsx,
    DateField.tsx, DateTimeField.tsx, TimeField.tsx, DateRangeField.tsx,
    SliderField.tsx, RatingField.tsx, NumericStepper.tsx, FileUploadField.tsx,
    CountrySelector.tsx, CountryRegionSelector.tsx, PhoneNumberField.tsx
  output/
    LabelComponent.tsx, StaticText.tsx, Badge.tsx, Alert.tsx, SeparatorLine.tsx,
    ImageComponent.tsx, LinkComponent.tsx,
    DataTable.tsx, DataChart.tsx, Sparkline.tsx, ProgressBar.tsx, Gauge.tsx,
    KeyValueList.tsx, StatTile.tsx, JsonViewer.tsx,
    TracePanel.tsx, ValidationSummary.tsx, EffectStatus.tsx
  aggregates/
    LayoutContainer.tsx        ← the five layouts, shared with ViewRenderer
    GroupComponent.tsx, FieldSetComponent.tsx, CardComponent.tsx,
    ToolbarComponent.tsx, TabsComponent.tsx, CollapsibleComponent.tsx,
    SectionList.tsx, SectionItem.tsx
  actions/
    ButtonComponent.tsx, MenuComponent.tsx, StepperComponent.tsx

Layouts. ViewSpec.layout has always documented vertical | horizontal | grid | tabs | wizard, but only the first three were ever rendered — a view asking for tabs silently got a vertical stack. LayoutContainer implements all five and is shared by ViewRenderer and every container component, so a view-level layout: "wizard" and a group with the same layout behave identically.

Two guards keep the renderer honest. renderKnown’s switch is exhaustiveness-checked by its ReactElement return type (a missing case fails tsc with “function lacks ending return statement”), and a const _typesInSync: AssertEqual<…> = true in types.ts fails to compile when KNOWN_COMPONENT_TYPES and the KnownComponentSpec union disagree — the array drives isKnownComponent’s narrowing while the union drives dispatch, so a type present in one and not the other passes the guard and then reaches a switch with no case for it.

useJSONataLiteral. The client evaluates text fields that the server leaves alone: ViewEvaluator only evaluates a string containing $, whereas jsonata("up") parses fine and resolves to nothing. Fields that are usually a literal and occasionally an expression — variant, trend, icon, target — go through useJSONataLiteral, which falls back to the literal, so the same spec renders identically through the raw-spec path and through GET /models/{id}/view.


Built-in UI integration

In the built-in UI, valem-ui consumes valem-view-react as a workspace package: it fetches a model’s spec, renders a “View” tab (shown only when spec.viewDefinition is set) via <ViewRenderer>, and re-fetches state on each WebSocket ChangeEvent. The bundled example specs (customer-satisfaction-survey.json, order-items-price-total.json, and others) ship a viewDefinition — see the examples gallery.