Skip to content

Host UI kit

A web front end for a host application is a page that draws a view state sent by a Rust engine and turns clicks into commands. This kit is the part every such page shares: a look that matches the Qt applications on macOS, and the plumbing to talk to the engine, show dialogs and pick files. Tag Programmer (host/applications/tagprogram-rs/ui) and Tag Monitor (host/applications/tagmonitor-rs/ui) use it.

Pages render with Preact through htm, which writes templates as tagged strings, so there is still no npm and no build step: every file here is plain CSS or a classic script, vendored or written by hand (decision 0038).

File What it is
kit.css Colour tokens (light and dark), window chrome, controls, group panels, status rows, key/value forms, dialogs and the log drawer
preact-htm.js Preact 10.29.8, its hooks and htm 3.1.1, vendored (see Updating Preact and htm)
kit.js Defines the global Kit: the engine connection, the menu bar, dialogs, file pickers, URL and folder opening
kit-components.js Adds Kit.html, Kit.render, Kit.hooks, the components in Kit.ui and Kit.logModel
heading-dial.js Adds Kit.HeadingDial({heading}), the heading indicator sensorui's MyCompass.qml draws (SVG): Tag Calibrator's Orientation page and sensorViz's compass panel. Load after kit-components.js
screenshot.sh Captures a page, against its mock, as a PNG (see Checking a page without the app)

Using it in a page

<link rel="stylesheet" href="…/ui-kit/kit.css">
<body>
  <div class="kit-window">
    <div class="kit-titlebar"><div class="kit-traffic"><span></span><span></span><span></span></div>
      <span class="kit-title"></span></div>
    <div class="kit-content"> <div id="menubar"></div> <div id="app" class="kit-root"></div> </div>
  </div>
  <script src="…/ui-kit/preact-htm.js"></script>
  <script src="…/ui-kit/kit.js"></script>
  <script src="…/ui-kit/kit-components.js"></script>
  <script src="mock-engine.js"></script>   <!-- defines MockEngine; unused in the app -->
  <script src="app.js"></script>
</body>
const { html, render } = Kit;
const { Group, Row, Button } = Kit.ui;
const params = Kit.setupPage("My App");          // ?theme=, preview title bar
const engine = Kit.createEngine(window.MockEngine ? MockEngine.create(params) : null);
const Window = ({ s }) => html`
  <${Group} title="Tag"><div class="kit-form"><${Row} single label="UUID" value=${s.uuid}/></div><//>
  <${Button} kind="default" onClick=${() => engine.command("start")}>Start<//>`;
engine.on("state", (s) => render(html`<${Window} s=${s}/>`, document.getElementById("app")));
engine.command("start");

The scripts load in that order. kit-root (display: contents) lets the rendered children lay out as children of kit-content.

The kit's scripts are classic scripts rather than ES modules on purpose. Browsers refuse module imports from file://, and a page you can open straight from disk, with no server, is what makes designing against a mock quick.

Rendering with Preact

A page renders its whole window from each view state: render(html`<${Window} s=${state}/>`, root). Preact compares the new tree with the old one and touches only what changed, so a field being typed into keeps its focus and text. The page keeps only what is its own (an open drawer, a selected tab) in plain variables and renders again when it changes.

Component Draws
Button({kind, small, onClick}) kit-button; kind is "default" or "destructive"
Disclosure({pressed, onClick}) kit-toolbutton with its arrow, such as "Log"
Select({options, value, onChange}) kit-select; options are strings or {value, text, disabled}. It shows value after every render, even when the user picked an entry the page refused; null shows none
Group({title, tooltip, titleTooltip}) kit-group: the title above a kit-panel holding the children; tooltip is the whole group's
Row({label, value, single}) One kit-form row: the label cell and a selectable value cell; children replace value
Progress({fraction}) kit-progress, fraction 0 to 1, or null for busy
StatusGrid({rows}) kit-status-grid; each row {level, name, text, buttons}
LogPane({log, level, onLevel, onSave, onClear}) The kit-log drawer; Clear only with onClear

Every component passes the attributes it does not use (id, title, hidden, class) to its outer element. Kit.logModel() holds a log's text outside the page's components, so the engine's log listener can be registered before the first render; LogPane writes the text area directly, so a long log costs nothing per render.

Kit.dialog takes a Preact element as its body as well as a Node. It is rendered into the dialog and unmounted when the dialog closes, and .update(body) on the returned promise renders a new one into the open dialog. Tag Programmer's Program dialog is a component with the label field and the notes that follow it; Tag Monitor's erase and download sheets are updated from each state.

The engine connection

Kit.createEngine(mock) returns { kind, command, query, on }:

  • command(cmd, args) → invoke("command", {cmd, args}). The answer is null, or an error string.
  • query(name, args) → invoke("query", {name, args}). The answer is JSON.
  • on(event, cb) → listen(event, …). Callbacks receive the payload. The call returns a promise, so await it before sending the first command, or the first state can be missed.

