# Authoring Talos UI

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. `docs/PLAN-UI.md` is the plan this implements and the place to
look for *why*; where a rule here has a reason worth knowing, the section number points at it.

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

```tsx
// 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.

| element | what it is | focusable by default |
|---|---|---|
| `<container>` | a layout box with no content of its own | no |
| `<text>` | a run of text | no |
| `<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 `height` | no |
| `<button>` | activatable | **yes** |
| `<list>` | a container whose children are rows | no |
| `<list-row>` | one row of a list | **yes** |
| `<dialog>` | a screen-level panel; a screen whose root is one is normally modal | no |
| `<toast>` | a transient notification; never focusable, never hit-testable | no |
| `<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 below | no |

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
(`docs/PLAN-UI.md` §7, U5 slice 1; 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.

| attribute | type | what it does |
|---|---|---|
| `value` | `string` | what the field starts with. A binding or the player changes it from there |
| `placeholder` | `string` | a `meta.strings` key, shown while the field is empty |
| `placeholderText` | `string` | the placeholder as a literal, for a value with no translation |
| `maxLength` | `number` | the most **characters** the field accepts — characters, so a limit means the same thing in Japanese |
| `readOnly` | `boolean` | focusable, selectable and copyable, and not editable. Not the same as `disabled`, which refuses focus outright |
| `password` | `boolean` | draw `•` per character, keep the value out of `GET /ui`, and refuse the system clipboard |
| `multiline` | `boolean` | take newlines; Enter inserts one rather than submitting |

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

### Attributes

| attribute | type | what it does |
|---|---|---|
| `class` | `string \| string[] \| Record<string, boolean>` | class names; the record form includes the keys whose value is true |
| `id` | `string` | the 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) |
| `key` | `string` | the 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 |
| `data` | `Record<string, string \| number \| boolean>` | data attributes, written without the `data-` prefix; values are stringified |
| `variant` | `string` | shorthand for `data={{ variant }}` |
| `theme` | `string` | shorthand for `data={{ theme }}`; on a root node this scopes a theme |
| `disabled` `selected` `checked` | `boolean` | authored state, matched by `:disabled`, `:selected`, `:checked` |
| `focusable` | `boolean` | overrides the role's default |
| `tabIndex` | `number` | explicit 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 |
| `style` | `Record<string, string \| number>` | inline CSS declarations, kebab-case or camelCase. See the warning below |
| `src` | `string` | on `<image>` only |
| `initialFocus` | `boolean` | focus lands here when the screen first takes input, instead of on the first node in Tab order. `autoFocus` is the same thing |
| `onClick` | `emit(event, data?)` | the bus event every activation of the node raises; see "Writing screens the React way" |
| `className` | as `class` | React's spelling of `class`. Writing both on one node is refused |
| `data-<name>` | `string \| number \| boolean` | HTML'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:

```tsx
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.

```tsx
// 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: {} },
});
```

```ts
// 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:

```rust
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

```css
/* 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

| form | example | notes |
|---|---|---|
| class | `.panel` | |
| role | `button` | one 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` `:last` | nine, and no others |
| child | `.list > .row` | the direct parent |
| descendant | `[data-theme="dark"] .menu .row` | any ancestor |
| list | `.a, .b` | becomes 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.

```css
/* 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:

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

### Size classes, not media queries

```css
@size compact {
  .stat-row { flex-direction: column; }
}
/* identical to: */
[data-size="compact"] .stat-row { flex-direction: column; }
```

| class | width in `ui` units | what it means |
|---|---|---|
| `compact` | under 1900 | a small window, or a player who turned the UI scale up |
| `regular` | 1900 to 2100 | every ordinary surface, 4:3 through 16:9, at any resolution |
| `expanded` | 2100 and over | wider 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-rule | example |
|---|---|
| `@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.

