FrameworksTofupilotOperator ui

Panels

Last updated on October 22, 2026

PreviewThis feature is in preview, so its scope and behavior may change. Request access.

A panel is your own operator screen: one plain HTML file, with inline SVG for instruments, drawn instead of the component list. The panel is the front panel, the Python phase is the block diagram.

A panel runs no JavaScript. It is standard HTML read the standard way: data-bind shows a value, a field's name edits a component, a button's name is a press your Python reads. Your CSS turns values into needles, bars and colors.

Phase Panel

You can add a panel to a phase with ui.panel, the path of an .html file in the procedure directory.

main:
  - key: charge
    name: Charge
    python: phases.charge:charge
    measurements:
      - key: pack_voltage
        name: Pack voltage
        unit: V
        validators:
          - { operator: ">=", expected_value: 30 }
          - { operator: "<=", expected_value: 60 }
    ui:
      panel: panels/charge.html
      components:
        - { key: setpoint_a, type: number_input, label: Charge current, min: 0, max: 20 }
        - key: verdict
          type: select
          label: Verdict
          options: [{ label: Pass, value: pass }, { label: Fail, value: fail }]
import time


def charge(ui, measurements, psu):
    for _ in range(50):
        measurements.pack_voltage = psu.read_voltage()
        ui.amps = psu.read_current()  # shown by the panel, never recorded
        if ui.pressed("tare"):
            psu.tare()
        time.sleep(0.1)
    answer = ui.wait()
    psu.set_current(answer["setpoint_a"])
<div class="cockpit">
  <svg viewBox="-150 -150 300 300">
    <circle r="110" fill="#111"/>
    <g data-bind="measurements.pack_voltage">
      <line class="needle" y2="-95"/>
      <text y="60" text-anchor="middle" fill="#fff" data-bind="measurements.pack_voltage" data-format="0.00"/>
    </g>
  </svg>
  <p>Current: <b data-bind="ui.amps" data-format="0.0"></b> A</p>
  <input type="range" name="setpoint_a" min="0" max="20">
  <button name="tare">Tare</button>
  <button type="submit" name="verdict" value="pass">PASS</button>
  <button type="submit" name="verdict" value="fail">FAIL</button>
