# User interface

Immediate-mode game UI. Describe layout during update; results return immediately; geometry draws as the final pass of the frame.

**Two surfaces.** Host/editor owns full `ui.UiContext` (`world.ui`). Games use `hi.ui()` (`UiApi`) — stacks (optional `id`), scroll, text, rect, sprite images/nine-slice, button, checkbox, dropdown, slider (`SliderStyle`), text field, numeric field, tabs, progress, tooltip, icon (string id), spacers/separator/flexSpacer, modals, density/scale, capacity, `richText`, world anchors, virtual list/grid, `lastInputDevice`, `setFont` / `setTheme` / `warmFont`. Host-only: trees, split panes, drag-drop, popups, context menus, custom SVG `Icon` values, and most ergonomics helpers (`withVStack`, `bind`, …).

| Page | Covers |
|------|--------|
| **This page** | Quick start, frame lifecycle, themes, ownership, rendering contract |
| [UI layout](/docs/guides/ui-layout.md) | Coordinates, lengths, stacks, justify/flex, scroll/clip |
| [UI widgets](/docs/guides/ui-widgets.md) | Controls, focus, modals, identity, icons |
| [Game cursors](/docs/guides/ui-cursors.md) | Native sprite cursors, automatic roles, game overrides, capture ownership |

Editor chrome: [UI and editor](/docs/systems/ui-and-editor.md). Tutorial: [First UI](/docs/tutorials/first-ui.md).

**Data-driven screens.** The same widgets can be authored as a `.ui.json` asset and edited visually; game code then binds slots and reads events instead of laying out widgets. See [UI documents](/docs/systems/ui-documents.md) and the [tutorial](/docs/tutorials/first-ui-document.md).

## Quick start

Build scene-owned UI from an entity update callback:

```zig
const hi = @import("hikari_game");

const UiBehaviour = struct {
    pub fn update(_: *@This(), _: hi.ActorContext, _: *const hi.TickContext) void {
        const ui = hi.ui();

        const toolbar = ui.vStack(.{
            .position = .{ 16, 16 },
            .width = .{ .percent = 0.30 },
            .height = .{ .points = 138 },
            .padding = 16,
            .spacing = 10,
            .surface = .surface,
        });
        defer ui.end(toolbar);

        ui.text("Scene Controls", .{ .role = .secondary });

        if (ui.button("Reload Scene", .{
            .id = "main-toolbar.reload-scene",
            .variant = .primary,
        })) {
            hi.world().requestSceneReload();
        }
    }
};

const UiLogic = hi.defineComponent(.{
    .name = "ui_logic",
    .storage = .embedded,
    .data = UiBehaviour,
});

pub const UiEntity = hi.defineActor(.{
    .archetype = "ui",
    .components = .{UiLogic},
});
```

Add that entity archetype to the active scene to make the interface follow the scene's normal lifetime. Step-by-step: [Tutorials — First UI](/docs/tutorials/first-ui.md).

## Frame lifecycle

Applications do not call `beginFrame` themselves when using `World`. `World.onTick` begins the UI frame before updating scene entities. Beginning a frame:

- clears the previous frame's generated vertices while retaining their capacity;
- copies the current viewport dimensions and mouse state;
- resets per-frame hover state;
- verifies that every stack opened during the previous frame was closed.

Entities then emit widgets into `world.ui`. After simulation, `World.publishRenderState` copies the completed vertices into the immutable render-frame snapshot. The render thread uploads that snapshot and executes the final `ui` render-graph pass.

This keeps widget and layout behavior backend-neutral. Metal and D3D12 are responsible only for vertex upload, pipeline state, alpha blending, and drawing.

## Sprite images and nine-slice

Author `*.spriteatlas.json` as metadata over an ordinary texture. Coordinates and borders are source texels; the texture remains a normal compressed, mipmapped `.shintexture` and can later be reused by a world-sprite renderer.

```json
{
  "kind": "com.hikari.sprite_atlas",
  "version": 1,
  "texture": "asset://./textures/hud",
  "sprites": {
    "heart": { "rect": [0, 0, 32, 32], "pivot": [0.5, 0.5] },
    "panel": { "rect": [32, 0, 64, 64], "border": [8, 8, 8, 8] }
  }
}
```

