Talos docs

Authoring Talos UI

Since 0.6Changed in 0.7

This is the reference for writing a game screen: the files, the TSX, the stylesheet, the values a behaviour pushes in, and every diagnostic the engine will hand back.

Plain text

This is the reference for writing a game screen: the files, the TSX, the stylesheet, the values a behaviour pushes in, and every diagnostic the engine will hand back. It is written to be read once and then used.

The shape of the whole thing, in one line:

ui/screens/*.tsx  --esbuild-->  h() calls  -->  description JSON  -->  crates/ui builder  -->  tree
ui/styles/*.css   ---------------------------------------parsed in Rust-->  typed rules  -->  cascade

Structure is TSX because that is what an author and an agent both write most reliably. Style is a documented CSS subset parsed in Rust, not in script, because that is where hot reload is free and where the cascade already lives (§1.3). A behaviour never walks the tree; it sets values, and the runtime rebuilds only what changed (§2.7).

1. Where the files go

ui/
  screens/**.tsx            one screen per file, exported as default, any depth
  components/**.tsx         vendored component source, any depth
  styles/**.css             the CSS subset, any depth
  theme/**.css              tokens and themes, any depth
  scenarios/**.json         named mock data, any depth
  fonts/**.ttf              the project's font chain, plus fonts.json to order it
  fonts/**.fnt|.json        bitmap fonts, each named by an @font-face rule, with its atlas PNG
  locales/<tag>.json        one language per file (§2.11)

Every one of those is watched. A change to a stylesheet or a theme is reparsed and re-resolved in Rust with no script involvement, and must not lose focus, scroll position or an open dialog. A change to a screen or a component goes through esbuild and a script reload, which keeps state only while the props shape is unchanged. A change to a scenario re-reads the mock data and nothing else. Anything else under ui/ — a README, a note, an editor's leftover — is not a UI input and fires no reload at all.

ui/fonts/** is read at project open and not watched: a font is an asset, and reloading one would mean throwing away the glyph atlas and every shaped run in it. It is the project's fallback chain — see "Fonts and fallback" in §4 for what that means and the order it resolves in.

A change to a locale takes the same tier as a stylesheet, and for the same reason: keys are resolved in Rust at render time, so a changed string is re-read and re-resolved with no bundle rebuilt and no script run. A translator saving a file keeps their focus, their scroll position and their open dialog.

One more directory under ui/ is read but not watched, because it is not compiled and not resolved: ui/icons/<name>.png is what Icon's name resolves to (§10). A change to it is a change to an asset, so it fires no reload. fixtures/ui/gallery/ui/icons is the worked example, and its README is worth reading: the one icon the library's shipped scenarios name is there, and the two that stand for a missing asset are deliberately not.

Detection polls; it is not a file-system watcher. Windows delivers no events for files on the WSL path and inotify inside WSL does not see writes made from Windows to /mnt/c, so a watcher would silently fail to fire on the machine that most needs it (§5).

The six directory names and the ui/ prefix are matched case-insensitively, as is the extension, because the same project is edited from Windows where Styles and styles are one directory. Nothing below them is: a file name is the author's. ui/Styles/Menu.CSS is a stylesheet and reloads; ui/style/menu.css is not one of the six names and is not a UI input. A locale file's name is its tag, so ui/locales/pt-BR.json is the locale pt-BR.

The esbuild configuration is @argus/ui's, stated once, and there are two spellings of it because there are two ways to use it. ESBUILD_JSX_FLAGS is the three flags to add to a list a host already has — --loader=tsx and the two JSX flags — which is what the engine's own TypeScript transpile does. ESBUILD_TSX_ARGS is the complete list for a caller that spawns esbuild itself, and it carries --format=esm --target=es2020 as well, because those are the contract with the engine's QuickJS rather than a host preference: without the target, ||= and static {} reach the runtime untranspiled and fail at load. Appending either list to an existing one is safe, since esbuild takes the last of a repeated flag.

2. A screen

// ui/screens/main-menu.tsx
import { defineScreen, h, t } from "@argus/ui";

export interface MainMenuProps {
  canRestart: boolean;
}

export default defineScreen<MainMenuProps>({
  name: "main-menu",
  inputContext: "menu",
  modal: true,
  dismissible: false,
  render: ({ canRestart }) => (
    <container class="screen" theme="sim">
      <text class="screen__title">{t("menu.title")}</text>
      <list id="menu" class="menu" nav={{ down: "start" }}>
        <list-row class="menu__row" key="start">
          <button id="start" class="btn" variant="primary">{t("menu.start")}</button>
        </list-row>
        <list-row class="menu__row" key="restart">
          <button id="restart" class="btn" disabled={!canRestart}>{t("menu.restart")}</button>
        </list-row>
      </list>
    </container>
  ),
  scenarios: {
    default: { canRestart: false },
    "mid-run": { canRestart: true },
  },
});

A screen is a pure function of typed props. There are no hooks, no context, no effects, no state and no handlers that run code, and none of them are coming: behaviour lives in the game's TypeScript and pushes values in (§4, §2.7). React's spellings are accepted wherever they mean the same thing here — <div className>, style={{ … }}, onClick={emit("start-game")} — and "Writing screens the React way" below lists them. What needs a DOM or code running inside a screen is refused with the way to write it.

The element set

Ten elements, which are the engine's ten roles. A role decides three things and nothing else: whether the node takes focus by default, whether it draws text or an image, and what an agent reading GET /ui is looking at. Appearance comes from the stylesheet, never from the role.

elementwhat it isfocusable by default
<container>a layout box with no content of its ownno
<text>a run of textno
<image>a PNG, by its path from the project root: ui/icons/key.png, or a scene's own sprite sheet (scenes/sprites/gun.png), any folder but a hidden one; a path that leaves the project, or is not there, draws the magenta missing-image box and raises ui.asset.image-missing. With no width or height it takes its file's size (one of them set, the other follows the file's aspect ratio); one that still lays out at zero size raises ui.layout.image-zero-size. Unlike CSS, such an image is not stretched by its container's default align-items: stretch: give it align-self: stretch to stretch it, or set both width and heightno
<button>activatableyes
<list>a container whose children are rowsno
<list-row>one row of a listyes
<dialog>a screen-level panel; a screen whose root is one is normally modalno
<toast>a transient notification; never focusable, never hit-testableno
<text-field>an editable run of text with a caret and a selection (U4)yes
<model-view>a 3D model rendered to a texture and shown in its box (U5 slice 1); see belowno

Capitalised aliases (<Button>, <ListRow>) exist and produce the same nodes. <text-field>'s is <Field>, to keep it apart from TextField in @argus/ui/components, which is the composition with a label and a helper line around it.

A <model-view asset="models/knight.glb" spin={30} /> shows a model, lit and turning, inside its box (standard renderer only). Size it like any box; with no width or height it is a 256 x 256 logical-pixel square, and one written dimension gives the other. Its background is the node's own CSS background, transparent by default. Attributes: asset (a project-relative model path, wins over entity), entity (a scene entity name; its mesh and material are drawn without its transform), yaw ("auto", the default, or degrees), spin (degrees per second, default 0, a still model), camera ("three-quarter", the default, or "front") and fov (degrees, default 35, clamped to 5..90). At most 4 render per frame, each capped at 512 px on its longest side, and a screen with none costs nothing. In a description it is "role": "model-view" with "props": { "modelView": { "asset": "…", "spin": 30 } }, written back only where it differs from the default.

A <text-field> carries its content as attributes rather than children. A button's label is a child text node; a field's content has a caret in it, and a caret between two child nodes is a caret with no defined position.

attributetypewhat it does
valuestringwhat the field starts with. A binding or the player changes it from there
placeholderstringa meta.strings key, shown while the field is empty
placeholderTextstringthe placeholder as a literal, for a value with no translation
maxLengthnumberthe most characters the field accepts — characters, so a limit means the same thing in Japanese
readOnlybooleanfocusable, selectable and copyable, and not editable. Not the same as disabled, which refuses focus outright
passwordbooleandraw • per character, keep the value out of GET /ui, and refuse the system clipboard
multilinebooleantake newlines; Enter inserts one rather than submitting

Each of the seven is refused by name on any other role.

Attributes

attributetypewhat it does
classstring | string[] | Record<string, boolean>class names; the record form includes the keys whose value is true
idstringthe name tests, behaviours and the query API address the node by. Flat within the screen, so no two nodes may share one. No : or /. On the root it is an alias of <screen>:root (below)
keystringthe diff key for a child of a list; a keyed row keeps its identity when the list reorders. Unique among its siblings, which is the scope it has
dataRecord<string, string | number | boolean>data attributes, written without the data- prefix; values are stringified
variantstringshorthand for data={{ variant }}
themestringshorthand for data={{ theme }}; on a root node this scopes a theme
disabled selected checkedbooleanauthored state, matched by :disabled, :selected, :checked
focusablebooleanoverrides the role's default
tabIndexnumberexplicit Tab position; lower comes first, and any node with one sorts before nodes without
hit"auto" | "none" | "always"none takes the node out of the hit-test; always buys it back on a click-through screen such as the HUD
nav{ up?, down?, left?, right? }per-element overrides for directional focus navigation, by node id
styleRecord<string, string | number>inline CSS declarations, kebab-case or camelCase. See the warning below
srcstringon <image> only
initialFocusbooleanfocus lands here when the screen first takes input, instead of on the first node in Tab order. autoFocus is the same thing
onClickemit(event, data?)the bus event every activation of the node raises; see "Writing screens the React way"
classNameas classReact's spelling of class. Writing both on one node is refused
data-<name>string | number | booleanHTML's spelling of data={{ <name> }}

The root node's id is <screen>:root, built from the screen's name, and that is the one name GET /ui, the critic and every report give it. An id written on the root is kept as an alias: ctx.ui.set and ctx.ui.get reach the root by <screen>:<id> (or the bare id) as well, while GET /ui, its ?id= filter and the critic know the root only as <screen>:root. The engine raises the warning ui.describe.root-id to say so. The screen is not refused. (It used to be, which cost a bench agent a HUD over nothing but the name hud-root.) Ids are flat within a screen, so a child may not reuse the root's id.

That table is the whole vocabulary. An attribute that is not on it is refused when the element is built, by name, with the nearest supported alternative — every on* handler but onClick is told what to use instead, ref and dangerouslySetInnerHTML are told there is no DOM to reach, and a name that is merely misspelled is corrected. Several at once are one refusal that names them all. TypeScript refuses all of them too, but the engine's own compile path strips types without checking them, so the refusal has to exist at run time as well. A key written as a number — items.map((item, i) => <row key={i}/>) — is accepted and stringified. A key on a fragment is refused: <>…</> is spliced into its parent and has no node of its own to carry one, so key the elements inside it instead.

style is for values only the code knows — a progress bar's width, a marker's position. A colour or a radius set here is a colour or a radius the stylesheet cannot reach, which is the trap the whole style layer exists to avoid (§2.2). Values are CSS text: there is no implicit pixel unit, so {{ width: 40 }} is an error telling you to write "40ui" or "40%". A bare number is accepted where CSS itself takes one (opacity, flexGrow, flexShrink, order, zIndex, fontWeight, lineHeight, aspectRatio, scale), and 0 anywhere.

Children and text

A child may be an element, a string or a number (which becomes text), null, undefined, false or true (all dropped, so cond && <x/> works), an array, or a fragment (spliced into the parent without adding a box). A render must produce exactly one root node.

A fragment needs its factory imported. <>…</> compiles to h(Fragment, …), so Fragment has to be in scope the way h does:

import { defineScreen, Fragment, h, t } from "@argus/ui";

h.Fragment is the same function, for a screen that would rather not add the import. Forgetting it is refused at describe time with a ScreenError naming the screen and this line, rather than the bare ReferenceError: Fragment is not defined it used to be.

A <text> may also hold <span> children, which are inline pieces of one shaped paragraph rather than boxes of their own. See §4, "Rich runs".

Words go in a <text>, or in the children. Only a text node draws its run, so text, textKey and textParams are attributes of <text> (and of the HTML names that lower to it: <p>, <label>, <h1>…<h6>). Written on any other element they are a type error, and the screen typecheck behaviour.write runs reports them as ui-unknown-prop, naming the element, the attribute and the form that works: <button text="Start" /> would describe and draw a blank button. A button's label is its children — <button>Start</button>, <button>{t("menu.start")}</button> — which the factory wraps in the text node the engine draws, or the Button component of @argus/ui/components, which takes text and textKey as props. In a running game the UI critic reports a non-text node that carries words under the same code.

t("menu.start") produces a string key rather than a literal. Every label a player reads should be a key: resolution happens at render time, so switching language is a re-render rather than a reload, and a key that is missing from the table is reported for the critic (§2.11). A literal is for something that is not translated — a build string, a number already formatted. Setting both on one node is an error.

t() takes a second argument for a message with slots: t("score.line", { n, max }) fills {n} and {max}, and a message written as a plural table picks its form from n — or from count where there is no n. Pass a number as a number. The plural rule selects on it, so a count written as text would make the plural form depend on how you spelled your own integer. Both the substitution and the selection happen in Rust at render time, so a later ctx.ui.setLocale resolves the same parameters into the other language's message with no behaviour running again. The long form is <text textKey="score.line" textParams={{ n, max }} />, and textParams without a textKey is refused — the slots fill a message's placeholders, so there has to be a message. See §12 for the file format.

Scenarios

scenarios are named sets of props. They are three things at once: the mock data a screen previews against with no game running, the critic's inputs, and the gallery's entries (§4). They are not what the game draws: a screen pushed with props renders those props, and a screen pushed with none renders its default scenario (default, or the first one declared). Ship at least two, and make one of them the empty or failing case — loading, empty and error states are part of a component's contract, not optional extras. defineScreen warns with ui.screen.scenarios when a screen declares fewer than two, because a screen with none is one the critic never runs and the gallery cannot show. It is a warning rather than a refusal: the screen still describes correctly, and a fixture or a test is allowed to declare a throwaway. The warning is on the screen as screen.diagnostics, and onDiagnostic routes the same one wherever a build tool wants it.

Writing screens the React way

A screen may be written the way React writes a page. Every React spelling below lowers onto the engine's own vocabulary when the element is built, so the description crates/ui reads is the one the Talos spelling produces, byte for byte (fixtures/ui/title.screen.json is written from both spellings). Nothing needs a DOM or a browser, which is what keeps a screen portable to consoles, phones and PCs.

// ui/screens/title.tsx
import { defineScreen, emit, h } from "@argus/ui";

function TitleMenu() {
  return (
    <div className="title-menu">
      <h1 style={{ fontSize: "48ui", marginBottom: "24ui" }}>Recipe Game</h1>
      <ul className="menu">
        <li key="start">
          <button id="start" className="btn primary" onClick={emit("start-game")} autoFocus>Start</button>
        </li>
        <li key="quit">
          <button id="quit" className="btn danger" onClick={emit("quit-game")}>Quit</button>
        </li>
      </ul>
    </div>
  );
}

export default defineScreen({
  name: "title",
  inputContext: "menu",
  modal: true,
  dismissible: false,
  render: () => <TitleMenu />,
  scenarios: { default: {}, again: {} },
});
// scenes/behaviors/title.ts, in init
ctx.events.on("start-game", () => ctx.ui.pop())
ctx.events.on("quit-game", () => ctx.session.quit())

Three things are the engine's rather than React's:

  • JSX compiles to h(…). The engine supplies h and Fragment when a screen does not import them, so a screen written the React 17 way works, and one that imports them from @argus/ui works the same. import … from "react" resolves to the engine's own answer (@argus/ui/react): createElement (which is h), Fragment, StrictMode, memo, useMemo and useCallback work, and useState, useEffect, Suspense, Component and the rest refuse when used, with the way to write it instead.
  • A screen is defineScreen({ name, render, scenarios }), not a default-exported function: the name is what ctx.ui.push addresses and the scenarios are what the preview and the critic render. A default-exported component is refused with the wrapper to write.
  • onClick holds emit(event, data?), not a function. A screen is described once and runs no code afterwards, so only data survives. Every activation of the node — a click, Return or Space on the focused node, the pad's uiActivate, POST /ui/activate — raises event on the script bus with data, on the tick after the press, just after the unchanged "ui-activated". A behaviour hears it with ctx.events.on("start-game", (ev) => …); ev.data is the data and by is null. A closure in onClick is refused with the emit to write instead.

Focus is the engine's, since React has none: buttons, list rows and text fields take focus whatever they are spelled, Tab follows tabIndex, the pad and the arrow keys move geometrically (§9, Input), and autoFocus picks where focus starts.

A stylesheet imported by the screen (import "./title.css") is not how styles arrive: the import is dropped when the screen is bundled, and every file in ui/styles/** and ui/theme/** is loaded for every screen. Put the rules there; a .css file anywhere else is never read. The selectors match what was built, so div { … } is refused with a pointer to container, and a class is the portable way to reach a React-spelled element. §13 is the whole mapping.

The builder API

TSX compiles onto a Rust builder that is a supported surface in its own right (§1.3). A fixture, a test or an editor panel writes it directly:

let pause = Ui::screen("pause")
    .input_context("menu")
    .modal()
    .child(
        Ui::dialog()
            .class("panel")
            .child(Ui::text("Paused"))
            .child(Ui::button("Resume").id("resume"))
            .child(Ui::button("Quit").id("quit").variant("danger")),
    )
    .build();

3. Stylesheets

/* ui/styles/menu.css */
.menu {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-5);
}