| property | values | initial | inherits | transitions as |
|---|---|---|---|---|
| `display` | `flex \| block \| grid \| none` | `flex` | — | — |
| `flex-direction` | `row \| column \| row-reverse \| column-reverse` | `row` | — | — |
| `flex-wrap` | `nowrap \| wrap \| wrap-reverse` | `nowrap` | — | — |
| `justify-content` | `auto \| flex-start \| start \| flex-end \| end \| center \| stretch \| space-between \| space-around \| space-evenly` | `auto` | — | — |
| `align-items` | `auto \| flex-start \| start \| flex-end \| end \| center \| stretch \| baseline` | `auto` | — | — |
| `align-self` | `auto \| flex-start \| start \| flex-end \| end \| center \| stretch \| baseline` | `auto` | — | — |
| `align-content` | `auto \| flex-start \| start \| flex-end \| end \| center \| stretch \| space-between \| space-around \| space-evenly` | `auto` | — | — |
| `gap` | `<length>{1,2}` | `0` | — | — |
| `row-gap` | `<length>` | `0` | — | — |
| `column-gap` | `<length>` | `0` | — | — |
| `flex-grow` | `<number>` | `0` | — | — |
| `flex-shrink` | `<number>` | `1` | — | — |
| `flex-basis` | `<length> \| auto` | `auto` | — | — |
| `order` | `<integer>` | `0` | — | — |
| `position` | `relative \| absolute` | `relative` | — | — |
| `inset` | `<length-or-auto>{1,4}` | `auto` | — | position |
| `top` | `<length> \| auto` | `auto` | — | position |
| `right` | `<length> \| auto` | `auto` | — | position |
| `bottom` | `<length> \| auto` | `auto` | — | position |
| `left` | `<length> \| auto` | `auto` | — | position |
| `width` | `<length> \| auto` | `auto` | — | size |
| `height` | `<length> \| auto` | `auto` | — | size |
| `min-width` | `<length> \| auto` | `auto` | — | — |
| `min-height` | `<length> \| auto` | `auto` | — | — |
| `max-width` | `<length> \| auto` | `auto` | — | — |
| `max-height` | `<length> \| auto` | `auto` | — | — |
| `aspect-ratio` | `<number> \| <number> / <number> \| auto` | `auto` | — | — |
| `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> \| auto` | `0` | — | — |
| `margin-right` | `<length> \| auto` | `0` | — | — |
| `margin-bottom` | `<length> \| auto` | `0` | — | — |
| `margin-left` | `<length> \| auto` | `0` | — | — |
| `overflow` | `(visible \| hidden \| scroll \| auto){1,2}` | `visible` | — | — |
| `grid-template-columns` | `none \| <track>+  where <track> = auto \| min-content \| max-content \| <length> \| <percentage> \| <number>fr \| repeat(<integer>, <track>+)` | `none` | — | — |
| `grid-template-rows` | `none \| <track>+  where <track> = auto \| min-content \| max-content \| <length> \| <percentage> \| <number>fr \| repeat(<integer>, <track>+)` | `none` | — | — |
| `grid-column` | `auto \| <integer> \| span <integer> \| <start> / <end>` | `auto` | — | — |
| `grid-row` | `auto \| <integer> \| span <integer> \| <start> / <end>` | `auto` | — | — |
| `background-color` | `<color>` | `transparent` | — | background-color |
| `background-image` | `none \| 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-style` | `solid` | `solid` | — | — |
| `border-image-slice` | `none \| <number>{1,4} \| <percentage>{1,4}` | `none` | — | — |
| `object-fit` | `fill \| contain \| cover` | `fill` | — | — |
| `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-shadow` | `none \| <offset-x> <offset-y> <blur> <color>` | `none` | — | — |
| `color` | `<color>` | `white` | yes | color |
| `font-size` | `<length> \| <percentage>` | `16px` | yes | — |
| `font-weight` | `100..900 by 100 \| normal \| bold` | `400` | yes | — |
| `text-align` | `start \| center \| end` | `start` | yes | — |
| `font-family` | `<family-name> \| <string> \| var(<token>)` | `the chain's first font` | yes | — |
| `font-style` | `normal \| italic` | `normal` | yes | — |
| `line-height` | `normal \| <number> \| <length>` | `normal` | yes | — |
| `letter-spacing` | `<length>` | `0` | yes | — |
| `white-space` | `normal \| nowrap \| pre` | `normal` | yes | — |
| `text-overflow` | `clip \| ellipsis` | `clip` | yes | — |
| `font-variant-numeric` | `normal \| tabular-nums` | `normal` | yes | — |
| `text-shadow` | `none \| [<offset-x> <offset-y> <blur>? <color>?]#` | `none` | yes | — |
| `transform` | `none \| 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` | — | — |
| `animation` | `none \| <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-function` | `linear \| ease \| ease-in \| ease-out \| ease-in-out` | `ease` | — | — |
| `z-index` | `<integer>` | `0` | — | — |
| `-argus-hairline` | `true \| false` | `false` | — | — |
| `max-lines` | `none \| <integer>` | `none` | yes | — |
| `direction` | `ltr \| rtl` | `ltr` | yes | — |
| `caret-color` | `<color> \| auto` | `auto` | yes | — |
| `selection-color` | `<color>` | `transparent` | yes | — |
| `-argus-scrollbar-color` | `auto \| <color>` | `auto` | — | — |
| `padding-inline-start` | `<length>` | `0` | — | — |
| `padding-inline-end` | `<length>` | `0` | — | — |
| `margin-inline-start` | `<length> \| auto` | `0` | — | — |
| `margin-inline-end` | `<length> \| auto` | `0` | — | — |
| `object-view-box` | `none \| xywh(<number>px\|<percentage>{4})` | `none` | — | — |
| `image-rendering` | `auto \| smooth \| pixelated \| crisp-edges` | `auto` | yes | — |
| `-webkit-text-stroke` | `<length> [<color>] \| <color> <length>` | `0 currentcolor` | yes | — |
| `-webkit-text-stroke-width` | `<length>` | `0` | yes | — |
| `-webkit-text-stroke-color` | `<color> \| currentcolor` | `currentcolor` | yes | — |

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](#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

| unit | meaning |
|---|---|
| `px` | logical pixels, unscaled — for a literal pixel such as a hairline |
| `ui` | logical pixels times the root UI scale (§2.5) — the default unit for anything the player reads |
| `%` | percent of the parent's corresponding axis |
| `deg` | degrees, clockwise from up |
| `ms` | milliseconds |
| `s` | seconds |
| `fr` | a 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.

| written | this subset wants |
|---|---|
| `em` | use `ui`, which scales with the player's UI setting rather than with a font |
| `rem` | use `ui`: 1rem of a 16px root is 16ui |
| `vh` | use `%` of a parent that fills the screen; the screen root *is* the surface |
| `vw` | use `%` of a parent that fills the screen |
| `vmin` | use `%` |
| `vmax` | use `%` |
| `ch` | use `ui` |
| `ex` | use `ui` |
| `pt` | use `ui` |
| `cm` | use `ui` |
| `mm` | use `ui` |
| `in` | use `ui` |

### Functions

| function | meaning |
|---|---|
| `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 |

| written | this 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 render lane; 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:

```jsonc
"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.

| flag | default | what it does |
|---|---|---|
| `--out <png>` | `ui/images/minimap.png` in the scene's project | where the image goes; the bounds go beside it, `.json` for `.png` |
| `--size <px>` | `512` | the longer edge, 16 to 2048; the other edge follows the track's shape |
| `--padding <m>` | `4` | ground left around the roads on every side |
| `--color <#rrggbb>` | `#ffffff` | the 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:

```css
.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:

```css
@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.

```css
.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.

```css
.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:

```tsx
<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:

```css
.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.

| property | status | instead |
|---|---|---|
| `flex` | refused | write `flex-grow`, `flex-shrink` and `flex-basis`; the shorthand's three implied defaults are the commonest source of a layout nobody can explain |
| `overflow-x` | refused | write `overflow: <x> <y>`; the runtime stores both axes in one value, so setting one alone would silently reset the other |
| `overflow-y` | refused | write `overflow: <x> <y>`; the runtime stores both axes in one value, so setting one alone would silently reset the other |
| `background` | refused | write `background-color` or `background-image`; the shorthand resets seven properties, five of which do not exist here |
| `text-transform` | deferred (U4) | write the string as it should read, or use a `meta.strings` key per locale — case mapping is locale-dependent (§2.11) |
| `grid` | refused | the `grid` shorthand sets eight properties including `grid-template-areas`, which needs named areas; write `grid-template-columns` and `grid-template-rows` |
| `grid-area` | deferred (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 |
| `float` | refused | use flex: `display: flex` with `flex-direction: row` |
| `box-sizing` | refused | every box is already border-box: padding and border are inside the width |
| `cursor` | refused | cursor visibility is a property of the topmost screen that wants input (§2.9), not of a node |
| `visibility` | refused | use `display: none`, or `opacity: 0` for something that still takes up space |
| `transform-origin` | refused | `scale` is about the box centre and that is the only origin there is |
| `filter` | refused | no filters and no backdrop blur: §1.2 records the reference project measuring backdrop blur as full-surface repaint cost |
| `backdrop-filter` | refused | no filters and no backdrop blur: §1.2 records the reference project measuring backdrop blur as full-surface repaint cost |
| `will-change` | refused | the 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 |
| `outline` | refused | the focus ring is drawn from the `focus-ring` token on `:focus-visible`; use `border-color` or `box-shadow` |
| `content` | refused | there are no pseudo-elements; author the text as a `text` node |
| `contain` | refused | §1.2 records layout containment on a scroll container costing several hundred milliseconds of input lag in the reference project |

## 5. Transitions

```css
.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

```css
.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.

| preset | what it does | default |
|---|---|---|
| `pop` | scales 0.6 → 1.08 → 1 and fades in: something arrives | 350ms |
| `slam-in` | drops in from 80ui above, overshoots, settles | 450ms |
| `slide-in-up` / `-down` / `-left` / `-right` | in from 60ui away on that side | 350ms |
| `fade-in` | opacity in | 300ms |
| `pulse` | scale 1 ↔ 1.06 (loop it): ready to press | 1.2s |
| `bob` | up and down 6ui (loop it): idle | 1.2s |
| `shake` | a short sideways jitter: refused, lost | 400ms |
| `stamp` | from 1.8x and clear to full size with a thump: a number, a record | 450ms |

**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).

```rust
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%"));
```

| property | value | what it costs |
|---|---|---|
| `text`, `text-key` | text or a number | that node re-measured, the draw list rebuilt. **No cascade** |
| `disabled`, `selected`, `checked` | a boolean | that node's subtree re-resolved |
| `data-<name>` | text or a number | that node's subtree re-resolved |
| `class:<name>` | a boolean, to add or remove | that node's subtree re-resolved |
| `style:<property>` | CSS value text | that node's subtree re-resolved |

A re-resolve lays the screen out again only if it changed something layout reads. A colour, an
opacity, a `translate`, a `scale` or a `z-index` is redrawn with no layout pass. So is moving a
`position: absolute` node by the inset it is anchored by (`left` or `right`, `top` or `bottom`,
one of each pair) inside a flex parent with no scrolling ancestor: a minimap marker written
every frame costs a redraw. Setting both insets of a pair, a block or grid parent, or a size,
padding, font or anything else that can move a box still lays out, as before.

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.

```json
{
  "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`:

```jsonc
"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

| code | severity |
|---|---|
| `ui.tree.duplicate-id` | error |
| `ui.tree.invalid-id` | error |
| `ui.style.unknown-token` | error |
| `ui.style.token-cycle` | error |
| `ui.style.token-kind` | error |
| `ui.style.gradient-stops` | error |
| `ui.style.unsupported-value` | error |
| `ui.style.root-size-ignored` | warning |
| `ui.layout.grid-unsupported` | error |
| `ui.layout.failed` | error |
| `ui.focus.nav-target-missing` | error |
| `ui.focus.target-missing` | error |
| `ui.focus.no-focusable` | warning |
| `ui.screen.missing` | error |
| `ui.screen.duplicate` | error |
| `ui.screen.stack-empty` | warning |
| `ui.screen.no-exit` | warning |
| `ui.transition.not-animatable` | warning |
| `ui.draw.rotate-unsupported` | warning |
| `ui.draw.gradient-truncated` | warning |
| `ui.draw.group-opacity` | warning |
| `ui.diagnostics.overflow` | warning |
| `ui.css.syntax` | error |
| `ui.css.unknown-property` | error |
| `ui.css.unknown-value` | error |
| `ui.css.unknown-unit` | error |
| `ui.css.unknown-function` | error |
| `ui.css.unknown-selector` | error |
| `ui.css.combinator-depth` | error |
| `ui.css.unknown-at-rule` | error |
| `ui.css.important` | error |
| `ui.css.media-query` | error |
| `ui.css.calc` | error |
| `ui.css.value-range` | error |
| `ui.css.unknown-semantic` | error |
| `ui.css.unknown-size-class` | error |
| `ui.css.reserved-token` | error |
| `ui.css.deferred-property` | error |
| `ui.css.physical-side` | warning |
| `ui.css.duplicate-declaration` | warning |
| `ui.describe.format` | error |
| `ui.describe.unknown-role` | error |
| `ui.describe.unknown-prop` | error |
| `ui.describe.value` | error |
| `ui.describe.too-large` | error |
| `ui.describe.model-view-subject.tentative` | warning |
| `ui.describe.model-view-value.tentative` | error |
| `ui.model-view.count-cap.tentative` | warning |
| `ui.model-view.unresolved.tentative` | warning |
| `ui.describe.root-id` | warning |
| `ui.bind.unknown-node` | error |
| `ui.bind.unknown-prop` | error |
| `ui.bind.value-kind` | error |
| `ui.asset.image-missing` | warning |
| `ui.layout.grid-placement-ignored` | warning |
| `ui.layout.grid-tracks-truncated` | warning |
| `ui.scroll.not-scrollable` | warning |
| `ui.asset.atlas-budget` | warning |
| `ui.locale.file` | error |
| `ui.locale.missing-param` | warning |
| `ui.draw.view-box-outside` | warning |
| `ui.font.face` | warning |
| `ui.font.glyph-missing` | warning |
| `ui.layout.image-zero-size` | warning |
| `ui.font.unavailable` | warning |

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:

| code | severity | raised by |
|---|---|---|
| `ui.screen.scenarios` | warning | `defineScreen`, 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

```text
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`. -->

```jsonc
"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`

```ts
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:

```ts
const name = ctx.ui.get("signin:name", "value") as string
```

| property | answers |
|---|---|
| `value` | what a `text-field` holds |
| `length` | how many **characters** it holds, which is what `maxLength` counts |
| `caret` | the caret's byte offset into `value` |
| `selection` | `[from, to]` byte offsets, or `null` when nothing is selected |
| `composition` | the IME preedit in flight, or `null` |
| `text` | any 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 (`docs/status/agent-demo-2.md`, gap 2). 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:

| code | when |
|---|---|
| `ui.screen.unknown` | no screen answers to that name; the message lists the ones that do |
| `ui.screen.bad-json` | the 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.duplicate` | `push` naming a screen already on the stack, or `replace` naming one below the top; the stack is unchanged |
| `ui.stack.empty` | `pop` with nothing on the stack |
| `ui.set.unknown-node` | no screen on the stack holds that id, or a bare id is ambiguous |
| `ui.set.unknown-prop` | the property is not bindable; the message carries §6's own hint |
| `ui.set.bad-value` | the property is bindable and the value is the wrong kind for it |
| `ui.unavailable` | this 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`:

```ts
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` (`docs/status/agent-demo-1.md`, gap 9). 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.

```ts
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()`.

```tsx
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.

```ts
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:

| input | what it does |
|---|---|
| wheel | scrolls the container under the pointer, **chaining** to the next scrollable ancestor when this one has reached its end |
| pointer drag | drags the content where the press found nothing focusable, and drags the thumb where it landed on a bar |
| press on a track | jumps the thumb to it and then drags, so a 500-row list is one gesture |
| Page Up / Page Down | seven eighths of a viewport; consumed **only if something moved**, so a page key on a screen with no scroller reaches the game |
| Home / End | the top and the end of the travel |
| an arrow key | moves focus while there is somewhere for focus to go, and scrolls a line when there is not |
| `uiScroll` | the pad's right stick, as a rate with a deadzone rather than a discrete step |
| `uiScrollPage` | a shoulder pair, one page per push and no auto-repeat |
| moving focus | brings the focused node into view through every scroller above it, moving each as little as it can |
| `POST /ui/activate`, `ui.activate` | the 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 `docs/PLAN-UI.md` §5 (Editor integration) and the "Hot reload" section of `docs/EDITOR.md` promise a hot reload keeps 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:

```js
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:

```jsonc
"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:

| class | what it is | stock look |
|---|---|---|
| `.hud-row` | a stacked field's row, full width | none |
| `.hud-anchored` | a `meta.hud` field's full-surface box, aligning its run to the anchor | none |
| `.hud-text` | the run the player reads | white on a `rgba(12 14 20 / 0.72)` plate, 26ui |
| `.hud-shadow` | a second run one pixel down and right, under the first | `display: 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. (A click-through screen pushed *above* a menu is the one exception: its opted-in
controls join the menu's Tab ring, which is how a sticky toast is acknowledged from a keyboard.
The HUD is the bottom of the stack, so its rows never are.)

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 changed | What it costs | What survives |
|---|---|---|
| `ui/theme/**`, `ui/styles/**` | A reparse and a re-resolve, in Rust, with no script involved | Focus, scroll position, an open dialog, every binding |
| `ui/screens/**`, `ui/components/**` | esbuild and a re-register | Hover, press and focus by id; state while the props shape is unchanged |
| `ui/scenarios/**` | Re-reading the mock data | Everything |

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.

```tsx
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` (`docs/status/agent-demo-2.md`, gap 7).

### The two layers, and which one you may copy

| layer | what | may you copy it |
|---|---|---|
| **primitive** | Box, Stack, Text, Image, Icon, Spacer, Divider, ScrollArea, Grid, Overlay | **No.** Engine-owned and versioned. Import it. |
| **shared** | the loading / empty / error views | **No.** A project with its own idea of "empty" has left the design system. |
| **composed** | Button, 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, Minimap | **Yes** — `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.

```tsx
<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"`.

| group | names |
|---|---|
| the eleven | `surface` `on-surface` `accent` `danger` `success` `warning` `focus-ring` `disabled` `scrim` `border` `muted` |
| surfaces | `surface-raised` `surface-sunken` `surface-overlay` `surface-hover` `surface-press` `surface-selected` `surface-disabled` `surface-accent` `surface-danger` `surface-success` `surface-warning` |
| foregrounds | `on-surface-strong` `inactive-ink` `on-accent` `on-danger` `on-success` `on-warning` |
| interaction, lines | `accent-hover` `accent-press` `border-strong` `border-subtle` `track` `shadow-color` |
| scales | `text-*` `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.

```tsx
<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.

| field | why it is there |
|---|---|
| `severity` | `info` `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. |
| `kickerKey` | the two-word label above the message: `OBJECTIVE`, `SUPPLY`, `RANGE SAFETY`. What makes a stack scannable from across a room. |
| `sourceKey` | attribution. A trainee who cannot tell the simulator from the scenario from their own squad cannot act on any of it. |
| `dedupeKey` | identity. Twelve identical "out of ammunition" toasts is a bug that only appears under load, so the fix lives in the component. |
| `durationMs` | per toast. "Saved" and "you are about to fail this exercise" do not deserve the same four seconds. |
| `requiresAcknowledgement` | sticky mode. A player looking down a sight is not present to see a toast; one that matters waits and carries something to press. |
| `requirements` | the 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).

**A keyboard and a pad reach the acknowledgement too, when a menu is up.** Keys go to the screen
that owns the keyboard, never to a click-through one. But when the toast's layer sits *above*
that screen (a toast arriving while a menu is open), the menu's focus ring continues onto
controls that opted back in with `hit="always"`. Tab past the menu's last control, Shift+Tab
before its first, or a pad direction with nothing left on the menu that way moves focus onto the
acknowledgement, and Enter or the pad's activate presses it. The same moves bring focus back.
Focus never moves onto a toast by itself, so a press meant for the menu cannot acknowledge a
message nobody read, and Escape still belongs to the stack. An ordinary toast is `hit="none"` and
is never on the ring. Under a menu, or on a bare HUD with no menu up, the keys belong to the menu
or to the game, and only a click reaches the acknowledgement.

`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**.

```ts
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

```tsx
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:

| component | what it shows | a behaviour writes it with (`@argus/gameplay`) |
|---|---|---|
| `RacePosition` | 2nd / 8, podium places in medal colours | `ctx.ui.set("pos-value", "text", ordinal(place))`, `data-place` |
| `RaceTimer` | the clock, last and best lap, the gap | `raceClock(t)` into `timer-time`; `raceDelta(gap)` into `timer-delta` |
| `BoostMeter` | a drift's boost charge with tier notches | `style:width` on `boost-fill`, `data-tier`, `data-boosting` |
| `ItemSlot` | the held item, with a roulette | `rouletteItem(elapsed, names, landOn)`, then `data-on` on the item |
| `StartCountdown` | 3, 2, 1, GO! | `startCountdown(ctx, { hold }).start()`, `update(dt)` each tick; emits `race-go` |
| `RaceBanner` | FINAL LAP, WRONG WAY, NEW RECORD | `banner(ctx).show("final-lap")`, `update(dt)` each tick |
| `Minimap` | a top-down map with a marker per racer | `minimap(ctx, { bounds }).place("you", ctx.entity.position)` |
| `RaceResults` | the finishing order; `null` time is DNF | pushed with `rows` |
| `RacePodium` | the top three on plinths, with model views | pushed 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 as of wave C; 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 lane'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:

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

The full one can say what the language *is*:

```jsonc
{
  // `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:

| rule | selects | languages |
|---|---|---|
| `invariant` | `other` | Japanese, Chinese, Korean, Vietnamese, Thai |
| `one-other` | `one` for 1, else `other` | English, German, Spanish, Italian, Dutch, Scandinavian |
| `french` | `one` for 0 and 1, else `other` | French, Brazilian Portuguese |
| `arabic` | `zero`, `one`, `two`, `few`, `many`, `other` | Arabic |
| `slavic` | `one` for 1, 21, 31…, `few` for 2–4, 22–24…, else `many` | Russian, Ukrainian, Belarusian; Serbian, Croatian, Bosnian (whose `many` is written `other`) |
| `polish` | `one` for 1, `few` for 2–4, 22–24…, else `many` | Polish |
| `czech` | `one` for 1, `few` for 2–4, else `other` | Czech, 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 rule | selects | languages |
|---|---|---|
| English | `one` for 1, 21, 31…; `two` for 2, 22…; `few` for 3, 23…; never for 11, 12, 13; else `other` | English |
| French | `one` for 1, else `other` | French, Vietnamese, Malay |
| Italian | `many` for 8, 11, 80, 800, else `other` | Italian |
| Swedish | `one` for 1, 2, 21, 22… (not 11, 12), else `other` | Swedish |
| other | `other` only | every 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:

| written | mirrors? |
|---|---|
| `flex-direction: row`, `row-reverse` | yes — and `row-reverse` reverses back, because it meant *the far end* |
| `flex-direction: column`, `column-reverse` | no — the block axis runs top to bottom in every language |
| `text-align: start`, `end` | yes |
| `text-align: center` | no |
| `align-items`/`align-self`/`align-content`: `start`, `end`, **on a column** | yes — a column's cross axis runs along the text |
| the same, **on a row** | no — a row's cross axis is vertical |
| `justify-content` | no, separately — a row's main axis is already mirrored by reversing it |
| `padding-inline-start`/`-end`, `margin-inline-start`/`-end` | yes |
| `padding-left`/`-right`, `margin-left`/`-right`, `top`/`right`/`bottom`/`left` | **no** — a physical side stays physical |
| left/right focus navigation | yes — 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 (`docs/status/agent-demo-2.md`, gap 1).

`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 / HTML | Talos | notes |
|---|---|---|
| `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>` / `password` | `placeholder` 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>` | |
| `className` | `class` | string, 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 }}` | |
| `autoFocus` | `initialFocus` | |
| `key` | `key` | a number is stringified |
| `onClick={emit("go", data)}` | the bus event `"go"`, after `"ui-activated"` | from every activation path |
| `onClick={() => …}` | refused | write `emit(…)` and handle the event in a behaviour |
| `onChange` `onInput` `onKeyDown` `onSubmit` | refused | read 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` `memo` | accepted | a memo in a function called once is the value |
| `import React from "react"`, `React.createElement` | `@argus/ui/react`, `h` | `h` and `Fragment` are supplied when a screen does not import them |
| `StrictMode` | a fragment | |
| `Suspense` `lazy` `startTransition` `createRef` `Children` `cloneElement` `isValidElement` | refused | nothing suspends: a screen is described once, synchronously |
| `class X extends Component` | refused | write a function component; state lives in a behaviour |
| `useState` `useReducer` | refused | keep the value in a behaviour; push it with `ctx.ui.set` or `ctx.ui.push(name, props)` |
| `useEffect` `useLayoutEffect` `useRef` `useContext` `createContext` `forwardRef` `useId` … | refused | a screen runs no code after it is described; pass values as props |
| `ref` `dangerouslySetInnerHTML` `htmlFor` `aria-*` | refused | no DOM, no HTML, no accessibility tree yet |
| `a` `select` `option` `table` `video` `audio` `canvas` `iframe` `svg` `br` `hr` | refused | each message names the Talos way, or says there is none |
| `window` `document` `fetch` `localStorage` `setTimeout` | refused | the `ReferenceError` is rewritten to say where the work goes |
| `export default function Page()` | refused | wrap it in `defineScreen({ name, render, scenarios })` |
| `import "./x.css"` | dropped | `ui/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.

