Panels
Last updated on October 22, 2026
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
| HTML | Effect |
|---|---|
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:
| Written | Use it in CSS |
|---|---|
data-value | The value as text, true / false for a flag: [data-value=true]. Removed while the value does not exist. |
--value | The value as a number (true is 1, false is 0), for calc(). Removed otherwise: use a fallback, var(--value, 0). |
| text | The value, on an element without child elements. |
value | On a <meter> or a <progress>. |
data-outcome, --low, --high | On 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.
| Path | Value |
|---|---|
ui.KEY | A component's value, or a value the phase writes with ui.KEY = v without declaring a component. |
measurements.KEY | The latest value of a measurement of this phase. |
measurements.KEY.outcome / .low / .high / .units | The verdict, the limits' range and the unit, as their own values. |
PHASE_KEY.measurements.KEY | A measurement of another phase. |
PLUG_KEY.status | A plug's status. |
unit.serial_number | Also part_number, revision_number, batch_number. |
run.status | RUNNING, then the verdict: PASS, FAIL, ERROR, ABORTED... |
run.operated_by | The operator attributed to the run. |
run.procedure.name / .version | The procedure's name and version. |
run.slot.key / .name | The slot, on a multi-slot fixture. |
phase.key / phase.name | The phase. |
A value that does not exist yet is empty, never an error: a gauge waits for its measurement.
Python
| Python | Effect |
|---|---|
ui.KEY = v | Sets 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:
| HTML | Effect |
|---|---|
<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>, noon*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) ordata:image/png;...; no.svgfile, nosrcset, no external URL - CSS in a
<style>block or astyleattribute, with no@import, nourl()other thanurl(#id)or raster data, noimage-set(), no rule for the whole page (@font-face,@layer...), and no CSS escapes (\) - a panel's CSS applies inside the panel only;
@keyframesnames are shared with the page, so give yours a distinctive name - no
idornamethat 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?