.btn {
  background-color: var(--surface);
  color: var(--on-surface);
  border: 1ui solid var(--border);
  border-radius: var(--radius-md);
  padding: 8ui 16ui;
  transition: background-color 120ms ease-out;
}

.btn[data-variant="primary"] { background-color: var(--accent); }
.btn:hover                   { background-color: var(--accent); }
.btn:focus-visible           { border-color: var(--focus-ring); }
.btn:disabled                { color: var(--disabled); }

.menu > .menu__row:first { margin-top: 0; }

Everything a component looks like should be reachable from its class and its data attributes, because that is what lets an agent restyle it without touching the TSX, and what let the reference project's design language travel to another application in another language (§2.2).

Selectors

formexamplenotes
class.panel
rolebuttonone per compound; the ten element names
data attribute[data-variant="danger"], [data-state]the data- namespace only
state:hover :active :focus :focus-visible :disabled :selected :checked :first :lastnine, and no others
child.list > .rowthe direct parent
descendant[data-theme="dark"] .menu .rowany ancestor
list.a, .bbecomes one rule per selector

Two combinators at most, child or descendant in any mix: .menu .row > .label and [data-theme="dark"] .menu .row are fine, and .a .b .c .d is refused with ui.css.combinator-depth. Two is what a theme-scoped component rule needs — one step for the theme, one for the component — and matching stays one walk up the node's ancestors, so its cost is the same order as one combinator's (measured on the gallery: no change within noise). Past two, give the element a class of its own.

Refused on purpose: *, #id, ::before, :not() and every other functional pseudo-class, + and ~, attribute selectors outside data-, and !important.

Specificity is CSS's (b, c): classes, attributes and pseudo-classes first, then roles, summed over every compound. There is no a, because there are no id selectors. A class beats a role wherever the two rules sit. A tie breaks on position, so the later rule wins. Inline declarations on the node beat every rule, and are the only override there is.

A theme or size scope counts. [data-theme="x"] is an attribute, so [data-theme="x"] text is (1, 1) and beats a bare .label at (1, 0), exactly as it would in a browser. A theme that restyles a role this way outranks every unscoped one-class rule; scope the class rules the same way ([data-theme="x"] .label, (2, 0)), or put the theme's look in its tokens, which need no selector at all.

Inheritance is four properties: color, font-size, font-weight, text-align. Nothing geometric inherits.

Tokens and themes

Two layers, and components should reference the second only.

/* ui/theme/tokens.css — the primitive layer */
@tokens {
  --ink-900: #0f1319;
  --ink-50: #f1f4f8;
  --teal-400: #2ebfba;
  --red-400: #ef6666;
  --space-4: 16ui;
  --radius-md: 6ui;
  --duration-base: 150ms;
}

/* ui/theme/dark.css — the semantic layer */
@theme dark {
  --surface: var(--ink-900);
  --on-surface: var(--ink-50);
  --accent: var(--teal-400);
  --danger: var(--red-400);
}

Every token name is written --name, and eleven of them are reserved: surface, on-surface, accent, danger, success, warning, focus-ring, disabled, scrim, border, muted. Those eleven are the semantic layer and may only be set inside a @theme block; setting one in @tokens is an error pointing at @theme. Every other name is a primitive. var(--x) resolves the semantic names first, so var(--accent) follows the active theme and var(--teal-400) names one palette entry.

A token holds a colour, a length, a number or a time, and the kind is checked where it is used: padding: var(--accent) is an error saying that accent is a colour.

A colour stays a reference until the cascade resolves it, so switching theme is a re-resolve rather than a reparse. A length is resolved when the sheet is parsed — taffy needs a number — so a theme that gives a length, number or time its own value (--radius-lg: 14ui in @theme game) has the rules parsed once more against its table, and switching to it swaps those rules in with the table. Either way a theme may move any token, and the rules take the active theme's value. The extra parse happens only for a theme that moves a non-colour; a project whose themes differ in colour alone (every theme the design system ships) parses once.

The parser starts from the engine's built-in table — the dark palette, the design system's extended colour names, and every measurement the component library reads: the type, spacing (space-0…space-9), radius (radius-none…radius-xl, radius-full), stroke, control, bar, shadow-geometry, duration and elevation scales — and a project's @tokens and @theme blocks override and extend it. A project never has to define --radius-lg or --space-4 to use them; GET /ui/schema lists the whole table. The first @theme block across the files is the default.

Scoping rules to a theme needs no special form. data-theme on the screen root plus a descendant selector is all of it:

[data-theme="alert"] .panel { background-color: var(--danger); }

Size classes, not media queries

@size compact {
  .stat-row { flex-direction: column; }
}
/* identical to: */
[data-size="compact"] .stat-row { flex-direction: column; }
classwidth in ui unitswhat it means
compactunder 1900a small window, or a player who turned the UI scale up
regular1900 to 2100every ordinary surface, 4:3 through 16:9, at any resolution
expanded2100 and overwider than 16:9: ultrawide and multi-monitor

There are no media queries because a pixel count has no useful answer here. The root scale follows viewport height and is then capped at width / 1920, so a screen is about 1080 ui units tall and exactly 1920 units wide on a 720p laptop, a 1440p monitor and a 4K television alike. What escapes that normalisation is a surface wider than 16:9, a surface small enough that the scale's floor caught it, and the player's UI-scale multiplier — which is the point: a screen that reflows in the compact band survives an accessibility setting (§2.5, §2.12).

A rule inside @size may have one combinator of its own: the scope spends the other of the two.

At-rules

