Skip to content

Multi-Renderer (Browser / CLI / WinUI3)

Consystence renders its operator and engineering surfaces on the server. Clients are thin: the server holds state in Orleans grains, builds a renderer-agnostic description of each surface, and pushes it to whichever client is attached. That description is the semantic UI tree, carried by a JSON wire format with two shipped consumers — the csy command-line client (CLI) and the browser’s wire renderer — and a native Windows client (WinUI3) specified against the same wire format as roadmap. The wire renderer is the only browser surface: the browser and the CLI render the same tree. The browser’s original server-rendered HTML transport has been retired.

What stays invariant is the wire-format protocol shape, not any one client. Renderers negotiate a protocol version with the server when they connect, and a new renderer consumes the same protocol rather than growing a private one. This page describes the tree, the wire format, which renderers consume it, and how the browser routes into it. For where this sits in the wider system, see the architecture overview.

flowchart TB
  SG["Scene graph<br/>authoritative server state"]
  UT["Semantic UI tree<br/>renderer-agnostic primitives"]
  WF["Wire format<br/>L1 render · L2 patch · L3 action · viewInvalidated"]
  SG --> UT
  UT --> WF
  WF --> B["Browser wire renderer<br/>tree → DOM"]
  WF --> C["CLI renderer<br/>text → terminal<br/>csy ui"]
  WF --> W["WinUI3 renderer<br/>XAML/Win2D → Visual tree<br/>(roadmap)"]

The scene graph — sites, devices, tags, states, alarms, and commands — remains the authoritative server-side domain model. Between it and any renderer sits the semantic UI tree: a tree of primitives defined by what they express, not how they paint. Each wire-format renderer consumes the same tree shape and produces target-specific output — terminal text in the CLI, DOM elements in the browser wire renderer, a WinUI3 Visual tree on the roadmap.

The primitive set is closed:

PrimitiveExpresses
ViewThe top-level container for a rendered surface
CardGrouped content with an optional title and body
StackVertical or horizontal child layout, spaced by a gap token
StateBadgeA compound state display — { state, color }
GaugeA scalar numeric value with optional unit and range
ButtonAn action trigger carrying an enabled flag and an optional confirmToken — a type-to-confirm gate the renderer enforces before invoking a destructive action
TableTabular rows of typed cells, with row-scoped actions
SceneRefA reference to a process scene (SVG) — identity, viewport, and lifecycle, never pixels; the scene’s graphics ride a separate view-bound channel
TrendChartA historian trend — the initial render carries a bounded backfilled series plus axis/range metadata; the live tail arrives as patches
ChatAn assistant chat surface — the initial render carries the bounded transcript, streamed assistant and agent progress arrive as patches, and sending a message is a declared action
TextPlain or limited-Markdown text in a closed set of styles
LinkA navigation link to another in-app route
PropertiesA labelled key/value list, laid out in one to three columns
TabsTabbed panes, each holding its own content or opening a separately gated route
StatOne headline figure, with its basis (window, coverage, provenance) for a windowed KPI
BannerA view-level condition banner — info, mode, warning, alarm, or fault
TreeA bounded hierarchy that hands off to a paged table beyond its size limit
CodeA paged, read-only code listing with server-computed highlighting
DiffA server-computed unified diff
FormA form whose fields and validation come from the action’s declared schema
SaveFileA save-file or CSV-export control that returns a single-use file handle

Form’s shape is now locked; List remains a candidate, and its final shape locks at its first real surface. Adding a primitive is a protocol-version event. The protocol is at v1.8. When a renderer connects it offers the versions it speaks, and the server picks the highest one they share; a renderer that does not negotiate is served v1.5. A renderer on an earlier supported version (back to v1.5) is served a downlevelled view: each newer node becomes its nearest older equivalent, and a form or file save the renderer cannot offer becomes a visible notice, never a different interaction. An unknown node renders as a visible placeholder rather than being silently dropped. Earlier revisions added quality/staleness descriptors on value nodes (v1.1), SceneRef and TrendChart (v1.2), the Chat surface carrying assistant chat and streamed agent progress (v1.3), type-to-confirm on Button (v1.4), and library symbols on Card (v1.5). v1.6 added the display and engineering-document nodes (Text through Diff above), v1.7 added Form and SaveFile; v1.8 adds no node, and carries a new action class for timed bypasses of a protection, fixed form fields and live confirmation details, which an older renderer shows as a visible notice rather than a control.

Layout uses tokens, never raw pixels. gap is drawn from a closed enum (xs, sm, md, lg, xl) and direction from { vertical, horizontal }. Each renderer maps the token at render time — the CLI renderer to terminal whitespace, the browser wire renderer to its component stylesheet, and a WinUI3 consumer to XAML spacing. Keeping pixels out of the wire format is what keeps the tree renderer-agnostic.

Bindings are property paths only — for example dev_BVSWSZ1Q.tag.Speed, dev_BVSWSZ1Q.state, or dev_BVSWSZ1Q.alarm.lowFlow.active. There is no arithmetic, comparison, unit conversion, or computed expression in the binding language yet; alarm-condition expressions are a deferred extension. Entity references in the wire format are canonical identifiers (for example dev_BVSWSZ1Q), not the operator-facing AssetNumber (for example PP-01) — the human-readable asset number is a presentation-layer concern applied at render time.

One wire format: three layers and an invalidation event

Section titled “One wire format: three layers and an invalidation event”

A single JSON protocol carries the tree to every wire-format renderer — the CLI and the browser wire renderer today, WinUI3 when it lands. It has three layers plus a distinct invalidation event.