Inside the app (window.__TAURI__ exists, withGlobalTauri: true) calls go to Tauri. Otherwise they go to mock, which must implement the same three functions. An application's contract document says what the commands, queries and view state are. Tag Programmer's is host/applications/tagprogram-rs/design/ui-engine-contract.md.

Helpers

Call Does
Kit.setupPage(title) Applies ?theme=dark\|light and, in a browser, shows the stand-in title bar. Returns the URLSearchParams.
Kit.setTitle(text) Sets the document title, the preview title bar and, in the app, the window title (which needs the core:window:allow-set-title permission).
Kit.el(tag, attrs, ...children) Small element builder. on* attributes become listeners.
Kit.menuBar(host, menus) An in-window menu bar, as the Qt apps use. menus = [{title, items}], where an item is {label, action, checked, disabled, tooltip} or "-". items can be a function, evaluated each time the menu opens, so check marks follow the state. An item with its own items is a submenu.
Kit.contextMenu(ev, items) A popup menu at the pointer, from a contextmenu event, with items as for a menu (submenus included).
Kit.dialog({title, body, buttons, width, onOpen}) A modal sheet. buttons run left to right. Each is {label, value, default, destructive, enabled, cancel, enter}, or "spacer". It resolves with the value of the button pressed. Escape picks the cancel button and Enter the enter one. After the call, b.node is each button's element, for enabling it live.
Kit.alert(title, text, info) A message box with an OK button.
Kit.confirm(title, text, info, buttons, width) A question box, with buttons as for dialog.
Kit.promptText(title, label, initial, placeholder) A one-line text prompt. Resolves with the text, or null.
Kit.chooseFromList(title, label, items, current) Pick one string. Resolves with its index, or null.
Kit.openFile({title, filters, defaultPath}) / Kit.saveFile(…) Native file dialogs through Tauri's dialog plugin. In a browser, a text prompt for the path.
Kit.openUrl(url) / Kit.revealPath(path, title) Open a URL, or show a file in its folder, through Tauri's opener plugin. In a browser, a new tab, or an alert naming the path.

Most dialog texts come from the engine. The few that a UI writes itself, such as a confirmation, should copy the Qt application's wording exactly.

Classes

Class Use
kit-window, kit-content, kit-titlebar The window. kit-content has the Qt 12 px margins. The title bar shows only in a browser preview (body.kit-preview).
kit-menubar Filled by Kit.menuBar.
kit-button (.default, .destructive, .small) Push buttons. .default is the blue one.
kit-select, kit-field A popup button and a text field.
kit-toolbutton + kit-arrow A disclosure toggle, such as "Log". Set aria-pressed.
kit-progress (.busy) A 200 px bar with an inner <div> for its width. .busy is indeterminate.
kit-group > kit-group-title + kit-panel A Qt macOS group box: a small title above a rounded grey panel.
kit-status-grid Status rows: kit-dot (.ok .warn .fail .pending), kit-status-name, kit-status-text, kit-status-buttons.
kit-form A key/value form: kit-form-label (right-aligned) and kit-form-value (.single for one line).
kit-text-ok, -fail, -warn, -secondary Text colours.
kit-log The log drawer: a <textarea> and a kit-log-bar.
kit-selectable Text the user can select. All other text is not, as in a native app.

The status colours are qtprogram's: #2e7d32, #c77700, #c62828 and #9e9e9e, lightened in dark mode.

Themes

The colours are custom properties on :root. Dark values apply under prefers-color-scheme: dark, and html[data-theme="dark"|"light"] forces one theme or the other. Use the tokens (--panel-bg, --text, --accent, --ok, …) in an application's own CSS, never literal colours, and dark mode comes for free.

Checking a page without the app

Open the page as a file in a browser; it runs against its mock engine. The mock's scenarios and the page's preview parameters put it in any state without the app or a tag, and screenshot.sh captures one as a PNG with headless Chrome:

host/ui-kit/screenshot.sh host/applications/tagmonitor-rs/ui/index.html out.png \
  "scenario=finished&page=tag" 1100 640

Each app's ui/screenshots.sh lists the user manual's pictures this way and writes them to host/docs/src/images. Rerun it after changing the page or the mock, and look at the pictures before committing them: the mock's samples are hand-written and can drift from what the engine sends.

Shipping the kit with an app

A page refers to the kit by a relative path (../../../ui-kit/ from tagprogram-rs/ui). A Tauri app serves only its frontendDist directory, so the shell must make the kit reachable from there. It can copy host/ui-kit/ into the bundled directory at build time, or point frontendDist at a directory that holds both the kit and the page.

Updating Preact and htm

preact-htm.js is the three published UMD builds, joined, with their source-map comments removed. Its header names the files and versions. To update, npm pack preact@<version> htm@<version> in an empty scratch directory, unpack both, and rebuild the file from preact/dist/preact.umd.js, preact/hooks/dist/hooks.umd.js and htm/dist/htm.umd.js, in that order. Then update the versions in its header, in this README and in LICENSES/host/ (README.md, third-party/preact.txt and htm.txt).