Creating a canvas
A custom React input UI for a workflow. Replaces the standard Inputs panel.
A canvas is a React component that collects a workflow's inputs. It replaces the standard auto-generated input form in the Workflow Editor's Inputs tab. Use one when the standard form is too clumsy: many inputs, inputs that depend on each other, or inputs that need a real widget (a plate map, an anchor picker) instead of a text box.
The canvas is compiled in your browser and rendered inside a locked-down sandboxed iframe. It never talks to the network. Everything it can do goes through a zeon global that the host injects, and every message crosses a postMessage bridge.
A workflow has at most one canvas, referenced by the workflow JSON's canvas_ui block.
There is no separate "execution screen". A canvas mounts in the Workflow Editor, in the same right-hand panel that would otherwise show the standard inputs form, and the run is started from the editor's Run button.
When you'd want a canvas
- The workflow has more inputs than a flat form can present sensibly.
- Inputs depend on each other: "which plate? OK, which well in that plate?".
- You want visual feedback: a well grid, a live tip count, a computed reagent total.
- A specific input needs a custom widget tied to a real-world object or anchor.
If your workflow has a handful of independent string/number inputs, the standard form is fine. Skip the canvas.
Creating a canvas
The workflow must exist first. From inside your project:
zeon new canvas my_workflowThat does two things, and validates both before writing either, so a failure leaves nothing half-applied on disk:
- Writes
canvas/my_workflow_screen.tsx. - Patches
workflows/my_workflow.jsonwith acanvas_uiblock:
{
"kind": "react",
"source_ref": "canvas/my_workflow_screen.tsx",
"enabled": true,
"version": 1,
"updated_at": "2026-07-19T10:15:32.004Z"
}version is a cache-bust counter: the editor refetches and recompiles the source whenever it changes.
You can also do this in the Web IDE: New file → Canvas. For that kind the name field becomes a workflow dropdown, whose placeholder invites you to pick a workflow, listing only workflows without a canvas_ui block. The IDE re-checks eligibility at create time, so a stale dialog can't clobber a canvas added from another window.
A workflow can only have one canvas.
zeon new canvasaborts withworkflow 'my_workflow' already has a canvas_ui blockand writes nothing. It also aborts ifcanvas/my_workflow_screen.tsxalready exists. In that case the workflow JSON is left intact. To start over, edit the existing file, or remove thecanvas_uiblock from the workflow JSON first.
What zeon new canvas actually generates
zeon new canvas actually generatesThis is the file verbatim, for a workflow called my_workflow. The component name is the workflow id in PascalCase plus Screen.
import React, { useState } from "react";
declare const zeon: {
schema: { name: string; type: string; description?: string; defaultValue?: unknown }[];
worldObjects: { uuid: string; name: string; displayName?: string }[];
defaults: Record<string, unknown>;
submit: (values: Record<string, unknown>) => void;
onValidationErrors: (cb: (errs: { path: string; message: string }[]) => void) => void;
};
export default function MyWorkflowScreen() {
const [values, setValues] = useState<Record<string, unknown>>(() => ({ ...zeon.defaults }));
const [errors, setErrors] = useState<{ path: string; message: string }[]>([]);
zeon.onValidationErrors(setErrors);
// TODO: build the input UI for `my_workflow` here.
// `zeon.schema` lists the workflow's declared inputs; collect values into
// the `values` state and call `zeon.submit(values)` to run the workflow.
return (
<div style={{ fontFamily: "monospace", padding: 16 }}>
<div style={{ marginBottom: 12, fontWeight: "bold" }}>my_workflow</div>
{errors.length > 0 && (
<div style={{ color: "red", fontSize: 12, marginBottom: 8 }}>
{errors.map((e, i) => (
<div key={i}>⚠ {e.path}: {e.message}</div>
))}
</div>
)}
<button onClick={() => zeon.submit(values)}>Submit</button>
</div>
);
}Two things about this starter are worth knowing before you build on it:
- Its
declare const zeonblock lists five members. The real global has eleven. Extend the declaration yourself. See the table below. - The TODO comment says
zeon.submit(values)runs the workflow. It doesn't. Submit stages inputs; the operator still has to press Run. That distinction is the next section, and it is the one that bites.
How a canvas arms the Run button
zeon.submit(values) does not start a run. It posts your values to the host, which validates them against the workflow's input schema. On success the host merges them into the editor's staged input values and marks the canvas as having produced a valid submit. Only then is the editor's Run button enabled, and only when the operator presses it does a run actually start.
If you skip this, Run stays disabled. Press it anyway and you get the toast:
Confirm your setup in the canvas before running.
Two calls can flip that gate:
| Call | Effect on Run |
|---|---|
zeon.submit(values) | Arms Run if the values pass schema validation; disarms it if they don't. |
zeon.setConfirmed(true | false) | Arms or disarms Run directly, without submitting. |
A canvas that only calls submit works, but the gate then stays armed even after the operator edits a field, so Run can launch with values that no longer match what's on screen. setConfirmed is how you fix that: mirror your own "are the on-screen inputs still the ones I submitted?" state into the host on every change.
The idiomatic pattern is a snapshot comparison:
// Snapshot the inputs at the moment the operator confirms.
const [snapshot, setSnapshot] = useState<string | null>(null);
const live = JSON.stringify(values);
// "Confirmed" means: nothing has changed since the submit, and the host
// didn't reject it.
const confirmed = snapshot !== null && snapshot === live && errors.length === 0;
// Mirror it to the host. Any edit (snapshot stops matching) or a fresh mount
// (snapshot is null) disarms Run until the operator confirms again.
useEffect(() => {
zeon.setConfirmed?.(confirmed);
}, [confirmed]);
return (
<button
onClick={() => {
zeon.submit(values);
setSnapshot(live);
}}
>
{confirmed ? "✓ Setup confirmed" : "Confirm setup"}
</button>
);Confirming only stages inputs in the editor's memory. They are not durable until the run starts, so keep any unsaved-work guard armed until then. See
zeon.setDirtybelow.
The zeon global
zeon globalThe sandbox bootstrap builds window.zeon before it evaluates your module. You don't import it. schema, worldObjects and defaults are deep-copied and frozen; tipBoxes is reassigned on each push.
| Member | Kind | What it does |
|---|---|---|
zeon.schema | data | The workflow's declared inputs. Frozen. |
zeon.worldObjects | data | Objects in the currently-loaded world. Frozen. |
zeon.defaults | data | Previously staged input values to seed your form with. Frozen. |
zeon.tipBoxes | data | Live tip-box counts. Starts []; replaced on each push from the host. |
zeon.submit(values) | → host | Stage values. Validated host-side; arms Run on success. |
zeon.setConfirmed(bool) | → host | Report whether the on-screen inputs are still confirmed. Gates Run. |
zeon.setDirty(bool) | → host | Report unsaved edits. Arms a beforeunload warning. |
zeon.resetInputs() | → host | Ask the host to wipe staged inputs, the saved draft, and remount you empty. |
zeon.resetTipBox(uuid) | → host | Ask the host to mark a tip box freshly replaced. |
zeon.onValidationErrors(cb) | ← host | Subscribe to validation failures from a rejected submit. |
zeon.onTipCounts(cb) | ← host | Subscribe to live tip-count updates. |
All five → host calls are fire-and-forget. Nothing returns a value; results come back through the two subscriptions, or through a remount.
zeon.schema
zeon.schemaEach entry is a normalized workflow input:
| Field | Type | Notes |
|---|---|---|
name | string | The key you must use in the object you pass to submit. |
type | "string" | "int" | "float" | "object" | "structured" | object means a world-object reference. |
isArray | boolean | Always present. |
description | string? | Optional, from the workflow's input editor. |
defaultValue | unknown? | Optional. Its absence is what makes an input required. |
itemSchema | object? | Field-name → { type, isArray?, itemSchema? }, recursive. |
The host normalizes the workflow JSON's snake_case (is_array, item_schema, default_value) to camelCase before injecting it, so read isArray, not is_array.
zeon.worldObjects
zeon.worldObjects| Field | Type | Notes |
|---|---|---|
uuid | string | Stable id. |
name | string | Operator-set name, e.g. parts_plate_a. |
displayName | string? | Shown name, when it differs from name. |
meshType | string? | e.g. wellplate_pcr, coldblock_small. This is what you filter on to offer "which plate?" pickers. |
pose | { xyz, wxyz }? | World pose. Position in metres; rotation as a scalar-first quaternion. |
For an object-typed input, submit either the object's name or its uuid. Validation accepts both, and the executor resolves names to UUIDs at run time.
The canvas world-object type also declares
anchors?: string[], but the Workflow Editor does not currently populate it: it builds each object fromuuid,name,displayName,meshTypeandposeonly. Treatanchorsas absent and provide your own fallback (existing canvases key a hard-coded hole count offmeshType).
zeon.defaults
zeon.defaultsNot the workflow's declared defaultValues. This is the map of previously staged input values, so a canvas can come back up showing what the operator already entered. It is populated from:
- the autosaved draft in
sessionStorage, restored after a refresh; or - the saved inputs of a run that is still live (running / paused / stepping), fetched when the Inputs tab opens.
Object references stored as UUIDs are mapped back to display names first, so your pickers show the right selection. On a fresh workflow with no draft and no live run, defaults is {}. In the in-editor source preview it is always {}.
defaults is part of the mount key: when it changes, the iframe remounts and your component state resets.
Validation
Every submit is checked against the workflow's inputs before it is accepted. Failures come back through onValidationErrors as { path, message }, where path is dotted: Destination, or Items.0.qty for a nested field. The host also renders a summary bar above your canvas.
The checks are:
| Situation | Result |
|---|---|
Input missing (undefined, null or "") and it has no defaultValue | Required input is missing |
string / int / float type mismatch | Expected a string / Expected an integer / Expected a number |
object value that is not a string, or is "" | Expected a world object reference |
object value matching no world object's name or uuid | Unknown world object '<value>' |
isArray input given a non-array | Expected an array |
Array element failing its itemSchema | Reported per index, recursively |
| A key you sent that the workflow doesn't declare | Not a declared workflow input |
That last one is deliberate: it catches drift after somebody renames a workflow input and forgets the canvas.
Live tip-box state
If your protocol consumes pipette tips, the host pushes live counts into the canvas over their own channel, separate from init, so an update doesn't remount you and wipe your form state.
type TipBox = {
uuid: string;
name: string;
type: string;
tip_index: number; // 1-based pointer to the next tip
capacity: number; // total tips in the box
remaining: number; // capacity - tip_index + 1, clamped to [0, capacity]
active: boolean; // is this the rack currently being consumed
};
function TipBoxes() {
const [boxes, setBoxes] = useState<TipBox[]>(zeon.tipBoxes || []);
useEffect(() => {
zeon.onTipCounts((next) => setBoxes(next || []));
}, []);
return (
<>
{boxes.map((b) => (
<div key={b.uuid}>
{b.name}: {b.remaining}/{b.capacity}
<button onClick={() => zeon.resetTipBox(b.uuid)}>Replaced</button>
</div>
))}
</>
);
}Seed from zeon.tipBoxes and also subscribe: the first push may land before or after your component mounts.
zeon.resetTipBox(uuid) is intent only. The host shows its own confirm dialog ("Replace tip box" → Yes, it's replaced), performs the reset, and the new count arrives back through onTipCounts. Your canvas doesn't get a return value and shouldn't optimistically decrement.
Counts seed from the platform and update over a WebSocket as tips are consumed during a run. They also re-seed when a different world is loaded.
Unsaved edits and resetting
zeon.setDirty(true) tells the host you have in-progress edits. The host arms a beforeunload guard so an accidental refresh or tab close warns the operator first. A fresh mount resets it to clean until you say otherwise.
Because confirming does not make inputs durable, keep dirty armed until the run has actually started. The sessionStorage draft does bring the values back after a plain refresh, but it is tab-scoped and the confirmation itself is dropped, so the operator has to re-confirm, and a closed tab or a new session loses everything.
zeon.resetInputs() asks the host for a true clear: it wipes the staged input values, the object bindings, the saved draft, drops the confirmed state, and forces a fresh empty remount of your iframe. Prefer it over resetting your own React state, which would leave the host's staged values behind. Guard it behind your own confirmation step, because the host doesn't add one.
The sandbox
Your source is compiled in the browser with sucrase (typescript, jsx, imports transforms) into CommonJS, then evaluated inside an iframe with sandbox="allow-scripts" and no allow-same-origin. The frame is opaque-origin and carries a Content-Security-Policy with connect-src 'none' and default-src 'none'.
The practical consequences:
- No network. No
fetch, no XHR, no WebSocket, no external images or fonts.img-srcandfont-srcallowdata:URIs only. - Only two imports resolve:
reactandreact-dom(React and ReactDOM are vendored into the frame). Anything else throws at module-evaluation time:Import of 'x' is not allowed in a canvas (only 'react' and 'react-dom' are available, no network). - You must
export defaulta React component. Otherwise:Screen must 'export default' a React component. - Errors are reported, not swallowed. An error boundary catches render crashes and shows
Screen crashed: <message>; windowerrorandunhandledrejectionhandlers forward everything else to the host, which replaces the canvas with aCanvas unavailablecard and a Use standard form instead button. - Height is automatic. A
ResizeObserverreports the document height to the host, which sizes the iframe. Don't set a fixed height or your own scroll container.
The iframe accepts its init message exactly once. The host therefore remounts the whole frame (resetting your component state) whenever the compiled code, the schema, the world objects, or defaults change. Reloading a world will reset an in-progress form. This is why tip counts get their own channel.
The canvas/ folder
canvas/ folderThe workflow's source_ref must be a single .tsx file directly under canvas/, and the file stem must be lowercase letters, digits and underscores. Anything else fails to resolve and the editor shows Invalid source reference: <ref>.
The folder itself can hold other things. One example project keeps a canvas/world_viz/ subfolder with a generate.py and a rendered world_viz.html next to its screen. Those are ordinary project files; they are not compiled or served to the sandbox, and only the file named by source_ref is loaded.
Saving, disabling, falling back
Edit the .tsx in the Web IDE or your own editor, then commit and push. The Workflow Editor refetches and recompiles when canvas_ui.version changes.
The Workflow Editor's Edit tab also has a Canvas section with an Edit canvas / Add canvas button, which opens a minimal paste-and-preview panel in the Inputs tab: source on top, compile status (Compiles, or the error), live preview below, and an Enabled switch. It is not an IDE. For real authoring use the Web IDE.
To fall back to the standard input form, set canvas_ui.enabled to false in the workflow JSON (or flip the Enabled switch). The workflow keeps its canvas file; the editor renders the auto-generated panel instead. A canvas that fails to load or compile falls back the same way, per-session, via Use standard form instead.
Where to go next
Inputs, parameters and references: what your canvas collects values for.
The canvas_ui block and the inputs schema in full.
Every scaffold the CLI can write, and what each one patches.
What happens after the operator presses Run.
Updated 3 days ago