</div>
<style>
  .cockpit { display: grid; grid-template-columns: 1fr 320px; gap: 24px; }
  .needle {
    stroke: #fff;
    stroke-width: 4;
    transform: rotate(calc((clamp(0, var(--value, 0), 60) / 60 * 270 - 135) * 1deg));
    transition: transform .3s;
  }
  [data-outcome=FAIL] .needle { stroke: #e33; }
</style>

The needle moves as soon as the phase records pack_voltage, and turns red if the value fails its limits: the engine judges a measurement as soon as it is recorded. A measurement records every value: to animate a reading without recording each step, write it to ui.KEY instead.

Attributes

HTMLEffect
data-bind="PATH"Shows one value, see Shown values.
data-format="0.00"With data-bind: a number with that many decimals (0, 0.0, 0.00...).
<input name="KEY">, <select name="KEY">, <textarea name="KEY">Edits the input component KEY, both ways.
<slot name="KEY"></slot>The standard rendering of component KEY, with its label and errors.
<button name="NAME">A press: the phase reads it with ui.pressed("NAME").
<button type="submit">Answers the screen: Continue, or ui.wait() returns. Without one, the standard Continue stays under the panel.
<button type="submit" name="KEY" value="V">Writes V into the input component KEY, then answers: PASS / FAIL buttons.

A <button> without type="submit" never submits, whatever HTML's default. Enter in a field never presses a named submit: a PASS is always a press.

Shown Values

data-bind="PATH" writes onto its element:

WrittenUse it in CSS
data-valueThe value as text, true / false for a flag: [data-value=true]. Removed while the value does not exist.
--valueThe value as a number (true is 1, false is 0), for calc(). Removed otherwise: use a fallback, var(--value, 0).
textThe value, on an element without child elements.
valueOn a <meter> or a <progress>.
data-outcome, --low, --highOn a measurement: PASS, FAIL or UNSET, and the range its limits draw.

CSS variables inherit: bind a <g> or a <div> and style its children with var(--value). Show and hide with CSS too:

<div class="alarm" data-bind="ui.overheat">Overheat</div>
<div class="bar" data-bind="measurements.current"><i></i></div>
<style>
  .alarm[data-value=false] { display: none; }
  .bar i { display: block; height: 12px; width: calc(clamp(0, var(--value, 0), 20) / 20 * 100%); }
  .bar[data-outcome=FAIL] i { background: #e33; }
</style>

Keep pass/fail decisions in the measurement's limits, not in CSS: the color then shows the same verdict the run records.

Paths

Paths use the names your Python phase uses for its parameters.

PathValue
ui.KEYA component's value, or a value the phase writes with ui.KEY = v without declaring a component.
measurements.KEYThe latest value of a measurement of this phase.
measurements.KEY.outcome / .low / .high / .unitsThe verdict, the limits' range and the unit, as their own values.
PHASE_KEY.measurements.KEYA measurement of another phase.
PLUG_KEY.statusA plug's status.
unit.serial_numberAlso part_number, revision_number, batch_number.
run.statusRUNNING, then the verdict: PASS, FAIL, ERROR, ABORTED...
run.operated_byThe operator attributed to the run.
run.procedure.name / .versionThe procedure's name and version.
run.slot.key / .nameThe slot, on a multi-slot fixture.
phase.key / phase.nameThe phase.

A value that does not exist yet is empty, never an error: a gauge waits for its measurement.

Python

PythonEffect
ui.KEY = vSets component KEY. On a key no component declares, a value only the panel shows: never recorded, never in an answer.
ui.pressed("NAME")Presses of <button name="NAME"> since the last call, 0 when none. Never blocks.
ui.wait()Blocks until the screen is answered, then returns the components' values.

ui.pressed raises ValueError for a name the panel has no button for, with the closest one.

Run Panel

You can frame the whole run with a panel at the procedure's root. It stays on screen from plug setup to the verdict, like a TestStand operator interface. Put one <slot></slot> where the current screen shows: a phase's panel, its component list, the running screen, or the verdict.

name: Battery FVT
ui: 
  panel: panels/run.html

main:
  - key: charge
    # ...
<header class="run">
  <b data-bind="unit.serial_number"></b>
  <span data-bind="phase.name"></span>
  <span class="status" data-bind="run.status"></span>
  <button name="stop">Stop</button>
</header>
<slot></slot>
<style>
  .run { display: flex; gap: 16px; align-items: center; padding: 8px 16px; }
  .status[data-value=PASS] { color: #2c2; }
  .status[data-value=FAIL] { color: #e33; }
</style>

A run panel reads unit.*, run.*, PHASE_KEY.measurements.* and PLUG_KEY.status; phase and measurements are the phase running now. It has no ui values and no fields. Its two buttons:

HTMLEffect
<button name="stop">Stops the run gracefully, like the control bar's Stop.
<button name="kill">Kills the run. It arms half a second after the panel's Stop, so a double tap never kills: a panel with a Kill button also has a Stop button.

They are disabled once the run has ended and while the station is unreachable. The run panel's CSS never reaches inside its <slot>.

Rules

A panel is shown to every viewer of the run, in the kiosk and in the dashboard. It can run nothing and load nothing from outside the procedure:

  • no <script>, no on* attribute, no <iframe>, <object>, <form>, <link> or <foreignObject>, no custom elements
  • links (<a href>) and <use href> point inside the panel only: #id
  • images by relative raster path (images/fixture.png) or data:image/png;...; no .svg file, no srcset, no external URL
  • CSS in a <style> block or a style attribute, with no @import, no url() other than url(#id) or raster data, no image-set(), no rule for the whole page (@font-face, @layer...), and no CSS escapes (\)
  • a panel's CSS applies inside the panel only; @keyframes names are shared with the page, so give yours a distinctive name
  • no id or name that is a property of the page or of a form (title, submit, action...): the browser drops it
  • at most 40 kB per file and 10,000 elements (a warning from 2,000)

The station checks every panel before the run starts. A forbidden element, a field for a component the phase does not declare, or a path that does not exist refuses the run, with the file, the line and the closest name:

procedure.yaml:18:7: main[0].ui.panel: `panels/charge.html` line 12: <input name="setpoint">: no component `setpoint` in this phase; a field writes the input component of that key; did you mean `setpoint_a`?

An undeclared measurement only warns: the phase may record it under that name. So does a ui.KEY the phase's Python file never assigns.

Other Surfaces

The terminal UI shows the components, then the panel's values as text and its buttons on F1, F2... tofupilot run --json lists the buttons on each ui_request (buttons); an agent presses one with {"type": "ui_event", "request_id": "...", "name": "tare"}. Neither ever receives the panel's HTML.

How is this guide?

On this page