Software cursors have a dedicated texture binding and do not consume the game imagery slot; see [Game cursors](/docs/guides/ui-cursors.md).

**One atlas per game-UI frame.** The UI pass is a single draw with one imagery slot. The first atlas a frame touches owns it; `image` / `nineSlice` from any other atlas that frame return `false` and a one-shot warning names both sides. Pack a game's UI sheets into one atlas (the example project's `tools/gen_ui_atlas.py` does this). A HUD from one sheet and a menu from another gives a silently empty menu, so if two systems can be on screen together make sure only one owns the slot.

The asset reference is the extension-free atlas stem:

```zig
const hud = hi.AssetRef.must(.sprite_atlas, "asset://./ui/hud");
_ = ui.image(hud, "heart", .{
    .width = .{ .points = 32 },
    .height = .{ .points = 32 },
    .fit = .contain, // stretch | contain | cover
});
_ = ui.nineSlice(hud, "panel", .{
    .width = .fill,
    .height = .{ .points = 48 },
});
```

Calls return `false` while assets are pending or a name is absent.

Author chrome — panels, frames, bars, icons — as **white RGB with the shape in alpha**. The UI shader multiplies `tint.rgb * texel.rgb` and `tint.a * texel.a`, so one sprite covers a control's idle, hover, accent and disabled states, and the sheet stays small. Only artwork that must carry its own colour (key art, thumbnails) needs real RGB.

Sprites are layout **leaves**: they claim a rect and paint, they never own children. Art behind content is therefore a `zStack` with the sprite as the first layer. A background sprite sized `.fill` also needs a parent with a resolved height — inside an `.auto` parent it falls back to the sprite's natural size. Either give the plate a concrete height or measure the content with `ui.contentSize` and apply it on the next frame.

### Theming game UI

Widget chrome is engine-painted: `ui.button`, `ui.slider`, `ui.checkbox` and `ui.dropdown` fill from the context's `Theme`, not from anything the game passes per call. `ui.tokens()` carries spacing and sizes only. A game shipping its own art direction therefore has to move the theme, or its sprite work sits on top of controls from a different palette.

```zig
hi.ui().setTheme(.{
    .accent = .{ 0.851, 0.647, 0.400, 1 },
    .text_primary = .{ 0.902, 0.886, 0.855, 1 },
    .text_on_accent = .{ 0.055, 0.051, 0.043, 1 }, // a light accent needs dark label text
    .border = .{ 0.902, 0.886, 0.855, 0.16 },
});
```

Every field of `ThemePatch` is optional — null keeps the current value. The host snapshots the theme on the first `setTheme` and `hi.ui().resetTheme()` restores it, so a scene that themes the UI must reset on teardown or the palette leaks into the next scene. Colours only: metrics, radii and font slots are not part of the patch.

### Padding

`padding` insets all four sides; `padding_x` / `padding_y` override it per axis. Reach for them on any row with a declared height: a uniform inset shortens the row's *inner* height too, which silently squeezes the content below the height of the controls sitting in it and leaves the whole row looking low.

### Group transforms

`opacity` and `offset` on any stack apply to that stack **and its whole subtree**, folded into the emitted vertices once when the stack closes. Nested groups multiply, like CSS or SwiftUI `.opacity`.

```zig
const panel = ui.vStack(.{
    .opacity = t,                    // whole panel fades
    .offset = .{ (1 - t) * -24, 0 }, // …and slides in
});
```

Layout does not reflow and hit rects follow the offset, so a panel can slide on and off without disturbing anything around it. At the identity values (`1`, `{0,0}`) it costs nothing; otherwise it is one linear pass over floats that are already hot. This is the intended way to animate a screen — not threading an alpha through every leaf.

### Cross-axis alignment

