- name
- runtime-ui
- description
- Author or review a reloadable Twin-facing HTML/CSS-like runtime surface in LunCoSim. Use this for HUDs, telemetry cards, progress overlays, view switchers, runtime UI bindings, HTML/CSS hot reload, HUI, Flair, EngineExposures, or questions about the limits of the native HTML UI. Use lunco-ui instead for workbench/egui panels and docking internals.
# Runtime-authored UI
## Read first
Before changing a runtime surface, read:
1. [`docs/architecture/runtime-authored-ui.md`](../../docs/architecture/runtime-authored-ui.md)
2. [`assets/ui/README.md`](../../assets/ui/README.md)
3. [`skills/lunco-ui/SKILL.md`](../lunco-ui/SKILL.md) when the surface overlaps
egui, the workbench, or docking
4. [`skills/test-via-api/SKILL.md`](../test-via-api/SKILL.md) for live verification
The generic implementation is `lunco-workbench-runtime-ui`; the `luncosim`
windowed host in `crates/lunco-luncosim-ui/src/ui/` supplies app-specific gates,
capture mode, and action handling. Do not assume that `lunica` or a headless
server has this surface manifest.
The current compatible dependency baseline is `bevy_hui 0.7.0`,
`bevy_flair 0.8.1`, and Bevy `0.19.1`; verify the lockfile and upstream release
notes before changing it. HUI 0.7 is the Bevy 0.19 release. The separate
`bevy_hui_widgets 0.6.0` crate provides primitive text-input, slider, and select
components, but is intentionally not a LunCoSim dependency: it does not define
our clipboard, validation, keyboard-navigation, accessibility, or modal
semantics.
## Choose the right layer
Use runtime HTML/CSS for a small authored presentation surface that should be
changed without recompiling Rust: HUDs, status cards, progress overlays,
telemetry summaries, and simple buttons.
Use `lunco-workbench`/egui for the application shell, docking, code editors,
large inspectors, rich text input, complex forms, modal dialogs, and controls
that need semantics not present in the runtime contract. Runtime UI uses the existing
egui host and dock geometry; it does not replace the workbench or create a
second hit-test/camera system.
The standard Twin and Files navigation is the optional
`lunco-workbench-browser` feature layered on the shell; do not recreate those
panels in an authored runtime surface.
`lunco-ui::modal` remains the owner for rich dialogs requiring focus, queued
outcomes, editing, validation, or accessibility. The generic runtime surface
also supports a deliberately small authored modal contract: a viewport surface
may declare `modal: true` and an authored `dismiss_action`; visible controls
own their computed input regions and Escape emits that semantic action. Generic
keyed collection hosts provide dynamic rows, while Rhai owns the records,
labels, ordering, visibility, and action meaning. Do not grow this into a
second rich-dialog implementation without a separate typed contract and
acceptance tests.
Twin-authored actions are open-ended semantic identifiers. The reusable
`program-browser` surface publishes typed arrays of records; its HUI row
template is reconciled by the generic keyed collection host. A surface may also
declare a generic dropdown: the manifest supplies the trigger, option source,
key/label/action field names, and Rhai-owned width/max-height sources. The
compact `camera-status` surface is one consumer of that primitive, not a
camera-specific widget.
`program_editor` owns selection, editor focus, atomic source switching, and
creation for any authored program, regardless of whether its owner is a rover,
lander, route, or another model. Rich source text entry remains in the existing
Rhai editor/REPL until HUI gains tested typed input semantics.
For dynamic semantic controls, use the HUI convention
`on_press="runtime_ui_authored_action" tag:action="{action}"`. Additional
`tag:*` values become a typed `HookValue` parameter map on the generic action
event; the runtime does not concatenate names and values into a protocol
string. This permits Twin-defined actions and dynamic controls without
registering one Rust callback per item. Do not use JavaScript or encode action
payloads as JSON.
Collection hosts retain the Rhai-authored row order, clip their list, and
consume wheel input at the host boundary. They own row lifecycle only; do not
add a per-surface Rust list resource or a fixed row-count view model.
Use stable `id` attributes and `#id` selectors for authored HUI nodes. Flair
supports class selectors, but HUI 0.7 does not turn an HTML `class` attribute
into a `ClassList`; it treats that attribute as an unknown style property.
Project-owned visibility policy belongs in the active Twin manifest's generic
`[settings]` table. A surface declares `setting` plus `setting_default` in
`runtime_surfaces.json`; Rhai reads/writes the same scope with
`get_twin_setting`/`set_twin_setting`. Keep user-global diagnostics, theme,
input visualisation, and window preferences in `lunco-settings`. Missing Twin
keys use the surface's authored default, so Rust does not grow a field for
each new preference.
## Authoring workflow
### 1. Define a generic capability namespace
Add or extend an engine-side producer only when the value is not already
available. Publish authoritative, presentation-ready named values through
`EngineExposures`:
```rust
let mut ui = exposures.writer("mission-status");
ui.visible(has_mission);
ui.property("title", mission_title);
ui.property("state", state_label);
ui.property("state_color", "var(--ok-color)");
```
The namespace is a capability boundary shared by HTML, egui, API, telemetry,
and remote consumers. Do not add `domain_to_view`, `vessel_exposure`, or a
widget-specific Rust registry. Resolve source state in the engine producer;
keep markup unaware of ECS/domain types.
Engine health follows the same generic path. Read the typed
`EngineHealthSnapshot`/`PhysicsHealthSnapshot` publication and expose named
properties through the ordinary `engine-health` namespace; do not add a HUD
reader for `DiagnosticsStore`, an Avian timing query, or another source-specific
bridge. Native UI, HUI, API, telemetry, and recording consumers all read the
common publication. For scalar participant state, use the shared
`PortRegistry`; do not create a parallel port reader for a HUD.
`SimulationProgress` owner and reason facts are projected into each authored
runtime surface as typed `simulation_progress` data. Let the active Rhai policy
turn those facts into user-facing labels such as `PHYSICS LOADING` or
`TERRAIN LOADING`; the engine does not hardcode HUD wording. Progress changes
invalidate the existing surface projection, so a readiness label does not poll
the simulation. During preparation the built-in Rhai visibility policy
temporarily shows an authored surface before possession, then returns to its
authored `possessed` or `always` mode when the holds clear.
Command and one-shot REPL timing is also generic presentation data. Read the
`application-cadence` exposure for command and REPL sequence/interval/rate
values; do not attach a HUD timer to `Time<Virtual>`, count API requests as
completed script evaluations, or read the command/REPL owners directly.
Producers must use change detection, revisions, or dirty flags. Continuous
values are coalesced to the current bounded presentation cadence (20 Hz).
`EngineExposures.revision` changes when a complete surface changes or retires;
it is not a frame counter. Do not use JSON to detect internal changes.
Runtime visibility/property policies prepare immutable facts through shared
bounded async admission in the same headless and windowed owner. Use
`ReadExposures` to wait for `state == "ready"` before asserting their properties.
A new identity starts hidden and `pending`; `failed` exposes an owner error
without partial properties and requires source/identity replacement to recover.
Ready telemetry samples remain available while the next sample prepares. Never
feed UI completion timing into simulation decisions or send live World/USD
readers to the worker. See the
[publication contract](../../docs/architecture/runtime-authored-ui.md).
Run the held-physics preparation and negative-result gate with the built
production binary in both host modes:
```sh
python3 scripts/api/test_runtime_ui_preparation.py --port 4374 --log target/ui-preparation-headless.log
python3 scripts/api/test_runtime_ui_preparation.py --port 4375 --windowed --log target/ui-preparation-windowed.log
```
When one producer owns several surfaces, keep invalidation domains separate so
continuous motion does not rebuild static authored topology. Use the existing
authoritative stage revision for USD-derived membership and cache that
membership plus static authored metadata such as program source facts and
declared public-output names. Do not reread those facts at publication cadence,
rescan all prims, or add a second revision/source registry. Keep simulation
status, telemetry, outputs, and Rhai policy results live.
Authored runtime-surface fields belong on a prim with `LunCoUiSchemaAPI`.
Their live edits refresh exposure discovery through the stage revision; they do
not require recreating scene entities or resetting simulation state.
For camera status, Rust publishes the current camera fact and compact label
through the generic exposure namespace. The shared
`lunco-usd-bevy::camera_switch::camera_display_labels` resolver is also used by
the picker, Camera menu, USD/entity trees, and Inspector: unique leaves stand
alone, duplicate leaves gain nearest-owner context and then ancestors,
generated hexadecimal/UUID-like owner suffixes are hidden, and an unavoidable
normalized collision gets an ordinal. The full USD path remains the typed
selection value and hover/diagnostic text. Rhai owns selection policy
(`set_camera(name)`) and can read the fact with `get_exposure(...)`; HUI/CSS
owns rendering. Camera status emits `CameraSelectionStatusChanged` after its
camera/viewport lifecycle projection changes, and the exposure observer
consumes that event. The UI is revision-gated. Do not add a Rhai `on_tick`
loop, a timer poll, or a per-frame camera scan for this HUD.
Rejected camera commands may use the shared warning toast. Camera admission
findings come from one camera-contract state and project to structured runtime
diagnostics and Recent Events. A valid operator camera keeps the viewport ready;
unresolved director tracks are warnings until director control resumes. Recent
Events presents camera findings as warnings, emitted directly as telemetry
rather than runtime errors, so the generic warning observer never dispatches a
toast. Keep camera messages out of the Camera menu.
Register open egui dropdown bounds with `ScenePickGate` so option clicks do not
fall through to the 3D scene.
### 2. Add the template and stylesheet
Place files under `assets/ui/`. The stable contract is:
- HUI `<template>`, `<property>`, `<node>`, `<text>`, and `<button>`;
- stable `id` values and `{property}` interpolation;
- `on_press="callback_name"` for semantic actions;
- Flair CSS-like layout and visual properties, custom properties, and
`var(...)`.
Declare every property that the bridge should write:
```html
<template>
<property name="title">Status</property>
<property name="state">offline</property>
<node id="status-root">
<text id="status-title">{title}</text>
<text id="status-state">{state}</text>
</node>
</template>
```
Each projected value is also available as `--ui-<property-name>` in CSS. Keep
the manifest-owned outer rectangle separate from CSS-owned internal layout:
```css
@import "ui/runtime_fonts.css";
#status-root {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background-color: var(--panel-background);
}
#status-state {
color: var(--ui-state-color);
}
```
Use the bundled font import for telemetry/status glyphs. Do not rely on Bevy's
minimal `default_font` or a host-installed fallback.
### 3. Register bindings, gates, actions, and placement
Add a surface entry to `assets/ui/runtime_surfaces.json`:
```json
{
"id": "mission-status",
"template": "ui/status.html",
"stylesheet": "ui/status.css",
"namespace": "mission-status",
"bindings": {
"title": { "source": "title" },
"state": { "source": "state" }
},
"actions": [
{ "callback": "runtime_status_focus_moon", "action": "view.body.moon" }
],
"visible_in_perspective": "sandbox_view",
"interactive": true,
"placement": {
"mode": "window",
"anchor": "top_right",
"offset": [-16.0, 16.0],
"width": 260.0,
"height": 84.0
}
}
```
Surface `id` values and callback names must be unique within the manifest. The
loader rejects unknown fields, unsafe asset paths, and invalid geometry.
Authored semantic actions are accepted and delivered to Rhai; do not add a Rust
enum arm for each Twin, route, rover, or lander.
Binding target names must be declared template properties. `map` translates
exact rendered strings, which is useful for `true`/`false`, body ids, `display`,
or CSS colors. It does not perform arithmetic or general expressions; publish a
formatted presentation value when that is what the surface needs.
Placement modes:
- `viewport` fills the window;
- `dock_panel` uses the workbench's authoritative `PanelRects` plus an inset;
- `window` uses logical-point width/height, a corner/center anchor, and an offset.
Only a `window` surface may set `draggable: true`. The primary-button drag
stores a finite logical top-left override by stable surface id, clamps it to
the live target after resize/DPI changes, and persists it in the active Twin's
existing workbench workspace state. A primary-button double click removes the
override and restores the authored anchor. The shipped `celestial-view`
switcher is a draggable window with a top-centre authored default; Settings ▸
HUD also exposes a surface-specific reset that removes its per-Twin override.
Manifest reconciliation prunes unknown surface ids, and `TwinClosed` clears the
in-memory layout scope.
Script-driven hint/objective/action cards belong to `lunco-workbench-guided-ui`.
Omitting `hud` uses the calling script's card; unscripted API/REPL input uses
a distinct command-context card. Several scenario/tool producers
share a scene card by supplying the same explicit `hud` id, for example
`hint("mission-controls", text)` and
`guided_hud_actions("mission-controls", tool, hook, actions)`. The Twin is part
of the identity; never choose a card by last writer, hook name, or label.
`GuidedHudVisibility` supplies the host default (View in LunCoSim).
`hud_perspectives(ids)` or `hud_perspectives(hud, ids)` declares a per-card
allowlist; `[]` explicitly allows all. Unknown ids reject atomically. Scope
covers the whole card before rendering or input creation. Switching perspective
preserves content. Coach, spotlight, and recovery presentation remains separate.
`SetGuidedHudActions` validates the endpoint and action ids, creates an explicit
`ToolHookBinding` entity, and retires the prior binding. The UI carries that
entity into `RunRhaiToolHook.binding`; the shared scripting queue validates its
exact endpoint, registered owner, Twin, and scene generation at admission and
again at execution. The immutable action row's widget identity includes its
binding entity, so replacement also retires in-progress input. Empty actions
retire the binding. Script removal immediately clears its card,
including while the driver is paused or preparing. Script-instance retirement
(including recompilation) clears implicit script cards; scene replacement clears
all cards; `TwinClosed` clears that Twin's cards. All three despawn their leases.
Stop hooks may clear existing content/actions idempotently; clears never create
cards. New content, perspective declarations, and bindings reject during stop.
HUI, dropdown, Escape, and native menu actions capture `RuntimeUiActionScope`
at emission; validate it before interpreting the action, then retain the shared
queue's execution-time scene check. Never resolve the next active Twin for an
old event. Named cards are scene-owned, so callback tools can update them independently of
a scenario host. `ClearGuidedOverlay` clears the calling script's card and the
active coach/spotlight presentation, preserving other cards. Inspect the admitted
scope, content, and visibility through `InspectGuidedHud`.
Script-opened scene menus retain the same captured scope while open and clear
on scene transition or owning Twin close; stop hooks cannot open menus. Each
popup has its own widget identity and child callback leases. Replacement or
dismissal retires unselected leases; selection transfers one `Invocation`
binding to the queue, which releases it on execution or rejection. HUD rows
use `Surface` retention. Scene transitions retire queued tools even while the
interpreter is preparing. Never leave a selected popup endpoint unowned.
Run `python3 scripts/api/test_guided_ui_callbacks.py --port PORT` for the
production popup/menu input, replacement, dismissal, reset, and Twin-replacement
gate. Installed workbench menus bind provider-lifetime endpoints beneath a fresh
installation root and the current input Twin/scene; disable input during scene
transition. Selection creates a scene-bound invocation child. This keeps
application menus reusable after scene reset while replacement revokes both
held input and selected work. Every terminal admission/queue path releases the
exact invocation lease, including overload with an omitted request owner.
Nested deferred calls retain their typed caller context and capabilities through
`CommandOrigin::Rhai`/`WorldScriptExecution`. Never rebuild a stopping caller as
UI/REPL evaluation. The gate stops `guided_hud_teardown.rhai`, then inspects the
completed authored verdict from both deferred queues. The wrapper uses
temporary application-source fixtures for the native asset watcher and removes
them after its owned runtime exits.
Run `assets/scripting/tests/test_guided_hud.rhai` with
`scripts/api/run_rhai_test.sh PORT FILE` in an owned windowed production session.
It checks View/Build/Editor, independent teaching scope, preservation, and invalid
scope/endpoint rejection. Capture screenshots to verify actual placement/input;
headless presentation acknowledgements do not prove visibility.
`assets/scenarios/tests/guided_hud_teardown.rhai` reports `HUD_TEARDOWN` after
its host is stopped; it also publishes a script-owned teaching card with an
explicit all-perspectives scope. Register the generic `hud_binding_probe.rhai` test library
only in an isolated test Twin when exercising real pointer callbacks and Twin
replacement; never persist test tools into a user's Twin.
`interactive: true` enables input ownership for visible HUI controls that carry
an authored `on_press` action. The runtime adds `Pickable` to those visible
controls and feeds each computed Bevy UI rectangle into the existing
`ScenePickGate`; it never registers the surface root or a full-window
`viewport` rectangle. The active presentation camera is enabled for UI picking
with Bevy's marker filter, so decorative retained nodes without `Pickable` pass
scene hits through. Visible HUI nodes in a `draggable` window surface,
including its root, receive pick markers so pointer drags can bubble to that
surface's owner. This keeps HUDs transparent to camera dragging and scene clicks
outside their explicit controls. Do not add a parallel pointer/interception
system. Do not add per-frame position correction; placement is applied after
HUI/Flair style work with change detection, and the startup resolver ignores a
zero-sized target in favor of the live primary window
dimensions.
### 4. Map actions through the existing command path
The HTML callback name is only an authored binding. The manifest maps it to a
semantic action string; the runtime emits a typed action event; the owning
Rhai program maps that action to a typed command/event. A template must not
mutate resources or call a domain API directly.
The generic dropdown mechanic owns only popup lifecycle, typed record
projection, scrolling, and authored dimensions. It does not parse action
prefixes or construct options. HUI 0.7 has no native select/accessibility
tree, so this shared egui primitive is the low-level option. Camera, program,
route, and future domain policies remain authored in Rhai.
If a new domain action is needed, author its semantic identifier and handle it
in the owning Rhai program through the existing typed command/query/event
surface. Do not add a legacy callback alias or a widget-specific Rust shim
merely to make one template work.
For the shared lunar view surface, publish the scene `SiteAnchor` position as a
typed f64 vector in the Moon inertial frame. Show a separate mission-site HUI
card when that site and the Moon orbital pin are available; it must not depend
on a possessed vessel or appear inside the body mode selector. Rhai computes a
target direction, reads the persisted camera animation-duration setting, then
sends a generic `AnimateOrbitCameraDirection` request. The camera owner
animates along the existing orbit while preserving its current radius and
vertical offset.
## Reload loop
On native desktop, keep one production `luncosim` process running and edit assets:
| Edit | Live effect |
|---|---|
| `assets/ui/*.html` | HUI rebuilds the affected retained surface tree. |
| `assets/ui/*.css` / imports | Flair reapplies the stylesheet. |
| `assets/ui/runtime_surfaces.json` | Surface roots and action registrations are rebuilt. |
| Rust producer/observer | Rebuild the production binary; replace the session through API `Exit`. |
`ReloadShader` reloads WGSL only. A bare engine path such as
`shaders/foo.wgsl` resolves whichever active asset identity the renderer holds
(default source or `lunco://`); explicit `lunco://…` and `twin://…` paths are
exact. An empty path queues every currently loaded WGSL asset, and an inactive
target fails visibly instead of being reported as a successful no-op.
`SetShaderSource` uses the same identity resolution for direct in-memory WGSL
edits; when a bare target is not loaded yet, it seeds the canonical
`lunco://` asset for journal replay. `RunScenario` hot-reloads Rhai only. None
of these commands reloads HTML/CSS. The native file watcher is not present in
the headless server; web builds use bundled static assets and browser cache
rules.
HUI caveats: one root per template component, no recursive imports, and a
nested component template reload may require reloading the top-level template
again. Never manually write Bevy styling components under the surface from Rust;
HUI/Flair owns those components.
Use a single `<node>` as the root of every template, including collection-row
templates. Keep `<button>` and `<text>` elements below that root because HUI
reuses the scope entity during hot reload and Flair retains its selector type
metadata there.
Lifecycle invariant: when an exposure or presentation gate turns off, the
bridge removes the retained root whenever any HUI state remains, even if its
local mounted marker is stale after a deferred rebuild. A hidden surface must
not leave a stale progress card in the render tree.
## Verification
For a markup/style-only change:
1. Start the already-built production binary with an explicit free API port.
2. Wait for `/api/ready` to report `ready:true`, `world_hold:false`, and
عرض على GitHub