L1 — initial render. On subscribing to a surface, a renderer receives the full semantic tree for a View. The View carries a template (the surface or bundle identifier) and an entity (the canonical ID it is bound to); its children are nested primitives. Two stable handles thread through the tree: a pointId anchors later L2 patches, and an actionId anchors L3 invocations. Both remain stable across the L1 → L2 lifetime of a View.

{
"type": "View",
"id": "v_pump001_faceplate",
"template": "pump-faceplate",
"entity": "dev_BVSWSZ1Q",
"children": [ { "type": "Card", "children": [] } ]
}

Containers always carry "children": [] and actions always carry "data": {} explicitly — omission is a malformed document, which keeps the non-null contract honest on the consuming side.

L2 — patch. Fine-grained updates to an existing View, addressed by stable pointId. The op set is closed at three operations:

OpEffect
replaceUpdate the value at a pointId
addInsert into a collection at a pointId (table rows, list items, alarm banners, trend samples)
removeDelete from a collection at a pointId

L3 — action. Client-initiated invocations addressed by actionId. The data field is a closed schema per action, declared in the view’s server-side action registration and validated server-side — missing and extra keys both reject. Shipped actions include admin-surface operations such as revoke on the API-keys view (data: { "keyId": "…" }) and device commands such as start and stop, with schemas derived from the device type; commands that take parameters are not yet offered. Device commands over the wire pass through the same fail-closed authorisation as every other command path through the platform.

viewInvalidated — structural re-render. When a surface’s structure (not just its values) changes, the server emits a distinct viewInvalidated event — not a flag piggybacked on an L3 response. The renderer responds by re-requesting L1 for the affected viewId. Four reasons are defined:

ReasonTrigger
structural-change-from-actionAn action result implies a structural re-render (for example a commissioning gate adds or removes sections)
entity-shape-changeThe bound entity changed type or substantive shape
template-version-changeThe surface’s template bundle was bumped
override-appliedA template override was applied and the view must re-materialise

Only structural-change-from-action has a live producer today; the other three reasons are defined in the protocol ahead of the template-bundle and override system that will emit them.

pointId is scoped per viewId, so the same point name in two different Views never collides — addressing is unambiguous by construction.

A renderer decides how to interpret a value by the element type at a pointId, never by parsing the point’s name. A StateBadge value is the compound { state, color }; a Gauge value is a scalar wrapped in the quality envelope { "value": 50, "quality": … } (introduced in v1.1); a Button’s enabled is a scalar boolean. The optional quality descriptor names one of the five platform quality states (good, uncertain, cached, stale, bad) with an asOf timestamp where the state implies one; when omitted, the value is good. A stale or bad value renders as such in every renderer — never as live. Each renderer dispatches on the primitive type; the pointId is purely an addressing handle.

The renderers share the scene graph at the top and the wire-format shape as a contract; they do not share a runtime substrate. Each is its own client drawing to its own target; the browser and the CLI connect over the same /hubs/ui-wire hub.

The browser client consumes the same L1 / L2 / L3 / viewInvalidated protocol as the CLI, over SignalR on /hubs/ui-wire, and renders it with a bespoke component stylesheet keyed to the closed primitive vocabulary. It uses no CSS utility framework and no SPA framework, and runs under a strict Content-Security-Policy. The browser and the csy CLI render the same server-resolved tree.

/ and every extensionless navigation route serve the wire shell. On an activated server the start view is home, which the server resolves per session to the right landing page for that person’s access and the deployment. Old /wire/{route} URLs permanently redirect (301) to /{route}, so existing bookmarks keep working.

The CLI renderer is a full peer, not a test harness bolted on. csy ui consumes the L1 / L2 / viewInvalidated wire format directly and renders it to the terminal: View, Card, and Stack become text blocks, StateBadge a status indicator, Gauge and Button labelled scalars, and Table typed rows with row-scoped actions. It also renders the v1.6 display and engineering-document nodes and the v1.7 Form and SaveFile nodes, and prints an unknown node as a visible “unrenderable node type” placeholder.

It runs in two modes:

  • REPL — an interactive session (goto, inspect, action, wait) that applies L2 patches to the current view over a persistent connection.
  • Script — a declarative YAML scenario that runs single-shot and deterministic, emitting structured output plus an exit code, which makes it the integration-test surface in place of browser click-throughs.

A working CLI consuming the wire format against a radically different output target is what demonstrates — rather than merely asserts — that the semantic tree is genuinely renderer-agnostic. CLI authentication rides the platform’s existing schemes (OIDC device-code / PKCE for interactive login, X-Api-Key / CONSYSTENCE_TOKEN for automation); see the CLI client and authentication.

The WinUI3 renderer consumes the wire format and produces a native Visual tree (XAML with Win2D / Composition visuals), targeting Windows control rooms and engineer workstations where native fidelity and system integration (multi-monitor layout, notifications, file dialogs) matter. It is Windows-only by design; macOS, Linux, and mobile users use the browser client.

Each connected client drives one server-side session object, keyed by session ID. It owns the session’s semantic view tree and is renderer-agnostic: one session is one client connection is one renderer, and the renderer is recorded as a session attribute, not as a separate session type per renderer. A CLI client and a browser client therefore attach to the same kind of session — only the attached renderer differs.

Routing events to the right View within a session is handled per-session: the session routes patches and invalidations to the right view. That routing state is per-session, kept off the global routing path. Live values reach each open view as L2 patches on that session’s own connection; the per-session coalescing stage in the scaling pipeline design is not yet in use.