`cross_align` places children on the axis a stack does not advance along, and applies to leaves as well as controls. The three single-pass rules (`.stretch` is `.start` for sized children; content-sized children are placed when they close, so their hit rects lag one frame; `flow` aligns per wrapped line) are in [UI layout](/docs/guides/ui-layout.md#justify-cross-axis-alignment-and-flex).

### Text

- `tracking` — extra advance per glyph. Measurement **and word wrap** account for it, which is why padding a string with spaces is not a substitute: spaces break centring and wrapping.
- `align_x` — `.start` / `.center` / `.end` within the available width; wrapped text aligns per line. Needs a resolved width (`max_width` or the parent's), otherwise it degrades to `.start`.
- `shadow` — one offset pass behind the glyphs. The cheapest way to keep a HUD readable over an arbitrary 3D scene without an opaque plate behind every label.
- `ellipsize` / `max_lines` — cut what does not fit and mark the cut. `text` forwards these to the rich-text layout pass rather than keeping a second answer to where a string should be cut, so a plain label and a styled paragraph truncate identically.

`ui.textWidth(value, style)` and `ui.textSize(value, style)` measure without drawing. Immediate mode places a widget before its content is measured, so anything that has to *fit* a label — a keycap, a chip, a plate behind a caption — needs the answer up front; the alternative is a hand-padded guess that is wrong at every other font and scale. Both honour the whole style, truncation included, so they agree with what `text` then draws.

```zig
const w = ui.textWidth(label, .{ .size = .caption });
const cap = ui.zStack(.{ .width = .{ .points = @max(20, w + 12) }, .height = .{ .points = 20 } });
```

### Rich text: styled spans on one line

`ui.text` draws one string in one style. A span list draws many styles as one
paragraph, so it still wraps as a unit:

```zig
const spans = [_]hi.ui_types.Span{
    .{ .text = "Press " },
    .{ .icon = hi.ui_types.icons.play, .icon_size = 14 },
    .{ .text = " to open the " },
    .{ .text = "Ancient Chest", .color = rare_blue },
};
ui.richText(&spans, .{ .wrap = true, .shadow = .{ .offset = .{ 1, 1 }, .color = shadow } });
```

Each span overrides only what it names: `color` or `role`, `scale` (a multiplier
on the paragraph's), `font`, `tracking`. A span with `icon` set draws an inline
glyph instead of text, sized to the line by default.

The reason to use this instead of three adjacent labels is wrapping. Adjacent
labels each wrap against their own box and none of them knows where the previous
one stopped, so a line break lands in the wrong place as soon as the text is
long or translated. A span list breaks anywhere in the stream, including in the
middle of a span, and a word may straddle two spans. Mixed sizes on a line share
one baseline.

`ui.richTextWidth(&spans, style)` measures one unwrapped line, for sizing a
plate before drawing into it.

### World-anchored UI

Nameplates, damage numbers, interaction prompts and quest markers are UI pinned
to a position in the world. The host installs the camera that formed the frame's
image, so a game only supplies the world position:

```zig
const plate = ui.worldAnchor(head_position, .{
    .pivot = .bottom_center,
    .offset = .{ 0, -8 },
    .max_distance = 40,
    .fade_distance = 8,
});
defer ui.end(plate.scope);
if (plate.visible) {
    ui.text("Wanderer", .{ .size = .caption });
}
```

Opening an anchor always succeeds, so the `defer` stays balanced; `visible` says
whether the target is worth building content for. It is false without a camera,
behind the camera, past `max_distance`, and well off screen.

The scope also reports `distance` (world units), `point` (where it landed, in
UI points), `fade` (the distance fade already applied to the block's opacity),
and `behind`.

Set `clamp_to_viewport` to turn an anchor into an offscreen marker: the block
slides along the viewport border, inset by `clamp_margin`, pointing at a target
that has left the screen. Pair it with `show_behind` so targets behind the
camera still mark an edge.

For raw projection, `ui.worldToScreen(position)` reports the point in UI points
along with `valid` (false without a camera), `behind`, `on_screen` and
`distance`, so an arrow can point at a target it cannot draw on.

Anchored content can be interactive. A nameplate's size is unknown until its
contents are laid out, so the block is placed by the size it had last frame,
through the same mechanism `offset` uses, which hit rects already follow. Any
difference is corrected in the emitted geometry, so painting is exact on every
frame and clicking works from the second frame an anchor draws. The frames that
can lag by one are the first, and one where the content changes size. That is
the same guarantee justify and flex already give.

### Resource bars

`progress` covers three shapes, because a health bar differs from a loading bar
only in two knobs.

```zig
ui.progress(health, .{ .segments = 4, .trailing = recent_health });
```

- **Continuous** is the default: a plain filled track.
- **Segmented** splits the track into equal cells with a gap. Players count
  blocks faster than they judge a length, which is why resource bars in games
  are usually segmented. The boundary cell fills partially, so the underlying
  value still reads continuously.
- **Chip** draws `trailing` as a band between itself and the value. Above the
  value it is the damage just taken, draining behind the bar; below it, the
  healing a pickup would add. The default colour follows the direction, using
  the destructive role for a loss and the muted accent for a gain, so the two
  never read the same. Equal values draw no band at all.

`color`, `track` and `trailing_color` override the theme when a bar needs to
belong to a specific piece of art rather than the palette.

### Truncating rich text

```zig
ui.richText(&spans, .{ .ellipsize = true, .max_width = 180 });
```

Unwrapped, that is one line cut to the width and marked with `...`; wrapped, set `max_lines` and the last permitted line is cut. The marker borrows the colour and face of the span it cut. Truncation needs a width from `max_width` or the parent; without one, unwrapped text does not truncate.

### Grids and large collections

`flow` packs children by their own width and wraps when it runs out, so columns
drift as soon as two cells hold different content. A grid steps by the cell
instead, so column N is at the same x in every row:

```zig
const bag = ui.grid(.{ .columns = 6, .cell = .{ 48, 48 }, .row_spacing = 4 });
defer ui.end(bag);
for (items) |item| drawSlot(item);
```

A collection of any size should be virtualized: only the rows in view are built,
while the container still scrolls as though all of them were there.

```zig
const rows = ui.virtualList("inventory", &scroll, .{
    .count = items.len,
    .item_height = 28,
    .container = .{ .height = .{ .points = 320 } },
});
defer ui.end(rows.scope);
for (rows.first..rows.end_index()) |i| drawRow(items[i]);
```

`virtualGrid` is the same idea windowed by row, so `first` is always a multiple
of `columns` and a row is built whole or not at all. Both report `count` and
`total_extent`, and both take an `overscan` in rows so a fast scroll does not
show a gap before the next frame catches up.

Two things to know. `item_height` is the **row pitch**, the row plus its gap:
virtualization maps a scroll offset to an index by dividing by a fixed extent,
so the container's own spacing is forced to zero and the gap belongs in that
number. And a scroll area learns its own limit by measuring once, so an offset
set before a container's first frame only takes effect on the second, exactly as
with a plain scroll area.

### Animation

`ui.animate(id, target, rate, dt)` is retained per id: frame-rate independent easing where `rate` controls exponential convergence toward the target. Slots are evicted a couple of frames after a widget stops drawing, so screens that go away stop costing anything.

```zig
const glow = ui.animate("menu.row.0", if (selected) 1 else 0, 12, dt);
```

### Focus and device

Widgets register themselves as focusable; a game only steers. `ui.moveFocus(.down)` / `.next` / … returns false when nothing focusable lies that way, `ui.hasFocus()` and `ui.clearFocus()` round it out, and `ui.lastInputDevice()` returns `.mouse` / `.keyboard` / `.gamepad` — swap button prompts on it and hide focus rings while the player is on the mouse.

`ui.setSafeArea(.{ left, top, right, bottom })` inset anything anchored to a screen edge, for TV overscan and notches.

`ui.widgetState()` returns the interaction state of the widget built immediately before the call — `hovered`, `pressed`, `focused`, `focus_visible`. A control only reports that it was *activated*, so this is how a front-end painting its own selection art follows the pointer. `focus_visible` separates a pad's focus from a mouse's: a highlight keyed on it tracks the stick without stranding a ring behind the cursor. Paint-only leaves (text, rect, image) report all-false rather than leaking the previous widget's state.

### Menu rows: one button as the whole hit surface

A menu row is not a boxed control with an icon glued beside it. Make the row's *button* the full-size hit surface, reserve the icon column in its label inset, and paint everything else over it:

```zig
const row = ui.zStack(.{ .id = id, .width = .{ .points = 400 }, .height = .{ .points = 44 }, .cross_align = .stretch });
defer ui.end(row);

const activated = ui.button(label, .{
    .id = id,
    .variant = .ghost,  // chrome-less: a row, not a box
    .width = .fill,
    .height = .fill,
    .padding_x = 52,    // the icon column — the label starts after it
    .align_x = .start,  // labels share a left edge instead of each centring
});
const state = ui.widgetState();
const lit = ui.animate(id, if (selected or state.hovered or state.focus_visible) 1 else 0, 14, dt);
// …then the icon, rail and chevron as paint, over the button.
```

One widget owns the row, so there is no second target to miss between the icon and the label, the engine still owns the hover/press wash, the focus ring and keyboard activation, and the label's left edge is a declared number rather than whatever the icon happened to measure.

`ButtonStyle` carries `height`, `padding_x`, `corner_radius`, `align_x`, `outline`, `fill`, `min_width`/`max_width` and `flex` for exactly this kind of composition. `text_scale` is optional: leave it null and the label matches the surrounding `ui.text` at the same environment, which is what keeps a screen on one type ramp.

Chrome comes from the variant, and two knobs override it:

| Variant | Plate | Outline |
|---------|-------|---------|
| `primary` / `destructive` | filled | none |
| `secondary` | filled surface | hairline |
| `ghost` | none at rest; wash on hover/press | none |

`ghost` is the chrome-less variant — that is what separates it from a secondary, and it is why `listItem`, `segmented`, `tabs` and a disclosure row do not draw a box around every unselected entry. `outline = true` boxes one deliberately; `fill = false` suppresses the plate *and* its hover/press wash, leaving a pure hit surface. The focus ring always draws — it is the only affordance a gamepad has.

A card is the same idiom with the layers reversed: `fill = false`, the button down **first** so the art paints over it, then paint the card's own response from `widgetState`.

Painting unconditionally is cheap: a fill below half of one 8-bit alpha step emits no geometry at all, so a wash at rest, a `ghost`'s plate and a selection glow at zero cost nothing but the layout claim.

### Controls

Beyond the basics: `segmented` (mutually exclusive row — the shape of a console tab strip), `toggle` (settings switch; prefer over `checkbox`), `listItem` (menu / inventory / save-slot rows), `numericField`, `tabs`.

A settings sheet reads as a table only when its columns are declared once and shared by every row: caption on the left, a fixed value column, then a fixed control column. Give a toggle the control column's width and a real label (`"Enabled"` / `"Disabled"`) — an empty-title toggle collapses to a bare square that says nothing about what it is showing.

### Filling the remainder (panels)

Layout is **single pass**, and `flex` only *shifts* siblings — it never grows them. `.fill` resolves against the parent's whole size, not what is left, so a body sized `.fill` between a header and a footer eats the panel and pushes the footer out of the dialog.

Ask for the remainder instead:

```zig
// header and tabs already emitted…
const footer_h: f32 = 40;
const body_h = @max(80, ui.remainingHeight(240) - footer_h - spacing);
const body = ui.scrollArea("settings", &scroll, .{ .width = .fill, .height = .{ .points = body_h } });
```

`ui.remainingSpace()` returns `[2]?f32` — null on an axis where the stack sizes to its content. `ui.remainingHeight(fallback)` is the vertical shorthand. Both are exact and cost nothing: the pen position and the declared size are already known.

Two controls are easy to get wrong:

- `ui.slider(label, …)` — `label` is the slider's **identity**, never drawn. Settings rows draw their own caption and value readout, which is also what lets a column of them align. `SliderStyle` carries `step`, `width`, `track_height`, `thumb_width`.
- `ui.dropdown(…, DropdownStyle)` — set `text_scale`, or a dropdown inherits the sheet's display scale and renders as a banner.

### Worked example

`scenes/ui_showcase.json` in the example project (**AURORA**) is a five-screen front-end — title, chapter select, loading, HUD, pause/settings — built entirely on `hi.ui()` and one atlas:

| Path | Role |
|------|------|
| `src/ui_showcase/theme.zig` | palette, the spacing/type/metric scale, sprite wrappers, composite widgets (`beginPanel`, `meter`, `menuRow`, `beginRow`, `keycap`, `promptStrip`) |
| `src/ui_showcase/screens.zig` | the five screens and all their state |
| `src/entities/ui_showcase_entity.zig` | component wiring, font slots, pointer mode |
| `tools/gen_ui_atlas.py` | regenerates `assets/ui/aurora.png` **and** its `.spriteatlas.json` |

The atlas is generated, not hand-packed, so the sheet and the JSON rect table cannot drift: re-run `python3 tools/gen_ui_atlas.py` from the project root after editing the sprite list. It also writes `tools/aurora_preview.png`, the sheet composited over a UI-dark plate — the raw PNG is mostly white-on-transparent chrome and is misleading to eyeball directly.

## Themes, tokens, and environment

Each context contains a `ui.Theme`. Defaults are **runtime/game** (`Theme.runtime()`): cool slate panels and cyan primary actions. Editor chrome calls `Theme.dark()` / `Theme.light()` — a neutral zinc elevation ramp whose `border` token is a *hairline* (white @ 9% on dark, near-black @ 11% on light) doing double duty as control outline and dock seam, with softened core-blue CTAs, muted selection washes, full `#3B5BFF` focus rings, and rare photon rose `#FF3D9A` brand. Do not apply the editor theme to `world.ui` when embedding game UI in the viewport — the two providers are independent.

Game modules reach the same setter through `hi.ui().setTheme` (see [Theming game UI](/docs/guides/user-interface.md#theming-game-ui)); host and editor branding uses `world.ui.setTheme` directly:

```zig
world.ui.setTheme(.{
    .accent = .{ 0.55, 0.25, 0.90, 1.0 },
    .surface = .{ 0.08, 0.07, 0.12, 0.96 },
    .text_primary = .{ 0.96, 0.96, 1.0, 1.0 },
});
```

`Theme` owns color tokens. `DesignTokens` owns spacing, control heights, radii, type sizes, and icon sizes for each `Density`. Game modules get the same table through `ui.tokens()` — spacing, control heights and type steps, plus `radius_sm/md/lg`, `control_padding_x/y`, `icon_sm/md/lg` and `slider_track`, so a game's own plate agrees with the corner and the inset of the engine-painted control in front of it instead of guessing at them.

There are **two metric tables** behind that one `Density` word, because a game HUD and a tool window want opposite things from it: `DesignTokens.forDensity` is HUD-sized (immediate UI / `world.ui`), `DesignTokens.forEditorDensity` is the dense editor scale (24pt rows compact, 30pt comfortable, a 4/6/8 radius ramp). `Environment.init` selects the runtime table and `Environment.initEditor` the editor one; `ui.Metrics` records the choice so a nested `pushEnv` inherits it instead of silently reverting to HUD sizing. Editor chrome is the only caller of `initEditor` (`RetainedUi`).

Both immediate and retained UI read the active `Environment` (`density` + `text_scale` multiplier + resolved tokens).

```zig
world.ui.setDensity(.comfortable);
world.ui.pushEnv(.{ .density = .compact, .text_scale = 1.1 });
defer world.ui.popEnv();
// or
world.ui.withEnv(.{ .text_scale = 1.2 }, &state, buildSettings);
```

Controls resolve missing metric fields from the environment (button height, text scale, padding, radii). Public styles take semantic roles/variants (`SurfaceRole`, `TextRole`, `ButtonVariant`, `TextSize`) rather than raw colors. Low-level `addRect`/`panel` still accept colors for data-driven drawing (color swatches, overlays).

## Ownership guidance

### Capacity prewarming

UI arrays retain their high-water capacity and therefore stop allocating after warmup. Editor hosts that need deterministic allocation-free CPU frames can reserve construction and triple-buffer publication storage up front:

```zig
world.reserveUiCapacity(
    256 * 1024, // float count, not bytes
    2048,       // focusable/layout widget estimate
    1024,       // persistent ID cache estimate
);
```

These are capacity ceilings, not per-frame allocations. Exceeding them grows the relevant retained buffer normally. GPU dynamic vertex buffers are independently grow-only per frame slot.

### Replacing the built-in UI

`ui.Provider` is the type-erased interface between `World` and UI frame production. It abstracts lifecycle and output instead of copying every built-in widget into a vtable, allowing a third-party immediate-mode or retained system to keep its own control model:

```zig
const provider = hikari.ui.Provider{
    .context = custom_ui,
    .vtable = &custom_ui_vtable,
};
world.setUiProvider(provider);

world.useGameUi();
```

The vtable receives `begin_frame(context, input, width, height)`, `set_display_scale(context, scale)`, `finalize_frame(context)`, and `vertices(context)`. The provider context is externally owned and must remain alive while registered.

The renderer-facing contract is a stream of `ui.QuadRecord`s — one 96-byte record per primitive carrying four clip-space corners, four RGBA8 colours, a UV rectangle and the page/mode/range metadata — expanded into six vertices per record on the GPU by instance (`src/hikari/src/ui/compositor/record.zig`). Each provider also publishes one immutable `ui.Atlas` for the frame. Solid primitives use the atlas's white texel and glyphs select coverage or MTSDF reconstruction through vertex metadata, preserving the existing single-draw overlay pass. Providers flattened together must use the same atlas; a system requiring per-command scissor rectangles or multiple materials needs a future draw-list extension and must not call Metal or D3D12 directly from application code.

Keep responsibilities separated as follows:

- The engine owns `ui.UiContext`, layout, widget interaction, themes, generated vertices, the built-in UI shader, and backend rendering.
- A game owns the screens, labels, actions, visibility rules, and scene entities that compose its interface.
- A platform backend must not implement widget behavior or expose native renderer types to game UI.

Scene entities are the preferred presentation controllers today. This means unloading a scene naturally removes its UI and avoids putting screen-specific behavior into a global game subsystem.

## Rendering contract

Responsive layout does not create renderer work per widget. Rectangles, glyphs, backdrops, panels, and controls are flattened in visual order into one contiguous CPU vertex stream. The completed stream is copied into the render-frame snapshot, uploaded as one UI vertex buffer, and submitted by the final UI pass with exactly one draw call when the stream is non-empty.

New widgets must preserve this batching contract. They should append geometry to `ui.UiContext`; they must not create their own GPU buffers, pipelines, descriptor changes, or draw submissions.

## Current scope

**In (host `UiContext`):** mouse/keyboard/gamepad immediate UI — stacks, fields, buttons, checkboxes, toggles, sliders, scroll, lists, trees, tabs, splits, modals, themes, flex spacers, focus navigation. Game `hi.ui()` is the subset in the first paragraph.

**Currently unsupported:** multiline edit, custom drawing, deeper a11y, stick-hold / left-stick nav, production text shaping. Immediate `scrollArea` paints vertical overflow thumbs; retained chrome paints and drags whichever axis overflows (`scroll_x` / `scroll_y`).

Flex/justify reposition only — they never resize interactive widgets. App code describes UI via `ui.UiContext`; composition stays in `ui.Compositor`.

Editor retained chrome (panels, docks, strings, bindings): [UI and editor](/docs/systems/ui-and-editor.md).

## Implementation reference

- Public entry point (barrel): [`src/hikari/src/ui/ui.zig`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/ui.zig)
- Style/value types (Theme, Length, StackStyle, ...): [`src/hikari/src/ui/style.zig`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/style.zig)
- Internal layout engine (Layout, geometry/length math): [`src/hikari/src/ui/layout.zig`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/layout.zig)
- The stateful `UiContext` facade: [`src/hikari/src/ui/context.zig`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/context.zig); domain modules under [`src/hikari/src/ui/context/`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/context/) (`scopes`, `frame`, `config`, `interaction`, `widgets`, `stack`, `focus`, `draw`)
- Geometry and overlay composition: [`src/hikari/src/ui/compositor.zig`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/compositor.zig)
- Bindings, edit results, and inspector ergonomics: [`src/hikari/src/ui/ergonomics.zig`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/ergonomics.zig)
- Default font atlas: [`src/hikari/src/ui/atlas.zig`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/atlas.zig) + [`src/hikari/src/ui/fonts/inter/`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/fonts/inter/) (Inter, page 0) + [`src/hikari/src/ui/fonts/geist/`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/ui/fonts/geist/) (Geist, page 1). Editor chrome defaults to Geist; game HUD stays Inter.
- Sample game HUD: `hikari-games/example/src/session_ui.zig`
- Render-frame publication: [`src/hikari/src/scene/world/render.zig`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/scene/world/render.zig)
- Render-graph pass: [`src/hikari/src/graphics/rendergraph/passes/ui.zig`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/src/graphics/rendergraph/passes/ui.zig)