at-ruleexample
@tokens@tokens { --ink-900: #0f1319; }
@theme <name>@theme dark { --surface: var(--ink-900); }
@size <class>@size compact { .row { flex-direction: column; } }
@font-face@font-face { font-family: "Score"; src: url("ui/fonts/score.fnt"); }

@media, @import, @keyframes and @supports are refused, each with the reason and the alternative. @font-face takes font-family and one src: url("ui/…") and nothing else; see "Bitmap fonts and @font-face" in §4 for what the file may be.

4. Every property

Generated from the parser's own tables. The same document is what U3 serves at GET /ui/schema, and it is the copy to trust if this file and the engine ever disagree: argus_ui::style::schema() produces it, and cargo run -p argus-ui --example schema prints it. The parser's dispatch and the schema are two exhaustive matches over one list, so a property cannot exist in one and not the other.

propertyvaluesinitialinheritstransitions as
displayflex | block | grid | noneflex——
flex-directionrow | column | row-reverse | column-reverserow——
flex-wrapnowrap | wrap | wrap-reversenowrap——
justify-contentauto | flex-start | start | flex-end | end | center | stretch | space-between | space-around | space-evenlyauto——
align-itemsauto | flex-start | start | flex-end | end | center | stretch | baselineauto——
align-selfauto | flex-start | start | flex-end | end | center | stretch | baselineauto——
align-contentauto | flex-start | start | flex-end | end | center | stretch | space-between | space-around | space-evenlyauto——
gap<length>{1,2}0——
row-gap<length>0——
column-gap<length>0——
flex-grow<number>0——
flex-shrink<number>1——
flex-basis<length> | autoauto——
order<integer>0——
positionrelative | absoluterelative——
inset<length-or-auto>{1,4}auto—position
top<length> | autoauto—position
right<length> | autoauto—position
bottom<length> | autoauto—position
left<length> | autoauto—position
width<length> | autoauto—size
height<length> | autoauto—size
min-width<length> | autoauto——
min-height<length> | autoauto——
max-width<length> | autoauto——
max-height<length> | autoauto——
aspect-ratio<number> | <number> / <number> | autoauto——
padding<length>{1,4}0——
padding-top<length>0——
padding-right<length>0——
padding-bottom<length>0——
padding-left<length>0——
margin<length-or-auto>{1,4}0——
margin-top<length> | auto0——
margin-right<length> | auto0——
margin-bottom<length> | auto0——
margin-left<length> | auto0——
overflow(visible | hidden | scroll | auto){1,2}visible——
grid-template-columnsnone | <track>+ where <track> = auto | min-content | max-content | <length> | <percentage> | <number>fr | repeat(<integer>, <track>+)none——
grid-template-rowsnone | <track>+ where <track> = auto | min-content | max-content | <length> | <percentage> | <number>fr | repeat(<integer>, <track>+)none——
grid-columnauto | <integer> | span <integer> | <start> / <end>auto——
grid-rowauto | <integer> | span <integer> | <start> / <end>auto——
background-color<color>transparent—background-color
background-imagenone | linear-gradient(<angle>, <color>{2,4})none——
border<length> solid <color>0 solid transparent——
border-width<length>0——
border-color<color>transparent—border-color
border-stylesolidsolid——
border-image-slicenone | <number>{1,4} | <percentage>{1,4}none——
object-fitfill | contain | coverfill——
border-radius<length>{1,4}0——
border-top-left-radius<length>0——
border-top-right-radius<length>0——
border-bottom-right-radius<length>0——
border-bottom-left-radius<length>0——
opacity<number 0..1>1—opacity
box-shadownone | <offset-x> <offset-y> <blur> <color>none——
color<color>whiteyescolor
font-size<length> | <percentage>16pxyes—
font-weight100..900 by 100 | normal | bold400yes—
text-alignstart | center | endstartyes—
font-family<family-name> | <string> | var(<token>)the chain's first fontyes—
font-stylenormal | italicnormalyes—
line-heightnormal | <number> | <length>normalyes—
letter-spacing<length>0yes—
white-spacenormal | nowrap | prenormalyes—
text-overflowclip | ellipsisclipyes—
font-variant-numericnormal | tabular-numsnormalyes—
text-shadownone | [<offset-x> <offset-y> <blur>? <color>?]#noneyes—
transformnone | translate(<length>[, <length>]) | scale(<number>[, <number>]) | rotate(<angle>)none——
translate<length>{1,2}0 0—translate
scale<number>{1,2}1 1—scale
rotate<angle>0deg——
animationnone | <preset> [<time>] [<time>] [infinite]none——
transition<property> <time> [<easing>] [<time>]{,}none——
transition-property<property>{,}none——
transition-duration<time>{,}0s——
transition-delay<time>{,}0s——
transition-timing-functionlinear | ease | ease-in | ease-out | ease-in-outease——
z-index<integer>0——
-argus-hairlinetrue | falsefalse——
max-linesnone | <integer>noneyes—
directionltr | rtlltryes—
caret-color<color> | autoautoyes—
selection-color<color>transparentyes—
-argus-scrollbar-colorauto | <color>auto——
padding-inline-start<length>0——
padding-inline-end<length>0——
margin-inline-start<length> | auto0——
margin-inline-end<length> | auto0——
object-view-boxnone | xywh(<number>px|<percentage>{4})none——
image-renderingauto | smooth | pixelated | crisp-edgesautoyes—
-webkit-text-stroke<length> [<color>] | <color> <length>0 currentcoloryes—
-webkit-text-stroke-width<length>0yes—
-webkit-text-stroke-color<color> | currentcolorcurrentcoloryes—

Notes the table cannot carry:

  • display: grid lays out from U4 (§11's wave-D table). It was refused through U1–U3 with a diagnostic, and taffy was compiled without its grid feature so the refusal could not decay into a silent flex fallback; the feature and the refusal moved together. What is still refused is named lines, grid-template-areas and repeat(auto-fill | auto-fit, …), each with its reason.
  • A grid-column on an item whose parent is not a grid does nothing, and CSS says nothing about it. This subset says ui.layout.grid-placement-ignored, because §2.2's rule is that nothing is silently dropped.
  • border-width is one width for all four sides. The instanced quad carries one, and a per-side border is a four-quad problem the shader would have to solve differently (§2.5).
  • box-shadow is outer only. inset is refused.
  • -webkit-text-stroke and text-shadow are for text, on outline and bitmap fonts alike; see Outlined text.
  • rotate is in the style model and not in the U1 instance format, so a rotated node draws unrotated and reports ui.draw.rotate-unsupported. Use translate and scale.
  • -argus-hairline: true exempts a node's border from UI scaling, so a one-pixel rule stays one device pixel. At fractional scale factors a scaled one-pixel rule blurs instead of staying sharp (§2.5).
  • A side longhand sets one side. padding-left leaves the other three alone; it does not reset them the way lowering it onto the shorthand would.
  • The -inline- sides are logical and mirror with the locale (§2.11). start is the edge a reader begins at — left under an LTR locale, right under an RTL one — and end is the other. The physical padding-left and margin-left never mirror, which is what makes them the right thing to write for something that is about the screen rather than about the text. Where both are written, the logical one wins over the physical side it lands on.
  • opacity is per instance. Group opacity that needs an offscreen layer is U4; where a faded subtree overlaps itself the overlap draws lighter than it should, and says so.
  • caret-color and selection-color belong to text-field and are ignored everywhere else. Both inherit, so a themed panel sets every field it contains with one rule. caret-color: auto is the node's own color, as CSS defines it. CSS spells the selection ::selection { background }; there are no pseudo-elements here (§2.2), so it is a property.

Units

unitmeaning
pxlogical pixels, unscaled — for a literal pixel such as a hairline
uilogical pixels times the root UI scale (§2.5) — the default unit for anything the player reads
%percent of the parent's corresponding axis
degdegrees, clockwise from up
msmilliseconds
sseconds
fra share of a grid track's leftover space; only inside grid-template-columns and grid-template-rows (§11's U4 table)

A bare 0 is a length. Any other bare number where a length is wanted is an error asking for a unit — there is no implicit pixel.

Use ui for anything a player reads. It is a logical pixel times the root UI scale, so it follows the resolution and the player's accessibility multiplier. px is for a literal pixel: a hairline, a one-pixel accent.

writtenthis subset wants
emuse ui, which scales with the player's UI setting rather than with a font
remuse ui: 1rem of a 16px root is 16ui
vhuse % of a parent that fills the screen; the screen root is the surface
vwuse % of a parent that fills the screen
vminuse %
vmaxuse %
chuse ui
exuse ui
ptuse ui
cmuse ui
mmuse ui
inuse ui

Functions

functionmeaning
var(--name)a design token: a primitive by name, or one of the eleven semantic names, which are reserved
rgb(r g b)channels 0..255 or percentages
rgba(r g b / a)channels 0..255 or percentages, alpha 0..1
linear-gradient(<angle>, <color>{2,4})two to four stops (§2.2)
translate(x[, y])inside transform
scale(x[, y])inside transform
rotate(<angle>)inside transform
repeat(<integer>, <track>+)inside grid-template-columns and grid-template-rows; expanded at parse time, so auto-fill and auto-fit are refused
writtenthis subset wants
calc()no calc() in U2 (§2.2); compute the number and write it, or name a token
clamp()no calc() family in U2; write the value, or use min-width/max-width
min()no calc() family in U2; use max-width
max()no calc() family in U2; use min-width
url()an atlas slot is resolved by the renderer; put the image on an image node
hsl()write the colour as a hex literal in a token, or use rgb()
cubic-bezier()§2.8 fixes the easing set: linear, ease, ease-in, ease-out, ease-in-out
blur()no filters: §1.2 measured backdrop blur as full-surface repaint cost

Colours

#rgb, #rgba, #rrggbb, #rrggbbaa, rgb(15 19 25), rgba(15 19 25 / 0.5), transparent, or a token. There are no named colours — red in one theme is a different red in another — and a component should name a semantic token: var(--danger).

Colours are written in sRGB and held in linear light. A hex literal's channels are divided by 255 and then decoded through the standard sRGB transfer, which is what the render pass expects: it takes linear light and encodes once on the way to the screen, so a channel handed through unchanged would be encoded twice and arrive lighter — #808080 reached the screen as 0xbc before this was fixed. Alpha is a coverage fraction, not light, and is not decoded. rgb() and rgba() channels go through the same transfer, and so do the built-in token tables, so a literal and a token still mean the same colour. GET /ui/schema publishes a token back in sRGB, the space it was written in, so a value read from it can be written straight into a stylesheet.

The UI pass is linear in, sRGB out: it takes a colour as linear light, like every other colour crates/render draws, and the encode happens once on the way to the target. Nothing on the path is tonemapped. crates/ui/tests/colour.rs pins the decode and crates/app/tests/ui_colour.rs pins the pass's convention, so the two halves are measured to meet.

Black and white are fixed points of the transform, which is why nothing caught it: the stock HUD draws in white with a black shadow. Do not correct for it by hand — a palette adjusted to look right today becomes wrong the day the parser is fixed.

Fonts and fallback

Talos never looks a font up on the system. Every glyph comes from bytes the project handed the renderer, so a screen shapes to the same glyphs on your machine, on the build server and on a player's console. That is also why a font is a checked-in asset with its licence recorded beside it, the way fixtures/render/fonts/README.md records the four the engine ships.

A project's fonts live in ui/fonts/**, read from the project root whatever folder the scene is in. They are loaded in the order fonts.json gives, and the engine's own defaults are appended underneath — so a project chain extends the engine's rather than replacing it, and a string the project's fonts cannot draw still reaches Noto rather than coming out as tofu.

Talos ships three small starter fonts to copy, all SIL OFL 1.1: Inter-Regular.ttf (body), Inter-SemiBold.ttf (headings; family name "Inter SemiBold") and JetBrainsMono-Regular.ttf (numbers, timers), in fixtures/render/fonts/ of a checkout and <sdk>/fixtures/render/fonts/ of an install, with OFL-Inter.txt / OFL-JetBrainsMono.txt to copy beside them. They cover Latin, Latin Extended-A and common punctuation; the rest of the chain covers other scripts only as described below. Copy them rather than a font from the machine's own font folders: system fonts are rarely redistributable, and a damaged copy is skipped with ui.font.unusable (the build says build.font-unusable) instead of drawing. Any other face a screen names is one the project brings, a .ttf with its licence recorded beside it, into ui/fonts/.

The default face is proportional. A project that ships no font of its own draws its text in Inter (Inter-Regular.ttf), and its export packs that file with OFL-Inter.txt beside it. DejaVu Sans Mono, the default until October 2026, is no longer in the engine's fallback chain (#5858): as a general-purpose last fallback it took over every run that did not name an exact family at an exact weight, so a project shipping single-weight faces drew its whole UI in monospace. Greek, Cyrillic and the symbols Inter's subset leaves out have no engine fallback at run time until that coverage comes back another way; an export still packs DejaVu when a shipped language needs it (argus::EXPORT_FALLBACK_FONTS). A screen laid out for the old monospace default (score columns, timers lined up by character) copies JetBrainsMono-Regular.ttf into ui/fonts/ and names it with font-family: "JetBrains Mono".

The engine's defaults are found in a checkout (fixtures/render/fonts), and they are subsets cut to the repository's fixture strings, not whole scripts. So they are no substitute for a language: a project that ships ui/locales/ja.json ships a Japanese font in ui/fonts/ too. Nothing behind the project's own chain exists on a player's machine, so both exporters refuse (project-invalid) a build in which some shipped locale — its strings, its own name and its number separators — needs a character no font under ui/fonts/ maps, and name the locale and the characters. Every load warns the same way (ui.locale.missing-glyphs), so a checkout run says it too.

ui/fonts/fonts.json is an object with these two members:

"format": 1,
"fonts": [
  { "file": "Display.ttf" },
  { "file": "ArgusNotoSansSC.ttf", "scripts": ["han", "kana", "hangul"] },
  { "file": "ArgusNotoSansArabic.ttf", "scripts": ["arabic"] }
]

scripts is a preference, not a claim: it says this font is the one meant for those scripts, and it is read before chain order. Naming a script a font does not actually have costs nothing but the declaration, because coverage still decides. The names are latin, greek, cyrillic, arabic, hebrew, devanagari, thai, han, kana, hangul, symbol, other, and common — which is the one that is not a script. Spaces, digits and punctuation are common and belong to whatever surrounds them, which is why 分数 12 shapes in the Chinese face throughout instead of changing font twice and losing its kerning.

A character resolves through the chain in this order:

  1. the family a stylesheet asked for with font-family, if it has that character;
  2. the first font that declares the character's script and has it;
  3. the first font in chain order that has it at all;
  4. the chain's first font, and then the shaper's own walk of every loaded face.

So font-family names where the chain starts, not the whole of it. A display face on a heading does not turn the Chinese in that heading into tofu; the Chinese resolves past it. That is also why font-family takes one family and refuses a comma-separated list: the project's font list is already the fallback list, and a second one written per rule would contradict it with no rule for which wins. The generic families — sans-serif, monospace and the rest — are refused by name, because there is nothing on the system for them to mean.

Resolution is per script run, not per string, and that is not a detail. The engine's default font has every Arabic codepoint and none of the joining rules, so a chain that only stepped in for a missing glyph would leave every Arabic word in its isolated forms — a word no reader recognises, and one that no test asserting a width would catch. Splitting the run at the point where the chain would choose differently is what fixes it.

If no font in the chain has a character, nothing is silent. The renderer reports render.text.missing-glyph naming the character, its script and the whole chain that was searched, once per character rather than once per frame:

argus-render: render.text.missing-glyph: no font in the chain [Inter, Noto Sans Arabic,
DejaVu Sans Mono] has 'ते' (U+0924), drawn from "नमस्ते"; add a font that covers devanagari to the
project's `ui/fonts` list

A track image for a minimap: argus minimap-bake

Run argus minimap-bake scenes/main.scene.json to get ui/images/minimap.png and ui/images/minimap.json for a HUD minimap.

The image is the scene's roads seen from above. They are the ribbons the engine builds, kerbs included, at each road entity's place. The road is drawn in --color (white by default) with smooth edges, and everything else is transparent. minimap.json gives the ground area the image shows, { minX, minZ, maxX, maxZ, width, height }, in metres and pixels. minX is the image's left edge and minZ its top. Pass those four as the minimap's bounds, so markers placed from world x and z land on the road.

flagdefaultwhat it does
--out <png>ui/images/minimap.png in the scene's projectwhere the image goes; the bounds go beside it, .json for .png
--size <px>512the longer edge, 16 to 2048; the other edge follows the track's shape
--padding <m>4ground left around the roads on every side
--color <#rrggbb>#ffffffthe road's colour

The same scene and size always give the same bytes, on any machine, so a baked image can be committed and checked. Bake again after editing a road. A scene with no road component is refused.

Sprite sheets and pixel art

An image node draws one frame of a sprite sheet with object-view-box, CSS's own property for showing part of an image:

.coin        { width: 32ui; height: 32ui; image-rendering: pixelated;
               object-view-box: xywh(0 0 16px 16px); }      /* frame 0 of a 16-texel strip */
.coin.spin-1 { object-view-box: xywh(16px 0 16px 16px); }   /* frame 1 */
.meter       { object-view-box: xywh(0 25% 100% 25%); }     /* the second of four rows */
  • The four values are x, y, width and height of the region in source texels (px, or a bare number) or as a percentage of the image's own edge. ui is refused: a cell of a sheet is a fact about the file and does not move with the UI scale.
  • The region is then treated as the whole image: object-fit fits it, a percentage border-image-slice measures against it.
  • Switch frames with a class, or from a behaviour with ctx.ui.set(id, "style:object-view-box", "xywh(32px 0 16px 16px)"). A region past the edge of the image is clamped and reported once as ui.draw.view-box-outside, which is usually a frame index one past the end of the strip.
  • background-position and background-size are not in the subset: a bitmap belongs on an image node (url() in a stylesheet is refused), and on an image node object-view-box is the one property that picks a region.

A segmented meter is a row of image nodes, each showing the "full" or the "empty" cell, and a behaviour setting each one's class; a digit counter is a bitmap font (below).

image-rendering: pixelated samples the nearest texel, so a 16-texel sprite drawn at 64 pixels stays sixteen hard squares a side instead of a blur. auto (the default) filters linearly; crisp-edges is read as pixelated and smooth as auto, because the renderer has exactly those two filters. It is inherited, so one rule on a HUD's root covers every sprite and every bitmap-font glyph under it. The renderer chooses the filter per quad, so a pixelated icon and a smooth portrait still draw in one batch. For crisp pixel art also draw at a whole multiple of the image's size.

Bitmap fonts and @font-face

@font-face gives a font file a family name, and that name is what font-family uses:

@font-face { font-family: "Title";  src: url("ui/fonts/PressStart2P-Regular.ttf"); }
@font-face { font-family: "Score";  src: url("ui/fonts/score.fnt"); }
@font-face { font-family: "Digits"; src: url("ui/fonts/digits.json"); }

.hud-score { font-family: "Digits"; font-size: 24px; image-rendering: pixelated; }
  • A .ttf or .otf is an outline font. The rule renames it: font-family: "Title" reaches the face whose file declares "Press Start 2P". A file outside ui/fonts/ is added to the project's chain too, behind the fonts ui/fonts/ holds.
  • A .fnt is a BMFont text descriptor (what AngelCode BMFont, Hiero, and most bitmap-font tools export): one atlas page, a common lineHeight, one char line per glyph with its cell, offsets and advance, and optional kerning pairs. The page image sits beside the descriptor.
  • A .json is a glyph grid, the simplest bitmap font: an image cut into equal cells, left to right and then down, one character per cell: jsonc { "image": "digits.png", "cell": [8, 12], "chars": "0123456789" } columns (cells per row; one row when absent), advance (the cell width), lineHeight (the cell height), origin ([x, y] of the first cell) and gap ([x, y] texels between cells) are optional.

A bitmap font is drawn by the UI itself, as one textured quad per glyph from the font's atlas image, tinted by color — so draw the cells white and colour them in CSS. Its native size is its line height in texels, and font-size scales it: an 8-texel font at font-size: 16px is drawn at ×2. Pick a whole multiple and set image-rendering: pixelated for pixel art. Layout measures a bitmap run with the same numbers its quads are drawn with; text-align, letter-spacing, line-height, wrapping at spaces, max-lines and text-overflow: ellipsis (when the font has a . cell) all apply. Inline spans and font-weight/font-style do not: a bitmap font is one face. A character with no cell is skipped with the font's space advance and reported once per node as ui.font.glyph-missing.

@font-face takes exactly font-family and src. A list of sources is refused, for the reason a font-family list is: the project's chain is already the fallback list. A path outside ui/, or with a .., is refused; a file that is missing or does not parse is reported at load as ui.font.face, and text in that family then draws in the chain's font. The critic says so before the engine runs: ui-font-face-missing (an error) names a rule whose file, or whose bitmap font's atlas image, is not in the project, and ui-default-font counts a family an @font-face backs with a file as the project's own face.

Wrapping, overflow and line limits

A text node is laid out into the box its parent gives it, and by default it wraps inside that box and spills past it if a single word cannot fit. Three properties change that.

white-space: nowrap keeps a run on one line. pre does the same and additionally says the string's own spacing is significant — they behave identically today, because Talos never collapses runs of whitespace in the first place. A string here is authored or comes from a locale table rather than being extracted from indented markup, so there is no collapsing for pre to switch off; the two values differ in what they tell the next reader.

text-overflow: ellipsis cuts a run to what fits and shapes a single … onto the end of it. It needs a box to cut against, so it does nothing on a run with no width — as in CSS. On its own it cuts each line of a nowrap or pre run that is too wide, and keeps every line: those two still break at the string's own newlines, and a line that fits is left alone. With max-lines it cuts the last line kept.

max-lines: <integer> is how many lines a run may show. It is CSS's line-clamp under the name §7 of the plan gave it; the -webkit-line-clamp spelling is not taken, because that one only works inside display: -webkit-box and this subset has no such display.

.results__note {
  white-space: normal;
  text-overflow: ellipsis;
  max-lines: 3;
}

A truncated run says so. GET /ui publishes truncated: true on the node, so the critic's ui-ellipsis check reports what was actually cut rather than inferring it from a clip rectangle that happened to be narrower than the run. Where the whole string matters — a shortfall of 12/30 cut to 12/ is the unscannable case the requirement contract exists to prevent — mark the node data-whole="true" and that check turns from a warning into an error.

line-height takes normal, a bare number or a px length. Prefer the bare number: it is a multiple of the element's own font size, so it survives a change of size, which a fixed length does not. letter-spacing takes a length and may be negative.

Timers and scores want font-variant-numeric: tabular-nums. It asks the font for its equal-width digits (OpenType tnum), so 1:11.111 is as wide as 8:88.888 and a counting label keeps its width. That matters twice: the text does not jiggle as it counts, and a text write whose width does not change is redrawn without the screen being laid out again. A font with no tnum (the engine's DejaVu Sans Mono, which is monospace anyway) draws its ordinary digits and nothing is reported; a bitmap font draws its own glyphs and ignores it. Absent, a run shapes exactly as before.

.race-timer { font-variant-numeric: tabular-nums; }

Direction and bidi

Arabic and Hebrew shape right to left without being asked: the Unicode Bidirectional Algorithm reads the string and the shaper follows it, so a run that contains a strong right-to-left character already reads correctly.

direction: ltr | rtl is for the case the string cannot answer by itself. A run that is all neutrals — 12 - 3, a time, a score — has no strong character, so which way round it reads is the paragraph's direction and nothing else. direction also decides which physical side text-align: start and end mean.

It is the run's direction, and it inherits. A screen root inherits it from the active locale, so under a right-to-left locale every run is rtl unless something says otherwise. Mirroring the screen's layout is §12's "RTL: what mirrors and what does not", and follows the root's direction. Write start and end rather than left and right in text-align, and the run follows whatever direction is in force.

Rich runs

A run can carry inline emphasis. <span> inside a <text> marks one piece of it:

<text id="score" class="hud__score">
  Score <span weight={700}>{score}</span> of {total}
</text>

That is one text node and one shaped paragraph, not three boxes side by side, and the difference shows the moment the box narrows: one line-breaking pass means the bold number wraps with its sentence instead of being laid out as its own unbreakable box, and the kerning across the boundary is the font's rather than the layout engine's guess.

A span is a stretch of its parent's run rather than a box, so it has no id, no class, no data attributes, no layout and no children. It takes text or textKey (or a child that is either), weight, italic, tone and family, and nothing else. It carries a tone rather than a colour for the same reason every component does: a literal colour written into a screen is a colour no theme can reach.

Each piece keeps its own text, so a key inside a span resolves exactly as a key on a node does and a rich run is as localisable as a plain one.

One rule is worth stating because it looks like an omission. <text>Score {t("hud.points")}</text> — a bare literal beside a translated key, with no span — is still refused, as it was before rich runs existed. A hardcoded word in a run a player reads is a localisation bug, and rich runs are not the loophole that removes the check. An author who means to mix says so with a span, which is deliberate and visible in review.

Outlined text

HUD text has to read over whatever the scene is doing, and the way games make it read is an outline. Two properties do it, both from CSS and both inherited, so one rule on a HUD's root outlines every label under it:

.hud { -webkit-text-stroke: 2px #000000; }                  /* an outline */
.hud__toast { text-shadow: 2px 2px #000000; }               /* a drop shadow */
.hud__title { text-shadow: 0 0 6px rgba(0 0 0 / 0.8); }     /* a soft glow */
  • -webkit-text-stroke: <width> [<color>] draws an outline width out from every glyph. It is painted behind the glyphs, like paint-order: stroke in a browser, so the letters keep their weight and the whole width shows outside them. A missing colour is the run's own color (currentcolor); -webkit-text-stroke-width and -webkit-text-stroke-color set one half each, and -webkit-text-stroke-width: 0 turns an inherited outline off. A width is a length in px or ui; a percentage is refused.
  • text-shadow: <x> <y> [<blur>] [<color>] draws a copy of the glyphs offset by x, y, under the outline. Up to four, comma-separated, the first on top; more are refused (ui.css.value-range). A blur of 0 is exact. A larger blur is approximated by nine copies spread over half its radius, each faded so the nine together reach the shadow's own alpha: a soft edge, not a Gaussian. text-shadow: none turns inherited shadows off.

How it is drawn. The glyph atlas holds coverage, not distance fields, so an outline is copies of the run drawn on rings around it, under it. For an outline font the renderer draws the one shaped run again at each position, in the same text band: nothing is reshaped and no batch is added, and the copies are at most a device pixel apart, so the edge is smooth. The ring is round. For a bitmap font the UI draws each glyph's cell again as a silhouette — the cell's alpha in the stroke colour, so a font drawn in colour still gets an outline in exactly the stroke colour — on a square of offsets, which is the outline pixel art has. The silhouettes batch with the glyphs and the sprites around them. Either way the cost is instances: a 2-device-pixel outline is 13 copies of a run for an outline font and a 4-pixel one 39, and a w-pixel outline is (2w + 1)² − 1 copies of every glyph of a bitmap font (capped at four rings, so a wide one spaces its copies out rather than adding more).

Two consequences of drawing copies. A translucent stroke colour draws denser than its alpha where copies overlap, so prefer an opaque outline and fade the node with opacity instead. And per-instance opacity (§2.5) fades each copy on its own, so a faded outlined label shows its outline through its glyphs.

The critic knows about outlines: ui-contrast holds outlined text to its outline. Text over the game has no background the critic can know, and an outline of at least one logical pixel is exactly what makes that not matter — so outlined text that clears the threshold against its outline passes. A shadow does not count; it is behind one side of each glyph. GET /ui reports both on a node's style, as text_stroke and text_shadow, and leaves them out when there are none.

Refused on purpose

Each of these is a property the parser knows and will not apply. deferred names the milestone that adds it; refused is a decision. Several come straight from the reference project's measured ban-list, which §1.2 reads as close to a specification for this subset — refusing at parse time beats scanning the source for it afterwards.

propertystatusinstead
flexrefusedwrite flex-grow, flex-shrink and flex-basis; the shorthand's three implied defaults are the commonest source of a layout nobody can explain
overflow-xrefusedwrite overflow: <x> <y>; the runtime stores both axes in one value, so setting one alone would silently reset the other
overflow-yrefusedwrite overflow: <x> <y>; the runtime stores both axes in one value, so setting one alone would silently reset the other
backgroundrefusedwrite background-color or background-image; the shorthand resets seven properties, five of which do not exist here
text-transformdeferred (U4)write the string as it should read, or use a meta.strings key per locale — case mapping is locale-dependent (§2.11)
gridrefusedthe grid shorthand sets eight properties including grid-template-areas, which needs named areas; write grid-template-columns and grid-template-rows
grid-areadeferred (a later milestone)grid-area names an area from grid-template-areas, which the subset does not carry; write grid-column and grid-row, which place by line number
floatrefuseduse flex: display: flex with flex-direction: row
box-sizingrefusedevery box is already border-box: padding and border are inside the width
cursorrefusedcursor visibility is a property of the topmost screen that wants input (§2.9), not of a node
visibilityrefuseduse display: none, or opacity: 0 for something that still takes up space
transform-originrefusedscale is about the box centre and that is the only origin there is
filterrefusedno filters and no backdrop blur: §1.2 records the reference project measuring backdrop blur as full-surface repaint cost
backdrop-filterrefusedno filters and no backdrop blur: §1.2 records the reference project measuring backdrop blur as full-surface repaint cost
will-changerefusedthe runtime already knows what changed — the three dirty flags in §2.13 are the whole of it — and a hint here can only be wrong
outlinerefusedthe focus ring is drawn from the focus-ring token on :focus-visible; use border-color or box-shadow
contentrefusedthere are no pseudo-elements; author the text as a text node
containrefused§1.2 records layout containment on a scroll container costing several hundred milliseconds of input lag in the reference project

5. Transitions

.btn {
  transition: background-color 120ms ease-out, opacity 200ms ease 50ms;
}

An entry is <property> <duration> [<easing>] [<delay>]. The four longhands (transition-property, -duration, -delay, -timing-function) also work and are combined across the block the way CSS combines them, but the shorthand is the better form.

The properties that can be transitioned are a fixed allowlist: opacity, translate, scale, color, background-color, border-color, width/height (together, as size), and top/right/bottom/left/inset (together, as position). transition: all is refused — the reference project banned it with a measured reason — and transform is refused pointing at translate and scale, which animate separately.

The easing set is fixed: linear, ease, ease-in, ease-out, ease-in-out. An arbitrary cubic-bezier() is a way for every screen to ease differently, and nothing about that makes a game feel better.

Transitions are driven in Rust on the frame clock, never from script. A frame in which only transitions are running does not rebuild the tree, and a fade does not go back through layout — only size and position do. An interrupted transition restarts from where the property is, so a half-faded button whose pointer leaves does not flash brighter on the way out. Two lengths in different units have no midpoint; those snap at the end of the duration and say so.

Reduced motion is a player setting the runtime honours, and no screen can opt out: a property takes its new value on the frame the cascade produced it rather than animating towards it.

Motion presets

.title__badge { animation: bob 1.2s infinite; }
.banner.on { animation: slam-in 450ms; }
.place.up { animation: pop; }

animation: none | <preset> [<duration>] [<delay>] [infinite] plays one of §2.8's named presets: an engine-owned curve, not author keyframes (@keyframes stays refused). The first time is the duration (each preset has a default), the second the delay (the preset holds its first frame meanwhile), and infinite loops for as long as the rule matches.

presetwhat it doesdefault
popscales 0.6 → 1.08 → 1 and fades in: something arrives350ms
slam-indrops in from 80ui above, overshoots, settles450ms
slide-in-up / -down / -left / -rightin from 60ui away on that side350ms
fade-inopacity in300ms
pulsescale 1 ↔ 1.06 (loop it): ready to press1.2s
bobup and down 6ui (loop it): idle1.2s
shakea short sideways jitter: refused, lost400ms
stampfrom 1.8x and clear to full size with a thump: a number, a record450ms

A preset starts when its rule starts to match — on mount, or when a class lands — and a one-shot ends exactly at the stylesheet's own values, so adding a class is how a script plays one. It moves opacity, translate and scale at paint time only: it never re-runs layout, never rebuilds the tree, and composes with a running transition (the preset offsets whatever the transition wrote). A translate offset is added in ui, so it applies to a node whose translate is zero or in ui. Time is the UI frame clock alone: the same frames give the same picture, and a preset never reaches the simulation or a replay. Reduced motion skips presets entirely: the node is drawn at its end state and a loop does not run. An unknown name is refused (ui.css.unknown-value) with the list. There is no rotating preset yet: the UI renderer does not draw rotate (ui.draw.rotate-unsupported).

6. Pushing values in

A behaviour never walks the tree. It writes values, and the runtime rebuilds only what changed (§2.7).

ui.set(&"hud:timer".into(), "text", BoundValue::text("01:23"));
ui.set(&"menu:start".into(), "disabled", BoundValue::flag(true));
ui.set(&"hud:bar".into(), "style:width", BoundValue::css("40%"));
propertyvaluewhat it costs
text, text-keytext or a numberthat node re-measured, the draw list rebuilt. No cascade
disabled, selected, checkeda booleanthat node's subtree re-resolved
data-<name>text or a numberthat node's subtree re-resolved
class:<name>a boolean, to add or removethat node's subtree re-resolved
style:<property>CSS value textthat node's subtree re-resolved

Nothing on that list changes structure. A binding cannot add a node, remove one or reorder a list, because that is a different tree: those go through a re-render and are diffed by id.

Three rules, each of which was a bug in the reference project before it was a rule:

  • An unchanged value costs nothing. The table holds the last value written and compares before doing anything. A HUD publishing its score at 60 Hz pays on the frames it changed.
  • A snapshot is the whole of what a screen shows. apply_snapshot clears any binding it leaves out, so the node goes back to the value it was authored with and a value that stops being published stops being shown. apply_delta leaves the others alone, and is for a list long enough that resending is wasteful.
  • A re-render forgets every binding, because the render is the author saying what the screen is now.

Every binding mistake — an id the screen does not hold, a property that is not bindable, a value of the wrong kind — is reported once and not again until the screen is re-rendered.

7. The description JSON

This is the frozen contract between packages/ui and crates/ui. Read it if you are writing a scenario by hand, generating a screen from another tool, or reading one back.

{
  "format": 1,
  "screen": "main-menu",
  "root": {
    "role": "container",
    "classes": ["screen"],
    "data": { "theme": "sim" },
    "props": { "inputContext": "menu", "modal": true, "dismissible": false },
    "children": [
      {
        "role": "button",
        "id": "start",
        "classes": ["btn"],
        "data": { "variant": "primary" },
        "props": {},
        "children": [
          { "role": "text", "classes": [], "data": {}, "textKey": "menu.start",
            "props": {}, "children": [] }
        ]
      }
    ]
  }
}

A node's keys, in this order: role, id, key, classes, data, text, textKey, spans, props, children. role is required and is one of the ten. text and textKey are mutually exclusive. classes, data, props and children are always written by the canonical writer and may be left out by hand.

spans is a rich run's inline pieces, on a text node only, following that node's own text or textKey (§4, "Rich runs"). Each entry takes text or textKey, and optionally weight, italic, color and family:

"role": "text",
"id": "score",
"text": "Score ",
"spans": [
  { "text": "370", "weight": 700 },
  { "textKey": "hud.of-total", "color": "var(--muted)" }
]

A span's color is always a token reference — var(--accent) and the other semantic names, or a primitive. A literal colour is refused, because a colour written into a screen is a colour no theme can reach.

props takes disabled, selected, checked, focusable (booleans), tabIndex (a whole number), hit ("auto" | "none" | "always"), nav ({up, down, left, right} of node ids), image (a path, on an image node only), style (property to CSS text), the text field's seven, initialFocus (a boolean) and emit ({ "event": "start-game", "data": … }, what onClick={emit(…)} describes to, with data left out when there is none). Anything else is an error, including a misspelling.

The screen's flags live on the root node's props: inputContext, modal, dismissible, clickThrough, cursor, pausesWorld. They are an error anywhere else. A flag equal to its default is left out. The root node is the screen, which is what §2.9's definition says.

A nav target without a : is resolved against the screen it is on, so "down": "start" means main-menu:start.

A description with an error produces no screen. Hot reload then keeps the screen that is already up, which is a working screen, rather than swapping in one missing a node. The diagnostics carry the file, line and column of the offending JSON.

That covers the three ways a description can be self-contradictory rather than malformed, each of which used to read cleanly and go wrong later:

  • Two nodes claiming one id is ui.tree.duplicate-id. Ids are flat within a screen and are what every binding, nav target, test and query resolves against, so the second node cannot have the name. The runtime raises the same code when it builds a tree; the reader's carries a line and column, and refuses before a screen exists to install.
  • Two siblings claiming one key is ui.describe.value. A key is what keeps a row's identity across a reorder, and two rows sharing one means the row that survives is whichever the diff reached first. Siblings only: two rows in two different lists may both be "k".
  • An id on the root node is the warning ui.describe.root-id, and the id becomes an alias of <screen>:root. See §2.

Caps: 4 MB, 20,000 nodes, 64 levels of nesting. A description past any of them is a generator that got away, and is refused with a message rather than by the allocator.

8. Reading a diagnostic

Every refusal carries a stable code, a message naming the thing that was wrong, the file, the line, the column, the offending token, and the nearest supported alternative. The last one is the half that turns a refusal into something you can act on, and it is required rather than customary (§2.2).

[ui.css.unknown-unit] `rem` is not a unit this subset has
  ui/styles/menu.css:14:12  `2rem`
  — use `ui`: 1rem of a 16px root is 16ui

Nothing is dropped in silence. If a declaration, a node or a value does not appear on screen, there is a diagnostic saying why. That covers the TSX as well as the stylesheet: an attribute the engine does not read is refused by name when the element is built rather than quietly ignored (§2), so a screen written in React's idiom fails with a sentence instead of rendering as though half of it had never been typed.

One mistake produces one diagnostic. A value that was refused does not also report the rest of itself as left over, and a refused entry in a comma-separated list does not repeat per entry — fixtures/ui/mistakes.css is a file whose every line is a different mistake, and the test over it asserts exactly that.

Per-frame conditions are reported once until the condition changes, because a draw-list refusal repeated at 60 Hz fills the sink in a second. The sink itself caps at 64 and reserves its last slot to say how many it dropped.

Codes

codeseverity
ui.tree.duplicate-iderror
ui.tree.invalid-iderror
ui.style.unknown-tokenerror
ui.style.token-cycleerror
ui.style.token-kinderror
ui.style.gradient-stopserror
ui.style.unsupported-valueerror
ui.style.root-size-ignoredwarning
ui.layout.grid-unsupportederror
ui.layout.failederror
ui.focus.nav-target-missingerror
ui.focus.target-missingerror
ui.focus.no-focusablewarning
ui.screen.missingerror
ui.screen.duplicateerror
ui.screen.stack-emptywarning
ui.screen.no-exitwarning
ui.transition.not-animatablewarning
ui.draw.rotate-unsupportedwarning
ui.draw.gradient-truncatedwarning
ui.draw.group-opacitywarning
ui.diagnostics.overflowwarning
ui.css.syntaxerror
ui.css.unknown-propertyerror
ui.css.unknown-valueerror
ui.css.unknown-uniterror
ui.css.unknown-functionerror
ui.css.unknown-selectorerror
ui.css.combinator-deptherror
ui.css.unknown-at-ruleerror
ui.css.importanterror
ui.css.media-queryerror
ui.css.calcerror
ui.css.value-rangeerror
ui.css.unknown-semanticerror
ui.css.unknown-size-classerror
ui.css.reserved-tokenerror
ui.css.deferred-propertyerror
ui.css.physical-sidewarning
ui.css.duplicate-declarationwarning
ui.describe.formaterror
ui.describe.unknown-roleerror
ui.describe.unknown-properror
ui.describe.valueerror
ui.describe.too-largeerror
ui.describe.model-view-subject.tentativewarning
ui.describe.model-view-value.tentativeerror
ui.model-view.count-cap.tentativewarning
ui.model-view.unresolved.tentativewarning
ui.describe.root-idwarning
ui.bind.unknown-nodeerror
ui.bind.unknown-properror
ui.bind.value-kinderror
ui.asset.image-missingwarning
ui.layout.grid-placement-ignoredwarning
ui.layout.grid-tracks-truncatedwarning
ui.scroll.not-scrollablewarning
ui.asset.atlas-budgetwarning
ui.locale.fileerror
ui.locale.missing-paramwarning
ui.draw.view-box-outsidewarning
ui.font.facewarning
ui.font.glyph-missingwarning
ui.layout.image-zero-sizewarning
ui.font.unavailablewarning

The table above is generated from the engine's own schema. @argus/ui — the TSX authoring package, which runs before the engine ever sees a description — raises one code of its own, and it is listed separately so that regenerating the table cannot drop it:

codeseverityraised by
ui.screen.scenarioswarningdefineScreen, for a screen with fewer than two scenarios (§2)

Everything else @argus/ui refuses is a thrown ScreenError rather than a code, because there is nothing to describe once a screen is refused: the message names the screen, the place in the tree and what to write instead.

9. The runtime in the engine

Sections 1 to 8 are authoring: what you write, and what crates/ui does with it. This is the other half — how the engine gives that runtime a clock, a text measurer, a renderer, a pointer and a keyboard, and how a behaviour and an agent reach it. crates/ui draws nothing and knows nothing about wgpu, winit or the ECS; crates/app's ui module is everything on the other side of that line.

Where it lives

The runtime is owned by Sim, which is also the HostWorld, so a behaviour that calls ctx.ui.push() and then ctx.ui.stack() in one tick sees its own write. Everything else a behaviour asks the engine for — a restart, a scene load, a HUD field — is recorded and acted on at the end of the tick, and for those that is right, because the caller is asking to change the world it is running inside. A screen stack is not that; it is state the caller reads straight back, and a queue would make two adjacent lines of script disagree.

The stock hud screen is registered and pushed when Sim is constructed. That is what makes "the HUD is the bottom entry" a fact rather than a convention: push appends to the top, so a HUD installed lazily on the first ctx.hud.set would land on top of whatever a behaviour's init had already pushed.

The frame

World::step                                     ← once per simulation tick
  └─ World::tick_ui(dt)
       ├─ set the surface    logical viewport = surface size ÷ device ratio
       ├─ sync the HUD       bind the rows whose text moved; rebuild only if the rows changed
       ├─ renderer.ui().set_scale(device ratio)      ← before anything is measured
       ├─ UiState::tick(dt, measure)                 ← layout, transitions, the publish
       └─ map the draw list onto UiQuad / UiText     ← a field copy and two enum matches

World::render                                   ← once per drawn frame, and only then
  ├─ World::tick_ui(0.0)    a repaint: a clean stack is a walk that finds nothing
  └─ renderer.set_ui(quads, texts, scale, viewport) ; renderer.draw(target)

The UI ticks on the simulation frame, not on the render. It used to run inside World::render and nowhere else, which meant an argus run --serve --headless engine — which renders during its initial frames and then serves without rendering — never laid out again: a screen pushed through POST /ui/push answered GET /ui with every node at [0,0,0,0] and visible: false for the life of the process. So the layout, the transitions and the publish are on the tick, where every subsystem's frame is, and drawing is still only in render.

A host-loop iteration that runs no tick calls World::tick_ui_idle instead, so a paused world still lays out and a menu's transitions still run. That is what keeps §2.8's frame clock rather than the sim clock: at scale 1 a tick is a frame; at scale 0.5 half the iterations step and the other half land in the idle tick; paused, all of them do.

Three orderings are load-bearing.

Scale before measurement. Renderer::measure_text shapes through the UI pass's own font stack at the pass's own scale, so the scale is set before anything is measured, never after. A stale one costs a frame of wrong layout on every resolution change, which is the kind of bug that only shows up on someone else's monitor.

The device ratio, once. §2.5 writes ui_scale as a clamped height-derived base × the surface DPI × the player's setting, and it also says layout is in logical pixels. Both are true only because UiScale splits them: units() carries the base and the player's setting and is applied inside layout, so the draw list is already in logical pixels, and device() carries the DPI ratio alone and is the only factor the renderer still needs. Passing units() × device() folds two of them in twice — correct at the reference surface, where both are 1.0, and wrong everywhere else.

The frame clock, not the sim clock. §2.8 drives transitions on the frame clock and requires that a frame in which only transitions run does not rebuild the tree. The tick therefore happens once per host-loop iteration — on the tick when one ran, in tick_ui_idle when none did — rather than once per rendered frame, because a headless --serve engine has iterations and no rendered frames. A world with no renderer still ticks, measuring text with a monospace estimate, so a headless run lays out and answers GET /ui exactly as a windowed one does.

The surface. Layout runs into the render target's size when there is one and into the load options' when there is not, which is what a headless run has. A run with no window takes that size from --viewport WxH, else from the project manifest's window block when the manifest declares one, else 640×360; POST /ui/viewport { width, height, dpr? } changes it while the engine runs, laying the UI out again before it answers. A sized POST /capture lays the screen out at the size it is capturing for that frame only and puts the layout back, so a screenshot is a picture of the screen at that resolution rather than of the engine's own screen in the corner of a bigger frame. That is the resolution axis of §3's matrix, over one engine.

The editor's viewport preview calls World::render like everything else, so the UI pass is in its texture by construction rather than through a second code path.

Input

The UI input layer sits above the existing ActionMapper (§2.6), which already has named contexts, gamepad support and remapping, and which has no focus, no pointer capture and no text input.

Directional navigation, activate, back and the two shoulders are named actions — uiNavigate, uiActivate, uiBack, uiNextTab, uiPrevTab — so a scene binds them in meta.input like anything else:

<!-- jsonc, not json: every ``json block in this guide is read as a screen description by crates/ui/tests/docs.rs, and this is a fragment of meta.input`. -->

"menu": {
  "uiNavigate": { "kind": "axis2", "keys": ["Up", "Left", "Down", "Right"] },
  "uiActivate": { "kind": "button", "keys": ["Return"] },
  "uiBack":     { "kind": "button", "keys": ["Escape"] }
}

Named actions are where a device and a POST /input hold already converge, so an agent drives a menu with the POST /input { actions } body it already has and there is no second code path to keep honest. Pointer positions and the UI's own keys come from the window instead, because there is nothing for an action map to say about them. Those keys are UI keys by name rather than by binding, so a scene that rebinds Tab for gameplay cannot take Tab traversal away from its own menus.

The named keys are Tab, Shift-Tab, Escape, Enter and Space, and — from U4 — Backspace, Delete, the four arrows, Home, End, and the four clipboard actions. The clipboard four are semantic: the window decides that this platform's Control-C is a copy, so the runtime is handed "copy" rather than a letter and a modifier and never learns what keyboard it is on.

Arrow keys bound to uiNavigate are read by the action map, not taken by the screen. Under a modal screen the arrows still reach ctx.input as uiNavigate (the screen steps focus from that action, once), so a behaviour that drives its own menu can read ctx.input.axis("uiNavigate"). A context that does not bind the arrows to uiNavigate, and a focused text field, leave them to the screen as before.

An unconsumed key reaches the game, which is what makes all of this safe to add. Backspace over a bare HUD is whatever the scene bound it to; Backspace in a text field is the field's and the game never sees it.

A pad navigates however it reports its directions. Whether uiNavigate is bound "gamepad": "dpad" or "left-stick", a menu takes the D-pad buttons (XInput through gilrs, the browser's standard mapping), the D-pad axes (an evdev hat) and the left stick, all with the same threshold, delay and repeat as the arrow keys. The sources are not added: a pad that reports one press as both the axis and the buttons is still one step. Any other action bound dpad also reads the buttons now (the four buttons as a unit direction, diagonals clamped), so a game's own "gamepad": "dpad" move works on a pad that has no hat axes. The right stick never navigates; it scrolls (uiScroll).

A stick flick is one step. A held direction repeats after 0.4 s, every 0.12 s, and returning past the release threshold re-arms it. A stick counts as a direction only past 0.5, and must fall back under 0.35 to re-arm (NAV_THRESHOLD, NAV_RELEASE, NAV_REPEAT_DELAY and NAV_REPEAT_INTERVAL in crates/app/src/ui/input.rs). This holds in a scene with no player too — a title or front-end menu — where the named actions are read straight from the action map's frame, since there is no entity for the devices to be folded into.

The screen stack drives the context, before the mapper folds each tick's devices — so a tick a menu opened never reads half of one context and half of the other. Popping restores the context the game was in before the first screen took it, not whatever is underneath. A context the scene does not declare leaves the game where it was and raises ui.screen.unknown, rather than switching into a context with nothing bound, which to a player looks exactly like the controls having died.

Only a modal screen takes the game's context away. A screen that is not modal sits over play rather than instead of it — a HUD with a Pause button, a panel beside the course — so the context it asks for is layered over the game's instead of replacing it: ctx.input.context reads the screen's, its actions are live, and every game action whose keys, mouse source and pad source the screen's context does not also bind stays live beneath it (the way a higher-priority input mapping context wins only the keys it binds). A generated HUD that copied inputContext: "menu" from the title recipe used to leave the player unable to move. For the same reason, with no modal screen up and no text field focused, Space, Enter, Tab and the arrows go to the game when the game's context reads them, rather than to a focus ring on a non-modal screen; a key the screen's context binds is the screen's. A modal screen replaces the context and owns those keys, as before.

Escape belongs to the stack, and an unconsumed Escape reaches the game. The second half is what makes the first safe: over a bare HUD nothing consumes it, so adding a HUD does not quietly take the quit key away, and over a pause menu it is consumed, so the window does not quit out from under it.

A back a non-dismissible screen refuses is the game's, and never quits. Escape over a dismissible: false screen does not pop it, and it is not swallowed either: it reaches the game's action map, so a script reading ctx.input.pressed("uiBack") (or whatever the live context binds to Escape) sees the player ask to leave and can answer it — nudge them toward the screen's own way out, or close it with ctx.ui.pop(). It is never the window's quit key, whatever the game binds: a screen that will not be dismissed is waiting for an answer, and quitting the game out from under it is not one. The same holds with another modal under it (neither pops), for the platform's auto-repeat of the held key (refused again, never a quit), and on the window and POST /input's key event alike (World::ui_routes_key). A dismissible screen is unchanged: Escape pops it and the game does not also see the press. The pad's B reaches uiBack through the action map whatever the stack does, so it was never swallowed.

Text input

A <text-field> holds a value, a caret and a selection, and it is the one role that takes the keyboard rather than only Enter and Space.

What it does. Insertion and deletion; Left and Right by character and, with Control or Alt, by word; Home and End by line and, with the accelerator, by value; Shift with any of them extends the selection; the accelerator with A selects everything, and with C, X and V copies, cuts and pastes. A click places the caret where it landed and a second click in the same place selects the word under it. Up and Down move between lines on a multi-line field and to the start and the end on a single-line one.

Routing, in the order it happens. Tab is focus's, always — a field that could take Tab would be a focus trap. Escape is the screen stack's, with one narrow exception below. A focused field takes everything else, Enter and Space included: a space in a name is a space, not an activation. With no field focused, Enter and Space activate and the arrows navigate, exactly as they did before U4.

The exception to §2.6's Escape rule, and why it is one. An IME composition in flight is cancelled by Escape and the press is consumed; the next Escape reaches the stack as usual. A preedit is an unconfirmed platform input session rather than screen state, every platform cancels one on Escape, and without this a player composing Japanese could not abandon a word without closing the screen they were typing into. It is the only place a control handles its own Escape, and it is one press, not a mode.

Key repeat is on the frame clock, not the platform's: 0.4 s, then every 0.033 s, for the caret and deletion keys only. Two reasons it is ours. POST /ui/input has no platform to ask, so an agent holding Backspace would otherwise get exactly one deletion; and a platform's repeat rate is a system setting, so a headless test that asserted on it would be asserting about the machine it ran on. So a platform auto-repeat of a key the clock is already repeating is consumed and does nothing, rather than re-arming the delay or adding a second stream. With no field focused a held arrow walks focus at the pad's rate — 0.4 s, then every 0.12 s — not the caret's. One long frame fires at most two repeats and drops the rest, so a stall never comes back as a burst of deletions. When the window loses focus the repeat stops, the modifiers are forgotten and a composition in flight is dropped, because the key-ups go to another application.

The caret does not blink. A blinking caret would dirty the draw list twice a second for as long as a field is focused, which §2.13's "nothing changed" row is a promise not to do, and it would make every screenshot of a focused field a coin toss.

A field does not wrap. A single-line field keeps its value on one line and scrolls sideways to keep the caret in view; a multi-line one breaks only at its own newlines. Both are drawn clipped to the field's box, whatever white-space, text-overflow or max-lines a stylesheet sets. The caret, the selection and a click are placed from the renderer's own glyph positions for the run as drawn, so they follow text-align, direction, letter-spacing, line-height and a scaled ancestor, and a right-to-left word's caret moves right to left. A field that soft-wrapped would need Up and Down to move by visual line, which is not built.

Editing moves by character, not by grapheme cluster. Deleting backwards over an e plus a combining accent removes the accent first. Doing better needs Unicode segmentation tables; the value stays valid UTF-8 whatever is deleted, so the failure is a cosmetic surprise rather than a broken string.

A composition is not in the value. The composed text is drawn with the composition underline and lands in the value only when the platform commits it, so abandoning one restores exactly what was there and ctx.ui.get(id, "value") never answers with half a word. An IME commit is held to the same length limit and control-character filter as a keystroke: an IME is not a way around maxLength.

A password is not copied and is not reported. The system clipboard is refused, because the text would then be readable by every other application on the desktop from a control whose whole purpose is that it is not shown; and GET /ui reports the field's length rather than its value, or its IME composition, because that endpoint is an HTTP response anything on the port can read. ctx.ui.get does answer with it — that is the screen's own script asking.

A rebuild keeps what was typed, unless the author changed their mind. Re-registering a screen with the same authored value carries the typed value and the caret across, so a HUD rebuild in the middle of a name does not swallow it; re-registering with a different authored value is the author saying what the field holds now, and that wins. It is the same rule disabled, selected and checked already follow. What is carried is held to the new spec: a rebuild that tightens maxLength or makes a field single-line cuts the typed value to fit.

A counter counts. A node with data-counter-for naming a field shows that field's length — n / max with a maxLength, n without — and carries data-full="true" at the limit. The runtime keeps it current on every edit, because a field's value reaches script only on submit. TextField's counter is this.

Two style properties are the field's, both inherited so a themed panel sets every field inside it at once: caret-color, which takes CSS's auto and then means the node's own color, and selection-color, the highlight behind selected text. CSS spells the second ::selection { background }; there are no pseudo-elements here (§2.2), so it is a property.

Clipboard. Inside the engine the clipboard is a port with an in-process implementation, so copy and paste work in a headless test, through POST /ui/input and in engine ui preview without a desktop. With a window, crates/app installs the system one (arboard, which the editor already links). A platform with no clipboard keeps the in-process one, so copying still works inside the game and only the exchange with other applications is missing. POST /ui/input never reaches the system clipboard: each request runs against the route's own in-process one, which persists between requests. Otherwise any caller on the port could focus a field, post ctrl+v and read the player's clipboard back from GET /ui.

Cursor visibility is a property of the topmost screen that wants input, not a global, and the window is told only when the answer changes.

ctx.ui

ctx.ui.screen(description): string   // register from the §7 JSON; returns its name
ctx.ui.push(name, props?): void
ctx.ui.pop(): string
ctx.ui.replace(name, props?): void
ctx.ui.stack(): string[]             // bottom first; ["hud"] when nothing else is up
ctx.ui.set(id, prop, value): void    // §6's bindings, from script
ctx.ui.get(id, prop): unknown        // read one property back (U4)
ctx.ui.locale(): string              // the locale keys resolve at; "base" for meta.strings
ctx.ui.locales(): string[]           // every locale this project ships, base first
ctx.ui.direction(): "ltr" | "rtl"    // the active locale's writing direction
ctx.ui.setLocale(tag): void          // switch language; ui.locale.unknown for one it has not

set takes the properties §6 documents. The id may be the qualified form GET /ui reports (pause:resume) or the bare author id (resume) when exactly one screen on the stack answers to it; an ambiguous bare id is refused rather than guessed at. null clears the binding and the node goes back to the value its screen was authored with — it is a clear, not a blank, so a row whose author wrote something shows that something again rather than an empty string.

get is the mirror of set, and it is what a behaviour reads a text field with:

const name = ctx.ui.get("signin:name", "value") as string
propertyanswers
valuewhat a text-field holds
lengthhow many characters it holds, which is what maxLength counts
caretthe caret's byte offset into value
selection[from, to] byte offsets, or null when nothing is selected
compositionthe IME preedit in flight, or null
textany node's drawn text, resolved from its key — the whole run, spans included

It refuses ui.get.unknown-node for an id no screen on the stack holds or a bare id two screens answer to, ui.get.unknown-prop for anything outside that table, and ui.get.not-a-field for a field property on a node of another role — the id was right and the node is the wrong kind, which is a different mistake worth a different code.

Why a read and not an event. Nothing in this engine delivers a UI action into script: a button activation goes to WS /stream and to the editor's feed. An event-delivered value would have meant an event bus built for one property, and a behaviour would still have had to keep its own copy to answer "what is in the field now". A read has neither problem — it is one call and it is always current. What is an event is the moment the player finished: Enter in a single-line field publishes a submitted UI event, with the value in its message, which is also what POST /ui/input reports in submitted. A per-keystroke change is deliberately not published; it would put a dozen events a second on the socket and into every session replay.

What set writes has a lifetime, and it is the screen's rather than the call's. The values written into a screen are remembered per screen and replayed when it is pushed or replaced back onto the stack, so a settings panel a behaviour filled in comes back filled in rather than authored. Without that, a menu was right the first time it was opened and wrong the second, which is the worst possible shape for a bug. ctx.ui.screen on a live name goes the other way: a rebuild is the author saying what the screen is now, so the values it carries win and everything written into the old one is forgotten. A null forgets its value too, so a cleared binding is not replayed back over the authored one.

A screen can only be on the stack once, and both push and replace refuse a name that is already up with ui.screen.duplicate, leaving the stack exactly as it was. For replace that is a choice: moving the live screen to the top was the alternative, and it would silently destroy the screen that was on top while the word promises to swap it — push("pause"); push("options"); replace("pause") would lose options without saying so. Pop down to it instead.

The exception is the screen on top. replace naming the screen that is already on top is an update, not a refusal: it stays where it is and is re-rendered at the new props, exactly as a push with those props would render it, and ?filter=ui reports a replaced from the screen to itself. Nothing is lost, because nothing is popped — so replace("results", { score }) is how a screen that is up shows new numbers.

setLocale is a re-render, not a rebuild. Every text key on the stack is resolved again in Rust, so a menu that is already up comes back in the other language with its focus, its scroll position and its open dialog intact, and no behaviour runs again. An unknown tag is refused with ui.locale.unknown and changes nothing: a screenshot matrix that silently rendered the base language for a locale nobody translated would report a clean pass over a language that does not exist. direction() is a call rather than something a behaviour derives from the tag, because which languages read right to left belongs to the locale file and a script's own table of them is a second copy that goes wrong the first time a project adds one.

push's props are what the screen renders. A push or a replace with a props object runs the screen's render again at exactly those props, so ctx.ui.push("results", { score: 0, seconds: 90 }) draws 0 and 1:30 — not the values of the screen's first scenario, which is what it drew before. No props, null or {} is the screen as described. The render is a rebuild, so a value ctx.ui.set wrote into the screen before the push is forgotten; one written after it is kept, and is how a value that changes while the screen is up gets there (§6). A render that throws at the pushed props is refused with ui.screen.bad-json and nothing goes up. In an exported game the screen renders from the bundle the export packed beside its description (bundles in ui/screens.json); a screen registered by hand with ctx.ui.screen(json) has no bundle and draws its description whatever it is pushed with.

The props are also reported on GET /ui/screens and read back by a behaviour deciding how to pop the screen. They are recorded only for a push that actually happened, so a refused one does not overwrite what GET /ui/screens says about the screen that is up.

Every failure throws a UiError whose message begins with a stable code:

codewhen
ui.screen.unknownno screen answers to that name; the message lists the ones that do
ui.screen.bad-jsonthe description has an error; the message carries the reader's file, line and column. Also a push or replace whose screen's render threw at the props it was given
ui.screen.duplicatepush naming a screen already on the stack, or replace naming one below the top; the stack is unchanged
ui.stack.emptypop with nothing on the stack
ui.set.unknown-nodeno screen on the stack holds that id, or a bare id is ambiguous
ui.set.unknown-propthe property is not bindable; the message carries §6's own hint
ui.set.bad-valuethe property is bindable and the value is the wrong kind for it
ui.unavailablethis world has no UI runtime at all

Hearing a press: "ui-activated"

Screens have no handlers, and that is deliberate: a screen is a function of its props. The game hears a press on the event bus instead. Every activation, whether a click, a tap, Return or Space on the focused node, the pad's uiActivate or POST /ui/activate, is raised by the engine as "ui-activated". It is raised on the tick after the press, before behaviours' update, with by null, and it reaches both onEvent and ctx.events.on:

ctx.events.on("ui-activated", (ev) => {
  // ev.data: { node: "main-menu:menu/start", screen: "main-menu", id: "start" }
  if (ev.data.screen === "main-menu" && ev.data.id === "start") ctx.ui.pop()
})

node is the qualified id GET /ui reports, which includes the path of named ancestors, and id is its last segment: the id the screen's author wrote. Match on screen and id. Each activation is delivered once. The ?filter=ui stream still carries the same activation as an activated event: World::take_ui_activity collects what the game has not heard yet before the stream drains the log, so neither reader can take a press away from the other. Before this, the press was visible to an observer and invisible to the game, so every button had to be shadowed by an input action in meta.input. The payload is typed as BusUiActivated in @argus/api.

A node written with onClick={emit("start-game", data)} raises "start-game" as well, right after its "ui-activated", with data as ev.data ({} when there is none) and by null: a behaviour can listen for what the press means rather than match screen and id.

Hearing a focus move: "ui-focused" and ctx.ui.focused()

Use ctx.events.on("ui-focused", …) for a hover sound, a description panel or a preview that follows the menu cursor.

Each time focus moves on a screen, the engine raises "ui-focused". A move is the arrow keys or the pad, Tab, a click on a focusable node, or the first focus a screen takes when it is laid out. It comes with "ui-activated": the tick after, before update, by null, and it reaches both onEvent and ctx.events.on. A frame where focus stays put raises nothing.

ctx.events.on("ui-focused", (ev) => {
  // ev.data: { node: "garage:karts/swift", screen: "garage", id: "swift" }
  if (ev.data.screen === "garage" && ev.data.id) ctx.ui.set("blurb", "text-key", `kart.${ev.data.id}`)
})

The fields are the same as "ui-activated"'s. node and id are null when focus left every node of the screen. The payload is typed as BusUiFocused.

ctx.ui.focused(screen?) answers the same question at any moment. It returns the qualified id of the node holding focus on screen, or, with no screen named, on the screen the arrow keys and the pad move. It returns null when nothing holds focus there or that screen is not on the stack. The answer works as the id for ctx.ui.set and ctx.ui.fx. The browser runtime does not serve ctx.ui.focused yet; there it throws WebUnsupportedError, as ctx.ui.get does.

Pausing the world under a menu: pausesWorld

A screen declared pausesWorld: true holds the world for as long as it is on the stack, covered or not; ctx.session.pause() does the same with no screen, until ctx.session.resume().

export default defineScreen({ name: "pause", inputContext: "menu", modal: true, pausesWorld: true, render: … });

While the world is held, each tick:

  • stops: physics, vehicles, character controllers, behaviours' update, nav agents, animators and their events, particles, the camera rig, and the game clock (ctx.time.now);
  • runs on: input, the UI (layout, focus, transitions, Escape on a dismissible screen), a press's "ui-activated" into onEvent and ctx.events.on, audio, and each behaviour's pausedUpdate(ctx, dt), which is called in place of update. Read a pause key there, since update is not running to read it. ctx.time.frame still counts ticks.
export default {
  update(ctx) { if (ctx.input?.pressed("pause")) ctx.ui.push("pause") },
  pausedUpdate(ctx) { if (ctx.input?.pressed("pause")) ctx.ui.pop() },
} satisfies Behavior

ctx.session.paused() says whether the world is held, by either. The decision is made once, at the top of each tick after the UI's presses are delivered, from the stack and the session flag, so a pause pushed during tick N holds tick N + 1 on, and a recorded (--record), lockstep or play.step run pauses on the same tick every time. A restart or a scene switch starts running. GET /ui/screens marks the screen (pauses_world) and says what holds the world (world_paused: the screen's name, or session). The playtester's dialog dismissal presses a held screen's button or sends back exactly as before, and treats a pausesWorld screen as blocking. Without the flag the engine does not pause for a menu: only the input context changes. fixtures/recipes/pause-screen is the worked example.

Reading it back

GET /ui is the live tree in paint order across the stack, filtered by ?screen=, ?id=, ?depth= and ?text=. Each node carries its id, screen, role and tag, classes and data attributes, bounds, visibility, whether it is clipped, focusable, focused, disabled, selected, checked, hovered, pressed and hittable, its paint order and depth, its parent and children, its resolved text with the meta.strings key it came from, and the appearance-affecting half of its resolved style.

**Bounds are reported in logical and device pixels**, so an agent never has to infer a scale factor — it would get that wrong at 1.25 and 1.5, the two common Windows display settings, which are exactly the ratios where it matters.

focusable and disabled answer different questions. A disabled button is still a button, so it stays focusable and the runtime refuses to focus it. An agent deciding what to press reads both.

Interaction state republishes even when it draws nothing. hovered, pressed, focused and focus_visible are state a stylesheet may turn into pixels and usually does not: with no :hover rule, a hovered button draws exactly like an unhovered one. The endpoint follows the runtime rather than the draw list, so an agent polling it to confirm a click landed is reading the tree. A frame in which nothing at all happened still republishes nothing.

A read is capped at 2 000 nodes. Past that it is a 400 naming ?id= and ?depth=, the two filters that make it answerable — a ten-thousand-node screen is a seven-megabyte response nobody asked for. A cap rather than paging, because a tree is read to find one thing and paging one means reassembling parents and children across requests to ask a question either filter answers in one request.

GET /ui/screens is the stack bottom first, with each screen's modality, dismissibility, click-through flag, requested input context, cursor preference, focus, node count and props — plus every registered screen, the context the stack asked for, the context the game is actually in, and the root scale. The two contexts differ exactly when the scene declares no context by that name, which is how an agent sees that happened.

?filter=ui on GET /events and on WS /stream carries pushes, pops, replaces, focus changes, activations, context switches and scrolls. It is an event kind rather than a new socket message, so the same filter works on both and a session's UI can be replayed with ?after_seq=.

Both routes follow the query crate's ?at= convention, so an agent that pressed something, waited for the stamp and then read the tree is looking at the tick it thinks it is.

Scroll containers

A node whose overflow is scroll or auto on either axis is a scroll container. Everything that moves is the engine's: the offset, the clamping, the scrollbars, the wheel, the pointer drag, the keys, the pad and scroll-into-view. A stylesheet declares overflow and gets all of it; there is nothing to write in the description.

The two keywords differ about who gets the event, not about how it looks. Both clip, both scroll, and both draw an overlay bar that fades when nothing has moved — neither reserves a gutter. A wheel over a scroll axis is the UI's even at the end of the list; a wheel over an auto axis with nothing to scroll reaches the game. A pause menu over a world the player can still zoom wants auto.

What drives one:

inputwhat it does
wheelscrolls the container under the pointer, chaining to the next scrollable ancestor when this one has reached its end
pointer dragdrags the content where the press found nothing focusable, and drags the thumb where it landed on a bar
press on a trackjumps the thumb to it and then drags, so a 500-row list is one gesture
Page Up / Page Downseven eighths of a viewport; consumed only if something moved, so a page key on a screen with no scroller reaches the game
Home / Endthe top and the end of the travel
an arrow keymoves focus while there is somewhere for focus to go, and scrolls a line when there is not
uiScrollthe pad's right stick, as a rate with a deadzone rather than a discrete step
uiScrollPagea shoulder pair, one page per push and no auto-repeat
moving focusbrings the focused node into view through every scroller above it, moving each as little as it can
POST /ui/activate, ui.activatethe same reveal for a node scrolled out of view, then the press where it lands; a node clipped by anything but a scroller (overflow: hidden, a box with no room) is still refused with ui.activate.clipped, and the refusal leaves every offset where it was

A scroll offset is not a binding. It is geometry the player owns, in the same family as focus, and the editor's hot reload promises to keep it — a binding would be replayed over the player's own position every time the screen re-rendered. So it is written with the same call and stored somewhere else:

ctx.ui.set("log:list", "scroll-top", 9999);   // past the end lands at the end
ctx.ui.set("log:list", "scroll-left", 0);
ctx.ui.set("log:list", "scroll-top", null);   // null puts it back at the top

The write is not clamped where it is made, because the extent it would be clamped against is last frame's and the commonest write — "put the log at the bottom" — is made in the same tick that lengthens the log. It is clamped at the next layout, so a read after that is what the container is actually showing. That holds in the tick a screen is pushed, before anything has been laid out: push a log and put it at the bottom in one tick and it opens at the bottom — the first focus does not pull it back to the top. A node that is not a scroll container raises ui.scroll.not-scrollable and changes nothing (a frame later, for a write made before the first layout); it does not throw, for the same reason §2.7's change gate does not. Number.MAX_VALUE is past the end too.

Under a right-to-left locale the horizontal axis starts at the right, as CSS's does. A mirrored row overflows to the left, scroll-left is still the distance from the inline start (0 shows the first item, against the right edge), the thumb starts at the right end of its track, and a wheel, a drag and scroll-into-view move the content the way the player's hand does.

GET /ui reports it, on the container and on no other node:

"id": "log:list",
"scroll": {
  // Where it is, clamped to what the last layout found.
  "offset": [0, 240],
  // What is in it. Never smaller than the viewport, so `max` is never negative.
  "content": [400, 2000],
  // The padding box: what the player can see at once.
  "viewport": [400, 300],
  // content - viewport, floored at zero.
  "max": [0, 1700],
  // Whether each axis can move right now. See below.
  "scrollable": [false, true]
}

scrollable is not the same question as style.overflow. An auto axis with nothing to scroll declares an overflow and cannot move; a scroll axis in the same state cannot move either, and still claims the wheel. An agent deciding whether to send a scroll reads scrollable; one deciding why nothing happened reads both.

Every move — by any of the inputs above, and by a focus change — puts a scrolled event on ?filter=ui carrying the node and the offset. A clamp does not: a list that shortened under a scrolled viewport moves the stored offset without anything having scrolled, and publishing that would mean a screen whose content is animating reports a scroll every frame.

The HUD

ctx.hud.set(field, value) is unchanged, and so is the contract's hud component. What changed is what draws it: the four known fields and the custom-field stack are slots on a stock hud screen, always the bottom entry and click-through, so a HUD node never takes a gameplay click unless it opts in. Row order is the order the fields were first written in — the four are named slots, addressable as hud:timer and so on, not fixed positions in the column.

The stock HUD reads like a game's, not a debug line (#76, #88). Each row is a panel (26 px type on a dark plate, readable over a bright sky or a dark cave on either renderer) with a label and the value: Lap 2, Score 40. The label is the field's name with its first letter capitalised (best_lap reads Best lap; a name that is not plain words, like km/h, is shown as written). The timer field is a clock with tenths, m:ss.s (ctx.hud.timer(2) shows Timer 0:02.0, 83.26 shows Timer 1:23.3), so its width only changes at ten minutes; any other number has at most two decimals with trailing zeros dropped (Score 12.3, Ammo 0.67).

ctx.hud.set(field, value, options) says how a field reads (#88): label (the text before the value), unit (after a number) and decimals (fixed digits, a whole number 0..6). For a kart's speed, ctx.hud.set("speed", Math.round(kmh), { unit: "km/h" }) reads Speed 92 km/h. The options apply from then on, until the field is set with options again; without options a field reads as above. An unknown option, or decimals that are not a whole number 0..6, throws, naming the allowed options. A field with a spec in the scene's meta.hud is formatted by that spec (format, decimals, prefix, suffix) and the script's options are not used.

A project's own hud screen replaces this one. The name is the stock HUD's, and it is always on the stack, at the bottom. A screen the project names hud — in ui/screens/**, or through ctx.ui.screen — is registered over it rather than refused: the bottom entry draws the author's tree from then on, and the stock HUD steps aside with no error. What that costs is spelt out, not hidden: ctx.hud.set fields are no longer drawn (they are still kept, each call is counted as unwired.hud_set on GET /perf, and meta.hud still validates), so a project HUD is driven the way any screen is, with ctx.ui.set. behaviour.write lands such a file and says so in a note. The override is per scene load: a scene whose project has no hud screen gets the stock one back.

Two things stay the same. The hud entry is still the one bottom entry, so ctx.ui.push("hud") and meta.ui.startScreen: "hud" are still refused as already on the stack. And it is the author's screen now, so it takes clicks the way any screen does: a stock-like HUD that should never eat a gameplay click declares clickThrough: true in its defineScreen, as the stock one is.

With no project hud, the stock HUD below is what draws, and restyling it is still the way to change how it looks while keeping ctx.hud.

A meta.hud field's anchor is one of nine points: top-left, top-center, top-right, center-left, center, center-right, bottom-left, bottom-center, bottom-right. The bare edges are accepted too and mean that edge's middle: top is top-center, bottom is bottom-center, left is center-left and right is center-right. Both contract twins take them and store the full spelling, so a saved scene always reads back with one of the nine.

It passes the UI critic as it ships, and a project restyles it like any screen. Its look is an engine stylesheet (@argus/engine/hud.css, crates/app/src/ui/hud.rs) loaded before every project sheet, so an ordinary rule in ui/styles/** wins over it:

classwhat it isstock look
.hud-rowa stacked field's row, full widthnone
.hud-anchoreda meta.hud field's full-surface box, aligning its run to the anchornone
.hud-textthe run the player readswhite on a rgba(12 14 20 / 0.72) plate, 26ui
.hud-shadowa second run one pixel down and right, under the firstdisplay: none

The plate is what makes the HUD legible over any game, and it is why it meets the critic's ui-contrast threshold in every theme: the stock sheet writes the white and the plate as literal colours, not tokens, so no theme changes them. Every run carries data-literal="true" because its words are already resolved (a number the HUD formatted, or a meta.strings key looked up in the live locale), so ui-untranslated does not ask for a key. A meta.hud spec's own size and color stay inline, so they beat a stylesheet: they are that field's decision. For example, .hud-text { background-color: transparent; } plus .hud-shadow { display: flex; } brings back the bare text with a drop shadow.

The opt-in is ctx.hud.hittable(field). §2.9 says a HUD's nodes "are not hit-testable and do not take focus unless they opt in", and this is the opt-in from the script surface: one call, naming the field whose row it is about, granting both halves at the node because §2.9 states them together: the row answers the hit-test and it is focusable. ctx.hud.hittable(field, false) gives it back, and a scene load forgets every opt-in with the rest of what that scene asked for. The field need not have a row yet — the opt-in applies the moment one is written.

On the HUD that is the pointer half. Tab, Enter and Space go to the topmost screen that is not click-through, and the HUD is click-through so that Escape still reaches the game over a bare one — so an opted-in row is clickable and reports focusable: true, and nothing routes a key to it to tab there. A HUD node that has to take the keyboard belongs on a screen that takes input.

It is not ctx.ui.set(row, "hit", true), and hit is deliberately not in §6's bindable set: a binding changes a value, and hit-testability and focusability are structure. So the opt-in re-registers the stock screen, once per opt-in rather than once per frame. In an authored screen the same thing is spelled hit: "always" on the node (§7), which also makes it focusable.

A field changing value is a binding, not a rebuild. Each row's two text runs — the shadow and the real one — are keyed, so they are hud:timer/shadow and hud:timer/text, and a custom field's row is hud:root/<field>. When the set of rows is unchanged and only their text moved, sync_hud publishes §2.7 bindings for the rows that actually changed and nothing else is touched. A field appearing, being cleared, or coming back is a different tree, and that is a rebuild, because §2.7 is explicit that a binding cannot add or remove a node.

It is a delta rather than a snapshot, which is the one place the engine departs from §2.7's default, and it departs for §2.7's own reason. A snapshot clears every binding it does not mention, and the rows are not necessarily the only thing bound to the HUD screen. Nothing can be stranded by the delta either, and not by luck: a row's text cannot stop being published while its row survives, because a field that stops resolving takes its row with it — and that is a structure change, which rebuilds and clears the table outright.

Measured on a 69-field HUD (208 nodes), both paths alternating in one loop so the machine cancels out: 0.034 ms as a binding against 0.195 ms as the re-register it replaced, and 39 changed values rebuild no tree where the old path rebuilt 39.

Previewing a screen in the editor

The editor previews a screen through this runtime, not an egui drawing of it: the screen is described by esbuild, registered through the same call ctx.ui.screen makes, laid out by Taffy against your project's own cascade, and drawn by the UI pass into the editor's viewport. What you see is what the game draws, at the resolution and scale you pick.

Where your files have to be. At the project root, under ui/, in the directories §1 lists. Only a top-level ui/ counts. A screen filed anywhere else is not a UI input: it will not appear in the Content browser's UI category, it will not preview, and it will not hot reload.

Opening one. The Content drawer's UI category lists every screen, component, stylesheet, theme and scenario. A screen's tile is a real frame of the screen, rendered at 1080p against its default scenario. Double-click it, or select it and press Preview.

Dragging one onto the viewport sets the scene's start screen: it writes meta.ui.startScreen as one undo step, and the name it writes is the screen's own — defineScreen({ name }) — which is why the drop describes the file first. The file's stem is a convention, and writing it would give you a scene that validates, loads, and then reports ui.screen.unknown for a screen sitting right there.

The pickers. Scenario, theme, scale, locale, resolution, and two overlays.

  • Scenario lists your screen's own defineScreen({ scenarios }) plus anything ui/scenarios/<stem>.json adds. That file is an object of scenario name to props, matched to a screen by file stem — ui/screens/main-menu.tsx takes ui/scenarios/main-menu.json — and a name in it overrides one of the same name on the screen. It is how you preview the one awkward case without editing the screen. The map may also be nested under a scenarios key, which is what lets a file keep notes beside it, as fixtures/ui/gallery does.
  • Theme lists every @theme your stylesheets declare. Switching is a re-resolve, not a reparse (§3), so it is instant and touches no disk.
  • Scale is the player's UI-scale setting from §2.12, 0.75× to 2×. Use it with the resolution picker: a screen that only works at 1× on 1080p is a screen that has not met an accessibility setting yet.
  • Locale lists what the engine says the project ships (§12) — base, the scene's own meta.strings, and one chip per ui/locales/<tag>.json. It is the engine's loader and the engine's switch, not the editor's, so the direction, the plural rule and the number separators move with the strings and what you are looking at is what the game does. Hover a chip for the language's own name, its direction, its plural rule and how many base keys it leaves untranslated. A key no table has shows as [[key]], and a key the locale does not translate falls back to the base language — which ui-locale-gap reports, because a screen that looks finished and is not is the case nobody notices.
  • Resolution is 1080p, 1440p, 2160p or a size you type. While a preview is up the picker owns the offscreen target, so the numbers mean the numbers rather than the size of the panel.
  • Bounds draws every visible node's computed border box. Focus order numbers the focusable nodes in the order Tab reaches them — tab_order's own answer, so a number that surprises you is telling you something true about your screen. A disabled button is absent, because the runtime refuses to focus one.

You can press the controls. A click on a previewed control goes to this runtime's hit-test, so hover, press, focus and activation all behave as they will in the game.

Hot reload. Save a file under ui/ and the editor reloads exactly as much as the change costs:

What you changedWhat it costsWhat survives
ui/theme/**, ui/styles/**A reparse and a re-resolve, in Rust, with no script involvedFocus, scroll position, an open dialog, every binding
ui/screens/**, ui/components/**esbuild and a re-registerHover, press and focus by id; state while the props shape is unchanged
ui/scenarios/**Re-reading the mock dataEverything

Detection polls (§1). A save from Windows into a WSL project, or the other way round, is seen because the workspace service polls for it; nothing here watches a directory.

When something will not compile, what is on screen stays. A stylesheet with a refused declaration, or a screen that stops parsing, leaves the working screen up and puts the refusal in the editor's feed with the file, the line, the column and the nearest supported alternative §2.2 requires. Fix it, save, and the reload happens on the next poll.

What the editor needs on the machine. esbuild (ARGUS_ESBUILD) and @argus/ui — named by ARGUS_UI_PACKAGE, by your tsconfig.json's compilerOptions.paths, or found in a node_modules above your project or beside the editor. A missing one is named on the tile and in the preview rather than leaving you with a blank viewport. Node is not on that list. It was, because describing a screen means evaluating its bundle and the engine's sandbox had no way to hand a value back to Rust; argus_host::Host::eval_json is that way, and the evaluation now happens in-process in a QuickJS context of the engine's own. One describer serves both the editor and World::load, which is what makes the preview the description the game registers rather than a second opinion about the same file.

10. The component library

Forty components under @argus/ui/components — a second entry point, because the package root already exports Button, List, Dialog, Toast, Text and Image as the capitalised aliases for the ten elements. Both layers keep the obvious names; the import line says which one you meant.

import { defineScreen, h, t } from "@argus/ui";
import { Button, Panel, StatBar } from "@argus/ui/components";

export default defineScreen<{ health: number | null }>({
  name: "squad",
  render: ({ health }) => (
    <container class="screen" theme="sim">
      <Panel id="vitals" titleKey="squad.vitals">
        <StatBar id="alpha" labelKey="squad.health" value={health} max={100} cap={100} />
        <Button id="resupply" textKey="squad.resupply" variant="primary" />
      </Panel>
    </container>
  ),
  scenarios: { ready: { health: 74 }, "not-in-this-world": { health: null } },
});

engine ui list prints the whole inventory with one line each. Read that before writing a screen; almost everything a training simulator needs is already here.

An imported component arrives styled. The engine carries the library's own stylesheets (packages/ui/src/components/**.css) and the design system's token layer (packages/ui/theme/tokens.css), compiled in, so an export has them too. For a project with a ui/ folder they load as a base layer: the token layer first, then ui/theme/**, then the library's sheets, then ui/styles/**. So a screen that imports Button or Stack needs no stylesheet of its own, and restyling one is an ordinary rule in ui/styles/ that wins by coming later. A project's copy of a library sheet replaces it rather than stacking on it: a sheet engine ui add vendored (its first line names the component) or a byte-for-byte copy, as the gallery's are. A sheet that only shares a name (ui/styles/menu.css) is the project's own and replaces nothing. Before this, the library's look reached a project only through engine ui add, which refuses the primitives, so an imported Stack could not be styled at all and both opus runs of the second agent demo wrote their own components.css.

The two layers, and which one you may copy

layerwhatmay you copy it
primitiveBox, Stack, Text, Image, Icon, Spacer, Divider, ScrollArea, Grid, OverlayNo. Engine-owned and versioned. Import it.
sharedthe loading / empty / error viewsNo. A project with its own idea of "empty" has left the design system.
composedButton, Menu, Panel, Card, List (with ListRow), Tabs, Dialog, Toast (with ToastStack), Tooltip, ProgressBar, StatBar, ScoreCard, Prompt, KeyHint, ObjectiveTracker, SettingsRow, Select, Slider, Toggle, TextField, Badge, Avatar, Countdown, ResultsSummary; the game HUD kit: HudCounter, HudLives, TitleBanner, DialogueBox, PauseMenu; the race kit: RacePosition, RaceTimer, BoostMeter, ItemSlot, StartCountdown, RaceBanner, RaceResults, RacePodium, MinimapYes — engine ui add <name> copies the source into your project for you to edit.

The split is a correction, not bookkeeping (§4 of the plan). The reference project shared a skin and almost no components, while three applications each vendored their own copy of one ~45-file primitive kit; those copies had already diverged and were exempted from the project's own render-cost rules as "base library code". So vendoring everything is how you get three drifting, unpoliced Buttons — and locking everything is why their component layer went unadopted. Nobody forks a Stack; everybody bends a results screen.

Composing from primitives

A primitive is one element, its class, and its variants as data attributes. It holds no data and has no logic. Anything that takes data is a composition.

<Stack id="row" direction="row" gap="md" align="center">
  <Icon id="row-icon" name="objective" />
  <Text id="row-label" size="md" tone="muted" textKey="hud.objective" />
  <Spacer id="row-gap" grow />
  <Badge id="row-count" count={3} tone="accent" />
</Stack>

Four rules, and they are the whole convention:

  • Every component takes an id, and every node inside it is id + "-" + part. A Button's caption is <id>-label; a StatBar's fill is <id>-fill. Ids are what §6's bindings, the critic, the query API and every test address, so a component that invented its own would collide the moment two of it appeared on one screen.
  • Variants are data attributes, never class strings (§3). The look is reachable from the stylesheet alone, which is what lets an agent restyle without touching TSX.
  • Every interactive node is focusable; a node that takes a click only to stop it reaching the game behind it says data-blocks="true" instead. A scrim, a dialog layer and a sticky toast are blockers. Everything else that is hit-testable can be reached with a stick.
  • A disabled control stays focusable. The runtime refuses to focus it and says why. A hole in the focus order that is visibly not a hole is worse than a control that explains itself.

The token rules

Components name semantic tokens only: the eleven reserved names, the extended semantic layer each theme defines, and the measurement scales. Never a palette step, never a colour literal. packages/ui/test/component-styles.test.ts refuses all three, and token("accent") does not compile for "neutral-700".

groupnames
the elevensurface on-surface accent danger success warning focus-ring disabled scrim border muted
surfacessurface-raised surface-sunken surface-overlay surface-hover surface-press surface-selected surface-disabled surface-accent surface-danger surface-success surface-warning
foregroundson-surface-strong inactive-ink on-accent on-danger on-success on-warning
interaction, linesaccent-hover accent-press border-strong border-subtle track shadow-color
scalestext-* space-* radius-* stroke-* target-min control-* bar-* shadow-y-* shadow-blur-* duration-*

--on-x is the foreground on a solid --x fill and nothing else. A tinted signal surface is --surface-x, and the foreground on that is the signal colour itself: --danger on --surface-danger is about 5.2:1.

--disabled is faint on purpose and belongs to an inactive control only: a disabled or busy button's label, a locked row, a disabled field. WCAG 1.4.3 exempts that text from contrast and ui-contrast exempts it the same way. Dimmed text that is not an off control (an unknown avatar's initials, an absent stat, tone="disabled" copy) is still read, and uses --inactive-ink, which holds 4.5:1 on the panel surfaces in both themes.

ui-contrast also skips text at opacity 0, or under an ancestor at opacity 0: nothing is drawn, as on a fade's first frame. Partial opacity still counts.

The theme layer is three files and its load order is a contract: ui/theme/tokens.css, then ui/theme/sim.css, then ui/theme/game.css. (The design system also ships two game themes, arcade.css and retro.css, which use the engine's default font; no font file ships with them. They define the same names as the other two; a project that wants one copies it into ui/theme/ and names it in settings.ui.defaultTheme.) The default theme is the first @theme block the parser meets, so an alphabetical directory sort would quietly make the game theme the default — game.css sorts first. The engine therefore does not sort the whole layer. It reads ui/theme/tokens.css, then the file settings.ui.defaultTheme names in argus.project.json, then the rest of ui/theme/** sorted, and only then ui/styles/**, where the sort order is the cascade and moving it would change which of two equally specific rules wins. A project that names no default gets tokens first and the rest sorted, which is the old behaviour minus the one decision it could not express.

The field names a file rather than a @theme name for exactly that reason: the question is which stylesheet is parsed second, and a theme name says nothing about parse order. Switching theme afterwards is UiRuntime::set_tokens and not a second stylesheet (§3).

fixtures/ui/gallery names ui/theme/sim.css, which is what puts a project on this layer into the order above. The editor's preview reads the same order, through the same function, so a screen previews in the theme the game would give it rather than in one the editor picked.

Both themes define an identical set of names and every one is a colour. That is the design system's choice, not the parser's: its themes share one measurement scale so a component keeps its geometry across a switch. A project's own theme may move a length (§3, "Tokens and themes").

A theme-local token is written as a literal, not var(--palette-step). That is the parser, not a preference: a @theme block's non-reserved names are resolved in the first pass against the table the parser started with, so they cannot reach the project's own @tokens. Each literal carries a trailing comment naming the palette step it is, and crates/ui/tests/design_system.rs compares decoded light on both sides so the comment cannot go stale.

Loading, empty and error are part of the contract

Not optional extras. A List with no empty state is an incomplete component, and an agent building from this library should never have to invent one.

<List id="roster" labelKey="squad.roster" state="empty" emptyKey="squad.nobody" />

state is "ready" | "loading" | "empty" | "error", it is written to data-state, and the three placeholders share one implementation. Empty and error are different sentences — "there is nothing here" and "we could not find out" — and a component that drew them from one code path eventually draws one as the other. The reference project shipped exactly that: a library screen reporting "you have nothing" over a full store.

Every state view is <id>-state, so GET /ui?id=roster-state answers "what is this list showing" without anyone reading pixels.

The Toast contract

Taken wholesale from the reference project, because every field is a lesson from a game context rather than a web one, and a toast without them gets rebuilt badly in every project.

fieldwhy it is there
severityinfo success warning danger. Decides the colour and nothing else — a toast that also changed its duration by severity would make an error vanish mid-sentence.
kickerKeythe two-word label above the message: OBJECTIVE, SUPPLY, RANGE SAFETY. What makes a stack scannable from across a room.
sourceKeyattribution. A trainee who cannot tell the simulator from the scenario from their own squad cannot act on any of it.
dedupeKeyidentity. Twelve identical "out of ammunition" toasts is a bug that only appears under load, so the fix lives in the component.
durationMsper toast. "Saved" and "you are about to fail this exercise" do not deserve the same four seconds.
requiresAcknowledgementsticky mode. A player looking down a sight is not present to see a toast; one that matters waits and carries something to press.
requirementsthe itemised shortfall, as { id, label, have, need, met } rows. "You need 3 more magazines and a medkit" is unreadable in a hurry; a table is scannable, and the critic can check that the unmet row is the one in the danger colour.

A ToastStack lives on the HUD, and the HUD screen is click-through, so hit-testability is worth a sentence. The stack itself is hit="none", an ordinary toast inherits that, and a sticky one says hit="always" — which is what buys its subtree back, so the acknowledge button inside it can actually be pressed. auto would not do: hit-testability is inherited the way pointer-events is, and on a click-through screen auto inherits "no" all the way down. The same rule is why every node marked data-blocks="true" in this library says always. A HUD row opting in is a different mechanism, ctx.hud.hittable(field) (§9).

dedupeToasts() is exported and pure: repeats collapse to one toast carrying the latest content and the summed count (latest, not first — "3 rounds left" then "1 round left" must not settle on the three), and sticky toasts sort first so an acknowledgement is never buried under four transient notices. Nothing schedules anything: the duration is published, and the runtime drives the clock, because a timer in script would run on the QuickJS tick and drift with it.

Mock-data mode

Every screen declares typed props and named scenarios; a component ships its own beside it. The mock is not a stub and the mode is chosen explicitly, never inferred.

import { chooseSource, liveSource, MockSource } from "@argus/ui";

const source = chooseSource<Roster>(mode, {
  mock: () =>
    new MockSource(FIXTURE, {
      handlers: {
        // A handler may write through, which is what makes this a mock and not a stub: the
        // screen's next read sees the consequence of its own call.
        resupply: (args, self) => {
          self.write((current) => ({ ...current, ammunition: current.ammunition + Number(args[0]) }));
          return { toast: "supply.arrived" };
        },
      },
    }),
  live: () => liveSource(host, "the roster"),
});
  • MockSource round-trips whole objects — what you write is what you read, deep-copied both ways, so a screen that mutates what it read cannot corrupt the fixture — and records every call: source.calls(), source.called("resupply"). That recording is what makes confirm-then-toast assertable with no game running.
  • liveSource returns { status: "unavailable", reason } when there is no host. It never returns fixtures. There is no fall-back path to write, which is the only way a rule like this survives. A screen bound to live state must render the unavailable state and say why: in Talos the UI and the game share a process, so the alternative is a screen previewing fixtures while an operator believes they are watching the running game.
  • chooseSource has no default mode. A caller with nothing to pass has not decided yet, and finding that out here is much cheaper than finding it out from a screenshot.

engine ui add, diff and preview

engine ui list
engine ui add results-summary            # source to ui/components/, styles to ui/styles/
engine ui diff results-summary           # what moved upstream, and what you changed
engine ui preview gallery-button --scenario=unavailable

add brings a composition's dependencies with it, stamps each copy with the version it came from, records it in ui/components/.vendored.json, and never overwrites an edited file without --force. The stylesheet goes to ui/styles/, not beside the source, because §1 loads CSS from ui/theme/** and ui/styles/** only — a .css next to the vendored .tsx is a file the engine never reads, and the component silently loses its look. The library's own string keys are written once to ui/strings/argus-ui.en.json for you to merge into the scene's meta.strings; that directory is deliberately not one of the six watched names, because it is a source for you rather than an engine input.

preview describes a screen against one of its scenarios with no engine and no GPU: the screen's flags, an outline naming every addressable node and the state it is in, and every string key it needs. --json prints the §7 description instead. Rendering a preview to pixels is the editor's job, not this command's.

fixtures/ui/gallery is the whole library on screen — one project, one screen per component, every variant and every state, with ui/scenarios/*.json as the named mock data and ui/expectations/*.json as what the critic checks a live tree against.

The race HUD kit

import { RacePosition, RaceTimer, Minimap } from "@argus/ui/components"; // a racing HUD, styled by the import

Whole, on a two-kart loop: argus://recipes/race-hud (fixtures/recipes/race-hud). Every piece is display-only, coloured by the --race-* names every built-in theme has, and each figure is its own node so a behaviour writes it with the cheapest binding:

componentwhat it showsa behaviour writes it with (@argus/gameplay)
RacePosition2nd / 8, podium places in medal coloursctx.ui.set("pos-value", "text", ordinal(place)), data-place
RaceTimerthe clock, last and best lap, the gapraceClock(t) into timer-time; raceDelta(gap) into timer-delta
BoostMetera drift's boost charge with tier notchesstyle:width on boost-fill, data-tier, data-boosting
ItemSlotthe held item, with a rouletterouletteItem(elapsed, names, landOn), then data-on on the item
StartCountdown3, 2, 1, GO!startCountdown(ctx, { hold }).start(), update(dt) each tick; emits race-go
RaceBannerFINAL LAP, WRONG WAY, NEW RECORDbanner(ctx).show("final-lap"), update(dt) each tick
Minimapa top-down map with a marker per racerminimap(ctx, { bounds }).place("you", ctx.entity.position)
RaceResultsthe finishing order; null time is DNFpushed with rows
RacePodiumthe top three on plinths, with model viewspushed with steps

Two things a race HUD trips on: the ui.race.* keys must be in the scene's meta.strings (engine ui add writes them to ui/strings/argus-ui.en.json to merge; an import alone does not), and a node the screen did not draw cannot be written, so give RaceTimer last={null} best={null} delta={null} ("not yet") for the lines a lap fills in.

Holding the karts until GO! An input: "auto" vehicle drives from the live input context, so the countdown can hold the player's kart still by switching the scene to a context with nothing to drive with. Declare one in meta.input.contexts ("countdown": {}) and pass its name: startCountdown(ctx, { hold: "countdown" }) switches to it at start() and back to the context it found at GO!, the tick it raises race-go. Run it from a behaviour on the player's kart (or the player spawn): a behaviour on a prop has no ctx.input, and holds nothing. A rival on the built-in road-driver waits with "startOn": "race-go" (VEHICLES.md §6). Without hold, or with a name the scene does not declare, nothing is held.

11. What does not exist yet

Do not reach for these; they are scheduled, and the parser will tell you so.

  • U3 landed, and this list said otherwise for a wave. The critic checks, GET /ui/schema and the /ui/* routes are all here: engine check-ui runs the checks over a saved tree or across the matrix against one or more running engines, GET /ui/schema publishes every property, selector, token, role and refusal code, and the routes are §3's. The component library, the design-system tokens, engine ui add and the gallery project are §10 above. KeyHint is in the library; it takes the bindings as a prop rather than reaching into the action map, because a screen is a pure function of its props (§2.7).
  • U3, still open: author @keyframes stay refused, and no preset rotates (the UI renderer does not draw rotate), so a busy button draws a bar rather than a spinner. animation: <preset> itself works (§5, "Motion presets").
  • What engine check-ui does not check: budget breaches. §3 lists them among its twelve, and they are measured in milliseconds and draw calls, which no computed tree carries. crates/app/src/ui/budget.rs holds the node ceiling and scripts/windows/perf-budget-check.ps1 the frame intervals, against fixtures/<scene>/perf-budget.json. ui-relayout is the part of that a tree can see: a node whose layout rectangle moves between two reads with nothing touched. A node rebuilt to the same rectangle costs the same and leaves no trace, and only GET /perf shows it.
  • U4 text landed: font fallback chains, wrapping, text-overflow: ellipsis with max-lines, line-height and letter-spacing, rich runs through <span>, and direction for a run that has no strong character of its own. §4's "Fonts and fallback", "Wrapping, overflow and line limits", "Direction and bidi" and "Rich runs" are all above, and the properties are in the table rather than in the refused list. text-transform stays refused: case mapping is locale-dependent, so write the string as it should read or key it per locale.
  • U4 text input landed: the <text-field> element, the caret and the selection, key repeat, the clipboard, IME composition, ctx.ui.get, and text and composition events on POST /ui/input; §9's Text input says what they do and where the edges are. TextField is in the component library and ui-field-no-label is in the critic. A field is a run of text like any other, so it takes font-family, line-height and the rest, and it is measured and shaped with them. It carries no inline spans: a field holds one editable string, and a span in it would be a range the caret could land inside and an edit could split.
  • U4 localisation landed: ui/locales/**, interpolation, plural forms and RTL layout mirroring. §12 below is the whole of it. With the text layout's bidi runs above, an Arabic string both reads right to left and sits in a box that has swapped sides.
  • U4, still open: group opacity through an offscreen layer, calc() as a candidate.
  • U5: world-space UI.

There is no calc(), no media query, no sibling combinator, no universal selector, no !important, no pseudo-element and no named colour. A React style={{ … }} object is the inline style attribute (§2), not CSS-in-JS: it has no selectors and no scoping. Each of those is refused with a reason rather than by omission.

12. Localisation

Every label a player reads is a key (§2's t()), and the table it resolves against is the project's. Resolution happens in Rust, at render time, which is the property everything else here follows from: switching language re-resolves the keys already on the stack, so a menu that is up comes back in the other language with its focus, its scroll position and its open dialog intact, and no behaviour runs again.

Where the strings are

Two places, and they are not alternatives:

  • meta.strings in the scene is the base table — the language the project is written in. A project with one language needs nothing else.
  • ui/locales/<tag>.json is one file per language. The file name is the tag: fr.json is the locale fr, and pt-BR.json is pt-BR.

For a key at the active locale, first match wins: the active locale's own file, then the base table, then [[key]]. So a half-translated project is a legitimate state — it draws the half it has in the translated language and the rest in the base one — and ui-locale-gap is the check that says so out loud rather than leaving it to somebody noticing.

The file format

Two shapes. The flat one is every file written before U4 and the right shape for a language that needs nothing said about it:

{ "menu.start": "Start", "menu.exit": "Exit" }

The full one can say what the language is:

{
  // `name` is what a picker shows, and the tag when absent.
  "name": "العربية",
  // `dir` is "ltr" (the default) or "rtl".
  "dir": "rtl",
  // `plural` is derived from the language subtag when absent.
  "plural": "arabic",
  "numbers": { "decimal": "٫", "group": "٬" },
  "strings": {
    "menu.start": "ابدأ",
    "score.line": { "one": "{n} cube", "other": "{n} cubes" }
  }
}

A document is read as the full form when its strings member is an object and every other top-level member is one of the four above; anything else is flat, so a flat file with a key called strings keeps its copy. A plural table is a value in either shape.

Every refusal is by name and costs one language, never the others: a dir that is not ltr or rtl, a plural rule this engine does not have, a plural table with no other form, a member the format does not know. The file is reported as ui.locale.file and the project's other translations still load.

Slots and plurals

{name} is a slot, filled from the node's parameters; {{ and }} are literal braces. A slot with no parameter draws [[key:name]] and raises ui.locale.missing-param, for the same reason [[key]] is ugly: it gets noticed.

A message may be a table of CLDR plural forms instead of one string. The category is chosen from the parameter named n, or count where there is no n, by the locale's rule:

ruleselectslanguages
invariantotherJapanese, Chinese, Korean, Vietnamese, Thai
one-otherone for 1, else otherEnglish, German, Spanish, Italian, Dutch, Scandinavian
frenchone for 0 and 1, else otherFrench, Brazilian Portuguese
arabiczero, one, two, few, many, otherArabic
slavicone for 1, 21, 31…, few for 2–4, 22–24…, else manyRussian, Ukrainian, Belarusian; Serbian, Croatian, Bosnian (whose many is written other)
polishone for 1, few for 2–4, 22–24…, else manyPolish
czechone for 1, few for 2–4, else otherCzech, Slovak

A file that names no rule gets the one its language subtag implies, so pt-BR is French's rule and ar-EG is Arabic's; a language the table does not list gets one-other, which is the commonest and whose failure mode is the mild one. Every category is accepted in a file whatever the rule, because a translator should not have to discover which subset this engine selects from — and other is required, because a count the rule does not name falls back to it.

The category is chosen from the absolute integer part of the count. 1.0 and 1 are alike, which is what a score line wants; CLDR's separate treatment of fractions is a corner no shipped locale needs.

Numbers are written with the locale's own separators, so a French 1234.5 reads 1 234,5.

Ordinals: 1st, 2nd, 3rd

Write "race.place": { "ordinal": { "one": "{n}st", "two": "{n}nd", "few": "{n}rd", "other": "{n}th" } } in ui/locales/en.json for a place that reads 1st, 2nd, 3rd, 4th.

A message whose only member is ordinal is a table of ordinal forms. The categories are the same as a plural table's, and other is required. They are chosen from the same n (or count), but by the language's CLDR ordinal rule. English counts are one/other, while its positions are one (1st, 21st), two (2nd), few (3rd) and other (4th, 11th, 12th, 13th). The rule comes from the language subtag. A file does not name it.

ordinal ruleselectslanguages
Englishone for 1, 21, 31…; two for 2, 22…; few for 3, 23…; never for 11, 12, 13; else otherEnglish
Frenchone for 1, else otherFrench, Vietnamese, Malay
Italianmany for 8, 11, 80, 800, else otherItalian
Swedishone for 1, 2, 21, 22… (not 11, 12), else otherSwedish
otherother onlyevery other language: write one form, such as German {n}.

A key a translation lacks falls back to the default locale's table with that locale's own ordinal rule, as a plural key does. meta.strings holds plain strings only, so an ordinal table lives in a locale file. Without one, a game writes the place as text, as before.

Parameters from a behaviour

ctx.ui.set(id, "text-param:n", 3) writes one slot, and a number stays a number across the binding so the plural rule can select on it. It is a binding like any other, so the change gate applies and a behaviour should write unconditionally. The text is re-resolved by the locale pass rather than by the write, so a later setLocale picks the right form in the new language.

RTL: what mirrors and what does not

A locale marked dir: "rtl" mirrors the inline axis of the whole UI. fixtures/ui/gallery's gallery-direction screen is the worked example, and the rule set is short:

writtenmirrors?
flex-direction: row, row-reverseyes — and row-reverse reverses back, because it meant the far end
flex-direction: column, column-reverseno — the block axis runs top to bottom in every language
text-align: start, endyes
text-align: centerno
align-items/align-self/align-content: start, end, on a columnyes — a column's cross axis runs along the text
the same, on a rowno — a row's cross axis is vertical
justify-contentno, separately — a row's main axis is already mirrored by reversing it
padding-inline-start/-end, margin-inline-start/-endyes
padding-left/-right, margin-left/-right, top/right/bottom/leftno — a physical side stays physical
left/right focus navigationyes — a pad flick walks the menu in reading order

The last pair of rows is the one worth reading twice. padding-inline-start is about the reader: it is the gap before the text begins, on the left in English and on the right in Arabic. padding-left is about the screen: it is the gap on the left, and it stays there, which is what something positioned against a window edge, a safe area or a hardware bezel needs. A mirroring that took the physical sides with it would look right on a page of text and be wrong the first time either of those came up.

Where an author writes both a logical side and the physical side it lands on, the logical one wins. CSS resolves the two by declaration order; one documented rule is a better trade than a rule nobody can predict. Write only the physical side if you want it to win.

text-align: left and right parse and are warned about, for exactly this reason: they are the spelling that goes wrong the first time a project ships a right-to-left language.

This is the layout half of right to left. The other half — which way a run's own characters read — is §4's "Direction and bidi", and it is the same value: a locale file's dir and the direction property are both argus_ui::TextDirection. The locale's dir is what every screen root inherits direction from, so it reaches each run the way a direction on a parent would. A run made only of neutrals — a score, a time — takes an rtl base under Arabic without anyone writing it, and start and end in text-align land on the right side for the run they are on.

Because it is inherited, an author's own direction wins wherever one is written. On a child, that child and its subtree read that way — a clock that must stay 7:30 rather than 30:7 is direction: ltr on the clock. On a screen's root, the whole screen does, layout included: the screen mirrors by its root's resolved direction, not by the locale's directly, so a debug overlay or a map legend opts out with one declaration.

Choosing the language

settings.ui.defaultLocale in the project manifest names the tag the game opens in — a tag, not a path, because POST /ui/locale, ctx.ui.setLocale and the editor's picker all say fr. A player's own choice is remembered in <project>/.argus/ui.json, beside their key rebinds and for the same reason, and it wins over the default. The player's choice is the one a game's own language menu makes, ctx.ui.setLocale; POST /ui/locale and the editor's picker switch without remembering, because they are an agent or an author looking at a language, and a critic matrix walking every locale must not leave its last cell behind as the player's pick. A run that names no project (a bare scene file) remembers nothing.

A Restart or a scene switch keeps all of it. ctx.session.restart() and ctx.session.loadScene build a fresh world, and its UI starts from the project the last one had: the same stylesheets and theme, the same locale tables, and the theme and locale in force at that moment, whoever chose them. Before this, the world after a Restart drew every screen unstyled in dark and a project whose strings live in ui/locales/** showed [[menu.title]], in editor Play and in the exported game.

meta.strings' <tag>.<key> prefixes are locales too: switching to a tag that only a prefix names moves the whole UI to it — its strings, the base table for what it lacks, left to right — even from a file locale such as ar. A plural key a translation lacks falls back to the default locale's plural table, selected by that locale's own rule, so 1 cube stays singular in a half-translated French.

At run time the switch is ctx.ui.setLocale(tag) from a behaviour, POST /ui/locale from the query API, or the locale picker in the editor's screen preview. All three go through one function, so they cannot disagree about what happened.

What the schema and the critic say

GET /ui/schema publishes locales (a flat list of tags, which is what a matrix walks), locale, direction, pluralRules, and localeDetail — per locale, its own name, its direction, its plural rule, how many keys it translates and which base keys it does not. The critic reads that rather than the directory, because two readers of ui/locales/** is what U3 ended up with between the editor and the game, and they disagreed.

Three checks are about this table. ui-missing-string fires on a key that resolved to [[key]]; ui-untranslated on a visible string with no key at all, naming the locales it is stuck in; and ui-locale-gap on a key the base table has and the active locale does not, which is the one the runtime's own fallback hides. ui-text-overflow measures both axes, because a wrapped translation grows downwards rather than sideways.

13. Reference: React → Talos

Each React spelling a screen accepts and what it lowers to; "Writing screens the React way" in §2 is the prose. Refused means the element is refused when it is built, with one sentence that names the way to write it.

React / HTMLTalosnotes
div section main header footer nav form article aside<container>a stylesheet selects them as container, or by class
p label<text>
h1 … h6<text data-level="1"> …select as text[data-level="1"]
strong b / em i inside a p<span weight={700}> / <span italic>a span outside a text run is refused
img src alt<image src>alt is accepted and not drawn: there is no accessibility tree yet
button<button>type="button" is accepted and changes nothing
input / type="password"<text-field> / passwordplaceholder is a literal here (placeholderText); any other type is refused
textarea<text-field multiline>
ul ol / li<list> / <list-row>no bullets or numbers are drawn
dialog<dialog>
classNameclassstring, array or record
style={{ backgroundColor: "…" }}style={{ "background-color": "…" }}a number only where CSS takes a bare number, or 0; a length needs "40ui" or "40%"
data-count="2"data={{ count: 2 }}
autoFocusinitialFocus
keykeya number is stringified
onClick={emit("go", data)}the bus event "go", after "ui-activated"from every activation path
onClick={() => …}refusedwrite emit(…) and handle the event in a behaviour
onChange onInput onKeyDown onSubmitrefusedread a field with ctx.ui.get(id, "value") in a behaviour
other on*refused:hover :active :focus in the stylesheet; ctx.input in a behaviour
useMemo useCallback memoaccepteda memo in a function called once is the value
import React from "react", React.createElement@argus/ui/react, hh and Fragment are supplied when a screen does not import them
StrictModea fragment
Suspense lazy startTransition createRef Children cloneElement isValidElementrefusednothing suspends: a screen is described once, synchronously
class X extends Componentrefusedwrite a function component; state lives in a behaviour
useState useReducerrefusedkeep the value in a behaviour; push it with ctx.ui.set or ctx.ui.push(name, props)
useEffect useLayoutEffect useRef useContext createContext forwardRef useId …refuseda screen runs no code after it is described; pass values as props
ref dangerouslySetInnerHTML htmlFor aria-*refusedno DOM, no HTML, no accessibility tree yet
a select option table video audio canvas iframe svg br hrrefusedeach message names the Talos way, or says there is none
window document fetch localStorage setTimeoutrefusedthe ReferenceError is rewritten to say where the work goes
export default function Page()refusedwrap it in defineScreen({ name, render, scenarios })
import "./x.css"droppedui/styles/** and ui/theme/** are loaded for every screen; a .css elsewhere is never read

Not here yet, on any platform: touch input (a tap will activate like a click once touch is routed to the pointer), safe-area insets (env(safe-area-inset-top)), and a virtual keyboard for a text field on consoles and phones.